Files
xiaoli-cli/ai_engines/openai_engine.py
T
shuiyu67 1383d32f8b feat: OpenAI 多模型注册表 + /model 命令,告别手改配置文件
痛点:很多模型都是 OpenAI 格式,但换模型只能手改 config.json 的单槽位,
且只能存一个。新增模型注册表与 /model 命令族:

- 新增 xcli_core/model_registry.py:在 api.engines.openai 下维护
  models 列表(name + base_url + api_key + model)与 current 指针,全部落盘。
  向后兼容:无 models 时把扁平 api_key/base_url/model 当作单条隐式模型。
- OpenaiAI 接入:__init__ 应用注册表 current 模型;新增
  set_registry_model / list_registry_models / add_model / remove_model /
  current_model_name。注意保留引擎原有的轻量 set_model(只改 model 名)
  与 list_models(查 API /models 端点),新方法与它们不重名、互不干扰。
- 新增 /model 命令族(仅 openai 引擎):
    /model            列出已配置模型(* 标记当前)
    /model <名称>     切换当前模型并持久化
    /model add        交互式录入(名称/base_url/api_key/model),回车即存并切换
    /model rm <名称>  删除(删当前则回退到列表第一个)
- 启动横幅引擎名后显示当前模型;紧凑提示与 /help 速查表均补 /model 入口。
- 测试:新增 tests/test_model_registry.py(13 项,覆盖兼容/增删/切换/落盘/
  引擎接入/CLI 路由,全程临时配置不污染真实 config.json)。
  全量 230 passed(原 217 passed)。

交互实测:/model add → 已添加并切换到 gpt4o;/model gpt4o → 已切换;
/model rm gpt4o → 回退到 xophunyuan7bmt,配置结构与内容完好。
2026-08-05 16:12:35 +08:00

