Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,69 @@ 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, works as `--model <name>` 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.
- 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/

### 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
Expand Down
3 changes: 3 additions & 0 deletions src/command_system/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -168,6 +169,8 @@
"ThemeCommand",
"ECO_COMMAND",
"eco_command_call",
"FUSION_COMMAND",
"fusion_command_call",
"EFFORT_COMMAND",
"EffortCommand",
"MODEL_COMMAND",
Expand Down
2 changes: 2 additions & 0 deletions src/command_system/builtins.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1333,6 +1334,7 @@ def get_builtin_commands() -> list[Command]:
EXPORT_COMMAND,
THEME_COMMAND,
ECO_COMMAND,
FUSION_COMMAND,
EFFORT_COMMAND,
MODEL_COMMAND,
LOGO_COMMAND,
Expand Down
179 changes: 179 additions & 0 deletions src/command_system/fusion_command.py
Original file line number Diff line number Diff line change
@@ -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 <name> <base> <vision> save a new one
/fusion delete <name> remove one
/fusion enable|disable <name> toggle without losing config
/fusion help usage

``<base>`` and ``<vision>`` are ``provider:model`` selectors, matching
``/advisor``'s grammar. CCR's dialog reads

New model = Base model + Tools(vision → Vision model)

which is exactly ``create <name> <base> <vision>``.

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 <name> <base> <vision> | delete <name> |\n"
" enable <name> | disable <name>]\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 <name>.\n\n"
"<base> and <vision> are <provider>:<model> 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 <name> <provider:model> <provider:model>\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 <name>.")
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 [<name>] <base> <vision>\n\n"
" <base> the reasoning model, as <provider>:<model>\n"
" <vision> a vision-capable model, as <provider>:<model>\n\n"
"Omit <name> 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 <name>"
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} <name>"
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 <name> <base> <vision> | delete|enable|disable <name>]",
# 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 <fusion>
# 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"]
79 changes: 70 additions & 9 deletions src/entrypoints/headless.py
Original file line number Diff line number Diff line change
Expand Up @@ -149,26 +149,87 @@ def run_headless(options: HeadlessOptions) -> int:
start_deferred_prefetches(cwd=str(workspace_root))

provider_name = options.provider_name or get_default_provider()

# 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.
# 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(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 "
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
# or behavior. Headless is non-interactive → the helper exits(2) on
# 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 = model_choice 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 ""))

Expand Down
Loading
Loading