diff --git a/packages/llm/src/provider/anthropic-messages.ts b/packages/llm/src/provider/anthropic-messages.ts index b396e37dee..24ca55a4da 100644 --- a/packages/llm/src/provider/anthropic-messages.ts +++ b/packages/llm/src/provider/anthropic-messages.ts @@ -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 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 @@ -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 @@ -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({ diff --git a/packages/llm/src/provider/bedrock-converse.ts b/packages/llm/src/provider/bedrock-converse.ts index 1bb8e4e0ee..893554c348 100644 --- a/packages/llm/src/provider/bedrock-converse.ts +++ b/packages/llm/src/provider/bedrock-converse.ts @@ -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 diff --git a/packages/llm/src/provider/gemini.ts b/packages/llm/src/provider/gemini.ts index 2bf9f50b46..c6f2ae7abd 100644 --- a/packages/llm/src/provider/gemini.ts +++ b/packages/llm/src/provider/gemini.ts @@ -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 diff --git a/packages/llm/src/provider/openai-chat.ts b/packages/llm/src/provider/openai-chat.ts index ae06ad947d..9d1d461061 100644 --- a/packages/llm/src/provider/openai-chat.ts +++ b/packages/llm/src/provider/openai-chat.ts @@ -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 & { readonly apiKey?: string readonly headers?: Record } +// ============================================================================= +// 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 -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 +// ============================================================================= +// 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 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 +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 => - 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 => { ] } +// ============================================================================= +// 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({ diff --git a/packages/llm/src/provider/openai-responses.ts b/packages/llm/src/provider/openai-responses.ts index e0c2cab87a..1dcdc742b4 100644 --- a/packages/llm/src/provider/openai-responses.ts +++ b/packages/llm/src/provider/openai-responses.ts @@ -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 @@ -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 @@ -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), diff --git a/packages/llm/src/provider/shared.ts b/packages/llm/src/provider/shared.ts index 93d8c6cc37..22b75b750f 100644 --- a/packages/llm/src/provider/shared.ts +++ b/packages/llm/src/provider/shared.ts @@ -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 = (schema: S) => Schema.optional(Schema.Array(schema)) +export const optionalNull = (schema: S) => Schema.optional(Schema.NullOr(schema)) /** * Plain-record narrowing. Excludes arrays so adapters checking nested JSON