From 09d1d351fa60cfbe5a0dacdec0dce575ec28bf23 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Thu, 30 Jul 2026 02:33:24 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(models):=20fusion=20models=20=E2=80=94?= =?UTF-8?q?=20give=20a=20text-only=20model=20vision?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 40 ++ src/command_system/__init__.py | 3 + src/command_system/builtins.py | 2 + src/command_system/fusion_command.py | 179 ++++++ src/entrypoints/headless.py | 67 ++- src/models/capabilities.py | 28 +- src/models/configs.py | 54 ++ src/providers/__init__.py | 22 + src/providers/fusion_models.py | 600 +++++++++++++++++++ src/providers/fusion_provider.py | 720 +++++++++++++++++++++++ src/server/agent_server.py | 393 +++++++++++-- tests/providers/test_fusion_models.py | 388 ++++++++++++ tests/providers/test_fusion_provider.py | 649 ++++++++++++++++++++ tests/server/test_fusion_control.py | 401 +++++++++++++ tests/test_fusion_command.py | 186 ++++++ ui-tui/src/__tests__/fusionModel.test.ts | 198 +++++++ ui-tui/src/gatewayClient.ts | 55 +- 17 files changed, 3911 insertions(+), 74 deletions(-) create mode 100644 src/command_system/fusion_command.py create mode 100644 src/providers/fusion_models.py create mode 100644 src/providers/fusion_provider.py create mode 100644 tests/providers/test_fusion_models.py create mode 100644 tests/providers/test_fusion_provider.py create mode 100644 tests/server/test_fusion_control.py create mode 100644 tests/test_fusion_command.py create mode 100644 ui-tui/src/__tests__/fusionModel.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8144703cc..1568edabb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,46 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- **Fusion models — give a text-only model vision.** Some strong reasoning + models cannot see images at all: `deepseek-v4-pro` rejects any image 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 model + capability` shape and its create/save/manage + lifecycle. One deliberate difference: CCR is a proxy, so it can only offer + vision as a 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 directly, substituting images + in place. Every entry point is covered at once: paste, `@file.png`, `Read` on + an image, and `Bash` image output. + + - `/fusion` lists, creates, deletes, and enables/disables saved models; they + are stored in `~/.clawcodex/config.json` under `fusionModels` (user-level + only, so a checked-out repo cannot redirect where your screenshots go). + - A saved fusion model behaves like a normal model: it appears in the + `/model` picker and works as `--model `, interactively and in `-p`. + - Each distinct image is described once per session, so replayed history + does not re-pay for the same screenshot. Vision failures degrade to a note + naming the cause rather than killing the turn. + - Requires the vision half to actually support images. Notably `glm-5.2` does + **not** — Z.ai's vision models are the separate `*v` family (`glm-4.5v`, + `glm-5v-turbo`), which is what CCR's own `GLM-5.2 + GLM-5V-Turbo` example + refers to. `deepseek-v4-pro`, `deepseek-v4-flash`, `glm-5.2`, and `glm-5.1` + are now marked vision-less in the model table. + + [claude-code-router]: https://ccrdesk.top/en/configuration/fusion-models/ + ### Changed - **Permissions are now loose by default and easy to change.** `/mode` is diff --git a/src/command_system/__init__.py b/src/command_system/__init__.py index ea3241d8b..c49eb31aa 100644 --- a/src/command_system/__init__.py +++ b/src/command_system/__init__.py @@ -77,6 +77,7 @@ from .export_command import EXPORT_COMMAND, ExportCommand from .theme_command import THEME_COMMAND, ThemeCommand from .eco_command import ECO_COMMAND, eco_command_call +from .fusion_command import FUSION_COMMAND, fusion_command_call from .effort_command import EFFORT_COMMAND, EffortCommand from .model_command import MODEL_COMMAND, ModelCommand from .logo_command import LOGO_COMMAND, LogoCommand @@ -168,6 +169,8 @@ "ThemeCommand", "ECO_COMMAND", "eco_command_call", + "FUSION_COMMAND", + "fusion_command_call", "EFFORT_COMMAND", "EffortCommand", "MODEL_COMMAND", diff --git a/src/command_system/builtins.py b/src/command_system/builtins.py index 6aa39972e..2a1082f48 100644 --- a/src/command_system/builtins.py +++ b/src/command_system/builtins.py @@ -30,6 +30,7 @@ from .statusline import STATUSLINE_COMMAND from .theme_command import THEME_COMMAND from .eco_command import ECO_COMMAND +from .fusion_command import FUSION_COMMAND from .effort_command import EFFORT_COMMAND from .model_command import MODEL_COMMAND from .logo_command import LOGO_COMMAND @@ -1333,6 +1334,7 @@ def get_builtin_commands() -> list[Command]: EXPORT_COMMAND, THEME_COMMAND, ECO_COMMAND, + FUSION_COMMAND, EFFORT_COMMAND, MODEL_COMMAND, LOGO_COMMAND, diff --git a/src/command_system/fusion_command.py b/src/command_system/fusion_command.py new file mode 100644 index 000000000..ae61d5d90 --- /dev/null +++ b/src/command_system/fusion_command.py @@ -0,0 +1,179 @@ +"""/fusion — create and manage fusion models (base reasoning + borrowed vision). + +The management surface for :mod:`src.providers.fusion_models`; the CCR web +UI's Fusion Models pane (``packages/ui/src/pages/home/components/virtual-models.tsx``) +rendered as a slash command. That pane is a list with an ``Add`` button and +per-row enable/edit/remove; the same operations here: + + /fusion list saved fusion models + /fusion create save a new one + /fusion delete remove one + /fusion enable|disable toggle without losing config + /fusion help usage + +```` and ```` are ``provider:model`` selectors, matching +``/advisor``'s grammar. CCR's dialog reads + + New model = Base model + Tools(vision → Vision model) + +which is exactly ``create ``. + +Every path is headless-safe (no UI surface needed), so the command works +identically in the TUI, ``-p`` headless, and the SDK. +""" + +from __future__ import annotations + +from .types import CommandContext, LocalCommand, LocalCommandResult + +_USAGE = ( + "Usage: /fusion [list | create | delete |\n" + " enable | disable ]\n\n" + "A fusion model gives a text-only reasoning model the ability to see\n" + "images, by sending any image to a second, vision-capable model and\n" + "passing its description to the base model. Pick it with /model .\n\n" + " and are : selectors.\n\n" + "Example:\n" + " /fusion create deepseek-v4-pro-V deepseek:deepseek-v4-pro \\\n" + " openrouter:google/gemini-2.5-flash\n" + " /model deepseek-v4-pro-V" +) + + +def _list_text() -> str: + from src.providers.fusion_models import load_fusion_models + + models = load_fusion_models() + if not models: + return ( + "No fusion models saved.\n\n" + "A fusion model lets a text-only model (deepseek-v4-pro, for one)\n" + "work with screenshots and images by borrowing vision from another\n" + "model. Create one with:\n" + " /fusion create \n" + "See /fusion help for an example." + ) + lines = [f"Fusion models ({len(models)}):"] + lines.extend(f" {m.summary()}" for m in models) + lines.append("") + lines.append("Select one with /model .") + return "\n".join(lines) + + +def _create(rest: list[str]) -> str: + from src.providers.fusion_models import ( + FusionModelError, + create_fusion_model, + suggest_fusion_name, + ) + + if len(rest) == 2: + # Name omitted — derive it from the base model, following CCR's + # "use names that make the capability source obvious" convention + # (GLM-5.2 + GLM-5V-Turbo = GLM-5.2V). Saves the common case from + # having to invent a name. + from src.providers.fusion_models import parse_selector + + try: + base_ref = parse_selector(rest[0], field="base") + except FusionModelError as exc: + return str(exc) + name, base, vision = suggest_fusion_name(base_ref.model), rest[0], rest[1] + elif len(rest) == 3: + name, base, vision = rest[0], rest[1], rest[2] + else: + return ( + "Usage: /fusion create [] \n\n" + " the reasoning model, as :\n" + " a vision-capable model, as :\n\n" + "Omit to have one derived from the base model.\n" + "Example: /fusion create deepseek:deepseek-v4-pro " + "openrouter:google/gemini-2.5-flash" + ) + + try: + model = create_fusion_model(name, base, vision) + except FusionModelError as exc: + return str(exc) + + return ( + f"Created fusion model {model.name!r}:\n" + f" reasoning {model.base.selector}\n" + f" vision {model.vision.selector}\n\n" + f"Select it with /model {model.name}. Images will be described by " + f"{model.vision.selector} before {model.base.model} sees them." + ) + + +def _delete(rest: list[str]) -> str: + from src.providers.fusion_models import FusionModelError, delete_fusion_model + + if len(rest) != 1: + return "Usage: /fusion delete " + try: + removed = delete_fusion_model(rest[0]) + except FusionModelError as exc: + return str(exc) + return f"Deleted fusion model {removed.name!r}." + + +def _set_enabled(rest: list[str], enabled: bool) -> str: + from src.providers.fusion_models import FusionModelError, set_fusion_model_enabled + + verb = "enable" if enabled else "disable" + if len(rest) != 1: + return f"Usage: /fusion {verb} " + try: + model = set_fusion_model_enabled(rest[0], enabled) + except FusionModelError as exc: + return str(exc) + state = "enabled" if enabled else "disabled" + suffix = "" if enabled else " It stays saved and can be re-enabled." + return f"Fusion model {model.name!r} {state}.{suffix}" + + +def fusion_command_call(args: str, context: CommandContext) -> LocalCommandResult: + """Handle /fusion. ``context`` is unused — the store is the config file.""" + # ``split()`` on arbitrary whitespace, so a line continuation or double + # space between selectors (easy to produce when pasting the example) + # parses the same as single spaces. + parts = (args or "").split() + if not parts: + return LocalCommandResult(type="text", value=_list_text()) + + action, rest = parts[0].lower(), parts[1:] + + if action in ("help", "-h", "--help"): + return LocalCommandResult(type="text", value=_USAGE) + if action in ("list", "ls", "status"): + return LocalCommandResult(type="text", value=_list_text()) + if action in ("create", "add", "new"): + return LocalCommandResult(type="text", value=_create(rest)) + if action in ("delete", "remove", "rm", "del"): + return LocalCommandResult(type="text", value=_delete(rest)) + if action == "enable": + return LocalCommandResult(type="text", value=_set_enabled(rest, True)) + if action == "disable": + return LocalCommandResult(type="text", value=_set_enabled(rest, False)) + + return LocalCommandResult( + type="text", value=f"Unknown /fusion action {parts[0]!r}.\n\n{_USAGE}" + ) + + +FUSION_COMMAND = LocalCommand( + name="fusion", + description="Give a text-only model vision by fusing it with a multimodal one", + argument_hint="[list | create | delete|enable|disable ]", + # Every path is text-in/text-out with no UI surface, so the command + # works non-interactively. Without registration the docstring's claim that + # this "works identically in the TUI, -p headless, and the SDK" would be + # false: only the agent-server control + the TUI's hardcoded SLASHES + # entry could reach it, leaving headless able to USE --model + # but unable to create, list, or delete one. + supports_non_interactive=True, +) +FUSION_COMMAND.set_call(fusion_command_call) + + +__all__ = ["FUSION_COMMAND", "fusion_command_call"] diff --git a/src/entrypoints/headless.py b/src/entrypoints/headless.py index 4387a44c1..1d739717f 100644 --- a/src/entrypoints/headless.py +++ b/src/entrypoints/headless.py @@ -149,6 +149,23 @@ def run_headless(options: HeadlessOptions) -> int: start_deferred_prefetches(cwd=str(workspace_root)) provider_name = options.provider_name or get_default_provider() + + # ``--model `` may name a FUSION model (a text-only base model plus + # a borrowed vision model — see src/providers/fusion_models.py). That + # name is not a real model id on any provider, so it has to be resolved + # here; passing it to the constructor would send it to the wire and 400. + # Images reach headless too (``@shot.png`` in the prompt, a Read of an + # image), so this is not interactive-only sugar. + from src.providers.fusion_models import get_fusion_model + + _fusion = get_fusion_model(options.model) if options.model else None + if _fusion is not None and not _fusion.enabled: + cli_error( + f"error: fusion model '{_fusion.name}' is disabled — enable it with " + f"`/fusion enable {_fusion.name}`", + 2, + ) + # ENTRY-2: the config-load + key checks moved into the SHARED startup # validator (src/entrypoints/provider_validation.py) so this path, the # bare-interactive path, and `clawcodex tui` can never drift on message @@ -156,19 +173,51 @@ def run_headless(options: HeadlessOptions) -> int: # failure with the exact messages this block used to print inline. # (cli.main already validated for the `-p` route — this repeat is # idempotent defense-in-depth for direct run_headless callers.) + # + # SKIPPED for a fusion model, whose record overrides the session's + # provider: this validator is FATAL (SystemExit(2)), so running it on the + # session default would refuse to start whenever that unrelated provider + # is unconfigured even though the fusion model's own two providers are + # fine. The fusion branch below validates those instead — both halves, + # with the same exit(2) contract, so nothing is merely skipped. from src.entrypoints.provider_validation import validate_provider_at_startup - validate_provider_at_startup(options.provider_name, interactive=False, exit_code=2) - provider_cfg = get_provider_config(provider_name) # validated above + if _fusion is None: + validate_provider_at_startup( + options.provider_name, interactive=False, exit_code=2 + ) + provider_cfg = get_provider_config(provider_name) api_key = resolve_api_key(provider_name, provider_cfg) - provider_cls = get_provider_class(provider_name) - model = options.model or provider_cfg.get("default_model") - provider = provider_cls( - api_key=api_key, - base_url=provider_cfg.get("base_url"), - model=model, - ) + if _fusion is not None: + from src.providers import provider_has_credentials + from src.providers.fusion_provider import build_fusion_provider + + # The fusion model's OWN two providers are what must be usable, so + # they are validated here in place of the session default. Both + # halves: without the vision check the run starts and then every + # image degrades to a "vision model failed" note. Non-interactive → + # exit(2) with the message, matching the validator's contract. + for _ref, _role in ((_fusion.base, "base"), (_fusion.vision, "vision")): + _cfg = get_provider_config(_ref.provider) + if not provider_has_credentials(_ref.provider, resolve_api_key(_ref.provider, _cfg)): + cli_error( + f"error: fusion model '{_fusion.name}' needs credentials for its " + f"{_role} provider '{_ref.provider}'. Run `clawcodex login`.", + 2, + ) + provider_name = _fusion.base.provider + provider_cfg = get_provider_config(provider_name) + model = _fusion.base.model + provider = build_fusion_provider(_fusion) + else: + provider_cls = get_provider_class(provider_name) + model = options.model or provider_cfg.get("default_model") + provider = provider_cls( + api_key=api_key, + base_url=provider_cfg.get("base_url"), + model=model, + ) session = Session.create(provider_name, getattr(provider, "model", model or "")) diff --git a/src/models/capabilities.py b/src/models/capabilities.py index 15ee6d93a..2dd30fb94 100644 --- a/src/models/capabilities.py +++ b/src/models/capabilities.py @@ -4,7 +4,7 @@ from dataclasses import dataclass -from .configs import get_model_config +from .configs import MODEL_CONFIGS, get_model_config @dataclass(frozen=True) @@ -26,7 +26,10 @@ def get_model_capabilities(model_id: str) -> ModelCapabilities: return ModelCapabilities( thinking=config.supports_thinking, tools=config.supports_tools, - vision=config.supports_vision, + # Routed through the helper, not read off ``config``, so the two + # entry points cannot disagree: ``config`` may be a PREFIX match, + # whose ``supports_vision=False`` is not evidence about this id. + vision=supports_vision(model_id), computer_use=config.supports_computer_use, cache=config.supports_cache, ) @@ -41,7 +44,26 @@ def supports_tools(model_id: str) -> bool: def supports_vision(model_id: str) -> bool: - return get_model_capabilities(model_id).vision + """Whether ``model_id`` accepts image input. + + A ``False`` verdict is trusted ONLY from an exact ``MODEL_CONFIGS`` hit. + ``get_model_config`` falls back to a prefix match on + ``key.rsplit("-", 1)[0]``, which is right for context windows (a dated + snapshot inherits its family's window) but wrong for a capability that + a sibling can flip: the ``glm-5.2`` row's prefix base is bare ``glm``, + so every ``glm*`` id — including the vision-capable ``glm-4.5v`` / + ``glm-5v-turbo`` — would inherit ``vision=False`` and be rejected by + the fusion-model validator that this function gates. Z.ai's ``*v`` + family is exactly what that validator's error message tells users to + pick, so the inherited ``False`` made the check reject its own advice. + + Unknown ids therefore stay permissive (the dataclass default), matching + the rest of this module: a model absent from the table is assumed + capable, and a real API error is a better outcome than a wrong refusal. + """ + if model_id in MODEL_CONFIGS: + return MODEL_CONFIGS[model_id].supports_vision + return True def supports_computer_use(model_id: str) -> bool: diff --git a/src/models/configs.py b/src/models/configs.py index f9b5ee13e..c1c98656e 100644 --- a/src/models/configs.py +++ b/src/models/configs.py @@ -237,12 +237,22 @@ class ModelConfig: # Recorded because the inertness is easy to mistake for a bug: this value # was briefly suspected of truncating long ``effort=max`` responses on # terminal-bench 2.1, and it cannot, because it never reaches the wire. + # + # ``supports_vision=False``: the DeepSeek API rejects any non-text + # content block outright — + # 400 unknown variant `image_url`, expected `text` + # — so a pasted screenshot, an ``@image.png`` mention, or a ``Read`` of + # an image kills the turn. Probed against api.deepseek.com 2026-07-30. + # A fusion model (``/fusion``, ``providers/fusion_models.py``) is the + # way to use images with these: it borrows vision from a second model + # and hands the base model a text description. "deepseek-v4-pro": ModelConfig( model_id="deepseek-v4-pro", display_name="DeepSeek V4 Pro", context_window=1_000_000, max_output_tokens=384_000, supports_cache=True, + supports_vision=False, ), "deepseek-v4-flash": ModelConfig( model_id="deepseek-v4-flash", @@ -250,17 +260,30 @@ class ModelConfig: context_window=1_000_000, max_output_tokens=384_000, supports_cache=True, + supports_vision=False, ), # Z.ai GLM Coding Plan. glm-5.2 ships a 1M context window (like DeepSeek V4); # glm-5.1 is 202_752 and legacy glm-4.x is 128K. Registered here so the # canonical window/threshold path agrees with the context display — both # exact keys, so glm-4 never prefix-matches glm-5.2's 1M. + # + # ``supports_vision=False`` on the glm-5.x text line. Probed 2026-07-30 + # against BOTH Z.ai endpoints (``api/coding/paas/v4`` and the general + # ``api/paas/v4``); each rejects an image part with + # 400 code 1210 messages.content.type is invalid, allowed values: ['text'] + # Z.ai's vision models are the separate ``*v`` family (glm-4.5v, + # glm-4.6v, glm-5v-turbo — the "GLM-5V-Turbo" of claude-code-router's + # own fusion example), which are NOT part of the GLM Coding Plan. Worth + # stating explicitly because "GLM-5.2 is multimodal" is an easy and + # costly assumption: it makes glm-5.2 look like a valid *vision* half + # for a fusion model, where it would fail on every image. "glm-5.2": ModelConfig( model_id="glm-5.2", display_name="GLM-5.2", context_window=1_000_000, max_output_tokens=8_192, supports_cache=True, + supports_vision=False, ), "glm-5.1": ModelConfig( model_id="glm-5.1", @@ -268,6 +291,7 @@ class ModelConfig: context_window=202_752, max_output_tokens=8_192, supports_cache=True, + supports_vision=False, ), "glm-4": ModelConfig( model_id="glm-4", @@ -276,6 +300,36 @@ class ModelConfig: max_output_tokens=8_192, supports_cache=True, ), + # Z.ai's VISION family (the ``*v`` models). Registered explicitly, and + # with ``supports_vision=True`` stated rather than defaulted, because + # these are the models the fusion-model validator's error message tells + # users to pick — so their capability must be a positive fact in the + # table, not an assumption about an absent id. Probed 2026-07-30: all + # three accept an image part on ``api.z.ai/api/paas/v4`` (they answer + # 429 "insufficient balance" on a Coding-Plan-only key, which is an + # entitlement result, not a rejection of the shape). NOT part of the + # GLM Coding Plan. + "glm-5v-turbo": ModelConfig( + model_id="glm-5v-turbo", + display_name="GLM-5V-Turbo", + context_window=128_000, + max_output_tokens=8_192, + supports_vision=True, + ), + "glm-4.6v": ModelConfig( + model_id="glm-4.6v", + display_name="GLM-4.6V", + context_window=128_000, + max_output_tokens=8_192, + supports_vision=True, + ), + "glm-4.5v": ModelConfig( + model_id="glm-4.5v", + display_name="GLM-4.5V", + context_window=128_000, + max_output_tokens=8_192, + supports_vision=True, + ), # MiniMax pricing is maintained in services/pricing.py. "MiniMax-M2.7": ModelConfig( model_id="MiniMax-M2.7", diff --git a/src/providers/__init__.py b/src/providers/__init__.py index 7c39e9749..5069226f7 100644 --- a/src/providers/__init__.py +++ b/src/providers/__init__.py @@ -324,6 +324,27 @@ class is what carries the wire contract. return isinstance(provider, (AnthropicProvider, MinimaxProvider)) +#: The provider ids whose classes :func:`is_anthropic_wire` matches. Kept +#: adjacent to it so the name-based and instance-based tests cannot drift: +#: adding an Anthropic-shaped provider means updating both. +_ANTHROPIC_WIRE_PROVIDERS: frozenset[str] = frozenset({"anthropic", "minimax"}) + + +def provider_name_is_anthropic_wire(provider_name: str) -> bool: + """Whether the provider *named* ``provider_name`` speaks the Anthropic shape. + + The by-name counterpart to :func:`is_anthropic_wire`, for callers that + must decide before any provider instance exists — validating a + user-typed ``provider:model`` selector, for one (see + ``providers/fusion_models.py``). Accepts legacy aliases. + + Prefer :func:`is_anthropic_wire` whenever an instance is in hand: the + class carries the wire contract, and a name cannot see that a provider + was constructed with a custom endpoint. + """ + return _canonical_provider_name(provider_name) in _ANTHROPIC_WIRE_PROVIDERS + + def provider_requires_api_key(provider_name: str) -> bool: """Whether a provider needs an API key. @@ -411,6 +432,7 @@ def resolve_api_key( "canonical_provider_name", "provider_env_vars", "provider_has_credentials", + "provider_name_is_anthropic_wire", "provider_requires_api_key", "resolve_api_key", "PROVIDER_INFO", diff --git a/src/providers/fusion_models.py b/src/providers/fusion_models.py new file mode 100644 index 000000000..f424a3933 --- /dev/null +++ b/src/providers/fusion_models.py @@ -0,0 +1,600 @@ +"""Fusion models — a base reasoning model plus a borrowed vision model. + +Port of claude-code-router's **Fusion model** concept +(``reference_projects/claude-code-router``: ``docs/.../configuration/fusion-models.md``, +``fusion-vision.md``, and the lifecycle helpers in +``packages/ui/src/pages/home/shared/virtual-models.ts``). + +The problem it solves: several strong reasoning/coding models are text-only. +``deepseek-v4-pro`` hard-rejects any image content block — + + HTTP 400 unknown variant `image_url`, expected `text` + +— so pasting a screenshot, ``@screenshot.png``-mentioning one, or letting +``Read`` return an image kills the turn. A Fusion model keeps the base +model's reasoning and *borrows* image understanding from a second, +vision-capable model. + +CCR's own framing (``fusion-models.md``): "Fusion keeps the base model's +reasoning, writing, and coding behavior, then adds the missing capability +layer… After saving, a Fusion model appears in routing and Agent Profiles +like a normal model." The naming convention is ``base + capability = new +model``, e.g. ``GLM-5.2 + GLM-5V-Turbo = GLM-5.2V``. + +What this module owns +--------------------- +The **record and its lifecycle** — create / configure / save / manage — +mirroring CCR's ``createVirtualModelDraft`` → ``validateVirtualModelDraft`` +→ ``virtualModelProfileFromDraft`` → persisted ``virtualModelProfiles[]``. +The runtime mechanism lives in :mod:`src.providers.fusion_provider`. + +Deliberate divergences from CCR +------------------------------- +* **Transport.** CCR is a *proxy*: it cannot touch the agent loop, so its + only lever is exposing vision as an MCP tool (``vision_understand``, + ``packages/core/src/mcp/fusion-vision-mcp.ts``) that the base model may + choose to call. That cannot fix a *pasted* image — the image block is + already on the wire and DeepSeek 400s before the model gets a turn. + clawcodex owns the whole 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-process. See :mod:`src.providers.fusion_provider`. +* **Scope.** Vision only. CCR also fuses web search, image/video + generation, and arbitrary MCP tools; those are separate capabilities + with their own config surfaces and are out of scope here. +* **Selector grammar.** ``provider:model`` (split on the FIRST colon), + matching clawcodex's existing ``/advisor :`` + convention, rather than CCR's ``provider/model`` — a bare ``/`` is + ambiguous with model ids that contain one (``google/gemini-2.5-flash``). + Splitting on the first colon also keeps OpenRouter's ``:free``-suffixed + ids intact (``openrouter:deepseek/deepseek-r1:free``). +* **Flat record.** CCR's profile carries match modes (alias/prefix/suffix), + materialization templates, execution modes, and per-tool schemas. A + Fusion model here is matched by exact name only, so the record stays a + flat dataclass instead of a nested profile. + +Storage +------- +The **global** config tier only (``~/.clawcodex/config.json`` → +``"fusionModels"``), read with ``load_global`` / written with +``save_global`` — never the merged tier. + +That is a security decision, not a convenience one. A fusion model names +the provider that receives image bytes, which makes it exactly the class +of routing config ``_UNTRUSTED_TIER_BLOCKED_KEYS`` (``src/config.py``) +already withholds from committable project/local tiers: a checked-out +repo must not be able to add a fusion model that quietly ships the user's +screenshots to a provider of its choosing while riding the user's stored +credentials. Keeping the key global-only means a project tier can never +contribute one, so no strip rule is needed. Same tier as ``/advisor``'s +secondary-model settings. +""" + +from __future__ import annotations + +from dataclasses import dataclass, replace +from typing import Any + +#: Config key holding the saved fusion models (global tier only). +FUSION_MODELS_KEY = "fusionModels" + +#: Default per-image vision-call timeout. CCR's ``defaultTimeoutMs`` is +#: 30s (``fusion-vision-mcp.ts``); doubled here because a fused turn makes +#: the vision call inline on the critical path of the user's request, and a +#: timeout costs the whole turn rather than one tool call. +DEFAULT_VISION_TIMEOUT_MS = 60_000 + +#: Cap on images described per request. A runaway conversation (long +#: history full of screenshots) would otherwise fan out into an unbounded +#: number of serial vision calls on one turn. +DEFAULT_MAX_IMAGES = 8 + +#: Suffix suggested for a fusion model derived from a base model, following +#: CCR's ``GLM-5.2 + GLM-5V-Turbo = GLM-5.2V`` naming ("use names that make +#: the capability source obvious"). +VISION_NAME_SUFFIX = "V" + + +class FusionModelError(ValueError): + """A fusion model is invalid, unknown, or conflicts with an existing one. + + A ``ValueError`` subclass so callers that only care about "bad input" + can catch the broader type, while ``/fusion`` and the agent-server + controls catch this specifically to render the message verbatim. + """ + + +@dataclass(frozen=True) +class ModelRef: + """A ``provider:model`` pair — which endpoint serves which model id.""" + + provider: str + model: str + + @property + def selector(self) -> str: + return f"{self.provider}:{self.model}" + + def __str__(self) -> str: # for f-strings in user-facing messages + return self.selector + + +@dataclass(frozen=True) +class FusionModel: + """A saved fusion model: ``name = base + vision``. + + ``name`` is the id the user selects (``/model ``) and the only + match key — CCR's alias/prefix/suffix match modes are not ported. + """ + + name: str + base: ModelRef + vision: ModelRef + enabled: bool = True + #: Extra instruction appended to the vision model's prompt. CCR exposes + #: this per-call as the tool's ``systemPrompt``; here it is configured + #: once on the record because there is no per-call tool invocation. + prompt: str = "" + timeout_ms: int = DEFAULT_VISION_TIMEOUT_MS + max_images: int = DEFAULT_MAX_IMAGES + + def summary(self) -> str: + """One-line ``name = base + vision`` description. + + The CCR list view's columns (``virtualModelBaseModelSummary`` + + ``virtualModelToolSummary``) collapsed into text for a terminal. + """ + state = "" if self.enabled else " (disabled)" + return f"{self.name} = {self.base.selector} + vision:{self.vision.selector}{state}" + + def to_json(self) -> dict[str, Any]: + """Serialize for the config file. + + Only non-default optional fields are written, so a hand-edited + config stays readable and a future default change is picked up by + records that never overrode it. + """ + data: dict[str, Any] = { + "name": self.name, + "base": self.base.selector, + "vision": self.vision.selector, + } + if not self.enabled: + data["enabled"] = False + if self.prompt: + data["prompt"] = self.prompt + if self.timeout_ms != DEFAULT_VISION_TIMEOUT_MS: + data["timeoutMs"] = self.timeout_ms + if self.max_images != DEFAULT_MAX_IMAGES: + data["maxImages"] = self.max_images + return data + + +def parse_selector(raw: object, *, field: str) -> ModelRef: + """Parse ``provider:model`` into a :class:`ModelRef`. + + Splits on the FIRST colon so model ids that contain one survive + (``openrouter:deepseek/deepseek-r1:free``). Mirrors ``/advisor``'s + parse (``command_system/builtins.py``: ``raw.split(":", 1)``) including + its "both halves non-empty" rule. + + ``field`` names the offending input in the error ("base", "vision") so + a two-selector command can say which half was wrong. + """ + if not isinstance(raw, str) or not raw.strip(): + raise FusionModelError(f"{field} model is required (expected :)") + text = raw.strip() + if ":" not in text: + raise FusionModelError( + f"{field} model must use : syntax. Got: {text!r}. " + "Example: deepseek:deepseek-v4-pro" + ) + provider, model = text.split(":", 1) + provider, model = provider.strip(), model.strip() + if not provider or not model: + raise FusionModelError( + f"{field} model must use : with both halves " + f"non-empty. Got: {text!r}" + ) + return ModelRef(provider=provider, model=model) + + +def _coerce_int(value: object, default: int) -> int: + """Read an int from JSON that may hold a string (hand-edited config).""" + if isinstance(value, bool): # bool is an int subclass — not a count + return default + if isinstance(value, int): + return value if value > 0 else default + if isinstance(value, str) and value.strip(): + try: + parsed = int(value.strip()) + except ValueError: + return default + return parsed if parsed > 0 else default + return default + + +def fusion_model_from_json(raw: object) -> FusionModel | None: + """Rehydrate one record, or ``None`` if it is unusable. + + Returns ``None`` instead of raising so a single malformed entry in a + hand-edited config cannot make every fusion model disappear — + mirroring CCR's ``normalizeMcpServers``, which drops invalid entries + and keeps the rest. + """ + if not isinstance(raw, dict): + return None + name = raw.get("name") + if not isinstance(name, str) or not name.strip(): + return None + try: + base = parse_selector(raw.get("base"), field="base") + vision = parse_selector(raw.get("vision"), field="vision") + except FusionModelError: + return None + prompt = raw.get("prompt") + return FusionModel( + name=name.strip(), + base=base, + vision=vision, + # Absent ⇒ enabled, matching CCR's ``profile.enabled !== false``. + enabled=raw.get("enabled") is not False, + prompt=prompt.strip() if isinstance(prompt, str) else "", + timeout_ms=_coerce_int(raw.get("timeoutMs"), DEFAULT_VISION_TIMEOUT_MS), + max_images=_coerce_int(raw.get("maxImages"), DEFAULT_MAX_IMAGES), + ) + + +def _manager(): + """The shared :class:`~src.config.ConfigManager`. + + Resolved through the module attribute at call time (not imported at + module scope) so test isolation that re-points the config dir is + honoured — the ``config.py`` ``_global_config_file`` discipline. + """ + from src import config as cfg_mod + + return cfg_mod._get_default_manager() + + +def load_fusion_models() -> list[FusionModel]: + """Every saved fusion model, including disabled ones. + + Global tier only — see the module docstring for why the merged tier is + deliberately not consulted. Never raises: a broken config yields an + empty list, because a fusion model is an enhancement and a config + problem must not brick startup or the ``/model`` picker. + + Records are shape-validated (:func:`fusion_model_from_json`) but NOT + re-run through :func:`validate_fusion_model`: the name-shadowing and + vision-capability guards are **create-time only**. A hand-edited + ``~/.clawcodex/config.json`` can therefore still register a fusion model + named after another vendor's id and hijack ``/model ``. That is + self-inflicted rather than an attack surface — the file is the user's own + and, being global-tier, no checked-out repo can contribute to it — and + re-validating on every read would make a since-renamed provider silently + drop working models. + """ + try: + raw = _manager().load_global().get(FUSION_MODELS_KEY) + except Exception: + return [] + if not isinstance(raw, list): + return [] + models: list[FusionModel] = [] + seen: set[str] = set() + for entry in raw: + model = fusion_model_from_json(entry) + if model is None: + continue + key = model.name.lower() + if key in seen: # first writer wins, mirroring load order + continue + seen.add(key) + models.append(model) + return models + + +def save_fusion_models(models: list[FusionModel]) -> None: + """Replace the saved set (global tier).""" + _manager().set_global(FUSION_MODELS_KEY, [m.to_json() for m in models]) + + +def get_fusion_model(name: object) -> FusionModel | None: + """Look up a fusion model by name, case-insensitively. + + Case-insensitive because this is what decides whether a ``/model`` + argument is a fusion model, and model ids are routinely typed with + inconsistent case (``GLM-5.2`` vs ``glm-5.2``). + + Returns disabled models too — callers that must not activate one + check :attr:`FusionModel.enabled` themselves, so a disabled name + reports "disabled" rather than "unknown". + """ + if not isinstance(name, str) or not name.strip(): + return None + key = name.strip().lower() + for model in load_fusion_models(): + if model.name.lower() == key: + return model + return None + + +def is_fusion_model_name(name: object) -> bool: + """Whether ``name`` is a saved, ENABLED fusion model. + + Stricter than :func:`get_fusion_model`, which also returns disabled + records so a caller can report "disabled" rather than "unknown". + + The three routing paths (``set_model``, agent-server session init, + headless) deliberately do NOT use this: each needs the record itself, + and each distinguishes disabled-with-a-message from not-a-fusion-model. + Kept as the public predicate for callers that only need the boolean. + """ + model = get_fusion_model(name) + return model is not None and model.enabled + + +def validate_fusion_model( + model: FusionModel, *, existing: list[FusionModel] | None = None +) -> None: + """Raise :class:`FusionModelError` if ``model`` cannot be saved. + + The CCR ``validateVirtualModelDraft`` analogue: name required, base + required, vision required. Adds three checks CCR does not need because + its virtual models live in a proxy's own namespace: + + * both providers must be **configured** (``/advisor``'s rule — an + unknown provider key is a typo that would otherwise surface much + later as a construction failure mid-turn), + * the name must not collide with an existing fusion model, and + * the name must not shadow a real model id on the base provider, + which would make ``/model `` ambiguous. + """ + name = model.name.strip() + if not name: + raise FusionModelError("Fusion model name is required.") + if ":" in name or any(ch.isspace() for ch in name): + raise FusionModelError( + f"Fusion model name may not contain ':' or whitespace. Got: {name!r}" + ) + if model.base.selector == model.vision.selector: + raise FusionModelError( + f"Base and vision model are identical ({model.base.selector}). A fusion " + "model borrows vision from a DIFFERENT model; select the base model " + "directly instead." + ) + + configured = _configured_providers() + for ref, field in ((model.base, "Base"), (model.vision, "Vision")): + if configured and ref.provider not in configured: + raise FusionModelError( + f"{field} provider {ref.provider!r} is not configured. Configured: " + f"{', '.join(configured)}. Add it in ~/.clawcodex/config.json or " + "run `clawcodex login`." + ) + + # The base provider must NOT speak the Anthropic wire. Six live call + # sites branch on ``isinstance(provider, AnthropicProvider)`` — prompt + # caching (state/cache_state.py), deferred tools (query/query.py), the + # goal judge, and the memory selector among them — and a delegating + # wrapper is invisible to every one of them, so fusing an Anthropic + # base would silently switch caching and tool deferral off. The + # restriction costs nothing real: every Anthropic-wire model in the + # registry already accepts images, so there is no text-only base there + # to rescue. (Vision may be any provider — the vision model is called + # directly, never wrapped.) + from src.providers import provider_name_is_anthropic_wire + + if provider_name_is_anthropic_wire(model.base.provider): + raise FusionModelError( + f"Base provider {model.base.provider!r} speaks the Anthropic wire, whose " + "models already accept images — a fusion model would add nothing and " + "would disable prompt caching. Select the model directly with " + "`/model` instead." + ) + + # The vision half must actually be able to see. ``supports_vision`` + # returns True for models absent from the table, so this rejects only + # KNOWN text-only ids and stays permissive for everything else — + # deliberately, since the table covers a fraction of what the providers + # serve and a false rejection here would be worse than a late API error. + # + # This is the check that catches the feature's most inviting mistake: + # picking a strong text model as the vision half because its family has + # a multimodal sibling. glm-5.2 is exactly that trap (see configs.py). + if not _vision_model_can_see(model.vision.model): + raise FusionModelError( + f"Vision model {model.vision.selector!r} does not support image input, " + "so it cannot serve as the vision half of a fusion model. Pick a " + "multimodal model (for Z.ai that means the *v family — glm-4.5v, " + "glm-5v-turbo — not the glm-5.x text line)." + ) + + pool = load_fusion_models() if existing is None else existing + if any(m.name.lower() == name.lower() for m in pool): + raise FusionModelError( + f"A fusion model named {name!r} already exists. Delete it first " + f"(`/fusion delete {name}`) or pick another name." + ) + shadowed = _shadowed_provider(name) + if shadowed: + raise FusionModelError( + f"{name!r} is already a real model on provider {shadowed!r} — pick a " + f"distinct name so `/model {name}` is unambiguous (e.g. " + f"{suggest_fusion_name(model.base.model)})." + ) + + +def _vision_model_can_see(model_id: str) -> bool: + """Whether ``model_id`` accepts image input, across id spellings. + + ``supports_vision`` matches the model table exactly, but the same model + reaches clawcodex under several spellings: direct (``glm-5.2``), + vendor-prefixed through a proxy (``z-ai/glm-5.2`` on OpenRouter), and + with a routing suffix (``…:free``). Checking only the literal string let + the provider-prefixed form through — and OpenRouter is the provider the + ``/fusion`` help text uses in its own example, so that was the likely + spelling for the very mistake this guard exists to catch. + + False only when SOME spelling is known text-only; unknown ids stay + permissive, as in :func:`src.models.capabilities.supports_vision`. + """ + from src.models.capabilities import supports_vision + + raw = (model_id or "").strip() + candidates = {raw} + # Strip a routing suffix (OpenRouter's ``:free`` / ``:nitro``). + head = raw.split(":", 1)[0] + candidates.add(head) + # Strip a vendor prefix (``z-ai/glm-5.2`` -> ``glm-5.2``). + for value in list(candidates): + if "/" in value: + candidates.add(value.rsplit("/", 1)[-1]) + return all(supports_vision(c) for c in candidates if c) + + +def _configured_providers() -> list[str]: + """Provider keys present in the global config. + + Empty on failure, and callers treat empty as "skip the check" — the + ``/advisor`` precedent, so a config read problem does not block a + valid selector. + """ + try: + providers = _manager().load_global().get("providers") + except Exception: + return [] + return sorted(providers.keys()) if isinstance(providers, dict) else [] + + +def _shadowed_provider(name: str) -> str | None: + """The provider on which ``name`` is already a real model id, if any. + + Checked across EVERY provider, not just the fusion model's own base. + The fusion lookup in ``agent_server._do_set_model`` runs before the + "model X expects provider Y" guard and ignores the requested provider, + so a fusion model named after another vendor's id would hijack it: with + a fusion model called ``claude-opus-5``, ``/model claude-opus-5`` on an + Anthropic session would silently route the turn to the fusion's base + provider instead. + + Uses the static registry (``PROVIDER_INFO``) rather than a live + ``get_available_models()`` call: validation runs while the user is + typing and must not make a network round trip. A model that appears + only via live discovery is therefore not caught — the cost is an + ambiguous name the user chose, not a silent cross-vendor reroute. + """ + try: + from src.providers import PROVIDER_INFO + + lowered = name.lower() + for provider_name, info in PROVIDER_INFO.items(): + for candidate in info.get("available_models", ()): + if str(candidate).lower() == lowered: + return provider_name + except Exception: + return None + return None + + +def suggest_fusion_name(base_model: str) -> str: + """Suggest a fusion name for ``base_model``. + + Follows CCR's "use names that make the capability source obvious" + convention (``GLM-5.2 + GLM-5V-Turbo = GLM-5.2V``): append ``V`` to + the base model's id. + """ + stem = (base_model or "model").strip().rsplit("/", 1)[-1] + return f"{stem}-{VISION_NAME_SUFFIX}" + + +def create_fusion_model( + name: str, + base: str, + vision: str, + *, + prompt: str = "", + timeout_ms: int = DEFAULT_VISION_TIMEOUT_MS, + max_images: int = DEFAULT_MAX_IMAGES, +) -> FusionModel: + """Validate and save a new fusion model. Returns the saved record. + + ``base`` / ``vision`` are ``provider:model`` selectors. Raises + :class:`FusionModelError` on any validation failure, leaving the + config untouched. + """ + model = FusionModel( + name=(name or "").strip(), + base=parse_selector(base, field="base"), + vision=parse_selector(vision, field="vision"), + prompt=(prompt or "").strip(), + timeout_ms=_coerce_int(timeout_ms, DEFAULT_VISION_TIMEOUT_MS), + max_images=_coerce_int(max_images, DEFAULT_MAX_IMAGES), + ) + existing = load_fusion_models() + validate_fusion_model(model, existing=existing) + save_fusion_models([*existing, model]) + return model + + +def delete_fusion_model(name: str) -> FusionModel: + """Remove a fusion model by name. Returns the removed record.""" + existing = load_fusion_models() + key = (name or "").strip().lower() + kept = [m for m in existing if m.name.lower() != key] + if len(kept) == len(existing): + raise FusionModelError(_unknown_message(name)) + removed = next(m for m in existing if m.name.lower() == key) + save_fusion_models(kept) + return removed + + +def set_fusion_model_enabled(name: str, enabled: bool) -> FusionModel: + """Enable or disable a fusion model. Returns the updated record. + + The CCR list view's per-row toggle (``setVirtualModelEnabled``); + disabling keeps the configuration so it can be turned back on without + retyping both selectors. + """ + existing = load_fusion_models() + key = (name or "").strip().lower() + updated: FusionModel | None = None + out: list[FusionModel] = [] + for model in existing: + if model.name.lower() == key: + updated = replace(model, enabled=enabled) + out.append(updated) + else: + out.append(model) + if updated is None: + raise FusionModelError(_unknown_message(name)) + save_fusion_models(out) + return updated + + +def _unknown_message(name: object) -> str: + known = [m.name for m in load_fusion_models()] + listing = f" Known: {', '.join(known)}." if known else " No fusion models are saved." + return f"No fusion model named {str(name).strip()!r}.{listing}" + + +__all__ = [ + "DEFAULT_MAX_IMAGES", + "DEFAULT_VISION_TIMEOUT_MS", + "FUSION_MODELS_KEY", + "FusionModel", + "FusionModelError", + "ModelRef", + "create_fusion_model", + "delete_fusion_model", + "fusion_model_from_json", + "get_fusion_model", + "is_fusion_model_name", + "load_fusion_models", + "parse_selector", + "save_fusion_models", + "set_fusion_model_enabled", + "suggest_fusion_name", + "validate_fusion_model", +] diff --git a/src/providers/fusion_provider.py b/src/providers/fusion_provider.py new file mode 100644 index 000000000..2dad653a0 --- /dev/null +++ b/src/providers/fusion_provider.py @@ -0,0 +1,720 @@ +"""FusionProvider — runs a vision model in front of a text-only base model. + +The runtime half of the fusion-model feature; the record and its lifecycle +live in :mod:`src.providers.fusion_models`. + +Mechanism +--------- +A delegating wrapper around the base provider (the ``_EffortProvider`` +pattern from ``server/agent_server.py``, including its ``_inner`` +recursion guard). On every outbound call it rewrites the message list: +each Anthropic ``image`` block is sent to the configured vision model and +**replaced** with a text block carrying that model's description. The base +provider — and therefore the wire — never sees an image. + +Why substitution rather than CCR's tool +-------------------------------------- +claude-code-router exposes vision as an MCP tool +(``vision_understand``) the base model may choose to call, because CCR is +a proxy and cannot touch the agent loop. That cannot fix the case this +feature exists for: when the user pastes a screenshot, the image block is +already in the request, so ``deepseek-v4-pro`` returns + + HTTP 400 unknown variant `image_url`, expected `text` + +before the model gets a turn to call any tool. clawcodex owns the loop, so +it implements what CCR's docs describe — a vision model "in front of" the +base model, whose result is passed into context — directly. One +consequence worth the trade: images work through *every* entry point at +once (paste, ``@file.png``, ``Read`` on an image, ``Bash`` image output), +with no reliance on the model electing to call a tool. + +Invariants +---------- +1. **No image block survives.** Every image is replaced, including when the + vision call fails, times out, or the per-request cap is hit — a + leftover image block is precisely the 400 this feature prevents, so a + failure degrades to a text note rather than a dead turn. +2. **The caller's messages are never mutated.** The session owns the + conversation; rewriting it in place would destroy the original images + and break a later switch back to a vision-capable model. Rewriting is + copy-on-write: untouched messages are passed through by reference. +3. **Each distinct image is described once.** Conversation history is + re-sent on every turn, so without a cache an N-turn conversation would + pay for the same screenshot N times. Keyed by content hash plus the + vision configuration in a process-wide bounded cache, so it also + survives the provider rebuild that a ``/model`` switch performs. + Failures are cached too — see ``_substitute``. + +Scope +----- +``image`` blocks only. Anthropic ``document`` blocks (a PDF handed straight +to the API) are NOT substituted and would still be rejected by a text-only +base model. Not a gap in practice: ``tool_system/tools/read.py`` rasterizes +PDF pages into ``image`` blocks, so PDFs read through the agent are covered; +a caller who hand-builds a ``document`` block is not. +""" + +from __future__ import annotations + +import asyncio +import hashlib +import logging +import threading +import time +from collections import OrderedDict +from typing import Any + +from .fusion_models import FusionModel + +logger = logging.getLogger(__name__) + +#: Max descriptions retained process-wide. Each entry is a short string +#: plus a 64-char key, so a few hundred costs well under a megabyte while +#: comfortably covering a long session's screenshots. +_CACHE_MAX_ENTRIES = 256 + +#: Longest description accepted from the vision model. A runaway response +#: would otherwise be pasted verbatim into every subsequent turn's context. +_MAX_DESCRIPTION_CHARS = 4_000 + +_DEFAULT_VISION_PROMPT = ( + "Describe this image in full, objective detail for a software engineer who " + "cannot see it. Transcribe every piece of visible text verbatim, including " + "code, terminal output, error messages, labels, and numbers. Describe the " + "layout, any UI elements, and anything that looks wrong or noteworthy. Do " + "not speculate about intent and do not offer advice — report only what is " + "visible." +) + +#: Ceiling on total time the rewrite may spend on vision calls for ONE +#: request, independent of the per-image timeout. A degraded provider that +#: answers slowly (rather than failing fast) would otherwise multiply +#: ``timeout_ms`` by the image count on the critical path of the user's turn. +_MAX_REQUEST_VISION_SECONDS = 180.0 + + +#: How long a cached FAILURE suppresses retries. Successes are cached for +#: the process lifetime (the description of an image does not change), but a +#: failure is usually transient — a rate limit, a timeout, a network blip — +#: so caching it forever would permanently degrade that image for the rest +#: of the session, and the user's obvious next move ("look at it again") +#: would keep returning the stale note. Long enough to collapse the +#: re-described history of one outage into a single attempt, short enough +#: that a recovered provider is picked up within a turn or two. +_FAILURE_TTL_SECONDS = 90.0 + + +class _Failure: + """A cached vision failure, with an expiry. + + Distinguishable from a description by type, so a replayed history shows + the note again instead of re-attempting a call that is still failing — + until :attr:`expires_at`, after which the entry is treated as a miss and + the image is retried. + """ + + __slots__ = ("expires_at", "reason") + + def __init__(self, reason: str) -> None: + self.reason = reason + self.expires_at = time.monotonic() + _FAILURE_TTL_SECONDS + + def expired(self) -> bool: + return time.monotonic() >= self.expires_at + + +class _Budget: + """Per-request limits on the rewrite: call count, wall clock, and abort. + + ``calls`` bounds network fan-out. Cache hits never touch it — the cap + exists to bound *calls*, and charging a cached description would make + the same conversation degrade as it grows. + """ + + __slots__ = ("abort_signal", "calls", "deadline") + + def __init__(self, calls: int, abort_signal: Any = None) -> None: + self.calls = calls + self.deadline = time.monotonic() + _MAX_REQUEST_VISION_SECONDS + self.abort_signal = abort_signal + + def exhausted(self) -> str | None: + """Why no further vision call may run, or ``None`` to proceed.""" + if _signal_aborted(self.abort_signal): + # ESC during a fused turn: stop describing immediately rather + # than working through a long history the user already cancelled. + return "the request was interrupted" + if self.calls <= 0: + return "this request reached its image limit" + if time.monotonic() >= self.deadline: + return "this request reached its vision time limit" + return None + + +def _signal_aborted(signal: Any) -> bool: + """Whether ``abort_signal`` has fired. Tolerant of any signal shape.""" + if signal is None: + return False + try: + aborted = getattr(signal, "aborted", None) + return bool(aborted() if callable(aborted) else aborted) + except Exception: # noqa: BLE001 — an unreadable signal is not an abort + return False + + +_cache: "OrderedDict[str, str | _Failure]" = OrderedDict() +_cache_lock = threading.Lock() + + +def _cache_get(key: str) -> "str | _Failure | None": + with _cache_lock: + value = _cache.get(key) + if isinstance(value, _Failure) and value.expired(): + # Expired failure ⇒ a miss, so the image is retried once the + # vision provider has had time to recover. + del _cache[key] + return None + if value is not None: + _cache.move_to_end(key) # LRU + return value + + +def _cache_put(key: str, value: "str | _Failure") -> None: + with _cache_lock: + _cache[key] = value + _cache.move_to_end(key) + while len(_cache) > _CACHE_MAX_ENTRIES: + _cache.popitem(last=False) + + +def clear_vision_cache() -> None: + """Drop every cached description and cached failure. + + Test-only: the cache key already covers everything that determines a + description (vision selector + effective prompt, see + :meth:`FusionProvider._scope`), so no production path needs to + invalidate it — a reconfigured fusion model simply keys elsewhere. Tests + need it because the cache is process-wide and would otherwise leak call + counts between them. + """ + with _cache_lock: + _cache.clear() + + +def _image_source(block: Any) -> dict[str, Any] | None: + """The ``source`` dict of an Anthropic image block, or ``None``. + + Defensive about shape: every in-repo producer emits + ``{"type": "image", "source": {...}}`` (``tool_system/tools/read.py``, + ``tool_system/tools/bash/image_output.py``, the agent-server paste + path), but a resumed session or an SDK caller could hand over + something else, and this walker runs on the critical path of every + request. + """ + if not isinstance(block, dict) or block.get("type") != "image": + return None + source = block.get("source") + return source if isinstance(source, dict) else {} + + +def _image_key(scope: str, source: dict[str, Any]) -> str | None: + """Cache key for an image under a given vision configuration, or ``None`` + if the block carries no usable payload. + + ``scope`` covers everything that determines the description — the vision + selector AND the effective prompt (see :meth:`FusionProvider._scope`). + Both matter: re-pointing a fusion model at a different vision model, or + changing its ``prompt``, must not serve descriptions produced under the + old configuration. Keying on the selector alone made + :attr:`FusionModel.prompt` silently inert whenever any fusion model had + already described that image. + """ + data = source.get("data") + if isinstance(data, str) and data: + digest = hashlib.sha256(data.encode("utf-8", "ignore")).hexdigest() + return f"{scope}|b64:{digest}" + url = source.get("url") + if isinstance(url, str) and url: + digest = hashlib.sha256(url.encode("utf-8", "ignore")).hexdigest() + return f"{scope}|url:{digest}" + return None + + +def _describe_size(source: dict[str, Any]) -> str: + """Human-readable size/type prefix for the substituted text block.""" + media_type = source.get("media_type") + parts = [str(media_type)] if isinstance(media_type, str) and media_type else [] + data = source.get("data") + if isinstance(data, str) and data: + # base64 inflates by 4/3; good enough for a context label. + parts.append(f"{len(data) * 3 // 4 // 1024} KB") + return ", ".join(parts) + + +class FusionProvider: + """Wraps ``inner`` so images are described by ``fusion.vision`` first. + + Not a :class:`~src.providers.base.BaseProvider` subclass, deliberately + — attribute delegation via :meth:`__getattr__` must reach the inner + provider's real values (``is_deepseek``, ``base_url``, + ``has_custom_endpoint``, …), and inheriting would shadow them with + ``BaseProvider``'s class-level defaults. Same choice as + ``_EffortProvider``. + """ + + def __init__( + self, + inner: Any, + fusion: FusionModel, + *, + vision_provider: Any | None = None, + ) -> None: + self._inner = inner + self._fusion = fusion + # Injected by tests; production builds it lazily on first image so a + # fusion session that never sees one makes no vision provider at all. + self._vision_provider = vision_provider + self._vision_lock = threading.Lock() + + # ── identity ───────────────────────────────────────────────────────── + # + # ``model`` reports the BASE model id, not the fusion name. Everything + # downstream keys off this string — context window + # (``models/configs.py``), cost (``cost_tracker``), capability probes, + # and the request's own ``model`` field — and the base model is the + # correct answer for all four: it is what actually serves the turn. The + # fusion name is surfaced separately via :attr:`fusion_name` so the UI + # can show it without corrupting those lookups. + + @property + def model(self) -> Any: + return getattr(self._inner, "model", None) + + @model.setter + def model(self, value: Any) -> None: + # An explicit property (not ``__getattr__``, which never sees + # assignment) so ``provider.model = x`` reaches the inner provider + # instead of silently shadowing it on the wrapper. Repointing the + # base model keeps fusion active; swapping to a different fusion + # model, or off fusion entirely, replaces the whole provider (see + # the agent-server's ``_do_set_model``). + self._inner.model = value + + @property + def fusion(self) -> FusionModel: + """The record backing this wrapper.""" + return self._fusion + + @property + def fusion_name(self) -> str: + """The user-facing fusion model name (for display, never the wire).""" + return self._fusion.name + + @property + def inner(self) -> Any: + """The wrapped base provider.""" + return self._inner + + def __getattr__(self, name: str) -> Any: + # Guard the delegate itself. Without this, an instance built WITHOUT + # __init__ (copy.copy / copy.deepcopy create one that way, then probe + # for __setstate__ / __deepcopy__) recurses until RecursionError: + # __getattr__ looks up self._inner, which is missing, which calls + # __getattr__… Two live sites copy the session provider + # (``agent/run_agent.py``'s per-subagent model override and + # ``permissions/yolo_classifier.py``) and both swallow Exception — + # which RecursionError is — so the failure would be silent. Carried + # over verbatim from ``_EffortProvider``, which learned it the hard + # way. + if name in ("_inner", "_fusion", "_vision_provider", "_vision_lock"): + raise AttributeError(name) + return getattr(self._inner, name) + + # ``copy``/``deepcopy`` are implemented explicitly because + # ``_vision_lock`` is a ``threading.Lock``, which cannot be pickled — + # and ``copy.deepcopy`` falls back to ``__reduce_ex__`` for objects it + # does not know, so the default would raise + # ``TypeError: cannot pickle '_thread.lock' object``. + # + # That would not be a loud failure. Two live sites copy the session + # provider — ``agent/run_agent.py``'s per-subagent model override and + # ``permissions/yolo_classifier.py`` — and BOTH swallow ``Exception``, + # so on a fusion session subagent model overrides and yolo + # classification would have quietly stopped working. Caught by + # ``test_deepcopy_does_not_recurse_forever``. + + def __copy__(self) -> "FusionProvider": + import copy as _copy + + # The INNER provider is shallow-copied, not shared. Both live call + # sites copy specifically to get an isolated ``.model``: + # ``agent/run_agent.py`` does ``turn_provider = copy.copy(provider); + # turn_provider.model = resolved_model`` under a comment reading + # "NEVER mutate the shared session provider … N parallel subagents + # share params.provider — mutating provider.model would race across + # them", and ``permissions/yolo_classifier.py`` does the same for + # its classifier model. + # + # Sharing ``_inner`` would defeat that: this class defines ``model`` + # as a property whose setter writes THROUGH to the inner provider, + # so a subagent's override would repoint the main loop's base model + # (e.g. leaving the session firing a Haiku id at api.deepseek.com) + # and N subagents would race. Both sites swallow Exception, so it + # would have been silent. Shallow-copying the inner reproduces plain + # provider semantics exactly — own ``.model``, shared HTTP client. + return FusionProvider( + _copy.copy(self._inner), self._fusion, vision_provider=self._vision_provider + ) + + def __deepcopy__(self, memo: dict) -> "FusionProvider": + import copy as _copy + + # The vision provider is deliberately NOT copied: it is rebuilt + # lazily on first image, and the description cache is process-wide, + # so the clone loses nothing but an idle SDK client. Copying it + # would drag another live HTTP client (and its own locks) along. + # + # ``memo`` is seeded BEFORE recursing so a cycle back to this object + # resolves to the clone instead of building a second one. No provider + # holds such a back-reference today; the guard is free. + clone = FusionProvider.__new__(FusionProvider) + memo[id(self)] = clone + clone.__init__(_copy.deepcopy(self._inner, memo), self._fusion) + return clone + + # ── vision ─────────────────────────────────────────────────────────── + + def _vision(self) -> Any: + """The vision provider, built once on first use. + + Double-checked under a lock: ``chat``/``chat_stream_response`` can + be entered from more than one thread (the async offload in + ``BaseProvider.chat_async``, subagents sharing a session provider), + and building two SDK clients would be wasteful rather than wrong. + """ + if self._vision_provider is not None: + return self._vision_provider + with self._vision_lock: + if self._vision_provider is not None: + return self._vision_provider + from src.config import get_provider_config + from src.providers import get_provider_class, resolve_api_key + + ref = self._fusion.vision + provider_cls = get_provider_class(ref.provider) + cfg = dict(get_provider_config(ref.provider) or {}) + # Translate config keys to constructor kwargs explicitly — + # ``default_model`` and any future config field must not be + # forwarded as kwargs. Same reasoning as ``utils/advisor.py``. + self._vision_provider = provider_cls( + api_key=resolve_api_key(ref.provider, cfg), + base_url=cfg.get("base_url"), + model=ref.model, + ) + return self._vision_provider + + def _prompt(self) -> str: + """The effective instruction sent to the vision model.""" + if self._fusion.prompt: + return ( + f"{_DEFAULT_VISION_PROMPT}\n\nAdditional instructions: " + f"{self._fusion.prompt}" + ) + return _DEFAULT_VISION_PROMPT + + def _scope(self) -> str: + """Cache scope: everything that determines a description's content. + + The vision selector plus a digest of the effective prompt, so two + fusion models sharing a vision model but differing in ``prompt`` do + not serve each other's descriptions. + """ + digest = hashlib.sha256(self._prompt().encode("utf-8", "ignore")).hexdigest()[:16] + return f"{self._fusion.vision.selector}|p:{digest}" + + def _vision_timeout(self, provider: Any) -> Any: + """The per-call timeout for the vision request. + + A phase-split ``httpx.Timeout`` when httpx is available, never a bare + float: httpx expands a float across ALL four phases, so a bare value + would silently lift ``connect`` from the client-level 15s to the full + vision timeout — the exact hazard ``anthropic_provider.chat`` + documents ("Pass the phase-split httpx.Timeout, never a bare float"), + where a black-holed SYN then hangs for the whole window. Keeps + ``connect`` short while allowing a slow image analysis to finish. + """ + seconds = max(1.0, self._fusion.timeout_ms / 1000.0) + try: + import httpx + + return httpx.Timeout(seconds, connect=min(15.0, seconds)) + except Exception: # noqa: BLE001 — the float still satisfies the SDK + return seconds + + def _describe(self, source: dict[str, Any]) -> str: + """Ask the vision model about one image. Returns its description. + + Raises on failure; :meth:`_substitute` converts that into a text note + so invariant 1 (no image block survives) always holds. + """ + prompt = self._prompt() + provider = self._vision() + # The image is handed over in ANTHROPIC block shape and the target + # provider translates it: OpenAI-compatible providers turn it into + # an ``image_url`` data URI in ``_prepare_messages``, and + # Anthropic-wire providers take it as-is. So one call shape covers + # every vision provider. Same for ``system``, which + # ``OpenAICompatibleProvider.chat`` folds into a leading system + # message and the Anthropic wire takes as a kwarg. + messages = [ + { + "role": "user", + "content": [ + {"type": "text", "text": prompt}, + {"type": "image", "source": dict(source)}, + ], + } + ] + response = provider.chat( + messages, + model=self._fusion.vision.model, + max_tokens=2_000, + timeout=self._vision_timeout(provider), + ) + # Record the vision leg's spend. The session cost tracker follows the + # BASE model (``self.model`` reports the base id, which is what + # cost/context-window lookups must key off), so these tokens are + # billed by the provider but appear in no clawcodex total. Logging + # them keeps the cost at least discoverable; wiring a second model's + # usage into the tracker's single-model accounting is a larger change + # than this feature should make. + usage = getattr(response, "usage", None) + if usage: + logger.info( + "[fusion] vision call billed to %s: %s", + self._fusion.vision.selector, usage, + ) + text = (getattr(response, "content", "") or "").strip() + if not text: + # Observed live: ``openrouter:z-ai/glm-4.5v`` answers 200 with an + # empty ``content`` (its text lands in a reasoning field the + # response shape drops). Treat it as a failure so the caller + # emits an explicit note rather than an empty text block that + # reads to the base model as "the image was blank". + raise RuntimeError( + f"vision model {self._fusion.vision.selector} returned no text" + ) + if len(text) > _MAX_DESCRIPTION_CHARS: + text = text[:_MAX_DESCRIPTION_CHARS] + "… [description truncated]" + return text + + def _substitute(self, block: Any, budget: "_Budget") -> Any: + """Return the replacement for one image block (or the block itself).""" + source = _image_source(block) + if source is None: + return block + + label = _describe_size(source) + prefix = f"[Image ({label})" if label else "[Image" + vision = self._fusion.vision.selector + + key = _image_key(self._scope(), source) + if key is None: + logger.warning("[fusion] image block has no data or url; substituting note") + return { + "type": "text", + "text": f"{prefix}: not described — the image block carried no data.]", + } + + cached = _cache_get(key) + if cached is not None: + # A cached FAILURE replays as its note instead of retrying. See + # ``_cache_failure`` for why that matters. + if isinstance(cached, _Failure): + return { + "type": "text", + "text": f"{prefix}: could not be described ({vision}): {cached.reason}]", + } + return {"type": "text", "text": f"{prefix}, described by {vision}]\n{cached}"} + + exhausted = budget.exhausted() + if exhausted: + logger.info("[fusion] %s; leaving image undescribed", exhausted) + return { + "type": "text", + "text": ( + f"{prefix}: not described — {exhausted}. Ask about this image " + "on its own to have it described.]" + ), + } + + budget.calls -= 1 + try: + description = self._describe(source) + except Exception as exc: # noqa: BLE001 — invariant 1: never re-raise + # Degrading to a note keeps the turn alive. Re-raising, or + # leaving the image in place, reproduces the exact 400 this + # feature exists to prevent. + logger.warning("[fusion] vision call failed: %s", exc) + # Cache the failure. Without this, a vision provider that is + # down or unreachable is retried for EVERY image on EVERY turn + # for the life of the session: history is replayed each turn, so + # 8 images × a 60s timeout × 2 SDK attempts adds ~16 minutes to + # every request, indefinitely, and none of it is interruptible. + # Cached negatively, the outage costs one attempt per image and + # the session stays usable (degraded, and the note says why). + _cache_put(key, _Failure(str(exc))) + return { + "type": "text", + "text": ( + f"{prefix}: could not be described — the vision model " + f"({vision}) failed: {exc}]" + ), + } + _cache_put(key, description) + return {"type": "text", "text": f"{prefix}, described by {vision}]\n{description}"} + + def _fuse_content(self, content: Any, budget: "_Budget") -> tuple[Any, bool]: + """Rewrite a content list. Returns ``(content, changed)``. + + Recurses one level into ``tool_result`` blocks, which is where a + ``Read``-returned or ``Bash``-captured image lives. Copy-on-write: + ``changed=False`` means the caller keeps its original object. + + A bare dict ``content`` is normalized to a one-element list first. + Every in-repo producer emits list-shaped content and the API accepts + only ``str | list``, so this is defence for a resumed session or an + SDK caller — the same population ``_image_source`` hardens the block + shape for. Without it, ``content = `` would walk past an + image and put it on the wire, breaking invariant 1. + """ + if isinstance(content, dict): + fused, changed = self._fuse_content([content], budget) + return (fused, True) if changed else (content, False) + if not isinstance(content, list): + return content, False + + out: list[Any] = [] + changed = False + for block in content: + if _image_source(block) is not None: + out.append(self._substitute(block, budget)) + changed = True + continue + if isinstance(block, dict) and block.get("type") == "tool_result": + inner, inner_changed = self._fuse_content(block.get("content"), budget) + if inner_changed: + out.append({**block, "content": inner}) + changed = True + continue + out.append(block) + return (out, True) if changed else (content, False) + + def _fuse(self, messages: Any, abort_signal: Any = None) -> Any: + """Describe-and-substitute every image in ``messages``. + + Returns the original list untouched when there are no images, which + is the overwhelmingly common case — a fusion session pays nothing + for turns without one. + + ``abort_signal`` (the caller's, when it passed one) is consulted + between images so ESC takes effect during the rewrite rather than + only after it: the rewrite runs BEFORE delegation, so a long history + of images against a slow vision provider would otherwise be + uninterruptible and look like a hang. + """ + if not isinstance(messages, list) or not messages: + return messages + + budget = _Budget(max(1, self._fusion.max_images), abort_signal) + started = time.monotonic() + out: list[Any] = [] + changed = False + for message in messages: + # Typed ChatMessage carries ``content: str`` and so can never + # hold an image block; only dict messages are walked. + if not isinstance(message, dict): + out.append(message) + continue + content, message_changed = self._fuse_content(message.get("content"), budget) + if message_changed: + out.append({**message, "content": content}) + changed = True + else: + out.append(message) + if not changed: + return messages + logger.info( + "[fusion] %s: substituted image blocks via %s (%d vision attempt(s), %.1fs)", + self._fusion.name, + self._fusion.vision.selector, + max(1, self._fusion.max_images) - budget.calls, + time.monotonic() - started, + ) + return out + + def _fused_args( + self, args: tuple[Any, ...], kwargs: dict[str, Any] + ) -> tuple[tuple[Any, ...], dict[str, Any]]: + """Rewrite whichever of ``args``/``kwargs`` carries ``messages``. + + Callers pass messages positionally (``query._call_model_sync``) or + by keyword; handling both keeps the wrapper transparent either way. + + The caller's ``abort_signal`` (present on + ``chat_stream_response``) is forwarded to the rewrite so ESC can + interrupt it, and left in ``kwargs`` for the inner provider. + """ + signal = kwargs.get("abort_signal") + if args: + return (self._fuse(args[0], signal), *args[1:]), kwargs + if "messages" in kwargs: + return args, {**kwargs, "messages": self._fuse(kwargs["messages"], signal)} + return args, kwargs + + # ── delegated chat surface ─────────────────────────────────────────── + + def chat_stream_response(self, *args: Any, **kwargs: Any) -> Any: + args, kwargs = self._fused_args(args, kwargs) + return self._inner.chat_stream_response(*args, **kwargs) + + def chat(self, *args: Any, **kwargs: Any) -> Any: + args, kwargs = self._fused_args(args, kwargs) + return self._inner.chat(*args, **kwargs) + + def chat_stream(self, *args: Any, **kwargs: Any) -> Any: + args, kwargs = self._fused_args(args, kwargs) + return self._inner.chat_stream(*args, **kwargs) + + async def chat_async(self, *args: Any, **kwargs: Any) -> Any: + # The vision call is blocking HTTP, so the rewrite is offloaded + # rather than run on the event loop — the same reasoning as + # ``BaseProvider.chat_async``'s own ``to_thread`` offload. + args, kwargs = await asyncio.to_thread(self._fused_args, args, kwargs) + return await self._inner.chat_async(*args, **kwargs) + + +def build_fusion_provider(fusion: FusionModel, **overrides: Any) -> FusionProvider: + """Construct a :class:`FusionProvider` for ``fusion`` from config. + + Builds the base provider from ``fusion.base`` the same way the + entrypoints do (provider class + resolved key + configured base_url), + then wraps it. ``overrides`` is forwarded to :class:`FusionProvider` + (tests inject ``vision_provider``). + """ + from src.config import get_provider_config + from src.providers import get_provider_class, resolve_api_key + + ref = fusion.base + cfg = dict(get_provider_config(ref.provider) or {}) + provider_cls = get_provider_class(ref.provider) + inner = provider_cls( + api_key=resolve_api_key(ref.provider, cfg), + base_url=cfg.get("base_url"), + model=ref.model, + ) + return FusionProvider(inner, fusion, **overrides) + + +__all__ = ["FusionProvider", "build_fusion_provider", "clear_vision_cache"] diff --git a/src/server/agent_server.py b/src/server/agent_server.py index c36acd583..f3d3501cf 100644 --- a/src/server/agent_server.py +++ b/src/server/agent_server.py @@ -283,6 +283,10 @@ def emit_init(self) -> None: "session_id": self.session_id, "protocol_version": PROTOCOL_VERSION, "model": getattr(self.provider, "model", self.config.model), + # The active fusion model's name, "" when not fused. Carried on + # init (not just the set_model reply) so a session started with + # ``--model `` shows it from the first frame. + "fusion": self._active_fusion_name(), "provider": self.provider_name, "cwd": self.cwd, "tools": tools, @@ -625,6 +629,12 @@ async def _handle_control_request(self, msg: dict) -> None: "model": getattr(self.provider, "model", None), "provider": self.provider_name, "available_models": self._available_models(), + # The active fusion model's NAME, or "" when not on one. + # ``model`` above stays the base model id (what serves the + # turn, and what cost/context-window lookups key off), so + # this is the only channel telling the client that images + # are being routed through a second model. + "fusion": self._active_fusion_name(), # OS-1 W3 — the /output-style no-arg display. "output_style": getattr(self.tool_context, "output_style_name", None) or "default", "available_output_styles": self._available_output_styles(), @@ -816,6 +826,9 @@ async def _handle_control_request(self, msg: dict) -> None: if subtype == "eco": self._do_eco_command(request_id, inner.get("arg")) return + if subtype == "fusion": + self._do_fusion_command(request_id, inner.get("arg")) + return if subtype == "worktree_status": await self._do_worktree_status(request_id) return @@ -1211,16 +1224,48 @@ def _available_output_styles(self) -> list[str]: except Exception: # noqa: BLE001 return ["default", "explanatory"] + def _active_fusion_name(self) -> str: + """The active fusion model's name, or ``""`` when not on one. + + Type-checked rather than a bare ``getattr(...) or ""``: this value + is serialized onto the NDJSON control channel, so a provider (or a + test double) answering a non-string for ``fusion_name`` would put + an unserializable object on the wire and break the channel for the + whole session. Cheap insurance on an attribute read from a + duck-typed object. + """ + name = getattr(self.provider, "fusion_name", "") + return name if isinstance(name, str) else "" + def _available_models(self) -> list[str]: - """Provider's selectable models (for the /model picker). Best-effort.""" + """Selectable models for the /model picker. Best-effort. + + Enabled fusion models are listed FIRST, then the provider's own + models. CCR's contract is that a saved Fusion model "appears in + routing and Agent Profiles like a normal model"; the picker is this + client's equivalent surface, and a fusion model that cannot be found + there is effectively unusable outside typed ``/model ``. + + Listed ahead of the provider's models because they are few, + hand-created, and the reason the user opened the picker; the + provider list can run to dozens of ids. + """ + fusion: list[str] = [] + try: + from src.providers.fusion_models import load_fusion_models + + fusion = [m.name for m in load_fusion_models() if m.enabled] + except Exception: # noqa: BLE001 — a bad record must not empty the picker + logger.debug("[agent-server] fusion model listing failed", exc_info=True) try: fn = getattr(self.provider, "get_available_models", None) if callable(fn): models = fn() - return [str(m) for m in models] if models else [] + if models: + return fusion + [str(m) for m in models] except Exception: # noqa: BLE001 pass - return [] + return fusion def _emit_agent_progress(self, ev: dict) -> None: """Forward a spawned subagent's progress to the client (the original's @@ -1379,6 +1424,13 @@ def _do_set_model(self, request_id: object, model: object, provider: object = No if self.provider is None: self._reply(request_id, {"ok": False, "error": "session not ready"}) return + # A fusion model is selected by name like any other model (CCR: + # "a Fusion model appears in routing … like a normal model"), but + # activating one swaps the whole provider rather than poking + # ``.model``: it carries its own base provider + model, and the + # vision wrapper has to be installed around it. + if self._do_set_fusion_model(request_id, model): + return if isinstance(provider, str) and provider and provider != self.provider_name: self._reply(request_id, { "ok": False, @@ -1386,8 +1438,40 @@ def _do_set_model(self, request_id: object, model: object, provider: object = No f"session is on '{self.provider_name}'", }) return + # Leaving a fusion model for a plain one: drop the wrapper, so the + # session stops paying for vision substitution it no longer needs + # and ``isinstance(provider, AnthropicProvider)`` checks downstream + # see the real provider again. + from src.providers.fusion_provider import FusionProvider + + target = self.provider + unfusing = isinstance(target, FusionProvider) + if unfusing: + # Idle-only, exactly like /provider and the fusion branch above: + # un-fusing replaces provider AND tool_registry, and the turn + # runs on the worker thread while this handler runs on the main + # loop — so a mid-turn swap pulls the registry out from under + # live tool dispatch. The plain path below is only a ``.model`` + # assignment, which is why it needs no gate; this branch is not. + with self._lock: + active = self._current_abort is not None + if active: + self._reply(request_id, { + "ok": False, + "error": "cannot leave a fusion model during an active turn", + }) + return try: - self.provider.model = model + if unfusing: + # Inside the try: _install_provider rebuilds the registry, + # re-registers MCP tools, and dispatches app state, any of + # which can raise. Escaping here replies NOTHING — on stdio + # the client's controlQuery hangs to its RPC timeout, and on + # the WS transport it kills the inbound task and drops the + # whole connection. + self._install_provider(target.inner, self.provider_name, model) + target = self.provider + target.model = model except Exception as exc: # noqa: BLE001 self._reply(request_id, {"ok": False, "error": f"model switch failed: {exc}"}) return @@ -1404,6 +1488,88 @@ def _do_set_model(self, request_id: object, model: object, provider: object = No ) self._reply(request_id, response) + def _do_set_fusion_model(self, request_id: object, model: str) -> bool: + """Activate ``model`` if it names a fusion model. Returns whether handled. + + Returns True (having replied) when ``model`` matched a saved fusion + model — enabled or not — so the caller stops. Returns False when the + name is not a fusion model, leaving the plain-model path to run. + + Selecting a fusion model whose base lives on another provider is a + genuine cross-provider switch, so this goes through + :meth:`_install_provider` — the same rebuild ``/provider`` does — + rather than the ``.model`` assignment a same-provider switch needs. + """ + from src.providers.fusion_models import get_fusion_model + + try: + fusion = get_fusion_model(model) + except Exception: # noqa: BLE001 — a config problem must not block /model + logger.debug("[agent-server] fusion lookup failed", exc_info=True) + return False + if fusion is None: + return False + if not fusion.enabled: + self._reply(request_id, { + "ok": False, + "error": f"fusion model '{fusion.name}' is disabled — run " + f"`/fusion enable {fusion.name}` first", + }) + return True + + # Idle-only, matching /provider: the turn runs on the worker thread + # while this handler runs on the main loop, so swapping the provider + # and tool registry mid-turn would pull them out from under it. + with self._lock: + active = self._current_abort is not None + if active: + self._reply(request_id, { + "ok": False, + "error": "cannot switch to a fusion model during an active turn", + }) + return True + + try: + from src.providers import provider_has_credentials, resolve_api_key + from src.providers.fusion_provider import build_fusion_provider + + # Check credentials for BOTH halves up front. Without the vision + # check the switch succeeds and then every image silently + # degrades to a "vision model failed" note — a failure the user + # would only discover mid-task. + for ref, role in ((fusion.base, "base"), (fusion.vision, "vision")): + key = resolve_api_key(ref.provider) + if not provider_has_credentials(ref.provider, key): + self._reply(request_id, { + "ok": False, + "error": f"fusion model '{fusion.name}' needs credentials for " + f"its {role} provider '{ref.provider}' (no API key " + f"configured)", + }) + return True + + fused = build_fusion_provider(fusion) + self._install_provider(fused, fusion.base.provider, fusion.base.model) + except Exception as exc: # noqa: BLE001 + logger.exception("[agent-server] fusion model switch failed") + self._reply(request_id, { + "ok": False, "error": f"fusion model switch failed: {exc}", + }) + return True + + self._reply(request_id, { + "ok": True, + # The fusion NAME is echoed as the switch result: it is what the + # user selected and what the client's model line should show. + # ``fusion_base`` carries the id actually on the wire. + "model": fusion.name, + "fusion": fusion.name, + "fusion_base": fusion.base.selector, + "fusion_vision": fusion.vision.selector, + "provider": fusion.base.provider, + }) + return True + def _do_set_provider(self, request_id: object, name: object) -> None: """Switch the LLM provider mid-session (the original's /provider). Rebuilds the provider + tool registry but keeps the conversation. Idle-only.""" @@ -1418,7 +1584,6 @@ def _do_set_provider(self, request_id: object, name: object) -> None: return from src.config import get_provider_config from src.providers import get_provider_class, provider_has_credentials, resolve_api_key - from src.tool_system.defaults import build_default_registry provider_cfg = get_provider_config(name) api_key = resolve_api_key(name, provider_cfg) @@ -1428,58 +1593,78 @@ def _do_set_provider(self, request_id: object, name: object) -> None: provider_cls = get_provider_class(name) model = provider_cfg.get("default_model") provider = provider_cls(api_key=api_key, base_url=provider_cfg.get("base_url"), model=model) - registry = build_default_registry(provider=provider) - cfg = self.config - # Canonicalize up front so alias-form flags (e.g. KillShell -> - # TaskStop) resolve while the tool is still registered. - allow = ( - registry.canonicalize_names(cfg.allowed_tools) - if cfg.allowed_tools - else None - ) - deny = ( - registry.canonicalize_names(cfg.disallowed_tools) - if cfg.disallowed_tools - else None - ) - if allow is not None: - _filter_registry(registry, keep=lambda n: n.lower() in allow) - if deny is not None: - _filter_registry(registry, keep=lambda n: n.lower() not in deny) - if self._mcp_runtime is not None: # keep MCP tools across the switch - for mtool in self._mcp_runtime.tools: - try: - registry.register(mtool) - except Exception: # noqa: BLE001 - pass - self.provider = provider - self.provider_name = name - cfg.provider_name = name - cfg.model = model - self.tool_registry = registry - # ch03 round-4 GAP A: keep the persisted (model, model_provider) - # pair coherent across a provider switch — the supplier reads - # self.provider_name, updated above, so on_change persists the - # new pairing. - _dispatch_app_state(self, main_loop_model=model) - # INTEG-1 warm-on-activation (the refreshStartupDiscoveryForActiveRoute - # analog, discoveryService.ts:415): one non-blocking - # get_available_models call kicks the single-flight background - # refresh at SWITCH time, so the picker's later read sees the - # discovered list instead of the static stub. (Server init warms - # the initial provider the same way via get_settings → - # _available_models.) - try: - warm = getattr(provider, "get_available_models", None) - if callable(warm): - warm() - except Exception: # noqa: BLE001 — warm is best-effort - logger.debug("[agent-server] discovery warm failed", exc_info=True) + self._install_provider(provider, name, model) self._reply(request_id, {"ok": True, "provider": name, "model": model or ""}) except Exception as exc: # noqa: BLE001 logger.exception("[agent-server] set_provider failed") self._reply(request_id, {"ok": False, "error": str(exc)}) + def _install_provider( + self, provider: Any, name: str, model: str | None + ) -> None: + """Adopt ``provider`` as the session's provider, rebuilding the registry. + + Extracted from :meth:`_do_set_provider` so the fusion-model branch of + :meth:`_do_set_model` performs an IDENTICAL swap — a fusion model + carries its own base provider, so selecting one can be a + cross-provider switch, and re-deriving these steps separately is how + the two paths would drift (a missed MCP re-register or app-state + dispatch is silent). + + ``model`` is the id to record as current; for a fusion model that is + the base model, since that is what serves the turn (see + ``FusionProvider.model``). + """ + from src.tool_system.defaults import build_default_registry + + registry = build_default_registry(provider=provider) + cfg = self.config + # Canonicalize up front so alias-form flags (e.g. KillShell -> + # TaskStop) resolve while the tool is still registered. + allow = ( + registry.canonicalize_names(cfg.allowed_tools) + if cfg.allowed_tools + else None + ) + deny = ( + registry.canonicalize_names(cfg.disallowed_tools) + if cfg.disallowed_tools + else None + ) + if allow is not None: + _filter_registry(registry, keep=lambda n: n.lower() in allow) + if deny is not None: + _filter_registry(registry, keep=lambda n: n.lower() not in deny) + if self._mcp_runtime is not None: # keep MCP tools across the switch + for mtool in self._mcp_runtime.tools: + try: + registry.register(mtool) + except Exception: # noqa: BLE001 + pass + self.provider = provider + self.provider_name = name + cfg.provider_name = name + cfg.model = model + self.tool_registry = registry + # ch03 round-4 GAP A: keep the persisted (model, model_provider) + # pair coherent across a provider switch — the supplier reads + # self.provider_name, updated above, so on_change persists the + # new pairing. + _dispatch_app_state(self, main_loop_model=model) + # INTEG-1 warm-on-activation (the refreshStartupDiscoveryForActiveRoute + # analog, discoveryService.ts:415): one non-blocking + # get_available_models call kicks the single-flight background + # refresh at SWITCH time, so the picker's later read sees the + # discovered list instead of the static stub. (Server init warms + # the initial provider the same way via get_settings → + # _available_models.) + try: + warm = getattr(provider, "get_available_models", None) + if callable(warm): + warm() + except Exception: # noqa: BLE001 — warm is best-effort + logger.debug("[agent-server] discovery warm failed", exc_info=True) + def _mcp_server_infos(self) -> list[Any] | None: """The connected MCP servers' info objects (name + instructions) for the system-prompt build, filtered by the registry's disabled set — @@ -3204,6 +3389,53 @@ def _do_advisor_command(self, request_id: object, arg: object) -> None: logger.exception("[agent-server] advisor command failed") self._reply(request_id, {"ok": False, "error": str(exc)}) + def _do_fusion_command(self, request_id: object, arg: object) -> None: + """Control handler for /fusion — manage fusion models. + + Bridges to the command-system implementation + (``fusion_command.fusion_command_call``), which owns the grammar + (list / create / delete / enable / disable / help). + + ``single_session``-gated for the same reason as /advisor: fusion + models are persisted USER-level config (``~/.clawcodex/config.json`` + → ``fusionModels``), so on the multi-session WS transport one + client's ``/fusion delete`` would remove a model out from under + every other session on the host. + + Reply shape mirrors /advisor: transport-level ``ok`` is True + whenever the command ran, and command-level rejections (bad + selector, unknown name, duplicate) ride ``text``. + """ + if not self.config.single_session: + self._reply(request_id, { + "ok": False, + "error": "/fusion is only available on single-session " + "(stdio) transports — it persists user-level " + "settings.", + }) + return + try: + from src.command_system.fusion_command import fusion_command_call + from src.command_system.types import CommandContext + + ctx = CommandContext( + workspace_root=Path(self.cwd), + cwd=Path(self.cwd), + conversation=getattr(self.session, "conversation", None), + cost_tracker=None, + history=None, + app_state_store=None, + provider=self.provider, + ) + result = fusion_command_call(str(arg or ""), ctx) + self._reply(request_id, { + "ok": True, + "text": str(getattr(result, "value", "") or ""), + }) + except Exception as exc: # noqa: BLE001 — must not kill the control channel + logger.exception("[agent-server] fusion command failed") + self._reply(request_id, {"ok": False, "error": str(exc)}) + def _maybe_continue_goal(self, outcome: dict | None) -> None: """Post-turn goal hook (worker thread) — the port of hermes tui_gateway's "/goal continuation" block and the observable behavior @@ -4513,21 +4745,68 @@ def _build_runtime(sess: _AgentSession, perm_mode: str | None) -> None: logger.debug("[agent-server] sandbox hard-gate check failed", exc_info=True) provider_name = cfg.provider_name or get_default_provider() + + # ``--model `` may name a FUSION model, which is not a real + # model id on any provider: handing it to the provider constructor + # would put it on the wire and 400. Resolved BEFORE the credential + # gate below, because a fusion model overrides the session's + # provider — gating on the session default first would refuse to + # start when that unrelated provider happens to be unconfigured, + # even though the fusion model's own providers are fine. + fusion = None + try: + from src.providers.fusion_models import get_fusion_model + + candidate = get_fusion_model(cfg.model) if cfg.model else None + fusion = candidate if (candidate and candidate.enabled) else None + except Exception: # noqa: BLE001 — never block startup on this + logger.debug("[agent-server] fusion lookup at init failed", exc_info=True) + + if fusion is not None: + # Selecting a fusion model IS a provider choice: its record + # names the base provider, which replaces the session's. + provider_name = fusion.base.provider + provider_cfg = get_provider_config(provider_name) api_key = resolve_api_key(provider_name, provider_cfg) if not provider_has_credentials(provider_name, api_key): sess.init_error = ( + f"fusion model '{fusion.name}' needs credentials for its base " + f"provider '{provider_name}'. Run `clawcodex login` to set it up." + if fusion is not None else f"API key for provider '{provider_name}' is not configured. " "Run `clawcodex login` to set it up." ) sess.provider_name = provider_name return - provider_cls = get_provider_class(provider_name) - model = cfg.model or provider_cfg.get("default_model") - provider = provider_cls( - api_key=api_key, base_url=provider_cfg.get("base_url"), model=model - ) + if fusion is not None: + from src.providers.fusion_provider import build_fusion_provider + + # The VISION half is credential-checked too — the same check + # /model and headless perform. Starting fused is the primary + # entry point, so it must not be the one that skips it: without + # this the session starts and then every image degrades to a + # "vision model failed" note, discovered mid-task. + vision_ref = fusion.vision + vision_cfg = get_provider_config(vision_ref.provider) + if not provider_has_credentials( + vision_ref.provider, resolve_api_key(vision_ref.provider, vision_cfg) + ): + sess.init_error = ( + f"fusion model '{fusion.name}' needs credentials for its vision " + f"provider '{vision_ref.provider}'. Run `clawcodex login` to set it up." + ) + sess.provider_name = provider_name + return + model = fusion.base.model + provider = build_fusion_provider(fusion) + else: + provider_cls = get_provider_class(provider_name) + model = cfg.model or provider_cfg.get("default_model") + provider = provider_cls( + api_key=api_key, base_url=provider_cfg.get("base_url"), model=model + ) profile_checkpoint("agent_server_provider_ready") # (ch03 round-4 GAP A: the per-session AppState store is created diff --git a/tests/providers/test_fusion_models.py b/tests/providers/test_fusion_models.py new file mode 100644 index 000000000..cd3dd887f --- /dev/null +++ b/tests/providers/test_fusion_models.py @@ -0,0 +1,388 @@ +"""Fusion model record + lifecycle (create / configure / save / manage). + +The global config these write to is isolated per test by the autouse +``_isolate_user_permission_settings`` fixture in ``tests/conftest.py``, +which repoints ``config.GLOBAL_CONFIG_DIR`` / ``GLOBAL_CONFIG_FILE`` at +``tmp_path`` — so nothing here touches the developer's real +``~/.clawcodex/config.json``. +""" + +from __future__ import annotations + +import pytest + +from src.providers.fusion_models import ( + DEFAULT_MAX_IMAGES, + DEFAULT_VISION_TIMEOUT_MS, + FusionModel, + FusionModelError, + ModelRef, + create_fusion_model, + delete_fusion_model, + fusion_model_from_json, + get_fusion_model, + is_fusion_model_name, + load_fusion_models, + parse_selector, + save_fusion_models, + set_fusion_model_enabled, + suggest_fusion_name, +) + +BASE = "deepseek:deepseek-v4-pro" +VISION = "openrouter:google/gemini-2.5-flash" + + +@pytest.fixture(autouse=True) +def _configured_providers(monkeypatch): + """Give validation a providers block to check selectors against. + + ``validate_fusion_model`` skips the configured-provider check when the + list is empty, so without this the "unknown provider" test would pass + for the wrong reason. + """ + import src.providers.fusion_models as mod + + monkeypatch.setattr( + mod, "_configured_providers", lambda: ["deepseek", "openrouter", "zai", "anthropic"] + ) + + +# ── selector parsing ───────────────────────────────────────────────────── + + +def test_parse_selector_splits_on_first_colon_only(): + # An OpenRouter ``:free`` suffix must survive — splitting on the LAST + # colon, or on every colon, would mangle it. + ref = parse_selector("openrouter:deepseek/deepseek-r1:free", field="vision") + assert ref.provider == "openrouter" + assert ref.model == "deepseek/deepseek-r1:free" + assert ref.selector == "openrouter:deepseek/deepseek-r1:free" + + +def test_parse_selector_trims_whitespace(): + ref = parse_selector(" deepseek : deepseek-v4-pro ", field="base") + assert (ref.provider, ref.model) == ("deepseek", "deepseek-v4-pro") + + +@pytest.mark.parametrize("bad", ["", " ", "deepseek", "deepseek:", ":model", ":", None, 7]) +def test_parse_selector_rejects_malformed(bad): + with pytest.raises(FusionModelError): + parse_selector(bad, field="base") + + +def test_parse_selector_names_the_offending_field(): + with pytest.raises(FusionModelError, match="vision"): + parse_selector("nocolon", field="vision") + + +def test_suggest_fusion_name_follows_ccr_convention(): + # CCR: "GLM-5.2 + GLM-5V-Turbo = GLM-5.2V" — capability-obvious names. + assert suggest_fusion_name("deepseek-v4-pro") == "deepseek-v4-pro-V" + # A provider-qualified id keeps only the model stem. + assert suggest_fusion_name("deepseek/deepseek-v4-pro") == "deepseek-v4-pro-V" + + +# ── create / save / load round-trip ────────────────────────────────────── + + +def test_create_saves_and_round_trips(): + created = create_fusion_model("dsv", BASE, VISION) + assert created.name == "dsv" + assert created.base == ModelRef("deepseek", "deepseek-v4-pro") + assert created.vision == ModelRef("openrouter", "google/gemini-2.5-flash") + assert created.enabled is True + + loaded = load_fusion_models() + assert [m.name for m in loaded] == ["dsv"] + assert loaded[0] == created + + +def test_to_json_omits_defaults(): + model = FusionModel(name="x", base=ModelRef("a", "b"), vision=ModelRef("c", "d")) + assert model.to_json() == {"name": "x", "base": "a:b", "vision": "c:d"} + + +def test_to_json_includes_non_defaults(): + model = FusionModel( + name="x", + base=ModelRef("a", "b"), + vision=ModelRef("c", "d"), + enabled=False, + prompt="focus on text", + timeout_ms=1234, + max_images=2, + ) + assert model.to_json() == { + "name": "x", + "base": "a:b", + "vision": "c:d", + "enabled": False, + "prompt": "focus on text", + "timeoutMs": 1234, + "maxImages": 2, + } + + +def test_get_fusion_model_is_case_insensitive(): + create_fusion_model("DeepSeek-V", BASE, VISION) + assert get_fusion_model("deepseek-v") is not None + assert get_fusion_model("DEEPSEEK-V") is not None + assert get_fusion_model("nope") is None + + +def test_is_fusion_model_name_requires_enabled(): + create_fusion_model("dsv", BASE, VISION) + assert is_fusion_model_name("dsv") is True + set_fusion_model_enabled("dsv", False) + # get_fusion_model still finds it (so the caller can say "disabled"), + # but the activation test must be False. + assert get_fusion_model("dsv") is not None + assert is_fusion_model_name("dsv") is False + + +# ── validation ─────────────────────────────────────────────────────────── + + +def test_rejects_duplicate_name(): + create_fusion_model("dsv", BASE, VISION) + with pytest.raises(FusionModelError, match="already exists"): + create_fusion_model("dsv", BASE, VISION) + + +def test_rejects_duplicate_name_case_insensitively(): + create_fusion_model("dsv", BASE, VISION) + with pytest.raises(FusionModelError, match="already exists"): + create_fusion_model("DSV", BASE, VISION) + + +def test_rejects_unknown_provider(): + with pytest.raises(FusionModelError, match="not configured"): + create_fusion_model("x", "nosuch:model", VISION) + + +def test_rejects_identical_base_and_vision(): + with pytest.raises(FusionModelError, match="identical"): + create_fusion_model("x", BASE, BASE) + + +def test_rejects_name_with_colon_or_space(): + with pytest.raises(FusionModelError, match="may not contain"): + create_fusion_model("a:b", BASE, VISION) + + +def test_rejects_name_shadowing_a_real_model(): + # ``deepseek-v4-pro`` is a real id on the deepseek provider, so reusing + # it would make ``/model deepseek-v4-pro`` ambiguous. + with pytest.raises(FusionModelError, match="already a real model"): + create_fusion_model("deepseek-v4-pro", BASE, VISION) + + +@pytest.mark.parametrize("hijack", ["claude-opus-5", "gpt-5.4", "MiniMax-M3"]) +def test_rejects_name_shadowing_ANOTHER_providers_model(hijack): + # Cross-vendor hijack: the fusion lookup in _do_set_model runs BEFORE + # the "model X expects provider Y" guard and ignores the requested + # provider, so a fusion model named after another vendor's id would make + # `/model claude-opus-5` on an Anthropic session silently route to the + # fusion's base provider instead. + with pytest.raises(FusionModelError, match="already a real model"): + create_fusion_model(hijack, BASE, VISION) + + +def test_rejects_anthropic_wire_base(): + # Six live call sites branch on isinstance(provider, AnthropicProvider); + # a wrapper is invisible to them, so an Anthropic base would silently + # disable prompt caching and deferred tools. + with pytest.raises(FusionModelError, match="Anthropic wire"): + create_fusion_model("x", "anthropic:claude-3-haiku-20240307", VISION) + + +def test_rejects_known_text_only_vision_model(): + # The feature's most inviting mistake: glm-5.2 has multimodal siblings + # but is itself text-only on both Z.ai endpoints (probed 2026-07-30). + with pytest.raises(FusionModelError, match="does not support image input"): + create_fusion_model("x", BASE, "zai:glm-5.2") + + +@pytest.mark.parametrize( + "spelling", + [ + "openrouter:z-ai/glm-5.2", # vendor-prefixed through a proxy + "openrouter:z-ai/glm-5.2:free", # …plus a routing suffix + "openrouter:glm-5.2", + ], +) +def test_rejects_text_only_vision_model_in_any_spelling(spelling): + # The same trap reached by the id form OpenRouter uses — and OpenRouter + # is the provider /fusion's own help example names, so this was the + # likely spelling for the mistake the guard exists to catch. + with pytest.raises(FusionModelError, match="does not support image input"): + create_fusion_model("x", BASE, spelling) + + +@pytest.mark.parametrize("vision", ["zai:glm-4.5v", "zai:glm-5v-turbo", "zai:glm-4.6v"]) +def test_accepts_zai_vision_family(vision): + # These are the models the rejection message tells users to pick, so + # they MUST be accepted. (They are explicitly registered in + # MODEL_CONFIGS, so this exercises the exact-hit path — the LOOKUP rule + # that protects unregistered ids is gated by the test below.) + model = create_fusion_model(f"x-{vision[-6:]}", BASE, vision) + assert model.vision.selector == vision + + +@pytest.mark.parametrize("vision", ["zai:glm-6v", "zai:glm-5.4v-flash"]) +def test_accepts_an_UNREGISTERED_glm_vision_model(vision): + # The one that gates the lookup rule itself, and the reason the rule + # exists rather than just registering the current *v models. + # + # ``get_model_config`` prefix-matches on ``key.rsplit("-", 1)[0]``, and + # the ``glm-5.2`` row's base is bare ``glm`` — so EVERY ``glm*`` id + # inherits that row unless it has an exact entry. A future Z.ai vision + # model nobody has registered yet would therefore inherit + # ``supports_vision=False`` and be refused, with an error message + # recommending the *v family it belongs to. + # + # Verified by mutation: reverting ``supports_vision`` to trust a + # prefix-inherited ``False`` fails THIS test and no other. + from src.models.configs import MODEL_CONFIGS + + model_id = vision.split(":", 1)[1] + assert model_id not in MODEL_CONFIGS, "pick an id that is genuinely unregistered" + created = create_fusion_model(f"x-{model_id}", BASE, vision) + assert created.vision.model == model_id + + +def test_allows_vision_model_absent_from_the_model_table(): + # supports_vision() defaults True for unknown ids, so the check is + # permissive rather than an allowlist — a false rejection would be + # worse than a late API error. + model = create_fusion_model("x", BASE, "openrouter:some/brand-new-vlm") + assert model.vision.model == "some/brand-new-vlm" + + +def test_failed_create_leaves_config_untouched(): + create_fusion_model("keep", BASE, VISION) + with pytest.raises(FusionModelError): + create_fusion_model("keep", BASE, VISION) + assert [m.name for m in load_fusion_models()] == ["keep"] + + +# ── manage: delete / enable / disable ──────────────────────────────────── + + +def test_delete_removes_only_the_named_model(): + create_fusion_model("a", BASE, VISION) + create_fusion_model("b", "deepseek:deepseek-v4-flash", VISION) + removed = delete_fusion_model("a") + assert removed.name == "a" + assert [m.name for m in load_fusion_models()] == ["b"] + + +def test_delete_unknown_raises_and_lists_known(): + create_fusion_model("a", BASE, VISION) + with pytest.raises(FusionModelError, match="Known: a"): + delete_fusion_model("nope") + + +def test_disable_preserves_configuration(): + create_fusion_model("a", BASE, VISION) + updated = set_fusion_model_enabled("a", False) + assert updated.enabled is False + reloaded = get_fusion_model("a") + # The whole record survives, so re-enabling needs no retyping. + assert reloaded.base.selector == BASE + assert reloaded.vision.selector == VISION + assert set_fusion_model_enabled("a", True).enabled is True + + +def test_summary_marks_disabled(): + create_fusion_model("a", BASE, VISION) + assert "(disabled)" not in get_fusion_model("a").summary() + set_fusion_model_enabled("a", False) + assert "(disabled)" in get_fusion_model("a").summary() + + +# ── tolerating a hand-edited config ───────────────────────────────────── + + +def test_malformed_entry_is_dropped_not_fatal(): + good = FusionModel(name="ok", base=ModelRef("deepseek", "m"), vision=ModelRef("openrouter", "v")) + save_fusion_models([good]) + # Splice junk in beside the good record, the way a hand edit would. + from src import config as cfg_mod + from src.providers.fusion_models import FUSION_MODELS_KEY + + mgr = cfg_mod._get_default_manager() + cfg = mgr.load_global() + cfg[FUSION_MODELS_KEY] = [ + "not-a-dict", + {"name": "missing-halves"}, + {"base": "a:b", "vision": "c:d"}, # no name + *cfg[FUSION_MODELS_KEY], + ] + mgr.save_global(cfg) + + # One bad record must not hide the rest. + assert [m.name for m in load_fusion_models()] == ["ok"] + + +def test_non_list_value_yields_empty(): + from src import config as cfg_mod + from src.providers.fusion_models import FUSION_MODELS_KEY + + mgr = cfg_mod._get_default_manager() + cfg = mgr.load_global() + cfg[FUSION_MODELS_KEY] = {"not": "a list"} + mgr.save_global(cfg) + assert load_fusion_models() == [] + + +def test_duplicate_names_in_config_collapse_to_first(): + from src import config as cfg_mod + from src.providers.fusion_models import FUSION_MODELS_KEY + + mgr = cfg_mod._get_default_manager() + cfg = mgr.load_global() + cfg[FUSION_MODELS_KEY] = [ + {"name": "dup", "base": "deepseek:first", "vision": "openrouter:v"}, + {"name": "DUP", "base": "deepseek:second", "vision": "openrouter:v"}, + ] + mgr.save_global(cfg) + loaded = load_fusion_models() + assert len(loaded) == 1 + assert loaded[0].base.model == "first" + + +def test_from_json_absent_enabled_means_enabled(): + model = fusion_model_from_json({"name": "x", "base": "a:b", "vision": "c:d"}) + assert model.enabled is True + + +def test_from_json_coerces_string_numbers(): + model = fusion_model_from_json( + {"name": "x", "base": "a:b", "vision": "c:d", "timeoutMs": "5000", "maxImages": "3"} + ) + assert (model.timeout_ms, model.max_images) == (5000, 3) + + +@pytest.mark.parametrize("junk", ["abc", "", "0", "-1", True, None, [], 0]) +def test_from_json_bad_numbers_fall_back_to_defaults(junk): + model = fusion_model_from_json( + {"name": "x", "base": "a:b", "vision": "c:d", "timeoutMs": junk, "maxImages": junk} + ) + assert model.timeout_ms == DEFAULT_VISION_TIMEOUT_MS + assert model.max_images == DEFAULT_MAX_IMAGES + + +def test_load_never_raises_when_config_is_unreadable(monkeypatch): + import src.providers.fusion_models as mod + + def boom(): + raise RuntimeError("config on fire") + + monkeypatch.setattr(mod, "_manager", boom) + # A fusion model is an enhancement; a config problem must not brick the + # /model picker or startup. + assert load_fusion_models() == [] + assert get_fusion_model("anything") is None + assert is_fusion_model_name("anything") is False diff --git a/tests/providers/test_fusion_provider.py b/tests/providers/test_fusion_provider.py new file mode 100644 index 000000000..ca3d3b024 --- /dev/null +++ b/tests/providers/test_fusion_provider.py @@ -0,0 +1,649 @@ +"""FusionProvider — vision substitution, caching, and transparent delegation. + +The three invariants from the module docstring are what these lock down: + +1. no image block survives the rewrite (a leftover one is the 400 the + feature exists to prevent), +2. the caller's message list is never mutated, and +3. each distinct image is described exactly once. +""" + +from __future__ import annotations + +import copy + +import pytest + +from src.providers.fusion_models import FusionModel, ModelRef +from src.providers.fusion_provider import ( + FusionProvider, + clear_vision_cache, +) + + +@pytest.fixture(autouse=True) +def _clean_cache(): + # The description cache is process-wide (so it survives the provider + # rebuild a /model switch performs), which means it must be reset + # between tests or call-count assertions leak across them. + clear_vision_cache() + yield + clear_vision_cache() + + +class FakeResponse: + def __init__(self, content: str) -> None: + self.content = content + self.usage = {"input_tokens": 1, "output_tokens": 1} + + +class FakeVision: + """Records every call and returns a canned description.""" + + def __init__(self, text: str = "A red square beside a blue one.") -> None: + self.text = text + self.calls: list[dict] = [] + + def chat(self, messages, **kwargs): + self.calls.append({"messages": messages, "kwargs": kwargs}) + return FakeResponse(self.text) + + +class ExplodingVision: + def __init__(self, exc: Exception | None = None) -> None: + self.exc = exc or RuntimeError("vision is down") + self.calls = 0 + + def chat(self, messages, **kwargs): + self.calls += 1 + raise self.exc + + +class FakeInner: + """Stands in for the base provider; captures what reached the wire.""" + + model = "deepseek-v4-pro" + is_deepseek = True + base_url = "https://api.deepseek.com" + + def __init__(self) -> None: + self.seen: list | None = None + self.seen_kwargs: dict | None = None + + def chat(self, messages, tools=None, **kwargs): + self.seen, self.seen_kwargs = messages, kwargs + return FakeResponse("base answered") + + def chat_stream_response(self, messages, tools=None, **kwargs): + self.seen, self.seen_kwargs = messages, kwargs + return FakeResponse("base streamed") + + def chat_stream(self, messages, tools=None, **kwargs): + self.seen = messages + return iter(["chunk"]) + + async def chat_async(self, messages, tools=None, **kwargs): + self.seen, self.seen_kwargs = messages, kwargs + return FakeResponse("base async") + + def get_available_models(self): + return ["deepseek-v4-pro", "deepseek-v4-flash"] + + +def make_fusion(**kw) -> FusionModel: + defaults = dict( + name="deepseek-v4-pro-V", + base=ModelRef("deepseek", "deepseek-v4-pro"), + vision=ModelRef("openrouter", "google/gemini-2.5-flash"), + ) + defaults.update(kw) + return FusionModel(**defaults) + + +def image(data: str = "AAAA", media_type: str = "image/png") -> dict: + return { + "type": "image", + "source": {"type": "base64", "media_type": media_type, "data": data}, + } + + +def count_images(node) -> int: + """Every remaining image block, at any nesting depth.""" + if isinstance(node, dict): + n = 1 if node.get("type") == "image" else 0 + return n + sum(count_images(v) for v in node.values()) + if isinstance(node, list): + return sum(count_images(v) for v in node) + return 0 + + +def build(vision=None, **fusion_kw): + inner = FakeInner() + vision = vision if vision is not None else FakeVision() + provider = FusionProvider(inner, make_fusion(**fusion_kw), vision_provider=vision) + return provider, inner, vision + + +# ── invariant 1: no image survives ─────────────────────────────────────── + + +def test_image_in_user_message_is_replaced_with_description(): + provider, inner, vision = build() + provider.chat([{"role": "user", "content": [{"type": "text", "text": "what?"}, image()]}]) + + assert count_images(inner.seen) == 0 + replaced = inner.seen[0]["content"][1] + assert replaced["type"] == "text" + assert "A red square beside a blue one." in replaced["text"] + # The substitution announces itself, so the base model reads it as a + # description rather than as its own observation. + assert "openrouter:google/gemini-2.5-flash" in replaced["text"] + + +def test_image_nested_in_tool_result_is_replaced(): + # Where a Read-returned or Bash-captured image actually lives. + provider, inner, _ = build() + provider.chat([{ + "role": "user", + "content": [{ + "type": "tool_result", + "tool_use_id": "t1", + "content": [image(), {"type": "text", "text": "read ok"}], + }], + }]) + + assert count_images(inner.seen) == 0 + block = inner.seen[0]["content"][0] + assert block["type"] == "tool_result" + assert block["tool_use_id"] == "t1" # siblings preserved + assert block["content"][0]["type"] == "text" + assert block["content"][1] == {"type": "text", "text": "read ok"} + + +def test_vision_failure_still_removes_the_image(): + # The whole point: a failed vision call must NOT leave an image block, + # or the base provider 400s and the turn dies. + provider, inner, vision = build(vision=ExplodingVision()) + provider.chat([{"role": "user", "content": [image()]}]) + + assert count_images(inner.seen) == 0 + text = inner.seen[0]["content"][0]["text"] + assert "could not be described" in text + assert "vision is down" in text + + +def test_empty_vision_response_is_treated_as_failure(): + # Observed live from openrouter:z-ai/glm-4.5v — HTTP 200 with empty + # content. An empty text block would read to the base model as "the + # image was blank", which is worse than an explicit note. + provider, inner, _ = build(vision=FakeVision(text=" ")) + provider.chat([{"role": "user", "content": [image()]}]) + + assert count_images(inner.seen) == 0 + assert "could not be described" in inner.seen[0]["content"][0]["text"] + + +def test_image_block_without_payload_is_replaced(): + provider, inner, vision = build() + provider.chat([{"role": "user", "content": [{"type": "image", "source": {}}]}]) + + assert count_images(inner.seen) == 0 + assert "no data" in inner.seen[0]["content"][0]["text"] + assert vision.calls == [] # nothing to send + + +def test_over_cap_images_are_replaced_with_a_note_not_left_in_place(): + provider, inner, vision = build(max_images=2) + provider.chat([{ + "role": "user", + "content": [image("A"), image("B"), image("C"), image("D")], + }]) + + assert count_images(inner.seen) == 0 # invariant 1 holds at the cap + assert len(vision.calls) == 2 # cap respected + texts = [b["text"] for b in inner.seen[0]["content"]] + assert "reached its image limit" in texts[2] + assert "reached its image limit" in texts[3] + + +# ── invariant 2: no mutation of the caller's messages ──────────────────── + + +def test_caller_messages_are_not_mutated(): + provider, inner, _ = build() + messages = [ + {"role": "user", "content": [{"type": "text", "text": "hi"}, image()]}, + {"role": "assistant", "content": "sure"}, + ] + snapshot = copy.deepcopy(messages) + provider.chat(messages) + + # The session owns the conversation: rewriting in place would destroy + # the originals and break a later switch to a vision-capable model. + assert messages == snapshot + assert count_images(messages) == 1 + + +def test_untouched_messages_pass_through_by_reference(): + # Copy-on-write: only rewritten messages are rebuilt. + provider, inner, _ = build() + plain = {"role": "assistant", "content": "no images here"} + provider.chat([{"role": "user", "content": [image()]}, plain]) + assert inner.seen[1] is plain + + +def test_no_images_returns_the_original_list_object(): + provider, inner, vision = build() + messages = [{"role": "user", "content": [{"type": "text", "text": "hi"}]}] + provider.chat(messages) + # A fusion session pays nothing on turns without an image. + assert inner.seen is messages + assert vision.calls == [] + + +# ── invariant 3: each distinct image described once ────────────────────── + + +def test_identical_images_share_one_vision_call(): + provider, inner, vision = build() + same = image("SAME") + provider.chat([ + {"role": "user", "content": [same]}, + {"role": "user", "content": [same]}, + {"role": "user", "content": [same]}, + ]) + assert len(vision.calls) == 1 + assert count_images(inner.seen) == 0 + + +def test_cache_spans_calls_so_replayed_history_is_not_re_described(): + # The load-bearing case: history is re-sent every turn, so without this + # an N-turn conversation pays N times for one screenshot. + provider, inner, vision = build() + history = [{"role": "user", "content": [image("SCREENSHOT")]}] + for _ in range(5): + provider.chat(list(history)) + assert len(vision.calls) == 1 + + +def test_distinct_images_get_distinct_calls(): + provider, inner, vision = build() + provider.chat([{"role": "user", "content": [image("ONE"), image("TWO")]}]) + assert len(vision.calls) == 2 + + +def test_cache_is_keyed_by_vision_model(): + # Re-pointing a fusion model at a different vision model must not serve + # descriptions written by the old one. + v1 = FakeVision(text="described by one") + p1, inner1, _ = build(vision=v1) + p1.chat([{"role": "user", "content": [image("X")]}]) + + v2 = FakeVision(text="described by two") + p2 = FusionProvider( + FakeInner(), + make_fusion(vision=ModelRef("openrouter", "anthropic/claude-sonnet-4.5")), + vision_provider=v2, + ) + p2.chat([{"role": "user", "content": [image("X")]}]) + assert len(v2.calls) == 1 + + +def test_cached_hits_do_not_consume_the_cap(): + # The cap bounds NETWORK fan-out; charging cached descriptions would + # make the same conversation degrade as it grows. + provider, inner, vision = build(max_images=1) + same = image("SAME") + provider.chat([{"role": "user", "content": [same, same, same, same]}]) + assert len(vision.calls) == 1 + assert count_images(inner.seen) == 0 + # All four became real descriptions, none the over-cap note. + assert all("image limit" not in b["text"] for b in inner.seen[0]["content"]) + + +# ── delegation ─────────────────────────────────────────────────────────── + + +def test_model_reports_the_base_model_not_the_fusion_name(): + # Everything downstream keys off .model — context window, cost, + # capability probes, the wire's own model field — and the base model is + # the right answer for all of them. + provider, _, _ = build() + assert provider.model == "deepseek-v4-pro" + assert provider.fusion_name == "deepseek-v4-pro-V" + + +def test_model_assignment_reaches_the_inner_provider(): + # Without an explicit property, ``provider.model = x`` would shadow the + # attribute on the wrapper and the wire would keep the old model. + provider, inner, _ = build() + provider.model = "deepseek-v4-flash" + assert inner.model == "deepseek-v4-flash" + assert provider.model == "deepseek-v4-flash" + + +def test_arbitrary_attributes_delegate(): + provider, inner, _ = build() + assert provider.is_deepseek is True # gates DeepSeek prefix caching + assert provider.base_url == "https://api.deepseek.com" + assert provider.get_available_models() == ["deepseek-v4-pro", "deepseek-v4-flash"] + assert provider.inner is inner + + +def test_deepcopy_does_not_recurse_forever(): + # copy.deepcopy builds an instance WITHOUT __init__ then probes for + # __deepcopy__/__setstate__; without the _inner guard in __getattr__ + # that recurses to RecursionError — and two live call sites deepcopy + # the session provider while swallowing Exception, so it would be + # silent. + provider, _, _ = build() + assert copy.deepcopy(provider) is not None + assert copy.copy(provider) is not None + + +@pytest.mark.parametrize("copier", [copy.copy, copy.deepcopy]) +def test_copies_get_an_isolated_model(copier): + # THE reason both live sites copy at all. agent/run_agent.py does + # turn_provider = copy.copy(provider); turn_provider.model = resolved + # under a comment reading "NEVER mutate the shared session provider … + # N parallel subagents share params.provider"; yolo_classifier.py does + # the same. Because ``model`` is a property whose setter writes THROUGH + # to the inner provider, a copy that SHARES the inner would repoint the + # main loop's base model — leaving the session firing a foreign model id + # at the base provider. Both sites swallow Exception, so it was silent. + provider, inner, _ = build() + clone = copier(provider) + clone.model = "SUBAGENT-OVERRIDE" + assert clone.model == "SUBAGENT-OVERRIDE" + assert provider.model == "deepseek-v4-pro" + assert inner.model == "deepseek-v4-pro" + + +def test_copies_still_substitute(): + # A subagent's copied provider must keep the fusion behaviour. + provider, _, vision = build() + clone = copy.copy(provider) + clone.chat([{"role": "user", "content": [image()]}]) + assert count_images(clone.inner.seen) == 0 + assert len(vision.calls) == 1 + + +def test_missing_delegate_raises_attribute_error_not_recursion(): + provider, _, _ = build() + with pytest.raises(AttributeError): + provider.definitely_not_a_real_attribute + + +@pytest.mark.parametrize("method", ["chat", "chat_stream_response"]) +def test_sync_entry_points_substitute(method): + provider, inner, vision = build() + getattr(provider, method)([{"role": "user", "content": [image()]}]) + assert count_images(inner.seen) == 0 + assert len(vision.calls) == 1 + + +def test_chat_stream_substitutes(): + provider, inner, vision = build() + list(provider.chat_stream([{"role": "user", "content": [image()]}])) + assert count_images(inner.seen) == 0 + + +@pytest.mark.asyncio +async def test_chat_async_substitutes(): + provider, inner, vision = build() + await provider.chat_async([{"role": "user", "content": [image()]}]) + assert count_images(inner.seen) == 0 + assert len(vision.calls) == 1 + + +def test_messages_passed_by_keyword_are_substituted(): + provider, inner, _ = build() + provider.chat(messages=[{"role": "user", "content": [image()]}]) + assert count_images(inner.seen) == 0 + + +def test_other_kwargs_are_forwarded_untouched(): + provider, inner, _ = build() + provider.chat([{"role": "user", "content": "hi"}], max_tokens=99, system="sys") + assert inner.seen_kwargs["max_tokens"] == 99 + assert inner.seen_kwargs["system"] == "sys" + + +# ── the vision sub-call's own shape ────────────────────────────────────── + + +def test_vision_call_sends_the_image_in_anthropic_shape(): + # Handing the image over in Anthropic shape lets each provider's own + # _prepare_messages translate it, so one call shape covers every + # vision provider (OpenAI-compatible and Anthropic-wire alike). + provider, _, vision = build() + provider.chat([{"role": "user", "content": [image("PAYLOAD")]}]) + + sent = vision.calls[0]["messages"][0]["content"] + assert sent[0]["type"] == "text" + assert sent[1]["type"] == "image" + assert sent[1]["source"]["data"] == "PAYLOAD" + assert vision.calls[0]["kwargs"]["model"] == "google/gemini-2.5-flash" + + +def test_timeout_is_phase_split_not_a_bare_float(): + # httpx expands a bare float across all four phases, which would lift + # ``connect`` from 15s to the whole vision window — the hazard + # anthropic_provider.chat documents ("never a bare float"). + provider, _, vision = build(timeout_ms=45_000) + provider.chat([{"role": "user", "content": [image()]}]) + timeout = vision.calls[0]["kwargs"]["timeout"] + assert timeout.read == pytest.approx(45.0) + assert timeout.connect == pytest.approx(15.0) + + +def test_custom_prompt_is_appended_to_the_default(): + provider, _, vision = build(prompt="Transcribe tables as CSV.") + provider.chat([{"role": "user", "content": [image()]}]) + prompt = vision.calls[0]["messages"][0]["content"][0]["text"] + assert "Transcribe tables as CSV." in prompt + assert "cannot see it" in prompt # the default instruction survives + + +def test_long_description_is_truncated(): + provider, inner, _ = build(vision=FakeVision(text="x" * 10_000)) + provider.chat([{"role": "user", "content": [image()]}]) + text = inner.seen[0]["content"][0]["text"] + assert "[description truncated]" in text + assert len(text) < 6_000 + + +def test_typed_non_dict_messages_pass_through(): + # ChatMessage carries ``content: str`` and cannot hold an image block. + from src.providers.base import ChatMessage + + provider, inner, _ = build() + msg = ChatMessage(role="user", content="plain text") + provider.chat([msg]) + assert inner.seen[0] is msg + + +@pytest.mark.parametrize("messages", [[], None, "not a list"]) +def test_degenerate_message_inputs_pass_through(messages): + provider, inner, vision = build() + provider.chat(messages) + assert inner.seen == messages + assert vision.calls == [] + + +# ── cache scope: prompt is description-determining ──────────────────────── + + +def test_cache_is_keyed_by_prompt_too(): + # Two fusion models sharing a vision model but differing in ``prompt`` + # must not serve each other's descriptions — otherwise FusionModel.prompt + # is silently inert whenever any record already described that image. + v1 = FakeVision(text="generic description") + p1, _, _ = build(vision=v1) + p1.chat([{"role": "user", "content": [image("SHARED")]}]) + + v2 = FakeVision(text="CSV transcription") + p2 = FusionProvider(FakeInner(), make_fusion(prompt="Transcribe tables as CSV."), + vision_provider=v2) + p2.chat([{"role": "user", "content": [image("SHARED")]}]) + assert len(v2.calls) == 1, "prompt change must not reuse the old description" + assert "CSV transcription" in p2.inner.seen[0]["content"][0]["text"] + + +# ── failure caching + abort + wall clock (M2) ──────────────────────────── + + +def test_cached_failure_expires_so_a_recovered_provider_is_retried(): + # A failure is usually transient (rate limit, timeout, blip). Caching it + # for the process lifetime would permanently degrade that image for the + # session, and the user's obvious next move — ask again — would keep + # returning the stale note. + import src.providers.fusion_provider as mod + + provider, inner, vision = build(vision=ExplodingVision()) + provider.chat([{"role": "user", "content": [image("X")]}]) + assert vision.calls == 1 + + saved = mod._FAILURE_TTL_SECONDS + try: + mod._FAILURE_TTL_SECONDS = -1.0 # every new failure is already stale + provider.chat([{"role": "user", "content": [image("Y")]}]) + provider.chat([{"role": "user", "content": [image("Y")]}]) + finally: + mod._FAILURE_TTL_SECONDS = saved + assert vision.calls == 3, "an expired failure must be retried" + + +def test_failures_are_cached_so_an_outage_is_not_retried_every_turn(): + # History is replayed each turn. Uncached, a down vision provider costs + # (images × timeout × SDK retries) on EVERY request, forever — minutes + # added per turn, none of it interruptible. + provider, inner, vision = build(vision=ExplodingVision()) + history = [{"role": "user", "content": [image("A"), image("B")]}] + for _ in range(5): + provider.chat(list(history)) + assert vision.calls == 2, f"expected one attempt per image, got {vision.calls}" + # Still no image survives, and the note explains why. + assert count_images(inner.seen) == 0 + assert "could not be described" in inner.seen[0]["content"][0]["text"] + + +class _FiredSignal: + def aborted(self): + return True + + +class _LiveSignal: + aborted = False + + +def test_abort_signal_stops_further_vision_calls(): + # _fuse runs BEFORE delegation, so without this an ESC during a fused + # turn would still work through the whole history first. + provider, inner, vision = build() + provider.chat_stream_response( + [{"role": "user", "content": [image("A"), image("B")]}], + abort_signal=_FiredSignal(), + ) + assert vision.calls == [] + assert count_images(inner.seen) == 0 # invariant 1 still holds + assert "interrupted" in inner.seen[0]["content"][0]["text"] + + +def test_already_described_images_still_render_after_abort(): + # The abort check gates only NEW vision calls. A cached description is + # free, so history the session already paid for keeps rendering — only + # the un-described images degrade to a note. + provider, inner, vision = build() + provider.chat([{"role": "user", "content": [image("CACHED")]}]) + assert len(vision.calls) == 1 + + provider.chat_stream_response( + [{"role": "user", "content": [image("CACHED"), image("NEW")]}], + abort_signal=_FiredSignal(), + ) + blocks = inner.seen[0]["content"] + assert "A red square beside a blue one." in blocks[0]["text"] + assert "interrupted" in blocks[1]["text"] + assert len(vision.calls) == 1 # no new call despite the new image + assert count_images(inner.seen) == 0 + + +def test_unfired_abort_signal_does_not_block_substitution(): + provider, inner, vision = build() + provider.chat_stream_response( + [{"role": "user", "content": [image()]}], abort_signal=_LiveSignal() + ) + assert len(vision.calls) == 1 + + +def test_abort_signal_is_still_forwarded_to_the_inner_provider(): + signal = _LiveSignal() + provider, inner, _ = build() + provider.chat_stream_response([{"role": "user", "content": "hi"}], abort_signal=signal) + assert inner.seen_kwargs["abort_signal"] is signal + + +def test_request_wall_clock_bound_stops_describing(): + import src.providers.fusion_provider as mod + + provider, inner, vision = build(max_images=8) + # Deadline already passed: a slow-but-not-failing vision provider must + # not multiply timeout_ms by the image count on the user's turn. + monkey = mod._MAX_REQUEST_VISION_SECONDS + try: + mod._MAX_REQUEST_VISION_SECONDS = -1.0 + provider.chat([{"role": "user", "content": [image("A"), image("B")]}]) + finally: + mod._MAX_REQUEST_VISION_SECONDS = monkey + assert vision.calls == [] + assert count_images(inner.seen) == 0 + assert "time limit" in inner.seen[0]["content"][0]["text"] + + +# ── container-shape hardening ───────────────────────────────────────────── + + +def test_bare_dict_content_is_walked(): + # Not produced in-repo (every producer emits a list), but a resumed + # session or SDK caller could; unwalked it would put an image on the + # wire and break invariant 1. + provider, inner, _ = build() + provider.chat([{"role": "user", "content": image()}]) + assert count_images(inner.seen) == 0 + + +def test_bare_dict_tool_result_content_is_walked(): + provider, inner, _ = build() + provider.chat([{ + "role": "user", + "content": [{"type": "tool_result", "tool_use_id": "t", "content": image()}], + }]) + assert count_images(inner.seen) == 0 + + +# ── concurrency ────────────────────────────────────────────────────────── + + +def test_concurrent_fuse_from_multiple_threads(): + # Subagents share the session provider, and chat_async offloads to a + # thread, so _fuse can be entered concurrently. + import threading + + provider, _, vision = build() + errors: list[BaseException] = [] + + def worker(n: int): + try: + provider.chat([{"role": "user", "content": [image(f"IMG{n % 3}")]}]) + except BaseException as exc: # noqa: BLE001 + errors.append(exc) + + threads = [threading.Thread(target=worker, args=(i,)) for i in range(12)] + for t in threads: + t.start() + for t in threads: + t.join() + assert errors == [] + # 3 distinct images; the cache may race so allow one extra call each. + assert 3 <= len(vision.calls) <= 12 diff --git a/tests/server/test_fusion_control.py b/tests/server/test_fusion_control.py new file mode 100644 index 000000000..b2fd4fb2d --- /dev/null +++ b/tests/server/test_fusion_control.py @@ -0,0 +1,401 @@ +"""Agent-server fusion wiring. + +Two surfaces: + +* the ``fusion`` control, which bridges to ``fusion_command_call`` and + replies ``{ok, text}`` (the /advisor contract), and +* ``set_model``, which must recognise a fusion model by name and install a + ``FusionProvider`` instead of poking ``provider.model`` — plus leave one + again for a plain model. + +Also covers the picker listing (``_available_models``) and the +``single_session`` gate. +""" + +from __future__ import annotations + +import asyncio +import os +import shutil +import tempfile +import unittest +import unittest.mock +from pathlib import Path +from unittest.mock import MagicMock + +from src.server.agent_server import AgentServerConfig, _AgentSession + + +class _IsolatedConfig: + """Redirect config persistence to a tmp dir (the test_advisor_control idiom). + + Seeds a ``providers`` map so fusion selector validation has real keys, + and clears the process-wide vision-description cache so call counts + cannot leak between tests. + """ + + def __enter__(self): + import src.config as cfg_mod + + self._cfg_mod = cfg_mod + self._tmp = Path(tempfile.mkdtemp(prefix="fusion_ctl_")) + self._saved = ( + cfg_mod.GLOBAL_CONFIG_FILE, + cfg_mod.HISTORY_FILE, + cfg_mod.GLOBAL_CONFIG_DIR, + ) + cfg_mod.GLOBAL_CONFIG_FILE = self._tmp / ".clawcodex" / "config.json" + cfg_mod.HISTORY_FILE = self._tmp / ".clawcodex" / "history.jsonl" + cfg_mod.GLOBAL_CONFIG_DIR = self._tmp / ".clawcodex" + cfg_mod._default_manager = None + + from src.settings.settings import invalidate_settings_cache + + invalidate_settings_cache() + + mgr = cfg_mod._get_default_manager() + cfg = mgr.load_global() + cfg["providers"] = { + "deepseek": { + "api_key": "k", + "base_url": "https://api.deepseek.com", + "default_model": "deepseek-v4-pro", + }, + "openrouter": { + "api_key": "k", + "base_url": "https://openrouter.ai/api/v1", + "default_model": "deepseek/deepseek-v4-pro", + }, + } + mgr.save_global(cfg) + + from src.providers.fusion_provider import clear_vision_cache + + clear_vision_cache() + return self + + def __exit__(self, *a): + cfg_mod = self._cfg_mod + ( + cfg_mod.GLOBAL_CONFIG_FILE, + cfg_mod.HISTORY_FILE, + cfg_mod.GLOBAL_CONFIG_DIR, + ) = self._saved + cfg_mod._default_manager = None + from src.settings.settings import invalidate_settings_cache + + invalidate_settings_cache() + shutil.rmtree(self._tmp, ignore_errors=True) + + +BASE = "deepseek:deepseek-v4-pro" +VISION = "openrouter:google/gemini-2.5-flash" + + +def _make_session(single_session: bool = True) -> tuple[_AgentSession, list[dict]]: + emitted: list[dict] = [] + sess = _AgentSession( + session_id="fusion-sess", + cwd="/tmp", + config=AgentServerConfig(single_session=single_session), + loop=MagicMock(), + out_queue=MagicMock(), + ) + sess._emit = lambda env: emitted.append(env) # type: ignore[method-assign] + provider = MagicMock() + provider.model = "deepseek-v4-pro" + provider.get_available_models = lambda: ["deepseek-v4-pro", "deepseek-v4-flash"] + sess.provider = provider + sess.provider_name = "deepseek" + return sess, emitted + + +def _control(sess: _AgentSession, subtype: str, **params) -> None: + asyncio.run( + sess._handle_control_request( + { + "type": "control_request", + "request_id": "req-1", + "request": {"subtype": subtype, **params}, + } + ) + ) + + +def _last_reply(emitted: list[dict]) -> dict: + for env in reversed(emitted): + if env.get("type") == "control_response": + return env["response"]["response"] + raise AssertionError(f"no control_response in {emitted!r}") + + +class TestFusionControl(unittest.TestCase): + def test_bare_fusion_lists_nothing_with_guidance(self) -> None: + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg="") + reply = _last_reply(emitted) + self.assertTrue(reply["ok"]) + self.assertIn("No fusion models saved", reply["text"]) + + def test_create_then_list_round_trip(self) -> None: + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create dsv {BASE} {VISION}") + reply = _last_reply(emitted) + self.assertTrue(reply["ok"]) + self.assertIn("Created fusion model 'dsv'", reply["text"]) + + _control(sess, "fusion", arg="list") + text = _last_reply(emitted)["text"] + self.assertIn("dsv", text) + self.assertIn(BASE, text) + self.assertIn(VISION, text) + + def test_create_without_a_name_derives_one(self) -> None: + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create {BASE} {VISION}") + reply = _last_reply(emitted) + # CCR's capability-obvious naming: -V. + self.assertIn("deepseek-v4-pro-V", reply["text"]) + + def test_command_level_rejection_rides_text_with_ok_true(self) -> None: + # The /advisor contract: transport ok=True whenever the command ran; + # only exceptions and the transport gate produce ok=False. + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg="create x nocolon openrouter:v") + reply = _last_reply(emitted) + self.assertTrue(reply["ok"]) + self.assertIn(":", reply["text"]) + + def test_delete_and_disable(self) -> None: + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create dsv {BASE} {VISION}") + _control(sess, "fusion", arg="disable dsv") + self.assertIn("disabled", _last_reply(emitted)["text"]) + _control(sess, "fusion", arg="list") + self.assertIn("(disabled)", _last_reply(emitted)["text"]) + _control(sess, "fusion", arg="delete dsv") + self.assertIn("Deleted", _last_reply(emitted)["text"]) + _control(sess, "fusion", arg="list") + self.assertIn("No fusion models saved", _last_reply(emitted)["text"]) + + def test_multi_session_transport_is_refused(self) -> None: + # Fusion models are persisted user-level config; on the shared WS + # transport one client's delete would hit every session on the host. + with _IsolatedConfig(): + sess, emitted = _make_session(single_session=False) + _control(sess, "fusion", arg="list") + reply = _last_reply(emitted) + self.assertFalse(reply["ok"]) + self.assertIn("single-session", reply["error"]) + + +class TestFusionModelSelection(unittest.TestCase): + def test_available_models_lists_fusion_models_first(self) -> None: + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create dsv {BASE} {VISION}") + models = sess._available_models() + # CCR: a saved fusion model "appears in routing … like a normal + # model" — the picker is this client's equivalent surface. + self.assertEqual(models[0], "dsv") + self.assertIn("deepseek-v4-pro", models) + + def test_disabled_fusion_models_are_not_listed(self) -> None: + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create dsv {BASE} {VISION}") + _control(sess, "fusion", arg="disable dsv") + self.assertNotIn("dsv", sess._available_models()) + + def test_set_model_installs_a_fusion_provider(self) -> None: + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create dsv {BASE} {VISION}") + _control(sess, "set_model", model="dsv") + reply = _last_reply(emitted) + self.assertTrue(reply["ok"], reply) + # The fusion NAME is echoed as the switch result (what the user + # picked); fusion_base carries the id actually on the wire. + self.assertEqual(reply["model"], "dsv") + self.assertEqual(reply["fusion"], "dsv") + self.assertEqual(reply["fusion_base"], BASE) + self.assertEqual(reply["fusion_vision"], VISION) + + from src.providers.fusion_provider import FusionProvider + + self.assertIsInstance(sess.provider, FusionProvider) + # .model stays the BASE id so cost/context-window lookups work. + self.assertEqual(sess.provider.model, "deepseek-v4-pro") + self.assertEqual(sess.provider.fusion_name, "dsv") + + def test_get_settings_reports_the_active_fusion_model(self) -> None: + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create dsv {BASE} {VISION}") + _control(sess, "set_model", model="dsv") + _control(sess, "get_settings") + reply = _last_reply(emitted) + self.assertEqual(reply["fusion"], "dsv") + self.assertEqual(reply["model"], "deepseek-v4-pro") + + def test_get_settings_reports_empty_fusion_when_not_fused(self) -> None: + with _IsolatedConfig(): + sess, emitted = _make_session() + + # A plain object, NOT the MagicMock _make_session installs: a + # mock answers every attribute, so it would report a truthy + # fusion_name and hide the real behaviour. It also stands in for + # the wire-safety case — whatever a duck-typed provider answers, + # this field must serialize as a string. + class PlainProvider: + model = "deepseek-v4-pro" + + def get_available_models(self): + return ["deepseek-v4-pro"] + + sess.provider = PlainProvider() + _control(sess, "get_settings") + self.assertEqual(_last_reply(emitted)["fusion"], "") + + def test_get_settings_fusion_field_is_always_a_string(self) -> None: + # A provider answering a non-string for fusion_name must not put an + # unserializable object on the NDJSON control channel. + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "get_settings") + self.assertIsInstance(_last_reply(emitted)["fusion"], str) + + def test_switching_away_from_fusion_drops_the_wrapper(self) -> None: + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create dsv {BASE} {VISION}") + _control(sess, "set_model", model="dsv") + from src.providers.fusion_provider import FusionProvider + + self.assertIsInstance(sess.provider, FusionProvider) + + _control(sess, "set_model", model="deepseek-v4-flash") + reply = _last_reply(emitted) + self.assertTrue(reply["ok"], reply) + # Unwrapped, so isinstance(provider, AnthropicProvider) checks + # downstream see the real provider and the session stops paying + # for substitution it no longer needs. + self.assertNotIsInstance(sess.provider, FusionProvider) + self.assertEqual(sess.provider.model, "deepseek-v4-flash") + + def test_disabled_fusion_model_cannot_be_selected(self) -> None: + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create dsv {BASE} {VISION}") + _control(sess, "fusion", arg="disable dsv") + _control(sess, "set_model", model="dsv") + reply = _last_reply(emitted) + self.assertFalse(reply["ok"]) + self.assertIn("disabled", reply["error"]) + self.assertIn("/fusion enable dsv", reply["error"]) + + def test_mid_turn_switch_away_from_fusion_is_refused(self) -> None: + # Un-fusing replaces provider AND tool_registry; the turn runs on the + # worker thread while this handler runs on the main loop, so a + # mid-turn swap pulls the registry out from under live tool dispatch. + # /provider and the fusion branch both refuse; this must too. + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create dsv {BASE} {VISION}") + _control(sess, "set_model", model="dsv") + from src.providers.fusion_provider import FusionProvider + + self.assertIsInstance(sess.provider, FusionProvider) + + sess._current_abort = object() # simulate an active turn + try: + _control(sess, "set_model", model="deepseek-v4-flash") + reply = _last_reply(emitted) + self.assertFalse(reply["ok"], reply) + self.assertIn("active turn", reply["error"]) + # Still fused: nothing was swapped. + self.assertIsInstance(sess.provider, FusionProvider) + finally: + sess._current_abort = None + + def test_install_provider_failure_still_replies(self) -> None: + # _install_provider rebuilds the registry and re-registers MCP tools; + # an escape would reply NOTHING — hanging the client's controlQuery on + # stdio and dropping the whole connection on the WS transport. + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create dsv {BASE} {VISION}") + _control(sess, "set_model", model="dsv") + + def boom(*a, **k): + raise RuntimeError("registry build blew up") + + sess._install_provider = boom # type: ignore[method-assign] + _control(sess, "set_model", model="deepseek-v4-flash") + reply = _last_reply(emitted) + self.assertFalse(reply["ok"]) + self.assertIn("registry build blew up", reply["error"]) + + def test_init_envelope_carries_the_fusion_name(self) -> None: + # Carried on init, not just the set_model reply, so a session started + # with --model shows it from the first frame. + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create dsv {BASE} {VISION}") + _control(sess, "set_model", model="dsv") + emitted.clear() + sess.emit_init() + init = next(e for e in emitted if e.get("subtype") == "init") + self.assertEqual(init["fusion"], "dsv") + self.assertEqual(init["model"], "deepseek-v4-pro") + + def test_plain_model_switch_is_unaffected(self) -> None: + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "set_model", model="deepseek-v4-flash") + reply = _last_reply(emitted) + self.assertTrue(reply["ok"]) + self.assertNotIn("fusion", reply) + + def test_missing_vision_credentials_refuses_the_switch(self) -> None: + # Without this check the switch succeeds and every image silently + # degrades to a "vision model failed" note, mid-task. + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create dsv {BASE} {VISION}") + + import src.config as cfg_mod + + mgr = cfg_mod._get_default_manager() + cfg = mgr.load_global() + cfg["providers"]["openrouter"]["api_key"] = "" + mgr.save_global(cfg) + + # Blanking the CONFIG key is not enough: resolve_api_key falls + # back to the provider's env vars via get_secret, so on a + # developer machine that exports OPENROUTER_API_KEY (exactly the + # machine this feature was built on) the switch would succeed + # and this test would fail for an environmental reason. + from src.providers import provider_env_vars + + with unittest.mock.patch.dict( + os.environ, + {name: "" for name in provider_env_vars("openrouter")}, + clear=False, + ): + for name in provider_env_vars("openrouter"): + os.environ.pop(name, None) + _control(sess, "set_model", model="dsv") + + reply = _last_reply(emitted) + self.assertFalse(reply["ok"]) + self.assertIn("vision provider", reply["error"]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_fusion_command.py b/tests/test_fusion_command.py new file mode 100644 index 000000000..41c6a341a --- /dev/null +++ b/tests/test_fusion_command.py @@ -0,0 +1,186 @@ +"""/fusion command grammar. + +The command owns parsing and message text; persistence is covered by +``tests/providers/test_fusion_models.py`` and the control bridge by +``tests/server/test_fusion_control.py``. Global config is isolated per test +by the autouse fixture in ``tests/conftest.py``. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from src.command_system.fusion_command import fusion_command_call +from src.command_system.types import CommandContext + +BASE = "deepseek:deepseek-v4-pro" +VISION = "openrouter:google/gemini-2.5-flash" + + +@pytest.fixture(autouse=True) +def _configured_providers(monkeypatch): + import src.providers.fusion_models as mod + + monkeypatch.setattr( + mod, "_configured_providers", lambda: ["deepseek", "openrouter", "zai"] + ) + + +@pytest.fixture +def ctx(tmp_path): + return CommandContext( + workspace_root=tmp_path, + cwd=tmp_path, + conversation=None, + cost_tracker=None, + history=None, + app_state_store=None, + provider=None, + ) + + +def run(args: str, ctx) -> str: + return fusion_command_call(args, ctx).value + + +# ── list ───────────────────────────────────────────────────────────────── + + +def test_bare_lists_and_teaches_when_empty(ctx): + out = run("", ctx) + assert "No fusion models saved" in out + # An empty list is the first thing a new user sees, so it explains what + # the feature is for rather than just saying "none". + assert "deepseek-v4-pro" in out + assert "/fusion create" in out + + +@pytest.mark.parametrize("alias", ["list", "ls", "status"]) +def test_list_aliases(alias, ctx): + run(f"create dsv {BASE} {VISION}", ctx) + assert "dsv" in run(alias, ctx) + + +def test_list_shows_both_halves_and_the_select_hint(ctx): + run(f"create dsv {BASE} {VISION}", ctx) + out = run("list", ctx) + assert BASE in out + assert VISION in out + assert "/model" in out + + +# ── create ─────────────────────────────────────────────────────────────── + + +def test_create_reports_both_halves(ctx): + out = run(f"create dsv {BASE} {VISION}", ctx) + assert "Created fusion model 'dsv'" in out + assert BASE in out + assert VISION in out + assert "/model dsv" in out + + +@pytest.mark.parametrize("alias", ["create", "add", "new"]) +def test_create_aliases(alias, ctx): + assert "Created" in run(f"{alias} n-{alias} {BASE} {VISION}", ctx) + + +def test_create_without_name_derives_one(ctx): + out = run(f"create {BASE} {VISION}", ctx) + assert "deepseek-v4-pro-V" in out + + +def test_create_with_extra_whitespace(ctx): + # Pasting the multi-line example collapses to arbitrary whitespace. + assert "Created" in run(f"create dsv {BASE} {VISION}", ctx) + + +def test_create_wrong_arity_shows_usage(ctx): + out = run("create only-one-arg", ctx) + assert "Usage: /fusion create" in out + + +def test_create_bad_selector_names_the_half(ctx): + assert "base" in run(f"create x nocolon {VISION}", ctx) + assert "vision" in run(f"create x {BASE} nocolon", ctx) + + +def test_create_surfaces_validation_errors_verbatim(ctx): + run(f"create dsv {BASE} {VISION}", ctx) + assert "already exists" in run(f"create dsv {BASE} {VISION}", ctx) + + +def test_create_rejects_text_only_vision_model(ctx): + # The glm-5.2 trap, surfaced at the command layer. + out = run(f"create x {BASE} zai:glm-5.2", ctx) + assert "does not support image input" in out + assert "glm-4.5v" in out # points at the models that do + + +# ── delete / enable / disable ──────────────────────────────────────────── + + +@pytest.mark.parametrize("alias", ["delete", "remove", "rm", "del"]) +def test_delete_aliases(alias, ctx): + run(f"create dsv {BASE} {VISION}", ctx) + assert "Deleted" in run(f"{alias} dsv", ctx) + + +def test_delete_unknown_lists_known(ctx): + run(f"create dsv {BASE} {VISION}", ctx) + out = run("delete nope", ctx) + assert "No fusion model named 'nope'" in out + assert "Known: dsv" in out + + +def test_delete_wrong_arity(ctx): + assert "Usage: /fusion delete " in run("delete", ctx) + + +def test_disable_then_enable(ctx): + run(f"create dsv {BASE} {VISION}", ctx) + out = run("disable dsv", ctx) + assert "disabled" in out + assert "re-enabled" in out # says the config is kept + assert "enabled" in run("enable dsv", ctx) + + +def test_enable_unknown(ctx): + assert "No fusion model named" in run("enable nope", ctx) + + +def test_enable_wrong_arity_names_the_verb(ctx): + assert "Usage: /fusion enable " in run("enable", ctx) + assert "Usage: /fusion disable " in run("disable", ctx) + + +# ── help / unknown ─────────────────────────────────────────────────────── + + +@pytest.mark.parametrize("arg", ["help", "-h", "--help"]) +def test_help(arg, ctx): + out = run(arg, ctx) + assert "Usage: /fusion" in out + assert "provider>: ({ proc: null as null | EventEmitter, spawnCalls: [] as unknown[][] })) + +vi.mock('node:child_process', () => ({ + spawn: (...args: unknown[]) => { + harness.spawnCalls.push(args) + + return harness.proc + } +})) + +import { GatewayClient } from '../gatewayClient.js' + +class FakeProc extends EventEmitter { + kill = vi.fn() + stderr = new PassThrough() + stdin = new PassThrough() + stdout = new PassThrough() + + line(obj: unknown): void { + this.stdout.write(JSON.stringify(obj) + '\n') + } +} + +const INIT_FUSED = { + cwd: '/ws', + // A fused session reports the BASE model here… + fusion: 'deepseek-v4-pro-V', // …and the fusion model's name separately. + model: 'deepseek-v4-pro', + protocol_version: '0.1.0', + session_id: 's1', + subtype: 'init', + tools: [{ name: 'Read' }], + type: 'system' +} + +describe('fusion models', () => { + const prevWs = process.env.CLAWCODEX_WORKSPACE + let events: any[] + let gw: GatewayClient + let proc: FakeProc + let seen: any[] + + beforeEach(() => { + process.env.CLAWCODEX_WORKSPACE = '/ws' + proc = new FakeProc() + harness.proc = proc + harness.spawnCalls = [] + events = [] + seen = [] + gw = new GatewayClient() + gw.on('event', (e: any) => events.push(e)) + gw.start() + gw.drain() + }) + + afterEach(() => { + gw.kill() + if (prevWs === undefined) {delete process.env.CLAWCODEX_WORKSPACE} + else {process.env.CLAWCODEX_WORKSPACE = prevWs} + }) + + const last = (t: string) => [...events].reverse().find(e => e.type === t) + + const stdinFrames = (): any[] => { + const raw = (proc.stdin as any).read()?.toString() ?? '' + + return raw.split('\n').filter(Boolean).map((l: string) => JSON.parse(l)) + } + + const replyToControl = async (subtype: string, response: unknown) => { + let req: any + + await vi.waitFor(() => { + seen.push(...stdinFrames()) + req = seen.find(f => f.type === 'control_request' && f.request?.subtype === subtype) + expect(req).toBeTruthy() + }) + proc.line({ response: { request_id: req.request_id, response }, type: 'control_response' }) + } + + // ── the model line ──────────────────────────────────────────────────────── + + it('shows the fusion name as the session model, not the base id', async () => { + proc.line(INIT_FUSED) + // The fusion name is what the user selected and the only signal that + // images are being routed through a second model. + await vi.waitFor(() => expect(last('session.info')?.payload?.model).toBe('deepseek-v4-pro-V')) + }) + + it('falls back to the plain model when not fused', async () => { + proc.line({ ...INIT_FUSED, fusion: '' }) + await vi.waitFor(() => expect(last('session.info')?.payload?.model).toBe('deepseek-v4-pro')) + }) + + it('tolerates a backend that omits the fusion field', async () => { + const { fusion: _fusion, ...noFusion } = INIT_FUSED + + proc.line(noFusion) + await vi.waitFor(() => expect(last('session.info')?.payload?.model).toBe('deepseek-v4-pro')) + }) + + // ── /fusion relay ───────────────────────────────────────────────────────── + + it('relays /fusion to the backend control and prints its text', async () => { + const p = gw.request('slash.exec', { command: 'fusion list' }) + + await replyToControl('fusion', { ok: true, text: 'Fusion models (1):\n dsv = a:b + vision:c:d' }) + await expect(p).resolves.toEqual({ + output: 'Fusion models (1):\n dsv = a:b + vision:c:d', + type: 'exec' + }) + }) + + it('surfaces a /fusion error when the backend refuses', async () => { + const p = gw.request('slash.exec', { command: 'fusion list' }) + + await replyToControl('fusion', { error: '/fusion is only available on single-session transports', ok: false }) + await expect(p).resolves.toMatchObject({ output: expect.stringContaining('single-session') }) + }) + + it('lists /fusion in the slash-command menu', async () => { + // SLASHES drives both recognition and the menu, so an entry missing here + // means /fusion is untypeable in the TUI. + const p = gw.request('complete.slash', { text: '/fus' }) + + // The menu merges dynamic workflow commands in, so that control has to + // be answered or the request never settles. + await replyToControl('list_workflow_commands', { commands: [] }) + const r: any = await p + + expect(JSON.stringify(r)).toContain('/fusion') + expect(JSON.stringify(r)).toContain('vision') // the hint/description + }) + + // ── /model interaction ──────────────────────────────────────────────────── + + it('marks the fusion model as current in the /model picker', async () => { + const p = gw.request('model.options', {}) + + await replyToControl('get_settings', { + available_models: ['dsv', 'deepseek-v4-pro'], + fusion: 'dsv', + model: 'deepseek-v4-pro', + provider: 'deepseek' + }) + // Must point at the fusion entry the user selected, not the base row. + await expect(p).resolves.toMatchObject({ model: 'dsv' }) + }) + + it('leaves the picker on the plain model when not fused', async () => { + const p = gw.request('model.options', {}) + + await replyToControl('get_settings', { + available_models: ['deepseek-v4-pro'], + fusion: '', + model: 'deepseek-v4-pro', + provider: 'deepseek' + }) + await expect(p).resolves.toMatchObject({ model: 'deepseek-v4-pro' }) + }) + + it('reports a refused /model switch instead of claiming success', async () => { + // set_model declines for several actionable reasons (a disabled fusion + // model, missing credentials for a half, an active turn). Printing + // "Model set to X" regardless told the user the opposite of the truth. + const p = gw.request('slash.exec', { command: 'model dsv' }) + + await replyToControl('set_model', { error: "fusion model 'dsv' is disabled — run `/fusion enable dsv` first", ok: false }) + const r: any = await p + + expect(r.output).toContain('is disabled') + expect(r.output).not.toContain('Model set to') + }) + + it('echoes the model the backend actually applied', async () => { + const p = gw.request('slash.exec', { command: 'model dsv' }) + + await replyToControl('set_model', { fusion: 'dsv', model: 'dsv', ok: true }) + await expect(p).resolves.toMatchObject({ output: 'Model set to dsv.' }) + }) + + it('appends a set_model warning when the backend sends one', async () => { + const p = gw.request('slash.exec', { command: 'model odd-id' }) + + await replyToControl('set_model', { model: 'odd-id', ok: true, warning: 'not in the model list' }) + await expect(p).resolves.toMatchObject({ + output: expect.stringContaining('not in the model list') + }) + }) +}) diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 119a3595c..9b8ec9cea 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -476,6 +476,11 @@ const SLASHES: ReadonlyArray<{ desc: string; hint?: string; name: string }> = [ hint: '[: [--client] | --no-client | off|unset]', name: '/advisor' }, + { + desc: 'Give a text-only model vision by fusing it with a multimodal one', + hint: '[list | create | delete|enable|disable ]', + name: '/fusion' + }, { desc: 'List running and recent dynamic workflows', name: '/workflows' }, { desc: 'Search / manage the knowledge base', hint: '[status|list|clear|enable|disable]', name: '/knowledge' }, { @@ -805,7 +810,11 @@ export class GatewayClient extends EventEmitter { const provider = String(r?.provider ?? 'clawcodex') return { - model: r?.model, + // On a fused session the backend reports `model` as the base id; + // the picker's "current" marker must point at the fusion entry + // the user actually selected (which `available_models` lists + // first), not at the base model row. + model: (typeof r?.fusion === 'string' && r.fusion) ? r.fusion : r?.model, provider, providers: [ { @@ -1243,6 +1252,17 @@ export class GatewayClient extends EventEmitter { return out(String(r.text ?? r.error ?? 'advisor: no response')) } + case 'fusion': { + // Manage fusion models (a text-only base model + a borrowed vision + // model). The backend owns the whole grammar — list / create / + // delete / enable / disable — so this only relays the arg and + // prints the reply, exactly like /advisor above. + const r = (await this.controlQuery('fusion', { arg: arg ?? '' })) as any + + if (!r || Object.keys(r).length === 0) {return out('fusion: backend not ready')} + + return out(String(r.text ?? r.error ?? 'fusion: no response')) + } case 'memory': { // Arg-ful /memory (status | pending | approve | reject) — the // bounded-store management surface. The no-arg picker never routes @@ -1344,10 +1364,26 @@ export class GatewayClient extends EventEmitter { // registry first and returns, so a gateway case here would be unreachable. // The RPC it uses is `config.set{key:'permission_mode'}` above. - case 'model': - await this.controlQuery('set_model', { model: arg }) + case 'model': { + const r = (await this.controlQuery('set_model', { model: arg })) as any + + // DEFENCE-IN-DEPTH, not the reachable path: like /permissions above, + // `/model` is a TUI-local command (app/slash/commands/session.ts) and + // createSlashHandler resolves the local registry first, so the live + // route is config.set → setModel — which already throws on + // `ok === false` and already patches the badge from `r.value`. This + // case only runs if that local command is ever removed. Kept correct + // anyway because set_model can now decline for several actionable + // reasons (a disabled fusion model, missing credentials for a fusion + // half, an active turn), each carrying the fix in its message. + if (r?.ok === false) { + return out(String(r.error ?? 'model switch failed')) + } + + const applied = typeof r?.model === 'string' && r.model ? r.model : (arg ?? '(unchanged)') - return out(`Model set to ${arg ?? '(unchanged)'}.`) + return out(`Model set to ${applied}.${r?.warning ? ` ${r.warning}` : ''}`) + } case 'output-style': { if (arg) { const r = (await this.controlQuery('set_output_style', { style: arg })) as any @@ -2171,9 +2207,18 @@ export class GatewayClient extends EventEmitter { ? init.tools.map((t: any) => t?.name).filter(Boolean) : [] + // A fused session reports `model` as the BASE model id (what serves the + // turn, and what the backend's cost/context-window lookups key off) and + // the fusion model's name separately. The name is what the user selected + // and the only signal that images are being routed through a second + // model, so it is what the model line shows — matching CCR's contract + // that a fusion model behaves "like a normal model". `/fusion list` + // remains the place to see which two models it is made of. + const fusion = typeof init.fusion === 'string' ? init.fusion : '' + return { cwd: init.cwd, - model: String(init.model ?? ''), + model: fusion || String(init.model ?? ''), permission_mode: typeof init.permission_mode === 'string' ? init.permission_mode : undefined, profile_name: init.provider ? String(init.provider) : undefined, // Rendered beside the model name; the backend sends the session's From cdfa11ef1bcbe6491a2d967298108fc89dd00d93 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Thu, 30 Jul 2026 09:18:32 -0700 Subject: [PATCH 2/3] fix(models): resolve the persisted model from one rule at every entrypoint MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 25 +++- src/entrypoints/headless.py | 18 ++- src/providers/fusion_models.py | 38 ++++++ src/server/agent_server.py | 67 ++++++++--- src/settings/settings.py | 82 +++++++++---- tests/providers/test_fusion_models.py | 94 +++++++++++++++ tests/server/test_fusion_control.py | 131 +++++++++++++++++++++ tests/test_app_state_persistence_round3.py | 67 +++++++---- 8 files changed, 459 insertions(+), 63 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1568edabb..084a574b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,7 +35,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 are stored in `~/.clawcodex/config.json` under `fusionModels` (user-level only, so a checked-out repo cannot redirect where your screenshots go). - A saved fusion model behaves like a normal model: it appears in the - `/model` picker and works as `--model `, interactively and in `-p`. + `/model` picker, works as `--model ` interactively and in `-p`, and + **survives a restart** — selecting one persists the fusion *name*, and + startup resolves it back to the pair. Deleting or disabling a fusion + model that was the persisted choice falls back to the provider default + instead of leaving a dangling name on the wire. - Each distinct image is described once per session, so replayed history does not re-pay for the same screenshot. Vision failures degrade to a note naming the cause rather than killing the turn. @@ -47,6 +51,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 [claude-code-router]: https://ccrdesk.top/en/configuration/fusion-models/ +### Fixed + +- **`--model`/`/model` selection is now resolved from one rule at every + entrypoint.** The persisted `/model` choice was applied only in the + interactive agent-server, and only *after* the provider was constructed + (`_build_runtime`'s post-construction `provider.model = ...`). Headless + (`-p`) ignored it entirely, so a `/model` switch had to be re-stated with + `--model` on every subsequent run. Both entrypoints now share + `settings.get_persisted_model` and TS's precedence — explicit `--model`, + then the persisted choice, then the provider default + (`main.tsx:1984`: `userSpecifiedModel ?? getUserSpecifiedModelSetting() ?? null`) + — resolved *before* construction, which is also what lets a persisted + fusion model decide which provider to build. The cross-provider staleness + guard (`settings.model_provider` must match) is unchanged; a fusion model + is exempt because its record names its own provider. + + `apply_persisted_model` (post-construction, no callers) is replaced by + `get_persisted_model`. + ### Changed - **Permissions are now loose by default and easy to change.** `/mode` is diff --git a/src/entrypoints/headless.py b/src/entrypoints/headless.py index 1d739717f..1ef340217 100644 --- a/src/entrypoints/headless.py +++ b/src/entrypoints/headless.py @@ -150,7 +150,19 @@ def run_headless(options: HeadlessOptions) -> int: provider_name = options.provider_name or get_default_provider() - # ``--model `` may name a FUSION model (a text-only base model plus + # Model precedence, mirroring TS ``main.tsx:1984`` + # (``userSpecifiedModel ?? getUserSpecifiedModelSetting() ?? null``): + # explicit --model, then the persisted /model choice, then the provider + # default. Headless honours the persisted choice for the same reason the + # interactive path does — one `/model` switch should not have to be + # re-stated on every subsequent `-p` run. + from src.settings.settings import get_persisted_model + + model_choice = options.model or get_persisted_model( + provider_name, provider_is_explicit=bool(options.provider_name) + ) + + # ``model_choice`` may name a FUSION model (a text-only base model plus # a borrowed vision model — see src/providers/fusion_models.py). That # name is not a real model id on any provider, so it has to be resolved # here; passing it to the constructor would send it to the wire and 400. @@ -158,7 +170,7 @@ def run_headless(options: HeadlessOptions) -> int: # image), so this is not interactive-only sugar. from src.providers.fusion_models import get_fusion_model - _fusion = get_fusion_model(options.model) if options.model else None + _fusion = get_fusion_model(model_choice) if model_choice else None if _fusion is not None and not _fusion.enabled: cli_error( f"error: fusion model '{_fusion.name}' is disabled — enable it with " @@ -212,7 +224,7 @@ def run_headless(options: HeadlessOptions) -> int: provider = build_fusion_provider(_fusion) else: provider_cls = get_provider_class(provider_name) - model = options.model or provider_cfg.get("default_model") + model = model_choice or provider_cfg.get("default_model") provider = provider_cls( api_key=api_key, base_url=provider_cfg.get("base_url"), diff --git a/src/providers/fusion_models.py b/src/providers/fusion_models.py index f424a3933..124a5ccdf 100644 --- a/src/providers/fusion_models.py +++ b/src/providers/fusion_models.py @@ -538,6 +538,43 @@ def create_fusion_model( return model +def _forget_persisted_selection(name: str) -> None: + """Clear ``settings.model`` if it names ``name``. + + A fusion model can be the persisted ``/model`` choice + (``settings.get_persisted_model`` restores it at startup). Deleting or + disabling it would otherwise leave a DANGLING name behind: the restore + reads it, finds no fusion record, and falls through to the plain-model + branch — putting a string that is not a real model id on the wire, for a + 400 on the next launch. + + Clearing at the mutation is the narrow fix. It cannot cover a config + hand-edited to remove the record directly; that residue degrades to a + clear API error rather than silent misbehaviour, the same bounded + caveat as the create-time-only shadow guard. + + Best-effort: failing to clear must not fail the delete/disable the user + asked for. + """ + try: + from src.settings.settings import invalidate_settings_cache + + mgr = _manager() + cfg = mgr.load_global() + section = cfg.get("settings") + if not isinstance(section, dict): + return + if str(section.get("model", "")).strip().lower() != name.strip().lower(): + return + section["model"] = "" + section["model_provider"] = "" + cfg["settings"] = section + mgr.save_global(cfg) + invalidate_settings_cache() + except Exception: # noqa: BLE001 + pass + + def delete_fusion_model(name: str) -> FusionModel: """Remove a fusion model by name. Returns the removed record.""" existing = load_fusion_models() @@ -547,6 +584,7 @@ def delete_fusion_model(name: str) -> FusionModel: raise FusionModelError(_unknown_message(name)) removed = next(m for m in existing if m.name.lower() == key) save_fusion_models(kept) + _forget_persisted_selection(removed.name) return removed diff --git a/src/server/agent_server.py b/src/server/agent_server.py index f3d3501cf..5e5e12bac 100644 --- a/src/server/agent_server.py +++ b/src/server/agent_server.py @@ -1549,7 +1549,12 @@ def _do_set_fusion_model(self, request_id: object, model: str) -> bool: return True fused = build_fusion_provider(fusion) - self._install_provider(fused, fusion.base.provider, fusion.base.model) + self._install_provider( + fused, fusion.base.provider, fusion.base.model, + # Persist the NAME the user selected, so a restart restores + # the fusion model rather than the bare base model. + persist_model=fusion.name, + ) except Exception as exc: # noqa: BLE001 logger.exception("[agent-server] fusion model switch failed") self._reply(request_id, { @@ -1600,7 +1605,8 @@ def _do_set_provider(self, request_id: object, name: object) -> None: self._reply(request_id, {"ok": False, "error": str(exc)}) def _install_provider( - self, provider: Any, name: str, model: str | None + self, provider: Any, name: str, model: str | None, + *, persist_model: str | None = None, ) -> None: """Adopt ``provider`` as the session's provider, rebuilding the registry. @@ -1614,6 +1620,14 @@ def _install_provider( ``model`` is the id to record as current; for a fusion model that is the base model, since that is what serves the turn (see ``FusionProvider.model``). + + ``persist_model`` is what gets PERSISTED as the user's choice, when + it differs from ``model``. For a fusion model that is the fusion + NAME: persisting the base id instead would restore the next session + as the plain base model and silently drop vision — the user picked + ``deepseek-v4-pro-V``, not ``deepseek-v4-pro``. The restore side + (``settings.get_persisted_model``) resolves that name back to the + fusion record. """ from src.tool_system.defaults import build_default_registry @@ -1650,7 +1664,7 @@ def _install_provider( # pair coherent across a provider switch — the supplier reads # self.provider_name, updated above, so on_change persists the # new pairing. - _dispatch_app_state(self, main_loop_model=model) + _dispatch_app_state(self, main_loop_model=persist_model or model) # INTEG-1 warm-on-activation (the refreshStartupDiscoveryForActiveRoute # analog, discoveryService.ts:415): one non-blocking # get_available_models call kicks the single-flight background @@ -4746,18 +4760,30 @@ def _build_runtime(sess: _AgentSession, perm_mode: str | None) -> None: provider_name = cfg.provider_name or get_default_provider() - # ``--model `` may name a FUSION model, which is not a real - # model id on any provider: handing it to the provider constructor - # would put it on the wire and 400. Resolved BEFORE the credential - # gate below, because a fusion model overrides the session's - # provider — gating on the session default first would refuse to - # start when that unrelated provider happens to be unconfigured, - # even though the fusion model's own providers are fine. + # Model precedence, mirroring TS ``main.tsx:1984`` + # (``userSpecifiedModel ?? getUserSpecifiedModelSetting() ?? null``): + # an explicit --model wins, then the persisted /model choice, then + # the provider default (applied below). Without the middle term a + # /model switch never survived a restart — the write side has always + # persisted (model, model_provider); nothing ever read it back. + from src.settings.settings import get_persisted_model + + model_choice = cfg.model or get_persisted_model( + provider_name, provider_is_explicit=bool(cfg.provider_name) + ) + + # ``model_choice`` may name a FUSION model, which is not a real model + # id on any provider: handing it to the provider constructor would + # put it on the wire and 400. Resolved BEFORE the credential gate + # below, because a fusion model overrides the session's provider — + # gating on the session default first would refuse to start when that + # unrelated provider happens to be unconfigured, even though the + # fusion model's own providers are fine. fusion = None try: from src.providers.fusion_models import get_fusion_model - candidate = get_fusion_model(cfg.model) if cfg.model else None + candidate = get_fusion_model(model_choice) if model_choice else None fusion = candidate if (candidate and candidate.enabled) else None except Exception: # noqa: BLE001 — never block startup on this logger.debug("[agent-server] fusion lookup at init failed", exc_info=True) @@ -4803,7 +4829,7 @@ def _build_runtime(sess: _AgentSession, perm_mode: str | None) -> None: provider = build_fusion_provider(fusion) else: provider_cls = get_provider_class(provider_name) - model = cfg.model or provider_cfg.get("default_model") + model = model_choice or provider_cfg.get("default_model") provider = provider_cls( api_key=api_key, base_url=provider_cfg.get("base_url"), model=model ) @@ -4910,13 +4936,26 @@ def _build_runtime(sess: _AgentSession, perm_mode: str | None) -> None: ) set_active_provider_supplier(lambda: sess.provider_name) + # The persisted-model restore now happens ONCE, above, via + # ``model_choice`` — before construction, because a fusion + # name decides which provider gets built at all. The old + # post-construction ``provider.model = main_loop_model`` + # assignment is therefore gone, not merely guarded: it read + # ``settings.model`` RAW, so a persisted fusion name that + # ``get_persisted_model`` had correctly declined (disabled, + # or not matching an explicit --provider) would still be + # assigned here and reach the wire as a bogus model id. + # One rule, one place. + # + # The store's ``main_loop_model`` is likewise pinned to the + # resolution actually in force, so the state the client reads + # cannot disagree with the provider that was built. seeded_state = replace_state( seed_app_state_from_settings(provider_name), permission_mode=mode, + main_loop_model=(fusion.name if fusion is not None else model), ) sess.app_state_store = create_app_state_store(seeded_state) - if cfg.model is None and seeded_state.main_loop_model: - provider.model = seeded_state.main_loop_model except Exception: # noqa: BLE001 — store failure must not break startup logger.debug("[agent-server] app-state store init failed", exc_info=True) diff --git a/src/settings/settings.py b/src/settings/settings.py index 6e8b8b979..03914aec1 100644 --- a/src/settings/settings.py +++ b/src/settings/settings.py @@ -67,30 +67,70 @@ def get_settings( return _settings_cache -def apply_persisted_model(provider: Any, provider_name: str) -> bool: - """Apply the persisted ``(model, model_provider)`` pair to a provider. - - The effective read-side of the ch03 round-3 model persistence: the - port's live model channel is ``provider.model``, so a persisted - `/model` choice must reach the constructed provider to survive a - restart. Mirrors ``getUserSpecifiedModelSetting`` - (TS ``utils/model/model.ts:109-135``) including its provider-match - guard — a model persisted under another provider is ignored (the - cross-provider staleness failure documented there). Callers gate on - explicit overrides: an explicit ``--model``-style choice made at - construction wins (override-first precedence), so call this only when - no explicit model was supplied. - - Returns True iff the persisted model was applied. +def get_persisted_model(provider_name: str, *, provider_is_explicit: bool = False) -> str: + """The persisted ``/model`` choice for ``provider_name``, or ``""``. + + The read side of the model persistence whose write side is + ``state/app_state._on_main_loop_model_change``. Mirrors TS + ``getUserSpecifiedModelSetting`` (``utils/model/model.ts:124-165``), + which resolves the saved ``settings.model`` after an explicit override + and before the built-in default. Callers own that precedence: + + model = explicit_override or get_persisted_model(provider) or default + + matching TS ``main.tsx:1984`` + (``userSpecifiedModel ?? getUserSpecifiedModelSetting() ?? null``). + + **Provider-match guard.** A persisted model is meaningful only with the + provider that served it; TS documents the cross-provider staleness + failure (a stale model fired at the wrong endpoint and 400s), and TS + itself guards it by reading only the env var matching the active + provider. Here the pairing is explicit: ``settings.model_provider``. + + **Fusion exception.** A fusion model (``providers/fusion_models.py``) + names its OWN base provider in its record, so it is self-describing and + the staleness hazard does not apply — it is restored even when it does + not match the session's *default* provider, which is the whole point of + having selected one. A disabled or since-deleted fusion name resolves to + nothing and falls through to the default, rather than reaching the wire + as a bogus id. + + ``provider_is_explicit`` marks that the caller's ``provider_name`` came + from an explicit ``--provider`` flag rather than the configured default. + That narrows the fusion exception: restoring a fusion model REPLACES the + session provider with the record's base, so honouring it over an explicit + flag would silently ignore what the user just typed. Explicit intent + wins, matching the override-first precedence throughout this resolution. + + Never raises: any failure yields ``""`` and the caller falls back to the + provider default. That covers the attribute reads too, not just the file + load — this runs on the startup path of every entrypoint, and a settings + object that does not carry these fields (a partial stub, an older + persisted shape) must degrade to the default rather than abort a launch. """ try: s = get_settings() - except Exception: - return False - if s.model and s.model_provider == provider_name: - provider.model = s.model - return True - return False + model = str(getattr(s, "model", "") or "").strip() + if not model: + return "" + persisted_provider = str(getattr(s, "model_provider", "") or "") + except Exception: # noqa: BLE001 — see the "never raises" contract above + logger.debug("settings read during model restore failed", exc_info=True) + return "" + try: + from src.providers.fusion_models import get_fusion_model + + fusion = get_fusion_model(model) + if fusion is not None: + if not fusion.enabled: + return "" + if provider_is_explicit and fusion.base.provider != provider_name: + return "" + return fusion.name + except Exception: # noqa: BLE001 — a fusion-config problem is not fatal + logger.debug("fusion lookup during model restore failed", exc_info=True) + return model if persisted_provider == provider_name else "" + def update_local_settings( updates: dict[str, Any], *, cwd: str | Path | None = None, diff --git a/tests/providers/test_fusion_models.py b/tests/providers/test_fusion_models.py index cd3dd887f..758dd5c02 100644 --- a/tests/providers/test_fusion_models.py +++ b/tests/providers/test_fusion_models.py @@ -278,6 +278,100 @@ def test_delete_removes_only_the_named_model(): assert [m.name for m in load_fusion_models()] == ["b"] +def _persist_selection(model: str, provider: str = "deepseek") -> None: + from src import config as cfg_mod + from src.settings.settings import invalidate_settings_cache + + mgr = cfg_mod._get_default_manager() + cfg = mgr.load_global() + cfg["settings"] = {"model": model, "model_provider": provider} + mgr.save_global(cfg) + invalidate_settings_cache() + + +def test_persisted_fusion_model_overrides_the_configured_default_provider(): + # A fusion record names its own provider, so restoring it is meaningful + # even when the session's DEFAULT provider is something else — that is + # the point of having selected it. + from src.settings.settings import get_persisted_model + + create_fusion_model("dsv", BASE, VISION) + _persist_selection("dsv") + assert get_persisted_model("anthropic") == "dsv" + + +def test_persisted_fusion_model_yields_to_an_EXPLICIT_provider(): + # Restoring a fusion model REPLACES the session provider with its base, + # so honouring it over an explicit `--provider` would silently ignore + # what the user just typed. Explicit intent wins. + from src.settings.settings import get_persisted_model + + create_fusion_model("dsv", BASE, VISION) # base is deepseek:… + _persist_selection("dsv") + assert get_persisted_model("openrouter", provider_is_explicit=True) == "" + # …but an explicit provider that MATCHES the fusion's base still restores. + assert get_persisted_model("deepseek", provider_is_explicit=True) == "dsv" + + +def test_delete_clears_a_persisted_selection_naming_it(): + # Otherwise the restore reads a dangling name, finds no fusion record, + # falls through to the plain-model branch, and puts a string that is not + # a real model id on the wire — a 400 on the next launch. + from src import config as cfg_mod + from src.settings.settings import get_persisted_model, invalidate_settings_cache + + create_fusion_model("dsv", BASE, VISION) + mgr = cfg_mod._get_default_manager() + cfg = mgr.load_global() + cfg["settings"] = {"model": "dsv", "model_provider": "deepseek"} + mgr.save_global(cfg) + invalidate_settings_cache() + assert get_persisted_model("deepseek") == "dsv" + + delete_fusion_model("dsv") + invalidate_settings_cache() + assert get_persisted_model("deepseek") == "" + + +def test_delete_leaves_an_unrelated_persisted_selection_alone(): + from src import config as cfg_mod + from src.settings.settings import get_persisted_model, invalidate_settings_cache + + create_fusion_model("dsv", BASE, VISION) + mgr = cfg_mod._get_default_manager() + cfg = mgr.load_global() + cfg["settings"] = {"model": "deepseek-v4-flash", "model_provider": "deepseek"} + mgr.save_global(cfg) + invalidate_settings_cache() + + delete_fusion_model("dsv") + invalidate_settings_cache() + assert get_persisted_model("deepseek") == "deepseek-v4-flash" + + +def test_disabled_fusion_model_resolves_to_nothing_without_clearing(): + # Disable does not dangle: the record still exists, so the restore side + # sees `enabled=False` and declines. The configuration is preserved so + # re-enabling restores the selection too. + from src import config as cfg_mod + from src.settings.settings import get_persisted_model, invalidate_settings_cache + + create_fusion_model("dsv", BASE, VISION) + mgr = cfg_mod._get_default_manager() + cfg = mgr.load_global() + cfg["settings"] = {"model": "dsv", "model_provider": "deepseek"} + mgr.save_global(cfg) + invalidate_settings_cache() + + set_fusion_model_enabled("dsv", False) + invalidate_settings_cache() + assert get_persisted_model("deepseek") == "" + + set_fusion_model_enabled("dsv", True) + invalidate_settings_cache() + assert get_persisted_model("deepseek") == "dsv" + + def test_delete_unknown_raises_and_lists_known(): create_fusion_model("a", BASE, VISION) with pytest.raises(FusionModelError, match="Known: a"): diff --git a/tests/server/test_fusion_control.py b/tests/server/test_fusion_control.py index b2fd4fb2d..e9c29b68e 100644 --- a/tests/server/test_fusion_control.py +++ b/tests/server/test_fusion_control.py @@ -75,6 +75,18 @@ def __enter__(self): return self def __exit__(self, *a): + # The active-provider supplier and app-state singletons are + # module-level; leaving them set would bleed into later tests. + try: + from src.state.app_state import ( + reset_state_for_tests, + set_active_provider_supplier, + ) + + set_active_provider_supplier(None) + reset_state_for_tests() + except Exception: # noqa: BLE001 + pass cfg_mod = self._cfg_mod ( cfg_mod.GLOBAL_CONFIG_FILE, @@ -107,6 +119,18 @@ def _make_session(single_session: bool = True) -> tuple[_AgentSession, list[dict provider.get_available_models = lambda: ["deepseek-v4-pro", "deepseek-v4-flash"] sess.provider = provider sess.provider_name = "deepseek" + # An AppState store, as `_build_runtime` creates for single_session. + # Without it `_dispatch_app_state` is a NO-OP, so nothing persists and + # any assertion about the saved model passes or fails for the wrong + # reason (the restart-blind-store trap). + from src.state.app_state import ( + AppState, + create_app_state_store, + set_active_provider_supplier, + ) + + set_active_provider_supplier(lambda: sess.provider_name) + sess.app_state_store = create_app_state_store(AppState()) return sess, emitted @@ -354,6 +378,113 @@ def test_init_envelope_carries_the_fusion_name(self) -> None: self.assertEqual(init["fusion"], "dsv") self.assertEqual(init["model"], "deepseek-v4-pro") + def test_switch_persists_the_fusion_NAME_not_the_base_model(self) -> None: + # The restore side resolves this string back to a fusion record, so + # persisting the base id would restore the next session as the plain + # base model and silently drop vision. + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create dsv {BASE} {VISION}") + _control(sess, "set_model", model="dsv") + + from src.settings.settings import get_settings, invalidate_settings_cache + + invalidate_settings_cache() + s = get_settings() + self.assertEqual(s.model, "dsv") + self.assertEqual(s.model_provider, "deepseek") + + def test_persisted_fusion_model_is_restored_at_startup(self) -> None: + # The round trip: /model dsv → restart → still fused. + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create dsv {BASE} {VISION}") + _control(sess, "set_model", model="dsv") + + from src.settings.settings import ( + get_persisted_model, + invalidate_settings_cache, + ) + + invalidate_settings_cache() + # A fusion model names its own provider, so it restores even when + # the session default is a DIFFERENT provider — unlike a plain + # model, which the staleness guard would drop. + self.assertEqual(get_persisted_model("deepseek"), "dsv") + self.assertEqual(get_persisted_model("anthropic"), "dsv") + + def test_disabled_fusion_model_is_not_restored(self) -> None: + # A since-disabled name must fall through to the provider default + # rather than reaching the wire as a bogus model id. + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create dsv {BASE} {VISION}") + _control(sess, "set_model", model="dsv") + _control(sess, "fusion", arg="disable dsv") + + from src.settings.settings import ( + get_persisted_model, + invalidate_settings_cache, + ) + + invalidate_settings_cache() + self.assertEqual(get_persisted_model("deepseek"), "") + + def test_deleted_fusion_model_is_not_restored(self) -> None: + with _IsolatedConfig(): + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create dsv {BASE} {VISION}") + _control(sess, "set_model", model="dsv") + _control(sess, "fusion", arg="delete dsv") + + from src.settings.settings import ( + get_persisted_model, + invalidate_settings_cache, + ) + + invalidate_settings_cache() + # Falls through to the provider default; "dsv" must never reach + # the wire as a model id. + self.assertEqual(get_persisted_model("deepseek"), "") + + def test_a_declined_persisted_fusion_name_never_reaches_the_provider(self) -> None: + # THE second read path. `get_persisted_model` correctly declines a + # DISABLED fusion model, but `seed_app_state_from_settings` reads + # `settings.model` RAW and still yields the fusion name — so the old + # post-construction `provider.model = seeded.main_loop_model` + # backstop would assign it anyway and put a string that is not a real + # model id on the wire. Both paths must agree. + with _IsolatedConfig(): + from src.settings.settings import ( + get_persisted_model, + invalidate_settings_cache, + ) + from src.state.app_state import seed_app_state_from_settings + + sess, emitted = _make_session() + _control(sess, "fusion", arg=f"create dsv {BASE} {VISION}") + _control(sess, "set_model", model="dsv") + _control(sess, "fusion", arg="disable dsv") + invalidate_settings_cache() + + # The raw seed still carries the name — that is the trap. + self.assertEqual( + seed_app_state_from_settings("deepseek").main_loop_model, "dsv" + ) + # The resolution in force declines it. + self.assertEqual(get_persisted_model("deepseek"), "") + # And no code path may turn that raw seed into a wire model id. + import inspect + + from src.server import agent_server as mod + + src = inspect.getsource(mod._build_runtime) + self.assertNotIn( + "provider.model = seeded_state.main_loop_model", src, + "the raw-seed assignment is back — a declined fusion name " + "would reach the wire", + ) + def test_plain_model_switch_is_unaffected(self) -> None: with _IsolatedConfig(): sess, emitted = _make_session() diff --git a/tests/test_app_state_persistence_round3.py b/tests/test_app_state_persistence_round3.py index acb7cee1b..75ee1368f 100644 --- a/tests/test_app_state_persistence_round3.py +++ b/tests/test_app_state_persistence_round3.py @@ -21,7 +21,7 @@ reset_state_for_tests, ) from src.settings.settings import ( - apply_persisted_model, + get_persisted_model, get_settings, invalidate_settings_cache, ) @@ -205,38 +205,60 @@ def test_store_factory_seeds_when_initial_omitted(_isolated_state): # --------------------------------------------------------------------------- -# Effective read side — apply_persisted_model +# Effective read side — get_persisted_model +# +# ``apply_persisted_model`` (which set ``provider.model`` post-construction) +# was replaced by ``get_persisted_model``, which returns the id instead: the +# entrypoints must know the model BEFORE constructing a provider, because a +# fusion model decides which provider to construct at all. Same rule, same +# provider-match guard; these tests keep their original intent. # --------------------------------------------------------------------------- -class _FakeProvider: - def __init__(self) -> None: - self.model = "default-model" - - -def test_apply_persisted_model_match(_isolated_state): +def test_get_persisted_model_match(_isolated_state): _persist( _isolated_state, settings={"model": "kimi-k3", "model_provider": "openrouter"}, ) - provider = _FakeProvider() - assert apply_persisted_model(provider, "openrouter") is True - assert provider.model == "kimi-k3" + assert get_persisted_model("openrouter") == "kimi-k3" + + +def test_get_persisted_model_never_raises(monkeypatch): + """The documented contract, and it is load-bearing. + + This runs on the startup path of EVERY entrypoint, so anything that + escapes here aborts a launch. A settings object that does not carry + these fields — a partial test stub, an older persisted shape — must + degrade to the provider default. Regression: an earlier revision + wrapped only the file load, not the attribute reads, and + ``tests/entrypoints/test_headless_goal.py`` (whose stub is a bare + SimpleNamespace) died with AttributeError inside headless startup. + """ + import types + + import src.settings.settings as mod + + monkeypatch.setattr(mod, "get_settings", lambda *a, **k: types.SimpleNamespace()) + assert mod.get_persisted_model("deepseek") == "" + + def boom(*a, **k): + raise RuntimeError("settings on fire") + + monkeypatch.setattr(mod, "get_settings", boom) + assert mod.get_persisted_model("deepseek") == "" -def test_apply_persisted_model_mismatch_or_unset(_isolated_state): +def test_get_persisted_model_mismatch_or_unset(_isolated_state): _persist( _isolated_state, settings={"model": "kimi-k3", "model_provider": "openrouter"}, ) - provider = _FakeProvider() - assert apply_persisted_model(provider, "anthropic") is False - assert provider.model == "default-model" + # Cross-provider staleness guard: a model persisted under another + # provider must not be fired at this one. + assert get_persisted_model("anthropic") == "" _persist(_isolated_state, settings={}) - provider2 = _FakeProvider() - assert apply_persisted_model(provider2, "openrouter") is False - assert provider2.model == "default-model" + assert get_persisted_model("openrouter") == "" def test_restart_round_trip_same_provider(_isolated_state): @@ -246,20 +268,17 @@ def test_restart_round_trip_same_provider(_isolated_state): store = create_app_state_store(AppState()) store.set_state(lambda s: replace_state(s, main_loop_model="kimi-k3")) - # "Restart": fresh seed + fresh provider, same provider name. + # "Restart": fresh seed, same provider name. reset_state_for_tests() set_active_provider_supplier(None) invalidate_settings_cache() config_module._default_manager = None - provider = _FakeProvider() - assert apply_persisted_model(provider, "openrouter") is True - assert provider.model == "kimi-k3" + assert get_persisted_model("openrouter") == "kimi-k3" state = seed_app_state_from_settings("openrouter") assert state.main_loop_model == "kimi-k3" # Provider switch: persisted pair ignored everywhere. - provider_b = _FakeProvider() - assert apply_persisted_model(provider_b, "anthropic") is False + assert get_persisted_model("anthropic") == "" state_b = seed_app_state_from_settings("anthropic") assert state_b.main_loop_model is None From aaa9f8b57024b16afca0bf50004227788e854283 Mon Sep 17 00:00:00 2001 From: Eric Lee Date: Thu, 30 Jul 2026 09:56:34 -0700 Subject: [PATCH 3/3] fix(models): scope the persisted-model read to single-session transports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/providers/fusion_models.py | 28 +++---- src/server/agent_server.py | 35 ++++++--- tests/server/test_fusion_control.py | 18 +++++ tests/test_headless_cli.py | 110 ++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 25 deletions(-) diff --git a/src/providers/fusion_models.py b/src/providers/fusion_models.py index 124a5ccdf..ab5da1ea7 100644 --- a/src/providers/fusion_models.py +++ b/src/providers/fusion_models.py @@ -553,24 +553,24 @@ def _forget_persisted_selection(name: str) -> None: clear API error rather than silent misbehaviour, the same bounded caveat as the create-time-only shadow guard. - Best-effort: failing to clear must not fail the delete/disable the user - asked for. + Best-effort: failing to clear must not fail the delete the user asked + for. + + Writes through ``state.app_state._persist_settings_keys`` — the same + helper the ``/model`` write side uses — rather than hand-rolling the + load/mutate/save/invalidate sequence. Hand-rolling it would silently + stop clearing if the persistence format ever moved, and the dangling + name would come back. ``model=""`` + ``model_provider=""`` is that + module's unset convention, so clear and set stay symmetric. """ try: - from src.settings.settings import invalidate_settings_cache + from src.settings.settings import get_settings + from src.state.app_state import _persist_settings_keys - mgr = _manager() - cfg = mgr.load_global() - section = cfg.get("settings") - if not isinstance(section, dict): + current = str(getattr(get_settings(), "model", "") or "").strip() + if current.lower() != name.strip().lower(): return - if str(section.get("model", "")).strip().lower() != name.strip().lower(): - return - section["model"] = "" - section["model_provider"] = "" - cfg["settings"] = section - mgr.save_global(cfg) - invalidate_settings_cache() + _persist_settings_keys(model="", model_provider="") except Exception: # noqa: BLE001 pass diff --git a/src/server/agent_server.py b/src/server/agent_server.py index 5e5e12bac..98485e30a 100644 --- a/src/server/agent_server.py +++ b/src/server/agent_server.py @@ -4768,8 +4768,22 @@ def _build_runtime(sess: _AgentSession, perm_mode: str | None) -> None: # persisted (model, model_provider); nothing ever read it back. from src.settings.settings import get_persisted_model - model_choice = cfg.model or get_persisted_model( - provider_name, provider_is_explicit=bool(cfg.provider_name) + # + # single_session ONLY, deliberately. The persisted choice lives in + # the HOST's user settings, and the old post-construction restore + # sat inside the ``if cfg.single_session:`` block below — so reading + # it here unguarded would newly apply the server operator's model to + # every client session on the multi-session --http transport. Same + # shape as the bypass-availability refusal further down ("would let + # the server host's own settings unlock bypass for every client + # session"); milder consequence, but a scope change should be a + # decision rather than a side effect of moving the read earlier. + model_choice = cfg.model or ( + get_persisted_model( + provider_name, provider_is_explicit=bool(cfg.provider_name) + ) + if cfg.single_session + else "" ) # ``model_choice`` may name a FUSION model, which is not a real model @@ -4917,15 +4931,14 @@ def _build_runtime(sess: _AgentSession, perm_mode: str | None) -> None: # ch03 round-4 GAP A — re-home the two-tier bridge: a per-session # AppState store whose on_change router runs the centralized side # effects (bootstrap model mirror + user-settings persistence). - # The seed applies a persisted /model choice back to the provider - # under seed_app_state_from_settings' provider-match guard — an - # explicit model (CLI/client cfg.model) always wins. The initial - # state carries the session's real launch permission mode (critic - # n5: seeding the default then dispatching the true mode would - # fire a spurious first mode-change notification). Gated - # single_session (same rule as ch02's env apply): user-level - # settings writes must not fire from client-supplied --http - # sessions. + # The persisted /model choice is applied ABOVE, pre-construction, + # via ``model_choice``; the seed no longer writes to the provider + # (see the note where the store is built). The initial state + # carries the session's real launch permission mode (critic n5: + # seeding the default then dispatching the true mode would fire a + # spurious first mode-change notification). Gated single_session + # (same rule as ch02's env apply): user-level settings writes must + # not fire from client-supplied --http sessions. if cfg.single_session: try: from src.state.app_state import ( diff --git a/tests/server/test_fusion_control.py b/tests/server/test_fusion_control.py index e9c29b68e..66a8a70ae 100644 --- a/tests/server/test_fusion_control.py +++ b/tests/server/test_fusion_control.py @@ -485,6 +485,24 @@ def test_a_declined_persisted_fusion_name_never_reaches_the_provider(self) -> No "would reach the wire", ) + def test_store_pinning_keeps_app_state_agreeing_with_the_provider(self) -> None: + # `_build_runtime` pins the seeded `main_loop_model` to the + # resolution actually in force. Without it the raw seed wins, and in + # the disabled-fusion case app state reports 'dsv' while the provider + # is on the plain default — the client would show a fusion model that + # is not running. Asserted on the pinning EXPRESSION, since exercising + # `_build_runtime` needs a full startup. + import inspect + + from src.server import agent_server as mod + + src = inspect.getsource(mod._build_runtime) + self.assertIn( + "main_loop_model=(fusion.name if fusion is not None else model)", src, + "the store seed is no longer pinned to the resolved selection — " + "app state can disagree with the provider that was built", + ) + def test_plain_model_switch_is_unaffected(self) -> None: with _IsolatedConfig(): sess, emitted = _make_session() diff --git a/tests/test_headless_cli.py b/tests/test_headless_cli.py index ab3455560..50ab2445b 100644 --- a/tests/test_headless_cli.py +++ b/tests/test_headless_cli.py @@ -354,3 +354,113 @@ def test_headless_empty_prompt_exits_2(fake_wiring, tmp_path): ) ) assert excinfo.value.code == 2 + + +# --------------------------------------------------------------------------- +# persisted-model resolution (the one deliberate behaviour change in the +# model-restore delta: headless previously ignored `settings.model` entirely, +# so a `/model` switch had to be re-stated with `--model` on every `-p` run) + + +def test_headless_uses_the_persisted_model_when_no_flag(fake_wiring, tmp_path, monkeypatch): + fake_wiring.append(_text_response("ok")) + monkeypatch.setattr( + "src.settings.settings.get_persisted_model", + lambda name, **kw: "persisted-model", + ) + seen: list[str | None] = [] + inner = headless_mod.get_provider_class + + def _capture(provider_name): + ctor = inner(provider_name) + + def _wrapped(api_key, base_url=None, model=None): + seen.append(model) + return ctor(api_key, base_url, model) + + return _wrapped + + monkeypatch.setattr(headless_mod, "get_provider_class", _capture) + run_headless( + HeadlessOptions( + prompt="hi", output_format="text", + stdout=io.StringIO(), stderr=io.StringIO(), workspace_root=tmp_path, + ) + ) + assert seen == ["persisted-model"] + + +def test_headless_explicit_model_beats_the_persisted_one(fake_wiring, tmp_path, monkeypatch): + # TS precedence (main.tsx:1984): explicit ?? persisted ?? default. + fake_wiring.append(_text_response("ok")) + monkeypatch.setattr( + "src.settings.settings.get_persisted_model", + lambda name, **kw: "persisted-model", + ) + seen: list[str | None] = [] + inner = headless_mod.get_provider_class + + def _capture(provider_name): + ctor = inner(provider_name) + + def _wrapped(api_key, base_url=None, model=None): + seen.append(model) + return ctor(api_key, base_url, model) + + return _wrapped + + monkeypatch.setattr(headless_mod, "get_provider_class", _capture) + run_headless( + HeadlessOptions( + prompt="hi", model="explicit-model", output_format="text", + stdout=io.StringIO(), stderr=io.StringIO(), workspace_root=tmp_path, + ) + ) + assert seen == ["explicit-model"] + + +def test_headless_falls_back_to_the_provider_default(fake_wiring, tmp_path, monkeypatch): + fake_wiring.append(_text_response("ok")) + monkeypatch.setattr("src.settings.settings.get_persisted_model", lambda name, **kw: "") + seen: list[str | None] = [] + inner = headless_mod.get_provider_class + + def _capture(provider_name): + ctor = inner(provider_name) + + def _wrapped(api_key, base_url=None, model=None): + seen.append(model) + return ctor(api_key, base_url, model) + + return _wrapped + + monkeypatch.setattr(headless_mod, "get_provider_class", _capture) + run_headless( + HeadlessOptions( + prompt="hi", output_format="text", + stdout=io.StringIO(), stderr=io.StringIO(), workspace_root=tmp_path, + ) + ) + assert seen == ["fake-model"] # provider_config default + + +def test_headless_passes_provider_is_explicit_when_provider_flagged( + fake_wiring, tmp_path, monkeypatch +): + # A persisted FUSION model replaces the session provider, so it must not + # win over a typed --provider; headless has to forward that signal. + fake_wiring.append(_text_response("ok")) + calls: list[dict] = [] + + def _spy(name, **kw): + calls.append({"name": name, **kw}) + return "" + + monkeypatch.setattr("src.settings.settings.get_persisted_model", _spy) + run_headless( + HeadlessOptions( + prompt="hi", provider_name="openrouter", output_format="text", + stdout=io.StringIO(), stderr=io.StringIO(), workspace_root=tmp_path, + ) + ) + assert calls and calls[0]["provider_is_explicit"] is True