From d0ddaa9a0ac687c53b2d70c22ebe8f776d527783 Mon Sep 17 00:00:00 2001 From: Daniel Poelzleithner Date: Sun, 22 Feb 2026 20:51:57 +0100 Subject: [PATCH 1/2] feat: add streaming option to provider config to support non-streaming backends Add a 'streaming' boolean option to the provider configuration schema. When set to false, the AI SDK's simulateStreamingMiddleware is used to make non-streaming (doGenerate) requests and convert the response into a simulated stream, allowing the rest of the processing pipeline to work unchanged. This is useful for custom OpenAI-compatible backends that do not support server-sent events / streaming responses. Usage in opencode.json: { "provider": { "myprovider": { "options": { "streaming": false } } } } --- packages/opencode/src/config/config.ts | 6 ++++++ packages/opencode/src/session/llm.ts | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index aad0fd76c4be..96d64f3061e7 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -989,6 +989,12 @@ export namespace Config { baseURL: z.string().optional(), enterpriseUrl: z.string().optional().describe("GitHub Enterprise URL for copilot authentication"), setCacheKey: z.boolean().optional().describe("Enable promptCacheKey for this provider (default false)"), + streaming: z + .boolean() + .optional() + .describe( + "Enable or disable streaming for this provider. When set to false, uses non-streaming requests and simulates streaming output. Useful for backends that do not support streaming. Default is true.", + ), timeout: z .union([ z diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 4e42fb0d2ec7..21556d8aac5e 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -4,6 +4,7 @@ import { Log } from "@/util/log" import { streamText, wrapLanguageModel, + simulateStreamingMiddleware, type ModelMessage, type StreamTextResult, type Tool, @@ -243,6 +244,10 @@ export namespace LLM { return args.params }, }, + // When streaming is disabled for the provider, use the AI SDK's + // simulateStreamingMiddleware to make non-streaming (generateText) + // requests and convert the result into a simulated stream. + ...(provider?.options?.streaming === false ? [simulateStreamingMiddleware()] : []), ], }), experimental_telemetry: { From fc84339cd918af1bab6b45c8bfe5c11a2e007ced Mon Sep 17 00:00:00 2001 From: 75ACOL <57381895+75ACOL@users.noreply.github.com> Date: Tue, 3 Mar 2026 18:08:27 +0800 Subject: [PATCH 2/2] test(opencode): cover non-streaming provider fallback for chat completions --- packages/opencode/test/session/llm.test.ts | 119 +++++++++++++++++++++ 1 file changed, 119 insertions(+) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index a89a00ebc05e..382ff8eb5f90 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -323,6 +323,125 @@ describe("session.llm.stream", () => { }) }) + test("supports non-streaming openai-compatible backends when provider streaming is disabled", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const providerID = "alibaba" + const modelID = "qwen-plus" + const fixture = await loadFixture(providerID, modelID) + const provider = fixture.provider + const model = fixture.model + + const request = waitRequest( + "/chat/completions", + new Response( + JSON.stringify({ + id: "chatcmpl-1", + object: "chat.completion", + created: Math.floor(Date.now() / 1000), + model: model.id, + choices: [ + { + index: 0, + message: { + role: "assistant", + content: "Hello from non-streaming backend", + }, + finish_reason: "stop", + }, + ], + usage: { + prompt_tokens: 1, + completion_tokens: 5, + total_tokens: 6, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ), + ) + + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write( + path.join(dir, "opencode.json"), + JSON.stringify({ + $schema: "https://opencode.ai/config.json", + enabled_providers: [providerID], + provider: { + [providerID]: { + options: { + apiKey: "test-key", + baseURL: `${server.url.origin}/v1`, + streaming: false, + }, + }, + }, + }), + ) + }, + }) + + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await Provider.getModel(providerID, model.id) + const sessionID = "session-test-1b" + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + temperature: 0.4, + topP: 0.8, + } satisfies Agent.Info + + const user = { + id: "user-1b", + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID, modelID: resolved.id }, + variant: "high", + } satisfies MessageV2.User + + const stream = await LLM.stream({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + abort: new AbortController().signal, + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }) + + let text = "" + for await (const chunk of stream.textStream) { + text += chunk + } + + const capture = await request + const body = capture.body + const headers = capture.headers + const url = capture.url + + expect(url.pathname.startsWith("/v1/")).toBe(true) + expect(url.pathname.endsWith("/chat/completions")).toBe(true) + expect(headers.get("Authorization")).toBe("Bearer test-key") + expect(body.model).toBe(resolved.api.id) + expect(body.stream).not.toBe(true) + expect(text).toContain("Hello from non-streaming backend") + }, + }) + }) + test("sends responses API payload for OpenAI models", async () => { const server = state.server if (!server) {