From 2b28c46caffc588e568686a429767dbe1027f392 Mon Sep 17 00:00:00 2001 From: Mani Saint-Victor Date: Mon, 13 Jul 2026 07:04:47 -0400 Subject: [PATCH] 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 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 --- EvoScientist/ccproxy_manager.py | 38 +++++++++- EvoScientist/llm/models.py | 81 ++++++++++++++++++---- tests/test_ccproxy_manager.py | 49 +++++++++++++ tests/test_llm.py | 118 +++++++++++++++++++++++++++++++- 4 files changed, 268 insertions(+), 18 deletions(-) diff --git a/EvoScientist/ccproxy_manager.py b/EvoScientist/ccproxy_manager.py index d9cbd41..865d407 100644 --- a/EvoScientist/ccproxy_manager.py +++ b/EvoScientist/ccproxy_manager.py @@ -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, ) diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py index a286ca6..d21fd8b 100644 --- a/EvoScientist/llm/models.py +++ b/EvoScientist/llm/models.py @@ -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 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 diff --git a/tests/test_ccproxy_manager.py b/tests/test_ccproxy_manager.py index 5804f51..7b9547e 100644 --- a/tests/test_ccproxy_manager.py +++ b/tests/test_ccproxy_manager.py @@ -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 diff --git a/tests/test_llm.py b/tests/test_llm.py index 4011af5..5809748 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -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(