需求
填完「连接地址 + API 密钥」之后,让应用自己探出该端点该用哪种「接口格式」,并在会话过程中按模型 / 按错误响应自动适配 —— 而不是让用户对着 Chat Completions / Responses / Anthropic / Google / OpenCode Go 逐个试。
一句话:接口格式应该是被探测出来的结果,而不是必须由用户猜对的输入。
现状:用户被迫手工试错(真实案例)
同一个 new-api 风格中转站,同一个 https://<relay-host>/v1,我不得不在 PI-Desktop 里建了两个服务才能把不同模型跑起来:
| 服务名 |
连接地址 |
接口格式 |
结果 |
| 自己的服务 |
https://<relay-host>/v1 |
OpenAI Chat Completions |
该服务下多数模型可用 |
| agentrouter |
https://<relay-host>/v1 |
Responses |
该服务下另一批模型可用 |
(截图即 agentrouter 的「编辑 AI 服务」对话框:连接地址填中转站 /v1,接口格式下拉停在 Responses。)
而这还只是第一层。同一个服务下不同模型还会撞上第三个维度:
gpt-5.6-luna 走 Chat Completions 时报
400: {"message":"Request is missing x-opencode-session ...","type":"MissingSessionID"}
- 同期同服务商的
deepseek-v4-flash 一切正常。
要修这个,还得再把接口格式改成 OpenCode Go(详见下文「为什么现在的机制必然导致试错」第 2 点)。
也就是说,接入一个中转站,用户要同时猜对:接口格式 × 每个模型是否属于特殊上游 × 要不要额外的会话头。这三层全在一个下拉框和一张请求头表里,没有任何提示。
日志证据
logs/app/provider.log —— 模型列表探测失败的三种不同形态,但 UI 都只给一句笼统的错:
{"event":"model.discovery.failed","data":{"providerId":"e53a9c38-...","error":"Unexpected token '<', \"<!doctype \"... is not valid JSON"}}
{"event":"model.discovery.failed","data":{"error":"model list request failed (401)"}}
第一类(出现 3 次)意味着端点返回了 HTML 首页而不是 JSON —— 这是「地址或协议不对」的典型信号,但用户看到的只是一句 JSON 解析错误,无从判断该改什么。
logs/app/session.log —— 真正的协议/报文装配不匹配,全被当成同一种 PROVIDER_ERROR 抛给用户:
| 次数 |
上游返回 |
说明 |
| 3 |
400: Invalid schema for function 'Read': null is not of type "array" |
工具 schema 序列化方式与所选协议不符 |
| 1 |
400: The content[].thinking in the thinking mode must be passed back to the API |
reasoning 字段回传方式不符 |
| 4 |
641d078d-... API error (400): Duplicate tool output for call_id: ... |
全部发生在 Responses 那个服务上,多轮工具结果的装配方式不符 |
| 2 |
400: ... MissingSessionID |
需要 OpenCode 会话头 |
顺带两个小问题:
- 错误文案里出现的是 provider UUID(
641d078d-070b-47b1-8fef-feff8d327636 API error (400))而不是服务名 agentrouter,用户难以定位是哪个服务出的问题。
Invalid schema for function 'Read' 这类报文装配差异,本该是应用内部可自愈的(换一种序列化再试一次),现在直接变成用户的死路。
为什么现在的机制必然导致试错
1. 协议是"用户声明的",不是"端点证明的"
「接口格式」纯粹是用户手选的一个声明值,应用从不去验证这个声明和端点是否自洽。选错的表现却是千奇百怪的上游 400,而不是"这个格式不对,我帮你换一个试试"。
2. OpenCode 端点只能靠用户猜
应用其实已经具备注入 x-opencode-session / x-opencode-client 的能力,但识别条件过于严格:
function isOpenCodeEndpoint(input) {
if (input.apiStyle === "opencode_go") return true;
if (vendorKey === "opencode" || vendorKey === "opencode-go") return true;
if (input.model?.provider === "opencode" || input.model?.provider === "opencode-go") return true;
return isOpenCodeHost(input.baseUrl); // 仅 opencode.ai / *.opencode.ai
}
中转站的域名永远不是 opencode.ai,vendorKey 也永远是 custom —— 于是即使上游就是 OpenCode Zen,这个头也永远不注入。
更棘手的是:用户也不能手填这个头来绕过,它在保留头清单里被明确禁止:
const FORBIDDEN_HEADER_KEYS = new Set([..., "x-opencode-session"]);
于是唯一的解法是"把接口格式改成 OpenCode Go"—— 而 apiBindingForStyle("opencode_go") 走的其实还是 openai-completions 适配器,跟 chat_completions 同一条代码路径,只多了几个头。这个开关的语义是"请按 OpenCode 端点对待我",而不是"换一种协议",但界面上没有任何东西能让人悟到这一点。
3. 用户已经在用"复制配置"当绕道
#152 关闭后提供了「复制配置」,本意是好的,但它把"同一个站点要用两种协议"这件事固化成必须建两个服务。这等于把探测的负担从应用转移给了用户:建两个、各填一遍密钥、各勾一次模型。
建议实现
A. 端点协议自动探测(核心)
在「测试连接」和首次保存时,用最小请求体(例如 1 个 token、max_tokens: 1)并行探测候选协议,按返回码打分:
POST {base}/chat/completions(openai-completions)
POST {base}/responses(openai-responses)
POST {base}/messages + anthropic-version: 2023-06-01 + x-api-key(anthropic_messages)
{base}/models(判断鉴权是否可用,以及端点是否返回 JSON)
- Google:
{base}/models?key=
把胜利者填进「接口格式」,UI 标注为"自动识别",并保留手动覆盖。探测结果可缓存,不必每次连接都跑。
「连接地址」也顺便归一化:用户可能填 .../v1、.../v1/chat/completions、.../v1/responses,应自动识别并回退到 base。代码里已有 ["/chat/completions","/models"]、["/messages","/models"]、["/responses","/models"] 这类线索清单,可以直接复用。
B. 错误驱动的自动适配(按需)
不必一开始就全自动,但至少把上游错误签名映射成"建议切换",并在错误横幅上给一键切换:
| 上游特征 |
建议动作 |
MissingSessionID / x-opencode-session |
启用 OpenCode 会话头(等价于切到 opencode_go 语义) |
返回 <!doctype / 非 JSON |
当前地址或协议不对,重跑探测 A |
Invalid schema for function ... null is not of type "array" |
换一种工具 schema 序列化重试,或提示切换协议 |
Duplicate tool output for call_id |
多轮工具结果装配方式与当前协议不符 |
content[].thinking ... must be passed back |
reasoning 回传方式不符 |
model not found / 404 |
该模型不属于这条通道,建议按模型切换协议 |
C. 按模型记忆协议
同一 base URL 下的不同模型可能属于不同上游。建议允许 per-model 覆盖「接口格式」,并记住每个模型最终成功的那一种 —— 这样 gpt-5.6-luna 与 deepseek-v4-flash 就能共用一个服务,不必分裂成两个。
D. 顺带修的小事
- 错误信息用服务名而不是 provider UUID。
- 模型探测失败时,把
Unexpected token '<' 翻译成"端点返回了 HTML(可能是站点首页),请检查连接地址或接口格式"。
相关 issue
环境
- PI-Desktop 0.15.3(Windows 11 专业版 10.0.26200 x64,NSIS 安装版)
- 中转站类型:
new-api 风格(第三方托管,非自建)
- 涉及的接口格式:
OpenAI Chat Completions、Responses,以及需要 opencode_go 才能拿到的 x-opencode-session 注入
- 截图可提供(已隐去密钥);本文字为便于公开已把中转站域名写成
<relay-host>
说明:日志取证与代码定位均由 AI 依据本机运行日志 + 0.15.3 安装包产物(resources/app.asar)完成,代码片段与仓库源码行号可能不一致。
需求
填完「连接地址 + API 密钥」之后,让应用自己探出该端点该用哪种「接口格式」,并在会话过程中按模型 / 按错误响应自动适配 —— 而不是让用户对着
Chat Completions / Responses / Anthropic / Google / OpenCode Go逐个试。一句话:接口格式应该是被探测出来的结果,而不是必须由用户猜对的输入。
现状:用户被迫手工试错(真实案例)
同一个
new-api风格中转站,同一个https://<relay-host>/v1,我不得不在 PI-Desktop 里建了两个服务才能把不同模型跑起来:https://<relay-host>/v1https://<relay-host>/v1(截图即
agentrouter的「编辑 AI 服务」对话框:连接地址填中转站/v1,接口格式下拉停在Responses。)而这还只是第一层。同一个服务下不同模型还会撞上第三个维度:
gpt-5.6-luna走 Chat Completions 时报400: {"message":"Request is missing x-opencode-session ...","type":"MissingSessionID"}deepseek-v4-flash一切正常。要修这个,还得再把接口格式改成 OpenCode Go(详见下文「为什么现在的机制必然导致试错」第 2 点)。
也就是说,接入一个中转站,用户要同时猜对:接口格式 × 每个模型是否属于特殊上游 × 要不要额外的会话头。这三层全在一个下拉框和一张请求头表里,没有任何提示。
日志证据
logs/app/provider.log—— 模型列表探测失败的三种不同形态,但 UI 都只给一句笼统的错:{"event":"model.discovery.failed","data":{"providerId":"e53a9c38-...","error":"Unexpected token '<', \"<!doctype \"... is not valid JSON"}} {"event":"model.discovery.failed","data":{"error":"model list request failed (401)"}}第一类(出现 3 次)意味着端点返回了 HTML 首页而不是 JSON —— 这是「地址或协议不对」的典型信号,但用户看到的只是一句 JSON 解析错误,无从判断该改什么。
logs/app/session.log—— 真正的协议/报文装配不匹配,全被当成同一种PROVIDER_ERROR抛给用户:400: Invalid schema for function 'Read': null is not of type "array"400: The content[].thinking in the thinking mode must be passed back to the API641d078d-... API error (400): Duplicate tool output for call_id: ...400: ... MissingSessionID顺带两个小问题:
641d078d-070b-47b1-8fef-feff8d327636 API error (400))而不是服务名agentrouter,用户难以定位是哪个服务出的问题。Invalid schema for function 'Read'这类报文装配差异,本该是应用内部可自愈的(换一种序列化再试一次),现在直接变成用户的死路。为什么现在的机制必然导致试错
1. 协议是"用户声明的",不是"端点证明的"
「接口格式」纯粹是用户手选的一个声明值,应用从不去验证这个声明和端点是否自洽。选错的表现却是千奇百怪的上游 400,而不是"这个格式不对,我帮你换一个试试"。
2. OpenCode 端点只能靠用户猜
应用其实已经具备注入
x-opencode-session/x-opencode-client的能力,但识别条件过于严格:中转站的域名永远不是
opencode.ai,vendorKey也永远是custom—— 于是即使上游就是 OpenCode Zen,这个头也永远不注入。更棘手的是:用户也不能手填这个头来绕过,它在保留头清单里被明确禁止:
于是唯一的解法是"把接口格式改成 OpenCode Go"—— 而
apiBindingForStyle("opencode_go")走的其实还是openai-completions适配器,跟chat_completions同一条代码路径,只多了几个头。这个开关的语义是"请按 OpenCode 端点对待我",而不是"换一种协议",但界面上没有任何东西能让人悟到这一点。3. 用户已经在用"复制配置"当绕道
#152 关闭后提供了「复制配置」,本意是好的,但它把"同一个站点要用两种协议"这件事固化成必须建两个服务。这等于把探测的负担从应用转移给了用户:建两个、各填一遍密钥、各勾一次模型。
建议实现
A. 端点协议自动探测(核心)
在「测试连接」和首次保存时,用最小请求体(例如
1个 token、max_tokens: 1)并行探测候选协议,按返回码打分:POST {base}/chat/completions(openai-completions)POST {base}/responses(openai-responses)POST {base}/messages+anthropic-version: 2023-06-01+x-api-key(anthropic_messages){base}/models(判断鉴权是否可用,以及端点是否返回 JSON){base}/models?key=把胜利者填进「接口格式」,UI 标注为"自动识别",并保留手动覆盖。探测结果可缓存,不必每次连接都跑。
「连接地址」也顺便归一化:用户可能填
.../v1、.../v1/chat/completions、.../v1/responses,应自动识别并回退到 base。代码里已有["/chat/completions","/models"]、["/messages","/models"]、["/responses","/models"]这类线索清单,可以直接复用。B. 错误驱动的自动适配(按需)
不必一开始就全自动,但至少把上游错误签名映射成"建议切换",并在错误横幅上给一键切换:
MissingSessionID/x-opencode-sessionopencode_go语义)<!doctype/ 非 JSONInvalid schema for function ... null is not of type "array"Duplicate tool output for call_idcontent[].thinking ... must be passed backmodel not found/404C. 按模型记忆协议
同一 base URL 下的不同模型可能属于不同上游。建议允许 per-model 覆盖「接口格式」,并记住每个模型最终成功的那一种 —— 这样
gpt-5.6-luna与deepseek-v4-flash就能共用一个服务,不必分裂成两个。D. 顺带修的小事
Unexpected token '<'翻译成"端点返回了 HTML(可能是站点首页),请检查连接地址或接口格式"。相关 issue
环境
new-api风格(第三方托管,非自建)OpenAI Chat Completions、Responses,以及需要opencode_go才能拿到的x-opencode-session注入<relay-host>