From cc39a35c42561d0779eb50552f42f3bc7cefa54b Mon Sep 17 00:00:00 2001 From: Yijia-Xiao Date: Thu, 1 Oct 2026 05:44:56 +0000 Subject: [PATCH] feat(llm): let each model tier run on its own provider (#1440) - quick_think_provider / deep_think_provider and their backend URLs (with TRADINGAGENTS_* variables) choose a tier's provider; unset, a tier uses llm_provider and backend_url - a tier on another provider gets that provider's own settings and endpoint, and the run settings and report header name each tier's provider - the CLI checks each tier provider's model variable and key before the run --- .env.example | 5 ++ README.md | 9 +++ cli/selections.py | 31 +++++++-- tests/test_cli_headless.py | 46 ++++++++++++++ tests/test_graph_end_to_end.py | 2 +- tests/test_tier_providers.py | 90 +++++++++++++++++++++++++++ tradingagents/default_config.py | 11 ++++ tradingagents/graph/trading_graph.py | 27 ++------ tradingagents/llm_clients/__init__.py | 4 +- tradingagents/llm_clients/factory.py | 22 +++++++ tradingagents/reporting.py | 10 ++- 11 files changed, 225 insertions(+), 32 deletions(-) create mode 100644 tests/test_tier_providers.py diff --git a/.env.example b/.env.example index 556bca125..1db9017cf 100644 --- a/.env.example +++ b/.env.example @@ -42,6 +42,11 @@ NVIDIA_API_KEY= #TRADINGAGENTS_LLM_PROVIDER=openai #TRADINGAGENTS_DEEP_THINK_LLM=gpt-6-sol #TRADINGAGENTS_QUICK_THINK_LLM=gpt-6-luna +# A tier on its own provider (set its model above too); its URL only for a local or relay endpoint. +#TRADINGAGENTS_DEEP_THINK_PROVIDER= +#TRADINGAGENTS_QUICK_THINK_PROVIDER= +#TRADINGAGENTS_DEEP_THINK_BACKEND_URL= +#TRADINGAGENTS_QUICK_THINK_BACKEND_URL= #TRADINGAGENTS_LLM_BACKEND_URL= #TRADINGAGENTS_OUTPUT_LANGUAGE=English #TRADINGAGENTS_MAX_DEBATE_ROUNDS=1 diff --git a/README.md b/README.md index 677379ae3..16268c846 100644 --- a/README.md +++ b/README.md @@ -277,6 +277,15 @@ _, decision = ta.propagate("NVDA", "2026-09-01") print(decision) ``` +The quick model serves the analysts, researchers, debaters and trader; the deep model serves the research and portfolio managers. Each can run on its own provider, for example the managers on Claude while the rest run on OpenAI: + +```python +config["deep_think_provider"] = "anthropic" +config["deep_think_llm"] = "claude-opus-5-5" +``` + +A tier on its own provider uses that provider's key and default endpoint; set `quick_think_backend_url` or `deep_think_backend_url` for a local or relay endpoint. The `TRADINGAGENTS_DEEP_THINK_PROVIDER` and `TRADINGAGENTS_QUICK_THINK_PROVIDER` variables set them for the CLI, together with the tier's model variable. + See `tradingagents/default_config.py` for all configuration options. ### Fundamentals as filed diff --git a/cli/selections.py b/cli/selections.py index a269158b2..2ef304d5c 100644 --- a/cli/selections.py +++ b/cli/selections.py @@ -68,6 +68,29 @@ def unattended_gaps(flags) -> list[str]: return gaps +def _check_tier_providers(main_provider: str) -> 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. + """ + used = [] + for tier in ("quick", "deep"): + provider = (DEFAULT_CONFIG.get(f"{tier}_think_provider") or main_provider).lower() + if provider != main_provider.lower(): + 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) + if provider not in used: + used.append(provider) + for provider in used: + ensure_api_key(provider) + + def _from_flag(parse, value, *args): """A flag's value through the same check its prompt applies; a bad one ends the run.""" try: @@ -223,8 +246,6 @@ def _prompt_selections(prefs, flags): ) console.print(f"[green]✓ LLM provider from environment:[/green] {selected_llm_provider}") console.print(f"[green]✓ Backend URL:[/green] {backend_url}") - # Still confirm/persist the API key so the run doesn't fail later. - ensure_api_key(selected_llm_provider) else: console.print( create_question_box( @@ -261,10 +282,8 @@ def _prompt_selections(prefs, flags): if selected_llm_provider == "ollama": confirm_ollama_endpoint(backend_url) - # Confirm the provider's API key is present; prompt the user to paste - # one and persist it to .env if it's missing, so the analysis run - # doesn't fail later at the first API call. - ensure_api_key(selected_llm_provider) + + _check_tier_providers(selected_llm_provider) # 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"): diff --git a/tests/test_cli_headless.py b/tests/test_cli_headless.py index 3ba31b20f..e08543cfb 100644 --- a/tests/test_cli_headless.py +++ b/tests/test_cli_headless.py @@ -185,3 +185,49 @@ def test_an_unknown_analyst_error_names_the_analysts_as_users_know_them(): with pytest.raises(ValueError) as caught: prompts.parse_analysts("macro", AssetType.STOCK) assert "sentiment" in str(caught.value) and "social" not in str(caught.value) + + +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) + checked = keys if keys is not None else [] + monkeypatch.setattr(selections, "ensure_api_key", lambda provider: checked.append(provider)) + return checked + + +@pytest.mark.unit +def test_a_tier_on_another_provider_needs_its_own_model(monkeypatch, capsys): + """Otherwise the CLI would take the main provider's model for it (#1440).""" + _selections_with_env(monkeypatch) + monkeypatch.setitem(selections.DEFAULT_CONFIG, "deep_think_provider", "anthropic") + monkeypatch.delenv("TRADINGAGENTS_DEEP_THINK_LLM", raising=False) + + with pytest.raises(typer.Exit): + selections._prompt_selections({}, FLAGS) + assert "TRADINGAGENTS_DEEP_THINK_LLM" in capsys.readouterr().out + + +@pytest.mark.unit +def test_each_tier_provider_s_key_is_checked_before_the_run(monkeypatch): + checked = _selections_with_env(monkeypatch) + monkeypatch.setitem(selections.DEFAULT_CONFIG, "deep_think_provider", "anthropic") + monkeypatch.setenv("TRADINGAGENTS_DEEP_THINK_LLM", "claude-opus-5-5") + + selections._prompt_selections({}, FLAGS) + + assert checked == ["openai", "anthropic"] + + +@pytest.mark.unit +def test_only_the_providers_the_tiers_use_need_a_key(monkeypatch): + """Both tiers on a local Ollama: the main provider's key is not needed.""" + checked = _selections_with_env(monkeypatch) + for tier in ("quick", "deep"): + monkeypatch.setitem(selections.DEFAULT_CONFIG, f"{tier}_think_provider", "ollama") + monkeypatch.setenv(f"TRADINGAGENTS_{tier.upper()}_THINK_LLM", "llama3.1") + + selections._prompt_selections({}, FLAGS) + + assert checked == ["ollama"] diff --git a/tests/test_graph_end_to_end.py b/tests/test_graph_end_to_end.py index 8083e3683..2f5b55e86 100644 --- a/tests/test_graph_end_to_end.py +++ b/tests/test_graph_end_to_end.py @@ -124,7 +124,7 @@ def _graph(tmp_path, monkeypatch, model, debug=False, **config): cfg = copy.deepcopy(DEFAULT_CONFIG) cfg.update(results_dir=str(tmp_path / "results"), data_cache_dir=str(tmp_path / "cache"), memory_log_path=str(tmp_path / "log.md"), **config) - monkeypatch.setattr(trading_graph, "create_llm_client", lambda **k: _Client(model)) + monkeypatch.setattr(trading_graph, "create_tier_client", lambda config, tier, **k: _Client(model)) return trading_graph.TradingAgentsGraph(config=cfg, debug=debug) diff --git a/tests/test_tier_providers.py b/tests/test_tier_providers.py new file mode 100644 index 000000000..83055e39b --- /dev/null +++ b/tests/test_tier_providers.py @@ -0,0 +1,90 @@ +"""Each model tier can name its own provider (#1440). + +The quick tier (analysts, researchers, debaters, trader) and the deep tier +(research and portfolio managers) default to ``llm_provider``. A tier that names +another provider gets that provider's own settings and endpoint, never the main +provider's URL, and the run's settings and report say which provider each used. +""" + +import pytest + +from tradingagents import default_config +from tradingagents.llm_clients import factory + + +def _config(**overrides): + config = {**default_config.DEFAULT_CONFIG, "llm_provider": "openai", "backend_url": "https://api.openai.com/v1", + "quick_think_llm": "gpt-6-luna", "deep_think_llm": "gpt-6-sol", + "openai_reasoning_effort": "low", "google_thinking_level": "high"} + config.update(overrides) + return config + + +def _captured(monkeypatch): + made = [] + monkeypatch.setattr(factory, "create_llm_client", + lambda provider, model, base_url=None, **kw: made.append((provider, model, base_url, kw))) + return made + + +@pytest.mark.unit +def test_a_tier_without_its_own_provider_uses_the_run_s(monkeypatch): + made = _captured(monkeypatch) + + factory.create_tier_client(_config(), "deep") + + provider, model, base_url, kw = made[0] + assert (provider, model, base_url) == ("openai", "gpt-6-sol", "https://api.openai.com/v1") + assert kw["reasoning_effort"] == "low" + + +@pytest.mark.unit +def test_a_tier_on_another_provider_gets_that_provider_s_settings_and_not_the_main_url(monkeypatch): + made = _captured(monkeypatch) + + factory.create_tier_client(_config(deep_think_provider="google", deep_think_llm="gemini-3.8-flash"), "deep") + + provider, model, base_url, kw = made[0] + assert (provider, model) == ("google", "gemini-3.8-flash") + assert base_url is None # the OpenAI URL must not follow it + assert kw["thinking_level"] == "high" and "reasoning_effort" not in kw + + +@pytest.mark.unit +def test_a_tier_url_is_used_for_that_tier_only(monkeypatch): + made = _captured(monkeypatch) + config = _config(quick_think_provider="openai_compatible", quick_think_backend_url="http://localhost:8000/v1", + quick_think_llm="local-model") + + factory.create_tier_client(config, "quick") + factory.create_tier_client(config, "deep") + + assert made[0][:3] == ("openai_compatible", "local-model", "http://localhost:8000/v1") + assert made[1][:3] == ("openai", "gpt-6-sol", "https://api.openai.com/v1") + + +@pytest.mark.unit +def test_the_run_settings_and_report_name_each_tier_s_provider(): + from tradingagents.graph.trading_graph import TradingAgentsGraph + from tradingagents.reporting import _header + + graph = object.__new__(TradingAgentsGraph) + graph.selected_analysts = ("market",) + graph.config = _config(deep_think_provider="anthropic", deep_think_llm="claude-opus-5-5") + + settings = graph.run_settings() + assert settings["quick_think_provider"] == "openai" and settings["deep_think_provider"] == "anthropic" + header = _header("NVDA", {}, settings) + assert "deep anthropic claude-opus-5-5" in header and "quick openai gpt-6-luna" in header + + +@pytest.mark.unit +def test_the_tier_settings_are_read_from_the_environment(monkeypatch): + monkeypatch.setenv("TRADINGAGENTS_DEEP_THINK_PROVIDER", "anthropic") + monkeypatch.setenv("TRADINGAGENTS_QUICK_THINK_BACKEND_URL", "http://localhost:8000/v1") + + config = default_config.build_default_config() + + assert config["deep_think_provider"] == "anthropic" + assert config["quick_think_backend_url"] == "http://localhost:8000/v1" + assert config["quick_think_provider"] is None diff --git a/tradingagents/default_config.py b/tradingagents/default_config.py index 49ecf7875..5723a70c0 100644 --- a/tradingagents/default_config.py +++ b/tradingagents/default_config.py @@ -11,6 +11,10 @@ _ENV_OVERRIDES = { "TRADINGAGENTS_LLM_PROVIDER": "llm_provider", "TRADINGAGENTS_DEEP_THINK_LLM": "deep_think_llm", "TRADINGAGENTS_QUICK_THINK_LLM": "quick_think_llm", + "TRADINGAGENTS_DEEP_THINK_PROVIDER": "deep_think_provider", + "TRADINGAGENTS_QUICK_THINK_PROVIDER": "quick_think_provider", + "TRADINGAGENTS_DEEP_THINK_BACKEND_URL": "deep_think_backend_url", + "TRADINGAGENTS_QUICK_THINK_BACKEND_URL": "quick_think_backend_url", "TRADINGAGENTS_LLM_BACKEND_URL": "backend_url", "TRADINGAGENTS_OUTPUT_LANGUAGE": "output_language", "TRADINGAGENTS_MAX_DEBATE_ROUNDS": "max_debate_rounds", @@ -94,6 +98,13 @@ def build_default_config() -> dict: # provider-specific URL here would leak (e.g. OpenAI's /v1 was previously # being forwarded to Gemini, producing malformed request URLs). "backend_url": None, + # A tier may name its own provider and endpoint (#1440): the quick tier serves + # the analysts, researchers, debaters and trader, the deep tier the managers. + # None means the tier uses llm_provider and backend_url. + "quick_think_provider": None, + "deep_think_provider": None, + "quick_think_backend_url": None, + "deep_think_backend_url": None, # Provider-specific thinking configuration "google_thinking_level": None, # "high", "minimal", etc. "openai_reasoning_effort": None, # "medium", "high", "low" diff --git a/tradingagents/graph/trading_graph.py b/tradingagents/graph/trading_graph.py index 8fe72eacc..bdd4a64b5 100644 --- a/tradingagents/graph/trading_graph.py +++ b/tradingagents/graph/trading_graph.py @@ -14,7 +14,7 @@ from tradingagents.dataflows.config import run_config, run_config_context, set_c from tradingagents.dataflows.date_window import get_current_date, is_historical from tradingagents.dataflows.symbols import safe_ticker_component from tradingagents.default_config import DEFAULT_CONFIG -from tradingagents.llm_clients import build_llm_kwargs, create_llm_client +from tradingagents.llm_clients import create_tier_client, tier_provider from tradingagents.memory import TradingMemoryLog, settlement from tradingagents.memory.reflection import Reflector from tradingagents.reporting import write_report_tree @@ -75,26 +75,9 @@ class TradingAgentsGraph: os.makedirs(self.config["data_cache_dir"], exist_ok=True) os.makedirs(self.config["results_dir"], exist_ok=True) - llm_kwargs = build_llm_kwargs(self.config) - - if self.callbacks: - llm_kwargs["callbacks"] = self.callbacks - - deep_client = create_llm_client( - provider=self.config["llm_provider"], - model=self.config["deep_think_llm"], - base_url=self.config.get("backend_url"), - **llm_kwargs, - ) - quick_client = create_llm_client( - provider=self.config["llm_provider"], - model=self.config["quick_think_llm"], - base_url=self.config.get("backend_url"), - **llm_kwargs, - ) - - self.deep_thinking_llm = deep_client.get_llm() - self.quick_thinking_llm = quick_client.get_llm() + extra = {"callbacks": self.callbacks} if self.callbacks else {} + self.deep_thinking_llm = create_tier_client(self.config, "deep", **extra).get_llm() + self.quick_thinking_llm = create_tier_client(self.config, "quick", **extra).get_llm() self.memory_log = TradingMemoryLog(self.config) @@ -279,7 +262,9 @@ class TradingAgentsGraph: return { "version": tradingagents.__version__, "llm_provider": cfg.get("llm_provider"), + "deep_think_provider": tier_provider(cfg, "deep") if cfg.get("llm_provider") else None, "deep_think_llm": cfg.get("deep_think_llm"), + "quick_think_provider": tier_provider(cfg, "quick") if cfg.get("llm_provider") else None, "quick_think_llm": cfg.get("quick_think_llm"), "analysts": list(self.selected_analysts), "max_debate_rounds": cfg.get("max_debate_rounds"), diff --git a/tradingagents/llm_clients/__init__.py b/tradingagents/llm_clients/__init__.py index fafb5e2dd..851a5bd97 100644 --- a/tradingagents/llm_clients/__init__.py +++ b/tradingagents/llm_clients/__init__.py @@ -1,4 +1,4 @@ from .base_client import BaseLLMClient -from .factory import build_llm_kwargs, create_llm_client +from .factory import build_llm_kwargs, create_llm_client, create_tier_client, tier_provider -__all__ = ["BaseLLMClient", "build_llm_kwargs", "create_llm_client"] +__all__ = ["BaseLLMClient", "build_llm_kwargs", "create_llm_client", "create_tier_client", "tier_provider"] diff --git a/tradingagents/llm_clients/factory.py b/tradingagents/llm_clients/factory.py index 14d7489b2..18f6a6860 100644 --- a/tradingagents/llm_clients/factory.py +++ b/tradingagents/llm_clients/factory.py @@ -128,3 +128,25 @@ def build_llm_kwargs(config: dict) -> dict[str, Any]: kwargs[key] = _coerce_max_tokens(max_tokens) return kwargs + + +def tier_provider(config: dict, tier: str) -> str: + """The provider of the ``quick`` or ``deep`` model tier: its own, else ``llm_provider``.""" + return (config.get(f"{tier}_think_provider") or config["llm_provider"]).lower() + + +def create_tier_client(config: dict, tier: str, **extra) -> BaseLLMClient: + """The client for the ``quick`` or ``deep`` model tier (#1440). + + A tier on its own provider gets that provider's settings (thinking level, + reasoning effort, the name of the output-token cap) and its own endpoint, + else the provider's default: ``backend_url`` belongs to ``llm_provider``. + """ + provider = tier_provider(config, tier) + if provider == config["llm_provider"].lower(): + base_url = config.get(f"{tier}_think_backend_url") or config.get("backend_url") + else: + base_url = config.get(f"{tier}_think_backend_url") + kwargs = build_llm_kwargs({**config, "llm_provider": provider}) + return create_llm_client(provider=provider, model=config[f"{tier}_think_llm"], base_url=base_url, + **kwargs, **extra) diff --git a/tradingagents/reporting.py b/tradingagents/reporting.py index 6d9a94c0f..7d1802f8a 100644 --- a/tradingagents/reporting.py +++ b/tradingagents/reporting.py @@ -18,8 +18,14 @@ def _header(ticker: str, final_state: dict, settings: dict | None) -> str: lines.append(f"- Generated: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") if settings: s = settings.get - lines.append(f"- TradingAgents {s('version', '?')}: {s('llm_provider', '?')}, " - f"deep {s('deep_think_llm', '?')}, quick {s('quick_think_llm', '?')}") + deep_provider = s("deep_think_provider") or s("llm_provider", "?") + quick_provider = s("quick_think_provider") or s("llm_provider", "?") + if deep_provider == quick_provider: + models = f"{deep_provider}, deep {s('deep_think_llm', '?')}, quick {s('quick_think_llm', '?')}" + else: # each tier on its own provider (#1440) + models = (f"deep {deep_provider} {s('deep_think_llm', '?')}, " + f"quick {quick_provider} {s('quick_think_llm', '?')}") + lines.append(f"- TradingAgents {s('version', '?')}: {models}") lines.append(f"- Analysts: {', '.join(s('analysts') or [])}; " f"research debate rounds {s('max_debate_rounds', '?')}, " f"risk debate rounds {s('max_risk_discuss_rounds', '?')}")