"""多提供商 LLM 抽象(OpenAI-compatible 接口)。 支持: OpenAI / Deepseek / Ollama / 任何 OpenAI-compatible 网关。 """ from __future__ import annotations import json import logging import time from dataclasses import dataclass, field from typing import Any from src.core.config import settings from src.core.http_client import get_client logger = logging.getLogger(__name__) @dataclass class LLMResponse: content: str parsed: dict | None = None prompt_tokens: int | None = None completion_tokens: int | None = None latency_ms: int | None = None raw: dict | None = None error: str | None = None @dataclass class LLMProvider: """OpenAI-compatible async provider。""" api_key: str = "" base_url: str = "https://api.openai.com/v1" model: str = "gpt-4o" timeout: int = 60 extra_headers: dict = field(default_factory=dict) async def chat( self, system: str, user: str, *, json_mode: bool = True, temperature: float = 0.3, max_tokens: int = 1000, ) -> LLMResponse: """发请求,返回结构化响应。""" headers = { "Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json", **self.extra_headers, } payload: dict[str, Any] = { "model": self.model, "messages": [ {"role": "system", "content": system}, {"role": "user", "content": user}, ], "temperature": temperature, "max_tokens": max_tokens, } if json_mode: payload["response_format"] = {"type": "json_object"} start = time.perf_counter() try: client = get_client() resp = await client.post( f"{self.base_url}/chat/completions", headers=headers, json=payload, timeout=self.timeout, ) resp.raise_for_status() data = resp.json() latency = int((time.perf_counter() - start) * 1000) usage = data.get("usage", {}) content = data["choices"][0]["message"]["content"] parsed = None if json_mode: try: parsed = json.loads(content) except json.JSONDecodeError: # 尝试从代码块提取 import re m = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", content) if m: try: parsed = json.loads(m.group(1)) except json.JSONDecodeError: pass return LLMResponse( content=content, parsed=parsed, prompt_tokens=usage.get("prompt_tokens"), completion_tokens=usage.get("completion_tokens"), latency_ms=latency, raw=data, ) except Exception as e: latency = int((time.perf_counter() - start) * 1000) logger.error("LLM request failed: %s", e) return LLMResponse(content="", error=str(e), latency_ms=latency) def get_default_provider() -> LLMProvider: return LLMProvider( api_key=settings.LLM_API_KEY, base_url=settings.LLM_BASE_URL, model=settings.LLM_MODEL, timeout=settings.LLM_TIMEOUT, )