From 609c8e80a181a1f73091237074f5186bea5d3565 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sat, 3 Oct 2026 22:22:57 +0000 Subject: [PATCH 01/10] chore: start 0.7.0 development (0.7.0.dev0) --- tradingagents/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tradingagents/__init__.py b/tradingagents/__init__.py index 4078b7954..785bee24f 100644 --- a/tradingagents/__init__.py +++ b/tradingagents/__init__.py @@ -1,6 +1,6 @@ """TradingAgents: multi-agent LLM financial trading framework.""" -__version__ = "0.6.0" +__version__ = "0.7.0.dev0" from dotenv import find_dotenv, load_dotenv From c9be8b6047d5b2bc269bce46ba426d8aa20a394f Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sat, 3 Oct 2026 22:28:36 +0000 Subject: [PATCH 02/10] ci: attach the source archive and wheel to each GitHub release - on a published release, or by hand for an earlier tag, build both from the tag, install the wheel in a clean environment, and upload them to the release --- .github/workflows/release-assets.yml | 59 ++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) create mode 100644 .github/workflows/release-assets.yml diff --git a/.github/workflows/release-assets.yml b/.github/workflows/release-assets.yml new file mode 100644 index 000000000..4f7864eae --- /dev/null +++ b/.github/workflows/release-assets.yml @@ -0,0 +1,59 @@ +name: Release assets + +# Builds the source archive and wheel from the release tag and attaches them to +# the GitHub release, so a version can be installed without cloning: +# pip install https://github.com/TauricResearch/TradingAgents/releases/download// +# Run it by hand with a tag to attach builds to an earlier release. + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: "Release tag to build and attach, for example v0.6.0" + required: true + +permissions: {} + +concurrency: + group: release-assets-${{ inputs.tag || github.event.release.tag_name }} + cancel-in-progress: false + +jobs: + build: + name: build and attach + runs-on: ubuntu-latest + permissions: + contents: write + env: + TAG: ${{ inputs.tag || github.event.release.tag_name }} + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v7 + with: + # Release branches share their tag's name, so name the tag explicitly. + ref: refs/tags/${{ env.TAG }} + - uses: actions/setup-python@v7 + with: + python-version: "3.13" + - name: Check the package version matches the tag + run: | + version=$(python -c "import re, pathlib; print(re.search(r'__version__ = \"([^\"]+)\"', pathlib.Path('tradingagents/__init__.py').read_text()).group(1))") + if [ "v$version" != "$TAG" ]; then + echo "tradingagents/__init__.py says $version, but the tag is $TAG" + exit 1 + fi + - name: Build the source archive and wheel + run: | + python -m pip install --upgrade build + python -m build + - name: Install the wheel in a clean environment + run: | + python -m venv /tmp/smoke + /tmp/smoke/bin/pip install dist/*.whl + # From another directory, so the imports come from the wheel, not the checkout. + cd /tmp + /tmp/smoke/bin/python -c "import importlib.resources as r, tradingagents, cli.main; assert (r.files('cli') / 'static' / 'welcome.txt').is_file(); print('wheel install OK', tradingagents.__version__)" + - name: Attach them to the release + run: gh release upload "$TAG" dist/* --clobber --repo "$GITHUB_REPOSITORY" From c3cdaeb4eb51bd0eb2be74b6989fbc2f227c226e Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sat, 3 Oct 2026 22:29:37 +0000 Subject: [PATCH 03/10] feat(cli): tell people at a terminal about a newer release and Tauric's notices - once a day a background thread records the latest GitHub release and the notices for each version; the welcome screen shows them from that cache, so no run waits on the network - the release notice names the upgrade command for how TradingAgents was installed - TRADINGAGENTS_NO_UPDATE_CHECK=1 turns the check off; unattended and CI runs never make it - notices are read from a new `notices` key, so earlier versions keep reading `announcements` --- Dockerfile | 4 +- cli/announcements.py | 53 ------ cli/config.py | 6 - cli/selections.py | 10 +- cli/updates.py | 220 ++++++++++++++++++++++++ pyproject.toml | 1 + tests/test_cli_env_skip.py | 9 +- tests/test_cli_headless.py | 19 +-- tests/test_cli_prefs.py | 6 +- tests/test_cli_updates.py | 337 +++++++++++++++++++++++++++++++++++++ 10 files changed, 574 insertions(+), 91 deletions(-) delete mode 100644 cli/announcements.py delete mode 100644 cli/config.py create mode 100644 cli/updates.py create mode 100644 tests/test_cli_updates.py diff --git a/Dockerfile b/Dockerfile index 19469e5c4..7d0006876 100644 --- a/Dockerfile +++ b/Dockerfile @@ -12,8 +12,10 @@ RUN pip install --no-cache-dir . FROM python:3.13-slim +# TRADINGAGENTS_IN_DOCKER lets an update notice name the rebuild command. ENV PYTHONDONTWRITEBYTECODE=1 \ - PYTHONUNBUFFERED=1 + PYTHONUNBUFFERED=1 \ + TRADINGAGENTS_IN_DOCKER=1 COPY --from=builder /opt/venv /opt/venv ENV PATH="/opt/venv/bin:$PATH" diff --git a/cli/announcements.py b/cli/announcements.py deleted file mode 100644 index a3c8223f4..000000000 --- a/cli/announcements.py +++ /dev/null @@ -1,53 +0,0 @@ -import getpass -import sys - -import requests -from rich.console import Console -from rich.panel import Panel - -from cli.config import CLI_CONFIG - - -def fetch_announcements(url: str = None, timeout: float = None) -> dict: - """Fetch announcements from endpoint. Returns dict with announcements and settings.""" - endpoint = url or CLI_CONFIG["announcements_url"] - timeout = timeout or CLI_CONFIG["announcements_timeout"] - fallback = CLI_CONFIG["announcements_fallback"] - - try: - response = requests.get(endpoint, timeout=timeout) - response.raise_for_status() - data = response.json() - return { - "announcements": data.get("announcements", [fallback]), - "require_attention": data.get("require_attention", False), - } - except Exception: - return { - "announcements": [fallback], - "require_attention": False, - } - - -def display_announcements(console: Console, data: dict) -> None: - """Display announcements panel. Prompts for Enter if require_attention is True.""" - announcements = data.get("announcements", []) - require_attention = data.get("require_attention", False) - - if not announcements: - return - - content = "\n".join(announcements) - - panel = Panel( - content, - border_style="cyan", - padding=(1, 2), - title="Announcements", - ) - console.print(panel) - - if require_attention and sys.stdin.isatty(): - getpass.getpass("Press Enter to continue...") - else: - console.print() diff --git a/cli/config.py b/cli/config.py deleted file mode 100644 index 08483f42a..000000000 --- a/cli/config.py +++ /dev/null @@ -1,6 +0,0 @@ -CLI_CONFIG = { - # Announcements - "announcements_url": "https://api.tauric.ai/v1/announcements", - "announcements_timeout": 1.0, - "announcements_fallback": "[cyan]For more information, please visit[/cyan] [link=https://github.com/TauricResearch]https://github.com/TauricResearch[/link]", -} diff --git a/cli/selections.py b/cli/selections.py index 2ef304d5c..2f9d43e51 100644 --- a/cli/selections.py +++ b/cli/selections.py @@ -8,7 +8,7 @@ import typer from rich.align import Align from rich.panel import Panel -from cli.announcements import display_announcements, fetch_announcements +from cli import updates from cli.display import ( console, ) @@ -124,9 +124,11 @@ def _prompt_selections(prefs, flags): console.print() console.print() # Add vertical space before announcements - # Fetch and display announcements (silent on failure) - announcements = fetch_announcements() - display_announcements(console, announcements) + # What's new comes from a cache refreshed in the background, and only for + # someone at a terminal: a run never waits on it and unattended runs skip it. + if updates.enabled(): + updates.show(console) + updates.start_background_refresh() def create_question_box(title, prompt, default=None): box_content = f"[bold]{title}[/bold]\n" diff --git a/cli/updates.py b/cli/updates.py new file mode 100644 index 000000000..70bffbc8f --- /dev/null +++ b/cli/updates.py @@ -0,0 +1,220 @@ +"""What's new, for the person at the terminal: a newer release, and notices from Tauric Research. + +At most once a day a background thread asks GitHub for the latest release and +the announcements server for notices, and writes the answers to a small cache. +The lines shown at start-up are read from that cache, so a run never waits on +the network and news shows up on the next run. Nothing identifying is sent. +Unattended, scripted and CI runs contact no server; set +TRADINGAGENTS_NO_UPDATE_CHECK=1 to turn the check off. +""" + +import importlib.metadata +import json +import os +import re +import shlex +import sys +import threading +import time +from collections.abc import Callable +from datetime import UTC, date, datetime +from pathlib import Path +from urllib.parse import unquote, urlparse + +import requests +from packaging.version import InvalidVersion, Version + +from tradingagents.dataflows.files import replace_file +from tradingagents.default_config import _TRADINGAGENTS_HOME + +RELEASES_URL = "https://api.github.com/repos/TauricResearch/TradingAgents/releases/latest" +NOTICES_URL = "https://api.tauric.ai/v1/announcements" +CACHE = Path(_TRADINGAGENTS_HOME) / "update_check.json" +CHECK_EVERY = 24 * 3600 +TIMEOUT = 2.0 +SUMMARY_LIMIT = 160 +OPT_OUT = "TRADINGAGENTS_NO_UPDATE_CHECK" +# Control characters, C0 and C1, read as spaces; invisible spaces and direction marks are dropped. +CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f]") +INVISIBLE = re.compile(r"[\u061c\u200b\u200e\u200f\u202a-\u202e\u2066-\u2069\ufeff]") +DATE = re.compile(r"\d{4}-\d{2}-\d{2}") + + +def enabled() -> bool: + """Whether a person is at the terminal and has not turned the check off.""" + at_a_terminal = bool(sys.stdin and sys.stdin.isatty() and sys.stdout and sys.stdout.isatty()) + return at_a_terminal and not os.environ.get("CI") and not os.environ.get(OPT_OUT) + + +# --- the cache and the check ------------------------------------------------------- + +def read_cache() -> dict: + """The cache, keeping only fields of the shape this version writes.""" + try: + data = json.loads(CACHE.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + if not isinstance(data, dict): + return {} + shapes = {"checked_at": (int, float), "latest": dict, "notices": list} + return {key: data[key] for key, shape in shapes.items() if isinstance(data.get(key), shape)} + + +def due(cache: dict, now: float) -> bool: + checked = cache.get("checked_at") + return not isinstance(checked, (int, float)) or not 0 <= now - checked < CHECK_EVERY + + +def summary_of(body: str) -> str: + """The release's one-line summary: its first paragraph on one line (sanitised when shown).""" + first = re.split(r"\n\s*\n", (body or "").strip(), maxsplit=1)[0] + text = " ".join(first.split()) + if len(text) > SUMMARY_LIMIT: + text = text[: SUMMARY_LIMIT - 3].rsplit(" ", 1)[0] + "..." + return text + + +def parse_notices(payload) -> list | None: + """The targeted notices in an announcements answer; None when it has no notices list.""" + notices = payload.get("notices") if isinstance(payload, dict) else None + return notices if isinstance(notices, list) else None + + +def _get_json(url: str): + response = requests.get(url, timeout=TIMEOUT) + return response.json() if response.ok else None + + +def refresh(now: float | None = None) -> None: + """Ask for the latest release and notices and record the answers. Never raises or prints; + a failed check keeps what was known and is not retried for a day.""" + now = time.time() if now is None else now + cache = read_cache() + cache["checked_at"] = now + try: + release = _get_json(RELEASES_URL) + if isinstance(release, dict) and release.get("tag_name"): + cache["latest"] = { + "version": str(release["tag_name"]).removeprefix("v"), + "url": release.get("html_url"), + "summary": summary_of(release.get("body") or ""), + } + except Exception: + pass + try: + notices = parse_notices(_get_json(NOTICES_URL)) + if notices is not None: + cache["notices"] = notices + except Exception: + pass + try: + CACHE.parent.mkdir(parents=True, exist_ok=True) + replace_file(CACHE, lambda temp: Path(temp).write_text(json.dumps(cache), encoding="utf-8")) + except Exception: + pass + + +def start_background_refresh() -> None: + """Refresh the cache in a daemon thread when a check is due.""" + if due(read_cache(), time.time()): + threading.Thread(target=refresh, name="tradingagents-update-check", daemon=True).start() + + +# --- what is shown --------------------------------------------------------------- + +def _version(text) -> Version | None: + try: + return Version(str(text)) + except (InvalidVersion, TypeError): + return None + + +def _plain(text) -> str: + return " ".join(CONTROL.sub(" ", INVISIBLE.sub("", str(text or ""))).split()) + + +def _applies(notice: dict, running: Version, today: date) -> bool: + """Whether a notice is for this version and unexpired; a bound or date that cannot be read + keeps it hidden. A notice is shown through its ``expires`` date (UTC).""" + low, high = notice.get("min_version"), notice.get("max_version") + if low is not None and ((floor := _version(low)) is None or running < floor): + return False + if high is not None and ((ceiling := _version(high)) is None or running > ceiling): + return False + expires = notice.get("expires") + if expires is None: + return True + try: + return isinstance(expires, str) and bool(DATE.fullmatch(expires)) and date.fromisoformat(expires) >= today + except ValueError: + return False + + +def messages(cache: dict, running: str, upgrade: Callable[[], str | None] = lambda: None) -> list[str]: + """The lines to show: a newer release with its upgrade command (``upgrade()``, asked only + when there is one), then the notices for this version.""" + current = _version(running) + if current is None: + return [] + lines = [] + latest = cache.get("latest") or {} + newest = _version(latest.get("version")) + if newest is not None and newest > current: + lines.append(f"TradingAgents {_plain(latest['version'])} is available (you have {_plain(running)}): " + f"{_plain(latest.get('summary'))}") + if latest.get("url"): + lines.append(_plain(latest["url"])) + command = upgrade() + if command: + lines.append(f"Upgrade: {_plain(command)}") + today = datetime.now(UTC).date() + for notice in cache.get("notices") or []: + if not (isinstance(notice, dict) and isinstance(notice.get("message"), str) and _plain(notice["message"]) + and _applies(notice, current, today)): + continue + url = notice.get("url") + link = url if isinstance(url, str) and url.lower().startswith(("http://", "https://")) else None + lines.append(_plain(notice["message"]) + (f" {_plain(link)}" if link else "")) + return lines + + +def upgrade_command(dist=None) -> str | None: + """The command that upgrades this install, from how pip or uv recorded it; None when unsure.""" + if os.environ.get("TRADINGAGENTS_IN_DOCKER"): + return "git pull && docker compose build (on the host)" + if os.name == "nt": # the commands below are POSIX shell + return None + try: + dist = dist or importlib.metadata.distribution("tradingagents") + direct = json.loads(dist.read_text("direct_url.json") or "null") + installer = (dist.read_text("INSTALLER") or "").strip() + except (importlib.metadata.PackageNotFoundError, OSError, ValueError): + return None + if not isinstance(direct, dict) or not direct.get("url"): + return None + python = shlex.quote(sys.executable) + install = f"uv pip install --python {python}" if installer == "uv" else f"{python} -m pip install" + if "vcs_info" in direct: + return f"{install} -U git+{direct['url']}" + parsed = urlparse(direct["url"]) + if parsed.scheme != "file": + return None + clone = Path(unquote(parsed.path)) + if not (clone / ".git").exists(): + return None + editable = (direct.get("dir_info") or {}).get("editable") + target = shlex.quote(str(clone)) + return f"git -C {target} pull && {install} {'-e ' if editable else ''}{target}" + + +def show(console) -> None: + """Print what's new from the cache, under the welcome panel.""" + from rich.panel import Panel + from rich.text import Text + + from tradingagents import __version__ + + lines = messages(read_cache(), running=__version__, upgrade=upgrade_command) + if lines: + console.print(Panel(Text("\n".join(lines)), border_style="cyan", padding=(1, 2), title="What's new")) + console.print() diff --git a/pyproject.toml b/pyproject.toml index 5dedc530c..4db28fc5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "langgraph>=1.2.12", "langgraph-checkpoint-sqlite>=3.1.1", "markdown-it-py>=4.0", + "packaging>=24.0", "pandas>=3.0.6", "python-dotenv>=1.0.0", "pytz>=2025.2", diff --git a/tests/test_cli_env_skip.py b/tests/test_cli_env_skip.py index 812f016fb..3a8420945 100644 --- a/tests/test_cli_env_skip.py +++ b/tests/test_cli_env_skip.py @@ -54,8 +54,7 @@ class TestCliSkipsPromptsFromEnv(unittest.TestCase): with mock.patch.dict(os.environ, env, clear=False), \ mock.patch.object(cli_selections, "DEFAULT_CONFIG", fake_cfg), \ - mock.patch.object(cli_selections, "fetch_announcements", return_value=None), \ - mock.patch.object(cli_selections, "display_announcements"), \ + mock.patch.object(cli_selections.updates, "enabled", return_value=False), \ mock.patch.object(cli_selections, "get_ticker", return_value="AAPL"), \ mock.patch.object(cli_selections, "get_analysis_date", return_value="2026-05-29"), \ mock.patch.object(cli_selections, "select_analysts", return_value=[]), \ @@ -96,8 +95,7 @@ class TestResearchDepthSkippedFromEnv(unittest.TestCase): with mock.patch.dict(os.environ, env, clear=False), \ mock.patch.object(cli_selections, "DEFAULT_CONFIG", fake_cfg), \ - mock.patch.object(cli_selections, "fetch_announcements", return_value=None), \ - mock.patch.object(cli_selections, "display_announcements"), \ + mock.patch.object(cli_selections.updates, "enabled", return_value=False), \ mock.patch.object(cli_selections, "get_ticker", return_value="AAPL"), \ mock.patch.object(cli_selections, "get_analysis_date", return_value="2026-05-29"), \ mock.patch.object(cli_selections, "select_analysts", return_value=[]), \ @@ -125,8 +123,7 @@ class TestReasoningEffortSkippedFromEnv(unittest.TestCase): with mock.patch.dict(os.environ, env, clear=False), \ mock.patch.object(cli_selections, "DEFAULT_CONFIG", fake_cfg), \ - mock.patch.object(cli_selections, "fetch_announcements", return_value=None), \ - mock.patch.object(cli_selections, "display_announcements"), \ + mock.patch.object(cli_selections.updates, "enabled", return_value=False), \ mock.patch.object(cli_selections, "get_ticker", return_value="AAPL"), \ mock.patch.object(cli_selections, "get_analysis_date", return_value="2026-05-29"), \ mock.patch.object(cli_selections, "select_analysts", return_value=[]), \ diff --git a/tests/test_cli_headless.py b/tests/test_cli_headless.py index a800ecfe3..15580f7aa 100644 --- a/tests/test_cli_headless.py +++ b/tests/test_cli_headless.py @@ -56,8 +56,7 @@ def test_flags_skip_exactly_their_own_steps(monkeypatch): for step in ("get_ticker", "get_analysis_date", "select_analysts"): monkeypatch.setattr(selections, step, no_prompt) - monkeypatch.setattr(selections, "fetch_announcements", lambda: None) - monkeypatch.setattr(selections, "display_announcements", lambda *a: None) + monkeypatch.setattr(selections.updates, "enabled", lambda: False) chosen = selections._prompt_selections({}, FLAGS) @@ -119,19 +118,6 @@ def test_save_and_show_answer_the_questions_after_the_run(monkeypatch, tmp_path) -@pytest.mark.unit -def test_an_announcement_does_not_wait_for_enter_without_a_terminal(monkeypatch): - from cli import announcements - from cli.display import console - - def no_wait(*a): - raise AssertionError("waited for Enter with no terminal") - - monkeypatch.setattr(announcements.getpass, "getpass", no_wait) - monkeypatch.setattr(announcements.sys.stdin, "isatty", lambda: False) - announcements.display_announcements(console, {"content": "Maintenance tonight", "require_attention": True}) - - @pytest.mark.unit def test_a_missing_key_without_a_terminal_names_the_variable(monkeypatch, capsys): monkeypatch.delenv("OPENAI_API_KEY", raising=False) @@ -200,8 +186,7 @@ def test_an_unknown_analyst_error_names_the_analysts_as_users_know_them(): def _selections_with_env(monkeypatch, keys=None): for name, value in UNATTENDED_ENV.items(): monkeypatch.setenv(name, value) - monkeypatch.setattr(selections, "fetch_announcements", lambda: None) - monkeypatch.setattr(selections, "display_announcements", lambda *a: None) + monkeypatch.setattr(selections.updates, "enabled", lambda: False) checked = keys if keys is not None else [] monkeypatch.setattr(selections, "ensure_api_key", lambda provider: checked.append(provider)) return checked diff --git a/tests/test_cli_prefs.py b/tests/test_cli_prefs.py index 1dbb8a149..a8a897b8e 100644 --- a/tests/test_cli_prefs.py +++ b/tests/test_cli_prefs.py @@ -95,8 +95,7 @@ def _answer_every_prompt(monkeypatch): """Drive the real selection flow, answering each prompt with a fixed value.""" import cli.main as m - monkeypatch.setattr(cli_selections, "fetch_announcements", lambda: []) - monkeypatch.setattr(cli_selections, "display_announcements", lambda *a: None) + monkeypatch.setattr(cli_selections.updates, "enabled", lambda: False) monkeypatch.setattr(cli_selections, "get_ticker", lambda: "NVDA") monkeypatch.setattr(cli_selections, "get_analysis_date", lambda: "2026-09-01") monkeypatch.setattr(cli_selections, "ask_output_language", lambda default=None: "English") @@ -148,8 +147,7 @@ def test_a_remembered_endpoint_is_offered_back(monkeypatch): monkeypatch.setattr(cli_selections, "select_llm_provider", lambda default=None: ("openai_compatible", None)) monkeypatch.setattr(cli_selections, "prompt_openai_compatible_url", lambda default=None: offered.setdefault("default", default) or "http://x/v1") - monkeypatch.setattr(cli_selections, "fetch_announcements", lambda: []) - monkeypatch.setattr(cli_selections, "display_announcements", lambda *a: None) + monkeypatch.setattr(cli_selections.updates, "enabled", lambda: False) monkeypatch.setattr(cli_selections, "get_ticker", lambda: "NVDA") monkeypatch.setattr(cli_selections, "get_analysis_date", lambda: "2026-09-01") monkeypatch.setattr(cli_selections, "ask_output_language", lambda default=None: "English") diff --git a/tests/test_cli_updates.py b/tests/test_cli_updates.py new file mode 100644 index 000000000..3e508dd2a --- /dev/null +++ b/tests/test_cli_updates.py @@ -0,0 +1,337 @@ +"""What's new reaches the person at a terminal: a newer release and Tauric's notices. + +The check runs at most daily in a background thread that only writes a cache; the +notice shown is read from that cache, so a run never waits on the network, and +unattended runs contact no server at all. +""" + +import io +import json +import os +from datetime import date +from types import SimpleNamespace +from unittest import mock + +import pytest +from packaging.version import Version +from rich.console import Console + +import cli.selections as cli_selections +import cli.updates as updates + + +class _Tty(io.StringIO): + def isatty(self): + return True + + +# --- who sees it ----------------------------------------------------------------- + +@pytest.mark.unit +@pytest.mark.parametrize("stdin_tty, stdout_tty, ci, opt_out, expected", [ + (True, True, None, None, True), + (False, True, None, None, False), + (True, False, None, None, False), + (True, True, "true", None, False), + (True, True, None, "1", False), +]) +def test_only_a_person_at_a_terminal_is_told(monkeypatch, stdin_tty, stdout_tty, ci, opt_out, expected): + monkeypatch.setattr("sys.stdin", _Tty() if stdin_tty else io.StringIO()) + monkeypatch.setattr("sys.stdout", _Tty() if stdout_tty else io.StringIO()) + for name, value in (("CI", ci), ("TRADINGAGENTS_NO_UPDATE_CHECK", opt_out)): + if value is None: + monkeypatch.delenv(name, raising=False) + else: + monkeypatch.setenv(name, value) + assert updates.enabled() is expected + + +ENV = { + "TRADINGAGENTS_LLM_PROVIDER": "openai", + "TRADINGAGENTS_DEEP_THINK_LLM": "gpt-6-sol", + "TRADINGAGENTS_QUICK_THINK_LLM": "gpt-6-luna", + "TRADINGAGENTS_OUTPUT_LANGUAGE": "English", + "TRADINGAGENTS_MAX_DEBATE_ROUNDS": "1", + "TRADINGAGENTS_MAX_RISK_ROUNDS": "1", +} + + +@pytest.mark.unit +def test_an_unattended_run_contacts_no_server(monkeypatch): + monkeypatch.setattr("sys.stdin", io.StringIO()) # no terminal + calls = [] + monkeypatch.setattr(updates.requests, "get", lambda *a, **k: calls.append(a)) + started = [] + monkeypatch.setattr(updates.threading, "Thread", lambda *a, **k: started.append(1)) + with mock.patch.dict(os.environ, ENV, clear=False), \ + mock.patch.object(cli_selections, "ensure_api_key"): + cli_selections.get_user_selections({"ticker": "AAPL", "date": "2026-09-01", "analysts": "market"}) + assert calls == [] and started == [] + + +# --- what it says ----------------------------------------------------------------- + +LATEST = {"version": "0.7.0", "url": "https://github.com/TauricResearch/TradingAgents/releases/tag/v0.7.0", + "summary": "Update notices and a ChatGPT plan provider."} + + +@pytest.mark.unit +@pytest.mark.parametrize("running, shown", [ + ("0.6.0", True), ("0.7.0.dev0", True), # a development build of the release is older than it + ("0.7.0", False), ("0.8.0.dev0", False), ("not-a-version", False), +]) +def test_a_newer_release_is_announced_once_in_the_notice(running, shown): + lines = updates.messages({"latest": LATEST}, running=running, upgrade=lambda: None) + assert any("0.7.0 is available" in line for line in lines) is shown + if shown: + text = "\n".join(lines) + assert f"you have {running}" in text and LATEST["url"] in text and LATEST["summary"] in text + + +@pytest.mark.unit +def test_the_notice_names_the_upgrade_command(): + lines = updates.messages({"latest": LATEST}, running="0.6.0", upgrade=lambda: "git -C /src pull && pip install /src") + assert "Upgrade: git -C /src pull && pip install /src" in "\n".join(lines) + + +@pytest.mark.unit +def test_remote_text_cannot_style_or_break_the_terminal(): + body = "[bold red]Grab it[/bold red] now\x1b[2J with `--html`, a very long line " + "x" * 400 + "\n\nSecond paragraph." + summary = updates.summary_of(body) + assert "\n" not in summary and "Second paragraph" not in summary and len(summary) <= 160 + lines = updates.messages({"latest": {**LATEST, "summary": summary}}, running="0.6.0", upgrade=lambda: None) + shown = "\n".join(lines) + assert "[bold red]Grab it" in shown and "\x1b" not in shown # shown as plain text, controls stripped + + +@pytest.mark.unit +def test_notices_reach_only_the_versions_they_name(): + cache = {"notices": [ + {"id": "a", "message": "For everyone", "url": None}, + {"id": "b", "message": "Old installs: news is fixed", "max_version": "0.6.0"}, + {"id": "c", "message": "New installs only", "min_version": "0.7.0"}, + {"id": "d", "message": "Expired", "expires": "2020-01-01"}, + {"id": "e", "message": "[link=x]markup[/link]"}, + "a bare string is not a notice", + ]} + text = "\n".join(updates.messages(cache, running="0.6.0", upgrade=lambda: None)) + assert "For everyone" in text and "Old installs" in text + assert "New installs only" not in text and "Expired" not in text + assert "[link=x]markup[/link]" in text and "bare string" not in text + + +@pytest.mark.unit +def test_a_notice_shows_through_its_bounds_and_date_and_hides_when_they_cannot_be_read(): + today = date(2026, 10, 16) + applies = lambda **notice: updates._applies(notice, Version("0.7.0"), today) # noqa: E731 + assert applies(min_version="0.7.0", max_version="0.7.0", expires="2026-10-16") + assert applies(expires=None) and applies(min_version=None, max_version=None) + assert not applies(expires="2026-10-15") and not applies(min_version="0.7.1") and not applies(max_version="0.6.9") + for unreadable in ("", "seven", [], False): + assert not applies(min_version=unreadable) and not applies(max_version=unreadable) + for unreadable in ("", "2026-10-16junk", "20261020", "2026-W42-5", "2026-10-16T23:00-08:00", 20261016): + assert not applies(expires=unreadable) + assert not updates._applies({"min_version": "0.7.0"}, Version("0.7.0.dev0"), today) + + +@pytest.mark.unit +def test_a_notice_is_shown_as_plain_text_with_only_a_web_link(): + cache = {"notices": [ + {"id": "a", "message": ":warning: [b]x[/b] \u202eev\u200bil", "url": "https://tauric.ai/a"}, + {"id": "b", "message": "Local link", "url": "file:///etc/passwd"}, + {"id": "e", "message": "Malformed link", "url": "https://[docs"}, + {"id": "c", "message": {"not": "text"}}, + {"id": "d", "message": "\x1b\x07"}, + ]} + assert updates.messages(cache, running="0.7.0") == [":warning: [b]x[/b] evil https://tauric.ai/a", "Local link", + "Malformed link https://[docs"] + console = Console(record=True, width=120, color_system=None) + with mock.patch.object(updates, "read_cache", lambda: cache), mock.patch("tradingagents.__version__", "0.7.0"): + updates.show(console) + assert ":warning: [b]x[/b] evil" in console.export_text() + + +@pytest.mark.unit +def test_notices_are_read_from_their_own_key(): + # Notices have their own key, so ``announcements`` stays a list of strings. + payload = {"announcements": ["Plain text for older clients"], + "notices": [{"id": "x", "message": "Targeted", "max_version": "0.6.0"}]} + assert updates.parse_notices(payload) == payload["notices"] + assert updates.parse_notices({"announcements": ["old shape only"]}) is None + assert updates.parse_notices({"notices": []}) == [] + + +# --- how it is checked --------------------------------------------------------------- + +@pytest.mark.unit +def test_a_check_is_due_once_a_day(): + now = 1_000_000.0 + assert updates.due({}, now) + assert not updates.due({"checked_at": now - 3600}, now) + assert updates.due({"checked_at": now - 25 * 3600}, now) + + +def _response(payload, status=200): + return SimpleNamespace(status_code=status, ok=status < 400, json=lambda: payload) + + +@pytest.mark.unit +def test_a_check_records_the_latest_release_and_notices(tmp_path, monkeypatch): + cache_file = tmp_path / "update_check.json" + monkeypatch.setattr(updates, "CACHE", cache_file) + + def get(url, timeout): + assert timeout <= 3 + if "api.github.com" in url: + return _response({"tag_name": "v0.7.0", "html_url": LATEST["url"], + "body": "Update notices and a ChatGPT plan provider.\n\n## Added"}) + return _response({"announcements": ["old"], "notices": [{"id": "n", "message": "hi"}]}) + + monkeypatch.setattr(updates.requests, "get", get) + updates.refresh(now=5000.0) + cache = json.loads(cache_file.read_text()) + assert cache["checked_at"] == 5000.0 + assert cache["latest"] == {"version": "0.7.0", "url": LATEST["url"], "summary": LATEST["summary"]} + assert cache["notices"] == [{"id": "n", "message": "hi"}] + + +@pytest.mark.unit +def test_a_failed_check_keeps_what_was_known_and_waits_a_day(tmp_path, monkeypatch, capsys): + cache_file = tmp_path / "update_check.json" + cache_file.write_text(json.dumps({"checked_at": 1.0, "latest": LATEST, "notices": []})) + monkeypatch.setattr(updates, "CACHE", cache_file) + + def get(url, timeout): + raise updates.requests.ConnectionError("offline") + + monkeypatch.setattr(updates.requests, "get", get) + updates.refresh(now=9000.0) # never raises + cache = json.loads(cache_file.read_text()) + assert cache["checked_at"] == 9000.0 and cache["latest"] == LATEST + assert capsys.readouterr() == ("", "") # the check never prints + + +@pytest.mark.unit +def test_only_a_notices_list_replaces_the_known_notices(tmp_path, monkeypatch): + cache_file = tmp_path / "update_check.json" + known = [{"id": "n", "message": "hi"}] + cache_file.write_text(json.dumps({"checked_at": 1.0, "notices": known})) + monkeypatch.setattr(updates, "CACHE", cache_file) + for answer in (_response({"detail": "Not Found"}, 404), _response({"error": "portal"}), _response(["x"])): + monkeypatch.setattr(updates.requests, "get", lambda url, timeout, answer=answer: answer) + updates.refresh(now=9000.0) + assert json.loads(cache_file.read_text())["notices"] == known + monkeypatch.setattr(updates.requests, "get", lambda url, timeout: _response({"announcements": [], "notices": []})) + updates.refresh(now=99000.0) + assert json.loads(cache_file.read_text())["notices"] == [] + + +@pytest.mark.unit +def test_an_unreadable_cache_is_no_news(tmp_path, monkeypatch): + cache_file = tmp_path / "update_check.json" + cache_file.write_text("{not json") + monkeypatch.setattr(updates, "CACHE", cache_file) + assert updates.read_cache() == {} + + +# --- the upgrade command for how it was installed ---------------------------------- + +def _dist(direct=None, installer="pip"): + files = {"direct_url.json": json.dumps(direct) if direct else None, "INSTALLER": installer} + return SimpleNamespace(read_text=lambda name: files.get(name)) + + +@pytest.mark.unit +def test_the_upgrade_command_follows_the_install(tmp_path, monkeypatch): + clone = tmp_path / "TradingAgents" + (clone / ".git").mkdir(parents=True) + monkeypatch.delenv("TRADINGAGENTS_IN_DOCKER", raising=False) + monkeypatch.setattr(updates.sys, "executable", "/venv/bin/python") + url = clone.as_uri() + + editable = updates.upgrade_command(_dist({"url": url, "dir_info": {"editable": True}})) + assert editable == f"git -C {clone} pull && /venv/bin/python -m pip install -e {clone}" + local = updates.upgrade_command(_dist({"url": url, "dir_info": {}}, installer="uv")) + assert local == f"git -C {clone} pull && uv pip install --python /venv/bin/python {clone}" + vcs = updates.upgrade_command(_dist({"url": "https://github.com/TauricResearch/TradingAgents.git", + "vcs_info": {"vcs": "git", "commit_id": "abc"}})) + assert vcs == "/venv/bin/python -m pip install -U git+https://github.com/TauricResearch/TradingAgents.git" + + +@pytest.mark.unit +def test_no_command_is_guessed_without_a_clone(tmp_path, monkeypatch): + monkeypatch.delenv("TRADINGAGENTS_IN_DOCKER", raising=False) + gone = (tmp_path / "deleted-clone").as_uri() + assert updates.upgrade_command(_dist({"url": gone, "dir_info": {}})) is None + assert updates.upgrade_command(_dist(None)) is None + monkeypatch.setenv("TRADINGAGENTS_IN_DOCKER", "1") + assert updates.upgrade_command(_dist(None)) == "git pull && docker compose build (on the host)" + + +@pytest.mark.unit +def test_whats_new_never_waits_for_the_person(tmp_path, monkeypatch): + cache_file = tmp_path / "update_check.json" + cache_file.write_text(json.dumps({"latest": LATEST, "notices": [{"id": "x", "message": "Notice"}]})) + monkeypatch.setattr(updates, "CACHE", cache_file) + monkeypatch.setattr("builtins.input", lambda *a: (_ for _ in ()).throw(AssertionError("asked for input"))) + from rich.console import Console + out = Console(file=io.StringIO(), width=200) + updates.show(out) + assert "Notice" in out.file.getvalue() + + +@pytest.mark.unit +@pytest.mark.parametrize("stored", [ + {"latest": "x"}, {"latest": ["0.9.0"]}, {"notices": 5}, {"notices": {"id": "x"}}, {"checked_at": "today"}, [], +]) +def test_a_cache_of_the_wrong_shape_is_no_news(tmp_path, monkeypatch, stored): + cache_file = tmp_path / "update_check.json" + cache_file.write_text(json.dumps(stored)) + monkeypatch.setattr(updates, "CACHE", cache_file) + assert updates.messages(updates.read_cache(), running="0.6.0", upgrade=lambda: None) == [] + assert updates.due(updates.read_cache(), 1_000_000.0) + + +@pytest.mark.unit +def test_a_check_dated_in_the_future_is_due(): + assert updates.due({"checked_at": 2_000_000.0}, 1_000_000.0) + + +@pytest.mark.unit +def test_a_notice_with_an_unreadable_expiry_is_not_shown(): + cache = {"notices": [{"message": "Bad date", "expires": "next week"}, + {"message": "Number", "expires": 20991231}, + {"message": "Timestamp", "expires": "2999-01-01T00:00:00Z"}, + {"message": "A date", "expires": "2999-01-01"}]} + text = "\n".join(updates.messages(cache, running="0.6.0", upgrade=lambda: None)) + assert "Bad date" not in text and "Number" not in text and "Timestamp" not in text and "A date" in text + + +@pytest.mark.unit +def test_remote_text_cannot_carry_c1_or_direction_controls(): + lines = updates.messages({"notices": [{"message": "A\x9b2Jb‮evil"}]}, running="0.6.0", upgrade=lambda: None) + assert lines == ["A 2Jbevil"] # the C1 control reads as a space, the override is dropped + + +@pytest.mark.unit +def test_a_uv_git_install_is_upgraded_with_uv(monkeypatch): + monkeypatch.delenv("TRADINGAGENTS_IN_DOCKER", raising=False) + monkeypatch.setattr(updates.sys, "executable", "/venv/bin/python") + vcs = updates.upgrade_command(_dist({"url": "https://github.com/TauricResearch/TradingAgents.git", + "vcs_info": {"vcs": "git"}}, installer="uv")) + assert vcs == "uv pip install --python /venv/bin/python -U git+https://github.com/TauricResearch/TradingAgents.git" + + +@pytest.mark.unit +def test_no_command_is_offered_on_windows(monkeypatch): + monkeypatch.delenv("TRADINGAGENTS_IN_DOCKER", raising=False) + monkeypatch.setattr(updates.os, "name", "nt") + assert updates.upgrade_command(_dist({"url": "https://github.com/TauricResearch/TradingAgents.git", + "vcs_info": {"vcs": "git"}})) is None + + +@pytest.mark.unit +def test_the_upgrade_command_is_worked_out_only_for_a_newer_release(): + asked = [] + updates.messages({"latest": LATEST}, running=LATEST["version"], upgrade=lambda: asked.append(1)) + assert asked == [] From 071a6cea2aab6ed8f636d6db63763c3fd476283c Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sat, 3 Oct 2026 22:44:32 +0000 Subject: [PATCH 04/10] refactor(llm): let a provider sign in with its own credential at a pinned endpoint - a provider entry can supply a credential, a token callable per request, and pin its base URL - such a provider never sends an API key, and refuses another backend_url or an explicit api_key --- tests/test_provider_registry.py | 56 ++++++++++++++++++++++ tradingagents/llm_clients/openai_client.py | 22 ++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/tests/test_provider_registry.py b/tests/test_provider_registry.py index 36bd4fcbb..69e2b053b 100644 --- a/tests/test_provider_registry.py +++ b/tests/test_provider_registry.py @@ -58,3 +58,59 @@ def test_key_optionality(): assert OPENAI_COMPATIBLE_PROVIDERS["xai"].key_optional is False # OLLAMA_BASE_URL is the only base-URL env override. assert OPENAI_COMPATIBLE_PROVIDERS["ollama"].base_url_env == "OLLAMA_BASE_URL" + + +# --- a provider that signs in: its own credential, its own pinned endpoint --------- + +class _Recorder: + """Stands in for the chat class and records what the client built.""" + + def __init__(self, **kwargs): + self.kwargs = kwargs + + +def _signed_in_provider(monkeypatch, token="plan-token"): + from tradingagents.llm_clients import openai_client + + spec = openai_client.ProviderSpec( + base_url="https://api.openai.com/v1", pinned_base_url=True, use_responses_api=True, + credential=lambda: (lambda: token), chat_class=_Recorder, + ) + monkeypatch.setitem(openai_client.OPENAI_COMPATIBLE_PROVIDERS, "signed-in", spec) + return openai_client + + +@pytest.mark.unit +def test_a_signed_in_provider_sends_its_own_credential_never_an_api_key(monkeypatch): + openai_client = _signed_in_provider(monkeypatch) + monkeypatch.setenv("OPENAI_API_KEY", "sk-must-not-be-used") + llm = openai_client.OpenAIClient("gpt-x", provider="signed-in").get_llm() + assert callable(llm.kwargs["api_key"]) and llm.kwargs["api_key"]() == "plan-token" + assert llm.kwargs["base_url"] == "https://api.openai.com/v1" + + +@pytest.mark.unit +@pytest.mark.parametrize("client_kwargs", [ + {"base_url": "https://relay.example/v1"}, # backend_url / TRADINGAGENTS_LLM_BACKEND_URL + {"api_key": "sk-explicit"}, +]) +def test_a_signed_in_provider_refuses_another_endpoint_or_key(monkeypatch, client_kwargs): + openai_client = _signed_in_provider(monkeypatch) + base_url = client_kwargs.pop("base_url", None) + client = openai_client.OpenAIClient("gpt-x", base_url=base_url, provider="signed-in", **client_kwargs) + with pytest.raises(ValueError): + client.get_llm() + + +@pytest.mark.unit +def test_a_missing_sign_in_fails_before_any_request(monkeypatch): + from tradingagents.llm_clients import openai_client + + def not_signed_in(): + raise ValueError("Not signed in; run `tradingagents login chatgpt`.") + + spec = openai_client.ProviderSpec(base_url="https://api.openai.com/v1", pinned_base_url=True, + credential=not_signed_in, chat_class=_Recorder) + monkeypatch.setitem(openai_client.OPENAI_COMPATIBLE_PROVIDERS, "signed-in", spec) + with pytest.raises(ValueError, match="login chatgpt"): + openai_client.OpenAIClient("gpt-x", provider="signed-in").get_llm() diff --git a/tradingagents/llm_clients/openai_client.py b/tradingagents/llm_clients/openai_client.py index b1d7b65d6..56dc50845 100644 --- a/tradingagents/llm_clients/openai_client.py +++ b/tradingagents/llm_clients/openai_client.py @@ -1,5 +1,6 @@ import os import re +from collections.abc import Callable from dataclasses import dataclass from typing import Any from urllib.parse import urlparse @@ -204,6 +205,11 @@ class ProviderSpec: placeholder_key: str = "EMPTY" # sent when no key is available (keyless local servers) require_base_url: bool = False # error if no base_url is resolved (generic endpoint) use_responses_api: bool = False # native OpenAI Responses API + # A provider the user signs in to: ``credential()`` returns the api_key callable + # (a fresh token per request) or raises when nobody is signed in. Its token goes + # only to its pinned base_url, and never alongside an API key. + credential: Callable[[], Callable[[], str]] | None = None + pinned_base_url: bool = False # refuse any other endpoint for this provider # Single source of truth for the OpenAI-compatible provider family. Dual-region @@ -234,6 +240,14 @@ OPENAI_COMPATIBLE_PROVIDERS: dict[str, ProviderSpec] = { } +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()) + if spec is not None and spec.pinned_base_url and base_url and base_url != spec.base_url: + raise ValueError(f"Provider '{provider}' only connects to {spec.base_url}; " + "unset backend_url / TRADINGAGENTS_LLM_BACKEND_URL for it.") + + def is_openai_compatible(provider: str) -> bool: """Whether ``provider`` is served by the OpenAI-compatible registry.""" return provider.lower() in OPENAI_COMPATIBLE_PROVIDERS @@ -289,6 +303,7 @@ class OpenAIClient(BaseLLMClient): # OLLAMA_BASE_URL) > provider default. None means use the SDK default. env_base_url = os.environ.get(spec.base_url_env) if spec.base_url_env else None base_url = self.base_url or env_base_url or spec.base_url + check_endpoint(self.provider, base_url) if spec.require_base_url and not base_url: raise ValueError( f"Provider '{self.provider}' requires a base_url. Set it via " @@ -301,9 +316,14 @@ class OpenAIClient(BaseLLMClient): # API key: required unless key_optional; keyless local servers get a # placeholder. The env-var name is the single source in api_key_env. + # A signed-in provider sends its own credential, and only that. api_key_env = get_api_key_env(self.provider) api_key = os.environ.get(api_key_env) if api_key_env else None - if api_key: + if spec.credential is not None: + if "api_key" in self.kwargs: + raise ValueError(f"Provider '{self.provider}' uses its sign-in, not an api_key.") + llm_kwargs["api_key"] = spec.credential() + elif api_key: llm_kwargs["api_key"] = api_key elif spec.key_optional: llm_kwargs["api_key"] = spec.placeholder_key From 69042d6d33d3e158f5feeffe93c24ad05d259bd2 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sat, 3 Oct 2026 23:04:17 +0000 Subject: [PATCH 05/10] feat(llm): sign in with a ChatGPT plan (#1475) - chatgpt_auth signs in through OpenAI's flow for open-source apps (PKCE, loopback or pasted redirect, verified ID token, plan scope) and keeps the sign-in in ~/.tradingagents/chatgpt-auth.json, readable only by its owner - access tokens renew under a lock shared across threads and processes, from the refresh token on disk - `tradingagents login chatgpt` and `tradingagents logout chatgpt`; logout revokes the sign-in at OpenAI --- cli/main.py | 37 ++ pyproject.toml | 1 + tests/test_chatgpt_auth.py | 424 ++++++++++++++++++++++ tests/test_cli_commands.py | 50 +++ tradingagents/llm_clients/chatgpt_auth.py | 404 +++++++++++++++++++++ 5 files changed, 916 insertions(+) create mode 100644 tests/test_chatgpt_auth.py create mode 100644 tradingagents/llm_clients/chatgpt_auth.py diff --git a/cli/main.py b/cli/main.py index 7c13acaed..d6c6d6fbf 100644 --- a/cli/main.py +++ b/cli/main.py @@ -1,6 +1,9 @@ import sys +from enum import StrEnum +from typing import Annotated import typer +from rich.markup import escape from cli.display import console from cli.models import AnalystType, AssetType @@ -156,5 +159,39 @@ def backtest( console.print(f"[yellow]unsettled:[/yellow] {ticker}: {reason}") +class Plan(StrEnum): + chatgpt = "chatgpt" + + +@app.command() +def login(plan: Annotated[Plan, typer.Argument(help="The plan to sign in with")]): + """Sign in with your ChatGPT Plus or Pro plan, for the chatgpt provider.""" + from tradingagents.llm_clients import chatgpt_auth + + try: + # One unwrapped line, so the sign-in address can be copied whole. + email = chatgpt_auth.sign_in( + say=lambda line: console.print(line, soft_wrap=True, markup=False, highlight=False)) + except chatgpt_auth.ChatGPTAuthError as exc: + console.print(f"[red]{escape(str(exc))}[/red]") + raise typer.Exit(code=1) from None + console.print(f"[green]Signed in to ChatGPT as {escape(email)}.[/green]") + + +@app.command() +def logout(plan: Annotated[Plan, typer.Argument(help="The plan to sign out of")]): + """Sign out of your ChatGPT plan: revoke this machine's access and forget it.""" + from tradingagents.llm_clients import chatgpt_auth + + outcome = chatgpt_auth.sign_out() + if outcome == "revoked": + console.print("Signed out of ChatGPT.") + elif outcome == "forgotten": + console.print("[yellow]Signed out here, but OpenAI could not be reached to revoke the sign-in; " + "it stays valid at OpenAI until it expires.[/yellow]") + else: + console.print("Not signed in to ChatGPT.") + + if __name__ == "__main__": app() diff --git a/pyproject.toml b/pyproject.toml index 4db28fc5e..70dcce013 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -9,6 +9,7 @@ description = "TradingAgents: Multi-Agents LLM Financial Trading Framework" readme = "README.md" requires-python = ">=3.11" dependencies = [ + "cryptography>=42.0", "langchain-core>=1.6.5", "langchain-anthropic>=1.7.4", "langchain-google-genai>=4.4.0", diff --git a/tests/test_chatgpt_auth.py b/tests/test_chatgpt_auth.py new file mode 100644 index 000000000..d2a11d916 --- /dev/null +++ b/tests/test_chatgpt_auth.py @@ -0,0 +1,424 @@ +"""Sign in with ChatGPT: the sign-in is checked, kept private, renewed once, and revoked. + +OpenAI is replaced by a fake that signs ID tokens with a key made here, so every +check of the sign-in (signature, issuer, audience, nonce, plan scope) runs for real. +""" + +import base64 +import json +import socket +import stat +import threading +import time +import urllib.error +import urllib.parse +import urllib.request + +import pytest +from cryptography.hazmat.primitives import hashes +from cryptography.hazmat.primitives.asymmetric import padding, rsa + +from tradingagents.llm_clients import chatgpt_auth as auth + +# The suite refuses all connections; the loopback test lets 127.0.0.1 through only. +_CONNECT, _GETADDRINFO = socket.socket.connect, socket.getaddrinfo +KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048) +CLIENT = "oaiapp_test" +SUBJECT = "user-123" + + +def _b64(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def _id_token(key=KEY, **claims) -> str: + payload = {"iss": auth.ISSUER, "aud": CLIENT, "sub": SUBJECT, "email": "a@example.com", + "exp": time.time() + 3600, **claims} + head = _b64(json.dumps({"alg": "RS256", "kid": "k1"}).encode()) + body = _b64(json.dumps(payload).encode()) + signature = key.sign(f"{head}.{body}".encode(), padding.PKCS1v15(), hashes.SHA256()) + return f"{head}.{body}.{_b64(signature)}" + + +def _jwks(): + numbers = KEY.public_key().public_numbers() + return {"keys": [{"kid": "k1", "kty": "RSA", "e": _b64(numbers.e.to_bytes(3, "big")), + "n": _b64(numbers.n.to_bytes(256, "big"))}]} + + +class _Response: + def __init__(self, body, status=200): + self.body, self.status_code = body, status + + def json(self): + return self.body + + +class FakeOpenAI: + """Answers the JWKS, token and revoke endpoints and records what was sent.""" + + def __init__(self, token_body=None, status=200): + self.posts = [] + self.token_body = token_body + self.status = status + self.lock = threading.Lock() + self.keys_reachable = True + self.revoke_status = 200 + self.post_error = None + + def get(self, url, timeout): + assert url == auth.JWKS_URL + if not self.keys_reachable: + raise auth.requests.ConnectionError("down") + return _Response(_jwks()) + + def post(self, url, timeout, data): + with self.lock: + self.posts.append((url, data)) + if url == auth.REVOKE_URL: + return _Response({}, self.revoke_status) + if self.post_error is not None: + raise self.post_error + time.sleep(0.05) # let a racing refresher reach the lock + body = self.token_body(data) if callable(self.token_body) else self.token_body + return _Response(body, self.status) + + +@pytest.fixture +def home(tmp_path, monkeypatch): + monkeypatch.setattr(auth, "AUTH_FILE", tmp_path / "home" / "chatgpt-auth.json") + monkeypatch.setattr(auth, "_retry_after", 0.0) + return tmp_path + + +@pytest.fixture +def openai(monkeypatch): + fake = FakeOpenAI() + monkeypatch.setattr(auth.requests, "get", fake.get) + monkeypatch.setattr(auth.requests, "post", fake.post) + return fake + + +def _signed_in(expires_at, earliest=None, refresh_token="r1"): + auth.AUTH_FILE.parent.mkdir(parents=True, exist_ok=True) + auth._write({"host_id": "urn:uuid:h", "account": {"client_id": CLIENT, "email": "a@example.com", + "subject": SUBJECT}, + "tokens": {"access_token": "a1", "refresh_token": refresh_token, + "expires_at": expires_at, "earliest_refresh_at": earliest}}) + + +def _renewal(data): + return {"access_token": "a2", "refresh_token": "r2", "expires_in": 3600, + "id_token": _id_token(), "scope": auth.SCOPE} + + +# --- the ID token ---------------------------------------------------------------- + +@pytest.mark.unit +def test_a_genuine_id_token_is_accepted(): + assert auth._validated_claims(_id_token(nonce="n"), CLIENT, "n", _jwks()["keys"])["sub"] == SUBJECT + + +@pytest.mark.unit +@pytest.mark.parametrize("token, nonce", [ + (lambda: _id_token(nonce="other"), "n"), + (lambda: _id_token(aud="oaiapp_someone_else", nonce="n"), "n"), + (lambda: _id_token(iss="https://evil.example", nonce="n"), "n"), + (lambda: _id_token(exp=time.time() - 600, nonce="n"), "n"), +]) +def test_an_id_token_for_another_sign_in_is_refused(token, nonce): + with pytest.raises(auth.ChatGPTAuthError): + auth._validated_claims(token(), CLIENT, nonce, _jwks()["keys"]) + + +@pytest.mark.unit +def test_an_id_token_signed_by_another_key_is_refused(): + stranger = rsa.generate_private_key(public_exponent=65537, key_size=2048) + with pytest.raises(auth.ChatGPTAuthError, match="do not verify"): + auth._validated_claims(_id_token(key=stranger, nonce="n"), CLIENT, "n", _jwks()["keys"]) + + +# --- tokens ---------------------------------------------------------------------- + +@pytest.mark.unit +def test_nobody_signed_in_is_told_how_to_sign_in(home, openai): + with pytest.raises(auth.ChatGPTAuthError, match="tradingagents login chatgpt"): + auth.credential() + with pytest.raises(auth.ChatGPTAuthError, match="tradingagents login chatgpt"): + auth.access_token() + assert openai.posts == [] + + +@pytest.mark.unit +def test_a_fresh_token_is_used_without_asking_openai(home, openai): + _signed_in(expires_at=time.time() + 3000) + assert auth.credential()() == "a1" + assert openai.posts == [] + + +@pytest.mark.unit +def test_a_token_near_expiry_is_renewed_with_the_stored_refresh_token(home, openai): + _signed_in(expires_at=time.time() + 60) + openai.token_body = _renewal + assert auth.access_token() == "a2" + (url, sent), = openai.posts + assert url == auth.TOKEN_URL and sent["refresh_token"] == "r1" and sent["client_id"] == CLIENT + stored = json.loads(auth.AUTH_FILE.read_text()) + assert stored["tokens"]["refresh_token"] == "r2" and stored["account"]["subject"] == SUBJECT + assert stat.S_IMODE(auth.AUTH_FILE.stat().st_mode) == 0o600 + + +@pytest.mark.unit +def test_renewal_waits_for_the_time_openai_allows_while_the_token_still_works(home, openai): + now = time.time() + _signed_in(expires_at=now + 200, earliest=now + 100) + assert auth.access_token() == "a1" + assert openai.posts == [] + + +@pytest.mark.unit +def test_concurrent_runs_renew_once_and_never_replay_a_used_refresh_token(home, openai): + _signed_in(expires_at=time.time() + 60) + openai.token_body = _renewal + tokens = [] + threads = [threading.Thread(target=lambda: tokens.append(auth.access_token())) for _ in range(4)] + for thread in threads: + thread.start() + for thread in threads: + thread.join() + assert tokens == ["a2"] * 4 + assert [sent["refresh_token"] for _, sent in openai.posts] == ["r1"] + + +@pytest.mark.unit +def test_a_refused_renewal_asks_to_sign_in_again_and_keeps_the_file(home, openai): + _signed_in(expires_at=time.time() + 60) + openai.token_body, openai.status = {"error": "invalid_grant"}, 400 + with pytest.raises(auth.ChatGPTAuthError, match="invalid_grant.*login chatgpt"): + auth.access_token() + assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1" + + +@pytest.mark.unit +def test_a_renewal_for_another_account_is_refused(home, openai): + _signed_in(expires_at=time.time() + 60) + openai.token_body = lambda data: {**_renewal(data), "id_token": _id_token(sub="someone-else")} + with pytest.raises(auth.ChatGPTAuthError, match="another account"): + auth.access_token() + + +# --- signing in and out ------------------------------------------------------------ + +def _nonce_of(printed): + url = next(line for line in printed if line.startswith(auth.AUTHORIZE_URL)) + return dict(urllib.parse.parse_qsl(urllib.parse.urlparse(url).query))["nonce"] + + +def _paster(printed, edit=lambda query: query): + calls = [] + + def read_line(): + calls.append(1) + if len(calls) > 1: + threading.Event().wait() + url = next(line for line in printed if line.startswith(auth.AUTHORIZE_URL)) + asked = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(url).query)) + query = edit({"code": "c1", "state": asked["state"], "client_id": CLIENT}) + return f"{asked['redirect_uri']}?{urllib.parse.urlencode(query)}" + + return read_line + + +@pytest.mark.unit +def test_signing_in_by_pasting_the_redirect_saves_a_private_sign_in(home, openai): + printed = [] + + def token(data): + assert data["grant_type"] == "authorization_code" and data["client_id"] == CLIENT + assert data["code"] == "c1" and data["code_verifier"] + return {"access_token": "a1", "refresh_token": "r1", "expires_in": 3600, + "id_token": _id_token(nonce=_nonce_of(printed)), "scope": auth.SCOPE} + + openai.token_body = token + email = auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0) + assert email == "a@example.com" + stored = json.loads(auth.AUTH_FILE.read_text()) + assert stored["account"] == {"client_id": CLIENT, "email": "a@example.com", "subject": SUBJECT} + assert stored["host_id"].startswith("urn:uuid:") + assert stat.S_IMODE(auth.AUTH_FILE.stat().st_mode) == 0o600 + asked = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(printed[1]).query)) + assert asked["client_id"] == auth.DYNAMIC_CLIENT_ID and asked["agent_name_hint"] == "TradingAgents" + assert asked["code_challenge_method"] == "S256" and asked["redirect_uri"].startswith("http://127.0.0.1:") + + +@pytest.mark.unit +def test_an_answer_for_another_sign_in_attempt_is_ignored(home, openai): + printed = [] + with pytest.raises(auth.ChatGPTAuthError, match="timed out"): + auth.sign_in(say=printed.append, read_line=_paster(printed, lambda q: {**q, "state": "forged"}), + timeout=1, port=0) + assert any("not the address" in line for line in printed) + assert openai.posts == [] and not auth.AUTH_FILE.exists() + + +def _status(url): + try: + return urllib.request.urlopen(url, timeout=5).status + except urllib.error.HTTPError as exc: + return exc.code + + +@pytest.mark.unit +def test_the_browser_answer_reaches_the_loopback_and_strays_are_ignored(home, openai, monkeypatch): + refused_connect, refused_lookup = socket.socket.connect, socket.getaddrinfo + monkeypatch.setattr(socket.socket, "connect", lambda sock, address: ( + _CONNECT if address[0] == "127.0.0.1" else refused_connect)(sock, address)) + monkeypatch.setattr(socket, "getaddrinfo", lambda host, *a, **k: ( + _GETADDRINFO if host == "127.0.0.1" else refused_lookup)(host, *a, **k)) + printed = [] + openai.token_body = lambda data: {"access_token": "a1", "refresh_token": "r1", "expires_in": 3600, + "id_token": _id_token(nonce=_nonce_of(printed)), "scope": auth.SCOPE} + + def browser(): + while not any(line.startswith(auth.AUTHORIZE_URL) for line in printed): + time.sleep(0.01) + asked = dict(urllib.parse.parse_qsl(urllib.parse.urlparse(printed[1]).query)) + results.append(_status(f"{asked['redirect_uri']}?error=access_denied")) + results.append(_status(f"{asked['redirect_uri']}?code=c1&state={asked['state']}&client_id={CLIENT}")) + + results = [] + threading.Thread(target=browser, daemon=True).start() + email = auth.sign_in(say=printed.append, read_line=threading.Event().wait, timeout=10, port=0) + assert email == "a@example.com" and results == [400, 200] + + +@pytest.mark.unit +def test_a_sign_in_without_plan_usage_is_refused(home, openai): + printed = [] + openai.token_body = lambda data: {"access_token": "a1", "refresh_token": "r1", "expires_in": 3600, + "id_token": _id_token(nonce=_nonce_of(printed)), + "scope": "openid email offline_access"} + with pytest.raises(auth.ChatGPTAuthError, match="approve plan usage"): + auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0) + assert not auth.AUTH_FILE.exists() + + +@pytest.mark.unit +def test_signing_out_revokes_the_sign_in_and_keeps_only_the_host_id(home, openai): + _signed_in(expires_at=time.time() + 3000) + assert auth.sign_out() == "revoked" + (url, sent), = openai.posts + assert url == auth.REVOKE_URL and sent["token"] == "r1" and sent["client_id"] == CLIENT + assert json.loads(auth.AUTH_FILE.read_text()) == {"host_id": "urn:uuid:h"} + assert auth.signed_in_as() is None + assert auth.sign_out() is None + + +@pytest.mark.unit +def test_unreachable_keys_stop_a_renewal_before_the_refresh_token_is_spent(home, openai): + _signed_in(expires_at=time.time() - 1) + openai.token_body, openai.keys_reachable = _renewal, False + with pytest.raises(auth.ChatGPTUnreachable, match="Could not reach OpenAI"): + auth.access_token() + assert openai.posts == [] + assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1" + + +@pytest.mark.unit +@pytest.mark.parametrize("failure", ["keys", "network", 429, 503]) +def test_a_temporary_failure_keeps_a_token_that_still_works(home, openai, failure): + _signed_in(expires_at=time.time() + 60) + openai.token_body = _renewal + if failure == "keys": + openai.keys_reachable = False + elif failure == "network": + openai.post_error = auth.requests.ConnectionError("reset") + else: + openai.token_body, openai.status = {"error": "busy"}, failure + assert auth.access_token() == "a1" + assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1" + if failure == "keys": + assert openai.posts == [] + + +@pytest.mark.unit +@pytest.mark.parametrize("failure", ["network", 408, 503]) +def test_a_temporary_failure_after_expiry_says_to_try_again_not_to_sign_in(home, openai, failure): + _signed_in(expires_at=time.time() - 1) + if failure == "network": + openai.post_error = auth.requests.Timeout("slow") + else: + openai.token_body, openai.status = {"error": "busy"}, failure + with pytest.raises(auth.ChatGPTUnreachable) as raised: + auth.access_token() + assert "try again" in str(raised.value) and "login" not in str(raised.value) + + +@pytest.mark.unit +def test_signing_in_again_revokes_the_replaced_sign_in(home, openai): + _signed_in(expires_at=time.time() + 3000, refresh_token="old") + printed = [] + openai.token_body = lambda data: {"access_token": "a2", "refresh_token": "r2", "expires_in": 3600, + "id_token": _id_token(nonce=_nonce_of(printed)), "scope": auth.SCOPE} + auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0) + assert openai.posts[-1][0] == auth.REVOKE_URL and openai.posts[-1][1]["token"] == "old" + assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r2" + + +@pytest.mark.unit +def test_another_account_cannot_replace_the_signed_in_one(home, openai): + _signed_in(expires_at=time.time() + 3000) + printed = [] + openai.token_body = lambda data: {"access_token": "a2", "refresh_token": "r2", "expires_in": 3600, + "id_token": _id_token(nonce=_nonce_of(printed), sub="someone-else"), + "scope": auth.SCOPE} + with pytest.raises(auth.ChatGPTAuthError, match="logout chatgpt"): + auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0) + assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1" + + +@pytest.mark.unit +def test_signing_out_while_openai_is_unreachable_still_forgets_the_sign_in(home, openai): + _signed_in(expires_at=time.time() + 3000) + openai.revoke_status = 503 + assert auth.sign_out() == "forgotten" + assert auth.signed_in_as() is None + + +@pytest.mark.unit +def test_after_a_temporary_failure_renewal_waits_a_minute_before_trying_again(home, openai): + _signed_in(expires_at=time.time() + 200) + openai.post_error = auth.requests.ConnectionError("reset") + clock = [time.time()] + assert auth.access_token(now=lambda: clock[0]) == "a1" + assert auth.access_token(now=lambda: clock[0] + 30) == "a1" + assert len(openai.posts) == 1 + openai.post_error, openai.token_body = None, _renewal + assert auth.access_token(now=lambda: clock[0] + 61) == "a2" + + +@pytest.mark.unit +def test_after_expiry_a_failed_renewal_is_not_retried_within_the_minute(home, openai): + _signed_in(expires_at=time.time() - 1) + openai.post_error = auth.requests.ConnectionError("reset") + for _ in range(3): + with pytest.raises(auth.ChatGPTUnreachable, match="try again"): + auth.access_token() + assert len(openai.posts) == 1 + + +@pytest.mark.unit +def test_an_unreadable_renewal_answer_keeps_a_token_that_still_works(home, openai): + _signed_in(expires_at=time.time() + 60) + openai.token_body = lambda data: ["not", "a", "token"] + assert auth.access_token() == "a1" + assert json.loads(auth.AUTH_FILE.read_text())["tokens"]["refresh_token"] == "r1" + + +@pytest.mark.unit +def test_a_network_failure_while_signing_in_is_reported_not_raised_raw(home, openai): + printed = [] + openai.post_error = auth.requests.ConnectionError("reset") + with pytest.raises(auth.ChatGPTUnreachable, match="sign you in to ChatGPT.*try again"): + auth.sign_in(say=printed.append, read_line=_paster(printed), timeout=10, port=0) + assert not auth.AUTH_FILE.exists() diff --git a/tests/test_cli_commands.py b/tests/test_cli_commands.py index 37c9c2859..0999a5349 100644 --- a/tests/test_cli_commands.py +++ b/tests/test_cli_commands.py @@ -180,3 +180,53 @@ def test_backtest_refuses_a_selection_before_the_sweep(runner, monkeypatch, args result = runner.invoke(m.app, ["backtest", "NVDA", "--start", "2026-06-01", "--end", "2026-06-01", *args]) assert result.exit_code == 1 + + +@pytest.mark.unit +def test_login_and_logout_chatgpt_do_not_run_an_analysis(runner, monkeypatch): + from tradingagents.llm_clients import chatgpt_auth + + monkeypatch.setattr(chatgpt_auth, "sign_in", lambda say: "a@example.com") + monkeypatch.setattr(chatgpt_auth, "sign_out", lambda: "revoked") + signed_in = runner.invoke(m.app, ["login", "chatgpt"]) + assert signed_in.exit_code == 0 and "Signed in to ChatGPT as a@example.com" in signed_in.output + signed_out = runner.invoke(m.app, ["logout", "chatgpt"]) + assert signed_out.exit_code == 0 and "Signed out of ChatGPT" in signed_out.output + assert runner.invoke(m.app, ["login", "claude"]).exit_code != 0 + assert calls == [] + + +@pytest.mark.unit +def test_a_failed_login_exits_with_the_reason(runner, monkeypatch): + from tradingagents.llm_clients import chatgpt_auth + + def refuse(say): + raise chatgpt_auth.ChatGPTAuthError("Signing in with ChatGPT timed out") + + monkeypatch.setattr(chatgpt_auth, "sign_in", refuse) + result = runner.invoke(m.app, ["login", "chatgpt"]) + assert result.exit_code == 1 and "timed out" in result.output + + +@pytest.mark.unit +@pytest.mark.parametrize("outcome, said", [ + (None, "Not signed in"), ("forgotten", "could not be reached to revoke"), +]) +def test_logout_says_whether_openai_revoked_the_sign_in(runner, monkeypatch, outcome, said): + from tradingagents.llm_clients import chatgpt_auth + + monkeypatch.setattr(chatgpt_auth, "sign_out", lambda: outcome) + result = runner.invoke(m.app, ["logout", "chatgpt"]) + assert result.exit_code == 0 and said in " ".join(result.output.split()) + + +@pytest.mark.unit +def test_a_failure_message_with_brackets_is_printed_as_text(runner, monkeypatch): + from tradingagents.llm_clients import chatgpt_auth + + def refuse(say): + raise chatgpt_auth.ChatGPTAuthError("ChatGPT sign-in failed ([/bogus]); sign in again.") + + monkeypatch.setattr(chatgpt_auth, "sign_in", refuse) + result = runner.invoke(m.app, ["login", "chatgpt"]) + assert result.exit_code == 1 and "[/bogus]" in result.output diff --git a/tradingagents/llm_clients/chatgpt_auth.py b/tradingagents/llm_clients/chatgpt_auth.py new file mode 100644 index 000000000..066212423 --- /dev/null +++ b/tradingagents/llm_clients/chatgpt_auth.py @@ -0,0 +1,404 @@ +"""Sign in with ChatGPT, so a run uses the person's ChatGPT Plus or Pro plan. + +This follows OpenAI's documented flow for open-source apps ("Sign in with ChatGPT": +developers.openai.com/siwc): dynamic client registration, PKCE, and a loopback +redirect on 127.0.0.1. The sign-in, its tokens and this host's id live in one file +under ~/.tradingagents, readable only by its owner. Access tokens last an hour and +are refreshed under a lock shared by every thread and process, because each refresh +rotates the refresh token and replaying a used one ends the sign-in. +""" + +import base64 +import hashlib +import http.server +import json +import os +import secrets +import threading +import time +import urllib.parse +import uuid +from collections.abc import Callable +from pathlib import Path + +import requests + +from tradingagents.dataflows.files import locked +from tradingagents.default_config import _TRADINGAGENTS_HOME + +ISSUER = "https://auth.openai.com" +AUTHORIZE_URL = f"{ISSUER}/api/accounts/authorize" +TOKEN_URL = f"{ISSUER}/api/accounts/oauth/token" +REVOKE_URL = f"{ISSUER}/api/accounts/oauth/revoke" +JWKS_URL = f"{ISSUER}/.well-known/jwks.json" +API_BASE = "https://api.openai.com/v1" +DYNAMIC_CLIENT_ID = "dynamic_agent_client" +APP_NAME = "TradingAgents" +SCOPE = "openid profile email offline_access resource.invoke chatgpt.tokens.use.direct" +PLAN_SCOPE = "chatgpt.tokens.use.direct" +CALLBACK_PATH = "/auth/callback" +DEFAULT_PORT = 1455 +REFRESH_MARGIN = 300 # refresh when the access token has under 5 minutes left +RETRY_AFTER = 60 # after OpenAI could not renew, wait this long before trying again +TIMEOUT = 30 + +AUTH_FILE = Path(_TRADINGAGENTS_HOME) / "chatgpt-auth.json" +LOGIN_HINT = "Not signed in to ChatGPT; run `tradingagents login chatgpt`." + + +_retry_after = 0.0 # no renewal attempt in this process before this time + + +class ChatGPTAuthError(RuntimeError): + """Signing in or refreshing failed; the message says what to do.""" + + +class ChatGPTUnreachable(ChatGPTAuthError): + """OpenAI could not be reached or was busy; the sign-in itself is still good.""" + + +# --- the auth file ------------------------------------------------------------------- + +def _read() -> dict: + try: + data = json.loads(AUTH_FILE.read_text(encoding="utf-8")) + except (OSError, ValueError): + return {} + return data if isinstance(data, dict) else {} + + +def _write(data: dict) -> None: + """Write the auth file whole, readable only by its owner. Unlike a cache, a failed + write must surface: losing a rotated refresh token would end the sign-in.""" + AUTH_FILE.parent.mkdir(parents=True, exist_ok=True) + temp = AUTH_FILE.with_name(f"{AUTH_FILE.name}.{uuid.uuid4().hex}.tmp") + fd = os.open(temp, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + try: + with os.fdopen(fd, "w", encoding="utf-8") as handle: + json.dump(data, handle) + os.replace(temp, AUTH_FILE) + except BaseException: + temp.unlink(missing_ok=True) + raise + + +def _host_id(data: dict) -> str: + return data.get("host_id") or f"urn:uuid:{uuid.uuid4()}" + + +def signed_in_as() -> str | None: + """The signed-in account's email, or None.""" + data = _read() + return (data.get("account") or {}).get("email") if data.get("tokens") else None + + +# --- tokens -------------------------------------------------------------------------- + +def _b64url(raw: bytes) -> str: + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode() + + +def _b64url_decode(text: str) -> bytes: + return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4)) + + +def _signing_keys() -> list[dict]: + """OpenAI's ID-token signing keys. Fetched before a token request, so nothing can fail + on the network between OpenAI issuing tokens and this file keeping them.""" + try: + keys = requests.get(JWKS_URL, timeout=TIMEOUT).json()["keys"] + except (requests.RequestException, ValueError, KeyError, TypeError) as exc: + raise ChatGPTUnreachable(f"Could not reach OpenAI to check the sign-in ({type(exc).__name__}); " + "try again.") from None + return keys if isinstance(keys, list) else [] + + +def _validated_claims(id_token: str, client_id: str, nonce: str | None, keys: list[dict]) -> dict: + """The ID token's claims after checking its RS256 signature against OpenAI's keys, + issuer, audience, expiry and (at sign-in) nonce, as the sign-in guide requires.""" + from cryptography.exceptions import InvalidSignature + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import padding, rsa + + try: + header_b64, payload_b64, signature_b64 = id_token.split(".") + header = json.loads(_b64url_decode(header_b64)) + key = next((k for k in keys if k.get("kid") == header.get("kid") and k.get("kty") == "RSA"), None) + if header.get("alg") != "RS256" or key is None: + raise InvalidSignature + public = rsa.RSAPublicNumbers(int.from_bytes(_b64url_decode(key["e"]), "big"), + int.from_bytes(_b64url_decode(key["n"]), "big")).public_key() + public.verify(_b64url_decode(signature_b64), f"{header_b64}.{payload_b64}".encode(), + padding.PKCS1v15(), hashes.SHA256()) + claims = json.loads(_b64url_decode(payload_b64)) + except (InvalidSignature, ValueError, KeyError, TypeError, AttributeError): + raise ChatGPTAuthError("ChatGPT sign-in returned an ID token OpenAI's keys do not verify.") from None + audience = claims.get("aud") + audiences = audience if isinstance(audience, list) else [audience] + if (claims.get("iss") != ISSUER or client_id not in audiences or not claims.get("sub") + or claims.get("exp", 0) + 5 < time.time() + or (nonce is not None and claims.get("nonce") != nonce)): + raise ChatGPTAuthError("ChatGPT sign-in returned an ID token that does not match this sign-in.") + return claims + + +def _token_record(response: dict, now: float, previous: dict | None = None) -> dict: + previous = previous or {} + return { + "access_token": response["access_token"], + "refresh_token": response.get("refresh_token") or previous.get("refresh_token"), + "expires_at": now + int(response.get("expires_in") or 3600), + "earliest_refresh_at": response.get("earliest_refresh_at"), + "scopes": sorted((response.get("scope") or "").split()) or previous.get("scopes", []), + } + + +def _oauth_error(response) -> str: + try: + body = response.json() + except ValueError: + return f"HTTP {response.status_code}" + error = body.get("error") + if isinstance(error, dict): + return error.get("code") or error.get("message") or f"HTTP {response.status_code}" + return error or f"HTTP {response.status_code}" + + +def _token_request(form: dict, task: str, refused: str) -> dict: + """OpenAI's answer to a token request, or the error to show: ``ChatGPTUnreachable`` + when OpenAI could not answer (try again), ``ChatGPTAuthError`` when it refused.""" + try: + response = requests.post(TOKEN_URL, timeout=TIMEOUT, data=form) + except requests.RequestException as exc: + raise ChatGPTUnreachable(f"Could not reach OpenAI to {task} ({type(exc).__name__}); try again.") from None + if response.status_code in (408, 429) or response.status_code >= 500: + raise ChatGPTUnreachable(f"OpenAI could not {task} now ({_oauth_error(response)}); try again.") + if response.status_code != 200: + raise ChatGPTAuthError(f"OpenAI did not {task} ({_oauth_error(response)}); {refused}.") + try: + body = response.json() + except ValueError: + body = None + if not isinstance(body, dict) or not isinstance(body.get("access_token"), str): + raise ChatGPTUnreachable(f"OpenAI's answer could not be read when asked to {task}; try again.") + return body + + +def _refresh(data: dict, now: float) -> dict: + tokens, account = data["tokens"], data["account"] + keys = _signing_keys() + body = _token_request({"grant_type": "refresh_token", "client_id": account["client_id"], + "refresh_token": tokens["refresh_token"], "resource": API_BASE}, + "renew your ChatGPT sign-in", "run `tradingagents login chatgpt` again") + if body.get("id_token"): + claims = _validated_claims(body["id_token"], account["client_id"], None, keys) + if claims["sub"] != account.get("subject"): + raise ChatGPTAuthError("The renewed ChatGPT sign-in belongs to another account.") + return {**data, "tokens": _token_record(body, now, tokens)} + + +def _due(tokens: dict, now: float) -> bool: + """Whether to renew: under five minutes left, and OpenAI accepts a renewal by now + (``earliest_refresh_at``), unless the token has already run out.""" + expires = tokens.get("expires_at", 0) + earliest = tokens.get("earliest_refresh_at") + if now < expires - REFRESH_MARGIN: + return False + return now >= expires or not isinstance(earliest, (int, float)) or now >= earliest + + +def access_token(now: Callable[[], float] = time.time) -> str: + """A valid access token, renewed when it has under five minutes left. + + Renewal happens under a lock every thread and process shares, and starts from + the token on disk: another run may have renewed it already, and the refresh + token it rotated away must never be sent again. When OpenAI cannot be reached + or is busy, a token that still works is used and renewal is tried again a minute later.""" + global _retry_after + tokens = _read().get("tokens") + if not tokens: + raise ChatGPTAuthError(LOGIN_HINT) + if not _due(tokens, now()): + return tokens["access_token"] + with locked(AUTH_FILE): + data = _read() + if not data.get("tokens"): + raise ChatGPTAuthError(LOGIN_HINT) + tokens = data["tokens"] + if _due(tokens, now()): + still_works = tokens.get("expires_at", 0) > now() + if now() < _retry_after: + if still_works: + return tokens["access_token"] + raise ChatGPTUnreachable("OpenAI could not renew your ChatGPT sign-in a moment ago; " + "try again in a minute.") + try: + data = _refresh(data, now()) + except ChatGPTUnreachable: + _retry_after = now() + RETRY_AFTER + if not still_works: + raise + return tokens["access_token"] + _write(data) + return data["tokens"]["access_token"] + + +def credential() -> Callable[[], str]: + """The api_key callable for the chatgpt provider; raises now when nobody is signed in, + so a run stops before it starts rather than at its first model call.""" + if not _read().get("tokens"): + raise ChatGPTAuthError(LOGIN_HINT) + return access_token + + +# --- signing in and out -------------------------------------------------------------- + +class _Callback: + """The first answer carrying this attempt's ``state``; stray or stale requests are ignored.""" + + def __init__(self, state: str): + self.state = state + self.params: dict | None = None + self.received = threading.Event() + self._lock = threading.Lock() + + def accept(self, url: str) -> bool: + parsed = urllib.parse.urlparse(url.strip()) + params = {k: v[0] for k, v in urllib.parse.parse_qs(parsed.query).items()} + if parsed.path != CALLBACK_PATH or params.get("state") != self.state: + return False + with self._lock: + if self.params is None: + self.params = params + self.received.set() + return True + + +def _listen(callback: _Callback, port: int) -> http.server.HTTPServer: + class Handler(http.server.BaseHTTPRequestHandler): + timeout = 5 # a connection that sends nothing cannot hold up the real answer + + def do_GET(self): # noqa: N802 (http.server's name) + ok = callback.accept(self.path) + self.send_response(200 if ok else 400) + self.send_header("Content-Type", "text/plain; charset=utf-8") + self.end_headers() + if ok: + self.wfile.write(b"Return to the terminal to finish signing in to TradingAgents.\n") + + def log_message(self, *args): # the URL carries the sign-in code + pass + + try: + server = http.server.HTTPServer(("127.0.0.1", port), Handler) + except OSError: + server = http.server.HTTPServer(("127.0.0.1", 0), Handler) + # A short poll interval lets the server stop promptly once the answer has arrived. + threading.Thread(target=server.serve_forever, kwargs={"poll_interval": 0.05}, daemon=True).start() + return server + + +def _revoke(tokens: dict, client_id: str | None) -> bool: + try: + response = requests.post(REVOKE_URL, timeout=TIMEOUT, data={ + "token": tokens.get("refresh_token"), "token_type_hint": "refresh_token", + "client_id": client_id, + }) + except requests.RequestException: + return False + return response.status_code == 200 + + +def sign_in(say: Callable[[str], None] = print, read_line: Callable[[], str] = input, + timeout: float = 600, port: int = DEFAULT_PORT) -> str: + """Sign in with ChatGPT and save the result; returns the account's email. + + The address to open is printed. The browser comes back to 127.0.0.1; when this + machine is remote or in a container, paste the address the browser ends up on. + The pasted line is read in a thread that can outlive this call, so sign in as the + last prompt of a process (``tradingagents login chatgpt``).""" + data = _read() + host_id = _host_id(data) + account = data.get("account") or {} + client_id = account.get("client_id") or DYNAMIC_CLIENT_ID + state, nonce, verifier = (_b64url(secrets.token_bytes(32)), _b64url(secrets.token_bytes(32)), + _b64url(secrets.token_bytes(64))) + callback = _Callback(state) + server = _listen(callback, port) + try: + redirect_uri = f"http://127.0.0.1:{server.server_address[1]}{CALLBACK_PATH}" + params = { + "client_id": client_id, "ext_agent_host_id": host_id, "response_type": "code", + "redirect_uri": redirect_uri, "scope": SCOPE, "resource": API_BASE, "state": state, + "nonce": nonce, "code_challenge_method": "S256", + "code_challenge": _b64url(hashlib.sha256(verifier.encode()).digest()), + } + if client_id == DYNAMIC_CLIENT_ID: + params["agent_name_hint"] = APP_NAME + elif account.get("email"): + params["login_hint"] = account["email"] + say("Continue with ChatGPT: open this address and sign in with your Plus or Pro plan:") + say(f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params, quote_via=urllib.parse.quote)}") + say("If the browser cannot reach this machine, paste here the address it ends up on.") + + def read_pasted(): + try: + while not callback.received.is_set(): + if not callback.accept(read_line()) and not callback.received.is_set(): + say("That is not the address this sign-in ends on; paste the whole address.") + except (EOFError, OSError): + return + + threading.Thread(target=read_pasted, daemon=True).start() + if not callback.received.wait(timeout): + raise ChatGPTAuthError("Signing in with ChatGPT timed out; run `tradingagents login chatgpt` again.") + finally: + server.shutdown() + server.server_close() + + answer = callback.params + if answer.get("error"): + raise ChatGPTAuthError(f"ChatGPT sign-in was not completed ({answer['error']}).") + issued = answer.get("client_id") or client_id + if issued == DYNAMIC_CLIENT_ID or (account.get("client_id") and issued != account["client_id"]): + raise ChatGPTAuthError("ChatGPT sign-in did not register TradingAgents; run " + "`tradingagents logout chatgpt`, then sign in again.") + keys = _signing_keys() + body = _token_request({"grant_type": "authorization_code", "client_id": issued, + "code": answer.get("code", ""), "code_verifier": verifier, + "redirect_uri": redirect_uri, "resource": API_BASE}, + "sign you in to ChatGPT", "sign in again") + claims = _validated_claims(body.get("id_token") or "", issued, nonce, keys) + if account.get("subject") and claims["sub"] != account["subject"]: + raise ChatGPTAuthError(f"This machine is signed in as {account.get('email') or 'another account'}; " + "run `tradingagents logout chatgpt` first.") + if PLAN_SCOPE not in (body.get("scope") or "").split(): + raise ChatGPTAuthError("ChatGPT did not allow TradingAgents to use your plan; " + "sign in again and approve plan usage.") + AUTH_FILE.parent.mkdir(parents=True, exist_ok=True) + with locked(AUTH_FILE): + replaced = _read().get("tokens") + _write({ + "host_id": host_id, + "account": {"client_id": issued, "email": claims.get("email"), "subject": claims["sub"]}, + "tokens": _token_record(body, time.time()), + }) + if replaced: + _revoke(replaced, issued) + return claims.get("email") or "your ChatGPT account" + + +def sign_out() -> str | None: + """Revoke the sign-in at OpenAI and forget it, keeping only this host's id. + Returns None when nobody was signed in, "revoked", or "forgotten" when OpenAI + could not be reached to revoke it.""" + if not _read().get("tokens"): + return None + with locked(AUTH_FILE): + data = _read() + tokens = data.get("tokens") + if not tokens: + return None + revoked = _revoke(tokens, (data.get("account") or {}).get("client_id")) + _write({"host_id": _host_id(data)}) + return "revoked" if revoked else "forgotten" From 36479ae1fad3b786716713122d0e52ee0b47273f Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sat, 3 Oct 2026 23:14:49 +0000 Subject: [PATCH 06/10] 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 --- cli/main.py | 5 +- cli/prefs.py | 6 +- cli/prompts.py | 148 +++++++++---- cli/selections.py | 26 ++- tests/test_chatgpt_provider.py | 245 +++++++++++++++++++++ tradingagents/llm_clients/api_key_env.py | 2 + tradingagents/llm_clients/chatgpt_auth.py | 12 + tradingagents/llm_clients/factory.py | 6 +- tradingagents/llm_clients/model_catalog.py | 2 + tradingagents/llm_clients/openai_client.py | 53 +++++ tradingagents/llm_clients/validators.py | 2 +- 11 files changed, 449 insertions(+), 58 deletions(-) create mode 100644 tests/test_chatgpt_provider.py 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 = { From 7142746bdea7a83c05d258f21e2e7bd61350eaae Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sat, 3 Oct 2026 23:16:33 +0000 Subject: [PATCH 07/10] fix(cli): keep a tier's configured thinking setting when the main provider has none (#1440) - the run config takes reasoning effort, thinking level and Claude effort from the selections only when one was chosen --- cli/run.py | 9 +++++---- tests/test_cli_config_precedence.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/cli/run.py b/cli/run.py index 77bf95558..dc934dd42 100644 --- a/cli/run.py +++ b/cli/run.py @@ -83,10 +83,11 @@ def _build_run_config(selections: dict, checkpoint: bool | None) -> dict: config["deep_think_llm"] = selections["deep_think_llm"] config["backend_url"] = selections["backend_url"] config["llm_provider"] = selections["llm_provider"].lower() - # Provider-specific thinking configuration - config["google_thinking_level"] = selections.get("google_thinking_level") - config["openai_reasoning_effort"] = selections.get("openai_reasoning_effort") - config["anthropic_effort"] = selections.get("anthropic_effort") + # Provider-specific thinking configuration. Only the main provider's is + # asked; a tier on another provider keeps its configured value (#1440). + for key in ("google_thinking_level", "openai_reasoning_effort", "anthropic_effort"): + if selections.get(key) is not None: + config[key] = selections[key] config["output_language"] = selections.get("output_language", "English") # --checkpoint/--no-checkpoint overrides only when explicitly given; omitting # the flag preserves TRADINGAGENTS_CHECKPOINT_ENABLED / the default (#976). diff --git a/tests/test_cli_config_precedence.py b/tests/test_cli_config_precedence.py index cb94cff8a..8e9fa3bff 100644 --- a/tests/test_cli_config_precedence.py +++ b/tests/test_cli_config_precedence.py @@ -105,3 +105,15 @@ def test_a_half_set_round_count_says_which_value_won(capsys, monkeypatch): assert config["max_risk_discuss_rounds"] == 5 assert any("TRADINGAGENTS_MAX_DEBATE_ROUNDS" in line for line in printed), printed + + +@pytest.mark.unit +def test_a_tier_s_thinking_setting_survives_a_main_provider_that_has_none(monkeypatch): + """A run on Google with an OpenAI tier (#1440): the run's selections carry no + OpenAI reasoning effort, so the configured one must reach that tier.""" + monkeypatch.setitem(cli_run.DEFAULT_CONFIG, "openai_reasoning_effort", "high") + monkeypatch.setitem(cli_run.DEFAULT_CONFIG, "anthropic_effort", "low") + cfg = cli_run._build_run_config({**SELECTIONS, "llm_provider": "google", "google_thinking_level": "high"}, + checkpoint=None) + assert (cfg["openai_reasoning_effort"], cfg["anthropic_effort"], cfg["google_thinking_level"]) == \ + ("high", "low", "high") From f5e3eae3a8885ddddc29513cf2da033600dc0be2 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 15 Oct 2026 23:55:41 +0000 Subject: [PATCH 08/10] fix(memory): score a decision from the first close after its date (#1518) - settlement enters at the first session after the decision date, so news published after that day's close is not scored against the same day's close --- README.md | 2 +- tests/test_graph_end_to_end.py | 6 +-- tests/test_memory_log.py | 48 ++++++++++++++++-------- tests/test_symbol_normalization_paths.py | 2 +- tradingagents/backtest.py | 2 +- tradingagents/default_config.py | 4 +- tradingagents/memory/reflection.py | 2 +- tradingagents/memory/settlement.py | 15 +++++--- 8 files changed, 51 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 38cc4f3cf..03f19b039 100644 --- a/README.md +++ b/README.md @@ -374,7 +374,7 @@ From the CLI: tradingagents backtest NVDA,AAPL --start 2026-06-01 --end 2026-08-01 --every 7 ``` -Each cell is scored on realized alpha against the instrument's regional benchmark, grouped by rating. Your own memory log is never written to, and re-running the same grid with `run_id=result.run_id` skips the cells that already ran, so an interrupted sweep continues where it stopped. +Each cell is scored on realized alpha against the instrument's regional benchmark, grouped by rating. A decision is entered at the first close after its date, because a run reads news published after that day's close. Your own memory log is never written to, and re-running the same grid with `run_id=result.run_id` skips the cells that already ran, so an interrupted sweep continues where it stopped. ## Reproducibility diff --git a/tests/test_graph_end_to_end.py b/tests/test_graph_end_to_end.py index 2f5b55e86..b55562e0d 100644 --- a/tests/test_graph_end_to_end.py +++ b/tests/test_graph_end_to_end.py @@ -312,7 +312,7 @@ def test_past_decisions_settle_alongside_the_analysts(tmp_path, monkeypatch, off model = TimedModel() graph = _graph(tmp_path, monkeypatch, model) - graph.memory_log.store_decision("NVDA", "2026-01-02", "Rating: Buy\n\nBuy NVDA.") + graph.memory_log.store_decision("NVDA", "2025-12-31", "Rating: Buy\n\nBuy NVDA.") closes = pd.Series([100.0 + i for i in range(10)], index=pd.bdate_range("2026-01-02", periods=10)) monkeypatch.setattr(settlement, "get_closes", lambda *a, **k: closes) @@ -322,7 +322,7 @@ def test_past_decisions_settle_alongside_the_analysts(tmp_path, monkeypatch, off assert len(reflections) == 1 # settled once, inside the run analyst_turns = [c for c in model.seen if c[0] == "agent"] assert reflections[0][2] < max(c[3] for c in analyst_turns), "settled before the analysts, not alongside" - settled = next(e for e in graph.memory_log.load_entries() if e["date"] == "2026-01-02") + settled = next(e for e in graph.memory_log.load_entries() if e["date"] == "2025-12-31") assert not settled["pending"] lessons = graph.memory_log.get_past_context("NVDA", as_of=TRADE_DATE) assert lessons @@ -372,7 +372,7 @@ def test_the_memory_step_reads_lessons_as_of_the_trade_date_and_settles_once(tmp from tradingagents.memory import settlement graph = _graph(tmp_path, monkeypatch, ScriptedModel(structured=True)) - graph.memory_log.store_decision("NVDA", "2026-01-02", "Rating: Buy\n\nBuy NVDA.") + graph.memory_log.store_decision("NVDA", "2025-12-31", "Rating: Buy\n\nBuy NVDA.") closes = pd.Series([100.0 + i for i in range(10)], index=pd.bdate_range("2026-01-02", periods=10)) monkeypatch.setattr(settlement, "get_closes", lambda *a, **k: closes) asked = [] diff --git a/tests/test_memory_log.py b/tests/test_memory_log.py index 375be979e..b7206afae 100644 --- a/tests/test_memory_log.py +++ b/tests/test_memory_log.py @@ -512,8 +512,8 @@ class TestDeferredReflection: # settlement.fetch_returns def test_fetch_returns_valid_ticker(self): - stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0] - spy_prices = [400.0, 402.0, 404.0, 403.0, 405.0, 406.0] + stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0, 107.0] + spy_prices = [400.0, 402.0, 404.0, 403.0, 405.0, 406.0, 407.0] with patch("yfinance.Ticker") as mock_ticker_cls: def _make_ticker(sym): m = MagicMock() @@ -524,8 +524,8 @@ class TestDeferredReflection: assert raw is not None and alpha is not None and days is not None assert isinstance(raw, float) and isinstance(alpha, float) and isinstance(days, int) assert days == 5 - # resolution date = the bar `days` sessions after the trade date (#1251) - assert resolved == "2026-01-10" + # resolution date = the bar `days` sessions after entry, the first close after the decision (#1251) + assert resolved == "2026-01-11" def test_fetch_returns_too_recent(self): """Only 1 data point available → returns all-None, no crash.""" @@ -549,7 +549,7 @@ class TestDeferredReflection: """SPY having fewer rows than the stock (but still a full window) must not raise IndexError.""" stock_prices = [100.0, 102.0, 104.0, 103.0, 105.0, 106.0, 107.0, 108.0] # 8 rows - spy_prices = [400.0, 402.0, 403.0, 405.0, 406.0, 407.0] # 6 rows + spy_prices = [400.0, 402.0, 403.0, 405.0, 406.0, 407.0, 408.0] # 7 rows with patch("yfinance.Ticker") as mock_ticker_cls: def _make_ticker(sym): m = MagicMock() @@ -559,7 +559,22 @@ class TestDeferredReflection: raw, alpha, days, resolved = settlement.fetch_returns("NVDA", "2026-01-05") assert raw is not None and alpha is not None assert days == 5 # full holding window used for both series - assert resolved == "2026-01-10" + assert resolved == "2026-01-11" + + def test_a_decision_is_scored_from_the_first_close_after_its_date(self): + """#1518: news published after the close on the decision date reaches the + analysts, so the close of that date is not a price they could have traded at.""" + stock_prices = [100.0, 150.0, 150.0, 150.0, 150.0, 150.0, 165.0] # jumps on the next close + spy_prices = [400.0] * 7 + with patch("yfinance.Ticker") as mock_ticker_cls: + def _make_ticker(sym): + m = MagicMock() + m.history.return_value = _price_df(spy_prices if sym == "SPY" else stock_prices) + return m + mock_ticker_cls.side_effect = _make_ticker + raw, alpha, days, resolved = settlement.fetch_returns("NVDA", "2026-01-05") + assert raw == pytest.approx(0.10) and alpha == pytest.approx(0.10) # 150 -> 165, not 100 -> 150 + assert resolved == "2026-01-11" def test_fetch_returns_incomplete_window_stays_pending(self): """#1169: a rerun before the full holding window has traded returns @@ -1082,10 +1097,10 @@ class TestSettlementWindow: raw, alpha, days, resolved = settlement.fetch_returns("BTC-USD", "2026-01-03", 5, "SPY") - # The coin is held Sat 3 -> Thu 8; SPY over the same span is Fri 2 -> Thu 8. - assert resolved == "2026-01-08" - assert raw == pytest.approx(105.0 / 100.0 - 1) - assert alpha == pytest.approx(raw - (spy["2026-01-08"] / spy["2026-01-02"] - 1)) + # Decided Sat 3, the coin is held Sun 4 -> Fri 9; SPY over the same span is Fri 2 -> Fri 9. + assert resolved == "2026-01-09" + assert raw == pytest.approx(106.0 / 101.0 - 1) + assert alpha == pytest.approx(raw - (spy["2026-01-09"] / spy["2026-01-02"] - 1)) def test_bars_stamped_in_different_time_zones_meet_on_their_dates(self, monkeypatch): # Yahoo stamps a coin's daily bar at midnight UTC and SPY's at midnight @@ -1098,9 +1113,9 @@ class TestSettlementWindow: raw, alpha, _, resolved = settlement.fetch_returns("BTC-USD", "2026-01-03", 5, "SPY") - assert resolved == "2026-01-08" + assert resolved == "2026-01-09" spy_by_day = spy.tz_localize(None) - assert alpha == pytest.approx(raw - (spy_by_day["2026-01-08"] / spy_by_day["2026-01-02"] - 1)) + assert alpha == pytest.approx(raw - (spy_by_day["2026-01-09"] / spy_by_day["2026-01-02"] - 1)) def test_the_outcome_waits_for_the_benchmark_to_trade_through_the_window(self, monkeypatch): coin = pd.Series([100.0 + i for i in range(10)], index=pd.date_range("2026-01-03", periods=10, freq="D")) @@ -1117,9 +1132,10 @@ class TestSettlementWindow: raw, alpha, _, resolved = settlement.fetch_returns("NVDA", "2026-01-05", 5, "SPY") - assert raw == pytest.approx(105.0 / 100.0 - 1) - assert resolved == "2026-01-13" - assert alpha == pytest.approx(raw - (spy["2026-01-13"] / spy["2026-01-05"] - 1)) + # Entered at the first real close after the decision: Wed 7, past the missing Tue 6. + assert raw == pytest.approx(106.0 / 101.0 - 1) + assert resolved == "2026-01-14" + assert alpha == pytest.approx(raw - (spy["2026-01-14"] / spy["2026-01-07"] - 1)) @pytest.mark.unit @@ -1130,7 +1146,7 @@ def test_a_close_that_is_not_a_price_is_skipped_not_scored(monkeypatch): spy = pd.Series([400.0 + i for i in range(8)], index=idx) monkeypatch.setattr(settlement, "get_closes", _closes({"NVDA": stock, "SPY": spy})) - raw, alpha, _, _ = settlement.fetch_returns("NVDA", "2026-01-05", 5, "SPY") + raw, alpha, _, _ = settlement.fetch_returns("NVDA", "2026-01-02", 5, "SPY") assert raw == pytest.approx(105.0 / 100.0 - 1) # the window opens on the first real close diff --git a/tests/test_symbol_normalization_paths.py b/tests/test_symbol_normalization_paths.py index a1971445d..9b4cf62d3 100644 --- a/tests/test_symbol_normalization_paths.py +++ b/tests/test_symbol_normalization_paths.py @@ -54,7 +54,7 @@ def test_fetch_returns_normalizes_symbol(monkeypatch): assert queried[0] == "GC=F" # stock symbol normalized (#984) assert queried[1] == "SPY" # benchmark left as the canonical symbol assert raw is not None and days is not None - assert resolved == "2025-01-07" # resolution date recorded (#1251) + assert resolved == "2025-01-08" # resolution date recorded (#1251) def test_news_lookup_normalizes_symbol(monkeypatch): diff --git a/tradingagents/backtest.py b/tradingagents/backtest.py index c4a33cb84..1b3503118 100644 --- a/tradingagents/backtest.py +++ b/tradingagents/backtest.py @@ -119,7 +119,7 @@ class BacktestSummary: if self.pending: lines.append("Pending cells are not scored above; re-run to settle them.") lines.append( - f"Alpha is measured over {self.holding} after each analysis date. " + f"Alpha is measured over {self.holding} from the first close after each analysis date. " "One model sampling per cell, and text feeds are not archived, so " "these figures are indicative rather than repeatable." ) diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index 5723a70c0..2ce4fadc6 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -175,8 +175,8 @@ def build_default_config() -> dict: # based on the ticker's exchange suffix. SPY remains the US default # so the reflection label keeps reading "Alpha vs SPY" for US tickers # while non-US tickers get their regional index automatically. - # Trading days after the analysis date over which a decision's outcome is - # measured, for reflection and for the backtest figures. + # Trading days from the first close after the analysis date over which a + # decision's outcome is measured, for reflection and for the backtest figures. "holding_period_days": 5, "benchmark_ticker": None, "benchmark_map": { diff --git a/tradingagents/memory/reflection.py b/tradingagents/memory/reflection.py index ba8bf5f31..cf23c6395 100644 --- a/tradingagents/memory/reflection.py +++ b/tradingagents/memory/reflection.py @@ -21,7 +21,7 @@ class Reflector: """ return ( "You are a trading analyst reviewing your own past decision now that the outcome is known.\n" - f"The outcome covers {holding_days} trading days after the analysis date, " + f"The outcome covers {holding_days} trading days from the first close after the analysis date, " "which may be shorter than the horizon the decision was written for.\n" "Write exactly 2-4 sentences of plain prose (no bullets, no headers, no markdown).\n\n" "Cover in order:\n" diff --git a/tradingagents/memory/settlement.py b/tradingagents/memory/settlement.py index 032b3c68b..c997b19f2 100644 --- a/tradingagents/memory/settlement.py +++ b/tradingagents/memory/settlement.py @@ -53,7 +53,10 @@ def fetch_returns( ticker: str, trade_date: str, holding_days: int = 5, benchmark: str = "SPY", ) -> tuple[float | None, float | None, int | None, str | None]: - """Fetch raw and alpha return for ticker over holding_days from trade_date. + """Fetch raw and alpha return for ticker over holding_days after trade_date. + + The position is entered at the first close after ``trade_date``: a run dated + ``trade_date`` reads news published after that day's close (#1518). ``benchmark`` is the index used as the alpha baseline (resolved by the caller via ``resolve_benchmark``). Returns ``(raw_return, alpha_return, @@ -64,13 +67,15 @@ def fetch_returns( to report. """ start = datetime.strptime(trade_date, "%Y-%m-%d") - # holding_days counts trading days, so ask for the calendar span they - # occupy (about 7 for every 5) plus a week for holidays. - end = start + timedelta(days=round(holding_days * 7 / 5) + 7) + # holding_days counts trading days after the entry session, so ask for the + # calendar span they occupy (about 7 for every 5) plus a week for holidays. + end = start + timedelta(days=round((holding_days + 1) * 7 / 5) + 7) end_str = end.strftime("%Y-%m-%d") - # Closes for the instrument the analysis priced (XAUUSD -> GC=F, #984). + # Closes for the instrument the analysis priced (XAUUSD -> GC=F, #984), + # from the first session after the decision date. stock = _by_day(get_closes(ticker, trade_date, end_str)) + stock = stock[stock.index > start] # From a week earlier, so the benchmark has a close on or before entry. bench_start = (start - timedelta(days=7)).strftime("%Y-%m-%d") bench = _by_day(get_closes(benchmark, bench_start, end_str)) From bc55fe3ed72e675e648696d148063313f1c053d2 Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sat, 3 Oct 2026 23:15:48 +0000 Subject: [PATCH 09/10] docs: ChatGPT plan sign-in and the start-up update check (#1475) - README and .env.example cover the chatgpt provider, `tradingagents login chatgpt` (also in Docker) and logout - README and .env.example name TRADINGAGENTS_NO_UPDATE_CHECK --- .env.example | 6 ++++++ README.md | 6 +++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.env.example b/.env.example index 1db9017cf..eaa51ca65 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,9 @@ NVIDIA_API_KEY= #AWS_DEFAULT_REGION=us-west-2 #AWS_PROFILE= +# ChatGPT Plus or Pro plan: no key; sign in once with `tradingagents login chatgpt`. +#TRADINGAGENTS_LLM_PROVIDER=chatgpt + # Remote Ollama server. Unset uses http://localhost:11434/v1. #OLLAMA_BASE_URL=http://your-ollama-host:11434/v1 @@ -54,6 +57,9 @@ NVIDIA_API_KEY= #TRADINGAGENTS_MAX_TOOL_ROUNDS=20 #TRADINGAGENTS_CHECKPOINT_ENABLED=false +# Turn off the start-up note about a newer release and Tauric Research notices. +#TRADINGAGENTS_NO_UPDATE_CHECK=1 + # Docker: a host folder for results, reports and the memory log (unset: the tradingagents_data volume). #TRADINGAGENTS_DATA_DIR=./data diff --git a/README.md b/README.md index 03f19b039..5b5835bbc 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,8 @@ export TYPESAFE_API_KEY=... # Jev social-post screening (optional) For Azure OpenAI, copy `.env.enterprise.example` to `.env.enterprise` and fill in your credentials. +To run on your ChatGPT Plus or Pro plan instead of an API key, choose "Subscription" in the CLI's provider menu, or set `llm_provider: "chatgpt"`. Sign in once with `tradingagents login chatgpt`: it prints an address to open in your browser, which returns to this machine on `127.0.0.1`. When the browser runs on another machine, or TradingAgents runs in Docker (`docker compose run --rm tradingagents login chatgpt`), paste the address the browser ends up on. The sign-in is kept in `~/.tradingagents/chatgpt-auth.json`, and `tradingagents logout chatgpt` revokes it. Runs count toward your plan's usage limits, and the plan does not accept `temperature` or `max_tokens`. + For AWS Bedrock, install the extra with `pip install ".[bedrock]"`, set `llm_provider: "bedrock"`, configure AWS credentials (environment variables, `~/.aws/credentials`, or an IAM role) and `AWS_DEFAULT_REGION`, and use a Bedrock model ID, e.g. `us.anthropic.claude-opus-5-5`. For local models, configure Ollama with `llm_provider: "ollama"`. The default endpoint is `http://localhost:11434/v1`; set `OLLAMA_BASE_URL` to point at a remote `ollama-serve`. Pull models with `ollama pull `, and pick "Custom model ID" in the CLI for any model not listed by default. @@ -213,6 +215,8 @@ tradingagents --ticker NVDA --date 2026-09-23 --analysts market,news,fundamental ``` Each flag skips only its own question. Run without a terminal, a missing answer stops the run before it starts and names the flag or variable to set. +At start-up the interactive CLI mentions a newer release and notices from Tauric Research. It checks at most once a day, in the background, and sends nothing about your runs; set `TRADINGAGENTS_NO_UPDATE_CHECK=1` to turn it off. Runs without a terminal never check. + A saved report also includes `complete_report.html`, the report as one page with its sections listed beside the text, for reading in a browser, on a phone or in print. Answering the save question at the prompt also asks about the page and can open it in your browser; `--no-html` skips it, and so does `ta.save_reports(state, "NVDA", html=False)` from Python. ### Markets and tickers @@ -243,7 +247,7 @@ An interface will appear showing results as they load, letting you track the age ### Implementation Details -We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, Google, Anthropic, xAI, DeepSeek, Qwen (Alibaba DashScope, international and China endpoints), GLM (Zhipu), MiniMax (global + China), OpenRouter, Ollama for local models, and Azure OpenAI for enterprise. +We built TradingAgents with LangGraph to ensure flexibility and modularity. The framework supports multiple LLM providers: OpenAI, Google, Anthropic, xAI, DeepSeek, Qwen (Alibaba DashScope, international and China endpoints), GLM (Zhipu), MiniMax (global + China), OpenRouter, Ollama for local models, Azure OpenAI for enterprise, and your own ChatGPT plan. ### Python Usage From f802271fb3fed3b778c7cb73fee365f20533770c Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Sat, 3 Oct 2026 23:17:47 +0000 Subject: [PATCH 10/10] release: 0.7.0 --- CHANGELOG.md | 25 +++++++++++++++++++++++++ README.md | 3 ++- tradingagents/__init__.py | 2 +- 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 717ee75b2..11e594a2c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). Changes that need action when upgrading are listed under "Upgrading from" in their release. +## [0.7.0] — 2026-10-16 + +Runs on your ChatGPT plan, backtests scored from the first close after each decision, a note at start-up when a newer release is out, and a wheel attached to each GitHub release. + +### Added + +- **Your ChatGPT plan.** The `chatgpt` provider runs TradingAgents on a ChatGPT Plus or Pro plan instead of an API key, through OpenAI's sign-in for open-source apps. Choose "Subscription" in the CLI's provider menu; for unattended runs, Docker and Python (`llm_provider="chatgpt"`), sign in once with `tradingagents login chatgpt`. `tradingagents logout chatgpt` revokes the sign-in. The CLI lists the plan's models, and reasoning effort applies as it does for OpenAI. (#1194, #1475) +- **What's new at start-up.** The interactive CLI mentions a newer release, with the command that upgrades this install when it can tell how TradingAgents was installed, and notices from Tauric Research for the running version. It checks at most once a day in the background, so a run never waits on it; runs without a terminal never check, and `TRADINGAGENTS_NO_UPDATE_CHECK=1` turns it off. +- **Release downloads.** Each GitHub release carries the source archive and a wheel, so a version installs with `pip install` from its release page without cloning. + +### Upgrading from 0.6.0 + +- `cryptography` 42 and `packaging` 24 or later are installed. +- Decisions are now scored from the first close after their date. Outcomes already settled in a memory log keep the earlier entry, the close of the decision date, so a backtest resumed with `run_id` mixes the two; start a new run to compare like with like. + +### Fixed + +- A decision is no longer scored against news it could not have traded on: a run dated *d* reads news published after the close on *d*, so the memory log and backtests enter it at the first close after *d*, not the close of *d*. (#1518) +- A run without a terminal, such as a scheduled job or CI, no longer contacts the announcements server before it starts. +- A model tier on another provider keeps its configured reasoning effort, thinking level or Claude effort when the main provider has none. (#1440) + +### Contributors + +[@LeonWTW](https://github.com/LeonWTW), [@OleksanderSS](https://github.com/OleksanderSS), [@sovorn-c](https://github.com/sovorn-c). + ## [0.6.0] — 2026-10-03 HTML reports, a provider per model tier, past decisions settled for every ticker while the analysts work, and company news read while Yahoo's news feed is down. diff --git a/README.md b/README.md index 5b5835bbc..cedcdb1e5 100644 --- a/README.md +++ b/README.md @@ -32,15 +32,16 @@ ## News +- [2026-10] **TradingAgents v0.7.0** released with runs on your own ChatGPT Plus or Pro plan, backtests scored from the first close after each decision, a note at start-up when a newer release is out, and a wheel attached to each GitHub release. - [2026-10] **TradingAgents v0.6.0** released with reports saved as one HTML page, a provider per model tier so the managers and analysts can run on different models, past decisions settled for every ticker while the analysts work, and company news read from Yahoo search while Yahoo's news feed is down. - [2026-09] **TradingAgents v0.5.2** released with parallel analysts for a faster analysis, a CLI that runs without prompts from flags such as `--ticker` and `--date`, the run's settings recorded in every report, and backtests that see only data published by each analysis date. -- [2026-09] **TradingAgents v0.5.1** released with a package layout organised by what each module holds (import paths moved), optional Jev screening of social posts, GPT-6 Sol and Luna as the default models, and fixes to run isolation and SEC EDGAR statements. Full release notes are in [CHANGELOG.md](CHANGELOG.md).
Earlier news +- [2026-09] **TradingAgents v0.5.1** released with a package layout organised by what each module holds (import paths moved), optional Jev screening of social posts, GPT-6 Sol and Luna as the default models, and fixes to run isolation and SEC EDGAR statements. - [2026-09] **TradingAgents v0.5.0** released with point-in-time integrity across every dated path, SEC EDGAR fundamentals served as filed, backtesting over a ticker and date grid, portfolio-aware runs, and current model lineups across every provider. - [2026-08] **TradingAgents v0.4.0** released with look-ahead / point-in-time fixes across FRED macro, social sentiment, and the decision-log memory; clearer decision signals; working CLI checkpoint resume; Trader price grounding; and the GPT-5.6 and GLM-5.3 models. - [2026-07] **TradingAgents v0.3.1** released with correctness and stability fixes: Alpha Vantage look-ahead filtering, graph-router crash-safety, graph-shape-aware checkpoint resume, working crypto sentiment sources, a configurable LLM retry budget, Bedrock API-key auth, and Claude Sonnet 5 / Fable 5 support. diff --git a/tradingagents/__init__.py b/tradingagents/__init__.py index 785bee24f..8c46d2f20 100644 --- a/tradingagents/__init__.py +++ b/tradingagents/__init__.py @@ -1,6 +1,6 @@ """TradingAgents: multi-agent LLM financial trading framework.""" -__version__ = "0.7.0.dev0" +__version__ = "0.7.0" from dotenv import find_dotenv, load_dotenv