diff --git a/CHANGELOG.md b/CHANGELOG.md index ed6e23968..f1066a41b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ## [Unreleased] ### 修复 +- Codex 提供商获取模型列表时复用已有版本配置传入 `client_version`,并解析 `models[].slug` 和显示名称,修复请求失败及列表为空的问题;非空列表没有有效标识时返回格式错误(#1992) - 云端协同对端专家的模型、连接器、知识库和对话模式会显示在本端输入框上方 - 侧栏 Beta 标记跟随主题色 - 管理员用量统计里,其他用户创建的专家显示名称而不是 id diff --git a/src/octop/i18n/en.json b/src/octop/i18n/en.json index c941c73f5..da5c7af4b 100644 --- a/src/octop/i18n/en.json +++ b/src/octop/i18n/en.json @@ -938,6 +938,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 75991e3e2..9e7141d52 100644 --- a/src/octop/i18n/zh.json +++ b/src/octop/i18n/zh.json @@ -938,6 +938,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..6e6a0572d 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,18 @@ 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} + ) + 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 new file mode 100644 index 000000000..6b7644d20 --- /dev/null +++ b/tests/unit/test_codex_fetch_models_http.py @@ -0,0 +1,158 @@ +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": {}}, + {"models": [{"id": "gpt-5.4"}]}, + {"models": [{"slug": ""}, {"slug": None}, None, 123]}, + ], + 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