Skip to content
Open
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
60 changes: 49 additions & 11 deletions src/command_system/model_command.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,10 @@
``kwargs.get("model", self.model)`` and neither the main loop nor the fast path passes a
``model=`` override, so the held provider's ``.model`` decides the next query's model. So
this command sets **``ctx.provider.model``** (the channel inference reads), reachable on the
REPL because Phase 7 also wires ``provider`` into the REPL command context. It is the **only**
write: the reactive ``AppState.main_loop_model`` has no production reader (its
``set_main_loop_model_override`` mirror is dead), and TS ``/model`` never writes disk — so
this is session-only, no settings write.
REPL because Phase 7 also wires ``provider`` into the REPL command context. Since #280 the
choice is ALSO persisted: ``_apply`` routes through ``persist_model_choice`` (reactive store
when wired, else a direct user-settings write paired with the provider key), and entrypoints
restore it at the next launch via ``get_persisted_model``.

**Headless keystone:** the arg paths (``/model <name>``, ``current``/``status``/…, ``help``)
need no UI; only the no-args picker needs a surface (``NullUIHost.select`` raises there).
Expand All @@ -31,8 +31,8 @@
the **exact listed id**; the picker is the ergonomic path there.
* **Static description** ("Set the AI model"); TS's is dynamic ``…(currently {model})`` — a
frozen ``CommandBase.description: str`` can't be a getter. ``current`` shows the live model.
* **``provider.model`` is the sole write** (Python reads it, not AppState — an architectural
divergence from TS, which writes ``AppState.mainLoopModel``).
* **``provider.model`` is the live-inference write**; ``persist_model_choice`` additionally
writes the reactive store / user settings so the choice survives restarts (#280).
* **Effort suffix in ``current``** reads ``settings.effort`` (the Phase 6 channel), not
AppState ``effortValue``.
* **Label = ``display_name``** (drops TS ``renderModelLabel``'s ``(default)``/alias decoration).
Expand Down Expand Up @@ -116,13 +116,51 @@ def _show_current(context: CommandContext) -> str:
return f"Current model: {_label(cur)}{_effort_suffix()}"


def _apply(provider, model: str) -> None:
"""Set the live model. ``provider.model`` is the channel inference reads; guarded
exactly like the TUI's ``_open_model_picker`` (app.py)."""
def _provider_key(provider) -> str | None:
"""Reverse-map a provider instance to its config key (exact class
match — each key resolves a distinct class). None for unknown/custom
providers, which skips the persistence pairing (#280)."""
try:
from src.providers import PROVIDER_INFO, get_provider_class

for name in PROVIDER_INFO:
try:
if type(provider) is get_provider_class(name):
return name
except Exception:
continue
except Exception:
pass
return None


def _apply(provider, model: str, context) -> None:
"""Set the live model + persist the choice (#280).

``provider.model`` is the channel inference reads; guarded exactly
like the TUI's ``_open_model_picker`` (app.py). Persistence goes
through ``persist_model_choice``: via the reactive store when wired
(fires the side-effect router), else straight to user settings.
"""
try:
provider.model = model
except Exception:
pass
try:
from src.state.app_state import persist_model_choice

persist_model_choice(
getattr(context, "app_state_store", None),
_provider_key(provider),
model,
)
except Exception:
# The live switch already took effect; only restarts lose it.
import logging

logging.getLogger(__name__).debug(
"model persistence failed", exc_info=True
)


@dataclass(frozen=True)
Expand Down Expand Up @@ -161,7 +199,7 @@ async def _pick(self, context: CommandContext) -> InteractiveOutcome:
return InteractiveOutcome(
message=f"Kept model as {_label(current)}", display="system"
)
_apply(prov, picked)
_apply(prov, picked, context)
return InteractiveOutcome(message=f"Set model to {_label(picked)}", display="user")

def _set(self, context: CommandContext, arg: str) -> InteractiveOutcome:
Expand All @@ -174,7 +212,7 @@ def _set(self, context: CommandContext, arg: str) -> InteractiveOutcome:
# provider lists nothing (unknown provider) so a valid id still goes through.
if models and canon not in models:
return InteractiveOutcome(message=f"Model '{arg}' not found", display="system")
_apply(prov, canon)
_apply(prov, canon, context)
return InteractiveOutcome(message=f"Set model to {_label(canon)}", display="user")


Expand Down
8 changes: 7 additions & 1 deletion src/entrypoints/headless.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,13 @@ def run_headless(options: HeadlessOptions) -> int:
)

provider_cls = get_provider_class(provider_name)
model = options.model or provider_cfg.get("default_model")
from src.settings.settings import get_persisted_model

model = (
options.model
or get_persisted_model(provider_name)
or provider_cfg.get("default_model")
)
provider = provider_cls(
api_key=provider_cfg["api_key"],
base_url=provider_cfg.get("base_url"),
Expand Down
8 changes: 7 additions & 1 deletion src/entrypoints/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,13 @@ def run_tui(options: TUIOptions) -> int:
2,
)
provider_cls = get_provider_class(provider_name)
model = options.model or provider_cfg.get("default_model")
from src.settings.settings import get_persisted_model

model = (
options.model
or get_persisted_model(provider_name)
or provider_cfg.get("default_model")
)
provider = provider_cls(
api_key=provider_cfg["api_key"],
base_url=provider_cfg.get("base_url"),
Expand Down
7 changes: 5 additions & 2 deletions src/repl/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -416,12 +416,15 @@ def __init__(
self.console.print("Run [bold]clawcodex login[/bold] to configure.")
sys.exit(1)

# Initialize provider
# Initialize provider. The persisted /model choice (#280) wins
# over the provider's configured default.
from src.settings.settings import get_persisted_model

provider_class = get_provider_class(provider_name)
self.provider = provider_class(
api_key=config["api_key"],
base_url=config.get("base_url"),
model=config.get("default_model")
model=get_persisted_model(provider_name) or config.get("default_model")
)

# Create session
Expand Down
42 changes: 42 additions & 0 deletions src/settings/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,3 +65,45 @@ def get_settings(
if _settings_cache is None:
_settings_cache = load_settings(config_manager=config_manager, cwd=cwd)
return _settings_cache


def get_persisted_model(
provider_name: str | None,
*,
cwd: str | Path | None = None,
) -> str | None:
"""The user's persisted /model choice for ``provider_name``, or None.

Restore channel for #280: entrypoints resolve the startup model as
``cli option > persisted model > provider default_model``. The model
is restored only when the persisted ``model_provider`` pairing
matches the launch provider — model names are provider-scoped in a
multi-provider config, and feeding provider B a model persisted on
provider A would fail the first API call.

Reads the config layers directly (most specific non-empty model
wins: local > project > global; an empty ``model`` does NOT mask a
less-specific layer) rather than the merged ``SettingsSchema`` — the
schema bakes in a DEFAULT_SETTINGS.model, which must NOT override a
provider's configured default for users who never ran /model.
Fail-soft — a broken settings file must not block startup.
"""
try:
manager = ConfigManager(cwd=cwd)
for cfg in (
manager.load_local(),
manager.load_project(),
manager.load_global(),
):
section = cfg.get("settings")
if isinstance(section, dict) and section.get("model"):
persisted_provider = section.get("model_provider") or ""
if provider_name and persisted_provider == provider_name:
return str(section["model"])
# Unpaired or mismatched: the persisted model belongs to
# another provider (or predates the pairing) — fall back
# to the provider's own default.
return None
return None
except Exception:
return None
13 changes: 13 additions & 0 deletions src/settings/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,19 @@ class SettingsSchema:
# Fast mode (use small model)
fast_mode: bool = False

# Display preferences persisted by the AppState side-effect router
# (#280; TS onChangeAppState.ts:123-136). ``expanded_view`` stores the
# Python store's own 'none' | 'tasks' | 'teammates' shape directly
# rather than the TS legacy showExpandedTodos/showSpinnerTree boolean
# pair — this port's config file is not shared with TS Claude Code.
verbose: bool = False
expanded_view: str = "" # "" = unset (AppState default applies)
# Provider key paired with the persisted ``model`` (#280): a /model
# choice is only restored when the next launch uses the same provider
# (the advisor_model/advisor_provider precedent — model names are
# provider-scoped in a multi-provider config).
model_provider: str = ""

# Disable dynamic workflows (also honored via CLAUDE_CODE_DISABLE_WORKFLOWS
# and the camelCase ``disableWorkflows`` JSON key). See src/workflow/gating.py.
disable_workflows: bool = False
Expand Down
Loading