643 lines
26 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.
"""
OpenAI 兼容格式引擎
支持所有兼容 OpenAI API 格式的服务(如 DeepSeek、Grok、硅基流动、本地 vLLM 等)
"""
import os
import sys
import json
import requests
from colorama import Fore, Style
import logging
# 设置日志
logger = logging.getLogger(__name__)
try:
from xcli_core.config import get_system_config
except Exception:
def get_system_config(key, default=None):
return default
try:
from xcli_core import model_registry
except Exception:
model_registry = None
try:
from xcli_core.verbose import vprint
except Exception:
def vprint(*args, **kwargs):
pass
try:
from xcli_core.tool_result import normalize_tool_text
except Exception:
def normalize_tool_text(result, default="无结果"):
"""兜底归一化:ToolResult / dict / str 统一成字符串"""
if result is None:
return default
if isinstance(result, dict):
return str(result.get("result", default))
return str(result)
# ── 常见模型的上下文窗口(token 数)──
# OpenAI /chat/completions 协议本身不返回模型窗口,需本地维护。
MODEL_CONTEXT_WINDOWS = {
# 讯飞星火(Spark,经 one-api/new-api 转发时模型名常为 spark-* / general*)
"spark-4.0": 128000, "spark-max": 8192, "spark-pro": 8192, "spark-lite": 8192,
"spark": 8192, "generalv3.5": 8192, "generalv3": 8192, "general": 4096,
"gpt-4o": 128000, "gpt-4o-mini": 128000, "gpt-4": 8192, "gpt-4-turbo": 128000,
"gpt-3.5-turbo": 16385,
"deepseek-chat": 64000, "deepseek-reasoner": 64000,
"qwen2.5": 32768, "qwen2": 32768, "qwen-max": 32768, "qwen-plus": 32768,
"llama3": 8192, "llama3.1": 128000, "llama3.2": 128000, "llama2": 4096,
"claude-3": 200000, "claude-3.5": 200000, "claude-3.7": 200000, "claude": 200000,
"grok": 131072, "grok-2": 131072, "grok-3": 131072,
"glm-4": 128000, "glm-4v": 128000,
"mistral": 32768, "mixtral": 32768, "mistral-large": 128000,
"gemini": 1000000, "gemini-1.5": 1000000, "gemini-2.0": 1000000, "gemini-2.5": 1000000,
"yi": 200000, "yi-large": 200000,
"kimi": 200000, "moonshot": 200000,
"abab": 200000, "minimax": 200000,
"baichuan": 192000, "chatglm": 32768,
}
# 添加项目根目录到sys.path
current_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(current_dir)
sys.path.insert(0, project_root)
class OpenaiAI:
"""OpenAI 兼容格式 AI 引擎 - 支持所有兼容 OpenAI API 的服务"""
requires_api_key = True
def get_help_info(self):
"""获取引擎帮助信息"""
help_text = """
OpenAI 兼容格式引擎帮助信息
============================
引擎名称: openai
描述: 通用 OpenAI API 兼容引擎,支持所有兼容 OpenAI 格式的服务
支持的服务:
- OpenAI 官方 API
- DeepSeek API
- Grok (xAI) API
- 硅基流动 (SiliconFlow) API
- 本地 vLLM / LiteLLM / OneAPI 等转发服务
- 任何兼容 /chat/completions 接口的服务
配置方式 (config.json):
{
"api": {
"engines": {
"openai": {
"api_key": "你的API密钥",
"base_url": "https://api.openai.com/v1",
"model": "gpt-4o"
}
}
}
}
可用命令:
/engine.openai models - 列出可用模型(需要服务端支持)
/engine.openai set <模型名> - 切换模型
/engine.openai info - 显示当前配置信息
使用说明:
1. 在 config.json 中配置 api_key、base_url、model
2. 使用 /engine switch openai 切换到此引擎
3. 直接输入自然语言与 AI 对话
注意:
- base_url 填写 API 的基础地址,无需包含 /chat/completions
- 例如 OpenAI 官方填 https://api.openai.com/v1
- 本地服务填 http://localhost:8000/v1
"""
return help_text
def __init__(self):
self.name = "openai"
self.cli = None
# 从 config.json 读取配置
config_file = os.path.join(project_root, "config.json")
try:
with open(config_file, 'r', encoding='utf-8') as f:
config = json.load(f)
engine_config = config.get("api", {}).get("engines", {}).get("openai", {})
self.api_key = engine_config.get("api_key", "")
self.base_url = engine_config.get("base_url", "").rstrip('/')
self.model = engine_config.get("model", "")
self.max_history = config.get("system", {}).get("max_history", 10)
except Exception as e:
logger.warning(f"读取配置文件失败: {e}")
self.api_key = ""
self.base_url = ""
self.model = ""
self.max_history = 10
# 初始化对话历史
self.conversation_history = []
self.shared_conversation_history = None
# 应用注册表中的当前模型(覆盖扁平默认配置,向后兼容)
self._apply_registry_model()
# 最近一次请求的 token 用量(供 token 感知压缩使用)
self.last_prompt_tokens = None
# 思考标记(兼容深度思考模型)
self.thinking_start_marker = "<think>"
self.thinking_end_marker = "</think>"
# 检查配置
if not self.api_key:
print(f"{Fore.YELLOW}警告: openai 引擎的 API 密钥未设置{Style.RESET_ALL}")
if not self.base_url:
print(f"{Fore.YELLOW}警告: openai 引擎的 base_url 未设置{Style.RESET_ALL}")
else:
vprint(f"{Fore.GREEN}OpenAI 兼容引擎已启用 (base_url: {self.base_url}){Style.RESET_ALL}")
# ── 模型注册表(多模型在线切换)──
def _apply_registry_model(self):
"""若注册表存在 current 模型,用它覆盖 __init__ 读到的扁平默认配置。"""
if not model_registry:
return
try:
m = model_registry.get_model()
if m:
self.api_key = m.get("api_key", self.api_key)
self.base_url = (m.get("base_url") or self.base_url).rstrip('/')
self.model = m.get("model", self.model)
except Exception:
pass
def list_registry_models(self):
"""返回 (models, current_name),供 CLI 列出已配置模型。"""
if not model_registry:
return [], None
return model_registry.list_models()
def current_model_name(self):
"""当前激活模型在注册表中的名称(兼容单槽位时返回 model 值)。"""
if not model_registry:
return self.model
try:
models, current = model_registry.list_models()
return current or self.model
except Exception:
return self.model
def set_registry_model(self, name):
"""切换到注册表中指定模型(含 base_url/api_key/model)并持久化 current。
成功返回 True。"""
if not model_registry:
return False
m = model_registry.get_model(name)
if not m:
return False
self.api_key = m.get("api_key", "")
self.base_url = (m.get("base_url") or "").rstrip('/')
self.model = m.get("model", "")
model_registry.set_current(name)
return True
def add_model(self, entry):
"""新增/更新模型并持久化。entry: {name, base_url, api_key, model}。"""
if not model_registry:
return None
return model_registry.add_model(entry)
def remove_model(self, name):
"""删除模型并持久化。成功返回 True。"""
if not model_registry:
return False
return model_registry.remove_model(name)
def _get_headers(self):
"""构建请求头"""
headers = {
'Content-Type': 'application/json'
}
if self.api_key:
headers['Authorization'] = f'Bearer {self.api_key}'
return headers
def _get_api_url(self):
"""获取完整的 API 地址"""
url = self.base_url
if not url:
return ""
if not url.endswith('/chat/completions'):
url = url.rstrip('/') + '/chat/completions'
return url
# ── FC 能力探测缓存 ──
def _fc_supported(self):
"""当前 (base_url, model) 是否支持 function calling(未探测过默认支持)"""
try:
from xcli_core.fc_tools import supports_fc
return supports_fc(self.base_url, self.model)
except Exception:
return True
def _mark_fc_unsupported(self):
"""降级成功后记住该模型不支持 FC,后续请求不再带 tools"""
try:
from xcli_core.fc_tools import mark_fc_unsupported
if mark_fc_unsupported(self.base_url, self.model):
print(f"{Fore.CYAN}已记住 {self.model} 不支持 FC,后续请求将跳过工具字段"
f"(/fc reset 可清除){Style.RESET_ALL}")
except Exception:
pass
def generate_response(self, user_input, tool_results=None, system_prompt=None, tools=None):
"""
生成 AI 响应
Args:
user_input: 用户输入
tool_results: 工具执行结果
system_prompt: 系统提示词
tools: OpenAI FC 工具定义列表 (可选)
"""
try:
if not self.base_url:
return "错误: openai 引擎的 base_url 未配置,请在 config.json 中设置"
# 处理工具结果
if tool_results:
tool_result_text = f"工具执行结果: {normalize_tool_text(tool_results)}"
messages = self._build_messages_with_history(system_prompt, tool_result_text)
else:
messages = self._build_messages_with_history(system_prompt, user_input)
# 构建请求体
body = {
"model": self.model,
"messages": messages,
"stream": False
}
# ── Function Calling: 注入工具定义(统一纯官方格式)──
# 已探测过不支持 FC 的模型直接跳过 tools,省掉每次白撞一次 500
if tools and self._fc_supported():
pure_tools = []
for t in tools:
if not isinstance(t, dict):
continue
if t.get("type") == "function" and isinstance(t.get("function"), dict):
pure_tools.append(t)
elif isinstance(t.get("function"), dict):
pure_tools.append({"type": "function", "function": t["function"]})
if pure_tools:
body["tools"] = pure_tools
body["tool_choice"] = "auto"
# 发送请求
api_url = self._get_api_url()
response = requests.post(
url=api_url,
json=body,
headers=self._get_headers(),
timeout=None
)
if response.status_code == 200:
response_data = response.json()
# ── 记录 token 用量(供 token 感知压缩)──
usage = response_data.get("usage", {})
if isinstance(usage, dict) and usage.get("prompt_tokens"):
try:
self.last_prompt_tokens = int(usage["prompt_tokens"])
except (TypeError, ValueError):
self.last_prompt_tokens = None
if "choices" in response_data and len(response_data["choices"]) > 0:
message = response_data["choices"][0].get("message", {})
# ── Function Calling: 检查 tool_calls ──
tool_calls_raw = message.get("tool_calls")
if tool_calls_raw:
# 返回 FC 结果(由调用方处理工具执行)
from xcli_core.fc_tools import parse_fc_response
fc_calls = parse_fc_response(response_data)
if fc_calls:
# 返回一个特殊的 JSON 字符串,标记为 FC 调用
return json.dumps({
"_fc": True,
"tool_calls": fc_calls,
"raw_message": message
}, ensure_ascii=False)
# 普通文本响应
ai_response = message.get("content", "")
if ai_response:
# 清理无效的 UTF-8 代理字符
ai_response = ai_response.encode('utf-8', 'replace').decode('utf-8')
# 处理思考内容
ai_response = self._process_thinking_content(ai_response)
return ai_response or ""
else:
return f"API 返回格式错误: {response_data}"
elif response.status_code == 401:
return "API 认证失败,请检查 api_key 是否正确"
elif response.status_code == 404:
return f"API 端点不存在,请检查 base_url 是否正确: {api_url}"
elif response.status_code == 400:
# 工具定义可能不兼容,降级为无工具重试
if body.get("tools") and "tools" in str(response.text).lower():
print(f"{Fore.YELLOW}FC 不支持,降级为普通模式{Style.RESET_ALL}")
body.pop("tools", None)
body.pop("tool_choice", None)
retry = requests.post(url=api_url, json=body, headers=self._get_headers(), timeout=None)
if retry.status_code == 200:
self._mark_fc_unsupported()
rd = retry.json()
if "choices" in rd and rd["choices"]:
ai_response = rd["choices"][0].get("message", {}).get("content", "")
return ai_response.encode('utf-8', 'replace').decode('utf-8') if ai_response else ""
return self._friendly_error(response.status_code, response)
else:
# 500 等错误:部分服务(如讯飞星火经 one-api 转发)不支持 FC 工具,
# 会直接报 500 Invalid Params —— 尝试去掉 tools 降级重试一次
lower = str(response.text).lower()
if body.get("tools") and ("xunfei" in lower or "invalid params" in lower or "requestparamserror" in lower):
print(f"{Fore.YELLOW}该接口可能不支持 FC 工具,降级为普通模式重试...{Style.RESET_ALL}")
body.pop("tools", None)
body.pop("tool_choice", None)
retry = requests.post(url=api_url, json=body, headers=self._get_headers(), timeout=None)
if retry.status_code == 200:
self._mark_fc_unsupported()
rd = retry.json()
retry_usage = rd.get("usage", {})
if isinstance(retry_usage, dict) and retry_usage.get("prompt_tokens"):
try:
self.last_prompt_tokens = int(retry_usage["prompt_tokens"])
except (TypeError, ValueError):
pass
if "choices" in rd and rd["choices"]:
ai_response = rd["choices"][0].get("message", {}).get("content", "")
ai_response = self._process_thinking_content(ai_response)
return ai_response.encode('utf-8', 'replace').decode('utf-8') if ai_response else ""
return self._friendly_error(response.status_code, response)
except requests.exceptions.ConnectionError:
return f"无法连接到 API 服务: {self.base_url},请检查服务是否运行"
except Exception as e:
return f"API 调用失败: {str(e)}"
def _build_messages_with_history(self, system_prompt, user_input):
"""构建包含历史记录的消息列表(支持 FC tool 消息)"""
messages = []
# 添加系统提示词
if system_prompt:
clean_prompt = system_prompt.encode('utf-8', 'replace').decode('utf-8')
messages.append({
"role": "system",
"content": clean_prompt
})
# 添加历史对话记录(使用共享历史)
if self.shared_conversation_history is not None:
for msg in self.shared_conversation_history[-self.max_history:]:
if not isinstance(msg, dict) or "role" not in msg:
continue
role = msg["role"]
# tool 角色消息(FC 工具结果)—— 官方 OpenAI 格式不含 name 字段
if role == "tool":
tool_msg = {"role": "tool", "content": msg.get("content", "")}
if "tool_call_id" in msg:
tool_msg["tool_call_id"] = msg["tool_call_id"]
messages.append(tool_msg)
continue
# assistant 消息可能包含 tool_calls —— 重建成纯官方结构
if role == "assistant" and "tool_calls" in msg:
assistant_msg = {
"role": "assistant",
"content": msg.get("content") or "",
"tool_calls": self._pure_tool_calls(msg["tool_calls"])
}
messages.append(assistant_msg)
continue
# 普通 user/assistant 消息
if "content" in msg:
clean_content = msg["content"]
if isinstance(clean_content, str):
clean_content = clean_content.encode('utf-8', 'replace').decode('utf-8')
messages.append({"role": role, "content": clean_content})
# 添加当前用户输入
if user_input:
clean_input = user_input.encode('utf-8', 'replace').decode('utf-8')
messages.append({
"role": "user",
"content": clean_input
})
return messages
def _call_api_with_history(self, content, system_prompt=None):
"""使用对话历史调用 API"""
if not self.base_url:
return "错误: openai 引擎的 base_url 未配置"
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
if self.shared_conversation_history is not None:
for msg in self.shared_conversation_history[-self.max_history:]:
if isinstance(msg, dict) and "role" in msg and "content" in msg:
messages.append(msg)
messages.append({"role": "user", "content": content})
body = {
"model": self.model,
"messages": messages,
"stream": False
}
try:
response = requests.post(
url=self._get_api_url(),
json=body,
headers=self._get_headers(),
timeout=None
)
if response.status_code == 200:
response_data = response.json()
if "choices" in response_data and len(response_data["choices"]) > 0:
ai_response = response_data["choices"][0]["message"]["content"]
ai_response = ai_response.encode('utf-8', 'replace').decode('utf-8')
return self._process_thinking_content(ai_response)
return f"API 调用失败: {response.status_code} - {response.text}"
except Exception as e:
return f"API 调用失败: {str(e)}"
def _process_thinking_content(self, response):
"""处理思考内容,将思考部分以灰色文本显示"""
import re
if not self.thinking_start_marker or not self.thinking_end_marker:
return response
if self.thinking_start_marker in response and self.thinking_end_marker in response:
parts = response.split(self.thinking_start_marker)
if len(parts) > 1:
remaining = parts[1]
if self.thinking_end_marker in remaining:
thinking_part, reply_part = remaining.split(self.thinking_end_marker, 1)
thinking_content = thinking_part.strip()
reply_content = reply_part.strip()
if thinking_content:
print(f"{Fore.LIGHTBLACK_EX}[思考: {thinking_content}]{Style.RESET_ALL}")
return reply_content if reply_content else ""
return response
@staticmethod
def _friendly_error(status_code, response):
"""把错误响应里的可读信息提取出来,附带常见原因提示"""
try:
data = response.json()
err = data.get("error", {})
msg = err.get("message", "") if isinstance(err, dict) else ""
except Exception:
msg = ""
if not msg:
return f"API 调用失败: {status_code} - {response.text}"
hint = ""
low = str(msg).lower()
if "invalid params" in low or "requestparamserror" in low or "xunfei" in low:
hint = ("\n提示: 讯飞星火接口报'参数错误',常见原因:"
"\n 1) model 名与 one-api/new-api 渠道里的模型映射不一致(如 spark-4.0-ultra / spark-max / generalv3.5)"
"\n 2) 该模型/渠道不支持 function calling(引擎已尝试去掉 tools 重试)"
"\n 3) base_url 需为 OpenAI 兼容转发地址(如 one-api 的 https://域名/v1)")
return f"API 调用失败: {status_code} - {msg}{hint}"
@staticmethod
def _pure_tool_calls(tool_calls):
"""把历史里的 tool_calls 重建成官方 OpenAI 格式:
[{"id": ..., "type": "function", "function": {"name": ..., "arguments": "json串"}}]
剔除内部字段(action/tool/args 等),arguments 统一为字符串。
"""
pure = []
for tc in (tool_calls or []):
if not isinstance(tc, dict):
continue
func = tc.get("function")
if isinstance(func, dict):
name = func.get("name", tc.get("name", ""))
args = func.get("arguments", "{}")
else:
name = tc.get("name", "")
args = tc.get("arguments", "{}")
if isinstance(args, str):
args_str = args
else:
try:
args_str = json.dumps(args, ensure_ascii=False)
except (TypeError, ValueError):
args_str = "{}"
pure.append({
"id": tc.get("id", f"call_{name}"),
"type": "function",
"function": {"name": name, "arguments": args_str}
})
return pure
def set_model(self, model_name):
"""设置模型"""
self.model = model_name
print(f"{Fore.GREEN}已切换模型为: {model_name}{Style.RESET_ALL}")
def context_window(self):
"""
返回当前模型的上下文窗口(token 数)。
优先级: config.system.context_window > 模型名查表 > 默认 128000。
注: OpenAI /chat/completions 协议本身不返回模型窗口,需本地维护。
"""
cfg = get_system_config("context_window", None)
if cfg:
try:
return int(cfg)
except (TypeError, ValueError):
pass
model = (self.model or "").lower()
for key, win in MODEL_CONTEXT_WINDOWS.items():
if key and key in model:
return win
return 128000
def list_models(self):
"""列出可用模型(调用 API 的 /models 端点)"""
if not self.base_url:
return ["错误: base_url 未配置"]
try:
models_url = self.base_url.rstrip('/')
if models_url.endswith('/chat/completions'):
models_url = models_url[:-len('/chat/completions')]
models_url = models_url.rstrip('/') + '/models'
response = requests.get(
url=models_url,
headers=self._get_headers(),
timeout=10
)
if response.status_code == 200:
data = response.json()
if "data" in data:
models = [m.get("id", "unknown") for m in data["data"]]
return models
else:
return ["API 返回格式不标准,无法解析模型列表"]
else:
return [f"获取模型列表失败: {response.status_code}"]
except Exception as e:
return [f"获取模型列表失败: {str(e)}"]
def handle_command(self, command):
"""处理引擎特定命令"""
if command == "models":
models = self.list_models()
print(f"{Fore.GREEN}可用模型列表:{Style.RESET_ALL}")
for model in models:
status = " (当前使用)" if model == self.model else ""
print(f"{Fore.GREEN} - {model}{status}{Style.RESET_ALL}")
return True
elif command.startswith("set "):
model_name = command[4:].strip()
if model_name:
self.set_model(model_name)
return True
else:
print(f"{Fore.RED}请提供模型名称. 用法: /engine.openai set <模型名>{Style.RESET_ALL}")
return False
elif command == "info":
print(f"{Fore.GREEN}OpenAI 兼容引擎配置:{Style.RESET_ALL}")
print(f" base_url: {self.base_url}")
print(f" model: {self.model}")
print(f" api_key: {'已设置' if self.api_key else '未设置'}")
return True
else:
print(f"{Fore.RED}未知命令. 可用命令: models, set, info{Style.RESET_ALL}")
return False