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
This commit is contained in:
Yijia-Xiao
2026-10-03 21:27:43 +00:00
parent f65fcabb60
commit cc39a35c42
11 changed files with 225 additions and 32 deletions
+5
View File
@@ -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
+9
View File
@@ -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
+25 -6
View File
@@ -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"):
+46
View File
@@ -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"]
+1 -1
View File
@@ -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)
+90
View File
@@ -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
+11
View File
@@ -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"
+6 -21
View File
@@ -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"),
+2 -2
View File
@@ -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"]
+22
View File
@@ -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)
+8 -2
View File
@@ -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', '?')}")