318 lines
12 KiB
Python
318 lines
12 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""大模型提供商适配器:OpenAI 兼容 / Anthropic / Google Gemini
|
||
|
||
统一接口: call_stream(cfg, prompt, gen, log, should_stop) -> metrics(dict)
|
||
度量指标:
|
||
prompt_tokens 上文/提示词 token 数(来自 provider usage)
|
||
output_tokens 输出 token 数
|
||
cached_tokens 命中缓存的 token 数(provider 返回时才有)
|
||
ttft_ms 首字延迟(time to first token)
|
||
decode_ms 解码阶段耗时(首字 -> 结束)
|
||
total_ms 总耗时(连接开始 -> 结束)
|
||
prefill_speed 预填充速度 = prompt_tokens / ttft(tok/s)
|
||
decode_speed 解码速度 = output_tokens / decode_time(tok/s)
|
||
"""
|
||
import json
|
||
import time
|
||
|
||
import requests
|
||
|
||
import config
|
||
|
||
|
||
class ProviderError(Exception):
|
||
"""API 调用失败"""
|
||
|
||
|
||
class StopRequested(Exception):
|
||
"""用户请求停止"""
|
||
|
||
|
||
DEFAULT_URLS = {
|
||
"openai": "https://api.openai.com/v1",
|
||
"anthropic": "https://api.anthropic.com",
|
||
"google": "https://generativelanguage.googleapis.com",
|
||
}
|
||
|
||
PROVIDER_LABELS = {
|
||
"openai": "OpenAI 兼容",
|
||
"anthropic": "Anthropic",
|
||
"google": "Google Gemini",
|
||
}
|
||
|
||
|
||
def _parse_sse_line(line):
|
||
line = (line or "").strip()
|
||
if not line.startswith("data:"):
|
||
return None
|
||
data = line[5:].strip()
|
||
if not data or data == "[DONE]":
|
||
return None
|
||
try:
|
||
return json.loads(data)
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
def _metrics(start, first_token_at, end, prompt_tokens, output_tokens,
|
||
cached_tokens, output_chars, prompt_chars):
|
||
if first_token_at is None:
|
||
first_token_at = end # 未收到正文但请求完成(如纯 usage 响应)
|
||
ttft_ms = (first_token_at - start) * 1000
|
||
decode_ms = (end - first_token_at) * 1000
|
||
total_ms = (end - start) * 1000
|
||
prefill = (prompt_tokens / (ttft_ms / 1000)) if prompt_tokens and ttft_ms > 0 else None
|
||
decode = (output_tokens / (decode_ms / 1000)) if output_tokens and decode_ms > 0 else None
|
||
return {
|
||
"prompt_tokens": int(prompt_tokens or 0),
|
||
"output_tokens": int(output_tokens or 0),
|
||
"cached_tokens": int(cached_tokens or 0),
|
||
"prompt_chars": int(prompt_chars or 0),
|
||
"output_chars": int(output_chars or 0),
|
||
"ttft_ms": round(ttft_ms, 1),
|
||
"decode_ms": round(decode_ms, 1),
|
||
"total_ms": round(total_ms, 1),
|
||
"prefill_speed": round(prefill, 1) if prefill else None,
|
||
"decode_speed": round(decode, 1) if decode else None,
|
||
}
|
||
|
||
|
||
# ───────────────────────── OpenAI 兼容 ─────────────────────────
|
||
|
||
def stream_openai(cfg, prompt, gen, log, should_stop=None):
|
||
base = (cfg.get("base_url") or DEFAULT_URLS["openai"]).rstrip("/")
|
||
url = base + "/chat/completions"
|
||
headers = {
|
||
"Authorization": "Bearer " + (cfg.get("api_key") or ""),
|
||
"Content-Type": "application/json",
|
||
}
|
||
payload = {
|
||
"model": cfg["model"],
|
||
"messages": [{"role": "user", "content": prompt}],
|
||
"stream": True,
|
||
"max_tokens": int(gen.get("max_tokens", 256)),
|
||
"temperature": float(cfg.get("temperature", 0.7)),
|
||
}
|
||
use_usage = True # stream_options include_usage(部分网关不支持则自动去掉重试)
|
||
|
||
def build_payload():
|
||
p = dict(payload)
|
||
if use_usage:
|
||
p["stream_options"] = {"include_usage": True}
|
||
return p
|
||
|
||
start = time.time()
|
||
first_token_at = None
|
||
output_chars = 0
|
||
prompt_tokens = output_tokens = cached_tokens = 0
|
||
event_count = 0
|
||
resp = None
|
||
try:
|
||
while True:
|
||
if should_stop and should_stop():
|
||
raise StopRequested()
|
||
resp = requests.post(url, json=build_payload(), headers=headers, stream=True,
|
||
timeout=(config.CONNECT_TIMEOUT, config.STREAM_READ_TIMEOUT))
|
||
if resp.status_code == 200:
|
||
break
|
||
err = resp.text[:400]
|
||
code = resp.status_code
|
||
resp.close()
|
||
resp = None
|
||
if use_usage and _bad_stream_options(err):
|
||
use_usage = False
|
||
if log:
|
||
log("WARN", "提供商不支持 stream_options=include_usage,已去掉参数重试")
|
||
continue
|
||
raise ProviderError("HTTP %s: %s" % (code, err))
|
||
|
||
for obj in _iter_json(resp):
|
||
if should_stop and should_stop():
|
||
raise StopRequested()
|
||
event_count += 1
|
||
if obj.get("choices"):
|
||
delta = obj["choices"][0].get("delta") or {}
|
||
# 兼容推理型模型:Qwen3/DeepSeek 思维链在 reasoning_content
|
||
piece = delta.get("content") or delta.get("reasoning_content") or ""
|
||
if piece:
|
||
if first_token_at is None:
|
||
first_token_at = time.time()
|
||
output_chars += len(piece)
|
||
usage = obj.get("usage")
|
||
if usage:
|
||
prompt_tokens = usage.get("prompt_tokens") or 0
|
||
output_tokens = usage.get("completion_tokens") or 0
|
||
details = usage.get("prompt_tokens_details") or {}
|
||
if isinstance(details, dict):
|
||
cached_tokens = details.get("cached_tokens") or 0
|
||
except StopRequested:
|
||
raise
|
||
except Exception as e:
|
||
raise ProviderError("流式请求异常: %s" % e)
|
||
finally:
|
||
if resp is not None:
|
||
resp.close()
|
||
|
||
if event_count == 0:
|
||
raise ProviderError("未收到任何输出内容(HTTP 200 但响应流为空)")
|
||
end = time.time()
|
||
return _metrics(start, first_token_at, end, prompt_tokens, output_tokens,
|
||
cached_tokens, output_chars, len(prompt))
|
||
|
||
|
||
def _bad_stream_options(err: str):
|
||
err = (err or "").lower()
|
||
return ("stream_options" in err or "unknown parameter" in err or "unknown field" in err
|
||
or "additional properties" in err)
|
||
|
||
|
||
def _iter_json(resp):
|
||
"""解析 SSE data: 行,逐个返回 JSON 对象"""
|
||
for raw in resp.iter_lines(decode_unicode=True):
|
||
obj = _parse_sse_line(raw)
|
||
if obj is not None:
|
||
yield obj
|
||
|
||
|
||
# ───────────────────────── Anthropic ─────────────────────────
|
||
|
||
def stream_anthropic(cfg, prompt, gen, log, should_stop=None):
|
||
base = (cfg.get("base_url") or DEFAULT_URLS["anthropic"]).rstrip("/")
|
||
url = base + "/v1/messages"
|
||
headers = {
|
||
"x-api-key": cfg.get("api_key") or "",
|
||
"anthropic-version": "2023-06-01",
|
||
"Content-Type": "application/json",
|
||
}
|
||
payload = {
|
||
"model": cfg["model"],
|
||
"messages": [{"role": "user", "content": prompt}],
|
||
"max_tokens": int(gen.get("max_tokens", 256)),
|
||
"temperature": float(cfg.get("temperature", 0.7)),
|
||
"stream": True,
|
||
}
|
||
start = time.time()
|
||
first_token_at = None
|
||
output_chars = 0
|
||
prompt_tokens = output_tokens = 0
|
||
event_count = 0
|
||
resp = None
|
||
try:
|
||
if should_stop and should_stop():
|
||
raise StopRequested()
|
||
resp = requests.post(url, json=payload, headers=headers, stream=True,
|
||
timeout=(config.CONNECT_TIMEOUT, config.STREAM_READ_TIMEOUT))
|
||
if resp.status_code != 200:
|
||
err = resp.text[:400]
|
||
resp.close()
|
||
resp = None
|
||
raise ProviderError("HTTP %s: %s" % (resp.status_code, err))
|
||
for obj in _iter_json(resp):
|
||
if should_stop and should_stop():
|
||
raise StopRequested()
|
||
event_count += 1
|
||
etype = obj.get("type")
|
||
if etype == "message_start":
|
||
usage = (obj.get("message") or {}).get("usage") or {}
|
||
prompt_tokens = usage.get("input_tokens") or 0
|
||
elif etype == "content_block_delta":
|
||
delta = obj.get("delta") or {}
|
||
# 兼容 extended thinking:thinking 文本也算输出
|
||
text = delta.get("text") or delta.get("thinking") or ""
|
||
if text:
|
||
if first_token_at is None:
|
||
first_token_at = time.time()
|
||
output_chars += len(text)
|
||
elif etype == "message_delta":
|
||
usage = obj.get("usage") or {}
|
||
output_tokens = usage.get("output_tokens") or output_tokens
|
||
except StopRequested:
|
||
raise
|
||
except Exception as e:
|
||
raise ProviderError("流式请求异常: %s" % e)
|
||
finally:
|
||
if resp is not None:
|
||
resp.close()
|
||
|
||
if event_count == 0:
|
||
raise ProviderError("未收到任何输出内容(HTTP 200 但响应流为空)")
|
||
end = time.time()
|
||
return _metrics(start, first_token_at, end, prompt_tokens, output_tokens,
|
||
0, output_chars, len(prompt))
|
||
|
||
|
||
# ───────────────────────── Google Gemini ─────────────────────────
|
||
|
||
def stream_google(cfg, prompt, gen, log, should_stop=None):
|
||
base = (cfg.get("base_url") or DEFAULT_URLS["google"]).rstrip("/")
|
||
model = cfg["model"]
|
||
url = "%s/v1beta/models/%s:streamGenerateContent" % (base, model)
|
||
params = {"alt": "sse", "key": cfg.get("api_key") or ""}
|
||
headers = {"Content-Type": "application/json"}
|
||
payload = {
|
||
"contents": [{"parts": [{"text": prompt}]}],
|
||
"generationConfig": {
|
||
"temperature": float(cfg.get("temperature", 0.7)),
|
||
"maxOutputTokens": int(gen.get("max_tokens", 256)),
|
||
"candidateCount": 1,
|
||
},
|
||
}
|
||
start = time.time()
|
||
first_token_at = None
|
||
output_chars = 0
|
||
prompt_tokens = output_tokens = cached_tokens = 0
|
||
event_count = 0
|
||
resp = None
|
||
try:
|
||
if should_stop and should_stop():
|
||
raise StopRequested()
|
||
resp = requests.post(url, params=params, json=payload, headers=headers, stream=True,
|
||
timeout=(config.CONNECT_TIMEOUT, config.STREAM_READ_TIMEOUT))
|
||
if resp.status_code != 200:
|
||
err = resp.text[:400]
|
||
resp.close()
|
||
resp = None
|
||
raise ProviderError("HTTP %s: %s" % (resp.status_code, err))
|
||
for obj in _iter_json(resp):
|
||
if should_stop and should_stop():
|
||
raise StopRequested()
|
||
event_count += 1
|
||
cands = obj.get("candidates") or []
|
||
if cands:
|
||
parts = (cands[0].get("content") or {}).get("parts") or []
|
||
for part in parts:
|
||
# 兼容 thinking 模型:thought 文本也算输出
|
||
text = part.get("text") or part.get("thought") or ""
|
||
if text:
|
||
if first_token_at is None:
|
||
first_token_at = time.time()
|
||
output_chars += len(text)
|
||
um = obj.get("usageMetadata") or {}
|
||
if um:
|
||
prompt_tokens = um.get("promptTokenCount") or 0
|
||
output_tokens = um.get("candidatesTokenCount") or 0
|
||
cached_tokens = um.get("cachedContentTokenCount") or 0
|
||
except StopRequested:
|
||
raise
|
||
except Exception as e:
|
||
raise ProviderError("流式请求异常: %s" % e)
|
||
finally:
|
||
if resp is not None:
|
||
resp.close()
|
||
|
||
if event_count == 0:
|
||
raise ProviderError("未收到任何输出内容(HTTP 200 但响应流为空)")
|
||
end = time.time()
|
||
return _metrics(start, first_token_at, end, prompt_tokens, output_tokens,
|
||
cached_tokens, output_chars, len(prompt))
|
||
|
||
|
||
# ───────────────────────── 统一入口 ─────────────────────────
|
||
|
||
def call_stream(cfg, prompt, gen, log=None, should_stop=None):
|
||
provider = cfg.get("provider", "openai")
|
||
fn = {"openai": stream_openai, "anthropic": stream_anthropic, "google": stream_google}.get(provider)
|
||
if fn is None:
|
||
raise ProviderError("不支持的提供商类型: %s" % provider)
|
||
return fn(cfg, prompt, gen, log, should_stop)
|