Refactor LLM route-first provider API (#28523)
This commit is contained in:
@@ -56,11 +56,11 @@ afterEach(async () => {
|
||||
})
|
||||
|
||||
const inApp = <A, E>(eff: Effect.Effect<A, E, AppServices>) =>
|
||||
Effect.flatMap(InstanceRef, (ctx) =>
|
||||
ctx
|
||||
? Effect.promise(() => AppRuntime.runPromise(eff.pipe(Effect.provideService(InstanceRef, ctx))))
|
||||
: Effect.die("InstanceRef not provided in test scope"),
|
||||
)
|
||||
Effect.gen(function* () {
|
||||
const ctx = yield* InstanceRef
|
||||
if (!ctx) return yield* Effect.die("InstanceRef not provided in test scope")
|
||||
return yield* Effect.promise(() => AppRuntime.runPromise(eff.pipe(Effect.provideService(InstanceRef, ctx))))
|
||||
})
|
||||
|
||||
const publishConnected = inApp(Bus.Service.use((svc) => svc.publish(ServerEvent.Connected, {})))
|
||||
|
||||
@@ -112,7 +112,7 @@ const readNextEvent = (reader: ReadableStreamDefaultReader<Uint8Array>) =>
|
||||
if (result.done || !result.value) return Effect.fail(new Error("event stream closed"))
|
||||
const frames = decodeFrame(result.value)
|
||||
if (frames.length === 0) return Effect.fail(new Error("empty SSE frame"))
|
||||
return Effect.succeed(frames[0]!)
|
||||
return Effect.succeed(frames[0])
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -186,8 +186,7 @@ describe("/event SSE delivery diagnostics", () => {
|
||||
|
||||
const collected = yield* collectUntilEvent(reader, isPartUpdated)
|
||||
const updated = collected.find(isPartUpdated)
|
||||
expect(updated).toBeDefined()
|
||||
expect((updated as SseEvent).properties.part.id).toBe(partID)
|
||||
expect(updated?.properties.part.id).toBe(partID)
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
@@ -217,7 +216,7 @@ describe("/event SSE delivery diagnostics", () => {
|
||||
}),
|
||||
)
|
||||
expect(event.type).toBe(MessageV2.Event.PartUpdated.type)
|
||||
expect((event.properties as { part: { id: string } }).part.id).toBe(partID)
|
||||
expect(event.properties).toMatchObject({ part: { id: partID } })
|
||||
}),
|
||||
{ git: true, config: { formatter: false, lsp: false } },
|
||||
)
|
||||
|
||||
@@ -13,7 +13,7 @@ import { Provider } from "@/provider/provider"
|
||||
import { ModelID, ProviderID } from "@/provider/schema"
|
||||
import { Filesystem } from "@/util/filesystem"
|
||||
import { LLMEvent, LLMResponse } from "@opencode-ai/llm"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
|
||||
import { RuntimeFlags } from "@/effect/runtime-flags"
|
||||
import type { Agent } from "../../src/agent/agent"
|
||||
import { LLM } from "../../src/session/llm"
|
||||
@@ -137,7 +137,7 @@ async function loadFixture(providerID: string, modelID: string) {
|
||||
function recordedNativeLLMLayer(spec: ProviderSpec) {
|
||||
// Only the HTTP client is recorded; RequestExecutor and the opencode LLM stack remain real.
|
||||
const recordedClient = LLMClient.layer.pipe(
|
||||
Layer.provide(RequestExecutor.layer),
|
||||
Layer.provide(Layer.mergeAll(RequestExecutor.layer, WebSocketExecutor.layer)),
|
||||
Layer.provide(
|
||||
HttpRecorder.recordingLayer(spec.cassette, {
|
||||
mode: shouldRecord ? "record" : "replay",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
|
||||
import { jsonSchema, tool, type ModelMessage } from "ai"
|
||||
import { Effect } from "effect"
|
||||
import { Effect, Layer } from "effect"
|
||||
import { LLMNative } from "@/session/llm/native-request"
|
||||
import { LLMNativeRuntime } from "@/session/llm/native-runtime"
|
||||
import type { Provider } from "@/provider/provider"
|
||||
@@ -138,16 +138,16 @@ describe("session.llm-native.request", () => {
|
||||
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,
|
||||
},
|
||||
route: { id: "openai-responses" },
|
||||
})
|
||||
expect(request.model.route.endpoint.baseURL).toBe("https://api.openai.com/v1")
|
||||
expect(request.model.route.defaults.headers).toEqual({
|
||||
"x-model": "model-header",
|
||||
"x-request": "request-header",
|
||||
})
|
||||
expect(request.model.route.defaults.limits).toMatchObject({
|
||||
context: 128_000,
|
||||
output: 32_000,
|
||||
})
|
||||
expect(request.system).toEqual([
|
||||
{ type: "text", text: "agent system" },
|
||||
@@ -211,29 +211,50 @@ describe("session.llm-native.request", () => {
|
||||
])
|
||||
})
|
||||
|
||||
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",
|
||||
test("selects native request routes for provider packages", () => {
|
||||
const openai = LLMNative.model({
|
||||
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },
|
||||
apiKey: "test-key",
|
||||
messages: [],
|
||||
})
|
||||
expect(LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/google" } })).toMatchObject({
|
||||
route: "gemini",
|
||||
baseURL: "https://generativelanguage.googleapis.com/v1beta",
|
||||
expect(openai.route.id).toBe("openai-responses")
|
||||
expect(openai.route.endpoint.baseURL).toBe("https://api.openai.com/v1")
|
||||
|
||||
const anthropic = LLMNative.model({
|
||||
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/anthropic" } },
|
||||
apiKey: "test-key",
|
||||
messages: [],
|
||||
})
|
||||
expect(
|
||||
LLMNative.model({ ...baseModel, api: { ...baseModel.api, npm: "@ai-sdk/openai-compatible" } }),
|
||||
).toMatchObject({
|
||||
route: "openai-compatible-chat",
|
||||
baseURL: "https://api.openai.com/v1",
|
||||
expect(anthropic.route.id).toBe("anthropic-messages")
|
||||
expect(anthropic.route.endpoint.baseURL).toBe("https://api.anthropic.com/v1")
|
||||
|
||||
const google = LLMNative.model({
|
||||
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/google" } },
|
||||
apiKey: "test-key",
|
||||
messages: [],
|
||||
})
|
||||
expect(
|
||||
LLMNative.model({ ...baseModel, api: { ...baseModel.api, url: "", npm: "@openrouter/ai-sdk-provider" } }),
|
||||
).toMatchObject({
|
||||
route: "openrouter",
|
||||
baseURL: "https://openrouter.ai/api/v1",
|
||||
expect(google.route.id).toBe("gemini")
|
||||
expect(google.route.endpoint.baseURL).toBe("https://generativelanguage.googleapis.com/v1beta")
|
||||
|
||||
const compatible = LLMNative.model({
|
||||
model: {
|
||||
...baseModel,
|
||||
providerID: ProviderID.make("opencode"),
|
||||
api: { ...baseModel.api, url: "https://ai.example.test/v1", npm: "@ai-sdk/openai-compatible" },
|
||||
},
|
||||
apiKey: "test-key",
|
||||
messages: [],
|
||||
})
|
||||
expect(compatible.route.id).toBe("openai-compatible-chat")
|
||||
expect(compatible.route.endpoint.baseURL).toBe("https://ai.example.test/v1")
|
||||
|
||||
const openrouter = LLMNative.model({
|
||||
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@openrouter/ai-sdk-provider" } },
|
||||
apiKey: "test-key",
|
||||
messages: [],
|
||||
})
|
||||
expect(openrouter.route.id).toBe("openrouter")
|
||||
expect(openrouter.route.endpoint.baseURL).toBe("https://openrouter.ai/api/v1")
|
||||
})
|
||||
|
||||
test("fails fast for unsupported provider packages", () => {
|
||||
@@ -260,6 +281,20 @@ describe("session.llm-native.request", () => {
|
||||
type: "supported",
|
||||
apiKey: "test-openai-key",
|
||||
})
|
||||
expect(
|
||||
LLMNativeRuntime.status({
|
||||
model: {
|
||||
...baseModel,
|
||||
providerID: ProviderID.make("opencode"),
|
||||
api: { ...baseModel.api, npm: "@ai-sdk/openai-compatible" },
|
||||
},
|
||||
provider: { ...providerInfo, id: ProviderID.make("opencode") },
|
||||
auth: undefined,
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: "supported",
|
||||
apiKey: "test-openai-key",
|
||||
})
|
||||
expect(
|
||||
LLMNativeRuntime.status({
|
||||
model: { ...baseModel, providerID: ProviderID.make("google") },
|
||||
@@ -281,7 +316,7 @@ describe("session.llm-native.request", () => {
|
||||
provider: providerInfo,
|
||||
auth: undefined,
|
||||
}),
|
||||
).toEqual({ type: "unsupported", reason: "provider package is not OpenAI or Anthropic" })
|
||||
).toEqual({ type: "unsupported", reason: "provider package is not OpenAI, OpenAI-compatible, or Anthropic" })
|
||||
|
||||
expect(
|
||||
LLMNativeRuntime.status({
|
||||
@@ -382,12 +417,16 @@ describe("session.llm-native.request", () => {
|
||||
LLMClient.prepare(
|
||||
LLMNative.request({
|
||||
model: baseModel,
|
||||
apiKey: "test-openai-key",
|
||||
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)),
|
||||
).pipe(
|
||||
Effect.provide(LLMClient.layer),
|
||||
Effect.provide(Layer.mergeAll(RequestExecutor.defaultLayer, WebSocketExecutor.layer)),
|
||||
),
|
||||
)
|
||||
|
||||
expect(prepared).toMatchObject({
|
||||
|
||||
@@ -8,7 +8,7 @@ import { makeRuntime } from "../../src/effect/run-service"
|
||||
import { InstanceRef } from "../../src/effect/instance-ref"
|
||||
import { LLM } from "../../src/session/llm"
|
||||
import type { InstanceContext } from "../../src/project/instance-context"
|
||||
import { LLMClient, RequestExecutor } from "@opencode-ai/llm/route"
|
||||
import { LLMClient, RequestExecutor, WebSocketExecutor } from "@opencode-ai/llm/route"
|
||||
import { Auth } from "@/auth"
|
||||
import { Config } from "@/config/config"
|
||||
import { Provider } from "@/provider/provider"
|
||||
@@ -82,7 +82,7 @@ function llmLayerWithExecutor(executor: Layer.Layer<RequestExecutor.Service>, fl
|
||||
Layer.provide(Config.defaultLayer),
|
||||
Layer.provide(Provider.defaultLayer),
|
||||
Layer.provide(Plugin.defaultLayer),
|
||||
Layer.provide(LLMClient.layer.pipe(Layer.provide(executor))),
|
||||
Layer.provide(LLMClient.layer.pipe(Layer.provide(Layer.mergeAll(executor, WebSocketExecutor.layer)))),
|
||||
Layer.provide(RuntimeFlags.layer(flags)),
|
||||
)
|
||||
}
|
||||
@@ -1975,54 +1975,45 @@ describe("session.llm.stream", () => {
|
||||
const body = capture.body
|
||||
|
||||
expect(capture.url.pathname.endsWith("/messages")).toBe(true)
|
||||
expect(body.messages).toStrictEqual([
|
||||
const messages = body.messages as Array<{ role: string; content: Array<Record<string, unknown>> }>
|
||||
expect(messages[0]?.role).toBe("user")
|
||||
expect(messages[0]?.content[0]).toMatchObject({
|
||||
type: "text",
|
||||
text: "Can you check whether there are any PDF files in my home directory?",
|
||||
})
|
||||
expect(messages.some((message) => message.content.some((part) => "cache_control" in part))).toBe(true)
|
||||
const toolUseIndex = messages.findIndex((message) => message.content.some((part) => part.type === "tool_use"))
|
||||
expect(toolUseIndex).toBeGreaterThan(0)
|
||||
expect(messages[toolUseIndex].role).toBe("assistant")
|
||||
expect(messages[toolUseIndex].content.filter((part) => part.type === "tool_use")).toMatchObject([
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Can you check whether there are any PDF files in my home directory?" }],
|
||||
type: "tool_use",
|
||||
id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
|
||||
name: "read",
|
||||
input: { filePath: "/root" },
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "I checked your home directory and looked for PDF files.",
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
|
||||
name: "read",
|
||||
input: { filePath: "/root" },
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "toolu_01APxrADs7VozN8uWzw9WwHr",
|
||||
name: "glob",
|
||||
input: { pattern: "**/*.pdf", path: "/root" },
|
||||
cache_control: {
|
||||
type: "ephemeral",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
|
||||
content: "<path>/root</path>",
|
||||
},
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_01APxrADs7VozN8uWzw9WwHr",
|
||||
content: "No files found",
|
||||
cache_control: {
|
||||
type: "ephemeral",
|
||||
},
|
||||
},
|
||||
],
|
||||
type: "tool_use",
|
||||
id: "toolu_01APxrADs7VozN8uWzw9WwHr",
|
||||
name: "glob",
|
||||
input: { pattern: "**/*.pdf", path: "/root" },
|
||||
},
|
||||
])
|
||||
expect(messages[toolUseIndex + 1]).toMatchObject({
|
||||
role: "user",
|
||||
content: [
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_01N8mDEzG8DSTs7UPHFtmgCT",
|
||||
content: "<path>/root</path>",
|
||||
},
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_01APxrADs7VozN8uWzw9WwHr",
|
||||
content: "No files found",
|
||||
},
|
||||
],
|
||||
})
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user