refactor(llm): simplify provider protocol wiring

This commit is contained in:
Kit Langton
2026-05-05 16:53:27 -04:00
parent 8523299069
commit e8d703108d
9 changed files with 230 additions and 49 deletions
+11 -3
View File
@@ -55,9 +55,17 @@ export interface Protocol<Payload, Frame, Chunk, State> {
}
/**
* Construct a `Protocol` from its parts. Currently a typed identity, but kept
* as the public constructor so future cross-cutting concerns (tracing spans,
* instrumentation) can be added in one place.
* Construct a `Protocol` from the four protocol-local pieces:
*
* - `payload` infers the provider-native request body shape.
* - `chunk` infers the framed response item and decoded chunk shape.
* - `initial`, `process`, and `onHalt` infer the parser state shape.
* - `prepare` ties the common `LLMRequest` to the provider payload.
*
* Provider implementations should usually call `Protocol.define({ ... })`
* without explicit type arguments; the schemas and parser functions are the
* source of truth. The constructor remains as the public seam for future
* cross-cutting concerns such as tracing or instrumentation.
*/
export const define = <Payload, Frame, Chunk, State>(
input: Protocol<Payload, Frame, Chunk, State>,
@@ -19,8 +19,14 @@ import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./share
const ADAPTER = "anthropic-messages"
// =============================================================================
// Public Model Input
// =============================================================================
export type AnthropicMessagesModelInput = AdapterModelInput
// =============================================================================
// Request Payload Schema
// =============================================================================
const AnthropicCacheControl = Schema.Struct({ type: Schema.Literal("ephemeral") })
const AnthropicTextBlock = Schema.Struct({
@@ -188,6 +194,9 @@ interface ParserState {
const invalid = ProviderShared.invalidRequest
// =============================================================================
// Request Lowering
// =============================================================================
const cacheControl = (cache: CacheHint | undefined) => cache?.type === "ephemeral" ? { type: "ephemeral" as const } : undefined
const lowerTool = (tool: ToolDefinition): AnthropicTool => ({
@@ -320,6 +329,9 @@ const prepare = Effect.fn("AnthropicMessages.prepare")(function* (request: LLMRe
}
})
// =============================================================================
// Stream Parsing
// =============================================================================
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
if (reason === "end_turn" || reason === "stop_sequence" || reason === "pause_turn") return "stop"
if (reason === "max_tokens") return "length"
@@ -474,18 +486,16 @@ const processChunk = (state: ParserState, chunk: AnthropicChunk) =>
return [state, []] as const
})
// =============================================================================
// Protocol And Anthropic Adapter
// =============================================================================
/**
* The Anthropic Messages protocol — request lowering, payload schema, and the
* streaming-chunk state machine. Used by native
* Anthropic Cloud and (once registered) Vertex Anthropic / Bedrock-hosted
* Anthropic passthrough.
*/
export const protocol = Protocol.define<
AnthropicMessagesPayload,
string,
AnthropicChunk,
ParserState
>({
export const protocol = Protocol.define({
id: ADAPTER,
payload: AnthropicMessagesPayload,
prepare,
@@ -503,6 +513,9 @@ export const adapter = Adapter.make({
headers: () => ({ "anthropic-version": "2023-06-01" }),
})
// =============================================================================
// Model Helper
// =============================================================================
export const model = Adapter.model(adapter, {
provider: "anthropic",
capabilities: capabilities({
+22 -6
View File
@@ -21,6 +21,9 @@ import { JsonObject, optionalArray, ProviderShared } from "./shared"
const ADAPTER = "bedrock-converse"
// =============================================================================
// Public Model Input
// =============================================================================
/**
* AWS credentials for SigV4 signing. Bedrock also supports Bearer API key auth
* via `model.apiKey`, which bypasses SigV4 signing. STS-vended credentials
@@ -48,6 +51,9 @@ export type BedrockConverseModelInput = AdapterModelInput & {
readonly headers?: Record<string, string>
}
// =============================================================================
// Request Payload Schema
// =============================================================================
const BedrockTextBlock = Schema.Struct({
text: Schema.String,
})
@@ -265,6 +271,9 @@ type BedrockChunk = Schema.Schema.Type<typeof BedrockChunk>
const invalid = ProviderShared.invalidRequest
// =============================================================================
// Request Lowering
// =============================================================================
const region = (request: LLMRequest) => {
const fromNative = request.model.native?.aws_region
if (typeof fromNative === "string" && fromNative !== "") return fromNative
@@ -477,6 +486,9 @@ const prepare = Effect.fn("BedrockConverse.prepare")(function* (request: LLMRequ
}
})
// =============================================================================
// Auth
// =============================================================================
// Credentials live on `model.native.aws_credentials` so the OpenCode bridge
// can resolve them via `@aws-sdk/credential-providers` and stuff them in
// without exposing the auth machinery to the rest of the LLM core. Schema
@@ -544,6 +556,9 @@ const auth: Auth = (input) => {
})
}
// =============================================================================
// Stream Parsing
// =============================================================================
const mapFinishReason = (reason: string): FinishReason => {
if (reason === "end_turn" || reason === "stop_sequence") return "stop"
if (reason === "max_tokens") return "length"
@@ -682,16 +697,14 @@ const onHalt = (state: ParserState): ReadonlyArray<LLMEvent> =>
? [{ type: "request-finish", reason: mapFinishReason(state.pendingStopReason) }]
: []
// =============================================================================
// Protocol And Bedrock Adapter
// =============================================================================
/**
* The Bedrock Converse protocol — request lowering, payload schema, and the
* streaming-chunk state machine.
*/
export const protocol = Protocol.define<
BedrockConversePayload,
object,
BedrockChunk,
ParserState
>({
export const protocol = Protocol.define({
id: ADAPTER,
payload: BedrockConversePayload,
prepare,
@@ -715,6 +728,9 @@ export const adapter = Adapter.make({
framing,
})
// =============================================================================
// Model Helper
// =============================================================================
export const defaultCapabilities = capabilities({
output: { reasoning: true },
tools: { calls: true, streamingInput: true },
+22 -1
View File
@@ -20,8 +20,14 @@ import { JsonObject, optionalArray, ProviderShared } from "./shared"
const ADAPTER = "gemini"
// =============================================================================
// Public Model Input
// =============================================================================
export type GeminiModelInput = AdapterModelInput
// =============================================================================
// Request Payload Schema
// =============================================================================
const GeminiTextPart = Schema.Struct({
text: Schema.String,
thought: Schema.optional(Schema.Boolean),
@@ -140,6 +146,9 @@ const mediaData = ProviderShared.mediaBytes
const isRecord = ProviderShared.isRecord
// =============================================================================
// Tool Schema Conversion
// =============================================================================
// Tool-schema conversion has two distinct concerns:
//
// 1. Sanitize — fix common authoring mistakes Gemini rejects: integer/number
@@ -253,6 +262,9 @@ const projectToolSchemaNode = (schema: unknown): Record<string, unknown> | undef
const convertToolSchema = (schema: unknown) => projectToolSchemaNode(sanitizeToolSchemaNode(schema))
// =============================================================================
// Request Lowering
// =============================================================================
const lowerTool = (tool: ToolDefinition) => ({
name: tool.name,
description: tool.description,
@@ -367,6 +379,9 @@ const prepare = Effect.fn("Gemini.prepare")(function* (request: LLMRequest) {
}
})
// =============================================================================
// Stream Parsing
// =============================================================================
const mapUsage = (usage: GeminiUsage | undefined) => {
if (!usage) return undefined
return new Usage({
@@ -436,12 +451,15 @@ const processChunk = (state: ParserState, chunk: GeminiChunk) => {
}, events] as const)
}
// =============================================================================
// Protocol And Gemini Adapter
// =============================================================================
/**
* The Gemini protocol — request lowering, payload schema, and the streaming-
* chunk state machine. Used by Google AI Studio Gemini and
* (once registered) Vertex Gemini.
*/
export const protocol = Protocol.define<GeminiPayload, string, GeminiChunk, ParserState>({
export const protocol = Protocol.define({
id: ADAPTER,
payload: GeminiPayload,
prepare,
@@ -463,6 +481,9 @@ export const adapter = Adapter.make({
framing: Framing.sse,
})
// =============================================================================
// Model Helper
// =============================================================================
export const model = Adapter.model(adapter, {
provider: "google",
capabilities: capabilities({
+26 -16
View File
@@ -79,6 +79,9 @@ const OpenAIChatPayloadFields = {
tool_choice: Schema.optional(OpenAIChatToolChoice),
stream: Schema.Literal(true),
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
usage: Schema.optional(JsonObject),
reasoning: Schema.optional(JsonObject),
prompt_cache_key: Schema.optional(Schema.String),
max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
@@ -293,37 +296,44 @@ const pushToolDelta = (tools: Record<number, ProviderShared.ToolAccumulator>, de
}
})
const applyToolDeltas = Effect.fn("OpenAIChat.applyToolDeltas")(function* (
stateTools: Record<number, ProviderShared.ToolAccumulator>,
toolDeltas: ReadonlyArray<OpenAIChatToolCallDelta>,
) {
const tools = toolDeltas.length === 0 ? stateTools : { ...stateTools }
const events: LLMEvent[] = []
for (const tool of toolDeltas) {
const current = yield* pushToolDelta(tools, tool)
tools[tool.index] = current
if (tool.function?.arguments) {
events.push({ type: "tool-input-delta", id: current.id, name: current.name, text: tool.function.arguments })
}
}
return { tools, events }
})
const finalizeToolCalls = (tools: Record<number, ProviderShared.ToolAccumulator>) =>
Effect.forEach(Object.values(tools), (tool) => ProviderShared.parsedToolCall(ADAPTER, tool))
const processChunk = (state: ParserState, chunk: OpenAIChatChunk) =>
Effect.gen(function* () {
const events: LLMEvent[] = []
const usage = mapUsage(chunk.usage) ?? state.usage
const choice = chunk.choices[0]
const finishReason = choice?.finish_reason ? mapFinishReason(choice.finish_reason) : state.finishReason
const delta = choice?.delta
const toolDeltas = delta?.tool_calls ?? []
const tools = toolDeltas.length === 0 ? state.tools : { ...state.tools }
if (delta?.content) events.push({ type: "text-delta", text: delta.content })
for (const tool of toolDeltas) {
const current = yield* pushToolDelta(tools, tool)
tools[tool.index] = current
if (tool.function?.arguments) {
events.push({ type: "tool-input-delta", id: current.id, name: current.name, text: tool.function.arguments })
}
}
const toolDeltas = yield* applyToolDeltas(state.tools, delta?.tool_calls ?? [])
// Finalize accumulated tool inputs eagerly when finish_reason arrives so
// JSON parse failures fail the stream at the boundary rather than at halt.
const toolCalls =
finishReason !== undefined && state.finishReason === undefined && Object.keys(tools).length > 0
? yield* finalizeToolCalls(tools)
finishReason !== undefined && state.finishReason === undefined && Object.keys(toolDeltas.tools).length > 0
? yield* finalizeToolCalls(toolDeltas.tools)
: state.toolCalls
return [{ tools, toolCalls, usage, finishReason }, events] as const
return [
{ tools: toolDeltas.tools, toolCalls, usage, finishReason },
[...(delta?.content ? ([{ type: "text-delta", text: delta.content }] satisfies LLMEvent[]) : []), ...toolDeltas.events],
] as const
})
const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
+19 -6
View File
@@ -18,8 +18,14 @@ import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./share
const ADAPTER = "openai-responses"
// =============================================================================
// Public Model Input
// =============================================================================
export type OpenAIResponsesModelInput = AdapterModelInput
// =============================================================================
// Request Payload Schema
// =============================================================================
const OpenAIResponsesInputText = Schema.Struct({
type: Schema.Literal("input_text"),
text: Schema.String,
@@ -130,6 +136,9 @@ interface ParserState {
const invalid = ProviderShared.invalidRequest
// =============================================================================
// Request Lowering
// =============================================================================
const lowerTool = (tool: ToolDefinition): OpenAIResponsesTool => ({
type: "function",
name: tool.name,
@@ -209,6 +218,9 @@ const prepare = Effect.fn("OpenAIResponses.prepare")(function* (request: LLMRequ
}
})
// =============================================================================
// Stream Parsing
// =============================================================================
const mapUsage = (usage: OpenAIResponsesUsage | null | undefined) => {
if (!usage) return undefined
return new Usage({
@@ -350,17 +362,15 @@ const processChunk = (state: ParserState, chunk: OpenAIResponsesChunk) =>
return [state, []] as const
})
// =============================================================================
// Protocol And OpenAI Adapter
// =============================================================================
/**
* The OpenAI Responses protocol — request lowering, payload schema, and the
* streaming-chunk state machine. Used by native OpenAI and
* (once registered) Azure OpenAI Responses.
*/
export const protocol = Protocol.define<
OpenAIResponsesPayload,
string,
OpenAIResponsesChunk,
ParserState
>({
export const protocol = Protocol.define({
id: ADAPTER,
payload: OpenAIResponsesPayload,
prepare,
@@ -377,6 +387,9 @@ export const adapter = Adapter.make({
framing: Framing.sse,
})
// =============================================================================
// Model Helper
// =============================================================================
export const model = Adapter.model(adapter, {
provider: "openai",
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
+49 -4
View File
@@ -1,14 +1,59 @@
import { OpenAICompatibleChat, type ProviderFamilyModelInput } from "./openai-compatible-chat"
import { Adapter, type AdapterModelInput } from "../adapter"
import { capabilities } from "../llm"
import { payload as payloadPatch } from "../patch"
import { OpenAICompatibleChat } from "./openai-compatible-chat"
import { OpenAICompatibleProfiles } from "./openai-compatible-profile"
import type { OpenAIChatPayload } from "./openai-chat"
import { isRecord } from "./shared"
export const profile = OpenAICompatibleProfiles.profiles.openrouter
export type ModelOptions = Omit<ProviderFamilyModelInput, "id">
export interface OpenRouterOptions {
readonly usage?: boolean | Record<string, unknown>
readonly reasoning?: Record<string, unknown>
readonly promptCacheKey?: string
}
export const adapters = [OpenAICompatibleChat.adapter]
export type ModelOptions = Omit<AdapterModelInput, "id"> & OpenRouterOptions
const nativeOptions = (options: ModelOptions) => {
const openrouter = {
...(isRecord(options.native?.openrouter) ? options.native.openrouter : {}),
...(options.usage === undefined ? {} : { usage: options.usage === true ? { include: true } : options.usage }),
...(options.reasoning === undefined ? {} : { reasoning: options.reasoning }),
...(options.promptCacheKey === undefined ? {} : { promptCacheKey: options.promptCacheKey }),
}
if (Object.keys(openrouter).length === 0) return options.native
return { ...options.native, openrouter }
}
export const applyOptions = payloadPatch<OpenAIChatPayload>("openrouter.options", {
reason: "apply OpenRouter provider options to the Chat payload",
when: (context) => context.model.provider === profile.provider && isRecord(context.model.native?.openrouter),
apply: (payload, context) => {
const openrouter = isRecord(context.model.native?.openrouter) ? context.model.native.openrouter : undefined
if (!openrouter) return payload
return {
...payload,
...(openrouter.usage === true ? { usage: { include: true } } : isRecord(openrouter.usage) ? { usage: openrouter.usage } : {}),
...(isRecord(openrouter.reasoning) ? { reasoning: openrouter.reasoning } : {}),
...(typeof openrouter.promptCacheKey === "string" ? { prompt_cache_key: openrouter.promptCacheKey } : {}),
}
},
})
export const adapter = OpenAICompatibleChat.adapter.withPatches([applyOptions])
export const adapters = [adapter]
const modelRef = Adapter.model<AdapterModelInput>(adapter, {
provider: profile.provider,
baseURL: profile.baseURL,
capabilities: capabilities({ tools: { calls: true, streamingInput: true } }),
})
export const model = (id: string, options: ModelOptions = {}) =>
OpenAICompatibleChat.profileModel(profile, { ...options, id })
modelRef({ ...options, id, native: nativeOptions(options) })
export const chat = model
@@ -3,6 +3,7 @@ import { Effect } from "effect"
import { LLM } from "../../src"
import { LLMClient } from "../../src/adapter"
import { OpenAICompatibleChat } from "../../src/provider/openai-compatible-chat"
import { OpenRouter } from "../../src/provider/openrouter"
import { expectFinish, expectWeatherToolCall, expectWeatherToolLoop, runWeatherToolLoop, textRequest, weatherToolLoopRequest, weatherToolRequest } from "../recorded-scenarios"
import { recordedTests } from "../recorded-test"
@@ -29,21 +30,18 @@ const groqModel = OpenAICompatibleChat.groq({
const groqRequest = textRequest({ id: "recorded_groq_text", model: groqModel })
const groqToolRequest = weatherToolRequest({ id: "recorded_groq_tool_call", model: groqModel })
const openrouterModel = OpenAICompatibleChat.openrouter({
id: "openai/gpt-4o-mini",
const openrouterModel = OpenRouter.model("openai/gpt-4o-mini", {
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
})
const openrouterRequest = textRequest({ id: "recorded_openrouter_text", model: openrouterModel })
const openrouterToolRequest = weatherToolRequest({ id: "recorded_openrouter_tool_call", model: openrouterModel })
const openrouterGpt55Model = OpenAICompatibleChat.openrouter({
id: "openai/gpt-5.5",
const openrouterGpt55Model = OpenRouter.model("openai/gpt-5.5", {
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
})
const openrouterOpus47Model = OpenAICompatibleChat.openrouter({
id: "anthropic/claude-opus-4.7",
const openrouterOpus47Model = OpenRouter.model("anthropic/claude-opus-4.7", {
apiKey: process.env.OPENROUTER_API_KEY ?? "fixture",
})
@@ -61,7 +59,7 @@ const xaiRequest = textRequest({ id: "recorded_xai_text", model: xaiModel })
const xaiToolRequest = weatherToolRequest({ id: "recorded_xai_tool_call", model: xaiModel })
const recorded = recordedTests({ prefix: "openai-compatible-chat", protocol: "openai-compatible-chat" })
const llm = LLMClient.make({ adapters: [OpenAICompatibleChat.adapter] })
const llm = LLMClient.make({ adapters: [OpenAICompatibleChat.adapter, ...OpenRouter.adapters] })
const openrouterToolLoops = [
{
@@ -0,0 +1,57 @@
import { describe, expect } from "bun:test"
import { Effect, Layer } from "effect"
import { LLM } from "../../src"
import { LLMClient } from "../../src/adapter"
import { OpenRouter } from "../../src/provider/openrouter"
import { testEffect } from "../lib/effect"
const it = testEffect(Layer.empty)
describe("OpenRouter", () => {
it.effect("prepares OpenRouter models through the OpenAI-compatible Chat route", () =>
Effect.gen(function* () {
const model = OpenRouter.model("openai/gpt-4o-mini", { apiKey: "test-key" })
expect(model).toMatchObject({
id: "openai/gpt-4o-mini",
provider: "openrouter",
protocol: "openai-compatible-chat",
baseURL: "https://openrouter.ai/api/v1",
apiKey: "test-key",
})
const prepared = yield* LLMClient.make({ adapters: OpenRouter.adapters }).prepare(
LLM.request({ model, prompt: "Say hello." }),
)
expect(prepared.adapter).toBe("openai-compatible-chat")
expect(prepared.payload).toMatchObject({
model: "openai/gpt-4o-mini",
messages: [{ role: "user", content: "Say hello." }],
stream: true,
})
}),
)
it.effect("applies OpenRouter payload options from the model helper", () =>
Effect.gen(function* () {
const prepared = yield* LLMClient.make({ adapters: OpenRouter.adapters }).prepare(
LLM.request({
model: OpenRouter.model("anthropic/claude-3.7-sonnet:thinking", {
usage: true,
reasoning: { effort: "high" },
promptCacheKey: "session_123",
}),
prompt: "Think briefly.",
}),
)
expect(prepared.payload).toMatchObject({
usage: { include: true },
reasoning: { effort: "high" },
prompt_cache_key: "session_123",
})
expect(prepared.patchTrace.map((item) => item.id)).toContain("payload.openrouter.options")
}),
)
})