mirror of
https://github.com/TauricResearch/TradingAgents.git
synced 2026-06-16 21:06:15 +03:00
feat(cli): skip interactive LLM selection when configured via environment (#873)
Setting the LLM env vars now skips the matching CLI selection step and uses the value, so OpenAI-compatible endpoints (opencode, LM Studio, etc.) and unattended runs work without prompting. Unset vars are chosen interactively as before. TRADINGAGENTS_LLM_PROVIDER -> skips provider step (still verifies API key) TRADINGAGENTS_LLM_BACKEND_URL -> custom endpoint (else provider default) TRADINGAGENTS_DEEP_THINK_LLM / _QUICK_THINK_LLM -> skips model step TRADINGAGENTS_OUTPUT_LANGUAGE -> skips language step Builds on the existing TRADINGAGENTS_* config overrides (which already feed DEFAULT_CONFIG); this wires the CLI to honor them instead of re-prompting.
This commit is contained in:
@@ -22,6 +22,9 @@ OPENROUTER_API_KEY=
|
|||||||
# Any TRADINGAGENTS_* variable below, when set, replaces the matching key
|
# Any TRADINGAGENTS_* variable below, when set, replaces the matching key
|
||||||
# in tradingagents/default_config.py. Values are coerced to the type of
|
# in tradingagents/default_config.py. Values are coerced to the type of
|
||||||
# the existing default (bool / int / str), so "true"/"3" work as expected.
|
# the existing default (bool / int / str), so "true"/"3" work as expected.
|
||||||
|
# In the CLI, setting the LLM provider / models / backend URL / language
|
||||||
|
# also skips the matching interactive selection step (useful for
|
||||||
|
# OpenAI-compatible endpoints like opencode or LM Studio, and unattended runs).
|
||||||
#TRADINGAGENTS_LLM_PROVIDER=openai
|
#TRADINGAGENTS_LLM_PROVIDER=openai
|
||||||
#TRADINGAGENTS_DEEP_THINK_LLM=gpt-5.4
|
#TRADINGAGENTS_DEEP_THINK_LLM=gpt-5.4
|
||||||
#TRADINGAGENTS_QUICK_THINK_LLM=gpt-5.4-mini
|
#TRADINGAGENTS_QUICK_THINK_LLM=gpt-5.4-mini
|
||||||
|
|||||||
110
cli/main.py
110
cli/main.py
@@ -1,4 +1,5 @@
|
|||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
import os
|
||||||
import datetime
|
import datetime
|
||||||
import typer
|
import typer
|
||||||
import questionary
|
import questionary
|
||||||
@@ -529,14 +530,20 @@ def get_user_selections():
|
|||||||
)
|
)
|
||||||
analysis_date = get_analysis_date()
|
analysis_date = get_analysis_date()
|
||||||
|
|
||||||
# Step 3: Output language
|
# Step 3: Output language (skipped when set via TRADINGAGENTS_OUTPUT_LANGUAGE)
|
||||||
console.print(
|
if os.environ.get("TRADINGAGENTS_OUTPUT_LANGUAGE"):
|
||||||
create_question_box(
|
output_language = DEFAULT_CONFIG["output_language"]
|
||||||
"Step 3: Output Language",
|
console.print(
|
||||||
"Select the language for analyst reports and final decision"
|
f"[green]✓ Output language from environment:[/green] {output_language}"
|
||||||
)
|
)
|
||||||
)
|
else:
|
||||||
output_language = ask_output_language()
|
console.print(
|
||||||
|
create_question_box(
|
||||||
|
"Step 3: Output Language",
|
||||||
|
"Select the language for analyst reports and final decision"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
output_language = ask_output_language()
|
||||||
|
|
||||||
# Step 4: Select analysts
|
# Step 4: Select analysts
|
||||||
console.print(
|
console.print(
|
||||||
@@ -557,42 +564,62 @@ def get_user_selections():
|
|||||||
)
|
)
|
||||||
selected_research_depth = select_research_depth()
|
selected_research_depth = select_research_depth()
|
||||||
|
|
||||||
# Step 6: LLM Provider
|
# Step 6: LLM Provider (skipped when set via TRADINGAGENTS_LLM_PROVIDER).
|
||||||
console.print(
|
# The backend URL comes from TRADINGAGENTS_LLM_BACKEND_URL when set,
|
||||||
create_question_box(
|
# otherwise the provider's default endpoint — the same value the menu
|
||||||
"Step 6: LLM Provider", "Select your LLM provider"
|
# would have picked.
|
||||||
|
provider_from_env = bool(os.environ.get("TRADINGAGENTS_LLM_PROVIDER"))
|
||||||
|
if provider_from_env:
|
||||||
|
selected_llm_provider = DEFAULT_CONFIG["llm_provider"].lower()
|
||||||
|
backend_url = DEFAULT_CONFIG["backend_url"] or provider_default_url(selected_llm_provider)
|
||||||
|
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(
|
||||||
|
"Step 6: LLM Provider", "Select your LLM provider"
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
selected_llm_provider, backend_url = select_llm_provider()
|
||||||
selected_llm_provider, backend_url = select_llm_provider()
|
|
||||||
|
|
||||||
# Providers with regional endpoints prompt for the region as a secondary
|
# Providers with regional endpoints prompt for the region as a secondary
|
||||||
# step so the main dropdown stays clean (mainland China and international
|
# step so the main dropdown stays clean (mainland China and international
|
||||||
# accounts cannot share API keys).
|
# accounts cannot share API keys).
|
||||||
if selected_llm_provider == "qwen":
|
if selected_llm_provider == "qwen":
|
||||||
selected_llm_provider, backend_url = ask_qwen_region()
|
selected_llm_provider, backend_url = ask_qwen_region()
|
||||||
elif selected_llm_provider == "minimax":
|
elif selected_llm_provider == "minimax":
|
||||||
selected_llm_provider, backend_url = ask_minimax_region()
|
selected_llm_provider, backend_url = ask_minimax_region()
|
||||||
elif selected_llm_provider == "glm":
|
elif selected_llm_provider == "glm":
|
||||||
selected_llm_provider, backend_url = ask_glm_region()
|
selected_llm_provider, backend_url = ask_glm_region()
|
||||||
|
|
||||||
# For Ollama, surface the resolved endpoint (OLLAMA_BASE_URL vs default)
|
# For Ollama, surface the resolved endpoint (OLLAMA_BASE_URL vs default)
|
||||||
# before model selection so it's obvious where we're connecting.
|
# before model selection so it's obvious where we're connecting.
|
||||||
if selected_llm_provider == "ollama":
|
if selected_llm_provider == "ollama":
|
||||||
confirm_ollama_endpoint(backend_url)
|
confirm_ollama_endpoint(backend_url)
|
||||||
|
|
||||||
# Confirm the provider's API key is present; prompt the user to paste
|
# 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
|
# one and persist it to .env if it's missing, so the analysis run
|
||||||
# doesn't fail later at the first API call.
|
# doesn't fail later at the first API call.
|
||||||
ensure_api_key(selected_llm_provider)
|
ensure_api_key(selected_llm_provider)
|
||||||
|
|
||||||
# Step 7: Thinking agents
|
# Step 7: Thinking agents (skipped when either model is set via environment)
|
||||||
console.print(
|
if os.environ.get("TRADINGAGENTS_QUICK_THINK_LLM") or os.environ.get("TRADINGAGENTS_DEEP_THINK_LLM"):
|
||||||
create_question_box(
|
selected_shallow_thinker = DEFAULT_CONFIG["quick_think_llm"]
|
||||||
"Step 7: Thinking Agents", "Select your thinking agents for analysis"
|
selected_deep_thinker = DEFAULT_CONFIG["deep_think_llm"]
|
||||||
|
console.print(
|
||||||
|
f"[green]✓ Thinking agents from environment:[/green] "
|
||||||
|
f"quick={selected_shallow_thinker}, deep={selected_deep_thinker}"
|
||||||
)
|
)
|
||||||
)
|
else:
|
||||||
selected_shallow_thinker = select_shallow_thinking_agent(selected_llm_provider)
|
console.print(
|
||||||
selected_deep_thinker = select_deep_thinking_agent(selected_llm_provider)
|
create_question_box(
|
||||||
|
"Step 7: Thinking Agents", "Select your thinking agents for analysis"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
selected_shallow_thinker = select_shallow_thinking_agent(selected_llm_provider)
|
||||||
|
selected_deep_thinker = select_deep_thinking_agent(selected_llm_provider)
|
||||||
|
|
||||||
# Step 8: Provider-specific thinking configuration
|
# Step 8: Provider-specific thinking configuration
|
||||||
thinking_level = None
|
thinking_level = None
|
||||||
@@ -600,7 +627,14 @@ def get_user_selections():
|
|||||||
anthropic_effort = None
|
anthropic_effort = None
|
||||||
|
|
||||||
provider_lower = selected_llm_provider.lower()
|
provider_lower = selected_llm_provider.lower()
|
||||||
if provider_lower == "google":
|
# When the provider is configured via environment we keep the run fully
|
||||||
|
# non-interactive and use the config defaults (None = each provider's own
|
||||||
|
# default reasoning/thinking behavior) instead of prompting.
|
||||||
|
if provider_from_env:
|
||||||
|
thinking_level = DEFAULT_CONFIG["google_thinking_level"]
|
||||||
|
reasoning_effort = DEFAULT_CONFIG["openai_reasoning_effort"]
|
||||||
|
anthropic_effort = DEFAULT_CONFIG["anthropic_effort"]
|
||||||
|
elif provider_lower == "google":
|
||||||
console.print(
|
console.print(
|
||||||
create_question_box(
|
create_question_box(
|
||||||
"Step 8: Thinking Mode",
|
"Step 8: Thinking Mode",
|
||||||
|
|||||||
31
cli/utils.py
31
cli/utils.py
@@ -268,14 +268,17 @@ def select_deep_thinking_agent(provider) -> str:
|
|||||||
"""Select deep thinking llm engine using an interactive selection."""
|
"""Select deep thinking llm engine using an interactive selection."""
|
||||||
return _select_model(provider, "deep")
|
return _select_model(provider, "deep")
|
||||||
|
|
||||||
def select_llm_provider() -> tuple[str, str | None]:
|
def _llm_provider_table() -> list[tuple[str, str, str | None]]:
|
||||||
"""Select the LLM provider and its API endpoint."""
|
"""(display_name, provider_key, base_url) for every supported provider.
|
||||||
# Ollama users can point at a remote ollama-serve via OLLAMA_BASE_URL
|
|
||||||
# (convention from the broader Ollama ecosystem); falls back to the
|
Shared by the interactive picker and by env-driven configuration so an
|
||||||
# localhost default when unset.
|
env-set provider resolves to the same default endpoint the menu uses.
|
||||||
|
Ollama users can point at a remote ollama-serve via OLLAMA_BASE_URL
|
||||||
|
(convention from the broader Ollama ecosystem); falls back to the
|
||||||
|
localhost default when unset.
|
||||||
|
"""
|
||||||
ollama_url = os.environ.get("OLLAMA_BASE_URL") or "http://localhost:11434/v1"
|
ollama_url = os.environ.get("OLLAMA_BASE_URL") or "http://localhost:11434/v1"
|
||||||
# (display_name, provider_key, base_url)
|
return [
|
||||||
PROVIDERS = [
|
|
||||||
("OpenAI", "openai", "https://api.openai.com/v1"),
|
("OpenAI", "openai", "https://api.openai.com/v1"),
|
||||||
("Google", "google", None),
|
("Google", "google", None),
|
||||||
("Anthropic", "anthropic", "https://api.anthropic.com/"),
|
("Anthropic", "anthropic", "https://api.anthropic.com/"),
|
||||||
@@ -289,6 +292,20 @@ def select_llm_provider() -> tuple[str, str | None]:
|
|||||||
("Ollama", "ollama", ollama_url),
|
("Ollama", "ollama", ollama_url),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def provider_default_url(provider_key: str) -> str | None:
|
||||||
|
"""Return the default backend URL for a provider key, or None if unknown."""
|
||||||
|
key = provider_key.lower()
|
||||||
|
for _, pk, url in _llm_provider_table():
|
||||||
|
if pk == key:
|
||||||
|
return url
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def select_llm_provider() -> tuple[str, str | None]:
|
||||||
|
"""Select the LLM provider and its API endpoint."""
|
||||||
|
PROVIDERS = _llm_provider_table()
|
||||||
|
|
||||||
choice = questionary.select(
|
choice = questionary.select(
|
||||||
"Select your LLM Provider:",
|
"Select your LLM Provider:",
|
||||||
choices=[
|
choices=[
|
||||||
|
|||||||
86
tests/test_cli_env_skip.py
Normal file
86
tests/test_cli_env_skip.py
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
"""Tests for env-driven CLI behavior (#897, #873).
|
||||||
|
|
||||||
|
The config-layer override (TRADINGAGENTS_* -> DEFAULT_CONFIG) is covered by
|
||||||
|
test_env_overrides.py. These tests cover the CLI layer: an env-configured
|
||||||
|
provider/model/language must skip its interactive prompt and use the value.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestProviderDefaultUrl(unittest.TestCase):
|
||||||
|
def test_known_providers_resolve(self):
|
||||||
|
from cli.utils import provider_default_url
|
||||||
|
self.assertEqual(provider_default_url("openai"), "https://api.openai.com/v1")
|
||||||
|
self.assertEqual(provider_default_url("DeepSeek"), "https://api.deepseek.com")
|
||||||
|
self.assertIsNone(provider_default_url("google")) # uses SDK default
|
||||||
|
|
||||||
|
def test_unknown_provider_returns_none(self):
|
||||||
|
from cli.utils import provider_default_url
|
||||||
|
self.assertIsNone(provider_default_url("not-a-provider"))
|
||||||
|
|
||||||
|
def test_ollama_honors_base_url_env(self):
|
||||||
|
from cli.utils import provider_default_url
|
||||||
|
with mock.patch.dict(os.environ, {"OLLAMA_BASE_URL": "http://host:1234/v1"}):
|
||||||
|
self.assertEqual(provider_default_url("ollama"), "http://host:1234/v1")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.unit
|
||||||
|
class TestCliSkipsPromptsFromEnv(unittest.TestCase):
|
||||||
|
def test_env_config_skips_llm_prompts(self):
|
||||||
|
import cli.main as m
|
||||||
|
|
||||||
|
env = {
|
||||||
|
"TRADINGAGENTS_LLM_PROVIDER": "openai",
|
||||||
|
"TRADINGAGENTS_DEEP_THINK_LLM": "kimi-k2.5",
|
||||||
|
"TRADINGAGENTS_QUICK_THINK_LLM": "deepseek-v4-pro",
|
||||||
|
"TRADINGAGENTS_LLM_BACKEND_URL": "https://opencode.ai/zen/go/v1",
|
||||||
|
"TRADINGAGENTS_OUTPUT_LANGUAGE": "Japanese",
|
||||||
|
}
|
||||||
|
fake_cfg = dict(m.DEFAULT_CONFIG)
|
||||||
|
fake_cfg.update({
|
||||||
|
"llm_provider": "openai",
|
||||||
|
"backend_url": "https://opencode.ai/zen/go/v1",
|
||||||
|
"quick_think_llm": "deepseek-v4-pro",
|
||||||
|
"deep_think_llm": "kimi-k2.5",
|
||||||
|
"output_language": "Japanese",
|
||||||
|
})
|
||||||
|
|
||||||
|
with mock.patch.dict(os.environ, env, clear=False), \
|
||||||
|
mock.patch.object(m, "DEFAULT_CONFIG", fake_cfg), \
|
||||||
|
mock.patch.object(m, "fetch_announcements", return_value=None), \
|
||||||
|
mock.patch.object(m, "display_announcements"), \
|
||||||
|
mock.patch.object(m, "get_ticker", return_value="AAPL"), \
|
||||||
|
mock.patch.object(m, "get_analysis_date", return_value="2026-05-29"), \
|
||||||
|
mock.patch.object(m, "select_analysts", return_value=[]), \
|
||||||
|
mock.patch.object(m, "select_research_depth", return_value=1), \
|
||||||
|
mock.patch.object(m, "ensure_api_key") as ensure_key, \
|
||||||
|
mock.patch.object(m, "select_llm_provider") as prompt_provider, \
|
||||||
|
mock.patch.object(m, "ask_output_language") as prompt_lang, \
|
||||||
|
mock.patch.object(m, "select_shallow_thinking_agent") as prompt_quick, \
|
||||||
|
mock.patch.object(m, "select_deep_thinking_agent") as prompt_deep:
|
||||||
|
sel = m.get_user_selections()
|
||||||
|
|
||||||
|
# None of the LLM selection prompts should have been shown.
|
||||||
|
prompt_provider.assert_not_called()
|
||||||
|
prompt_lang.assert_not_called()
|
||||||
|
prompt_quick.assert_not_called()
|
||||||
|
prompt_deep.assert_not_called()
|
||||||
|
# API key is still verified for the env-configured provider.
|
||||||
|
ensure_key.assert_called_once()
|
||||||
|
|
||||||
|
# The env values flow into the returned selections.
|
||||||
|
self.assertEqual(sel["llm_provider"], "openai")
|
||||||
|
self.assertEqual(sel["backend_url"], "https://opencode.ai/zen/go/v1")
|
||||||
|
self.assertEqual(sel["shallow_thinker"], "deepseek-v4-pro")
|
||||||
|
self.assertEqual(sel["deep_thinker"], "kimi-k2.5")
|
||||||
|
self.assertEqual(sel["output_language"], "Japanese")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
@@ -7,6 +7,23 @@ import importlib
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module", autouse=True)
|
||||||
|
def _resync_reloaded_modules():
|
||||||
|
"""Restore module state after this file's importlib.reload() calls.
|
||||||
|
|
||||||
|
Several tests below reload ``cli.utils`` to re-evaluate OLLAMA_BASE_URL.
|
||||||
|
That leaves ``cli.main``'s star-imported names (e.g. get_ticker) bound to
|
||||||
|
the pre-reload module objects, which breaks identity checks in unrelated
|
||||||
|
tests that happen to run afterward. Re-sync once on teardown so the reload
|
||||||
|
doesn't leak across test modules.
|
||||||
|
"""
|
||||||
|
yield
|
||||||
|
import cli.utils
|
||||||
|
import cli.main
|
||||||
|
importlib.reload(cli.utils)
|
||||||
|
importlib.reload(cli.main)
|
||||||
|
|
||||||
|
|
||||||
# ---- openai_client side: _resolve_provider_base_url -----------------------
|
# ---- openai_client side: _resolve_provider_base_url -----------------------
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user