diff --git a/.env.example b/.env.example index b0db7571f..8f321e548 100644 --- a/.env.example +++ b/.env.example @@ -22,6 +22,9 @@ OPENROUTER_API_KEY= # Any TRADINGAGENTS_* variable below, when set, replaces the matching key # in tradingagents/default_config.py. Values are coerced to the type of # 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_DEEP_THINK_LLM=gpt-5.4 #TRADINGAGENTS_QUICK_THINK_LLM=gpt-5.4-mini diff --git a/cli/main.py b/cli/main.py index 3074271d6..5f0b1c7cf 100644 --- a/cli/main.py +++ b/cli/main.py @@ -1,4 +1,5 @@ from typing import Optional +import os import datetime import typer import questionary @@ -529,14 +530,20 @@ def get_user_selections(): ) analysis_date = get_analysis_date() - # Step 3: Output language - console.print( - create_question_box( - "Step 3: Output Language", - "Select the language for analyst reports and final decision" + # Step 3: Output language (skipped when set via TRADINGAGENTS_OUTPUT_LANGUAGE) + if os.environ.get("TRADINGAGENTS_OUTPUT_LANGUAGE"): + output_language = DEFAULT_CONFIG["output_language"] + console.print( + f"[green]✓ Output language from environment:[/green] {output_language}" ) - ) - output_language = ask_output_language() + else: + 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 console.print( @@ -557,42 +564,62 @@ def get_user_selections(): ) selected_research_depth = select_research_depth() - # Step 6: LLM Provider - console.print( - create_question_box( - "Step 6: LLM Provider", "Select your LLM provider" + # Step 6: LLM Provider (skipped when set via TRADINGAGENTS_LLM_PROVIDER). + # The backend URL comes from TRADINGAGENTS_LLM_BACKEND_URL when set, + # otherwise the provider's default endpoint — the same value the menu + # 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 - # step so the main dropdown stays clean (mainland China and international - # accounts cannot share API keys). - if selected_llm_provider == "qwen": - selected_llm_provider, backend_url = ask_qwen_region() - elif selected_llm_provider == "minimax": - selected_llm_provider, backend_url = ask_minimax_region() - elif selected_llm_provider == "glm": - selected_llm_provider, backend_url = ask_glm_region() + # Providers with regional endpoints prompt for the region as a secondary + # step so the main dropdown stays clean (mainland China and international + # accounts cannot share API keys). + if selected_llm_provider == "qwen": + selected_llm_provider, backend_url = ask_qwen_region() + elif selected_llm_provider == "minimax": + selected_llm_provider, backend_url = ask_minimax_region() + elif selected_llm_provider == "glm": + selected_llm_provider, backend_url = ask_glm_region() - # For Ollama, surface the resolved endpoint (OLLAMA_BASE_URL vs default) - # before model selection so it's obvious where we're connecting. - if selected_llm_provider == "ollama": - confirm_ollama_endpoint(backend_url) + # For Ollama, surface the resolved endpoint (OLLAMA_BASE_URL vs default) + # before model selection so it's obvious where we're connecting. + 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) + # 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) - # Step 7: Thinking agents - console.print( - create_question_box( - "Step 7: Thinking Agents", "Select your thinking agents for analysis" + # 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"): + selected_shallow_thinker = DEFAULT_CONFIG["quick_think_llm"] + 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}" ) - ) - selected_shallow_thinker = select_shallow_thinking_agent(selected_llm_provider) - selected_deep_thinker = select_deep_thinking_agent(selected_llm_provider) + else: + console.print( + 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 thinking_level = None @@ -600,7 +627,14 @@ def get_user_selections(): anthropic_effort = None 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( create_question_box( "Step 8: Thinking Mode", diff --git a/cli/utils.py b/cli/utils.py index d6adb6fad..013a2b34d 100644 --- a/cli/utils.py +++ b/cli/utils.py @@ -268,14 +268,17 @@ def select_deep_thinking_agent(provider) -> str: """Select deep thinking llm engine using an interactive selection.""" return _select_model(provider, "deep") -def select_llm_provider() -> tuple[str, str | None]: - """Select the LLM provider and its API endpoint.""" - # 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. +def _llm_provider_table() -> list[tuple[str, str, str | None]]: + """(display_name, provider_key, base_url) for every supported provider. + + Shared by the interactive picker and by env-driven configuration so an + 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" - # (display_name, provider_key, base_url) - PROVIDERS = [ + return [ ("OpenAI", "openai", "https://api.openai.com/v1"), ("Google", "google", None), ("Anthropic", "anthropic", "https://api.anthropic.com/"), @@ -289,6 +292,20 @@ def select_llm_provider() -> tuple[str, str | None]: ("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( "Select your LLM Provider:", choices=[ diff --git a/tests/test_cli_env_skip.py b/tests/test_cli_env_skip.py new file mode 100644 index 000000000..b9497d74d --- /dev/null +++ b/tests/test_cli_env_skip.py @@ -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() diff --git a/tests/test_ollama_base_url.py b/tests/test_ollama_base_url.py index 6c7cbe096..70c3c610b 100644 --- a/tests/test_ollama_base_url.py +++ b/tests/test_ollama_base_url.py @@ -7,6 +7,23 @@ import importlib 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 -----------------------