feat(llm): chatgpt provider runs on the signed-in ChatGPT plan (#1475)

- ChatGPTPlanChatOpenAI streams unstored Responses, sends system instructions as developer messages, drops refused settings with a warning, and sends no API organization or project
- the CLI offers it under "Subscription" after Anthropic, checks the sign-in before a run (signing in or stopping with the login command), and lists the plan's models
- reasoning effort applies to chatgpt as to openai; remembered plan models are offered again
This commit is contained in:
Yijia-Xiao
2026-10-16 21:12:26 +00:00
parent 69042d6d33
commit 36479ae1fa
11 changed files with 449 additions and 58 deletions
+3 -2
View File
@@ -11,6 +11,7 @@ from cli.prompts import filter_analysts_for_asset_type, parse_analysts
from cli.run import run_analysis
from tradingagents.backtest import iter_grid, run_backtest, summarize
from tradingagents.default_config import DEFAULT_CONFIG
from tradingagents.llm_clients.openai_client import SIGNED_IN_PROVIDERS
from tradingagents.portfolio import load_portfolio
# prompt_toolkit's win32 output module is importable only on Windows (it asserts
@@ -159,8 +160,8 @@ def backtest(
console.print(f"[yellow]unsettled:[/yellow] {ticker}: {reason}")
class Plan(StrEnum):
chatgpt = "chatgpt"
# The plans `login` and `logout` take: the providers signed in to, not keyed.
Plan = StrEnum("Plan", {name: name for name in SIGNED_IN_PROVIDERS})
@app.command()
+4 -2
View File
@@ -21,7 +21,7 @@ import os
from pathlib import Path
from cli.models import AnalystType, AssetType
from cli.prompts import _llm_provider_table, filter_analysts_for_asset_type
from cli.prompts import LISTED_LIVE, _llm_provider_table, filter_analysts_for_asset_type
from tradingagents.dataflows.files import replace_file
from tradingagents.llm_clients.model_catalog import get_model_options
@@ -86,6 +86,8 @@ def sanitize(prefs: dict, asset_type) -> dict:
offered = {model for _, model in get_model_options(base, mode)}
except KeyError:
continue
if prefs.get(field) in offered:
# Models listed live are not in the catalog, so any model ID is kept.
listed_live = base in LISTED_LIVE and isinstance(prefs.get(field), str)
if prefs.get(field) in offered or listed_live:
kept[field] = prefs[field]
return kept
+103 -45
View File
@@ -1,16 +1,19 @@
import datetime
import os
import subprocess
import sys
from pathlib import Path
import questionary
import typer
from dotenv import find_dotenv, set_key
from rich.markup import escape
from cli.display import console
from cli.models import AnalystType, AssetType
from tradingagents.llm_clients.api_key_env import get_api_key_env
from tradingagents.llm_clients.model_catalog import get_model_options
from tradingagents.llm_clients.openai_client import OPENAI_COMPATIBLE_PROVIDERS, SIGNED_IN_PROVIDERS
TICKER_INPUT_EXAMPLES = "SPY, 0700.HK, BTC-USD"
@@ -24,6 +27,14 @@ ANALYST_CHOICES = [
CRYPTO_SUFFIXES = ("-USD", "-USDT", "-USDC", "-BTC", "-ETH")
# The style of every selection menu.
_MENU_STYLE = questionary.Style([
("selected", "fg:magenta noinherit"),
("highlighted", "fg:magenta noinherit"),
("pointer", "fg:magenta noinherit"),
])
def is_valid_ticker_input(value: str) -> bool:
"""Whether a ticker entry is acceptable (charset + length).
@@ -238,6 +249,35 @@ def _fetch_openrouter_models() -> list[tuple[str, str]]:
return []
def select_chatgpt_model(mode: str, default=None) -> str:
"""Select one of the ChatGPT plan's models, or enter a model ID."""
from tradingagents.llm_clients import chatgpt_auth
try:
models = chatgpt_auth.list_models()
except Exception as e:
console.print(f"\n[yellow]Could not list your ChatGPT plan's models: {escape(str(e))}[/yellow]")
models = []
return _select_listed_model(f"Select Your [{mode.title()}-Thinking] ChatGPT Plan Model:",
models, default, _prompt_custom_model_id)
def _select_listed_model(title: str, models, default, custom) -> str:
"""Select from ``models`` ((name, model ID) pairs, listed live) or enter one with ``custom``."""
choices = [questionary.Choice(name, value=mid) for name, mid in models]
choices.append(questionary.Choice("Custom model ID", value="custom"))
choice = questionary.select(
title,
choices=choices,
default=default if any(c.value == default for c in choices) else None,
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=_MENU_STYLE,
).ask()
if choice is None:
console.print("\n[red]No model selected. Exiting...[/red]")
exit(1)
return custom() if choice == "custom" else choice
def _require_text(message: str, hint: str) -> str:
"""Prompt for a required value; exit cleanly if the user cancels.
@@ -255,7 +295,7 @@ def _require_text(message: str, hint: str) -> str:
return response.strip()
def select_openrouter_model(mode: str) -> str:
def select_openrouter_model(mode: str, default=None) -> str:
"""Select an OpenRouter model from the newest available, or enter a custom ID.
``mode`` ("quick"/"deep") labels the prompt so the two consecutive
@@ -270,30 +310,14 @@ def select_openrouter_model(mode: str) -> str:
and mid.split("/", 1)[0] in _OPENROUTER_MAINSTREAM
]
top = (mainstream or models)[:5]
return _select_listed_model(
f"Select Your [{mode.title()}-Thinking] OpenRouter Model (latest available):", top, default,
lambda: _require_text("Enter OpenRouter model ID (e.g. google/gemma-4-26b-a4b-it):",
"Please enter a model ID."))
choices = [questionary.Choice(name, value=mid) for name, mid in top]
choices.append(questionary.Choice("Custom model ID", value="custom"))
choice = questionary.select(
f"Select Your [{mode.title()}-Thinking] OpenRouter Model (latest available):",
choices=choices,
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=questionary.Style([
("selected", "fg:magenta noinherit"),
("highlighted", "fg:magenta noinherit"),
("pointer", "fg:magenta noinherit"),
]),
).ask()
if choice is None:
console.print("\n[red]No model selected. Exiting...[/red]")
exit(1)
if choice == "custom":
return _require_text(
"Enter OpenRouter model ID (e.g. google/gemma-4-26b-a4b-it):",
"Please enter a model ID.",
)
return choice
# Providers whose models are listed live, with the menu that offers them.
LISTED_LIVE = {"openrouter": select_openrouter_model, "chatgpt": select_chatgpt_model}
def _prompt_custom_model_id() -> str:
@@ -303,8 +327,9 @@ def _prompt_custom_model_id() -> str:
def _select_model(provider: str, mode: str, default=None) -> str:
"""Select a model for the given provider and mode (quick/deep)."""
if provider.lower() == "openrouter":
return select_openrouter_model(mode)
listed = LISTED_LIVE.get(provider.lower())
if listed is not None:
return listed(mode, default)
if provider.lower() == "azure":
return _require_text(
@@ -320,13 +345,7 @@ def _select_model(provider: str, mode: str, default=None) -> str:
],
default=_matching_choice(get_model_options(provider, mode), default),
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=questionary.Style(
[
("selected", "fg:magenta noinherit"),
("highlighted", "fg:magenta noinherit"),
("pointer", "fg:magenta noinherit"),
]
),
style=_MENU_STYLE,
).ask()
if choice is None:
@@ -363,6 +382,7 @@ def _llm_provider_table() -> list[tuple[str, str, str | None]]:
("OpenAI", "openai", "https://api.openai.com/v1"),
("Google", "google", None),
("Anthropic", "anthropic", "https://api.anthropic.com/"),
("ChatGPT (Plus or Pro plan)", "chatgpt", "https://api.openai.com/v1"),
("xAI", "xai", "https://api.x.ai/v1"),
("DeepSeek", "deepseek", "https://api.deepseek.com"),
("Qwen", "qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"),
@@ -419,37 +439,50 @@ def prompt_openai_compatible_url(default=None) -> str:
return url.strip()
# The menu entry that groups the providers used through a personal plan.
_SUBSCRIPTION = ("subscription", None)
def select_llm_provider(default=None) -> tuple[str, str | None]:
"""Select the LLM provider and its API endpoint."""
PROVIDERS = _llm_provider_table()
# A region-specific key (qwen-cn) is chosen in a later prompt; the menu
# lists the base provider.
base = (default or "").split("-cn")[0]
preselected = next(
choices = []
for display, provider_key, url in PROVIDERS:
if provider_key in SIGNED_IN_PROVIDERS:
continue
choices.append(questionary.Choice(display, value=(provider_key, url)))
if provider_key == "anthropic":
choices.append(questionary.Choice("Subscription (use your ChatGPT plan)", value=_SUBSCRIPTION))
preselected = _SUBSCRIPTION if base in SIGNED_IN_PROVIDERS else next(
((key, url) for _, key, url in PROVIDERS if key == base), None
)
choice = questionary.select(
"Select your LLM Provider:",
choices=[
questionary.Choice(display, value=(provider_key, url))
for display, provider_key, url in PROVIDERS
],
choices=choices,
default=preselected,
instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=questionary.Style(
[
("selected", "fg:magenta noinherit"),
("highlighted", "fg:magenta noinherit"),
("pointer", "fg:magenta noinherit"),
]
),
style=_MENU_STYLE,
).ask()
if choice is None:
console.print("\n[red]No LLM provider selected. Exiting...[/red]")
exit(1)
if choice == _SUBSCRIPTION:
choice = questionary.select(
"Select your subscription:",
choices=[questionary.Choice(display, value=(key, url))
for display, key, url in PROVIDERS if key in SIGNED_IN_PROVIDERS],
style=_MENU_STYLE,
).ask()
if choice is None:
console.print("\n[red]No subscription selected. Exiting...[/red]")
exit(1)
provider, url = choice
return provider, url
@@ -624,6 +657,32 @@ def confirm_ollama_endpoint(url: str) -> None:
)
def ensure_signed_in(provider: str) -> None:
"""Make sure a plan provider's sign-in works before the run, offering to sign in now.
Signing in runs as its own process (``tradingagents login chatgpt``), so its
read of a pasted address ends with it and never takes a later answer."""
if provider.lower() not in SIGNED_IN_PROVIDERS:
return
from tradingagents.llm_clients import chatgpt_auth # the one signed-in provider
try:
chatgpt_auth.access_token()
except chatgpt_auth.ChatGPTUnreachable as exc:
console.print(f"[red]{escape(str(exc))}[/red]")
raise typer.Exit(code=1) from None
except chatgpt_auth.ChatGPTAuthError as exc:
if not sys.stdin.isatty():
console.print(f"[red]{escape(str(exc))}[/red]")
raise typer.Exit(code=1) from None
console.print(f"\n[yellow]{escape(str(exc))}[/yellow]")
# -P keeps a cli/ package in the working directory from shadowing ours.
login = [sys.executable, "-P", "-m", "cli.main", "login", "chatgpt"]
if subprocess.run(login).returncode != 0:
raise typer.Exit(code=1) from None
console.print(f"[green]✓ Using your ChatGPT plan:[/green] {escape(chatgpt_auth.signed_in_as() or '')}")
def ensure_api_key(provider: str) -> str | None:
"""Make sure the API key for `provider` is available in the environment.
@@ -641,7 +700,6 @@ def ensure_api_key(provider: str) -> str | None:
# Key-optional providers (generic OpenAI-compatible / local servers) read the
# key when present but must never force an interactive prompt.
from tradingagents.llm_clients.openai_client import OPENAI_COMPATIBLE_PROVIDERS
spec = OPENAI_COMPATIBLE_PROVIDERS.get(provider.lower())
if spec is not None and spec.key_optional:
return os.environ.get(env_var)
+19 -7
View File
@@ -24,6 +24,7 @@ from cli.prompts import (
confirm_ollama_endpoint,
detect_asset_type,
ensure_api_key,
ensure_signed_in,
get_ticker,
parse_analysis_date,
parse_analysts,
@@ -37,6 +38,8 @@ from cli.prompts import (
select_shallow_thinking_agent,
)
from tradingagents.default_config import DEFAULT_CONFIG
from tradingagents.llm_clients.factory import REASONING_EFFORT_PROVIDERS
from tradingagents.llm_clients.openai_client import check_endpoint
def get_user_selections(flags=None):
@@ -68,26 +71,35 @@ def unattended_gaps(flags) -> list[str]:
return gaps
def _check_tier_providers(main_provider: str) -> None:
def _check_tier_providers(main_provider: str, backend_url: str | None = None) -> None:
"""Check each provider the model tiers use (#1440) before the run.
A tier on another provider needs its model from its variable, since the
model question offers the main provider's models. Each provider a tier uses
has its key checked now, prompting for a missing one, rather than at its
first call, which for the deep tier comes after everything else has been
paid for; a provider no tier uses needs no key.
has its endpoint, sign-in and key checked now, prompting for a missing key,
rather than at its first call, which for the deep tier comes after everything
else has been paid for; a provider no tier uses needs no key.
"""
used = []
for tier in ("quick", "deep"):
provider = (DEFAULT_CONFIG.get(f"{tier}_think_provider") or main_provider).lower()
if provider != main_provider.lower():
on_main = provider == main_provider.lower()
if not on_main:
variable = f"TRADINGAGENTS_{tier.upper()}_THINK_LLM"
if not os.environ.get(variable):
console.print(f"[red]The {tier} tier runs on {provider}; set {variable} to one of its models.[/red]")
raise typer.Exit(code=1)
# The tier's endpoint, as create_tier_client resolves it.
url = DEFAULT_CONFIG.get(f"{tier}_think_backend_url") or (backend_url if on_main else None)
try:
check_endpoint(provider, url)
except ValueError as exc:
console.print(f"[red]{exc}[/red]")
raise typer.Exit(code=1) from None
if provider not in used:
used.append(provider)
for provider in used:
ensure_signed_in(provider)
ensure_api_key(provider)
@@ -285,7 +297,7 @@ def _prompt_selections(prefs, flags):
confirm_ollama_endpoint(backend_url)
_check_tier_providers(selected_llm_provider)
_check_tier_providers(selected_llm_provider, backend_url)
# Step 7: Thinking agents (skipped when either model is set via environment)
if os.environ.get("TRADINGAGENTS_QUICK_THINK_LLM") or os.environ.get("TRADINGAGENTS_DEEP_THINK_LLM"):
@@ -329,7 +341,7 @@ def _prompt_selections(prefs, flags):
"Gemini thinking mode", "Step 8: Thinking Mode",
"Configure Gemini thinking mode", ask_gemini_thinking_config,
)
elif provider_lower == "openai":
elif provider_lower in REASONING_EFFORT_PROVIDERS:
reasoning_effort = thinking_value_or_prompt(
"TRADINGAGENTS_OPENAI_REASONING_EFFORT", "openai_reasoning_effort",
"Reasoning effort", "Step 8: Reasoning Effort",
+245
View File
@@ -0,0 +1,245 @@
"""The chatgpt provider runs on the person's ChatGPT plan.
It sends their sign-in and nothing else, only to OpenAI, in the shape plan usage
accepts (streamed, unstored, no ``system`` role); the CLI offers it under
"Subscription", checks the sign-in before a run, and lists the plan's models.
"""
import logging
from types import SimpleNamespace
from unittest import mock
import pytest
import typer
from langchain_core.messages import HumanMessage, SystemMessage
import cli.prompts as prompts
import cli.selections as selections
from tradingagents import default_config
from tradingagents.llm_clients import chatgpt_auth, factory
from tradingagents.llm_clients.openai_client import ChatGPTPlanChatOpenAI
@pytest.fixture
def signed_in(monkeypatch):
monkeypatch.setattr(chatgpt_auth, "credential", lambda: (lambda: "plan-token"))
monkeypatch.setattr(chatgpt_auth, "access_token", lambda: "plan-token")
monkeypatch.setattr(chatgpt_auth, "signed_in_as", lambda: "a@example.com")
def _llm(**kwargs):
config = {**default_config.DEFAULT_CONFIG, "llm_provider": "chatgpt", "openai_reasoning_effort": "high", **kwargs}
return factory.create_llm_client("chatgpt", "gpt-5.5", **factory.build_llm_kwargs(config)).get_llm()
# --- the client ------------------------------------------------------------------
@pytest.mark.unit
def test_the_plan_is_reached_with_the_sign_in_never_an_api_key(signed_in, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-should-not-be-used")
llm = _llm()
assert isinstance(llm, ChatGPTPlanChatOpenAI)
assert llm.openai_api_base == "https://api.openai.com/v1"
assert llm.openai_api_key() == "plan-token"
assert (llm.use_responses_api, llm.streaming, llm.store, llm.reasoning_effort) == (True, True, False, "high")
@pytest.mark.unit
def test_system_instructions_go_as_developer_and_refused_settings_are_dropped(signed_in, caplog):
with caplog.at_level(logging.WARNING):
llm = _llm(temperature=0.3, max_tokens=500)
payload = llm._get_request_payload([SystemMessage("Be brief."), HumanMessage("Hi")])
assert [item["role"] for item in payload["input"]] == ["developer", "user"]
assert not {"temperature", "max_output_tokens"} & payload.keys()
assert payload["stream"] is True and payload["store"] is False
assert "does not accept temperature" in caplog.text and "does not accept max_tokens" in caplog.text
@pytest.mark.unit
def test_a_run_without_a_sign_in_stops_before_any_request(monkeypatch, tmp_path):
monkeypatch.setattr(chatgpt_auth, "AUTH_FILE", tmp_path / "chatgpt-auth.json")
with pytest.raises(chatgpt_auth.ChatGPTAuthError, match="tradingagents login chatgpt"):
_llm()
@pytest.mark.unit
def test_a_chatgpt_tier_beside_an_openai_tier_keeps_each_credential(signed_in, monkeypatch):
monkeypatch.setenv("OPENAI_API_KEY", "sk-openai")
config = {**default_config.DEFAULT_CONFIG, "llm_provider": "openai", "backend_url": "https://api.openai.com/v1",
"quick_think_provider": "chatgpt", "quick_think_llm": "gpt-5.5", "deep_think_llm": "gpt-6-sol",
"openai_reasoning_effort": "low"}
quick = factory.create_tier_client(config, "quick").get_llm()
deep = factory.create_tier_client(config, "deep").get_llm()
assert isinstance(quick, ChatGPTPlanChatOpenAI) and quick.reasoning_effort == "low"
assert quick.openai_api_key() == "plan-token"
assert not isinstance(deep, ChatGPTPlanChatOpenAI)
assert deep.openai_api_key.get_secret_value() == "sk-openai"
# --- the CLI ---------------------------------------------------------------------
def _menu(answers):
"""questionary.select stand-in: records each menu and answers in turn."""
shown = []
def select(message, choices, **kwargs):
shown.append((message, [c.title for c in choices], kwargs.get("default")))
return SimpleNamespace(ask=lambda: answers.pop(0)(choices))
return shown, select
@pytest.mark.unit
def test_chatgpt_is_offered_under_subscription_after_anthropic():
shown, select = _menu([lambda cs: next(c.value for c in cs if c.title.startswith("Subscription")),
lambda cs: cs[0].value])
with mock.patch("cli.prompts.questionary.select", select):
provider, url = prompts.select_llm_provider()
titles = shown[0][1]
assert titles.index("Anthropic") + 1 == titles.index(next(t for t in titles if t.startswith("Subscription")))
assert not any(t.startswith("ChatGPT") for t in titles)
assert shown[1][1] == ["ChatGPT (Plus or Pro plan)"]
assert (provider, url) == ("chatgpt", "https://api.openai.com/v1")
@pytest.mark.unit
def test_a_remembered_chatgpt_choice_preselects_subscription():
shown, select = _menu([lambda cs: ("openai", "https://api.openai.com/v1")])
with mock.patch("cli.prompts.questionary.select", select):
prompts.select_llm_provider(default="chatgpt")
assert shown[0][2] == ("subscription", None)
@pytest.mark.unit
def test_a_signed_in_plan_is_named_before_the_run(signed_in, capsys):
prompts.ensure_signed_in("chatgpt")
assert "Using your ChatGPT plan" in capsys.readouterr().out
def _expired(monkeypatch):
def refuse():
raise chatgpt_auth.ChatGPTAuthError("Your ChatGPT sign-in could not be renewed (invalid_grant); "
"run `tradingagents login chatgpt` again.")
monkeypatch.setattr(chatgpt_auth, "access_token", refuse)
@pytest.mark.unit
def test_an_unattended_run_with_a_dead_sign_in_says_how_to_sign_in(monkeypatch, capsys):
_expired(monkeypatch)
monkeypatch.setattr("sys.stdin", SimpleNamespace(isatty=lambda: False))
with pytest.raises(typer.Exit):
prompts.ensure_signed_in("chatgpt")
assert "tradingagents login chatgpt" in " ".join(capsys.readouterr().out.split())
@pytest.mark.unit
def test_an_interactive_run_signs_in_again_through_the_login_command(monkeypatch, capsys):
_expired(monkeypatch)
monkeypatch.setattr(chatgpt_auth, "signed_in_as", lambda: "a@example.com")
monkeypatch.setattr("sys.stdin", SimpleNamespace(isatty=lambda: True))
ran = []
monkeypatch.setattr(prompts.subprocess, "run", lambda argv: ran.append(argv) or SimpleNamespace(returncode=0))
prompts.ensure_signed_in("chatgpt")
assert ran[0][1:] == ["-P", "-m", "cli.main", "login", "chatgpt"]
assert "Using your ChatGPT plan" in capsys.readouterr().out
@pytest.mark.unit
def test_each_tier_s_provider_is_signed_in_or_keyed(monkeypatch):
monkeypatch.setitem(selections.DEFAULT_CONFIG, "quick_think_provider", "chatgpt")
monkeypatch.setitem(selections.DEFAULT_CONFIG, "deep_think_provider", None)
monkeypatch.setenv("TRADINGAGENTS_QUICK_THINK_LLM", "gpt-5.5")
signed, keyed = [], []
monkeypatch.setattr(selections, "ensure_signed_in", signed.append)
monkeypatch.setattr(selections, "ensure_api_key", keyed.append)
selections._check_tier_providers("openai")
assert signed == ["chatgpt", "openai"] and keyed == ["chatgpt", "openai"]
@pytest.mark.unit
def test_the_plan_s_listed_models_are_offered_in_openai_s_order(signed_in, monkeypatch):
monkeypatch.setattr(chatgpt_auth, "access_token", lambda: "plan-token")
body = {"models": [
{"slug": "gpt-5.5", "display_name": "GPT-5.5", "visibility": "list", "priority": 9},
{"slug": "internal", "display_name": "Internal", "visibility": "hide", "priority": 0},
{"slug": "gpt-6-astra", "display_name": "GPT-6-Astra", "visibility": "list", "priority": 2},
]}
response = SimpleNamespace(raise_for_status=lambda: None, json=lambda: body)
with mock.patch("requests.get", return_value=response) as get:
assert chatgpt_auth.list_models() == [("GPT-6-Astra", "gpt-6-astra"), ("GPT-5.5", "gpt-5.5")]
assert get.call_args.kwargs["headers"] == {"Authorization": "Bearer plan-token"}
@pytest.mark.unit
def test_api_organization_and_project_do_not_travel_with_the_sign_in(signed_in, monkeypatch):
monkeypatch.setenv("OPENAI_ORG_ID", "org-for-api-keys")
monkeypatch.setenv("OPENAI_PROJECT_ID", "proj-for-api-keys")
llm = _llm()
for client in (llm.root_client, llm.root_async_client):
sent = {k: v for k, v in client.default_headers.items() if isinstance(v, str)}
assert "OpenAI-Organization" not in sent and "OpenAI-Project" not in sent
@pytest.mark.unit
def test_a_remembered_plan_model_is_offered_again():
from cli.prefs import sanitize
kept = sanitize({"llm_provider": "chatgpt", "quick_think_llm": "gpt-5.5", "deep_think_llm": "gpt-6-astra"}, "stock")
assert (kept["quick_think_llm"], kept["deep_think_llm"]) == ("gpt-5.5", "gpt-6-astra")
@pytest.mark.unit
def test_a_malformed_model_entry_does_not_hide_the_others(signed_in):
body = {"models": [{"display_name": "No slug", "visibility": "list"},
{"slug": "gpt-5.5", "display_name": "GPT-5.5", "visibility": "list"}]}
response = SimpleNamespace(raise_for_status=lambda: None, json=lambda: body)
with mock.patch("requests.get", return_value=response):
assert chatgpt_auth.list_models() == [("GPT-5.5", "gpt-5.5")]
@pytest.mark.unit
def test_an_unreachable_openai_stops_the_run_without_a_new_sign_in(monkeypatch, capsys):
def unreachable():
raise chatgpt_auth.ChatGPTUnreachable("Could not reach OpenAI to renew your ChatGPT sign-in; try again.")
monkeypatch.setattr(chatgpt_auth, "access_token", unreachable)
monkeypatch.setattr("sys.stdin", SimpleNamespace(isatty=lambda: True))
monkeypatch.setattr(prompts.subprocess, "run", lambda argv: pytest.fail("offered a new sign-in"))
with pytest.raises(typer.Exit):
prompts.ensure_signed_in("chatgpt")
assert "try again" in capsys.readouterr().out
@pytest.mark.unit
@pytest.mark.parametrize("main, tier_provider, tier_url, backend_url", [
("chatgpt", None, None, "http://localhost:8000/v1"), # the run's own endpoint
("openai", "chatgpt", "http://relay.example/v1", None), # a chatgpt tier's endpoint
])
def test_a_plan_pointed_at_another_endpoint_stops_before_the_run(monkeypatch, capsys, main, tier_provider,
tier_url, backend_url):
monkeypatch.setitem(selections.DEFAULT_CONFIG, "quick_think_provider", tier_provider)
monkeypatch.setitem(selections.DEFAULT_CONFIG, "quick_think_backend_url", tier_url)
monkeypatch.setitem(selections.DEFAULT_CONFIG, "deep_think_provider", None)
monkeypatch.setenv("TRADINGAGENTS_QUICK_THINK_LLM", "gpt-6-luna")
monkeypatch.setattr(selections, "ensure_signed_in", lambda p: pytest.fail("checked the sign-in first"))
with pytest.raises(typer.Exit):
selections._check_tier_providers(main, backend_url)
assert "only connects to https://api.openai.com/v1" in " ".join(capsys.readouterr().out.split())
@pytest.mark.unit
def test_a_remembered_openrouter_model_is_preselected(monkeypatch):
monkeypatch.setattr(prompts, "_fetch_openrouter_models",
lambda: [("A", "openai/a"), ("B", "anthropic/b")])
shown, select = _menu([lambda cs: cs[1].value])
with mock.patch("cli.prompts.questionary.select", select):
assert prompts.select_openrouter_model("deep", default="anthropic/b") == "anthropic/b"
assert shown[0][2] == "anthropic/b"
@pytest.mark.unit
def test_the_chatgpt_plan_is_the_only_provider_signed_in_to():
# `login`, `logout` and the pre-run check are wired to the ChatGPT sign-in; a
# second signed-in provider needs its own sign-in wired into them first.
from tradingagents.llm_clients.openai_client import SIGNED_IN_PROVIDERS
assert SIGNED_IN_PROVIDERS == ("chatgpt",)
+2
View File
@@ -35,6 +35,8 @@ PROVIDER_API_KEY_ENV: dict[str, str | None] = {
"kimi": "MOONSHOT_API_KEY",
"groq": "GROQ_API_KEY",
"nvidia": "NVIDIA_API_KEY",
# A ChatGPT plan authenticates with the person's sign-in, not a key.
"chatgpt": None,
# Local runtimes do not authenticate.
"ollama": None,
# Generic OpenAI-compatible endpoint: the client reads this when set (keyed
+12
View File
@@ -251,6 +251,18 @@ def credential() -> Callable[[], str]:
return access_token
def list_models() -> list[tuple[str, str]]:
"""(name, model ID) for each model the signed-in plan offers, in OpenAI's order."""
response = requests.get(f"{API_BASE}/models", timeout=10,
headers={"Authorization": f"Bearer {access_token()}"})
response.raise_for_status()
models = [m for m in response.json().get("models", [])
if isinstance(m, dict) and isinstance(m.get("slug"), str)
and m.get("visibility") == "list" and m.get("supported_in_api", True)]
models.sort(key=lambda m: m.get("priority") or 0)
return [(m.get("display_name") or m["slug"], m["slug"]) for m in models]
# --- signing in and out --------------------------------------------------------------
class _Callback:
+5 -1
View File
@@ -87,6 +87,10 @@ def _coerce_max_tokens(value):
return n
# Providers that take OpenAI's ``openai_reasoning_effort``.
REASONING_EFFORT_PROVIDERS = ("openai", "chatgpt")
def build_llm_kwargs(config: dict) -> dict[str, Any]:
"""Keyword arguments for ``create_llm_client`` from a TradingAgents config."""
kwargs = {}
@@ -97,7 +101,7 @@ def build_llm_kwargs(config: dict) -> dict[str, Any]:
if thinking_level:
kwargs["thinking_level"] = thinking_level
elif provider == "openai":
elif provider in REASONING_EFFORT_PROVIDERS:
reasoning_effort = config.get("openai_reasoning_effort")
if reasoning_effort:
kwargs["reasoning_effort"] = reasoning_effort
@@ -221,6 +221,8 @@ MODEL_OPTIONS: ProviderModeOptions = {
"nvidia": _CUSTOM_ONLY,
# Bedrock model IDs / cross-region inference profile IDs are user-specified.
"bedrock": _CUSTOM_ONLY,
# The plan's models are listed live from OpenAI once signed in.
"chatgpt": _CUSTOM_ONLY,
}
@@ -1,3 +1,4 @@
import logging
import os
import re
from collections.abc import Callable
@@ -13,6 +14,8 @@ from .base_client import BaseLLMClient, normalize_content
from .capabilities import get_capabilities
from .validators import validate_model
logger = logging.getLogger(__name__)
class NormalizedChatOpenAI(ChatOpenAI):
"""ChatOpenAI with normalized content output and capability-aware binding.
@@ -163,6 +166,48 @@ class MinimaxChatOpenAI(NormalizedChatOpenAI):
return payload
# Settings a ChatGPT plan refuses.
_PLAN_REFUSED = ("temperature", "top_p", "max_tokens")
class ChatGPTPlanChatOpenAI(NormalizedChatOpenAI):
"""The person's ChatGPT plan through OpenAI's Responses API.
Plan usage accepts no stored responses and no ``system`` role, and refuses
sampling and length settings. Responses stream and are not stored; system
instructions are sent as ``developer`` messages, OpenAI's name for the same
role; refused settings are dropped with a warning; no API organization or
project is sent.
"""
def __init__(self, **kwargs):
for name in _PLAN_REFUSED:
if kwargs.pop(name, None) is not None:
logger.warning("A ChatGPT plan does not accept %s; it is not sent.", name)
kwargs.setdefault("streaming", True)
kwargs.setdefault("store", False)
super().__init__(**kwargs)
# The sign-in names the account; an organization or project set for API
# keys (OPENAI_ORG_ID, OPENAI_PROJECT_ID) must not travel with it.
self.openai_organization = None
for client in (self.root_client, self.root_async_client):
client.organization = None
client.project = None
def _get_request_payload(self, input_, *, stop=None, **kwargs):
payload = super()._get_request_payload(input_, stop=stop, **kwargs)
for item in payload.get("input") or []:
if isinstance(item, dict) and item.get("role") == "system":
item["role"] = "developer"
return payload
def _chatgpt_credential() -> Callable[[], str]:
from .chatgpt_auth import credential
return credential()
# Kwargs forwarded from user config to ChatOpenAI
_PASSTHROUGH_KWARGS = (
"timeout", "max_retries", "reasoning_effort", "temperature", "max_tokens",
@@ -233,6 +278,10 @@ OPENAI_COMPATIBLE_PROVIDERS: dict[str, ProviderSpec] = {
"ollama": ProviderSpec(base_url="http://localhost:11434/v1", base_url_env="OLLAMA_BASE_URL",
key_optional=True, placeholder_key="ollama",
chat_class=LocalCompatibleChatOpenAI),
# The person's ChatGPT plan, through their own sign-in.
"chatgpt": ProviderSpec(base_url="https://api.openai.com/v1", chat_class=ChatGPTPlanChatOpenAI,
use_responses_api=True, credential=_chatgpt_credential,
pinned_base_url=True),
# Generic endpoint: user supplies base_url; key optional (keyless local).
"openai_compatible": ProviderSpec(
require_base_url=True, key_optional=True, chat_class=LocalCompatibleChatOpenAI
@@ -240,6 +289,10 @@ OPENAI_COMPATIBLE_PROVIDERS: dict[str, ProviderSpec] = {
}
# Providers the user signs in to instead of setting an API key.
SIGNED_IN_PROVIDERS = tuple(key for key, spec in OPENAI_COMPATIBLE_PROVIDERS.items() if spec.credential)
def check_endpoint(provider: str, base_url: str | None) -> None:
"""Raise ValueError when a provider pinned to its own endpoint is pointed elsewhere."""
spec = OPENAI_COMPATIBLE_PROVIDERS.get(provider.lower())
+1 -1
View File
@@ -7,7 +7,7 @@ from .model_catalog import get_known_models
# accepted without warning.
_ANY_MODEL_PROVIDERS = (
"ollama", "openrouter", "openai_compatible",
"mistral", "kimi", "groq", "nvidia", "bedrock",
"mistral", "kimi", "groq", "nvidia", "bedrock", "chatgpt",
)
VALID_MODELS = {