Skip to content

[Feature] 自定义服务端点:自动探测并适配「接口格式」,而不是让用户逐个试 #907

Description

@wxyhaiy

需求

填完「连接地址 + 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 会话头

顺带两个小问题:

  1. 错误文案里出现的是 provider UUID(641d078d-070b-47b1-8fef-feff8d327636 API error (400))而不是服务名 agentrouter,用户难以定位是哪个服务出的问题。
  2. 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)完成,代码片段与仓库源码行号可能不一致。

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions