Skip to content

feat(models): fusion models — give a text-only model vision - #771

Merged
ericleepi314 merged 3 commits into
mainfrom
worktree-fusion-model
Jul 30, 2026
Merged

feat(models): fusion models — give a text-only model vision#771
ericleepi314 merged 3 commits into
mainfrom
worktree-fusion-model

Conversation

@ericleepi314

@ericleepi314 ericleepi314 commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

The problem

Some strong reasoning models cannot see images at all. deepseek-v4-pro rejects any non-text content block outright — probed against api.deepseek.com:

400  Failed to deserialize the JSON body into the target type:
     messages[0]: unknown variant `image_url`, expected `text`

So pasting a screenshot, @-mentioning an image, or letting Read return one ended the turn. Reproduced through the real CLI before this change:

$ clawcodex -p --model deepseek-v4-pro "Read probe.png and tell me the colour of the LEFT half."
Query error: Error code: 400 - ... unknown variant `image_url`, expected `text`

What this adds

A fusion model pairs that base model with a second, vision-capable one. Every image is sent to the vision model first, and its description is what the base model reads.

/fusion create deepseek-v4-pro-V deepseek:deepseek-v4-pro openrouter:google/gemini-2.5-flash
/model deepseek-v4-pro-V

Same prompt, same image, after:

$ clawcodex -p --model deepseek-v4-pro-V "Read probe.png and tell me the colour of the LEFT half."
The left half is red, and the shape in the middle is a white horizontal bar.

Ported from claude-code-router's Fusion Models, keeping its new model = base + capability shape and its create / configure / save / manage lifecycle.

  • /fusion list | create | delete | enable | disable — registered as a real builtin, so it works in the TUI, -p headless, and the SDK.
  • A saved fusion model behaves like a normal model: /model <name>, --model <name>, it appears first in the /model picker, and it survives a restart. (CCR: "a Fusion model appears in routing and Agent Profiles like a normal model.")
  • Stored in ~/.clawcodex/config.jsonfusionModels.

Mechanism — and one deliberate divergence from CCR

CCR is a proxy, so its only lever is exposing vision as an MCP tool the base model may choose to call. That cannot fix a pasted image: it is already on the wire, and DeepSeek 400s before the model gets a turn.

clawcodex owns the agent loop, so it does what CCR's docs actually describe — "connects a vision model in front of the base model … passes that visual result to the base model" — directly, by substituting image blocks in place. One consequence worth the trade: every entry point works at once (paste, @file.png, Read on an image, Bash image output), with no reliance on the model electing to call a tool.

FusionProvider is a delegating wrapper (the established _EffortProvider pattern) with three invariants:

  1. No image block survives — even on vision failure, timeout, or the per-request cap. A leftover one is the 400 this feature prevents, so failures degrade to a text note naming the cause.
  2. The caller's message list is never mutated — the session owns the conversation, and the original images must survive a switch back to a vision-capable model. Copy-on-write, so a turn with no images returns the original list object and costs nothing.
  3. Each distinct image is described once — history is replayed every turn, so a process-wide bounded LRU keyed by content hash plus the vision configuration keeps an N-turn conversation from paying N times for one screenshot. Failures are cached too, with a 90s TTL so a transient outage neither repeats nor poisons.

Other design points:

  • .model reports the BASE model id, not the fusion name — context window, cost, capability probes, and the wire's own model field all key off it. The fusion name rides fusion_name plus a fusion field on the init envelope and set_model reply, which is what the TUI displays.
  • Anthropic-wire base providers are rejected at validation: six live isinstance(provider, AnthropicProvider) sites (prompt caching, deferred tools, the goal judge, the memory selector) are blind to a wrapper, and every Anthropic-wire model already accepts images.
  • Global config tier only. A fusion model names the provider that receives image bytes — exactly what _UNTRUSTED_TIER_BLOCKED_KEYS withholds from committable tiers — so a checked-out repo can never contribute one.
  • _install_provider is extracted from _do_set_provider so selecting a fusion model (which can be a cross-provider switch) performs an identical rebuild instead of a second, drifting copy.

Model-table corrections (probed 2026-07-30)

supports_vision was dead outside tests. It is now load-bearing — the validator rejects a known text-only vision half — and correspondingly corrected:

