Skip to content

Commit d37bc3e

Browse files
committed
refactor(session): inject native LLM client
1 parent a2b0ef9 commit d37bc3e

2 files changed

Lines changed: 162 additions & 59 deletions

File tree

packages/opencode/src/session/llm.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import * as Stream from "effect/Stream"
55
import { streamText, wrapLanguageModel, type ModelMessage, type Tool, tool as aiTool, jsonSchema, asSchema } from "ai"
66
import { tool as nativeTool, ToolFailure, type JsonSchema, type LLMEvent } from "@opencode-ai/llm"
77
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
8+
import type { LLMClientService } from "@opencode-ai/llm/route"
89
import { mergeDeep } from "remeda"
910
import { GitLabWorkflowLanguageModel } from "gitlab-ai-provider"
1011
import { ProviderTransform } from "@/provider/transform"
@@ -66,7 +67,7 @@ export class Service extends Context.Service<Service, Interface>()("@opencode/LL
6667
const live: Layer.Layer<
6768
Service,
6869
never,
69-
Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service
70+
Auth.Service | Config.Service | Provider.Service | Plugin.Service | Permission.Service | LLMClientService
7071
> = Layer.effect(
7172
Service,
7273
Effect.gen(function* () {
@@ -75,6 +76,7 @@ const live: Layer.Layer<
7576
const provider = yield* Provider.Service
7677
const plugin = yield* Plugin.Service
7778
const perm = yield* Permission.Service
79+
const llmClient = yield* LLMClient.Service
7880

7981
const run = Effect.fn("LLM.run")(function* (input: StreamRequest) {
8082
const l = log
@@ -380,10 +382,7 @@ const live: Layer.Layer<
380382
})
381383
return {
382384
type: "native" as const,
383-
stream: LLMClient.stream({ request, tools: nativeTools(sortedTools, input) }).pipe(
384-
Stream.provide(LLMClient.layer),
385-
Stream.provide(RequestExecutor.defaultLayer),
386-
),
385+
stream: llmClient.stream({ request, tools: nativeTools(sortedTools, input) }),
387386
}
388387
}
389388

@@ -492,6 +491,7 @@ export const defaultLayer = Layer.suspend(() =>
492491
Layer.provide(Config.defaultLayer),
493492
Layer.provide(Provider.defaultLayer),
494493
Layer.provide(Plugin.defaultLayer),
494+
Layer.provide(LLMClient.layer.pipe(Layer.provide(RequestExecutor.defaultLayer))),
495495
),
496496
)
497497

packages/opencode/test/session/llm.test.ts

Lines changed: 157 additions & 54 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
11
import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test"
22
import path from "path"
33
import { tool, type ModelMessage } from "ai"
4-
import { Cause, Effect, Exit, Stream } from "effect"
4+
import { Cause, Effect, Exit, Layer, Stream } from "effect"
5+
import { HttpClientRequest, HttpClientResponse } from "effect/unstable/http"
56
import z from "zod"
6-
import { makeRuntime } from "../../src/effect/run-service"
7+
import { attach, makeRuntime } from "../../src/effect/run-service"
78
import { LLM } from "../../src/session/llm"
9+
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
810
import { WithInstance } from "../../src/project/with-instance"
11+
import { Auth } from "@/auth"
12+
import { Config } from "@/config/config"
913
import { Provider } from "@/provider/provider"
1014
import { ProviderTransform } from "@/provider/transform"
1115
import { ModelsDev } from "@/provider/models"
16+
import { Plugin } from "@/plugin"
1217
import { ProviderID, ModelID } from "../../src/provider/schema"
1318
import { Filesystem } from "@/util/filesystem"
1419
import { tmpdir } from "../fixture/fixture"
@@ -17,6 +22,29 @@ import { MessageV2 } from "../../src/session/message-v2"
1722
import { SessionID, MessageID } from "../../src/session/schema"
1823
import { AppRuntime } from "../../src/effect/app-runtime"
1924

25+
const openAIConfig = (model: ModelsDev.Provider["models"][string], baseURL: string): Partial<Config.Info> => {
26+
const { experimental: _experimental, ...configModel } = model
27+
type ConfigModel = NonNullable<NonNullable<Config.Info["provider"]>[string]["models"]>[string]
28+
return {
29+
enabled_providers: ["openai"],
30+
provider: {
31+
openai: {
32+
name: "OpenAI",
33+
env: ["OPENAI_API_KEY"],
34+
npm: "@ai-sdk/openai",
35+
api: "https://api.openai.com/v1",
36+
models: {
37+
[model.id]: JSON.parse(JSON.stringify(configModel)) as ConfigModel,
38+
},
39+
options: {
40+
apiKey: "test-openai-key",
41+
baseURL,
42+
},
43+
},
44+
},
45+
}
46+
}
47+
2048
async function getModel(providerID: ProviderID, modelID: ModelID) {
2149
return AppRuntime.runPromise(
2250
Effect.gen(function* () {
@@ -32,6 +60,22 @@ async function drain(input: LLM.StreamInput) {
3260
return llm.runPromise((svc) => svc.stream(input).pipe(Stream.runDrain))
3361
}
3462

63+
async function drainWith(layer: Layer.Layer<LLM.Service>, input: LLM.StreamInput) {
64+
return Effect.runPromise(
65+
attach(LLM.Service.use((svc) => svc.stream(input).pipe(Stream.runDrain))).pipe(Effect.provide(layer)),
66+
)
67+
}
68+
69+
function llmLayerWithExecutor(executor: Layer.Layer<RequestExecutor.Service>) {
70+
return LLM.layer.pipe(
71+
Layer.provide(Auth.defaultLayer),
72+
Layer.provide(Config.defaultLayer),
73+
Layer.provide(Provider.defaultLayer),
74+
Layer.provide(Plugin.defaultLayer),
75+
Layer.provide(LLMClient.layer.pipe(Layer.provide(executor))),
76+
)
77+
}
78+
3579
describe("session.llm.hasToolCalls", () => {
3680
test("returns false for empty messages array", () => {
3781
expect(LLM.hasToolCalls([])).toBe(false)
@@ -614,32 +658,7 @@ describe("session.llm.stream", () => {
614658
]
615659
const request = waitRequest("/responses", createEventResponse(responseChunks, true))
616660

617-
await using tmp = await tmpdir({
618-
init: async (dir) => {
619-
await Bun.write(
620-
path.join(dir, "opencode.json"),
621-
JSON.stringify({
622-
$schema: "https://opencode.ai/config.json",
623-
enabled_providers: ["openai"],
624-
provider: {
625-
openai: {
626-
name: "OpenAI",
627-
env: ["OPENAI_API_KEY"],
628-
npm: "@ai-sdk/openai",
629-
api: "https://api.openai.com/v1",
630-
models: {
631-
[model.id]: model,
632-
},
633-
options: {
634-
apiKey: "test-openai-key",
635-
baseURL: `${server.url.origin}/v1`,
636-
},
637-
},
638-
},
639-
}),
640-
)
641-
},
642-
})
661+
await using tmp = await tmpdir({ config: openAIConfig(model, `${server.url.origin}/v1`) })
643662

644663
await WithInstance.provide({
645664
directory: tmp.path,
@@ -726,32 +745,7 @@ describe("session.llm.stream", () => {
726745
]
727746
const request = waitRequest("/responses", createEventResponse(chunks, true))
728747

729-
await using tmp = await tmpdir({
730-
init: async (dir) => {
731-
await Bun.write(
732-
path.join(dir, "opencode.json"),
733-
JSON.stringify({
734-
$schema: "https://opencode.ai/config.json",
735-
enabled_providers: ["openai"],
736-
provider: {
737-
openai: {
738-
name: "OpenAI",
739-
env: ["OPENAI_API_KEY"],
740-
npm: "@ai-sdk/openai",
741-
api: "https://api.openai.com/v1",
742-
models: {
743-
[model.id]: model,
744-
},
745-
options: {
746-
apiKey: "test-openai-key",
747-
baseURL: `${server.url.origin}/v1`,
748-
},
749-
},
750-
},
751-
}),
752-
)
753-
},
754-
})
748+
await using tmp = await tmpdir({ config: openAIConfig(model, `${server.url.origin}/v1`) })
755749

756750
await WithInstance.provide({
757751
directory: tmp.path,
@@ -802,6 +796,115 @@ describe("session.llm.stream", () => {
802796
})
803797
})
804798

799+
test("uses injected native request executor for tool calls", async () => {
800+
const source = await loadFixture("openai", "gpt-5.2")
801+
const model = source.model
802+
const chunks = [
803+
{
804+
type: "response.output_item.added",
805+
item: { type: "function_call", id: "item-injected-tool", call_id: "call-injected-tool", name: "lookup" },
806+
},
807+
{
808+
type: "response.function_call_arguments.delta",
809+
item_id: "item-injected-tool",
810+
delta: '{"query":"weather"}',
811+
},
812+
{
813+
type: "response.output_item.done",
814+
item: {
815+
type: "function_call",
816+
id: "item-injected-tool",
817+
call_id: "call-injected-tool",
818+
name: "lookup",
819+
arguments: '{"query":"weather"}',
820+
},
821+
},
822+
{
823+
type: "response.completed",
824+
response: { incomplete_details: null, usage: { input_tokens: 1, output_tokens: 1 } },
825+
},
826+
]
827+
let captured: Record<string, unknown> | undefined
828+
let executed: unknown
829+
const executor = Layer.succeed(
830+
RequestExecutor.Service,
831+
RequestExecutor.Service.of({
832+
execute: (request) =>
833+
Effect.gen(function* () {
834+
const web = yield* HttpClientRequest.toWeb(request).pipe(Effect.orDie)
835+
captured = (yield* Effect.promise(() => web.json())) as Record<string, unknown>
836+
return HttpClientResponse.fromWeb(request, createEventResponse(chunks, true))
837+
}),
838+
}),
839+
)
840+
841+
await using tmp = await tmpdir({ config: openAIConfig(model, "https://injected-openai.test/v1") })
842+
843+
await WithInstance.provide({
844+
directory: tmp.path,
845+
fn: async () => {
846+
const previous = process.env.OPENCODE_LLM_RUNTIME
847+
process.env.OPENCODE_LLM_RUNTIME = "native"
848+
try {
849+
const resolved = await getModel(ProviderID.openai, ModelID.make(model.id))
850+
const sessionID = SessionID.make("session-test-native-injected-tool")
851+
const agent = {
852+
name: "test",
853+
mode: "primary",
854+
options: {},
855+
permission: [{ permission: "*", pattern: "*", action: "allow" }],
856+
} satisfies Agent.Info
857+
858+
await drainWith(llmLayerWithExecutor(executor), {
859+
user: {
860+
id: MessageID.make("msg_user-native-injected-tool"),
861+
sessionID,
862+
role: "user",
863+
time: { created: Date.now() },
864+
agent: agent.name,
865+
model: { providerID: ProviderID.make("openai"), modelID: resolved.id },
866+
} satisfies MessageV2.User,
867+
sessionID,
868+
model: resolved,
869+
agent,
870+
system: [],
871+
messages: [{ role: "user", content: "Use lookup" }],
872+
tools: {
873+
lookup: tool({
874+
description: "Lookup data",
875+
inputSchema: z.object({ query: z.string() }),
876+
execute: async (args, options) => {
877+
executed = { args, toolCallId: options.toolCallId }
878+
return { output: "looked up" }
879+
},
880+
}),
881+
},
882+
})
883+
} finally {
884+
if (previous === undefined) delete process.env.OPENCODE_LLM_RUNTIME
885+
else process.env.OPENCODE_LLM_RUNTIME = previous
886+
}
887+
888+
expect(captured?.model).toBe(model.id)
889+
expect(captured?.tools).toEqual([
890+
{
891+
type: "function",
892+
name: "lookup",
893+
description: "Lookup data",
894+
parameters: {
895+
type: "object",
896+
properties: { query: { type: "string" } },
897+
required: ["query"],
898+
additionalProperties: false,
899+
$schema: "http://json-schema.org/draft-07/schema#",
900+
},
901+
},
902+
])
903+
expect(executed).toEqual({ args: { query: "weather" }, toolCallId: "call-injected-tool" })
904+
},
905+
})
906+
})
907+
805908
test("executes OpenAI tool calls through native runtime", async () => {
806909
const server = state.server
807910
if (!server) {

0 commit comments

Comments
 (0)