From 1f450ad704193fa0cdc80a3c819fc74cb924c212 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 12 May 2026 22:32:38 -0400 Subject: [PATCH] feat(llm): support native Zen preview --- packages/opencode/src/session/llm/README.md | 2 +- .../src/session/llm/native-runtime.ts | 15 +- .../session/native-zen-tool-call.json | 31 ++++ .../test/session/llm-native-recorded.test.ts | 143 +++++++++++++++--- .../opencode/test/session/llm-native.test.ts | 14 +- 5 files changed, 182 insertions(+), 23 deletions(-) create mode 100644 packages/opencode/test/fixtures/recordings/session/native-zen-tool-call.json diff --git a/packages/opencode/src/session/llm/README.md b/packages/opencode/src/session/llm/README.md index 139db3a52e..2983c5ce24 100644 --- a/packages/opencode/src/session/llm/README.md +++ b/packages/opencode/src/session/llm/README.md @@ -12,5 +12,5 @@ Safety boundary: - AI SDK remains the default. - `OPENCODE_LLM_RUNTIME=native` is an opt-in hint, not a global replacement. -- Native execution currently runs only for OpenAI API-key auth via `@ai-sdk/openai`. +- Native execution currently runs only for OpenAI-compatible Responses models exposed through `@ai-sdk/openai`: direct `openai` API-key auth and console-managed `opencode`/Zen API-key config. - Unsupported providers, OpenAI OAuth, and missing API-key cases fall back to AI SDK. diff --git a/packages/opencode/src/session/llm/native-runtime.ts b/packages/opencode/src/session/llm/native-runtime.ts index a57a84c7cc..3a2e053068 100644 --- a/packages/opencode/src/session/llm/native-runtime.ts +++ b/packages/opencode/src/session/llm/native-runtime.ts @@ -2,6 +2,7 @@ import type { Auth } from "@/auth" import type { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { errorMessage } from "@/util/error" +import { isRecord } from "@/util/record" import { asSchema, type ModelMessage, type Tool } from "ai" import { Effect } from "effect" import * as Stream from "effect/Stream" @@ -36,9 +37,10 @@ type StreamInput = { } export function status(input: Pick): RuntimeStatus { - if (input.model.providerID !== "openai") return { type: "unsupported", reason: "provider is not openai" } + if (input.model.providerID !== "openai" && !input.model.providerID.startsWith("opencode")) + return { type: "unsupported", reason: "provider is not openai or opencode" } if (input.model.api.npm !== "@ai-sdk/openai") return { type: "unsupported", reason: "provider package is not OpenAI" } - if (input.auth?.type === "oauth") return { type: "unsupported", reason: "OpenAI OAuth is not supported" } + if (input.auth?.type === "oauth") return { type: "unsupported", reason: "OAuth auth is not supported" } const apiKey = input.auth?.type === "api" @@ -74,13 +76,20 @@ export function stream(input: StreamInput): StreamResult { topK: input.topK, maxOutputTokens: input.maxOutputTokens, providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}), - headers: input.headers, + headers: { ...providerHeaders(input.provider.options.headers), ...input.headers }, }), tools: nativeTools(input.tools, input), }), } } +function providerHeaders(value: unknown): Record | undefined { + if (!isRecord(value)) return undefined + return Object.fromEntries( + Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === "string"), + ) +} + function nativeSchema(value: unknown): JsonSchema { if (!value || typeof value !== "object") return { type: "object", properties: {} } if ("jsonSchema" in value && value.jsonSchema && typeof value.jsonSchema === "object") diff --git a/packages/opencode/test/fixtures/recordings/session/native-zen-tool-call.json b/packages/opencode/test/fixtures/recordings/session/native-zen-tool-call.json new file mode 100644 index 0000000000..a7951cad5d --- /dev/null +++ b/packages/opencode/test/fixtures/recordings/session/native-zen-tool-call.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "metadata": { + "name": "session/native-zen-tool-call", + "recordedAt": "2026-05-13T02:31:23.884Z", + "provider": "opencode", + "protocol": "openai-responses", + "route": "openai-responses", + "tags": ["opencode", "zen", "native", "tool-call"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://console.opencode.ai/proxy/connections/{connection}/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.2-codex\",\"input\":[{\"role\":\"system\",\"content\":\"Call tools exactly as instructed.\\nYou must call the lookup tool exactly once with query weather. Do not answer in text.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Use lookup.\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"lookup\",\"description\":\"Lookup data.\",\"parameters\":{\"$schema\":\"http://json-schema.org/draft-07/schema#\",\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"}},\"required\":[\"query\"],\"additionalProperties\":false}}],\"tool_choice\":\"required\",\"store\":false,\"prompt_cache_key\":\"session-recorded-native-zen-tool\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"max_output_tokens\":32000,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_04ca34b8d77b281a016a03e27aa97c819ba8b0b5fca73ab4be\",\"object\":\"response\",\"created_at\":1778639482,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":32000,\"max_tool_calls\":null,\"model\":\"gpt-5.2-codex\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"session-recorded-native-zen-tool\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":\"wrk_redacted\",\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"required\",\"tools\":[{\"type\":\"function\",\"description\":\"Lookup data.\",\"name\":\"lookup\",\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"}},\"required\":[\"query\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_04ca34b8d77b281a016a03e27aa97c819ba8b0b5fca73ab4be\",\"object\":\"response\",\"created_at\":1778639482,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":32000,\"max_tool_calls\":null,\"model\":\"gpt-5.2-codex\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"session-recorded-native-zen-tool\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":\"wrk_redacted\",\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"required\",\"tools\":[{\"type\":\"function\",\"description\":\"Lookup data.\",\"name\":\"lookup\",\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"}},\"required\":[\"query\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_04ca34b8d77b281a016a03e27b0698819b856a269e323c764c\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_04ca34b8d77b281a016a03e27b0698819b856a269e323c764c\",\"type\":\"reasoning\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_04ca34b8d77b281a016a03e27bb13c819bbd320fe32a98884a\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_4A3XM5Y1Nr1TtrbAaO61NyBa\",\"name\":\"lookup\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_04ca34b8d77b281a016a03e27bb13c819bbd320fe32a98884a\",\"obfuscation\":\"ZIWPTYcHCo2Crg\",\"output_index\":1,\"sequence_number\":5}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"query\",\"item_id\":\"fc_04ca34b8d77b281a016a03e27bb13c819bbd320fe32a98884a\",\"obfuscation\":\"TZYnEWuRnuY\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_04ca34b8d77b281a016a03e27bb13c819bbd320fe32a98884a\",\"obfuscation\":\"mR4nrEBFjAaQp\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"weather\",\"item_id\":\"fc_04ca34b8d77b281a016a03e27bb13c819bbd320fe32a98884a\",\"obfuscation\":\"JjG0yWAbO\",\"output_index\":1,\"sequence_number\":8}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_04ca34b8d77b281a016a03e27bb13c819bbd320fe32a98884a\",\"obfuscation\":\"vzmP5bsEBES4nV\",\"output_index\":1,\"sequence_number\":9}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"query\\\":\\\"weather\\\"}\",\"item_id\":\"fc_04ca34b8d77b281a016a03e27bb13c819bbd320fe32a98884a\",\"output_index\":1,\"sequence_number\":10}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_04ca34b8d77b281a016a03e27bb13c819bbd320fe32a98884a\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"query\\\":\\\"weather\\\"}\",\"call_id\":\"call_4A3XM5Y1Nr1TtrbAaO61NyBa\",\"name\":\"lookup\"},\"output_index\":1,\"sequence_number\":11}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_04ca34b8d77b281a016a03e27aa97c819ba8b0b5fca73ab4be\",\"object\":\"response\",\"created_at\":1778639482,\"status\":\"completed\",\"background\":false,\"completed_at\":1778639483,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":32000,\"max_tool_calls\":null,\"model\":\"gpt-5.2-codex\",\"moderation\":null,\"output\":[{\"id\":\"rs_04ca34b8d77b281a016a03e27b0698819b856a269e323c764c\",\"type\":\"reasoning\",\"summary\":[]},{\"id\":\"fc_04ca34b8d77b281a016a03e27bb13c819bbd320fe32a98884a\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"query\\\":\\\"weather\\\"}\",\"call_id\":\"call_4A3XM5Y1Nr1TtrbAaO61NyBa\",\"name\":\"lookup\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"session-recorded-native-zen-tool\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":\"medium\",\"summary\":\"detailed\"},\"safety_identifier\":\"wrk_redacted\",\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"medium\"},\"tool_choice\":\"required\",\"tools\":[{\"type\":\"function\",\"description\":\"Lookup data.\",\"name\":\"lookup\",\"parameters\":{\"type\":\"object\",\"properties\":{\"query\":{\"type\":\"string\"}},\"required\":[\"query\"],\"additionalProperties\":false},\"strict\":true}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":69,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":37,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":106},\"user\":null,\"metadata\":{}},\"sequence_number\":12}\n\nevent: ping\ndata: {\"type\":\"ping\",\"cost\":\"0\"}\n\n" + } + } + ] +} diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index 23b4a1dc76..bc1297e5d1 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -1,5 +1,5 @@ import { NodeFileSystem } from "@effect/platform-node" -import { HttpRecorder } from "@opencode-ai/http-recorder" +import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder" import { describe, expect } from "bun:test" import { tool } from "ai" import { Effect, Layer, Stream } from "effect" @@ -21,14 +21,22 @@ import type { ModelsDev } from "../../src/provider/models" import { TestInstance } from "../fixture/fixture" import { testEffect } from "../lib/effect" -const CASSETTE = "session/native-openai-tool-call" +const OPENAI_CASSETTE = "session/native-openai-tool-call" +const ZEN_CASSETTE = "session/native-zen-tool-call" const FIXTURES_DIR = path.join(import.meta.dir, "../fixtures/recordings") const OPENAI_API_KEY = process.env.OPENCODE_RECORD_OPENAI_API_KEY ?? process.env.OPENAI_API_KEY +const CONSOLE_TOKEN = process.env.OPENCODE_RECORD_CONSOLE_TOKEN +const ZEN_ORG_ID = process.env.OPENCODE_RECORD_ZEN_ORG_ID +const ZEN_API_URL = + process.env.OPENCODE_RECORD_ZEN_API_URL ?? "https://console.opencode.ai/proxy/connections/fixture/v1" const shouldRecord = process.env.RECORD === "true" -const canRun = shouldRecord +const canRunOpenAI = shouldRecord ? Boolean(OPENAI_API_KEY) - : HttpRecorder.hasCassetteSync(CASSETTE, { directory: FIXTURES_DIR }) + : HttpRecorder.hasCassetteSync(OPENAI_CASSETTE, { directory: FIXTURES_DIR }) +const canRunZen = shouldRecord + ? Boolean(CONSOLE_TOKEN && ZEN_ORG_ID) + : HttpRecorder.hasCassetteSync(ZEN_CASSETTE, { directory: FIXTURES_DIR }) async function loadFixture(providerID: string, modelID: string) { const data = await Filesystem.readJson>( @@ -62,19 +70,47 @@ const openAIConfig = (model: ModelsDev.Provider["models"][string]): Partial => ({ + enabled_providers: ["opencode"], + provider: { + opencode: { + name: "OpenCode Zen", + env: ["OPENCODE_CONSOLE_TOKEN"], + npm: "@ai-sdk/openai-compatible", + api: ZEN_API_URL, + models: { + [model.id]: JSON.parse(JSON.stringify(model)) as NonNullable< + NonNullable[string]["models"] + >[string], + }, + options: { + apiKey: CONSOLE_TOKEN ?? "fixture-console-token", + headers: { + "x-org-id": ZEN_ORG_ID ?? "fixture-org", + }, + }, + }, + }, +}) + +function recordedNativeLLMLayer(cassette: string, metadata: Record) { const cassetteService = HttpRecorder.Cassette.fileSystem({ directory: FIXTURES_DIR }).pipe( Layer.provide(NodeFileSystem.layer), ) // Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real. - const recorder = HttpRecorder.recordingLayer(CASSETTE, { + const recorder = HttpRecorder.recordingLayer(cassette, { mode: shouldRecord ? "record" : "replay", - metadata: { - provider: "openai", - protocol: "openai-responses", - route: "openai-responses", - tags: ["opencode", "native", "tool-call"], - }, + metadata, + redactor: Redactor.compose( + Redactor.defaults({ + url: { + transform: (url) => url.replace(/\/proxy\/connections\/[^/]+\/v1/, "/proxy/connections/{connection}/v1"), + }, + }), + { + response: (snapshot) => ({ ...snapshot, body: snapshot.body.replace(/wrk_[A-Z0-9]+/g, "wrk_redacted") }), + }, + ), }).pipe(Layer.provide(FetchHttpClient.layer)) const executor = RequestExecutor.layer.pipe(Layer.provide(recorder)) const client = LLMClient.layer.pipe(Layer.provide(executor)) @@ -96,14 +132,34 @@ function recordedNativeLLMLayer() { return Layer.mergeAll(providerLayer, llmLayer) } -const it = testEffect(recordedNativeLLMLayer()) -const recordedInstance = canRun ? it.instance : it.instance.skip +const openAIIt = testEffect( + recordedNativeLLMLayer(OPENAI_CASSETTE, { + provider: "openai", + protocol: "openai-responses", + route: "openai-responses", + tags: ["opencode", "native", "tool-call"], + }), +) +const zenIt = testEffect( + recordedNativeLLMLayer(ZEN_CASSETTE, { + provider: "opencode", + protocol: "openai-responses", + route: "openai-responses", + tags: ["opencode", "zen", "native", "tool-call"], + }), +) +const recordedOpenAIInstance = canRunOpenAI ? openAIIt.instance : openAIIt.instance.skip +const recordedZenInstance = canRunZen ? zenIt.instance : zenIt.instance.skip -const writeConfig = (directory: string, model: ModelsDev.Provider["models"][string]) => +const writeConfig = ( + directory: string, + model: ModelsDev.Provider["models"][string], + config: (model: ModelsDev.Provider["models"][string]) => Partial = openAIConfig, +) => Effect.promise(() => Bun.write( path.join(directory, "opencode.json"), - JSON.stringify({ $schema: "https://opencode.ai/config.json", ...openAIConfig(model) }), + JSON.stringify({ $schema: "https://opencode.ai/config.json", ...config(model) }), ), ) @@ -136,7 +192,7 @@ const nativeRuntime = (effect: Effect.Effect) => { } describe("session.llm native recorded", () => { - recordedInstance("uses real RequestExecutor with HTTP recorder for native OpenAI tools", () => + recordedOpenAIInstance("uses real RequestExecutor with HTTP recorder for native OpenAI tools", () => Effect.gen(function* () { const test = yield* TestInstance const model = yield* Effect.promise(() => loadFixture("openai", "gpt-4.1-mini")) @@ -189,4 +245,57 @@ describe("session.llm native recorded", () => { expect(executed).toMatchObject({ args: { query: "weather" }, toolCallId: expect.any(String) }) }), ) + + recordedZenInstance("uses console-managed Zen config with native OpenAI-compatible tools", () => + Effect.gen(function* () { + const test = yield* TestInstance + const model = yield* Effect.promise(() => loadFixture("opencode", "gpt-5.2-codex")) + yield* writeConfig(test.directory, model, zenConfig) + + const sessionID = SessionID.make("session-recorded-native-zen-tool") + const agent = { + name: "test", + mode: "primary", + prompt: "Call tools exactly as instructed.", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + const resolved = yield* getModel(ProviderID.opencode, ModelID.make(model.id)) + let executed: unknown + + const events = yield* nativeRuntime( + collect({ + user: { + id: MessageID.make("msg_user-recorded-native-zen-tool"), + sessionID, + role: "user", + time: { created: 0 }, + agent: agent.name, + model: { providerID: ProviderID.opencode, modelID: ModelID.make(model.id) }, + } satisfies MessageV2.User, + sessionID, + model: resolved, + agent, + system: ["You must call the lookup tool exactly once with query weather. Do not answer in text."], + messages: [{ role: "user", content: "Use lookup." }], + toolChoice: "required", + tools: { + lookup: tool({ + description: "Lookup data.", + inputSchema: z.object({ query: z.string() }), + execute: async (args, options) => { + executed = { args, toolCallId: options.toolCallId } + return { output: "looked up" } + }, + }), + }, + }), + ) + + expect(events.filter((event) => event.type === "step-finish")).toHaveLength(1) + expect(events.filter((event) => event.type === "finish")).toHaveLength(1) + expect(events.some((event) => event.type === "tool-result")).toBe(true) + expect(executed).toMatchObject({ args: { query: "weather" }, toolCallId: expect.any(String) }) + }), + ) }) diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index 741c9de328..6de16cbc99 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -249,20 +249,30 @@ describe("session.llm-native.request", () => { type: "supported", apiKey: "test-openai-key", }) + expect( + LLMNativeRuntime.status({ + model: { ...baseModel, providerID: ProviderID.make("opencode") }, + provider: { ...providerInfo, id: ProviderID.make("opencode") }, + auth: undefined, + }), + ).toMatchObject({ + type: "supported", + apiKey: "test-openai-key", + }) expect( LLMNativeRuntime.status({ model: { ...baseModel, providerID: ProviderID.make("anthropic") }, provider: { ...providerInfo, id: ProviderID.make("anthropic") }, auth: undefined, }), - ).toEqual({ type: "unsupported", reason: "provider is not openai" }) + ).toEqual({ type: "unsupported", reason: "provider is not openai or opencode" }) expect( LLMNativeRuntime.status({ model: baseModel, provider: providerInfo, auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 }, }), - ).toEqual({ type: "unsupported", reason: "OpenAI OAuth is not supported" }) + ).toEqual({ type: "unsupported", reason: "OAuth auth is not supported" }) }) test("compiles through the native OpenAI Responses route", async () => {