diff --git a/cli/main.py b/cli/main.py index d6c6d6fbf..ecf003018 100644 --- a/cli/main.py +++ b/cli/main.py @@ -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() diff --git a/cli/prefs.py b/cli/prefs.py index af2e8eb5c..7f9d78017 100644 --- a/cli/prefs.py +++ b/cli/prefs.py @@ -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 diff --git a/cli/prompts.py b/cli/prompts.py index 549e63e49..021ee7a4c 100644 --- a/cli/prompts.py +++ b/cli/prompts.py @@ -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) diff --git a/cli/selections.py b/cli/selections.py index 2f9d43e51..76426dc38 100644 --- a/cli/selections.py +++ b/cli/selections.py @@ -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", diff --git a/tests/test_chatgpt_provider.py b/tests/test_chatgpt_provider.py new file mode 100644 index 000000000..de02ce680 --- /dev/null +++ b/tests/test_chatgpt_provider.py @@ -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",) diff --git a/tradingagents/llm_clients/api_key_env.py b/tradingagents/llm_clients/api_key_env.py index 3c11cf8d6..058b4ff4e 100644 --- a/tradingagents/llm_clients/api_key_env.py +++ b/tradingagents/llm_clients/api_key_env.py @@ -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 diff --git a/tradingagents/llm_clients/chatgpt_auth.py b/tradingagents/llm_clients/chatgpt_auth.py index 066212423..55bdbfa93 100644 --- a/tradingagents/llm_clients/chatgpt_auth.py +++ b/tradingagents/llm_clients/chatgpt_auth.py @@ -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: diff --git a/tradingagents/llm_clients/factory.py b/tradingagents/llm_clients/factory.py index 18f6a6860..dde1001bd 100644 --- a/tradingagents/llm_clients/factory.py +++ b/tradingagents/llm_clients/factory.py @@ -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 diff --git a/tradingagents/llm_clients/model_catalog.py b/tradingagents/llm_clients/model_catalog.py index d57ffe0a4..52bceb86e 100644 --- a/tradingagents/llm_clients/model_catalog.py +++ b/tradingagents/llm_clients/model_catalog.py @@ -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, } diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index 56dc50845..c23a2bece 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -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()) diff --git a/tradingagents/llm_clients/validators.py b/tradingagents/llm_clients/validators.py index b03cfb860..a473d3c7e 100644 --- a/tradingagents/llm_clients/validators.py +++ b/tradingagents/llm_clients/validators.py @@ -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 = {