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",
|
||||
|
||||
@@ -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",)
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user