Compare commits

...
Author SHA1 Message Date
Shoubhit Dash a4cec2c78a refactor(ai): type each protocol's provider metadata round-trip
Every protocol wrote its own provider metadata onto stream events through
an untyped `providerMetadata(key, Record<string, unknown>)` one-liner —
copied verbatim into anthropic-messages, bedrock-converse and gemini — and
read it back in request lowering with `isRecord(x) && typeof x.signature
=== "string"`. Writer and reader of the same namespace never met at
compile time, so a typo on either side only showed up as a signature that
failed to round-trip.

`ProviderShared.providerMetadata(schema)` now pairs a typed `write` with a
synchronous `read` (`decodeUnknownOption`). Each of anthropic-messages,
bedrock-converse, gemini, open-responses and openai-chat declares one
`Schema.Struct` of the fields it publishes under its `providerMetadataKey`
and uses the pair; the hand-written readers are deleted, and
responses-compaction's raw metadata literal goes through the typed writer.

Fields use `lenient` because the old readers were independent per field:
a malformed `signature` never dropped `redactedData`. A new test pins
that. Every reader was probed against v2 for undefined/null/array/string/
empty-object metadata and unknown keys; behaviour is unchanged. Bedrock
keeps its own two-field struct rather than importing Anthropic's, since no
protocol depends on another protocol's internals. Raw usage passthroughs
stay raw: they forward each provider's usage object and are never read
back.
2026-09-18 19:49:06 +05:30
9 changed files with 195 additions and 121 deletions
+24 -23
View File
@@ -26,7 +26,7 @@ import {
type ToolDefinition,
type ToolResultPart,
} from "../schema/index.js"
import { JsonObject, knownString, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { JsonObject, knownString, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate, resolveEffortUpdates } from "../effort-updates.js"
import * as Cache from "./utils/cache.js"
@@ -510,19 +510,15 @@ const cacheControl = (breakpoints: Cache.Breakpoints, cache: CacheHint | undefin
return Cache.ttlBucket(cache.ttlSeconds) === "1h" ? EPHEMERAL_1H : EPHEMERAL_5M
}
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
const signatureFromMetadata = (metadata: ProviderMetadata | undefined, key: string): string | undefined => {
const provider = metadata?.[key]
if (!ProviderShared.isRecord(provider)) return undefined
return typeof provider.signature === "string" ? provider.signature : undefined
}
const redactedDataFromMetadata = (metadata: ProviderMetadata | undefined, key: string): string | undefined => {
const provider = metadata?.[key]
if (!ProviderShared.isRecord(provider)) return undefined
return typeof provider.redactedData === "string" ? provider.redactedData : undefined
}
const AnthropicProviderMetadata = ProviderShared.providerMetadata(
Schema.Struct({
signature: lenient(Schema.String),
redactedData: lenient(Schema.String),
blockType: lenient(Schema.String),
result: lenient(Schema.Unknown),
stopSequence: lenient(Schema.String),
}),
)
const lowerTool = (breakpoints: Cache.Breakpoints, tool: ToolDefinition, inputSchema: JsonSchema): AnthropicTool => ({
name: tool.name,
@@ -590,7 +586,8 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
return yield* invalid(`Anthropic Messages does not know how to round-trip server tool result for ${part.name}`)
// Prefer the provider-owned replay payload; fall back to the result value for
// histories constructed directly from provider events.
const payload = part.providerMetadata?.[providerMetadataKey]?.["result"] ?? part.result.value
const payload =
AnthropicProviderMetadata.read(part.providerMetadata, providerMetadataKey)?.result ?? part.result.value
return {
type: wireType,
tool_use_id: scrubToolCallID(part.id),
@@ -937,8 +934,9 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
if (part.type === "reasoning") {
// A signature marks visible thinking; only signature-less parts carrying
// redactedData round-trip as opaque redacted_thinking blocks.
const signature = part.encrypted ?? signatureFromMetadata(part.providerMetadata, providerMetadataKey)
const redactedData = redactedDataFromMetadata(part.providerMetadata, providerMetadataKey)
const metadata = AnthropicProviderMetadata.read(part.providerMetadata, providerMetadataKey)
const signature = part.encrypted ?? metadata?.signature
const redactedData = metadata?.redactedData
if (signature === undefined && redactedData !== undefined) {
content.push({ type: "redacted_thinking", data: redactedData })
continue
@@ -1220,7 +1218,10 @@ const serverToolResultEvent = (block: AnthropicStreamBlock, providerMetadataKey:
providerExecuted: true,
// The complete payload is irreducible provider replay state: subsequent
// stateless requests must round-trip the typed result block verbatim.
providerMetadata: providerMetadata(providerMetadataKey, { blockType: block.type, result: block.content }),
providerMetadata: AnthropicProviderMetadata.write(providerMetadataKey, {
blockType: block.type,
result: block.content,
}),
})
}
@@ -1284,7 +1285,7 @@ const onContentBlockStart = (
const metadata =
block.signature === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { signature: block.signature })
: AnthropicProviderMetadata.write(state.providerMetadataKey, { signature: block.signature })
const lifecycle = Lifecycle.reasoningStart(state.lifecycle, events, id, metadata)
return [
{
@@ -1313,7 +1314,7 @@ const onContentBlockStart = (
state.lifecycle,
events,
`reasoning-${event.index ?? 0}`,
providerMetadata(state.providerMetadataKey, { redactedData: block.data }),
AnthropicProviderMetadata.write(state.providerMetadataKey, { redactedData: block.data }),
),
},
events,
@@ -1421,7 +1422,7 @@ const onContentBlockStop = Effect.fn("AnthropicMessages.onContentBlockStop")(fun
Lifecycle.textEnd(state.lifecycle, events, `text-${event.index}`),
events,
`reasoning-${event.index}`,
signature === undefined ? undefined : providerMetadata(state.providerMetadataKey, { signature }),
signature === undefined ? undefined : AnthropicProviderMetadata.write(state.providerMetadataKey, { signature }),
)
events.push(...resultEvents)
const reasoningSignatures = { ...state.reasoningSignatures }
@@ -1442,7 +1443,7 @@ const onMessageDelta = (
const finishMetadata =
stopSequence === null || stopSequence === undefined
? state.pendingFinish?.providerMetadata
: providerMetadata(state.providerMetadataKey, { stopSequence })
: AnthropicProviderMetadata.write(state.providerMetadataKey, { stopSequence })
return {
reason: {
normalized: mapFinishReason(stopReason),
@@ -1474,7 +1475,7 @@ const onMessageStop = Effect.fn("AnthropicMessages.onMessageStop")(function* (st
current,
events,
`reasoning-${index}`,
providerMetadata(state.providerMetadataKey, { signature }),
AnthropicProviderMetadata.write(state.providerMetadataKey, { signature }),
),
lifecycle,
)
+16 -21
View File
@@ -12,15 +12,13 @@ import {
type JsonSchema,
type LLMRequest,
type LanguageModelToolSchemaCompatibility,
type ProviderMetadata,
type ReasoningPart,
type ToolCallPart,
type ToolDefinition,
type ToolResultPart,
} from "../schema/index.js"
import { BedrockEventStream } from "./bedrock-event-stream.js"
import { classifyProviderFailure } from "../provider-error.js"
import { JsonObject, optionalArray, ProviderShared } from "./shared.js"
import { JsonObject, lenient, optionalArray, ProviderShared } from "./shared.js"
import { BedrockAuth } from "./utils/bedrock-auth.js"
import { BedrockCache } from "./utils/bedrock-cache.js"
import { BedrockMedia } from "./utils/bedrock-media.js"
@@ -259,18 +257,12 @@ const lowerToolChoice = (toolChoice: NonNullable<LLMRequest["toolChoice"]>) =>
tool: (name) => ({ tool: { name } }) as const,
})
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
const reasoningSignature = (part: ReasoningPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (part.encrypted !== undefined) return part.encrypted
if (ProviderShared.isRecord(metadata) && typeof metadata.signature === "string") return metadata.signature
}
const reasoningRedactedData = (part: ReasoningPart, providerMetadataKey: string) => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (ProviderShared.isRecord(metadata) && typeof metadata.redactedData === "string") return metadata.redactedData
}
const BedrockProviderMetadata = ProviderShared.providerMetadata(
Schema.Struct({
signature: lenient(Schema.String),
redactedData: lenient(Schema.String),
}),
)
const removeEmptyToolInputKeys = (input: unknown): unknown => {
if (Array.isArray(input)) return input.map(removeEmptyToolInputKeys)
@@ -387,8 +379,9 @@ const lowerMessages = Effect.fn("BedrockConverse.lowerMessages")(function* (
continue
}
if (part.type === "reasoning") {
const signature = reasoningSignature(part, providerMetadataKey)
const redactedData = reasoningRedactedData(part, providerMetadataKey)
const metadata = BedrockProviderMetadata.read(part.providerMetadata, providerMetadataKey)
const signature = part.encrypted ?? metadata?.signature
const redactedData = metadata?.redactedData
if (signature === undefined && redactedData !== undefined) {
content.push({ reasoningContent: { redactedContent: redactedData } })
continue
@@ -602,8 +595,10 @@ const step = (state: ParserState, event: BedrockEvent) =>
})()
const redactedData = redactedChunks === undefined ? reasoning.data : encodeRedactedContent(redactedChunks)
const metadata = (() => {
if (reasoning.signature) return providerMetadata(state.providerMetadataKey, { signature: reasoning.signature })
if (redactedData !== undefined) return providerMetadata(state.providerMetadataKey, { redactedData })
if (reasoning.signature)
return BedrockProviderMetadata.write(state.providerMetadataKey, { signature: reasoning.signature })
if (redactedData !== undefined)
return BedrockProviderMetadata.write(state.providerMetadataKey, { redactedData })
})()
const lifecycle = (() => {
if (reasoning.text === undefined && metadata === undefined) return state.lifecycle
@@ -655,10 +650,10 @@ const step = (state: ParserState, event: BedrockEvent) =>
if (resultEvents.length) return Lifecycle.stepStart(state.lifecycle, events)
const metadata = (() => {
const signature = state.reasoningSignatures[index]
if (signature) return providerMetadata(state.providerMetadataKey, { signature })
if (signature) return BedrockProviderMetadata.write(state.providerMetadataKey, { signature })
const redactedContent = state.reasoningRedactedContent[index]
if (redactedContent)
return providerMetadata(state.providerMetadataKey, {
return BedrockProviderMetadata.write(state.providerMetadataKey, {
redactedData: encodeRedactedContent(redactedContent),
})
})()
+25 -18
View File
@@ -300,14 +300,15 @@ const lowerUserPart = Effect.fn("Gemini.lowerUserPart")(function* (part: TextPar
return { inlineData: { mimeType: media.mime, data: media.base64 } }
})
const providerMetadata = (key: string, metadata: Record<string, unknown>): ProviderMetadata => ({ [key]: metadata })
const GeminiProviderMetadata = ProviderShared.providerMetadata(
Schema.Struct({
thoughtSignature: lenient(Schema.String),
promptFeedback: lenient(GeminiPromptFeedback),
}),
)
const thoughtSignature = (metadata: ProviderMetadata | undefined, key: string) => {
const value = metadata?.[key]
return ProviderShared.isRecord(value) && typeof value.thoughtSignature === "string"
? value.thoughtSignature
: undefined
}
const thoughtSignature = (metadata: ProviderMetadata | undefined, key: string) =>
GeminiProviderMetadata.read(metadata, key)?.thoughtSignature
const lowerToolCall = (part: ToolCallPart, omitIds: boolean, metadataKey: string) => ({
functionCall: { ...(omitIds ? {} : { id: part.id }), name: part.name, args: part.input },
@@ -510,7 +511,7 @@ const mapUsage = (usage: GeminiUsage | undefined, metadataKey: string) => {
cacheReadInputTokens: cached,
reasoningTokens: thoughts,
totalTokens: ProviderShared.totalTokens(promptTokens, outputTokens, usage.totalTokenCount ?? undefined),
providerMetadata: providerMetadata(metadataKey, usage),
providerMetadata: { [metadataKey]: usage },
})
}
@@ -560,7 +561,7 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
state.reasoningId,
state.reasoningSignature === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
: GeminiProviderMetadata.write(state.providerMetadataKey, { thoughtSignature: state.reasoningSignature }),
)
if (state.textId !== undefined)
lifecycle = Lifecycle.textEnd(
@@ -569,7 +570,7 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
state.textId,
state.textSignature === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
: GeminiProviderMetadata.write(state.providerMetadataKey, { thoughtSignature: state.textSignature }),
)
Lifecycle.finish(lifecycle, events, {
reason: {
@@ -581,7 +582,7 @@ const finish = (state: ParserState): ReadonlyArray<LLMEvent> => {
providerMetadata:
state.promptFeedback === undefined
? undefined
: providerMetadata(state.providerMetadataKey, { promptFeedback: state.promptFeedback }),
: GeminiProviderMetadata.write(state.providerMetadataKey, { promptFeedback: state.promptFeedback }),
})
return events
}
@@ -670,7 +671,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
events,
textId,
textSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature })
? GeminiProviderMetadata.write(state.providerMetadataKey, { thoughtSignature: textSignature })
: undefined,
)
textId = undefined
@@ -685,7 +686,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
events,
reasoningId,
part.text,
signature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: signature }) : undefined,
signature
? GeminiProviderMetadata.write(state.providerMetadataKey, { thoughtSignature: signature })
: undefined,
)
continue
}
@@ -695,7 +698,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
events,
reasoningId,
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
? GeminiProviderMetadata.write(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
)
reasoningId = undefined
@@ -710,7 +713,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
events,
textId,
part.text,
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
textSignature
? GeminiProviderMetadata.write(state.providerMetadataKey, { thoughtSignature: textSignature })
: undefined,
)
textSignature = undefined
continue
@@ -732,7 +737,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
events,
reasoningId,
reasoningSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
? GeminiProviderMetadata.write(state.providerMetadataKey, { thoughtSignature: reasoningSignature })
: undefined,
)
reasoningId = undefined
@@ -743,7 +748,9 @@ const step = (state: ParserState, event: GeminiEvent) => {
lifecycle,
events,
textId,
textSignature ? providerMetadata(state.providerMetadataKey, { thoughtSignature: textSignature }) : undefined,
textSignature
? GeminiProviderMetadata.write(state.providerMetadataKey, { thoughtSignature: textSignature })
: undefined,
)
textId = undefined
textSignature = undefined
@@ -755,7 +762,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
name: part.functionCall.name,
input,
providerMetadata: part.thoughtSignature
? providerMetadata(state.providerMetadataKey, { thoughtSignature: part.thoughtSignature })
? GeminiProviderMetadata.write(state.providerMetadataKey, { thoughtSignature: part.thoughtSignature })
: undefined,
}),
)
+54 -34
View File
@@ -11,14 +11,13 @@ import {
type JsonSchema,
type LLMRequest,
type MediaPart,
type ProviderMetadata,
type ReasoningPart,
type TextPart,
type ToolCallPart,
type ToolDefinition,
type ToolResultPart,
} from "../schema/index.js"
import { JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { JsonObject, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { classifyProviderFailure } from "../provider-error.js"
import { effortUpdate } from "../effort-updates.js"
import { OpenResponsesOptions } from "./utils/open-responses-options.js"
@@ -521,17 +520,27 @@ export const lowerToolChoice = (protocolName: string, toolChoice: NonNullable<LL
tool: (toolName) => ({ type: "function" as const, name: toolName }),
})
export const providerMetadata = ProviderShared.providerMetadata(
Schema.Struct({
itemId: lenient(Schema.String),
phase: lenient(MessagePhase),
reasoningEncryptedContent: lenient(Schema.NullOr(Schema.String)),
responseId: lenient(Schema.String),
serviceTier: lenient(Schema.NullOr(Schema.String)),
}),
)
// Server-issued item ids need a nonempty prefix and suffix, but the prefix is
// provider-defined and does not necessarily identify the item's semantic type.
const itemID = (providerMetadata: ProviderMetadata | undefined, providerMetadataKey: string) => {
const metadata = providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata) || typeof metadata.itemId !== "string") return undefined
const separator = metadata.itemId.indexOf("_")
return separator > 0 && separator < metadata.itemId.length - 1 ? metadata.itemId : undefined
const itemID = (metadata: ReturnType<typeof providerMetadata.read>) => {
const itemId = metadata?.itemId
if (itemId === undefined) return undefined
const separator = itemId.indexOf("_")
return separator > 0 && separator < itemId.length - 1 ? itemId : undefined
}
const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenResponsesInputItem => {
const id = itemID(part.providerMetadata, providerMetadataKey)
const id = itemID(providerMetadata.read(part.providerMetadata, providerMetadataKey))
return {
type: "function_call",
...(id === undefined ? {} : { id }),
@@ -543,18 +552,14 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
}
const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenResponsesReasoningInput | undefined => {
const metadata = part.providerMetadata?.[providerMetadataKey]
if (!ProviderShared.isRecord(metadata)) return undefined
const id = itemID(part.providerMetadata, providerMetadataKey)
const encryptedContent =
typeof metadata.reasoningEncryptedContent === "string" || metadata.reasoningEncryptedContent === null
? metadata.reasoningEncryptedContent
: undefined
const metadata = providerMetadata.read(part.providerMetadata, providerMetadataKey)
if (!metadata) return undefined
const id = itemID(metadata)
return {
type: "reasoning",
...(id === undefined ? {} : { id }),
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
encrypted_content: encryptedContent,
encrypted_content: metadata.reasoningEncryptedContent,
}
}
@@ -696,10 +701,9 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
const groups = content.reduce<
Array<{ id: string | undefined; phase: MessagePhase | null | undefined; parts: TextPart[] }>
>((groups, part) => {
const partMetadata = part.providerMetadata?.[providerMetadataKey]
const id = itemID(part.providerMetadata, providerMetadataKey) ?? metadata?.itemId
const partPhase = messagePhase(partMetadata?.phase)
const phase = partPhase === undefined ? metadata?.phase : partPhase
const partMetadata = providerMetadata.read(part.providerMetadata, providerMetadataKey)
const id = itemID(partMetadata) ?? metadata?.itemId
const phase = partMetadata?.phase === undefined ? metadata?.phase : partMetadata.phase
const group = groups.at(-1)
if (group && group.id === id && group.phase === phase) group.parts.push(part)
else groups.push({ id, phase, parts: [part] })
@@ -755,7 +759,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (
}
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const id = itemID(part.providerMetadata, providerMetadataKey)
const id = itemID(providerMetadata.read(part.providerMetadata, providerMetadataKey))
const hosted =
part.result.type !== "json"
? undefined
@@ -937,10 +941,6 @@ const mapFinishReason = (event: Event, hasFunctionCall: boolean): FinishReason =
export const metadataKey = (model: LLMRequest["model"]) => model.route.providerMetadataKey ?? "openresponses"
export const providerMetadata = (state: ParserState, metadata: Record<string, unknown>): ProviderMetadata => ({
[state.providerMetadataKey]: metadata,
})
export type StepResult = readonly [ParserState, ReadonlyArray<LLMEvent>]
const NO_EVENTS: StepResult["1"] = []
@@ -955,7 +955,10 @@ const onOutputTextDelta = (state: ParserState, event: Event, id: string): StepRe
if (!event.delta || state.message?.id !== id) return [state, NO_EVENTS]
const events: LLMEvent[] = []
const phase = state.message.phase
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) })
const metadata = providerMetadata.write(state.providerMetadataKey, {
itemId: id,
...(phase === undefined ? {} : { phase }),
})
const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata)
return [{ ...state, lifecycle: Lifecycle.textDelta(lifecycle, events, id, event.delta) }, events]
}
@@ -1025,7 +1028,12 @@ const startReasoningSummaryPart = (state: ParserState, itemID: string, index: nu
.filter((entry) => entry[1] !== "concluded")
.reduce(
(lifecycle, entry) =>
Lifecycle.reasoningEnd(lifecycle, events, `${itemID}:${entry[0]}`, providerMetadata(state, { itemId: itemID })),
Lifecycle.reasoningEnd(
lifecycle,
events,
`${itemID}:${entry[0]}`,
providerMetadata.write(state.providerMetadataKey, { itemId: itemID }),
),
state.lifecycle,
)
return [
@@ -1035,7 +1043,10 @@ const startReasoningSummaryPart = (state: ParserState, itemID: string, index: nu
lifecycle,
events,
`${itemID}:${index}`,
providerMetadata(state, { itemId: itemID, reasoningEncryptedContent: item.encryptedContent ?? null }),
providerMetadata.write(state.providerMetadataKey, {
itemId: itemID,
reasoningEncryptedContent: item.encryptedContent ?? null,
}),
),
reasoningItems: {
...state.reasoningItems,
@@ -1090,7 +1101,10 @@ export const onReasoningDone = (state: ParserState, event: Event, itemID: string
}
const reasoningMetadata = (state: ParserState, item: OutputItem) =>
providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
providerMetadata.write(state.providerMetadataKey, {
itemId: item.id,
reasoningEncryptedContent: item.encrypted_content ?? null,
})
// Responses APIs normally stream reasoning items in this order:
// `output_item.added` (reasoning) →
@@ -1117,7 +1131,10 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
lifecycle,
events,
id,
providerMetadata(state, { itemId: id, ...(openPhase === undefined ? {} : { phase: openPhase }) }),
providerMetadata.write(state.providerMetadataKey, {
itemId: id,
...(openPhase === undefined ? {} : { phase: openPhase }),
}),
)
}, state.lifecycle)
return [
@@ -1153,7 +1170,7 @@ const onOutputItemAdded = (state: ParserState, event: NormalizedEvent): StepResu
}
if (item.type !== "function_call" || !item.call_id) return [state, NO_EVENTS]
if (state.tools[item.id] !== undefined) return [state, NO_EVENTS]
const metadata = providerMetadata(state, { itemId: item.id })
const metadata = providerMetadata.write(state.providerMetadataKey, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
return [
@@ -1276,7 +1293,10 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
content.push(decoded.type === "output_text" ? decoded.text : decoded.refusal)
}
const text = content.length > 0 ? content.join("") : undefined
const metadata = providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })
const metadata = providerMetadata.write(state.providerMetadataKey, {
itemId: item.id,
...(phase === undefined ? {} : { phase }),
})
const events: LLMEvent[] = []
const lifecycle = text ? Lifecycle.textStart(state.lifecycle, events, item.id, metadata) : state.lifecycle
return [
@@ -1291,7 +1311,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (item.type === "function_call") {
if (!item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult
const metadata = providerMetadata(state, { itemId: item.id })
const metadata = providerMetadata.write(state.providerMetadataKey, { itemId: item.id })
const registered = state.tools[item.id] !== undefined
const tools = registered
? state.tools
@@ -1413,7 +1433,7 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
usage: mapUsage(event.response?.usage, current.providerMetadataKey),
providerMetadata:
event.response?.id || event.response?.service_tier
? providerMetadata(current, {
? providerMetadata.write(current.providerMetadataKey, {
responseId: event.response.id,
serviceTier: event.response.service_tier,
})
+23 -23
View File
@@ -26,7 +26,7 @@ import {
type ToolDefinition,
} from "../schema/index.js"
import { classifyProviderFailure } from "../provider-error.js"
import { isRecord, JsonObject, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { isRecord, JsonObject, lenient, optionalArray, optionalNull, ProviderShared } from "./shared.js"
import { OpenAIOptions } from "./utils/openai-options.js"
import { Lifecycle } from "./utils/lifecycle.js"
import { ToolSchemaProjection } from "./utils/tool-schema.js"
@@ -333,18 +333,20 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
const openAICompatibleReasoningContent = (native: unknown) =>
isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined
const reasoningField = (part: ReasoningPart, providerMetadataKey: string) => {
const field = part.providerMetadata?.[providerMetadataKey]?.reasoningField
return typeof field === "string" ? field : undefined
}
const OpenAIChatProviderMetadata = ProviderShared.providerMetadata(
Schema.Struct({
reasoningField: lenient(Schema.String),
// OpenRouter-defined passthrough; its element shape is not modelled.
reasoningDetails: lenient(Schema.Array(Schema.Unknown)),
}),
)
const reasoningDetails = (parts: ReadonlyArray<ReasoningPart>, native: unknown, providerMetadataKey: string) => {
const observed = parts.flatMap((part) => {
const details = part.providerMetadata?.[providerMetadataKey]?.reasoningDetails
return Array.isArray(details) ? details : []
})
if (parts.some((part) => Array.isArray(part.providerMetadata?.[providerMetadataKey]?.reasoningDetails)))
return observed
const reasoningDetails = (
observed: ReadonlyArray<ReturnType<typeof OpenAIChatProviderMetadata.read>>,
native: unknown,
) => {
if (observed.some((metadata) => metadata?.reasoningDetails !== undefined))
return observed.flatMap((metadata) => metadata?.reasoningDetails ?? [])
if (isRecord(native) && Array.isArray(native.reasoning_details)) return native.reasoning_details
}
@@ -398,14 +400,13 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
}
}
const text = reasoning.map((part) => part.text).join("")
const details = reasoningDetails(reasoning, message.native?.openaiCompatible, options.providerMetadataKey)
const observedField = reasoning
.map((part) => reasoningField(part, options.providerMetadataKey))
.find((value) => value !== undefined)
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
const fullyStructured = reasoning.every((part) =>
Array.isArray(part.providerMetadata?.[options.providerMetadataKey]?.reasoningDetails),
const observed = reasoning.map((part) =>
OpenAIChatProviderMetadata.read(part.providerMetadata, options.providerMetadataKey),
)
const details = reasoningDetails(observed, message.native?.openaiCompatible)
const observedField = observed.map((metadata) => metadata?.reasoningField).find((value) => value !== undefined)
const nativeReasoning = openAICompatibleReasoningContent(message.native?.openaiCompatible)
const fullyStructured = observed.every((metadata) => metadata?.reasoningDetails !== undefined)
const field = (() => {
if (configuredField !== undefined && (requireReasoning || reasoning.length > 0 || nativeReasoning !== undefined))
return configuredField
@@ -937,12 +938,11 @@ const reasoningMetadata = (
providerMetadataKey: string,
field: ParserState["reasoningField"],
details?: ReadonlyArray<unknown>,
) => ({
[providerMetadataKey]: {
) =>
OpenAIChatProviderMetadata.write(providerMetadataKey, {
...(field ? { reasoningField: field } : {}),
...(details ? { reasoningDetails: details } : {}),
},
})
})
const step = (state: ParserState, event: OpenAIChatEvent) =>
Effect.gen(function* () {
+12
View File
@@ -15,6 +15,7 @@ import {
type ContentPart,
type MediaPart,
type ProviderID,
type ProviderMetadata,
type TextPart,
type ToolEntry,
type ToolResultPart,
@@ -34,6 +35,17 @@ export const lenient = <const S extends Schema.Top>(schema: S) =>
Schema.optionalKey(
Schema.UndefinedOr(schema).pipe(Schema.catchDecoding(() => Effect.succeed(Option.some(undefined)))),
)
/**
* Typed slice of `ProviderMetadata` a protocol publishes under the route's `providerMetadataKey`.
* Declare fields with `lenient` so `read` drops a malformed field without discarding its siblings.
*/
export const providerMetadata = <const S extends Schema.ConstraintDecoder<Record<string, unknown>>>(schema: S) => {
const decode = Schema.decodeUnknownOption(schema)
return {
write: (key: string, value: S["Type"]) => ({ [key]: value }),
read: (metadata: ProviderMetadata | undefined, key: string) => Option.getOrUndefined(decode(metadata?.[key])),
}
}
/** Provider-defined string enum: known values for autocomplete, any string accepted at runtime. */
export const knownString = <Known extends string>() =>
Schema.declare<Known | (string & {})>((value): value is Known | (string & {}) => typeof value === "string", {
@@ -144,7 +144,10 @@ function toMessage(item: (typeof Response.Type.output)[number], model: LLMReques
summary.map((part) => ({
type: "reasoning" as const,
text: part.text,
providerMetadata: { [key]: { itemId: item.id, reasoningEncryptedContent: item.encrypted_content } },
providerMetadata: OpenResponses.providerMetadata.write(key, {
itemId: item.id,
reasoningEncryptedContent: item.encrypted_content,
}),
})),
)
}
@@ -37,7 +37,7 @@ export const onDone: (
function* (state, item, tools) {
const tool = tools[item.type]
if (!tool) return [state, []] satisfies OpenResponses.StepResult
const providerMetadata = OpenResponses.providerMetadata(state, { itemId: item.id })
const providerMetadata = OpenResponses.providerMetadata.write(state.providerMetadataKey, { itemId: item.id })
const events: LLMEvent[] = []
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
events.push(
@@ -916,6 +916,42 @@ describe("Anthropic Messages route", () => {
}),
)
it.effect("drops malformed provider metadata fields without discarding their siblings", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([
{
type: "reasoning",
text: "",
providerMetadata: { anthropic: { signature: 123, redactedData: "opaque_1" } },
},
{
type: "reasoning",
text: "visible",
providerMetadata: { anthropic: { signature: "sig_1", redactedData: 7 } },
},
]),
],
}),
)
expect(prepared.body).toMatchObject({
messages: [
{
role: "assistant",
content: [
{ type: "redacted_thinking", data: "opaque_1" },
{ type: "thinking", thinking: "visible", signature: "sig_1" },
],
},
],
})
}),
)
it.effect("round-trips compatible provider metadata in its own namespace", () =>
Effect.gen(function* () {
const compatible = Route.make({