fix(llm): make gpt-5.x usable through ccproxy Codex OAuth (#324)
* fix(llm): make gpt-5.x usable through ccproxy Codex OAuth
Two independent blockers made current OpenAI models fail when routed
through ccproxy's Codex OAuth endpoint:
1. ccproxy's default Codex model mappings rewrite any gpt-*/o1-*/o3-*/
claude-* model to gpt-5.3-codex before forwarding, silently overriding
the configured model and failing outright on accounts where
gpt-5.3-codex is not served ("The 'gpt-5.3-codex' model is not
supported when using Codex with a ChatGPT account").
start_ccproxy() now generates a config with empty codex model
mappings and passes it via 'ccproxy serve --config'.
2. ccproxy forwards the client's own User-Agent upstream and only
gap-fills its Codex headers, so the backend gates current models on
the client identity ("The '<model>' model requires a newer version
of Codex"). get_chat_model() now sends Codex-CLI-shaped
originator/version/User-Agent headers when the ccproxy Codex adapter
is detected, overridable via EVOSCIENTIST_CODEX_CLIENT_VERSION.
Verified live: gpt-5.5 and gpt-5.4 complete successfully through
ccproxy Codex OAuth on a ChatGPT Plus account with both fixes; each
fails without them.
* fix(ccproxy): harden Codex client routing
* fix(llm): keep Codex client identity consistent
* docs: clarify Codex version floor
* style: ruff format models.py after merge
---------
Co-authored-by: Xi Zhang <106144707+X-iZhang@users.noreply.github.com>
Co-authored-by: X-iZhang <zacharyzhang2022@gmail.com>
This commit is contained in:
committed by
GitHub
parent
da6ca38d53
commit
2b28c46caf
@@ -183,6 +183,33 @@ def is_ccproxy_running(port: int) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def write_ccproxy_config() -> str:
|
||||
"""Write the ccproxy config file EvoScientist passes to ``serve --config``.
|
||||
|
||||
Disables ccproxy's default Codex model mappings, which rewrite any
|
||||
``gpt-*``/``o1-*``/``o3-*``/``claude-*`` model to ``gpt-5.3-codex``
|
||||
before forwarding — silently overriding the model the user configured
|
||||
(and failing outright on accounts where ``gpt-5.3-codex`` is not
|
||||
served). With no mappings, the requested model reaches the Codex
|
||||
backend unmodified.
|
||||
|
||||
Returns:
|
||||
Absolute path to the generated config file.
|
||||
"""
|
||||
from EvoScientist.config import get_config_dir
|
||||
|
||||
path = get_config_dir() / "ccproxy.toml"
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(
|
||||
"# Generated by EvoScientist (ccproxy_manager) — do not edit;\n"
|
||||
"# regenerated on every ccproxy start.\n"
|
||||
"[plugins.codex]\n"
|
||||
"model_mappings = []\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return str(path)
|
||||
|
||||
|
||||
def start_ccproxy(port: int) -> subprocess.Popen:
|
||||
"""Start ccproxy serve as a background process.
|
||||
|
||||
@@ -198,13 +225,22 @@ def start_ccproxy(port: int) -> subprocess.Popen:
|
||||
FileNotFoundError: If ccproxy binary is not found.
|
||||
"""
|
||||
exe = _ccproxy_exe() or "ccproxy"
|
||||
cmd = [exe, "serve", "--port", str(port)]
|
||||
try:
|
||||
cmd += ["--config", write_ccproxy_config()]
|
||||
except (OSError, UnicodeError) as exc:
|
||||
logger.warning(
|
||||
"Could not write ccproxy config (%s); starting with defaults — "
|
||||
"Codex model mappings will rewrite gpt-* models to gpt-5.3-codex",
|
||||
exc,
|
||||
)
|
||||
logger.warning(
|
||||
"Starting ccproxy on port %d; first startup may take up to %d seconds",
|
||||
port,
|
||||
_CCPROXY_HEALTH_TIMEOUT_SECONDS,
|
||||
)
|
||||
proc = subprocess.Popen(
|
||||
[exe, "serve", "--port", str(port)],
|
||||
cmd,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
|
||||
+66
-15
@@ -10,7 +10,10 @@ endpoints) and convenient short names for common models.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import warnings
|
||||
from functools import lru_cache
|
||||
from typing import Any
|
||||
|
||||
from langchain.chat_models import init_chat_model
|
||||
@@ -42,6 +45,44 @@ _DEEPSEEK_BASE_URL = "https://api.deepseek.com"
|
||||
_MOONSHOT_BASE_URL = "https://api.moonshot.cn/v1"
|
||||
_KIMI_CODING_BASE_URL = "https://api.kimi.com/coding/"
|
||||
|
||||
# Minimum Codex CLI version advertised when no explicit override is set. Newer
|
||||
# installed versions are advertised automatically.
|
||||
_CODEX_CLIENT_VERSION_FALLBACK = "0.144.1"
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _installed_codex_client_version() -> str:
|
||||
"""Return the installed Codex CLI version, or an empty string."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["codex", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
return ""
|
||||
|
||||
if result.returncode != 0:
|
||||
return ""
|
||||
match = re.search(r"\b(\d+\.\d+\.\d+)\b", result.stdout + result.stderr)
|
||||
return match.group(1) if match else ""
|
||||
|
||||
|
||||
def _resolve_codex_client_version() -> str:
|
||||
"""Resolve an explicit override or the newer of installed and minimum versions."""
|
||||
override = os.environ.get("EVOSCIENTIST_CODEX_CLIENT_VERSION", "").strip()
|
||||
if override:
|
||||
return override
|
||||
|
||||
installed = _installed_codex_client_version()
|
||||
if installed and tuple(map(int, installed.split("."))) >= tuple(
|
||||
map(int, _CODEX_CLIENT_VERSION_FALLBACK.split("."))
|
||||
):
|
||||
return installed
|
||||
return _CODEX_CLIENT_VERSION_FALLBACK
|
||||
|
||||
|
||||
def _resolve_reasoning_effort(default: str) -> str:
|
||||
"""Return the configured reasoning effort or a provider-specific default."""
|
||||
@@ -361,22 +402,18 @@ def _apply_auto_config(
|
||||
|
||||
# OpenAI (native, not third-party routed): reasoning
|
||||
if provider == "openai" and not is_third_party and "reasoning" not in kwargs:
|
||||
if _is_ccproxy_codex():
|
||||
# ccproxy uses Chat Completions which doesn't support reasoning.
|
||||
pass
|
||||
else:
|
||||
_default_effort = (
|
||||
"xhigh"
|
||||
if (
|
||||
"5.4" in model_id
|
||||
or "5.5" in model_id
|
||||
or "5.6" in model_id
|
||||
or "codex" in model_id
|
||||
)
|
||||
else "high"
|
||||
_default_effort = (
|
||||
"xhigh"
|
||||
if (
|
||||
"5.4" in model_id
|
||||
or "5.5" in model_id
|
||||
or "5.6" in model_id
|
||||
or "codex" in model_id
|
||||
)
|
||||
_eff = _resolve_reasoning_effort(_default_effort)
|
||||
kwargs["reasoning"] = {"effort": _eff, "summary": "auto"}
|
||||
else "high"
|
||||
)
|
||||
_eff = _resolve_reasoning_effort(_default_effort)
|
||||
kwargs["reasoning"] = {"effort": _eff, "summary": "auto"}
|
||||
|
||||
# Google GenAI: surface thinking traces
|
||||
if provider == "google-genai":
|
||||
@@ -473,6 +510,20 @@ def get_chat_model(
|
||||
# for Chat Completions tool_call duplication — not an issue
|
||||
# with the Responses API SSE format.)
|
||||
kwargs.pop("streaming", None) # remove if set elsewhere
|
||||
# ccproxy forwards client headers upstream and only
|
||||
# gap-fills its own, so the Codex backend sees this
|
||||
# client's identity. Without Codex-CLI-shaped headers it
|
||||
# rejects current models ("The '<model>' model requires
|
||||
# a newer version of Codex").
|
||||
_codex_ver = _resolve_codex_client_version()
|
||||
_headers = kwargs.get("default_headers") or {}
|
||||
kwargs["default_headers"] = _headers
|
||||
_headers.setdefault("originator", "codex_cli_rs")
|
||||
_headers.setdefault("version", _codex_ver)
|
||||
_headers.setdefault(
|
||||
"User-Agent",
|
||||
f"codex_cli_rs/{_headers['version']} (EvoScientist)",
|
||||
)
|
||||
api_key = os.environ.get("OPENAI_API_KEY", "")
|
||||
if api_key:
|
||||
kwargs["api_key"] = api_key
|
||||
|
||||
@@ -17,6 +17,7 @@ from EvoScientist.ccproxy_manager import (
|
||||
setup_codex_env,
|
||||
start_ccproxy,
|
||||
stop_ccproxy,
|
||||
write_ccproxy_config,
|
||||
)
|
||||
|
||||
# =============================================================================
|
||||
@@ -168,6 +169,54 @@ class TestStartCcproxy:
|
||||
with pytest.raises(FileNotFoundError):
|
||||
start_ccproxy(8000)
|
||||
|
||||
@patch("EvoScientist.ccproxy_manager.is_ccproxy_running")
|
||||
@patch("subprocess.Popen")
|
||||
def test_passes_generated_config(self, mock_popen, mock_running, tmp_path):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = None
|
||||
mock_popen.return_value = proc
|
||||
mock_running.side_effect = [True]
|
||||
|
||||
with patch("EvoScientist.config.get_config_dir", return_value=tmp_path):
|
||||
start_ccproxy(8000)
|
||||
|
||||
cmd = mock_popen.call_args[0][0]
|
||||
assert "--config" in cmd
|
||||
assert cmd[cmd.index("--config") + 1] == str(tmp_path / "ccproxy.toml")
|
||||
|
||||
@patch("EvoScientist.ccproxy_manager.is_ccproxy_running")
|
||||
@patch("EvoScientist.ccproxy_manager.write_ccproxy_config", side_effect=OSError)
|
||||
@patch("subprocess.Popen")
|
||||
def test_config_write_failure_starts_without_config(
|
||||
self, mock_popen, mock_write, mock_running
|
||||
):
|
||||
proc = MagicMock()
|
||||
proc.poll.return_value = None
|
||||
mock_popen.return_value = proc
|
||||
mock_running.side_effect = [True]
|
||||
|
||||
start_ccproxy(8000)
|
||||
|
||||
cmd = mock_popen.call_args[0][0]
|
||||
assert "--config" not in cmd
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# write_ccproxy_config
|
||||
# =============================================================================
|
||||
|
||||
|
||||
class TestWriteCcproxyConfig:
|
||||
def test_writes_codex_mapping_override(self, tmp_path):
|
||||
config_dir = tmp_path / "missing" / "config"
|
||||
with patch("EvoScientist.config.get_config_dir", return_value=config_dir):
|
||||
path = write_ccproxy_config()
|
||||
|
||||
assert path == str(config_dir / "ccproxy.toml")
|
||||
content = (config_dir / "ccproxy.toml").read_text(encoding="utf-8")
|
||||
assert "[plugins.codex]" in content
|
||||
assert "model_mappings = []" in content
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# ensure_ccproxy
|
||||
|
||||
+116
-2
@@ -2597,8 +2597,8 @@ class TestAutoConfig:
|
||||
assert call_kwargs["model_provider"] == "openai"
|
||||
assert call_kwargs["base_url"] == "http://127.0.0.1:8000/codex/v1"
|
||||
assert call_kwargs["api_key"] == "ccproxy-oauth"
|
||||
# Proxy mode: reasoning skipped (ccproxy untested)
|
||||
assert "reasoning" not in call_kwargs
|
||||
# ccproxy uses the Responses API, so reasoning configuration is valid.
|
||||
assert call_kwargs["reasoning"] == {"effort": "high", "summary": "auto"}
|
||||
# Proxy mode: Responses API (bypasses format chain), streaming ON
|
||||
assert call_kwargs["use_responses_api"] is True
|
||||
assert "streaming" not in call_kwargs
|
||||
@@ -2631,6 +2631,120 @@ class TestAutoConfig:
|
||||
call_kwargs = mock_init.call_args[1]
|
||||
assert call_kwargs["reasoning"] == {"effort": "high", "summary": "auto"}
|
||||
assert "use_responses_api" not in call_kwargs
|
||||
assert "default_headers" not in call_kwargs
|
||||
|
||||
@patch(
|
||||
"EvoScientist.llm.models._installed_codex_client_version",
|
||||
return_value="0.144.1",
|
||||
)
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_openai_ccproxy_codex_client_headers(
|
||||
self, mock_init, mock_installed_version, monkeypatch
|
||||
):
|
||||
"""ccproxy Codex mode sends Codex-CLI-shaped client headers."""
|
||||
mock_init.return_value = "mock_model"
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://127.0.0.1:8000/codex/v1")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "ccproxy-oauth")
|
||||
monkeypatch.delenv("EVOSCIENTIST_CODEX_CLIENT_VERSION", raising=False)
|
||||
|
||||
get_chat_model("gpt-5.5", provider="openai")
|
||||
|
||||
headers = mock_init.call_args[1]["default_headers"]
|
||||
assert headers["originator"] == "codex_cli_rs"
|
||||
assert headers["version"] == "0.144.1"
|
||||
assert headers["User-Agent"].startswith("codex_cli_rs/0.144.1")
|
||||
mock_installed_version.assert_called_once_with()
|
||||
assert mock_init.call_args[1]["reasoning"]["effort"] == "xhigh"
|
||||
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_openai_ccproxy_codex_client_version_env(self, mock_init, monkeypatch):
|
||||
"""EVOSCIENTIST_CODEX_CLIENT_VERSION overrides the pinned version."""
|
||||
mock_init.return_value = "mock_model"
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://127.0.0.1:8000/codex/v1")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "ccproxy-oauth")
|
||||
monkeypatch.setenv("EVOSCIENTIST_CODEX_CLIENT_VERSION", "9.9.9")
|
||||
|
||||
get_chat_model("gpt-5.5", provider="openai")
|
||||
|
||||
headers = mock_init.call_args[1]["default_headers"]
|
||||
assert headers["version"] == "9.9.9"
|
||||
assert headers["User-Agent"].startswith("codex_cli_rs/9.9.9")
|
||||
|
||||
@patch("EvoScientist.llm.models.subprocess.run")
|
||||
def test_installed_codex_client_version(self, mock_run):
|
||||
"""The advertised version follows the installed Codex CLI."""
|
||||
from EvoScientist.llm.models import _installed_codex_client_version
|
||||
|
||||
mock_run.return_value.returncode = 0
|
||||
mock_run.return_value.stdout = "codex-cli 0.144.1\n"
|
||||
mock_run.return_value.stderr = ""
|
||||
_installed_codex_client_version.cache_clear()
|
||||
try:
|
||||
assert _installed_codex_client_version() == "0.144.1"
|
||||
assert _installed_codex_client_version() == "0.144.1"
|
||||
finally:
|
||||
_installed_codex_client_version.cache_clear()
|
||||
mock_run.assert_called_once_with(
|
||||
["codex", "--version"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
check=False,
|
||||
)
|
||||
|
||||
@patch(
|
||||
"EvoScientist.llm.models._installed_codex_client_version",
|
||||
return_value="0.140.0",
|
||||
)
|
||||
def test_older_installed_codex_uses_fallback(
|
||||
self, mock_installed_version, monkeypatch
|
||||
):
|
||||
"""An outdated installed CLI must not undercut the safe fallback."""
|
||||
from EvoScientist.llm.models import (
|
||||
_CODEX_CLIENT_VERSION_FALLBACK,
|
||||
_resolve_codex_client_version,
|
||||
)
|
||||
|
||||
monkeypatch.delenv("EVOSCIENTIST_CODEX_CLIENT_VERSION", raising=False)
|
||||
|
||||
assert _resolve_codex_client_version() == _CODEX_CLIENT_VERSION_FALLBACK
|
||||
mock_installed_version.assert_called_once_with()
|
||||
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_openai_ccproxy_codex_headers_respect_caller(self, mock_init, monkeypatch):
|
||||
"""Caller-supplied default_headers keys are not overridden."""
|
||||
mock_init.return_value = "mock_model"
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://127.0.0.1:8000/codex/v1")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "ccproxy-oauth")
|
||||
|
||||
get_chat_model(
|
||||
"gpt-5.5",
|
||||
provider="openai",
|
||||
default_headers={"originator": "codex_vscode", "version": "9.9.9"},
|
||||
)
|
||||
|
||||
headers = mock_init.call_args[1]["default_headers"]
|
||||
assert headers["originator"] == "codex_vscode"
|
||||
assert headers["version"] == "9.9.9"
|
||||
assert headers["User-Agent"].startswith("codex_cli_rs/9.9.9")
|
||||
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_openai_ccproxy_codex_none_headers(self, mock_init, monkeypatch):
|
||||
"""An explicit default_headers=None is normalized before gap-filling."""
|
||||
mock_init.return_value = "mock_model"
|
||||
monkeypatch.setenv("OPENAI_BASE_URL", "http://127.0.0.1:8000/codex/v1")
|
||||
monkeypatch.setenv("OPENAI_API_KEY", "ccproxy-oauth")
|
||||
monkeypatch.setenv("EVOSCIENTIST_CODEX_CLIENT_VERSION", "9.9.9")
|
||||
|
||||
get_chat_model(
|
||||
"gpt-5.5",
|
||||
provider="openai",
|
||||
default_headers=None,
|
||||
)
|
||||
|
||||
headers = mock_init.call_args[1]["default_headers"]
|
||||
assert headers["originator"] == "codex_cli_rs"
|
||||
assert headers["version"] == "9.9.9"
|
||||
|
||||
@patch("EvoScientist.llm.models.init_chat_model")
|
||||
def test_openai_ccproxy_key_but_wrong_path_not_ccproxy(
|
||||
|
||||
Reference in New Issue
Block a user