feat(ai): dynamic Routstr model lineup, vision icons, and image attachments#250
feat(ai): dynamic Routstr model lineup, vision icons, and image attachments#250Kelbie wants to merge 3 commits into
Conversation
…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).
|
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):
Accepted as intended (documented tradeoffs): cross-provider fallback can bill via an unselected model (pre-existing design, 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 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).
|
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:
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 |
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
TIER_MATRIXhardcoded 9 model ids; 4 no longer exist in the live 338-model/v1/modelscatalog (claude-3.5-haiku,grok-3-mini,grok-4.1-fast,grok-4). Pricing lookups returnednull→modelPickerrendered the literal "cost unavailable". Grok + some Claude models were exactly the dead ids.shared/lib/routstr/lineup.tsderives 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).exif:false), re-encodes viaexpo-image-manipulator(EXIF/GPS strip, ≤1536px, JPEG 0.85) and sends inlinedata:image_urlcontent 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.tslives inshared/lib/routstr(notfeatures/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.TIER_MATRIXdeleted outright: no alias table, no fallback ids. The only fallback anywhere is the persisted last-known lineup.resolveSelectedEntryreturnsnullon true first-run-offline; every caller renders a loading state instead of guessing an id.RoutstrContentPart/RoutstrChatMessage) exported fromapi.ts;assembleApiMessagesis 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
providerIdForModelnormalizer, union-lockstep — resolved stronger via single-source import).Implementation deviations from the audited plan (all flagged):
MIN_CONTEXT_LENGTH = 16_000qualification floor (justified by the 8k+2k typical-turn estimate; guards recency-ranking against toy releases).claude-fable-5vsclaude-opus-4.8-fastlegitimately share pricing+context); replaced with a stronger fixture tripwire: every self-declared "redirects to the latest" row must carry the~slug marker.Security / privacy
measureMessageContentcounts, never serialises)..catchdegradation); 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.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.Verification table
log-doctor redaction --latestafter an image sendKnown risks
grok-4.20-multi-agentin Grok's Auto slot — row 7 verifies it.gpt-5.5and Claude's toclaude-opus-4.8-fast— capability-ordered tiers mean Auto is no longer guaranteed-cheapest (user-approved default; per-row prices carry the signal).