问题现象与根源分析当你在 OpenCode 工具中调用模型时如果遇到Expected id to be a string错误问题通常不在于模型名称本身例如gpt-5.4而在于上游的 OpenAI-compatible 网关返回了不符合规范的tool_calls[].id字段。错误根源某些第三方 OpenAI 兼容网关在返回工具调用响应时将tool_calls[].id字段设置为数字类型numeric而 AI SDK 的 schema 校验要求该字段必须是字符串类型string。当 OpenCode 接收到数值类型的 id 时就会抛出Expected id to be a string错误。三种解决方案对比面对这个问题你有三条路径可以选择方案一修改上游网关从源头解决问题要求上游服务提供商按照 OpenAI 官方规范返回字符串类型的 id。优点一劳永逸符合标准缺点依赖第三方配合不可控方案二修改 OpenCode 本体在 OpenCode 的 provider fetch 包装层进行兼容性处理将数字 id 自动转换为字符串。优点对用户透明无需额外配置缺点需要修改 OpenCode 核心代码涉及版本维护方案三添加本地代理层推荐在 OpenCode 前面增加一层本地代理服务器动态修复请求和响应中的 id 字段。优点非侵入式不修改上游或 OpenCode对标准提供商无副作用比在每个模型配置中单独绕开更稳定比禁用 tool_call 更合理不会损失工具调用能力推荐修复方案本地代理实现核心修复思路在 OpenCode 处理ai-sdk/openai-compatible响应时拦截 JSON 数据将相关字段统一转换为字符串非流式响应tool_calls[].id流式响应choices[].delta.tool_calls[].id核心转换逻辑if(typeoftoolCall.idnumber){toolCall.idString(toolCall.id);}完整代理实现代码以下是一个完整的 Node.js 代理服务器实现可以部署在本地自动修复 id 类型问题importhttpfromnode:http;importhttpsfromnode:https;importfsfromnode:fs;importpathfromnode:path;importcryptofromnode:crypto;import{URL}fromnode:url;constTARGETprocess.env.PROXY_TARGET||https://api.xbai.top;constPORTparseInt(process.env.PROXY_PORT||3457,10);// 读取 auth.json 获取 API keyfunctionloadApiKey(){constauthPaths[path.join(process.env.USERPROFILE||,.local,share,opencode,auth.json),path.join(process.env.USERPROFILE||,.config,opencode,auth.json),];for(constpofauthPaths){try{constrawfs.readFileSync(p,utf8);constauthJSON.parse(raw);// 找任意一个有 key 的 providerfor(const[provider,info]ofObject.entries(auth)){if(infotypeofinfoobjectinfo.key){console.log([proxy] loaded API key from provider:${provider});returninfo.key;}}}catch{}}returnnull;}constAPI_KEYloadApiKey();if(!API_KEY){console.error([proxy] WARNING: no API key found in auth.json);}functioncoerceId(val){if(valnull)returncrypto.randomUUID();if(typeofvalnumber)returnString(val);returnval;}functiondeepFix(obj){if(!obj||typeofobj!object)returnobj;if(Array.isArray(obj)){for(leti0;iobj.length;i)obj[i]deepFix(obj[i]);returnobj;}// Convert any numeric id/tool_call_id at any level to stringsif(idinobjobj.id!nulltypeofobj.idnumber){obj.idString(obj.id);}if(tool_call_idinobjobj.tool_call_id!nulltypeofobj.tool_call_idnumber){obj.tool_call_idString(obj.tool_call_id);}// Fix tool call delta/message — handles streaming non-streaming// Structure: choices[].delta.tool_calls[] or choices[].message.tool_calls[]if(obj.tool_callsArray.isArray(obj.tool_calls)){for(consttcofobj.tool_calls){// Force all required string fields — provider may omit them entirelyif(tc.idnull||typeoftc.id!string)tc.idcrypto.randomUUID();if(tc.typenull||typeoftc.type!string)tc.typefunction;if(tc.functiontypeoftc.functionobject){if(tc.function.namenull||typeoftc.function.name!string)tc.function.name;if(tc.function.argumentsnull||typeoftc.function.arguments!string)tc.function.arguments;}}}// Recurse (skip already-handled keys to reduce noise)for(constkeyofObject.keys(obj)){if(![id,tool_call_id,tool_calls].includes(key)){deepFix(obj[key]);}}returnobj;}functionpipeRequest(client,targetUrl,req,res){constchunks[];req.on(data,(c)chunks.push(c));req.on(end,(){letrawBodyBuffer.concat(chunks).toString(utf8);letbodyObj;try{bodyObjJSON.parse(rawBody);deepFix(bodyObj);rawBodyJSON.stringify(bodyObj);}catch{}constparsednewURL(targetUrl);// 构建 headers确保 Authorization 正确constheaders{...req.headers};headers.hostparsed.hostname;headers[content-length]Buffer.byteLength(rawBody);// 如果原始请求没有 Authorization就加上if(!headers.authorizationAPI_KEY){headers.authorizationBearer${API_KEY};}constoptions{hostname:parsed.hostname,port:parsed.port||(parsed.protocolhttps:?443:80),path:parsed.pathnameparsed.search,method:req.method,headers,};console.log([proxy]${req.method}${targetUrl}(auth:${headers.authorization?yes:no}));constproxyclient.request(options,(proxyRes){constcontentTypeproxyRes.headers[content-type]||;// 流式 SSE 响应逐行处理if(contentType.includes(text/event-stream)){constheaders2{...proxyRes.headers};res.writeHead(proxyRes.statusCode,headers2);letsseBuffer;proxyRes.on(data,(chunk){sseBufferchunk.toString(utf8);// Split by \n\n to get whole SSE events, then by \n per eventconstrawEventssseBuffer.split(\n\n);sseBufferrawEvents.pop()||;for(constrawofrawEvents){constlinesraw.split(\n);lethasDatafalse;for(leti0;ilines.length;i){constlinelines[i];if(line.startsWith(data: )){hasDatatrue;constdataline.slice(6);if(data[DONE]){res.write(data: [DONE]\n\n);}else{try{constparsedJSON.parse(data);console.log([proxy] RAW SSE:,JSON.stringify(parsed).slice(0,300));deepFix(parsed);console.log([proxy] FIXED SSE:,JSON.stringify(parsed).slice(0,300));res.write(data: JSON.stringify(parsed)\n\n);}catch(e){console.error([proxy] SSE JSON parse error:,e.message);res.write(line\n);}}}else{// preserve non-data lines (like event:, retry:, blank)if(i1lines.length||line)res.write(line\n);}}// write event separator unless the last event was data: (which already wrote \n\n)if(!hasData)res.write(\n);}});proxyRes.on(end,(){if(sseBuffer){// leftover might be a partial event, flush as-isconsole.log([proxy] SSE leftover (flush):,sseBuffer.slice(0,200));res.write(sseBuffer);}res.end();});return;}// 非流式 JSONif(contentType.includes(application/json)){constrespChunks[];proxyRes.on(data,(c)respChunks.push(c));proxyRes.on(end,(){letrespRawBuffer.concat(respChunks).toString(utf8);try{letrespObjJSON.parse(respRaw);deepFix(respObj);respRawJSON.stringify(respObj);}catch{}consth{...proxyRes.headers};h[content-length]Buffer.byteLength(respRaw);deleteh[content-encoding];res.writeHead(proxyRes.statusCode,h);res.end(respRaw);});return;}// 其他直接透传res.writeHead(proxyRes.statusCode,proxyRes.headers);proxyRes.pipe(res,{end:true});});proxy.on(error,(e){console.error([proxy error],e.message);res.writeHead(502);res.end(JSON.stringify({error:e.message}));});proxy.write(rawBody);proxy.end();});}constserverhttp.createServer((req,res){if(req.methodOPTIONS){res.writeHead(204,{Access-Control-Allow-Origin:*,Access-Control-Allow-Methods:GET,POST,PUT,DELETE,PATCH,OPTIONS,Access-Control-Allow-Headers:*,});returnres.end();}consttargetUrlTARGETreq.url;pipeRequest(https,targetUrl,req,res);});server.listen(PORT,127.0.0.1,(){console.log([compat-proxy] listening on http://127.0.0.1:${PORT});console.log([compat-proxy] target:${TARGET});console.log([compat-proxy] api key:${API_KEY?loaded:MISSING});});部署与使用步骤步骤一保存并运行代理将上述代码保存为opencode-compat-proxy.mjs在终端中运行nodeopencode-compat-proxy.mjs步骤二配置 OpenCode 自定义服务商在 OpenCode 中添加自定义服务商将请求地址指向本地代理服务商地址http://127.0.0.1:3457步骤三环境变量配置可选如果需要自定义代理目标或端口可以设置环境变量PROXY_TARGET指定上游网关地址默认https://api.xxx.topPROXY_PORT指定代理监听端口默认3457方案优势总结兼容性自动修复不规范第三方网关的响应无副作用对标准 OpenAI 兼容提供商完全透明稳定性统一处理方案避免每个模型单独配置功能完整保留完整的工具调用能力不因兼容性而禁用功能易于部署纯 Node.js 实现无需复杂依赖总结本文详细分析了 OpenCode 工具调用中Expected id to be a string错误的根本原因并提供了三种解决方案。推荐使用本地代理方案它通过非侵入式的方式解决了第三方网关的兼容性问题同时保持了系统的稳定性和功能的完整性。通过部署这个代理服务器你可以在不修改上游服务或 OpenCode 核心代码的情况下透明地解决工具调用中的类型兼容性问题确保开发流程的顺畅进行。