diff --git a/packages/llm/src/protocols/anthropic-messages.ts b/packages/llm/src/protocols/anthropic-messages.ts index 53c6886e5d..e24758e89c 100644 --- a/packages/llm/src/protocols/anthropic-messages.ts +++ b/packages/llm/src/protocols/anthropic-messages.ts @@ -10,6 +10,7 @@ import { type CacheHint, type FinishReason, type LLMRequest, + type MediaPart, type ProviderMetadata, type ToolCallPart, type ToolDefinition, @@ -39,6 +40,17 @@ const AnthropicTextBlock = Schema.Struct({ }) type AnthropicTextBlock = Schema.Schema.Type +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 + const AnthropicThinkingBlock = Schema.Struct({ type: Schema.tag("thinking"), thinking: Schema.String, @@ -92,7 +104,8 @@ const AnthropicToolResultBlock = Schema.Struct({ cache_control: Schema.optional(AnthropicCacheControl), }) -const AnthropicUserBlock = Schema.Union([AnthropicTextBlock, AnthropicToolResultBlock]) +const AnthropicUserBlock = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicToolResultBlock]) +type AnthropicUserBlock = Schema.Schema.Type const AnthropicAssistantBlock = Schema.Union([ AnthropicTextBlock, 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 }) +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* ( request: LLMRequest, breakpoints: Cache.Breakpoints, @@ -280,11 +306,17 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* ( for (const message of request.messages) { if (message.role === "user") { - const content: AnthropicTextBlock[] = [] + const content: AnthropicUserBlock[] = [] for (const part of message.content) { - if (!ProviderShared.supportsContent(part, ["text"])) - return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text"]) - content.push({ type: "text", text: part.text, cache_control: cacheControl(breakpoints, part.cache) }) + if (part.type === "text") { + 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 }) continue diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index e92ad0bd80..e2eac85ec2 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -6,11 +6,11 @@ import { HttpTransport, WebSocketTransport } from "../route/transport" import { Protocol } from "../route/protocol" import { LLMEvent, - type MediaPart, Usage, type FinishReason, type LLMRequest, type ProviderMetadata, + type ReasoningPart, type TextPart, type ToolCallPart, type ToolDefinition, @@ -43,10 +43,23 @@ const OpenAIResponsesOutputText = Schema.Struct({ 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([ Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }), Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }), + OpenAIResponsesReasoningItem, Schema.Struct({ type: Schema.tag("function_call"), call_id: Schema.String, @@ -149,6 +162,7 @@ const OpenAIResponsesStreamItem = Schema.Struct({ server_label: Schema.optional(Schema.String), output: Schema.optional(Schema.Unknown), error: Schema.optional(Schema.Unknown), + encrypted_content: optionalNull(Schema.String), }) type OpenAIResponsesStreamItem = Schema.Schema.Type @@ -206,17 +220,31 @@ const lowerToolCall = (part: ToolCallPart): OpenAIResponsesInputItem => ({ arguments: ProviderShared.encodeJson(part.input), }) -const imageUrl = (part: MediaPart) => - typeof part.data === "string" && part.data.startsWith("data:") - ? part.data - : `data:${part.mediaType};base64,${ProviderShared.mediaBytes(part)}` +const lowerReasoning = (part: ReasoningPart, store: boolean | undefined): OpenAIResponsesInputItem | undefined => { + const openai = part.providerMetadata?.openai + if (!ProviderShared.isRecord(openai) || typeof openai.itemId !== "string") return undefined + // 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* ( part: LLMRequest["messages"][number]["content"][number], ) { if (part.type === "text") return { type: "input_text" as const, text: part.text } 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") return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"]) @@ -226,6 +254,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ const system: OpenAIResponsesInputItem[] = request.system.length === 0 ? [] : [{ role: "system", content: ProviderShared.joinText(request.system) }] const input: OpenAIResponsesInputItem[] = [...system] + const store = OpenAIOptions.store(request) for (const message of request.messages) { if (message.role === "user") { @@ -235,20 +264,34 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ if (message.role === "assistant") { 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) { - if (!ProviderShared.supportsContent(part, ["text", "tool-call"])) - return yield* ProviderShared.unsupportedContent("OpenAI Responses", "assistant", ["text", "tool-call"]) if (part.type === "text") { content.push(part) continue } + if (part.type === "reasoning") { + flushText() + const reasoning = lowerReasoning(part, store) + if (reasoning) input.push(reasoning) + continue + } if (part.type === "tool-call") { + flushText() input.push(lowerToolCall(part)) continue } + return yield* ProviderShared.unsupportedContent("OpenAI Responses", "assistant", [ + "text", + "reasoning", + "tool-call", + ]) } - if (content.length > 0) - input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) }) + flushText() continue } @@ -367,6 +410,11 @@ const isHostedToolItem = ( ): item is OpenAIResponsesStreamItem & { type: HostedToolType; id: string } => 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 // outputs / sources / status without re-decoding. const hostedToolResult = (item: OpenAIResponsesStreamItem) => { @@ -428,16 +476,12 @@ const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): Step ] } -const onReasoningDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { - const events: LLMEvent[] = [] - return [ - { - ...state, - lifecycle: Lifecycle.reasoningEnd(state.lifecycle, events, event.item_id ?? "reasoning-0"), - }, - events, - ] -} +// The summary done event does not carry encrypted continuation state. Finish the +// common reasoning block when the full reasoning item arrives in output_item.done. +const onReasoningDone = (state: ParserState, _event: OpenAIResponsesEvent): StepResult => [state, NO_EVENTS] + +const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) => + openaiMetadata({ itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null }) const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { const item = event.item @@ -518,6 +562,21 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* 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 }) diff --git a/packages/llm/src/protocols/shared.ts b/packages/llm/src/protocols/shared.ts index a5d5e04df7..aa37c62e43 100644 --- a/packages/llm/src/protocols/shared.ts +++ b/packages/llm/src/protocols/shared.ts @@ -80,7 +80,7 @@ export const subtractTokens = (total: number | undefined, subtrahend: number | u */ export const sumTokens = (...values: ReadonlyArray): number | undefined => { if (values.every((value) => value === undefined)) return undefined - return values.reduce((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) => @@ -122,6 +122,16 @@ export const parseToolInput = (route: string, name: string, raw: string) => export const mediaBytes = (part: MediaPart) => 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 toolResultText = (part: ToolResultPart) => { diff --git a/packages/llm/test/continuation-scenarios.ts b/packages/llm/test/continuation-scenarios.ts new file mode 100644 index 0000000000..93677bd71b --- /dev/null +++ b/packages/llm/test/continuation-scenarios.ts @@ -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 + +export const nativeAnthropicMessagesContinuation = [ + ...basicContinuation, + ...toolContinuation, + "assistant-reasoning", + ...mediaContinuation, +] as const satisfies ReadonlyArray + +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 + 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 }, + }) +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json new file mode 100644 index 0000000000..f2f2ace01d --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json @@ -0,0 +1,58 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses/openai-responses-gpt-5-5-reasoning-continuation", + "recordedAt": "2026-05-21T15:44:44.148Z", + "provider": "openai", + "route": "openai-responses", + "transport": "http", + "model": "gpt-5.5", + "tags": [ + "prefix:openai-responses", + "provider:openai", + "flagship", + "reasoning", + "continuation", + "encrypted-reasoning", + "golden" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"system\",\"content\":\"Show concise reasoning when the provider supports visible reasoning summaries.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":120,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_074702e1ce54271e016a0f2867ca3881a2aafce9c77ab21317\",\"object\":\"response\",\"created_at\":1779378279,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_074702e1ce54271e016a0f2867ca3881a2aafce9c77ab21317\",\"object\":\"response\",\"created_at\":1779378279,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_074702e1ce54271e016a0f2868d12c81a29a2e54c6ef9e6378\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqDyhonX_F5cxG-2PVDX1Xw6zpBKHmGD5J3tCshwxmVUTCLHLw3muwXbefIYTPIxsKAAEnDKh6yRHVsYKAfrTUogES5Qma1iR5sK8_3azy06IYWLzJUlAjJI3VSwL8VQL-QAjkJT9G3ALi6J1v1TgCfex_hUTJSCPZ8nXyzJb4KvI5LsmGilAKgVZmNHqUgEOn4BCZRZIA71qpbKhnMV6v_oS8PSxSN46RNPs3Wgp5WQBVk3XPQ2PFqvs8ntR8vvyfizGizcHbLC_diWLb9qx-GHD6ysu-4IO4dVmXsmd4t0u_HPS-a1mUli09fsYwQ75XsQOPhdfFp2wnRMeBx00p5iOZaTDRy_g8AQ5F9mGl3NYe48HG78SYVSFYYGvpRoJ2ob-3i9zdSO6zsC9uRkYGAvSPp31xfLiEIxw-Czwms26EBpSHzuO-91L4e25Yr-_PEdkDbQYZi2QdjnQMtTj688xVQcNar5rGrMKdv-yYCcb3qvkhQLkUGTaqvCfxh1aLzkmH0uCEEshpfbTD5mr_l1sDKn4kh4JWVEr_kS93kJUVqkdhgw8UkIJ_ZOQ8uNzBWU-SjkInsS3doRY42SlsnEt_g-h0TwJeVx1DQy3c1dHiHzDKI2zp_9_pSjUhrB4s5qdneU1rUQIoO8zUxCVwul8Sujd6MLkeiOScA6VfTZIOtV80ixVJ3_TO-DcaQLePCAAxNUEccvrEiwMZgC6iZnxr4UBbQnOEitwfE5yidKFmKzCNFjxfC0bLxeVonZ2nqMIBR9PXCo-Fm89HhKQUIVXhRaukCxGozYaADMxdsfsC8dVyYgNph5npUBJ6j1eAbThzmRxj3MHNP5TSSth8qGbPbTFwn3zTMgU_mwZiJISee7e0GRD6OqZOpL4lJ8O7W1GUxdszZM545dJQu2o_yD-ZwQ==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_074702e1ce54271e016a0f2868d12c81a29a2e54c6ef9e6378\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqDyhpNt-fJi04K-Nx4EFaFnHjFXBx5J64JL0smJDJQm0Ruuyg1oihcOlT9unQclJXlLyxHVV_9bRb1iXwz26IpmsiDPeA-y9NCEQ9KV4lyfAsmwCrw5De9D-sGWYAtqnlTpsQdRjLPBECcDjEDB8dn1KPY12EJ9WnA9ocDURv-XTGVPp9JhKkSrz0FZsU0toBo_GK6c_SwyCAPorcvzjHAojAibi71LAq2ZWFYFXMmafvRCS9mGZF2PD0WwkQBPCc4Q-_3ZVx0LbBU6ceAZmL-DpRNFnSY0YdGQ-RpJm_nNY-ojZg6Or67nnuCY7N9BMCyuZ81ytcfuym2vKeux8VTYPyQ5a8km4xZoTaoNRY0Y3IJvsqCRD9mxyGx1rpIGlguyVeEG7_PyY4Wr0HKV_jSBUAjqIuJi809wFNIfS8e6beaqqV5CwjdfYp8_uncWwF98fUsMXaLdvKKj-RshZxSSIj1JMKJm_-BbJHfa_TNe9vcq51Qu6xY6Fwc1vB4hwXJ7k0VGkiqAlMIRbJ0erRTJv7LeiMFoT_KRVMF6lC9DOdn0LmVWenrqFOPu2ZKVr1WdOU9ow9r6bAztojXN0iGveCTpu5Vabs_G5fJE38nS868IOH9VqX0vuDzNSJUKErGmOp4EGM6M1C8gAtDAP9c9gU60khFJbYDRxnO24tDLtrZ71YCCYSmKLL6BGniNo5TBZZJL6blPiTsEYJpy_yB5hjXIkVF1EmTWrXhTw8RjI0QcKIhDPNSb3tpTLcGr7G3WnRqQc-91M9BQCmBD2LczfhkESGKpuciWG7tyR1XPEF4B9QitL0x3uxNob5u3j2Rwr1dlZ0JFvZ7Ezo2J_zOsaxtN5BfZjwJuelihxneoPHRWq2M61Domhe3MOc5lYL3_a0EluLRnK0smX0UcC0dfG8gy6cL_jJfs-mkpZmn-bcUsbGTRDTVQ_tPc1BODz3LxKP1rOZuL_JvBPoTvm-NFHluA==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_074702e1ce54271e016a0f2869830481a2983268f801d1ee8c\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_074702e1ce54271e016a0f2869830481a2983268f801d1ee8c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hello\",\"item_id\":\"msg_074702e1ce54271e016a0f2869830481a2983268f801d1ee8c\",\"logprobs\":[],\"obfuscation\":\"BnS137oNVXg\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"!\",\"item_id\":\"msg_074702e1ce54271e016a0f2869830481a2983268f801d1ee8c\",\"logprobs\":[],\"obfuscation\":\"RhAyjCQ9wUStcwz\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_074702e1ce54271e016a0f2869830481a2983268f801d1ee8c\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Hello!\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_074702e1ce54271e016a0f2869830481a2983268f801d1ee8c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_074702e1ce54271e016a0f2869830481a2983268f801d1ee8c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_074702e1ce54271e016a0f2867ca3881a2aafce9c77ab21317\",\"object\":\"response\",\"created_at\":1779378279,\"status\":\"completed\",\"background\":false,\"completed_at\":1779378281,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_074702e1ce54271e016a0f2868d12c81a29a2e54c6ef9e6378\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqDyhprORGpIaaHUWq-Sk1sPvEW3zXpkk5q0tKIwclTPkbOyg0Ef1A03skcjgIxNV18Qm9QiKZynle8lTgc3Ib_DmNQAB10g3lXDcxtkyLuikKM3qbn8i-pwGqRctZINaH2hG8asgvq0ARMD87vLgF36To5DK2tc5j16TQb7HZYMOGdPrvXXswalEaD1EB73qN8Oao8jpPH1jsbcJJk6rKU7RsAjyRC3nHs36HH1JWAKG04meDezHYPfag9PAM55VPA6PfMs_zVMD2k-gv0eBNR17Xk_kY-Mbng9dITuxYTtOELYJ3mCkZmHKzZXzd9VtnfftgM4y5YOcOkBEeJQ5l6YNyOi2Zml3KUT9fZfnJbZWq8HSvCf20T7GpuLyQ_0XHgjR4r4dFPii6XKVgzXP4C144OWC4kPStOhmzAoaM9HaDPOY0rS77Om_C4nzqzhHl1U79wCMxEEkHj7q90opjjfUgS-u74skEa8cv2m5LRSIOJtXTOY0kowWlw5N3OtoER3l2c24OantC6MyQBCkl0FxqL635DiMmBBuTkWM-pQfGwlbbwlqJktMsAByoYihO7Qreialx-22D2QJsxBS4fYuEt1yQUVY8PwmH3Sf7JqV08VVROHMAZNKknd098UCGjP2ciAxda7bMQ-eF_hC-7tK0YVO2xdupT-yW2dRrbGOAx8ZVfoAICbIB1bP7VMu5-KIiQNsjVw7vK502uHxuh0HOhGL0Cq0-V67luspb5QlRoY8ZfFhmLhM20qm8BZpdRFrUoI3OvGgoKPzrg2MMxY7g-rMQQmCWuZ6QKL9aI8GpwHOphM3ttFJbQFu4DhqULW29Q9oDmssMuuTbUhkvtfUztkibyYphFA2l9Q7neSYBQtL6xM921uwgrOAodsZWrakLMZNgFzRoFH_T0KYMcbw5E9dFgYdCn0Bspy28RIJUy4XmXWYTtJ0yKaLuLo0s73ZPWbuELy7ifE8mEVKwGTFT3Q==\",\"summary\":[]},{\"id\":\"msg_074702e1ce54271e016a0f2869830481a2983268f801d1ee8c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":31,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":21,\"output_tokens_details\":{\"reasoning_tokens\":13},\"total_tokens\":52},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"id\":\"rs_074702e1ce54271e016a0f2868d12c81a29a2e54c6ef9e6378\",\"summary\":[],\"encrypted_content\":\"gAAAAABqDyhpNt-fJi04K-Nx4EFaFnHjFXBx5J64JL0smJDJQm0Ruuyg1oihcOlT9unQclJXlLyxHVV_9bRb1iXwz26IpmsiDPeA-y9NCEQ9KV4lyfAsmwCrw5De9D-sGWYAtqnlTpsQdRjLPBECcDjEDB8dn1KPY12EJ9WnA9ocDURv-XTGVPp9JhKkSrz0FZsU0toBo_GK6c_SwyCAPorcvzjHAojAibi71LAq2ZWFYFXMmafvRCS9mGZF2PD0WwkQBPCc4Q-_3ZVx0LbBU6ceAZmL-DpRNFnSY0YdGQ-RpJm_nNY-ojZg6Or67nnuCY7N9BMCyuZ81ytcfuym2vKeux8VTYPyQ5a8km4xZoTaoNRY0Y3IJvsqCRD9mxyGx1rpIGlguyVeEG7_PyY4Wr0HKV_jSBUAjqIuJi809wFNIfS8e6beaqqV5CwjdfYp8_uncWwF98fUsMXaLdvKKj-RshZxSSIj1JMKJm_-BbJHfa_TNe9vcq51Qu6xY6Fwc1vB4hwXJ7k0VGkiqAlMIRbJ0erRTJv7LeiMFoT_KRVMF6lC9DOdn0LmVWenrqFOPu2ZKVr1WdOU9ow9r6bAztojXN0iGveCTpu5Vabs_G5fJE38nS868IOH9VqX0vuDzNSJUKErGmOp4EGM6M1C8gAtDAP9c9gU60khFJbYDRxnO24tDLtrZ71YCCYSmKLL6BGniNo5TBZZJL6blPiTsEYJpy_yB5hjXIkVF1EmTWrXhTw8RjI0QcKIhDPNSb3tpTLcGr7G3WnRqQc-91M9BQCmBD2LczfhkESGKpuciWG7tyR1XPEF4B9QitL0x3uxNob5u3j2Rwr1dlZ0JFvZ7Ezo2J_zOsaxtN5BfZjwJuelihxneoPHRWq2M61Domhe3MOc5lYL3_a0EluLRnK0smX0UcC0dfG8gy6cL_jJfs-mkpZmn-bcUsbGTRDTVQ_tPc1BODz3LxKP1rOZuL_JvBPoTvm-NFHluA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_074702e1ce54271e016a0f286a489081a2b0380518cc94ee61\",\"object\":\"response\",\"created_at\":1779378282,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_074702e1ce54271e016a0f286a489081a2b0380518cc94ee61\",\"object\":\"response\",\"created_at\":1779378282,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_074702e1ce54271e016a0f286b72fc81a2afab49f726aa46a1\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqDyhrbGxCpVJ1BUUZF9hT17B8nh20OfRV2njUYnn2rHuNNnMj_VzITpd1H65KoM24ZiZ4OGf0o0PTezI-wSgBfx_fCA_aSOzjBuHngYPaFNE3tUM3aeEItcK-sml8fpo6nOJH--0AJcPhnLAkIUCEngSvefGE-Icvrdf1VTBaol_C5s_g5n6gTZQmAWof9mJRbztj-2utqJxhYI1AuzD11FHZ-o74PpvXcp_n8A1mfs8ea6d_bpwZ6B-oEvzn5agsM8xoLPkD_rdetVeyxRO2pQnH6GpdkgEzVA5co0QQdLmhtmiQ53ieHoz-cGaxtGBZlv8e5UVcG4eRoxCfT5Djd6dwCaLTGeVxryqAHko3TwXoiSYMJh1djmdkQsF0wAYvlJ5lajtJdbhfoytGf1TWUtwaet7Y04APsCnBhEkY9_bArfhV6cwEo_Aa9zgsGoT6Sdj7iNQnEGmJDVXmG8dYqVdvhhOfIhLP9BByXXxBbfYLSvMyvb5G9RmHI68xnP0ceYwu8x2j12kamPVMKDJVnINmpETLTmZUD7Iagsx-c88xSbtg9ZuS52gjocRy-WOelZ5CrT7huJQswqlMghwedMHBNoMh7xS6t1diHTw3r5Vf-K5h1C5WOsMacZ4WjMc2ylR9aCCp3UVl-GCAICzlsz44ahyFpipL0c7rGwkLG3zoiDRPCVIuXsYxJem053EB6WuYi0CqptnT4piiAUK12Jq0Vth5m25fGz5f_E7qlJhp98WGjaB683nxakLZNPaE6T-sq39QaSciJ_EQztU6AhJ2eqjL07tOrtJ-dgp5N5c0dlSi2XbA7wEm2AWxFR_zT8FeZCpWsGyM24tSAxvdAyShKNiM-d8PZwemNayHY3X29vf27VX3E95VtPt03Uy8D7u3giZu6iGJIMy4Zga5NZqh2Q==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_074702e1ce54271e016a0f286b72fc81a2afab49f726aa46a1\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqDyhrrrpzGPdm-CBVv7P321p9aOAXnAsONy_JoAKXrfCzGzvC6r0gHUlaLtjcGSPtzCLDvzLxX73y_KHw6Rvb-4cAay3vaicFR7UDmgouTSuzmIpUf-o431N1Pu9k9w49OdwKLHPxZdlVYi9aaVWLMs9SQyinOZmhffmAmA_OoQAlVaTqgcZrW1jKkql4lcywrOJpItyu3_7czpDOszAuc7Zn1BNufkUq8K5UrwizWTYIB9kKpwrKMeSQSM5qX-z6pM9sHKDG4EsLBXom-ESPOpHo2yw4xO9E7-9VSaELYjm2tq-BEmT6SnamUZrDWQESFOnzxNDFnpXZgBTZOgWZjrMEPlJv0Mos-6CLB_odMLaLR6I4o_6ovFnrBUJmSc4VYD4uF2M4bn6XhoC4t3cB1jm3MuPwtLRTs-KPpBAVXYL9NSJDvy-2tJSqJwADXCdMDPTDZQLUJ9F_htrADQtKlHBnLo9RQkwjvV_tRmi7z1En_fEZtiugWKn3jjonQDgsrDpreBF7Wge4RKX0tLAYGxfTqqkM9f2l8YuedCb5DBo4-RJR8MM6ziCnKFWYfZWl06yXrCs4ZdFF0vo73CHNvAT-8xnewcZD_acePekly0GquMEPPnkMqNoVo_72rKY9A4en9s6iZ1iaqXOhvtt8K8oECkWY2g358P742FI--gOaUPYfSh1T5bOm-4pcAl0Ystzxmu4xraWDLyOs6idtw6Bo_tp1KFQH88RhCsbYb_GtC8Ofn1CzCxDB6XHw2FkJNwLpGxo-ctZLqqKghkhy9R76QKxlA8QoW_cQ2-ZQ_X0z4m3E5R61plzkMmBHmp2MJ58C0G2wmOaPQokBYurZeptMJPHCMndGCdZI5QCfgkP9QZmjdHNoNDaBkgyAlyB4Eg4ym7HtK9KaZg-nygYZiJfnmnQCzOc0TE8cvUvji2xKHDu5fHx0F4mO9cViU7v94RBvTHcEDgdb8__aAfcF-mhvQg==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_074702e1ce54271e016a0f286bd96481a2a778def95bcafbb8\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_074702e1ce54271e016a0f286bd96481a2a778def95bcafbb8\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Done\",\"item_id\":\"msg_074702e1ce54271e016a0f286bd96481a2a778def95bcafbb8\",\"logprobs\":[],\"obfuscation\":\"IsAVLUUil7jS\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_074702e1ce54271e016a0f286bd96481a2a778def95bcafbb8\",\"logprobs\":[],\"obfuscation\":\"IT5ulfUNH3uOm2t\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_074702e1ce54271e016a0f286bd96481a2a778def95bcafbb8\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Done.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_074702e1ce54271e016a0f286bd96481a2a778def95bcafbb8\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_074702e1ce54271e016a0f286bd96481a2a778def95bcafbb8\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_074702e1ce54271e016a0f286a489081a2b0380518cc94ee61\",\"object\":\"response\",\"created_at\":1779378282,\"status\":\"completed\",\"background\":false,\"completed_at\":1779378283,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_074702e1ce54271e016a0f286b72fc81a2afab49f726aa46a1\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqDyhrNP-3ayBM6JnfBvNL9VA4UViQ_PEk5V7OoQXg_gQtoH257m2m_ezKiov86gyX52tIBW2UkpkMt6-vQF6W4r9a-YkxfIZ9lX4gQihE1fdrkNLGHX6D2lloHFnJ__95zj3bF1rXX_XiGb5ysWyMtj0KzWuxepUIgtFJPTL_5K5fBgyZf9pPJKfFarOaaMBPlv0A9sI0zaC08zQ03vHITevCtUj5zY1qMYP8Fr5O_GsDzp3yc2ZiSXzlPm5WUY4DxF6lgH-dw6F-366o0e7dNFoIOx-VxVjDAE2t5krH4iLg-k5bZ57BP50QR8ASVG2IhzsTOxKwl2f8iVuKx-VGwn0GpLltgl5D0fCKPmOzbWHvg44GnpklfvjXhRaVAArFcLoHiosjHuOeDATmRAwJdyMc3sKysGtJFJDkv_-ZkFtPDTmhR_xohENwdHcNJscB0eYsg7Q5gcaRorzruQDp2y4i0LkmGaV4mYXElqe4wlqWfv-yPTXPt9GFe9XwPv3oHRsc53p4CcFKcXovW-cAIBwngNZPEr5Q0hIKo995pz-A3Ya8Qz61hxDBBacZdrDbR8xroHPNd8MWqq5_UQC2puSBGOKpPrxqXfyNS6E6p3TrpLFq2kah5JCC8TahnMDpvcaoYXhkctoIwVOfNsgq7ewYSEGTmapuW9FGWqusQOt44MhvPczSYUncfeZK2b-B6MurN_lsfzylKG0evE4lHNfEjs1dpbWdYBpCS5SpPSheT8ns6tGrFDzg2MmnZAWAq3NSf7AXDb_z6znszSreqcLZ1ywU0qspzDcDCGPPmzEmEehm7YZ4D0dNuKtfhGJhYpmZ06va5xzdnSoYdHkfShg1sbSd-26EqeiAvv-xAjsNwqc-uAf9P59D7M7v441oXujYE7N7CP60XxMqAhNz78TV0dmqBzQnN9wf1WFMjJe1MOB-75L5oJRLxJzkRRbcGbbS_xKJ95RSOxNFPzF4_Ozh4w==\",\"summary\":[]},{\"id\":\"msg_074702e1ce54271e016a0f286bd96481a2a778def95bcafbb8\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":35,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":17,\"output_tokens_details\":{\"reasoning_tokens\":9},\"total_tokens\":52},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" + } + } + ] +} diff --git a/packages/llm/test/provider/anthropic-messages.test.ts b/packages/llm/test/provider/anthropic-messages.test.ts index e9b03c621f..681e3414f6 100644 --- a/packages/llm/test/provider/anthropic-messages.test.ts +++ b/packages/llm/test/provider/anthropic-messages.test.ts @@ -1,10 +1,12 @@ import { describe, expect } from "bun:test" import { Effect } from "effect" +import { HttpClientRequest } from "effect/unstable/http" import { CacheHint, LLM, LLMError, Message, ToolCallPart, Usage } from "../../src" import { Auth, LLMClient } from "../../src/route" import * as AnthropicMessages from "../../src/protocols/anthropic-messages" +import { continuationRequest, nativeAnthropicMessagesContinuation } from "../continuation-scenarios" import { it } from "../lib/effect" -import { fixedResponse } from "../lib/http" +import { dynamicResponse, fixedResponse } from "../lib/http" import { sseEvents } from "../lib/sse" const model = AnthropicMessages.route @@ -40,7 +42,7 @@ describe("Anthropic Messages route", () => { it.effect("prepares tool call and tool result messages", () => Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( + const prepared = yield* LLMClient.prepare( LLM.request({ id: "req_tool_result", 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( + 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", () => Effect.gen(function* () { 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* () { - const error = yield* LLMClient.prepare( + const response = yield* LLMClient.generate( LLM.request({ id: "req_media", 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.") }), ) diff --git a/packages/llm/test/provider/golden.recorded.test.ts b/packages/llm/test/provider/golden.recorded.test.ts index d000943f02..331a393fe2 100644 --- a/packages/llm/test/provider/golden.recorded.test.ts +++ b/packages/llm/test/provider/golden.recorded.test.ts @@ -84,6 +84,7 @@ describeRecordedGoldenScenarios([ scenarios: [ { id: "text", temperature: false }, { id: "reasoning", temperature: false }, + { id: "reasoning-continuation", temperature: false }, { id: "tool-call", temperature: false }, { id: "tool-loop", temperature: false }, ], diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index 1b7ae038c6..845a634128 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -7,6 +7,7 @@ import * as Azure from "../../src/providers/azure" import * as OpenAI from "../../src/providers/openai" import * as OpenAIResponses from "../../src/protocols/openai-responses" import * as ProviderShared from "../../src/protocols/shared" +import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios" import { it } from "../lib/effect" import { dynamicResponse, fixedResponse } from "../lib/http" 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( + 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", () => Effect.gen(function* () { const prepared = yield* LLMClient.prepare( @@ -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( + 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", () => Effect.gen(function* () { const body = sseEvents( diff --git a/packages/llm/test/recorded-golden.ts b/packages/llm/test/recorded-golden.ts index eb12613674..76568ca671 100644 --- a/packages/llm/test/recorded-golden.ts +++ b/packages/llm/test/recorded-golden.ts @@ -2,7 +2,7 @@ import type { HttpRecorder } from "@opencode-ai/http-recorder" import { describe } from "bun:test" import { Effect } from "effect" 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 { kebab } from "./recorded-utils" @@ -35,14 +35,6 @@ type TargetInput = { 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) => { if (target.prefix) return target.prefix const transport = target.transport === "websocket" ? "-websocket" : "" @@ -77,7 +69,7 @@ const runTarget = (target: TargetInput) => { describe(`${target.name} recorded`, () => { target.scenarios.forEach((raw) => { const input = scenarioInput(raw) - const name = input.name ?? scenarioTitle(input.id) + const name = input.name ?? goldenScenarioTitle(input.id) recorded.effect.with( name, { diff --git a/packages/llm/test/recorded-scenarios.ts b/packages/llm/test/recorded-scenarios.ts index b3db266647..042ddec3c3 100644 --- a/packages/llm/test/recorded-scenarios.ts +++ b/packages/llm/test/recorded-scenarios.ts @@ -1,6 +1,17 @@ import { expect } from "bun:test" 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 { 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: { readonly id: string readonly model: Model @@ -116,52 +86,6 @@ const restroomImage = () => 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) => LLMClient.stream({ request, @@ -212,8 +136,6 @@ export const expectGoldenWeatherToolLoop = (events: ReadonlyArray) => expect(LLMResponse.text({ events }).trim()).toMatch(/^Paris is sunny\.?$/) } -export type GoldenScenarioID = "text" | "tool-call" | "tool-loop" | "image" | "reasoning" - export interface GoldenScenarioContext { readonly id: string readonly model: Model @@ -223,6 +145,9 @@ export interface GoldenScenarioContext { 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) => value .toLowerCase() @@ -230,75 +155,193 @@ const normalizeImageText = (value: string) => .replace(/\s+/g, " ") .trim() -export const goldenScenarioTags = (id: GoldenScenarioID) => { - if (id === "text") return ["text", "golden"] - if (id === "tool-call") return ["tool", "tool-call", "golden"] - if (id === "image") return ["media", "image", "vision", "golden"] - if (id === "reasoning") return ["reasoning", "golden"] - return ["tool", "tool-loop", "golden"] +const encryptedReasoningOptions = { + openai: { + store: false, + includeEncryptedReasoning: true, + reasoningEffort: "low", + 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, + ): ConversationStep => ({ type: "assistant", text, ...options }), + expectToolCall: ( + name: string, + input: unknown, + options?: Omit, + ): ConversationStep => ({ type: "assistant", toolCall: { name, input }, finish: "tool-calls", ...options }), + expectEncryptedReasoningText: ( + text: AssistantTextExpectation, + options?: Omit, + ): 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) => { + 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.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) => 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( - textRequest({ - id: context.id, + LLM.request({ + id: step.id ? `${context.id}_${step.id}` : `${context.id}_${generated}`, model: context.model, - prompt: "Reply exactly with: Hello!", - maxTokens: context.maxTokens ?? 40, - temperature: context.temperature, + system: step.system, + cache: "none", + messages, + tools: step.tools, + toolChoice: step.toolChoice, + providerOptions: step.providerOptions, + generation: generation(context, step.maxTokens ?? context.maxTokens ?? 80), }), ) - expect(response.text.trim()).toMatch(/^Hello!?$/) - expectFinish(response.events, "stop") - return + if (step.text !== undefined) assertAssistantText(response.text, step.text) + if (step.toolCall) assertAssistantToolCall(response, step.toolCall) + step.assert?.(response) + expectFinish(response.events, step.finish ?? "stop") + messages.push(assistantMessageFromResponse(response, step)) } + }) - if (id === "tool-call") { - const response = yield* generate( - weatherToolRequest({ - id: context.id, - model: context.model, - maxTokens: context.maxTokens ?? 80, - temperature: context.temperature, - }), - ) - expectWeatherToolCall(response) - expectFinish(response.events, "tool-calls") - return - } +const runTextScenario = (context: GoldenScenarioContext) => + runGeneratedConversation(context, [ + user("Reply exactly with: Hello!"), + assistant.expectText(/^Hello!?$/, { + system: "You are concise.", + maxTokens: context.maxTokens ?? 40, + providerOptions: + context.model.route.id === "gemini" ? { gemini: { thinkingConfig: { thinkingBudget: 0 } } } : undefined, + }), + ]) - if (id === "image") { - const response = yield* generate( - imageRequest({ - id: context.id, - model: context.model, - image: yield* restroomImage(), - maxTokens: context.maxTokens ?? 20, - temperature: context.temperature, - }), - ) - expect(normalizeImageText(response.text)).toBe(RESTROOM_IMAGE_TEXT) - expectFinish(response.events, "stop") - return - } +const runToolCallScenario = (context: GoldenScenarioContext) => + runGeneratedConversation(context, [ + user("Call get_weather with city exactly Paris."), + assistant.expectToolCall( + weatherToolName, + { city: "Paris" }, + { + system: "Call tools exactly as requested.", + tools: [weatherTool], + toolChoice: ToolChoice.make(weatherTool), + maxTokens: context.maxTokens ?? 80, + }, + ), + ]) - if (id === "reasoning") { - const response = yield* generate( - reasoningRequest({ - id: context.id, - model: context.model, - maxTokens: context.maxTokens ?? 120, - temperature: context.temperature, - }), - ) - expect(response.text.trim()).toMatch(/^Hello!?$/) - expect(response.usage?.reasoningTokens ?? 0).toBeGreaterThan(0) - expectFinish(response.events, "stop") - return - } +const runImageScenario = (context: GoldenScenarioContext) => + Effect.gen(function* () { + yield* runGeneratedConversation(context, [ + 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: yield* restroomImage() }, + ]), + assistant.expectText(/.+/, { + system: "Read images carefully. Reply only with the visible text.", + 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( yield* runWeatherToolLoop( 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) => { if (!usage) return undefined return Object.fromEntries( diff --git a/packages/opencode/src/session/llm/native-request.ts b/packages/opencode/src/session/llm/native-request.ts index 21e6413a29..b7f30e24c3 100644 --- a/packages/opencode/src/session/llm/native-request.ts +++ b/packages/opencode/src/session/llm/native-request.ts @@ -42,10 +42,15 @@ const providerMetadata = (value: unknown): ProviderMetadata | undefined => { 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) => + providerMetadata(part.providerMetadata) ?? providerMetadata(part.providerOptions) + const textPart = (part: Record) => ({ type: "text" as const, text: typeof part.text === "string" ? part.text : "", - providerMetadata: providerMetadata(part.providerOptions), + providerMetadata: partProviderMetadata(part), }) const mediaPart = (part: Record) => { @@ -68,7 +73,7 @@ const toolResult = (part: Record) => { result: "value" in output ? output.value : output, resultType: type, providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined, - providerMetadata: providerMetadata(part.providerOptions), + providerMetadata: partProviderMetadata(part), }) } @@ -80,7 +85,7 @@ const contentPart = (part: unknown) => { return { type: "reasoning" as const, text: typeof part.text === "string" ? part.text : "", - providerMetadata: providerMetadata(part.providerOptions), + providerMetadata: partProviderMetadata(part), } if (part.type === "tool-call") return ToolCallPart.make({ @@ -88,7 +93,7 @@ const contentPart = (part: unknown) => { name: typeof part.toolName === "string" ? part.toolName : "", input: part.input, providerExecuted: typeof part.providerExecuted === "boolean" ? part.providerExecuted : undefined, - providerMetadata: providerMetadata(part.providerOptions), + providerMetadata: partProviderMetadata(part), }) if (part.type === "tool-result") return toolResult(part) throw new Error(`Native LLM request adapter does not support ${String(part.type)} content parts`) diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index a15d3f92b0..94943aa7d4 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -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", () => { const openai = LLMNative.model({ model: { ...baseModel, api: { ...baseModel.api, url: "", npm: "@ai-sdk/openai" } },