diff --git a/bun.lock b/bun.lock index 002ed612aa..0d166bb339 100644 --- a/bun.lock +++ b/bun.lock @@ -421,6 +421,7 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", + "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -489,6 +490,7 @@ "@babel/core": "7.28.4", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/core": "workspace:*", + "@opencode-ai/http-recorder": "workspace:*", "@opencode-ai/script": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", diff --git a/packages/llm/src/schema/errors.ts b/packages/llm/src/schema/errors.ts index 9bcc8e1694..514db23cd6 100644 --- a/packages/llm/src/schema/errors.ts +++ b/packages/llm/src/schema/errors.ts @@ -198,5 +198,6 @@ export class LLMError extends Schema.TaggedErrorClass()("LLM.Error", { */ export class ToolFailure extends Schema.TaggedErrorClass()("LLM.ToolFailure", { message: Schema.String, + error: Schema.optional(Schema.Unknown), metadata: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)), }) {} diff --git a/packages/llm/src/schema/events.ts b/packages/llm/src/schema/events.ts index 6a088dc873..85f36ba5b4 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -171,6 +171,7 @@ export const ToolError = Schema.Struct({ id: ToolCallID, name: Schema.String, message: Schema.String, + error: Schema.optional(Schema.Unknown), providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.ToolError" }) export type ToolError = Schema.Schema.Type diff --git a/packages/llm/src/tool-runtime.ts b/packages/llm/src/tool-runtime.ts index d83dcc67ad..ef527faa21 100644 --- a/packages/llm/src/tool-runtime.ts +++ b/packages/llm/src/tool-runtime.ts @@ -112,17 +112,29 @@ export const stream = (options: StreamOptions): Stream.Strea const dispatched = yield* Effect.forEach( state.toolCalls, - (call) => dispatch(tools, call).pipe(Effect.map((result) => [call, result] as const)), + (call) => + dispatch(tools, call).pipe(Effect.map((result) => [call, result.result, result.error] as const)), { concurrency }, ) - const resultStream = Stream.fromIterable(dispatched.flatMap(([call, result]) => emitEvents(call, result))) + const resultStream = Stream.fromIterable( + dispatched.flatMap(([call, result, error]) => emitEvents(call, result, error)), + ) if (!options.stopWhen) return resultStream.pipe(Stream.concat(finishStream)) if (options.stopWhen({ step, request })) return resultStream.pipe(Stream.concat(finishStream)) return resultStream.pipe( Stream.concat( - loop(followUpRequest(request, state, dispatched), step + 1, totalUsage, totalProviderMetadata), + loop( + followUpRequest( + request, + state, + dispatched.map(([call, result]) => [call, result] as const), + ), + step + 1, + totalUsage, + totalProviderMetadata, + ), ), ) }), @@ -215,7 +227,7 @@ const addUsage = (left: Usage | undefined, right: Usage | undefined) => { | "reasoningTokens" | "totalTokens" const sum = (key: UsageKey) => - left[key] === undefined && right[key] === undefined ? undefined : Number(left[key] ?? 0) + Number(right[key] ?? 0) + left[key] === undefined && right[key] === undefined ? undefined : (left[key] ?? 0) + (right[key] ?? 0) return new Usage({ inputTokens: sum("inputTokens"), @@ -264,16 +276,20 @@ const appendStreamingText = ( state.assistantContent.push({ type, text, providerMetadata }) } -const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect => { +const dispatch = (tools: Tools, call: ToolCallPart): Effect.Effect<{ result: ToolResultValue; error?: unknown }> => { const tool = tools[call.name] - if (!tool) return Effect.succeed({ type: "error" as const, value: `Unknown tool: ${call.name}` }) + if (!tool) return Effect.succeed({ result: { type: "error" as const, value: `Unknown tool: ${call.name}` } }) if (!tool.execute) - return Effect.succeed({ type: "error" as const, value: `Tool has no execute handler: ${call.name}` }) + return Effect.succeed({ result: { type: "error" as const, value: `Tool has no execute handler: ${call.name}` } }) return decodeAndExecute(tool, call).pipe( Effect.catchTag("LLM.ToolFailure", (failure) => - Effect.succeed({ type: "error" as const, value: failure.message } satisfies ToolResultValue), + Effect.succeed({ + result: { type: "error" as const, value: failure.message } satisfies ToolResultValue, + error: failure.error, + }), ), + Effect.map((result) => ("result" in result ? result : { result })), ) } @@ -294,10 +310,10 @@ const decodeAndExecute = (tool: AnyTool, call: ToolCallPart): Effect.Effect ({ type: "json", value: encoded })), ) -const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray => +const emitEvents = (call: ToolCallPart, result: ToolResultValue, error: unknown): ReadonlyArray => result.type === "error" ? [ - LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value) }), + LLMEvent.toolError({ id: call.id, name: call.name, message: String(result.value), error }), LLMEvent.toolResult({ id: call.id, name: call.name, result }), ] : [LLMEvent.toolResult({ id: call.id, name: call.name, result })] diff --git a/packages/llm/test/tool-runtime.test.ts b/packages/llm/test/tool-runtime.test.ts index 573021c4c2..81389a466b 100644 --- a/packages/llm/test/tool-runtime.test.ts +++ b/packages/llm/test/tool-runtime.test.ts @@ -25,6 +25,7 @@ const baseRequest = LLM.request({ model, prompt: "Use the tool.", }) +const weatherFailureCause = new Error("weather lookup denied") const get_weather = tool({ description: "Get current weather for a city.", @@ -32,7 +33,8 @@ const get_weather = tool({ success: Schema.Struct({ temperature: Schema.Number, condition: Schema.String }), execute: ({ city }) => Effect.gen(function* () { - if (city === "FAIL") return yield* new ToolFailure({ message: `Weather lookup failed for ${city}` }) + if (city === "FAIL") + return yield* new ToolFailure({ message: `Weather lookup failed for ${city}`, error: weatherFailureCause }) return { temperature: 22, condition: "sunny" } }), }) @@ -85,23 +87,27 @@ describe("LLMClient tools", () => { tools: { get_weather }, }).pipe(Stream.runCollect, Effect.provide(layer)) - const second = bodies[1] as { - readonly messages?: ReadonlyArray> - readonly tools?: ReadonlyArray - readonly tool_choice?: unknown - readonly max_tokens?: unknown - } + const second = bodies[1] + if (!second || typeof second !== "object") throw new Error("Expected second request body") + const messages = Reflect.get(second, "messages") + const tools = Reflect.get(second, "tools") - expect(second.max_tokens).toBe(50) - expect(second.tool_choice).toBe("auto") - expect(second.tools).toHaveLength(1) - expect(second.messages?.map((message) => message.role)).toEqual(["user", "assistant", "tool"]) - expect(second.messages?.[1]).toMatchObject({ + expect(Reflect.get(second, "max_tokens")).toBe(50) + expect(Reflect.get(second, "tool_choice")).toBe("auto") + expect(tools).toHaveLength(1) + expect( + Array.isArray(messages) + ? messages.map((message) => + message && typeof message === "object" ? Reflect.get(message, "role") : undefined, + ) + : undefined, + ).toEqual(["user", "assistant", "tool"]) + expect(Array.isArray(messages) ? messages[1] : undefined).toMatchObject({ role: "assistant", content: null, tool_calls: [{ id: "call_1", type: "function", function: { name: "get_weather" } }], }) - expect(second.messages?.[2]).toMatchObject({ + expect(Array.isArray(messages) ? messages[2] : undefined).toMatchObject({ role: "tool", tool_call_id: "call_1", content: '{"temperature":22,"condition":"sunny"}', @@ -327,6 +333,7 @@ describe("LLMClient tools", () => { const toolError = events.find(LLMEvent.is.toolError) expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "get_weather" }) expect(toolError?.message).toBe("Weather lookup failed for FAIL") + expect(toolError?.error).toBe(weatherFailureCause) }), ) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ba7b22c691..cb0401ee44 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -42,8 +42,9 @@ "devDependencies": { "@babel/core": "7.28.4", "@octokit/webhooks-types": "7.6.1", - "@opencode-ai/script": "workspace:*", "@opencode-ai/core": "workspace:*", + "@opencode-ai/http-recorder": "workspace:*", + "@opencode-ai/script": "workspace:*", "@parcel/watcher-darwin-arm64": "2.5.1", "@parcel/watcher-darwin-x64": "2.5.1", "@parcel/watcher-linux-arm64-glibc": "2.5.1", @@ -104,6 +105,7 @@ "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", + "@opencode-ai/llm": "workspace:*", "@opencode-ai/plugin": "workspace:*", "@opencode-ai/script": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index 4d184c43b3..268087346d 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -24,6 +24,10 @@ export class Service extends ConfigService.Service()("@opencode/Runtime experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"), experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), experimentalWorkspaces: enabledByExperimental("OPENCODE_EXPERIMENTAL_WORKSPACES"), + experimentalNativeLlm: Config.all({ + enabled: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"), + legacy: Config.string("OPENCODE_LLM_RUNTIME").pipe(Config.withDefault("")), + }).pipe(Config.map((flags) => flags.enabled || flags.legacy === "native")), client: Config.string("OPENCODE_CLIENT").pipe(Config.withDefault("cli")), }) {} diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 116254a81e..29f9e7953c 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -2,7 +2,10 @@ import { Provider } from "@/provider/provider" import * as Log from "@opencode-ai/core/util/log" import { Context, Effect, Layer, Record } from "effect" import * as Stream from "effect/Stream" -import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool, jsonSchema } from "ai" +import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool as aiTool, jsonSchema } from "ai" +import type { LLMEvent } from "@opencode-ai/llm" +import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" +import type { LLMClientService } from "@opencode-ai/llm/route" import { mergeDeep } from "remeda" import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider" import { ProviderTransform } from "@/provider/transform" @@ -23,10 +26,11 @@ import { EffectBridge } from "@/effect/bridge" import { RuntimeFlags } from "@/effect/runtime-flags" import * as Option from "effect/Option" import * as OtelTracer from "@effect/opentelemetry/Tracer" +import { LLMAISDK } from "./llm/ai-sdk" +import { LLMNativeRuntime } from "./llm/native-runtime" const log = Log.create({ service: "llm" }) export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX -type Result = Awaited> // Avoid re-instantiating remeda's deep merge types in this hot LLM path; the runtime behavior is still mergeDeep. const mergeOptions = (target: Record, source: Record | undefined): Record => @@ -51,10 +55,8 @@ export type StreamRequest = StreamInput & { abort: AbortSignal } -export type Event = Result["fullStream"] extends AsyncIterable ? T : never - export interface Interface { - readonly stream: (input: StreamInput) => Stream.Stream + readonly stream: (input: StreamInput) => Stream.Stream } export class Service extends Context.Service()("@opencode/LLM") {} @@ -62,7 +64,13 @@ export class Service extends Context.Service()("@opencode/LL const live: Layer.Layer< Service, never, - Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service | RuntimeFlags.Service + | Auth.Service + | Config.Service + | Provider.Service + | Plugin.Service + | Permission.Service + | LLMClientService + | RuntimeFlags.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -71,6 +79,7 @@ const live: Layer.Layer< const provider = yield* Provider.Service const plugin = yield* Plugin.Service const perm = yield* Permission.Service + const llmClient = yield* LLMClient.Service const flags = yield* RuntimeFlags.Service const run = Effect.fn("LLM.run")(function* (input: StreamRequest) { @@ -214,7 +223,7 @@ const live: Layer.Layer< Object.keys(tools).length === 0 && hasToolCalls(input.messages) ) { - tools["_noop"] = tool({ + tools["_noop"] = aiTool({ description: "Do not call this tool. It exists only for API compatibility and must never be invoked.", inputSchema: jsonSchema({ type: "object", @@ -334,86 +343,141 @@ const live: Layer.Layer< ? (yield* InstanceState.context).project.id : undefined - return streamText({ - onError(error) { - l.error("stream error", { - error, - }) - }, - async experimental_repairToolCall(failed) { - const lower = failed.toolCall.toolName.toLowerCase() - if (lower !== failed.toolCall.toolName && sortedTools[lower]) { - l.info("repairing tool call", { - tool: failed.toolCall.toolName, - repaired: lower, + const requestHeaders = { + ...(input.model.providerID.startsWith("opencode") + ? { + ...(opencodeProjectID ? { "x-opencode-project": opencodeProjectID } : {}), + "x-opencode-session": input.sessionID, + "x-opencode-request": input.user.id, + "x-opencode-client": flags.client, + "User-Agent": `opencode/${InstallationVersion}`, + } + : { + "x-session-affinity": input.sessionID, + ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), + "User-Agent": `opencode/${InstallationVersion}`, + }), + ...input.model.headers, + ...headers, + } + + if (flags.experimentalNativeLlm) { + const native = LLMNativeRuntime.stream({ + model: input.model, + provider: item, + auth: info, + llmClient, + isOpenaiOauth, + system, + messages, + tools: sortedTools, + toolChoice: input.toolChoice, + temperature: params.temperature, + topP: params.topP, + topK: params.topK, + maxOutputTokens: params.maxOutputTokens, + providerOptions: params.options, + headers: requestHeaders, + abort: input.abort, + }) + if (native.type === "supported") { + yield* Effect.logInfo("llm runtime selected").pipe( + Effect.annotateLogs({ + "llm.runtime": "native", + "llm.provider": input.model.providerID, + "llm.model": input.model.id, + }), + ) + return { + type: "native" as const, + stream: native.stream, + } + } + yield* Effect.logInfo("llm runtime selected").pipe( + Effect.annotateLogs({ + "llm.runtime": "ai-sdk", + "llm.provider": input.model.providerID, + "llm.model": input.model.id, + "llm.native_unsupported_reason": native.reason, + }), + ) + l.info("native runtime unavailable; falling back to ai-sdk", { reason: native.reason }) + } + + yield* Effect.logInfo("llm runtime selected").pipe( + Effect.annotateLogs({ + "llm.runtime": "ai-sdk", + "llm.provider": input.model.providerID, + "llm.model": input.model.id, + }), + ) + return { + type: "ai-sdk" as const, + result: streamText({ + onError(error) { + l.error("stream error", { + error, }) + }, + async experimental_repairToolCall(failed) { + const lower = failed.toolCall.toolName.toLowerCase() + if (lower !== failed.toolCall.toolName && sortedTools[lower]) { + l.info("repairing tool call", { + tool: failed.toolCall.toolName, + repaired: lower, + }) + return { + ...failed.toolCall, + toolName: lower, + } + } return { ...failed.toolCall, - toolName: lower, - } - } - return { - ...failed.toolCall, - input: JSON.stringify({ - tool: failed.toolCall.toolName, - error: failed.error.message, - }), - toolName: "invalid", - } - }, - temperature: params.temperature, - topP: params.topP, - topK: params.topK, - providerOptions: ProviderTransform.providerOptions(input.model, params.options), - activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"), - tools: sortedTools, - toolChoice: input.toolChoice, - maxOutputTokens: params.maxOutputTokens, - abortSignal: input.abort, - headers: { - ...(input.model.providerID.startsWith("opencode") - ? { - "x-opencode-project": opencodeProjectID, - "x-opencode-session": input.sessionID, - "x-opencode-request": input.user.id, - "x-opencode-client": flags.client, - "User-Agent": `opencode/${InstallationVersion}`, - } - : { - "x-session-affinity": input.sessionID, - ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), - "User-Agent": `opencode/${InstallationVersion}`, + input: JSON.stringify({ + tool: failed.toolCall.toolName, + error: failed.error.message, }), - ...input.model.headers, - ...headers, - }, - maxRetries: input.retries ?? 0, - messages, - model: wrapLanguageModel({ - model: language, - middleware: [ - { - specificationVersion: "v3" as const, - async transformParams(args) { - if (args.type === "stream") { - // @ts-expect-error - args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options) - } - return args.params - }, - }, - ], - }), - experimental_telemetry: { - isEnabled: cfg.experimental?.openTelemetry, - functionId: "session.llm", - tracer: telemetryTracer, - metadata: { - userId: cfg.username ?? "unknown", - sessionId: input.sessionID, + toolName: "invalid", + } }, - }, - }) + temperature: params.temperature, + topP: params.topP, + topK: params.topK, + providerOptions: ProviderTransform.providerOptions(input.model, params.options), + activeTools: Object.keys(sortedTools).filter((x) => x !== "invalid"), + tools: sortedTools, + toolChoice: input.toolChoice, + maxOutputTokens: params.maxOutputTokens, + abortSignal: input.abort, + headers: requestHeaders, + maxRetries: input.retries ?? 0, + messages, + model: wrapLanguageModel({ + model: language, + middleware: [ + { + specificationVersion: "v3" as const, + async transformParams(args) { + if (args.type === "stream") { + // @ts-expect-error + args.params.prompt = ProviderTransform.message(args.params.prompt, input.model, options) + } + return args.params + }, + }, + ], + }), + experimental_telemetry: { + isEnabled: cfg.experimental?.openTelemetry, + functionId: "session.llm", + tracer: telemetryTracer, + metadata: { + userId: cfg.username ?? "unknown", + sessionId: input.sessionID, + }, + }, + }), + } }) const stream: Interface["stream"] = (input) => @@ -427,7 +491,15 @@ const live: Layer.Layer< const result = yield* run({ ...input, abort: ctrl.signal }) - return Stream.fromAsyncIterable(result.fullStream, (e) => (e instanceof Error ? e : new Error(String(e)))) + if (result.type === "native") return result.stream + + const state = LLMAISDK.adapterState() + return Stream.fromAsyncIterable(result.result.fullStream, (e) => + e instanceof Error ? e : new Error(String(e)), + ).pipe( + Stream.mapEffect((event) => LLMAISDK.toLLMEvents(state, event)), + Stream.flatMap((events) => Stream.fromIterable(events)), + ) }), ), ) @@ -444,6 +516,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Config.defaultLayer), Layer.provide(Provider.defaultLayer), Layer.provide(Plugin.defaultLayer), + Layer.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer))), Layer.provide(RuntimeFlags.defaultLayer), ), ) diff --git a/packages/opencode/src/session/llm/README.md b/packages/opencode/src/session/llm/README.md new file mode 100644 index 0000000000..b3081664b3 --- /dev/null +++ b/packages/opencode/src/session/llm/README.md @@ -0,0 +1,16 @@ +# Session LLM Runtime Boundaries + +`../llm.ts` is the opencode session LLM service. It owns opencode concerns: auth, config, model/provider resolution, plugins, permissions, telemetry headers, and runtime selection. + +This folder contains adapters behind that service boundary: + +- `ai-sdk.ts` converts AI SDK `fullStream` parts into `@opencode-ai/llm` `LLMEvent`s. This is the default runtime path. +- `native-request.ts` converts opencode's normalized session input into a native `@opencode-ai/llm` `LLMRequest`. It does not execute requests. +- `native-runtime.ts` is the opt-in native runtime adapter. It decides whether a selected model is supported, builds the native request, bridges opencode tools into native executable tools, and delegates transport to `LLMClient` / `RequestExecutor`. + +Safety boundary: + +- AI SDK remains the default. +- `OPENCODE_EXPERIMENTAL_NATIVE_LLM=true` is an opt-in hint, not a global replacement. The legacy `OPENCODE_LLM_RUNTIME=native` env var is still accepted by `RuntimeFlags` for local testing. +- 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/ai-sdk.ts b/packages/opencode/src/session/llm/ai-sdk.ts new file mode 100644 index 0000000000..48fe539a8c --- /dev/null +++ b/packages/opencode/src/session/llm/ai-sdk.ts @@ -0,0 +1,235 @@ +import { FinishReason, LLMEvent, ProviderMetadata, ToolResultValue } from "@opencode-ai/llm" +import { Effect, Schema } from "effect" +import { type streamText } from "ai" +import { errorMessage } from "@/util/error" + +type Result = Awaited> +type AISDKEvent = Result["fullStream"] extends AsyncIterable ? T : never + +export function adapterState() { + return { + step: 0, + text: 0, + reasoning: 0, + currentTextID: undefined as string | undefined, + currentReasoningID: undefined as string | undefined, + toolNames: {} as Record, + } +} + +function finishReason(value: string | undefined): FinishReason { + return Schema.is(FinishReason)(value) ? value : "unknown" +} + +function providerMetadata(value: unknown): ProviderMetadata | undefined { + return Schema.is(ProviderMetadata)(value) ? value : undefined +} + +function usage(value: unknown) { + if (!value || typeof value !== "object") return undefined + const item = value as { + inputTokens?: number + outputTokens?: number + totalTokens?: number + reasoningTokens?: number + cachedInputTokens?: number + inputTokenDetails?: { cacheReadTokens?: number; cacheWriteTokens?: number } + outputTokenDetails?: { reasoningTokens?: number } + } + const result = Object.fromEntries( + Object.entries({ + inputTokens: item.inputTokens, + outputTokens: item.outputTokens, + totalTokens: item.totalTokens, + reasoningTokens: item.outputTokenDetails?.reasoningTokens ?? item.reasoningTokens, + cacheReadInputTokens: item.inputTokenDetails?.cacheReadTokens ?? item.cachedInputTokens, + cacheWriteInputTokens: item.inputTokenDetails?.cacheWriteTokens, + }).filter((entry) => entry[1] !== undefined), + ) + return result +} + +function currentTextID(state: ReturnType, id: string | undefined) { + state.currentTextID = id ?? state.currentTextID ?? `text-${state.text++}` + return state.currentTextID +} + +function currentReasoningID(state: ReturnType, id: string | undefined) { + state.currentReasoningID = id ?? state.currentReasoningID ?? `reasoning-${state.reasoning++}` + return state.currentReasoningID +} + +export function toLLMEvents( + state: ReturnType, + event: AISDKEvent, +): Effect.Effect, unknown> { + switch (event.type) { + case "start": + return Effect.succeed([]) + + case "start-step": + return Effect.succeed([LLMEvent.stepStart({ index: state.step })]) + + case "finish-step": + return Effect.sync(() => [ + LLMEvent.stepFinish({ + index: state.step++, + reason: finishReason(event.finishReason), + usage: usage(event.usage), + providerMetadata: providerMetadata(event.providerMetadata), + }), + ]) + + case "finish": + return Effect.sync(() => { + state.toolNames = {} + return [ + LLMEvent.finish({ + reason: finishReason(event.finishReason), + usage: usage(event.totalUsage), + }), + ] + }) + + case "text-start": + return Effect.sync(() => { + state.currentTextID = currentTextID(state, event.id) + return [ + LLMEvent.textStart({ + id: state.currentTextID, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ] + }) + + case "text-delta": + return Effect.succeed([ + LLMEvent.textDelta({ + id: currentTextID(state, event.id), + text: event.text, + }), + ]) + + case "text-end": + return Effect.sync(() => { + const id = currentTextID(state, event.id) + state.currentTextID = undefined + return [ + LLMEvent.textEnd({ + id, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ] + }) + + case "reasoning-start": + return Effect.sync(() => { + state.currentReasoningID = currentReasoningID(state, event.id) + return [ + LLMEvent.reasoningStart({ + id: state.currentReasoningID, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ] + }) + + case "reasoning-delta": + return Effect.succeed([ + LLMEvent.reasoningDelta({ + id: currentReasoningID(state, event.id), + text: event.text, + }), + ]) + + case "reasoning-end": + return Effect.sync(() => { + const id = currentReasoningID(state, event.id) + state.currentReasoningID = undefined + return [ + LLMEvent.reasoningEnd({ + id, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ] + }) + + case "tool-input-start": + return Effect.sync(() => { + state.toolNames[event.id] = event.toolName + return [ + LLMEvent.toolInputStart({ + id: event.id, + name: event.toolName, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ] + }) + + case "tool-input-delta": + return Effect.succeed([ + LLMEvent.toolInputDelta({ + id: event.id, + name: state.toolNames[event.id] ?? "unknown", + text: event.delta ?? "", + }), + ]) + + case "tool-input-end": + return Effect.succeed([ + LLMEvent.toolInputEnd({ + id: event.id, + name: state.toolNames[event.id] ?? "unknown", + }), + ]) + + case "tool-call": + return Effect.sync(() => { + state.toolNames[event.toolCallId] = event.toolName + return [ + LLMEvent.toolCall({ + id: event.toolCallId, + name: event.toolName, + input: event.input, + providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ] + }) + + case "tool-result": + return Effect.sync(() => { + const name = state.toolNames[event.toolCallId] ?? "unknown" + delete state.toolNames[event.toolCallId] + return [ + LLMEvent.toolResult({ + id: event.toolCallId, + name, + result: ToolResultValue.make(event.output), + providerExecuted: "providerExecuted" in event ? event.providerExecuted : undefined, + }), + ] + }) + + case "tool-error": + return Effect.sync(() => { + const name = state.toolNames[event.toolCallId] ?? ("toolName" in event ? event.toolName : "unknown") + delete state.toolNames[event.toolCallId] + return [ + LLMEvent.toolError({ + id: event.toolCallId, + name, + message: errorMessage(event.error), + error: event.error, + }), + ] + }) + + case "error": + return Effect.fail(event.error) + + default: + return Effect.succeed([]) + } +} + +export * as LLMAISDK from "./ai-sdk" diff --git a/packages/opencode/src/session/llm/native-request.ts b/packages/opencode/src/session/llm/native-request.ts new file mode 100644 index 0000000000..ca3ddef173 --- /dev/null +++ b/packages/opencode/src/session/llm/native-request.ts @@ -0,0 +1,188 @@ +import type { JsonSchema, LLMRequest, ProviderMetadata } from "@opencode-ai/llm" +import { LLM, Message, SystemPart, ToolCallPart, ToolDefinition, ToolResultPart } from "@opencode-ai/llm" +import "@opencode-ai/llm/providers" +import type { ModelMessage } from "ai" +import type { Provider } from "@/provider/provider" +import { isRecord } from "@/util/record" + +type ToolInput = { + readonly description?: string + readonly inputSchema?: unknown +} + +export type RequestInput = { + readonly model: Provider.Model + readonly apiKey?: string + readonly baseURL?: string + readonly system?: readonly string[] + readonly messages: readonly ModelMessage[] + readonly tools?: Record + readonly toolChoice?: "auto" | "required" | "none" + readonly temperature?: number + readonly topP?: number + readonly topK?: number + readonly maxOutputTokens?: number + readonly providerOptions?: LLMRequest["providerOptions"] + readonly headers?: Record +} + +const DEFAULT_BASE_URL: Record = { + "@ai-sdk/openai": "https://api.openai.com/v1", + "@ai-sdk/anthropic": "https://api.anthropic.com/v1", + "@ai-sdk/google": "https://generativelanguage.googleapis.com/v1beta", + "@ai-sdk/amazon-bedrock": "https://bedrock-runtime.us-east-1.amazonaws.com", + "@openrouter/ai-sdk-provider": "https://openrouter.ai/api/v1", +} + +const ROUTE: Record = { + "@ai-sdk/openai": "openai-responses", + "@ai-sdk/azure": "azure-openai-responses", + "@ai-sdk/anthropic": "anthropic-messages", + "@ai-sdk/google": "gemini", + "@ai-sdk/amazon-bedrock": "bedrock-converse", + "@ai-sdk/openai-compatible": "openai-compatible-chat", + "@openrouter/ai-sdk-provider": "openrouter", +} + +const providerMetadata = (value: unknown): ProviderMetadata | undefined => { + if (!isRecord(value)) return undefined + const result = Object.fromEntries( + Object.entries(value).filter((entry): entry is [string, Record] => isRecord(entry[1])), + ) + return Object.keys(result).length === 0 ? undefined : result +} + +const textPart = (part: Record) => ({ + type: "text" as const, + text: typeof part.text === "string" ? part.text : "", + providerMetadata: providerMetadata(part.providerOptions), +}) + +const mediaPart = (part: Record) => { + if (typeof part.data !== "string" && !(part.data instanceof Uint8Array)) + throw new Error("Native LLM request adapter only supports file parts with string or Uint8Array data") + return { + type: "media" as const, + mediaType: typeof part.mediaType === "string" ? part.mediaType : "application/octet-stream", + data: part.data, + filename: typeof part.filename === "string" ? part.filename : undefined, + } +} + +const toolResult = (part: Record) => { + const output = isRecord(part.output) ? part.output : { type: "json", value: part.output } + const type = output.type === "text" ? "text" : output.type === "error-text" ? "error" : "json" + return ToolResultPart.make({ + id: typeof part.toolCallId === "string" ? part.toolCallId : "", + name: typeof part.toolName === "string" ? part.toolName : "", + result: "value" in output ? output.value : output, + resultType: type, + providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined, + providerMetadata: providerMetadata(part.providerOptions), + }) +} + +const contentPart = (part: unknown) => { + if (!isRecord(part)) throw new Error("Native LLM request adapter only supports object content parts") + if (part.type === "text") return textPart(part) + if (part.type === "file") return mediaPart(part) + if (part.type === "reasoning") + return { + type: "reasoning" as const, + text: typeof part.text === "string" ? part.text : "", + providerMetadata: providerMetadata(part.providerOptions), + } + if (part.type === "tool-call") + return ToolCallPart.make({ + id: typeof part.toolCallId === "string" ? part.toolCallId : "", + name: typeof part.toolName === "string" ? part.toolName : "", + input: part.input, + providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined, + providerMetadata: providerMetadata(part.providerOptions), + }) + if (part.type === "tool-result") return toolResult(part) + throw new Error(`Native LLM request adapter does not support ${String(part.type)} content parts`) +} + +const content = (value: ModelMessage["content"]) => + typeof value === "string" ? [{ type: "text" as const, text: value }] : value.map(contentPart) + +const messages = (input: readonly ModelMessage[]) => { + const system = input.flatMap((message) => (message.role === "system" ? [SystemPart.make(message.content)] : [])) + const messages = input.flatMap((message) => { + if (message.role === "system") return [] + return [ + Message.make({ + role: message.role, + content: content(message.content), + native: isRecord(message.providerOptions) ? { providerOptions: message.providerOptions } : undefined, + }), + ] + }) + return { system, messages } +} + +const schema = (value: unknown): JsonSchema => { + if (!isRecord(value)) return { type: "object", properties: {} } + if (isRecord(value.jsonSchema)) return value.jsonSchema + return value +} + +const tools = (input: Record | undefined): ToolDefinition[] => + Object.entries(input ?? {}).map(([name, item]) => + ToolDefinition.make({ + name, + description: item.description ?? "", + inputSchema: schema(item.inputSchema), + }), + ) + +const generation = (input: RequestInput) => { + const result = { + temperature: input.temperature, + topP: input.topP, + topK: input.topK, + maxTokens: input.maxOutputTokens, + } + return Object.values(result).some((value) => value !== undefined) ? result : undefined +} + +const baseURL = (model: Provider.Model) => { + if (model.api.url) return model.api.url + const fallback = DEFAULT_BASE_URL[model.api.npm] + if (fallback) return fallback + throw new Error(`Native LLM request adapter requires a base URL for ${model.providerID}/${model.id}`) +} + +export const model = (input: Provider.Model | RequestInput, headers?: Record) => { + const model = "model" in input ? input.model : input + const route = ROUTE[model.api.npm] + if (!route) throw new Error(`Native LLM request adapter does not support provider package ${model.api.npm}`) + return LLM.model({ + id: model.api.id, + provider: model.providerID, + route, + baseURL: "model" in input && input.baseURL ? input.baseURL : baseURL(model), + apiKey: "model" in input ? input.apiKey : undefined, + headers: Object.keys({ ...model.headers, ...headers }).length === 0 ? undefined : { ...model.headers, ...headers }, + limits: { + context: model.limit.context, + output: model.limit.output, + }, + }) +} + +export const request = (input: RequestInput) => { + const converted = messages(input.messages) + return LLM.request({ + model: model(input, input.headers), + system: [...(input.system ?? []).map(SystemPart.make), ...converted.system], + messages: converted.messages, + tools: tools(input.tools), + toolChoice: input.toolChoice, + generation: generation(input), + providerOptions: input.providerOptions, + }) +} + +export * as LLMNative from "./native-request" diff --git a/packages/opencode/src/session/llm/native-runtime.ts b/packages/opencode/src/session/llm/native-runtime.ts new file mode 100644 index 0000000000..e2991e161a --- /dev/null +++ b/packages/opencode/src/session/llm/native-runtime.ts @@ -0,0 +1,124 @@ +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" +import { tool as nativeTool, ToolFailure, type JsonSchema, type LLMEvent } from "@opencode-ai/llm" +import type { LLMClientShape } from "@opencode-ai/llm/route" +import { LLMNative } from "./native-request" + +export type RuntimeStatus = + | { readonly type: "supported"; readonly apiKey: string; readonly baseURL?: string } + | { readonly type: "unsupported"; readonly reason: string } +export type StreamResult = + | { readonly type: "supported"; readonly stream: Stream.Stream } + | { readonly type: "unsupported"; readonly reason: string } + +type StreamInput = { + readonly model: Provider.Model + readonly provider: Provider.Info + readonly auth: Auth.Info | undefined + readonly llmClient: LLMClientShape + readonly isOpenaiOauth: boolean + readonly system: string[] + readonly messages: ModelMessage[] + readonly tools: Record + readonly toolChoice?: "auto" | "required" | "none" + readonly temperature?: number + readonly topP?: number + readonly topK?: number + readonly maxOutputTokens?: number + readonly providerOptions?: Record + readonly headers: Record + readonly abort: AbortSignal +} + +export function status(input: Pick): RuntimeStatus { + 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: "OAuth auth is not supported" } + + const apiKey = + input.auth?.type === "api" + ? input.auth.key + : typeof input.provider.options.apiKey === "string" + ? input.provider.options.apiKey + : undefined + if (!apiKey) return { type: "unsupported", reason: "OpenAI API key is not configured" } + + return { + type: "supported", + apiKey, + baseURL: typeof input.provider.options.baseURL === "string" ? input.provider.options.baseURL : undefined, + } +} + +export function stream(input: StreamInput): StreamResult { + const current = status(input) + if (current.type === "unsupported") return current + + return { + ...current, + stream: input.llmClient.stream({ + request: LLMNative.request({ + model: input.model, + apiKey: current.apiKey, + baseURL: current.baseURL, + system: input.isOpenaiOauth ? input.system : [], + messages: ProviderTransform.message(input.messages, input.model, input.providerOptions ?? {}), + toolChoice: input.toolChoice, + temperature: input.temperature, + topP: input.topP, + topK: input.topK, + maxOutputTokens: input.maxOutputTokens, + providerOptions: ProviderTransform.providerOptions(input.model, input.providerOptions ?? {}), + 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") + return value.jsonSchema as JsonSchema + return asSchema(value as Parameters[0]).jsonSchema as JsonSchema +} + +function nativeTools(tools: Record, input: Pick) { + return Object.fromEntries( + Object.entries(tools).map(([name, item]) => [ + name, + nativeTool({ + description: item.description ?? "", + jsonSchema: nativeSchema(item.inputSchema), + execute: (args: unknown, ctx) => + Effect.tryPromise({ + try: () => { + if (!item.execute) throw new Error(`Tool has no execute handler: ${name}`) + return item.execute(args, { + toolCallId: ctx?.id ?? name, + messages: input.messages, + abortSignal: input.abort, + }) + }, + catch: (error) => new ToolFailure({ message: errorMessage(error), error }), + }), + }), + ]), + ) +} + +export * as LLMNativeRuntime from "./native-runtime" diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts index 7ba9631e66..2d5ddc7c8c 100644 --- a/packages/opencode/src/session/processor.ts +++ b/packages/opencode/src/session/processor.ts @@ -1,4 +1,4 @@ -import { Cause, Deferred, Effect, Exit, Layer, Context, Scope } from "effect" +import { Cause, Deferred, Effect, Exit, Layer, Context, Scope, Schema } from "effect" import * as Stream from "effect/Stream" import { Agent } from "@/agent/agent" import { Bus } from "@/bus" @@ -9,7 +9,6 @@ import { Snapshot } from "@/snapshot" import * as Session from "./session" import { LLM } from "./llm" import { MessageV2 } from "./message-v2" -import { Image } from "@/image/image" import { isOverflow } from "./overflow" import { PartID } from "./schema" import type { SessionID } from "./schema" @@ -27,14 +26,13 @@ import { ModelV2 } from "@opencode-ai/core/model" import { ProviderV2 } from "@opencode-ai/core/provider" import * as DateTime from "effect/DateTime" import { RuntimeFlags } from "@/effect/runtime-flags" +import { Usage, type LLMEvent } from "@opencode-ai/llm" const DOOM_LOOP_THRESHOLD = 3 const log = Log.create({ service: "session.processor" }) export type Result = "compact" | "stop" | "continue" -export type Event = LLM.Event - export interface Handle { readonly message: MessageV2.Assistant readonly updateToolCall: ( @@ -68,6 +66,7 @@ type ToolCall = { messageID: MessageV2.ToolPart["messageID"] sessionID: MessageV2.ToolPart["sessionID"] done: Deferred.Deferred + inputEnded: boolean } interface ProcessorContext extends Input { @@ -80,7 +79,7 @@ interface ProcessorContext extends Input { reasoningMap: Record } -type StreamEvent = Event +type StreamEvent = LLMEvent export class Service extends Context.Service()("@opencode/SessionProcessor") {} @@ -95,7 +94,6 @@ export const layer: Layer.Layer< | LLM.Service | Permission.Service | Plugin.Service - | Image.Service | SessionSummary.Service | SessionStatus.Service | SyncEvent.Service @@ -114,7 +112,6 @@ export const layer: Layer.Layer< const summary = yield* SessionSummary.Service const scope = yield* Scope.Scope const status = yield* SessionStatus.Service - const image = yield* Image.Service const sync = yield* SyncEvent.Service const flags = yield* RuntimeFlags.Service @@ -152,7 +149,7 @@ export const layer: Layer.Layer< const readToolCall = Effect.fn("SessionProcessor.readToolCall")(function* (toolCallID: string) { const call = ctx.toolcalls[toolCallID] - if (!call) return + if (!call) return undefined const part = yield* session.getPart({ partID: call.partID, messageID: call.messageID, @@ -160,7 +157,7 @@ export const layer: Layer.Layer< }) if (!part || part.type !== "tool") { delete ctx.toolcalls[toolCallID] - return + return undefined } return { call, part } }) @@ -170,7 +167,7 @@ export const layer: Layer.Layer< update: (part: MessageV2.ToolPart) => MessageV2.ToolPart, ) { const match = yield* readToolCall(toolCallID) - if (!match) return + if (!match) return undefined const part = yield* session.updatePart(update(match.part)) ctx.toolcalls[toolCallID] = { ...match.call, @@ -226,12 +223,98 @@ export const layer: Layer.Layer< return true }) + const finishReasoning = Effect.fn("SessionProcessor.finishReasoning")(function* (reasoningID: string) { + if (!(reasoningID in ctx.reasoningMap)) return + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + if (flags.experimentalEventSystem) { + yield* sync.run(SessionEvent.Reasoning.Ended.Sync, { + sessionID: ctx.sessionID, + reasoningID, + text: ctx.reasoningMap[reasoningID].text, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } + // oxlint-disable-next-line no-self-assign -- reactivity trigger + ctx.reasoningMap[reasoningID].text = ctx.reasoningMap[reasoningID].text + ctx.reasoningMap[reasoningID].time = { ...ctx.reasoningMap[reasoningID].time, end: Date.now() } + yield* session.updatePart(ctx.reasoningMap[reasoningID]) + delete ctx.reasoningMap[reasoningID] + }) + + const ensureToolCall = Effect.fn("SessionProcessor.ensureToolCall")(function* (input: { + id: string + name: string + providerExecuted?: boolean + }) { + const existing = yield* readToolCall(input.id) + if (existing) { + if (!input.providerExecuted || existing.part.metadata?.providerExecuted) return existing + const part = yield* session.updatePart({ + ...existing.part, + metadata: { ...existing.part.metadata, providerExecuted: true }, + }) + ctx.toolcalls[input.id] = { + ...existing.call, + partID: part.id, + messageID: part.messageID, + sessionID: part.sessionID, + } + return { call: ctx.toolcalls[input.id], part } + } + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + if (flags.experimentalEventSystem) { + yield* sync.run(SessionEvent.Tool.Input.Started.Sync, { + sessionID: ctx.sessionID, + callID: input.id, + name: input.name, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } + const part = yield* session.updatePart({ + id: PartID.ascending(), + messageID: ctx.assistantMessage.id, + sessionID: ctx.assistantMessage.sessionID, + type: "tool", + tool: input.name, + callID: input.id, + state: { status: "pending", input: {}, raw: "" }, + metadata: input.providerExecuted ? { providerExecuted: true } : undefined, + } satisfies MessageV2.ToolPart) + ctx.toolcalls[input.id] = { + done: yield* Deferred.make(), + partID: part.id, + messageID: part.messageID, + sessionID: part.sessionID, + inputEnded: false, + } + return { call: ctx.toolcalls[input.id], part } + }) + + const isFilePart = Schema.is(MessageV2.FilePart) + + const toolResultOutput = (value: Extract) => { + if (isRecord(value.result.value) && typeof value.result.value.output === "string") { + return { + title: typeof value.result.value.title === "string" ? value.result.value.title : value.name, + metadata: isRecord(value.result.value.metadata) ? value.result.value.metadata : {}, + output: value.result.value.output, + attachments: Array.isArray(value.result.value.attachments) + ? value.result.value.attachments.filter(isFilePart) + : undefined, + } + } + return { + title: value.name, + metadata: value.result.type === "json" && isRecord(value.result.value) ? value.result.value : {}, + output: + typeof value.result.value === "string" ? value.result.value : (JSON.stringify(value.result.value) ?? ""), + } + } + + const toolInput = (value: unknown): Record => (isRecord(value) ? value : { value }) + const handleEvent = Effect.fnUntraced(function* (value: StreamEvent) { switch (value.type) { - case "start": - yield* status.set(ctx.sessionID, { type: "busy" }) - return - case "reasoning-start": if (value.id in ctx.reasoningMap) return // TODO(v2): Temporary dual-write while migrating session messages to v2 events. @@ -254,116 +337,132 @@ export const layer: Layer.Layer< yield* session.updatePart(ctx.reasoningMap[value.id]) return - case "reasoning-delta": - if (!(value.id in ctx.reasoningMap)) return - ctx.reasoningMap[value.id].text += value.text - if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata + case "reasoning-delta": { + const reasoningID = value.id ?? "reasoning" + if (!(reasoningID in ctx.reasoningMap)) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + if (flags.experimentalEventSystem) { + yield* sync.run(SessionEvent.Reasoning.Started.Sync, { + sessionID: ctx.sessionID, + reasoningID, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } + ctx.reasoningMap[reasoningID] = { + id: PartID.ascending(), + messageID: ctx.assistantMessage.id, + sessionID: ctx.assistantMessage.sessionID, + type: "reasoning", + text: "", + time: { start: Date.now() }, + } + yield* session.updatePart(ctx.reasoningMap[reasoningID]) + } + ctx.reasoningMap[reasoningID].text += value.text yield* session.updatePartDelta({ - sessionID: ctx.reasoningMap[value.id].sessionID, - messageID: ctx.reasoningMap[value.id].messageID, - partID: ctx.reasoningMap[value.id].id, + sessionID: ctx.reasoningMap[reasoningID].sessionID, + messageID: ctx.reasoningMap[reasoningID].messageID, + partID: ctx.reasoningMap[reasoningID].id, field: "text", delta: value.text, }) return + } case "reasoning-end": - if (!(value.id in ctx.reasoningMap)) return - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Reasoning.Ended.Sync, { - sessionID: ctx.sessionID, - reasoningID: value.id, - text: ctx.reasoningMap[value.id].text, - timestamp: DateTime.makeUnsafe(Date.now()), - }) + if (value.providerMetadata && value.id in ctx.reasoningMap) { + ctx.reasoningMap[value.id].metadata = value.providerMetadata } - // oxlint-disable-next-line no-self-assign -- reactivity trigger - ctx.reasoningMap[value.id].text = ctx.reasoningMap[value.id].text - ctx.reasoningMap[value.id].time = { ...ctx.reasoningMap[value.id].time, end: Date.now() } - if (value.providerMetadata) ctx.reasoningMap[value.id].metadata = value.providerMetadata - yield* session.updatePart(ctx.reasoningMap[value.id]) - delete ctx.reasoningMap[value.id] + yield* finishReasoning(value.id) return case "tool-input-start": if (ctx.assistantMessage.summary) { - throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) - } - // TODO(v2): Temporary dual-write while migrating session messages to v2 events. - if (flags.experimentalEventSystem) { - yield* sync.run(SessionEvent.Tool.Input.Started.Sync, { - sessionID: ctx.sessionID, - callID: value.id, - name: value.toolName, - timestamp: DateTime.makeUnsafe(Date.now()), - }) - } - const part = yield* session.updatePart({ - id: ctx.toolcalls[value.id]?.partID ?? PartID.ascending(), - messageID: ctx.assistantMessage.id, - sessionID: ctx.assistantMessage.sessionID, - type: "tool", - tool: value.toolName, - callID: value.id, - state: { status: "pending", input: {}, raw: "" }, - metadata: value.providerExecuted ? { providerExecuted: true } : undefined, - } satisfies MessageV2.ToolPart) - ctx.toolcalls[value.id] = { - done: yield* Deferred.make(), - partID: part.id, - messageID: part.messageID, - sessionID: part.sessionID, + throw new Error(`Tool call not allowed while generating summary: ${value.name}`) } + yield* ensureToolCall(value) return - case "tool-input-delta": + case "tool-input-delta": { + if (ctx.assistantMessage.summary) { + throw new Error(`Tool call not allowed while generating summary: ${value.name}`) + } + yield* ensureToolCall(value) + if (value.text) { + yield* updateToolCall(value.id, (match) => ({ + ...match, + state: + match.state.status === "pending" + ? { ...match.state, raw: match.state.raw + value.text } + : match.state, + })) + } return + } case "tool-input-end": { + const toolCall = yield* ensureToolCall(value) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Tool.Input.Ended.Sync, { sessionID: ctx.sessionID, callID: value.id, - text: "", + text: toolCall.part.state.status === "pending" ? toolCall.part.state.raw : "", timestamp: DateTime.makeUnsafe(Date.now()), }) } + ctx.toolcalls[value.id] = { ...toolCall.call, inputEnded: true } return } case "tool-call": { if (ctx.assistantMessage.summary) { - throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`) + throw new Error(`Tool call not allowed while generating summary: ${value.name}`) + } + const toolCall = yield* ensureToolCall(value) + const input = toolInput(value.input) + const raw = toolCall.part.state.status === "pending" ? toolCall.part.state.raw : "" + if (!toolCall.call.inputEnded) { + // TODO(v2): Temporary dual-write while migrating session messages to v2 events. + if (flags.experimentalEventSystem) { + yield* sync.run(SessionEvent.Tool.Input.Ended.Sync, { + sessionID: ctx.sessionID, + callID: value.id, + text: raw, + timestamp: DateTime.makeUnsafe(Date.now()), + }) + } } - const toolCall = yield* readToolCall(value.toolCallId) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Tool.Called.Sync, { sessionID: ctx.sessionID, - callID: value.toolCallId, - tool: value.toolName, - input: value.input, + callID: value.id, + tool: value.name, + input, provider: { - executed: toolCall?.part.metadata?.providerExecuted === true, + executed: toolCall.part.metadata?.providerExecuted === true, ...(value.providerMetadata ? { metadata: value.providerMetadata } : {}), }, timestamp: DateTime.makeUnsafe(Date.now()), }) } - yield* updateToolCall(value.toolCallId, (match) => ({ + yield* updateToolCall(value.id, (match) => ({ ...match, - tool: value.toolName, - state: { - ...match.state, - status: "running", - input: value.input, - time: { start: Date.now() }, + tool: value.name, + state: + match.state.status === "running" + ? { ...match.state, input } + : { + status: "running", + input, + time: { start: Date.now() }, + }, + metadata: { + ...match.metadata, + ...value.providerMetadata, + ...(match.metadata?.providerExecuted ? { providerExecuted: true } : {}), }, - metadata: match.metadata?.providerExecuted - ? { ...value.providerMetadata, providerExecuted: true } - : value.providerMetadata, })) const parts = MessageV2.parts(ctx.assistantMessage.id) @@ -374,9 +473,9 @@ export const layer: Layer.Layer< !recentParts.every( (part) => part.type === "tool" && - part.tool === value.toolName && + part.tool === value.name && part.state.status !== "pending" && - JSON.stringify(part.state.input) === JSON.stringify(value.input), + JSON.stringify(part.state.input) === JSON.stringify(input), ) ) { return @@ -385,54 +484,36 @@ export const layer: Layer.Layer< const agent = yield* agents.get(ctx.assistantMessage.agent) yield* permission.ask({ permission: "doom_loop", - patterns: [value.toolName], + patterns: [value.name], sessionID: ctx.assistantMessage.sessionID, - metadata: { tool: value.toolName, input: value.input }, - always: [value.toolName], + metadata: { tool: value.name, input }, + always: [value.name], ruleset: agent.permission, }) return } case "tool-result": { - const toolCall = yield* readToolCall(value.toolCallId) - const toolAttachments: MessageV2.FilePart[] = ( - Array.isArray(value.output.attachments) ? value.output.attachments : [] - ).filter( - (attachment: unknown): attachment is MessageV2.FilePart => - isRecord(attachment) && - attachment.type === "file" && - typeof attachment.mime === "string" && - typeof attachment.url === "string", - ) - const normalized = yield* Effect.forEach(toolAttachments, (attachment) => - attachment.mime.startsWith("image/") - ? image - .normalize(attachment) - .pipe( - Effect.catchIf( - (error) => error instanceof Image.ResizerUnavailableError, - () => Effect.succeed(attachment), - ), - Effect.exit, - ) - : Effect.succeed(Exit.succeed(attachment)), + const toolCall = yield* readToolCall(value.id) + const rawOutput = toolResultOutput(value) + const normalized = yield* Effect.forEach(rawOutput.attachments ?? [], (attachment) => + Effect.succeed(Exit.succeed(attachment)), ) const omitted = normalized.filter(Exit.isFailure).length const attachments = normalized.filter(Exit.isSuccess).map((item) => item.value) const output = { - ...value.output, + ...rawOutput, output: omitted === 0 - ? value.output.output - : `${value.output.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`, - attachments: attachments?.length ? attachments : undefined, + ? rawOutput.output + : `${rawOutput.output}\n\n[${omitted} image${omitted === 1 ? "" : "s"} omitted: could not be resized below the image size limit.]`, + attachments: attachments.length ? attachments : undefined, } // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Tool.Success.Sync, { sessionID: ctx.sessionID, - callID: value.toolCallId, + callID: value.id, structured: output.metadata, content: [ { @@ -440,32 +521,32 @@ export const layer: Layer.Layer< text: output.output, }, ...(output.attachments?.map((item: MessageV2.FilePart) => ({ - type: "file", + type: "file" as const, uri: item.url, mime: item.mime, name: item.filename, })) ?? []), ], provider: { - executed: toolCall?.part.metadata?.providerExecuted === true, + executed: value.providerExecuted === true || toolCall?.part.metadata?.providerExecuted === true, }, timestamp: DateTime.makeUnsafe(Date.now()), }) } - yield* completeToolCall(value.toolCallId, output) + yield* completeToolCall(value.id, output) return } case "tool-error": { - const toolCall = yield* readToolCall(value.toolCallId) + const toolCall = yield* readToolCall(value.id) // TODO(v2): Temporary dual-write while migrating session messages to v2 events. if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Tool.Failed.Sync, { sessionID: ctx.sessionID, - callID: value.toolCallId, + callID: value.id, error: { type: "unknown", - message: errorMessage(value.error), + message: value.message, }, provider: { executed: toolCall?.part.metadata?.providerExecuted === true, @@ -473,14 +554,14 @@ export const layer: Layer.Layer< timestamp: DateTime.makeUnsafe(Date.now()), }) } - yield* failToolCall(value.toolCallId, value.error) + yield* failToolCall(value.id, value.error ?? new Error(value.message)) return } - case "error": - throw value.error + case "provider-error": + throw new Error(value.message) - case "start-step": + case "step-start": if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track() if (!ctx.assistantMessage.summary) { // TODO(v2): Temporary dual-write while migrating session messages to v2 events. @@ -507,11 +588,12 @@ export const layer: Layer.Layer< }) return - case "finish-step": { + case "step-finish": { const completedSnapshot = yield* snapshot.track() + yield* Effect.forEach(Object.keys(ctx.reasoningMap), finishReasoning) const usage = Session.getUsage({ model: ctx.model, - usage: value.usage, + usage: value.usage ?? new Usage({}), metadata: value.providerMetadata, }) if (!ctx.assistantMessage.summary) { @@ -519,7 +601,7 @@ export const layer: Layer.Layer< if (flags.experimentalEventSystem) { yield* sync.run(SessionEvent.Step.Ended.Sync, { sessionID: ctx.sessionID, - finish: value.finishReason, + finish: value.reason, cost: usage.cost, tokens: usage.tokens, snapshot: completedSnapshot, @@ -527,12 +609,12 @@ export const layer: Layer.Layer< }) } } - ctx.assistantMessage.finish = value.finishReason + ctx.assistantMessage.finish = value.reason ctx.assistantMessage.cost += usage.cost ctx.assistantMessage.tokens = usage.tokens yield* session.updatePart({ id: PartID.ascending(), - reason: value.finishReason, + reason: value.reason, snapshot: completedSnapshot, messageID: ctx.assistantMessage.id, sessionID: ctx.assistantMessage.sessionID, @@ -595,7 +677,6 @@ export const layer: Layer.Layer< case "text-delta": if (!ctx.currentText) return ctx.currentText.text += value.text - if (value.providerMetadata) ctx.currentText.metadata = value.providerMetadata yield* session.updatePartDelta({ sessionID: ctx.currentText.sessionID, messageID: ctx.currentText.messageID, @@ -639,10 +720,6 @@ export const layer: Layer.Layer< case "finish": return - - default: - slog.info("unhandled", { event: value.type, value }) - return } }) @@ -744,6 +821,7 @@ export const layer: Layer.Layer< yield* Effect.gen(function* () { ctx.currentText = undefined ctx.reasoningMap = {} + yield* status.set(ctx.sessionID, { type: "busy" }) const stream = llm.stream(streamInput) yield* stream.pipe( @@ -829,10 +907,9 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Plugin.defaultLayer), Layer.provide(SessionSummary.defaultLayer), Layer.provide(SessionStatus.defaultLayer), - Layer.provide(Image.defaultLayer), + Layer.provide(SyncEvent.defaultLayer), Layer.provide(Bus.layer), Layer.provide(Config.defaultLayer), - Layer.provide(SyncEvent.defaultLayer), Layer.provide(RuntimeFlags.defaultLayer), ), ) diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 633f21e451..72f8fc0c12 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -62,6 +62,7 @@ import * as DateTime from "effect/DateTime" import { eq } from "@/storage/db" import * as Database from "@/storage/db" import { SessionTable } from "./session.sql" +import { LLMEvent } from "@opencode-ai/llm" // @ts-ignore globalThis.AI_SDK_LOG_WARNINGS = false @@ -363,7 +364,7 @@ export const layer = Layer.effect( messages: [{ role: "user", content: "Generate a title for this conversation:\n" }, ...msgs], }) .pipe( - Stream.filter((e): e is Extract => e.type === "text-delta"), + Stream.filter(LLMEvent.is.textDelta), Stream.map((e) => e.text), Stream.mkString, Effect.orDie, diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 85486480aa..e35e539fdb 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -3,7 +3,8 @@ import path from "path" import { BusEvent } from "@/bus/bus-event" import { Bus } from "@/bus" import { Decimal } from "decimal.js" -import { type ProviderMetadata, type LanguageModelUsage } from "ai" +import { Flag } from "@opencode-ai/core/flag/flag" +import type { ProviderMetadata, Usage } from "@opencode-ai/llm" import { InstallationVersion } from "@opencode-ai/core/installation/version" import { Database } from "@/storage/db" @@ -373,21 +374,19 @@ export function plan(input: { slug: string; time: { created: number } }, instanc return path.join(base, [input.time.created, input.slug].join("-") + ".md") } -export const getUsage = (input: { model: Provider.Model; usage: LanguageModelUsage; metadata?: ProviderMetadata }) => { +export const getUsage = (input: { model: Provider.Model; usage: Usage; metadata?: ProviderMetadata }) => { const safe = (value: number) => { if (!Number.isFinite(value)) return 0 return Math.max(0, value) } const inputTokens = safe(input.usage.inputTokens ?? 0) const outputTokens = safe(input.usage.outputTokens ?? 0) - const reasoningTokens = safe(input.usage.outputTokenDetails?.reasoningTokens ?? input.usage.reasoningTokens ?? 0) + const reasoningTokens = safe(input.usage.reasoningTokens ?? 0) - const cacheReadInputTokens = safe( - input.usage.inputTokenDetails?.cacheReadTokens ?? input.usage.cachedInputTokens ?? 0, - ) + const cacheReadInputTokens = safe(input.usage.cacheReadInputTokens ?? 0) const cacheWriteInputTokens = safe( Number( - input.usage.inputTokenDetails?.cacheWriteTokens ?? + input.usage.cacheWriteInputTokens ?? input.metadata?.["anthropic"]?.["cacheCreationInputTokens"] ?? // google-vertex-anthropic returns metadata under "vertex" key // (AnthropicMessagesLanguageModel custom provider key from 'vertex.anthropic.messages') diff --git a/packages/opencode/test/effect/runtime-flags.test.ts b/packages/opencode/test/effect/runtime-flags.test.ts index 2f5fa25abf..204d63fad7 100644 --- a/packages/opencode/test/effect/runtime-flags.test.ts +++ b/packages/opencode/test/effect/runtime-flags.test.ts @@ -35,10 +35,21 @@ describe("RuntimeFlags", () => { expect(flags.experimentalPlanMode).toBe(true) expect(flags.experimentalEventSystem).toBe(true) expect(flags.experimentalWorkspaces).toBe(true) + expect(flags.experimentalNativeLlm).toBe(false) expect(flags.client).toBe("desktop") }), ) + it.effect("requires explicit native LLM opt-in", () => + Effect.gen(function* () { + const explicit = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_EXPERIMENTAL_NATIVE_LLM: "true" }))) + const legacy = yield* readFlags.pipe(Effect.provide(fromConfig({ OPENCODE_LLM_RUNTIME: "native" }))) + + expect(explicit.experimentalNativeLlm).toBe(true) + expect(legacy.experimentalNativeLlm).toBe(true) + }), + ) + it.effect("layer accepts partial test overrides and fills defaults from Config definitions", () => Effect.gen(function* () { const flags = yield* readFlags.pipe(Effect.provide(RuntimeFlags.layer({ disableDefaultPlugins: true }))) diff --git a/packages/opencode/test/fixtures/recordings/session/native-openai-tool-call.json b/packages/opencode/test/fixtures/recordings/session/native-openai-tool-call.json new file mode 100644 index 0000000000..b6670d58aa --- /dev/null +++ b/packages/opencode/test/fixtures/recordings/session/native-openai-tool-call.json @@ -0,0 +1,31 @@ +{ + "version": 1, + "metadata": { + "name": "session/native-openai-tool-call", + "recordedAt": "2026-05-13T00:27:15.166Z", + "provider": "openai", + "protocol": "openai-responses", + "route": "openai-responses", + "tags": ["opencode", "native", "tool-call"] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-4.1-mini\",\"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-tool\",\"temperature\":0,\"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_09354e0087427e4e016a03c56273e481a1842a9d4d6e5c3434\",\"object\":\"response\",\"created_at\":1778632034,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"session-recorded-native-tool\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":0.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\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_09354e0087427e4e016a03c56273e481a1842a9d4d6e5c3434\",\"object\":\"response\",\"created_at\":1778632034,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"session-recorded-native-tool\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":0.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\":1.0,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_09354e0087427e4e016a03c562e5e881a1af75085c0cd7b52f\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_0bqJ0EdThTwv5g1VILLkf9bo\",\"name\":\"lookup\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_09354e0087427e4e016a03c562e5e881a1af75085c0cd7b52f\",\"obfuscation\":\"sDHc7xGP1uQu4v\",\"output_index\":0,\"sequence_number\":3}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"query\",\"item_id\":\"fc_09354e0087427e4e016a03c562e5e881a1af75085c0cd7b52f\",\"obfuscation\":\"wGG9bOcTCVa\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_09354e0087427e4e016a03c562e5e881a1af75085c0cd7b52f\",\"obfuscation\":\"i3uIOqQeUw5x4\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"weather\",\"item_id\":\"fc_09354e0087427e4e016a03c562e5e881a1af75085c0cd7b52f\",\"obfuscation\":\"Y6emvEwAT\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_09354e0087427e4e016a03c562e5e881a1af75085c0cd7b52f\",\"obfuscation\":\"e5oTX3Ry6hrVEC\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"query\\\":\\\"weather\\\"}\",\"item_id\":\"fc_09354e0087427e4e016a03c562e5e881a1af75085c0cd7b52f\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_09354e0087427e4e016a03c562e5e881a1af75085c0cd7b52f\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"query\\\":\\\"weather\\\"}\",\"call_id\":\"call_0bqJ0EdThTwv5g1VILLkf9bo\",\"name\":\"lookup\"},\"output_index\":0,\"sequence_number\":9}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_09354e0087427e4e016a03c56273e481a1842a9d4d6e5c3434\",\"object\":\"response\",\"created_at\":1778632034,\"status\":\"completed\",\"background\":false,\"completed_at\":1778632034,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":null,\"max_tool_calls\":null,\"model\":\"gpt-4.1-mini-2025-04-14\",\"moderation\":null,\"output\":[{\"id\":\"fc_09354e0087427e4e016a03c562e5e881a1af75085c0cd7b52f\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"query\\\":\\\"weather\\\"}\",\"call_id\":\"call_0bqJ0EdThTwv5g1VILLkf9bo\",\"name\":\"lookup\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":\"session-recorded-native-tool\",\"prompt_cache_retention\":\"in_memory\",\"reasoning\":{\"effort\":null,\"summary\":null},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":0.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\":1.0,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":72,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":6,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":78},\"user\":null,\"metadata\":{}},\"sequence_number\":10}\n\n" + } + } + ] +} 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/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index d8a4167902..ea5e937166 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -29,6 +29,7 @@ import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { TestConfig } from "../fixture/config" import { SyncEvent } from "@/sync" import { RuntimeFlags } from "@/effect/runtime-flags" +import { LLMEvent, Usage } from "@opencode-ai/llm" void Log.init({ print: false }) @@ -46,6 +47,10 @@ const ref = { modelID: ModelID.make("test-model"), } +const usage = (input: ConstructorParameters[0]) => new Usage(input) + +const basicUsage = () => usage({ inputTokens: 1, outputTokens: 1, totalTokens: 2 }) + afterEach(() => { mock.restore() }) @@ -293,11 +298,11 @@ function readCompactionPart(sessionID: SessionID) { function llm() { const queue: Array< - Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream) + Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream) > = [] return { - push(stream: Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream)) { + push(stream: Stream.Stream | ((input: LLM.StreamInput) => Stream.Stream)) { queue.push(stream) }, layer: Layer.succeed( @@ -316,54 +321,22 @@ function llm() { function reply( text: string, capture?: (input: LLM.StreamInput) => void, -): (input: LLM.StreamInput) => Stream.Stream { +): (input: LLM.StreamInput) => Stream.Stream { return (input) => { capture?.(input) return Stream.make( - { type: "start" } satisfies LLM.Event, - { type: "text-start", id: "txt-0" } satisfies LLM.Event, - { type: "text-delta", id: "txt-0", delta: text, text } as LLM.Event, - { type: "text-end", id: "txt-0" } satisfies LLM.Event, - { - type: "finish-step", - finishReason: "stop", - rawFinishReason: "stop", - response: { id: "res", modelId: "test-model", timestamp: new Date() }, - providerMetadata: undefined, - usage: { - inputTokens: 1, - outputTokens: 1, - totalTokens: 2, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, - } satisfies LLM.Event, - { - type: "finish", - finishReason: "stop", - rawFinishReason: "stop", - totalUsage: { - inputTokens: 1, - outputTokens: 1, - totalTokens: 2, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, - } satisfies LLM.Event, + LLMEvent.textStart({ id: "txt-0" }), + LLMEvent.textDelta({ id: "txt-0", text }), + LLMEvent.textEnd({ id: "txt-0" }), + LLMEvent.stepFinish({ + index: 0, + reason: "stop", + usage: basicUsage(), + }), + LLMEvent.finish({ + reason: "stop", + usage: basicUsage(), + }), ) } } @@ -1201,7 +1174,7 @@ describe("session.compaction.process", () => { Stream.fromAsyncIterable( { async *[Symbol.asyncIterator]() { - yield { type: "start" } as LLM.Event + yield LLMEvent.stepStart({ index: 0 }) throw new APICallError({ message: "boom", url: "https://example.com/v1/chat/completions", @@ -1293,49 +1266,16 @@ describe("session.compaction.process", () => { const stub = llm() stub.push( Stream.make( - { type: "start" } satisfies LLM.Event, - { type: "tool-input-start", id: "call-1", toolName: "_noop" } satisfies LLM.Event, - { type: "tool-call", toolCallId: "call-1", toolName: "_noop", input: {} } satisfies LLM.Event, - { - type: "finish-step", - finishReason: "tool-calls", - rawFinishReason: "tool_calls", - response: { id: "res", modelId: "test-model", timestamp: new Date() }, - providerMetadata: undefined, - usage: { - inputTokens: 1, - outputTokens: 1, - totalTokens: 2, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, - } satisfies LLM.Event, - { - type: "finish", - finishReason: "tool-calls", - rawFinishReason: "tool_calls", - totalUsage: { - inputTokens: 1, - outputTokens: 1, - totalTokens: 2, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, - } satisfies LLM.Event, + LLMEvent.toolCall({ id: "call-1", name: "_noop", input: {} }), + LLMEvent.stepFinish({ + index: 0, + reason: "tool-calls", + usage: basicUsage(), + }), + LLMEvent.finish({ + reason: "tool-calls", + usage: basicUsage(), + }), ), ) return Effect.gen(function* () { @@ -1541,20 +1481,7 @@ describe("SessionNs.getUsage", () => { const model = createModel({ context: 100_000, output: 32_000 }) const result = SessionNs.getUsage({ model, - usage: { - inputTokens: 1000, - outputTokens: 500, - totalTokens: 1500, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, + usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }), }) expect(result.tokens.input).toBe(1000) @@ -1568,20 +1495,7 @@ describe("SessionNs.getUsage", () => { const model = createModel({ context: 100_000, output: 32_000 }) const result = SessionNs.getUsage({ model, - usage: { - inputTokens: 1000, - outputTokens: 500, - totalTokens: 1500, - inputTokenDetails: { - noCacheTokens: 800, - cacheReadTokens: 200, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, + usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }), }) expect(result.tokens.input).toBe(800) @@ -1592,20 +1506,7 @@ describe("SessionNs.getUsage", () => { const model = createModel({ context: 100_000, output: 32_000 }) const result = SessionNs.getUsage({ model, - usage: { - inputTokens: 1000, - outputTokens: 500, - totalTokens: 1500, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, + usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500 }), metadata: { anthropic: { cacheCreationInputTokens: 300, @@ -1621,20 +1522,7 @@ describe("SessionNs.getUsage", () => { // AI SDK v6 normalizes inputTokens to include cached tokens for all providers const result = SessionNs.getUsage({ model, - usage: { - inputTokens: 1000, - outputTokens: 500, - totalTokens: 1500, - inputTokenDetails: { - noCacheTokens: 800, - cacheReadTokens: 200, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, + usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }), metadata: { anthropic: {}, }, @@ -1648,20 +1536,7 @@ describe("SessionNs.getUsage", () => { const model = createModel({ context: 100_000, output: 32_000 }) const result = SessionNs.getUsage({ model, - usage: { - inputTokens: 1000, - outputTokens: 500, - totalTokens: 1500, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: 400, - reasoningTokens: 100, - }, - }, + usage: usage({ inputTokens: 1000, outputTokens: 500, reasoningTokens: 100, totalTokens: 1500 }), }) expect(result.tokens.input).toBe(1000) @@ -1682,20 +1557,7 @@ describe("SessionNs.getUsage", () => { }) const result = SessionNs.getUsage({ model, - usage: { - inputTokens: 0, - outputTokens: 1_000_000, - totalTokens: 1_000_000, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: 750_000, - reasoningTokens: 250_000, - }, - }, + usage: usage({ inputTokens: 0, outputTokens: 1_000_000, reasoningTokens: 250_000, totalTokens: 1_000_000 }), }) expect(result.tokens.output).toBe(750_000) @@ -1707,20 +1569,7 @@ describe("SessionNs.getUsage", () => { const model = createModel({ context: 100_000, output: 32_000 }) const result = SessionNs.getUsage({ model, - usage: { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, + usage: usage({ inputTokens: 0, outputTokens: 0, totalTokens: 0 }), }) expect(result.tokens.input).toBe(0) @@ -1743,20 +1592,7 @@ describe("SessionNs.getUsage", () => { }) const result = SessionNs.getUsage({ model, - usage: { - inputTokens: 1_000_000, - outputTokens: 100_000, - totalTokens: 1_100_000, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, + usage: usage({ inputTokens: 1_000_000, outputTokens: 100_000, totalTokens: 1_100_000 }), }) expect(result.cost).toBe(3 + 1.5) @@ -1793,20 +1629,12 @@ describe("SessionNs.getUsage", () => { }) const result = SessionNs.getUsage({ model, - usage: { + usage: usage({ inputTokens: 650_000, outputTokens: 100_000, totalTokens: 750_000, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: 100_000, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, + cacheReadInputTokens: 100_000, + }), }) expect(result.tokens.input).toBe(550_000) @@ -1838,20 +1666,7 @@ describe("SessionNs.getUsage", () => { }) const result = SessionNs.getUsage({ model, - usage: { - inputTokens: 300_000, - outputTokens: 100_000, - totalTokens: 400_000, - inputTokenDetails: { - noCacheTokens: undefined, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, + usage: usage({ inputTokens: 300_000, outputTokens: 100_000, totalTokens: 400_000 }), }) expect(result.cost).toBe(0.9 + 0.4) @@ -1862,24 +1677,16 @@ describe("SessionNs.getUsage", () => { (npm) => { const model = createModel({ context: 100_000, output: 32_000, npm }) // AI SDK v6: inputTokens includes cached tokens for all providers - const usage = { + const item = usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, - inputTokenDetails: { - noCacheTokens: 800, - cacheReadTokens: 200, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - } + cacheReadInputTokens: 200, + }) if (npm === "@ai-sdk/amazon-bedrock") { const result = SessionNs.getUsage({ model, - usage, + usage: item, metadata: { bedrock: { usage: { @@ -1900,7 +1707,7 @@ describe("SessionNs.getUsage", () => { const result = SessionNs.getUsage({ model, - usage, + usage: item, metadata: { anthropic: { cacheCreationInputTokens: 300, @@ -1921,20 +1728,7 @@ describe("SessionNs.getUsage", () => { const model = createModel({ context: 100_000, output: 32_000, npm: "@ai-sdk/google-vertex/anthropic" }) const result = SessionNs.getUsage({ model, - usage: { - inputTokens: 1000, - outputTokens: 500, - totalTokens: 1500, - inputTokenDetails: { - noCacheTokens: 800, - cacheReadTokens: 200, - cacheWriteTokens: undefined, - }, - outputTokenDetails: { - textTokens: undefined, - reasoningTokens: undefined, - }, - }, + usage: usage({ inputTokens: 1000, outputTokens: 500, totalTokens: 1500, cacheReadInputTokens: 200 }), metadata: { vertex: { cacheCreationInputTokens: 300, diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts new file mode 100644 index 0000000000..2c845a654b --- /dev/null +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -0,0 +1,283 @@ +import { NodeFileSystem } from "@effect/platform-node" +import { HttpRecorder, Redactor } from "@opencode-ai/http-recorder" +import { describe, expect } from "bun:test" +import { tool } from "ai" +import { Effect, Layer, Stream } from "effect" +import { FetchHttpClient } from "effect/unstable/http" +import path from "node:path" +import z from "zod" +import { Auth } from "@/auth" +import { Config } from "@/config/config" +import { Plugin } from "@/plugin" +import { Provider } from "@/provider/provider" +import { ModelID, ProviderID } from "@/provider/schema" +import { Filesystem } from "@/util/filesystem" +import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" +import { RuntimeFlags } from "@/effect/runtime-flags" +import type { Agent } from "../../src/agent/agent" +import { LLM } from "../../src/session/llm" +import { MessageV2 } from "../../src/session/message-v2" +import { MessageID, SessionID } from "../../src/session/schema" +import type { ModelsDev } from "../../src/provider/models" +import { TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" + +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 canRunOpenAI = shouldRecord + ? Boolean(OPENAI_API_KEY) + : 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>( + path.join(import.meta.dir, "../tool/fixtures/models-api.json"), + ) + const provider = data[providerID] + if (!provider) throw new Error(`Missing provider in fixture: ${providerID}`) + const model = provider.models[modelID] + if (!model) throw new Error(`Missing model in fixture: ${modelID}`) + return model +} + +const openAIConfig = (model: ModelsDev.Provider["models"][string]): Partial => ({ + enabled_providers: ["openai"], + provider: { + openai: { + name: "OpenAI", + env: ["OPENAI_API_KEY"], + npm: "@ai-sdk/openai", + api: "https://api.openai.com/v1", + models: { + [model.id]: JSON.parse(JSON.stringify(model)) as NonNullable< + NonNullable[string]["models"] + >[string], + }, + options: { + apiKey: OPENAI_API_KEY ?? "fixture-openai-key", + baseURL: "https://api.openai.com/v1", + }, + }, + }, +}) + +const zenConfig = (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, { + mode: shouldRecord ? "record" : "replay", + 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)) + + const providerLayer = Provider.defaultLayer.pipe( + Layer.provide(Auth.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Plugin.defaultLayer), + ) + const llmLayer = LLM.layer.pipe( + Layer.provide(Auth.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(client), + Layer.provide(cassetteService), + Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: true })), + ) + + return Layer.mergeAll(providerLayer, llmLayer) +} + +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], + 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", ...config(model) }), + ), + ) + +const getModel = (providerID: ProviderID, modelID: ModelID) => + Effect.gen(function* () { + const provider = yield* Provider.Service + return yield* provider.getModel(providerID, modelID) + }) + +const collect = (input: LLM.StreamInput) => + Effect.gen(function* () { + const llm = yield* LLM.Service + return Array.from(yield* llm.stream(input).pipe(Stream.runCollect)) + }) + +describe("session.llm native recorded", () => { + 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")) + yield* writeConfig(test.directory, model) + + const sessionID = SessionID.make("session-recorded-native-tool") + const agent = { + name: "test", + mode: "primary", + prompt: "Call tools exactly as instructed.", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + temperature: 0, + } satisfies Agent.Info + const resolved = yield* getModel(ProviderID.openai, ModelID.make(model.id)) + let executed: unknown + + const events = yield* collect({ + user: { + id: MessageID.make("msg_user-recorded-native-tool"), + sessionID, + role: "user", + time: { created: 0 }, + agent: agent.name, + model: { providerID: ProviderID.make("openai"), 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) }) + }), + ) + + 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* 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 new file mode 100644 index 0000000000..6de16cbc99 --- /dev/null +++ b/packages/opencode/test/session/llm-native.test.ts @@ -0,0 +1,303 @@ +import { describe, expect, test } from "bun:test" +import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" +import { jsonSchema, tool, type ModelMessage } from "ai" +import { Effect } from "effect" +import { LLMNative } from "@/session/llm/native-request" +import { LLMNativeRuntime } from "@/session/llm/native-runtime" +import type { Provider } from "@/provider/provider" +import { ModelID, ProviderID } from "@/provider/schema" + +const baseModel: Provider.Model = { + id: ModelID.make("gpt-5-mini"), + providerID: ProviderID.make("openai"), + api: { + id: "gpt-5-mini", + url: "https://api.openai.com/v1", + npm: "@ai-sdk/openai", + }, + name: "GPT-5 Mini", + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { + text: true, + audio: false, + image: true, + video: false, + pdf: false, + }, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + interleaved: false, + }, + cost: { + input: 0, + output: 0, + cache: { + read: 0, + write: 0, + }, + }, + limit: { + context: 128_000, + input: 128_000, + output: 32_000, + }, + status: "active", + options: {}, + headers: { + "x-model": "model-header", + }, + release_date: "2026-01-01", +} + +const providerInfo: Provider.Info = { + id: ProviderID.make("openai"), + name: "OpenAI", + source: "config", + env: ["OPENAI_API_KEY"], + options: { apiKey: "test-openai-key" }, + models: {}, +} + +describe("session.llm-native.request", () => { + test("maps normalized stream inputs to a native LLM request", () => { + const messages: ModelMessage[] = [ + { + role: "system", + content: "system from messages", + }, + { + role: "user", + content: [ + { type: "text", text: "hello", providerOptions: { openai: { cacheControl: { type: "ephemeral" } } } }, + { type: "file", mediaType: "image/png", filename: "img.png", data: "data:image/png;base64,Zm9v" }, + ], + }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking", providerOptions: { openai: { encryptedContent: "secret" } } }, + { type: "text", text: "I'll run it" }, + { + type: "tool-call", + toolCallId: "call-1", + toolName: "bash", + input: { command: "ls" }, + providerOptions: { openai: { itemId: "item-1" } }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "call-1", + toolName: "bash", + output: { type: "text", value: "ok" }, + providerOptions: { openai: { outputId: "output-1" } }, + }, + ], + }, + ] + + const request = LLMNative.request({ + model: baseModel, + system: ["agent system"], + messages, + tools: { + bash: tool({ + description: "Run a shell command", + inputSchema: jsonSchema({ + type: "object", + properties: { + command: { type: "string" }, + }, + required: ["command"], + }), + }), + }, + toolChoice: "required", + temperature: 0.2, + topP: 0.9, + topK: 40, + maxOutputTokens: 1024, + providerOptions: { openai: { store: false } }, + headers: { "x-request": "request-header" }, + }) + + expect(request.model).toMatchObject({ + id: "gpt-5-mini", + provider: "openai", + route: "openai-responses", + baseURL: "https://api.openai.com/v1", + headers: { + "x-model": "model-header", + "x-request": "request-header", + }, + limits: { + context: 128_000, + output: 32_000, + }, + }) + expect(request.system).toEqual([ + { type: "text", text: "agent system" }, + { type: "text", text: "system from messages" }, + ]) + expect(request.generation).toMatchObject({ + temperature: 0.2, + topP: 0.9, + topK: 40, + maxTokens: 1024, + }) + expect(request.providerOptions).toEqual({ openai: { store: false } }) + expect(request.toolChoice).toMatchObject({ type: "required" }) + expect(request.tools).toMatchObject([ + { + name: "bash", + description: "Run a shell command", + inputSchema: { + type: "object", + properties: { + command: { type: "string" }, + }, + required: ["command"], + }, + }, + ]) + expect(request.messages).toMatchObject([ + { + role: "user", + content: [ + { type: "text", text: "hello", providerMetadata: { openai: { cacheControl: { type: "ephemeral" } } } }, + { type: "media", mediaType: "image/png", filename: "img.png", data: "data:image/png;base64,Zm9v" }, + ], + }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking", providerMetadata: { openai: { encryptedContent: "secret" } } }, + { type: "text", text: "I'll run it" }, + { + type: "tool-call", + id: "call-1", + name: "bash", + input: { command: "ls" }, + providerMetadata: { openai: { itemId: "item-1" } }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + id: "call-1", + name: "bash", + result: { type: "text", value: "ok" }, + providerMetadata: { openai: { outputId: "output-1" } }, + }, + ], + }, + ]) + }) + + test("selects native routes from existing provider packages", () => { + expect( + LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/anthropic" } }), + ).toMatchObject({ + route: "anthropic-messages", + baseURL: "https://api.anthropic.com/v1", + }) + expect(LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/google" } })).toMatchObject({ + route: "gemini", + baseURL: "https://generativelanguage.googleapis.com/v1beta", + }) + expect( + LLMNative.model({ ...baseModel, api: { ...baseModel.api, npm: "@ai-sdk/openai-compatible" } }), + ).toMatchObject({ + route: "openai-compatible-chat", + baseURL: "https://api.openai.com/v1", + }) + expect( + LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@openrouter/ai-sdk-provider" } }), + ).toMatchObject({ + route: "openrouter", + baseURL: "https://openrouter.ai/api/v1", + }) + }) + + test("fails fast for unsupported provider packages", () => { + expect(() => + LLMNative.request({ + model: { ...baseModel, api: { ...baseModel.api, npm: "unknown-provider" } }, + messages: [], + }), + ).toThrow("Native LLM request adapter does not support provider package unknown-provider") + }) + + test("only enables native runtime for supported OpenAI API-key models", () => { + expect(LLMNativeRuntime.status({ model: baseModel, provider: providerInfo, auth: undefined })).toMatchObject({ + 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 or opencode" }) + expect( + LLMNativeRuntime.status({ + model: baseModel, + provider: providerInfo, + auth: { type: "oauth", refresh: "refresh", access: "access", expires: 1 }, + }), + ).toEqual({ type: "unsupported", reason: "OAuth auth is not supported" }) + }) + + test("compiles through the native OpenAI Responses route", async () => { + const prepared = await Effect.runPromise( + LLMClient.prepare( + LLMNative.request({ + model: baseModel, + messages: [{ role: "user", content: "hello" }], + providerOptions: { openai: { store: false } }, + maxOutputTokens: 512, + headers: { "x-request": "request-header" }, + }), + ).pipe(Effect.provide(LLMClient.layer), Effect.provide(RequestExecutor.defaultLayer)), + ) + + expect(prepared).toMatchObject({ + route: "openai-responses", + protocol: "openai-responses", + body: { + model: "gpt-5-mini", + input: [{ role: "user", content: [{ type: "input_text", text: "hello" }] }], + max_output_tokens: 512, + store: false, + stream: true, + }, + }) + }) +}) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 4a6b1e8b7f..e7b9fa1c6e 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -1,15 +1,19 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test" import path from "path" import { tool, type ModelMessage } from "ai" -import { Cause, Effect, Exit, Stream } from "effect" +import { Cause, Effect, Exit, Layer, Stream } from "effect" +import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http" import z from "zod" -import { makeRuntime } from "../../src/effect/run-service" +import { attach, makeRuntime } from "../../src/effect/run-service" import { LLM } from "../../src/session/llm" -import { Instance } from "../../src/project/instance" +import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route" import { WithInstance } from "../../src/project/with-instance" +import { Auth } from "@/auth" +import { Config } from "@/config/config" import { Provider } from "@/provider/provider" import { ProviderTransform } from "@/provider/transform" import { ModelsDev } from "@/provider/models" +import { Plugin } from "@/plugin" import { ProviderID, ModelID } from "../../src/provider/schema" import { Filesystem } from "@/util/filesystem" import { tmpdir } from "../fixture/fixture" @@ -17,6 +21,32 @@ import type { Agent } from "../../src/agent/agent" import { MessageV2 } from "../../src/session/message-v2" import { SessionID, MessageID } from "../../src/session/schema" import { AppRuntime } from "../../src/effect/app-runtime" +import { RuntimeFlags } from "@/effect/runtime-flags" +import { Permission } from "@/permission" +import { LLMAISDK } from "@/session/llm/ai-sdk" + +const openAIConfig = (model: ModelsDev.Provider["models"][string], baseURL: string): Partial => { + const { experimental: _experimental, ...configModel } = model + type ConfigModel = NonNullable[string]["models"]>[string] + return { + enabled_providers: ["openai"], + provider: { + openai: { + name: "OpenAI", + env: ["OPENAI_API_KEY"], + npm: "@ai-sdk/openai", + api: "https://api.openai.com/v1", + models: { + [model.id]: JSON.parse(JSON.stringify(configModel)) as ConfigModel, + }, + options: { + apiKey: "test-openai-key", + baseURL, + }, + }, + }, + } +} async function getModel(providerID: ProviderID, modelID: ModelID) { return AppRuntime.runPromise( @@ -33,6 +63,23 @@ async function drain(input: LLM.StreamInput) { return llm.runPromise((svc) => svc.stream(input).pipe(Stream.runDrain)) } +async function drainWith(layer: Layer.Layer, input: LLM.StreamInput) { + return Effect.runPromise( + attach(LLM.Service.use((svc) => svc.stream(input).pipe(Stream.runDrain))).pipe(Effect.provide(layer)), + ) +} + +function llmLayerWithExecutor(executor: Layer.Layer, flags: Partial = {}) { + return LLM.layer.pipe( + Layer.provide(Auth.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(LLMClient.layer.pipe(Layer.provide(executor))), + Layer.provide(RuntimeFlags.layer(flags)), + ) +} + describe("session.llm.hasToolCalls", () => { test("returns false for empty messages array", () => { expect(LLM.hasToolCalls([])).toBe(false) @@ -120,6 +167,30 @@ describe("session.llm.hasToolCalls", () => { }) }) +describe("session.llm.ai-sdk adapter", () => { + test("preserves tool-error cause", async () => { + const error = new Permission.RejectedError() + const events = await Effect.runPromise( + LLMAISDK.toLLMEvents(LLMAISDK.adapterState(), { + type: "tool-error", + toolCallId: "call_123", + toolName: "bash", + input: {}, + error, + }), + ) + + expect(events).toHaveLength(1) + expect(events[0]).toMatchObject({ + type: "tool-error", + id: "call_123", + name: "bash", + message: error.message, + error, + }) + }) +}) + type Capture = { url: URL headers: Headers @@ -600,6 +671,18 @@ describe("session.llm.stream", () => { service_tier: null, }, }, + { + type: "response.output_item.added", + output_index: 0, + item: { type: "message", id: "item-1", status: "in_progress", role: "assistant", content: [] }, + }, + { + type: "response.content_part.added", + item_id: "item-1", + output_index: 0, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }, { type: "response.output_text.delta", item_id: "item-1", @@ -622,32 +705,7 @@ describe("session.llm.stream", () => { ] const request = waitRequest("/responses", createEventResponse(responseChunks, true)) - 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: ["openai"], - provider: { - openai: { - name: "OpenAI", - env: ["OPENAI_API_KEY"], - npm: "@ai-sdk/openai", - api: "https://api.openai.com/v1", - models: { - [model.id]: configModel(model), - }, - options: { - apiKey: "test-openai-key", - baseURL: `${server.url.origin}/v1`, - }, - }, - }, - }), - ) - }, - }) + await using tmp = await tmpdir({ config: openAIConfig(model, `${server.url.origin}/v1`) }) await WithInstance.provide({ directory: tmp.path, @@ -695,6 +753,312 @@ describe("session.llm.stream", () => { }) }) + test("streams OpenAI through native runtime when opted in", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const source = await loadFixture("openai", "gpt-5.2") + const model = source.model + const chunks = [ + { + type: "response.created", + response: { + id: "resp-native", + }, + }, + { + type: "response.output_item.added", + item: { type: "message", id: "item-native", status: "in_progress" }, + }, + { + type: "response.output_text.delta", + item_id: "item-native", + delta: "Hello native", + }, + { + type: "response.completed", + response: { + incomplete_details: null, + usage: { + input_tokens: 1, + input_tokens_details: null, + output_tokens: 1, + output_tokens_details: null, + }, + }, + }, + ] + const request = waitRequest("/responses", createEventResponse(chunks, true)) + + await using tmp = await tmpdir({ config: openAIConfig(model, `${server.url.origin}/v1`) }) + + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-native") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + temperature: 0.2, + } satisfies Agent.Info + + await drainWith(llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }), { + user: { + id: MessageID.make("msg_user-native"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make("openai"), modelID: resolved.id, variant: "high" }, + } satisfies MessageV2.User, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [{ role: "user", content: "Hello" }], + tools: {}, + }) + + const capture = await request + expect(capture.url.pathname.endsWith("/responses")).toBe(true) + expect(capture.headers.get("Authorization")).toBe("Bearer test-openai-key") + expect(capture.body.model).toBe(model.id) + expect(capture.body.stream).toBe(true) + expect((capture.body.reasoning as { effort?: string } | undefined)?.effort).toBe("high") + expect(JSON.stringify(capture.body.input)).toContain("You are a helpful assistant.") + expect(capture.body.input).toContainEqual({ role: "user", content: [{ type: "input_text", text: "Hello" }] }) + }, + }) + }) + + test("uses injected native request executor for tool calls", async () => { + const source = await loadFixture("openai", "gpt-5.2") + const model = source.model + const chunks = [ + { + type: "response.output_item.added", + item: { type: "function_call", id: "item-injected-tool", call_id: "call-injected-tool", name: "lookup" }, + }, + { + type: "response.function_call_arguments.delta", + item_id: "item-injected-tool", + delta: '{"query":"weather"}', + }, + { + type: "response.output_item.done", + item: { + type: "function_call", + id: "item-injected-tool", + call_id: "call-injected-tool", + name: "lookup", + arguments: '{"query":"weather"}', + }, + }, + { + type: "response.completed", + response: { incomplete_details: null, usage: { input_tokens: 1, output_tokens: 1 } }, + }, + ] + let captured: Record | undefined + let executed: unknown + const executor = Layer.succeed( + RequestExecutor.Service, + RequestExecutor.Service.of({ + execute: (request) => + Effect.gen(function* () { + const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie) + captured = (yield* Effect.promise(() => web.json())) as Record + return HttpClientResponse.fromWeb(request, createEventResponse(chunks, true)) + }), + }), + ) + + await using tmp = await tmpdir({ config: openAIConfig(model, "https://injected-openai.test/v1") }) + + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-native-injected-tool") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + await drainWith(llmLayerWithExecutor(executor, { experimentalNativeLlm: true }), { + user: { + id: MessageID.make("msg_user-native-injected-tool"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make("openai"), modelID: resolved.id }, + } satisfies MessageV2.User, + sessionID, + model: resolved, + agent, + system: [], + messages: [{ role: "user", content: "Use lookup" }], + 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(captured?.model).toBe(model.id) + expect(captured?.tools).toEqual([ + { + type: "function", + name: "lookup", + description: "Lookup data", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + additionalProperties: false, + $schema: "http://json-schema.org/draft-07/schema#", + }, + }, + ]) + expect(executed).toEqual({ args: { query: "weather" }, toolCallId: "call-injected-tool" }) + }, + }) + }) + + test("executes OpenAI tool calls through native runtime", async () => { + const server = state.server + if (!server) { + throw new Error("Server not initialized") + } + + const source = await loadFixture("openai", "gpt-5.2") + const model = source.model + const chunks = [ + { + type: "response.output_item.added", + item: { type: "function_call", id: "item-native-tool", call_id: "call-native-tool", name: "lookup" }, + }, + { + type: "response.function_call_arguments.delta", + item_id: "item-native-tool", + delta: '{"query":"weather"}', + }, + { + type: "response.output_item.done", + item: { + type: "function_call", + id: "item-native-tool", + call_id: "call-native-tool", + name: "lookup", + arguments: '{"query":"weather"}', + }, + }, + { + type: "response.completed", + response: { incomplete_details: null, usage: { input_tokens: 1, output_tokens: 1 } }, + }, + ] + const request = waitRequest("/responses", createEventResponse(chunks, true)) + let executed: unknown + + 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: ["openai"], + provider: { + openai: { + name: "OpenAI", + env: ["OPENAI_API_KEY"], + npm: "@ai-sdk/openai", + api: "https://api.openai.com/v1", + models: { + [model.id]: model, + }, + options: { + apiKey: "test-openai-key", + baseURL: `${server.url.origin}/v1`, + }, + }, + }, + }), + ) + }, + }) + + await WithInstance.provide({ + directory: tmp.path, + fn: async () => { + const resolved = await getModel(ProviderID.openai, ModelID.make(model.id)) + const sessionID = SessionID.make("session-test-native-tool") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + await drainWith(llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }), { + user: { + id: MessageID.make("msg_user-native-tool"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderID.make("openai"), modelID: resolved.id }, + } satisfies MessageV2.User, + sessionID, + model: resolved, + agent, + system: [], + messages: [{ role: "user", content: "Use lookup" }], + 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" } + }, + }), + }, + }) + + const capture = await request + expect(capture.body.tools).toEqual([ + { + type: "function", + name: "lookup", + description: "Lookup data", + parameters: { + type: "object", + properties: { query: { type: "string" } }, + required: ["query"], + additionalProperties: false, + $schema: "http://json-schema.org/draft-07/schema#", + }, + }, + ]) + expect(executed).toEqual({ args: { query: "weather" }, toolCallId: "call-native-tool" }) + }, + }) + }) + test("accepts user image attachments as data URLs for OpenAI models", async () => { const server = state.server if (!server) { @@ -713,6 +1077,18 @@ describe("session.llm.stream", () => { service_tier: null, }, }, + { + type: "response.output_item.added", + output_index: 0, + item: { type: "message", id: "item-data-url", status: "in_progress", role: "assistant", content: [] }, + }, + { + type: "response.content_part.added", + item_id: "item-data-url", + output_index: 0, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }, { type: "response.output_text.delta", item_id: "item-data-url",