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:
Mani Saint-Victor
2026-07-13 07:04:47 -04:00
committed by GitHub
parent da6ca38d53
commit 2b28c46caf
4 changed files with 268 additions and 18 deletions
+37 -1
View File
@@ -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
View File
@@ -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
+49
View File
@@ -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
View File
@@ -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(