mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-11-05 09:02:47 +02:00
- 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
246 lines
12 KiB
Python
246 lines
12 KiB
Python
"""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",)
|