feat: add OpenAI support with OAuth and environment configuration
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 <thinking>...</thinking> tags
|
||||
# inside the content string. Strip these so they don't appear in output.
|
||||
# ---------------------------------------------------------------------------
|
||||
_THINKING_TAG_RE = re.compile(r"<thinking>.*?</thinking>\s*", re.DOTALL)
|
||||
|
||||
|
||||
def strip_thinking_tags(content: str) -> str:
|
||||
"""Remove ``<thinking>...</thinking>`` 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]:
|
||||
|
||||
@@ -591,7 +591,12 @@ def _process_chunk_content(
|
||||
|
||||
if isinstance(content, str):
|
||||
if content:
|
||||
yield emitter.text(content)
|
||||
# Strip ccproxy <thinking>...</thinking> tags from content
|
||||
from ..llm.models import strip_thinking_tags
|
||||
|
||||
cleaned = strip_thinking_tags(content)
|
||||
if cleaned:
|
||||
yield emitter.text(cleaned)
|
||||
return
|
||||
|
||||
blocks = None
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+31
-1
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user