diff --git a/raven/cli/provider_commands.py b/raven/cli/provider_commands.py index 92fbfaf..3d2878b 100644 --- a/raven/cli/provider_commands.py +++ b/raven/cli/provider_commands.py @@ -10,7 +10,7 @@ - ``provider list`` — overview of every provider's status - ``provider get `` — current config (secrets redacted) - ``provider set [...]`` — patch fields (--api-key, --api-base, ...) -- ``provider test `` — verify creds via free ``GET /v1/models`` +- ``provider test `` — verify creds via a free model catalog request - ``provider reset `` — restore schema defaults; OAuth providers also lose their token file - ``provider show `` — reflect available ``--flag`` fields @@ -383,15 +383,14 @@ def provider_test_cmd( name: str = typer.Argument(..., help="Provider name"), timeout: int = typer.Option(10, "--timeout", "-t", help="Timeout seconds"), ): - """Verify a provider's credentials via a free ``GET /v1/models`` call. + """Verify a provider's credentials via a free model catalog request. Does NOT consume inference quota — hits the provider's models metadata - endpoint, which is free, fast, and tells you whether the key is valid, - has credit, and isn't rate-limited. + endpoint to verify that the configured credentials can list models. """ from raven.config.update_providers import test_provider as probe - console.print(f"[dim]Pinging {name}/v1/models ...[/dim]") + console.print(f"[dim]Pinging {name} model catalog ...[/dim]") try: result = probe(name, timeout_s=timeout) except KeyError as exc: @@ -406,12 +405,21 @@ def provider_test_cmd( ) return + from raven.providers.registry import CRED_OAUTH, credential_kind + + oauth_login_hint = f"Run: raven provider login {name.replace('_', '-')}" + invalid_credential_hint = ( + oauth_login_hint + if credential_kind(name) == CRED_OAUTH + else f"Run: raven provider set {name} --api-key " + ) hints = { "not_configured": f"Run: raven provider set {name} --api-key ", - "invalid_key": f"Run: raven provider set {name} --api-key ", + "invalid_key": invalid_credential_hint, "no_credits": "Fund your account at the provider's billing page", "rate_limited": "Wait a few minutes and retry, or switch provider", - "oauth_token_missing": (f"Run: raven provider login {name.replace('_', '-')}"), + "oauth_token_missing": oauth_login_hint, + "no_models": "No visible models are available for this account", "network_error": "Check network / firewall / VPN settings", } hint = hints.get(result["status"], "") diff --git a/raven/config/update_providers.py b/raven/config/update_providers.py index 7d0408a..411b7a3 100644 --- a/raven/config/update_providers.py +++ b/raven/config/update_providers.py @@ -771,15 +771,16 @@ def test_provider( config_path: Path | None = None, transport: httpx.BaseTransport | None = None, ) -> dict[str, Any]: - """Verify a provider's credentials via a free GET request to ``/v1/models``. + """Verify credentials with the provider's free model metadata endpoint. - Why ``/v1/models`` rather than a chat completion (same rationale as - hermes-agent's ``doctor._probe_apikey_provider``): + OpenAI-compatible providers use ``/v1/models``. OpenAI Codex uses its + account-scoped catalog because its OAuth token is not an API key and the + generic endpoint rejects it. - Zero token cost — metadata endpoint, not LLM-generated content. - No charge to the user, doesn't burn inference quota. - - Supported by virtually every OpenAI-compatible provider (the 18 we - ship today). + - Supported by the OpenAI-compatible providers we ship, with a dedicated + adapter for providers whose catalog contract differs. - No "which test model?" maintenance burden. Behavior: @@ -787,7 +788,8 @@ def test_provider( 1. Look up the provider's ``api_key`` or provider-specific OAuth access token and ``api_base`` (falling back to ``ProviderSpec.default_api_base`` when unset). - 2. ``GET {api_base}/v1/models`` with ``Authorization: Bearer {key}``. + 2. Fetch the provider-specific model catalog with its required auth + headers. 3. Map status code → keyword (see ``_HTTP_STATUS_MAP``). Unknown codes render as ``http_{code}``. Network errors → ``network_error``. @@ -813,6 +815,7 @@ def test_provider( api_key = cfg.get("api_key") or "" api_base = cfg.get("api_base") or (spec.default_api_base if spec else "") or "" + oauth_token: Any | None = None if spec and spec.is_oauth: try: @@ -856,6 +859,7 @@ def test_provider( "error": "no OAuth token stored", } api_key = token.access + oauth_token = token if not api_key and not (spec and spec.is_local): return { @@ -879,6 +883,53 @@ def test_provider( "error": "api_base is empty and provider has no default", } + if spec and spec.name == "openai_codex": + from raven.providers.openai_codex_catalog import CodexModelCatalogError, fetch_codex_models + + start = time.monotonic() + try: + model_ids = fetch_codex_models(oauth_token, timeout=timeout_s, transport=transport) + except httpx.HTTPStatusError as exc: + status_code = exc.response.status_code + return { + "ok": False, + "status": _HTTP_STATUS_MAP.get(status_code, f"http_{status_code}"), + "elapsed_ms": int((time.monotonic() - start) * 1000), + "http_status": status_code, + "models_count": None, + "model_ids": None, + "error": f"HTTP {status_code}", + } + except CodexModelCatalogError as exc: + return { + "ok": False, + "status": "no_models", + "elapsed_ms": int((time.monotonic() - start) * 1000), + "http_status": 200, + "models_count": 0, + "model_ids": [], + "error": str(exc), + } + except httpx.HTTPError as exc: + return { + "ok": False, + "status": "network_error", + "elapsed_ms": int((time.monotonic() - start) * 1000), + "http_status": None, + "models_count": None, + "model_ids": None, + "error": str(exc), + } + return { + "ok": True, + "status": "valid", + "elapsed_ms": int((time.monotonic() - start) * 1000), + "http_status": 200, + "models_count": len(model_ids), + "model_ids": model_ids, + "error": None, + } + url = api_base.rstrip("/") + "/models" if "/v1" not in api_base: url = api_base.rstrip("/") + "/v1/models" diff --git a/raven/providers/openai_codex_catalog.py b/raven/providers/openai_codex_catalog.py new file mode 100644 index 0000000..3c4a1f6 --- /dev/null +++ b/raven/providers/openai_codex_catalog.py @@ -0,0 +1,88 @@ +"""Account-scoped model catalog for the OpenAI Codex OAuth provider.""" + +from __future__ import annotations + +from typing import Any + +import httpx + +AUTO_CODEX_MODEL = "openai-codex/auto" +CODEX_CATALOG_URL = "https://chatgpt.com/backend-api/codex/models" +# This version declares the Codex protocol Raven currently implements. It is +# intentionally independent of Raven's package version. +CODEX_CATALOG_CLIENT_VERSION = "0.146.0" +CODEX_CATALOG_TIMEOUT = 5.0 +CODEX_CATALOG_CACHE_TTL = 300.0 + + +class CodexModelCatalogError(RuntimeError): + """The Codex account catalog cannot provide a usable model.""" + + +def is_auto_codex_model(model: str) -> bool: + return model in {"auto", AUTO_CODEX_MODEL, "openai_codex/auto"} + + +def _catalog_headers(token: Any) -> dict[str, str]: + access = getattr(token, "access", None) + if not isinstance(access, str) or not access: + raise CodexModelCatalogError("Codex OAuth access token is missing") + + headers = { + "Authorization": f"Bearer {access}", + "User-Agent": "raven (python)", + "accept": "application/json", + } + account_id = getattr(token, "account_id", None) + if isinstance(account_id, str) and account_id: + headers["chatgpt-account-id"] = account_id + return headers + + +def _visible_models(payload: Any) -> list[str]: + raw_models = payload.get("models") if isinstance(payload, dict) else None + if not isinstance(raw_models, list): + raise CodexModelCatalogError("Codex model catalog contains no visible models") + + candidates: list[tuple[int, int, str]] = [] + for index, item in enumerate(raw_models): + if not isinstance(item, dict) or item.get("visibility") != "list": + continue + slug = item.get("slug") + if not isinstance(slug, str) or not slug.strip(): + continue + priority = item.get("priority") + if not isinstance(priority, int) or isinstance(priority, bool): + priority = 2**31 - 1 + candidates.append((priority, index, slug.strip())) + + candidates.sort(key=lambda candidate: (candidate[0], candidate[1])) + models = list(dict.fromkeys(candidate[2] for candidate in candidates)) + if not models: + raise CodexModelCatalogError("Codex model catalog contains no visible models") + return models + + +def fetch_codex_models( + token: Any, + *, + timeout: float = CODEX_CATALOG_TIMEOUT, + transport: httpx.BaseTransport | None = None, +) -> list[str]: + """Fetch visible account models in the server's priority order.""" + client_kwargs: dict[str, Any] = {"timeout": timeout} + if transport is not None: + client_kwargs["transport"] = transport + + with httpx.Client(**client_kwargs) as client: + response = client.get( + CODEX_CATALOG_URL, + params={"client_version": CODEX_CATALOG_CLIENT_VERSION}, + headers=_catalog_headers(token), + ) + response.raise_for_status() + try: + payload = response.json() + except ValueError as exc: + raise CodexModelCatalogError("Codex model catalog returned invalid JSON") from exc + return _visible_models(payload) diff --git a/raven/providers/openai_codex_provider.py b/raven/providers/openai_codex_provider.py index b38e177..06a5141 100644 --- a/raven/providers/openai_codex_provider.py +++ b/raven/providers/openai_codex_provider.py @@ -5,12 +5,20 @@ import asyncio import hashlib import json +import time from typing import Any, AsyncGenerator import httpx from loguru import logger from raven.providers.base import LLMProvider, LLMResponse, ToolCallRequest +from raven.providers.openai_codex_catalog import ( + AUTO_CODEX_MODEL, + CODEX_CATALOG_CACHE_TTL, + CODEX_CATALOG_TIMEOUT, + fetch_codex_models, + is_auto_codex_model, +) DEFAULT_CODEX_URL = "https://chatgpt.com/backend-api/codex/responses" DEFAULT_ORIGINATOR = "raven" @@ -19,9 +27,12 @@ class OpenAICodexProvider(LLMProvider): """Use Codex OAuth to call the Responses API.""" - def __init__(self, default_model: str = "openai-codex/gpt-5.1-codex"): + def __init__(self, default_model: str = AUTO_CODEX_MODEL): super().__init__(api_key=None, api_base=None) self.default_model = default_model + self._resolved_auto_model: str | None = None + self._resolved_auto_model_for: str | None = None + self._resolved_auto_model_expires_at = 0.0 async def chat( self, @@ -45,6 +56,15 @@ async def chat( ) from e token = await asyncio.to_thread(get_codex_token) + if is_auto_codex_model(model): + try: + model = await self._resolve_auto_model(token) + except Exception as e: + return LLMResponse( + content=f"Error calling Codex: {str(e)}", + finish_reason="error", + error_classification=self.classify_error(e), + ) headers = _build_headers(token.account_id, token.access) body: dict[str, Any] = { @@ -96,6 +116,30 @@ async def chat( def get_default_model(self) -> str: return self.default_model + async def _resolve_auto_model(self, token: Any) -> str: + cache_key = _catalog_cache_key(token) + if ( + self._resolved_auto_model is not None + and self._resolved_auto_model_for == cache_key + and time.monotonic() < self._resolved_auto_model_expires_at + ): + return self._resolved_auto_model + + timeout = min(self.generation.timeout, CODEX_CATALOG_TIMEOUT) + models = await asyncio.to_thread(fetch_codex_models, token, timeout=timeout) + self._resolved_auto_model = models[0] + self._resolved_auto_model_for = cache_key + self._resolved_auto_model_expires_at = time.monotonic() + CODEX_CATALOG_CACHE_TTL + return self._resolved_auto_model + + +def _catalog_cache_key(token: Any) -> str: + account_id = getattr(token, "account_id", None) + if isinstance(account_id, str) and account_id: + return f"account:{account_id}" + access = getattr(token, "access", "") + return f"token:{hashlib.sha256(str(access).encode()).hexdigest()}" + def _strip_model_prefix(model: str) -> str: if model.startswith("openai-codex/") or model.startswith("openai_codex/"): diff --git a/raven/providers/registry.py b/raven/providers/registry.py index a86d06d..28f91fb 100644 --- a/raven/providers/registry.py +++ b/raven/providers/registry.py @@ -24,6 +24,8 @@ from dataclasses import dataclass from typing import Any +from raven.providers.openai_codex_catalog import AUTO_CODEX_MODEL + @dataclass(frozen=True) class ProviderSpec: @@ -321,7 +323,7 @@ def claims(self, model: str) -> bool: strip_model_prefix=False, model_overrides=(), is_oauth=True, # OAuth-based authentication - default_model="openai-codex/gpt-5-codex", + default_model=AUTO_CODEX_MODEL, ), # Github Copilot: uses OAuth, not API key. ProviderSpec( diff --git a/tests/test_cli_onboard_commands.py b/tests/test_cli_onboard_commands.py index f64389b..2454229 100644 --- a/tests/test_cli_onboard_commands.py +++ b/tests/test_cli_onboard_commands.py @@ -2212,6 +2212,42 @@ def ask(self): assert any(c.startswith("moonshot/") for c in offered["choices"]), offered["choices"][:3] +def test_codex_picker_preserves_auto_as_default_above_live_catalog(monkeypatch) -> None: + from raven.cli import onboard_commands + from raven.providers.openai_codex_catalog import AUTO_CODEX_MODEL + from raven.providers.registry import find_by_name + + offered: dict[str, Any] = {} + + class _Prompt: + def __init__(self, _label, **kwargs): + offered["choices"] = list(kwargs["choices"]) + offered["default"] = kwargs["default"] + + def ask(self): + return offered["default"] + + monkeypatch.setattr( + onboard_commands, + "_require_questionary", + lambda: SimpleNamespace(autocomplete=_Prompt), + ) + + chosen = onboard_commands._pick_model( + "openai_codex", + find_by_name("openai_codex"), + current_model=None, + model_ids=["gpt-new-default", "gpt-secondary"], + probe_status="valid", + user_provided_model=None, + non_interactive=False, + ) + + assert offered["default"] == AUTO_CODEX_MODEL + assert offered["choices"] == [AUTO_CODEX_MODEL, "gpt-new-default", "gpt-secondary"] + assert chosen == AUTO_CODEX_MODEL + + def test_a_spec_less_vendors_model_id_carries_its_route_prefix() -> None: """A bare id is routed by keyword and fallback, not to the section configured. diff --git a/tests/test_cli_provider_commands.py b/tests/test_cli_provider_commands.py index 9a9c648..df53976 100644 --- a/tests/test_cli_provider_commands.py +++ b/tests/test_cli_provider_commands.py @@ -382,6 +382,7 @@ def fake_probe(name: str, *, timeout_s: int = 10) -> dict: assert r.exit_code == 0, r.output assert "412 models" in r.output assert "234ms" in r.output + assert "model catalog" in r.output def test_test_command_failure_renders_hint( @@ -408,6 +409,32 @@ def fake_probe(name: str, *, timeout_s: int = 10) -> dict: assert "provider set openrouter --api-key" in r.output +def test_test_command_oauth_auth_failure_renders_login_hint( + tmp_config: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from raven.config import update_providers + + monkeypatch.setattr( + update_providers, + "test_provider", + lambda name, *, timeout_s=10: { + "ok": False, + "status": "invalid_key", + "elapsed_ms": 50, + "http_status": 403, + "models_count": None, + "error": "HTTP 403", + }, + ) + + result = runner.invoke(app, ["provider", "test", "openai-codex"]) + + assert result.exit_code == 1 + assert "provider login openai-codex" in result.output + assert "--api-key" not in result.output + + def test_test_command_unknown_provider_exits_1(tmp_config: Path) -> None: r = runner.invoke(app, ["provider", "test", "no-such-provider"]) assert r.exit_code == 1 diff --git a/tests/test_config_update_providers.py b/tests/test_config_update_providers.py index 1cbc3c8..f4d4806 100644 --- a/tests/test_config_update_providers.py +++ b/tests/test_config_update_providers.py @@ -425,7 +425,19 @@ def test_test_provider_oauth_reads_token_from_oauth_cli_kit( def handler(request: httpx.Request) -> httpx.Response: seen["auth"] = request.headers.get("Authorization") - return httpx.Response(200, json={"data": [{"id": "m1"}]}) + seen["account"] = request.headers.get("chatgpt-account-id") + seen["path"] = request.url.path + seen["client_version"] = request.url.params.get("client_version") + return httpx.Response( + 200, + json={ + "models": [ + {"slug": "second", "visibility": "list", "priority": 20}, + {"slug": "hidden", "visibility": "hide", "priority": 1}, + {"slug": "first", "visibility": "list", "priority": 2}, + ] + }, + ) # openai_codex has default_api_base set; github_copilot doesn't — pick # the former so the request can resolve a URL without extra setup. @@ -436,6 +448,64 @@ def handler(request: httpx.Request) -> httpx.Response: ) assert result["ok"] is True assert seen["auth"] == "Bearer oauth-token-xyz" + assert seen["account"] == "me@x" + assert seen["path"] == "/backend-api/codex/models" + assert seen["client_version"] + assert result["models_count"] == 2 + assert result["model_ids"] == ["first", "second"] + + +def test_test_provider_oauth_empty_catalog_is_not_valid( + cfg_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + import sys + + fake_token = SimpleNamespace(access="oauth-token-xyz", account_id="me@x") + monkeypatch.setitem(sys.modules, "oauth_cli_kit", SimpleNamespace(get_token=lambda: fake_token)) + transport = _mock_transport(lambda _: httpx.Response(200, json={"models": []})) + + result = probe_provider("openai_codex", config_path=cfg_path, transport=transport) + + assert result["ok"] is False + assert result["status"] == "no_models" + assert result["http_status"] == 200 + + +def test_test_provider_oauth_catalog_403_maps_to_invalid_key( + cfg_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + import sys + + fake_token = SimpleNamespace(access="oauth-token-xyz", account_id="me@x") + monkeypatch.setitem(sys.modules, "oauth_cli_kit", SimpleNamespace(get_token=lambda: fake_token)) + transport = _mock_transport(lambda _: httpx.Response(403, json={"detail": "forbidden"})) + + result = probe_provider("openai_codex", config_path=cfg_path, transport=transport) + + assert result["ok"] is False + assert result["status"] == "invalid_key" + assert result["http_status"] == 403 + + +def test_test_provider_oauth_catalog_network_error_is_not_a_credential_failure( + cfg_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + import sys + + fake_token = SimpleNamespace(access="oauth-token-xyz", account_id="me@x") + monkeypatch.setitem(sys.modules, "oauth_cli_kit", SimpleNamespace(get_token=lambda: fake_token)) + + def handler(request: httpx.Request) -> httpx.Response: + raise httpx.ConnectError("offline", request=request) + + result = probe_provider("openai_codex", config_path=cfg_path, transport=_mock_transport(handler)) + + assert result["ok"] is False + assert result["status"] == "network_error" + assert result["http_status"] is None def test_test_provider_oauth_missing_token_returns_oauth_token_missing( diff --git a/tests/test_openai_codex_catalog.py b/tests/test_openai_codex_catalog.py new file mode 100644 index 0000000..01704bd --- /dev/null +++ b/tests/test_openai_codex_catalog.py @@ -0,0 +1,95 @@ +"""Contract tests for the account-scoped OpenAI Codex model catalog.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import httpx +import pytest + +from raven.providers.openai_codex_catalog import ( + CODEX_CATALOG_CLIENT_VERSION, + CodexModelCatalogError, + fetch_codex_models, +) + + +def _transport(handler) -> httpx.MockTransport: + return httpx.MockTransport(handler) + + +def test_fetch_codex_models_uses_account_catalog_contract() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "GET" + assert request.url.path == "/backend-api/codex/models" + assert request.url.params["client_version"] == CODEX_CATALOG_CLIENT_VERSION + assert request.headers["Authorization"] == "Bearer oauth-token" + assert request.headers["chatgpt-account-id"] == "acct-123" + return httpx.Response( + 200, + json={ + "models": [ + {"slug": "low-priority", "visibility": "list", "priority": 20}, + {"slug": "hidden", "visibility": "hide", "priority": 1}, + {"slug": "default-model", "visibility": "list", "priority": 2}, + {"slug": "default-model", "visibility": "list", "priority": 3}, + {"slug": "", "visibility": "list", "priority": 0}, + {"slug": {"nested": True}, "visibility": "list", "priority": 0}, + "malformed", + ] + }, + ) + + token = SimpleNamespace(access="oauth-token", account_id="acct-123") + models = fetch_codex_models(token, transport=_transport(handler)) + + assert models == ["default-model", "low-priority"] + + +def test_fetch_codex_models_omits_absent_optional_account_header() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert "chatgpt-account-id" not in request.headers + return httpx.Response( + 200, + json={"models": [{"slug": "model-1", "visibility": "list", "priority": 1}]}, + ) + + token = SimpleNamespace(access="oauth-token", account_id=None) + assert fetch_codex_models(token, transport=_transport(handler)) == ["model-1"] + + +@pytest.mark.parametrize( + "payload", + [ + {}, + {"models": None}, + {"models": []}, + {"models": [{"slug": "hidden", "visibility": "hide", "priority": 1}]}, + ], +) +def test_fetch_codex_models_rejects_catalog_without_visible_models(payload: object) -> None: + token = SimpleNamespace(access="oauth-token", account_id="acct-123") + transport = _transport(lambda _: httpx.Response(200, json=payload)) + + with pytest.raises(CodexModelCatalogError, match="no visible models"): + fetch_codex_models(token, transport=transport) + + +def test_fetch_codex_models_preserves_http_failure() -> None: + token = SimpleNamespace(access="oauth-token", account_id="acct-123") + transport = _transport(lambda _: httpx.Response(403, json={"detail": "forbidden"})) + + with pytest.raises(httpx.HTTPStatusError) as raised: + fetch_codex_models(token, transport=transport) + + assert raised.value.response.status_code == 403 + + +def test_fetch_codex_models_rejects_invalid_json_without_leaking_body() -> None: + token = SimpleNamespace(access="oauth-token", account_id="acct-123") + transport = _transport(lambda _: httpx.Response(200, content=b"not-json")) + + with pytest.raises(CodexModelCatalogError, match="invalid JSON") as raised: + fetch_codex_models(token, transport=transport) + + assert "not-json" not in str(raised.value) diff --git a/tests/test_openai_codex_model_resolution.py b/tests/test_openai_codex_model_resolution.py new file mode 100644 index 0000000..988c4d3 --- /dev/null +++ b/tests/test_openai_codex_model_resolution.py @@ -0,0 +1,125 @@ +"""Automatic model-resolution tests for the OpenAI Codex provider.""" + +from __future__ import annotations + +from types import SimpleNamespace + +import oauth_cli_kit +import pytest + +from raven.providers import openai_codex_provider as codex_module +from raven.providers.openai_codex_catalog import CodexModelCatalogError +from raven.providers.openai_codex_provider import OpenAICodexProvider + + +@pytest.mark.asyncio +async def test_provider_resolves_and_caches_auto_model(monkeypatch: pytest.MonkeyPatch): + token = SimpleNamespace(access="oauth-token", account_id="acct-123") + monkeypatch.setattr(oauth_cli_kit, "get_token", lambda: token) + catalog_calls = 0 + requested_models: list[str] = [] + + def fetch_models(received_token, **kwargs): + nonlocal catalog_calls + catalog_calls += 1 + assert received_token is token + return ["account-default", "account-second"] + + async def request(url, headers, body, verify, timeout): + requested_models.append(body["model"]) + return "ok", [], "stop" + + monkeypatch.setattr(codex_module, "fetch_codex_models", fetch_models) + monkeypatch.setattr(codex_module, "_request_codex", request) + provider = OpenAICodexProvider() + + first = await provider.chat(messages=[]) + second = await provider.chat(messages=[]) + + assert first.content == second.content == "ok" + assert catalog_calls == 1 + assert requested_models == ["account-default", "account-default"] + + +@pytest.mark.asyncio +async def test_provider_auto_model_cache_is_account_scoped(monkeypatch: pytest.MonkeyPatch): + catalog_accounts: list[str] = [] + + def fetch_models(token, **kwargs): + catalog_accounts.append(token.account_id) + return [f"model-for-{token.account_id}"] + + monkeypatch.setattr(codex_module, "fetch_codex_models", fetch_models) + provider = OpenAICodexProvider() + + first = await provider._resolve_auto_model(SimpleNamespace(access="token-a", account_id="account-a")) + second = await provider._resolve_auto_model(SimpleNamespace(access="token-b", account_id="account-b")) + + assert first == "model-for-account-a" + assert second == "model-for-account-b" + assert catalog_accounts == ["account-a", "account-b"] + + +@pytest.mark.asyncio +async def test_provider_auto_model_cache_expires(monkeypatch: pytest.MonkeyPatch): + catalog_results = iter([["model-before-refresh"], ["model-after-refresh"]]) + monkeypatch.setattr(codex_module, "fetch_codex_models", lambda token, **kwargs: next(catalog_results)) + provider = OpenAICodexProvider() + token = SimpleNamespace(access="token-a", account_id="account-a") + + assert await provider._resolve_auto_model(token) == "model-before-refresh" + provider._resolved_auto_model_expires_at = 0.0 + assert await provider._resolve_auto_model(token) == "model-after-refresh" + + +@pytest.mark.asyncio +async def test_provider_preserves_explicit_model_without_catalog(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + oauth_cli_kit, + "get_token", + lambda: SimpleNamespace(access="oauth-token", account_id="acct-123"), + ) + requested_models: list[str] = [] + + def unexpected_catalog(*args, **kwargs): + raise AssertionError("explicit models must not fetch the catalog") + + async def request(url, headers, body, verify, timeout): + requested_models.append(body["model"]) + return "ok", [], "stop" + + monkeypatch.setattr(codex_module, "fetch_codex_models", unexpected_catalog) + monkeypatch.setattr(codex_module, "_request_codex", request) + provider = OpenAICodexProvider(default_model="openai-codex/user-selected") + + response = await provider.chat(messages=[]) + + assert response.content == "ok" + assert requested_models == ["user-selected"] + + +@pytest.mark.asyncio +async def test_provider_does_not_post_when_auto_catalog_is_empty(monkeypatch: pytest.MonkeyPatch): + monkeypatch.setattr( + oauth_cli_kit, + "get_token", + lambda: SimpleNamespace(access="oauth-token", account_id="acct-123"), + ) + request_called = False + + def no_models(*args, **kwargs): + raise CodexModelCatalogError("Codex model catalog contains no visible models") + + async def request(*args, **kwargs): + nonlocal request_called + request_called = True + return "unexpected", [], "stop" + + monkeypatch.setattr(codex_module, "fetch_codex_models", no_models) + monkeypatch.setattr(codex_module, "_request_codex", request) + + response = await OpenAICodexProvider().chat(messages=[]) + + assert response.finish_reason == "error" + assert "no visible models" in response.content + assert request_called is False diff --git a/tests/test_openai_codex_provider.py b/tests/test_openai_codex_provider.py index d75b417..9080154 100644 --- a/tests/test_openai_codex_provider.py +++ b/tests/test_openai_codex_provider.py @@ -34,9 +34,9 @@ def test_headers_declare_experimental_responses_beta(): assert headers["accept"] == "text/event-stream" -def test_provider_default_model_is_codex(): - provider = OpenAICodexProvider(default_model="openai-codex/gpt-5.1-codex") - assert provider.get_default_model() == "openai-codex/gpt-5.1-codex" +def test_provider_default_model_is_auto(): + provider = OpenAICodexProvider() + assert provider.get_default_model() == "openai-codex/auto" # OAuth-based: constructed without an API key. assert provider.api_key is None diff --git a/tests/test_provider_catalog.py b/tests/test_provider_catalog.py index 5b294bd..31fb303 100644 --- a/tests/test_provider_catalog.py +++ b/tests/test_provider_catalog.py @@ -131,6 +131,14 @@ def test_seeded_provider_default_model_in_shortlist(slug: str) -> None: assert default in common_models_for(slug) +def test_openai_codex_registry_and_runtime_share_auto_default() -> None: + from raven.providers.openai_codex_catalog import AUTO_CODEX_MODEL + from raven.providers.openai_codex_provider import OpenAICodexProvider + + assert find_by_name("openai_codex").default_model == AUTO_CODEX_MODEL + assert OpenAICodexProvider().get_default_model() == AUTO_CODEX_MODEL + + def _concrete_provider_subclasses() -> set[type]: """All non-abstract LLMProvider subclasses defined in raven.providers.""" # Import each backend module so its subclass is registered on LLMProvider.