Files
xiaoli-cli/xcli_core/provider_router.py
T

270 lines
10 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.
"""
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", ""),
}