Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
## [Unreleased]

### 修复
- Codex 提供商获取模型列表时复用已有版本配置传入 `client_version`,并解析 `models[].slug` 和显示名称,修复请求失败及列表为空的问题;非空列表没有有效标识时返回格式错误(#1992)
- 云端协同对端专家的模型、连接器、知识库和对话模式会显示在本端输入框上方
- 侧栏 Beta 标记跟随主题色
- 管理员用量统计里,其他用户创建的专家显示名称而不是 id
Expand Down
3 changes: 3 additions & 0 deletions src/octop/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
3 changes: 3 additions & 0 deletions src/octop/i18n/zh.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 目录服务",
Expand Down
24 changes: 19 additions & 5 deletions src/octop/infra/agents/providers/probe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)",
Expand All @@ -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, …}]})",
Expand All @@ -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}
158 changes: 158 additions & 0 deletions tests/unit/test_codex_fetch_models_http.py
Original file line number Diff line number Diff line change
@@ -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