Files
NovalRedot/backend/app/services/ai_provider.py

172 lines
6.3 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.

"""AI Provider 抽象层,支持 OpenAI 兼容 API 和 Anthropic 原生 API。"""
import json
from typing import AsyncGenerator
import httpx
from ..models import AIConfig
class StreamEvent:
"""流式事件文本chunk或usage信息"""
def __init__(self, text: str = "", usage: dict | None = None):
self.text = text
self.usage = usage
async def stream_chat(
config: AIConfig,
messages: list[dict],
system_prompt: str = "",
) -> AsyncGenerator[StreamEvent, None]:
"""根据 provider 类型调用对应的流式 API返回 StreamEvent。"""
if config.provider == "anthropic":
async for event in _stream_anthropic(config, messages, system_prompt):
yield event
else:
async for event in _stream_openai(config, messages, system_prompt):
yield event
async def _stream_openai(
config: AIConfig,
messages: list[dict],
system_prompt: str,
) -> AsyncGenerator[StreamEvent, None]:
"""调用 OpenAI 兼容 APIstream=true"""
url = f"{config.base_url.rstrip('/')}/chat/completions"
payload_messages = []
if system_prompt:
payload_messages.append({"role": "system", "content": system_prompt})
payload_messages.extend(messages)
body = {
"model": config.model,
"messages": payload_messages,
"stream": True,
"stream_options": {"include_usage": True},
}
async with httpx.AsyncClient(timeout=120) as client:
async with client.stream(
"POST",
url,
headers={
"Authorization": f"Bearer {config.api_key}",
"Content-Type": "application/json",
},
json=body,
) as resp:
if resp.status_code >= 400:
body_text = await resp.aread()
try:
err = json.loads(body_text)
msg = err.get("error", {}).get("message", "") or str(err)
except Exception:
msg = body_text.decode(errors="replace")[:200]
raise RuntimeError(f"API错误 ({resp.status_code}): {msg}")
ct = resp.headers.get("content-type", "")
if "text/html" in ct:
raise RuntimeError(f"API地址返回了HTML页面请检查API地址是否正确当前: {url}")
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
data = line[6:]
if data.strip() == "[DONE]":
break
try:
chunk = json.loads(data)
# 提取usageOpenAI在最后一个chunk返回
usage = chunk.get("usage")
if usage:
yield StreamEvent(usage={
"prompt_tokens": usage.get("prompt_tokens", 0),
"completion_tokens": usage.get("completion_tokens", 0),
"total_tokens": usage.get("total_tokens", 0),
})
choices = chunk.get("choices", [])
if choices:
delta = choices[0].get("delta", {})
content = delta.get("content")
if content:
yield StreamEvent(text=content)
except (json.JSONDecodeError, KeyError, IndexError):
continue
async def _stream_anthropic(
config: AIConfig,
messages: list[dict],
system_prompt: str,
) -> AsyncGenerator[StreamEvent, None]:
"""调用 Anthropic Messages APIstream=true"""
url = f"{config.base_url.rstrip('/')}/messages"
body: dict = {
"model": config.model,
"max_tokens": 4096,
"messages": messages,
"stream": True,
}
if system_prompt:
body["system"] = system_prompt
async with httpx.AsyncClient(timeout=120) as client:
async with client.stream(
"POST",
url,
headers={
"x-api-key": config.api_key,
"anthropic-version": "2023-06-01",
"content-type": "application/json",
},
json=body,
) as resp:
if resp.status_code >= 400:
body_text = await resp.aread()
try:
err = json.loads(body_text)
msg = err.get("error", {}).get("message", "") or str(err)
except Exception:
msg = body_text.decode(errors="replace")[:200]
raise RuntimeError(f"API错误 ({resp.status_code}): {msg}")
ct = resp.headers.get("content-type", "")
if "text/html" in ct:
raise RuntimeError(f"API地址返回了HTML页面请检查API地址是否正确当前: {url}")
async for line in resp.aiter_lines():
if not line.startswith("data: "):
continue
try:
event = json.loads(line[6:])
if event.get("type") == "content_block_delta":
text = event.get("delta", {}).get("text", "")
if text:
yield StreamEvent(text=text)
elif event.get("type") == "message_delta":
usage = event.get("usage", {})
if usage:
yield StreamEvent(usage={
"prompt_tokens": 0,
"completion_tokens": usage.get("output_tokens", 0),
"total_tokens": 0,
})
elif event.get("type") == "message_start":
msg = event.get("message", {})
usage = msg.get("usage", {})
if usage:
yield StreamEvent(usage={
"prompt_tokens": usage.get("input_tokens", 0),
"completion_tokens": 0,
"total_tokens": 0,
})
elif event.get("type") == "message_stop":
break
except json.JSONDecodeError:
continue