WorkBuddy 自定义模型接 OpenCode:从一路 400 到稳定可用,全套配置和八个坑

夜里的书房桌面,台灯下合着的笔记本、外接显示器、一杯喝剩的茶和散乱的线缆
折腾到后半夜,键盘上那块 U 盘一直没拔——里面是改到第七版的配置。

先说这事的起点

我 WorkBuddy 用了快一年,前阵子又订了个模型服务,两边都是 OpenAI 兼容接口,按理说接一下就能用。结果从点下「添加自定义模型」那一刻起,报错就没停过。

先是 400,改成能返 200 之后开始「模型服务未返回有效响应」,再修好变成转圈十七秒然后弹一句「用户已取消」,最后稳定在两秒内返回一个 400 messages[1].skipRun。

每一个报错看着都像网络问题,实际上一个都不是。折腾到凌晨才彻底通,中间自己写过脚本、换过三种网关。这篇把整条路写下来:先讲清楚为什么直接填不行,再给一套能抄的配置,最后七个坑按症状排开,遇到哪个查哪个。

先说明一句:下面所有操作的前提是你有自己合法的订阅账号。这套东西是让「你已经付过钱的额度」能在另一个客户端里用起来,不涉及任何绕过,也不要去借别人的 Key——那既不安全也没意义。

WorkBuddy 的自定义模型,只能填三样东西

入口在头像 → 系统设置 → 模型 → 自定义模型。点「添加自定义模型」,你能填的就三格:接口地址、API Key、模型 ID。下面四个开关:工具调用、图片输入、思考模式、自定义协议。

没有「自定义请求头」这一栏。

这就是全部麻烦的源头。如果你接的是 OpenAI 官方、DeepSeek 官方那种只认 Authorization 的服务,三格填完就完事,一秒钟都不用多花。但有些服务除了 Key,还要求带一个额外的头——比如会话标识——客户端没地方填,你就卡在这了。

我第一次踩的就是这个。填完地址和 Key,点测试连接,返回 400 MissingSessionID。

动手之前,先用 curl 确认上游到底要什么

别急着装东西。打开命令行,把下面两条都跑一遍,看清楚差别在哪。这是我后来养成的习惯——先证明问题存在,再动手解决,能省掉一大半瞎试的时间。

# 第一条:只带 Key
curl -sS -m 30 "https://你的上游地址/v1/chat/completions" 
  -H "Authorization: Bearer 你的Key" 
  -H "Content-Type: application/json" 
  -d '{"model":"模型ID","messages":[{"role":"user","content":"hi"}],"max_tokens":16}'

# 第二条:额外带一个会话头
curl -sS -m 30 "https://你的上游地址/v1/chat/completions" 
  -H "Authorization: Bearer 你的Key" 
  -H "Content-Type: application/json" 
  -H "x-opencode-session: 7f3a1c2e-4b5d-4e6f-8a9b-0c1d2e3f4a5b" 
  -d '{"model":"模型ID","messages":[{"role":"user","content":"hi"}],"max_tokens":16}'

如果第一条报错、第二条正常(我这边就是 MissingSessionID 对 200),那结论很明确:必须有个地方帮你把这个头补上,而 WorkBuddy 自己补不了。顺便提一句,这个头传空值也会被拒,不是「有就行」,得是有效格式。

顺带说一个我当时试错浪费时间的点:同一家服务往往有好几个兼容端点,长得像但计费口径完全不同。我把 /inference/go/openai/v1 写成过不带 /openai 的版本,结果拿到 402 余额不足,以为是订阅没生效,查了半天。端点多试几个,但别在报错 402 的时候怀疑账号。

三条路,我为什么选了最土的那条

能想到的做法有三种。

一是直接填。不通,就是上面那个原因,连试的价值都没有。

二是找现成的客户端插件。我去翻过几个社区插件仓库,把跟这个订阅相关的都看了一遍,结论是没有——仓库里确实有同名服务的插件,但那是另一条产品线的免费额度,不是订阅本身。这类东西名字很像,别看着关键词就装。

