Skip to content

ADR-018: Model routing, per-node model selection, and the unified catalog - #305

Merged
dovvnloading merged 7 commits into
mainfrom
adr018-model-routing
Aug 9, 2026
Merged

ADR-018: Model routing, per-node model selection, and the unified catalog#305
dovvnloading merged 7 commits into
mainfrom
adr018-model-routing

Conversation

@dovvnloading

Copy link
Copy Markdown
Owner

Summary

Implements ADR-018 end to end, all 5 stages on one branch:

  • 18.1ModelRef (provider, model_id) replaces task-keyed dispatch as the addressing unit; a unified catalog aggregates all 5 providers' models with capabilities, cost, and latency metadata.
  • 18.2 — The resolution chain (node override → branch override → workspace default → auto policy), wired into the chat dispatch path.
  • 18.3 — Per-node/branch model-override UI: a pin/clear affordance in the node menu, a badge on the node, full persistence (session save/load, undo classification, wire schema).
  • 18.4 — Auto-routing policies (cheapest-capable / fastest / best-quality) over the unified catalog, a Settings page control, and live wiring so a task with nothing explicitly configured resolves through the policy instead of erroring.
  • 18.5 — Fallback chains: a retryable/unavailable failure on a fallback-enabled task (naming/triage — task_title, task_web_validate) retries once against a different provider, with the substitution surfaced as a visible notification. Correctness-sensitive tasks (chat, chart, ...) never auto-swap.

An adversarial review pass after stage 18.5 surfaced two real bugs, both fixed with regression tests in the final commit:

  • unified_catalog() stored llama.cpp scanned models by their full filesystem path, but dispatch only ever accepts the basename — any auto/fallback pick landing on a llama.cpp candidate was unconditionally rejected.
  • The "nothing configured, auto-pick" recursive call inside chat()/chat_stream() dropped settings_manager, silently disabling the fallback rung specifically for the population of requests that reach it via auto-pick.

Test plan

  • python -m pytest -q (full suite) — 2199 passed, 16 skipped
  • npm run check (schema + typecheck + lint + test + build + bundle-size) — 1584 tests passed, clean build
  • Adversarial review pass; both findings fixed with dedicated regression tests

dovvnloading and others added 7 commits August 9, 2026 12:33
ModelRef(provider, model_id) replaces "task" as the addressing scheme for
which provider+model a chat call actually hits. graphlink_model_catalog.py
gains ModelRef/ResolvedModel, the resolution chain (resolve_model_ref),
three auto policies over the catalog (choose_auto_model_ref: cheapest-
capable/fastest/best-quality, always capability-filtered), and
unified_catalog() - a pure aggregation of Ollama/llama.cpp scan results
plus each API provider's cached catalog into one list, annotated with
per-mtok cost via a caller-supplied price_lookup (token_counter.py's
pricing table, extracted into price_per_mtok so both the cost estimator
and the catalog price off the same numbers).

api_provider.chat()/chat_stream() accept an optional model_ref kwarg that
takes precedence over the existing task-keyed dict lookup; every call
site that doesn't pass one is unaffected. A resolved ref naming Ollama or
Llama.cpp is always constructible regardless of the session's configured
mode (neither needs credentials) - the realistic mixed local+cloud
comparison this ADR exists for. A ref naming a cloud provider other than
the session's currently configured one raises an actionable error rather
than silently falling back or reaching for credentials the request
snapshot was never given; genuine simultaneous multi-cloud-credential
routing is out of scope for this stage.

Test plan:
- backend/tests/test_model_routing.py (17 tests): the three auto policies
  including capability-filtering (never routes a vision requirement to a
  text-only model) and the ready/available filter; the full resolution
  chain's precedence order and its "explicit pins are never capability-
  filtered" posture; unified_catalog's aggregation and price annotation;
  api_provider.chat()/chat_stream() actually dispatching on a supplied
  model_ref (bypassing an intentionally-unconfigured task table), routing
  to Ollama while the session is in API mode, and the cross-cloud-mismatch
  actionable error.
- tests/test_node_state_migration.py: added the one new non-SceneNode
  "provider" access shape (ModelDescriptor, not SceneNode) the new test
  file's own iteration introduced.
