diff --git a/EvoScientist/ccproxy_manager.py b/EvoScientist/ccproxy_manager.py
index 37bd9bf..2019051 100644
--- a/EvoScientist/ccproxy_manager.py
+++ b/EvoScientist/ccproxy_manager.py
@@ -35,24 +35,65 @@ def is_ccproxy_available() -> bool:
return shutil.which("ccproxy") is not None
-def check_ccproxy_auth() -> tuple[bool, str]:
+def _summarize_auth_output(raw: str) -> str:
+ """Extract key fields from ccproxy auth status output into a one-line summary.
+
+ Parses the Rich table output for Email, Subscription, and Status fields.
+ Returns e.g. ``"user@example.com (plus, active)"``.
+ Falls back to ``"Authenticated"`` if parsing fails.
+ """
+ import re as _re
+
+ # Strip ANSI escape sequences
+ clean = _re.sub(r"\x1b\[[0-9;]*m", "", raw)
+
+ # Parse "Key<2+ spaces>Value" table rows, match exact key names
+ fields: dict[str, str] = {}
+ for line in clean.splitlines():
+ m = _re.match(r"\s*(.+?)\s{2,}(.+)", line)
+ if not m:
+ continue
+ key, val = m.group(1).strip(), m.group(2).strip()
+ if key in ("Email", "Subscription", "Subscription Status"):
+ fields[key.lower().replace(" ", "_")] = val
+
+ email = fields.get("email", "")
+ sub = fields.get("subscription", "")
+ status = fields.get("subscription_status", "")
+
+ if email:
+ detail = ", ".join(filter(None, [sub, status]))
+ return f"{email} ({detail})" if detail else email
+ return "Authenticated"
+
+
+def check_ccproxy_auth(provider: str = "claude_api") -> tuple[bool, str]:
"""Check if ccproxy has valid OAuth credentials.
+ Args:
+ provider: ccproxy provider name ("claude_api" or "codex").
+
Returns:
(is_valid, message) tuple.
"""
try:
result = subprocess.run(
- ["ccproxy", "auth", "status", "claude_api"],
+ ["ccproxy", "auth", "status", provider],
capture_output=True,
text=True,
timeout=10,
)
- output = (result.stdout + result.stderr).strip()
# ccproxy auth status exits 0 when authed
if result.returncode == 0:
- return True, output or "Authenticated"
- return False, output or "Not authenticated"
+ summary = _summarize_auth_output(result.stdout)
+ return True, summary or "Authenticated"
+ # On failure, include stderr for diagnostics
+ raw = (result.stdout + result.stderr).strip()
+ # Strip ANSI escapes for cleaner error messages
+ import re as _re
+
+ clean = _re.sub(r"\x1b\[[0-9;]*m", "", raw)
+ return False, clean or "Not authenticated"
except FileNotFoundError:
return False, "ccproxy not found"
except subprocess.TimeoutExpired:
@@ -151,7 +192,7 @@ def ensure_ccproxy(port: int = _DEFAULT_PORT) -> subprocess.Popen | None:
def setup_ccproxy_env(port: int = _DEFAULT_PORT) -> None:
- """Set environment variables for ccproxy routing.
+ """Set environment variables for Anthropic ccproxy routing.
Force-sets ``ANTHROPIC_BASE_URL`` and ``ANTHROPIC_API_KEY`` so that
downstream LangChain/Anthropic clients route through ccproxy.
@@ -163,6 +204,20 @@ def setup_ccproxy_env(port: int = _DEFAULT_PORT) -> None:
os.environ["ANTHROPIC_API_KEY"] = "ccproxy-oauth"
+def setup_codex_env(port: int = _DEFAULT_PORT) -> None:
+ """Set environment variables for OpenAI/Codex ccproxy routing.
+
+ Force-sets ``OPENAI_BASE_URL`` and ``OPENAI_API_KEY`` so that
+ downstream LangChain/OpenAI clients route through ccproxy's Codex
+ endpoint.
+
+ Always overrides existing values — when this function is called,
+ we've decided to use ccproxy, so env must point to it.
+ """
+ os.environ["OPENAI_BASE_URL"] = f"http://127.0.0.1:{port}/codex/v1"
+ os.environ["OPENAI_API_KEY"] = "ccproxy-oauth"
+
+
# =============================================================================
# High-level orchestration
# =============================================================================
@@ -171,9 +226,13 @@ def setup_ccproxy_env(port: int = _DEFAULT_PORT) -> None:
def maybe_start_ccproxy(config: object) -> subprocess.Popen | None:
"""High-level: conditionally start ccproxy based on config.
- Checks ``config.anthropic_auth_mode``:
+ Checks ``config.anthropic_auth_mode`` and ``config.openai_auth_mode``:
- ``oauth``: ccproxy must work — raises on failure.
- - ``api_key``: no-op.
+ - ``api_key``: no-op for that provider.
+
+ When either provider uses OAuth, ccproxy is started (single process
+ serves both providers). Environment variables are set for each
+ provider that uses OAuth.
Args:
config: An ``EvoScientistConfig`` instance.
@@ -181,8 +240,10 @@ def maybe_start_ccproxy(config: object) -> subprocess.Popen | None:
Returns:
Popen handle if we started ccproxy, None otherwise.
"""
- auth_mode = getattr(config, "anthropic_auth_mode", "api_key")
- if auth_mode != "oauth":
+ anthropic_oauth = getattr(config, "anthropic_auth_mode", "api_key") == "oauth"
+ openai_oauth = getattr(config, "openai_auth_mode", "api_key") == "oauth"
+
+ if not anthropic_oauth and not openai_oauth:
return None
if not is_ccproxy_available():
@@ -191,15 +252,32 @@ def maybe_start_ccproxy(config: object) -> subprocess.Popen | None:
"Install it with: pip install 'evoscientist[oauth]'"
)
- authed, msg = check_ccproxy_auth()
- if not authed:
- raise RuntimeError(
- f"ccproxy OAuth not authenticated: {msg}\n"
- "Run: ccproxy auth login claude_api"
- )
+ # Check auth for each provider that uses OAuth
+ if anthropic_oauth:
+ authed, msg = check_ccproxy_auth("claude_api")
+ if not authed:
+ raise RuntimeError(
+ f"ccproxy Anthropic OAuth not authenticated: {msg}\n"
+ "Run: ccproxy auth login claude_api"
+ )
+ if openai_oauth:
+ authed, msg = check_ccproxy_auth("codex")
+ if not authed:
+ raise RuntimeError(
+ f"ccproxy Codex OAuth not authenticated: {msg}\n"
+ "Run: ccproxy auth login codex"
+ )
+
+ # Start ccproxy (single process serves both providers)
proc = ensure_ccproxy()
- setup_ccproxy_env()
+
+ # Set environment for each OAuth provider
+ if anthropic_oauth:
+ setup_ccproxy_env()
+ if openai_oauth:
+ setup_codex_env()
+
if proc:
logger.info("Started ccproxy on port %d", _DEFAULT_PORT)
else:
diff --git a/EvoScientist/cli/commands.py b/EvoScientist/cli/commands.py
index 992a306..56d3cac 100644
--- a/EvoScientist/cli/commands.py
+++ b/EvoScientist/cli/commands.py
@@ -512,9 +512,9 @@ def serve(
config = get_effective_config(cli_overrides)
apply_config_to_env(config)
- # Auto-start ccproxy if OAuth mode
+ # Auto-start ccproxy if any provider uses OAuth mode
_ccproxy_proc_serve = None
- if config.provider == "anthropic" and config.anthropic_auth_mode == "oauth":
+ if config.anthropic_auth_mode == "oauth" or config.openai_auth_mode == "oauth":
try:
from ..ccproxy_manager import maybe_start_ccproxy, stop_ccproxy
@@ -917,7 +917,7 @@ def _main_callback(
auth_mode: Optional[str] = typer.Option(
None,
"--auth-mode",
- help="Anthropic auth mode: api_key (default) or oauth (ccproxy).",
+ help="Auth mode for Anthropic/OpenAI: api_key (default) or oauth (ccproxy).",
),
ui: Optional[str] = typer.Option(
None,
@@ -956,13 +956,14 @@ def _main_callback(
if auth_mode not in ("api_key", "oauth"):
raise typer.BadParameter("--auth-mode must be 'api_key' or 'oauth'")
cli_overrides["anthropic_auth_mode"] = auth_mode
+ cli_overrides["openai_auth_mode"] = auth_mode
config = get_effective_config(cli_overrides)
apply_config_to_env(config)
- # Auto-start ccproxy if OAuth mode
+ # Auto-start ccproxy if any provider uses OAuth mode
_ccproxy_proc = None
- if config.provider == "anthropic" and config.anthropic_auth_mode == "oauth":
+ if config.anthropic_auth_mode == "oauth" or config.openai_auth_mode == "oauth":
try:
from ..ccproxy_manager import maybe_start_ccproxy, stop_ccproxy
diff --git a/EvoScientist/config/onboard.py b/EvoScientist/config/onboard.py
index 709680f..af59e2b 100644
--- a/EvoScientist/config/onboard.py
+++ b/EvoScientist/config/onboard.py
@@ -755,6 +755,80 @@ def _step_anthropic_auth_mode(config: EvoScientistConfig) -> str:
return auth_mode
+def _step_openai_auth_mode(config: EvoScientistConfig) -> str:
+ """Step 2b: Select OpenAI authentication mode (API key vs Codex OAuth).
+
+ Args:
+ config: Current configuration.
+
+ Returns:
+ Selected auth mode: "api_key" or "oauth".
+ """
+ from ..ccproxy_manager import is_ccproxy_available, check_ccproxy_auth
+
+ if not is_ccproxy_available():
+ console.print(
+ " [dim]OAuth via ccproxy not available. "
+ 'Install with: pip install "evoscientist[oauth]"[/dim]'
+ )
+ return "api_key"
+
+ choices = [
+ Choice(title="API Key (direct OpenAI access)", value="api_key"),
+ Choice(
+ title="Codex OAuth (via ccproxy — no API key needed)", value="oauth"
+ ),
+ ]
+
+ current = config.openai_auth_mode
+ if current not in ("api_key", "oauth"):
+ current = "api_key"
+
+ auth_mode = questionary.select(
+ "OpenAI authentication mode:",
+ choices=choices,
+ default=current,
+ style=WIZARD_STYLE,
+ qmark=QMARK,
+ use_indicator=True,
+ ).ask()
+
+ if auth_mode is None:
+ raise KeyboardInterrupt()
+
+ # If OAuth selected, check auth status and offer login
+ if auth_mode == "oauth":
+ authed, msg = check_ccproxy_auth("codex")
+ if authed:
+ console.print(f" [green]✓ Codex OAuth: {msg}[/green]")
+ else:
+ console.print(f" [yellow]Codex OAuth not authenticated: {msg}[/yellow]")
+ login = questionary.confirm(
+ "Log in to Codex now?",
+ default=True,
+ style=CONFIRM_STYLE,
+ qmark=QMARK,
+ ).ask()
+ if login:
+ console.print(" [dim]Opening browser for authentication...[/dim]")
+ try:
+ subprocess.run(
+ ["ccproxy", "auth", "login", "codex"],
+ timeout=120,
+ )
+ authed, msg = check_ccproxy_auth("codex")
+ if authed:
+ console.print(f" [green]✓ Codex OAuth: {msg}[/green]")
+ else:
+ console.print(f" [red]Authentication failed: {msg}[/red]")
+ except subprocess.TimeoutExpired:
+ console.print(" [red]Login timed out.[/red]")
+ except Exception as exc:
+ console.print(f" [red]Login error: {exc}[/red]")
+
+ return auth_mode
+
+
def _step_provider_api_key(
config: EvoScientistConfig,
provider: str,
@@ -2194,13 +2268,16 @@ def run_onboard(skip_validation: bool = False) -> bool:
ollama_url, ollama_detected_models = _step_ollama_base_url(config)
config.ollama_base_url = ollama_url
- # Step 2b: Auth mode (Anthropic only — API key vs OAuth)
+ # Step 2b: Auth mode (Anthropic or OpenAI — API key vs OAuth)
if provider == "anthropic":
auth_mode = _step_anthropic_auth_mode(config)
config.anthropic_auth_mode = auth_mode
+ elif provider == "openai":
+ auth_mode = _step_openai_auth_mode(config)
+ config.openai_auth_mode = auth_mode
# Step 2c: Provider API Key (skip for Ollama — no key needed,
- # and for Anthropic pure OAuth — key provided by ccproxy)
+ # and for Anthropic/OpenAI pure OAuth — key provided by ccproxy)
_PROVIDER_KEY_ATTR = {
"anthropic": "anthropic_api_key",
"nvidia": "nvidia_api_key",
@@ -2214,6 +2291,8 @@ def run_onboard(skip_validation: bool = False) -> bool:
}
_skip_api_key = provider == "ollama" or (
provider == "anthropic" and config.anthropic_auth_mode == "oauth"
+ ) or (
+ provider == "openai" and config.openai_auth_mode == "oauth"
)
if not _skip_api_key:
new_key = _step_provider_api_key(config, provider, skip_validation)
diff --git a/EvoScientist/config/settings.py b/EvoScientist/config/settings.py
index d77148f..25d970b 100644
--- a/EvoScientist/config/settings.py
+++ b/EvoScientist/config/settings.py
@@ -63,6 +63,7 @@ class EvoScientistConfig:
anthropic_base_url: str = ""
anthropic_auth_mode: str = "api_key" # "api_key" | "oauth"
openai_api_key: str = ""
+ openai_auth_mode: str = "api_key" # "api_key" | "oauth"
nvidia_api_key: str = ""
google_api_key: str = ""
siliconflow_api_key: str = ""
@@ -336,6 +337,7 @@ _ENV_MAPPINGS = {
"anthropic_base_url": "ANTHROPIC_BASE_URL",
"anthropic_auth_mode": "EVOSCIENTIST_ANTHROPIC_AUTH_MODE",
"openai_api_key": "OPENAI_API_KEY",
+ "openai_auth_mode": "EVOSCIENTIST_OPENAI_AUTH_MODE",
"nvidia_api_key": "NVIDIA_API_KEY",
"google_api_key": "GOOGLE_API_KEY",
"siliconflow_api_key": "SILICONFLOW_API_KEY",
diff --git a/EvoScientist/llm/models.py b/EvoScientist/llm/models.py
index abd7f26..65050ca 100644
--- a/EvoScientist/llm/models.py
+++ b/EvoScientist/llm/models.py
@@ -9,6 +9,7 @@ convenient short names for common models.
from __future__ import annotations
import os
+import re
from typing import Any
from langchain.chat_models import init_chat_model
@@ -50,6 +51,18 @@ def _patch_anthropic_proxy_compat() -> None:
_patch_anthropic_proxy_compat()
+# ---------------------------------------------------------------------------
+# Patch: ccproxy Codex embeds thinking as ... tags
+# inside the content string. Strip these so they don't appear in output.
+# ---------------------------------------------------------------------------
+_THINKING_TAG_RE = re.compile(r".*?\s*", re.DOTALL)
+
+
+def strip_thinking_tags(content: str) -> str:
+ """Remove ``...`` tags from ccproxy response content."""
+ return _THINKING_TAG_RE.sub("", content)
+
+
_SILICONFLOW_BASE_URL = "https://api.siliconflow.cn/v1"
_OPENROUTER_BASE_URL = "https://openrouter.ai/api/v1"
_ZHIPU_BASE_URL = "https://open.bigmodel.cn/api/paas/v4"
@@ -188,7 +201,15 @@ def _apply_auto_config(
# OpenAI (native, not third-party routed): reasoning
if provider == "openai" and not is_third_party and "reasoning" not in kwargs:
- kwargs["reasoning"] = {"effort": "high", "summary": "auto"}
+ base_url = os.environ.get("OPENAI_BASE_URL", "")
+ _is_openai_proxy = "127.0.0.1" in base_url or "localhost" in base_url
+ if _is_openai_proxy:
+ # ccproxy forces store=False. Setting `reasoning` triggers
+ # langchain-openai's Responses API path, which produces
+ # rs_ summary items that 404 on multi-turn. Skip entirely.
+ pass
+ else:
+ kwargs["reasoning"] = {"effort": "high", "summary": "auto"}
# Google GenAI: surface thinking traces
if provider == "google-genai":
@@ -255,6 +276,7 @@ def get_chat_model(
# Anthropic base_url override (e.g. ccproxy at localhost:8000/api/v1)
_is_third_party = provider in _THIRD_PARTY_PROVIDERS
+ _is_openai_proxy = False
if provider == "anthropic":
base_url = os.environ.get("ANTHROPIC_BASE_URL", "")
if base_url:
@@ -263,6 +285,19 @@ def get_chat_model(
if api_key:
kwargs["api_key"] = api_key
+ # Native OpenAI base_url override (e.g. ccproxy Codex at localhost:8000/codex/v1)
+ elif provider == "openai":
+ base_url = os.environ.get("OPENAI_BASE_URL", "")
+ if base_url:
+ kwargs["base_url"] = base_url
+ _is_openai_proxy = "127.0.0.1" in base_url or "localhost" in base_url
+ if _is_openai_proxy:
+ kwargs.setdefault("streaming", False) # ccproxy streaming incompatible
+ kwargs.setdefault("use_responses_api", False) # force Chat Completions
+ api_key = os.environ.get("OPENAI_API_KEY", "")
+ if api_key:
+ kwargs["api_key"] = api_key
+
# Third-party providers → route through OpenAI provider with base_url
elif provider in _THIRD_PARTY_PROVIDERS:
base_url_default, api_key_env = _THIRD_PARTY_PROVIDERS[provider]
@@ -308,7 +343,9 @@ def get_chat_model(
_apply_auto_config(provider, model_id, _is_third_party, kwargs)
- return init_chat_model(model=model_id, model_provider=provider, **kwargs)
+ chat_model = init_chat_model(model=model_id, model_provider=provider, **kwargs)
+
+ return chat_model
def list_models() -> list[str]:
diff --git a/EvoScientist/stream/events.py b/EvoScientist/stream/events.py
index adf0fd6..de43cbb 100644
--- a/EvoScientist/stream/events.py
+++ b/EvoScientist/stream/events.py
@@ -591,7 +591,12 @@ def _process_chunk_content(
if isinstance(content, str):
if content:
- yield emitter.text(content)
+ # Strip ccproxy ... tags from content
+ from ..llm.models import strip_thinking_tags
+
+ cleaned = strip_thinking_tags(content)
+ if cleaned:
+ yield emitter.text(cleaned)
return
blocks = None
diff --git a/tests/test_ccproxy_manager.py b/tests/test_ccproxy_manager.py
index 3257a82..b10c788 100644
--- a/tests/test_ccproxy_manager.py
+++ b/tests/test_ccproxy_manager.py
@@ -13,6 +13,7 @@ from EvoScientist.ccproxy_manager import (
stop_ccproxy,
ensure_ccproxy,
setup_ccproxy_env,
+ setup_codex_env,
maybe_start_ccproxy,
)
@@ -47,6 +48,17 @@ class TestCheckCcproxyAuth:
valid, msg = check_ccproxy_auth()
assert valid is True
assert "Authenticated" in msg
+ mock_run.assert_called_once()
+ assert mock_run.call_args[0][0] == ["ccproxy", "auth", "status", "claude_api"]
+
+ @patch("subprocess.run")
+ def test_valid_auth_codex(self, mock_run):
+ mock_run.return_value = MagicMock(
+ returncode=0, stdout="Authenticated", stderr=""
+ )
+ valid, msg = check_ccproxy_auth("codex")
+ assert valid is True
+ assert mock_run.call_args[0][0] == ["ccproxy", "auth", "status", "codex"]
@patch("subprocess.run")
def test_invalid_auth(self, mock_run):
@@ -168,6 +180,32 @@ class TestSetupCcproxyEnv:
assert os.environ["ANTHROPIC_API_KEY"] == "ccproxy-oauth"
+# =============================================================================
+# setup_codex_env
+# =============================================================================
+
+
+class TestSetupCodexEnv:
+ def test_sets_vars(self, monkeypatch):
+ monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
+ monkeypatch.delenv("OPENAI_API_KEY", raising=False)
+
+ setup_codex_env(8000)
+
+ assert os.environ["OPENAI_BASE_URL"] == "http://127.0.0.1:8000/codex/v1"
+ assert os.environ["OPENAI_API_KEY"] == "ccproxy-oauth"
+
+ def test_overrides_existing(self, monkeypatch):
+ """Force-sets vars even if already configured (oauth takes priority)."""
+ monkeypatch.setenv("OPENAI_BASE_URL", "http://custom:9999")
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-real-key")
+
+ setup_codex_env(8000)
+
+ assert os.environ["OPENAI_BASE_URL"] == "http://127.0.0.1:8000/codex/v1"
+ assert os.environ["OPENAI_API_KEY"] == "ccproxy-oauth"
+
+
# =============================================================================
# stop_ccproxy
# =============================================================================
@@ -193,6 +231,7 @@ class TestMaybeStartCcproxy:
def test_api_key_mode_noop(self):
config = MagicMock()
config.anthropic_auth_mode = "api_key"
+ config.openai_auth_mode = "api_key"
assert maybe_start_ccproxy(config) is None
@patch("EvoScientist.ccproxy_manager.setup_ccproxy_env")
@@ -204,6 +243,7 @@ class TestMaybeStartCcproxy:
mock_ensure.return_value = proc
config = MagicMock()
config.anthropic_auth_mode = "oauth"
+ config.openai_auth_mode = "api_key"
result = maybe_start_ccproxy(config)
assert result is proc
@@ -213,6 +253,7 @@ class TestMaybeStartCcproxy:
def test_oauth_mode_raises_no_binary(self, mock_avail):
config = MagicMock()
config.anthropic_auth_mode = "oauth"
+ config.openai_auth_mode = "api_key"
with pytest.raises(RuntimeError, match="not found"):
maybe_start_ccproxy(config)
@@ -224,5 +265,61 @@ class TestMaybeStartCcproxy:
def test_oauth_mode_raises_no_auth(self, mock_avail, mock_auth):
config = MagicMock()
config.anthropic_auth_mode = "oauth"
+ config.openai_auth_mode = "api_key"
with pytest.raises(RuntimeError, match="not authenticated"):
maybe_start_ccproxy(config)
+
+ @patch("EvoScientist.ccproxy_manager.setup_codex_env")
+ @patch("EvoScientist.ccproxy_manager.ensure_ccproxy")
+ @patch("EvoScientist.ccproxy_manager.check_ccproxy_auth", return_value=(True, "OK"))
+ @patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True)
+ def test_openai_oauth_mode_starts(self, mock_avail, mock_auth, mock_ensure, mock_env):
+ proc = MagicMock()
+ mock_ensure.return_value = proc
+ config = MagicMock()
+ config.anthropic_auth_mode = "api_key"
+ config.openai_auth_mode = "oauth"
+
+ result = maybe_start_ccproxy(config)
+ assert result is proc
+ mock_auth.assert_called_once_with("codex")
+ mock_env.assert_called_once()
+
+ @patch("EvoScientist.ccproxy_manager.setup_codex_env")
+ @patch("EvoScientist.ccproxy_manager.setup_ccproxy_env")
+ @patch("EvoScientist.ccproxy_manager.ensure_ccproxy")
+ @patch("EvoScientist.ccproxy_manager.check_ccproxy_auth", return_value=(True, "OK"))
+ @patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True)
+ def test_both_oauth_starts_both(self, mock_avail, mock_auth, mock_ensure, mock_anthropic_env, mock_codex_env):
+ proc = MagicMock()
+ mock_ensure.return_value = proc
+ config = MagicMock()
+ config.anthropic_auth_mode = "oauth"
+ config.openai_auth_mode = "oauth"
+
+ result = maybe_start_ccproxy(config)
+ assert result is proc
+ # Auth checked for both providers
+ assert mock_auth.call_count == 2
+ mock_anthropic_env.assert_called_once()
+ mock_codex_env.assert_called_once()
+
+ @patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=False)
+ def test_openai_oauth_raises_no_binary(self, mock_avail):
+ config = MagicMock()
+ config.anthropic_auth_mode = "api_key"
+ config.openai_auth_mode = "oauth"
+ with pytest.raises(RuntimeError, match="not found"):
+ maybe_start_ccproxy(config)
+
+ @patch(
+ "EvoScientist.ccproxy_manager.check_ccproxy_auth",
+ return_value=(False, "expired"),
+ )
+ @patch("EvoScientist.ccproxy_manager.is_ccproxy_available", return_value=True)
+ def test_openai_oauth_raises_no_auth(self, mock_avail, mock_auth):
+ config = MagicMock()
+ config.anthropic_auth_mode = "api_key"
+ config.openai_auth_mode = "oauth"
+ with pytest.raises(RuntimeError, match="Codex OAuth not authenticated"):
+ maybe_start_ccproxy(config)
diff --git a/tests/test_cli_serve.py b/tests/test_cli_serve.py
index 77ad63e..6a84b9a 100644
--- a/tests/test_cli_serve.py
+++ b/tests/test_cli_serve.py
@@ -18,6 +18,7 @@ def _make_config(
channel_send_thinking=channel_send_thinking,
provider="anthropic",
anthropic_auth_mode="api_key",
+ openai_auth_mode="api_key",
)
diff --git a/tests/test_config.py b/tests/test_config.py
index f5155fe..3439aaa 100644
--- a/tests/test_config.py
+++ b/tests/test_config.py
@@ -91,6 +91,16 @@ class TestEvoScientistConfig:
config = EvoScientistConfig(anthropic_auth_mode="oauth")
assert config.anthropic_auth_mode == "oauth"
+ def test_openai_auth_mode_default(self):
+ """Test that openai_auth_mode defaults to api_key."""
+ config = EvoScientistConfig()
+ assert config.openai_auth_mode == "api_key"
+
+ def test_openai_auth_mode_set(self):
+ """Test that openai_auth_mode can be set."""
+ config = EvoScientistConfig(openai_auth_mode="oauth")
+ assert config.openai_auth_mode == "oauth"
+
def test_custom_values(self):
"""Test that custom values can be set."""
config = EvoScientistConfig(
@@ -349,6 +359,14 @@ class TestPriorityChain:
config = get_effective_config()
assert config.anthropic_auth_mode == "oauth"
+ def test_env_openai_auth_mode_override(self, temp_config_dir, monkeypatch):
+ """Test openai_auth_mode from env overrides file."""
+ save_config(EvoScientistConfig(openai_auth_mode="api_key"))
+ monkeypatch.setenv("EVOSCIENTIST_OPENAI_AUTH_MODE", "oauth")
+
+ config = get_effective_config()
+ assert config.openai_auth_mode == "oauth"
+
# =============================================================================
# Test apply_config_to_env
diff --git a/tests/test_llm.py b/tests/test_llm.py
index 639227e..8370b97 100644
--- a/tests/test_llm.py
+++ b/tests/test_llm.py
@@ -525,15 +525,45 @@ class TestAutoConfig:
assert call_kwargs["thinking"] == custom_thinking
@patch("EvoScientist.llm.models.init_chat_model")
- def test_openai_reasoning(self, mock_init):
+ def test_openai_reasoning(self, mock_init, monkeypatch):
"""Native OpenAI models get auto-reasoning."""
mock_init.return_value = "mock_model"
+ monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
get_chat_model("gpt-5-nano")
call_kwargs = mock_init.call_args[1]
assert call_kwargs["reasoning"] == {"effort": "high", "summary": "auto"}
+ @patch("EvoScientist.llm.models.init_chat_model")
+ def test_openai_base_url_override(self, mock_init, monkeypatch):
+ """OpenAI provider should support base_url override (e.g. ccproxy Codex)."""
+ 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-nano", provider="openai")
+
+ call_kwargs = mock_init.call_args[1]
+ 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 (triggers Responses API → rs_ 404)
+ assert "reasoning" not in call_kwargs
+
+ @patch("EvoScientist.llm.models.init_chat_model")
+ def test_openai_no_base_url_when_unset(self, mock_init, monkeypatch):
+ """OpenAI provider should not set base_url when env var is empty."""
+ mock_init.return_value = "mock_model"
+ monkeypatch.delenv("OPENAI_BASE_URL", raising=False)
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-real")
+
+ get_chat_model("gpt-5-nano", provider="openai")
+
+ call_kwargs = mock_init.call_args[1]
+ assert call_kwargs["model_provider"] == "openai"
+ assert "base_url" not in call_kwargs
+
@patch("EvoScientist.llm.models.init_chat_model")
def test_google_thoughts(self, mock_init):
"""Google GenAI models get include_thoughts=True by default."""