From 4eeddc434babac3f41205dba39131fbdcbcf86c4 Mon Sep 17 00:00:00 2001 From: ChengFa <166748424+indexGin@users.noreply.github.com> Date: Tue, 13 Oct 2026 22:41:38 +0800 Subject: [PATCH 1/2] fix(providers): support Codex model discovery protocol --- CHANGELOG.md | 1 + src/octop/i18n/en.json | 3 + src/octop/i18n/zh.json | 3 + src/octop/infra/agents/providers/probe.py | 22 ++- tests/unit/test_codex_fetch_models_http.py | 149 +++++++++++++++++++++ 5 files changed, 173 insertions(+), 5 deletions(-) create mode 100644 tests/unit/test_codex_fetch_models_http.py diff --git a/CHANGELOG.md b/CHANGELOG.md index eb939654d..9a199b6ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ 格式遵循 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),版本号遵循 [语义化版本规范](https://semver.org/spec/v2.0.0.html)。 ## [Unreleased] +- Codex 提供商获取模型列表时复用已有版本配置传入 `client_version`,并解析 `models[].slug` 和显示名称,修复请求失败及列表为空的问题(#1992) - Telegram 通道可以限制允许的用户 ID(私聊和群聊都生效)。v1.0.2b7 的更新说明里已经写过这项,但合并时前端字段和 octop-gateway 1.0.1 依赖被丢掉了,这次补回 - 知识库文档上限设为 0(不限制)时,空库不再被当成已满,可以继续上传和新建文档 - 对话输入栏在专家配置了默认模型时显示该模型;与专家默认相同的选择不再写成会话粘性覆盖。专家的默认模型、知识库或连接器变更后,静默刷新会更新输入栏 diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index ca8c9a061..01974ec1c 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -924,6 +924,9 @@ "version_mismatch": "The installer finished, but installed version {actual} does not match requested version {expected}.", "version_not_newer": "The installer finished, but installed version {actual} is not newer than {previous}." }, + "providers": { + "codex_models_invalid_response": "Invalid Codex model list (expected models entries with slug identifiers)." + }, "ldap": { "test": { "ok": "Connected to the LDAP directory", diff --git a/src/octop/i18n/zh.json b/src/octop/i18n/zh.json index 3849431bf..c8953a1c8 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -924,6 +924,9 @@ "version_mismatch": "安装程序已结束,但已安装版本 {actual} 与目标版本 {expected} 不一致。", "version_not_newer": "安装程序已结束,但已安装版本 {actual} 不高于原版本 {previous}。" }, + "providers": { + "codex_models_invalid_response": "Codex 模型列表格式无效(应包含 models 列表和 slug 标识)。" + }, "ldap": { "test": { "ok": "已连接 LDAP 目录服务", diff --git a/src/octop/infra/agents/providers/probe.py b/src/octop/infra/agents/providers/probe.py index 7f4c5aa65..3cabfebe5 100644 --- a/src/octop/infra/agents/providers/probe.py +++ b/src/octop/infra/agents/providers/probe.py @@ -8,10 +8,14 @@ import time from types import SimpleNamespace from typing import Any +from urllib.parse import urlsplit import httpx +from octop.i18n import tr from octop.infra.agents.providers import KIND_TO_PROTOCOL +from octop.infra.agents.providers.codex_apply import CODEX_PROVIDER_NAME +from octop.infra.agents.providers.codex_oauth import CODEX_ATTRIBUTION_VERSION from octop.infra.agents.providers.opencode_session import ensure_opencode_session_header logger = logging.getLogger(__name__) @@ -39,7 +43,8 @@ def provider_headers(row: Any) -> dict[str, str]: def _is_codex_base_url(base_url: str | None) -> bool: - return bool(base_url and "chatgpt.com/backend-api/codex" in base_url) + parsed = urlsplit((base_url or "").strip()) + return parsed.hostname == "chatgpt.com" and parsed.path.rstrip("/") == "/backend-api/codex" def build_probe_chat_model(row: Any, *, model_id: str | None = None) -> Any: @@ -284,13 +289,15 @@ async def fetch_openai_compatible_models( ) -> dict[str, Any]: """List models via OpenAI-compatible ``GET {base}/models``.""" url = _models_list_url(base_url) + is_codex = provider_name == CODEX_PROVIDER_NAME or _is_codex_base_url(base_url) + params = {"client_version": CODEX_ATTRIBUTION_VERSION} if is_codex else None headers: dict[str, str] = {"Authorization": f"Bearer {api_key}"} extra_headers = ensure_opencode_session_header(provider_name, extra_headers, base_url=base_url) if extra_headers: headers.update(extra_headers) try: async with httpx.AsyncClient(timeout=_FETCH_MODELS_TIMEOUT_S) as client: - response = await client.get(url, headers=headers) + response = await client.get(url, headers=headers, params=params) except Exception as exc: logger.info( "provider fetch-models failed for %s: %r (%s)", @@ -317,8 +324,10 @@ async def fetch_openai_compatible_models( "error": "response is not valid JSON (expected OpenAI-compatible /models)", } - data = payload.get("data") if isinstance(payload, dict) else None + data = payload.get("models" if is_codex else "data") if isinstance(payload, dict) else None if not isinstance(data, list): + if is_codex: + return {"ok": False, "error": tr("providers.codex_models_invalid_response", locale)} return { "ok": False, "error": "response is not OpenAI-compatible (expected {data: [{id, …}]})", @@ -329,13 +338,16 @@ async def fetch_openai_compatible_models( for item in data: if not isinstance(item, dict): continue - mid = item.get("id") + mid = item.get("slug" if is_codex else "id") if not isinstance(mid, str) or not mid.strip(): continue mid = mid.strip() if mid in seen: continue seen.add(mid) - models.append({"id": mid, "name": mid}) + name = item.get("display_name") if is_codex else mid + models.append( + {"id": mid, "name": name.strip() if isinstance(name, str) and name.strip() else mid} + ) models.sort(key=lambda m: m["id"]) return {"ok": True, "models": models} diff --git a/tests/unit/test_codex_fetch_models_http.py b/tests/unit/test_codex_fetch_models_http.py new file mode 100644 index 000000000..677a9a743 --- /dev/null +++ b/tests/unit/test_codex_fetch_models_http.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import json +from collections.abc import Iterator +from http.server import HTTPServer, SimpleHTTPRequestHandler +from pathlib import Path +from threading import Thread +from urllib.parse import parse_qs, urlsplit + +import pytest + +from octop.infra.agents.providers.codex_apply import CODEX_PROVIDER_NAME +from octop.infra.agents.providers.codex_oauth import CODEX_ATTRIBUTION_VERSION +from octop.infra.agents.providers.probe import ( + _is_codex_base_url, + fetch_openai_compatible_models, +) + + +@pytest.fixture +def model_server( + tmp_path: Path, request: pytest.FixtureRequest +) -> Iterator[tuple[str, list[tuple[str, dict[str, str]]]]]: + (tmp_path / "models").write_text(json.dumps(request.param), encoding="utf-8") + requests: list[tuple[str, dict[str, str]]] = [] + + class Handler(SimpleHTTPRequestHandler): + def __init__(self, *args: object, **kwargs: object) -> None: + super().__init__(*args, directory=str(tmp_path), **kwargs) + + def do_GET(self) -> None: + requests.append((self.path, dict(self.headers))) + super().do_GET() + + with HTTPServer(("127.0.0.1", 0), Handler) as server: + thread = Thread(target=server.serve_forever) + thread.start() + try: + yield f"http://127.0.0.1:{server.server_port}", requests + finally: + server.shutdown() + thread.join() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_server", [{"models": []}], indirect=True) +async def test_codex_models_request_uses_attribution_version(model_server) -> None: + base_url, requests = model_server + await fetch_openai_compatible_models( + base_url=base_url, + api_key="test-access-token", + provider_name=CODEX_PROVIDER_NAME, + extra_headers={"chatgpt-account-id": "test-account", "version": CODEX_ATTRIBUTION_VERSION}, + ) + path, headers = requests[0] + assert urlsplit(path).path == "/models" + assert parse_qs(urlsplit(path).query) == {"client_version": [CODEX_ATTRIBUTION_VERSION]} + assert headers["Authorization"] == "Bearer test-access-token" + assert headers["chatgpt-account-id"] == "test-account" + assert headers["version"] == CODEX_ATTRIBUTION_VERSION + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "model_server", + [ + { + "models": [ + {"slug": " gpt-5.4 ", "display_name": " GPT-5.4 "}, + {"slug": "gpt-5.4", "display_name": "Duplicate"}, + {"slug": "gpt-5.3-codex", "display_name": "GPT-5.3 Codex"}, + {"slug": "gpt-5.4-mini"}, + {"slug": ""}, + {"slug": 123}, + {"id": "wrong-protocol"}, + None, + ] + } + ], + indirect=True, +) +async def test_codex_models_normalizes_slugs_and_display_names(model_server) -> None: + base_url, _ = model_server + result = await fetch_openai_compatible_models( + base_url=base_url, api_key="test-access-token", provider_name=CODEX_PROVIDER_NAME + ) + assert result == { + "ok": True, + "models": [ + {"id": "gpt-5.3-codex", "name": "GPT-5.3 Codex"}, + {"id": "gpt-5.4", "name": "GPT-5.4"}, + {"id": "gpt-5.4-mini", "name": "gpt-5.4-mini"}, + ], + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_server", [{"models": []}], indirect=True) +async def test_codex_models_accepts_empty_list(model_server) -> None: + base_url, _ = model_server + result = await fetch_openai_compatible_models( + base_url=base_url, api_key="test-access-token", provider_name=CODEX_PROVIDER_NAME + ) + assert result == {"ok": True, "models": []} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_server", [{"data": []}, {"models": {}}], indirect=True) +@pytest.mark.parametrize( + ("locale", "expected_error"), + [ + ("en", "Invalid Codex model list (expected models entries with slug identifiers)."), + ("zh", "Codex 模型列表格式无效(应包含 models 列表和 slug 标识)。"), + ], +) +async def test_codex_models_rejects_invalid_collection( + model_server, locale, expected_error +) -> None: + base_url, _ = model_server + result = await fetch_openai_compatible_models( + base_url=base_url, + api_key="test-access-token", + provider_name=CODEX_PROVIDER_NAME, + locale=locale, + ) + assert result == {"ok": False, "error": expected_error} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("model_server", [{"data": [{"id": "gpt-4o"}]}], indirect=True) +async def test_openai_models_keeps_original_protocol(model_server) -> None: + base_url, requests = model_server + result = await fetch_openai_compatible_models(base_url=base_url, api_key="test-api-key") + assert result == {"ok": True, "models": [{"id": "gpt-4o", "name": "gpt-4o"}]} + assert requests[0][0] == "/models" + + +@pytest.mark.parametrize( + ("base_url", "expected"), + [ + ("https://chatgpt.com/backend-api/codex", True), + ("https://chatgpt.com/backend-api/codex/", True), + ("https://example.com/chatgpt.com/backend-api/codex", False), + ("https://chatgpt.com/backend-api/codex-proxy", False), + ("https://chatgpt.com.example.com/backend-api/codex", False), + ], +) +def test_codex_base_url_requires_matching_host_and_path(base_url: str, expected: bool) -> None: + assert _is_codex_base_url(base_url) is expected From d106f72e1e606ff7303f95e2ca2daf8ff4e12610 Mon Sep 17 00:00:00 2001 From: ChengFa <166748424+indexGin@users.noreply.github.com> Date: Wed, 14 Oct 2026 09:54:37 +0800 Subject: [PATCH 2/2] fix(providers): reject invalid nonempty Codex model lists --- CHANGELOG.md | 2 +- src/octop/infra/agents/providers/probe.py | 2 ++ tests/unit/test_codex_fetch_models_http.py | 11 ++++++++++- 3 files changed, 13 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a199b6ff..01a3a532c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ 格式遵循 [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),版本号遵循 [语义化版本规范](https://semver.org/spec/v2.0.0.html)。 ## [Unreleased] -- Codex 提供商获取模型列表时复用已有版本配置传入 `client_version`,并解析 `models[].slug` 和显示名称,修复请求失败及列表为空的问题(#1992) +- Codex 提供商获取模型列表时复用已有版本配置传入 `client_version`,并解析 `models[].slug` 和显示名称,修复请求失败及列表为空的问题;非空列表没有有效标识时返回格式错误(#1992) - Telegram 通道可以限制允许的用户 ID(私聊和群聊都生效)。v1.0.2b7 的更新说明里已经写过这项,但合并时前端字段和 octop-gateway 1.0.1 依赖被丢掉了,这次补回 - 知识库文档上限设为 0(不限制)时,空库不再被当成已满,可以继续上传和新建文档 - 对话输入栏在专家配置了默认模型时显示该模型;与专家默认相同的选择不再写成会话粘性覆盖。专家的默认模型、知识库或连接器变更后,静默刷新会更新输入栏 diff --git a/src/octop/infra/agents/providers/probe.py b/src/octop/infra/agents/providers/probe.py index 3cabfebe5..6e6a0572d 100644 --- a/src/octop/infra/agents/providers/probe.py +++ b/src/octop/infra/agents/providers/probe.py @@ -349,5 +349,7 @@ async def fetch_openai_compatible_models( models.append( {"id": mid, "name": name.strip() if isinstance(name, str) and name.strip() else mid} ) + if is_codex and data and not models: + return {"ok": False, "error": tr("providers.codex_models_invalid_response", locale)} models.sort(key=lambda m: m["id"]) return {"ok": True, "models": models} diff --git a/tests/unit/test_codex_fetch_models_http.py b/tests/unit/test_codex_fetch_models_http.py index 677a9a743..6b7644d20 100644 --- a/tests/unit/test_codex_fetch_models_http.py +++ b/tests/unit/test_codex_fetch_models_http.py @@ -105,7 +105,16 @@ async def test_codex_models_accepts_empty_list(model_server) -> None: @pytest.mark.asyncio -@pytest.mark.parametrize("model_server", [{"data": []}, {"models": {}}], indirect=True) +@pytest.mark.parametrize( + "model_server", + [ + {"data": []}, + {"models": {}}, + {"models": [{"id": "gpt-5.4"}]}, + {"models": [{"slug": ""}, {"slug": None}, None, 123]}, + ], + indirect=True, +) @pytest.mark.parametrize( ("locale", "expected_error"), [