feat(llm): unify OpenAI-compatible providers behind a registry + generic endpoint

The OpenAI-compatible family (openai, xAI, DeepSeek, Qwen, GLM, MiniMax,
OpenRouter, Ollama) all speak the same Chat Completions API and differ only by
base_url, key, and two narrow wire-format quirks already isolated in subclasses.
Replace the scattered base-URL dict, key handling, and client-class branches with
one ProviderSpec registry that get_llm and the factory drive off; provider quirks
stay in their subclasses. Add a generic "openai_compatible" provider for any
OpenAI-compatible server (vLLM, LM Studio, llama.cpp, relays) via backend_url +
optional key — adding a provider is now one registry row. Native Anthropic/Google
keep their own clients (genuinely different APIs). Also fixes the env backend URL
being ignored when the provider was chosen interactively (#978).
This commit is contained in:
Yijia-Xiao
2026-06-14 03:22:24 +00:00
parent 4e7821d574
commit 20d3b0782f
11 changed files with 301 additions and 85 deletions

View File

@@ -571,7 +571,9 @@ def get_user_selections():
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)
backend_url = resolve_backend_url(
selected_llm_provider, env_url=DEFAULT_CONFIG["backend_url"]
)
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.
@@ -594,6 +596,17 @@ def get_user_selections():
elif selected_llm_provider == "glm":
selected_llm_provider, backend_url = ask_glm_region()
# Honor an explicit env backend URL even when the provider was chosen
# interactively, so it isn't overwritten by the menu default (#978).
backend_url = resolve_backend_url(
selected_llm_provider, backend_url, env_url=DEFAULT_CONFIG["backend_url"]
)
# The generic OpenAI-compatible endpoint has no default; ask for it if
# neither the menu nor the environment supplied one.
if selected_llm_provider == "openai_compatible" and not backend_url:
backend_url = prompt_openai_compatible_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":

View File

@@ -313,6 +313,7 @@ def _llm_provider_table() -> list[tuple[str, str, str | None]]:
("OpenRouter", "openrouter", "https://openrouter.ai/api/v1"),
("Azure OpenAI", "azure", None),
("Ollama", "ollama", ollama_url),
("OpenAI-compatible (vLLM, LM Studio, llama.cpp, custom relay)", "openai_compatible", None),
]
@@ -325,6 +326,33 @@ def provider_default_url(provider_key: str) -> str | None:
return None
def resolve_backend_url(
provider: str, menu_url: str | None = None, env_url: str | None = None
) -> str | None:
"""Resolve the backend URL with the correct precedence.
An explicit env override (``env_url``, from ``TRADINGAGENTS_LLM_BACKEND_URL``
via ``DEFAULT_CONFIG['backend_url']``) is honored regardless of how the
provider was chosen — interactively or from the environment (#978).
Otherwise the menu/region URL, then the provider's default.
"""
return env_url or menu_url or provider_default_url(provider)
def prompt_openai_compatible_url() -> str:
"""Prompt for a custom OpenAI-compatible endpoint base URL."""
url = questionary.text(
"Enter the OpenAI-compatible base URL "
"(e.g. http://localhost:8000/v1 for vLLM, http://localhost:1234/v1 for LM Studio):",
validate=lambda x: x.strip().startswith(("http://", "https://"))
or "Enter a URL starting with http:// or https://",
).ask()
if not url:
console.print("\n[red]No endpoint URL provided. Exiting...[/red]")
exit(1)
return url.strip()
def select_llm_provider() -> tuple[str, str | None]:
"""Select the LLM provider and its API endpoint."""
PROVIDERS = _llm_provider_table()
@@ -538,6 +566,13 @@ def ensure_api_key(provider: str) -> Optional[str]:
if env_var is None:
return None # ollama / unknown — no key check possible
# 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)
existing = os.environ.get(env_var)
if existing:
return existing