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",