Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/opencode/src/provider/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
37 changes: 37 additions & 0 deletions packages/opencode/src/session/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,43 @@ export namespace LLM {
return args.params
},
},
...(provider.source === "config"
? [
{
async wrapStream({ doStream }: { doStream: () => ReturnType<import("@ai-sdk/provider").LanguageModelV2["doStream"]> }) {
const result = await doStream()
let text = ""
let hasToolCall = false
const transformed = result.stream.pipeThrough(
new TransformStream<import("@ai-sdk/provider").LanguageModelV2StreamPart, import("@ai-sdk/provider").LanguageModelV2StreamPart>({
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: {
Expand Down
76 changes: 75 additions & 1 deletion packages/opencode/test/provider/provider.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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<string, unknown> | 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()
})
89 changes: 89 additions & 0 deletions packages/opencode/test/session/llm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading