From 6d9cd2350fade7fc425419485587473d0f1b4489 Mon Sep 17 00:00:00 2001 From: Xiang Shen Date: Sun, 18 Oct 2026 05:52:25 +0000 Subject: [PATCH] Route Codex smart routing over the catalog Codex loads Without a managed config, the Codex router menu came from every cached system.ai GPT and OSS service, while Codex could only start models in its own catalog. Router picks such as gpt-6-luna then failed spawn_agent with "Unknown model". Routed launches now use one catalog file for both. A configured model_catalog_json is used as before. Otherwise ug discovers the system.ai models Codex can use from /codex/v1/models, builds the catalog with prepare_codex_catalog like a managed list, and passes it to Codex. When discovery fails, the session has no catalog and no routing. Co-authored-by: Isaac --- README.md | 6 + src/ucode/agents/codex.py | 38 ++++++- src/ucode/agents/codex_catalog.py | 9 +- src/ucode/codex_config.py | 17 +-- src/ucode/smart_routing/v2.py | 23 +--- tests/test_codex_catalog.py | 19 ++++ tests/test_codex_smart_routing_v2.py | 163 ++++++++++++++++++--------- 7 files changed, 186 insertions(+), 89 deletions(-) diff --git a/README.md b/README.md index e21fb4983..578f8349d 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,12 @@ ug removes its shared reference on discovery/validation failure, reconfiguration revert, or before installing/updating Codex. After an update, run `ug codex` to refresh discovery or `ug configure` for a managed static list, then restart the app server. +With smart routing on, `ug codex` offers the router only the models listed in the catalog +Codex loads, so every routed model is one Codex can start. Without a configured catalog, +ug discovers the `system.ai` models Codex can use at launch and gives them to that session +as its catalog; Codex App is unchanged. If discovery is unavailable, the session starts +without automatic model switching. + ## Configure ```bash diff --git a/src/ucode/agents/codex.py b/src/ucode/agents/codex.py index 3408c9365..4882da255 100644 --- a/src/ucode/agents/codex.py +++ b/src/ucode/agents/codex.py @@ -22,7 +22,8 @@ codex_config_args, codex_config_precedence_paths, codex_managed_config_path, - custom_catalog_models, + custom_catalog_path, + listed_catalog_models, ) from ucode.config_io import ( APP_DIR, @@ -59,6 +60,7 @@ build_auth_token_argv, build_tool_base_url, get_databricks_token, + model_version_sort_key, ug_binary, ) from ucode.launcher import exec_or_spawn @@ -83,7 +85,6 @@ from ucode.smart_routing import v2 as smart_routing_v2 from ucode.smart_routing.codex_hooks import ( remove_smart_routing_hooks, - routing_models, sync_smart_routing_hooks, ) from ucode.smart_routing.codex_routing import codex_model_id @@ -118,6 +119,8 @@ # Retained only to identify and remove state written by the legacy persisted opt-in. SMART_ROUTING_STATE_KEY = smart_routing_v2.LEGACY_STATE_KEY APP_SERVER_SMART_ROUTING_STARTING_MODEL = "gpt-5.6-luna" +SMART_ROUTING_MODEL_SCHEMA = "system.ai" +SMART_ROUTING_CATALOG_SCOPE = "smart-routing" SPEC: ToolSpec = { "binary": "codex", @@ -1409,8 +1412,8 @@ def _launch_smart_routing(state: dict, tool_args: list[str]) -> None: binary = SPEC["binary"] configured_model = _smart_routing_config_model(state) - # Prefer the custom catalog if it exists. - models = custom_catalog_models() or routing_models(state) + catalog_path = custom_catalog_path() or _write_smart_routing_catalog(state, binary) + models = listed_catalog_models(catalog_path) if catalog_path else [] start_model = ( configured_model or (codex_model_id(models[0]) if models else None) @@ -1421,10 +1424,37 @@ def _launch_smart_routing(state: dict, tool_args: list[str]) -> None: tool_args, binary=binary, start_model=start_model, + catalog_path=catalog_path, render_overlay=render_overlay, ) +def _write_smart_routing_catalog(state: dict, binary: str) -> Path | None: + """Write the workspace's system.ai Codex models to a catalog file; None when that fails.""" + workspace = state.get("workspace") + if not workspace: + return None + try: + gateway_catalog = _fetch_codex_model_catalog( + workspace, + _launch_token(state, workspace), + source=CodexCatalogSource.PARENT_SCHEMA, + identifier=SMART_ROUTING_MODEL_SCHEMA, + ) + # Newest GPT first: the first listed model starts the session when none is configured. + model_names = sorted( + catalog_slugs(gateway_catalog), + key=lambda model: (not _is_gpt_family(model), model_version_sort_key(model)), + ) + catalog = prepare_codex_catalog(binary, model_names, warn_missing_metadata=False) + catalog_path = _model_catalog_path(workspace, SMART_ROUTING_CATALOG_SCOPE) + _write_model_catalog(catalog_path, catalog) + except RuntimeError as exc: + print_warning_err(str(exc)) + return None + return catalog_path + + def disable_smart_routing(state: dict) -> bool: """Disable routing and remove only ucode's Codex routing hooks.""" state.pop(SMART_ROUTING_STATE_KEY, None) diff --git a/src/ucode/agents/codex_catalog.py b/src/ucode/agents/codex_catalog.py index 531932218..3a807d665 100644 --- a/src/ucode/agents/codex_catalog.py +++ b/src/ucode/agents/codex_catalog.py @@ -167,7 +167,9 @@ def validate_codex_catalog(binary: str, catalog: dict) -> None: ) from None -def prepare_codex_catalog(binary: str, names: list[str]) -> dict: +def prepare_codex_catalog( + binary: str, names: list[str], *, warn_missing_metadata: bool = True +) -> dict: """Extract and validate with the launch binary; never fetch or reuse stale metadata. --bundled bypasses discovery. Validation reads an explicit local catalog. @@ -198,6 +200,7 @@ def prepare_codex_catalog(binary: str, names: list[str]) -> dict: "`codex debug models --bundled` works, then retry configuration. " "Gateway discovery was not used." ) from None - for message in fallback_warnings: - print_warning(message) + if warn_missing_metadata: + for message in fallback_warnings: + print_warning(message) return catalog diff --git a/src/ucode/codex_config.py b/src/ucode/codex_config.py index b93148f5d..7c32e0a6d 100644 --- a/src/ucode/codex_config.py +++ b/src/ucode/codex_config.py @@ -12,7 +12,6 @@ from ucode.config_io import read_json_safe, read_toml_safe from ucode.managed_files import OS, current_os -from ucode.ui import print_warning CODEX_PROFILE_NAME = "ucode" DEFAULT_CODEX_CONFIG_PATH = Path.home() / ".codex" / f"{CODEX_PROFILE_NAME}.config.toml" @@ -70,19 +69,9 @@ def custom_catalog_path() -> Path | None: return None -def custom_catalog_models() -> list[str] | None: - """Read model slugs from the configured model_catalog_json, if present.""" - catalog_path = custom_catalog_path() - if catalog_path is None: - return None - slugs = catalog_slugs(read_json_safe(catalog_path), required_visibility=ModelVisibility.LIST) - if slugs: - return slugs - print_warning( - f"Codex smart routing could not read models from the custom catalog {catalog_path}; " - "falling back to the cached model services." - ) - return None +def listed_catalog_models(catalog_path: Path) -> list[str]: + """Read the model slugs a Codex catalog file shows in the model picker.""" + return catalog_slugs(read_json_safe(catalog_path), required_visibility=ModelVisibility.LIST) def catalog_slugs( diff --git a/src/ucode/smart_routing/v2.py b/src/ucode/smart_routing/v2.py index f568af8e7..92a374414 100644 --- a/src/ucode/smart_routing/v2.py +++ b/src/ucode/smart_routing/v2.py @@ -15,11 +15,7 @@ from typing import NoReturn, TextIO from ucode import config_io -from ucode.codex_config import ( - codex_config_args, - custom_catalog_models, - custom_catalog_path, -) +from ucode.codex_config import codex_config_args, listed_catalog_models from ucode.config_io import ( APP_DIR, read_json_safe, @@ -65,7 +61,7 @@ sync_first_prompt_hook, sync_smart_routing_hooks, ) -from ucode.smart_routing.codex_hooks import merge_pre_tool_use_hooks, routing_models +from ucode.smart_routing.codex_hooks import merge_pre_tool_use_hooks from ucode.smart_routing.config import apply_config, resolve_environment from ucode.smart_routing.session_env import SESSION_ENV_VAR, SESSION_PYTHON_ENV_VAR, start_session from ucode.ui import print_note, print_warning @@ -769,12 +765,6 @@ def route_prompt(prompt: str) -> claude_pty.FirstPromptRoute: sys.exit(returncode) -# TODO: Replace with /codex/v1/models once /codex/v1/models can send GPT models as well. -def _cached_routing_models(state: dict) -> list[str]: - """Return the persisted UC model-service ids usable by Codex routing.""" - return routing_models(state) - - def _v2_hooks(state: dict, available_models: list[str]) -> dict: # Codex combines hook sources itself; copying user hooks here would register them twice. doc = { @@ -792,6 +782,7 @@ def launch_codex( *, binary: str, start_model: str | None, + catalog_path: Path | None, render_overlay: Callable[..., dict], ) -> NoReturn: workspace = state.get("workspace") @@ -805,12 +796,11 @@ def launch_codex( ) os.environ[OAUTH_TOKEN_ENV_VAR] = _launch_token(state, workspace) - catalog_models = custom_catalog_models() - available_models = catalog_models or _cached_routing_models(state) + available_models = listed_catalog_models(catalog_path) if catalog_path else [] if not available_models: print_warning( - "Smart routing model metadata is unavailable; automatic model switching is unavailable. " - "Run `ucode configure codex` to enable routing." + "Smart routing has no Codex models to route between; " + "automatic model switching is unavailable." ) custom_oauth = state.get("custom_oauth") overlay = render_overlay( @@ -821,7 +811,6 @@ def launch_codex( custom_oauth=(custom_oauth if custom_oauth_cli_enabled(custom_oauth) else None), managed_http_headers=state.get("codex_http_headers"), ) - catalog_path = custom_catalog_path() if catalog_path is not None: overlay["model_catalog_json"] = str(catalog_path) overlay["hooks"] = _v2_hooks(state, available_models) diff --git a/tests/test_codex_catalog.py b/tests/test_codex_catalog.py index 3d5aa26e5..d90bd85c5 100644 --- a/tests/test_codex_catalog.py +++ b/tests/test_codex_catalog.py @@ -280,6 +280,25 @@ def run(argv, **kwargs): ] +def test_missing_gpt_warning_can_be_silenced(monkeypatch, bundled): + warnings = [] + monkeypatch.setattr( + catalog.subprocess, + "run", + lambda argv, **kwargs: subprocess.CompletedProcess( + argv, 0, json.dumps({"models": bundled}) + ), + ) + monkeypatch.setattr(catalog, "print_warning", warnings.append) + + result = catalog.prepare_codex_catalog( + "codex", ["system.ai.gpt-99"], warn_missing_metadata=False + ) + + assert result["models"][0]["slug"] == "system.ai.gpt-99" + assert warnings == [] + + @pytest.mark.parametrize( "stdout", [ diff --git a/tests/test_codex_smart_routing_v2.py b/tests/test_codex_smart_routing_v2.py index 4c26cdaad..6a365e612 100644 --- a/tests/test_codex_smart_routing_v2.py +++ b/tests/test_codex_smart_routing_v2.py @@ -13,6 +13,14 @@ WS = "https://example.databricks.com" +def _catalog_file(path, slugs): + path.write_text( + json.dumps({"models": [{"slug": slug, "visibility": "list"} for slug in slugs]}), + encoding="utf-8", + ) + return path + + def test_smart_routing_switch_message_is_boxed(): message = v2.format_routing_notice("model-x", "Because X.") @@ -53,6 +61,8 @@ def test_codex_smart_routing_launch_dispatches_to_v2(self, monkeypatch, tool_arg monkeypatch.setenv(v2.ENABLE_SMART_ROUTING_ENV_VAR, "1") monkeypatch.setattr(codex, "_smart_routing_config_model", lambda state: "gpt-start") monkeypatch.setattr(codex, "clear_model_preferences", lambda state: False) + monkeypatch.setattr(codex, "custom_catalog_path", lambda: None) + monkeypatch.setattr(codex, "_write_smart_routing_catalog", lambda state, binary: None) def launch_v2(state, tool_args, **kwargs): calls.append((state, tool_args, kwargs)) @@ -72,6 +82,7 @@ def launch_v2(state, tool_args, **kwargs): { "binary": "codex", "start_model": "gpt-start", + "catalog_path": None, "render_overlay": codex.render_overlay, }, ) @@ -109,27 +120,78 @@ def test_codex_launch_bypasses_routing_for_other_shapes(self, tmp_path, monkeypa assert launches == [["codex", "--config", 'model_provider="ucode-databricks"', *tool_args]] - def test_codex_launch_normalizes_cached_bootstrap_model(self, monkeypatch): + def test_routed_launch_discovers_system_ai_catalog(self, monkeypatch): + fetches = [] + prepared = [] calls = [] monkeypatch.setenv(v2.ENABLE_SMART_ROUTING_ENV_VAR, "1") - monkeypatch.setattr(codex, "custom_catalog_models", lambda: None) - monkeypatch.setattr(codex, "clear_model_preferences", lambda state: False) + monkeypatch.setattr(codex, "custom_catalog_path", lambda: None) monkeypatch.setattr(codex, "_smart_routing_config_model", lambda state: None) + monkeypatch.setattr(codex, "_launch_token", lambda state, workspace: "token") + + def fetch_catalog(workspace, token, *, source, identifier): + fetches.append((workspace, token, source, identifier)) + slugs = ("system.ai.glm-5-3", "system.ai.gpt-5-6-luna", "system.ai.gpt-6-astra") + return {"models": [{"slug": slug} for slug in slugs]} + + def prepare_catalog(binary, names, *, warn_missing_metadata): + prepared.append((binary, names, warn_missing_metadata)) + return {"models": [{"slug": name, "visibility": "list"} for name in names]} def launch_v2(state, tool_args, **kwargs): calls.append(kwargs) raise SystemExit(0) + monkeypatch.setattr(codex, "_fetch_codex_model_catalog", fetch_catalog) + monkeypatch.setattr(codex, "prepare_codex_catalog", prepare_catalog) monkeypatch.setattr(v2, "launch_codex", launch_v2) with pytest.raises(SystemExit): codex.launch( - {"workspace": WS, "codex_models": ["system.ai.gpt-5-6-luna"]}, + {"workspace": WS, "codex_models": ["system.ai.gpt-6-1-sol"]}, + [], + options=LaunchOptions(launch_smart_routing=True), + ) + + newest_gpt_first = ["system.ai.gpt-6-astra", "system.ai.gpt-5-6-luna", "system.ai.glm-5-3"] + assert fetches == [(WS, "token", codex.CodexCatalogSource.PARENT_SCHEMA, "system.ai")] + assert prepared == [("codex", newest_gpt_first, False)] + assert codex_config.listed_catalog_models(calls[0]["catalog_path"]) == newest_gpt_first + assert calls[0]["start_model"] == "gpt-6-astra" + + def test_routed_launch_without_discovered_models_has_no_catalog(self, monkeypatch): + warnings = [] + calls = [] + unavailable = "codex/v1/models is not enabled for this workspace." + monkeypatch.setenv(v2.ENABLE_SMART_ROUTING_ENV_VAR, "1") + monkeypatch.setattr(codex, "custom_catalog_path", lambda: None) + monkeypatch.setattr(codex, "_smart_routing_config_model", lambda state: None) + monkeypatch.setattr(codex, "_launch_token", lambda state, workspace: "token") + monkeypatch.setattr(codex, "print_warning_err", warnings.append) + + def fetch_catalog(*_args, **_kwargs): + raise codex.CodexMpsModelCatalogUnavailable(unavailable) + + def launch_v2(state, tool_args, **kwargs): + calls.append(kwargs) + raise SystemExit(0) + + monkeypatch.setattr(codex, "_fetch_codex_model_catalog", fetch_catalog) + monkeypatch.setattr( + codex, "prepare_codex_catalog", lambda *_args, **_kwargs: pytest.fail("built") + ) + monkeypatch.setattr(v2, "launch_codex", launch_v2) + + with pytest.raises(SystemExit): + codex.launch( + {"workspace": WS, "codex_models": ["system.ai.gpt-6-1-sol"]}, [], options=LaunchOptions(launch_smart_routing=True), ) - assert calls[0]["start_model"] == "gpt-5.6-luna" + assert warnings == [unavailable] + assert calls[0]["catalog_path"] is None + assert calls[0]["start_model"] == codex.APP_SERVER_SMART_ROUTING_STARTING_MODEL @pytest.mark.parametrize("custom_home", [False, True]) @pytest.mark.parametrize( @@ -156,7 +218,8 @@ def test_startup_config_precedence( monkeypatch.setattr(codex, "CODEX_CONFIG_PATH", profile_path) monkeypatch.setattr(codex, "codex_managed_config_path", lambda: managed_path) monkeypatch.setattr(codex, "agent_version", lambda _: "0.145.0") - monkeypatch.setattr(codex, "custom_catalog_models", lambda: None) + monkeypatch.setattr(codex, "custom_catalog_path", lambda: None) + monkeypatch.setattr(codex, "_write_smart_routing_catalog", lambda state, binary: None) if custom_home: monkeypatch.setenv("CODEX_HOME", str(config_home)) monkeypatch.setattr(codex, "CODEX_CONFIG_PATH", tmp_path / "unused.config.toml") @@ -181,8 +244,11 @@ def test_startup_config_precedence( ("platform_name", "tui_has_provider"), [("posix", False), ("nt", True)] ) def test_owns_app_server_interposer_and_tui_lifecycle( - self, monkeypatch, platform_name, tui_has_provider + self, tmp_path, monkeypatch, platform_name, tui_has_provider ): + catalog = _catalog_file( + tmp_path / "catalog.json", ["system.ai.gpt-5-6-sol", "system.ai.glm-5-2"] + ) processes = [] interposer_args = {} stopped = [] @@ -231,15 +297,11 @@ def start_interposer(*args, **kwargs): with pytest.raises(SystemExit) as exc: v2.launch_codex( - { - "workspace": WS, - "profile": "myprof", - "codex_models": ["system.ai.gpt-5-6-sol"], - "oss_models": ["system.ai.glm-5-2"], - }, + {"workspace": WS, "profile": "myprof"}, ["--search"], binary="codex", start_model="gpt-start", + catalog_path=catalog, render_overlay=codex.render_overlay, ) @@ -254,8 +316,12 @@ def start_interposer(*args, **kwargs): "--config", ] assert processes[0].argv[7].startswith("model_providers.Databricks={") - assert processes[0].argv[8] == "--config" - hook_override = processes[0].argv[9] + assert processes[0].argv[8:10] == [ + "--config", + "model_catalog_json=" + json.dumps(str(catalog)), + ] + assert processes[0].argv[10] == "--config" + hook_override = processes[0].argv[11] assert hook_override.startswith("hooks.PreToolUse=[{") assert 'matcher = "Agent|.*spawn_agent$"' in hook_override assert "codex-router-hook route-subagent" in hook_override @@ -349,12 +415,12 @@ def send_signal(self, _signal): v2.launch_codex( { "workspace": WS, - "codex_models": ["system.ai.gpt-5-6-sol"], "codex_http_headers": {"x-databricks-workspace": "eng-ml-inference"}, }, [], binary="codex", start_model="gpt-start", + catalog_path=None, render_overlay=codex.render_overlay, ) @@ -365,6 +431,7 @@ def send_signal(self, _signal): assert "eng-ml-inference" in provider_arg def test_subagent_only_launch_runs_tui_directly(self, tmp_path, monkeypatch): + catalog = _catalog_file(tmp_path / "catalog.json", ["system.ai.gpt-5-6-sol"]) monkeypatch.setenv(v2.ENABLE_SUBAGENT_ROUTING_ENV_VAR, "1") monkeypatch.setenv("CODEX_HOME", str(tmp_path)) monkeypatch.setattr(codex, "ug_version", lambda: "0.1.0") @@ -390,10 +457,11 @@ def fake_exec(argv): with pytest.raises(SystemExit) as exc: v2.launch_codex( - {"workspace": WS, "codex_models": ["system.ai.gpt-5-6-sol"]}, + {"workspace": WS}, ["--search"], binary="codex", start_model="gpt-start", + catalog_path=catalog, render_overlay=codex.render_overlay, ) @@ -402,6 +470,7 @@ def fake_exec(argv): assert argv[0] == "codex" assert argv[-1] == "--search" assert 'model="gpt-start"' in argv + assert "model_catalog_json=" + json.dumps(str(catalog)) in argv hook_override = next(arg for arg in argv if arg.startswith("hooks.PreToolUse=")) assert "codex-router-hook route-subagent" in hook_override assert "--model system.ai.gpt-5-6-sol" in hook_override @@ -471,7 +540,7 @@ def test_v2_pre_tool_hook_uses_current_model(self, tmp_path, monkeypatch): assert "--model system.ai.gpt-5-6-sol" in routing_commands[0] assert "--model old" not in routing_commands[0] - def test_missing_cached_models_starts_with_bootstrap_model(self, monkeypatch): + def test_launch_without_catalog_starts_with_bootstrap_model(self, monkeypatch): monkeypatch.setenv(v2.ENABLE_SMART_ROUTING_ENV_VAR, "1") monkeypatch.setattr(v2, "get_databricks_token", lambda workspace, profile: "token") monkeypatch.setattr(codex, "agent_version", lambda binary: "unknown") @@ -502,6 +571,7 @@ def test_missing_cached_models_starts_with_bootstrap_model(self, monkeypatch): [], binary="codex", start_model="gpt-5.6-luna", + catalog_path=None, render_overlay=codex.render_overlay, ) @@ -509,13 +579,6 @@ def test_missing_cached_models_starts_with_bootstrap_model(self, monkeypatch): class TestCustomCatalogModels: - def _catalog(self, path, slugs): - path.write_text( - json.dumps({"models": [{"slug": slug, "visibility": "list"} for slug in slugs]}), - encoding="utf-8", - ) - return path - def _settings(self, tmp_path, monkeypatch, *, managed=None, cli=None, default=None): home = tmp_path / "codex-home" home.mkdir() @@ -555,7 +618,7 @@ def test_config_precedence( expected, ): managed, cli, default = ( - self._catalog(tmp_path / f"{name}.json", [slug]) if slug else None + _catalog_file(tmp_path / f"{name}.json", [slug]) if slug else None for name, slug in ( ("managed", managed_catalog), ("cli", cli_catalog), @@ -564,24 +627,20 @@ def test_config_precedence( ) self._settings(tmp_path, monkeypatch, managed=managed, cli=cli, default=default) - assert codex_config.custom_catalog_models() == expected + catalog_path = codex_config.custom_catalog_path() + listed = codex_config.listed_catalog_models(catalog_path) if catalog_path else None + assert listed == expected def test_catalog_path_uses_config_precedence(self, tmp_path, monkeypatch): - managed = self._catalog(tmp_path / "managed.json", ["gpt-managed"]) - cli = self._catalog(tmp_path / "cli.json", ["gpt-cli"]) - default = self._catalog(tmp_path / "default.json", ["gpt-default"]) + managed = _catalog_file(tmp_path / "managed.json", ["gpt-managed"]) + cli = _catalog_file(tmp_path / "cli.json", ["gpt-cli"]) + default = _catalog_file(tmp_path / "default.json", ["gpt-default"]) self._settings(tmp_path, monkeypatch, managed=managed, cli=cli, default=default) assert codex_config.custom_catalog_path() == managed - def test_unreadable_catalog_warns_and_falls_back(self, tmp_path, monkeypatch): - self._settings(tmp_path, monkeypatch, cli=tmp_path / "missing.json") - warnings = [] - monkeypatch.setattr(codex_config, "print_warning", warnings.append) - - assert codex_config.custom_catalog_models() is None - assert len(warnings) == 1 - assert "falling back to the cached model services" in warnings[0] + def test_unreadable_catalog_lists_no_models(self, tmp_path): + assert codex_config.listed_catalog_models(tmp_path / "missing.json") == [] def _catalog_with_visibility(self, path, rows): path.write_text( @@ -590,7 +649,7 @@ def _catalog_with_visibility(self, path, rows): ) return path - def test_only_visible_models_offered_to_router(self, tmp_path, monkeypatch): + def test_only_visible_models_offered_to_router(self, tmp_path): catalog = self._catalog_with_visibility( tmp_path / "cli.json", [ @@ -601,19 +660,13 @@ def test_only_visible_models_offered_to_router(self, tmp_path, monkeypatch): ("gpt-5-6-luna", "hide"), ], ) - self._settings(tmp_path, monkeypatch, cli=catalog) - - assert codex_config.custom_catalog_models() == [ + assert codex_config.listed_catalog_models(catalog) == [ "system.ai.glm-5-3", "system.ai.gpt-5-6-luna", ] - def test_launch_prefers_catalog_over_cached_models(self, tmp_path, monkeypatch, capsys): - self._settings( - tmp_path, - monkeypatch, - cli=self._catalog(tmp_path / "cli.json", ["gpt-6-astra", "gpt-6-b"]), - ) + def test_router_and_codex_share_the_catalog(self, tmp_path, monkeypatch, capsys): + catalog = _catalog_file(tmp_path / "cli.json", ["gpt-6-astra", "gpt-6-b"]) monkeypatch.setenv(v2.ENABLE_SMART_ROUTING_ENV_VAR, "1") monkeypatch.setattr(v2, "get_databricks_token", lambda *_args: "token") monkeypatch.setattr(v2, "_free_port", lambda: 41001) @@ -650,24 +703,31 @@ def start_interposer(*args, **kwargs): [], binary="codex", start_model="gpt-6-astra", + catalog_path=catalog, render_overlay=codex.render_overlay, ) assert interposer_kwargs["available_models"] == ["gpt-6-astra", "gpt-6-b"] catalog_override = next(arg for arg in launched[0] if arg.startswith("model_catalog_json=")) - assert catalog_override == "model_catalog_json=" + json.dumps(str(tmp_path / "cli.json")) + assert catalog_override == "model_catalog_json=" + json.dumps(str(catalog)) hook_override = next(arg for arg in launched[0] if arg.startswith("hooks.PreToolUse=")) assert "--model gpt-6-astra" in hook_override assert "--model gpt-6-b" in hook_override assert "gpt-5-6-sol" not in hook_override assert "Smart routing:" not in capsys.readouterr().out - def test_start_model_comes_from_custom_catalog(self, monkeypatch): + def test_start_model_comes_from_custom_catalog(self, tmp_path, monkeypatch): + catalog = _catalog_file(tmp_path / "custom.json", ["gpt-6-astra", "gpt-6-b"]) calls = [] monkeypatch.setenv(v2.ENABLE_SMART_ROUTING_ENV_VAR, "1") monkeypatch.setattr(codex, "clear_model_preferences", lambda state: False) monkeypatch.setattr(codex, "_smart_routing_config_model", lambda state: None) - monkeypatch.setattr(codex, "custom_catalog_models", lambda: ["gpt-6-astra", "gpt-6-b"]) + monkeypatch.setattr(codex, "custom_catalog_path", lambda: catalog) + monkeypatch.setattr( + codex, + "_write_smart_routing_catalog", + lambda state, binary: pytest.fail("a configured catalog needs no discovery"), + ) def launch_v2(state, tool_args, **kwargs): calls.append(kwargs) @@ -683,6 +743,7 @@ def launch_v2(state, tool_args, **kwargs): ) assert calls[0]["start_model"] == "gpt-6-astra" + assert calls[0]["catalog_path"] == catalog def test_interposer_startup_failure_is_propagated(monkeypatch):