"""OpenAI 兼容 API 客户端 对接任意 OpenAI /v1 兼容服务(OpenAI、Together、硅基流动、llama.cpp server 等), 与 OllamaClient 保持相同的 generate/chat 接口,测试用例无需区分后端。 指标来源: - TTFT: 流式请求中首个 content 片段到达时间(真实测量) - Prefill: 优先取服务端 timings.prompt_ms(llama.cpp server 暴露),否则回退 TTFT - Decode Speed: 优先取服务端 timings.predicted_per_second,否则按 completion_tokens/e2e 估算 - E2E: 客户端请求总耗时 """ import json import logging import time import requests from . import config from .client_base import InferenceResult, ClientError logger = logging.getLogger(__name__) class OpenAIError(ClientError): """OpenAI 兼容 API 调用错误""" class OpenAIClient: """封装对 OpenAI 兼容 /v1 服务的调用""" def __init__(self, base_url: str = None, model: str = None, api_key: str = None, timeout: int = 300): self.base_url = (base_url or config.OPENAI_BASE_URL).rstrip("/") self.model = model or config.OPENAI_DEFAULT_MODEL self.api_key = api_key if api_key not in (None, "") else config.OPENAI_API_KEY self.timeout = timeout self._session = requests.Session() # ---------- 请求头 ---------- @property def _headers(self) -> dict: headers = {"Content-Type": "application/json"} if self.api_key: headers["Authorization"] = f"Bearer {self.api_key}" return headers # ---------- 服务探测 ---------- def ping(self) -> bool: """探测服务是否可用""" try: return self._session.get( f"{self.base_url}/models", headers=self._headers, timeout=5 ).ok except Exception: return False def list_models(self) -> list: """列出可用模型""" try: resp = self._session.get( f"{self.base_url}/models", headers=self._headers, timeout=10 ) resp.raise_for_status() data = resp.json().get("data", []) models = [] for m in data: mid = m.get("id") or m.get("name") if mid: models.append(mid) return models except requests.exceptions.ConnectionError as e: raise OpenAIError( f"无法连接服务({self.base_url}),请检查地址与网络" ) from e except requests.exceptions.HTTPError as e: if resp.status_code in (401, 403): raise OpenAIError("API Key 无效或被拒绝(401/403)") from e raise OpenAIError(f"获取模型列表失败: {resp.status_code} {resp.text[:200]}") from e except Exception as e: raise OpenAIError(f"获取模型列表失败: {e}") from e # ---------- 文本生成 ---------- def generate(self, prompt: str, system: str = None, options: dict = None) -> InferenceResult: """单轮文本生成(流式),等价于单条 user 消息的 chat""" messages = [] if system: messages.append({"role": "system", "content": system}) messages.append({"role": "user", "content": prompt}) return self.chat(messages, options) # ---------- 多轮对话 ---------- def chat(self, messages: list, options: dict = None) -> InferenceResult: """多轮对话,messages 为 [{"role": ..., "content": ...}]""" options = options or {} payload = { "model": self.model, "messages": messages, "stream": True, "temperature": options.get("temperature", 0.3), "max_tokens": options.get("num_predict", 256), } start = time.perf_counter() ttft_ms = 0.0 chunks = [] final = {} timings = {} try: resp = self._session.post( f"{self.base_url}/chat/completions", headers=self._headers, json=payload, stream=True, timeout=self.timeout, ) except requests.exceptions.ConnectionError as e: raise OpenAIError( f"无法连接服务({self.base_url}),请检查地址与网络" ) from e except requests.exceptions.Timeout as e: raise OpenAIError(f"请求超时({self.timeout}s): {e}") from e if resp.status_code != 200: err_text = resp.text[:300] raise OpenAIError(f"API 错误 {resp.status_code}: {err_text}") # 流式解析 SSE try: for line in resp.iter_lines(decode_unicode=True): if not line: continue line = line.strip() if line.startswith("data:"): line = line[5:].strip() if line == "[DONE]": break if not line: continue try: data = json.loads(line) except json.JSONDecodeError: continue choices = data.get("choices") or [] if not choices: continue delta = choices[0].get("delta") or {} # 兼容推理模型:token 可能放在 reasoning_content(思考过程)或 content piece = delta.get("content") or delta.get("reasoning_content") or "" if piece: # 首个内容片段到达时间 = TTFT if ttft_ms == 0.0: ttft_ms = (time.perf_counter() - start) * 1000 chunks.append(piece) # 服务端最终块可能携带 usage / timings if choices[0].get("finish_reason"): final = data if data.get("usage"): final = data except (requests.exceptions.ConnectionError, requests.exceptions.ChunkedEncodingError) as e: raise OpenAIError(f"流式读取中断: {e}") from e e2e_ms = (time.perf_counter() - start) * 1000 result = InferenceResult() result.response = "".join(chunks) result.e2e_ms = e2e_ms result.ttft_ms = ttft_ms # usage: token 计数(部分服务端流式响应不含 usage,回退用 timings) usage = final.get("usage", {}) or {} prompt_tok = int(usage.get("prompt_tokens", 0) or 0) completion_tok = int(usage.get("completion_tokens", 0) or 0) # timings: llama.cpp / llama-server 暴露的服务端计时(单位毫秒) timings = final.get("timings", {}) or {} prompt_n = int(timings.get("prompt_n", 0) or 0) predicted_n = int(timings.get("predicted_n", 0) or 0) prompt_ms = float(timings.get("prompt_ms", 0) or 0) predicted_ms = float(timings.get("predicted_ms", 0) or 0) predicted_pps = float(timings.get("predicted_per_second", 0) or 0) result.prompt_tokens = prompt_tok or prompt_n result.completion_tokens = completion_tok or predicted_n result.total_tokens = result.prompt_tokens + result.completion_tokens result.prefill_ms = prompt_ms # 解码速度:优先服务端计时 if predicted_pps > 0: result.decode_speed_tok_s = predicted_pps elif result.completion_tokens > 0 and result.e2e_ms > 0: result.decode_speed_tok_s = result.completion_tokens / (result.e2e_ms / 1000) # TTFT 回退:流式被缓冲或未测到时,用服务端 prompt_ms if result.ttft_ms <= 0 or result.ttft_ms > e2e_ms * 0.95: if prompt_ms > 0: result.ttft_ms = prompt_ms else: result.ttft_ms = e2e_ms if e2e_ms > 0 else 0.0 logger.debug( "推理完成(openai): e2e=%.1fms ttft=%.1fms decode=%.1f tok/s tokens=%d/%d", result.e2e_ms, result.ttft_ms, result.decode_speed_tok_s, result.prompt_tokens, result.completion_tokens, ) return result