Quellcode durchsuchen

fix(llm): preserve native continuation metadata (#28678)

Kit Langton vor 3 Monaten
Ursprung
Commit
61390dbb49

+ 37 - 5
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<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({
   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<typeof AnthropicUserBlock>
 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

+ 79 - 20
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<typeof OpenAIResponsesStreamItem>
 
@@ -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
 })
 

+ 11 - 1
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>): number | 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) =>
@@ -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) => {

+ 104 - 0
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<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 },
+  })
+}

Datei-Diff unterdrückt, da er zu groß ist
+ 35 - 0
packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json


+ 87 - 7
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<AnthropicMessages.AnthropicMessagesBody>(
         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<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", () =>
     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.")
     }),
   )
 

+ 1 - 0
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 },
     ],

+ 210 - 0
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<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", () =>
     Effect.gen(function* () {
       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", () =>
     Effect.gen(function* () {
       const body = sseEvents(

+ 2 - 10
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,
         {

+ 209 - 147
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<LLMEvent>) =>
   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<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* () {
-    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(

+ 9 - 4
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<string, unknown>) =>
+  providerMetadata(part.providerMetadata) ?? providerMetadata(part.providerOptions)
+
 const textPart = (part: Record<string, unknown>) => ({
   type: "text" as const,
   text: typeof part.text === "string" ? part.text : "",
-  providerMetadata: providerMetadata(part.providerOptions),
+  providerMetadata: partProviderMetadata(part),
 })
 
 const mediaPart = (part: Record<string, unknown>) => {
@@ -68,7 +73,7 @@ const toolResult = (part: Record<string, unknown>) => {
     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`)

+ 36 - 0
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" } },

Einige Dateien werden nicht angezeigt, da zu viele Dateien in diesem Diff geändert wurden.