Fix Codex OAuth request metadata

Send current Codex client metadata and compatibility headers on proxied Responses requests so ChatGPT OAuth calls pass upstream call checks. Return Codex model slugs as the primary list while preserving model metadata for descriptions in the OAuth settings UI.

Alessandro committed Jul 8, 2026 at 15:08 UTC 6dfe33c4932dafe5e5f6039be1e20124d95951b8
9 files changed +420 -16
plugins/_oauth/AGENTS.md
+2
@@ -30,6 +30,7 @@
30 - OAuth settings model slots must keep provider choice editable per slot, list only connected OAuth account providers, and persist the selected provider IDs into `chat_model.provider` and `utility_model.provider`.
31 - OAuth settings must dispatch `model-setup-changed` when a provider connection completes, but model selection remains explicit and must not be filled automatically.
32 - `helpers/providers/registry.py` is the source of truth for connectable OAuth providers.
33 +- The models API must preserve the legacy plain `models` slug list and may add `model_metadata` entries for richer provider catalogs.
34 - OAuth provider config must not expose the dummy `oauth` API key in `conf/model_providers.yaml`; the dummy key is a runtime-only shim supplied by the `get_api_key` extension after the account provider reports connected.
35 - Usage-plan metadata belongs only to connectable providers. Do not add metadata-only subscription families for providers this plugin cannot connect.
36 - API handlers should remain provider-aware. Missing or blank `provider_id` defaults to Codex only for existing backward compatibility; falsey non-string IDs must not silently default.
@@ -39,6 +40,7 @@
40 - Stored upstream base URLs and OAuth token endpoints must be validated against provider-owned allowlists before sending bearer or refresh tokens.
41 - Browser callback providers must support manual callback paste when the browser cannot reach the local callback route.
42 - Local proxy routes must remain loopback or token protected and must not add broad CORS access.
43 +- Codex Responses proxy requests must include Codex client metadata and compatibility headers such as `client_metadata`, `x-codex-installation-id`, `originator`, `session-id`, and `thread-id`.
44
45 ## Work Guidance
46
plugins/_oauth/api/models.py
+17 -2
@@ -10,8 +10,23 @@ class Models(ApiHandler):
10 raw_provider_id = _provider_id(input)
11 try:
12 provider = get_provider(raw_provider_id)
13 - models = provider.models()
14 - return {"ok": True, "provider_id": provider.provider_id, "models": models}
13 + model_catalog = getattr(provider, "model_catalog", None)
14 + if callable(model_catalog):
15 + catalog = model_catalog()
16 + models = [
17 + str(item.get("slug") or item.get("id") or "")
18 + for item in catalog
19 + if isinstance(item, dict) and (item.get("slug") or item.get("id"))
20 + ]
21 + else:
22 + catalog = []
23 + models = provider.models()
24 + return {
25 + "ok": True,
26 + "provider_id": provider.provider_id,
27 + "models": models,
28 + "model_metadata": catalog,
29 + }
30 except Exception as exc:
31 return {
32 "ok": False,
plugins/_oauth/helpers/codex.py
+143 -10
@@ -6,9 +6,11 @@ import hashlib
6 import json
7 import os
8 import secrets
9 +import stat
10 import subprocess
11 import threading
12 import time
13 +import uuid
14 from contextlib import contextmanager
15 from dataclasses import dataclass
16 from datetime import datetime, timedelta, timezone
@@ -33,9 +35,28 @@ except ImportError:
35
36
37 AUTH_FILENAME = "auth.json"
38 +INSTALLATION_ID_FILENAME = "installation_id"
39 ACCESS_EXPIRY_MARGIN = timedelta(minutes=5)
40 REFRESH_INTERVAL = timedelta(minutes=55)
41 DEFAULT_CODEX_MODEL = "gpt-5.5"
42 +CODEX_ORIGINATOR = "codex_cli_rs"
43 +CLIENT_METADATA_INSTALLATION_ID = "x-codex-installation-id"
44 +CLIENT_METADATA_WINDOW_ID = "x-codex-window-id"
45 +CLIENT_METADATA_KEYS = (
46 + "slug",
47 + "id",
48 + "display_name",
49 + "description",
50 + "visibility",
51 + "supported_in_api",
52 + "default_reasoning_level",
53 + "supported_reasoning_levels",
54 + "additional_speed_tiers",
55 + "service_tiers",
56 + "context_window",
57 + "max_context_window",
58 + "priority",
59 +)
60 OAUTH_ERROR_KEYS = ("error_description", "error")
61 DEVICE_CODE_TIMEOUT_SECONDS = 15 * 60
62 WINDOWS_LOCK_RETRY_SECONDS = 0.05
@@ -565,11 +586,17 @@ def request_codex(
586 auth = load_auth()
587 target = build_upstream_url(path, cfg["upstream_base_url"])
588 request_headers = sanitize_forward_headers(headers or {})
589 + metadata = client_metadata_from_body(body) or build_client_metadata()
590 request_headers.update(
591 {
592 "Authorization": f"Bearer {auth.access_token}",
593 "chatgpt-account-id": auth.account_id,
594 "OpenAI-Beta": "responses=experimental",
595 + "originator": CODEX_ORIGINATOR,
596 + CLIENT_METADATA_INSTALLATION_ID: metadata[CLIENT_METADATA_INSTALLATION_ID],
597 + CLIENT_METADATA_WINDOW_ID: metadata[CLIENT_METADATA_WINDOW_ID],
598 + "session-id": metadata["session_id"],
599 + "thread-id": metadata["thread_id"],
600 }
601 )
602
@@ -584,11 +611,14 @@ def request_codex(
611 )
612
613
587 -def fetch_models() -> list[str]:
614 +def fetch_model_catalog() -> list[dict[str, Any]]:
615 cfg = codex_config()
616 configured = cfg["models"]
617 if configured:
591 - return configured
618 + return [
619 + {"slug": model, "id": model, "display_name": model}
620 + for model in configured
621 + ]
622
623 client_version = resolve_codex_version()
624 params = {"client_version": client_version} if client_version else None
@@ -601,31 +631,103 @@ def fetch_models() -> list[str]:
631
632 payload = response.json()
633 raw_models = payload.get("models") if isinstance(payload, dict) else None
634 + if raw_models is None and isinstance(payload, dict):
635 + raw_models = payload.get("data")
636 if not isinstance(raw_models, list):
637 raise RuntimeError("Codex returned a malformed models response.")
638
607 - models: list[str] = []
639 + catalog: list[dict[str, Any]] = []
640 seen: set[str] = set()
641 for item in raw_models:
610 - slug = item.get("slug") if isinstance(item, dict) else None
611 - if isinstance(slug, str) and slug and slug not in seen:
642 + if isinstance(item, dict):
643 + slug = _string(item.get("slug") or item.get("id"))
644 + model = {
645 + key: item[key]
646 + for key in CLIENT_METADATA_KEYS
647 + if key in item
648 + }
649 + else:
650 + slug = _string(item)
651 + model = {}
652 + if slug and slug not in seen:
653 seen.add(slug)
613 - models.append(slug)
614 - if not models:
654 + model["slug"] = slug
655 + model.setdefault("id", slug)
656 + model.setdefault("display_name", slug)
657 + catalog.append(model)
658 + if not catalog:
659 raise RuntimeError("Codex returned an empty models list.")
616 - return models
660 + return catalog
661 +
662 +
663 +def fetch_models() -> list[str]:
664 + return [model["slug"] for model in fetch_model_catalog()]
665
666
667 def prepare_responses_body(body: dict[str, Any], *, force_stream: bool) -> dict[str, Any]:
668 normalized = dict(body)
669 normalized.setdefault("instructions", "")
670 normalized.setdefault("store", False)
671 + normalized["client_metadata"] = merge_client_metadata(normalized.get("client_metadata"))
672 if force_stream:
673 normalized["stream"] = True
674 + if isinstance(normalized.get("reasoning"), dict):
675 + include = normalized.get("include")
676 + values = list(include) if isinstance(include, list) else []
677 + if "reasoning.encrypted_content" not in values:
678 + values.append("reasoning.encrypted_content")
679 + normalized["include"] = values
680 normalized.pop("max_output_tokens", None)
681 return normalized
682
683
684 +def build_client_metadata() -> dict[str, str]:
685 + request_id = f"agent-zero-{uuid.uuid4()}"
686 + return {
687 + CLIENT_METADATA_INSTALLATION_ID: resolve_installation_id(),
688 + "session_id": request_id,
689 + "thread_id": request_id,
690 + CLIENT_METADATA_WINDOW_ID: "agent-zero",
691 + }
692 +
693 +
694 +def merge_client_metadata(value: Any) -> dict[str, str]:
695 + metadata = {
696 + str(key): str(item)
697 + for key, item in (value.items() if isinstance(value, dict) else [])
698 + if item is not None and str(item)
699 + }
700 + metadata.update(build_client_metadata())
701 + return metadata
702 +
703 +
704 +def client_metadata_from_body(body: bytes | str | None) -> dict[str, str] | None:
705 + if body is None:
706 + return None
707 + try:
708 + text = body.decode("utf-8") if isinstance(body, bytes) else body
709 + payload = json.loads(text)
710 + except Exception:
711 + return None
712 + if not isinstance(payload, dict):
713 + return None
714 + metadata = payload.get("client_metadata")
715 + if not isinstance(metadata, dict):
716 + return None
717 + result = {
718 + str(key): str(value)
719 + for key, value in metadata.items()
720 + if value is not None and str(value)
721 + }
722 + required = {
723 + CLIENT_METADATA_INSTALLATION_ID,
724 + "session_id",
725 + "thread_id",
726 + CLIENT_METADATA_WINDOW_ID,
727 + }
728 + return result if required.issubset(result) else None
729 +
730 +
731 def collect_completed_response(response: requests.Response) -> dict[str, Any]:
732 latest_response: dict[str, Any] | None = None
733 latest_error: Any = None
@@ -728,9 +830,7 @@ def extract_sse_text_deltas(payload: dict[str, Any], event_type: str = "") -> li
830
831 if (payload.get("type") or event_type) in {
832 "response.output_text.delta",
731 - "response.output_text.done",
833 "response.text.delta",
733 - "response.text.done",
834 }:
835 _append_text_value(pieces, payload.get("text"))
836
@@ -985,6 +1085,39 @@ def resolve_codex_version() -> str:
1085 return ""
1086
1087
1088 +def resolve_installation_id() -> str:
1089 + for path in installation_id_candidates():
1090 + try:
1091 + value = path.read_text(encoding="utf-8").strip()
1092 + except OSError:
1093 + continue
1094 + if value:
1095 + return value
1096 +
1097 + value = str(uuid.uuid4())
1098 + path = plugin_installation_id_path()
1099 + try:
1100 + path.parent.mkdir(parents=True, exist_ok=True)
1101 + path.write_text(value, encoding="utf-8")
1102 + os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
1103 + except OSError:
1104 + pass
1105 + return value
1106 +
1107 +
1108 +def installation_id_candidates() -> list[Path]:
1109 + candidates = [plugin_installation_id_path()]
1110 + codex_home = os.environ.get("CODEX_HOME")
1111 + if codex_home:
1112 + candidates.append(Path(codex_home).expanduser() / INSTALLATION_ID_FILENAME)
1113 + candidates.append(Path.home() / ".codex" / INSTALLATION_ID_FILENAME)
1114 + return candidates
1115 +
1116 +
1117 +def plugin_installation_id_path() -> Path:
1118 + return Path(files.get_abs_path("usr", "plugins", "_oauth", "codex", INSTALLATION_ID_FILENAME))
1119 +
1120 +
1121 def resolve_auth_file_candidates() -> list[Path]:
1122 return [resolve_auth_write_path()]
1123
plugins/_oauth/helpers/providers/codex.py
+5
@@ -232,6 +232,11 @@ class CodexOAuthProvider:
232
233 return codex.fetch_models()
234
235 + def model_catalog(self) -> list[dict[str, Any]]:
236 + from plugins._oauth.helpers import codex
237 +
238 + return codex.fetch_model_catalog()
239 +
240 def disconnect(self) -> dict[str, Any]:
241 from plugins._oauth.helpers import codex
242
plugins/_oauth/helpers/summary.py
+12
@@ -63,13 +63,25 @@ def _provider_status(provider: Any) -> dict[str, Any]:
63 status["usage_windows"] = usage_windows
64 return status
65 except Exception as exc:
66 + metadata = _provider_metadata(provider)
67 return {
68 + **metadata,
69 "provider_id": str(getattr(provider, "provider_id", "")),
70 "connected": False,
71 "error": str(exc),
72 }
73
74
75 +def _provider_metadata(provider: Any) -> dict[str, Any]:
76 + try:
77 + metadata = provider.metadata()
78 + to_dict = getattr(metadata, "to_dict", None)
79 + value = to_dict() if callable(to_dict) else metadata
80 + return value if isinstance(value, dict) else {}
81 + except Exception:
82 + return {}
83 +
84 +
85 def _account_summary(provider: dict[str, Any], catalog: dict[str, Any]) -> dict[str, Any]:
86 provider_id = str(provider.get("provider_id") or "")
87 plan_entry = catalog.get(provider_id) if isinstance(catalog, dict) else None
plugins/_oauth/webui/config.html
+30 -4
@@ -259,8 +259,16 @@
259 type="button"
260 class="oauth-model-item"
261 @click="$store.oauthConfig.selectModel(slot.key, model)"
262 - x-text="model"
263 - ></button>
262 + >
263 + <span
264 + class="oauth-model-name"
265 + x-text="model"
266 + ></span>
267 + <small
268 + x-show="$store.oauthConfig.modelDescription(slot.provider, model)"
269 + x-text="$store.oauthConfig.modelDescription(slot.provider, model)"
270 + ></small>
271 + </button>
272 </template>
273 <div class="oauth-model-item muted" x-show="$store.oauthConfig.filteredModels(slot.key).length === 0">
274 No models found. You can still type the model name manually.
@@ -282,7 +290,10 @@
290 </div>
291 <div class="oauth-models">
292 <template x-for="model in $store.oauthConfig.activeProviderModels()" :key="model">
285 - <span x-text="model"></span>
293 + <span
294 + :title="$store.oauthConfig.modelDescription($store.oauthConfig.activeModelProvider, model)"
295 + x-text="model"
296 + ></span>
297 </template>
298 </div>
299 </section>
@@ -991,7 +1002,9 @@
1002 }
1003
1004 .oauth-model-item {
994 - display: block;
1005 + display: flex;
1006 + flex-direction: column;
1007 + gap: 3px;
1008 width: 100%;
1009 min-height: 34px;
1010 padding: 8px 10px;
@@ -1004,6 +1017,19 @@
1017 cursor: pointer;
1018 }
1019
1020 + .oauth-model-name {
1021 + font-weight: 650;
1022 + }
1023 +
1024 + .oauth-model-item small {
1025 + overflow: hidden;
1026 + color: var(--color-text-secondary);
1027 + font-size: 0.72rem;
1028 + line-height: 1.3;
1029 + text-overflow: ellipsis;
1030 + white-space: nowrap;
1031 + }
1032 +
1033 .oauth-model-item:hover,
1034 .oauth-model-item:focus-visible {
1035 background: color-mix(in srgb, var(--color-border) 42%, transparent);
plugins/_oauth/webui/oauth-config-store.js
+21
@@ -107,6 +107,7 @@ export const store = createStore("oauthConfig", {
107 disconnecting: false,
108 loadingModels: false,
109 providerModels: {},
110 + providerModelMetadata: {},
111 providerUi: providerUiDefaults(),
112 connectingProvider: "",
113 disconnectingProvider: "",
@@ -150,6 +151,7 @@ export const store = createStore("oauthConfig", {
151 this.disconnecting = false;
152 this.loadingModels = false;
153 this.providerModels = {};
154 + this.providerModelMetadata = {};
155 this.providerUi = providerUiDefaults();
156 this.connectingProvider = "";
157 this.disconnectingProvider = "";
@@ -556,6 +558,14 @@ export const store = createStore("oauthConfig", {
558 return this.providerModels[this.activeModelProvider] || [];
559 },
560
561 + modelMetadata(providerId, model) {
562 + return this.providerModelMetadata[providerId]?.[model] || {};
563 + },
564 +
565 + modelDescription(providerId, model) {
566 + return this.modelMetadata(providerId, model).description || "";
567 + },
568 +
569 activeModelsDescription() {
570 if (!this.providerConnected(this.activeModelProvider)) return "";
571 return `Available models from ${this.providerLabel(this.activeModelProvider)}`;
@@ -967,12 +977,20 @@ export const store = createStore("oauthConfig", {
977 throw new Error(response?.error || `Could not load ${this.providerLabel(selectedProvider)} models.`);
978 }
979 const models = Array.isArray(response.models) ? response.models : [];
980 + const metadata = Array.isArray(response.model_metadata) ? response.model_metadata : [];
981 + const metadataMap = metadata.reduce((result, item) => {
982 + const key = String(item?.slug || item?.id || "");
983 + if (key) result[key] = item;
984 + return result;
985 + }, {});
986 this.providerModels = { ...this.providerModels, [selectedProvider]: models };
987 + this.providerModelMetadata = { ...this.providerModelMetadata, [selectedProvider]: metadataMap };
988 this.models = models;
989 if (openDropdown) this.openModelDropdown(openDropdown);
990 if (!silent) void toastFrontendSuccess(`${this.providerLabel(selectedProvider)} models loaded.`, "OAuth Connections");
991 } catch (error) {
992 this.providerModels = { ...this.providerModels, [selectedProvider]: [] };
993 + this.providerModelMetadata = { ...this.providerModelMetadata, [selectedProvider]: {} };
994 this.models = [];
995 if (!silent) void toastFrontendError(messageOf(error), "OAuth Connections");
996 } finally {
@@ -999,6 +1017,9 @@ export const store = createStore("oauthConfig", {
1017 const providerModels = { ...this.providerModels };
1018 delete providerModels[providerId];
1019 this.providerModels = providerModels;
1020 + const providerModelMetadata = { ...this.providerModelMetadata };
1021 + delete providerModelMetadata[providerId];
1022 + this.providerModelMetadata = providerModelMetadata;
1023 if (this.activeModelProvider === providerId) this.models = [];
1024 this.clearProviderDevice(providerId);
1025 if (this.connectingProvider === providerId) this.connectingProvider = "";
tests/test_oauth_codex.py
+149
@@ -137,6 +137,145 @@ def test_codex_fetch_models_omits_fake_client_version(monkeypatch):
137 assert calls == [("/models", None)]
138
139
140 +def test_codex_fetch_model_catalog_preserves_model_metadata(monkeypatch):
141 + class FakeResponse:
142 + ok = True
143 +
144 + def json(self):
145 + return {
146 + "models": [
147 + {
148 + "slug": "gpt-5.5",
149 + "display_name": "GPT-5.5",
150 + "description": "Frontier coding model.",
151 + "default_reasoning_level": "medium",
152 + "base_instructions": "too large for the settings UI",
153 + }
154 + ]
155 + }
156 +
157 + monkeypatch.setattr(codex, "codex_config", lambda: {"models": []})
158 + monkeypatch.setattr(codex, "resolve_codex_version", lambda: "0.142.5")
159 + monkeypatch.setattr(codex, "request_codex", lambda path, params=None: FakeResponse())
160 +
161 + assert codex.fetch_model_catalog() == [
162 + {
163 + "slug": "gpt-5.5",
164 + "id": "gpt-5.5",
165 + "display_name": "GPT-5.5",
166 + "description": "Frontier coding model.",
167 + "default_reasoning_level": "medium",
168 + }
169 + ]
170 +
171 +
172 +def test_prepare_responses_body_adds_codex_client_metadata(monkeypatch):
173 + monkeypatch.setattr(
174 + codex,
175 + "build_client_metadata",
176 + lambda: {
177 + "x-codex-installation-id": "install-1",
178 + "session_id": "session-1",
179 + "thread_id": "thread-1",
180 + "x-codex-window-id": "agent-zero",
181 + },
182 + )
183 +
184 + body = codex.prepare_responses_body(
185 + {
186 + "model": "gpt-5.5",
187 + "input": "hello",
188 + "client_metadata": {
189 + "caller": "plugin-test",
190 + "x-codex-installation-id": "stale",
191 + },
192 + "reasoning": {"effort": "medium"},
193 + "include": ["output_text"],
194 + },
195 + force_stream=True,
196 + )
197 +
198 + assert body["client_metadata"] == {
199 + "caller": "plugin-test",
200 + "x-codex-installation-id": "install-1",
201 + "session_id": "session-1",
202 + "thread_id": "thread-1",
203 + "x-codex-window-id": "agent-zero",
204 + }
205 + assert body["stream"] is True
206 + assert body["include"] == ["output_text", "reasoning.encrypted_content"]
207 +
208 +
209 +def test_request_codex_sends_current_codex_headers_from_body(monkeypatch):
210 + calls = []
211 + body = json.dumps(
212 + {
213 + "client_metadata": {
214 + "x-codex-installation-id": "install-1",
215 + "session_id": "session-1",
216 + "thread_id": "thread-1",
217 + "x-codex-window-id": "agent-zero",
218 + }
219 + }
220 + )
221 +
222 + class FakeResponse:
223 + ok = True
224 +
225 + monkeypatch.setattr(
226 + codex,
227 + "codex_config",
228 + lambda: {
229 + "upstream_base_url": "https://chatgpt.example/backend-api/codex",
230 + "request_timeout_seconds": 120,
231 + },
232 + )
233 + monkeypatch.setattr(
234 + codex,
235 + "load_auth",
236 + lambda: codex.EffectiveAuth(
237 + access_token="access-token",
238 + account_id="account-1",
239 + ),
240 + )
241 +
242 + def fake_request(method, target, headers, data, params, timeout, stream):
243 + calls.append(
244 + {
245 + "method": method,
246 + "target": target,
247 + "headers": headers,
248 + "data": data,
249 + "params": params,
250 + "timeout": timeout,
251 + "stream": stream,
252 + }
253 + )
254 + return FakeResponse()
255 +
256 + monkeypatch.setattr(codex.requests, "request", fake_request)
257 +
258 + response = codex.request_codex(
259 + "/responses",
260 + method="POST",
261 + headers={"Content-Type": "application/json"},
262 + body=body,
263 + stream=True,
264 + )
265 +
266 + assert response.ok is True
267 + call = calls[0]
268 + assert call["target"] == "https://chatgpt.example/backend-api/codex/responses"
269 + assert call["headers"]["Authorization"] == "Bearer access-token"
270 + assert call["headers"]["chatgpt-account-id"] == "account-1"
271 + assert call["headers"]["OpenAI-Beta"] == "responses=experimental"
272 + assert call["headers"]["originator"] == "codex_cli_rs"
273 + assert call["headers"]["x-codex-installation-id"] == "install-1"
274 + assert call["headers"]["session-id"] == "session-1"
275 + assert call["headers"]["thread-id"] == "thread-1"
276 + assert call["headers"]["x-codex-window-id"] == "agent-zero"
277 +
278 +
279 def test_chat_messages_to_response_body_preserves_image_parts_for_responses():
280 data_url = "data:image/png;base64,abcd"
281
@@ -231,6 +370,16 @@ def test_extract_sse_text_deltas_reads_chat_completion_chunks():
370 assert deltas == ["Hel", "lo"]
371
372
373 +def test_extract_sse_text_deltas_ignores_final_done_text():
374 + assert (
375 + codex.extract_sse_text_deltas(
376 + {"type": "response.output_text.done", "text": "Hello"},
377 + "response.output_text.done",
378 + )
379 + == []
380 + )
381 +
382 +
383 def test_collect_completed_response_falls_back_to_text_deltas():
384 class FakeResponse:
385 encoding = "utf-8"
tests/test_oauth_providers.py
+41
@@ -390,6 +390,13 @@ def test_status_api_contains_provider_status_exceptions(monkeypatch):
390 class FailingProvider:
391 provider_id = XAI_GROK_PROVIDER_ID
392
393 + def metadata(self):
394 + return {
395 + "provider_id": XAI_GROK_PROVIDER_ID,
396 + "display_name": "xAI Grok",
397 + "auth_flow": "browser_pkce",
398 + }
399 +
400 def status(self):
401 raise RuntimeError("status failed")
402
@@ -409,6 +416,8 @@ def test_status_api_contains_provider_status_exceptions(monkeypatch):
416 assert response["provider_map"][CODEX_PROVIDER_ID]["connected"] is True
417 assert response["provider_map"][XAI_GROK_PROVIDER_ID] == {
418 "provider_id": XAI_GROK_PROVIDER_ID,
419 + "display_name": "xAI Grok",
420 + "auth_flow": "browser_pkce",
421 "connected": False,
422 "error": "status failed",
423 }
@@ -951,6 +960,38 @@ def test_manual_callback_unknown_provider_returns_structured_error():
960 assert "Unknown OAuth provider" in response["error"]
961
962
963 +def test_models_api_returns_optional_model_metadata(monkeypatch):
964 + class FakeProvider:
965 + provider_id = CODEX_PROVIDER_ID
966 +
967 + def model_catalog(self):
968 + return [
969 + {
970 + "slug": "gpt-5.5",
971 + "display_name": "GPT-5.5",
972 + "description": "Frontier coding model.",
973 + }
974 + ]
975 +
976 + def models(self):
977 + return ["ignored-when-catalog-exists"]
978 +
979 + models_module = sys.modules["plugins._oauth.api.models"]
980 + monkeypatch.setattr(models_module, "get_provider", lambda provider_id: FakeProvider())
981 +
982 + response = asyncio.run(Models(None, None).process({"provider_id": CODEX_PROVIDER_ID}, FakeRequest()))
983 +
984 + assert response["ok"] is True
985 + assert response["models"] == ["gpt-5.5"]
986 + assert response["model_metadata"] == [
987 + {
988 + "slug": "gpt-5.5",
989 + "display_name": "GPT-5.5",
990 + "description": "Frontier coding model.",
991 + }
992 + ]
993 +
994 +
995 def test_unknown_provider_id_on_provider_aware_api_returns_structured_error():
996 response = asyncio.run(Models(None, None).process({"provider_id": "missing"}, FakeRequest()))
997