refactor(llm): share provider schema helpers

This commit is contained in:
Kit Langton
2026-05-05 16:45:20 -04:00
parent 11b892d2db
commit c4e0972ab1
6 changed files with 127 additions and 105 deletions
@@ -15,7 +15,7 @@ import {
type ToolDefinition,
type ToolResultPart,
} from "../schema"
import { ProviderShared } from "./shared"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
const ADAPTER = "anthropic-messages"
@@ -106,7 +106,7 @@ type AnthropicMessage = Schema.Schema.Type<typeof AnthropicMessage>
const AnthropicTool = Schema.Struct({
name: Schema.String,
description: Schema.String,
input_schema: Schema.Record(Schema.String, Schema.Unknown),
input_schema: JsonObject,
cache_control: Schema.optional(AnthropicCacheControl),
})
type AnthropicTool = Schema.Schema.Type<typeof AnthropicTool>
@@ -123,15 +123,15 @@ const AnthropicThinking = Schema.Struct({
const AnthropicTargetFields = {
model: Schema.String,
system: Schema.optional(Schema.Array(AnthropicTextBlock)),
system: optionalArray(AnthropicTextBlock),
messages: Schema.Array(AnthropicMessage),
tools: Schema.optional(Schema.Array(AnthropicTool)),
tools: optionalArray(AnthropicTool),
tool_choice: Schema.optional(AnthropicToolChoice),
stream: Schema.Literal(true),
max_tokens: Schema.Number,
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
stop_sequences: Schema.optional(Schema.Array(Schema.String)),
stop_sequences: optionalArray(Schema.String),
thinking: Schema.optional(AnthropicThinking),
}
const AnthropicMessagesTarget = Schema.Struct(AnthropicTargetFields)
@@ -140,8 +140,8 @@ export type AnthropicMessagesTarget = Schema.Schema.Type<typeof AnthropicMessage
const AnthropicUsage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
output_tokens: Schema.optional(Schema.Number),
cache_creation_input_tokens: Schema.optional(Schema.NullOr(Schema.Number)),
cache_read_input_tokens: Schema.optional(Schema.NullOr(Schema.Number)),
cache_creation_input_tokens: optionalNull(Schema.Number),
cache_read_input_tokens: optionalNull(Schema.Number),
})
type AnthropicUsage = Schema.Schema.Type<typeof AnthropicUsage>
@@ -165,8 +165,8 @@ const AnthropicStreamDelta = Schema.Struct({
thinking: Schema.optional(Schema.String),
partial_json: Schema.optional(Schema.String),
signature: Schema.optional(Schema.String),
stop_reason: Schema.optional(Schema.NullOr(Schema.String)),
stop_sequence: Schema.optional(Schema.NullOr(Schema.String)),
stop_reason: optionalNull(Schema.String),
stop_sequence: optionalNull(Schema.String),
})
const AnthropicChunk = Schema.Struct({
@@ -17,7 +17,7 @@ import {
type ToolResultPart,
} from "../schema"
import { BedrockEventStream } from "./bedrock-event-stream"
import { ProviderShared } from "./shared"
import { JsonObject, optionalArray, ProviderShared } from "./shared"
const ADAPTER = "bedrock-converse"
@@ -163,7 +163,7 @@ const BedrockTool = Schema.Struct({
name: Schema.String,
description: Schema.String,
inputSchema: Schema.Struct({
json: Schema.Record(Schema.String, Schema.Unknown),
json: JsonObject,
}),
}),
})
@@ -178,13 +178,13 @@ const BedrockToolChoice = Schema.Union([
const BedrockTargetFields = {
modelId: Schema.String,
messages: Schema.Array(BedrockMessage),
system: Schema.optional(Schema.Array(BedrockSystemBlock)),
system: optionalArray(BedrockSystemBlock),
inferenceConfig: Schema.optional(
Schema.Struct({
maxTokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
topP: Schema.optional(Schema.Number),
stopSequences: Schema.optional(Schema.Array(Schema.String)),
stopSequences: optionalArray(Schema.String),
}),
),
toolConfig: Schema.optional(
@@ -193,7 +193,7 @@ const BedrockTargetFields = {
toolChoice: Schema.optional(BedrockToolChoice),
}),
),
additionalModelRequestFields: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
additionalModelRequestFields: Schema.optional(JsonObject),
}
const BedrockConverseTarget = Schema.Struct(BedrockTargetFields)
export type BedrockConverseTarget = Schema.Schema.Type<typeof BedrockConverseTarget>
+6 -6
View File
@@ -16,7 +16,7 @@ import {
type ToolCallPart,
type ToolDefinition,
} from "../schema"
import { ProviderShared } from "./shared"
import { JsonObject, optionalArray, ProviderShared } from "./shared"
const ADAPTER = "gemini"
@@ -73,7 +73,7 @@ const GeminiSystemInstruction = Schema.Struct({
const GeminiFunctionDeclaration = Schema.Struct({
name: Schema.String,
description: Schema.String,
parameters: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
parameters: Schema.optional(JsonObject),
})
const GeminiTool = Schema.Struct({
@@ -83,7 +83,7 @@ const GeminiTool = Schema.Struct({
const GeminiToolConfig = Schema.Struct({
functionCallingConfig: Schema.Struct({
mode: Schema.Literals(["AUTO", "NONE", "ANY"]),
allowedFunctionNames: Schema.optional(Schema.Array(Schema.String)),
allowedFunctionNames: optionalArray(Schema.String),
}),
})
@@ -96,14 +96,14 @@ const GeminiGenerationConfig = Schema.Struct({
maxOutputTokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
topP: Schema.optional(Schema.Number),
stopSequences: Schema.optional(Schema.Array(Schema.String)),
stopSequences: optionalArray(Schema.String),
thinkingConfig: Schema.optional(GeminiThinkingConfig),
})
const GeminiTargetFields = {
contents: Schema.Array(GeminiContent),
systemInstruction: Schema.optional(GeminiSystemInstruction),
tools: Schema.optional(Schema.Array(GeminiTool)),
tools: optionalArray(GeminiTool),
toolConfig: Schema.optional(GeminiToolConfig),
generationConfig: Schema.optional(GeminiGenerationConfig),
}
@@ -125,7 +125,7 @@ const GeminiCandidate = Schema.Struct({
})
const GeminiChunk = Schema.Struct({
candidates: Schema.optional(Schema.Array(GeminiCandidate)),
candidates: optionalArray(GeminiCandidate),
usageMetadata: Schema.optional(GeminiUsage),
})
type GeminiChunk = Schema.Schema.Type<typeof GeminiChunk>
+97 -78
View File
@@ -1,4 +1,4 @@
import { Effect, Schema } from "effect"
import { Array as Arr, Effect, Schema } from "effect"
import { Adapter } from "../adapter"
import { Auth } from "../auth"
import { Endpoint } from "../endpoint"
@@ -14,19 +14,25 @@ import {
type ToolCallPart,
type ToolDefinition,
} from "../schema"
import { ProviderShared } from "./shared"
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
const ADAPTER = "openai-chat"
// =============================================================================
// Public Model Input
// =============================================================================
export type OpenAIChatModelInput = Omit<ModelInput, "provider" | "protocol" | "headers"> & {
readonly apiKey?: string
readonly headers?: Record<string, string>
}
// =============================================================================
// Request Target Schema
// =============================================================================
const OpenAIChatFunction = Schema.Struct({
name: Schema.String,
description: Schema.String,
parameters: Schema.Record(Schema.String, Schema.Unknown),
parameters: JsonObject,
})
const OpenAIChatTool = Schema.Struct({
@@ -51,85 +57,83 @@ const OpenAIChatMessage = Schema.Union([
Schema.Struct({
role: Schema.Literal("assistant"),
content: Schema.NullOr(Schema.String),
tool_calls: Schema.optional(Schema.Array(OpenAIChatAssistantToolCall)),
tool_calls: optionalArray(OpenAIChatAssistantToolCall),
reasoning_content: Schema.optional(Schema.String),
}),
Schema.Struct({ role: Schema.Literal("tool"), tool_call_id: Schema.String, content: Schema.String }),
])
type OpenAIChatMessage = Schema.Schema.Type<typeof OpenAIChatMessage>
const OpenAIChatToolChoiceFunction = Schema.Struct({ name: Schema.String })
const OpenAIChatToolChoice = Schema.Union([
Schema.Literals(["auto", "none", "required"]),
Schema.Struct({
type: Schema.Literal("function"),
function: OpenAIChatToolChoiceFunction,
function: Schema.Struct({ name: Schema.String }),
}),
])
const OpenAIChatTargetFields = {
model: Schema.String,
messages: Schema.Array(OpenAIChatMessage),
tools: Schema.optional(Schema.Array(OpenAIChatTool)),
tools: optionalArray(OpenAIChatTool),
tool_choice: Schema.optional(OpenAIChatToolChoice),
stream: Schema.Literal(true),
stream_options: Schema.optional(Schema.Struct({ include_usage: Schema.Boolean })),
max_tokens: Schema.optional(Schema.Number),
temperature: Schema.optional(Schema.Number),
top_p: Schema.optional(Schema.Number),
stop: Schema.optional(Schema.Array(Schema.String)),
stop: optionalArray(Schema.String),
}
const OpenAIChatTarget = Schema.Struct(OpenAIChatTargetFields)
export type OpenAIChatTarget = Schema.Schema.Type<typeof OpenAIChatTarget>
// =============================================================================
// Streaming Chunk Schema
// =============================================================================
const OpenAIChatUsage = Schema.Struct({
prompt_tokens: Schema.optional(Schema.Number),
completion_tokens: Schema.optional(Schema.Number),
total_tokens: Schema.optional(Schema.Number),
prompt_tokens_details: Schema.optional(
Schema.NullOr(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
}),
),
prompt_tokens_details: optionalNull(
Schema.Struct({
cached_tokens: Schema.optional(Schema.Number),
}),
),
completion_tokens_details: Schema.optional(
Schema.NullOr(
Schema.Struct({
reasoning_tokens: Schema.optional(Schema.Number),
}),
),
completion_tokens_details: optionalNull(
Schema.Struct({
reasoning_tokens: Schema.optional(Schema.Number),
}),
),
})
const OpenAIChatToolCallDeltaFunction = Schema.Struct({
name: Schema.optional(Schema.NullOr(Schema.String)),
arguments: Schema.optional(Schema.NullOr(Schema.String)),
name: optionalNull(Schema.String),
arguments: optionalNull(Schema.String),
})
const OpenAIChatToolCallDelta = Schema.Struct({
index: Schema.Number,
id: Schema.optional(Schema.NullOr(Schema.String)),
function: Schema.optional(Schema.NullOr(OpenAIChatToolCallDeltaFunction)),
id: optionalNull(Schema.String),
function: optionalNull(OpenAIChatToolCallDeltaFunction),
})
type OpenAIChatToolCallDelta = Schema.Schema.Type<typeof OpenAIChatToolCallDelta>
const OpenAIChatDelta = Schema.Struct({
content: Schema.optional(Schema.NullOr(Schema.String)),
tool_calls: Schema.optional(Schema.NullOr(Schema.Array(OpenAIChatToolCallDelta))),
content: optionalNull(Schema.String),
tool_calls: optionalNull(Schema.Array(OpenAIChatToolCallDelta)),
})
const OpenAIChatChoice = Schema.Struct({
delta: Schema.optional(Schema.NullOr(OpenAIChatDelta)),
finish_reason: Schema.optional(Schema.NullOr(Schema.String)),
delta: optionalNull(OpenAIChatDelta),
finish_reason: optionalNull(Schema.String),
})
const OpenAIChatChunk = Schema.Struct({
choices: Schema.Array(OpenAIChatChoice),
usage: Schema.optional(Schema.NullOr(OpenAIChatUsage)),
usage: optionalNull(OpenAIChatUsage),
})
type OpenAIChatChunk = Schema.Schema.Type<typeof OpenAIChatChunk>
type OpenAIChatRequestMessage = LLMRequest["messages"][number]
interface ParsedToolCall {
readonly id: string
@@ -146,6 +150,9 @@ interface ParserState {
const invalid = ProviderShared.invalidRequest
// =============================================================================
// Request Lowering
// =============================================================================
const lowerTool = (tool: ToolDefinition): OpenAIChatTool => ({
type: "function",
function: {
@@ -172,58 +179,61 @@ const lowerToolCall = (part: ToolCallPart): OpenAIChatAssistantToolCall => ({
},
})
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
const openAICompatibleReasoningContent = (native: unknown) =>
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message: OpenAIChatRequestMessage) {
const content: TextPart[] = []
for (const part of message.content) {
if (part.type !== "text") return yield* invalid(`OpenAI Chat user messages only support text content for now`)
content.push(part)
}
return { role: "user" as const, content: ProviderShared.joinText(content) }
})
const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(function* (
message: OpenAIChatRequestMessage,
) {
const content: TextPart[] = []
const toolCalls: OpenAIChatAssistantToolCall[] = []
for (const part of message.content) {
if (part.type === "text") {
content.push(part)
continue
}
if (part.type === "tool-call") {
toolCalls.push(lowerToolCall(part))
continue
}
return yield* invalid(`OpenAI Chat assistant messages only support text and tool-call content for now`)
}
return {
role: "assistant" as const,
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_content: openAICompatibleReasoningContent(message.native?.openaiCompatible),
}
})
const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (message: OpenAIChatRequestMessage) {
const messages: OpenAIChatMessage[] = []
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: ProviderShared.toolResultText(part) })
}
return messages
})
const lowerMessage = Effect.fn("OpenAIChat.lowerMessage")(function* (message: OpenAIChatRequestMessage) {
if (message.role === "user") return [yield* lowerUserMessage(message)]
if (message.role === "assistant") return [yield* lowerAssistantMessage(message)]
return yield* lowerToolMessages(message)
})
const lowerMessages = Effect.fn("OpenAIChat.lowerMessages")(function* (request: LLMRequest) {
const system: OpenAIChatMessage[] =
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
const messages: OpenAIChatMessage[] = [...system]
for (const message of request.messages) {
if (message.role === "user") {
const content: TextPart[] = []
for (const part of message.content) {
if (part.type !== "text") return yield* invalid(`OpenAI Chat user messages only support text content for now`)
content.push(part)
}
messages.push({ role: "user", content: ProviderShared.joinText(content) })
continue
}
if (message.role === "assistant") {
const content: TextPart[] = []
const toolCalls: OpenAIChatAssistantToolCall[] = []
for (const part of message.content) {
if (part.type === "text") {
content.push(part)
continue
}
if (part.type === "tool-call") {
toolCalls.push(lowerToolCall(part))
continue
}
return yield* invalid(`OpenAI Chat assistant messages only support text and tool-call content for now`)
}
messages.push({
role: "assistant",
content: content.length === 0 ? null : ProviderShared.joinText(content),
tool_calls: toolCalls.length === 0 ? undefined : toolCalls,
reasoning_content: isRecord(message.native?.openaiCompatible) && typeof message.native.openaiCompatible.reasoning_content === "string"
? message.native.openaiCompatible.reasoning_content
: undefined,
})
continue
}
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: ProviderShared.toolResultText(part) })
}
}
return messages
return [...system, ...Arr.flatten(yield* Effect.forEach(request.messages, lowerMessage))]
})
const prepare = Effect.fn("OpenAIChat.prepare")(function* (request: LLMRequest) {
@@ -240,6 +250,9 @@ const prepare = Effect.fn("OpenAIChat.prepare")(function* (request: LLMRequest)
}
})
// =============================================================================
// Stream Parsing
// =============================================================================
const mapFinishReason = (reason: string | null | undefined): FinishReason => {
if (reason === "stop") return "stop"
if (reason === "length") return "length"
@@ -322,6 +335,9 @@ const finishEvents = (state: ParserState): ReadonlyArray<LLMEvent> => {
]
}
// =============================================================================
// Protocol And OpenAI Adapter
// =============================================================================
/**
* The OpenAI Chat protocol — request lowering, target schema, and the
* streaming-chunk state machine. Reused by every adapter
@@ -351,6 +367,9 @@ export const adapter = Adapter.make({
framing: Framing.sse,
})
// =============================================================================
// Model Helper And Patches
// =============================================================================
export const model = (input: OpenAIChatModelInput) =>
Adapter.bindModel(
llmModel({
@@ -14,7 +14,7 @@ import {
type ToolCallPart,
type ToolDefinition,
} from "../schema"
import { ProviderShared } from "./shared"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared"
const ADAPTER = "openai-responses"
@@ -55,7 +55,7 @@ const OpenAIResponsesTool = Schema.Struct({
type: Schema.Literal("function"),
name: Schema.String,
description: Schema.String,
parameters: Schema.Record(Schema.String, Schema.Unknown),
parameters: JsonObject,
strict: Schema.optional(Schema.Boolean),
})
type OpenAIResponsesTool = Schema.Schema.Type<typeof OpenAIResponsesTool>
@@ -68,7 +68,7 @@ const OpenAIResponsesToolChoice = Schema.Union([
const OpenAIResponsesTargetFields = {
model: Schema.String,
input: Schema.Array(OpenAIResponsesInputItem),
tools: Schema.optional(Schema.Array(OpenAIResponsesTool)),
tools: optionalArray(OpenAIResponsesTool),
tool_choice: Schema.optional(OpenAIResponsesToolChoice),
stream: Schema.Literal(true),
max_output_tokens: Schema.optional(Schema.Number),
@@ -80,9 +80,9 @@ export type OpenAIResponsesTarget = Schema.Schema.Type<typeof OpenAIResponsesTar
const OpenAIResponsesUsage = Schema.Struct({
input_tokens: Schema.optional(Schema.Number),
input_tokens_details: Schema.optional(Schema.NullOr(Schema.Struct({ cached_tokens: Schema.optional(Schema.Number) }))),
input_tokens_details: optionalNull(Schema.Struct({ cached_tokens: Schema.optional(Schema.Number) })),
output_tokens: Schema.optional(Schema.Number),
output_tokens_details: Schema.optional(Schema.NullOr(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) }))),
output_tokens_details: optionalNull(Schema.Struct({ reasoning_tokens: Schema.optional(Schema.Number) })),
total_tokens: Schema.optional(Schema.Number),
})
type OpenAIResponsesUsage = Schema.Schema.Type<typeof OpenAIResponsesUsage>
@@ -117,8 +117,8 @@ const OpenAIResponsesChunk = Schema.Struct({
item: Schema.optional(OpenAIResponsesStreamItem),
response: Schema.optional(
Schema.Struct({
incomplete_details: Schema.optional(Schema.NullOr(Schema.Struct({ reason: Schema.String }))),
usage: Schema.optional(Schema.NullOr(OpenAIResponsesUsage)),
incomplete_details: optionalNull(Schema.Struct({ reason: Schema.String })),
usage: optionalNull(OpenAIResponsesUsage),
}),
),
code: Schema.optional(Schema.String),
+3
View File
@@ -7,6 +7,9 @@ import { InvalidRequestError, ProviderChunkError, type MediaPart, type ToolResul
export const Json = Schema.fromJsonString(Schema.Unknown)
export const decodeJson = Schema.decodeUnknownSync(Json)
export const encodeJson = Schema.encodeSync(Json)
export const JsonObject = Schema.Record(Schema.String, Schema.Unknown)
export const optionalArray = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.Array(schema))
export const optionalNull = <const S extends Schema.Top>(schema: S) => Schema.optional(Schema.NullOr(schema))
/**
* Plain-record narrowing. Excludes arrays so adapters checking nested JSON