Model Endpoint result
deepseek-v4-pro, deepseek-v4-flash 400 unknown variant image_urlsupports_vision=False
glm-5.2, glm-5.1 400 code 1210 content.type is invalid, allowed values: ['text'] on both Z.ai endpoints → supports_vision=False
glm-4.5v, glm-4.6v, glm-5v-turbo accept images (429 insufficient-balance on a Coding-Plan-only key = entitlement, not shape) → registered explicitly

glm-5.2 is not multimodal, despite the family having vision siblings. Z.ai's vision models are the separate *v family — which is what CCR's own GLM-5.2 + GLM-5V-Turbo = GLM-5.2V example refers to. That is the feature's most inviting mistake, so the validator rejects it and names the *v family in the error.

supports_vision also stops trusting a prefix-inherited False: get_model_config falls back to key.rsplit("-", 1)[0], and the glm-5.2 row's base is bare glm — so every glm* id, including the vision models the error message recommends, would have inherited False. A False reached by prefix match is not evidence.

Verification

  • Live, through the real CLI with real keys, via the Read tool (the nested tool_result path): base model 400s, fusion model describes the image correctly. Also verified starting fused with an unconfigured default provider, which the credential-ordering fix enables.
  • All /fusion and set_model controls driven over the real agent-server --stdio transport — create/duplicate/validate, picker ordering, switch on, switch away, disable → refuse → re-enable.
  • Python suite: 9225 passed, 3 skipped, 0 failed.
  • vitest: baseline + 11. Its 8 failures are pre-existing, established independently by reverting this branch's TS change (createGatewayEventHandler ×2, cursorDriftRegression, statusRule ×2, useConfigSync ×2, virtualHeights — none touches fusion).
  • Mutation-tested: every fix in the review pass was verified to be gated by a named test — reverting the fix fails that test and no other.

Second commit: model restore, unified (cdfa11ef)

Applies the TS reference's approach (main.tsx:1984userSpecifiedModel ?? getUserSpecifiedModelSetting() ?? null). clawcodex had two partial read sides and one dead one: _build_runtime applied the persisted model after constructing the provider, reading settings.model raw; headless ignored it entirely (so a /model switch had to be re-stated with --model every -p run); and settings.apply_persisted_model was a third implementation with no callers.

Now one helper — settings.get_persisted_model — used by both entrypoints before construction, which is what lets a persisted fusion model decide which provider to build. A fusion model persists its name (it was persisting the base id, so a restart silently dropped vision) and is exempt from the provider-match guard, since its record names its own provider — but not from an explicit --provider, which still wins.

Three defects found while building it, each with a regression test:

Found by Defect
review A disabled fusion name still reached the wire — get_persisted_model declined it, the raw-seed backstop assigned it anyway. Backstop deleted, not guarded; a mutation-verified test asserts it stays gone.
self-probe A persisted fusion model overrode an explicit --provider.
full suite get_persisted_model broke its own "never raises" contract — only the load was wrapped, not the attribute reads, so a partial settings stub raised AttributeError inside headless startup.

Deleting a fusion model clears a persisted selection naming it; disabling needs no clearing (the record survives and the read side checks enabled).

Verified live: /model deepseek-v4-pro-V → kill → fresh session with no flags restores it and reads an image; disable → provider default, no dangling name; re-enable → restored.

Known limitations (stated, not overlooked)

  • The vision leg's tokens are not in /cost. The cost tracker follows a single model — correctly the base, since that is what pricing and context-window lookups key off. The usage is logged so the spend is discoverable; threading a second model into single-model accounting is a cost-subsystem change.
  • The name-shadowing guard is create-time only. A hand-edited config can still register a fusion model named after another vendor's id. Self-inflicted rather than an attack surface — global tier, so no repo can contribute one — and documented on load_fusion_models.
  • Vision only. CCR also fuses web search, image/video generation, and arbitrary MCP tools; those are separate capabilities with their own config surfaces.

🤖 Generated with Claude Code

Some strong reasoning models cannot see images at all. `deepseek-v4-pro`
rejects any non-text content block outright:

    400  unknown variant `image_url`, expected `text`

