diff --git a/bun.lock b/bun.lock index 3fafe9d5e02d..6a1368dbca8c 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/events.ts b/packages/llm/src/schema/events.ts index 63c9b7b7df54..cee489a689e3 100644 --- a/packages/llm/src/schema/events.ts +++ b/packages/llm/src/schema/events.ts @@ -91,6 +91,7 @@ export const TextDelta = Schema.Struct({ type: Schema.tag("text-delta"), id: ContentBlockID, text: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.TextDelta" }) export type TextDelta = Schema.Schema.Type @@ -112,6 +113,7 @@ export const ReasoningDelta = Schema.Struct({ type: Schema.tag("reasoning-delta"), id: ContentBlockID, text: Schema.String, + providerMetadata: Schema.optional(ProviderMetadata), }).annotate({ identifier: "LLM.Event.ReasoningDelta" }) export type ReasoningDelta = Schema.Schema.Type diff --git a/packages/llm/src/schema/ids.ts b/packages/llm/src/schema/ids.ts index ada133f0db58..f3161318c6b2 100644 --- a/packages/llm/src/schema/ids.ts +++ b/packages/llm/src/schema/ids.ts @@ -33,7 +33,15 @@ export type TextVerbosity = Schema.Schema.Type export const MessageRole = Schema.Literals(["user", "assistant", "tool"]) export type MessageRole = Schema.Schema.Type -export const FinishReason = Schema.Literals(["stop", "length", "tool-calls", "content-filter", "error", "unknown"]) +export const FinishReason = Schema.Literals([ + "stop", + "length", + "tool-calls", + "content-filter", + "error", + "other", + "unknown", +]) export type FinishReason = Schema.Schema.Type export const JsonSchema = Schema.Record(Schema.String, Schema.Unknown) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index ba7b22c69140..cb0401ee4467 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 4d184c43b31f..268087346de7 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 116254a81e75..29f9e7953cd8 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, - }) - return { - ...failed.toolCall, - toolName: 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}`, } - } - return { - ...failed.toolCall, - input: JSON.stringify({ - tool: failed.toolCall.toolName, - error: failed.error.message, + : { + "x-session-affinity": input.sessionID, + ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), + "User-Agent": `opencode/${InstallationVersion}`, }), - toolName: "invalid", + ...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, } - }, - 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}`, + } + 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, } - : { - "x-session-affinity": input.sessionID, - ...(input.parentSessionID ? { "x-parent-session-id": input.parentSessionID } : {}), - "User-Agent": `opencode/${InstallationVersion}`, + } + return { + ...failed.toolCall, + 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 + 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, }, - ], - }), - 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/AGENTS.md b/packages/opencode/src/session/llm/AGENTS.md new file mode 100644 index 000000000000..b3081664b3ab --- /dev/null +++ b/packages/opencode/src/session/llm/AGENTS.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 000000000000..1d2036b7683d --- /dev/null +++ b/packages/opencode/src/session/llm/ai-sdk.ts @@ -0,0 +1,252 @@ +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), + providerMetadata: "providerMetadata" in event ? providerMetadata(event.providerMetadata) : undefined, + }), + ] + }) + + 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, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ]) + + 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, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ]) + + 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", + providerMetadata: providerMetadata(event.providerMetadata), + }), + ]) + + 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, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ] + }) + + 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, + providerMetadata: providerMetadata(event.providerMetadata), + }), + ] + }) + + case "error": + return Effect.fail(event.error) + + case "abort": + case "source": + case "file": + case "raw": + case "tool-output-denied": + case "tool-approval-request": + return Effect.succeed([]) + + default: { + const _exhaustive: never = event + void _exhaustive + 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 000000000000..ca3ddef17392 --- /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 000000000000..e2991e161a13 --- /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 9765175e9e11..c2333ca7d30f 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,133 @@ 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 + if (value.providerMetadata) ctx.reasoningMap[reasoningID].metadata = value.providerMetadata 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 +474,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,50 +485,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", - ) - // temporarily disabled - // const normalized = yield* Effect.forEach(toolAttachments, (attachment) => - // attachment.mime.startsWith("image/") - // ? image.normalize(attachment).pipe(Effect.exit) - // : Effect.succeed(Exit.succeed(attachment)), - // ) - const normalized = yield* Effect.forEach(toolAttachments, (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: [ { @@ -436,32 +522,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, @@ -469,14 +555,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. @@ -503,11 +589,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) { @@ -515,7 +602,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, @@ -523,12 +610,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, @@ -635,10 +722,6 @@ export const layer: Layer.Layer< case "finish": return - - default: - slog.info("unhandled", { event: value.type, value }) - return } }) @@ -740,6 +823,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( @@ -825,10 +909,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 1ae411426931..5fecdc804b93 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -61,6 +61,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 @@ -361,7 +362,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 85486480aa4c..e35e539fdb90 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 2f5fa25abfb4..204d63fad7fe 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 000000000000..b6670d58aa99 --- /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 000000000000..a7951cad5d71 --- /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 d8a416790275..ea5e93716615 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 000000000000..981a4bd38903 --- /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 "@opencode-ai/core/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 000000000000..6de16cbc99e7 --- /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 3a949287e489..8452d4370904 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 "@opencode-ai/core/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,190 @@ describe("session.llm.hasToolCalls", () => { }) }) +describe("session.llm.ai-sdk adapter", () => { + type AISDKAdapterEvent = Parameters[1] + + const adapt = (events: ReadonlyArray) => { + const state = LLMAISDK.adapterState() + return Effect.runPromise( + Effect.forEach(events, (event) => LLMAISDK.toLLMEvents(state, event)).pipe(Effect.map((items) => items.flat())), + ) + } + // oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- tests defensive adapter branches outside AI SDK's current typed surface + const uncheckedAdapterEvent = (input: unknown) => input as AISDKAdapterEvent + + test("maps AI SDK stream chunks without losing session-visible fields", async () => { + const metadata = { openai: { itemID: "item-1" } } + const events = await adapt([ + { type: "start" }, + { type: "start-step", request: {}, warnings: [] }, + { type: "text-start", id: "text-1", providerMetadata: metadata }, + { type: "text-delta", id: "text-1", text: "Hel", providerMetadata: { openai: { delta: 1 } } }, + { type: "text-delta", id: "text-1", text: "lo", providerMetadata: { openai: { delta: 2 } } }, + { type: "text-end", id: "text-1", providerMetadata: { openai: { done: true } } }, + { type: "reasoning-start", id: "reasoning-1", providerMetadata: metadata }, + { type: "reasoning-delta", id: "reasoning-1", text: "Think", providerMetadata: { openai: { delta: 3 } } }, + { type: "reasoning-end", id: "reasoning-1", providerMetadata: { openai: { done: true } } }, + { type: "tool-input-start", id: "call-1", toolName: "lookup", providerMetadata: metadata }, + { type: "tool-input-delta", id: "call-1", delta: '{"query":' }, + { type: "tool-input-delta", id: "call-1", delta: '"weather"}' }, + { type: "tool-input-end", id: "call-1", providerMetadata: { openai: { inputDone: true } } }, + { + type: "tool-call", + toolCallId: "call-1", + toolName: "lookup", + input: { query: "weather" }, + providerExecuted: true, + providerMetadata: { openai: { called: true } }, + }, + { + type: "tool-result", + toolCallId: "call-1", + toolName: "lookup", + input: { query: "weather" }, + output: { title: "Lookup", output: "sunny", metadata: { ok: true } }, + providerExecuted: true, + providerMetadata: { openai: { result: true } }, + }, + { + type: "finish-step", + response: { id: "response-1", timestamp: new Date(0), modelId: "gpt-test" }, + finishReason: "other", + rawFinishReason: "other", + usage: { + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + inputTokenDetails: { noCacheTokens: 5, cacheReadTokens: 3, cacheWriteTokens: 2 }, + outputTokenDetails: { textTokens: 4, reasoningTokens: 1 }, + }, + providerMetadata: { openai: { step: true } }, + }, + { + type: "finish", + finishReason: "other", + rawFinishReason: "other", + totalUsage: { + inputTokens: 11, + outputTokens: 6, + totalTokens: 17, + cachedInputTokens: 4, + reasoningTokens: 2, + inputTokenDetails: { noCacheTokens: 7, cacheReadTokens: 4, cacheWriteTokens: undefined }, + outputTokenDetails: { textTokens: 4, reasoningTokens: 2 }, + }, + }, + ]) + + expect(events).toMatchObject([ + { type: "step-start", index: 0 }, + { type: "text-start", id: "text-1", providerMetadata: metadata }, + { type: "text-delta", id: "text-1", text: "Hel", providerMetadata: { openai: { delta: 1 } } }, + { type: "text-delta", id: "text-1", text: "lo", providerMetadata: { openai: { delta: 2 } } }, + { type: "text-end", id: "text-1", providerMetadata: { openai: { done: true } } }, + { type: "reasoning-start", id: "reasoning-1", providerMetadata: metadata }, + { type: "reasoning-delta", id: "reasoning-1", text: "Think", providerMetadata: { openai: { delta: 3 } } }, + { type: "reasoning-end", id: "reasoning-1", providerMetadata: { openai: { done: true } } }, + { type: "tool-input-start", id: "call-1", name: "lookup", providerMetadata: metadata }, + { type: "tool-input-delta", id: "call-1", name: "lookup", text: '{"query":' }, + { type: "tool-input-delta", id: "call-1", name: "lookup", text: '"weather"}' }, + { type: "tool-input-end", id: "call-1", name: "lookup", providerMetadata: { openai: { inputDone: true } } }, + { + type: "tool-call", + id: "call-1", + name: "lookup", + input: { query: "weather" }, + providerExecuted: true, + providerMetadata: { openai: { called: true } }, + }, + { + type: "tool-result", + id: "call-1", + name: "lookup", + result: { type: "json", value: { title: "Lookup", output: "sunny", metadata: { ok: true } } }, + providerExecuted: true, + providerMetadata: { openai: { result: true } }, + }, + { + type: "step-finish", + index: 0, + reason: "other", + usage: { + inputTokens: 10, + outputTokens: 5, + totalTokens: 15, + reasoningTokens: 1, + cacheReadInputTokens: 3, + cacheWriteInputTokens: 2, + }, + providerMetadata: { openai: { step: true } }, + }, + { + type: "finish", + reason: "other", + usage: { + inputTokens: 11, + outputTokens: 6, + totalTokens: 17, + reasoningTokens: 2, + cacheReadInputTokens: 4, + }, + }, + ]) + }) + + test("creates stable block ids when AI SDK omits them", async () => { + const events = await adapt([ + uncheckedAdapterEvent({ type: "text-delta", text: "implicit text" }), + uncheckedAdapterEvent({ type: "text-end" }), + uncheckedAdapterEvent({ type: "reasoning-delta", text: "implicit reasoning" }), + uncheckedAdapterEvent({ type: "reasoning-end" }), + ]) + + expect(events).toMatchObject([ + { type: "text-delta", id: "text-0", text: "implicit text" }, + { type: "text-end", id: "text-0" }, + { type: "reasoning-delta", id: "reasoning-0", text: "implicit reasoning" }, + { type: "reasoning-end", id: "reasoning-0" }, + ]) + }) + + test("explicitly ignores non-session-visible AI SDK chunks", async () => { + expect( + await adapt([ + uncheckedAdapterEvent({ type: "abort" }), + uncheckedAdapterEvent({ type: "source" }), + uncheckedAdapterEvent({ type: "file" }), + uncheckedAdapterEvent({ type: "raw" }), + uncheckedAdapterEvent({ type: "tool-output-denied" }), + uncheckedAdapterEvent({ type: "tool-approval-request" }), + ]), + ).toEqual([]) + }) + + 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 +831,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 +865,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 +913,425 @@ describe("session.llm.stream", () => { }) }) + test("keeps supported OpenAI models on AI SDK path when native flag is off", 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 request = waitRequest( + "/responses", + createEventResponse( + [ + { + type: "response.created", + response: { + id: "resp-flag-off", + created_at: Math.floor(Date.now() / 1000), + model: model.id, + service_tier: null, + }, + }, + { + type: "response.output_item.added", + output_index: 0, + item: { type: "message", id: "item-flag-off", status: "in_progress", role: "assistant", content: [] }, + }, + { + type: "response.content_part.added", + item_id: "item-flag-off", + output_index: 0, + content_index: 0, + part: { type: "output_text", text: "", annotations: [] }, + }, + { + type: "response.output_text.delta", + item_id: "item-flag-off", + delta: "Flag off", + logprobs: null, + }, + { + type: "response.completed", + response: { + incomplete_details: null, + usage: { + input_tokens: 1, + input_tokens_details: null, + output_tokens: 1, + output_tokens_details: null, + }, + service_tier: null, + }, + }, + ], + true, + ), + ) + const failingNativeClient = Layer.succeed( + LLMClient.Service, + LLMClient.Service.of({ + prepare: () => Effect.die(new Error("native LLM client should not be used when the flag is off")), + stream: () => Stream.die(new Error("native LLM client should not be used when the flag is off")), + generate: () => Effect.die(new Error("native LLM client should not be used when the flag is off")), + }), + ) + + 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-flag-off") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + await drainWith( + LLM.layer.pipe( + Layer.provide(Auth.defaultLayer), + Layer.provide(Config.defaultLayer), + Layer.provide(Provider.defaultLayer), + Layer.provide(Plugin.defaultLayer), + Layer.provide(failingNativeClient), + Layer.provide(RuntimeFlags.layer({ experimentalNativeLlm: false })), + ), + { + user: { + id: MessageID.make("msg_user-native-flag-off"), + 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.body.model).toBe(resolved.api.id) + }, + }) + }) + + 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 +1350,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", diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 61c566eaec2d..1ca270a0a7d6 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -1,7 +1,9 @@ import { NodeFileSystem } from "@effect/platform-node" import { expect } from "bun:test" +import { tool } from "ai" import { Cause, Effect, Exit, Fiber, Layer } from "effect" import path from "path" +import z from "zod" import type { Agent } from "../../src/agent/agent" import { Agent as AgentSvc } from "../../src/agent/agent" import { Bus } from "../../src/bus" @@ -659,6 +661,71 @@ it.live("session.processor effect tests compact on structured context overflow", ), ) +it.live("session.processor effect tests complete AI SDK tool calls when native flag is off", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.tool("lookup", { query: "weather" }) + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "tool") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies MessageV2.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "tool" }], + tools: { + lookup: tool({ + description: "Look up information", + inputSchema: z.object({ query: z.string() }), + execute: async (input) => ({ + title: "Weather lookup", + output: `result:${input.query}`, + metadata: { source: "test" }, + }), + }), + }, + }) + + const parts = MessageV2.parts(msg.id) + const call = parts.find((part): part is MessageV2.ToolPart => part.type === "tool") + + expect(value).toBe("continue") + expect(yield* llm.calls).toBe(1) + expect(call?.callID).toBe("call_1") + expect(call?.tool).toBe("lookup") + expect(call?.state.status).toBe("completed") + if (call?.state.status !== "completed") return + expect(call.state.input).toEqual({ query: "weather" }) + expect(call.state.output).toBe("result:weather") + expect(call.state.title).toBe("Weather lookup") + expect(call.state.metadata).toEqual({ source: "test" }) + expect(call.state.time.start).toBeDefined() + expect(call.state.time.end).toBeDefined() + }), + { git: true, config: (url) => providerCfg(url) }, + ), +) + it.live("session.processor effect tests mark pending tools as aborted on cleanup", () => provideTmpdirServer( ({ dir, llm }) =>