| from __future__ import annotations |
|
|
| import json |
| import os |
| from dataclasses import dataclass |
| from typing import Any |
|
|
| import requests |
|
|
|
|
| POLLINATIONS_OPENAI_URL = "https://text.pollinations.ai/openai" |
|
|
|
|
| @dataclass(frozen=True) |
| class LLMConfig: |
| provider: str |
| url: str |
| model: str |
| api_key: str | None = None |
| ignore_proxy: bool = True |
|
|
|
|
| DEFAULT_MODELS = { |
| "pollinations": "openai-fast", |
| "groq": "openai/gpt-oss-20b", |
| "siliconflow": "THUDM/GLM-Z1-9B-0414", |
| "gemini": "gemini-2.5-flash", |
| "openrouter": "qwen/qwen3-32b:free", |
| } |
|
|
|
|
| OPENAI_COMPATIBLE_URLS = { |
| "pollinations": POLLINATIONS_OPENAI_URL, |
| "groq": "https://api.groq.com/openai/v1/chat/completions", |
| "siliconflow": "https://api.siliconflow.cn/v1/chat/completions", |
| "gemini": "https://generativelanguage.googleapis.com/v1beta/openai/chat/completions", |
| "openrouter": "https://openrouter.ai/api/v1/chat/completions", |
| } |
|
|
|
|
| API_KEY_ENVS = { |
| "groq": "GROQ_API_KEY", |
| "siliconflow": "SILICONFLOW_API_KEY", |
| "gemini": "GEMINI_API_KEY", |
| "openrouter": "OPENROUTER_API_KEY", |
| } |
|
|
|
|
| class LLMError(RuntimeError): |
| pass |
|
|
|
|
| def chat_completion( |
| messages: list[dict[str, Any]], |
| tools: list[dict[str, Any]] | None = None, |
| temperature: float = 0.2, |
| timeout: int = 45, |
| ) -> dict[str, Any]: |
| config = get_llm_config() |
| payload: dict[str, Any] = { |
| "model": config.model, |
| "messages": messages, |
| "temperature": temperature, |
| "stream": False, |
| } |
| if tools: |
| payload["tools"] = tools |
| payload["tool_choice"] = "auto" |
|
|
| headers = {"Content-Type": "application/json"} |
| if config.api_key: |
| headers["Authorization"] = f"Bearer {config.api_key}" |
| if config.provider == "openrouter": |
| headers["HTTP-Referer"] = os.getenv("OPENROUTER_SITE_URL", "http://localhost:7860") |
| headers["X-Title"] = os.getenv("OPENROUTER_APP_NAME", "RouteOpt Agent") |
|
|
| session = requests.Session() |
| session.trust_env = not config.ignore_proxy |
|
|
| try: |
| response = session.post(config.url, json=payload, headers=headers, timeout=timeout) |
| response.raise_for_status() |
| data = response.json() |
| except Exception as exc: |
| raise LLMError(f"LLM 请求失败(provider={config.provider}, model={config.model}):{exc}") from exc |
|
|
| try: |
| return data["choices"][0]["message"] |
| except Exception as exc: |
| raise LLMError(f"LLM 返回格式异常:{json.dumps(data, ensure_ascii=False)[:300]}") from exc |
|
|
|
|
| def extract_tool_arguments(message: dict[str, Any], tool_name: str) -> dict[str, Any] | None: |
| for tool_call in message.get("tool_calls") or []: |
| function = tool_call.get("function") or {} |
| if function.get("name") != tool_name: |
| continue |
| raw_arguments = function.get("arguments") or "{}" |
| if isinstance(raw_arguments, dict): |
| return raw_arguments |
| try: |
| return json.loads(raw_arguments) |
| except json.JSONDecodeError: |
| return None |
| return None |
|
|
|
|
| def get_llm_config() -> LLMConfig: |
| provider = normalize_provider(os.getenv("LLM_PROVIDER", "auto")) |
| if provider == "auto": |
| provider = first_configured_provider() or "pollinations" |
|
|
| if provider == "custom": |
| url = required_env("OPENAI_COMPATIBLE_BASE_URL") |
| api_key = os.getenv("LLM_API_KEY") or os.getenv("OPENAI_API_KEY") |
| model = os.getenv("LLM_MODEL") or os.getenv("OPENAI_COMPATIBLE_MODEL") or required_env("LLM_MODEL") |
| else: |
| url = os.getenv(f"{provider.upper()}_BASE_URL") or OPENAI_COMPATIBLE_URLS[provider] |
| api_key_env = API_KEY_ENVS.get(provider) |
| api_key = os.getenv(api_key_env) if api_key_env else None |
| provider_model_env = os.getenv(f"{provider.upper()}_MODEL") |
| legacy_pollinations_model = os.getenv("POLLINATIONS_MODEL") if provider == "pollinations" else None |
| model = ( |
| os.getenv("LLM_MODEL") |
| or provider_model_env |
| or legacy_pollinations_model |
| or DEFAULT_MODELS[provider] |
| ) |
|
|
| ignore_proxy = os.getenv("LLM_IGNORE_PROXY", "1").strip().lower() not in {"0", "false", "no"} |
| return LLMConfig(provider=provider, url=url, model=model, api_key=api_key, ignore_proxy=ignore_proxy) |
|
|
|
|
| def normalize_provider(provider: str) -> str: |
| provider = provider.strip().lower() |
| aliases = { |
| "pollinations.ai": "pollinations", |
| "pollination": "pollinations", |
| "silicon": "siliconflow", |
| "sf": "siliconflow", |
| "google": "gemini", |
| "google-gemini": "gemini", |
| "openai-compatible": "custom", |
| } |
| provider = aliases.get(provider, provider) |
| allowed = {"auto", "pollinations", "groq", "siliconflow", "gemini", "openrouter", "custom"} |
| if provider not in allowed: |
| raise LLMError(f"未知 LLM_PROVIDER={provider},可选:{', '.join(sorted(allowed))}") |
| return provider |
|
|
|
|
| def first_configured_provider() -> str | None: |
| for provider, env_name in API_KEY_ENVS.items(): |
| if os.getenv(env_name): |
| return provider |
| if os.getenv("OPENAI_COMPATIBLE_BASE_URL"): |
| return "custom" |
| return None |
|
|
|
|
| def required_env(name: str) -> str: |
| value = os.getenv(name) |
| if not value: |
| raise LLMError(f"缺少环境变量 {name}") |
| return value |
|
|
|
|
| def extract_json_from_text(text: str) -> dict[str, Any] | None: |
| text = text.strip() |
| if not text: |
| return None |
| if text.startswith("```"): |
| text = text.strip("`") |
| text = text.removeprefix("json").strip() |
| try: |
| return json.loads(text) |
| except json.JSONDecodeError: |
| pass |
|
|
| start = text.find("{") |
| end = text.rfind("}") |
| if start >= 0 and end > start: |
| try: |
| return json.loads(text[start : end + 1]) |
| except json.JSONDecodeError: |
| return None |
| return None |
|
|