so pasting a screenshot, `@`-mentioning an image, or letting `Read` return
one ended the turn. A *fusion model* pairs that base model with a second,
vision-capable one: every image is sent to the vision model first, and its
description is what the base model reads.

    /fusion create deepseek-v4-pro-V deepseek:deepseek-v4-pro \
            openrouter:google/gemini-2.5-flash
    /model deepseek-v4-pro-V

Ported from claude-code-router's Fusion Model concept, keeping its
`new model = base + capability` shape and its create/save/manage lifecycle
(https://ccrdesk.top/en/configuration/fusion-models/).

One deliberate divergence in mechanism: CCR is a proxy, so its only lever
is exposing vision as an MCP tool the model may choose to call — which
cannot help a *pasted* image, already on the wire before the model gets a
turn. clawcodex owns the agent loop, so it does what CCR's docs describe
("connects a vision model in front of the base model") directly, by
substituting image blocks in place. That covers every entry point at once:
paste, `@file.png`, `Read` on an image, and `Bash` image output.

Scope is vision only; CCR's web-search, media-generation, and custom-MCP
fusion capabilities are separate surfaces and are not ported.

Design notes
------------
* `FusionProvider` (src/providers/fusion_provider.py) is a delegating
  wrapper, the established `_EffortProvider` pattern. Three invariants:
  no image block survives the rewrite (even on failure, timeout, or cap —
  a leftover one is the 400 this exists to prevent); the caller's message
  list is never mutated (the session owns it, and the originals must
  survive a switch back to a vision-capable model); each distinct image is
  described once, via a process-wide bounded LRU keyed by content hash plus
  the vision configuration, because history is replayed every turn.
* `.model` reports the BASE model id, not the fusion name: context window,
  cost, capability probes, and the wire's own `model` field all key off it.
  The fusion name rides `fusion_name` and a `fusion` field on the init
  envelope / `set_model` reply, which is what the TUI displays.
* Anthropic-wire base providers are rejected at validation: six live
  `isinstance(provider, AnthropicProvider)` sites (prompt caching, deferred
  tools, the goal judge, the memory selector) are blind to a wrapper, and
  every Anthropic-wire model already accepts images anyway.
* Records live in the GLOBAL config tier only (`fusionModels`). A fusion
  model names the provider that receives image bytes, which is exactly what
  `_UNTRUSTED_TIER_BLOCKED_KEYS` withholds from committable tiers; keeping
  the key global-only means a checked-out repo can never contribute one.
* `_install_provider` is extracted from `_do_set_provider` so selecting a
  fusion model — which can be a cross-provider switch — performs an
  identical rebuild instead of a second, drifting copy.

Model table
-----------
`supports_vision` was dead outside tests; it is now load-bearing (the
validator rejects a known text-only vision half) and correspondingly
corrected. Probed 2026-07-30: `deepseek-v4-pro`, `deepseek-v4-flash`,
`glm-5.2`, and `glm-5.1` are vision-less; Z.ai's vision models are the
separate `*v` family (`glm-4.5v`, `glm-4.6v`, `glm-5v-turbo` — the
"GLM-5V-Turbo" of CCR's own example), now registered explicitly.

`supports_vision` also stops trusting a prefix-inherited `False`:
`get_model_config` falls back to `key.rsplit("-", 1)[0]`, and the `glm-5.2`
row's base is bare `glm`, so every `glm*` id — including the vision models
the validator's error message recommends — would have inherited `False`.

Verification
------------
* Live, through the real CLI with real keys: `--model deepseek-v4-pro`
  400s on an image; `--model deepseek-v4-pro-V` describes it correctly,
  via the `Read` tool (the nested `tool_result` path).
* All `/fusion` and `set_model` controls driven over the real
  `agent-server --stdio` transport.
* Full Python suite green (9210 passed, 3 skipped); vitest at
  baseline + 11 (its 8 failures are pre-existing, confirmed by reverting
  this branch's TS change).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

Test Results

    1 files      1 suites   8m 1s ⏱️
9 233 tests 9 227 ✅ 6 💤 0 ❌
9 479 runs  9 473 ✅ 6 💤 0 ❌

Results for commit aaa9f8b.

♻️ This comment has been updated with latest results.

ericleepi314 and others added 2 commits July 30, 2026 09:18
…point

Follows the TS reference the user pointed at: ``main.tsx:1984`` resolves
``userSpecifiedModel ?? getUserSpecifiedModelSetting() ?? null`` — explicit
flag, then the saved setting, then the default — with a guard so a model
saved under one provider is never fired at another.

clawcodex had two partial read sides and one dead one:

* ``_build_runtime`` applied the persisted model AFTER constructing the
  provider (``provider.model = seeded_state.main_loop_model``), reading
  ``settings.model`` raw.
* ``headless`` ignored it entirely, so a ``/model`` switch had to be
  re-stated with ``--model`` on every subsequent ``-p`` run.
* ``settings.apply_persisted_model`` was a third implementation with no
  callers at all.

Now one helper, ``settings.get_persisted_model``, used by both entrypoints
BEFORE the provider is constructed — which is also what lets a persisted
fusion model decide which provider to build. ``apply_persisted_model`` is
removed; its tests are ported, intent intact.

Fusion models survive a restart
-------------------------------
Selecting one persisted the BASE model id, so a restart silently dropped
vision. ``_install_provider`` gains ``persist_model=`` and the fusion branch
persists the fusion NAME; startup resolves it back to the record. Because a
fusion record names its own base provider, it is exempt from the
provider-match guard — but NOT from an explicit ``--provider``, which still
wins (restoring a fusion model replaces the session provider, so honouring
it over a typed flag would silently ignore the user).

Three defects found while building this, each with a regression test:

* A DISABLED fusion name still reached the wire. ``get_persisted_model``
  declined it, but the raw-seed backstop assigned it anyway — two read paths
  disagreeing. The backstop is deleted rather than guarded, and a test
  asserts it stays gone (mutation-verified: restoring it fails that test and
  no other).
* A persisted fusion model overrode an explicit ``--provider``.
* ``get_persisted_model`` violated its own "never raises" contract — only the
  settings load was wrapped, not the attribute reads, so a partial settings
  stub raised AttributeError inside headless startup
  (``tests/entrypoints/test_headless_goal.py``). It runs on every
  entrypoint's startup path, so anything escaping there aborts a launch.

Deleting a fusion model now clears a persisted selection naming it;
otherwise the restore reads a dangling name, misses the fusion branch, and
sends it to the wire. Disabling needs no clearing — the record survives and
the read side checks ``enabled``.

Verified live: ``/model deepseek-v4-pro-V`` -> kill -> a fresh session with
no flags restores it and reads an image; disable -> falls back to the
provider default with no dangling name; re-enable -> restored. Plain-model
restart and the cross-provider staleness guard verified too.

Full suite 9225 passed / 3 skipped / 0 failed; vitest at baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review follow-ups on the model-restore change.

The substantive one: ``model_choice`` was computed OUTSIDE the
``if cfg.single_session:`` block that used to hold the persisted-model
restore, so moving the read earlier silently widened its scope — the
server operator's own model choice would have applied to every client
session on the multi-session ``--http`` transport. Same shape as the
bypass-availability refusal a few lines below ("would let the server host's
own settings unlock bypass for every client session"), with a much milder
consequence, but a scope change should be a decision rather than a side
effect. Gated, restoring the previous behaviour exactly.

Also:

* ``_forget_persisted_selection`` now writes through
  ``state.app_state._persist_settings_keys`` instead of hand-rolling
  load/mutate/save/invalidate. The hand-rolled copy would have silently
  stopped clearing if the persistence format ever moved, bringing the
  dangling-fusion-name class back.
* Rewrote the app-state comment left stale by the previous commit: it still
  claimed the seed applies a persisted model to the provider, which is now
  the opposite of what happens (and contradicted the comment 30 lines
  below).
* Tests for the two behaviours nothing guarded: headless persisted-model
  resolution (precedence, provider-default fallback, and the
  ``provider_is_explicit`` signal — mutation-verified: dropping the wiring
  fails two of them) and the store-seed pinning that keeps app state
  agreeing with the provider actually built.

Full suite 9230 passed / 3 skipped / 0 failed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@ericleepi314
ericleepi314 merged commit 8006799 into main Jul 30, 2026
3 checks passed
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