Files
QuantDinger/backend_api_python/app/services/llm.py
T
TIANHE cbe8bbc591 v5.0.4
Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
2026-07-19 20:03:09 +08:00

994 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
LLM service.
Supports multiple providers: OpenRouter, OpenAI, Google Gemini, DeepSeek, Grok,
AtlasCloud, Custom (OpenAI-compatible), MiniMax.
Kept separate from AnalysisService to avoid circular imports.
"""
import json
import os
import requests
from typing import Dict, Any, Optional, List
from enum import Enum
from app.utils.logger import get_logger
from app.config import APIKeys
from app.utils.config_loader import load_addon_config
logger = get_logger(__name__)
class LLMAPIError(ValueError):
"""Provider HTTP error with status and request metadata preserved."""
def __init__(self, message: str, *, status_code: int, request_id: str = ""):
super().__init__(message)
self.status_code = status_code
self.request_id = request_id
class LLMProvider(Enum):
"""Supported LLM providers"""
OPENROUTER = "openrouter"
OPENAI = "openai"
GOOGLE = "google"
DEEPSEEK = "deepseek"
GROK = "grok"
ATLASCLOUD = "atlascloud"
CUSTOM = "custom"
MINIMAX = "minimax"
LITELLM = "litellm"
# Provider configurations
PROVIDER_CONFIGS = {
LLMProvider.OPENROUTER: {
"base_url": "https://openrouter.ai/api/v1",
"default_model": "openai/gpt-5.4",
"fallback_model": "openai/gpt-4o-mini",
},
LLMProvider.OPENAI: {
"base_url": "https://api.openai.com/v1",
"default_model": "gpt-5.4",
"fallback_model": "gpt-4o-mini",
},
LLMProvider.GOOGLE: {
"base_url": "https://generativelanguage.googleapis.com/v1beta",
"default_model": "gemini-1.5-flash",
"fallback_model": "gemini-1.5-flash",
},
LLMProvider.DEEPSEEK: {
"base_url": "https://api.deepseek.com/v1",
"default_model": "deepseek-chat",
"fallback_model": "deepseek-chat",
},
LLMProvider.GROK: {
"base_url": "https://api.x.ai/v1",
"default_model": "grok-beta",
"fallback_model": "grok-beta",
},
LLMProvider.ATLASCLOUD: {
"base_url": "https://api.atlascloud.ai/v1",
"default_model": "openai/gpt-5.4",
"fallback_model": "deepseek-v3",
},
LLMProvider.CUSTOM: {
"base_url": "", # User configured via CUSTOM_API_URL
"default_model": "", # User configured via CUSTOM_MODEL
"fallback_model": "",
},
LLMProvider.MINIMAX: {
"base_url": "https://api.minimax.io/v1",
"default_model": "MiniMax-M2.7",
"fallback_model": "MiniMax-M2.7-highspeed",
},
LLMProvider.LITELLM: {
"base_url": "", # LiteLLM SDK handles routing
"default_model": "openai/gpt-5.4",
"fallback_model": "gpt-4o-mini",
},
}
class LLMService:
"""LLM provider wrapper with multi-provider support."""
def __init__(self, provider: str = None):
"""
Initialize LLM service.
Args:
provider: Override the default provider (openrouter, openai, google, deepseek, grok, atlascloud, custom, minimax)
"""
self._provider_override = provider
@property
def provider(self) -> LLMProvider:
"""Get the active LLM provider."""
if self._provider_override:
try:
return LLMProvider(self._provider_override.lower())
except ValueError:
pass
# Check env/config for provider selection
config = load_addon_config()
provider_name = config.get('llm', {}).get('provider') or os.getenv('LLM_PROVIDER', '')
if provider_name:
try:
# Explicit selection should always be respected.
# API key validation happens later in call path.
selected = LLMProvider(provider_name.lower())
return selected
except ValueError:
pass
# Auto-detect: find any provider with a configured API key
# Priority: DeepSeek > AtlasCloud > Grok > MiniMax > OpenAI > Google > OpenRouter
# (LiteLLM excluded from auto-detect; must be set explicitly via LLM_PROVIDER=litellm)
priority_order = [
LLMProvider.DEEPSEEK,
LLMProvider.ATLASCLOUD,
LLMProvider.GROK,
LLMProvider.MINIMAX,
LLMProvider.OPENAI,
LLMProvider.GOOGLE,
LLMProvider.OPENROUTER,
]
for p in priority_order:
if self.get_api_key(p):
logger.info(f"Auto-detected LLM provider: {p.value}")
return p
# Fallback to OpenRouter (will fail later if no key)
return LLMProvider.OPENROUTER
def get_api_key(self, provider: LLMProvider = None) -> str:
"""Get API key for the specified provider."""
p = provider or self.provider
key_map = {
LLMProvider.OPENROUTER: APIKeys.OPENROUTER_API_KEY,
LLMProvider.OPENAI: APIKeys.OPENAI_API_KEY,
LLMProvider.GOOGLE: APIKeys.GOOGLE_API_KEY,
LLMProvider.DEEPSEEK: APIKeys.DEEPSEEK_API_KEY,
LLMProvider.GROK: APIKeys.GROK_API_KEY,
LLMProvider.ATLASCLOUD: APIKeys.ATLASCLOUD_API_KEY,
LLMProvider.CUSTOM: APIKeys.CUSTOM_API_KEY,
LLMProvider.MINIMAX: APIKeys.MINIMAX_API_KEY,
LLMProvider.LITELLM: APIKeys.LITELLM_API_KEY,
}
return key_map.get(p, "") or ""
def get_base_url(self, provider: LLMProvider = None) -> str:
"""Get base URL for the specified provider."""
p = provider or self.provider
config = load_addon_config()
# Check for custom base URL in config
provider_config = config.get(p.value, {})
custom_url = provider_config.get('base_url') or os.getenv(f'{p.value.upper()}_BASE_URL', '').strip()
# PR #56 uses CUSTOM_API_URL (not CUSTOM_BASE_URL); APIKeys mirrors env + addon.
if p == LLMProvider.CUSTOM and not custom_url:
custom_url = (os.getenv("CUSTOM_API_URL", "").strip() or (APIKeys.CUSTOM_API_URL or "")).strip()
if custom_url:
return custom_url.rstrip('/')
return PROVIDER_CONFIGS[p]["base_url"]
def get_default_model(self, provider: LLMProvider = None) -> str:
"""Get default model for the specified provider."""
p = provider or self.provider
config = load_addon_config()
provider_config = config.get(p.value, {})
custom_model = provider_config.get('model') or os.getenv(f'{p.value.upper()}_MODEL', '').strip()
if custom_model:
return custom_model
return PROVIDER_CONFIGS[p]["default_model"]
def get_code_generation_model(self, provider: LLMProvider = None) -> str:
"""Get model for AI code generation; fallback to provider default when unset."""
model = os.getenv('AI_CODE_GEN_MODEL', '').strip()
if model:
return model
return self.get_default_model(provider)
def is_configured(self, provider: LLMProvider = None) -> bool:
"""Return whether the provider has enough configuration to make a request."""
p = provider or self.provider
if (self.get_api_key(p) or "").strip():
return True
if p == LLMProvider.CUSTOM:
return bool((self.get_base_url(p) or "").strip())
return p == LLMProvider.LITELLM
# Legacy properties for backward compatibility
@property
def api_key(self):
return self.get_api_key()
@property
def base_url(self):
return self.get_base_url()
@staticmethod
def _truthy(value: Any) -> bool:
return str(value or "").strip().lower() in {"1", "true", "yes", "on"}
def _llm_proxy_url(self) -> str:
config = load_addon_config()
return str(
config.get("llm", {}).get("proxy_url")
or os.getenv("LLM_PROXY_URL", "")
or ""
).strip()
def _llm_use_system_proxy(self) -> bool:
config = load_addon_config()
value = config.get("llm", {}).get("use_system_proxy")
if value is None:
value = os.getenv("LLM_USE_SYSTEM_PROXY", "false")
return self._truthy(value)
def _llm_post(self, url: str, *, headers: dict, json_payload: dict, timeout: int, stream: bool = False):
"""
Send LLM HTTP requests without inheriting exchange/data-source proxies.
PROXY_URL is intentionally global for market data and broker/exchange APIs,
but LLM providers should not be routed through it unless explicitly requested.
This avoids failures such as host.docker.internal:7890 refusing LLM traffic.
"""
session = requests.Session()
proxy_url = self._llm_proxy_url()
use_system_proxy = self._llm_use_system_proxy()
session.trust_env = use_system_proxy and not proxy_url
kwargs = {
"headers": headers,
"json": json_payload,
"timeout": timeout,
"stream": stream,
}
if proxy_url:
kwargs["proxies"] = {"http": proxy_url, "https": proxy_url}
try:
if not stream and not proxy_url:
session.close()
post_kwargs = dict(kwargs)
post_kwargs.pop("stream", None)
return requests.post(url, **post_kwargs)
response = session.post(url, **kwargs)
except requests.exceptions.RequestException as exc:
session.close()
hint = ""
msg = str(exc)
if "SOCKS" in msg or "Proxy" in msg or "proxy" in msg:
hint = (
" LLM request was routed through a proxy. Leave LLM_PROXY_URL empty "
"for direct LLM access, or set it to a reachable proxy and keep "
"LLM_USE_SYSTEM_PROXY disabled unless you really want system proxy env vars."
)
raise requests.exceptions.ConnectionError(f"{msg}{hint}") from exc
if stream:
response._quantdinger_llm_session = session
else:
session.close()
return response
def _call_openai_compatible(self, messages: list, model: str, temperature: float,
api_key: str, base_url: str, timeout: int,
use_json_mode: bool = True) -> str:
"""Call OpenAI-compatible API (OpenAI, DeepSeek, Grok, AtlasCloud, OpenRouter)."""
url = f"{base_url}/chat/completions"
headers = {"Content-Type": "application/json"}
if (api_key or "").strip():
headers["Authorization"] = f"Bearer {api_key.strip()}"
# OpenRouter specific headers
if "openrouter" in base_url:
headers["HTTP-Referer"] = "https://quantdinger.com"
headers["X-Title"] = "QuantDinger Analysis"
data = {
"model": model,
"messages": messages,
"temperature": temperature,
}
# AtlasCloud documents the OpenAI-compatible ChatCompletion shape, but
# its public parameter table currently lists model/messages/temperature/
# max_tokens/stream/top_p and not response_format. Keep prompts JSON-
# oriented while avoiding a provider-side 400 from an unsupported knob.
if use_json_mode and "atlascloud" not in (base_url or "").lower():
data["response_format"] = {"type": "json_object"}
response = self._llm_post(url, headers=headers, json_payload=data, timeout=timeout)
# Handle non-2xx with provider/model-aware details
if response.status_code >= 400:
normalized_base_url = (base_url or "").lower()
if "atlascloud" in normalized_base_url:
provider_name = "AtlasCloud"
elif "openrouter" in normalized_base_url:
provider_name = "OpenRouter"
else:
provider_name = "LLM"
err_text = self._extract_provider_error(response)
request_id = self._provider_request_id(response)
metadata = [f"model={model}"]
if request_id:
metadata.append(f"request_id={request_id}")
error_msg = (
f"{provider_name} API {response.status_code} "
f"({', '.join(metadata)})"
)
if err_text:
error_msg = f"{error_msg}: {err_text}"
# OpenRouter targeted hints
if "openrouter" in (base_url or "").lower():
from app.config.api_keys import APIKeys
if not APIKeys.OPENROUTER_API_KEY:
error_msg += ". OPENROUTER_API_KEY 未配置,请在 backend_api_python/.env 中设置"
elif response.status_code == 403:
error_msg += ". 可能原因:API 密钥无效/过期、余额不足、或无模型权限。请检查 https://openrouter.ai/keys"
elif response.status_code == 404:
error_msg += ". 可能原因:模型不可用或账户隐私/数据策略限制。请检查 https://openrouter.ai/settings/privacy"
raise LLMAPIError(
error_msg,
status_code=response.status_code,
request_id=request_id,
)
result = response.json()
if "choices" in result and len(result["choices"]) > 0:
content = result["choices"][0]["message"]["content"]
if not content:
raise ValueError(f"Model {model} returned empty content")
return content
else:
raise ValueError("API response is missing 'choices'")
@staticmethod
def _provider_request_id(response) -> str:
headers = getattr(response, "headers", None) or {}
for name in (
"x-request-id",
"request-id",
"x-correlation-id",
"cf-ray",
):
value = headers.get(name) or headers.get(name.title())
if value:
return str(value).strip()[:200]
return ""
@classmethod
def _extract_provider_error(cls, response) -> str:
payload = None
try:
payload = response.json()
except Exception:
pass
detail = cls._format_provider_error_value(payload)
if not detail:
detail = str(getattr(response, "text", "") or "").strip()
return " ".join(detail.split())[:1000]
@classmethod
def _format_provider_error_value(cls, value) -> str:
if isinstance(value, str):
return value.strip()
if isinstance(value, list):
parts = [cls._format_provider_error_value(item) for item in value]
return "; ".join(part for part in parts if part)
if not isinstance(value, dict):
return ""
parts = []
location = value.get("loc") or value.get("location")
if isinstance(location, (list, tuple)):
location = ".".join(str(item) for item in location)
if location:
parts.append(str(location).strip())
for key in ("error", "message", "msg", "detail", "reason"):
if key not in value:
continue
text = cls._format_provider_error_value(value.get(key))
if text and text not in parts:
parts.append(text)
if not parts:
for key in ("code", "type", "status"):
item = value.get(key)
if isinstance(item, (str, int, float)) and str(item).strip():
parts.append(f"{key}={item}")
return ": ".join(parts)
def _call_google_gemini(self, messages: list, model: str, temperature: float,
api_key: str, base_url: str, timeout: int) -> str:
"""Call Google Gemini API."""
url = f"{base_url}/models/{model}:generateContent?key={api_key}"
# Convert OpenAI message format to Gemini format
contents = []
system_instruction = None
for msg in messages:
role = msg["role"]
content = msg["content"]
parts = []
if isinstance(content, list):
for item in content:
if not isinstance(item, dict):
continue
if item.get("type") == "text":
parts.append({"text": str(item.get("text") or "")})
elif item.get("type") == "image_url":
image_url = item.get("image_url") or {}
data_url = image_url.get("url") if isinstance(image_url, dict) else None
if data_url and data_url.startswith("data:image/") and ";base64," in data_url:
header, b64 = data_url.split(",", 1)
mime_type = header.replace("data:", "").split(";", 1)[0]
parts.append({"inline_data": {"mime_type": mime_type, "data": b64}})
else:
parts.append({"text": str(content or "")})
if role == "system":
system_instruction = str(content or "") if not isinstance(content, list) else ""
elif role == "user":
contents.append({"role": "user", "parts": parts or [{"text": ""}]})
elif role == "assistant":
contents.append({"role": "model", "parts": parts or [{"text": ""}]})
data = {
"contents": contents,
"generationConfig": {
"temperature": temperature,
"responseMimeType": "application/json",
}
}
if system_instruction:
data["systemInstruction"] = {"parts": [{"text": system_instruction}]}
headers = {"Content-Type": "application/json"}
response = self._llm_post(url, headers=headers, json_payload=data, timeout=timeout)
response.raise_for_status()
result = response.json()
if "candidates" in result and len(result["candidates"]) > 0:
candidate = result["candidates"][0]
if "content" in candidate and "parts" in candidate["content"]:
text = candidate["content"]["parts"][0].get("text", "")
if text:
return text
raise ValueError("Gemini API response is missing content")
def _call_litellm(self, messages: list, model: str, temperature: float,
api_key: str, base_url: str, timeout: int,
use_json_mode: bool = True) -> str:
"""Call LLM via LiteLLM SDK (supports 100+ providers)."""
try:
import litellm
except ImportError as e:
raise ImportError(
"litellm is required for the LiteLLM provider. "
"Install it with: pip install 'litellm>=1.80,<1.87'"
) from e
kwargs = {
"model": model,
"messages": messages,
"temperature": temperature,
"timeout": timeout,
"drop_params": True,
}
if use_json_mode:
kwargs["response_format"] = {"type": "json_object"}
if (api_key or "").strip():
kwargs["api_key"] = api_key.strip()
if (base_url or "").strip():
kwargs["api_base"] = base_url.strip().rstrip('/')
try:
response = litellm.completion(**kwargs)
except Exception as e:
raise ValueError(f"LiteLLM API error ({model}): {e}") from e
if response.choices and len(response.choices) > 0:
content = response.choices[0].message.content
if not content:
raise ValueError(f"Model {model} returned empty content")
return content
else:
raise ValueError("LiteLLM response is missing 'choices'")
def _stream_openai_compatible(self, messages: list, model: str, temperature: float,
api_key: str, base_url: str, timeout: int):
"""Stream text deltas from an OpenAI-compatible chat endpoint."""
url = f"{base_url}/chat/completions"
headers = {"Content-Type": "application/json"}
if (api_key or "").strip():
headers["Authorization"] = f"Bearer {api_key.strip()}"
if "openrouter" in base_url:
headers["HTTP-Referer"] = "https://quantdinger.com"
headers["X-Title"] = "QuantDinger Analysis"
data = {
"model": model,
"messages": messages,
"temperature": temperature,
"stream": True,
}
response = self._llm_post(url, headers=headers, json_payload=data, timeout=timeout, stream=True)
if response.status_code >= 400:
err_text = ""
try:
err = response.json().get("error")
err_text = err.get("message") if isinstance(err, dict) else str(err or "")
except Exception:
err_text = (response.text or "").strip()[:300]
session = getattr(response, "_quantdinger_llm_session", None)
response.close()
if session is not None:
session.close()
raise ValueError(f"LLM API {response.status_code}: {err_text}".strip())
try:
for raw_line in response.iter_lines(decode_unicode=False):
if not raw_line:
continue
if isinstance(raw_line, bytes):
line = raw_line.decode("utf-8", errors="replace").strip()
else:
line = str(raw_line).strip()
if line.startswith("data:"):
line = line[5:].strip()
if line == "[DONE]":
break
try:
payload = json.loads(line)
except Exception:
continue
choices = payload.get("choices") or []
if not choices:
continue
delta = choices[0].get("delta") or {}
content = delta.get("content")
if content:
yield content
finally:
session = getattr(response, "_quantdinger_llm_session", None)
response.close()
if session is not None:
session.close()
def _normalize_model_for_provider(self, model: str, provider: LLMProvider) -> str:
"""
Normalize model name for the target provider.
Frontend may send OpenRouter-style model names (e.g., 'openai/gpt-5.4').
This converts them to the correct format for each provider.
"""
if not model:
return self.get_default_model(provider)
model = model.strip()
# LiteLLM and OpenRouter use provider/model format natively.
if provider in (LLMProvider.OPENROUTER, LLMProvider.LITELLM):
return model
# AtlasCloud is OpenAI-compatible and may expose routed model ids such
# as openai/gpt-5.4. Keep third-party prefixes intact, while still
# accepting atlascloud/model as a convenience alias.
if provider == LLMProvider.ATLASCLOUD:
if '/' in model:
prefix, actual_model = model.split('/', 1)
if prefix.lower() in ('atlascloud', 'atlas'):
return actual_model
return model
return model
# For direct providers, extract the model name from OpenRouter format
# e.g., 'openai/gpt-5.4' -> 'gpt-5.4'
# 'google/gemini-1.5-flash' -> 'gemini-1.5-flash'
# 'deepseek/deepseek-chat' -> 'deepseek-chat'
# 'x-ai/grok-beta' -> 'grok-beta'
if '/' in model:
prefix, actual_model = model.split('/', 1)
prefix_lower = prefix.lower()
# Map OpenRouter prefixes to providers
prefix_to_provider = {
'openai': LLMProvider.OPENAI,
'google': LLMProvider.GOOGLE,
'deepseek': LLMProvider.DEEPSEEK,
'x-ai': LLMProvider.GROK,
'xai': LLMProvider.GROK,
'atlascloud': LLMProvider.ATLASCLOUD,
'atlas': LLMProvider.ATLASCLOUD,
'minimax': LLMProvider.MINIMAX,
}
# If the model prefix matches the current provider, use the extracted model name
matched_provider = prefix_to_provider.get(prefix_lower)
if matched_provider == provider:
return actual_model
# If model prefix doesn't match current provider, use provider's default model
# This prevents sending a wrong provider's model name to DeepSeek, etc.
logger.warning(f"Model '{model}' doesn't match provider '{provider.value}', using default model")
return self.get_default_model(provider)
# Model name without prefix - use as is
return model
def _detect_provider_from_model(self, model: str) -> Optional[LLMProvider]:
"""
Detect which provider a model belongs to based on its name.
Returns None if detection fails.
"""
if not model or '/' not in model:
return None
prefix = model.split('/')[0].lower()
prefix_to_provider = {
'openai': LLMProvider.OPENAI,
'google': LLMProvider.GOOGLE,
'deepseek': LLMProvider.DEEPSEEK,
'x-ai': LLMProvider.GROK,
'xai': LLMProvider.GROK,
'atlascloud': LLMProvider.ATLASCLOUD,
'atlas': LLMProvider.ATLASCLOUD,
'minimax': LLMProvider.MINIMAX,
'anthropic': LLMProvider.OPENROUTER, # Anthropic only via OpenRouter
'meta': LLMProvider.OPENROUTER, # Meta/Llama only via OpenRouter
'mistral': LLMProvider.OPENROUTER, # Mistral only via OpenRouter
}
return prefix_to_provider.get(prefix)
def call_llm_api(self, messages: list, model: str = None, temperature: float = 0.7,
use_fallback: bool = True, provider: LLMProvider = None,
use_json_mode: bool = True, try_alternative_providers: bool = True) -> str:
"""
Call LLM API with the specified or default provider.
Args:
messages: List of message dicts with 'role' and 'content'
model: Model name (uses provider default if not specified). Supports OpenRouter format (e.g., 'openai/gpt-5.4')
temperature: Sampling temperature
use_fallback: Whether to try fallback model on failure
provider: Override the service's default provider
use_json_mode: Whether to request JSON output format (default True for analysis, False for code generation)
try_alternative_providers: Whether to try alternative providers when current provider fails with 403/402
Returns:
Generated text content
Model Resolution Priority:
1. If model is specified and matches a direct provider (openai/, google/, deepseek/, x-ai/),
use that provider directly if its API key is configured
2. Otherwise, use the configured LLM_PROVIDER with normalized model name
3. Fall back to provider's default model if model name is incompatible
"""
# Smart provider detection: if model specifies a provider and we have its API key, use it
if model and not provider:
detected_provider = self._detect_provider_from_model(model)
if detected_provider and detected_provider != LLMProvider.OPENROUTER:
# Check if we have API key for the detected provider
if self.get_api_key(detected_provider):
provider = detected_provider
logger.debug(f"Auto-detected provider '{provider.value}' from model '{model}'")
p = provider or self.provider
cfg = load_addon_config()
explicit_provider_name = str(cfg.get('llm', {}).get('provider') or os.getenv('LLM_PROVIDER', '')).strip().lower()
explicit_provider = None
if explicit_provider_name:
try:
explicit_provider = LLMProvider(explicit_provider_name)
except ValueError:
explicit_provider = None
api_key = (self.get_api_key(p) or "").strip()
base_url = (self.get_base_url(p) or "").strip()
if not self.is_configured(p):
# If provider is explicitly configured by user, don't silently switch.
if explicit_provider is not None and p == explicit_provider:
if p == LLMProvider.CUSTOM:
raise ValueError(
"已选择自定义 OpenAI 兼容接口:请配置 CUSTOM_API_URL(例如本机 Ollama:"
"http://127.0.0.1:11434/v1)。本地 Ollama 通常无需填写 API Key。"
)
raise ValueError(
f"API key not configured for explicit provider: {p.value}. "
f"Please set {p.value.upper()}_API_KEY in settings."
)
# If no API key for current provider, try to find any available provider
if try_alternative_providers:
for alt_provider in [LLMProvider.DEEPSEEK, LLMProvider.ATLASCLOUD, LLMProvider.GROK, LLMProvider.MINIMAX, LLMProvider.OPENAI, LLMProvider.GOOGLE, LLMProvider.OPENROUTER]:
if alt_provider != p and self.get_api_key(alt_provider):
logger.warning(f"No API key for {p.value}, switching to {alt_provider.value}")
p = alt_provider
api_key = (self.get_api_key(p) or "").strip()
base_url = (self.get_base_url(p) or "").strip()
break
if not self.is_configured(p):
raise ValueError(f"API key not configured for provider: {p.value}. Please configure at least one LLM provider API key.")
if p == LLMProvider.CUSTOM and not base_url:
raise ValueError(
"Custom LLM base URL 未配置:请在后台设置或 .env 中填写 CUSTOM_API_URL "
"(须为 OpenAI 兼容网关的根地址,例如 https://api.example.com/v1)。"
)
# Normalize model name for the provider
original_model = model
model = self._normalize_model_for_provider(model, p)
config = load_addon_config()
timeout = int(config.get(p.value, {}).get('timeout', 120))
# Build model candidates
models_to_try = [model]
provider_default_model = PROVIDER_CONFIGS[p]["default_model"]
if use_fallback:
fallback = PROVIDER_CONFIGS[p].get("fallback_model")
if fallback and fallback != model:
models_to_try.append(fallback)
last_error = None
last_status_code = None
for current_model in models_to_try:
try:
if p == LLMProvider.LITELLM:
return self._call_litellm(
messages, current_model, temperature,
api_key, base_url, timeout,
use_json_mode=use_json_mode
)
elif p == LLMProvider.GOOGLE:
return self._call_google_gemini(
messages, current_model, temperature,
api_key, base_url, timeout
)
else:
# OpenAI-compatible providers
return self._call_openai_compatible(
messages, current_model, temperature,
api_key, base_url, timeout,
use_json_mode=use_json_mode
)
except LLMAPIError as e:
status_code = e.status_code
last_status_code = status_code
last_error = str(e)
logger.warning(
"%s API HTTP error (%s): %s",
p.value,
current_model,
e,
)
if (
status_code in (402, 403)
and try_alternative_providers
and current_model == models_to_try[-1]
):
logger.warning(
"%s returned %s. Trying alternative providers...",
p.value,
status_code,
)
return self._try_alternative_providers(
messages,
original_model,
temperature,
use_json_mode,
excluded_provider=p,
)
if not use_fallback or current_model == models_to_try[-1]:
raise
logger.warning(
"%s returned %s for model %s; trying fallback model...",
p.value,
status_code,
current_model,
)
continue
except requests.exceptions.HTTPError as e:
error_detail = e.response.text if e.response else str(e)
status_code = e.response.status_code if e.response else None
last_status_code = status_code
logger.error(f"{p.value} API HTTP error ({current_model}): {status_code} - {error_detail}")
last_error = str(e)
# 403/402 errors usually mean API key issue - try alternative provider
if status_code in (402, 403) and try_alternative_providers and current_model == models_to_try[-1]:
# Only try alternative providers after all models in current provider failed
logger.warning(f"{p.value} returned {status_code} (likely API key issue). Trying alternative providers...")
return self._try_alternative_providers(
messages, original_model, temperature,
use_json_mode, excluded_provider=p
)
# Check for recoverable errors - try fallback model
# 402: Payment required, 403: Forbidden (invalid key), 404: Model not found, 429: Rate limit
if status_code in (402, 403, 404, 429):
logger.warning(f"{p.value} returned {status_code} for model {current_model}; trying fallback...")
continue
if not use_fallback or current_model == models_to_try[-1]:
raise
except requests.exceptions.RequestException as e:
logger.error(f"{p.value} API request error ({current_model}): {str(e)}")
last_error = str(e)
if not use_fallback or current_model == models_to_try[-1]:
raise
except ValueError as e:
logger.warning(f"Model {current_model} returned invalid data: {str(e)}")
last_error = str(e)
if current_model == models_to_try[-1]:
raise
error_msg = f"All model calls failed for {p.value}. Last error: {last_error}"
if last_status_code in (402, 403):
error_msg += f"\nStatus {last_status_code} usually means: API key invalid/expired, insufficient balance, or no access to model."
error_msg += f"\nPlease check your {p.value} API key configuration and account balance."
logger.error(error_msg)
raise Exception(error_msg)
def stream_llm_api(self, messages: list, model: str = None, temperature: float = 0.7):
"""Stream LLM response deltas for providers with OpenAI-compatible streaming."""
p = self.provider
api_key = (self.get_api_key(p) or "").strip()
base_url = (self.get_base_url(p) or "").strip()
if not self.is_configured(p):
raise ValueError(f"API key not configured for provider: {p.value}. Please set {p.value.upper()}_API_KEY in settings.")
if p == LLMProvider.GOOGLE:
yield self.call_llm_api(messages, model=model, temperature=temperature, use_json_mode=False)
return
if p == LLMProvider.LITELLM:
yield self.call_llm_api(messages, model=model, temperature=temperature, use_json_mode=False)
return
model = self._normalize_model_for_provider(model, p)
config = load_addon_config()
timeout = int(config.get(p.value, {}).get('timeout', 120))
yield from self._stream_openai_compatible(messages, model, temperature, api_key, base_url, timeout)
def _try_alternative_providers(self, messages: list, model: str, temperature: float,
use_json_mode: bool, excluded_provider: LLMProvider = None) -> str:
"""
Try alternative providers when current provider fails.
Priority: DeepSeek > AtlasCloud > Grok > MiniMax > OpenAI > Google > OpenRouter
"""
priority_order = [
LLMProvider.DEEPSEEK,
LLMProvider.ATLASCLOUD,
LLMProvider.GROK,
LLMProvider.MINIMAX,
LLMProvider.OPENAI,
LLMProvider.GOOGLE,
LLMProvider.OPENROUTER,
]
for alt_provider in priority_order:
if alt_provider == excluded_provider:
continue
api_key = self.get_api_key(alt_provider)
if not api_key:
continue
logger.info(f"Trying alternative provider: {alt_provider.value}")
try:
return self.call_llm_api(
messages, model, temperature,
use_fallback=True, provider=alt_provider,
use_json_mode=use_json_mode,
try_alternative_providers=False # Prevent infinite recursion
)
except Exception as e:
logger.warning(f"Alternative provider {alt_provider.value} also failed: {str(e)}")
continue
raise Exception(f"All LLM providers failed. Please check your API key configurations.")
# Legacy method for backward compatibility
def call_openrouter_api(self, messages: list, model: str = None, temperature: float = 0.7, use_fallback: bool = True) -> str:
"""Call LLM API (legacy method name for backward compatibility)."""
return self.call_llm_api(messages, model, temperature, use_fallback)
def safe_call_llm(self, system_prompt: str, user_prompt: str, default_structure: Dict[str, Any],
model: str = None, provider: LLMProvider = None) -> Dict[str, Any]:
"""Safe LLM call with robust JSON parsing and fallback structure."""
response_text = ""
try:
response_text = self.call_llm_api([
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt}
], model=model, provider=provider)
# Strip markdown fences if present
clean_text = response_text.strip()
if clean_text.startswith("```"):
first_newline = clean_text.find("\n")
if first_newline != -1:
clean_text = clean_text[first_newline+1:]
if clean_text.endswith("```"):
clean_text = clean_text[:-3]
clean_text = clean_text.strip()
# Parse JSON
result = json.loads(clean_text)
return result
except json.JSONDecodeError:
logger.error(f"JSON parse failed. Raw text: {response_text[:200] if response_text else 'N/A'}")
# Try extracting JSON substring
try:
if response_text:
start = response_text.find('{')
end = response_text.rfind('}') + 1
if start >= 0 and end > start:
result = json.loads(response_text[start:end])
return result
except:
pass
default_structure['report'] = f"Failed to parse analysis result JSON. Raw output (partial): {response_text[:500] if response_text else 'N/A'}"
return default_structure
except Exception as e:
logger.error(f"LLM call failed: {str(e)}")
default_structure['report'] = f"Analysis failed: {str(e)}"
return default_structure
@classmethod
def get_available_providers(cls) -> List[Dict[str, Any]]:
"""Get list of available (configured) providers."""
providers = []
for p in LLMProvider:
service = cls()
api_key = service.get_api_key(p)
providers.append({
"id": p.value,
"name": p.value.title(),
"configured": bool(api_key),
"default_model": PROVIDER_CONFIGS[p]["default_model"],
})
return providers