三是本机放一个转发层。WorkBuddy 连本机,本机补上头再发出去。可行,也是我最后用的。

但这里有个岔路:转发层自己写,还是用现成的服务器软件。

我先自己写了一个 Python 的,跑起来了,也确实能返回结果。然后踩了两个坑,都不是配置能救的:

  • 进程活不长。跟着终端窗口走,窗口一关它就没了,第二天打开 WorkBuddy 全是连接失败。
  • 流式被它自己吃掉了。我一开始用 subprocess.run(capture_output=True) 转发,这个写法必须等上游把整段响应吐完、进程退出,才一次性返回。客户端等的是第一块数据,十七秒过去什么都没收到,它判定超时,弹「用户已取消」。

第二个坑特别隐蔽。你在命令行里 curl 测试一切正常,因为 curl 本来就在等最终结果;但界面里是流式的,等的节奏完全不一样。

所以我换成了 nginx。理由很实在:Windows 版解压即用、自带后台化没有黑窗口、改完配置 reload 就生效、开机自启一条注册表就行。它不用「写代码」,只需要写配置。流式转发它本来就是干这个的。

一台小型交换机放在木架上,几根网线插在端口里,链路指示灯亮着
看着是多了个中间环节,其实这一跳就在本机回环里,不出网卡。

配置:两个文件,能直接抄

去 nginx 官网下 Windows 稳定版压缩包,解压到你想放的目录,比如 C:gatewaynginx。不要下第三方「绿色版」「集成包」,那些里面塞了什么你不知道。

然后改 conf 目录下两个文件。第一个是密钥和转发参数,我单独拆成一个文件,方便它不进版本库、也方便单独收权限:

# conf/proxy_params.conf
proxy_ssl_server_name on;
proxy_set_header Host opencode.ai;
proxy_set_header Authorization "Bearer 你的Key填这里";
proxy_set_header x-opencode-session $oc_session;
proxy_set_header Accept-Encoding "";
proxy_set_header User-Agent "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36";
proxy_buffering off;
proxy_cache off;
proxy_request_buffering off;
proxy_socket_keepalive on;
proxy_connect_timeout 30s;
proxy_send_timeout 600s;
proxy_read_timeout 600s;

第二个是主配置。核心是那个 map——把 WorkBuddy 自带的会话 ID 拿过来当上游要的会话头,缺了就回落到一个固定值:

worker_processes 1;
error_log logs/error.log warn;
pid logs/nginx.pid;

events {
    worker_connections 256;
}

http {
    access_log logs/access.log;
    client_max_body_size 64m;
    proxy_http_version 1.1;

    # 客户端没带会话头时用一个固定值兜底
    map $http_x_conversation_id $oc_session {
        default $http_x_conversation_id;
        ""      "c1a7e5f0-2d84-4b6a-9e13-5f7b8c9d0a21";
    }

    server {
        listen 127.0.0.1:18790;
        server_name _;

        location = /health {
            access_log off;
            return 200 "okn";
        }

        location / {
            proxy_pass https://opencode.ai/inference/go/openai/;
            include proxy_params.conf;
        }
    }
}

几个参数值得单独说,都是踩出来的:

  • map $http_x_conversation_id:WorkBuddy 每个对话都会带一个 X-Conversation-ID,它天然满足「同一对话固定、不同对话不同」,拿它当会话头最合适。这直接关系到费用——上游有提示词缓存,会话 ID 稳定才能命中,缓存命中和不命中差的钱不是一点。
  • proxy_buffering off 加 proxy_cache off:不关掉,nginx 会把流式响应攒着再发,又回到「十七秒没反应」。
  • proxy_request_buffering off:请求体大时(我的会话历史一度到 46 万字节),nginx 默认会先写临时文件再转发,首包就慢。关掉它。
  • 固定 User-Agent:这个我后面单独讲,是第七个坑之前的那个 403。
  • proxy_read_timeout 600s:推理模型长上下文能跑很久,别用默认的 60 秒。
  • Accept-Encoding "":避免上游压缩,压缩加上缓冲,流式就更不实时了。

