From 62fe970476a582a3f283aa9e6b08c932a823fbfd Mon Sep 17 00:00:00 2001 From: X-iZhang Date: Mon, 16 Mar 2026 00:25:19 +0000 Subject: [PATCH] feat: add OpenAI support with OAuth and environment configuration --- EvoScientist/ccproxy_manager.py | 112 +++++++++++++++++++++++++++----- EvoScientist/cli/commands.py | 11 ++-- EvoScientist/config/onboard.py | 83 ++++++++++++++++++++++- EvoScientist/config/settings.py | 2 + EvoScientist/llm/models.py | 41 +++++++++++- EvoScientist/stream/events.py | 7 +- tests/test_ccproxy_manager.py | 97 +++++++++++++++++++++++++++ tests/test_cli_serve.py | 1 + tests/test_config.py | 18 +++++ tests/test_llm.py | 32 ++++++++- 10 files changed, 376 insertions(+), 28 deletions(-) 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."""