refactor(llm): simplify adapter shared logic

This commit is contained in:
Kit Langton
2026-04-26 20:30:50 -04:00
parent 1a839c6233
commit ecd73f26fc
9 changed files with 233 additions and 164 deletions
@@ -206,15 +206,10 @@ const decodeTarget = Schema.decodeUnknownEffect(AnthropicMessagesDraft.pipe(Sche
const invalid = ProviderShared.invalidRequest
const baseUrl = (request: LLMRequest) => (request.model.baseURL ?? "https://api.anthropic.com/v1").replace(/\/+$/, "")
const baseUrl = (request: LLMRequest) => ProviderShared.trimBaseUrl(request.model.baseURL ?? "https://api.anthropic.com/v1")
const cacheControl = (cache: CacheHint | undefined) => cache?.type === "ephemeral" ? { type: "ephemeral" as const } : undefined
const resultText = (part: ToolResultPart) => {
if (part.result.type === "text" || part.result.type === "error") return String(part.result.value)
return ProviderShared.encodeJson(part.result.value)
}
const lowerTool = (tool: ToolDefinition): AnthropicTool => ({
name: tool.name,
description: tool.description,
@@ -306,7 +301,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (re
content.push({
type: "tool_result",
tool_use_id: part.id,
content: resultText(part),
content: ProviderShared.toolResultText(part),
is_error: part.result.type === "error" ? true : undefined,
})
}
+3 -10
View File
@@ -1,4 +1,3 @@
import { Buffer } from "node:buffer"
import { Effect, Schema, Stream } from "effect"
import type { HttpClientResponse } from "effect/unstable/http"
import { Adapter } from "../adapter"
@@ -13,7 +12,6 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolResultPart,
} from "../schema"
import { ProviderShared } from "./shared"
@@ -153,14 +151,9 @@ const decodeTarget = Schema.decodeUnknownEffect(GeminiDraft.pipe(Schema.decodeTo
const invalid = ProviderShared.invalidRequest
const baseUrl = (request: LLMRequest) =>
(request.model.baseURL ?? "https://generativelanguage.googleapis.com/v1beta").replace(/\/+$/, "")
ProviderShared.trimBaseUrl(request.model.baseURL ?? "https://generativelanguage.googleapis.com/v1beta")
const mediaData = (part: MediaPart) => typeof part.data === "string" ? part.data : Buffer.from(part.data).toString("base64")
const resultText = (part: ToolResultPart) => {
if (part.result.type === "text" || part.result.type === "error") return String(part.result.value)
return ProviderShared.encodeJson(part.result.value)
}
const mediaData = ProviderShared.mediaBytes
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
@@ -269,7 +262,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
name: part.name,
response: {
name: part.name,
content: resultText(part),
content: ProviderShared.toolResultText(part),
},
},
})
+2 -8
View File
@@ -11,7 +11,6 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolResultPart,
} from "../schema"
import { ProviderShared } from "./shared"
@@ -165,12 +164,7 @@ const decodeTarget = Schema.decodeUnknownEffect(OpenAIChatDraft.pipe(Schema.deco
const invalid = ProviderShared.invalidRequest
const baseUrl = (request: LLMRequest) => (request.model.baseURL ?? "https://api.openai.com/v1").replace(/\/+$/, "")
const resultText = (part: ToolResultPart) => {
if (part.result.type === "text" || part.result.type === "error") return String(part.result.value)
return ProviderShared.encodeJson(part.result.value)
}
const baseUrl = (request: LLMRequest) => ProviderShared.trimBaseUrl(request.model.baseURL ?? "https://api.openai.com/v1")
const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({
type: "function",
@@ -239,7 +233,7 @@ const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request:
for (const part of message.content) {
if (part.type !== "tool-result")
return yield* invalid(`OpenAI Chat tool messages only support tool-result content`)
messages.push({ role: "tool", tool_call_id: part.id, content: resultText(part) })
messages.push({ role: "tool", tool_call_id: part.id, content: ProviderShared.toolResultText(part) })
}
}
@@ -32,7 +32,7 @@ const queryParams = (request: LLMRequest) => {
const completionUrl = (request: LLMRequest) => {
if (!request.model.baseURL) return undefined
const url = new URL(`${request.model.baseURL.replace(/\/+$/, "")}/chat/completions`)
const url = new URL(`${ProviderShared.trimBaseUrl(request.model.baseURL)}/chat/completions`)
for (const [key, value] of Object.entries(queryParams(request) ?? {})) url.searchParams.set(key, value)
return url.toString()
}
@@ -10,7 +10,6 @@ import {
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolResultPart,
} from "../schema"
import { ProviderShared } from "./shared"
@@ -150,12 +149,7 @@ interface ParserState {
const invalid = ProviderShared.invalidRequest
const baseUrl = (request: LLMRequest) => (request.model.baseURL ?? "https://api.openai.com/v1").replace(/\/+$/, "")
const resultText = (part: ToolResultPart) => {
if (part.result.type === "text" || part.result.type === "error") return String(part.result.value)
return ProviderShared.encodeJson(part.result.value)
}
const baseUrl = (request: LLMRequest) => ProviderShared.trimBaseUrl(request.model.baseURL ?? "https://api.openai.com/v1")
const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({
type: "function",
@@ -216,7 +210,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
for (const part of message.content) {
if (part.type !== "tool-result")
return yield* invalid(`OpenAI Responses tool messages only support tool-result content`)
input.push({ type: "function_call_output", call_id: part.id, output: resultText(part) })
input.push({ type: "function_call_output", call_id: part.id, output: ProviderShared.toolResultText(part) })
}
}
@@ -281,6 +275,9 @@ const finishToolCall = (tools: Record<string, ToolAccumulator>, item: NonNullabl
return [{ type: "tool-call" as const, id: item.call_id, name: item.name, input }]
})
const withoutTool = (tools: Record<string, ToolAccumulator>, id: string | undefined) =>
id === undefined ? tools : Object.fromEntries(Object.entries(tools).filter(([key]) => key !== id))
// Hosted tool items (provider-executed) ship their typed input + status + result
// fields all in one item. We expose them as a `tool-call` + `tool-result` pair
// so consumers can treat them uniformly with client tools, only differentiated
@@ -360,7 +357,7 @@ const processChunk = (state: ParserState, chunk: OpenAIResponsesChunk) =>
if (chunk.type === "response.output_item.done" && chunk.item?.type === "function_call") {
const events = yield* finishToolCall(state.tools, chunk.item)
return [state, events] as const
return [{ tools: withoutTool(state.tools, chunk.item.id) }, events] as const
}
if (chunk.type === "response.output_item.done" && chunk.item && isHostedToolItem(chunk.item)) {
+18 -1
View File
@@ -1,7 +1,8 @@
import { Buffer } from "node:buffer"
import { Cause, Effect, Schema, Stream } from "effect"
import * as Sse from "effect/unstable/encoding/Sse"
import { HttpClientRequest, type HttpClientResponse } from "effect/unstable/http"
import { InvalidRequestError, ProviderChunkError } from "../schema"
import { InvalidRequestError, ProviderChunkError, type MediaPart, type ToolResultPart } from "../schema"
export const Json = Schema.fromJsonString(Schema.Unknown)
export const decodeJson = Schema.decodeUnknownSync(Json)
@@ -34,6 +35,22 @@ export const joinText = (parts: ReadonlyArray<{ readonly text: string }>) =>
export const parseToolInput = (adapter: string, name: string, raw: string) =>
parseJson(adapter, raw || "{}", `Invalid JSON input for ${adapter} tool call ${name}`)
/**
* Encode a `MediaPart`'s raw bytes for inclusion in a JSON request body.
* `data: string` is assumed to already be base64 (matches caller convention
* across Gemini / Bedrock); `data: Uint8Array` is base64-encoded here. Used
* by every adapter that supports image / document inputs.
*/
export const mediaBytes = (part: MediaPart) =>
typeof part.data === "string" ? part.data : Buffer.from(part.data).toString("base64")
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
export const toolResultText = (part: ToolResultPart) => {
if (part.result.type === "text" || part.result.type === "error") return String(part.result.value)
return encodeJson(part.result.value)
}
const streamError = (adapter: string, message: string, cause: Cause.Cause<unknown>) => {
const failed = cause.reasons.find(Cause.isFailReason)?.error
if (failed instanceof ProviderChunkError) return failed
+13 -10
View File
@@ -10,7 +10,6 @@ import {
type LLMEvent,
LLMRequest,
type ToolCallPart,
type ToolResultPart,
type ToolResultValue,
} from "./schema"
import { ToolFailure } from "./schema"
@@ -63,9 +62,13 @@ export const run = <T extends Tools>(
const maxSteps = options.maxSteps ?? 10
const concurrency = options.concurrency ?? 10
const tools = options.tools as Tools
const runtimeTools = toDefinitions(tools)
const initialRequest = new LLMRequest({
...options.request,
tools: [...options.request.tools, ...toDefinitions(tools)],
tools: [
...options.request.tools.filter((tool) => !runtimeTools.some((runtimeTool) => runtimeTool.name === tool.name)),
...runtimeTools,
],
})
const loop = (request: LLMRequest, step: number): Stream.Stream<LLMEvent, LLMError, RequestExecutor.Service> =>
@@ -128,13 +131,12 @@ const accumulate = (state: StepState, event: LLMEvent) => {
return
}
if (event.type === "tool-call") {
const part: ToolCallPart = {
type: "tool-call",
const part = LLM.toolCall({
id: event.id,
name: event.name,
input: event.input,
providerExecuted: event.providerExecuted,
}
})
state.assistantContent.push(part)
// Provider-executed tools are dispatched by the provider; the runtime must
// not invoke a client handler. The matching `tool-result` event arrives
@@ -144,14 +146,12 @@ const accumulate = (state: StepState, event: LLMEvent) => {
return
}
if (event.type === "tool-result" && event.providerExecuted) {
const part: ToolResultPart = {
type: "tool-result",
state.assistantContent.push(LLM.toolResult({
id: event.id,
name: event.name,
result: event.result,
providerExecuted: true,
}
state.assistantContent.push(part)
}))
return
}
if (event.type === "request-finish") {
@@ -198,7 +198,10 @@ const decodeAndExecute = (tool: AnyTool, input: unknown): Effect.Effect<ToolResu
const emitEvents = (call: ToolCallPart, result: ToolResultValue): ReadonlyArray<LLMEvent> =>
result.type === "error"
? [{ type: "tool-error", id: call.id, name: call.name, message: String(result.value) }]
? [
{ type: "tool-error", id: call.id, name: call.name, message: String(result.value) },
{ type: "tool-result", id: call.id, name: call.name, result },
]
: [{ type: "tool-result", id: call.id, name: call.name, result }]
export * as ToolRuntime from "./tool-runtime"
+6
View File
@@ -82,6 +82,12 @@ describe("ToolRuntime", () => {
const toolError = events.find(LLMEvent.guards["tool-error"])
expect(toolError).toMatchObject({ type: "tool-error", id: "call_1", name: "missing_tool" })
expect(toolError?.message).toContain("Unknown tool")
expect(events.find(LLMEvent.guards["tool-result"])).toMatchObject({
type: "tool-result",
id: "call_1",
name: "missing_tool",
result: { type: "error", value: "Unknown tool: missing_tool" },
})
}),
)
+182 -118
View File
@@ -1,11 +1,13 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { AnthropicMessages } from "@opencode-ai/llm"
import { client } from "@opencode-ai/llm/adapter"
import { OpenAIResponses } from "@opencode-ai/llm/provider/openai-responses"
import { Effect, Schema } from "effect"
import { Cause, Effect, Exit, Layer, Schema } from "effect"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { LLMNative } from "../../src/session/llm-native"
import { MessageID, PartID, SessionID } from "../../src/session/schema"
import { ProviderTest } from "../fake/provider"
import { testEffect } from "../lib/effect"
import type { MessageV2 } from "../../src/session/message-v2"
import type { Provider } from "../../src/provider"
import type { Tool } from "../../src/tool"
@@ -111,26 +113,26 @@ const lookupTool = {
execute: () => Effect.succeed({ title: "", metadata: {}, output: "" }),
} satisfies Tool.Def<typeof lookupParameters>
const it = testEffect(Layer.empty)
describe("LLMNative.request", () => {
test("builds a text-only native LLM request", async () => {
it.effect("builds a text-only native LLM request", () => Effect.gen(function* () {
const mdl = model()
const provider = ProviderTest.info({ id: ProviderID.openai, key: "openai-key" }, mdl)
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
const request = await Effect.runPromise(
LLMNative.request({
id: "request-1",
provider,
model: mdl,
system: ["You are concise.", ""],
generation: { maxTokens: 123, temperature: 0.2, topP: 0.9 },
messages: [
userMessage(mdl, userID, [textPart(userID, "ignored", { ignored: true }), textPart(userID, "Hello")]),
assistantMessage(mdl, assistantID, userID, [textPart(assistantID, "Hi")]),
],
}),
)
const request = yield* LLMNative.request({
id: "request-1",
provider,
model: mdl,
system: ["You are concise.", ""],
generation: { maxTokens: 123, temperature: 0.2, topP: 0.9 },
messages: [
userMessage(mdl, userID, [textPart(userID, "ignored", { ignored: true }), textPart(userID, "Hello")]),
assistantMessage(mdl, assistantID, userID, [textPart(assistantID, "Hi")]),
],
})
expect(request).toMatchObject({
id: "request-1",
@@ -148,18 +150,16 @@ describe("LLMNative.request", () => {
{ id: userID, role: "user", content: [{ type: "text", text: "Hello" }] },
{ id: assistantID, role: "assistant", content: [{ type: "text", text: "Hi" }] },
])
})
}))
test("converts native tool definitions", async () => {
it.effect("converts native tool definitions", () => Effect.gen(function* () {
const mdl = model()
const request = await Effect.runPromise(
LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [],
tools: [lookupTool],
}),
)
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [],
tools: [lookupTool],
})
expect(request.tools).toHaveLength(1)
expect(request.tools[0]).toMatchObject({
@@ -179,38 +179,36 @@ describe("LLMNative.request", () => {
opencodeToolID: "lookup",
},
})
})
}))
test("converts assistant reasoning and tool history", async () => {
it.effect("converts assistant reasoning and tool history", () => Effect.gen(function* () {
const mdl = model()
const provider = ProviderTest.info({ id: ProviderID.openai }, mdl)
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
const request = await Effect.runPromise(
LLMNative.request({
provider,
model: mdl,
messages: [
userMessage(mdl, userID, [textPart(userID, "Check weather")]),
assistantMessage(mdl, assistantID, userID, [
reasoningPart(assistantID, "Need a lookup."),
toolPart(assistantID, {
callID: "call_1",
tool: "lookup",
state: {
status: "completed",
input: { query: "weather" },
output: "sunny",
title: "Weather",
metadata: {},
time: { start: 1, end: 2 },
},
}),
]),
],
}),
)
const request = yield* LLMNative.request({
provider,
model: mdl,
messages: [
userMessage(mdl, userID, [textPart(userID, "Check weather")]),
assistantMessage(mdl, assistantID, userID, [
reasoningPart(assistantID, "Need a lookup."),
toolPart(assistantID, {
callID: "call_1",
tool: "lookup",
state: {
status: "completed",
input: { query: "weather" },
output: "sunny",
title: "Weather",
metadata: {},
time: { start: 1, end: 2 },
},
}),
]),
],
})
expect(request.messages.map((message) => ({ role: message.role, content: message.content }))).toEqual([
{ role: "user", content: [{ type: "text", text: "Check weather" }] },
@@ -234,36 +232,34 @@ describe("LLMNative.request", () => {
],
},
])
})
}))
test("keeps provider-executed tool results on assistant messages", async () => {
it.effect("keeps provider-executed tool results on assistant messages", () => Effect.gen(function* () {
const mdl = model()
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
const request = await Effect.runPromise(
LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [
userMessage(mdl, userID, [textPart(userID, "Search docs")]),
assistantMessage(mdl, assistantID, userID, [
toolPart(assistantID, {
callID: "ws_1",
tool: "web_search",
metadata: { providerExecuted: true, provider: "openai" },
state: {
status: "completed",
input: { query: "effect" },
output: "found",
title: "Search",
metadata: {},
time: { start: 1, end: 2 },
},
}),
]),
],
}),
)
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [
userMessage(mdl, userID, [textPart(userID, "Search docs")]),
assistantMessage(mdl, assistantID, userID, [
toolPart(assistantID, {
callID: "ws_1",
tool: "web_search",
metadata: { providerExecuted: true, provider: "openai" },
state: {
status: "completed",
input: { query: "effect" },
output: "found",
title: "Search",
metadata: {},
time: { start: 1, end: 2 },
},
}),
]),
],
})
expect(request.messages.map((message) => ({ role: message.role, content: message.content }))).toEqual([
{ role: "user", content: [{ type: "text", text: "Search docs" }] },
@@ -289,53 +285,55 @@ describe("LLMNative.request", () => {
],
},
])
})
}))
test("fails instead of dropping unsupported native parts", async () => {
it.effect("fails instead of dropping unsupported native parts", () => Effect.gen(function* () {
const mdl = model()
const userID = MessageID.ascending()
const exit = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [userMessage(mdl, userID, [filePart(userID)])],
}).pipe(Effect.exit)
await expect(
Effect.runPromise(
LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [userMessage(mdl, userID, [filePart(userID)])],
}),
),
).rejects.toThrow(`Native LLM request conversion does not support file parts in message ${userID}`)
})
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) {
const err = Cause.squash(exit.cause)
expect(err).toBeInstanceOf(Error)
if (err instanceof Error) {
expect(err.message).toBe(`Native LLM request conversion does not support file parts in message ${userID}`)
}
}
}))
test("prepares OpenAI Responses text and tool request body", async () => {
it.effect("prepares OpenAI Responses text and tool request body", () => Effect.gen(function* () {
const mdl = model()
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
const request = await Effect.runPromise(
LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [
userMessage(mdl, userID, [textPart(userID, "What is the weather?")]),
assistantMessage(mdl, assistantID, userID, [
toolPart(assistantID, {
callID: "call_1",
tool: "lookup",
state: {
status: "completed",
input: { query: "weather" },
output: '{"forecast":"sunny"}',
title: "Weather",
metadata: {},
time: { start: 1, end: 2 },
},
}),
]),
],
tools: [lookupTool],
toolChoice: "lookup",
}),
)
const prepared = await Effect.runPromise(client({ adapters: [OpenAIResponses.adapter] }).prepare(request))
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.openai }, mdl),
model: mdl,
messages: [
userMessage(mdl, userID, [textPart(userID, "What is the weather?")]),
assistantMessage(mdl, assistantID, userID, [
toolPart(assistantID, {
callID: "call_1",
tool: "lookup",
state: {
status: "completed",
input: { query: "weather" },
output: '{"forecast":"sunny"}',
title: "Weather",
metadata: {},
time: { start: 1, end: 2 },
},
}),
]),
],
tools: [lookupTool],
toolChoice: "lookup",
})
const prepared = yield* client({ adapters: [OpenAIResponses.adapter] }).prepare(request)
expect(prepared.target).toMatchObject({
model: "gpt-5",
@@ -359,5 +357,71 @@ describe("LLMNative.request", () => {
tool_choice: { type: "function", name: "lookup" },
stream: true,
})
})
}))
it.effect("prepares Anthropic Messages text and tool request body", () => Effect.gen(function* () {
const mdl = model({
id: ModelID.make("claude-sonnet-4-5"),
providerID: ProviderID.make("anthropic"),
api: { id: "claude-sonnet-4-5", url: "https://api.anthropic.com/v1", npm: "@ai-sdk/anthropic" },
})
const userID = MessageID.ascending()
const assistantID = MessageID.ascending()
const request = yield* LLMNative.request({
provider: ProviderTest.info({ id: ProviderID.make("anthropic"), key: "anthropic-key" }, mdl),
model: mdl,
system: ["You are concise."],
generation: { maxTokens: 20, temperature: 0 },
messages: [
userMessage(mdl, userID, [textPart(userID, "What is the weather?")]),
assistantMessage(mdl, assistantID, userID, [
toolPart(assistantID, {
callID: "call_1",
tool: "lookup",
state: {
status: "completed",
input: { query: "weather" },
output: '{"forecast":"sunny"}',
title: "Weather",
metadata: {},
time: { start: 1, end: 2 },
},
}),
]),
],
tools: [lookupTool],
toolChoice: "lookup",
})
const prepared = yield* client({ adapters: [AnthropicMessages.adapter] }).prepare(request)
expect(request.model).toMatchObject({
provider: "anthropic",
protocol: "anthropic-messages",
headers: { "x-api-key": "anthropic-key" },
})
expect(prepared.target).toMatchObject({
model: "claude-sonnet-4-5",
system: [{ type: "text", text: "You are concise." }],
messages: [
{ role: "user", content: [{ type: "text", text: "What is the weather?" }] },
{ role: "assistant", content: [{ type: "tool_use", id: "call_1", name: "lookup", input: { query: "weather" } }] },
{ role: "user", content: [{ type: "tool_result", tool_use_id: "call_1", content: '{"forecast":"sunny"}' }] },
],
tools: [
{
name: "lookup",
description: "Lookup project data",
input_schema: {
type: "object",
properties: { query: { type: "string", description: "Search query" } },
required: ["query"],
},
},
],
tool_choice: { type: "tool", name: "lookup" },
stream: true,
max_tokens: 20,
temperature: 0,
})
}))
})