改完先校验语法,再启动:

nginx.exe -p C:/gateway/nginx -t
nginx.exe -p C:/gateway/nginx

看到 syntax is ok 和 test is successful 就对了。然后开个命令行验证一下,这一步别跳过:

curl -sS -m 5 "http://127.0.0.1:18790/health"
# 期望输出:ok

开机自启就一条注册表,指向 nginx 本体,不要指向 bat 或 vbs——中间多一层就多一个可能被安全软件拦的地方:

reg add "HKCUSoftwareMicrosoftWindowsCurrentVersionRun" /v MyGateway ^
  /t REG_SZ /d ""C:gatewaynginxnginx.exe" -p "C:gatewaynginx"" /f

WorkBuddy 这边怎么填

回到「添加自定义模型」,三格这么填:

  • 接口地址:http://127.0.0.1:18790/v1。填到 /v1 就停,后面的 /chat/completions 由客户端自己补。这时候「自定义协议」不要开,开了它会原样用你填的地址,容易拼出 /v1/v1/chat/completions 这种。
  • API Key:填一个占位值,比如 local-gateway。真正的 Key 在网关那个配置文件里,不放进客户端——客户端会把 Key 写进本地配置、日志、可能还有报错信息里,能不放就不放。注意不能留空,有的版本空 Key 直接不让保存。
  • 模型 ID:必须是上游认得的那串字符,大小写敏感。填 deepseek-v4.1-flash 和 DeepSeek-V4.1-Flash 是两个东西。

四个开关我的建议:

  • 工具调用:开。不开的话 WorkBuddy 里所有需要执行动作的功能都用不了,模型只能聊天。
  • 图片输入:关。文本模型开了会报参数错误,除非你确认那个模型真能吃图。
  • 思考模式:看你接的模型。推理模型建议开,但要注意有的上游不认 thinking 这类字段,会直接 400。
  • 自定义协议:关。除非你的网关确实要求完整路径。

还有两个数字藏在 models.json 里,界面上没有:contextWindow 和 maxTokens。不填它会用默认值,默认值偏小,你会感觉「稍微长一点的对话就被压缩了」。

这里有个反直觉的地方:contextWindow 不要填宣传页上的最大值。我一开始给某个模型填了 100 万,结果每次请求都特别慢——客户端以为能塞这么多,把历史一路堆上去,模型处理时间跟着涨,反而更容易超时。填它实际能稳定跑的值,比填上限有用。

maxTokens 也一样,有些模型标称能输出几十万 token,但按那个值下发会直接被上游拒。我统一压到 65536,稳一点。

七个坑,按症状查

先列这七个,第八个跟开机自启有关,放在后面单独讲——它的症状和前面几个完全不同。

坑 1:400 MissingSessionID

原因就是开头说的,缺会话头。解法是把网关的 proxy_set_header x-opencode-session 加上。注意空值也报错,不是「传了就过」。

坑 2:测试连接能过,一用就「模型服务未返回有效响应」

这个提示很有欺骗性,看着像网络断了,其实是响应头不对。

流式请求的响应必须是 Content-Type: text/event-stream,如果你在转发层写死了 application/json,客户端按 SSE 解析就解析不出来,报「无有效响应」。nginx 默认透传上游的 Content-Type,所以用 nginx 反而不会踩这个坑——我是在自己写的脚本里踩的。

坑 3:转圈十几秒,然后「用户已取消」

注意这个提示:它不是你点的取消,是客户端等太久自己掐的。

根因是流式没有真的流起来——响应被攒在中间某一层,等全部生成完才发出去。推理模型首字本来就要三到十几秒,攒到二十秒以上必然触发。检查三个地方:proxy_buffering off、转发层有没有用「等进程结束再返回」的写法、有没有开压缩。

坑 4:额度掉得比预期快

