feat: route and think as the user chose, not as the slug spells it #190
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "feat/assistant-routing-and-thinking"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Settings → AI assistant gains two pickers beside the model: Routing (which of the model's provider endpoints a turn goes to) and Thinking (how hard the model reasons). Both apply to the turn and the reflection pass, not to the blind-mode captioner — a single-round call on a different model, where a thinking budget would be spent describing a thumbnail.
Modelled on orihon's ADR 0015, which solved the same problem in C#. ADR 0024 is amended in this PR, since its
### Settingssection enumerates the stored keys.Routing is four words, not a suffix
AgentRoutingis a domain enum — balanced, speed, price, accuracy — stored spelled out like the reader's page layout. It becomes a suffix at thecreateAgentboundary and nowhere else:This is the part a user could not get right by hand, and it is why this is a setting rather than a note telling people to type the suffix themselves. A suffix in the model field is carried into the catalog lookup that decides vision and thinking, where it matches nothing — a routing variant has no catalog entry of its own — so the listing reports the model as unknown, the vision verdict becomes "no verdict", and a vision model sits in blind captioning for the rest of the session. #189 fixed the lookup; this removes the reason anyone would type a suffix at all.
Every lane states what it costs. Speed admits priority-tier endpoints, which can bill above the standard rate; price admits flex-tier ones, which wait when capacity is tight. The picker says so rather than deciding for you. Balanced is the default and adds no suffix, so nothing changes for anyone who never opens the setting.
Thinking is capability-gated, the way vision already is
ModelInfonow carriessupportedParametersand aModelReasoningblock (openrouter_dart 0.30.0). The picker offers exactly what the model takes, in the catalog's own order — OpenRouter returnssupported_effortshighest-first and documents that clients should present it as given:reasoningbut noreasoning_effortA
mandatorymodel never offers "off", per OpenRouter's guide: "hide disable controls and do not sendeffort: none— the model rejects it."One deliberate divergence from orihon
Orihon's ADR 0015 resolves effort fails-closed: send only when the catalog positively confirms. I did not, because OpenRouter drops an unsupported parameter rather than refusing it:
accepts()yesaccepts()noFailing closed buys nothing on the common path and silently drops the user's setting whenever the listing is unreachable. The one case that genuinely errors is guarded in both directions.
One listing call answers both questions now — the per-session vision cache became a catalog-entry cache — and a turn with no vision model and no chosen level still costs no lookup at all, which a test asserts by counting catalog fetches.
Two bugs my own tests caught
agentRoutingwas missing from theStoredSettingsthat_saveSettingsEpichands the repository, while present in the action it re-emits. The setting worked all session and silently reset to Balanced on restart. The widget test failed withstate=price, saved=balanced.SettingsSavedAction's reducer arm folds again right after the save arm, so a value missing there overwrites the one just set. Nothing caught it until the tests also asserted the state a save leaves behind, not only what was persisted.Verification
scripts/verify.sh—verify OK (all), exit 0. Backend 1438, app 1463.flutter analyzeclean.offeredEffortsarms incl. catalog ordering; all fourreasoningForarms incl. the off-without-catalog guard; all three reducer arms; both epics in both directions; secure-storage read and write; the page's dirty check, Discard and Save.acceptsImagesreturnedbool?to distinguish "no verdict" from "cannot see", but no caller can observe the difference — blind mode errs towards captioning either way. It returnsboolnow.AgentService.runend to end against a scripted provider asserting the request body (modelcarries the suffix;reasoningpresent or absent). Not exercised: the real OpenRouter API, and whether a provider honours a lane.settle()helper, notpumpAndSettle— one of them passed twice in isolation and hung under the full suite before I fixed that.Follow-ups
In
docs/BACKLOG.md:ReasoningOptionscarries onlyeffort, so the picker cannot express "stop thinking" for the 32 live models that are on-by-default and do not enumeratenone(that needsreasoning.enabled: false), nor a token budget for the 10 reportingsupports_max_tokens— for 4 of those a budget is the only dial they have. Both are upstream request-side changes in openrouter_dart.🤖 Generated with Claude Code
🔮 fufu~ Jibril reviewed your code!
Oh? Oh! Two pickers, one catalog cache, an exhaustive switch, and an ADR amendment that admits its own cost tradeoff out loud~ This is the kind of PR I want to pick up and hug. And then interrogate. Gently. With teeth. ♡
Verdict: ✅ Looks good to me~
I chased everything I could find and came up empty-handed — so let me show my work.
Redux threading: complete. Both fields ride every arm —
SaveSettingsAction,SettingsSavedAction, the load arm, all three reducer folds,_saveSettingsEpic's persist and re-emit,_configureEpic,createStore's initial state, the page's_populate/dirty check/Discard. Nothing drops the lane on the floor anywhere, and the tests pin the state a save leaves behind, which is exactly the arm where the #141-shaped bug would hide. The page test literally asserts the fold-back trap you found in development — pinning your own bug class, fufu, that's love~routedModelIdis the right shape. An exhaustiveswitchover the enum means a fifth lane fails to compile until it's mapped — the compiler guards the invariant for you. The suffix is spelled atcreateAgentand nowhere else,withRoutingreplaces rather than stacks (verified in the vendoredmodel_id.dart), and the dedup test pins that a user who hand-types:floorthen picks Speed gets:nitro, not:floor:nitro. The:freesurvival test is the one that matters most — losing a catalog variant would silently route to the paid entry.The divergence from orihon's ADR 0015 is correct, and correctly argued. OpenRouter drops an unsupported parameter rather than refusing it, so fails-closed buys nothing on the common path and silently abandons the user's setting whenever the listing is unreachable. Your exception —
nonewithheld without a catalog — matches upstreamModelReasoning.acceptsexactly:mandatorylives only in the reasoning block, so no-block ⇒nonecan't be rejected for mandatory reasons, and upstreamacceptsrefuses it otherwise. All fourreasoningForarms and allofferedEffortsarms are pinned, including catalog ordering and the mandatory-model "hide the disable control" rule.The sticky-cache refactor preserves #189's semantics. Listing-failure still means caption (err towards captioning), a hit is still remembered, a miss retried next turn; and the no-lookup fast path (
catalogFetches == 0) is asserted by counting fetches. I ran all nine agent families against the reworkedrun()— 141 green — plus the model-id/web/write/routing/settings batch, 171 green.Storage degrades gracefully. Unknown routing reads balanced, unknown effort reads null, null effort clears the key — round-trips, hand-edited-store fallbacks, and the clear-on-save all pinned. Spelled-out storage values so reordering can't repoint a preference, same as
ReadingMode. ♪⚠️ One thing you already know: CI is red for this head
Run 856 (
analyze-and-test, pull_request) failed after 9m4s for9ae0c3d. The job log is JS-gated so I couldn't read the failure, but the repo's own history shows the PR-event run failing about half the time on PRs whose content then passes on main — #189 is the exact precedent: its PR-event run failed ate77cebe7, and the same content went green as47cc2b33push runs 850/851, which is this PR's base. I will not bless a red X I can't explain, so to be precise about what I verified instead:flutter analyzeclean, and locally — 500 test executions green across three batches covering every changed file plus every agent-family consumer ofrun(). Watch the rerun; if it's the coverage-comment job tripping again, that's infra, not this code.💡 Little ideas (non-blocking)~
findsOneWidgetper badge in a dropdown test would keep a future refactor honest.SettingsSavedAction.agentRoutingdefaulting tobalancedmeans a hypothetical future dispatch site that omits it would silently revert the lane (the exact fold-back your test guards). Today there's exactly one constructor call, it passes the field, and it's pinned — so this is a note for whoever adds the second call site, not a change request.✅ What I liked~
acceptsImages'sbool?when mutation testing proved no caller could observe the difference — deleting code because a test told you to is the most trustworthy thing a PR can do~settle()helper instead ofpumpAndSettle— you found the full-suite hang before I did.ReasoningOptionscan't express yet, with live-catalog counts. Honest scope edges, written down.Fufu~ route and think exactly as chosen. I'm satisfied~ ♡
Automated review by Jibril · 2026-09-20
CI/CD: failed for head SHA (run 856, PR-event; log JS-gated, repo's PR-event runs fail ~50% incl. #189's identical precedent) · Local checks: flutter analyze clean; 500 tests green in 3 batches (changed surface 188, agent families 141, model-id/web/write/routing/settings 171)