diff --git a/src/agent/agent_definitions.py b/src/agent/agent_definitions.py index c1be5543d..4444e78f0 100644 --- a/src/agent/agent_definitions.py +++ b/src/agent/agent_definitions.py @@ -24,7 +24,10 @@ class AgentDefinition: tools: list[str] | None = None # None or ['*'] means all tools source: AgentSource = "built-in" base_dir: str = "built-in" - model: str | None = None # None → inherit parent, 'inherit' → force inherit + # None → the provider's default subagent model (PROVIDER_INFO + # ``subagent_model``; inherits the parent when the provider declares + # none). 'inherit' → always the parent's model. + model: str | None = None permission_mode: PermissionMode | None = None max_turns: int | None = None background: bool = False @@ -96,7 +99,8 @@ def _general_purpose_system_prompt(**_kwargs: Any) -> str: tools=["*"], source="built-in", base_dir="built-in", - # model intentionally omitted — uses default subagent model + # model intentionally omitted — uses the provider's default subagent + # model (the cheap fan-out tier; see PROVIDER_INFO subagent_model) get_system_prompt=_general_purpose_system_prompt, ) @@ -160,8 +164,10 @@ def _explore_system_prompt(**_kwargs: Any) -> str: omit_clawcodex_md=True, # ch08 round-4 (critic M1) — Explore is the fast/cheap read-only agent; # TS exploreAgent.ts:77 runs it on Haiku. get_agent_model resolves this - # against the session provider and inherits on providers that don't - # serve haiku (e.g. DeepSeek), so it is cross-provider safe. + # per provider via the ``subagent_tier_models`` tables (anthropic → + # claude-haiku-4-5, deepseek → deepseek-v4-flash) and inherits on + # providers without a haiku-class mapping, so it is cross-provider + # safe. model="haiku", get_system_prompt=_explore_system_prompt, ) diff --git a/src/agent/agent_model.py b/src/agent/agent_model.py index 62e499fb4..f10e125ab 100644 --- a/src/agent/agent_model.py +++ b/src/agent/agent_model.py @@ -1,14 +1,52 @@ -"""ch08 round-4 WI-1 — per-subagent model resolution. +"""Per-subagent model resolution. Port of TS ``getAgentModel`` (``utils/model/agent.ts``, called at ``runAgent.ts:340``): resolve the model a subagent should run on from the tool ``model`` param, the agent definition's ``model:`` frontmatter, and the session model, with a ``CLAUDE_CODE_SUBAGENT_MODEL`` env override. -Multi-provider guard: the port runs on 7+ providers, and the abstract -aliases (``sonnet``/``opus``/``haiku``) only map on Anthropic-family -providers. So an alias/id the SESSION provider doesn't recognize falls -back to the session model rather than 400-ing the request. +Per-provider defaults (2026-08): each provider's row in +``src.providers.PROVIDER_INFO`` may declare + +* ``subagent_tier_models`` — the resolution targets for bare tier aliases + (anthropic haiku → ``claude-haiku-4-5``; deepseek haiku → + ``deepseek-v4-flash``). This is the port of the TS reference's + per-provider ``getDefault{Opus,Sonnet,Haiku}Model()`` functions. +* ``subagent_model`` — the model subagents run on when neither the Agent + tool call nor the agent definition names one (anthropic: + ``claude-haiku-4-5``, the cheapest current-gen tier; deepseek: + ``deepseek-v4-flash``), overridable per provider via + ``providers..subagent_model`` in config.json. NOTE this is a + DELIBERATE DIVERGENCE from both references, by explicit user directive + (cheap fan-outs): TS ``getDefaultSubagentModel()`` returns ``'inherit'`` + and opencode's task tool inherits the parent model (its per-provider + ``getSmallModel`` serves title/name side calls — the config knob here + borrows that ``small_model`` spelling, not its call sites). Escape + hatches: ``model: inherit`` in an agent definition or tool call, + ``providers..subagent_model = "inherit"`` in config, or + ``CLAUDE_CODE_SUBAGENT_MODEL=inherit``; the coordinator path pins + ``inherit`` at the tool layer since workers cannot pass a model param. + +Before this table existed the aliases resolved through the static global +``MODEL_ALIASES`` map, whose targets had been retired from the live API — +every Explore spawn on an Anthropic session died with a 404 +``not_found_error`` (claude-3-5-haiku-20241022), and DeepSeek sessions +silently ran every subagent on the expensive session model. + +Multi-provider guard: providers WITHOUT a subagent table keep the reference +behavior — an alias/id the session provider doesn't recognize falls back to +the session model rather than 400-ing the request, and an unspecified model +inherits. A custom Anthropic-compatible endpoint (proxy / self-hosted) also +inherits rather than trusting the first-party table, mirroring TS +``checkIsClaudeNativeProvider``. + +Deliberate asymmetry: a KNOWN alias whose canonical target is retired +degrades to inherit through the availability gate (``h35``, +``claude-3.5-haiku``), but an explicitly-pinned FULL id — even a retired +one — is trusted verbatim and will 404. An explicit full id means the user +is naming a deployment the static catalog can't know about (proxies, +Bedrock shims), and second-guessing it would break those; the alias +spellings carry no such claim. Concurrency (ch07): Agent is now concurrency-safe, so N parallel subagents share the session ``provider`` instance. This module only @@ -24,14 +62,139 @@ logger = logging.getLogger(__name__) _INHERIT = "inherit" -# Bare family aliases (TS agent.ts). A request for one of these that -# matches the parent's TIER keeps the parent's EXACT model rather than -# downgrading to the alias's canonical (older) target. -_FAMILY_ALIASES = ("opus", "sonnet", "haiku") +# Bare family aliases (TS agent.ts + clawcodex's ``fable`` tier). A request +# for one of these that matches the parent's TIER keeps the parent's EXACT +# model rather than downgrading to the alias's canonical (older) target. +_FAMILY_ALIASES = ("opus", "sonnet", "haiku", "fable") + +# Operator env pins for the tier aliases, honored only on the ANTHROPIC +# provider. They bypass availability gating (an operator who sets one is +# naming a Bedrock/proxy deployment the static catalog can't know about), +# which is exactly why they must not leak across providers: an +# ANTHROPIC_DEFAULT_HAIKU_MODEL exported for a Bedrock setup would +# otherwise ship an Anthropic id to a DeepSeek session on every Explore +# spawn — a hard 400. Deliberate divergence from TS, which consults these +# env vars before its provider dispatch (the vars are ANTHROPIC_-named; +# honoring them on other vendors' wires trades a certain 400 for parity +# nobody wants). +_TIER_ENV_OVERRIDES = { + "opus": "ANTHROPIC_DEFAULT_OPUS_MODEL", + "sonnet": "ANTHROPIC_DEFAULT_SONNET_MODEL", + "haiku": "ANTHROPIC_DEFAULT_HAIKU_MODEL", +} + + +def _unwrap(provider: Any) -> Any: + """The provider that owns the wire (FusionProvider delegates via .inner).""" + try: + from src.providers import unwrap_provider + + return unwrap_provider(provider) + except Exception: # noqa: BLE001 — identity is best-effort + return provider + + +def _provider_id(session_provider: Any) -> str: + """The unwrapped provider's canonical id ("" when unregistered).""" + return getattr(_unwrap(session_provider), "provider_id", "") or "" + + +def _is_custom_anthropic(session_provider: Any) -> bool: + """An anthropic provider pointed at a non-first-party endpoint. + + TS ``checkIsClaudeNativeProvider``: only the first-party endpoint has a + guaranteed catalog — a proxy / self-hosted shim may serve none of the + first-party ids, so both the subagent table and the haiku/sonnet alias + resolution are disabled for it (subagents inherit instead). NOTE + ``has_custom_endpoint`` is a METHOD on AnthropicProvider (not a + property) — it must be called, or the truthy bound method would flag + every anthropic session as custom. + """ + if _provider_id(session_provider) != "anthropic": + return False + try: + return bool(_unwrap(session_provider).has_custom_endpoint()) + except Exception: # noqa: BLE001 — endpoint check is best-effort + return False + + +def _provider_info_row(session_provider: Any) -> dict[str, Any]: + """The session provider's PROVIDER_INFO row, or ``{}``. + + Empty when the provider carries no ``provider_id`` (unregistered) or is + an anthropic provider on a custom endpoint — both mean "no subagent + table", so every lookup falls through to the reference (inherit) + behavior. + """ + provider_id = _provider_id(session_provider) + if not provider_id: + return {} + if _is_custom_anthropic(session_provider): + return {} + try: + from src.providers import PROVIDER_INFO + + return dict(PROVIDER_INFO.get(provider_id) or {}) + except Exception: # noqa: BLE001 — registry lookup is best-effort + return {} + + +def _subagent_tier_models(session_provider: Any) -> dict[str, str]: + """The provider's ``subagent_tier_models`` map (bare alias → model).""" + tiers = _provider_info_row(session_provider).get("subagent_tier_models") + out: dict[str, str] = {} + if isinstance(tiers, dict): + for tier, model in tiers.items(): + if isinstance(tier, str) and isinstance(model, str) and model.strip(): + out[tier.strip().lower()] = model.strip() + return out + + +def _registry_subagent_default(session_provider: Any) -> str: + """The provider's registry ``subagent_model`` default (or ``""``).""" + default = _provider_info_row(session_provider).get("subagent_model") + return default.strip() if isinstance(default, str) else "" + + +def _config_subagent_model(session_provider: Any) -> str: + """A user-configured ``providers..subagent_model`` (or ``""``). + + The per-provider knob opencode spells ``small_model``: a user override + for what unspecified-model subagents run on. The RAW configured string — + the caller resolves it like any other user-specified model (so + ``inherit`` and the bare tier aliases work, and a full id is trusted + literally rather than availability-gated). + + Trust note: ``providers`` is one of config.py's + ``_UNTRUSTED_TIER_BLOCKED_KEYS``, so a committable per-repo config + cannot set this knob while the session is untrusted — load-bearing, + since the value is trusted onto the wire. Don't relocate the knob to a + flat settings key without re-establishing that guarantee. + """ + provider_id = _provider_id(session_provider) + if not provider_id: + return "" + try: + from src.config import get_provider_config + + value = (get_provider_config(provider_id) or {}).get("subagent_model") + except Exception: # noqa: BLE001 — config read is best-effort + return "" + return value.strip() if isinstance(value, str) else "" + + +def _provider_serves(model: str, session_provider: Any) -> bool: + """Whether the session provider's catalog lists ``model``.""" + try: + available = session_provider.get_available_models() or [] + except Exception: # noqa: BLE001 — provider can't enumerate → not servable + return False + return model in [str(m) for m in available] def _resolve_against_provider( - value: str, session_provider: Any, *, trust_literal: bool = False, + value: str, session_provider: Any, *, + trust_literal: bool = False, quiet: bool = False, ) -> str: """Resolve an alias/id to the model the subagent should run on; inherit the session model on a miss. Never raises. @@ -39,11 +202,17 @@ def _resolve_against_provider( - ``'inherit'``/empty → the session model. - A bare family alias whose tier == the parent's tier → the parent's EXACT model (critic M2 — TS ``aliasMatchesParentTier``; avoids the - surprising same-tier downgrade, e.g. sonnet-4-6 → sonnet-4-2025...). + surprising same-tier downgrade, e.g. sonnet-4-6 → an older sonnet). + - A bare family alias with an ``ANTHROPIC_DEFAULT__MODEL`` env pin + → that pin, verbatim (TS getDefault*Model consults env first). + - A bare family alias the session provider maps in its + ``subagent_tier_models`` table → that model (availability-gated, so a + stale table row degrades to inherit instead of a 404). - A full (non-alias) model id → trusted literally when ``trust_literal`` (the env override / an explicit id — critic M3, TS ``parseUserSpecifiedModel``); otherwise gated by availability. - - An alias mapped to a model the provider serves → that canonical id. + - An alias mapped by the global table to a model the provider serves → + that canonical id. - Anything the provider doesn't serve → the session model. """ session_model = getattr(session_provider, "model", "") or "" @@ -51,35 +220,85 @@ def _resolve_against_provider( if not normalized or normalized == _INHERIT: return session_model + is_bare_alias = normalized in _FAMILY_ALIASES + # M2 — same-tier alias keeps the parent's exact model. - if normalized in _FAMILY_ALIASES and normalized in session_model.lower(): + if is_bare_alias and normalized in session_model.lower(): return session_model + if is_bare_alias: + # Env pins apply only on the anthropic provider (incl. custom + # endpoints — pinning a proxy/Bedrock deployment name is their + # whole use case); see _TIER_ENV_OVERRIDES. + if _provider_id(session_provider) == "anthropic": + env_var = _TIER_ENV_OVERRIDES.get(normalized, "") + env_pin = os.environ.get(env_var, "") if env_var else "" + if env_pin.strip(): + return env_pin.strip() + # TS agent.ts:105-115 — haiku/sonnet on a non-Claude-native endpoint + # inherit the parent model outright (the global alias targets are + # first-party ids a proxy has no obligation to serve). 'opus' (and + # 'fable') deliberately fall through, matching the TS asymmetry. + # Deliberate divergence from TS: the guard applies to the tool-param + # path too (TS returns from its toolSpecifiedModel branch before the + # guard) — on a proxy an explicit tool 'haiku' inherits here, which + # is the safe direction. Reached also by an alias-valued + # providers..subagent_model knob on a proxy session, hence the + # trace: naming a full id is the working spelling there. + if normalized in ("haiku", "sonnet") and _is_custom_anthropic( + session_provider + ): + logger.debug( + "alias %r on a custom Anthropic endpoint inherits the " + "session model %r (first-party tier ids are not assumed " + "served there; pin a full model id to override)", + value, session_model, + ) + return session_model + tier_model = _subagent_tier_models(session_provider).get(normalized, "") + if tier_model: + if tier_model == session_model or _provider_serves( + tier_model, session_provider + ): + return tier_model + logger.log( + logging.DEBUG if quiet else logging.WARNING, + "provider tier model %r for alias %r is not in the session " + "provider's catalog; inheriting the session model %r", + tier_model, value, session_model, + ) + return session_model + # No tier table for this provider — fall through to the global + # alias path below, which availability-gates and inherits on a + # miss (e.g. 'haiku' on a provider with no haiku-class model). + + cleaned = (value or "").strip() try: from src.models.model import canonical_model_name - canonical = canonical_model_name(value) + canonical = canonical_model_name(cleaned) except Exception: # noqa: BLE001 — resolution failure → inherit return session_model - # M3 — a full id (canonical didn't change it, i.e. not a known alias) - # from the env override or an explicit pin is trusted literally, so it - # survives static-list staleness / proxy deployments with custom names. - is_bare_alias = normalized in _FAMILY_ALIASES - if trust_literal and not is_bare_alias: - return value + # M3 — an id NO alias table knows (canonical == the input) from the env + # override or an explicit pin is trusted literally, so it survives + # static-list staleness / proxy deployments with custom names. A known + # alias spelling (canonical != input: 'claude-haiku', 'claude-4-sonnet', + # 'h35', …) is never trusted raw — the alias string itself is not a + # servable model id, so it resolves to its canonical target and takes + # the availability gate below like any other alias. + if trust_literal and canonical == cleaned: + return cleaned - try: - available = session_provider.get_available_models() or [] - available = [str(m) for m in available] - except Exception: # noqa: BLE001 — provider can't enumerate → inherit - available = [] - if canonical in available: + if _provider_serves(canonical, session_provider): return canonical - # Not served by this provider (e.g. 'haiku' on a DeepSeek session). - # Inherit rather than 400. Elevated to WARNING (M3) so an ignored - # explicit pin is observable, not silently dropped at debug. - logger.warning( + # Not served by this provider (e.g. 'haiku' on a provider with no + # tier table and no such model). Inherit rather than 400. Elevated to + # WARNING (M3) so an ignored explicit pin is observable, not silently + # dropped at debug — except under ``quiet`` (the Agent tool's + # reporting-only re-resolution), which would double every warning. + logger.log( + logging.DEBUG if quiet else logging.WARNING, "agent model %r (→ %r) is not available on the session provider; " "inheriting the session model %r", value, canonical, session_model, @@ -87,26 +306,77 @@ def _resolve_against_provider( return session_model +def get_default_subagent_model( + session_provider: Any, *, quiet: bool = False, +) -> str: + """The model an unspecified-model subagent runs on. + + Precedence: the user's ``providers..subagent_model`` config knob + (resolved like any user-specified model, so ``inherit``, bare tier + aliases, and full ids all behave — a full id is trusted literally) > + the provider's ``subagent_model`` registry default (availability-gated) + > the session model (inherit — the TS ``getDefaultSubagentModel()`` + behavior, and the behavior of every provider that designates no + default). + """ + session_model = getattr(session_provider, "model", "") or "" + + configured = _config_subagent_model(session_provider) + if configured: + # Through the same resolver as an explicit pin: 'inherit' → session + # model, 'sonnet'/'haiku'/… → the provider tier table, a full id → + # trusted verbatim. Returning the raw string here shipped alias + # spellings (and the literal 'inherit') onto the wire as model ids. + return _resolve_against_provider( + configured, session_provider, trust_literal=True, quiet=quiet, + ) + + default = _registry_subagent_default(session_provider) + if default and default != session_model: + if _provider_serves(default, session_provider): + return default + logger.log( + logging.DEBUG if quiet else logging.WARNING, + "provider default subagent model %r is not in the session " + "provider's catalog; inheriting the session model %r", + default, session_model, + ) + return session_model + + def get_agent_model( tool_model: str | None, agent_def_model: str | None, session_provider: Any, + *, + quiet: bool = False, ) -> str: """Resolve the subagent's model. Precedence (TS getAgentModel): ``CLAUDE_CODE_SUBAGENT_MODEL`` env > tool ``model`` param > agent-def - ``model:`` > ``'inherit'`` (= the session model). Always returns a - non-empty model when the session provider has one; never raises.""" + ``model:`` > the provider's default subagent model (falling back to + ``'inherit'`` = the session model). Always returns a non-empty model + when the session provider has one; never raises. + + ``quiet`` demotes the fallback warnings to debug — for callers that + re-resolve purely for REPORTING (the Agent tool surfaces the routed + model in its result), so each spawn warns once, from the wire-authority + resolution in run_agent.""" env_override = os.environ.get("CLAUDE_CODE_SUBAGENT_MODEL") if env_override: # M3 — the env override is honored more literally (a full id it # names is trusted; TS agent.ts:43-45 bypasses provider gating). return _resolve_against_provider( - env_override, session_provider, trust_literal=True, + env_override, session_provider, trust_literal=True, quiet=quiet, ) - chosen = tool_model or agent_def_model or _INHERIT + # Trim per layer (TS trims toolSpecifiedModel before testing it), so a + # whitespace-only tool param falls through to the agent-def model + # rather than swallowing it. + chosen = (tool_model or "").strip() or (agent_def_model or "").strip() + if not chosen: + return get_default_subagent_model(session_provider, quiet=quiet) # A tool param / frontmatter that names a full model id is trusted # literally; bare aliases still go through availability/tier logic. return _resolve_against_provider( - chosen, session_provider, trust_literal=True, + chosen, session_provider, trust_literal=True, quiet=quiet, ) diff --git a/src/agent/run_agent.py b/src/agent/run_agent.py index e361ca9c3..2149680f3 100644 --- a/src/agent/run_agent.py +++ b/src/agent/run_agent.py @@ -361,11 +361,13 @@ async def run_agent(params: RunAgentParams) -> AsyncGenerator[Message, None]: # ch08 round-4 WI-1 — per-subagent model resolution (TS getAgentModel, # runAgent.ts:340). Resolve the model from the tool param / agent-def / - # env, then apply it to a per-subagent provider CLONE. NEVER mutate the - # shared session provider: ch07 made Agent concurrency-safe, so N - # parallel subagents share params.provider — mutating provider.model - # would race across them. copy.copy shares the HTTP client (thread-safe, - # per-request model) and gives this subagent its own .model. + # env / the provider's default subagent model (PROVIDER_INFO + # ``subagent_model``), then apply it to a per-subagent provider CLONE. + # NEVER mutate the shared session provider: ch07 made Agent + # concurrency-safe, so N parallel subagents share params.provider — + # mutating provider.model would race across them. copy.copy shares the + # HTTP client (thread-safe, per-request model) and gives this subagent + # its own .model. turn_provider = params.provider try: from .agent_model import get_agent_model @@ -378,6 +380,16 @@ async def run_agent(params: RunAgentParams) -> AsyncGenerator[Message, None]: ): turn_provider = copy.copy(params.provider) turn_provider.model = resolved_model + # Debug trace of the routing — nothing in the app configures a + # level that shows INFO, so don't pretend otherwise. The + # USER-facing surfaces are the Agent tool's result/registry + # "model" field and the agent-progress emits (tools/agent.py + # resolves the same inputs for reporting). + logger.debug( + "subagent %s runs on model %s (session model %s)", + agent_def.agent_type, resolved_model, + getattr(params.provider, "model", None), + ) except Exception: # noqa: BLE001 — model resolution never blocks a spawn logger.debug("subagent model resolution failed; using session model", exc_info=True) diff --git a/src/command_system/rename_command.py b/src/command_system/rename_command.py index 55d0719b4..86f51b96d 100644 --- a/src/command_system/rename_command.py +++ b/src/command_system/rename_command.py @@ -93,7 +93,7 @@ async def _generate_session_name(messages: Any) -> str | None: default_headers=get_anthropic_custom_headers() or None ) result = client.messages.create( - model="claude-3-5-haiku-20241022", + model="claude-haiku-4-5", max_tokens=100, system=_NAME_PROMPT, messages=[{"role": "user", "content": text[:4000]}], diff --git a/src/hooks/exec_agent_hook.py b/src/hooks/exec_agent_hook.py index 01f0ea452..f3d11a550 100644 --- a/src/hooks/exec_agent_hook.py +++ b/src/hooks/exec_agent_hook.py @@ -59,7 +59,7 @@ async def execute_agent_hook( messages = [{"role": "user", "content": user_prompt}] system_prompt = AGENT_HOOK_SYSTEM_PROMPT - effective_model = model or "claude-sonnet-4-20250514" + effective_model = model or "claude-sonnet-5" if hasattr(provider, "chat_async"): response = await provider.chat_async( diff --git a/src/memdir/find_relevant_memories.py b/src/memdir/find_relevant_memories.py index 62ff3eb0b..93ca9f3df 100644 --- a/src/memdir/find_relevant_memories.py +++ b/src/memdir/find_relevant_memories.py @@ -34,7 +34,7 @@ def _resolve_recall_model(provider: Any) -> str | None: TS pins the selector to a small default (``getDefaultSonnetModel``) so a turn on an expensive session model doesn't pay full price for the recall call. The multi-provider port wires the ``small_fast_model`` setting — - BUT its shipped default is an Anthropic id (``claude-3-5-haiku-…``, + BUT its shipped default is an Anthropic id (``claude-haiku-4-5``, settings/constants.py), which is only valid on the first-party Anthropic endpoint. Passing it to a DeepSeek/OpenAI/Minimax session would 400 and (since recall swallows errors) silently kill recall every turn (critic diff --git a/src/models/__init__.py b/src/models/__init__.py index 3bfd60372..7be3c2f5c 100644 --- a/src/models/__init__.py +++ b/src/models/__init__.py @@ -27,13 +27,16 @@ get_context_window_for_model, get_model_max_output_tokens, ) -from .agent_routing import get_model_for_agent, AgentModelConfig +# NOTE: the old ``agent_routing`` module (get_model_for_agent / +# AgentModelConfig) was deleted 2026-08: it was a dead parallel path with +# inherit-parent semantics that contradicted the shipped resolver +# (src/agent/agent_model.get_agent_model), and nothing wrote the +# ``agent_models`` config key it read. __all__ = [ "BEDROCK_MODEL_MAP", "MODEL_ALIASES", "MODEL_CONFIGS", - "AgentModelConfig", "ModelConfig", "canonical_model_name", "deprecation_warning", @@ -42,7 +45,6 @@ "get_context_window_for_model", "get_model_capabilities", "get_model_config", - "get_model_for_agent", "get_model_max_output_tokens", "is_model_allowed", "resolve_alias", diff --git a/src/models/agent_routing.py b/src/models/agent_routing.py deleted file mode 100644 index 5c8be5ce7..000000000 --- a/src/models/agent_routing.py +++ /dev/null @@ -1,37 +0,0 @@ -"""Per-agent model routing matching TypeScript model/agent.ts.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from typing import Any - - -@dataclass -class AgentModelConfig: - """Model configuration for an agent.""" - model: str = "" - inherit_parent: bool = True - - -def get_model_for_agent( - agent_type: str, - *, - parent_model: str = "", - config: dict[str, Any] | None = None, -) -> str: - """Resolve which model an agent should use. - - Priority: - 1. Agent-specific model from config - 2. Agent definition model - 3. Parent model (inheritance) - """ - if config: - agent_models = config.get("agent_models", {}) - if agent_type in agent_models: - model = agent_models[agent_type] - if isinstance(model, str) and model: - return model - - # Default: inherit parent model - return parent_model diff --git a/src/models/aliases.py b/src/models/aliases.py index 5b0cd6696..58849be57 100644 --- a/src/models/aliases.py +++ b/src/models/aliases.py @@ -1,21 +1,49 @@ -"""Model alias table matching TypeScript model/aliases.ts.""" +"""Model alias table matching TypeScript model/aliases.ts. + +The bare family aliases (``sonnet`` / ``opus`` / ``haiku`` / ``fable``) track +the CURRENT first-party model of each family — the TS reference resolves +them through ``getDefaultSonnetModel()``-style functions that are updated at +every model launch (the ``@[MODEL LAUNCH]`` markers in model.ts). Targets +below were verified against the live ``GET /v1/models`` catalog 2026-08-12: +the API no longer serves ``claude-sonnet-4-20250514`` or +``claude-3-5-haiku-20241022`` (404 ``not_found_error``), so pointing an +alias at them turns every ``--model sonnet`` / Explore-agent spawn into a +hard API error. + +Note on ``haiku``: the live catalog LISTS only the dated +``claude-haiku-4-5-20251001``, but the bare ``claude-haiku-4-5`` resolves +server-side to it (probed live 2026-08-12 — the API's max_tokens 400 names +the dated id), so the alias uses the bare form like every other current +target. + +Per-provider SUBAGENT tier resolution (e.g. ``haiku`` on a DeepSeek +session) does not use this table — see ``subagent_model`` / +``subagent_tier_models`` in ``src/providers/__init__.py`` and +``src/agent/agent_model.py``. +""" from __future__ import annotations MODEL_ALIASES: dict[str, str] = { - # Short names → canonical - "sonnet": "claude-sonnet-4-20250514", - "opus": "claude-opus-4-20250514", - "haiku": "claude-3-5-haiku-20241022", - - # Version aliases - "claude-4-sonnet": "claude-sonnet-4-20250514", - "claude-4-opus": "claude-opus-4-20250514", - "claude-sonnet": "claude-sonnet-4-20250514", - "claude-opus": "claude-opus-4-20250514", - "claude-haiku": "claude-3-5-haiku-20241022", - - # Legacy aliases + # Short names → canonical (current family heads) + "sonnet": "claude-sonnet-5", + "opus": "claude-opus-5", + "haiku": "claude-haiku-4-5", + "fable": "claude-fable-5", + + # Version aliases. The explicitly-versioned ``claude-4-*`` spellings + # point at the newest LIVE 4.x snapshot of their family (the dated + # 2025-05-14 ids they used to target were retired from the API). + "claude-4-sonnet": "claude-sonnet-4-6", + "claude-4-opus": "claude-opus-4-8", + "claude-sonnet": "claude-sonnet-5", + "claude-opus": "claude-opus-5", + "claude-haiku": "claude-haiku-4-5", + "claude-fable": "claude-fable-5", + + # Legacy aliases — explicit historical pins, kept verbatim. These name a + # specific retired generation on purpose; the API answers 404 for them, + # which is more honest than silently substituting a different model. "claude-3.5-sonnet": "claude-3-5-sonnet-20241022", "claude-3.5-haiku": "claude-3-5-haiku-20241022", "claude-3-sonnet": "claude-3-sonnet-20240229", @@ -23,9 +51,13 @@ "claude-3-haiku": "claude-3-haiku-20240307", "claude-3.7-sonnet": "claude-3-7-sonnet-20250219", - # Common typos / shortcuts - "s4": "claude-sonnet-4-20250514", - "o4": "claude-opus-4-20250514", + # Common typos / shortcuts. ``s4``/``o4`` mean "sonnet 4"/"opus 4" — + # same intent as claude-4-sonnet/claude-4-opus above, so they track the + # same newest LIVE 4.x snapshots (their old dated targets were retired + # and 404). ``h35`` names a specific retired generation like the legacy + # block, and keeps its historical pin. + "s4": "claude-sonnet-4-6", + "o4": "claude-opus-4-8", "h35": "claude-3-5-haiku-20241022", } diff --git a/src/models/configs.py b/src/models/configs.py index 0b17ec5a8..26f0fe4b1 100644 --- a/src/models/configs.py +++ b/src/models/configs.py @@ -96,10 +96,9 @@ class ModelConfig: # Every 4.x id still matches the earlier ``claude-opus-4`` base first, # so their 200K/legacy resolution is unchanged, but two other strings # now land here: an unregistered future ``claude-opus-``, AND the - # literal ``claude-opus`` — a live MODEL_ALIASES key (aliases.py:15) - # that resolves to claude-opus-4-20250514, so ``display_name()`` on the - # UNRESOLVED alias now reads "Claude Opus 5". Callers that canonicalize - # first (the normal path) are unaffected; test_model_system pins both. + # literal ``claude-opus`` — a live MODEL_ALIASES key that (since the + # 2026-08 alias refresh) also RESOLVES to claude-opus-5, so the + # resolved and unresolved spellings agree on "Claude Opus 5". # This inverts the "under-estimate is the safe direction" note above # for unknown opus ids, and over-estimating is the worse failure — # auto-compact never fires and the request eventually exceeds the real @@ -122,6 +121,57 @@ class ModelConfig: cost_cache_create_per_mtok=6.25, cost_cache_read_per_mtok=0.50, ), + # Claude Sonnet 5 — the current Sonnet tier (the ``sonnet`` alias and + # subagent tier target; see ``subagent_tier_models`` in + # src/providers/__init__.py). 128K true output cap probed live + # 2026-08-12 (``max_tokens: 2000000 > 128000`` from the API's own + # 400); the 1M context window is NOT probed — the window probe kept + # rate-limiting — and comes from the launch docs plus the Claude-5 + # family convention (opus-5 / fable-5 / opus-4-8 all ship 1M). $2/$10 + # per MTok — Sonnet 5's launch pricing was made permanent, so it does + # NOT inherit the 4.x sonnet 3/15 tier. Same 32_000 first-attempt wire + # ``max_tokens`` convention as the other 1M rows above. + # + # Placement is load-bearing the same way ``claude-opus-5``'s is: this + # row's prefix base is the family-wide ``claude-sonnet``, so it must + # sit AFTER ``claude-sonnet-4-20250514`` (the table's first row) — + # unknown 4.x sonnet ids (claude-sonnet-4-5-*, -4-6 …) keep resolving + # to that conservative 200K row first, and only unregistered future + # ``claude-sonnet-`` ids land here. + "claude-sonnet-5": ModelConfig( + model_id="claude-sonnet-5", + display_name="Claude Sonnet 5", + context_window=1_000_000, + max_output_tokens=32_000, + supports_thinking=True, + cost_input_per_mtok=2.0, + cost_output_per_mtok=10.0, + cost_cache_create_per_mtok=2.50, + cost_cache_read_per_mtok=0.20, + ), + # Claude Haiku 4.5 — the anthropic DEFAULT SUBAGENT model and its + # ``haiku`` tier target (Explore runs on it, mirroring TS + # exploreAgent.ts; cheapest current-gen tier at 1/5 per MTok). The + # live catalog LISTS only this dated id, but the bare + # ``claude-haiku-4-5`` — the spelling the alias table and subagent + # tables use — resolves server-side to it (probed live 2026-08-12). + # Window and cap probed the same day: 200K window (``prompt is too + # long: … > 200000``) and a 64_000 true output cap (``max_tokens: … + # > 64000``), so the 32_000 first-attempt convention leaves the 64K + # truncation-escalation exactly at the model's real ceiling. Prefix + # base ``claude-haiku-4-5`` also catches the bare spelling and future + # dated snapshots. + "claude-haiku-4-5-20251001": ModelConfig( + model_id="claude-haiku-4-5-20251001", + display_name="Claude Haiku 4.5", + context_window=200_000, + max_output_tokens=32_000, + supports_thinking=True, + cost_input_per_mtok=1.0, + cost_output_per_mtok=5.0, + cost_cache_create_per_mtok=1.25, + cost_cache_read_per_mtok=0.10, + ), # Claude 3.7 series "claude-3-7-sonnet-20250219": ModelConfig( diff --git a/src/models/model.py b/src/models/model.py index 2ceba76f9..0c0a458f0 100644 --- a/src/models/model.py +++ b/src/models/model.py @@ -15,7 +15,7 @@ def resolve_model(name: str) -> str: """Resolve a model name/alias to its canonical form. Steps: - 1. Resolve alias (e.g. "sonnet" → "claude-sonnet-4-20250514") + 1. Resolve alias (e.g. "sonnet" → "claude-sonnet-5") 2. Return canonical name """ canonical = resolve_alias(name) diff --git a/src/providers/__init__.py b/src/providers/__init__.py index d8f769f77..742a1a3f5 100644 --- a/src/providers/__init__.py +++ b/src/providers/__init__.py @@ -8,7 +8,27 @@ # Provider metadata for login/UI -class ProviderInfo(TypedDict): +class _ProviderInfoOptional(TypedDict, total=False): + # Subagent model defaults, read by ``src.agent.agent_model``. Optional: + # providers without them keep the reference behavior (subagents inherit + # the session model; unknown tier aliases fall back to inherit). + # + # ``subagent_tier_models`` — resolution targets for the bare + # ``opus`` / ``sonnet`` / ``haiku`` tier aliases an agent definition may + # pin (the built-in Explore agent pins ``haiku``, TS exploreAgent.ts). + # Port of the TS reference's per-provider getDefault*Model() family. + # + # ``subagent_model`` — what a spawned agent runs on when neither the + # Agent tool call nor the agent definition names a model. Deliberate + # divergence from both references (they inherit the session model + # here), by explicit user directive: fan-out subagents default to the + # provider's cheap tier. Overridable per provider via the + # ``providers..subagent_model`` config knob (incl. ``"inherit"``). + subagent_model: str + subagent_tier_models: dict[str, str] + + +class ProviderInfo(_ProviderInfoOptional): label: str default_base_url: str default_model: str @@ -20,34 +40,48 @@ class ProviderInfo(TypedDict): "label": "Anthropic Claude", "default_base_url": "https://api.anthropic.com", "default_model": "claude-sonnet-4-6", + # Subagent defaults, verified against the live /v1/models catalog + # 2026-08-12 — the retired ids the old alias table pointed at + # (claude-3-5-haiku-20241022, claude-sonnet-4-20250514) 404 there, + # which is exactly the failure these fields exist to prevent. + # Haiku 4.5 is the designated default: cheapest current-gen tier + # (1/5 per MTok vs sonnet-5's 2/10). The catalog LISTS only the + # dated ``claude-haiku-4-5-20251001``, but the bare id resolves + # server-side to it (probed live: the API's max_tokens 400 names + # the dated id), matching the undated style of the other targets. + "subagent_model": "claude-haiku-4-5", + "subagent_tier_models": { + "fable": "claude-fable-5", + "opus": "claude-opus-5", + "sonnet": "claude-sonnet-5", + "haiku": "claude-haiku-4-5", + }, + # Mirrors the live /v1/models catalog (2026-08-12) plus the bare + # ``claude-haiku-4-5``, which the API resolves server-side (probed). + # Retired ids (claude-sonnet-4-20250514, claude-3-5-haiku-20241022, + # every 3.x/4.0/4.1 id) are deliberately ABSENT: this list feeds + # both the /model picker and the subagent availability gate, and a + # listed-but-dead id turns the gate's "degrade to inherit" into a + # shipped 404. Free-text model entry still accepts any id. Keep in + # sync with AnthropicProvider.get_available_models (the invariant + # test in tests/test_ch08_subagents_round4.py pins the subagent + # targets against BOTH lists). "available_models": [ # Frontier (above Opus tier) "claude-fable-5", - # Claude 5 series (Opus tier; sonnet-5 not registered yet — - # it needs the same model-table entry opus-5 got) + # Claude 5 series "claude-opus-5", - # Claude 4 series - "claude-sonnet-4-6", - "claude-sonnet-4-5", - "claude-sonnet-4-5-20250929", - "claude-sonnet-4-0", - "claude-sonnet-4-20250514", + "claude-sonnet-5", + # Claude 4.x series (still served) "claude-opus-4-8", + "claude-opus-4-7", "claude-opus-4-6", - "claude-opus-4-5", "claude-opus-4-5-20251101", - "claude-opus-4-1", - "claude-opus-4-1-20250805", - "claude-opus-4-0", - "claude-opus-4-20250514", + "claude-sonnet-4-6", + "claude-sonnet-4-5-20250929", + # Haiku "claude-haiku-4-5", "claude-haiku-4-5-20251001", - # Legacy - "claude-3-5-sonnet-20241022", - "claude-3-5-haiku-20241022", - "claude-3-opus-20240229", - "claude-3-sonnet-20240229", - "claude-3-haiku-20240307", ], }, "openai": { @@ -117,6 +151,16 @@ class ProviderInfo(TypedDict): "label": "DeepSeek", "default_base_url": "https://api.deepseek.com", "default_model": "deepseek-v4-pro", + # Subagent defaults: v4-flash is DeepSeek's fast/cheap line, the + # equivalent of the sonnet/haiku work tiers; v4-pro (the session + # default above) stays the opus-tier target. Catalog verified live + # 2026-08-12 (GET /models returns exactly v4-pro and v4-flash). + "subagent_model": "deepseek-v4-flash", + "subagent_tier_models": { + "opus": "deepseek-v4-pro", + "sonnet": "deepseek-v4-flash", + "haiku": "deepseek-v4-flash", + }, "available_models": [ # V4 series (current) "deepseek-v4-pro", diff --git a/src/providers/anthropic_provider.py b/src/providers/anthropic_provider.py index 23644ea14..de4c5ad2a 100644 --- a/src/providers/anthropic_provider.py +++ b/src/providers/anthropic_provider.py @@ -244,6 +244,8 @@ def _default_max_tokens(model: str | None) -> int: class AnthropicProvider(BaseProvider): """Anthropic Claude provider.""" + provider_id = "anthropic" + def __init__( self, api_key: str, base_url: Optional[str] = None, model: Optional[str] = None ): @@ -1048,32 +1050,25 @@ def get_available_models(self) -> list[str]: Returns: List of model names """ + # Mirrors the live /v1/models catalog (2026-08-12) plus the bare + # ``claude-haiku-4-5`` (server-side alias, probed). Retired ids are + # deliberately absent — a listed-but-dead id turns the subagent + # availability gate's "degrade to inherit" into a shipped 404. + # Keep in sync with PROVIDER_INFO["anthropic"]["available_models"]. return [ # Frontier (above Opus tier) "claude-fable-5", - # Claude 5 series (Opus tier; sonnet-5 not registered yet — - # it needs the same model-table entry opus-5 got) + # Claude 5 series "claude-opus-5", - # Claude 4 series - "claude-sonnet-4-6", - "claude-sonnet-4-5", - "claude-sonnet-4-5-20250929", - "claude-sonnet-4-0", - "claude-sonnet-4-20250514", + "claude-sonnet-5", + # Claude 4.x series (still served) "claude-opus-4-8", + "claude-opus-4-7", "claude-opus-4-6", - "claude-opus-4-5", "claude-opus-4-5-20251101", - "claude-opus-4-1", - "claude-opus-4-1-20250805", - "claude-opus-4-0", - "claude-opus-4-20250514", + "claude-sonnet-4-6", + "claude-sonnet-4-5-20250929", + # Haiku "claude-haiku-4-5", "claude-haiku-4-5-20251001", - # Legacy - "claude-3-5-sonnet-20241022", - "claude-3-5-haiku-20241022", - "claude-3-opus-20240229", - "claude-3-sonnet-20240229", - "claude-3-haiku-20240307", ] diff --git a/src/providers/base.py b/src/providers/base.py index bbf7c2824..d2d45710e 100644 --- a/src/providers/base.py +++ b/src/providers/base.py @@ -54,6 +54,14 @@ class ChatResponse: class BaseProvider(ABC): """Base class for LLM providers.""" + #: Canonical provider id ("anthropic", "deepseek", …) — the key this + #: provider's row carries in ``src.providers.PROVIDER_INFO``. Set on every + #: concrete provider class (registry-generated classes get it from + #: ``build_provider_class``). Empty string means "unregistered": lookups + #: keyed on it (per-provider subagent model defaults, for one) must treat + #: that as a miss, never as a valid key. + provider_id: str = "" + #: Whether this provider talks to DeepSeek's API. Overridden to ``True`` #: in :class:`~src.providers.deepseek_provider.DeepSeekProvider`. Gates #: DeepSeek-only token-efficiency behaviour (prompt-prefix-cache diff --git a/src/providers/deepseek_provider.py b/src/providers/deepseek_provider.py index a4f9783b2..54abe5baf 100644 --- a/src/providers/deepseek_provider.py +++ b/src/providers/deepseek_provider.py @@ -22,6 +22,8 @@ class DeepSeekProvider(OpenAICompatibleProvider): """DeepSeek provider using the OpenAI SDK against the DeepSeek base URL.""" + provider_id = "deepseek" + DEFAULT_BASE_URL = "https://api.deepseek.com" #: Marks this provider as DeepSeek so the query layer relocates the diff --git a/src/providers/gemini_provider.py b/src/providers/gemini_provider.py index 63755f34c..cc04d93c1 100644 --- a/src/providers/gemini_provider.py +++ b/src/providers/gemini_provider.py @@ -185,6 +185,8 @@ def _sanitize_schema_for_gemini(schema: Any) -> Any: class GeminiProvider(BaseProvider): """Native Gemini provider via the google-genai SDK.""" + provider_id = "gemini" + DEFAULT_MODEL = "gemini-2.5-pro" def __init__( diff --git a/src/providers/minimax_provider.py b/src/providers/minimax_provider.py index fde1a0727..93862c938 100644 --- a/src/providers/minimax_provider.py +++ b/src/providers/minimax_provider.py @@ -31,6 +31,8 @@ class MinimaxProvider(BaseProvider): Uses the Anthropic SDK with Minimax-specific models. """ + provider_id = "minimax" + DEFAULT_BASE_URL = "https://api.minimax.io/anthropic" def __init__( diff --git a/src/providers/openai_compatible_specs.py b/src/providers/openai_compatible_specs.py index f2e22c328..174ebf7be 100644 --- a/src/providers/openai_compatible_specs.py +++ b/src/providers/openai_compatible_specs.py @@ -573,6 +573,7 @@ def build_provider_class(provider_id: str) -> type[_SpecOpenAICompatibleProvider (_SpecOpenAICompatibleProvider,), { "SPEC": spec, + "provider_id": provider_id, "DEFAULT_BASE_URL": spec.default_base_url, "DEFAULT_MODEL": spec.default_model, "supported_reasoning_efforts": spec.reasoning_efforts, diff --git a/src/providers/openai_provider.py b/src/providers/openai_provider.py index 31e83bd5a..c543d3927 100644 --- a/src/providers/openai_provider.py +++ b/src/providers/openai_provider.py @@ -151,6 +151,8 @@ class OpenAIProvider(OpenAICompatibleProvider): subscription OAuth. See the module docstring for the protocol/route split.""" + provider_id = "openai" + def __init__( self, api_key: str, base_url: Optional[str] = None, model: Optional[str] = None ): diff --git a/src/providers/openrouter_provider.py b/src/providers/openrouter_provider.py index de62172b3..b7ca2f8b1 100644 --- a/src/providers/openrouter_provider.py +++ b/src/providers/openrouter_provider.py @@ -20,6 +20,8 @@ class OpenRouterProvider(OpenAICompatibleProvider): """OpenRouter provider using the OpenAI SDK against the OpenRouter base URL.""" + provider_id = "openrouter" + DEFAULT_BASE_URL = "https://openrouter.ai/api/v1" def __init__( diff --git a/src/providers/zai_provider.py b/src/providers/zai_provider.py index a356abdd0..5e2334948 100644 --- a/src/providers/zai_provider.py +++ b/src/providers/zai_provider.py @@ -49,6 +49,8 @@ def _canonical_glm_model(model: str) -> str: class ZaiProvider(OpenAICompatibleProvider): """Z.ai GLM Coding Plan provider using the OpenAI SDK against the Z.ai base URL.""" + provider_id = "zai" + DEFAULT_BASE_URL = "https://api.z.ai/api/coding/paas/v4" DEFAULT_MODEL = "GLM-5.1" diff --git a/src/services/pricing.py b/src/services/pricing.py index 672dfc28b..3fd70f2e5 100644 --- a/src/services/pricing.py +++ b/src/services/pricing.py @@ -47,6 +47,16 @@ "cache_creation": 12.50 / 1_000_000, "cache_read": 1.00 / 1_000_000, } +# Sonnet 5 launched at 2/10 and Anthropic made that pricing permanent (the +# scheduled 2026-09 move to the 3/15 sonnet tier was cancelled), so it gets +# its own tier rather than joining the 4.x sonnets. Cache rates follow the +# standard first-party ratios (write 1.25x input, read 0.1x input). +_TIER_2_10 = { + "input": 2.0 / 1_000_000, + "output": 10.0 / 1_000_000, + "cache_creation": 2.50 / 1_000_000, + "cache_read": 0.20 / 1_000_000, +} _TIER_HAIKU_45 = { "input": 1.0 / 1_000_000, "output": 5.0 / 1_000_000, @@ -231,7 +241,9 @@ "claude-haiku-4-5": _TIER_HAIKU_45, "claude-3-5-haiku-20241022": _TIER_HAIKU_45, "claude-3-haiku-20240307": _TIER_HAIKU_3, - # Sonnet family — all on the standard 3/15 tier + # Sonnet family — Sonnet 5 on its permanent 2/10 launch tier, the 4.x + # and 3.x sonnets on the standard 3/15 tier + "claude-sonnet-5": _TIER_2_10, "claude-sonnet-4-6": _TIER_3_15, "claude-sonnet-4-5": _TIER_3_15, "claude-sonnet-4-20250514": _TIER_3_15, @@ -297,6 +309,7 @@ ("claude-haiku-4", _TIER_HAIKU_45), ("claude-3-5-haiku", _TIER_HAIKU_45), ("claude-3-haiku", _TIER_HAIKU_3), + ("claude-sonnet-5", _TIER_2_10), ("claude-opus-5", _TIER_5_25), ("claude-opus-4-8", _TIER_5_25), ("claude-opus-4-7", _TIER_5_25), diff --git a/src/services/session_title.py b/src/services/session_title.py index 7494a03d0..5252ad905 100644 --- a/src/services/session_title.py +++ b/src/services/session_title.py @@ -44,7 +44,7 @@ def auto_title_from_message(text: str) -> str: async def generate_llm_title( messages: list[dict[str, Any]], *, - model: str = "claude-3-5-haiku-20241022", + model: str = "claude-haiku-4-5", ) -> str | None: """Generate a 5-10 word title using a side LLM query. diff --git a/src/settings/constants.py b/src/settings/constants.py index 3c71437b8..4b4658fcd 100644 --- a/src/settings/constants.py +++ b/src/settings/constants.py @@ -10,8 +10,11 @@ ) DEFAULT_SETTINGS = SettingsSchema( - model="claude-sonnet-4-20250514", - small_fast_model="claude-3-5-haiku-20241022", + # Aligned with PROVIDER_INFO["anthropic"]["default_model"] — the ids + # these used to pin (claude-sonnet-4-20250514 / claude-3-5-haiku-20241022) + # were retired from the live API and 404 (verified 2026-08-12). + model="claude-sonnet-4-6", + small_fast_model="claude-haiku-4-5", provider="anthropic", permission_mode="default", permissions=[], diff --git a/src/token_estimation.py b/src/token_estimation.py index e12fe3cc7..d82d3afaf 100644 --- a/src/token_estimation.py +++ b/src/token_estimation.py @@ -352,7 +352,7 @@ async def count_messages_tokens_with_api( default_headers=get_anthropic_custom_headers() or None ) response = await client.beta.messages.count_tokens( - model="claude-sonnet-4-20250514", + model="claude-sonnet-5", messages=messages, tools=tools if tools else [], ) diff --git a/src/tool_system/tools/agent.py b/src/tool_system/tools/agent.py index f591358e5..f6e5fa173 100644 --- a/src/tool_system/tools/agent.py +++ b/src/tool_system/tools/agent.py @@ -62,6 +62,7 @@ def _emit_terminal_agent_progress( description: Any, subagent_type: Any, status: str, + model: Any = None, ) -> None: """R5 round-5 (ch13) — emit a TERMINAL ``agent_progress`` so the TUI subagent HUD marks the subagent done instead of lingering "running" @@ -80,6 +81,12 @@ def _emit_terminal_agent_progress( "name": name, "description": description, "subagent_type": subagent_type, + # Carried on the terminal emit too: a subagent that dies + # BEFORE any message gets its first (and only) emit here, + # and the overlay would otherwise label it 'inherit' — in + # exactly the routed-to-a-bad-model case where the model + # matters most. + "model": model, "activity": None, "status": status, }) @@ -108,9 +115,15 @@ def _emit_terminal_agent_progress( "description": ( "Optional model override for this agent. Takes precedence over " "the agent definition's model frontmatter. If omitted, uses the " - "agent definition's model, or inherits from the parent." + "agent definition's model, or the provider's default subagent " + "model (typically a fast, inexpensive tier). Pass \"inherit\" " + "to force the parent conversation's model, e.g. for hard " + "tasks that need its full capability." ), - "enum": ["sonnet", "opus", "haiku"], + # Tier aliases resolve per provider (see subagent_tier_models in + # src/providers/__init__.py); "fable" mirrors the CC harness enum + # and "inherit" is the TS AGENT_MODEL_OPTIONS escape hatch. + "enum": ["sonnet", "opus", "haiku", "fable", "inherit"], }, "run_in_background": { "type": "boolean", @@ -193,12 +206,22 @@ def _agent_call(tool_input: dict[str, Any], context: ToolContext) -> ToolResult: subagent_type = tool_input.get("subagent_type") # Coordinator mode ignores the model param — the coordinator prompt # says "Do not set the model parameter. Workers need the default - # model"; this enforces it. Mirrors AgentTool.tsx:252. Function-local - # import: src.coordinator's package init imports worker_agent → - # agent_definitions (cycle at import time, safe at call time). + # model"; this enforces it. Mirrors AgentTool.tsx:252. "Default + # model" meant the SESSION model when that prompt was written, so + # this pins 'inherit' rather than None: None now resolves to the + # provider's cheap default subagent model, and coordinator workers — + # whose model param is discarded here, leaving them no way to opt + # out — do implementation work that should stay on the session + # model. Deliberately a BARE 'inherit' (unlike the workflow + # runner's spec.model-or-agent-def chain): it overrides agent-def + # tiers too, so even an Explore spawned inside a coordinator + # session runs on the session model — every coordinator delegate + # is a worker here. Function-local import: src.coordinator's + # package init imports worker_agent → agent_definitions (cycle at + # import time, safe at call time). from src.coordinator.mode import is_coordinator_mode - model = None if is_coordinator_mode() else tool_input.get("model") + model = "inherit" if is_coordinator_mode() else tool_input.get("model") run_in_background = bool(tool_input.get("run_in_background", False)) # Chapter-10 / WI-6.1 — optional human-readable name. We # validate / register it AFTER agent_id is generated so the @@ -366,6 +389,24 @@ def _agent_call(tool_input: dict[str, Any], context: ToolContext) -> ToolResult: parent_system_prompt=fork_parent_system_prompt, ) + # Auditability: the model may differ from the session's with NOTHING + # in the tool call naming one (agent-def tiers, the provider + # default-subagent-model path), so report the routing in the task + # registry and the tool result. Same inputs as run_agent's own + # resolution → same answer; run_agent stays the authority for what + # actually goes on the wire. Best-effort — never blocks a spawn. + resolved_model = None + try: + from src.agent.agent_model import get_agent_model + + # quiet: this resolution is reporting-only; run_agent's own + # (authoritative) resolution emits any fallback warning once. + resolved_model = get_agent_model( + model, agent_def.model, provider, quiet=True, + ) + except Exception: # noqa: BLE001 + logger.debug("subagent model preview failed", exc_info=True) + # Stream the subagent's live progress to the UI when the host wired a # hook (agent-server only). run_agent calls on_message per message, so # this covers both the sync and background paths. Purely additive — no @@ -393,6 +434,7 @@ def _on_subagent_message(message: Any) -> None: "name": agent_name, "description": description, "subagent_type": subagent_type, + "model": resolved_model, "activity": activity, "tool_use_count": _tracker.tool_use_count, "tokens": total_tokens_from_tracker(_tracker), @@ -412,6 +454,7 @@ def _on_subagent_message(message: Any) -> None: prompt=prompt, agent_type=agent_def.agent_type, agent_name=agent_name, + resolved_model=resolved_model, ) else: return _run_sync_agent( @@ -422,6 +465,7 @@ def _on_subagent_message(message: Any) -> None: agent_type=agent_def.agent_type, description=description, agent_name=agent_name, + resolved_model=resolved_model, ) def _run_sync_agent( @@ -433,6 +477,7 @@ def _run_sync_agent( agent_type: str, description: Any = None, agent_name: Any = None, + resolved_model: str | None = None, ) -> ToolResult: """Run an agent synchronously and return the result.""" from ..protocol import ToolResult as TR @@ -478,7 +523,8 @@ def _run_sync_agent( # the existing error flow is unchanged. _emit_terminal_agent_progress( run_params.parent_context, agent_id=agent_id, name=_hud_name, - description=_hud_desc, subagent_type=agent_type, status="failed", + description=_hud_desc, subagent_type=agent_type, + status="failed", model=resolved_model, ) raise @@ -487,7 +533,8 @@ def _run_sync_agent( # lingering "running". The per-message emits carry status:"running". _emit_terminal_agent_progress( run_params.parent_context, agent_id=agent_id, name=_hud_name, - description=_hud_desc, subagent_type=agent_type, status="completed", + description=_hud_desc, subagent_type=agent_type, + status="completed", model=resolved_model, ) return TR( @@ -497,6 +544,10 @@ def _run_sync_agent( "prompt": prompt, "agent_id": result.agent_id, "agent_type": result.agent_type, + # The model the subagent ran on — may differ from the session + # model with nothing in the tool call naming one (agent-def + # tier / provider default-subagent-model), so surface it. + "model": resolved_model, "content": result.content, "total_duration_ms": result.total_duration_ms, "total_tokens": result.total_tokens, @@ -513,6 +564,7 @@ def _launch_async_agent( prompt: str, agent_type: str, agent_name: str | None = None, + resolved_model: str | None = None, ) -> ToolResult: """Launch an agent in the background and return immediately. @@ -587,6 +639,7 @@ def _launch_async_agent( description=description, prompt=prompt, agent_type=agent_type, + model=resolved_model, abort_controller=_async_abort, registry=context.runtime_tasks, ) @@ -687,7 +740,7 @@ async def _background_lifecycle() -> None: _emit_terminal_agent_progress( context, agent_id=agent_id, name=agent_name, description=description, subagent_type=agent_type, - status=_final_status, + status=_final_status, model=resolved_model, ) # Chunk D / WI-3.1 + WI-3.2 — enqueue a single # ```` envelope. Atomic check-and- @@ -732,7 +785,7 @@ async def _background_lifecycle() -> None: _emit_terminal_agent_progress( context, agent_id=agent_id, name=agent_name, description=description, subagent_type=agent_type, - status="failed", + status="failed", model=resolved_model, ) logger.exception( "Async agent %s (%s) failed", @@ -765,6 +818,9 @@ def _runner(_stop_event: Any) -> None: "status": "async_launched", "agent_id": agent_id, "agent_type": agent_type, + # See the sync path — the routed model is reportable even + # when the tool call named none. + "model": resolved_model, "description": description, "prompt": prompt, "task_output_key": agent_id, diff --git a/src/utils/fast_mode.py b/src/utils/fast_mode.py index 2fb56f2f3..a666801d2 100644 --- a/src/utils/fast_mode.py +++ b/src/utils/fast_mode.py @@ -6,7 +6,9 @@ from dataclasses import dataclass, field -FAST_MODE_MODEL = "claude-3-5-haiku-20241022" +# The current-generation haiku (the 3.5 id it used to pin was retired from +# the live API and 404s). Overridable via CLAUDE_FAST_MODE_MODEL below. +FAST_MODE_MODEL = "claude-haiku-4-5" @dataclass diff --git a/src/workflow/runner.py b/src/workflow/runner.py index d54b78705..f29a8abf8 100644 --- a/src/workflow/runner.py +++ b/src/workflow/runner.py @@ -196,7 +196,20 @@ async def _attempt(prompt_text, collector, context_messages=None): available_tools=worker_tools, tool_registry=agent_registry, provider=self._provider, - model=spec.model, + # The Workflow tool's contract says an agent() call without + # opts.model "inherits the main-loop model", so default to + # 'inherit' rather than passing None through: None now + # resolves to the provider's cheap default subagent model, + # which both breaks that contract and hands flash-class + # models the deep-research WebSearch/WebFetch loop that + # tools/workflow.py documents as a ~30x token burner on + # exactly such models. The agent definition's own ``model:`` + # sits BETWEEN those (critic r4): this value fills the + # tool-param slot, which outranks the agent-def slot in + # get_agent_model — a bare 'inherit' here would silently + # discard an opts.agentType agent's declared model (e.g. + # Explore's 'haiku'). + model=spec.model or agent_definition.model or "inherit", agent_id=agent_id, abort_controller=abort, max_turns=self._max_turns, diff --git a/tests/coordinator/test_wiring.py b/tests/coordinator/test_wiring.py index c74a55ad5..0d0282e1e 100644 --- a/tests/coordinator/test_wiring.py +++ b/tests/coordinator/test_wiring.py @@ -388,7 +388,11 @@ def test_coordinator_suppresses_model_param( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: """The coordinator's model param is discarded (``AgentTool.tsx:252``) — - workers need the default model for substantive tasks.""" + and replaced with 'inherit', not None: workers do implementation work + and must stay on the SESSION model. None would resolve to the + provider's cheap default subagent model (PROVIDER_INFO subagent_model), + and workers have no way to opt out since this very suppression eats + their model param.""" from src.tool_system.defaults import build_default_registry from src.tool_system.protocol import ToolCall from src.tool_system.context import ToolContext @@ -424,7 +428,7 @@ async def _capturing_run_agent(params): while not seen_models and _time.time() < deadline: _time.sleep(0.02) assert seen_models, "async worker never invoked run_agent" - assert seen_models[0] is None + assert seen_models[0] == "inherit" def test_model_param_honored_when_mode_off( diff --git a/tests/test_agent_tool_async.py b/tests/test_agent_tool_async.py index 57d782286..0f91855a5 100644 --- a/tests/test_agent_tool_async.py +++ b/tests/test_agent_tool_async.py @@ -106,3 +106,60 @@ async def _failing_run_agent(_params): final_status = _wait_for_task_status(context, task_id) assert final_status == "failed" assert "boom" in _task_output_text(context, task_id) + + +class _ModelReportingProvider: + """Minimal provider for the resolved-model reporting assertions.""" + + model = "session-model-x" + + def get_available_models(self): + return ["session-model-x"] + + +def test_async_launch_reports_resolved_model(tmp_path: Path) -> None: + """critic r4 MINOR-3 — the routed model is surfaced in the tool result + and the task registry even when the tool call names none (here: no + provider_id → inherit → the session model).""" + registry = build_default_registry(provider=_ModelReportingProvider()) + context = ToolContext(workspace_root=tmp_path) + + async def _fake_run_agent(_params): + yield AssistantMessage(content=[TextBlock(text="ok")]) + + with patch("src.tool_system.tools.agent.run_agent", _fake_run_agent): + result = registry.dispatch( + ToolCall( + name="Agent", + input={ + "description": "model reporting", + "prompt": "p", + "run_in_background": True, + }, + ), + context, + ) + assert result.output.get("model") == "session-model-x" + task_id = str(result.output["agent_id"]) + state = context.runtime_tasks.get(task_id) + assert getattr(state, "model", None) == "session-model-x" + _wait_for_task_status(context, task_id) + + +def test_sync_run_reports_resolved_model(tmp_path: Path) -> None: + registry = build_default_registry(provider=_ModelReportingProvider()) + context = ToolContext(workspace_root=tmp_path) + + async def _fake_run_agent(_params): + yield AssistantMessage(content=[TextBlock(text="sync ok")]) + + with patch("src.tool_system.tools.agent.run_agent", _fake_run_agent): + result = registry.dispatch( + ToolCall( + name="Agent", + input={"description": "model reporting", "prompt": "p"}, + ), + context, + ) + assert result.output.get("status") == "completed" + assert result.output.get("model") == "session-model-x" diff --git a/tests/test_ch08_subagents_round4.py b/tests/test_ch08_subagents_round4.py index 868ecf8d7..27f14f253 100644 --- a/tests/test_ch08_subagents_round4.py +++ b/tests/test_ch08_subagents_round4.py @@ -22,8 +22,14 @@ def get_available_models(self): return list(self._available) -_SONNET = "claude-sonnet-4-20250514" -_HAIKU = "claude-3-5-haiku-20241022" +# Current live ids (2026-08 refresh) — the retired 2025 ids these fixtures +# used to pin were removed from the API, and the alias table now targets +# these. ``_FakeProvider`` carries no ``provider_id``, so these tests +# exercise the provider-table-less MECHANISM (global aliases + availability +# gate); the per-provider table behavior is covered by +# ``TestPerProviderSubagentDefaults`` below. +_SONNET = "claude-sonnet-5" +_HAIKU = "claude-haiku-4-5" class TestAgentModelResolution(unittest.TestCase): @@ -31,6 +37,12 @@ def setUp(self): # Ensure no env override leaks between tests. self._env = dict(os.environ) os.environ.pop("CLAUDE_CODE_SUBAGENT_MODEL", None) + for var in ( + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + ): + os.environ.pop(var, None) def tearDown(self): os.environ.clear() @@ -103,6 +115,303 @@ class _Nothing: self.assertEqual(get_agent_model(None, "haiku", _Nothing()), "") +class TestPerProviderSubagentDefaults(unittest.TestCase): + """The per-provider subagent tables (PROVIDER_INFO ``subagent_model`` / + ``subagent_tier_models``) — the 2026-08 fix for subagents 404-ing on + retired first-party ids and DeepSeek fan-outs silently billing the + expensive session model.""" + + def setUp(self): + self._env = dict(os.environ) + for var in ( + "CLAUDE_CODE_SUBAGENT_MODEL", + "ANTHROPIC_DEFAULT_OPUS_MODEL", + "ANTHROPIC_DEFAULT_SONNET_MODEL", + "ANTHROPIC_DEFAULT_HAIKU_MODEL", + "ANTHROPIC_BASE_URL", + ): + os.environ.pop(var, None) + # Hermetic: the resolver consults the user's real config.json for + # the ``providers..subagent_model`` knob — neutralize it. + self._cfg = patch("src.config.get_provider_config", return_value={}) + self._cfg.start() + + def tearDown(self): + self._cfg.stop() + os.environ.clear() + os.environ.update(self._env) + + @staticmethod + def _anthropic(model="claude-fable-5", **kwargs): + from src.providers.anthropic_provider import AnthropicProvider + + return AnthropicProvider(api_key="test-key", model=model, **kwargs) + + @staticmethod + def _deepseek(model="deepseek-v4-pro"): + from src.providers.deepseek_provider import DeepSeekProvider + + return DeepSeekProvider(api_key="test-key", model=model) + + def test_anthropic_unspecified_uses_default_subagent_model(self): + # Goal ask #1: on the anthropic provider the default subagent model + # is claude-haiku-4-5 (the cheapest current-gen tier), NOT an + # inherit of the (pricier) session model. + p = self._anthropic() + self.assertEqual(get_agent_model(None, None, p), "claude-haiku-4-5") + + def test_anthropic_haiku_tier_resolves_to_live_haiku(self): + # The screenshot bug: Explore pins 'haiku', whose old alias target + # (claude-3-5-haiku-20241022) was retired and 404'd every spawn. + # The bare claude-haiku-4-5 resolves server-side (probed live). + p = self._anthropic() + self.assertEqual( + get_agent_model(None, "haiku", p), "claude-haiku-4-5", + ) + + def test_deepseek_unspecified_uses_flash(self): + # Goal ask #2: deepseek-v4-flash is the subagent default. + p = self._deepseek() + self.assertEqual(get_agent_model(None, None, p), "deepseek-v4-flash") + + def test_deepseek_haiku_tier_uses_flash(self): + # Previously 'haiku' fell back to inherit → every Explore fan-out + # ran (and billed) the v4-pro session model. + p = self._deepseek() + self.assertEqual(get_agent_model(None, "haiku", p), "deepseek-v4-flash") + + def test_deepseek_opus_tier_uses_pro(self): + p = self._deepseek(model="deepseek-v4-flash") + self.assertEqual(get_agent_model("opus", None, p), "deepseek-v4-pro") + + def test_explicit_inherit_still_forces_session_model(self): + # The Plan/fork agents pin 'inherit' — the provider default must + # not override an explicit inherit. + self.assertEqual( + get_agent_model(None, "inherit", self._anthropic()), + "claude-fable-5", + ) + self.assertEqual( + get_agent_model("inherit", None, self._deepseek()), + "deepseek-v4-pro", + ) + + def test_custom_anthropic_endpoint_inherits(self): + # TS checkIsClaudeNativeProvider: a proxy/self-hosted endpoint has + # no guaranteed first-party catalog — the table is off, and the + # haiku/sonnet aliases inherit rather than resolve to first-party + # ids the proxy may not serve. + p = self._anthropic( + model="my-proxy-model", base_url="https://proxy.example/v1", + ) + self.assertEqual(get_agent_model(None, None, p), "my-proxy-model") + self.assertEqual(get_agent_model(None, "haiku", p), "my-proxy-model") + self.assertEqual(get_agent_model("sonnet", None, p), "my-proxy-model") + + def _set_config_knob(self, value): + self._cfg.stop() + self._cfg = patch( + "src.config.get_provider_config", + return_value={"subagent_model": value}, + ) + self._cfg.start() + + def test_config_subagent_model_knob_wins_and_is_trusted(self): + # providers..subagent_model (the opencode ``small_model`` knob) + # beats the registry default; a full id bypasses the availability + # gate (the user is naming a deployment). + self._set_config_knob("my-finetuned-small") + p = self._anthropic() + self.assertEqual(get_agent_model(None, None, p), "my-finetuned-small") + + def test_config_knob_inherit_restores_session_model(self): + # critic B1 — 'inherit' is the natural "stop downgrading my + # subagents" spelling; it must resolve to the session model, never + # go on the wire as a literal model id. + self._set_config_knob("inherit") + p = self._anthropic() + self.assertEqual(get_agent_model(None, None, p), "claude-fable-5") + + def test_config_knob_alias_resolves_through_tier_table(self): + # critic B1 — a bare tier alias in the knob resolves like any other + # user-specified alias instead of shipping the raw string. + self._set_config_knob("sonnet") + p = self._anthropic() + self.assertEqual(get_agent_model(None, None, p), "claude-sonnet-5") + + def test_markdown_agent_without_model_gets_provider_default(self): + # critic M4 — a user-authored .md agent with no ``model:`` + # frontmatter parses to model=None and takes the provider default. + from src.agent.parse_agent_markdown import parse_agent_from_markdown + + agent = parse_agent_from_markdown( + "/tmp/my-agent.md", + {"name": "my-agent", "description": "d"}, + "Body", + "user", + "/tmp", + ) + self.assertIsNotNone(agent) + self.assertIsNone(agent.model) + self.assertEqual( + get_agent_model(None, agent.model, self._anthropic()), + "claude-haiku-4-5", + ) + + def test_coordinator_mode_pins_inherit_at_the_tool_layer(self): + # critic B2 — coordinator workers cannot pass a model param (the + # tool layer discards it), so that path pins 'inherit': workers do + # implementation work and must stay on the session model. + self.assertEqual( + get_agent_model("inherit", None, self._anthropic()), + "claude-fable-5", + ) + import inspect + + from src.tool_system.tools import agent as agent_tool_module + + src_text = inspect.getsource(agent_tool_module) + self.assertIn( + 'model = "inherit" if is_coordinator_mode()', src_text, + "coordinator path must pin 'inherit', not None — None now " + "resolves to the provider's cheap default subagent model", + ) + + def test_registry_subagent_targets_are_available(self): + # critic M5 — the availability gate silently degrades a stale + # registry row to inherit, so a typo in a future row would be an + # invisible no-op. Pin the invariant against the list the RUNTIME + # gate actually reads — the provider INSTANCE's + # get_available_models(), a separately-maintained literal from + # PROVIDER_INFO's available_models — and against the registry list + # too, so drift in either direction fails here rather than + # silently no-oping in production. + from src.providers import PROVIDER_INFO, get_provider_class + + checked = 0 + for provider_id, info in PROVIDER_INFO.items(): + targets = [] + default = info.get("subagent_model") + if default: + targets.append(("subagent_model", default)) + for tier, model in (info.get("subagent_tier_models") or {}).items(): + targets.append((f"tier:{tier}", model)) + if not targets: + continue + checked += 1 + registry_list = info.get("available_models") or [] + instance = get_provider_class(provider_id)( + api_key="test-key", model=None, + ) + runtime_list = instance.get_available_models() + for label, target in targets: + for name, available in ( + ("PROVIDER_INFO available_models", registry_list), + ("get_available_models()", runtime_list), + ): + self.assertIn( + target, available, + f"{provider_id} {label} names {target!r}, which its " + f"{name} does not list — the runtime gate would " + "silently ignore it", + ) + # Today: anthropic + deepseek. If this drops to zero the tables were + # deleted and this test should go with them. + self.assertGreaterEqual(checked, 2) + + def test_tier_env_pin_beats_table(self): + os.environ["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "my-bedrock-haiku" + p = self._anthropic() + self.assertEqual(get_agent_model(None, "haiku", p), "my-bedrock-haiku") + + def test_tier_env_pin_does_not_leak_to_other_providers(self): + # critic r4 — the ANTHROPIC_* env pins name Anthropic deployments; + # honoring one on a DeepSeek session would ship that id to + # api.deepseek.com on every Explore spawn (hard 400). + os.environ["ANTHROPIC_DEFAULT_HAIKU_MODEL"] = "my-bedrock-haiku" + d = self._deepseek() + self.assertEqual(get_agent_model(None, "haiku", d), "deepseek-v4-flash") + + def test_known_alias_spellings_never_ship_raw(self): + # critic r3 — trust_literal must only trust ids NO alias table + # knows. 's4', 'claude-4-sonnet', 'claude-haiku' are alias + # spellings, not servable model ids: they resolve to their + # canonical target (and take the availability gate), never go on + # the wire verbatim. + p = self._anthropic() + self.assertEqual( + get_agent_model("claude-4-sonnet", None, p), "claude-sonnet-4-6", + ) + self.assertEqual( + get_agent_model("claude-haiku", None, p), "claude-haiku-4-5", + ) + os.environ["CLAUDE_CODE_SUBAGENT_MODEL"] = "s4" + try: + resolved = get_agent_model(None, None, p) + finally: + os.environ.pop("CLAUDE_CODE_SUBAGENT_MODEL", None) + self.assertEqual(resolved, "claude-sonnet-4-6") + + def test_alias_of_retired_model_degrades_to_inherit(self): + # The legacy pins ('claude-3.5-haiku', 'h35') canonicalize to ids + # the live catalog no longer serves; post-prune the availability + # gate degrades them to inherit instead of shipping a 404. + p = self._anthropic() + self.assertEqual( + get_agent_model("claude-3.5-haiku", None, p), "claude-fable-5", + ) + self.assertEqual(get_agent_model("h35", None, p), "claude-fable-5") + + def test_alias_unservable_on_provider_inherits(self): + # A known alias whose canonical target the session provider does + # not serve degrades to inherit (never the raw spelling, never a + # foreign id that would 400 louder). + d = self._deepseek() + self.assertEqual( + get_agent_model("claude-haiku", None, d), "deepseek-v4-pro", + ) + + def test_same_tier_alias_still_keeps_parent_exact_model(self): + # M2 precedes the table: a sonnet-tier session asked for 'sonnet' + # keeps its exact (possibly older) model — no surprise upgrade to + # the table's claude-sonnet-5. + p = self._anthropic(model="claude-sonnet-4-6") + self.assertEqual(get_agent_model("sonnet", None, p), "claude-sonnet-4-6") + + def test_stale_table_row_degrades_to_inherit(self): + # If a registry row ever names a model the provider's catalog does + # not list, degrade to inherit instead of shipping a 404. + p = self._anthropic() + with patch.object( + type(p), "get_available_models", return_value=["claude-fable-5"], + ): + self.assertEqual(get_agent_model(None, None, p), "claude-fable-5") + self.assertEqual(get_agent_model(None, "haiku", p), "claude-fable-5") + + def test_general_purpose_and_explore_defs_route_as_designed(self): + # End-to-end over the built-in defs: general-purpose (no model) → + # provider default; Explore (haiku) → provider haiku tier. + from src.agent.agent_definitions import EXPLORE_AGENT, GENERAL_PURPOSE_AGENT + + a = self._anthropic() + d = self._deepseek() + self.assertEqual( + get_agent_model(None, GENERAL_PURPOSE_AGENT.model, a), + "claude-haiku-4-5", + ) + self.assertEqual( + get_agent_model(None, EXPLORE_AGENT.model, a), + "claude-haiku-4-5", + ) + self.assertEqual( + get_agent_model(None, GENERAL_PURPOSE_AGENT.model, d), + "deepseek-v4-flash", + ) + self.assertEqual( + get_agent_model(None, EXPLORE_AGENT.model, d), "deepseek-v4-flash", + ) + + class TestModelResolutionIsConcurrencySafe(unittest.TestCase): """The resolver must NOT mutate the shared session provider (ch07 made Agent concurrency-safe → parallel subagents share the provider).""" diff --git a/tests/test_model_command.py b/tests/test_model_command.py index db59682ee..9f6838551 100644 --- a/tests/test_model_command.py +++ b/tests/test_model_command.py @@ -39,8 +39,11 @@ ) from src.models.model import canonical_model_name, display_name -_SONNET = "claude-sonnet-4-20250514" -_OPUS = "claude-opus-4-20250514" +# Current live ids (2026-08 alias refresh): ``canonical_model_name("opus")`` +# must equal _OPUS for the alias-resolution tests, and the bare aliases now +# track the Claude 5 family. +_SONNET = "claude-sonnet-5" +_OPUS = "claude-opus-5" # --------------------------------------------------------------------------- # diff --git a/tests/test_model_system.py b/tests/test_model_system.py index 6ae237eff..20629b368 100644 --- a/tests/test_model_system.py +++ b/tests/test_model_system.py @@ -18,26 +18,32 @@ from src.models.validation import validate_model_name, is_model_allowed, _matches_pattern from src.models.bedrock import BEDROCK_MODEL_MAP, to_bedrock_model_id, from_bedrock_model_id from src.models.context import get_context_window_for_model, get_model_max_output_tokens -from src.models.agent_routing import get_model_for_agent class TestAliases: def test_resolve_known_alias(self): - assert resolve_alias("sonnet") == "claude-sonnet-4-20250514" - assert resolve_alias("opus") == "claude-opus-4-20250514" - assert resolve_alias("haiku") == "claude-3-5-haiku-20241022" + # The bare family aliases track the CURRENT live model of each + # family (2026-08 refresh; the old 2025 targets were retired from + # the API and 404). + assert resolve_alias("sonnet") == "claude-sonnet-5" + assert resolve_alias("opus") == "claude-opus-5" + assert resolve_alias("haiku") == "claude-haiku-4-5" + assert resolve_alias("fable") == "claude-fable-5" def test_resolve_case_insensitive(self): - assert resolve_alias("Sonnet") == "claude-sonnet-4-20250514" - assert resolve_alias("OPUS") == "claude-opus-4-20250514" + assert resolve_alias("Sonnet") == "claude-sonnet-5" + assert resolve_alias("OPUS") == "claude-opus-5" def test_resolve_unknown_returns_input(self): assert resolve_alias("gpt-4o") == "gpt-4o" assert resolve_alias("unknown-model") == "unknown-model" def test_shortcut_aliases(self): - assert resolve_alias("s4") == "claude-sonnet-4-20250514" - assert resolve_alias("o4") == "claude-opus-4-20250514" + # s4/o4 track the newest LIVE 4.x snapshots, same as their long + # forms claude-4-sonnet/claude-4-opus (the dated 2025-05-14 + # targets were retired from the API). + assert resolve_alias("s4") == "claude-sonnet-4-6" + assert resolve_alias("o4") == "claude-opus-4-8" class TestModelConfigs: @@ -143,7 +149,7 @@ def test_minimax_model_modalities(self): class TestModelResolution: def test_resolve_alias(self): - assert resolve_model("sonnet") == "claude-sonnet-4-20250514" + assert resolve_model("sonnet") == "claude-sonnet-5" def test_resolve_canonical(self): assert resolve_model("claude-sonnet-4-20250514") == "claude-sonnet-4-20250514" @@ -157,7 +163,7 @@ def test_display_name_unknown(self): assert len(name) > 0 def test_canonical_model_name(self): - assert canonical_model_name("sonnet") == "claude-sonnet-4-20250514" + assert canonical_model_name("sonnet") == "claude-sonnet-5" def test_deprecation_warning_deprecated(self): warning = deprecation_warning("claude-3-5-sonnet-20240620") @@ -234,23 +240,10 @@ def test_max_output_unknown(self): assert get_model_max_output_tokens("unknown") == 8_192 -class TestAgentRouting: - def test_inherit_parent(self): - model = get_model_for_agent("general-purpose", parent_model="claude-sonnet-4-20250514") - assert model == "claude-sonnet-4-20250514" - - def test_config_override(self): - config = {"agent_models": {"general-purpose": "claude-opus-4-20250514"}} - model = get_model_for_agent( - "general-purpose", - parent_model="claude-sonnet-4-20250514", - config=config, - ) - assert model == "claude-opus-4-20250514" - - def test_no_config(self): - model = get_model_for_agent("explore", parent_model="my-model") - assert model == "my-model" +# TestAgentRouting was deleted with src/models/agent_routing.py (2026-08): +# it pinned inherit-parent semantics for unspecified-model agents, which now +# contradicts the shipped resolver (see tests/test_ch08_subagents_round4.py +# TestPerProviderSubagentDefaults for the live contract). class TestOneMillionContextSuffix: diff --git a/tests/test_providers.py b/tests/test_providers.py index 00776e477..e19880aea 100644 --- a/tests/test_providers.py +++ b/tests/test_providers.py @@ -128,11 +128,16 @@ def test_build_response_preserves_signed_thinking_blocks(self): ) def test_get_available_models(self): - """Test getting available models.""" + """Test getting available models (live-catalog ids; the retired + claude-sonnet-4-20250514 / claude-3-5-* generation was pruned — + listed-but-dead ids turn the subagent availability gate's degrade + path into a shipped 404).""" provider = AnthropicProvider(api_key="test_key") models = provider.get_available_models() - self.assertIn("claude-sonnet-4-20250514", models) - self.assertIn("claude-3-5-sonnet-20241022", models) + self.assertIn("claude-sonnet-5", models) + self.assertIn("claude-haiku-4-5", models) + self.assertNotIn("claude-sonnet-4-20250514", models) + self.assertNotIn("claude-3-5-haiku-20241022", models) @patch("src.providers.anthropic_provider.anthropic.Anthropic") def test_chat(self, mock_anthropic): diff --git a/tests/workflow/test_runner_integration.py b/tests/workflow/test_runner_integration.py index aeb83be20..852bf8480 100644 --- a/tests/workflow/test_runner_integration.py +++ b/tests/workflow/test_runner_integration.py @@ -150,3 +150,51 @@ def test_schema_repair_prompt_quotes_error_and_schema(): assert "do NOT" in s and "search" in s # tells the model not to re-search # a None error (model skipped the tool) gets explanatory phrasing assert "did not call the StructuredOutput tool" in _schema_repair_prompt(schema, None) + + +async def test_runner_model_slot_defaults_through_agent_definition(tmp_path): + """critic r4 MAJOR-1 — the runner's model slot resolves + spec.model > agent-def ``model:`` > 'inherit'. A bare 'inherit' pin + would override an opts.agentType agent's declared model (Explore's + haiku); a bare None would take the provider's cheap subagent default, + breaking the Workflow contract ("inherits the main-loop model").""" + from unittest.mock import patch + + from src.agent.agent_definitions import EXPLORE_AGENT + from src.types.content_blocks import TextBlock + from src.types.messages import AssistantMessage + + captured: list = [] + + async def _capture(params): + captured.append(params.model) + yield AssistantMessage(content=[TextBlock(text="ok")]) + + provider = _ScriptedProvider([_resp("unused")]) + registry = build_default_registry(provider=provider) + ctx = ToolContext(workspace_root=tmp_path) + + def _mk(resolver): + return LiveAgentRunner( + provider=provider, + tool_registry=registry, + parent_context=ctx, + base_tools=list(registry.list_tools()), + resolve_agent=resolver, + run_id="wf_mtest", + max_turns=2, + ) + + with patch("src.agent.run_agent.run_agent", _capture): + await _mk(lambda _t: GENERAL_PURPOSE_AGENT).run( + AgentSpec(prompt="p"), abort=create_abort_controller(), index="0", + ) + await _mk(lambda _t: EXPLORE_AGENT).run( + AgentSpec(prompt="p"), abort=create_abort_controller(), index="1", + ) + await _mk(lambda _t: GENERAL_PURPOSE_AGENT).run( + AgentSpec(prompt="p", model="opus"), + abort=create_abort_controller(), index="2", + ) + + assert captured == ["inherit", "haiku", "opus"] diff --git a/ui-tui/src/__tests__/gatewayClient.test.ts b/ui-tui/src/__tests__/gatewayClient.test.ts index d321396b6..5e7820f88 100644 --- a/ui-tui/src/__tests__/gatewayClient.test.ts +++ b/ui-tui/src/__tests__/gatewayClient.test.ts @@ -860,13 +860,19 @@ describe('GatewayClient NDJSON adapter', () => { it('maps agent_progress to subagent.start + subagent.progress', async () => { proc.line({ activity: 'reading src/', agent_id: 'a1', description: 'explore the repo', - name: 'Explore', status: 'running', subagent_type: 'Explore', - tokens: 120, tool_use_count: 2, type: 'agent_progress' + model: 'claude-haiku-4-5', name: 'Explore', status: 'running', + subagent_type: 'Explore', tokens: 120, tool_use_count: 2, + type: 'agent_progress' }) await vi.waitFor(() => expect(last('subagent.start')).toBeTruthy()) expect(last('subagent.start').payload.subagent_id).toBe('a1') + // The routed model must survive the bridge — the agents overlay falls + // back to 'inherit' without it, which the per-provider subagent + // defaults make actively wrong. + expect(last('subagent.start').payload.model).toBe('claude-haiku-4-5') await vi.waitFor(() => expect(last('subagent.progress')).toBeTruthy()) expect(last('subagent.progress').payload.text).toBe('reading src/') + expect(last('subagent.progress').payload.model).toBe('claude-haiku-4-5') }) it('emits subagent.start only once, then progress + complete', async () => { diff --git a/ui-tui/src/gatewayClient.ts b/ui-tui/src/gatewayClient.ts index 993ec014c..69d9821d3 100644 --- a/ui-tui/src/gatewayClient.ts +++ b/ui-tui/src/gatewayClient.ts @@ -2475,6 +2475,11 @@ export class GatewayClient extends EventEmitter { const payload: any = { depth: msg.depth ?? 0, goal: msg.description || msg.name || 'subagent', + // The model the subagent actually runs on. Without this the + // agents overlay falls back to 'inherit' — which the + // per-provider default-subagent-model change makes actively + // wrong (spawns default to e.g. claude-haiku-4-5 now). + model: msg.model, subagent_id: aid, subagent_type: msg.subagent_type }