ForcePilot/backend/package/yuxi/channel/gateway/openai_adapter.py
Kris ecd3c90e80 feat(channel/gateway): 新增完整网关通道模块
新增设备身份管理、认证限流、并发通道、Webhook路由、RBAC权限控制、SSE/轮询降级等全套网关通道功能,包含:
1. 设备身份生成与签名验证
2. 设备令牌认证与速率限制
3. 内存+数据库双重设备注册表
4. 并发通道限流管理
5. Webhook安全处理与路由
6. RBAC权限校验系统
7. OpenAI API兼容适配层
8. Tailscale认证支持
9. HTTP轮询降级机制
2026-05-21 10:26:33 +08:00

162 lines
5.0 KiB
Python

"""OpenAI API 兼容层 — 请求格式转换与 SS 流式响应"""
from __future__ import annotations
import json
import time
from collections.abc import AsyncIterator
OPENAI_CHAT_COMPLETIONS_PATH = "/v1/chat/completions"
OPENAI_MODELS_PATH = "/v1/models"
class OpenAIMessageConverter:
@staticmethod
def extract_system_prompt(messages: list[dict]) -> str | None:
for msg in messages:
if msg.get("role") == "system":
return msg.get("content", "").strip() or None
return None
@staticmethod
def extract_user_query(messages: list[dict]) -> tuple[str, str | None]:
user_texts: list[str] = []
image_base64: str | None = None
for msg in messages:
if msg.get("role") != "user":
continue
content = msg.get("content", "")
if isinstance(content, str):
user_texts.append(content.strip())
elif isinstance(content, list):
for part in content:
if isinstance(part, dict):
if part.get("type") == "text":
user_texts.append(part.get("text", "").strip())
elif part.get("type") == "image_url":
url = part.get("image_url", {}).get("url", "")
if url.startswith("data:"):
image_base64 = url.split(",", 1)[1] if "," in url else url
return "\n".join(t for t in user_texts if t), image_base64
@staticmethod
def extract_conversation_history(messages: list[dict]) -> list[dict]:
history: list[dict] = []
for msg in messages:
role = msg.get("role", "")
content = msg.get("content", "")
if role in ("user", "assistant") and content:
if isinstance(content, str) and content.strip():
history.append({"role": role, "content": content.strip()})
return history
@staticmethod
def extract_model_name(request_data: dict) -> str:
return request_data.get("model", "default")
@staticmethod
def extract_stream_flag(request_data: dict) -> bool:
return bool(request_data.get("stream", False))
@staticmethod
def extract_max_tokens(request_data: dict) -> int | None:
return request_data.get("max_tokens")
@staticmethod
def extract_temperature(request_data: dict) -> float | None:
return request_data.get("temperature")
def _format_sse_chunk(
content: str | None = None,
status: str = "streaming",
*,
finish_reason: str | None = None,
model: str = "default",
index: int = 0,
) -> bytes:
delta: dict = {}
if content is not None:
delta["content"] = content
if status is not None:
payload: dict = {
"id": f"chatcmpl-{int(time.time() * 1000)}",
"object": "chat.completion.chunk",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": index,
"delta": delta,
"finish_reason": finish_reason,
}
],
}
return f"data: {json.dumps(payload, ensure_ascii=False)}\n\n".encode()
return b"data: [DONE]\n\n"
def _format_openai_non_stream_response(
content: str,
*,
model: str = "default",
finish_reason: str = "stop",
) -> dict:
return {
"id": f"chatcmpl-{int(time.time() * 1000)}",
"object": "chat.completion",
"created": int(time.time()),
"model": model,
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": content,
},
"finish_reason": finish_reason,
}
],
"usage": {
"prompt_tokens": 0,
"completion_tokens": 0,
"total_tokens": 0,
},
}
async def format_sse_stream(
raw_stream: AsyncIterator[bytes],
*,
model: str = "default",
) -> AsyncIterator[bytes]:
accumulated: list[str] = []
async for raw in raw_stream:
try:
data = json.loads(raw.decode("utf-8"))
except (json.JSONDecodeError, UnicodeDecodeError):
continue
status = data.get("status", "")
content = data.get("response", "")
if status == "streaming" and content:
accumulated.append(content)
yield _format_sse_chunk(content=content, status="streaming", model=model)
elif status == "finished":
pass
elif status == "error":
error_msg = data.get("error_message", content or "未知错误")
yield _format_sse_chunk(
content=error_msg,
status="error",
finish_reason="error",
model=model,
)
return
yield _format_sse_chunk(content="", status=None, finish_reason="stop", model=model)