diff --git a/packages/opencode/src/effect/runtime-flags.ts b/packages/opencode/src/effect/runtime-flags.ts index b1b8ab25ac..565f6b1631 100644 --- a/packages/opencode/src/effect/runtime-flags.ts +++ b/packages/opencode/src/effect/runtime-flags.ts @@ -23,6 +23,11 @@ export class Service extends ConfigService.Service()("@opencode/Runtime experimentalLspTool: enabledByExperimental("OPENCODE_EXPERIMENTAL_LSP_TOOL"), experimentalPlanMode: enabledByExperimental("OPENCODE_EXPERIMENTAL_PLAN_MODE"), experimentalEventSystem: enabledByExperimental("OPENCODE_EXPERIMENTAL_EVENT_SYSTEM"), + experimentalNativeLlm: Config.all({ + experimental, + enabled: bool("OPENCODE_EXPERIMENTAL_NATIVE_LLM"), + legacy: Config.string("OPENCODE_LLM_RUNTIME").pipe(Config.withDefault("")), + }).pipe(Config.map((flags) => flags.experimental || 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 87d2e3973d..4a43423951 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -29,6 +29,7 @@ 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" +import { RuntimeFlags } from "@/effect/runtime-flags" const log = Log.create({ service: "llm" }) export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX @@ -37,8 +38,6 @@ export const OUTPUT_TOKEN_MAX = ProviderTransform.OUTPUT_TOKEN_MAX const mergeOptions = (target: Record, source: Record | undefined): Record => mergeDeep(target, source ?? {}) as Record -const runtime = () => (process.env.OPENCODE_LLM_RUNTIME === "native" ? "native" : "ai-sdk") - export type StreamInput = { user: MessageV2.User sessionID: string @@ -67,7 +66,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 | LLMClientService + | Auth.Service + | Config.Service + | Provider.Service + | Plugin.Service + | Permission.Service + | LLMClientService + | RuntimeFlags.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -77,6 +82,7 @@ const live: Layer.Layer< 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) { const l = log @@ -357,7 +363,7 @@ const live: Layer.Layer< ...headers, } - if (runtime() === "native") { + if (flags.experimentalNativeLlm) { const native = LLMNativeRuntime.stream({ model: input.model, provider: item, @@ -491,6 +497,7 @@ export const defaultLayer = Layer.suspend(() => Layer.provide(Provider.defaultLayer), Layer.provide(Plugin.defaultLayer), Layer.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer))), + Layer.provide(RuntimeFlags.defaultLayer), ), ) diff --git a/packages/opencode/src/session/llm/README.md b/packages/opencode/src/session/llm/README.md index 2983c5ce24..b3081664b3 100644 --- a/packages/opencode/src/session/llm/README.md +++ b/packages/opencode/src/session/llm/README.md @@ -11,6 +11,6 @@ This folder contains adapters behind that service boundary: Safety boundary: - AI SDK remains the default. -- `OPENCODE_LLM_RUNTIME=native` is an opt-in hint, not a global replacement. +- `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/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 3c276d84dc..ea5e937166 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1629,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) @@ -1674,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) diff --git a/packages/opencode/test/session/llm-native-recorded.test.ts b/packages/opencode/test/session/llm-native-recorded.test.ts index bc1297e5d1..2c845a654b 100644 --- a/packages/opencode/test/session/llm-native-recorded.test.ts +++ b/packages/opencode/test/session/llm-native-recorded.test.ts @@ -13,6 +13,7 @@ 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" @@ -127,6 +128,7 @@ function recordedNativeLLMLayer(cassette: string, metadata: Record return Array.from(yield* llm.stream(input).pipe(Stream.runCollect)) }) -const nativeRuntime = (effect: Effect.Effect) => { - return Effect.acquireUseRelease( - Effect.sync(() => { - const previous = process.env.OPENCODE_LLM_RUNTIME - process.env.OPENCODE_LLM_RUNTIME = "native" - return previous - }), - () => effect, - (previous) => - Effect.sync(() => { - if (previous === undefined) delete process.env.OPENCODE_LLM_RUNTIME - else process.env.OPENCODE_LLM_RUNTIME = previous - }), - ) -} - describe("session.llm native recorded", () => { recordedOpenAIInstance("uses real RequestExecutor with HTTP recorder for native OpenAI tools", () => Effect.gen(function* () { @@ -210,34 +196,32 @@ describe("session.llm native recorded", () => { const resolved = yield* getModel(ProviderID.openai, ModelID.make(model.id)) let executed: unknown - const events = yield* nativeRuntime( - 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, + const events = yield* collect({ + user: { + id: MessageID.make("msg_user-recorded-native-tool"), 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" } - }, - }), - }, - }), - ) + 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) @@ -263,34 +247,32 @@ describe("session.llm native recorded", () => { const resolved = yield* getModel(ProviderID.opencode, ModelID.make(model.id)) let executed: unknown - const events = yield* nativeRuntime( - collect({ - user: { - id: MessageID.make("msg_user-recorded-native-zen-tool"), - sessionID, - role: "user", - time: { created: 0 }, - agent: agent.name, - model: { providerID: ProviderID.opencode, modelID: ModelID.make(model.id) }, - } satisfies MessageV2.User, + const events = yield* collect({ + user: { + id: MessageID.make("msg_user-recorded-native-zen-tool"), 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" } - }, - }), - }, - }), - ) + 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) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 6744362a71..d0dfd21e08 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -21,6 +21,7 @@ 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" const openAIConfig = (model: ModelsDev.Provider["models"][string], baseURL: string): Partial => { const { experimental: _experimental, ...configModel } = model @@ -66,13 +67,14 @@ async function drainWith(layer: Layer.Layer, input: LLM.StreamInput ) } -function llmLayerWithExecutor(executor: Layer.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)), ) } @@ -769,39 +771,32 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, fn: async () => { - const previous = process.env.OPENCODE_LLM_RUNTIME - process.env.OPENCODE_LLM_RUNTIME = "native" - try { - 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 + 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 drain({ - 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, + await drainWith(llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }), { + user: { + id: MessageID.make("msg_user-native"), sessionID, - model: resolved, - agent, - system: ["You are a helpful assistant."], - messages: [{ role: "user", content: "Hello" }], - tools: {}, - }) - } finally { - if (previous === undefined) delete process.env.OPENCODE_LLM_RUNTIME - else process.env.OPENCODE_LLM_RUNTIME = previous - } + 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) @@ -862,47 +857,40 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, fn: async () => { - const previous = process.env.OPENCODE_LLM_RUNTIME - process.env.OPENCODE_LLM_RUNTIME = "native" - try { - 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 + 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), { - 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, + await drainWith(llmLayerWithExecutor(executor, { experimentalNativeLlm: true }), { + user: { + id: MessageID.make("msg_user-native-injected-tool"), 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" } - }, - }), - }, - }) - } finally { - if (previous === undefined) delete process.env.OPENCODE_LLM_RUNTIME - else process.env.OPENCODE_LLM_RUNTIME = previous - } + 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([ @@ -990,47 +978,40 @@ describe("session.llm.stream", () => { await WithInstance.provide({ directory: tmp.path, fn: async () => { - const previous = process.env.OPENCODE_LLM_RUNTIME - process.env.OPENCODE_LLM_RUNTIME = "native" - try { - 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 + 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 drain({ - 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, + await drainWith(llmLayerWithExecutor(RequestExecutor.defaultLayer, { experimentalNativeLlm: true }), { + user: { + id: MessageID.make("msg_user-native-tool"), 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" } - }, - }), - }, - }) - } finally { - if (previous === undefined) delete process.env.OPENCODE_LLM_RUNTIME - else process.env.OPENCODE_LLM_RUNTIME = previous - } + 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([