如果你每次都随机生成会话 ID,上游的提示词缓存永远不命中,每次都是全量计费。换成透传 X-Conversation-ID 之后,同一对话的长历史只算一次的钱。

这一点对长上下文场景影响很大,尤其是我这种动辄几十万字节会话历史的用法。

坑 5:上游返回 403,但 curl 正常

这个问题我卡了很久。同一个请求,命令行跑 200,网关转发就 403。

后来发现是 User-Agent。上游对某些默认 UA(比如脚本库自带的 Python-urllib/3.13)会拦。网关会把客户端的 UA 原样带上去,客户端换一个 UA 就跟着出问题。解法是在网关里固定成一个常见浏览器的 UA,别让它随客户端漂。

这也是为什么我在配置里写死了那一行——看着没道理,实际上是让上游看到的东西保持稳定。

坑 6:请求体一大,首包就慢

日志里会看到类似 a client request body is buffered to a temporary file 的告警。nginx 默认把请求体先落盘再转发,几十万字节的时候这一步很明显。加 proxy_request_buffering off。

坑 7:400 messages[1].skipRun,换模型也没用

这是最难的一个,也是唯一一个「配置层面解决不了」的。

完整报错大概是这个意思:cannot preserve this message field across protocols; stateful assistant history requires matching provider_metadata。翻译成人话:请求里某条历史消息带了上游不认识的字段,上游严格校验,直接拒收。

关键在于这个字段是怎么来的。WorkBuddy 会在会话历史里存一些内部标记,比如某条消息是被中断的、是压缩产生的、是系统插入的。正常用它自己的模型时,这些标记在它自己的协议里是合法的;一旦你切成第三方模型,它把这批消息原样序列化发出去,标记就跟着进了 messages 数组。

更麻烦的是它的触发条件:只要这个会话里出现过一次「用户已取消」,那条消息就会被永久标记下来,之后每次请求都带着它。所以表现是——新建对话能用,用着用着突然开始一直报同一个 400,重试、换模型、重启客户端全都没用。

解法是让网关在转发前把请求体洗一遍:消息里只留标准字段,content 如果是数组就压平成字符串,顶层的非标准参数也去掉。

nginx 改不了请求体(官方 Windows 版没编 njs,也不支持动态模块),所以我在这层前面加了一个小服务。用 Node 写,不装任何依赖,一百多行,只做字段过滤和透传:

const http = require('http');

// 上游 openai-chat 目标只认这些字段
const ALLOWED_MSG = new Set(['role','content','name','tool_calls','tool_call_id','refusal']);
const ALLOWED_TOP = new Set(['model','messages','stream','stream_options','temperature',
  'top_p','max_tokens','max_completion_tokens','stop','tools','tool_choice',
  'parallel_tool_calls','response_format','seed','presence_penalty',
  'frequency_penalty','n','logit_bias','user','reasoning_effort','service_tier']);

// 把 Responses 风格的内容数组压平成字符串
function flatten(c) {
  if (c == null) return c;
  if (typeof c === 'string') return c;
  if (Array.isArray(c)) {
    return c.map(p => (p && typeof p === 'object')
      ? (typeof p.text === 'string' ? p.text
        : (Array.isArray(p.content) ? flatten(p.content) : ''))
      : String(p || '')).join('');
  }
  return String(c);
}

function clean(obj) {
  const out = {};
  for (const k of Object.keys(obj)) if (ALLOWED_TOP.has(k)) out[k] = obj[k];
  if (Array.isArray(out.messages)) {
    out.messages = out.messages.map(m => {
      const msg = {};
      for (const k of Object.keys(m)) if (ALLOWED_MSG.has(k)) msg[k] = m[k];
      const hasTools = Array.isArray(msg.tool_calls) && msg.tool_calls.length > 0;
      if (msg.content === undefined || msg.content === null) {
        if (!hasTools) msg.content = '';
      } else {
        msg.content = flatten(msg.content);
        if (msg.content === null && !hasTools) msg.content = '';
      }
      return msg;
    });
  }
  return out;
}

