fix(llm): preserve native continuation metadata (#28678)
This commit is contained in:
parent
a58c3c53a9
commit
61390dbb49
12 changed files with 843 additions and 194 deletions
|
|
@ -10,6 +10,7 @@ import {
|
||||||
type CacheHint,
|
type CacheHint,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
|
type MediaPart,
|
||||||
type ProviderMetadata,
|
type ProviderMetadata,
|
||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
|
|
@ -39,6 +40,17 @@ const AnthropicTextBlock = Schema.Struct({
|
||||||
})
|
})
|
||||||
type AnthropicTextBlock = Schema.Schema.Type<typeof AnthropicTextBlock>
|
type AnthropicTextBlock = Schema.Schema.Type<typeof AnthropicTextBlock>
|
||||||
|
|
||||||
|
const AnthropicImageBlock = Schema.Struct({
|
||||||
|
type: Schema.tag("image"),
|
||||||
|
source: Schema.Struct({
|
||||||
|
type: Schema.tag("base64"),
|
||||||
|
media_type: Schema.String,
|
||||||
|
data: Schema.String,
|
||||||
|
}),
|
||||||
|
cache_control: Schema.optional(AnthropicCacheControl),
|
||||||
|
})
|
||||||
|
type AnthropicImageBlock = Schema.Schema.Type<typeof AnthropicImageBlock>
|
||||||
|
|
||||||
const AnthropicThinkingBlock = Schema.Struct({
|
const AnthropicThinkingBlock = Schema.Struct({
|
||||||
type: Schema.tag("thinking"),
|
type: Schema.tag("thinking"),
|
||||||
thinking: Schema.String,
|
thinking: Schema.String,
|
||||||
|
|
@ -92,7 +104,8 @@ const AnthropicToolResultBlock = Schema.Struct({
|
||||||
cache_control: Schema.optional(AnthropicCacheControl),
|
cache_control: Schema.optional(AnthropicCacheControl),
|
||||||
})
|
})
|
||||||
|
|
||||||
const AnthropicUserBlock = Schema.Union([AnthropicTextBlock, AnthropicToolResultBlock])
|
const AnthropicUserBlock = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicToolResultBlock])
|
||||||
|
type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
|
||||||
const AnthropicAssistantBlock = Schema.Union([
|
const AnthropicAssistantBlock = Schema.Union([
|
||||||
AnthropicTextBlock,
|
AnthropicTextBlock,
|
||||||
AnthropicThinkingBlock,
|
AnthropicThinkingBlock,
|
||||||
|
|
@ -272,6 +285,19 @@ const lowerServerToolResult = Effect.fn("AnthropicMessages.lowerServerToolResult
|
||||||
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
|
return { type: wireType, tool_use_id: part.id, content: part.result.value } satisfies AnthropicServerToolResultBlock
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: MediaPart) {
|
||||||
|
if (!part.mediaType.startsWith("image/"))
|
||||||
|
return yield* invalid(`Anthropic Messages user media content only supports images`)
|
||||||
|
return {
|
||||||
|
type: "image" as const,
|
||||||
|
source: {
|
||||||
|
type: "base64" as const,
|
||||||
|
media_type: part.mediaType,
|
||||||
|
data: ProviderShared.mediaBase64(part),
|
||||||
|
},
|
||||||
|
} satisfies AnthropicImageBlock
|
||||||
|
})
|
||||||
|
|
||||||
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||||
request: LLMRequest,
|
request: LLMRequest,
|
||||||
breakpoints: Cache.Breakpoints,
|
breakpoints: Cache.Breakpoints,
|
||||||
|
|
@ -280,11 +306,17 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
|
||||||
|
|
||||||
for (const message of request.messages) {
|
for (const message of request.messages) {
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
const content: AnthropicTextBlock[] = []
|
const content: AnthropicUserBlock[] = []
|
||||||
for (const part of message.content) {
|
for (const part of message.content) {
|
||||||
if (!ProviderShared.supportsContent(part, ["text"]))
|
if (part.type === "text") {
|
||||||
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text"])
|
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
|
||||||
content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) })
|
continue
|
||||||
|
}
|
||||||
|
if (part.type === "media") {
|
||||||
|
content.push(yield* lowerImage(part))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])
|
||||||
}
|
}
|
||||||
messages.push({ role: "user", content })
|
messages.push({ role: "user", content })
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -6,11 +6,11 @@ import { HttpTransport, WebSocketTransport } from "../route/transport"
|
||||||
import { Protocol } from "../route/protocol"
|
import { Protocol } from "../route/protocol"
|
||||||
import {
|
import {
|
||||||
LLMEvent,
|
LLMEvent,
|
||||||
type MediaPart,
|
|
||||||
Usage,
|
Usage,
|
||||||
type FinishReason,
|
type FinishReason,
|
||||||
type LLMRequest,
|
type LLMRequest,
|
||||||
type ProviderMetadata,
|
type ProviderMetadata,
|
||||||
|
type ReasoningPart,
|
||||||
type TextPart,
|
type TextPart,
|
||||||
type ToolCallPart,
|
type ToolCallPart,
|
||||||
type ToolDefinition,
|
type ToolDefinition,
|
||||||
|
|
@ -43,10 +43,23 @@ const OpenAIResponsesOutputText = Schema.Struct({
|
||||||
text: Schema.String,
|
text: Schema.String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const OpenAIResponsesReasoningSummaryText = Schema.Struct({
|
||||||
|
type: Schema.tag("summary_text"),
|
||||||
|
text: Schema.String,
|
||||||
|
})
|
||||||
|
|
||||||
|
const OpenAIResponsesReasoningItem = Schema.Struct({
|
||||||
|
type: Schema.tag("reasoning"),
|
||||||
|
id: Schema.String,
|
||||||
|
summary: Schema.Array(OpenAIResponsesReasoningSummaryText),
|
||||||
|
encrypted_content: optionalNull(Schema.String),
|
||||||
|
})
|
||||||
|
|
||||||
const OpenAIResponsesInputItem = Schema.Union([
|
const OpenAIResponsesInputItem = Schema.Union([
|
||||||
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
Schema.Struct({ role: Schema.tag("system"), content: Schema.String }),
|
||||||
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
|
Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }),
|
||||||
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
|
Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }),
|
||||||
|
OpenAIResponsesReasoningItem,
|
||||||
Schema.Struct({
|
Schema.Struct({
|
||||||
type: Schema.tag("function_call"),
|
type: Schema.tag("function_call"),
|
||||||
call_id: Schema.String,
|
call_id: Schema.String,
|
||||||
|
|
@ -149,6 +162,7 @@ const OpenAIResponsesStreamItem = Schema.Struct({
|
||||||
server_label: Schema.optional(Schema.String),
|
server_label: Schema.optional(Schema.String),
|
||||||
output: Schema.optional(Schema.Unknown),
|
output: Schema.optional(Schema.Unknown),
|
||||||
error: Schema.optional(Schema.Unknown),
|
error: Schema.optional(Schema.Unknown),
|
||||||
|
encrypted_content: optionalNull(Schema.String),
|
||||||
})
|
})
|
||||||
type OpenAIResponsesStreamItem = Schema.Schema.Type<typeof OpenAIResponsesStreamItem>
|
type OpenAIResponsesStreamItem = Schema.Schema.Type<typeof OpenAIResponsesStreamItem>
|
||||||
|
|
||||||
|
|
@ -206,17 +220,31 @@ const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({
|
||||||
arguments: ProviderShared.encodeJson(part.input),
|
arguments: ProviderShared.encodeJson(part.input),
|
||||||
})
|
})
|
||||||
|
|
||||||
const imageUrl = (part: MediaPart) =>
|
const lowerReasoning = (part: ReasoningPart, store: boolean | undefined): OpenAIResponsesInputItem | undefined => {
|
||||||
typeof part.data === "string" && part.data.startsWith("data:")
|
const openai = part.providerMetadata?.openai
|
||||||
? part.data
|
if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string") return undefined
|
||||||
: `data:${part.mediaType};base64,${ProviderShared.mediaBytes(part)}`
|
// With store:false, OpenAI only accepts previous reasoning items when the
|
||||||
|
// encrypted state is present. Bare rs_* ids point to non-persisted items.
|
||||||
|
if (store === false && typeof openai.reasoningEncryptedContent !== "string") return undefined
|
||||||
|
return {
|
||||||
|
type: "reasoning",
|
||||||
|
id: openai.itemId,
|
||||||
|
summary: part.text.length > 0 ? [{ type: "summary_text", text: part.text }] : [],
|
||||||
|
encrypted_content:
|
||||||
|
typeof openai.reasoningEncryptedContent === "string"
|
||||||
|
? openai.reasoningEncryptedContent
|
||||||
|
: openai.reasoningEncryptedContent === null
|
||||||
|
? null
|
||||||
|
: undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* (
|
const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* (
|
||||||
part: LLMRequest["messages"][number]["content"][number],
|
part: LLMRequest["messages"][number]["content"][number],
|
||||||
) {
|
) {
|
||||||
if (part.type === "text") return { type: "input_text" as const, text: part.text }
|
if (part.type === "text") return { type: "input_text" as const, text: part.text }
|
||||||
if (part.type === "media" && part.mediaType.startsWith("image/")) {
|
if (part.type === "media" && part.mediaType.startsWith("image/")) {
|
||||||
return { type: "input_image" as const, image_url: imageUrl(part) }
|
return { type: "input_image" as const, image_url: ProviderShared.mediaDataUrl(part) }
|
||||||
}
|
}
|
||||||
if (part.type === "media") return yield* invalid("OpenAI Responses user media content only supports images")
|
if (part.type === "media") return yield* invalid("OpenAI Responses user media content only supports images")
|
||||||
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"])
|
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"])
|
||||||
|
|
@ -226,6 +254,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||||
const system: OpenAIResponsesInputItem[] =
|
const system: OpenAIResponsesInputItem[] =
|
||||||
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }]
|
||||||
const input: OpenAIResponsesInputItem[] = [...system]
|
const input: OpenAIResponsesInputItem[] = [...system]
|
||||||
|
const store = OpenAIOptions.store(request)
|
||||||
|
|
||||||
for (const message of request.messages) {
|
for (const message of request.messages) {
|
||||||
if (message.role === "user") {
|
if (message.role === "user") {
|
||||||
|
|
@ -235,20 +264,34 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
|
||||||
|
|
||||||
if (message.role === "assistant") {
|
if (message.role === "assistant") {
|
||||||
const content: TextPart[] = []
|
const content: TextPart[] = []
|
||||||
|
const flushText = () => {
|
||||||
|
if (content.length === 0) return
|
||||||
|
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
|
||||||
|
content.splice(0, content.length)
|
||||||
|
}
|
||||||
for (const part of message.content) {
|
for (const part of message.content) {
|
||||||
if (!ProviderShared.supportsContent(part, ["text", "tool-call"]))
|
|
||||||
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "assistant", ["text", "tool-call"])
|
|
||||||
if (part.type === "text") {
|
if (part.type === "text") {
|
||||||
content.push(part)
|
content.push(part)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if (part.type === "reasoning") {
|
||||||
|
flushText()
|
||||||
|
const reasoning = lowerReasoning(part, store)
|
||||||
|
if (reasoning) input.push(reasoning)
|
||||||
|
continue
|
||||||
|
}
|
||||||
if (part.type === "tool-call") {
|
if (part.type === "tool-call") {
|
||||||
|
flushText()
|
||||||
input.push(lowerToolCall(part))
|
input.push(lowerToolCall(part))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
return yield* ProviderShared.unsupportedContent("OpenAI Responses", "assistant", [
|
||||||
|
"text",
|
||||||
|
"reasoning",
|
||||||
|
"tool-call",
|
||||||
|
])
|
||||||
}
|
}
|
||||||
if (content.length > 0)
|
flushText()
|
||||||
input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) })
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -367,6 +410,11 @@ const isHostedToolItem = (
|
||||||
): item is OpenAIResponsesStreamItem & { type: HostedToolType; id: string } =>
|
): item is OpenAIResponsesStreamItem & { type: HostedToolType; id: string } =>
|
||||||
item.type in HOSTED_TOOLS && typeof item.id === "string" && item.id.length > 0
|
item.type in HOSTED_TOOLS && typeof item.id === "string" && item.id.length > 0
|
||||||
|
|
||||||
|
const isReasoningItem = (
|
||||||
|
item: OpenAIResponsesStreamItem,
|
||||||
|
): item is OpenAIResponsesStreamItem & { type: "reasoning"; id: string } =>
|
||||||
|
item.type === "reasoning" && typeof item.id === "string" && item.id.length > 0
|
||||||
|
|
||||||
// Round-trip the full item as the structured result so consumers can extract
|
// Round-trip the full item as the structured result so consumers can extract
|
||||||
// outputs / sources / status without re-decoding.
|
// outputs / sources / status without re-decoding.
|
||||||
const hostedToolResult = (item: OpenAIResponsesStreamItem) => {
|
const hostedToolResult = (item: OpenAIResponsesStreamItem) => {
|
||||||
|
|
@ -428,16 +476,12 @@ const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): Step
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
const onReasoningDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
// The summary done event does not carry encrypted continuation state. Finish the
|
||||||
const events: LLMEvent[] = []
|
// common reasoning block when the full reasoning item arrives in output_item.done.
|
||||||
return [
|
const onReasoningDone = (state: ParserState, _event: OpenAIResponsesEvent): StepResult => [state, NO_EVENTS]
|
||||||
{
|
|
||||||
...state,
|
const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) =>
|
||||||
lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, event.item_id ?? "reasoning-0"),
|
openaiMetadata({ itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null })
|
||||||
},
|
|
||||||
events,
|
|
||||||
]
|
|
||||||
}
|
|
||||||
|
|
||||||
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => {
|
||||||
const item = event.item
|
const item = event.item
|
||||||
|
|
@ -518,6 +562,21 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function*
|
||||||
return [{ ...state, lifecycle }, events] satisfies StepResult
|
return [{ ...state, lifecycle }, events] satisfies StepResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isReasoningItem(item)) {
|
||||||
|
const events: LLMEvent[] = []
|
||||||
|
const providerMetadata = reasoningMetadata(item)
|
||||||
|
if (!state.lifecycle.reasoning.has(item.id)) {
|
||||||
|
const lifecycle = Lifecycle.stepStart(state.lifecycle, events)
|
||||||
|
events.push(LLMEvent.reasoningStart({ id: item.id, providerMetadata }))
|
||||||
|
events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata }))
|
||||||
|
return [{ ...state, lifecycle }, events] satisfies StepResult
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{ ...state, lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, item.id, providerMetadata) },
|
||||||
|
events,
|
||||||
|
] satisfies StepResult
|
||||||
|
}
|
||||||
|
|
||||||
return [state, NO_EVENTS] satisfies StepResult
|
return [state, NO_EVENTS] satisfies StepResult
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,7 @@ export const subtractTokens = (total: number | undefined, subtrahend: number | u
|
||||||
*/
|
*/
|
||||||
export const sumTokens = (...values: ReadonlyArray<number | undefined>): number | undefined => {
|
export const sumTokens = (...values: ReadonlyArray<number | undefined>): number | undefined => {
|
||||||
if (values.every((value) => value === undefined)) return undefined
|
if (values.every((value) => value === undefined)) return undefined
|
||||||
return values.reduce<number>((acc, value) => acc + (value ?? 0), 0)
|
return values.reduce((acc: number, value) => acc + (value ?? 0), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const eventError = (route: string, message: string, raw?: string) =>
|
export const eventError = (route: string, message: string, raw?: string) =>
|
||||||
|
|
@ -122,6 +122,16 @@ export const parseToolInput = (route: string, name: string, raw: string) =>
|
||||||
export const mediaBytes = (part: MediaPart) =>
|
export const mediaBytes = (part: MediaPart) =>
|
||||||
typeof part.data === "string" ? part.data : Buffer.from(part.data).toString("base64")
|
typeof part.data === "string" ? part.data : Buffer.from(part.data).toString("base64")
|
||||||
|
|
||||||
|
export const mediaBase64 = (part: MediaPart) => {
|
||||||
|
if (typeof part.data !== "string" || !part.data.startsWith("data:")) return mediaBytes(part)
|
||||||
|
return part.data.slice(part.data.indexOf(",") + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mediaDataUrl = (part: MediaPart) =>
|
||||||
|
typeof part.data === "string" && part.data.startsWith("data:")
|
||||||
|
? part.data
|
||||||
|
: `data:${part.mediaType};base64,${mediaBytes(part)}`
|
||||||
|
|
||||||
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
|
export const trimBaseUrl = (value: string) => value.replace(/\/+$/, "")
|
||||||
|
|
||||||
export const toolResultText = (part: ToolResultPart) => {
|
export const toolResultText = (part: ToolResultPart) => {
|
||||||
|
|
|
||||||
104
packages/llm/test/continuation-scenarios.ts
Normal file
104
packages/llm/test/continuation-scenarios.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
import { LLM, Message, ToolCallPart, ToolDefinition, ToolResultPart, type ContentPart, type Model } from "../src"
|
||||||
|
|
||||||
|
export const basicContinuation = ["system", "user-text", "assistant-text", "user-follow-up"] as const
|
||||||
|
export const toolContinuation = ["tool-call", "tool-result"] as const
|
||||||
|
export const reasoningContinuation = ["assistant-reasoning", "encrypted-reasoning"] as const
|
||||||
|
export const mediaContinuation = ["user-image"] as const
|
||||||
|
export const maximalContinuation = [
|
||||||
|
...basicContinuation,
|
||||||
|
...toolContinuation,
|
||||||
|
...reasoningContinuation,
|
||||||
|
...mediaContinuation,
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export type ContinuationFeature = (typeof maximalContinuation)[number]
|
||||||
|
|
||||||
|
export const nativeOpenAIResponsesContinuation = [
|
||||||
|
...basicContinuation,
|
||||||
|
...toolContinuation,
|
||||||
|
"encrypted-reasoning",
|
||||||
|
...mediaContinuation,
|
||||||
|
] as const satisfies ReadonlyArray<ContinuationFeature>
|
||||||
|
|
||||||
|
export const nativeAnthropicMessagesContinuation = [
|
||||||
|
...basicContinuation,
|
||||||
|
...toolContinuation,
|
||||||
|
"assistant-reasoning",
|
||||||
|
...mediaContinuation,
|
||||||
|
] as const satisfies ReadonlyArray<ContinuationFeature>
|
||||||
|
|
||||||
|
export const continuationTool = ToolDefinition.make({
|
||||||
|
name: "get_weather",
|
||||||
|
description: "Get current weather for a city.",
|
||||||
|
inputSchema: {
|
||||||
|
type: "object",
|
||||||
|
properties: { city: { type: "string" } },
|
||||||
|
required: ["city"],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
export function continuationRequest(input: {
|
||||||
|
readonly id: string
|
||||||
|
readonly model: Model
|
||||||
|
readonly features: ReadonlyArray<ContinuationFeature>
|
||||||
|
readonly image?: string
|
||||||
|
}) {
|
||||||
|
const features = new Set(input.features)
|
||||||
|
const messages = []
|
||||||
|
const firstUser: ContentPart[] = []
|
||||||
|
const firstAssistant: ContentPart[] = []
|
||||||
|
|
||||||
|
if (features.has("user-text")) firstUser.push({ type: "text", text: "What is shown here?" })
|
||||||
|
if (features.has("user-image"))
|
||||||
|
firstUser.push({ type: "media", mediaType: "image/png", data: input.image ?? "AAECAw==" })
|
||||||
|
if (firstUser.length > 0) messages.push(Message.user(firstUser))
|
||||||
|
|
||||||
|
if (features.has("assistant-reasoning"))
|
||||||
|
firstAssistant.push({
|
||||||
|
type: "reasoning",
|
||||||
|
text: "I inspected the previous turn.",
|
||||||
|
providerMetadata: { anthropic: { signature: "sig_continuation_1" } },
|
||||||
|
})
|
||||||
|
if (features.has("encrypted-reasoning"))
|
||||||
|
firstAssistant.push({
|
||||||
|
type: "reasoning",
|
||||||
|
text: "I inspected the previous turn.",
|
||||||
|
providerMetadata: {
|
||||||
|
openai: {
|
||||||
|
itemId: "rs_continuation_1",
|
||||||
|
reasoningEncryptedContent: "encrypted-continuation-state",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if (features.has("assistant-text")) firstAssistant.push({ type: "text", text: "It shows a small test image." })
|
||||||
|
if (firstAssistant.length > 0) messages.push(Message.assistant(firstAssistant))
|
||||||
|
|
||||||
|
if (features.has("tool-call")) {
|
||||||
|
messages.push(Message.user("Check the weather in Paris before continuing."))
|
||||||
|
messages.push(
|
||||||
|
Message.assistant([ToolCallPart.make({ id: "call_weather_1", name: "get_weather", input: { city: "Paris" } })]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (features.has("tool-result")) {
|
||||||
|
messages.push(
|
||||||
|
Message.tool(ToolResultPart.make({ id: "call_weather_1", name: "get_weather", result: { temperature: 22 } })),
|
||||||
|
)
|
||||||
|
if (features.has("assistant-text")) messages.push(Message.assistant("Paris is 22 degrees."))
|
||||||
|
}
|
||||||
|
if (features.has("user-follow-up"))
|
||||||
|
messages.push(Message.user("Continue from this conversation in one short sentence."))
|
||||||
|
|
||||||
|
return LLM.request({
|
||||||
|
id: input.id,
|
||||||
|
model: input.model,
|
||||||
|
system: features.has("system") ? "You are concise. Continue from the provided history." : undefined,
|
||||||
|
messages,
|
||||||
|
tools: features.has("tool-call") ? [continuationTool] : [],
|
||||||
|
cache: "none",
|
||||||
|
providerOptions: features.has("encrypted-reasoning")
|
||||||
|
? { openai: { store: false, includeEncryptedReasoning: true, reasoningSummary: "auto" } }
|
||||||
|
: undefined,
|
||||||
|
generation: { maxTokens: 80, temperature: 0 },
|
||||||
|
})
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
|
@ -1,10 +1,12 @@
|
||||||
import { describe, expect } from "bun:test"
|
import { describe, expect } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
|
import { HttpClientRequest } from "effect/unstable/http"
|
||||||
import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
|
import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src"
|
||||||
import { Auth, LLMClient } from "../../src/route"
|
import { Auth, LLMClient } from "../../src/route"
|
||||||
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
import * as AnthropicMessages from "../../src/protocols/anthropic-messages"
|
||||||
|
import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios"
|
||||||
import { it } from "../lib/effect"
|
import { it } from "../lib/effect"
|
||||||
import { fixedResponse } from "../lib/http"
|
import { dynamicResponse, fixedResponse } from "../lib/http"
|
||||||
import { sseEvents } from "../lib/sse"
|
import { sseEvents } from "../lib/sse"
|
||||||
|
|
||||||
const model = AnthropicMessages.route
|
const model = AnthropicMessages.route
|
||||||
|
|
@ -40,7 +42,7 @@ describe("Anthropic Messages route", () => {
|
||||||
|
|
||||||
it.effect("prepares tool call and tool result messages", () =>
|
it.effect("prepares tool call and tool result messages", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare(
|
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
id: "req_tool_result",
|
id: "req_tool_result",
|
||||||
model,
|
model,
|
||||||
|
|
@ -69,6 +71,50 @@ describe("Anthropic Messages route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("prepares the composed native continuation request", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
|
||||||
|
continuationRequest({
|
||||||
|
id: "req_native_continuation_anthropic",
|
||||||
|
model,
|
||||||
|
features: nativeAnthropicMessagesContinuation,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body).toMatchObject({
|
||||||
|
system: [{ type: "text", text: "You are concise. Continue from the provided history." }],
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "What is shown here?" },
|
||||||
|
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{ type: "thinking", thinking: "I inspected the previous turn.", signature: "sig_continuation_1" },
|
||||||
|
{ type: "text", text: "It shows a small test image." },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ role: "user", content: [{ type: "text", text: "Check the weather in Paris before continuing." }] },
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [{ type: "tool_use", id: "call_weather_1", name: "get_weather", input: { city: "Paris" } }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "tool_result", tool_use_id: "call_weather_1", content: '{"temperature":22}' }],
|
||||||
|
},
|
||||||
|
{ role: "assistant", content: [{ type: "text", text: "Paris is 22 degrees." }] },
|
||||||
|
{ role: "user", content: [{ type: "text", text: "Continue from this conversation in one short sentence." }] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
expect(prepared.body.tools).toEqual([expect.objectContaining({ name: "get_weather" })])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("lowers preserved Anthropic reasoning signature metadata", () =>
|
it.effect("lowers preserved Anthropic reasoning signature metadata", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare(
|
const prepared = yield* LLMClient.prepare(
|
||||||
|
|
@ -392,17 +438,51 @@ describe("Anthropic Messages route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
it.effect("rejects unsupported user media content", () =>
|
it.effect("continues a conversation with user image content", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const error = yield* LLMClient.prepare(
|
const response = yield* LLMClient.generate(
|
||||||
LLM.request({
|
LLM.request({
|
||||||
id: "req_media",
|
id: "req_media",
|
||||||
model,
|
model,
|
||||||
messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
|
messages: [
|
||||||
|
Message.user([
|
||||||
|
{ type: "text", text: "What is in this image?" },
|
||||||
|
{ type: "media", mediaType: "image/png", data: "AAECAw==" },
|
||||||
|
]),
|
||||||
|
],
|
||||||
}),
|
}),
|
||||||
).pipe(Effect.flip)
|
).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
expect(yield* Effect.promise(() => web.json())).toMatchObject({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "text", text: "What is in this image?" },
|
||||||
|
{ type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return input.respond(
|
||||||
|
sseEvents(
|
||||||
|
{ type: "content_block_start", index: 0, content_block: { type: "text", text: "" } },
|
||||||
|
{ type: "content_block_delta", index: 0, delta: { type: "text_delta", text: "An image." } },
|
||||||
|
{ type: "content_block_stop", index: 0 },
|
||||||
|
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 3 } },
|
||||||
|
{ type: "message_stop" },
|
||||||
|
),
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
expect(error.message).toContain("Anthropic Messages user messages only support text content for now")
|
expect(response.text).toBe("An image.")
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,7 @@ describeRecordedGoldenScenarios([
|
||||||
scenarios: [
|
scenarios: [
|
||||||
{ id: "text", temperature: false },
|
{ id: "text", temperature: false },
|
||||||
{ id: "reasoning", temperature: false },
|
{ id: "reasoning", temperature: false },
|
||||||
|
{ id: "reasoning-continuation", temperature: false },
|
||||||
{ id: "tool-call", temperature: false },
|
{ id: "tool-call", temperature: false },
|
||||||
{ id: "tool-loop", temperature: false },
|
{ id: "tool-loop", temperature: false },
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import * as Azure from "../../src/providers/azure"
|
||||||
import * as OpenAI from "../../src/providers/openai"
|
import * as OpenAI from "../../src/providers/openai"
|
||||||
import * as OpenAIResponses from "../../src/protocols/openai-responses"
|
import * as OpenAIResponses from "../../src/protocols/openai-responses"
|
||||||
import * as ProviderShared from "../../src/protocols/shared"
|
import * as ProviderShared from "../../src/protocols/shared"
|
||||||
|
import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios"
|
||||||
import { it } from "../lib/effect"
|
import { it } from "../lib/effect"
|
||||||
import { dynamicResponse, fixedResponse } from "../lib/http"
|
import { dynamicResponse, fixedResponse } from "../lib/http"
|
||||||
import { sseEvents } from "../lib/sse"
|
import { sseEvents } from "../lib/sse"
|
||||||
|
|
@ -247,6 +248,49 @@ describe("OpenAI Responses route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("prepares the composed native continuation request", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
continuationRequest({
|
||||||
|
id: "req_native_continuation_openai",
|
||||||
|
model,
|
||||||
|
features: nativeOpenAIResponsesContinuation,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body).toMatchObject({
|
||||||
|
input: [
|
||||||
|
{ role: "system", content: "You are concise. Continue from the provided history." },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [
|
||||||
|
{ type: "input_text", text: "What is shown here?" },
|
||||||
|
{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
id: "rs_continuation_1",
|
||||||
|
encrypted_content: "encrypted-continuation-state",
|
||||||
|
summary: [{ type: "summary_text", text: "I inspected the previous turn." }],
|
||||||
|
},
|
||||||
|
{ role: "assistant", content: [{ type: "output_text", text: "It shows a small test image." }] },
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }] },
|
||||||
|
{ type: "function_call", call_id: "call_weather_1", name: "get_weather", arguments: '{"city":"Paris"}' },
|
||||||
|
{ type: "function_call_output", call_id: "call_weather_1", output: '{"temperature":22}' },
|
||||||
|
{ role: "assistant", content: [{ type: "output_text", text: "Paris is 22 degrees." }] },
|
||||||
|
{
|
||||||
|
role: "user",
|
||||||
|
content: [{ type: "input_text", text: "Continue from this conversation in one short sentence." }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
include: ["reasoning.encrypted_content"],
|
||||||
|
store: false,
|
||||||
|
})
|
||||||
|
expect(prepared.body.tools).toEqual([expect.objectContaining({ type: "function", name: "get_weather" })])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("maps OpenAI provider options to Responses options", () =>
|
it.effect("maps OpenAI provider options to Responses options", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
|
@ -380,6 +424,172 @@ describe("OpenAI Responses route", () => {
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
it.effect("preserves encrypted reasoning metadata for continuation", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(request).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
fixedResponse(
|
||||||
|
sseEvents(
|
||||||
|
{ type: "response.reasoning_summary_text.delta", item_id: "rs_1", delta: "thinking" },
|
||||||
|
{
|
||||||
|
type: "response.output_item.done",
|
||||||
|
item: {
|
||||||
|
type: "reasoning",
|
||||||
|
id: "rs_1",
|
||||||
|
encrypted_content: "encrypted-state",
|
||||||
|
summary: [{ type: "summary_text", text: "thinking" }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "response.completed", response: { id: "resp_1" } },
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.events).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
type: "reasoning-end",
|
||||||
|
id: "rs_1",
|
||||||
|
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("continues a stateless reasoning conversation", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const response = yield* LLMClient.generate(
|
||||||
|
LLM.request({
|
||||||
|
id: "req_reasoning_continue",
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.user("What changed?"),
|
||||||
|
Message.assistant([
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "Checked the previous diff.",
|
||||||
|
providerMetadata: {
|
||||||
|
openai: {
|
||||||
|
itemId: "rs_1",
|
||||||
|
reasoningEncryptedContent: "encrypted-state",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "text", text: "The parser changed." },
|
||||||
|
]),
|
||||||
|
Message.user("Summarize it."),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).pipe(
|
||||||
|
Effect.provide(
|
||||||
|
dynamicResponse((input) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const web = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
|
||||||
|
expect(yield* Effect.promise(() => web.json())).toMatchObject({
|
||||||
|
input: [
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
id: "rs_1",
|
||||||
|
encrypted_content: "encrypted-state",
|
||||||
|
summary: [{ type: "summary_text", text: "Checked the previous diff." }],
|
||||||
|
},
|
||||||
|
{ role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
return input.respond(
|
||||||
|
sseEvents(
|
||||||
|
{ type: "response.output_text.delta", item_id: "msg_1", delta: "Parser now round-trips reasoning." },
|
||||||
|
{ type: "response.completed", response: { id: "resp_1" } },
|
||||||
|
),
|
||||||
|
{ headers: { "content-type": "text/event-stream" } },
|
||||||
|
)
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response.text).toBe("Parser now round-trips reasoning.")
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("preserves assistant content order around reasoning items", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
|
||||||
|
LLM.request({
|
||||||
|
id: "req_reasoning_order",
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.assistant([
|
||||||
|
{ type: "text", text: "Before." },
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "Checked order.",
|
||||||
|
providerMetadata: {
|
||||||
|
openai: {
|
||||||
|
itemId: "rs_1",
|
||||||
|
reasoningEncryptedContent: "encrypted-state",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "text", text: "After." },
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body.input).toEqual([
|
||||||
|
{ role: "assistant", content: [{ type: "output_text", text: "Before." }] },
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
id: "rs_1",
|
||||||
|
encrypted_content: "encrypted-state",
|
||||||
|
summary: [{ type: "summary_text", text: "Checked order." }],
|
||||||
|
},
|
||||||
|
{ role: "assistant", content: [{ type: "output_text", text: "After." }] },
|
||||||
|
])
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
it.effect("skips non-persisted reasoning ids without encrypted state", () =>
|
||||||
|
Effect.gen(function* () {
|
||||||
|
const prepared = yield* LLMClient.prepare(
|
||||||
|
LLM.request({
|
||||||
|
id: "req_reasoning_without_encrypted_state",
|
||||||
|
model,
|
||||||
|
messages: [
|
||||||
|
Message.user("What changed?"),
|
||||||
|
Message.assistant([
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "Checked the previous diff.",
|
||||||
|
providerMetadata: {
|
||||||
|
openai: {
|
||||||
|
itemId: "rs_1",
|
||||||
|
reasoningEncryptedContent: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ type: "text", text: "The parser changed." },
|
||||||
|
]),
|
||||||
|
Message.user("Summarize it."),
|
||||||
|
],
|
||||||
|
providerOptions: { openai: { store: false } },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(prepared.body).toMatchObject({
|
||||||
|
input: [
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "What changed?" }] },
|
||||||
|
{ role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] },
|
||||||
|
{ role: "user", content: [{ type: "input_text", text: "Summarize it." }] },
|
||||||
|
],
|
||||||
|
store: false,
|
||||||
|
})
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
|
||||||
it.effect("assembles streamed function call input", () =>
|
it.effect("assembles streamed function call input", () =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
const body = sseEvents(
|
const body = sseEvents(
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import type { HttpRecorder } from "@opencode-ai/http-recorder"
|
||||||
import { describe } from "bun:test"
|
import { describe } from "bun:test"
|
||||||
import { Effect } from "effect"
|
import { Effect } from "effect"
|
||||||
import type { Model } from "../src"
|
import type { Model } from "../src"
|
||||||
import { goldenScenarioTags, runGoldenScenario, type GoldenScenarioID } from "./recorded-scenarios"
|
import { goldenScenarioTags, goldenScenarioTitle, runGoldenScenario, type GoldenScenarioID } from "./recorded-scenarios"
|
||||||
import { recordedTests } from "./recorded-test"
|
import { recordedTests } from "./recorded-test"
|
||||||
import { kebab } from "./recorded-utils"
|
import { kebab } from "./recorded-utils"
|
||||||
|
|
||||||
|
|
@ -35,14 +35,6 @@ type TargetInput = {
|
||||||
|
|
||||||
const scenarioInput = (input: ScenarioInput) => (typeof input === "string" ? { id: input } : input)
|
const scenarioInput = (input: ScenarioInput) => (typeof input === "string" ? { id: input } : input)
|
||||||
|
|
||||||
const scenarioTitle = (id: GoldenScenarioID) => {
|
|
||||||
if (id === "text") return "streams text"
|
|
||||||
if (id === "tool-call") return "streams tool call"
|
|
||||||
if (id === "reasoning") return "uses reasoning"
|
|
||||||
if (id === "image") return "reads image text"
|
|
||||||
return "drives a tool loop"
|
|
||||||
}
|
|
||||||
|
|
||||||
const defaultPrefix = (target: TargetInput) => {
|
const defaultPrefix = (target: TargetInput) => {
|
||||||
if (target.prefix) return target.prefix
|
if (target.prefix) return target.prefix
|
||||||
const transport = target.transport === "websocket" ? "-websocket" : ""
|
const transport = target.transport === "websocket" ? "-websocket" : ""
|
||||||
|
|
@ -77,7 +69,7 @@ const runTarget = (target: TargetInput) => {
|
||||||
describe(`${target.name} recorded`, () => {
|
describe(`${target.name} recorded`, () => {
|
||||||
target.scenarios.forEach((raw) => {
|
target.scenarios.forEach((raw) => {
|
||||||
const input = scenarioInput(raw)
|
const input = scenarioInput(raw)
|
||||||
const name = input.name ?? scenarioTitle(input.id)
|
const name = input.name ?? goldenScenarioTitle(input.id)
|
||||||
recorded.effect.with(
|
recorded.effect.with(
|
||||||
name,
|
name,
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,17 @@
|
||||||
import { expect } from "bun:test"
|
import { expect } from "bun:test"
|
||||||
import { Effect, Schema, Stream } from "effect"
|
import { Effect, Schema, Stream } from "effect"
|
||||||
import { LLM, LLMEvent, LLMResponse, Message, ToolChoice, ToolDefinition, type LLMRequest, type Model } from "../src"
|
import {
|
||||||
|
LLM,
|
||||||
|
LLMEvent,
|
||||||
|
LLMResponse,
|
||||||
|
Message,
|
||||||
|
ToolChoice,
|
||||||
|
ToolDefinition,
|
||||||
|
type ContentPart,
|
||||||
|
type FinishReason,
|
||||||
|
type LLMRequest,
|
||||||
|
type Model,
|
||||||
|
} from "../src"
|
||||||
import { LLMClient } from "../src/route"
|
import { LLMClient } from "../src/route"
|
||||||
import { tool } from "../src/tool"
|
import { tool } from "../src/tool"
|
||||||
|
|
||||||
|
|
@ -39,47 +50,6 @@ export const weatherRuntimeTool = tool({
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const textRequest = (input: {
|
|
||||||
readonly id: string
|
|
||||||
readonly model: Model
|
|
||||||
readonly prompt?: string
|
|
||||||
readonly maxTokens?: number
|
|
||||||
readonly temperature?: number | false
|
|
||||||
}) =>
|
|
||||||
LLM.request({
|
|
||||||
id: input.id,
|
|
||||||
model: input.model,
|
|
||||||
system: "You are concise.",
|
|
||||||
prompt: input.prompt ?? "Reply with exactly: Hello!",
|
|
||||||
cache: "none",
|
|
||||||
providerOptions:
|
|
||||||
input.model.route.id === "gemini" ? { gemini: { thinkingConfig: { thinkingBudget: 0 } } } : undefined,
|
|
||||||
generation:
|
|
||||||
input.temperature === false
|
|
||||||
? { maxTokens: input.maxTokens ?? 80 }
|
|
||||||
: { maxTokens: input.maxTokens ?? 80, temperature: input.temperature ?? 0 },
|
|
||||||
})
|
|
||||||
|
|
||||||
export const weatherToolRequest = (input: {
|
|
||||||
readonly id: string
|
|
||||||
readonly model: Model
|
|
||||||
readonly maxTokens?: number
|
|
||||||
readonly temperature?: number | false
|
|
||||||
}) =>
|
|
||||||
LLM.request({
|
|
||||||
id: input.id,
|
|
||||||
model: input.model,
|
|
||||||
system: "Call tools exactly as requested.",
|
|
||||||
prompt: "Call get_weather with city exactly Paris.",
|
|
||||||
tools: [weatherTool],
|
|
||||||
toolChoice: ToolChoice.make(weatherTool),
|
|
||||||
cache: "none",
|
|
||||||
generation:
|
|
||||||
input.temperature === false
|
|
||||||
? { maxTokens: input.maxTokens ?? 80 }
|
|
||||||
: { maxTokens: input.maxTokens ?? 80, temperature: input.temperature ?? 0 },
|
|
||||||
})
|
|
||||||
|
|
||||||
export const weatherToolLoopRequest = (input: {
|
export const weatherToolLoopRequest = (input: {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly model: Model
|
readonly model: Model
|
||||||
|
|
@ -116,52 +86,6 @@ const restroomImage = () =>
|
||||||
Effect.map((bytes) => Buffer.from(bytes).toString("base64")),
|
Effect.map((bytes) => Buffer.from(bytes).toString("base64")),
|
||||||
)
|
)
|
||||||
|
|
||||||
export const imageRequest = (input: {
|
|
||||||
readonly id: string
|
|
||||||
readonly model: Model
|
|
||||||
readonly image: string
|
|
||||||
readonly maxTokens?: number
|
|
||||||
readonly temperature?: number | false
|
|
||||||
}) =>
|
|
||||||
LLM.request({
|
|
||||||
id: input.id,
|
|
||||||
model: input.model,
|
|
||||||
system: "Read images carefully. Reply only with the visible text.",
|
|
||||||
messages: [
|
|
||||||
Message.user([
|
|
||||||
{
|
|
||||||
type: "text",
|
|
||||||
text: "The image contains exactly three lowercase English words. Read them left to right and reply with only those words.",
|
|
||||||
},
|
|
||||||
{ type: "media", mediaType: "image/png", data: input.image },
|
|
||||||
]),
|
|
||||||
],
|
|
||||||
cache: "none",
|
|
||||||
generation:
|
|
||||||
input.temperature === false
|
|
||||||
? { maxTokens: input.maxTokens ?? 20 }
|
|
||||||
: { maxTokens: input.maxTokens ?? 20, temperature: input.temperature ?? 0 },
|
|
||||||
})
|
|
||||||
|
|
||||||
export const reasoningRequest = (input: {
|
|
||||||
readonly id: string
|
|
||||||
readonly model: Model
|
|
||||||
readonly maxTokens?: number
|
|
||||||
readonly temperature?: number | false
|
|
||||||
}) =>
|
|
||||||
LLM.request({
|
|
||||||
id: input.id,
|
|
||||||
model: input.model,
|
|
||||||
system: "Show concise reasoning when the provider supports visible reasoning summaries.",
|
|
||||||
prompt: "Think briefly, then reply exactly with: Hello!",
|
|
||||||
cache: "none",
|
|
||||||
providerOptions: { openai: { reasoningEffort: "low", reasoningSummary: "auto" } },
|
|
||||||
generation:
|
|
||||||
input.temperature === false
|
|
||||||
? { maxTokens: input.maxTokens ?? 120 }
|
|
||||||
: { maxTokens: input.maxTokens ?? 120, temperature: input.temperature ?? 0 },
|
|
||||||
})
|
|
||||||
|
|
||||||
export const runWeatherToolLoop = (request: LLMRequest) =>
|
export const runWeatherToolLoop = (request: LLMRequest) =>
|
||||||
LLMClient.stream({
|
LLMClient.stream({
|
||||||
request,
|
request,
|
||||||
|
|
@ -212,8 +136,6 @@ export const expectGoldenWeatherToolLoop = (events: ReadonlyArray<LLMEvent>) =>
|
||||||
expect(LLMResponse.text({ events }).trim()).toMatch(/^Paris is sunny\.?$/)
|
expect(LLMResponse.text({ events }).trim()).toMatch(/^Paris is sunny\.?$/)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type GoldenScenarioID = "text" | "tool-call" | "tool-loop" | "image" | "reasoning"
|
|
||||||
|
|
||||||
export interface GoldenScenarioContext {
|
export interface GoldenScenarioContext {
|
||||||
readonly id: string
|
readonly id: string
|
||||||
readonly model: Model
|
readonly model: Model
|
||||||
|
|
@ -223,6 +145,9 @@ export interface GoldenScenarioContext {
|
||||||
|
|
||||||
const generate = (request: LLMRequest) => LLMClient.generate(request)
|
const generate = (request: LLMRequest) => LLMClient.generate(request)
|
||||||
|
|
||||||
|
const generation = (context: GoldenScenarioContext, maxTokens: number) =>
|
||||||
|
context.temperature === false ? { maxTokens } : { maxTokens, temperature: context.temperature ?? 0 }
|
||||||
|
|
||||||
const normalizeImageText = (value: string) =>
|
const normalizeImageText = (value: string) =>
|
||||||
value
|
value
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
|
|
@ -230,75 +155,193 @@ const normalizeImageText = (value: string) =>
|
||||||
.replace(/\s+/g, " ")
|
.replace(/\s+/g, " ")
|
||||||
.trim()
|
.trim()
|
||||||
|
|
||||||
export const goldenScenarioTags = (id: GoldenScenarioID) => {
|
const encryptedReasoningOptions = {
|
||||||
if (id === "text") return ["text", "golden"]
|
openai: {
|
||||||
if (id === "tool-call") return ["tool", "tool-call", "golden"]
|
store: false,
|
||||||
if (id === "image") return ["media", "image", "vision", "golden"]
|
includeEncryptedReasoning: true,
|
||||||
if (id === "reasoning") return ["reasoning", "golden"]
|
reasoningEffort: "low",
|
||||||
return ["tool", "tool-loop", "golden"]
|
reasoningSummary: "auto",
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
type AssistantTextExpectation = string | RegExp
|
||||||
|
|
||||||
|
type UserStep = { readonly type: "user"; readonly content: Message.ContentInput }
|
||||||
|
type AssistantStep = {
|
||||||
|
readonly type: "assistant"
|
||||||
|
readonly text?: AssistantTextExpectation
|
||||||
|
readonly toolCall?: { readonly name: string; readonly input: unknown }
|
||||||
|
readonly reasoning?: "openai-encrypted"
|
||||||
|
readonly id?: string
|
||||||
|
readonly system?: string
|
||||||
|
readonly maxTokens?: number
|
||||||
|
readonly finish?: FinishReason
|
||||||
|
readonly tools?: LLM.RequestInput["tools"]
|
||||||
|
readonly toolChoice?: LLM.RequestInput["toolChoice"]
|
||||||
|
readonly providerOptions?: LLMRequest["providerOptions"]
|
||||||
|
readonly assert?: (response: LLMResponse) => void
|
||||||
|
}
|
||||||
|
type ConversationStep = UserStep | AssistantStep
|
||||||
|
|
||||||
|
const user = (content: Message.ContentInput): ConversationStep => ({ type: "user", content })
|
||||||
|
|
||||||
|
const assistant = {
|
||||||
|
expectText: (
|
||||||
|
text: AssistantTextExpectation,
|
||||||
|
options?: Omit<AssistantStep, "type" | "text" | "reasoning" | "toolCall">,
|
||||||
|
): ConversationStep => ({ type: "assistant", text, ...options }),
|
||||||
|
expectToolCall: (
|
||||||
|
name: string,
|
||||||
|
input: unknown,
|
||||||
|
options?: Omit<AssistantStep, "type" | "text" | "reasoning" | "toolCall" | "finish">,
|
||||||
|
): ConversationStep => ({ type: "assistant", toolCall: { name, input }, finish: "tool-calls", ...options }),
|
||||||
|
expectEncryptedReasoningText: (
|
||||||
|
text: AssistantTextExpectation,
|
||||||
|
options?: Omit<AssistantStep, "type" | "text" | "reasoning" | "toolCall" | "providerOptions">,
|
||||||
|
): ConversationStep => ({
|
||||||
|
type: "assistant",
|
||||||
|
text,
|
||||||
|
reasoning: "openai-encrypted",
|
||||||
|
providerOptions: encryptedReasoningOptions,
|
||||||
|
...options,
|
||||||
|
}),
|
||||||
}
|
}
|
||||||
|
|
||||||
export const runGoldenScenario = (id: GoldenScenarioID, context: GoldenScenarioContext) =>
|
const assertAssistantText = (actual: string, expected: AssistantTextExpectation) => {
|
||||||
|
if (typeof expected === "string") {
|
||||||
|
expect(actual.trim()).toBe(expected)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
expect(actual.trim()).toMatch(expected)
|
||||||
|
}
|
||||||
|
|
||||||
|
const assertAssistantToolCall = (response: LLMResponse, expected: NonNullable<AssistantStep["toolCall"]>) => {
|
||||||
|
expect(response.toolCalls).toMatchObject([
|
||||||
|
{ type: "tool-call", id: expect.any(String), name: expected.name, input: expected.input },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
// The generated golden scenarios only model one assistant shape at a time:
|
||||||
|
// encrypted reasoning + text, text, or tool call. Keep mixed interleavings in
|
||||||
|
// focused protocol tests where event order can be asserted directly.
|
||||||
|
const assistantMessageFromResponse = (response: LLMResponse, step: AssistantStep) => {
|
||||||
|
const content: ContentPart[] = []
|
||||||
|
if (step.reasoning === "openai-encrypted") {
|
||||||
|
const reasoning = response.events.find(
|
||||||
|
(event): event is Extract<LLMEvent, { readonly type: "reasoning-end" }> =>
|
||||||
|
LLMEvent.is.reasoningEnd(event) && typeof event.providerMetadata?.openai?.itemId === "string",
|
||||||
|
)
|
||||||
|
if (!reasoning) throw new Error("OpenAI Responses did not return reasoning metadata")
|
||||||
|
expect(reasoning.providerMetadata?.openai?.reasoningEncryptedContent).toEqual(expect.any(String))
|
||||||
|
content.push({ type: "reasoning", text: response.reasoning, providerMetadata: reasoning.providerMetadata })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.text.length > 0) content.push({ type: "text", text: response.text })
|
||||||
|
content.push(...response.toolCalls)
|
||||||
|
return Message.assistant(content)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runGeneratedConversation = (context: GoldenScenarioContext, steps: ReadonlyArray<ConversationStep>) =>
|
||||||
Effect.gen(function* () {
|
Effect.gen(function* () {
|
||||||
if (id === "text") {
|
const messages: Message[] = []
|
||||||
|
let generated = 0
|
||||||
|
for (const step of steps) {
|
||||||
|
if (step.type === "user") {
|
||||||
|
messages.push(Message.user(step.content))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
generated += 1
|
||||||
const response = yield* generate(
|
const response = yield* generate(
|
||||||
textRequest({
|
LLM.request({
|
||||||
id: context.id,
|
id: step.id ? `${context.id}_${step.id}` : `${context.id}_${generated}`,
|
||||||
model: context.model,
|
model: context.model,
|
||||||
prompt: "Reply exactly with: Hello!",
|
system: step.system,
|
||||||
maxTokens: context.maxTokens ?? 40,
|
cache: "none",
|
||||||
temperature: context.temperature,
|
messages,
|
||||||
|
tools: step.tools,
|
||||||
|
toolChoice: step.toolChoice,
|
||||||
|
providerOptions: step.providerOptions,
|
||||||
|
generation: generation(context, step.maxTokens ?? context.maxTokens ?? 80),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
expect(response.text.trim()).toMatch(/^Hello!?$/)
|
if (step.text !== undefined) assertAssistantText(response.text, step.text)
|
||||||
expectFinish(response.events, "stop")
|
if (step.toolCall) assertAssistantToolCall(response, step.toolCall)
|
||||||
return
|
step.assert?.(response)
|
||||||
|
expectFinish(response.events, step.finish ?? "stop")
|
||||||
|
messages.push(assistantMessageFromResponse(response, step))
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
if (id === "tool-call") {
|
const runTextScenario = (context: GoldenScenarioContext) =>
|
||||||
const response = yield* generate(
|
runGeneratedConversation(context, [
|
||||||
weatherToolRequest({
|
user("Reply exactly with: Hello!"),
|
||||||
id: context.id,
|
assistant.expectText(/^Hello!?$/, {
|
||||||
model: context.model,
|
system: "You are concise.",
|
||||||
maxTokens: context.maxTokens ?? 80,
|
maxTokens: context.maxTokens ?? 40,
|
||||||
temperature: context.temperature,
|
providerOptions:
|
||||||
}),
|
context.model.route.id === "gemini" ? { gemini: { thinkingConfig: { thinkingBudget: 0 } } } : undefined,
|
||||||
)
|
}),
|
||||||
expectWeatherToolCall(response)
|
])
|
||||||
expectFinish(response.events, "tool-calls")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (id === "image") {
|
const runToolCallScenario = (context: GoldenScenarioContext) =>
|
||||||
const response = yield* generate(
|
runGeneratedConversation(context, [
|
||||||
imageRequest({
|
user("Call get_weather with city exactly Paris."),
|
||||||
id: context.id,
|
assistant.expectToolCall(
|
||||||
model: context.model,
|
weatherToolName,
|
||||||
image: yield* restroomImage(),
|
{ city: "Paris" },
|
||||||
maxTokens: context.maxTokens ?? 20,
|
{
|
||||||
temperature: context.temperature,
|
system: "Call tools exactly as requested.",
|
||||||
}),
|
tools: [weatherTool],
|
||||||
)
|
toolChoice: ToolChoice.make(weatherTool),
|
||||||
expect(normalizeImageText(response.text)).toBe(RESTROOM_IMAGE_TEXT)
|
maxTokens: context.maxTokens ?? 80,
|
||||||
expectFinish(response.events, "stop")
|
},
|
||||||
return
|
),
|
||||||
}
|
])
|
||||||
|
|
||||||
if (id === "reasoning") {
|
const runImageScenario = (context: GoldenScenarioContext) =>
|
||||||
const response = yield* generate(
|
Effect.gen(function* () {
|
||||||
reasoningRequest({
|
yield* runGeneratedConversation(context, [
|
||||||
id: context.id,
|
user([
|
||||||
model: context.model,
|
{
|
||||||
maxTokens: context.maxTokens ?? 120,
|
type: "text",
|
||||||
temperature: context.temperature,
|
text: "The image contains exactly three lowercase English words. Read them left to right and reply with only those words.",
|
||||||
}),
|
},
|
||||||
)
|
{ type: "media", mediaType: "image/png", data: yield* restroomImage() },
|
||||||
expect(response.text.trim()).toMatch(/^Hello!?$/)
|
]),
|
||||||
expect(response.usage?.reasoningTokens ?? 0).toBeGreaterThan(0)
|
assistant.expectText(/.+/, {
|
||||||
expectFinish(response.events, "stop")
|
system: "Read images carefully. Reply only with the visible text.",
|
||||||
return
|
maxTokens: context.maxTokens ?? 20,
|
||||||
}
|
assert: (response) => expect(normalizeImageText(response.text)).toBe(RESTROOM_IMAGE_TEXT),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
const runReasoningScenario = (context: GoldenScenarioContext) =>
|
||||||
|
runGeneratedConversation(context, [
|
||||||
|
user("Think briefly, then reply exactly with: Hello!"),
|
||||||
|
assistant.expectText(/^Hello!?$/, {
|
||||||
|
system: "Show concise reasoning when the provider supports visible reasoning summaries.",
|
||||||
|
providerOptions: { openai: { reasoningEffort: "low", reasoningSummary: "auto" } },
|
||||||
|
maxTokens: context.maxTokens ?? 120,
|
||||||
|
assert: (response) => expect(response.usage?.reasoningTokens ?? 0).toBeGreaterThan(0),
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
|
||||||
|
const runReasoningContinuationScenario = (context: GoldenScenarioContext) =>
|
||||||
|
runGeneratedConversation(context, [
|
||||||
|
user("Think briefly, then reply exactly with: Hello!"),
|
||||||
|
assistant.expectEncryptedReasoningText(/^Hello!?$/, {
|
||||||
|
id: "first",
|
||||||
|
system: "Show concise reasoning when the provider supports visible reasoning summaries.",
|
||||||
|
maxTokens: context.maxTokens ?? 120,
|
||||||
|
}),
|
||||||
|
user("Now reply exactly with: Done."),
|
||||||
|
assistant.expectText(/^Done\.?$/, { id: "second", maxTokens: 40, providerOptions: encryptedReasoningOptions }),
|
||||||
|
])
|
||||||
|
|
||||||
|
const runToolLoopScenario = (context: GoldenScenarioContext) =>
|
||||||
|
Effect.gen(function* () {
|
||||||
expectGoldenWeatherToolLoop(
|
expectGoldenWeatherToolLoop(
|
||||||
yield* runWeatherToolLoop(
|
yield* runWeatherToolLoop(
|
||||||
goldenWeatherToolLoopRequest({
|
goldenWeatherToolLoopRequest({
|
||||||
|
|
@ -311,6 +354,25 @@ export const runGoldenScenario = (id: GoldenScenarioID, context: GoldenScenarioC
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const goldenScenarios = {
|
||||||
|
text: { title: "streams text", tags: ["text", "golden"], run: runTextScenario },
|
||||||
|
"tool-call": { title: "streams tool call", tags: ["tool", "tool-call", "golden"], run: runToolCallScenario },
|
||||||
|
"tool-loop": { title: "drives a tool loop", tags: ["tool", "tool-loop", "golden"], run: runToolLoopScenario },
|
||||||
|
image: { title: "reads image text", tags: ["media", "image", "vision", "golden"], run: runImageScenario },
|
||||||
|
reasoning: { title: "uses reasoning", tags: ["reasoning", "golden"], run: runReasoningScenario },
|
||||||
|
"reasoning-continuation": {
|
||||||
|
title: "continues encrypted reasoning",
|
||||||
|
tags: ["reasoning", "continuation", "encrypted-reasoning", "golden"],
|
||||||
|
run: runReasoningContinuationScenario,
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type GoldenScenarioID = keyof typeof goldenScenarios
|
||||||
|
export const goldenScenarioTitle = (id: GoldenScenarioID) => goldenScenarios[id].title
|
||||||
|
export const goldenScenarioTags = (id: GoldenScenarioID) => [...goldenScenarios[id].tags]
|
||||||
|
export const runGoldenScenario = (id: GoldenScenarioID, context: GoldenScenarioContext) =>
|
||||||
|
goldenScenarios[id].run(context)
|
||||||
|
|
||||||
const usageSummary = (usage: LLMResponse["usage"] | undefined) => {
|
const usageSummary = (usage: LLMResponse["usage"] | undefined) => {
|
||||||
if (!usage) return undefined
|
if (!usage) return undefined
|
||||||
return Object.fromEntries(
|
return Object.fromEntries(
|
||||||
|
|
|
||||||
|
|
@ -42,10 +42,15 @@ const providerMetadata = (value: unknown): ProviderMetadata | undefined => {
|
||||||
return Object.keys(result).length === 0 ? undefined : result
|
return Object.keys(result).length === 0 ? undefined : result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stored AI SDK parts historically kept provider-owned continuation metadata in
|
||||||
|
// `providerOptions`; native parts now use `providerMetadata` directly.
|
||||||
|
const partProviderMetadata = (part: Record<string, unknown>) =>
|
||||||
|
providerMetadata(part.providerMetadata) ?? providerMetadata(part.providerOptions)
|
||||||
|
|
||||||
const textPart = (part: Record<string, unknown>) => ({
|
const textPart = (part: Record<string, unknown>) => ({
|
||||||
type: "text" as const,
|
type: "text" as const,
|
||||||
text: typeof part.text === "string" ? part.text : "",
|
text: typeof part.text === "string" ? part.text : "",
|
||||||
providerMetadata: providerMetadata(part.providerOptions),
|
providerMetadata: partProviderMetadata(part),
|
||||||
})
|
})
|
||||||
|
|
||||||
const mediaPart = (part: Record<string, unknown>) => {
|
const mediaPart = (part: Record<string, unknown>) => {
|
||||||
|
|
@ -68,7 +73,7 @@ const toolResult = (part: Record<string, unknown>) => {
|
||||||
result: "value" in output ? output.value : output,
|
result: "value" in output ? output.value : output,
|
||||||
resultType: type,
|
resultType: type,
|
||||||
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
|
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
|
||||||
providerMetadata: providerMetadata(part.providerOptions),
|
providerMetadata: partProviderMetadata(part),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -80,7 +85,7 @@ const contentPart = (part: unknown) => {
|
||||||
return {
|
return {
|
||||||
type: "reasoning" as const,
|
type: "reasoning" as const,
|
||||||
text: typeof part.text === "string" ? part.text : "",
|
text: typeof part.text === "string" ? part.text : "",
|
||||||
providerMetadata: providerMetadata(part.providerOptions),
|
providerMetadata: partProviderMetadata(part),
|
||||||
}
|
}
|
||||||
if (part.type === "tool-call")
|
if (part.type === "tool-call")
|
||||||
return ToolCallPart.make({
|
return ToolCallPart.make({
|
||||||
|
|
@ -88,7 +93,7 @@ const contentPart = (part: unknown) => {
|
||||||
name: typeof part.toolName === "string" ? part.toolName : "",
|
name: typeof part.toolName === "string" ? part.toolName : "",
|
||||||
input: part.input,
|
input: part.input,
|
||||||
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
|
providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined,
|
||||||
providerMetadata: providerMetadata(part.providerOptions),
|
providerMetadata: partProviderMetadata(part),
|
||||||
})
|
})
|
||||||
if (part.type === "tool-result") return toolResult(part)
|
if (part.type === "tool-result") return toolResult(part)
|
||||||
throw new Error(`Native LLM request adapter does not support ${String(part.type)} content parts`)
|
throw new Error(`Native LLM request adapter does not support ${String(part.type)} content parts`)
|
||||||
|
|
|
||||||
|
|
@ -219,6 +219,42 @@ describe("session.llm-native.request", () => {
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test("maps stored provider metadata to native content metadata", () => {
|
||||||
|
const reasoning = Object.assign(
|
||||||
|
{ type: "reasoning" as const, text: "thinking" },
|
||||||
|
{
|
||||||
|
providerMetadata: {
|
||||||
|
openai: {
|
||||||
|
itemId: "rs_1",
|
||||||
|
reasoningEncryptedContent: "encrypted-state",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const request = LLMNative.request({
|
||||||
|
model: baseModel,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [reasoning],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(request.messages).toMatchObject([
|
||||||
|
{
|
||||||
|
role: "assistant",
|
||||||
|
content: [
|
||||||
|
{
|
||||||
|
type: "reasoning",
|
||||||
|
text: "thinking",
|
||||||
|
providerMetadata: { openai: { itemId: "rs_1", reasoningEncryptedContent: "encrypted-state" } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
test("selects native request routes for provider packages", () => {
|
test("selects native request routes for provider packages", () => {
|
||||||
const openai = LLMNative.model({
|
const openai = LLMNative.model({
|
||||||
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },
|
model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue