Skip to content

feat(ai): dynamic Routstr model lineup, vision icons, and image attachments#250

Open
Kelbie wants to merge 3 commits into
mainfrom
feat/routstr-dynamic-models
Open

feat(ai): dynamic Routstr model lineup, vision icons, and image attachments#250
Kelbie wants to merge 3 commits into
mainfrom
feat/routstr-dynamic-models

Conversation

@Kelbie

@Kelbie Kelbie commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Summary

Fixes the Routstr AI tab's three linked problems: (1) "cost unavailable" rows, (2) a hardcoded model list that rots as the API drifts, (3) the composer's dead [+] button.

Resolved task

  • Diagnosis (confirmed): TIER_MATRIX hardcoded 9 model ids; 4 no longer exist in the live 338-model /v1/models catalog (claude-3.5-haiku, grok-3-mini, grok-4.1-fast, grok-4). Pricing lookups returned nullmodelPicker rendered the literal "cost unavailable". Grok + some Claude models were exactly the dead ids.
  • Dynamic lineup (user-decided): new deep module shared/lib/routstr/lineup.ts derives top-3 chat-capable models per provider (OpenAI / Claude / Grok / Google — new 4th tab) from the live catalog. Text-output-only qualification (excludes image-gen/embedding/audio models and <16k-context toys), rolling ~-alias dedup, documented capability ordering (created ↓ → context ↓ → id), tiers capability-first (Max = newest). Pricing + vision capability come live; a compact tolerant last-known snapshot persists so the menu works offline ("last known"-annotated when substituted).
  • Image upload: [+] opens the photo library (PostComposer picker UX, exif:false), re-encodes via expo-image-manipulator (EXIF/GPS strip, ≤1536px, JPEG 0.85) and sends inline data: image_url content parts — routstr-core decodes data-URLs server-side; no upload endpoint; Blossom deliberately not used. Vision-gated: [+] dims (with a11y label) when the resolved model lacks image input; the failover chain filters to vision models when a request carries images. Encodes memoized (LRU 8); only the 2 newest image turns (≤4 images) resend inline.

Architecture notes

  • lineup.ts lives in shared/lib/routstr (not features/ai) so the persisting store can import it without a feature→store cycle; it is the single seam feeding store, picker, chip, and send path.
  • The store's duplicated provider/tier literal unions are replaced by imports from the shared module — single source beats the audit's proposed lockstep test.
  • TIER_MATRIX deleted outright: no alias table, no fallback ids. The only fallback anywhere is the persisted last-known lineup. resolveSelectedEntry returns null on true first-run-offline; every caller renders a loading state instead of guessing an id.
  • One wire contract (RoutstrContentPart/RoutstrChatMessage) exported from api.ts; assembleApiMessages is the single send/retry assembly seam (parity structural, not by convention).

Planning ledger

pr-gauntlet run: Claude Plan A + Codex Plan B, judge fold, 8-reviewer audit → 17 findings (14 accepted / 1 rejected / 2 deferred), all accepted findings implemented incl. the 5 the reviser's truncated input missed (vision-aware failover, tolerant catalog spine + all-provider-drift degradation, shared wire contract, explicit providerIdForModel normalizer, union-lockstep — resolved stronger via single-source import).

Implementation deviations from the audited plan (all flagged):

  • MIN_CONTEXT_LENGTH = 16_000 qualification floor (justified by the 8k+2k typical-turn estimate; guards recency-ranking against toy releases).
  • The audit's "no byte-identical pricing per provider" backstop is falsified by real data (claude-fable-5 vs claude-opus-4.8-fast legitimately share pricing+context); replaced with a stronger fixture tripwire: every self-declared "redirects to the latest" row must carry the ~ slug marker.
  • Audio-output models also excluded (gpt-audio would otherwise qualify).

Security / privacy

  • EXIF/GPS stripped by full re-encode (PR Media pipeline hardening: privacy, upload resilience, render interop, a11y #242 pattern) before any image leaves the device.
  • Base64 never persisted (bounded local-URI metadata only, ≤4/message) and never logged (measureMessageContent counts, never serialises).
  • Persisted-schema invariant: all new fields additive + tolerant (.catch degradation); regression fixtures prove a malformed attachment or lineup snapshot cannot whole-blob-wipe the apiKey/sessions. No version bump needed; drift snapshot updated and routstrStore registered in the drift test.
  • 402→top-up, streaming, and clearPaymentContext paths untouched.

Testing

  • routstrLineup.test.ts (13): selection, alias dedup per provider, tie-breaks, exclusions, partial/zero providers, vision flags, garbage safety, merge fallback, alias-marker tripwire — against a fixture trimmed from the live 2026-07-02 snapshot.
  • aiSendAssembly.test.ts (12): content-part expansion, inline window + caps, missing-URI degradation, send/retry parity, log measurement, vision-chain building blocks, Gemini per-image fee.
  • routstrStorePersistResilience.test.ts (4): legacy blob, malformed attachments, garbage lineup, round-trip.
  • Gates: type-check ✓, lint 0 errors ✓, full jest 174 suites / 1818 tests ✓, knip ✓, analyze-structure (duplicate-export finding fixed) ✓.

Verification table

# What to verify How to test Expected result Variants Status
1 No "cost unavailable" on live rows Open Model menu, all 4 tabs Every row shows a real ~sats/msg price OpenAI, Claude, Grok, Google
2 Google tab exists with 3 models Open Model menu 4 provider pills; Google shows Gemini rows
3 Vision icon on image-capable rows Model menu rows Small image glyph beside description all providers
4 Offline menu degradation Airplane mode, relaunch, open menu Last-known lineup with prices (not empty/loading) first-run offline shows "Models loading"
5 [+] attaches and sends an image Pick photo, send with text Thumbnail in bubble; assistant answers about the image per provider (Anthropic/Google/xAI/OpenAI)
6 [+] dims on non-vision model Select a text-only model (if any in lineup) [+] dimmed, inert, a11y label
7 grok-4.20-multi-agent sanity (deferred audit finding) Grok → Auto, plain text send Normal chat reply; if it errors, we should exclude it via qualification
8 Image EXIF strip Send a GPS-tagged photo, inspect payload/log No location metadata leaves device; logs show counts only
9 402 → top-up unchanged Send on an unaffordable tier Insufficient-balance popup with Switch to Auto / Top up
10 Redaction log-doctor redaction --latest after an image send High-signal tier empty; no base64 in logs

Known risks

  • Recency-as-capability can rank a niche new model above a stronger older one (documented; context-length secondary). Live data currently puts grok-4.20-multi-agent in Grok's Auto slot — row 7 verifies it.
  • Per-provider last-known fallback can surface ids dead on the API; the candidate chain + failed-send popup absorb it.
  • OpenAI's Auto currently resolves to gpt-5.5 and Claude's to claude-opus-4.8-fast — capability-ordered tiers mean Auto is no longer guaranteed-cheapest (user-approved default; per-row prices carry the signal).
  • Image-heavy threads resend up to ~2MB base64 per request (bounded by the 2-turn window).

Kelbie added 2 commits July 2, 2026 13:06
…hments

Root cause of "cost unavailable": 4 of TIER_MATRIX's 9 hardcoded model ids
(claude-3.5-haiku, grok-3-mini, grok-4.1-fast, grok-4) no longer exist in
the live /v1/models catalog, so estimateTurnCostSats returned null and the
picker rendered the literal fallback string.

- Delete TIER_MATRIX; derive top-3 chat-capable models per provider
  (OpenAI/Claude/Grok/Google) from the live catalog in the new deep module
  shared/lib/routstr/lineup.ts: text-output-only qualification, rolling
  '~'-alias dedup in favor of dated concrete rows, one documented
  capability ordering (created desc → context desc → id), tiers assigned
  capability-first (Max = newest).
- Persist a compact tolerant last-known lineup snapshot in routstrStore so
  the menu keeps prices offline; per-provider zero-row drift substitutes
  from it, annotated "last known". Provider/tier unions now import from
  the shared module (previous hand-lockstep duplicate removed).
- Model picker: 4th Google tab, vision glyph on image-input rows, partial
  rows for thin providers, neutral loading row on first-run offline.
- Composer [+] attaches images (PostComposer picker UX): EXIF-strip +
  ≤1536px downscale + base64 data-URL image_url content parts inline in
  chat/completions (routstr-core transport), memoized per-session encode,
  2-newest-turn / 4-image inline window, vision-aware candidate-chain
  fallback, dim-disabled [+] on non-vision models, thumbnails in bubbles
  with missing-file placeholders.
- Persisted message attachments are bounded local-URI metadata (never
  base64) behind a tolerant schema; routstrStore joins the schema-drift
  registry with legacy/malformed-blob regression fixtures.
…egrade, stale-attachment clear

Folded from three parallel real-diff reviews (redteam, security-privacy,
data-contracts; zero leak/data-loss defects found):

- Advance the send-path candidate chain on model-rejection errors (404,
  or 400 whose message references the model), not just network/5xx —
  without this a dead last-known model id hard-failed on the first
  candidate and the chain's documented dead-id absorption never ran.
- When an image-bearing request has NO vision-capable candidate (retry of
  an old image turn after switching to a text-only slot), strip the
  image parts and send text-only instead of guaranteeing a non-retryable
  400 (new stripImageParts + test).
- Clear pending composer attachments when the resolved model loses
  vision input after picking, so submit can never carry images the
  selected model must reject.
- Correct the format.ts docblock's description of what persists (the
  (provider, tier) selection is session-only; persisted selectedModel is
  the legacy UserMessagesScreen surface).
