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