- backend/tests/test_providers.py + test_api_provider_reasoning.py +
  test_backend_composer.py + test_agents.py: 337 passed, confirming the
  dispatch rewrite is behavior-preserving for every existing call site.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
ChatState gains override_provider/override_model_id - an explicit model
pin, the input-routing opposite of the existing provider/model provenance
fields (which record what generated a completed reply, not what should
generate the next one). SceneDocument.set_model_override/
clear_model_override write it; resolve_model_for_node reads it back for a
node and, separately, for its branch root - mirroring _resolve_branch_
system_prompt's exact root-walk shape (get_branch_root, then read one
field off the root) rather than inventing a new inheritance mechanism.

AgentDispatcher._resolve_model_ref_for_dispatch computes node-override ->
branch-override (auto/workspace-default stay out of scope for this stage -
see the method's own docstring) and threads the result through the same
omit-when-None kwarg chain persona overrides already use: _dispatch ->
_call_chat_agent(_stream) -> ChatAgent.get_response -> ChatWorker.run ->
api_provider.chat/chat_stream(model_ref=...). No pin anywhere means the
kwarg is never even passed - every existing call site is unaffected.

Test plan:
- backend/tests/test_agents.py (7 new): resolve_model_for_node's
  precedence order (node pin beats branch-root pin) and root-walk
  correctness at every depth; sendMessage/regenerateResponse actually
  carrying the resolved ModelRef through the real dispatch pipeline into
  _call_chat_agent_stream; the negative case (no pin anywhere never adds
  a model_ref kwarg at all, not even as None).
- backend/domain/branches.py sanity-checked directly (set/clear/root-walk
  precedence) before writing the dispatch-level tests.
- Full suite from repo root: 2180 passed, 16 skipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…for the model-override pin

SceneNodeRow gains overrideProvider/overrideModelId (additive, "" default,
codegen regenerated), populated in scene_payload() from ChatState's own
fields. session_save.py/session_load.py round-trip them the same way
provider/model already do. Two new WS intents (setModelOverride/
clearModelOverride, backend/api/intents_model_routing.py) wrap
SceneDocument.set_model_override/clear_model_override in record_command -
classified "A" (undoable) in the ADR-010 close-out table, same posture as
setGroupColor.

Test plan:
- backend/tests/test_session_save.py + test_session_load.py: the pin
  round-trips through build_chat_data/restore_chat_payload; absent in a
  save (every pre-18.3 row) restores to "" / no pin, never a crash.
- backend/tests/test_canvas.py: the two intents actually mutate the node
  and publish through the real WS dispatch path.
- tests/test_node_state_migration.py: golden scene_payload() key-set
  snapshot updated (additive keys only).
- tests/test_undo_classification_gate.py +
  tests/undo_classification.py: both new intents classified and the
  locked registered-intent population count updated 140 -> 142.
- contracts/codegen.py --check: clean, two-field diff only.
- Full suite from repo root: 2185 passed, 16 skipped.

Frontend (badge + picker UI) is the remaining half of this stage.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A distinct badge (📌, tinted not muted) renders whenever a node carries a
model pin - deliberately separate from the existing provenance badge, since
they answer different questions (what generated the last reply vs. what
pins the next one) and can legitimately co-occur.

The node menu gains "Pin to Current Model" (pins to whatever the Composer's
live route currently resolves to - reading composerStore.getComposer() at
click time, never subscribed, since only the click matters) and, when a pin
exists, "Clear Model Pin (<model>)". A full "browse the installed catalog"
picker needs a live catalog fetch this stage doesn't wire to the frontend -
"pin to current" is the honestly-scoped, fully-functional slice; see
ADR-018's own status note.

Wiring: sceneStore gains setModelOverride/clearModelOverride (plain
fireIntent wrappers, queueable like setGroupColor - both operations are
idempotent). SceneCanvas's stable per-node dispatcher gains the two
callbacks; getComposerRoute threads through as an optional getter
(App.tsx's real render supplies it, every other <SceneCanvas> render -
every existing test - keeps its harmless no-op default).

Test plan:
- ChatNodeView.test.tsx: badge absent/present/tooltip, co-rendering with
  the provenance badge, both menu items' click behavior including the
  current-route-unresolved no-op case.
- SceneCanvas.test.tsx: dispatcher wiring reads getComposerRoute at click
  time and calls the right store method; the no-op case when the route
  has nothing resolved yet.
- sceneStore.test.ts: both new methods send the right intent/args.
- Fixed 5 pre-existing test fixture builders (sceneStore.test.ts,
  SceneCanvas.test.tsx, ChatNodeView.test.tsx, renderCountGate.test.tsx,
  SceneCanvas.pinSearchJump.test.tsx, SceneCanvas.virtualization.test.tsx)
  that needed the two new SceneNodeRow/ChatNodeData fields to keep
  satisfying the (now stricter) generated wire schema/types.
- Full `npm run check` (schema + typecheck + lint + test + build +
  bundle-size): clean. 1582 tests passed across 60 files.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…gs UI

Adds SettingsManager.get/set_auto_model_policy (persisted, closed-vocabulary
cheapest-capable/fastest/best-quality) and wires it into the auto rung of the
resolution chain: api_provider.chat()/chat_stream() now attempt an
auto-fallback via unified_catalog + the persisted policy at the exact point
they would otherwise raise "no model configured" - filtered to only
providers this session can actually dispatch to right now (both local
providers always; a cloud provider only when it is the session's live
credentialed one), so the auto rung can never hand back a ref
_provider_for_model_ref would then reject.

settings_manager threads down the same additive, omit-when-None kwarg chain
already built for model_ref (agents.py -> graphlink_chat_agent.py ->
api_provider.py). Adds the setAutoModelPolicy WS intent and a General-page
dropdown in the Settings dialog.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wraps api_provider.chat()/chat_stream() in a fallback-chain outer layer:
when a task in FALLBACK_ENABLED_TASKS (task_title, task_web_validate -
"naming/triage" per the ADR's own framing) hits a retryable/unavailable
failure after ADR-006's own same-provider transport retry is exhausted, one
more attempt fires against a different provider chosen by the same
auto-policy catalog stage 18.4 built, excluding the provider that just
failed. Correctness-sensitive tasks (task_chat, task_chart, ...) are
untouched - no fallback fires for them, matching "off by default for
correctness-sensitive tasks" from the ADR's decision #4.

The substitution is never silent: on_fallback threads down the same
additive kwarg chain as model_ref/settings_manager, and backend/agents.py's
dispatch surfaces it as a warning notification naming both the provider
that failed and the one substituted in. chat_stream's own fallback is
additionally guarded so it can only fire before any real text has reached
the caller - mirroring the existing "nothing forwarded yet" invariant the
transport-retry layer already established, so a partially-delivered reply
is never silently replayed against a different model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…cursion leak

Two bugs surfaced by an adversarial review of the full 5-stage branch:

- unified_catalog() stored llama.cpp scanned models as their full scanned
  path, but _provider_for_model_ref's llama.cpp branch only ever accepts a
  model_id matching the BASENAME of a configured path (the same convention
  describe_active_model already uses - llama.cpp has no "load any installed
  model by id" catalog the way Ollama does). Every auto-pick or fallback
  landing on a llama.cpp candidate was therefore unconditionally rejected.
  Fixed by reducing to Path(...).name at catalog-build time.

- The "no model configured" auto-pick branches inside chat()/chat_stream()
  (now _chat_dispatch/_chat_stream_dispatch after 18.5's rename) recurse
  into the module-level chat()/chat_stream() names to dispatch the
  auto-picked ref - but that name now resolves to 18.5's own fallback
  wrapper, which had already popped settings_manager into a local before
  ever calling into the dispatch body, and the recursive call never
  re-included it. A failure on an auto-picked model (exactly the
  "nothing was configured" population FALLBACK_ENABLED_TASKS targets)
  could never trigger a further fallback attempt as a result. Fixed by
  re-including settings_manager in both recursive calls, in both
  functions.

Adds regression tests for both (pure-function catalog shape, live dispatch
through the real basename-matching path, and a live end-to-end trace
through the previously-broken recursion).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dovvnloading
dovvnloading merged commit 4d2a279 into main Aug 9, 2026
3 checks passed
@dovvnloading
dovvnloading deleted the adr018-model-routing branch August 9, 2026 18:34
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