fix(v8.0): openai 引擎改纯官方格式 + 讯飞 500 兼容 + <input> 向导
- openai_engine: 请求体严格纯官方 OpenAI /chat/completions 格式(tool 消息去 name、 tool_calls 重建、tools 统一 type=function);500(含 xunfei/Invalid Params)自动去掉 tools 降级重试;_friendly_error 友好提示;MODEL_CONTEXT_WINDOWS 补讯飞星火。 - ollama_engine/fc_tools/cli_core: tool 消息去 name。 - config.py resolve_input_value(<input> 语法);ollama max_token_k(默认 <input:...>); launcher 向导加 max_token_k 步骤。
This commit is contained in:
1 parent
539c21be72
commit
dfc42d8583
7 files changed
+203
-23
No files matched your search
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
import json
|
||||
import requests
|
||||
@@ -21,6 +22,13 @@ current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
project_root = os.path.dirname(current_dir)
|
||||
sys.path.insert(0, project_root)
|
||||
|
||||
# <input> 特殊语法解析(配置模型时可交互式询问用户)
|
||||
try:
|
||||
from xcli_core.config import resolve_input_value
|
||||
except Exception:
|
||||
def resolve_input_value(value, field_name="", default_prompt=None):
|
||||
return value
|
||||
|
||||
|
||||
class OllamaAI:
|
||||
"""基于Ollama的本地AI引擎"""
|
||||
@@ -62,6 +70,9 @@ Ollama AI引擎插件帮助信息
|
||||
self.conversation_history = []
|
||||
|
||||
# 直接从 config.json 读取配置
|
||||
# 模型最大 token 大小(单位 K,如 32 = 32K = 32768 token)
|
||||
# 支持 <input> 语法:配置里写 <input:提示语> 会在配置模型时询问用户
|
||||
max_token_k = "<input:请输入模型最大token大小(K)>"
|
||||
config_file = os.path.join(project_root, "config.json")
|
||||
try:
|
||||
with open(config_file, 'r', encoding='utf-8') as f:
|
||||
@@ -71,12 +82,17 @@ Ollama AI引擎插件帮助信息
|
||||
self.base_url = engine_config.get("base_url", "")
|
||||
self.model = engine_config.get("model", "")
|
||||
self.max_history = config.get("system", {}).get("max_history", 10)
|
||||
max_token_k = engine_config.get("max_token_k", max_token_k)
|
||||
except Exception as e:
|
||||
# 如果读取失败,使用空值
|
||||
self.api_key = ""
|
||||
self.base_url = ""
|
||||
self.model = ""
|
||||
self.max_history = 10
|
||||
|
||||
# 解析 <input> 语法(询问用户)并换算成 num_ctx(token 数)
|
||||
resolved_k = resolve_input_value(max_token_k, field_name="模型最大token大小(K)")
|
||||
self.num_ctx = self._parse_num_ctx(resolved_k)
|
||||
|
||||
# 共享对话历史引用(由CLI设置)
|
||||
self.shared_conversation_history = None
|
||||
@@ -93,6 +109,23 @@ Ollama AI引擎插件帮助信息
|
||||
else:
|
||||
print(f"{Fore.RED}Ollama服务未运行,引擎将不可用{Style.RESET_ALL}")
|
||||
|
||||
@staticmethod
|
||||
def _parse_num_ctx(value):
|
||||
"""把用户输入的 K 值换算成 token 数:32 / 32K → 32768。非法/空 → 4096"""
|
||||
if value is None:
|
||||
return 4096
|
||||
s = str(value).strip().lower()
|
||||
digits = re.sub(r"[^\d.]", "", s)
|
||||
if not digits:
|
||||
return 4096
|
||||
try:
|
||||
k = float(digits)
|
||||
except ValueError:
|
||||
return 4096
|
||||
if k <= 0:
|
||||
return 4096
|
||||
return max(1024, int(k * 1024))
|
||||
|
||||
def check_ollama_service(self):
|
||||
"""检测Ollama服务是否运行"""
|
||||
# 检查 ollama 库是否可用
|
||||
@@ -136,11 +169,12 @@ Ollama AI引擎插件帮助信息
|
||||
else:
|
||||
messages = self._build_messages_with_history(system_prompt, user_input)
|
||||
|
||||
# 构建调用参数
|
||||
# 构建调用参数(num_ctx: 模型上下文窗口大小,由配置的 max_token_k 决定)
|
||||
kwargs = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"stream": False
|
||||
"stream": False,
|
||||
"options": {"num_ctx": self.num_ctx}
|
||||
}
|
||||
|
||||
# ── Function Calling: 注入工具定义 ──
|
||||
@@ -217,13 +251,11 @@ Ollama AI引擎插件帮助信息
|
||||
|
||||
role = msg["role"]
|
||||
|
||||
# tool 角色消息(FC 工具结果)
|
||||
# tool 角色消息(FC 工具结果)—— 官方格式不含 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"]
|
||||
if "name" in msg:
|
||||
tool_msg["name"] = msg["name"]
|
||||
messages.append(tool_msg)
|
||||
continue
|
||||
|
||||
|
||||
+93
-11
@@ -22,6 +22,9 @@ except Exception:
|
||||
# ── 常见模型的上下文窗口(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,
|
||||
@@ -181,10 +184,19 @@ OpenAI 兼容格式引擎帮助信息
|
||||
"stream": False
|
||||
}
|
||||
|
||||
# ── Function Calling: 注入工具定义 ──
|
||||
# ── Function Calling: 注入工具定义(统一纯官方格式)──
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
body["tool_choice"] = "auto"
|
||||
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()
|
||||
@@ -247,9 +259,29 @@ OpenAI 兼容格式引擎帮助信息
|
||||
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 f"API 调用失败: {response.status_code} - {response.text}"
|
||||
return self._friendly_error(response.status_code, response)
|
||||
else:
|
||||
return f"API 调用失败: {response.status_code} - {response.text}"
|
||||
# 500 等错误:部分服务(如讯飞星火经 one-api 转发)不支持 FC 工具,
|
||||
# 会直接报 500 Invalid Params —— 尝试去掉 tools 降级重试一次
|
||||
lower = str(response.text).lower()
|
||||
if 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:
|
||||
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},请检查服务是否运行"
|
||||
@@ -276,22 +308,20 @@ OpenAI 兼容格式引擎帮助信息
|
||||
|
||||
role = msg["role"]
|
||||
|
||||
# tool 角色消息(FC 工具结果)
|
||||
# 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"]
|
||||
if "name" in msg:
|
||||
tool_msg["name"] = msg["name"]
|
||||
messages.append(tool_msg)
|
||||
continue
|
||||
|
||||
# assistant 消息可能包含 tool_calls
|
||||
# assistant 消息可能包含 tool_calls —— 重建成纯官方结构
|
||||
if role == "assistant" and "tool_calls" in msg:
|
||||
assistant_msg = {
|
||||
"role": "assistant",
|
||||
"content": msg.get("content"),
|
||||
"tool_calls": msg["tool_calls"]
|
||||
"content": msg.get("content") or "",
|
||||
"tool_calls": self._pure_tool_calls(msg["tool_calls"])
|
||||
}
|
||||
messages.append(assistant_msg)
|
||||
continue
|
||||
@@ -372,6 +402,58 @@ OpenAI 兼容格式引擎帮助信息
|
||||
|
||||
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
|
||||
|
||||
+2
-1
@@ -4,7 +4,8 @@
|
||||
"ollama": {
|
||||
"api_key": "",
|
||||
"base_url": "http://localhost:11434",
|
||||
"model": "gemma4:31b"
|
||||
"model": "gemma4:31b",
|
||||
"max_token_k": "<input:请输入模型最大token大小(K)>"
|
||||
},
|
||||
"openai": {
|
||||
"api_key": "your-api-key-here",
|
||||
|
||||
+35
-3
@@ -16,6 +16,13 @@ from pathlib import Path
|
||||
from datetime import datetime
|
||||
import platform
|
||||
|
||||
# <input> 特殊语法:配置模型时可用它交互式询问用户并获取输入
|
||||
try:
|
||||
from xcli_core.config import resolve_input_value
|
||||
except Exception:
|
||||
def resolve_input_value(value, field_name="", default_prompt=None):
|
||||
return value
|
||||
|
||||
# ── 编码设置 ──
|
||||
if sys.stdout.encoding != 'utf-8':
|
||||
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
||||
@@ -471,6 +478,12 @@ class Launcher:
|
||||
entry['base_url'] = 'https://api.deepseek.com/v1'
|
||||
entry['model'] = 'deepseek-chat'
|
||||
config['api']['engines'][name] = entry
|
||||
if name == 'ollama':
|
||||
# 模型最大 token 大小(单位 K,如 32 = 32K = 32768 token)
|
||||
# 默认用 <input> 语法:配置模型时交互式询问用户
|
||||
config['api']['engines'][name].setdefault(
|
||||
'max_token_k', '<input:请输入模型最大token大小(K)>'
|
||||
)
|
||||
|
||||
self.config_file.write_text(json.dumps(config, ensure_ascii=False, indent=2), 'utf-8')
|
||||
return config
|
||||
@@ -498,7 +511,7 @@ class Launcher:
|
||||
info(f'当前密钥: {masked}')
|
||||
if prompt_yn(f'配置 {name} 的 API 密钥?', 'n'):
|
||||
key = self.secret_input(f'{name} API 密钥')
|
||||
cfg['api_key'] = key
|
||||
cfg['api_key'] = resolve_input_value(key, field_name='API 密钥')
|
||||
|
||||
# URL
|
||||
current_url = cfg.get('base_url', '')
|
||||
@@ -506,7 +519,7 @@ class Launcher:
|
||||
info(f'当前 URL: {current_url}')
|
||||
if prompt_yn(f'配置 {name} 的 Base URL?', 'n'):
|
||||
url = prompt('Base URL', current_url)
|
||||
cfg['base_url'] = url
|
||||
cfg['base_url'] = resolve_input_value(url, field_name='Base URL')
|
||||
|
||||
# Model
|
||||
current_model = cfg.get('model', '')
|
||||
@@ -514,7 +527,26 @@ class Launcher:
|
||||
info(f'当前模型: {current_model}')
|
||||
if prompt_yn(f'配置 {name} 的模型?', 'n'):
|
||||
model = prompt('模型名称', current_model)
|
||||
cfg['model'] = model
|
||||
cfg['model'] = resolve_input_value(model, field_name='模型')
|
||||
|
||||
# 最大 token 大小(仅 ollama;单位 K,如 32 = 32K = 32768 token;支持 <input> 语法)
|
||||
if name == 'ollama':
|
||||
current_ctx = cfg.get('max_token_k', '')
|
||||
is_syntax = isinstance(current_ctx, str) and '<input' in current_ctx
|
||||
if is_syntax:
|
||||
info('当前最大 token: (启动时询问)')
|
||||
elif current_ctx:
|
||||
shown = str(current_ctx)
|
||||
info(f'当前最大 token: {shown}K' if shown.replace('.', '', 1).isdigit() else f'当前最大 token: {shown}')
|
||||
else:
|
||||
info('当前最大 token: 未设置')
|
||||
if prompt_yn(f'配置 {name} 的最大 token 大小(K)?', 'n'):
|
||||
default_ctx = '' if is_syntax else str(current_ctx)
|
||||
val = prompt('最大 token (K)', default_ctx)
|
||||
if val.strip():
|
||||
cfg['max_token_k'] = resolve_input_value(val, field_name='最大 token 大小(K)')
|
||||
elif not current_ctx:
|
||||
cfg['max_token_k'] = '<input:请输入模型最大token大小(K)>'
|
||||
|
||||
config.setdefault('api', {}).setdefault('engines', {})[name] = cfg
|
||||
|
||||
|
||||
@@ -795,11 +795,10 @@ multi 操作支持一次修改多处:
|
||||
# 通过插件管理器执行
|
||||
result = self._execute_tool_by_name(tool_name, tool_args)
|
||||
|
||||
# 记录工具结果到历史
|
||||
# 记录工具结果到历史(官方 OpenAI tool 消息不含 name 字段)
|
||||
tool_result_msg = {
|
||||
"role": "tool",
|
||||
"tool_call_id": call_id,
|
||||
"name": tool_name,
|
||||
"content": result[:5000] if result else "执行完成"
|
||||
}
|
||||
self.shared_conversation_history.append(tool_result_msg)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import re
|
||||
import json
|
||||
import logging
|
||||
from colorama import Fore, Style
|
||||
@@ -6,6 +7,39 @@ from colorama import Fore, Style
|
||||
# 配置文件路径
|
||||
CONFIG_FILE = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "config.json")
|
||||
|
||||
# <input> 特殊语法:配置模型时可用它交互式询问用户并获取输入
|
||||
# <input> → 用默认提示询问
|
||||
# <input:提示语> → 用自定义提示语询问
|
||||
_INPUT_PATTERN = re.compile(r"<input(?::([^>]*))?>")
|
||||
|
||||
|
||||
def resolve_input_value(value, field_name="", default_prompt=None):
|
||||
"""解析配置值中的 <input> 特殊语法,交互式询问用户并返回其输入。
|
||||
|
||||
用法(写在 config.json 的字段值里):
|
||||
"model": "<input>" → 启动时提示"请输入model: "
|
||||
"model": "<input:请输入模型名>" → 启动时提示自定义文案
|
||||
也可以夹在字符串中间: "prefix<input>suffix"
|
||||
|
||||
不含 <input> 的值原样返回;用户直接回车/中断时返回空串,由调用方兜底。
|
||||
"""
|
||||
if not isinstance(value, str) or "<input" not in value:
|
||||
return value
|
||||
|
||||
def _ask(match):
|
||||
label = (match.group(1) or "").strip()
|
||||
prompt = label or default_prompt or (f"请输入{field_name}" if field_name else "请输入")
|
||||
prompt = prompt.rstrip()
|
||||
if prompt and not prompt.endswith((":", ":", "?", "?", "!", "!", " ")):
|
||||
prompt += ": "
|
||||
try:
|
||||
answer = input(f"{Fore.CYAN}{prompt}{Style.RESET_ALL}").strip()
|
||||
return answer
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return ""
|
||||
|
||||
return _INPUT_PATTERN.sub(_ask, value)
|
||||
|
||||
|
||||
def load_config():
|
||||
"""加载配置文件"""
|
||||
|
||||
@@ -120,11 +120,11 @@ def fc_result_to_message(tool_call_id: str, tool_name: str, result: str) -> Dict
|
||||
|
||||
Returns:
|
||||
{"role": "tool", "tool_call_id": ..., "content": ...}
|
||||
注意: 官方 OpenAI 的 tool 消息不含 name 字段(严格服务端如讯飞会拒),故不写入。
|
||||
"""
|
||||
return {
|
||||
"role": "tool",
|
||||
"tool_call_id": tool_call_id,
|
||||
"name": tool_name,
|
||||
"content": result[:10000] if result else "" # 截断过长结果
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user