http.createServer((req, res) => {
  const buf = [];
  req.on('data', c => buf.push(c));
  req.on('end', () => {
    let body = Buffer.concat(buf);
    // 解析失败就原样转发,绝不吞请求
    if (/chat/completions|responses/.test(req.url) && body.length) {
      try { body = Buffer.from(JSON.stringify(clean(JSON.parse(body.toString('utf8'))))); }
      catch (e) { /* keep original */ }
    }
    const headers = {};
    for (const [k, v] of Object.entries(req.headers)) {
      const lk = k.toLowerCase();
      if (lk === 'host' || lk === 'content-length' || lk === 'accept-encoding') continue;
      headers[k] = v;
    }
    headers['content-length'] = String(body.length);

    const up = http.request({ host:'127.0.0.1', port:18792, path:req.url,
      method:req.method, headers }, upres => {
      res.writeHead(upres.statusCode || 502, upres.headers);
      upres.pipe(res);                       // 边收边转,不缓冲
    });
    up.setTimeout(660000, () => up.destroy());
    up.on('error', () => {
      if (!res.headersSent) { res.writeHead(502); res.end('{"error":"upstream"}'); }
    });
    up.end(body);
  });
}).listen(18790, '127.0.0.1');

结构就是:WorkBuddy → 清洗层 127.0.0.1:18790 → nginx 127.0.0.1:18792 → 上游。清洗层不碰 Key,只管字段;nginx 只管注入头和转发。

我一开始想图省事就把 nginx 直接监听 18790,后来觉得分开更清楚:清洗层出了问题可以单独停掉、单独看日志;Key 也只存在 nginx 那一处,不会散落在两处。

这一层加不加,取决于你接的上游严不严格。有些上游对多余字段是忽略的,那就用不上;撞上坑 7 了再加。

让它开机自己活过来

前面配完,关掉窗口还能用。但电脑一重启,八成会失效——不是配置丢了,是那个转发进程没起来。这一段是我花时间最多的地方,因为它不报错,只是安静地不工作。

自启要指向服务本体,不要指向批处理

很多人(包括我)第一反应是写个 .bat,把启动命令塞进去,再让开机自启指向这个 bat。这条路能通,但中间多一层就多一个死法:bat 里的 start /b 会让子进程继承当前控制台,窗口一关进程跟着走;有些安全软件还会把 bat 当可疑脚本拦掉。

直接指向 exe 最省事:

reg add "HKCUSoftwareMicrosoftWindowsCurrentVersionRun" /v MyGateway ^
  /t REG_SZ /d ""C://gateway//nginx//nginx.exe//" -p "C://gateway//nginx//"" /f

nginx 在 Windows 下启动后会自己转入后台,所以这条命令执行完,进程留在后台,桌面上没有窗口。

第八个坑:重启后一切正常,但所有模型都报「网络异常」

这个坑让我卡了两天。现象是:重启电脑,打开客户端,所有自定义模型全部连不上;可是手动敲命令启动 nginx,它又能跑。

原因是 nginx 启动时第一件事就是打开错误日志文件。我之前为了收紧密钥文件的权限,顺手给整个目录做了递归重置,结果把 logs 目录里几个日志文件的访问控制列表清空了——文件还在,但谁都读不了、也写不了。nginx 打开失败就直接退出,而且因为没有日志可写,它连一句提示都不给。

排查的关键是:不要停在「进程没起来」这种笼统判断上,要去挖它为什么退出。用 -e 参数把错误日志指到一个确定可写的位置,问题立刻现形,还能顺手当成长期方案:

nginx.exe -p C://gateway//nginx -e C://gateway//logs2//error.log

自启命令里也要带上这个参数,否则重启后还是老样子。

教训很直白:动权限的时候只动那个文件,不要对整个目录做递归重置。密钥文件需要收紧就单独收紧,别把同目录的日志一起带走。

别让它在桌面上留一个黑窗口

自己写的转发层通常是个控制台程序,开机自启后会留一个黑窗口。不影响功能,但很难看,而且手滑关掉就断线。

