270 lines
10 KiB
Python
270 lines
10 KiB
Python
"""
|
||
Provider 抽象层(opencode 式原生多 Provider 路由)
|
||
|
||
小狸核心引擎是 OpenAI 兼容的 OpenaiAI(已支持 base_url,讯飞/xunfei 即借此接入)。
|
||
本模块在此基础上提供:
|
||
- PROVIDER_PRESETS:主流 Provider 的默认 base_url / 模型前缀 / 协议族
|
||
- resolve_provider():从 config["providers"] 解析出 {api_key, base_url, model, headers}
|
||
- convert_messages() / convert_tools():OpenAI ↔ Anthropic ↔ Google 线缆格式互转(纯函数)
|
||
- list_providers():给 /providers 命令用的汇总
|
||
|
||
设计原则:纯函数 + 配置驱动,不触网、不强求真实 key,不改动现有 engine 行为。
|
||
真实调用仍由 OpenaiAI 在用户填入 base_url/api_key 后完成(OpenAI 兼容族直接可用;
|
||
非兼容族可借助 convert_* 在 engine 层二次适配,本模块只负责「翻译」与「注册」)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
# ───────────────────────── Provider 预设 ─────────────────────────
|
||
# protocol: openai 兼容 | anthropic | google
|
||
PROVIDER_PRESETS: Dict[str, Dict[str, Any]] = {
|
||
"openai": {
|
||
"label": "OpenAI",
|
||
"base_url": "https://api.openai.com/v1",
|
||
"protocol": "openai",
|
||
"models": ["gpt-4o", "gpt-4o-mini", "o1", "o3-mini"],
|
||
"env_key": "OPENAI_API_KEY",
|
||
},
|
||
"anthropic": {
|
||
"label": "Anthropic Claude",
|
||
"base_url": "https://api.anthropic.com/v1",
|
||
"protocol": "anthropic",
|
||
"models": ["claude-opus-4", "claude-sonnet-4", "claude-3-5-sonnet"],
|
||
"env_key": "ANTHROPIC_API_BASE",
|
||
},
|
||
"google": {
|
||
"label": "Google Gemini",
|
||
"base_url": "https://generativelanguage.googleapis.com/v1beta/openai",
|
||
"protocol": "google",
|
||
"models": ["gemini-2.0-pro", "gemini-2.0-flash", "gemini-1.5-pro"],
|
||
"env_key": "GEMINI_API_KEY",
|
||
},
|
||
"deepseek": {
|
||
"label": "DeepSeek",
|
||
"base_url": "https://api.deepseek.com/v1",
|
||
"protocol": "openai",
|
||
"models": ["deepseek-chat", "deepseek-reasoner"],
|
||
"env_key": "DEEPSEEK_API_KEY",
|
||
},
|
||
"moonshot": {
|
||
"label": "Moonshot (Kimi)",
|
||
"base_url": "https://api.moonshot.cn/v1",
|
||
"protocol": "openai",
|
||
"models": ["moonshot-v1-8k", "moonshot-v1-32k"],
|
||
"env_key": "MOONSHOT_API_KEY",
|
||
},
|
||
"qwen": {
|
||
"label": "阿里通义千问",
|
||
"base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1",
|
||
"protocol": "openai",
|
||
"models": ["qwen-max", "qwen-plus", "qwen-turbo"],
|
||
"env_key": "DASHSCOPE_API_KEY",
|
||
},
|
||
"xunfei": {
|
||
"label": "讯飞开放平台",
|
||
"base_url": "https://spark-api-open.xf-yun.com/v1",
|
||
"protocol": "openai",
|
||
"models": ["generalv3.5", "generalv4"],
|
||
"env_key": "XUNFEI_API_KEY",
|
||
},
|
||
"ollama": {
|
||
"label": "Ollama (本地)",
|
||
"base_url": "http://localhost:11434/v1",
|
||
"protocol": "openai",
|
||
"models": ["qwen2.5", "llama3.1", "deepseek-r1"],
|
||
"env_key": "",
|
||
},
|
||
}
|
||
|
||
# 协议族 → 该族内「消息格式」标识(转换时使用)
|
||
_PROTOCOL_FORMAT = {
|
||
"openai": "openai",
|
||
"anthropic": "anthropic",
|
||
"google": "google",
|
||
}
|
||
|
||
|
||
def get_provider_preset(name: str) -> Optional[Dict[str, Any]]:
|
||
return PROVIDER_PRESETS.get(name)
|
||
|
||
|
||
def list_providers() -> List[str]:
|
||
return list(PROVIDER_PRESETS.keys())
|
||
|
||
|
||
def resolve_provider(name: str, providers_cfg: Optional[Dict[str, Any]] = None,
|
||
env: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||
"""从 config["providers"][name] 解析出连接参数。
|
||
|
||
优先级:providers_cfg 显式字段 > 环境变量 > 预设默认值。
|
||
返回:{name, label, base_url, api_key, model, protocol, headers}
|
||
缺失 api_key 时 api_key 为空字符串(调用层据此判断是否配置完成)。
|
||
"""
|
||
preset = PROVIDER_PRESETS.get(name)
|
||
if preset is None:
|
||
return {}
|
||
env = env if env is not None else dict(os.environ)
|
||
cfg = (providers_cfg or {}).get(name, {}) if providers_cfg else {}
|
||
|
||
api_key = cfg.get("api_key") or (env.get(preset["env_key"]) if preset["env_key"] else "")
|
||
base_url = cfg.get("base_url") or preset["base_url"]
|
||
model = cfg.get("model") or (preset["models"][0] if preset["models"] else "")
|
||
protocol = preset["protocol"]
|
||
|
||
headers: Dict[str, str] = {}
|
||
# Anthropic 需要 beta header与协议版本(即便走 OpenAI 代理也保留以便真实适配)
|
||
if protocol == "anthropic":
|
||
headers["anthropic-version"] = "2023-06-01"
|
||
# Google 的 Gemini OpenAI 兼容端点需要把 key 放 query/header,这里统一放 header
|
||
if protocol == "google" and api_key:
|
||
headers["x-goog-api-key"] = api_key
|
||
|
||
return {
|
||
"name": name,
|
||
"label": preset["label"],
|
||
"base_url": base_url,
|
||
"api_key": api_key or "",
|
||
"model": model,
|
||
"protocol": protocol,
|
||
"headers": headers,
|
||
}
|
||
|
||
|
||
def _wire_format(protocol: str) -> str:
|
||
return _PROTOCOL_FORMAT.get(protocol, "openai")
|
||
|
||
|
||
# ───────────────────────── 消息格式互转 ─────────────────────────
|
||
def convert_messages(target: str, messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""把小狸内部 OpenAI 风格 messages 转换为 target provider 的线缆格式。
|
||
|
||
target ∈ {"openai", "anthropic", "google"}
|
||
内部统一表示:[{"role": "system"|"user"|"assistant"|"tool", "content": ..., "tool_calls"?, "name"?}]
|
||
- anthropic:system 抽成顶层;user/assistant 交替,content 包装为 blocks
|
||
- google:system 抽成 systemInstruction;content 包装为 parts
|
||
纯转换,不调用网络。
|
||
"""
|
||
fmt = _wire_format(target) if target in _PROTOCOL_FORMAT.values() else _wire_format(target)
|
||
if fmt == "openai":
|
||
return [_normalize_openai(m) for m in messages]
|
||
if fmt == "anthropic":
|
||
return _to_anthropic(messages)
|
||
if fmt == "google":
|
||
return _to_google(messages)
|
||
return [_normalize_openai(m) for m in messages]
|
||
|
||
|
||
def _normalize_openai(m: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""确保 content 为字符串(统一线缆格式),保留 role/tool_calls/name。"""
|
||
out = {"role": m.get("role", "user")}
|
||
content = m.get("content")
|
||
if content is None:
|
||
content = ""
|
||
if isinstance(content, list):
|
||
# 多模态 blocks → 拼接文本
|
||
text = "".join(p.get("text", "") for p in content if isinstance(p, dict))
|
||
content = text
|
||
out["content"] = content
|
||
if "tool_calls" in m:
|
||
out["tool_calls"] = m["tool_calls"]
|
||
if "name" in m:
|
||
out["name"] = m["name"]
|
||
return out
|
||
|
||
|
||
def _to_anthropic(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""Anthropic Messages API 格式:system 单独抽离,消息为 user/assistant 交替。"""
|
||
out: List[Dict[str, Any]] = []
|
||
for m in messages:
|
||
role = m.get("role")
|
||
if role == "system":
|
||
continue # 顶层 system 由调用方单独处理
|
||
content = m.get("content", "")
|
||
blocks = []
|
||
if isinstance(content, list):
|
||
for p in content:
|
||
if isinstance(p, dict) and p.get("type") == "text":
|
||
blocks.append({"type": "text", "text": p.get("text", "")})
|
||
elif isinstance(p, dict) and p.get("type") == "image":
|
||
blocks.append(p)
|
||
else:
|
||
blocks.append({"type": "text", "text": str(content)})
|
||
out.append({"role": "user" if role in ("user", "tool") else "assistant",
|
||
"content": blocks})
|
||
return out
|
||
|
||
|
||
def _to_google(messages: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""Google Gemini 格式:system 抽为 systemInstruction,消息为 content.parts。"""
|
||
out: List[Dict[str, Any]] = []
|
||
for m in messages:
|
||
role = m.get("role")
|
||
if role == "system":
|
||
continue
|
||
content = m.get("content", "")
|
||
parts = []
|
||
if isinstance(content, list):
|
||
for p in content:
|
||
if isinstance(p, dict) and p.get("type") == "text":
|
||
parts.append({"text": p.get("text", "")})
|
||
elif isinstance(p, dict) and p.get("type") == "image":
|
||
parts.append(p)
|
||
else:
|
||
parts.append({"text": str(content)})
|
||
out.append({"role": "user" if role in ("user", "tool") else "model",
|
||
"parts": parts})
|
||
return out
|
||
|
||
|
||
def convert_tools(target: str, tools: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||
"""把 OpenAI FC tools([{type, function:{name,description,parameters}}]) 转目标格式。
|
||
|
||
- openai:原样
|
||
- anthropic:tools=[{name, description, input_schema}]
|
||
- google:tools=[{functionDeclarations:[{name,description,parameters}]}]
|
||
"""
|
||
fmt = _wire_format(target)
|
||
if fmt == "openai":
|
||
return tools
|
||
if fmt == "anthropic":
|
||
out = []
|
||
for t in tools:
|
||
fn = t.get("function", {})
|
||
out.append({
|
||
"name": fn.get("name", ""),
|
||
"description": fn.get("description", ""),
|
||
"input_schema": fn.get("parameters", {"type": "object", "properties": {}}),
|
||
})
|
||
return out
|
||
if fmt == "google":
|
||
decls = []
|
||
for t in tools:
|
||
fn = t.get("function", {})
|
||
decls.append({
|
||
"name": fn.get("name", ""),
|
||
"description": fn.get("description", ""),
|
||
"parameters": fn.get("parameters", {"type": "object", "properties": {}}),
|
||
})
|
||
return [{"functionDeclarations": decls}] if decls else []
|
||
return tools
|
||
|
||
|
||
def provider_summary(name: str, providers_cfg: Optional[Dict[str, Any]] = None,
|
||
env: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
|
||
"""给 /providers 命令用的单条汇总(是否配置、协议、模型数)。"""
|
||
preset = PROVIDER_PRESETS.get(name)
|
||
if not preset:
|
||
return {}
|
||
resolved = resolve_provider(name, providers_cfg, env)
|
||
configured = bool(resolved.get("api_key"))
|
||
return {
|
||
"name": name,
|
||
"label": preset["label"],
|
||
"protocol": preset["protocol"],
|
||
"configured": configured,
|
||
"model_count": len(preset["models"]),
|
||
"base_url": resolved.get("base_url", ""),
|
||
}
|