From f1b51ee9f3c0725ce41dd3f089ed6dc8bc58dd22 Mon Sep 17 00:00:00 2001 From: mateo Date: Tue, 3 Nov 2026 22:09:46 +0000 Subject: [PATCH] feat(proxy): ask OpenAI for the Codex catalog before GitHub, keyed by the deployment's OpenAI key Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../common_utils/codex_catalog_source.py | 73 ++++++++++++++++--- .../proxy/common_utils/codex_model_catalog.py | 36 +++++++++ litellm/proxy/proxy_server.py | 14 +++- .../compatibility/test_codex_catalog_fetch.py | 47 +++++++++++- .../common_utils/test_codex_catalog_source.py | 52 +++++++++++-- .../common_utils/test_codex_model_catalog.py | 34 +++++++++ 6 files changed, 234 insertions(+), 22 deletions(-) diff --git a/litellm/proxy/common_utils/codex_catalog_source.py b/litellm/proxy/common_utils/codex_catalog_source.py index 3f1c54ed23d..8c2f1513658 100644 --- a/litellm/proxy/common_utils/codex_catalog_source.py +++ b/litellm/proxy/common_utils/codex_catalog_source.py @@ -18,7 +18,7 @@ import asyncio import os import re import time -from collections import OrderedDict +from collections import Counter, OrderedDict from collections.abc import Awaitable, Callable from dataclasses import dataclass from functools import cache @@ -38,6 +38,7 @@ from litellm.proxy.common_utils.codex_model_catalog import ( codex_base_instructions, ) +CODEX_CATALOG_OPENAI_URL: Final = "https://chatgpt.com/backend-api/codex/models" CODEX_CATALOG_BASE_URL: Final = "https://raw.githubusercontent.com/openai/codex" CODEX_CATALOG_RETRY_SECONDS: Final = 300.0 CODEX_CATALOG_FETCH_TIMEOUT_SECONDS: Final = 10.0 @@ -49,7 +50,13 @@ _PROMPT_PATH: Final = "codex-rs/models-manager/prompt.md" class _AsyncGetClient(Protocol): - def get(self, url: str, *, timeout: float | None = None) -> Awaitable[httpx.Response]: ... + def get( + self, + url: str, + *, + headers: dict[str, str] | None = None, # mutable-ok: httpx's own get signature + timeout: float | None = None, + ) -> Awaitable[httpx.Response]: ... @dataclass(frozen=True, slots=True) @@ -103,6 +110,7 @@ class CodexCatalogSource: *, client: _AsyncGetClient | None = None, base_url: str | None = None, + openai_url: str | None = None, clock: Callable[[], float] = time.monotonic, retry_seconds: float = CODEX_CATALOG_RETRY_SECONDS, timeout: float = CODEX_CATALOG_FETCH_TIMEOUT_SECONDS, @@ -112,6 +120,11 @@ class CodexCatalogSource: self._base_url = ( base_url if base_url is not None else os.getenv("LITELLM_CODEX_CATALOG_BASE_URL", CODEX_CATALOG_BASE_URL) ).rstrip("/") + self._openai_url = ( + openai_url + if openai_url is not None + else os.getenv("LITELLM_CODEX_CATALOG_OPENAI_URL", CODEX_CATALOG_OPENAI_URL) + ) self._clock = clock self._retry_seconds = retry_seconds self._timeout = timeout @@ -119,9 +132,11 @@ class CodexCatalogSource: self._cache: OrderedDict[str, _Cached] = OrderedDict() # mutable-ok: filled as releases are first asked for self._pending: dict[str, asyncio.Task[CodexStock]] = {} # mutable-ok: the fetches in flight, one per release - async def stock(self, client_version: str | None) -> CodexStock: + async def stock(self, client_version: str | None, *, openai_api_key: str | None = None) -> CodexStock: + """The catalog for ``client_version``: OpenAI's live one when an OpenAI key is at hand, + else the release's models.json on GitHub, else the bundled pair.""" tag: Final = codex_release_tag(client_version) - if tag is None or uses_vendored_codex_catalog(): + if tag is None or client_version is None or uses_vendored_codex_catalog(): return vendored_codex_stock() cached: Final = self._cache.get(tag) if cached is not None and (cached.retry_at is None or cached.retry_at > self._clock()): @@ -130,13 +145,13 @@ class CodexCatalogSource: pending: Final = self._pending.get(tag) if pending is not None: return await asyncio.shield(pending) - task: Final = asyncio.ensure_future(self._resolve(tag)) + task: Final = asyncio.ensure_future(self._resolve(tag, client_version, openai_api_key)) self._pending[tag] = task task.add_done_callback(lambda _: self._pending.pop(tag, None)) return await asyncio.shield(task) - async def _resolve(self, tag: str) -> CodexStock: - fetched: Final = await self._fetch(tag) + async def _resolve(self, tag: str, version: str, openai_api_key: str | None) -> CodexStock: + fetched: Final = await self._fetch(tag, version, openai_api_key) if isinstance(fetched, _FetchFailed): verbose_proxy_logger.warning( "Codex catalog %s not fetched (%s), serving the %s one for the next %.0fs", @@ -146,7 +161,9 @@ class CodexCatalogSource: self._retry_seconds, ) else: - verbose_proxy_logger.info("Codex catalog %s fetched, %d models", tag, len(fetched.models)) + verbose_proxy_logger.info( + "Codex catalog %s fetched from %s, %d models", tag, fetched.source, len(fetched.models) + ) entry: Final = ( _Cached(stock=vendored_codex_stock(), retry_at=self._clock() + self._retry_seconds) if isinstance(fetched, _FetchFailed) @@ -158,7 +175,35 @@ class CodexCatalogSource: self._cache.popitem(last=False) return entry.stock - async def _fetch(self, tag: str) -> CodexStock | _FetchFailed: + async def _fetch(self, tag: str, version: str, openai_api_key: str | None) -> CodexStock | _FetchFailed: + if openai_api_key: + fetched: Final = await self._fetch_openai(version, openai_api_key) + if not isinstance(fetched, _FetchFailed): + return fetched + verbose_proxy_logger.info( + "Codex catalog %s not fetched from OpenAI (%s), trying GitHub", tag, fetched.reason + ) + return await self._fetch_github(tag) + + async def _fetch_openai(self, version: str, openai_api_key: str) -> CodexStock | _FetchFailed: + url: Final = f"{self._openai_url}?client_version={version}" + body: Final = await self._get(url, headers={"Authorization": f"Bearer {openai_api_key}"}) + if isinstance(body, _FetchFailed): + return body + try: + catalog: Final = CodexStockCatalog.model_validate_json(body) + except ValidationError as error: + return _FetchFailed(reason=f"{url} body does not parse: {type(error).__name__}") + prompts: Final = Counter(model.base_instructions for model in catalog.models if model.base_instructions.strip()) + if not catalog.models or not prompts: + return _FetchFailed(reason=f"{url} catalog is empty") + return CodexStock( + models=MappingProxyType({model.slug: model for model in catalog.models}), + instructions=prompts.most_common(1)[0][0], + source=f"openai {version}", + ) + + async def _fetch_github(self, tag: str) -> CodexStock | _FetchFailed: models, prompt = await asyncio.gather( self._get(f"{self._base_url}/{tag}/{_MODELS_PATH}"), self._get(f"{self._base_url}/{tag}/{_PROMPT_PATH}") ) @@ -176,13 +221,17 @@ class CodexCatalogSource: return CodexStock( models=MappingProxyType({model.slug: model for model in catalog.models}), instructions=instructions, - source=tag, + source=f"github {tag}", ) - async def _get(self, url: str) -> bytes | _FetchFailed: + async def _get( + self, + url: str, + headers: dict[str, str] | None = None, # mutable-ok: httpx's own get signature + ) -> bytes | _FetchFailed: client: Final = self._client if self._client is not None else _default_client() try: - response: Final = await client.get(url, timeout=self._timeout) + response: Final = await client.get(url, headers=headers, timeout=self._timeout) except httpx.HTTPError as error: return _FetchFailed(reason=f"{type(error).__name__} fetching {url}") if response.status_code != 200: diff --git a/litellm/proxy/common_utils/codex_model_catalog.py b/litellm/proxy/common_utils/codex_model_catalog.py index 84b87aa9a6e..90a47e12757 100644 --- a/litellm/proxy/common_utils/codex_model_catalog.py +++ b/litellm/proxy/common_utils/codex_model_catalog.py @@ -23,6 +23,7 @@ newer tag and renaming the json after it. from __future__ import annotations +import os from collections.abc import Mapping, Sequence from dataclasses import dataclass from functools import cache @@ -36,12 +37,14 @@ from pydantic import ConfigDict, Field, StringConstraints, TypeAdapter, Validati from litellm._logging import verbose_proxy_logger from litellm.litellm_core_utils.reasoning_effort_strength import nearest_declared_reasoning_effort +from litellm.secret_managers.main import get_secret_str from litellm.types.llms.base_model import LiteLLMBaseModel if TYPE_CHECKING: from litellm.router import Router from litellm.router_api import RouterRegistry from litellm.types.proxy.model_listing import ModelInfoResponse + from litellm.types.router import DeploymentTypedDict CODEX_BASE_INSTRUCTIONS_PATH: Final = Path(__file__).with_name("codex_base_instructions.md") CODEX_BUNDLED_VERSION: Final = "0.166.0" @@ -164,6 +167,7 @@ class CodexStockModel(LiteLLMBaseModel): supported_reasoning_levels: tuple[CodexReasoningLevel, ...] = () context_window: int | None = None max_context_window: int | None = None + base_instructions: str = "" class CodexStockCatalog(LiteLLMBaseModel): @@ -565,6 +569,38 @@ def _catalog_row( ) +def _deployment_openai_key(deployment: DeploymentTypedDict) -> str | None: + params: Final = MappingProxyType[str, object](deployment["litellm_params"]) + model: Final = params.get("model") + provider: Final = params.get("custom_llm_provider") or (model.partition("/")[0] if isinstance(model, str) else "") + api_key: Final = params.get("api_key") + if provider != "openai" or not isinstance(api_key, str) or not api_key: + return None + return (get_secret_str(api_key) if api_key.startswith("os.environ/") else api_key) or None + + +def codex_catalog_openai_key( + llm_router: Router | None, + entries: Sequence[tuple[str, str]], + team_id: str | None = None, +) -> str | None: + """The OpenAI API key the catalog fetch authenticates with: the first one among the + listed entries' OpenAI deployments, else ``OPENAI_API_KEY`` from the environment.""" + registry: Final = llm_router.registry() if llm_router is not None else None + deployments: Final = ( + tuple( + deployment + for _, lookup_id in entries + for deployment in registry.deployments( + lookup_id, source="all", team_id=team_id + ) # comprehension-ok: flattening the entries' deployment lists + ) + if registry is not None + else () + ) + return next(filter(None, map(_deployment_openai_key, deployments)), None) or os.getenv("OPENAI_API_KEY") or None + + def codex_catalog_rows( rows: Sequence[ModelInfoResponse], entries: Sequence[tuple[str, str]], diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 3957a45fc1e..6820a9e9d42 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -514,7 +514,7 @@ from litellm.proxy.common_utils.auth_cache_invalidation_pubsub import ( ) from litellm.proxy.common_utils.callback_utils import initialize_callbacks_on_proxy from litellm.proxy.common_utils.codex_catalog_source import codex_catalog_source -from litellm.proxy.common_utils.codex_model_catalog import codex_model_list_body +from litellm.proxy.common_utils.codex_model_catalog import codex_catalog_openai_key, codex_model_list_body from litellm.proxy.common_utils.config_includes import resolve_include_file_path, resolve_includes from litellm.proxy.common_utils.config_sync_pubsub import ConfigSyncSubscriber from litellm.proxy.common_utils.credential_hydration import decrypted_or_stored @@ -12815,7 +12815,12 @@ async def model_list( llm_router, team_id or user_api_key_dict.team_id, client_version=client_version, - stock=await codex_catalog_source().stock(client_version), + stock=await codex_catalog_source().stock( + client_version, + openai_api_key=codex_catalog_openai_key( + llm_router, admin_entries, team_id or user_api_key_dict.team_id + ), + ), ), media_type="application/json", ) @@ -12898,7 +12903,10 @@ async def model_list( team_id or user_api_key_dict.team_id, labels, client_version=client_version, - stock=await codex_catalog_source().stock(client_version), + stock=await codex_catalog_source().stock( + client_version, + openai_api_key=codex_catalog_openai_key(llm_router, entries, team_id or user_api_key_dict.team_id), + ), ), media_type="application/json", ) diff --git a/tests/integration/compatibility/test_codex_catalog_fetch.py b/tests/integration/compatibility/test_codex_catalog_fetch.py index f1b5fc1cef3..41fcd728a0c 100644 --- a/tests/integration/compatibility/test_codex_catalog_fetch.py +++ b/tests/integration/compatibility/test_codex_catalog_fetch.py @@ -31,6 +31,11 @@ BUNDLED_STOCK_PATH: Final = ROOT / "litellm" / "proxy" / "common_utils" / "codex BASE_INSTRUCTIONS_PATH: Final = ROOT / "litellm" / "proxy" / "common_utils" / "codex_base_instructions.md" PUBLISHED_VERSION: Final = "0.999.0" UNPUBLISHED_VERSION: Final = "0.998.0" +OPENAI_VERSION: Final = "0.997.0" +OPENAI_SLUG: Final = "gpt-openai-release" +OPENAI_PROMPT: Final = "# Base prompt OpenAI serves for its release" +OPENAI_MODELS_PATH: Final = "/codex/models" +DEPLOYMENT_KEY: Final = "integration-provider-key" FRESH_SLUG: Final = "gpt-fresh-release" FRESH_PROMPT: Final = "# Base prompt of the published release" MODELS_PATH: Final = "codex-rs/models-manager/models.json" @@ -57,7 +62,21 @@ def _published_entry() -> dict[str, JsonValue]: return {**_vendored_entry("gpt-5.5"), "slug": FRESH_SLUG, "display_name": "Fresh Release", "upgrade": None} +def _openai_entry() -> dict[str, JsonValue]: + return { + **_published_entry(), + "slug": OPENAI_SLUG, + "display_name": "OpenAI Release", + "base_instructions": OPENAI_PROMPT, + } + + def _raw_tree(request: Request) -> Reply: + if request.target.startswith(f"{OPENAI_MODELS_PATH}?"): + served: Final = request.target == f"{OPENAI_MODELS_PATH}?client_version={OPENAI_VERSION}" + if served and request.headers.get("authorization") == f"Bearer {DEPLOYMENT_KEY}": + return Reply(body=json.dumps({"models": [_openai_entry()]}).encode()) + return Reply(status=401, body=b'{"detail":"Unauthorized"}') if request.target == f"/rust-v{PUBLISHED_VERSION}/{MODELS_PATH}": return Reply(body=json.dumps({"models": [_published_entry()]}).encode()) if request.target == f"/rust-v{PUBLISHED_VERSION}/{PROMPT_PATH}": @@ -83,7 +102,11 @@ def raw_tree() -> Iterator[Wire]: def gateway(tmp_path_factory: pytest.TempPathFactory, raw_tree: Wire) -> Iterator[Gateway]: with gateway_from_environment() as parent_gateway: directory: Final = tmp_path_factory.mktemp("codex_catalog_fetch") - overrides: Final = {"LITELLM_LOCAL_CODEX_CATALOG": "False", "LITELLM_CODEX_CATALOG_BASE_URL": raw_tree.url} + overrides: Final = { + "LITELLM_LOCAL_CODEX_CATALOG": "False", + "LITELLM_CODEX_CATALOG_BASE_URL": raw_tree.url, + "LITELLM_CODEX_CATALOG_OPENAI_URL": f"{raw_tree.url}{OPENAI_MODELS_PATH}", + } config: Final = _config_without_model_list(directory) with owned_proxy(parent_gateway, directory, overrides, config=config) as isolated_gateway: yield isolated_gateway @@ -108,6 +131,28 @@ def _catalog(gateway: Gateway, client_version: str) -> dict[str, dict[str, JsonV return {string_value(object_value(entry)["slug"]): object_value(entry) for entry in models} +def test_a_release_openai_serves_is_fetched_from_openai_with_the_deployment_key_and_github_is_skipped( + gateway: Gateway, raw_tree: Wire +) -> None: + assert OPENAI_SLUG not in _vendored_slugs() + with gateway.scenario() as scenario: + scenario.model(name=OPENAI_SLUG) + listing: Final = _catalog_listing(gateway, OPENAI_VERSION, OPENAI_SLUG) + expected: Final = { + **_openai_entry(), + "priority": listing[OPENAI_SLUG]["priority"], + "visibility": "list", + "supported_in_api": True, + "upgrade": None, + } + assert listing[OPENAI_SLUG] == expected, listing[OPENAI_SLUG] + requests: Final = raw_tree.drain() + openai_requests: Final = [request for request in requests if request.target.startswith(OPENAI_MODELS_PATH)] + assert len(openai_requests) == gateway.workers, [request.target for request in requests] + assert {request.headers.get("authorization") for request in openai_requests} == {f"Bearer {DEPLOYMENT_KEY}"} + assert not any(request.target.startswith(f"/rust-v{OPENAI_VERSION}/") for request in requests), requests + + def test_a_published_release_gets_its_own_entry_for_a_model_the_vendored_pair_lacks( gateway: Gateway, raw_tree: Wire ) -> None: diff --git a/tests/unit/proxy/common_utils/test_codex_catalog_source.py b/tests/unit/proxy/common_utils/test_codex_catalog_source.py index d8b8d30a166..de1363b31cc 100644 --- a/tests/unit/proxy/common_utils/test_codex_catalog_source.py +++ b/tests/unit/proxy/common_utils/test_codex_catalog_source.py @@ -9,6 +9,7 @@ from pydantic import JsonValue, TypeAdapter from litellm.proxy.common_utils.codex_catalog_source import ( CODEX_CATALOG_BASE_URL, + CODEX_CATALOG_OPENAI_URL, CodexCatalogSource, codex_release_tag, vendored_codex_stock, @@ -44,9 +45,13 @@ class _RawTree: def __init__(self, replies: Mapping[str, tuple[int, bytes]]) -> None: self.replies = replies self.calls: list[str] = [] + self.headers: list[dict[str, str] | None] = [] - async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response: + async def get( + self, url: str, *, headers: dict[str, str] | None = None, timeout: float | None = None + ) -> httpx.Response: self.calls.append(url) + self.headers.append(headers) status, body = self.replies.get(url, (404, b"404: Not Found")) return httpx.Response(status, content=body) @@ -96,7 +101,7 @@ def test_the_requesting_release_catalog_serves_its_entry_for_a_model_the_vendore tree = _RawTree(_tree(RELEASE)) stock = asyncio.run(_source(tree).stock(RELEASE)) entry = _entry(stock.models, stock.instructions, FRESH_SLUG) - assert stock.source == f"rust-v{RELEASE}" + assert stock.source == f"github rust-v{RELEASE}" assert entry["shell_type"] == "shell_command" and entry["apply_patch_tool_type"] == "freeform", entry assert entry["supports_parallel_tool_calls"] is True, entry assert sorted(tree.calls) == sorted(_tree(RELEASE)), tree.calls @@ -111,7 +116,7 @@ def test_a_release_is_fetched_once_and_concurrent_requests_share_the_fetch() -> again = await source.stock(RELEASE) return [stock.source for stock in (*stocks, again)] - assert asyncio.run(burst()) == [f"rust-v{RELEASE}"] * 6 + assert asyncio.run(burst()) == [f"github rust-v{RELEASE}"] * 6 assert len(tree.calls) == 2, tree.calls @@ -128,7 +133,7 @@ def test_a_release_without_a_tag_serves_the_vendored_pair_and_is_retried_after_t assert len(tree.calls) == calls_after_first, "a failed fetch was retried inside its window" clock.now += 2 tree.replies = _tree(RELEASE) - assert asyncio.run(source.stock(RELEASE)).source == f"rust-v{RELEASE}" + assert asyncio.run(source.stock(RELEASE)).source == f"github rust-v{RELEASE}" assert len(tree.calls) > calls_after_first, "a failed fetch was never retried" @@ -152,7 +157,9 @@ def test_a_release_whose_pair_does_not_parse_serves_the_vendored_pair(replies: M def test_a_transport_error_serves_the_vendored_pair() -> None: class _Down: - async def get(self, url: str, *, timeout: float | None = None) -> httpx.Response: + async def get( + self, url: str, *, headers: dict[str, str] | None = None, timeout: float | None = None + ) -> httpx.Response: raise httpx.ConnectError("offline", request=httpx.Request("GET", url)) stock = asyncio.run(CodexCatalogSource(client=_Down(), clock=_Clock()).stock(RELEASE)) @@ -191,7 +198,7 @@ def test_the_base_url_setting_points_the_fetch_at_a_mirror(monkeypatch: pytest.M for url, reply in _tree(RELEASE).items() } tree = _RawTree(mirrored) - assert asyncio.run(_source(tree).stock(RELEASE)).source == f"rust-v{RELEASE}" + assert asyncio.run(_source(tree).stock(RELEASE)).source == f"github rust-v{RELEASE}" assert sorted(tree.calls) == sorted(mirrored), tree.calls @@ -211,3 +218,36 @@ def test_the_cache_keeps_only_the_newest_releases() -> None: newest_models_url = next(url for url in _tree(versions[-1]) if url.endswith("models.json")) assert tree.calls.count(oldest_models_url) == 2, tree.calls assert tree.calls.count(newest_models_url) == 1, tree.calls + + +OPENAI_KEY: Final = "sk-unit-openai-key" +OPENAI_ENTRY: Final = {**FRESH_ENTRY, "shell_type": "unified_exec", "base_instructions": "# OpenAI live prompt"} +OPENAI_CATALOG: Final = json.dumps({"models": [OPENAI_ENTRY]}).encode() + + +def _openai_url(version: str) -> str: + return f"{CODEX_CATALOG_OPENAI_URL}?client_version={version}" + + +def test_an_openai_key_fetches_the_live_catalog_from_openai_with_that_key_and_skips_github() -> None: + tree = _RawTree({**_tree(RELEASE), _openai_url(RELEASE): (200, OPENAI_CATALOG)}) + stock = asyncio.run(_source(tree).stock(RELEASE, openai_api_key=OPENAI_KEY)) + entry = _entry(stock.models, stock.instructions, FRESH_SLUG) + assert stock.source == f"openai {RELEASE}" and stock.instructions == "# OpenAI live prompt", stock.source + assert entry["shell_type"] == "unified_exec", entry + assert tree.calls == [_openai_url(RELEASE)] and tree.headers == [{"Authorization": f"Bearer {OPENAI_KEY}"}] + + +@pytest.mark.parametrize("status", [401, 500]) +def test_a_refused_openai_fetch_falls_back_to_the_release_tag_on_github(status: int) -> None: + tree = _RawTree({**_tree(RELEASE), _openai_url(RELEASE): (status, b"{}")}) + stock = asyncio.run(_source(tree).stock(RELEASE, openai_api_key=OPENAI_KEY)) + assert stock.source == f"github rust-v{RELEASE}" and FRESH_SLUG in stock.models + assert tree.calls[0] == _openai_url(RELEASE) and sorted(tree.calls[1:]) == sorted(_tree(RELEASE)), tree.calls + + +def test_without_an_openai_key_openai_is_never_asked() -> None: + tree = _RawTree({**_tree(RELEASE), _openai_url(RELEASE): (200, OPENAI_CATALOG)}) + stock = asyncio.run(_source(tree).stock(RELEASE)) + assert stock.source == f"github rust-v{RELEASE}" + assert _openai_url(RELEASE) not in tree.calls and tree.headers == [None, None], tree.calls diff --git a/tests/unit/proxy/common_utils/test_codex_model_catalog.py b/tests/unit/proxy/common_utils/test_codex_model_catalog.py index e3d941a6aa4..45861aea67c 100644 --- a/tests/unit/proxy/common_utils/test_codex_model_catalog.py +++ b/tests/unit/proxy/common_utils/test_codex_model_catalog.py @@ -14,6 +14,7 @@ from litellm.proxy.common_utils.codex_model_catalog import ( CodexStockModel, CodexStockUpgrade, bundled_codex_models, + codex_catalog_openai_key, codex_catalog_rows, codex_model_list_body, codex_models_response_json, @@ -944,3 +945,36 @@ def test_codex_tool_search_is_offered_off_the_route_and_model_info_and_only_when "mixed": False, } assert models[0]["context_window"] == 1000000 + + +def _router_with(*litellm_params: Mapping[str, str]) -> litellm.Router: + return litellm.Router( + model_list=[ + {"model_name": f"catalog-model-{index}", "litellm_params": dict(params)} + for index, params in enumerate(litellm_params) + ] + ) + + +def test_the_catalog_key_is_the_listed_openai_deployments_key_before_the_environments( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setenv("OPENAI_API_KEY", "sk-from-env") + monkeypatch.setenv("CATALOG_OPENAI_KEY", "sk-from-secret-ref") + router: Final = _router_with( + {"model": "anthropic/claude-sonnet-4-5", "api_key": "sk-ant-not-openai"}, + {"model": "openai/gpt-5.5", "api_key": "os.environ/CATALOG_OPENAI_KEY"}, + {"model": "openai/gpt-5.5", "api_key": "sk-second-openai"}, + ) + entries: Final = [(f"catalog-model-{index}", f"catalog-model-{index}") for index in range(3)] + assert codex_catalog_openai_key(router, entries) == "sk-from-secret-ref" + assert codex_catalog_openai_key(router, entries[:1]) == "sk-from-env" + + +def test_the_catalog_key_is_absent_without_an_openai_deployment_or_environment_key( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + router: Final = _router_with({"model": "anthropic/claude-sonnet-4-5", "api_key": "sk-ant-not-openai"}) + assert codex_catalog_openai_key(router, [("catalog-model-0", "catalog-model-0")]) is None + assert codex_catalog_openai_key(None, [("catalog-model-0", "catalog-model-0")]) is None