diff --git a/patch_provider.py b/patch_provider.py new file mode 100644 index 0000000..e6adc3d --- /dev/null +++ b/patch_provider.py @@ -0,0 +1,86 @@ +path = '/mnt/NVME/Docker/opencode-build/opencode/packages/opencode/src/provider/provider.ts' +with open(path, 'r') as f: + src = f.read() + +MARKER_1 = 'statusText: res.statusText,\n })\n}\n\nfunction googleVertexAnthropicBaseURL' +PATCH_FN = r"""statusText: res.statusText, + }) +} + +// Fix missing/null tool_call ids in SSE streams from OpenAI-compatible providers. +// Some proxies (e.g. 9Router/LiteLLM forwarding Anthropic) emit tool-call chunks +// without an `id` field, causing @ai-sdk/openai-compatible to throw +// "Expected 'id' to be a string." Inject a stable synthetic id per tool-call index. +function patchToolCallIds(res: Response, npm: string): Response { + if (npm !== "@ai-sdk/openai-compatible" && npm !== "@ai-sdk/openai") return res + if (!res.body) return res + if (!res.headers.get("content-type")?.includes("text/event-stream")) return res + const reader = res.body.getReader() + const encoder = new TextEncoder() + const decoder = new TextDecoder() + const idByIndex = new Map() + let buf = "" + const body = new ReadableStream({ + async pull(ctrl) { + const { done, value } = await reader.read() + if (done) { if (buf) ctrl.enqueue(encoder.encode(buf)); ctrl.close(); return } + buf += decoder.decode(value, { stream: true }) + const nl = buf.lastIndexOf("\n") + if (nl === -1) return + const complete = buf.slice(0, nl + 1) + buf = buf.slice(nl + 1) + const lines = complete.split("\n") + const out: string[] = [] + for (const line of lines) { + if (!line.startsWith("data: ")) { out.push(line); continue } + const payload = line.slice(6) + if (payload === "[DONE]") { out.push(line); continue } + try { + const parsed = JSON.parse(payload) + let dirty = false + for (const choice of parsed?.choices ?? []) { + for (const tc of choice?.delta?.tool_calls ?? []) { + if (typeof tc.id !== "string" || tc.id === "") { + const idx: number = tc.index ?? 0 + if (!idByIndex.has(idx)) + idByIndex.set(idx, `call_${crypto.randomUUID().replace(/-/g,"").slice(0,24)}`) + tc.id = idByIndex.get(idx); dirty = true + } + } + } + out.push(dirty ? `data: ${JSON.stringify(parsed)}` : line) + } catch { out.push(line) } + } + ctrl.enqueue(encoder.encode(out.join("\n"))) + }, + async cancel(reason) { await reader.cancel(reason) }, + }) + return new Response(body, { + headers: new Headers(res.headers), + status: res.status, + statusText: res.statusText, + }) +} + +function googleVertexAnthropicBaseURL""" + +if MARKER_1 in src: + src = src.replace(MARKER_1, PATCH_FN, 1) + print('OK: inserted patchToolCallIds()') +else: + print('ERROR: marker 1 not found') + raise SystemExit(1) + +MARKER_2 = ' if (!chunkAbortCtl) return res\n return wrapSSE(res, chunkTimeout, chunkAbortCtl)' +PATCH_RETURN = ' const patched = patchToolCallIds(res, model.api.npm)\n if (!chunkAbortCtl) return patched\n return wrapSSE(patched, chunkTimeout, chunkAbortCtl)' + +if MARKER_2 in src: + src = src.replace(MARKER_2, PATCH_RETURN, 1) + print('OK: patched fetch return') +else: + print('ERROR: marker 2 not found') + raise SystemExit(1) + +with open(path, 'w') as f: + f.write(src) +print('DONE')