From 0ced629003d9bd32bceca8f0874c96201b215cf1 Mon Sep 17 00:00:00 2001 From: LeonWTW Date: Sat, 1 Aug 2026 09:50:19 -0700 Subject: [PATCH] feat(llm): add openai_codex provider using ChatGPT subscription quota MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an `openai_codex` provider so a run can be billed to an existing ChatGPT subscription instead of API credits. It is the only provider here that does not authenticate from an env var. It reuses the OAuth token the official Codex app/CLI stores in `~/.codex/auth.json` (overridable with `TRADINGAGENTS_CODEX_AUTH_PATH`) via `ProviderSpec.credentials_fn`, refreshes it when it nears expiry, and writes the rotated refresh token back — not persisting it would log the Codex client itself out. The endpoint also rejects three things the shared OpenAI client sends by default, so `CodexChatOpenAI` overrides them: - `stream: false` and `store: true` are pinned as constructor kwargs; a class-level field default sets the payload but not langchain's streaming dispatch, so the non-streaming path would still be taken. - `temperature` is filtered out in `get_llm`. - Input items with role `system` return 400. Every agent prompt in this repo is a `ChatPromptTemplate` whose first message is a system message, so the first analyst node failed on its first call and the run stalled. `_get_request_payload` relabels those items `developer`, the Responses-API name for the same role. Adding `instructions` does not make `system` acceptable, and only the item list preserves ordering when several system messages reach one call. Because subscription traffic hits transient overload errors far more often than the paid API, the README suggests raising `TRADINGAGENTS_LLM_MAX_RETRIES`. Caveat, stated in the README too: this endpoint is undocumented and unversioned, so OpenAI can change it without notice, and driving subscription credentials from a third-party tool is not clearly sanctioned by OpenAI's terms. Nothing else in the codebase changes behaviour unless the provider is selected. Tests: auth-file resolution/refresh/rotation, provider registration and payload shaping, the CLI login preflight, plus an opt-in live smoke test behind `-m smoke`. `pytest -q` is 614 passed / 5 skipped and `ruff check .` is clean. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01FFuMQCxPhTkWLwrCQ57tTp --- README.md | 4 +- cli/main.py | 5 +- cli/utils.py | 23 ++ tests/test_api_key_env.py | 2 +- tests/test_cli_env_skip.py | 34 +++ tests/test_codex_auth.py | 230 ++++++++++++++++++ tests/test_codex_cli.py | 57 +++++ tests/test_codex_provider.py | 201 ++++++++++++++++ tests/test_codex_smoke.py | 72 ++++++ tests/test_provider_registry.py | 2 + tradingagents/graph/trading_graph.py | 4 +- tradingagents/llm_clients/api_key_env.py | 3 + tradingagents/llm_clients/codex_auth.py | 258 +++++++++++++++++++++ tradingagents/llm_clients/model_catalog.py | 17 ++ tradingagents/llm_clients/openai_client.py | 111 +++++++-- 15 files changed, 1004 insertions(+), 19 deletions(-) create mode 100644 tests/test_codex_auth.py create mode 100644 tests/test_codex_cli.py create mode 100644 tests/test_codex_provider.py create mode 100644 tests/test_codex_smoke.py create mode 100644 tradingagents/llm_clients/codex_auth.py diff --git a/README.md b/README.md index def85acbef5..25db537de22 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,8 @@ For local models, configure Ollama with `llm_provider: "ollama"`. The default en For any other OpenAI-compatible server (vLLM, LM Studio, llama.cpp, or a custom relay), use `llm_provider: "openai_compatible"` and set the endpoint via `backend_url` (or `TRADINGAGENTS_LLM_BACKEND_URL`), e.g. `http://localhost:8000/v1` for vLLM or `http://localhost:1234/v1` for LM Studio. The model is whatever your server serves. No key is needed for local servers; set `OPENAI_COMPATIBLE_API_KEY` when the endpoint requires one. +To bill runs to a ChatGPT subscription instead of API credits, use `llm_provider: "openai_codex"`. It reuses the login held by the official Codex app or CLI (`~/.codex/auth.json`, overridable with `TRADINGAGENTS_CODEX_AUTH_PATH`), so sign in there first; no API key is needed and none is read. TradingAgents refreshes and rewrites that file when the token nears expiry, because the refresh token rotates and not persisting it would log the Codex client out. Set `TRADINGAGENTS_LLM_MAX_RETRIES=5` or higher: subscription traffic hits transient "servers are currently overloaded" errors far more often than the paid API. Note that this endpoint is undocumented and unversioned — OpenAI can change it without notice — and that driving subscription credentials from a third-party tool is not clearly sanctioned by OpenAI's terms. + Alternatively, copy `.env.example` to `.env` and fill in your keys: ```bash cp .env.example .env @@ -197,7 +199,7 @@ An interface will appear showing results as they load, letting you track the age ### Implementation Details -We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, Google, Anthropic, xAI, DeepSeek, Qwen (Alibaba DashScope, international and China endpoints), GLM (Zhipu), MiniMax (global + China), OpenRouter, Ollama for local models, and Azure OpenAI for enterprise. +We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, OpenAI Codex (ChatGPT subscription), Google, Anthropic, xAI, DeepSeek, Qwen (Alibaba DashScope, international and China endpoints), GLM (Zhipu), MiniMax (global + China), OpenRouter, Ollama for local models, and Azure OpenAI for enterprise. ### Python Usage diff --git a/cli/main.py b/cli/main.py index 2e0c41d0dd3..be3c45a36ef 100644 --- a/cli/main.py +++ b/cli/main.py @@ -711,7 +711,10 @@ def thinking_value_or_prompt(env_var, config_key, label, box_title, box_body, pr "Gemini thinking mode", "Step 8: Thinking Mode", "Configure Gemini thinking mode", ask_gemini_thinking_config, ) - elif provider_lower == "openai": + elif provider_lower in ("openai", "openai_codex"): + # Same branch set as TradingAgentsGraph._get_provider_kwargs: the Codex + # endpoint serves the same GPT-5 family and accepts reasoning.effort, so + # leaving it out here would forward a knob the CLI never offers. reasoning_effort = thinking_value_or_prompt( "TRADINGAGENTS_OPENAI_REASONING_EFFORT", "openai_reasoning_effort", "Reasoning effort", "Step 8: Reasoning Effort", diff --git a/cli/utils.py b/cli/utils.py index da8524d2d62..b2e3d34a493 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -347,6 +347,8 @@ def _llm_provider_table() -> list[tuple[str, str, str | None]]: ollama_url = os.environ.get("OLLAMA_BASE_URL") or "http://localhost:11434/v1" return [ ("OpenAI", "openai", "https://api.openai.com/v1"), + ("OpenAI Codex (ChatGPT subscription quota)", "openai_codex", + "https://chatgpt.com/backend-api/codex"), ("Google", "google", None), ("Anthropic", "anthropic", "https://api.anthropic.com/"), ("xAI", "xai", "https://api.x.ai/v1"), @@ -600,6 +602,23 @@ def confirm_ollama_endpoint(url: str) -> None: ) +def _preflight_codex_auth() -> None: + """Validate the Codex login before the run starts. + + Codex has no API-key env var, so the usual prompt does not apply. Resolving + the credential now turns a missing or dead login into an immediate, fixable + message instead of a failure on the first LLM call. + """ + from tradingagents.llm_clients.codex_auth import CodexAuthError, resolve + + try: + resolve() + except CodexAuthError as exc: + console.print(f"\n[red]{exc}[/red]") + raise SystemExit(1) from exc + console.print("[green]✓ Codex credentials OK (ChatGPT subscription quota)[/green]") + + def ensure_api_key(provider: str) -> str | None: """Make sure the API key for `provider` is available in the environment. @@ -613,6 +632,10 @@ def ensure_api_key(provider: str) -> str | None: """ env_var = get_api_key_env(provider) if env_var is None: + # Codex authenticates from the Codex client's auth file rather than an + # env var, so it gets a login check in place of the key prompt. + if provider.lower() == "openai_codex": + _preflight_codex_auth() return None # ollama / unknown — no key check possible # Key-optional providers (generic OpenAI-compatible / local servers) read the diff --git a/tests/test_api_key_env.py b/tests/test_api_key_env.py index 7361ea7b566..59aa329f707 100644 --- a/tests/test_api_key_env.py +++ b/tests/test_api_key_env.py @@ -18,7 +18,7 @@ def test_every_select_llm_provider_choice_has_an_entry(): # stay in lockstep. Region-specific keys (qwen-cn / minimax-cn / glm-cn) # are reached via the secondary region prompt, so they must also be present. expected = { - "openai", "google", "anthropic", "xai", "deepseek", + "openai", "openai_codex", "google", "anthropic", "xai", "deepseek", "qwen", "qwen-cn", "glm", "glm-cn", "minimax", "minimax-cn", diff --git a/tests/test_cli_env_skip.py b/tests/test_cli_env_skip.py index c98c2454925..168f23bc0cc 100644 --- a/tests/test_cli_env_skip.py +++ b/tests/test_cli_env_skip.py @@ -145,5 +145,39 @@ def test_effort_env_skips_step8_prompt(self): self.assertEqual(sel["openai_reasoning_effort"], "high") +@pytest.mark.unit +class TestReasoningEffortOfferedForCodex(unittest.TestCase): + def test_codex_provider_is_asked_for_reasoning_effort(self): + # openai_codex serves the same GPT-5 family over the ChatGPT-subscription + # endpoint and TradingAgentsGraph._get_provider_kwargs forwards + # openai_reasoning_effort for it, so Step 8 must offer the knob here too + # — otherwise the config key is wired end-to-end but can only ever be set + # through the env var. + import cli.main as m + + with mock.patch.dict( + os.environ, {"TRADINGAGENTS_OPENAI_REASONING_EFFORT": ""}, clear=False + ), \ + mock.patch.object(m, "fetch_announcements", return_value=None), \ + mock.patch.object(m, "display_announcements"), \ + mock.patch.object(m, "get_ticker", return_value="AAPL"), \ + mock.patch.object(m, "get_analysis_date", return_value="2026-05-29"), \ + mock.patch.object(m, "select_analysts", return_value=[]), \ + mock.patch.object(m, "select_research_depth", return_value=1), \ + mock.patch.object(m, "ensure_api_key"), \ + mock.patch.object( + m, "select_llm_provider", + return_value=("openai_codex", "https://chatgpt.com/backend-api/codex"), + ), \ + mock.patch.object(m, "ask_output_language", return_value="English"), \ + mock.patch.object(m, "select_shallow_thinking_agent", return_value="gpt-5.4-mini"), \ + mock.patch.object(m, "select_deep_thinking_agent", return_value="gpt-5.6-sol"), \ + mock.patch.object(m, "ask_openai_reasoning_effort", return_value="high") as prompt_effort: + sel = m.get_user_selections() + + prompt_effort.assert_called_once() + self.assertEqual(sel["openai_reasoning_effort"], "high") + + if __name__ == "__main__": unittest.main() diff --git a/tests/test_codex_auth.py b/tests/test_codex_auth.py new file mode 100644 index 00000000000..f9f0255fe22 --- /dev/null +++ b/tests/test_codex_auth.py @@ -0,0 +1,230 @@ +"""Codex credential resolution from the official client's auth.json. + +Two behaviours matter for not breaking the user's Codex login: refresh only +when the token is genuinely near expiry, and always persist the rotated refresh +token atomically (the refresh token rotates on every refresh, so refreshing +without persisting silently logs the user out of Codex). +""" +from __future__ import annotations + +import base64 +import json +import os +import stat +import time +from pathlib import Path + +import pytest + +from tradingagents.llm_clients import codex_auth + + +def _jwt(exp: float, aud: str = "app_TEST") -> str: + """Build an unsigned JWT whose payload carries exp and aud.""" + def part(obj: dict) -> str: + return base64.urlsafe_b64encode(json.dumps(obj).encode()).decode().rstrip("=") + + return f"{part({'alg': 'none'})}.{part({'exp': exp, 'aud': [aud]})}.sig" + + +def _auth_file(tmp_path: Path, exp_offset: float = 3600.0, **overrides) -> Path: + payload = { + "auth_mode": "chatgpt", + "OPENAI_API_KEY": None, + "tokens": { + "id_token": _jwt(time.time() + exp_offset), + "access_token": _jwt(time.time() + exp_offset), + "refresh_token": "refresh-old", + "account_id": "acct-123", + }, + "last_refresh": "2026-07-20T05:33:55.696789Z", + } + payload.update(overrides) + path = tmp_path / "auth.json" + path.write_text(json.dumps(payload)) + path.chmod(0o600) + return path + + +def _fresh_tokens(**overrides) -> dict: + payload = { + "access_token": _jwt(time.time() + 864000), + "refresh_token": "refresh-new", + "id_token": _jwt(time.time() + 3600), + "expires_in": 864000, + } + payload.update(overrides) + return payload + + +@pytest.mark.unit +def test_valid_token_is_returned_without_refreshing(tmp_path, monkeypatch): + path = _auth_file(tmp_path) + before = path.read_text() + + def _explode(*args, **kwargs): + raise AssertionError("refresh must not run for a healthy token") + + monkeypatch.setattr(codex_auth, "_refresh", _explode) + creds = codex_auth.resolve(str(path)) + + assert creds.account_id == "acct-123" + assert creds.token == json.loads(before)["tokens"]["access_token"] + assert path.read_text() == before + + +@pytest.mark.unit +def test_headers_carry_the_account_id(): + creds = codex_auth.CodexCredentials(token="t", account_id="acct-9") + assert creds.headers["chatgpt-account-id"] == "acct-9" + assert "Authorization" not in creds.headers + + +@pytest.mark.unit +@pytest.mark.parametrize("exp_offset", [-10.0, 60.0]) +def test_expiring_token_is_refreshed_and_persisted(tmp_path, monkeypatch, exp_offset): + path = _auth_file(tmp_path, exp_offset=exp_offset) + calls = [] + fresh = _fresh_tokens() + + def _fake_refresh(refresh_token: str, client_id: str) -> dict: + calls.append((refresh_token, client_id)) + return fresh + + monkeypatch.setattr(codex_auth, "_refresh", _fake_refresh) + creds = codex_auth.resolve(str(path)) + + assert calls == [("refresh-old", "app_TEST")] + assert creds.token == fresh["access_token"] + assert creds.account_id == "acct-123" + + written = json.loads(path.read_text()) + assert written["tokens"]["access_token"] == fresh["access_token"] + assert written["tokens"]["refresh_token"] == "refresh-new" + assert written["tokens"]["account_id"] == "acct-123" + assert set(written) == {"auth_mode", "OPENAI_API_KEY", "tokens", "last_refresh"} + assert written["auth_mode"] == "chatgpt" + assert written["last_refresh"] != "2026-07-20T05:33:55.696789Z" + assert stat.S_IMODE(path.stat().st_mode) == 0o600 + assert not list(tmp_path.glob("*.tmp")) + + +@pytest.mark.unit +def test_write_back_is_atomic(tmp_path, monkeypatch): + path = _auth_file(tmp_path, exp_offset=-10.0) + monkeypatch.setattr(codex_auth, "_refresh", lambda *a, **k: _fresh_tokens()) + destinations = [] + real_replace = os.replace + + def _spy(src, dst): + destinations.append(str(dst)) + real_replace(src, dst) + + monkeypatch.setattr(codex_auth.os, "replace", _spy) + codex_auth.resolve(str(path)) + + # A direct truncating write would never call os.replace. + assert destinations == [str(path)] + + +@pytest.mark.unit +def test_refresh_failure_leaves_the_file_untouched(tmp_path, monkeypatch): + path = _auth_file(tmp_path, exp_offset=-10.0) + before = path.read_text() + + def _fail(*args, **kwargs): + raise codex_auth.CodexAuthError("refresh rejected") + + monkeypatch.setattr(codex_auth, "_refresh", _fail) + with pytest.raises(codex_auth.CodexAuthError): + codex_auth.resolve(str(path)) + assert path.read_text() == before + + +@pytest.mark.unit +def test_missing_file_names_the_path_and_the_fix(tmp_path): + missing = tmp_path / "nope.json" + with pytest.raises(codex_auth.CodexAuthError, match="Sign in"): + codex_auth.resolve(str(missing)) + + +@pytest.mark.unit +def test_api_key_auth_mode_points_at_the_openai_provider(tmp_path): + path = _auth_file(tmp_path, auth_mode="apikey") + with pytest.raises(codex_auth.CodexAuthError, match="'openai' provider"): + codex_auth.resolve(str(path)) + + +@pytest.mark.unit +def test_missing_account_id_is_rejected(tmp_path): + path = _auth_file(tmp_path) + payload = json.loads(path.read_text()) + del payload["tokens"]["account_id"] + path.write_text(json.dumps(payload)) + with pytest.raises(codex_auth.CodexAuthError, match="account"): + codex_auth.resolve(str(path)) + + +@pytest.mark.unit +def test_expired_token_without_refresh_token_is_rejected(tmp_path): + path = _auth_file(tmp_path, exp_offset=-10.0) + payload = json.loads(path.read_text()) + del payload["tokens"]["refresh_token"] + path.write_text(json.dumps(payload)) + with pytest.raises(codex_auth.CodexAuthError, match="refresh token"): + codex_auth.resolve(str(path)) + + +@pytest.mark.unit +def test_client_id_falls_back_when_the_id_token_is_unreadable(tmp_path, monkeypatch): + path = _auth_file(tmp_path, exp_offset=-10.0) + payload = json.loads(path.read_text()) + payload["tokens"]["id_token"] = "not-a-jwt" + path.write_text(json.dumps(payload)) + seen = [] + + def _capture(refresh_token: str, client_id: str) -> dict: + seen.append(client_id) + return _fresh_tokens() + + monkeypatch.setattr(codex_auth, "_refresh", _capture) + codex_auth.resolve(str(path)) + assert seen == [codex_auth.FALLBACK_CLIENT_ID] + + +def _degrading_reads(monkeypatch, path, drop: str): + """Make the second read of the auth file return a file missing ``drop``. + + resolve() reads once to decide whether a refresh is needed, then re-reads + under the lock. Nothing guarantees the two reads see the same file — the + official Codex client can rewrite it in between. + """ + intact = json.loads(path.read_text()) + degraded = json.loads(json.dumps(intact)) + del degraded["tokens"][drop] + reads = iter([intact, degraded]) + monkeypatch.setattr(codex_auth, "_read_auth", lambda _path: next(reads)) + + +@pytest.mark.unit +@pytest.mark.parametrize( + "dropped,message", [("refresh_token", "refresh token"), ("account_id", "account")] +) +def test_file_degrading_under_the_lock_stays_actionable( + tmp_path, monkeypatch, dropped, message +): + path = _auth_file(tmp_path, exp_offset=-10.0) + _degrading_reads(monkeypatch, path, dropped) + monkeypatch.setattr(codex_auth, "_refresh", lambda *a, **k: _fresh_tokens()) + + # Without re-validating after the second read this raises a bare KeyError, + # which tells the user nothing about how to fix it. + with pytest.raises(codex_auth.CodexAuthError, match=message): + codex_auth.resolve(str(path)) + + +@pytest.mark.unit +def test_env_var_selects_the_auth_path(tmp_path, monkeypatch): + path = _auth_file(tmp_path) + monkeypatch.setenv("TRADINGAGENTS_CODEX_AUTH_PATH", str(path)) + assert codex_auth.resolve().account_id == "acct-123" diff --git a/tests/test_codex_cli.py b/tests/test_codex_cli.py new file mode 100644 index 00000000000..bf684bd63bd --- /dev/null +++ b/tests/test_codex_cli.py @@ -0,0 +1,57 @@ +"""CLI surface for the openai_codex provider. + +Codex has no API-key env var, so the usual ensure_api_key prompt is a no-op for +it. Without a preflight the run would look configured and then fail on the first +LLM call, so ensure_api_key validates the Codex auth file instead. +""" +from __future__ import annotations + +import pytest + +from cli.utils import _llm_provider_table, ensure_api_key +from tradingagents.llm_clients import codex_auth +from tradingagents.llm_clients.model_catalog import get_model_options + + +@pytest.mark.unit +def test_dropdown_offers_codex(): + rows = {key: (label, url) for label, key, url in _llm_provider_table()} + label, url = rows["openai_codex"] + assert url == "https://chatgpt.com/backend-api/codex" + assert "subscription" in label.lower() + + +@pytest.mark.unit +@pytest.mark.parametrize("mode,expected_first", [("quick", "gpt-5.4-mini"), ("deep", "gpt-5.6-sol")]) +def test_catalog_defaults(mode, expected_first): + options = get_model_options("openai_codex", mode) + assert options[0][1] == expected_first + assert options[-1][1] == "custom" + + +@pytest.mark.unit +def test_preflight_passes_when_credentials_resolve(monkeypatch): + monkeypatch.setattr( + codex_auth, "resolve", + lambda *a, **k: codex_auth.CodexCredentials(token="t", account_id="a"), + ) + assert ensure_api_key("openai_codex") is None + + +@pytest.mark.unit +def test_preflight_exits_when_credentials_are_missing(monkeypatch): + def _fail(*args, **kwargs): + raise codex_auth.CodexAuthError("no credentials at ~/.codex/auth.json") + + monkeypatch.setattr(codex_auth, "resolve", _fail) + with pytest.raises(SystemExit): + ensure_api_key("openai_codex") + + +@pytest.mark.unit +def test_other_keyless_providers_are_unaffected(monkeypatch): + def _explode(*args, **kwargs): + raise AssertionError("ollama must not touch Codex auth") + + monkeypatch.setattr(codex_auth, "resolve", _explode) + assert ensure_api_key("ollama") is None diff --git a/tests/test_codex_provider.py b/tests/test_codex_provider.py new file mode 100644 index 00000000000..0844d5a4ab1 --- /dev/null +++ b/tests/test_codex_provider.py @@ -0,0 +1,201 @@ +"""The openai_codex provider targets the ChatGPT-subscription Codex endpoint. + +That endpoint rejects `stream: false`, `store: true`, `temperature`, and any +input item carrying the `system` role (all HTTP 400), and authenticates from the +Codex client's auth file rather than an env var. These tests pin each of those so +a future registry edit can't silently produce a client that 400s on its first +call. +""" +from __future__ import annotations + +import dataclasses + +import pytest +from langchain_core.messages import AIMessage, HumanMessage, SystemMessage + +from tradingagents.graph.trading_graph import TradingAgentsGraph +from tradingagents.llm_clients import codex_auth +from tradingagents.llm_clients.api_key_env import get_api_key_env +from tradingagents.llm_clients.factory import create_llm_client +from tradingagents.llm_clients.openai_client import ( + OPENAI_COMPATIBLE_PROVIDERS, + CodexChatOpenAI, + OpenAIClient, + _supports_temperature, + is_openai_compatible, +) + +CREDENTIALS = codex_auth.CodexCredentials(token="tok-abc", account_id="acct-123") + + +def _patch_credentials_fn(monkeypatch, fn): + """Swap the registry's credential hook. + + The registry captured ``codex_auth.resolve`` by reference at import time, so + monkeypatching the module attribute would not reach it — the spec row itself + has to be replaced. + """ + spec = OPENAI_COMPATIBLE_PROVIDERS["openai_codex"] + monkeypatch.setitem( + OPENAI_COMPATIBLE_PROVIDERS, + "openai_codex", + dataclasses.replace(spec, credentials_fn=fn), + ) + + +@pytest.fixture() +def stub_credentials(monkeypatch): + _patch_credentials_fn(monkeypatch, lambda: CREDENTIALS) + + +@pytest.mark.unit +def test_provider_is_registered(): + assert is_openai_compatible("openai_codex") + spec = OPENAI_COMPATIBLE_PROVIDERS["openai_codex"] + assert spec.base_url == "https://chatgpt.com/backend-api/codex" + assert spec.chat_class is CodexChatOpenAI + assert spec.use_responses_api is True + assert spec.forces_responses_api is True + assert spec.credentials_fn is codex_auth.resolve + + +@pytest.mark.unit +def test_no_api_key_env_var(): + # Authenticates from the Codex auth file, like bedrock's credential chain. + assert get_api_key_env("openai_codex") is None + + +@pytest.mark.unit +def test_client_is_built_from_the_resolved_credentials(stub_credentials): + llm = create_llm_client("openai_codex", "gpt-5.5").get_llm() + + assert isinstance(llm, CodexChatOpenAI) + assert llm.default_headers["chatgpt-account-id"] == "acct-123" + assert llm.use_responses_api is True + assert llm.streaming is True + assert llm.store is False + + +@pytest.mark.unit +def test_streaming_dispatch_matches_the_payload(stub_credentials): + # langchain takes `stream` for the payload from the attribute but decides + # which code path to run from model_fields_set. A class-level field default + # satisfies the first and not the second, so the request says stream:true + # and the SSE reply is then handed to the non-streaming parser + # ("'Stream' object has no attribute 'error'"). Asserting llm.streaming is + # True does not catch that; asserting the two agree does. + llm = create_llm_client("openai_codex", "gpt-5.4-mini").get_llm() + assert llm._should_stream(async_api=False) is True + assert llm._get_request_payload([("user", "hi")])["stream"] is True + + +@pytest.mark.unit +def test_system_messages_are_sent_as_developer(stub_credentials): + # Every agent prompt is a ChatPromptTemplate whose first message is a system + # message, and the endpoint answers any input item with role "system" with + # 400 "System messages are not allowed" — even when `instructions` is also + # set. "developer" is the Responses-API name for the same role and is + # accepted, so the rewrite must happen before the request leaves. + llm = create_llm_client("openai_codex", "gpt-5.4-mini").get_llm() + + payload = llm._get_request_payload( + [SystemMessage("be terse"), HumanMessage("hi"), AIMessage("yo")] + ) + + roles = [item["role"] for item in payload["input"] if "role" in item] + assert roles == ["developer", "user", "assistant"] + assert payload["input"][0]["content"] == "be terse" + + +@pytest.mark.unit +def test_system_messages_survive_untouched_on_native_openai(monkeypatch): + # The rewrite is a Codex-endpoint workaround, not a repo-wide policy: native + # OpenAI accepts (and bills prompt-cache hits on) the system role. + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + llm = OpenAIClient("gpt-5.4-mini", provider="openai").get_llm() + + payload = llm._get_request_payload([SystemMessage("be terse"), HumanMessage("hi")]) + + assert [item["role"] for item in payload["input"] if "role" in item] == [ + "system", + "user", + ] + + +@pytest.mark.unit +def test_responses_api_survives_the_non_openai_host(stub_credentials): + # The #1024 hostname guard would otherwise downgrade chatgpt.com to Chat + # Completions, which this endpoint does not serve. + llm = OpenAIClient("gpt-5.5", provider="openai_codex").get_llm() + assert llm.use_responses_api is True + + +@pytest.mark.unit +@pytest.mark.parametrize( + "provider,expected", [("openai_codex", False), ("openai", True), ("xai", True)] +) +def test_temperature_support_by_provider(provider, expected): + assert _supports_temperature(provider) is expected + + +@pytest.mark.unit +def test_temperature_is_dropped_for_codex_only(stub_credentials, monkeypatch): + # gpt-4.1, not a gpt-5 ID: langchain_openai nulls temperature itself for the + # gpt-5/o-series, which would mask whether the drop was ours. With a model + # langchain does forward, the only difference between the two arms is the + # provider — which is exactly what this test is about. + monkeypatch.setenv("OPENAI_API_KEY", "sk-test") + codex = OpenAIClient("gpt-4.1", provider="openai_codex", temperature=0.3).get_llm() + openai = OpenAIClient("gpt-4.1", provider="openai", temperature=0.3).get_llm() + + # Asserting "not 0.3" rather than "is None" keeps this independent of + # whatever ChatOpenAI uses as its own unset default. + assert codex.temperature != 0.3 + assert openai.temperature == 0.3 + + +@pytest.mark.unit +def test_max_retries_still_reaches_the_client(stub_credentials): + # The retry budget stays the cross-provider llm_max_retries knob (#1091); + # nothing Codex-specific may shadow it. + llm = OpenAIClient("gpt-5.5", provider="openai_codex", max_retries=7).get_llm() + assert llm.max_retries == 7 + + +@pytest.mark.unit +def test_auth_failure_propagates(monkeypatch): + def _fail(): + raise codex_auth.CodexAuthError("no credentials") + + _patch_credentials_fn(monkeypatch, _fail) + with pytest.raises(codex_auth.CodexAuthError): + OpenAIClient("gpt-5.5", provider="openai_codex").get_llm() + + +# --- reasoning effort forwarding ------------------------------------------- + + +def _bare_graph(config: dict) -> TradingAgentsGraph: + """A graph shell with only the config attribute the method under test reads.""" + graph = object.__new__(TradingAgentsGraph) + graph.config = config + return graph + + +@pytest.mark.unit +@pytest.mark.parametrize("provider", ["openai", "openai_codex"]) +def test_reasoning_effort_is_forwarded(provider): + # _get_provider_kwargs dispatches on an exact provider name, so a new key + # silently receives nothing unless it is added to the branch. + kwargs = _bare_graph( + {"llm_provider": provider, "openai_reasoning_effort": "high"} + )._get_provider_kwargs() + assert kwargs["reasoning_effort"] == "high" + + +@pytest.mark.unit +def test_reasoning_effort_absent_when_unset(): + kwargs = _bare_graph( + {"llm_provider": "openai_codex", "openai_reasoning_effort": None} + )._get_provider_kwargs() + assert "reasoning_effort" not in kwargs diff --git a/tests/test_codex_smoke.py b/tests/test_codex_smoke.py new file mode 100644 index 00000000000..12e14528455 --- /dev/null +++ b/tests/test_codex_smoke.py @@ -0,0 +1,72 @@ +"""Live check against the ChatGPT-subscription Codex endpoint. + +Skipped unless the smoke marker is selected explicitly *and* a real Codex login +exists, so CI, contributors without a ChatGPT subscription, and ordinary +`pytest -q` runs are all unaffected — each call here spends the user's +subscription quota. Run with: pytest -m smoke +""" +from __future__ import annotations + +import pytest +from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder + +from tradingagents.llm_clients import codex_auth +from tradingagents.llm_clients.factory import create_llm_client + + +@pytest.fixture() +def codex_llm(request): + """A live Codex client, or a skip when this run should not make live calls. + + The credential check is deliberately lazy: resolving at import time would + read (and possibly rewrite) the real ~/.codex/auth.json during every plain + `pytest -q` collection. + """ + if "smoke" not in (request.config.getoption("-m") or ""): + pytest.skip("live Codex call; select it explicitly with -m smoke") + try: + codex_auth.resolve() + except codex_auth.CodexAuthError: + pytest.skip("no Codex credentials; skipping live call") + return create_llm_client("openai_codex", "gpt-5.4-mini").get_llm() + + +@pytest.mark.smoke +def test_codex_round_trip(codex_llm): + response = codex_llm.invoke("Reply with exactly: ok") + assert "ok" in response.content.lower() + + +@pytest.mark.smoke +def test_codex_system_prompt(codex_llm): + # Every agent node drives the model through a ChatPromptTemplate whose first + # message is a system message, so a bare-string round trip is not evidence + # that this provider works — the raw `system` role 400s on this endpoint. + prompt = ChatPromptTemplate.from_messages( + [ + ("system", "You answer with exactly one word, always lowercase."), + MessagesPlaceholder(variable_name="messages"), + ] + ) + + response = (prompt | codex_llm).invoke( + {"messages": [("user", "What colour is a clear midday sky?")]} + ) + + assert "blue" in response.content.lower() + + +@pytest.mark.smoke +def test_codex_tool_call(codex_llm): + # The analyst loop depends on function calling, so a chat-only round trip is + # not sufficient evidence that this provider is usable. + schema = { + "title": "Decision", + "type": "object", + "properties": {"action": {"type": "string", "enum": ["BUY", "HOLD", "SELL"]}}, + "required": ["action"], + } + result = codex_llm.with_structured_output(schema, method="function_calling").invoke( + "Rate AAPL." + ) + assert result["action"] in {"BUY", "HOLD", "SELL"} diff --git a/tests/test_provider_registry.py b/tests/test_provider_registry.py index 596108a7766..8d552d91b16 100644 --- a/tests/test_provider_registry.py +++ b/tests/test_provider_registry.py @@ -6,6 +6,7 @@ from tradingagents.llm_clients.openai_client import ( OPENAI_COMPATIBLE_PROVIDERS, + CodexChatOpenAI, DeepSeekChatOpenAI, MinimaxChatOpenAI, NormalizedChatOpenAI, @@ -26,6 +27,7 @@ def test_registry_membership(): @pytest.mark.unit @pytest.mark.parametrize("provider,base_url,chat_class,responses", [ ("openai", None, NormalizedChatOpenAI, True), + ("openai_codex", "https://chatgpt.com/backend-api/codex", CodexChatOpenAI, True), ("xai", "https://api.x.ai/v1", NormalizedChatOpenAI, False), ("deepseek", "https://api.deepseek.com", DeepSeekChatOpenAI, False), ("qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1", NormalizedChatOpenAI, False), diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index a1902177d4f..872a1d5bd6a 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -160,7 +160,9 @@ def _get_provider_kwargs(self) -> dict[str, Any]: if thinking_level: kwargs["thinking_level"] = thinking_level - elif provider == "openai": + # openai_codex serves the same GPT-5 family over the ChatGPT-subscription + # endpoint, which accepts reasoning.effort just as the API does. + elif provider in ("openai", "openai_codex"): reasoning_effort = self.config.get("openai_reasoning_effort") if reasoning_effort: kwargs["reasoning_effort"] = reasoning_effort diff --git a/tradingagents/llm_clients/api_key_env.py b/tradingagents/llm_clients/api_key_env.py index b97db38aa0d..1bca7e145fc 100644 --- a/tradingagents/llm_clients/api_key_env.py +++ b/tradingagents/llm_clients/api_key_env.py @@ -18,6 +18,9 @@ "azure": "AZURE_OPENAI_API_KEY", # Bedrock authenticates via the AWS credential chain, not a single key env. "bedrock": None, + # Codex authenticates from the Codex client's auth.json (an OAuth token that + # this framework refreshes), not from a single key env var. + "openai_codex": None, "xai": "XAI_API_KEY", "deepseek": "DEEPSEEK_API_KEY", # Dual-region providers each carry their own account; keys are not diff --git a/tradingagents/llm_clients/codex_auth.py b/tradingagents/llm_clients/codex_auth.py new file mode 100644 index 00000000000..cd21465203f --- /dev/null +++ b/tradingagents/llm_clients/codex_auth.py @@ -0,0 +1,258 @@ +"""Resolve a usable credential for the ChatGPT-subscription Codex endpoint. + +That endpoint authenticates with a short-lived OAuth bearer token plus an +account id, both stored by the official Codex client in ``~/.codex/auth.json``. +This module reads that file and refreshes the token when it is close to expiry, +writing the rotated tokens back: the refresh token rotates on every refresh, so +refreshing without persisting would invalidate the copy the Codex client holds +and silently log the user out of it. +""" + +from __future__ import annotations + +import base64 +import contextlib +import datetime as dt +import json +import os +import stat +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from pathlib import Path + +try: + import fcntl +except ImportError: # pragma: no cover - Windows has no fcntl + fcntl = None + +DEFAULT_AUTH_PATH = "~/.codex/auth.json" +TOKEN_URL = "https://auth.openai.com/oauth/token" +# Codex's public OAuth client id. Only used when the stored id_token carries no +# readable ``aud`` claim to take it from. +FALLBACK_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +# Refresh this far ahead of expiry so a long run cannot begin with a token that +# dies part-way through. +REFRESH_MARGIN_SECONDS = 300 + + +class CodexAuthError(RuntimeError): + """Raised when no usable Codex credential can be produced.""" + + +@dataclass(frozen=True) +class CodexCredentials: + """A bearer token and the account it belongs to.""" + + token: str + account_id: str + + @property + def headers(self) -> dict[str, str]: + """Extra request headers the Codex endpoint requires. + + The bearer token is passed separately as the client's api_key; only the + account routing and the beta opt-in belong here. + """ + return { + "chatgpt-account-id": self.account_id, + "OpenAI-Beta": "responses=experimental", + } + + +def _auth_path(auth_path: str | None) -> Path: + raw = ( + auth_path + or os.environ.get("TRADINGAGENTS_CODEX_AUTH_PATH") + or DEFAULT_AUTH_PATH + ) + return Path(raw).expanduser() + + +def _jwt_claims(token: str) -> dict: + """Decode a JWT payload without verifying its signature. + + Only ``exp`` (to decide whether to refresh) and ``aud`` (the OAuth client + id) are read. The server remains the authority on validity, so an opaque or + malformed token simply reports no claims. + """ + parts = token.split(".") + if len(parts) != 3: + return {} + payload = parts[1] + "=" * (-len(parts[1]) % 4) + try: + claims = json.loads(base64.urlsafe_b64decode(payload)) + except (ValueError, TypeError): + return {} + return claims if isinstance(claims, dict) else {} + + +def _is_expiring(token: str, now: float) -> bool: + """Whether ``token`` is expired or close enough to expiry to replace.""" + try: + return float(_jwt_claims(token).get("exp")) - now <= REFRESH_MARGIN_SECONDS + except (TypeError, ValueError): + # An unreadable expiry is treated as expiring: better one wasted refresh + # than a run that dies on the first call. + return True + + +def _client_id(id_token: str | None) -> str: + audience = _jwt_claims(id_token or "").get("aud") + if isinstance(audience, list) and audience: + return str(audience[0]) + if isinstance(audience, str) and audience: + return audience + return FALLBACK_CLIENT_ID + + +def _read_auth(path: Path) -> dict: + try: + with path.open() as handle: + return json.load(handle) + except FileNotFoundError as exc: + raise CodexAuthError( + f"No Codex credentials at {path}. Sign in with the Codex app or CLI " + "first, or point TRADINGAGENTS_CODEX_AUTH_PATH at your auth.json." + ) from exc + except json.JSONDecodeError as exc: + raise CodexAuthError( + f"Codex credentials at {path} are not valid JSON. Sign in again with " + "the Codex app or CLI." + ) from exc + + +def _refresh(refresh_token: str, client_id: str) -> dict: + """Exchange a refresh token for a new token set.""" + body = json.dumps( + { + "client_id": client_id, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "scope": "openid profile email", + } + ).encode() + request = urllib.request.Request( + TOKEN_URL, + data=body, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=30) as response: + return json.loads(response.read()) + except urllib.error.HTTPError as exc: + raise CodexAuthError( + f"Codex token refresh was rejected (HTTP {exc.code}). The stored " + "refresh token is no longer valid — sign in again with the Codex " + "app or CLI." + ) from exc + except urllib.error.URLError as exc: + raise CodexAuthError( + f"Codex token refresh could not reach {TOKEN_URL}: {exc.reason}" + ) from exc + + +def _write_auth(path: Path, auth: dict) -> None: + """Replace the auth file atomically, preserving its permissions.""" + mode = stat.S_IMODE(path.stat().st_mode) + tmp = path.with_name(path.name + ".tmp") + with tmp.open("w") as handle: + json.dump(auth, handle, indent=2) + os.chmod(tmp, mode) + os.replace(tmp, path) + + +@contextlib.contextmanager +def _refresh_lock(path: Path): + """Serialise refresh-and-write across concurrent runs. + + Windows has no ``fcntl``; there this degrades to a no-op. The atomic + ``os.replace`` still rules out a torn file, so only a redundant double + refresh becomes possible. + """ + if fcntl is None: + yield + return + lock_path = path.with_name(path.name + ".lock") + with lock_path.open("w") as handle: + fcntl.flock(handle, fcntl.LOCK_EX) + try: + yield + finally: + fcntl.flock(handle, fcntl.LOCK_UN) + + +def _credentials(tokens: dict) -> CodexCredentials: + return CodexCredentials( + token=tokens["access_token"], account_id=tokens["account_id"] + ) + + +def _validated_tokens(auth: dict, path: Path) -> dict: + """Return the token bundle, or explain what is missing. + + Applied to every read of the file, not just the first: ``resolve`` reads + once to decide whether to refresh and again under the lock, and the official + Codex client may rewrite the file in between. Without this the second read + would surface a bare ``KeyError``. + """ + tokens = auth.get("tokens") or {} + if not tokens.get("access_token") or not tokens.get("account_id"): + raise CodexAuthError( + f"Codex credentials at {path} are missing an access token or an " + "account id. Sign in again with the Codex app or CLI." + ) + return tokens + + +def _require_refresh_token(tokens: dict, path: Path) -> None: + """Fail with guidance when an expired token cannot be renewed.""" + if not tokens.get("refresh_token"): + raise CodexAuthError( + f"The Codex access token in {path} has expired and no refresh token " + "is stored. Sign in again with the Codex app or CLI." + ) + + +def resolve(auth_path: str | None = None) -> CodexCredentials: + """Return a currently-valid Codex bearer token and account id.""" + path = _auth_path(auth_path) + auth = _read_auth(path) + + if auth.get("auth_mode") != "chatgpt": + raise CodexAuthError( + f"Codex credentials at {path} use auth_mode " + f"{auth.get('auth_mode')!r}, not 'chatgpt'. An API-key Codex install " + "should use the 'openai' provider instead." + ) + + tokens = _validated_tokens(auth, path) + if not _is_expiring(tokens["access_token"], time.time()): + return _credentials(tokens) + _require_refresh_token(tokens, path) + + with _refresh_lock(path): + # Re-read under the lock: a concurrent run may already have refreshed, + # in which case its token is the valid one and ours is already stale. + # Re-validate as well — this is a fresh read of a file another process + # may have rewritten, not the one checked above. + auth = _read_auth(path) + tokens = _validated_tokens(auth, path) + if not _is_expiring(tokens["access_token"], time.time()): + return _credentials(tokens) + _require_refresh_token(tokens, path) + + fresh = _refresh(tokens["refresh_token"], _client_id(tokens.get("id_token"))) + tokens["access_token"] = fresh["access_token"] + tokens["refresh_token"] = fresh.get("refresh_token") or tokens["refresh_token"] + if fresh.get("id_token"): + tokens["id_token"] = fresh["id_token"] + auth["tokens"] = tokens + auth["last_refresh"] = ( + dt.datetime.now(dt.timezone.utc).isoformat().replace("+00:00", "Z") + ) + _write_auth(path, auth) + + return _credentials(tokens) diff --git a/tradingagents/llm_clients/model_catalog.py b/tradingagents/llm_clients/model_catalog.py index 3dd71715186..7a0d3f81082 100644 --- a/tradingagents/llm_clients/model_catalog.py +++ b/tradingagents/llm_clients/model_catalog.py @@ -92,6 +92,23 @@ ("GPT-5.5 Pro - Most capable, expensive ($30/$180 per 1M tokens)", "gpt-5.5-pro"), ], }, + # OpenAI Codex (ChatGPT-subscription endpoint). The account's own catalog + # also lists the gpt-5.6 Luna/Terra variants; the two exposed here are the + # ones verified against this framework's function-calling and structured + # -output paths. Anything else the plan serves can be entered as a custom ID. + "openai_codex": { + "quick": [ + ("GPT-5.4 Mini - Fastest, lightest on subscription quota", "gpt-5.4-mini"), + ("GPT-5.4 - Balanced", "gpt-5.4"), + ("Custom model ID", "custom"), + ], + "deep": [ + ("GPT-5.6-Sol - Latest, deepest reasoning", "gpt-5.6-sol"), + ("GPT-5.5 - Previous-gen flagship", "gpt-5.5"), + ("GPT-5.4 - Cost-effective", "gpt-5.4"), + ("Custom model ID", "custom"), + ], + }, "anthropic": { "quick": [ ("Claude Sonnet 5 - Best speed and intelligence balance", "claude-sonnet-5"), diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index bf6e554578e..ab97e1c106d 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -1,5 +1,6 @@ import os import re +from collections.abc import Callable from dataclasses import dataclass from typing import Any from urllib.parse import urlparse @@ -7,6 +8,7 @@ from langchain_core.messages import AIMessage from langchain_openai import ChatOpenAI +from . import codex_auth from .api_key_env import get_api_key_env from .base_client import BaseLLMClient, normalize_content from .capabilities import get_capabilities @@ -162,6 +164,46 @@ def _get_request_payload(self, input_, *, stop=None, **kwargs): return payload +class CodexChatOpenAI(NormalizedChatOpenAI): + """Client for the ChatGPT-subscription Codex endpoint. + + That endpoint rejects any request carrying ``stream: false`` or + ``store: true`` with HTTP 400, so both are pinned here rather than left to + each call site. ``temperature`` is rejected too, but it arrives as a user + kwarg, so it is filtered in ``OpenAIClient.get_llm`` alongside the other + unsupported-parameter drops instead of being silently swallowed here. + + It also refuses input items whose role is ``system`` ("System messages are + not allowed"), which every agent prompt in this repo sends, so + ``_get_request_payload`` relabels them ``developer`` — the Responses-API + name for the same role, which the endpoint accepts and obeys. + """ + + streaming: bool = True + store: bool = False + + def __init__(self, **kwargs: Any): + # These must be *constructor* arguments, not just field defaults. + # langchain decides whether to run the streaming code path from + # ``model_fields_set`` (i.e. "was streaming passed in?"), while the + # request payload takes ``stream`` from the attribute. A subclass + # default alone therefore sends ``stream: true`` and then parses the + # reply with the non-streaming parser, which fails on the SSE object. + kwargs.setdefault("streaming", True) + kwargs.setdefault("store", False) + super().__init__(**kwargs) + + def _get_request_payload(self, input_, *, stop=None, **kwargs): + payload = super()._get_request_payload(input_, stop=stop, **kwargs) + # Relabel rather than fold the text into `instructions`: several system + # messages can reach one call, and only the item list preserves their + # order and their position relative to the user turns. + for item in payload.get("input", []): + if isinstance(item, dict) and item.get("role") == "system": + item["role"] = "developer" + return payload + + # Kwargs forwarded from user config to ChatOpenAI _PASSTHROUGH_KWARGS = ( "timeout", "max_retries", "reasoning_effort", "temperature", @@ -180,6 +222,17 @@ def _supports_reasoning_effort(model: str) -> bool: return bool(_OPENAI_REASONING_MODEL.match(model.lower().strip())) +# The ChatGPT-subscription Codex endpoint answers any request carrying +# temperature with 400 "Unsupported parameter: temperature". Every other +# OpenAI-compatible provider accepts it. +_NO_TEMPERATURE_PROVIDERS = frozenset({"openai_codex"}) + + +def _supports_temperature(provider: str) -> bool: + """Whether the provider accepts the cross-provider ``temperature`` kwarg.""" + return provider.lower() not in _NO_TEMPERATURE_PROVIDERS + + @dataclass(frozen=True) class ProviderSpec: """Declarative config for one OpenAI-compatible provider. @@ -204,6 +257,13 @@ class ProviderSpec: placeholder_key: str = "EMPTY" # sent when no key is available (keyless local servers) require_base_url: bool = False # error if no base_url is resolved (generic endpoint) use_responses_api: bool = False # native OpenAI Responses API + # Providers whose credentials are dynamic (OAuth tokens that expire) resolve + # them through this hook instead of reading a fixed env var. + credentials_fn: Callable[[], codex_auth.CodexCredentials] | None = None + # Enable the Responses API even though the host is not api.openai.com. The + # hostname guard exists to protect proxied `openai` setups (#1024); a + # provider whose only dialect *is* Responses opts out of it here. + forces_responses_api: bool = False # Single source of truth for the OpenAI-compatible provider family. Dual-region @@ -226,6 +286,16 @@ class ProviderSpec: "nvidia": ProviderSpec(base_url="https://integrate.api.nvidia.com/v1"), "ollama": ProviderSpec(base_url="http://localhost:11434/v1", base_url_env="OLLAMA_BASE_URL", key_optional=True, placeholder_key="ollama"), + # OpenAI Codex: the ChatGPT-subscription endpoint. Speaks only the Responses + # API, authenticates with an OAuth bearer plus an account-id header, and is + # billed to the user's ChatGPT plan rather than to API credits. + "openai_codex": ProviderSpec( + base_url="https://chatgpt.com/backend-api/codex", + chat_class=CodexChatOpenAI, + use_responses_api=True, + forces_responses_api=True, + credentials_fn=codex_auth.resolve, + ), # Generic endpoint: user supplies base_url; key optional (keyless local). "openai_compatible": ProviderSpec( require_base_url=True, key_optional=True, chat_class=LocalCompatibleChatOpenAI @@ -298,25 +368,34 @@ def get_llm(self) -> Any: if base_url: llm_kwargs["base_url"] = base_url - # API key: required unless key_optional; keyless local servers get a - # placeholder. The env-var name is the single source in api_key_env. - api_key_env = get_api_key_env(self.provider) - api_key = os.environ.get(api_key_env) if api_key_env else None - if api_key: - llm_kwargs["api_key"] = api_key - elif spec.key_optional: - llm_kwargs["api_key"] = spec.placeholder_key - elif api_key_env: - raise ValueError( - f"API key for provider '{self.provider}' is not set. " - f"Please set the {api_key_env} environment variable " - f"(e.g. add {api_key_env}=your_key to your .env file)." - ) + # Credentials: providers with dynamic auth (an OAuth token that + # expires) resolve them through the spec hook; everyone else reads a + # fixed env var whose name lives in api_key_env. Keyless local + # servers get a placeholder. + if spec.credentials_fn is not None: + credentials = spec.credentials_fn() + llm_kwargs["api_key"] = credentials.token + llm_kwargs["default_headers"] = credentials.headers + else: + api_key_env = get_api_key_env(self.provider) + api_key = os.environ.get(api_key_env) if api_key_env else None + if api_key: + llm_kwargs["api_key"] = api_key + elif spec.key_optional: + llm_kwargs["api_key"] = spec.placeholder_key + elif api_key_env: + raise ValueError( + f"API key for provider '{self.provider}' is not set. " + f"Please set the {api_key_env} environment variable " + f"(e.g. add {api_key_env}=your_key to your .env file)." + ) # The Responses API only exists on native OpenAI; if the user points # the openai provider at a custom base_url (proxy/gateway/local), it # only speaks Chat Completions, so keep Responses off there (#1024). - if spec.use_responses_api and _is_native_openai_base_url(base_url): + if spec.use_responses_api and ( + spec.forces_responses_api or _is_native_openai_base_url(base_url) + ): llm_kwargs["use_responses_api"] = True elif self.base_url: llm_kwargs["base_url"] = self.base_url @@ -327,6 +406,8 @@ def get_llm(self) -> Any: continue if key == "reasoning_effort" and not _supports_reasoning_effort(self.model): continue + if key == "temperature" and not _supports_temperature(self.provider): + continue llm_kwargs[key] = self.kwargs[key] # The subclass (provider quirks) comes from the registry spec.