feat(models): fusion models — give a text-only model vision - #771
Merged
Conversation
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>
Test Results 1 files 1 suites 8m 1s ⏱️ Results for commit aaa9f8b. ♻️ This comment has been updated with latest results. |
…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>
3 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The problem
Some strong reasoning models cannot see images at all.
deepseek-v4-prorejects any non-text content block outright — probed againstapi.deepseek.com:So pasting a screenshot,
@-mentioning an image, or lettingReadreturn one ended the turn. Reproduced through the real CLI before this change: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.
Same prompt, same image, after:
Ported from claude-code-router's Fusion Models, keeping its
new model = base + capabilityshape and its create / configure / save / manage lifecycle./fusion list | create | delete | enable | disable— registered as a real builtin, so it works in the TUI,-pheadless, and the SDK./model <name>,--model <name>, it appears first in the/modelpicker, and it survives a restart. (CCR: "a Fusion model appears in routing and Agent Profiles like a normal model.")~/.clawcodex/config.json→fusionModels.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,Readon an image,Bashimage output), with no reliance on the model electing to call a tool.FusionProvideris a delegating wrapper (the established_EffortProviderpattern) with three invariants:Other design points:
.modelreports the BASE model id, not the fusion name — context window, cost, capability probes, and the wire's ownmodelfield all key off it. The fusion name ridesfusion_nameplus afusionfield on the init envelope andset_modelreply, which is what the TUI displays.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._UNTRUSTED_TIER_BLOCKED_KEYSwithholds from committable tiers — so a checked-out repo can never contribute one._install_provideris extracted from_do_set_providerso 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_visionwas dead outside tests. It is now load-bearing — the validator rejects a known text-only vision half — and correspondingly corrected:deepseek-v4-pro,deepseek-v4-flash400 unknown variant image_url→supports_vision=Falseglm-5.2,glm-5.1400 code 1210 content.type is invalid, allowed values: ['text']on both Z.ai endpoints →supports_vision=Falseglm-4.5v,glm-4.6v,glm-5v-turboglm-5.2is not multimodal, despite the family having vision siblings. Z.ai's vision models are the separate*vfamily — which is what CCR's ownGLM-5.2 + GLM-5V-Turbo = GLM-5.2Vexample refers to. That is the feature's most inviting mistake, so the validator rejects it and names the*vfamily in the error.supports_visionalso stops trusting a prefix-inheritedFalse:get_model_configfalls back tokey.rsplit("-", 1)[0], and theglm-5.2row's base is bareglm— so everyglm*id, including the vision models the error message recommends, would have inheritedFalse. AFalsereached by prefix match is not evidence.Verification
Readtool (the nestedtool_resultpath): base model 400s, fusion model describes the image correctly. Also verified starting fused with an unconfigured default provider, which the credential-ordering fix enables./fusionandset_modelcontrols driven over the realagent-server --stdiotransport — create/duplicate/validate, picker ordering, switch on, switch away, disable → refuse → re-enable.createGatewayEventHandler×2,cursorDriftRegression,statusRule×2,useConfigSync×2,virtualHeights— none touches fusion).Second commit: model restore, unified (
cdfa11ef)Applies the TS reference's approach (
main.tsx:1984—userSpecifiedModel ?? getUserSpecifiedModelSetting() ?? null). clawcodex had two partial read sides and one dead one:_build_runtimeapplied the persisted model after constructing the provider, readingsettings.modelraw;headlessignored it entirely (so a/modelswitch had to be re-stated with--modelevery-prun); andsettings.apply_persisted_modelwas 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:
get_persisted_modeldeclined it, the raw-seed backstop assigned it anyway. Backstop deleted, not guarded; a mutation-verified test asserts it stays gone.--provider.get_persisted_modelbroke its own "never raises" contract — only the load was wrapped, not the attribute reads, so a partial settings stub raisedAttributeErrorinside 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)
/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.load_fusion_models.🤖 Generated with Claude Code