Windows 有个现成的办法,用 conhost 的无头模式跑:

conhost.exe --headless "C://你的路径//node.exe" "C://你的路径//sanitizer.js"

跑起来之后进程在、窗口没有,任务栏也干净。验证方法很简单:看这个进程的主窗口句柄是不是 0。

留一个应急开关

再稳的方案也会遇到进程被杀——安全软件清理、手动结束、更新重启。这种时候不该让人去翻命令历史。

我做了个批处理,只干三件事:查端口有没有在听、缺了哪个补哪个、最后跑一次本地健康检查。全部显示 OK 就关窗口走人。

写的时候有两个小坑。一是脚本里别写中文,cmd 的编码很容易把中文变成乱码命令,导致整个脚本错乱,全用英文最稳。二是启动子进程不要用 start /b,删掉 /b 或者用最小化窗口的方式,否则主窗口一关,服务跟着走。

重启之后花十秒钟验证

开机登录后别急着发消息,等十秒左右,让两个进程把端口监听起来。然后随便发一句话:

  • 有回复,说明整条链路能自愈,以后不用管它;
  • 报「网络异常,正在重连中」,就是服务没起来,跑一遍应急脚本;
  • 能连上但报 400,去看日志里的状态码,多半又回到前面那几个坑。

怎么确认真的通了

「保存成功」不算通,「测试连接通过」也只是一次性验证。我当时就是测试通过、一用就崩。下面六项都过一遍,才算真的能用:

  1. 非流式能出内容:命令行发一条,看返回里的 choices[0].message.content 是不是真的文字,不是空字符串。
  2. 流式有结尾:加 "stream":true,最后一行必须是 data: [DONE]。缺这一行,界面会一直转圈。
  3. 首字节够快:正常应该在五秒内。超过十秒就要查是不是又被缓冲了。
  4. 三轮上下文能记住:第一轮让它记住一个暗号,第三轮再问。记不住说明历史没带上去。
  5. 工具调用单独测:在请求里带一个 tools 定义,看返回里有没有 tool_calls。这一项不能拿「能聊天」代替——很多模型能聊天但不能调工具。
  6. 重启之后还在:重启电脑,直接打开客户端发消息。这一条能验证开机自启到底配没配对。

我最后是自己写了个小脚本,把这些项一次性跑完的。手点太累,而且容易漏。九个模型挨个过一遍,三分钟,比在界面里一个个试快得多。

这套方案不适合什么

只有优点没有边界的教程不靠谱,说几个它确实做不到的:

  • 本机得常驻一个进程。网关停了,所有自定义模型一起失效,报「网络异常,正在重连中」。这不是 bug,是架构决定的。所以开机自启一定要配,而且要指向服务本体,不要指向批处理——中间层越多,被安全软件拦的概率越大。
  • 换台电脑要重配。这套东西是本地的,没有同步。笔记本和台式机各配一遍。
  • 上游改规则你得跟着改。我这一路撞的七个坑,有五个是上游的校验变严导致的。所以日志一定要留:每条请求记下时间、模型、耗时、状态码、丢了哪些字段。出问题第一件事是看日志,不是重装。
  • 能力别乱勾。图片、推理这些开关,上游支持什么才勾什么。乱勾的表现不是报错,是「有时好有时坏」,最难查。
  • 不要拿它做量。订阅有额度,缓存策略、并发限制都是按官方客户端的用法设计的。挂个批量任务跑通宵,很可能第二天就限额了。

最后补一句感受。这件事从头到尾没有一处是「技术很难」,全是「信息不全」——客户端不支持自定义头、上游强制某个头、中间层默认会缓冲、上游会按 UA 拦、历史里的内部字段会跟着请求发出去。每一条单独拿出来都能查,凑在一起就很折磨。

所以这篇把顺序写清楚了:先用 curl 确认上游要什么,再决定用哪一层,最后按症状查坑。先证明再动手,比装一堆东西再一个个排除要快得多。

WorkBuddy 系列其他文章

THE END