@Kelbie

Kelbie commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Real-diff review pass complete — three parallel read-only reviewers (red-team, security/privacy, data-contracts) over 66c32d6.

Zero leak / data-loss defects. Security confirmed: no base64 in any log path, EXIF strip via full re-encode is sound, apiKey untouched, persisted-blob tolerance verified with the shipped regression tests, encode-cache cross-profile bleed ruled out (profile switch = full app reload). Data-contracts re-ran the persistence suites independently and traced every zod chain — all additive+tolerant, no import cycle.

Fixed in 1fb567e (all reviewer corrections folded before any edit):

  1. Red-team, high: candidate chain only advanced on network/5xx, so a dead last-known model id (400/404) hard-failed on the first candidate — the exact case the chain claims to absorb. Now advances on model-rejection errors (404, or 400 with a model-referencing message).
  2. Red-team + security, converging: image request with zero vision-capable candidates now degrades to text-only (stripImageParts + warn log + test) instead of sending image parts to a text-only model (guaranteed non-retryable 400); pending composer attachments clear when the resolved model loses vision after picking.
  3. Data-contracts, low: format.ts docblock corrected re what persists.

Accepted as intended (documented tradeoffs): cross-provider fallback can bill via an unselected model (pre-existing design, ai.send.fallback_used warn + server-side max_cost reservation bounds spend); array-level .catch([]) drops all of a message's attachments if one is malformed (bounded blast radius, never the blob); top-level derivedAt doesn't track per-provider substitution age (per-provider staleness diagnosable via ai.lineup.derived logs).

Remaining uncertainty flagged by reviewers: the actual HTTP status Routstr returns for a retired model id is unverifiable without a funded key — verification row 7's device pass should confirm the chain-advance fires (look for ai.send.candidate_failed with status 400/404 in logs).

Gates re-run post-fix: type-check ✓, full jest 174/1819 ✓, lint 0 errors ✓, knip ✓.

… mount

On-device diagnosis (log.txt 2026-07-02): the app gated sends against a
persisted balance of 299 sats while the server reported 216 mSats
available ("Insufficient balance: 85577 mSats required. 216 available."),
so the cheapest lineup model (gemma-4-26b, ~107-sat reservation) passed
every client-side affordability check and 402'd on every send — an
endless insufficient-balance popup. The only balance refresh point was
the post-SUCCESSFUL-stream diff in useAiSend, which a 402 never reaches,
so the stale figure could never self-correct.

- throwResponseError now syncs the store balance from the 402's parsed
  `available` mSats, next to the existing 401 key-clear — the pill,
  picker fades, and estimates turn truthful the moment the server
  disagrees (every Routstr surface benefits).
- ModelChip mount refreshes checkBalance alongside the models fetch, so
  a stale balance self-heals on opening the AI tab.
- The 401/402 store access switches from dynamic import() to a lazy
  require() accessor: identical cycle-avoidance, but executable under
  Jest's CJS VM — which is what makes the new regression test possible
  (the 402 body in the test is byte-shaped like the live response).
@Kelbie

Kelbie commented Jul 2, 2026

Copy link
Copy Markdown
Contributor Author

Follow-up (b8b7819): stale-balance 402 loop fixed — from an on-device log-doctor investigation of the recurring "Insufficient balance" popup.

Diagnosis: the app gated sends against a persisted balance of 299 sats while Routstr reported 0.2 sats available ("Insufficient balance: 85577 mSats required for this model. 216 available."). The lineup itself behaved correctly — it resolved to the cheapest model anywhere (gemma-4-26b, ~107-sat reservation) — but the only balance-refresh point was the post-successful-stream diff, which a 402 never reaches, so the stale figure could never self-correct and the popup looped.

Fix:

  1. throwResponseError syncs the store balance from the 402's parsed available mSats (next to the existing 401 key-clear) — pill/picker/estimates turn truthful the moment the server disagrees.
  2. ModelChip mount refreshes checkBalance alongside the models fetch.
  3. The 401/402 lazy store access moves from dynamic import() to a require() accessor (same cycle-avoidance, Jest-executable), enabling a regression test whose 402 body is byte-shaped like the live response.

Gates: type-check ✓, lint 0 errors ✓, full jest 175 suites / 1821 tests ✓, knip ✓. Redaction on the investigated device log: high-signal tier clean.

Verification addendum: after topping up, the pill should update after the first successful send; and with a drained key, the first failed send should immediately drop the pill to ~0 (watch for api.routstr.balance_synced_from_402 in logs).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant