mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-11-03 16:12:44 +02:00
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:
+3
-2
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user