diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 6ab45d028b9a..252f56c2a63c 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1196,7 +1196,11 @@ export namespace Provider { delete options.fetch } - if (model.api.npm.includes("@ai-sdk/openai-compatible") && options["includeUsage"] !== false) { + if ( + model.api.npm.includes("@ai-sdk/openai-compatible") && + options["includeUsage"] === undefined && + provider.source !== "config" + ) { options["includeUsage"] = true } diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 075f070e4264..ad2d6d3f0f4c 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -281,6 +281,43 @@ export namespace LLM { return args.params }, }, + ...(provider.source === "config" + ? [ + { + async wrapStream({ doStream }: { doStream: () => ReturnType }) { + const result = await doStream() + let text = "" + let hasToolCall = false + const transformed = result.stream.pipeThrough( + new TransformStream({ + transform(chunk, ctrl) { + if (chunk.type === "tool-call" || chunk.type === "tool-input-start") hasToolCall = true + if (chunk.type === "text-delta") text += chunk.delta + if (chunk.type === "finish" && chunk.finishReason === "stop" && !hasToolCall && text.trim()) { + try { + const parsed = JSON.parse(text.trim()) as { name?: string; arguments?: unknown } + if (typeof parsed.name === "string" && parsed.name in tools) { + l.info("repairing raw JSON tool call", { tool: parsed.name }) + ctrl.enqueue({ + type: "tool-call", + toolCallId: `raw-${Date.now()}`, + toolName: parsed.name, + input: JSON.stringify(parsed.arguments ?? {}), + }) + ctrl.enqueue({ ...chunk, finishReason: "tool-calls" }) + return + } + } catch {} + } + ctrl.enqueue(chunk) + }, + }), + ) + return { ...result, stream: transformed } + }, + }, + ] + : []), ], }), experimental_telemetry: { diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 72ba9dba5a5c..fc71af6d4906 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "bun:test" +import { test, expect, mock } from "bun:test" import path from "path" import { tmpdir } from "../fixture/fixture" @@ -2282,3 +2282,77 @@ test("cloudflare-ai-gateway forwards config metadata options", async () => { }, }) }) + +test("ollama local provider does not send stream_options to ollama", async () => { + // Local Ollama does not support stream_options: { include_usage: true }. + // Sending it causes errors. A user-configured @ai-sdk/openai-compatible + // provider must not have includeUsage forced on. + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + ollama: { + name: "Ollama", + npm: "@ai-sdk/openai-compatible", + env: [], + models: { + "llama3.2": { + name: "Llama 3.2", + limit: { context: 4096, output: 2048 }, + }, + }, + options: { + baseURL: "http://localhost:11434/v1", + }, + }, + }, + }), + ) + }, + }) + + let body: Record | undefined + + const orig = globalThis.fetch + globalThis.fetch = mock(async (input: RequestInfo | URL, init?: RequestInit) => { + body = JSON.parse((init?.body as string) ?? "{}") + const stream = new ReadableStream({ + start(c) { + c.enqueue( + new TextEncoder().encode( + `data: {"id":"1","object":"chat.completion.chunk","created":1,"model":"llama3.2","choices":[{"index":0,"delta":{"role":"assistant","content":"hi"},"finish_reason":null}]}\n\n`, + ), + ) + c.enqueue(new TextEncoder().encode("data: [DONE]\n\n")) + c.close() + }, + }) + return new Response(stream, { status: 200, headers: { "Content-Type": "text/event-stream" } }) + }) as unknown as typeof fetch + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const model = await Provider.getModel(ProviderID.make("ollama"), ModelID.make("llama3.2")) + const lang = await Provider.getLanguage(model) + const { stream } = await lang.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "hi" }] }], + includeRawChunks: false, + }) + // drain the stream + const reader = stream.getReader() + while (!(await reader.read()).done) {} + }, + }) + } finally { + globalThis.fetch = orig + } + + expect(body).toBeDefined() + // stream_options must not be present — local Ollama rejects it + expect(body!["stream_options"]).toBeUndefined() +}) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 5202c06dd934..aa373ea69b32 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -654,6 +654,95 @@ describe("session.llm.stream", () => { }) }) + test("repairs raw JSON tool call from local openai-compatible provider", async () => { + const server = state.server + if (!server) throw new Error("Server not initialized") + + // Simulate qwen2.5-coder style: finish_reason=stop, no tool_calls, content is raw JSON + const rawJson = JSON.stringify({ name: "read_file", arguments: { path: "foo.txt" } }) + const chunks = [ + { id: "chatcmpl-1", object: "chat.completion.chunk", choices: [{ delta: { role: "assistant" } }] }, + { id: "chatcmpl-1", object: "chat.completion.chunk", choices: [{ delta: { content: rawJson } }] }, + { id: "chatcmpl-1", object: "chat.completion.chunk", choices: [{ delta: {}, finish_reason: "stop" }] }, + "[DONE]", + ] + + waitRequest("/chat/completions", createEventResponse(chunks)) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + provider: { + "local-ollama": { + npm: "@ai-sdk/openai-compatible", + name: "Local Ollama", + options: { baseURL: `${server.url.origin}/v1`, apiKey: "ollama" }, + models: { + "qwen2.5-coder:7b": { name: "Qwen2.5 Coder 7B" }, + }, + }, + }, + model: "local-ollama/qwen2.5-coder:7b", + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await Provider.getModel( + ProviderID.make("local-ollama"), + ModelID.make("qwen2.5-coder:7b"), + ) + const sessionID = SessionID.make("session-raw-json-repair") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + const user = { + id: MessageID.make("user-raw-json"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make("local-ollama"), modelID: resolved.id }, + } satisfies MessageV2.User + + const read_file = tool({ + description: "Read a file", + inputSchema: z.object({ path: z.string() }), + execute: async () => ({ output: "file contents" }), + }) + + const stream = await LLM.stream({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + abort: new AbortController().signal, + messages: [{ role: "user", content: "Read foo.txt" }], + tools: { read_file }, + }) + + const parts: string[] = [] + for await (const chunk of stream.fullStream) { + parts.push(chunk.type) + } + + expect(parts).toContain("tool-call") + expect(parts).not.toContain("text") + }, + }) + }) + test("sends Google API payload for Gemini models", async () => { const server = state.server if (!server) {