fix(claude-native): cold-resume a session on its persisted canonical model (#5167)
A pane's /model persists the exact id it runs (claude-opus-4-8), but the launch gate only accepted an exact catalog row, and a direct-login catalog spells that family as alias rows (opus -> claude-opus-5, the appended claude-opus-4-8[1m] default). Every live switch to a non-default model therefore armed a resume failure: "not in this host's current model list". Accept a canonical Anthropic id when the endpoint serves canonical spellings and the catalog lists the id's family — the same fold /model already applies to an unpinned canonical id — and keep refusing gateways, Bedrock, and unlisted families so a stale pick still fails fast. Covers the cold resume of a persisted pick for claude and codex in the live model-flows suite (red on claude before the fix, green after; codex has no alias layer and passes both ways), plus unit coverage of the fold and the launch gate. Closes #5158
This commit is contained in:
@@ -415,6 +415,57 @@ def _serves_canonical_anthropic_ids(claude_config: ClaudeNativeUcodeConfig) -> b
|
||||
return host == "anthropic.com" or host.endswith(".anthropic.com")
|
||||
|
||||
|
||||
def _claude_family(token: str) -> str | None:
|
||||
"""
|
||||
The family alias a model id or alias folds onto, bracket markers dropped.
|
||||
|
||||
:param token: A picker id or model id, e.g. ``"opus[1m]"``,
|
||||
``"claude-opus-4-8"``.
|
||||
:returns: The family alias, e.g. ``"opus"``, or ``None`` for none.
|
||||
"""
|
||||
from omnigent.claude_model_vocabulary import claude_model_alias
|
||||
|
||||
alias = claude_model_alias(token, {})
|
||||
return alias.partition("[")[0] if alias else None
|
||||
|
||||
|
||||
def claude_catalog_serves_model(
|
||||
rows: list[dict[str, object]],
|
||||
model: str,
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
) -> bool:
|
||||
"""
|
||||
Whether a launch of *model* is backed by this config's catalog.
|
||||
|
||||
An exact row — a picker id or its wire model — always serves. A canonical
|
||||
Anthropic id no row spells exactly still launches when the endpoint takes
|
||||
canonical spellings (``--model`` passes any string through, and a pane's
|
||||
``/model`` persists exactly this id) and the catalog lists the id's
|
||||
family: the same family fold ``/model`` applies to an unpinned canonical
|
||||
id. A gateway that routes only its own ids, and a family the catalog
|
||||
does not list, refuse — a genuinely stale pick still fails fast.
|
||||
|
||||
:param rows: Catalog rows, e.g.
|
||||
``[{"id": "opus", "model": "claude-opus-5"}]``.
|
||||
:param model: A picker id or model id, e.g. ``"claude-opus-4-8"``.
|
||||
:param claude_config: The resolved launch config, or ``None`` (Claude's
|
||||
own login).
|
||||
:returns: ``True`` when the launch can run *model* against this catalog.
|
||||
"""
|
||||
from omnigent.model_catalog_store import catalog_contains
|
||||
|
||||
if catalog_contains(rows, model):
|
||||
return True
|
||||
if claude_config is not None and not _serves_canonical_anthropic_ids(claude_config):
|
||||
return False
|
||||
if not model.lower().startswith("claude-"):
|
||||
return False
|
||||
family = _claude_family(model)
|
||||
return family is not None and any(
|
||||
_claude_family(str(row.get("id") or row.get("model") or "")) == family for row in rows
|
||||
)
|
||||
|
||||
|
||||
def resolve_claude_native_model_selection(
|
||||
model: str | None,
|
||||
claude_config: ClaudeNativeUcodeConfig | None,
|
||||
|
||||
@@ -6486,8 +6486,8 @@ async def _auto_create_claude_terminal(
|
||||
# or to resolve a Default launch that would otherwise pass no ``--model``
|
||||
# and leave the model to invisible CLI-private state.
|
||||
if session_model_override or launch_model is None:
|
||||
from omnigent.claude_native import claude_launch_catalog
|
||||
from omnigent.model_catalog_store import catalog_contains, default_row
|
||||
from omnigent.claude_native import claude_catalog_serves_model, claude_launch_catalog
|
||||
from omnigent.model_catalog_store import default_row
|
||||
|
||||
launch_catalog: list[dict[str, object]] | None = None
|
||||
try:
|
||||
@@ -6501,9 +6501,11 @@ async def _auto_create_claude_terminal(
|
||||
resolve_claude_native_model_selection(session_model_override, claude_config)
|
||||
or session_model_override
|
||||
)
|
||||
# A pane's ``/model`` persists the exact id it runs; the catalog
|
||||
# may spell that model only by its family alias.
|
||||
if not (
|
||||
catalog_contains(launch_catalog, session_model_override)
|
||||
or catalog_contains(launch_catalog, resolved_request)
|
||||
claude_catalog_serves_model(launch_catalog, session_model_override, claude_config)
|
||||
or claude_catalog_serves_model(launch_catalog, resolved_request, claude_config)
|
||||
):
|
||||
raise click.ClickException(
|
||||
f"the requested model {session_model_override!r} is not in this "
|
||||
|
||||
@@ -12,7 +12,8 @@ the sandbox config per test group, mirroring how ``omnigent setup`` flips the
|
||||
Every test drives the product the way a person uses it: the browser drives the
|
||||
rig server's real SPA (Playwright sync API), and harness truth is read where a
|
||||
user reads it — the tmux pane and the harness's own on-disk state. REST reads
|
||||
are secondary probes only.
|
||||
are secondary probes only, and REST writes are reserved for the rows whose
|
||||
actor is not the browser (a REPL ``/model``, a routing pin, an API client).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -522,6 +523,24 @@ class PaneWatcher:
|
||||
)
|
||||
|
||||
|
||||
def kill_pane(pane: PaneWatcher) -> None:
|
||||
"""
|
||||
End the session's pane the way an idle reap or a host restart does.
|
||||
|
||||
Kills the pane's whole tmux server, so the harness process goes with it
|
||||
and the runner's next turn has to re-create the terminal (its cold-resume
|
||||
launch path).
|
||||
|
||||
:param pane: The session's discovered pane.
|
||||
"""
|
||||
assert pane.socket is not None, "call wait_for_pane first"
|
||||
subprocess.run(
|
||||
["tmux", "-S", str(pane.socket), "kill-server"],
|
||||
capture_output=True,
|
||||
timeout=15,
|
||||
)
|
||||
|
||||
|
||||
def codex_config_copy_model(session_id: str) -> str | None:
|
||||
"""Return the ``model =`` line of a codex session's private config copy.
|
||||
|
||||
@@ -552,6 +571,96 @@ def codex_config_copy_model(session_id: str) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# REST driving (the SPA's own calls, for rows whose actor is not the browser)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def rest_create_session(
|
||||
base_url: str,
|
||||
*,
|
||||
agent_name: str,
|
||||
host_id: str,
|
||||
workspace: Path,
|
||||
terminal_launch_args: list[str] | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Create a host-spawned native session with the create call the SPA makes.
|
||||
|
||||
:param base_url: Rig server base URL.
|
||||
:param agent_name: Built-in wrapper agent, e.g. ``"claude-native-ui"``.
|
||||
:param host_id: Host to launch on.
|
||||
:param workspace: Absolute workspace path on that host.
|
||||
:param terminal_launch_args: Pass-through CLI args, e.g. :func:`bypass_args`.
|
||||
:returns: The new session id.
|
||||
"""
|
||||
agents = httpx.get(f"{base_url}/v1/agents", timeout=30)
|
||||
agents.raise_for_status()
|
||||
agent_id = next((a["id"] for a in agents.json()["data"] if a["name"] == agent_name), None)
|
||||
assert agent_id is not None, f"{agent_name!r} is not registered on the rig server"
|
||||
body: dict[str, Any] = {"agent_id": agent_id, "host_id": host_id, "workspace": str(workspace)}
|
||||
if terminal_launch_args:
|
||||
body["terminal_launch_args"] = list(terminal_launch_args)
|
||||
resp = httpx.post(f"{base_url}/v1/sessions", json=body, timeout=120)
|
||||
assert resp.status_code < 400, f"create failed {resp.status_code}: {resp.text[:2000]}"
|
||||
return str(resp.json()["id"])
|
||||
|
||||
|
||||
def rest_patch_session(base_url: str, session_id: str, **fields: Any) -> dict[str, Any]:
|
||||
"""
|
||||
PATCH session fields — a REPL ``/model`` and an API client write this way.
|
||||
|
||||
A native model change is forwarded to the pane and answered only once the
|
||||
harness confirmed it, so the call may take a while.
|
||||
|
||||
:param base_url: Rig server base URL.
|
||||
:param session_id: Session id.
|
||||
:param fields: Wire fields, e.g. ``model_override="claude-opus-4-8"``.
|
||||
:returns: The updated session payload.
|
||||
"""
|
||||
resp = httpx.patch(f"{base_url}/v1/sessions/{session_id}", json=fields, timeout=180)
|
||||
assert resp.status_code < 400, (
|
||||
f"PATCH {sorted(fields)} failed {resp.status_code}: {resp.text[:2000]}"
|
||||
)
|
||||
return dict(resp.json())
|
||||
|
||||
|
||||
def rest_post_user_message(base_url: str, session_id: str, text: str) -> None:
|
||||
"""
|
||||
POST a user message the way the composer does.
|
||||
|
||||
:param base_url: Rig server base URL.
|
||||
:param session_id: Session id.
|
||||
:param text: The message text.
|
||||
"""
|
||||
resp = httpx.post(
|
||||
f"{base_url}/v1/sessions/{session_id}/events",
|
||||
json={
|
||||
"type": "message",
|
||||
"data": {"role": "user", "content": [{"type": "input_text", "text": text}]},
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
assert resp.status_code < 400, f"message POST failed {resp.status_code}: {resp.text[:1000]}"
|
||||
|
||||
|
||||
def assistant_message_count(snapshot: dict[str, Any]) -> int:
|
||||
"""
|
||||
Count the assistant messages a session snapshot carries.
|
||||
|
||||
:param snapshot: A ``GET /v1/sessions/{id}`` payload.
|
||||
:returns: The number of assistant ``message`` items.
|
||||
"""
|
||||
count = 0
|
||||
for item in snapshot.get("items") or []:
|
||||
if not isinstance(item, dict) or item.get("type") != "message":
|
||||
continue
|
||||
data = item.get("data") if isinstance(item.get("data"), dict) else item
|
||||
if data.get("role") == "assistant":
|
||||
count += 1
|
||||
return count
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Browser driving (sync Playwright over the rig server's real SPA)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Live model-flow CUJs (model-flows-design.md §10.1, the "UI-live" tier).
|
||||
|
||||
Every test drives the product the way a person uses it — the browser drives the
|
||||
rig server's real SPA, and harness truth is read from the tmux pane — with REST
|
||||
snapshots as secondary probes only. Assertions encode the DESIGN's target
|
||||
rig server's real SPA (or, for the rows whose actor is a REPL, a routing pin,
|
||||
or an API client, the same REST calls), and harness truth is read from the
|
||||
tmux pane — with REST snapshots as secondary probes only. Assertions encode the DESIGN's target
|
||||
behavior, so this suite is red on unmodified main (and partially red on the PR
|
||||
branch) in exactly the ways `model-flows-report.md` cites, and goes green as
|
||||
the landing-order steps land. Run one row's red twin with::
|
||||
@@ -18,20 +19,30 @@ from __future__ import annotations
|
||||
import re
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from omnigent.native_coding_agents import CLAUDE_NATIVE_AGENT_NAME, CODEX_NATIVE_AGENT_NAME
|
||||
from tests.e2e.omnigent._model_flows_rig import (
|
||||
ModelFlowsRig,
|
||||
PaneWatcher,
|
||||
Ui,
|
||||
assistant_message_count,
|
||||
booted_rig,
|
||||
browser_ui,
|
||||
bypass_args,
|
||||
codex_config_copy_model,
|
||||
dismiss_blocking_dialogs,
|
||||
host_model_options,
|
||||
kill_pane,
|
||||
require_clis,
|
||||
require_opt_in,
|
||||
rest_create_session,
|
||||
rest_patch_session,
|
||||
rest_post_user_message,
|
||||
session_snapshot,
|
||||
shape_providers,
|
||||
)
|
||||
@@ -551,3 +562,164 @@ def test_row9_codex_subscription_default_first_turn_succeeds(
|
||||
f"stale-config-line class (finding B). Pane:\n{settled[-600:]}\n"
|
||||
f"(config-copy pin: {codex_config_copy_model(session_id)!r})"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Cold resume: the persisted pick survives the pane (claude + codex)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
#: Host shape, wrapper agent, and pane-ready marker per harness.
|
||||
_COLD_RESUME_HARNESSES: dict[str, tuple[str, str, str]] = {
|
||||
"claude-native": ("claude-subscription", CLAUDE_NATIVE_AGENT_NAME, r"│"),
|
||||
"codex-native": ("codex-subscription", CODEX_NATIVE_AGENT_NAME, r"›"),
|
||||
}
|
||||
|
||||
|
||||
def _resume_override(harness: str, rows: list[dict[str, Any]]) -> str | None:
|
||||
"""
|
||||
The model to persist, spelled the way the harness itself reports it.
|
||||
|
||||
claude: a canonical Anthropic id no row spells exactly — the plain twin of
|
||||
a listed ``[1m]`` model. The endpoint serves it (the 1M marker is a request
|
||||
flag on the same model) and the pane reports exactly that id after a
|
||||
``/model`` to it; only the catalog's alias rows know its family.
|
||||
|
||||
codex: a non-default row id — codex has no alias layer, so the harness's
|
||||
own report IS a catalog id.
|
||||
|
||||
:param harness: ``"claude-native"`` or ``"codex-native"``.
|
||||
:param rows: The host's pre-launch catalog rows.
|
||||
:returns: The override to persist, or ``None`` when the catalog offers
|
||||
no such spelling.
|
||||
"""
|
||||
listed = {str(v) for row in rows for v in (row.get("id"), row.get("model")) if v}
|
||||
if harness == "claude-native":
|
||||
for row in rows:
|
||||
model = str(row.get("model") or "")
|
||||
if model.startswith("claude-") and model.endswith("[1m]") and model[:-4] not in listed:
|
||||
return model[:-4]
|
||||
return None
|
||||
for row in rows:
|
||||
if row.get("isDefault") is True or row.get("hidden") is True:
|
||||
continue
|
||||
row_id = row.get("id")
|
||||
if isinstance(row_id, str) and row_id:
|
||||
return row_id
|
||||
return None
|
||||
|
||||
|
||||
def _same_model(reported: object, wanted: str) -> bool:
|
||||
"""
|
||||
Whether a harness report names *wanted* (a dated suffix allowed)."""
|
||||
if not isinstance(reported, str) or not reported:
|
||||
return False
|
||||
lhs, rhs = reported.lower(), wanted.lower()
|
||||
return lhs == rhs or lhs.startswith(f"{rhs}-")
|
||||
|
||||
|
||||
@pytest.mark.timeout(900)
|
||||
@pytest.mark.parametrize("harness", sorted(_COLD_RESUME_HARNESSES))
|
||||
def test_row18_cold_resume_honors_the_persisted_model_override(
|
||||
rig: ModelFlowsRig,
|
||||
shaped_host: Callable[[str], str],
|
||||
harness: str,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""
|
||||
A session resumes on the persisted model the harness was already running.
|
||||
|
||||
The pane exits (idle reap, host restart) and the next message re-creates
|
||||
the terminal. The relaunch must pass the persisted model straight through
|
||||
— the host demonstrably served it moments earlier — instead of refusing
|
||||
it as "not in this host's current model list" because the catalog spells
|
||||
the same model differently (an alias row carries the family, the override
|
||||
the canonical id).
|
||||
"""
|
||||
shape, agent_name, ready = _COLD_RESUME_HARNESSES[harness]
|
||||
require_clis(harness.split("-")[0])
|
||||
host_id = shaped_host(shape)
|
||||
|
||||
def _rows() -> list[dict[str, Any]] | None:
|
||||
try:
|
||||
payload = host_model_options(rig.base_url, host_id, harness)
|
||||
except httpx.HTTPError:
|
||||
return None
|
||||
return payload.get("models") or None
|
||||
|
||||
rows = wait_for(_rows, timeout=180.0, what=f"the boot-warmed {harness} catalog")
|
||||
override = _resume_override(harness, rows)
|
||||
if override is None:
|
||||
pytest.skip(f"this {harness} catalog offers no spelling to exercise: {rows}")
|
||||
|
||||
workspace = tmp_path / "ws"
|
||||
workspace.mkdir()
|
||||
(workspace / "README.md").write_text("# cold resume workspace\n")
|
||||
pane = PaneWatcher()
|
||||
pane.arm()
|
||||
session_id = rest_create_session(
|
||||
rig.base_url,
|
||||
agent_name=agent_name,
|
||||
host_id=host_id,
|
||||
workspace=workspace,
|
||||
terminal_launch_args=bypass_args(harness),
|
||||
)
|
||||
rest_post_user_message(rig.base_url, session_id, "Reply with exactly: ok. Nothing else.")
|
||||
pane.wait_for_pane()
|
||||
pane.wait_for_text(ready)
|
||||
dismiss_blocking_dialogs(pane)
|
||||
|
||||
def _replies(at_least: int) -> Callable[[], int | None]:
|
||||
def _count() -> int | None:
|
||||
snapshot = session_snapshot(rig.base_url, session_id)
|
||||
assert snapshot.get("status") != "failed", (
|
||||
f"session {session_id} failed: "
|
||||
f"{snapshot.get('last_task_error') or snapshot.get('error')}"
|
||||
)
|
||||
count = assistant_message_count(snapshot)
|
||||
return count if count >= at_least else None
|
||||
|
||||
return _count
|
||||
|
||||
def _reported_override() -> str | None:
|
||||
reported = session_snapshot(rig.base_url, session_id).get("llm_model")
|
||||
return str(reported) if _same_model(reported, override) else None
|
||||
|
||||
wait_for(_replies(1), timeout=150.0, what="the first reply")
|
||||
|
||||
# The actor is a REPL ``/model``, a routing pin, or an API client: they
|
||||
# persist the harness's own spelling. The pane switches live — this host
|
||||
# serves the model — and reports it back verbatim.
|
||||
rest_patch_session(rig.base_url, session_id, model_override=override)
|
||||
rest_post_user_message(rig.base_url, session_id, "Reply with exactly: switched. Nothing else.")
|
||||
wait_for(_replies(2), timeout=150.0, what="the reply on the switched model")
|
||||
wait_for(_reported_override, timeout=60.0, what=f"the harness to report {override!r}")
|
||||
|
||||
# The pane exits; the next message is the resume.
|
||||
kill_pane(pane)
|
||||
resumed = PaneWatcher()
|
||||
resumed.arm()
|
||||
rest_post_user_message(rig.base_url, session_id, "Reply with exactly: back. Nothing else.")
|
||||
wait_for(_replies(3), timeout=240.0, what="the reply after the cold resume")
|
||||
|
||||
snapshot = session_snapshot(rig.base_url, session_id)
|
||||
assert snapshot.get("model_override") == override, (
|
||||
f"the resume rewrote the persisted pick: {snapshot.get('model_override')!r}"
|
||||
)
|
||||
assert _same_model(snapshot.get("llm_model"), override), (
|
||||
f"the resumed pane runs {snapshot.get('llm_model')!r}, not the persisted "
|
||||
f"{override!r} (session {session_id})"
|
||||
)
|
||||
resumed.wait_for_pane()
|
||||
pane_text = resumed.wait_for_text(ready)
|
||||
if harness == "codex-native":
|
||||
pinned = codex_config_copy_model(session_id)
|
||||
assert pinned == override, (
|
||||
f"the relaunch pinned {pinned!r} in the config copy, not {override!r}"
|
||||
)
|
||||
else:
|
||||
family = override.removeprefix("claude-").split("-")[0]
|
||||
footer = next((line for line in pane_text.splitlines() if "│" in line), "")
|
||||
assert family in footer.lower(), (
|
||||
f"the resumed pane footer {footer!r} does not name the {family} family "
|
||||
f"of {override!r} (session {session_id})"
|
||||
)
|
||||
|
||||
@@ -10,6 +10,7 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
@@ -3182,3 +3183,122 @@ def test_routed_spawn_launch_args_need_a_router() -> None:
|
||||
assert note and tools
|
||||
assert _routed_spawn_launch_args(True, router_started=False) == (None, ())
|
||||
assert _routed_spawn_launch_args(False) == (None, ())
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("endpoint", ["subscription", "gateway"])
|
||||
async def test_auto_create_claude_terminal_launch_gate_folds_a_canonical_override(
|
||||
endpoint: str,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""
|
||||
A persisted canonical id the catalog lists only by family still launches.
|
||||
|
||||
A live ``/model`` persists the pane's exact id (``claude-opus-4-8``) while
|
||||
the catalog spells that family as alias rows and the 1M default. On a
|
||||
canonical endpoint the relaunch must pass the id through as ``--model``
|
||||
rather than refuse the resume; a gateway, which routes only its own
|
||||
spellings, keeps refusing it.
|
||||
"""
|
||||
from omnigent.claude_native import ClaudeNativeUcodeConfig
|
||||
|
||||
monkeypatch.setattr(claude_native_bridge, "_TRUSTED_PARENT", tmp_path)
|
||||
monkeypatch.setattr(claude_native_bridge, "_BRIDGE_ROOT", tmp_path / "root")
|
||||
monkeypatch.setenv("RUNNER_SERVER_URL", "http://127.0.0.1:8000")
|
||||
|
||||
async def _no_op_forwarder(**kwargs: Any) -> None:
|
||||
del kwargs
|
||||
|
||||
monkeypatch.setattr(
|
||||
"omnigent.claude_native_forwarder.supervise_forwarder",
|
||||
_no_op_forwarder,
|
||||
)
|
||||
prefix = "" if endpoint == "subscription" else "system.ai."
|
||||
catalog = [
|
||||
{"id": "opus", "model": f"{prefix}claude-opus-5", "displayName": "Opus 5"},
|
||||
{
|
||||
"id": f"{prefix}claude-opus-4-8[1m]",
|
||||
"model": f"{prefix}claude-opus-4-8[1m]",
|
||||
"displayName": "Opus 4.8 (1M context)",
|
||||
"isDefault": True,
|
||||
},
|
||||
]
|
||||
|
||||
async def _catalog(config: object) -> list[dict[str, object]]:
|
||||
del config
|
||||
return catalog
|
||||
|
||||
monkeypatch.setattr("omnigent.claude_native.claude_launch_catalog", _catalog)
|
||||
|
||||
captured: dict[str, Any] = {}
|
||||
|
||||
class _FakeResourceRegistry:
|
||||
"""Captures the launched terminal spec."""
|
||||
|
||||
terminal_registry = None
|
||||
|
||||
async def launch_required_terminal(
|
||||
self,
|
||||
*,
|
||||
session_id: str,
|
||||
terminal_name: str,
|
||||
session_key: str,
|
||||
spec: Any,
|
||||
resource_role: str | None = None,
|
||||
parent_os_env: Any = None,
|
||||
) -> SessionResourceView:
|
||||
"""Record the spec and return a terminal resource view."""
|
||||
del terminal_name, session_key
|
||||
captured["spec"] = spec
|
||||
return SessionResourceView(
|
||||
id="terminal_claude_main",
|
||||
type="terminal",
|
||||
session_id=session_id,
|
||||
name="claude:main",
|
||||
metadata={"terminal_name": "claude", "session_key": "main", "running": True},
|
||||
)
|
||||
|
||||
def _handle_request(_request: httpx.Request) -> httpx.Response:
|
||||
return httpx.Response(200, json={"model_override": "claude-opus-4-8", "labels": {}})
|
||||
|
||||
fake_client = httpx.AsyncClient(
|
||||
base_url="http://test-server",
|
||||
transport=httpx.MockTransport(_handle_request),
|
||||
)
|
||||
config = (
|
||||
None
|
||||
if endpoint == "subscription"
|
||||
else ClaudeNativeUcodeConfig(
|
||||
env={"ANTHROPIC_BASE_URL": "https://gateway.example/anthropic"},
|
||||
api_key_helper="printf %s sk-gateway",
|
||||
model="system.ai.claude-opus-5",
|
||||
)
|
||||
)
|
||||
|
||||
async def _resolve() -> ClaudeNativeUcodeConfig | None:
|
||||
return config
|
||||
|
||||
session_id = "0f2d3d5c9a6b4e1f8c7d6e5f4a3b2c1d"
|
||||
if endpoint == "subscription":
|
||||
await _auto_create_claude_terminal(
|
||||
session_id,
|
||||
_FakeResourceRegistry(),
|
||||
lambda _sid, _evt: None,
|
||||
server_client=fake_client,
|
||||
resolve_launch_config=_resolve,
|
||||
)
|
||||
args = captured["spec"].args
|
||||
assert args[args.index("--model") + 1] == "claude-opus-4-8"
|
||||
else:
|
||||
with pytest.raises(click.ClickException, match="not in this host's current model list"):
|
||||
await _auto_create_claude_terminal(
|
||||
session_id,
|
||||
_FakeResourceRegistry(),
|
||||
lambda _sid, _evt: None,
|
||||
server_client=fake_client,
|
||||
resolve_launch_config=_resolve,
|
||||
)
|
||||
assert "spec" not in captured, "a refused launch must not start a terminal"
|
||||
|
||||
await fake_client.aclose()
|
||||
|
||||
@@ -9570,3 +9570,78 @@ async def test_probe_claude_model_options_returns_none_on_probe_failure(
|
||||
monkeypatch.setattr("asyncio.create_subprocess_exec", _fake_exec)
|
||||
|
||||
assert await claude_native.probe_claude_model_options(_gateway_probe_config()) is None
|
||||
|
||||
|
||||
def _subscription_catalog() -> list[dict[str, object]]:
|
||||
"""
|
||||
A direct-login catalog: alias rows plus the appended settings default."""
|
||||
return [
|
||||
{"id": "sonnet", "model": "claude-sonnet-5", "displayName": "Sonnet 5"},
|
||||
{"id": "opus", "model": "claude-opus-5", "displayName": "Opus 5"},
|
||||
{"id": "fable", "model": "fable", "displayName": "fable"},
|
||||
{"id": "opus[1m]", "model": "claude-opus-5[1m]", "displayName": "Opus 5 (1M context)"},
|
||||
{
|
||||
"id": "claude-opus-4-8[1m]",
|
||||
"model": "claude-opus-4-8[1m]",
|
||||
"displayName": "Opus 4.8 (1M context)",
|
||||
"isDefault": True,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("model", "config", "served"),
|
||||
[
|
||||
# Exact rows always serve: a picker id, a wire model, the appended default.
|
||||
("opus", None, True),
|
||||
("claude-sonnet-5", None, True),
|
||||
("claude-opus-4-8[1m]", None, True),
|
||||
# A canonical id the endpoint serves but no row spells: the default's
|
||||
# plain twin, an older generation, the bare id behind a bare alias row,
|
||||
# a 1M request on a listed family.
|
||||
("claude-opus-4-8", None, True),
|
||||
("claude-sonnet-4-5-20250929", None, True),
|
||||
("claude-fable-5", None, True),
|
||||
("claude-sonnet-5[1m]", None, True),
|
||||
# Anthropic's own endpoint behind a key serves canonical ids too.
|
||||
(
|
||||
"claude-opus-4-8",
|
||||
claude_native.ClaudeNativeUcodeConfig(
|
||||
env={"ANTHROPIC_BASE_URL": "https://api.anthropic.com"},
|
||||
api_key_helper="printf sk-key",
|
||||
),
|
||||
True,
|
||||
),
|
||||
# A family the catalog does not list is a genuinely stale pick.
|
||||
("claude-haiku-4-5", None, False),
|
||||
("claude-mythos-5", None, False),
|
||||
# Not a canonical Anthropic id: only an exact row could serve it.
|
||||
("gpt-5.4", None, False),
|
||||
("", None, False),
|
||||
# Gateways and Bedrock route their own spellings only.
|
||||
(
|
||||
"claude-opus-4-8",
|
||||
claude_native.ClaudeNativeUcodeConfig(
|
||||
env={"ANTHROPIC_BASE_URL": "https://gateway.example/anthropic"},
|
||||
api_key_helper="printf sk-key",
|
||||
),
|
||||
False,
|
||||
),
|
||||
(
|
||||
"claude-opus-4-8",
|
||||
claude_native.ClaudeNativeUcodeConfig(
|
||||
env={"ANTHROPIC_BEDROCK_BASE_URL": "https://bedrock.example"},
|
||||
api_key_helper=None,
|
||||
),
|
||||
False,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_claude_catalog_serves_model(
|
||||
model: str, config: claude_native.ClaudeNativeUcodeConfig | None, served: bool
|
||||
) -> None:
|
||||
"""
|
||||
Exact rows serve; a canonical id serves on a canonical endpoint when its family is listed."""
|
||||
assert (
|
||||
claude_native.claude_catalog_serves_model(_subscription_catalog(), model, config) is served
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user