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 cli.run import run_analysis
from tradingagents.backtest import iter_grid, run_backtest, summarize from tradingagents.backtest import iter_grid, run_backtest, summarize
from tradingagents.default_config import DEFAULT_CONFIG from tradingagents.default_config import DEFAULT_CONFIG
from tradingagents.llm_clients.openai_client import SIGNED_IN_PROVIDERS
from tradingagents.portfolio import load_portfolio from tradingagents.portfolio import load_portfolio
# prompt_toolkit's win32 output module is importable only on Windows (it asserts # 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}") console.print(f"[yellow]unsettled:[/yellow] {ticker}: {reason}")
class Plan(StrEnum): # The plans `login` and `logout` take: the providers signed in to, not keyed.
chatgpt = "chatgpt" Plan = StrEnum("Plan", {name: name for name in SIGNED_IN_PROVIDERS})
@app.command() @app.command()
+4 -2
View File
@@ -21,7 +21,7 @@ import os
from pathlib import Path from pathlib import Path
from cli.models import AnalystType, AssetType 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.dataflows.files import replace_file
from tradingagents.llm_clients.model_catalog import get_model_options 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)} offered = {model for _, model in get_model_options(base, mode)}
except KeyError: except KeyError:
continue 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] kept[field] = prefs[field]
return kept return kept
+103 -45
View File
@@ -1,16 +1,19 @@
import datetime import datetime
import os import os
import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
import questionary import questionary
import typer import typer
from dotenv import find_dotenv, set_key from dotenv import find_dotenv, set_key
from rich.markup import escape
from cli.display import console from cli.display import console
from cli.models import AnalystType, AssetType from cli.models import AnalystType, AssetType
from tradingagents.llm_clients.api_key_env import get_api_key_env 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.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" TICKER_INPUT_EXAMPLES = "SPY, 0700.HK, BTC-USD"
@@ -24,6 +27,14 @@ ANALYST_CHOICES = [
CRYPTO_SUFFIXES = ("-USD", "-USDT", "-USDC", "-BTC", "-ETH") 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: def is_valid_ticker_input(value: str) -> bool:
"""Whether a ticker entry is acceptable (charset + length). """Whether a ticker entry is acceptable (charset + length).
@@ -238,6 +249,35 @@ def _fetch_openrouter_models() -> list[tuple[str, str]]:
return [] 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: def _require_text(message: str, hint: str) -> str:
"""Prompt for a required value; exit cleanly if the user cancels. """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() 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. """Select an OpenRouter model from the newest available, or enter a custom ID.
``mode`` ("quick"/"deep") labels the prompt so the two consecutive ``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 and mid.split("/", 1)[0] in _OPENROUTER_MAINSTREAM
] ]
top = (mainstream or models)[:5] 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( # Providers whose models are listed live, with the menu that offers them.
f"Select Your [{mode.title()}-Thinking] OpenRouter Model (latest available):", LISTED_LIVE = {"openrouter": select_openrouter_model, "chatgpt": select_chatgpt_model}
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
def _prompt_custom_model_id() -> str: 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: def _select_model(provider: str, mode: str, default=None) -> str:
"""Select a model for the given provider and mode (quick/deep).""" """Select a model for the given provider and mode (quick/deep)."""
if provider.lower() == "openrouter": listed = LISTED_LIVE.get(provider.lower())
return select_openrouter_model(mode) if listed is not None:
return listed(mode, default)
if provider.lower() == "azure": if provider.lower() == "azure":
return _require_text( 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), default=_matching_choice(get_model_options(provider, mode), default),
instruction="\n- Use arrow keys to navigate\n- Press Enter to select", instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=questionary.Style( style=_MENU_STYLE,
[
("selected", "fg:magenta noinherit"),
("highlighted", "fg:magenta noinherit"),
("pointer", "fg:magenta noinherit"),
]
),
).ask() ).ask()
if choice is None: 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"), ("OpenAI", "openai", "https://api.openai.com/v1"),
("Google", "google", None), ("Google", "google", None),
("Anthropic", "anthropic", "https://api.anthropic.com/"), ("Anthropic", "anthropic", "https://api.anthropic.com/"),
("ChatGPT (Plus or Pro plan)", "chatgpt", "https://api.openai.com/v1"),
("xAI", "xai", "https://api.x.ai/v1"), ("xAI", "xai", "https://api.x.ai/v1"),
("DeepSeek", "deepseek", "https://api.deepseek.com"), ("DeepSeek", "deepseek", "https://api.deepseek.com"),
("Qwen", "qwen", "https://dashscope-intl.aliyuncs.com/compatible-mode/v1"), ("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() 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]: def select_llm_provider(default=None) -> tuple[str, str | None]:
"""Select the LLM provider and its API endpoint.""" """Select the LLM provider and its API endpoint."""
PROVIDERS = _llm_provider_table() PROVIDERS = _llm_provider_table()
# A region-specific key (qwen-cn) is chosen in a later prompt; the menu # A region-specific key (qwen-cn) is chosen in a later prompt; the menu
# lists the base provider. # lists the base provider.
base = (default or "").split("-cn")[0] 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 ((key, url) for _, key, url in PROVIDERS if key == base), None
) )
choice = questionary.select( choice = questionary.select(
"Select your LLM Provider:", "Select your LLM Provider:",
choices=[ choices=choices,
questionary.Choice(display, value=(provider_key, url))
for display, provider_key, url in PROVIDERS
],
default=preselected, default=preselected,
instruction="\n- Use arrow keys to navigate\n- Press Enter to select", instruction="\n- Use arrow keys to navigate\n- Press Enter to select",
style=questionary.Style( style=_MENU_STYLE,
[
("selected", "fg:magenta noinherit"),
("highlighted", "fg:magenta noinherit"),
("pointer", "fg:magenta noinherit"),
]
),
).ask() ).ask()
if choice is None: if choice is None:
console.print("\n[red]No LLM provider selected. Exiting...[/red]") console.print("\n[red]No LLM provider selected. Exiting...[/red]")
exit(1) 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 provider, url = choice
return provider, url 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: def ensure_api_key(provider: str) -> str | None:
"""Make sure the API key for `provider` is available in the environment. """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-optional providers (generic OpenAI-compatible / local servers) read the
# key when present but must never force an interactive prompt. # 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()) spec = OPENAI_COMPATIBLE_PROVIDERS.get(provider.lower())
if spec is not None and spec.key_optional: if spec is not None and spec.key_optional:
return os.environ.get(env_var) return os.environ.get(env_var)
+19 -7
View File
@@ -24,6 +24,7 @@ from cli.prompts import (
confirm_ollama_endpoint, confirm_ollama_endpoint,
detect_asset_type, detect_asset_type,
ensure_api_key, ensure_api_key,
ensure_signed_in,
get_ticker, get_ticker,
parse_analysis_date, parse_analysis_date,
parse_analysts, parse_analysts,
@@ -37,6 +38,8 @@ from cli.prompts import (
select_shallow_thinking_agent, select_shallow_thinking_agent,
) )
from tradingagents.default_config import DEFAULT_CONFIG 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): def get_user_selections(flags=None):
@@ -68,26 +71,35 @@ def unattended_gaps(flags) -> list[str]:
return gaps 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. """Check each provider the model tiers use (#1440) before the run.
A tier on another provider needs its model from its variable, since the 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 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 has its endpoint, sign-in and key checked now, prompting for a missing key,
first call, which for the deep tier comes after everything else has been rather than at its first call, which for the deep tier comes after everything
paid for; a provider no tier uses needs no key. else has been paid for; a provider no tier uses needs no key.
""" """
used = [] used = []
for tier in ("quick", "deep"): for tier in ("quick", "deep"):
provider = (DEFAULT_CONFIG.get(f"{tier}_think_provider") or main_provider).lower() 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" variable = f"TRADINGAGENTS_{tier.upper()}_THINK_LLM"
if not os.environ.get(variable): if not os.environ.get(variable):
console.print(f"[red]The {tier} tier runs on {provider}; set {variable} to one of its models.[/red]") console.print(f"[red]The {tier} tier runs on {provider}; set {variable} to one of its models.[/red]")
raise typer.Exit(code=1) 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: if provider not in used:
used.append(provider) used.append(provider)
for provider in used: for provider in used:
ensure_signed_in(provider)
ensure_api_key(provider) ensure_api_key(provider)
@@ -285,7 +297,7 @@ def _prompt_selections(prefs, flags):
confirm_ollama_endpoint(backend_url) 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) # 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"): 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", "Gemini thinking mode", "Step 8: Thinking Mode",
"Configure Gemini thinking mode", ask_gemini_thinking_config, "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( reasoning_effort = thinking_value_or_prompt(
"TRADINGAGENTS_OPENAI_REASONING_EFFORT", "openai_reasoning_effort", "TRADINGAGENTS_OPENAI_REASONING_EFFORT", "openai_reasoning_effort",
"Reasoning effort", "Step 8: 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", "kimi": "MOONSHOT_API_KEY",
"groq": "GROQ_API_KEY", "groq": "GROQ_API_KEY",
"nvidia": "NVIDIA_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. # Local runtimes do not authenticate.
"ollama": None, "ollama": None,
# Generic OpenAI-compatible endpoint: the client reads this when set (keyed # 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 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 -------------------------------------------------------------- # --- signing in and out --------------------------------------------------------------
class _Callback: class _Callback:
+5 -1
View File
@@ -87,6 +87,10 @@ def _coerce_max_tokens(value):
return n return n
# Providers that take OpenAI's ``openai_reasoning_effort``.
REASONING_EFFORT_PROVIDERS = ("openai", "chatgpt")
def build_llm_kwargs(config: dict) -> dict[str, Any]: def build_llm_kwargs(config: dict) -> dict[str, Any]:
"""Keyword arguments for ``create_llm_client`` from a TradingAgents config.""" """Keyword arguments for ``create_llm_client`` from a TradingAgents config."""
kwargs = {} kwargs = {}
@@ -97,7 +101,7 @@ def build_llm_kwargs(config: dict) -> dict[str, Any]:
if thinking_level: if thinking_level:
kwargs["thinking_level"] = thinking_level kwargs["thinking_level"] = thinking_level
elif provider == "openai": elif provider in REASONING_EFFORT_PROVIDERS:
reasoning_effort = config.get("openai_reasoning_effort") reasoning_effort = config.get("openai_reasoning_effort")
if reasoning_effort: if reasoning_effort:
kwargs["reasoning_effort"] = reasoning_effort kwargs["reasoning_effort"] = reasoning_effort
@@ -221,6 +221,8 @@ MODEL_OPTIONS: ProviderModeOptions = {
"nvidia": _CUSTOM_ONLY, "nvidia": _CUSTOM_ONLY,
# Bedrock model IDs / cross-region inference profile IDs are user-specified. # Bedrock model IDs / cross-region inference profile IDs are user-specified.
"bedrock": _CUSTOM_ONLY, "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 os
import re import re
from collections.abc import Callable from collections.abc import Callable
@@ -13,6 +14,8 @@ from .base_client import BaseLLMClient, normalize_content
from .capabilities import get_capabilities from .capabilities import get_capabilities
from .validators import validate_model from .validators import validate_model
logger = logging.getLogger(__name__)
class NormalizedChatOpenAI(ChatOpenAI): class NormalizedChatOpenAI(ChatOpenAI):
"""ChatOpenAI with normalized content output and capability-aware binding. """ChatOpenAI with normalized content output and capability-aware binding.
@@ -163,6 +166,48 @@ class MinimaxChatOpenAI(NormalizedChatOpenAI):
return payload 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 # Kwargs forwarded from user config to ChatOpenAI
_PASSTHROUGH_KWARGS = ( _PASSTHROUGH_KWARGS = (
"timeout", "max_retries", "reasoning_effort", "temperature", "max_tokens", "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", "ollama": ProviderSpec(base_url="http://localhost:11434/v1", base_url_env="OLLAMA_BASE_URL",
key_optional=True, placeholder_key="ollama", key_optional=True, placeholder_key="ollama",
chat_class=LocalCompatibleChatOpenAI), 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). # Generic endpoint: user supplies base_url; key optional (keyless local).
"openai_compatible": ProviderSpec( "openai_compatible": ProviderSpec(
require_base_url=True, key_optional=True, chat_class=LocalCompatibleChatOpenAI 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: def check_endpoint(provider: str, base_url: str | None) -> None:
"""Raise ValueError when a provider pinned to its own endpoint is pointed elsewhere.""" """Raise ValueError when a provider pinned to its own endpoint is pointed elsewhere."""
spec = OPENAI_COMPATIBLE_PROVIDERS.get(provider.lower()) 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. # accepted without warning.
_ANY_MODEL_PROVIDERS = ( _ANY_MODEL_PROVIDERS = (
"ollama", "openrouter", "openai_compatible", "ollama", "openrouter", "openai_compatible",
"mistral", "kimi", "groq", "nvidia", "bedrock", "mistral", "kimi", "groq", "nvidia", "bedrock", "chatgpt",
) )
VALID_MODELS = { VALID_MODELS = {