瀏覽代碼

feat(ai): support PDF inputs (#38253)

Aiden Cline 2 周之前
父節點
當前提交
2271f9b222
共有 24 個文件被更改,包括 1020 次插入96 次删除
  1. 36 29
      packages/ai/src/protocols/anthropic-messages.ts
  2. 1 2
      packages/ai/src/protocols/bedrock-converse.ts
  3. 24 9
      packages/ai/src/protocols/gemini.ts
  4. 59 22
      packages/ai/src/protocols/openai-responses.ts
  5. 2 1
      packages/ai/src/protocols/shared.ts
  6. 5 3
      packages/ai/src/protocols/utils/bedrock-media.ts
  7. 22 3
      packages/ai/src/tool-runtime.ts
  8. 35 0
      packages/ai/test/fixtures/recordings/pdf/anthropic-tool-result.json
  9. 34 0
      packages/ai/test/fixtures/recordings/pdf/anthropic-user-input.json
  10. 30 0
      packages/ai/test/fixtures/recordings/pdf/bedrock-tool-result.json
  11. 35 0
      packages/ai/test/fixtures/recordings/pdf/bedrock-user-input.json
  12. 41 0
      packages/ai/test/fixtures/recordings/pdf/gemini-tool-result.json
  13. 29 0
      packages/ai/test/fixtures/recordings/pdf/gemini-user-input.json
  14. 30 0
      packages/ai/test/fixtures/recordings/pdf/openai-tool-result.json
  15. 29 0
      packages/ai/test/fixtures/recordings/pdf/openai-user-input.json
  16. 30 0
      packages/ai/test/fixtures/recordings/pdf/xai-tool-result.json
  17. 29 0
      packages/ai/test/fixtures/recordings/pdf/xai-user-input.json
  18. 6 1
      packages/ai/test/lib/tool-runtime.ts
  19. 9 5
      packages/ai/test/provider/anthropic-messages.test.ts
  20. 95 4
      packages/ai/test/provider/bedrock-converse.test.ts
  21. 64 11
      packages/ai/test/provider/gemini.test.ts
  22. 123 6
      packages/ai/test/provider/openai-responses.test.ts
  23. 207 0
      packages/ai/test/provider/pdf.recorded.test.ts
  24. 45 0
      packages/ai/test/tool-runtime.test.ts

+ 36 - 29
packages/ai/src/protocols/anthropic-messages.ts

@@ -27,6 +27,7 @@ import { ToolSchemaProjection } from "./utils/tool-schema"
 import { ToolStream } from "./utils/tool-stream"
 
 const ADAPTER = "anthropic-messages"
+const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
 export const DEFAULT_BASE_URL = "https://api.anthropic.com/v1"
 export const PATH = "/messages"
 
@@ -56,6 +57,17 @@ const AnthropicImageBlock = Schema.Struct({
 })
 type AnthropicImageBlock = Schema.Schema.Type<typeof AnthropicImageBlock>
 
+const AnthropicDocumentBlock = Schema.Struct({
+  type: Schema.tag("document"),
+  source: Schema.Struct({
+    type: Schema.tag("base64"),
+    media_type: Schema.Literal("application/pdf"),
+    data: Schema.String,
+  }),
+  cache_control: Schema.optional(AnthropicCacheControl),
+})
+type AnthropicDocumentBlock = Schema.Schema.Type<typeof AnthropicDocumentBlock>
+
 const AnthropicThinkingBlock = Schema.Struct({
   type: Schema.tag("thinking"),
   thinking: Schema.String,
@@ -101,13 +113,10 @@ const AnthropicServerToolResultBlock = Schema.Struct({
 })
 type AnthropicServerToolResultBlock = Schema.Schema.Type<typeof AnthropicServerToolResultBlock>
 
-// Anthropic accepts either a plain string or an ordered array of text/image
-// blocks inside `tool_result.content`. The array form is required when a tool
-// returns image bytes (screenshot, image search, etc.) so they can be passed
-// to the model as proper image inputs instead of being JSON-stringified into
-// the prompt — which silently inflates context by megabytes and can push the
-// conversation over the model's token limit.
-const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock])
+// Anthropic accepts either a plain string or an ordered array of text, image, and
+// document blocks inside `tool_result.content`. The array form keeps media as native
+// model input instead of JSON-stringifying base64 into prompt text.
+const AnthropicToolResultContent = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicDocumentBlock])
 
 const AnthropicToolResultBlock = Schema.Struct({
   type: Schema.tag("tool_result"),
@@ -117,7 +126,12 @@ const AnthropicToolResultBlock = Schema.Struct({
   cache_control: Schema.optional(AnthropicCacheControl),
 })
 
-const AnthropicUserBlock = Schema.Union([AnthropicTextBlock, AnthropicImageBlock, AnthropicToolResultBlock])
+const AnthropicUserBlock = Schema.Union([
+  AnthropicTextBlock,
+  AnthropicImageBlock,
+  AnthropicDocumentBlock,
+  AnthropicToolResultBlock,
+])
 type AnthropicUserBlock = Schema.Schema.Type<typeof AnthropicUserBlock>
 const AnthropicAssistantBlock = Schema.Union([
   AnthropicTextBlock,
@@ -319,12 +333,17 @@ 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) {
-  const media = yield* ProviderShared.validateMedia(
-    "Anthropic Messages",
-    part,
-    new Set<string>(ProviderShared.IMAGE_MIMES),
-  )
+const lowerMedia = Effect.fn("AnthropicMessages.lowerMedia")(function* (part: MediaPart) {
+  const media = yield* ProviderShared.validateMedia("Anthropic Messages", part, MEDIA_MIMES)
+  if (media.mime === "application/pdf")
+    return {
+      type: "document" as const,
+      source: {
+        type: "base64" as const,
+        media_type: "application/pdf" as const,
+        data: media.base64,
+      },
+    } satisfies AnthropicDocumentBlock
   return {
     type: "image" as const,
     source: {
@@ -335,25 +354,13 @@ const lowerImage = Effect.fn("AnthropicMessages.lowerImage")(function* (part: Me
   } satisfies AnthropicImageBlock
 })
 
-// Tool results may carry structured text/images. Keep media as provider-native
+// Tool results may carry structured text, images, and documents. Keep media as provider-native
 // content instead of JSON-stringifying base64 into a prompt string.
 const lowerToolResultContentItem = Effect.fn("AnthropicMessages.lowerToolResultContentItem")(function* (
   item: ToolContent,
 ) {
   if (item.type === "text") return { type: "text" as const, text: item.text } satisfies AnthropicTextBlock
-  const media = yield* ProviderShared.validateToolFile(
-    "Anthropic Messages",
-    item,
-    new Set<string>(ProviderShared.IMAGE_MIMES),
-  )
-  return {
-    type: "image" as const,
-    source: {
-      type: "base64" as const,
-      media_type: media.mime,
-      data: media.base64,
-    },
-  } satisfies AnthropicImageBlock
+  return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name })
 })
 
 const lowerToolResultContent = Effect.fn("AnthropicMessages.lowerToolResultContent")(function* (part: ToolResultPart) {
@@ -445,7 +452,7 @@ const lowerMessages = Effect.fn("AnthropicMessages.lowerMessages")(function* (
           continue
         }
         if (part.type === "media") {
-          content.push(yield* lowerImage(part))
+          content.push(yield* lowerMedia(part))
           continue
         }
         return yield* ProviderShared.unsupportedContent("Anthropic Messages", "user", ["text", "media"])

+ 1 - 2
packages/ai/src/protocols/bedrock-converse.ts

@@ -52,6 +52,7 @@ const BedrockToolResultContentItem = Schema.Union([
   Schema.Struct({ text: Schema.String }),
   Schema.Struct({ json: Schema.Unknown }),
   BedrockMedia.ImageBlock,
+  BedrockMedia.DocumentBlock,
 ])
 
 const BedrockToolResultBlock = Schema.Struct({
@@ -283,8 +284,6 @@ const lowerToolResultContent = Effect.fn("BedrockConverse.lowerToolResultContent
       data: item.uri,
       filename: item.name,
     })
-    if (!("image" in media))
-      return yield* ProviderShared.invalidRequest("Bedrock Converse only supports image media in tool results")
     content.push(media)
   }
   return content

+ 24 - 9
packages/ai/src/protocols/gemini.ts

@@ -41,9 +41,11 @@ const GeminiInlineDataPart = Schema.Struct({
     data: Schema.String,
   }),
 })
+type GeminiInlineDataPart = Schema.Schema.Type<typeof GeminiInlineDataPart>
 
 const GeminiFunctionCallPart = Schema.Struct({
   functionCall: Schema.Struct({
+    id: Schema.optional(Schema.String),
     name: Schema.String,
     args: Schema.Unknown,
   }),
@@ -52,8 +54,10 @@ const GeminiFunctionCallPart = Schema.Struct({
 
 const GeminiFunctionResponsePart = Schema.Struct({
   functionResponse: Schema.Struct({
+    id: Schema.optional(Schema.String),
     name: Schema.String,
     response: Schema.Unknown,
+    parts: Schema.optional(Schema.Array(GeminiInlineDataPart)),
   }),
 })
 
@@ -197,8 +201,13 @@ const thoughtSignature = (providerMetadata: ProviderMetadata | undefined) => {
     : undefined
 }
 
+const functionCallId = (providerMetadata: ProviderMetadata | undefined) => {
+  const google = providerMetadata?.google
+  return ProviderShared.isRecord(google) && typeof google.functionCallId === "string" ? google.functionCallId : undefined
+}
+
 const lowerToolCall = (part: ToolCallPart) => ({
-  functionCall: { name: part.name, args: part.input },
+  functionCall: { id: functionCallId(part.providerMetadata), name: part.name, args: part.input },
   thoughtSignature: thoughtSignature(part.providerMetadata),
 })
 
@@ -255,6 +264,7 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
       if (part.result.type !== "content") {
         parts.push({
           functionResponse: {
+            id: functionCallId(part.providerMetadata),
             name: part.name,
             response: {
               name: part.name,
@@ -266,20 +276,23 @@ const lowerMessages = Effect.fn("Gemini.lowerMessages")(function* (request: LLMR
       }
       const content: ReadonlyArray<ToolContent> = part.result.value
       const text = content.filter((item) => item.type === "text").map((item) => item.text)
+      const media: GeminiInlineDataPart[] = []
+      for (const item of content) {
+        if (item.type === "text") continue
+        const value = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
+        media.push({ inlineData: { mimeType: value.mime, data: value.base64 } })
+      }
       parts.push({
         functionResponse: {
+          id: functionCallId(part.providerMetadata),
           name: part.name,
           response: {
             name: part.name,
             content: text.join("\n"),
           },
+          parts: media.length > 0 ? media : undefined,
         },
       })
-      for (const item of content) {
-        if (item.type === "text") continue
-        const media = yield* ProviderShared.validateToolFile("Gemini", item, MEDIA_MIMES)
-        parts.push({ inlineData: { mimeType: media.mime, data: media.base64 } })
-      }
     }
     contents.push({ role: "user", parts })
   }
@@ -441,6 +454,10 @@ const step = (state: ParserState, event: GeminiEvent) => {
     if ("functionCall" in part) {
       const input = part.functionCall.args
       const id = `tool_${nextToolCallId++}`
+      const metadata = {
+        ...(part.functionCall.id === undefined ? {} : { functionCallId: part.functionCall.id }),
+        ...(part.thoughtSignature === undefined ? {} : { thoughtSignature: part.thoughtSignature }),
+      }
       lifecycle = Lifecycle.reasoningEnd(
         lifecycle,
         events,
@@ -453,9 +470,7 @@ const step = (state: ParserState, event: GeminiEvent) => {
           id,
           name: part.functionCall.name,
           input,
-          providerMetadata: part.thoughtSignature
-            ? googleMetadata({ thoughtSignature: part.thoughtSignature })
-            : undefined,
+          providerMetadata: Object.keys(metadata).length > 0 ? googleMetadata(metadata) : undefined,
         }),
       )
       hasToolCalls = true

+ 59 - 22
packages/ai/src/protocols/openai-responses.ts

@@ -11,6 +11,7 @@ import {
   type FinishReason,
   type JsonSchema,
   type LLMRequest,
+  type MediaPart,
   type ProviderMetadata,
   type ReasoningPart,
   type TextPart,
@@ -28,6 +29,7 @@ import { ToolStream } from "./utils/tool-stream"
 import { OpenAIImage } from "./utils/openai-image"
 
 const ADAPTER = "openai-responses"
+const MEDIA_MIMES = new Set<string>([...ProviderShared.IMAGE_MIMES, ...ProviderShared.PDF_MIMES])
 export const DEFAULT_BASE_URL = "https://api.openai.com/v1"
 export const PATH = "/responses"
 
@@ -42,7 +44,17 @@ const OpenAIResponsesInputImage = Schema.Struct({
   type: Schema.tag("input_image"),
   image_url: Schema.String,
 })
-const OpenAIResponsesInputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
+const OpenAIResponsesInputFile = Schema.Struct({
+  type: Schema.tag("input_file"),
+  filename: Schema.String,
+  file_data: Schema.String,
+  mime_type: Schema.optional(Schema.String),
+})
+const OpenAIResponsesInputContent = Schema.Union([
+  OpenAIResponsesInputText,
+  OpenAIResponsesInputImage,
+  OpenAIResponsesInputFile,
+])
 type OpenAIResponsesInputContent = Schema.Schema.Type<typeof OpenAIResponsesInputContent>
 
 const OpenAIResponsesOutputText = Schema.Struct({
@@ -68,9 +80,13 @@ const OpenAIResponsesItemReference = Schema.Struct({
 })
 
 // `function_call_output.output` accepts either a plain string or an ordered
-// array of content items so tools can return images in addition to text.
+// array of content items so tools can return images and files in addition to text.
 // https://platform.openai.com/docs/api-reference/responses/object
-const OpenAIResponsesFunctionCallOutputContent = Schema.Union([OpenAIResponsesInputText, OpenAIResponsesInputImage])
+const OpenAIResponsesFunctionCallOutputContent = Schema.Union([
+  OpenAIResponsesInputText,
+  OpenAIResponsesInputImage,
+  OpenAIResponsesInputFile,
+])
 
 const OpenAIResponsesFunctionCallOutput = Schema.Union([
   Schema.String,
@@ -343,42 +359,58 @@ const hostedToolItemID = (part: ToolResultPart) => {
     : undefined
 }
 
+const lowerMedia = Effect.fn("OpenAIResponses.lowerMedia")(function* (part: MediaPart, provider: string) {
+  const media = yield* ProviderShared.validateMedia("OpenAI Responses", part, MEDIA_MIMES)
+  if (media.mime === "application/pdf") {
+    // xAI models inline bytes and MIME separately; OpenAI uses a data URL in file_data.
+    if (provider === "xai")
+      return {
+        type: "input_file" as const,
+        filename: part.filename ?? "document.pdf",
+        file_data: media.base64,
+        mime_type: media.mime,
+      }
+    return {
+      type: "input_file" as const,
+      filename: part.filename ?? "document.pdf",
+      file_data: media.dataUrl,
+    }
+  }
+  return { type: "input_image" as const, image_url: media.dataUrl }
+})
+
 const lowerUserContent = Effect.fn("OpenAIResponses.lowerUserContent")(function* (
   part: LLMRequest["messages"][number]["content"][number],
+  provider: string,
 ) {
   if (part.type === "text") return { type: "input_text" as const, text: part.text }
-  if (part.type === "media") {
-    const media = yield* ProviderShared.validateMedia(
-      "OpenAI Responses",
-      part,
-      new Set<string>(ProviderShared.IMAGE_MIMES),
-    )
-    return { type: "input_image" as const, image_url: media.dataUrl }
-  }
+  if (part.type === "media") return yield* lowerMedia(part, provider)
   return yield* ProviderShared.unsupportedContent("OpenAI Responses", "user", ["text", "media"])
 })
 
-// Tool results may carry structured text/images. Keep media as provider-native
+// Tool results may carry structured text, images, and files. Keep media as provider-native
 // content instead of JSON-stringifying base64 into a prompt string.
 const lowerToolResultContentItem = Effect.fn("OpenAIResponses.lowerToolResultContentItem")(function* (
   item: ToolContent,
+  provider: string,
 ) {
   if (item.type === "text") return { type: "input_text" as const, text: item.text }
-  const media = yield* ProviderShared.validateToolFile(
-    "OpenAI Responses",
-    item,
-    new Set<string>(ProviderShared.IMAGE_MIMES),
+  return yield* lowerMedia(
+    { type: "media", mediaType: item.mime, data: item.uri, filename: item.name },
+    provider,
   )
-  return { type: "input_image" as const, image_url: media.dataUrl }
 })
 
-const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (part: ToolResultPart) {
+const lowerToolResultOutput = Effect.fn("OpenAIResponses.lowerToolResultOutput")(function* (
+  part: ToolResultPart,
+  provider: string,
+) {
   // Text/json/error results are encoded as a plain string for backward
   // compatibility with existing cassettes and provider expectations.
   if (part.result.type !== "content") return ProviderShared.toolResultText(part)
   // Preserve the narrowed array element type when compiled through a consumer package.
   const content: ReadonlyArray<ToolContent> = part.result.value
-  return yield* Effect.forEach(content, lowerToolResultContentItem)
+  return yield* Effect.forEach(content, (item) => lowerToolResultContentItem(item, provider))
 })
 
 const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (request: LLMRequest) {
@@ -401,7 +433,10 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
     }
 
     if (message.role === "user") {
-      input.push({ role: "user", content: yield* Effect.forEach(message.content, lowerUserContent) })
+      input.push({
+        role: "user",
+        content: yield* Effect.forEach(message.content, (part) => lowerUserContent(part, request.model.provider)),
+      })
       continue
     }
 
@@ -460,7 +495,9 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
             const content: ReadonlyArray<ToolContent> = part.result.value
             input.push({
               role: "user",
-              content: yield* Effect.forEach(content, lowerToolResultContentItem),
+              content: yield* Effect.forEach(content, (item) =>
+                lowerToolResultContentItem(item, request.model.provider),
+              ),
             })
           }
           if (itemID) hostedToolReferences.add(itemID)
@@ -483,7 +520,7 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ
       input.push({
         type: "function_call_output",
         call_id: part.id,
-        output: yield* lowerToolResultOutput(part),
+        output: yield* lowerToolResultOutput(part, request.model.provider),
       })
     }
   }

+ 2 - 1
packages/ai/src/protocols/shared.ts

@@ -158,7 +158,8 @@ export const parseToolInput = (route: string, name: string, raw: string) =>
 export const IMAGE_MIMES = ["image/png", "image/jpeg", "image/gif", "image/webp"] as const
 export const VIDEO_MIMES = ["video/mp4", "video/webm", "video/quicktime"] as const
 export const AUDIO_MIMES = ["audio/wav", "audio/mp3", "audio/aiff", "audio/aac", "audio/ogg", "audio/flac"] as const
-export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES] as const
+export const PDF_MIMES = ["application/pdf"] as const
+export const MEDIA_MIMES = [...IMAGE_MIMES, ...VIDEO_MIMES, ...AUDIO_MIMES, ...PDF_MIMES] as const
 export const MAX_MEDIA_ENCODED_BYTES = 28 * 1024 * 1024
 export const MAX_MEDIA_DECODED_BYTES = 20 * 1024 * 1024
 

+ 5 - 3
packages/ai/src/protocols/utils/bedrock-media.ts

@@ -49,10 +49,10 @@ const DOCUMENT_FORMATS = {
   "text/markdown": "md",
 } as const satisfies Record<string, DocumentFormat>
 
-const documentBlock = (part: MediaPart, format: DocumentFormat, bytes: string): DocumentBlock => ({
+const documentBlock = (name: string, format: DocumentFormat, bytes: string): DocumentBlock => ({
   document: {
     format,
-    name: part.filename ?? `document.${format}`,
+    name,
     source: { bytes },
   },
 })
@@ -77,12 +77,14 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part: MediaPart)
     return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`)
   const documentFormat = DOCUMENT_FORMATS[mime as keyof typeof DOCUMENT_FORMATS]
   if (documentFormat) {
+    if (!part.filename)
+      return yield* ProviderShared.invalidRequest("Bedrock Converse document media requires a filename")
     const media = yield* ProviderShared.validateMedia(
       "Bedrock Converse",
       part,
       new Set<string>(Object.keys(DOCUMENT_FORMATS)),
     )
-    return documentBlock(part, documentFormat, media.base64)
+    return documentBlock(part.filename, documentFormat, media.base64)
   }
   return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`)
 })

+ 22 - 3
packages/ai/src/tool-runtime.ts

@@ -68,10 +68,29 @@ const result = (call: ToolCallPart, value: ToolResultValueType | ToolSettlement,
     events:
       settlement.result.type === "error"
         ? [
-            LLMEvent.toolError({ id: call.id, name: call.name, message: String(settlement.result.value), error }),
-            LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result }),
+            LLMEvent.toolError({
+              id: call.id,
+              name: call.name,
+              message: String(settlement.result.value),
+              error,
+              providerMetadata: call.providerMetadata,
+            }),
+            LLMEvent.toolResult({
+              id: call.id,
+              name: call.name,
+              result: settlement.result,
+              providerMetadata: call.providerMetadata,
+            }),
           ]
-        : [LLMEvent.toolResult({ id: call.id, name: call.name, result: settlement.result, output: settlement.output })],
+        : [
+            LLMEvent.toolResult({
+              id: call.id,
+              name: call.name,
+              result: settlement.result,
+              output: settlement.output,
+              providerMetadata: call.providerMetadata,
+            }),
+          ],
   }
 }
 

+ 35 - 0
packages/ai/test/fixtures/recordings/pdf/anthropic-tool-result.json

@@ -0,0 +1,35 @@
+{
+  "version": 1,
+  "metadata": {
+    "tags": [
+      "prefix:pdf",
+      "pdf",
+      "provider:anthropic",
+      "protocol:anthropic-messages",
+      "tool",
+      "tool-result"
+    ],
+    "name": "pdf/anthropic-tool-result",
+    "recordedAt": "2026-07-22T18:15:39.002Z"
+  },
+  "interactions": [
+    {
+      "transport": "http",
+      "request": {
+        "method": "POST",
+        "url": "https://api.anthropic.com/v1/messages",
+        "headers": {
+          "content-type": "application/json"
+        },
+        "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"system\":[{\"type\":\"text\",\"text\":\"Read the PDF returned by the tool and follow the user's response format exactly.\"}],\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]},{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"call_pdf_1\",\"name\":\"read_pdf\",\"input\":{}}]},{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"call_pdf_1\",\"content\":[{\"type\":\"text\",\"text\":\"PDF read successfully\"},{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}]}]}],\"tools\":[{\"name\":\"read_pdf\",\"description\":\"Read the attached PDF.\",\"input_schema\":{\"type\":\"object\",\"properties\":{},\"additionalProperties\":false}}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
+      },
+      "response": {
+        "status": 200,
+        "headers": {
+          "content-type": "text/event-stream; charset=utf-8"
+        },
+        "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdHYzxyRpSVFgwTm6ccUr\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":2229,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}        }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}              }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ORCH\"}  }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ID-7391\"}           }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0               }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":2229,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":9}       }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n"
+      }
+    }
+  ]
+}

+ 34 - 0
packages/ai/test/fixtures/recordings/pdf/anthropic-user-input.json

@@ -0,0 +1,34 @@
+{
+  "version": 1,
+  "metadata": {
+    "tags": [
+      "prefix:pdf",
+      "pdf",
+      "provider:anthropic",
+      "protocol:anthropic-messages",
+      "user-input"
+    ],
+    "name": "pdf/anthropic-user-input",
+    "recordedAt": "2026-07-22T18:15:37.979Z"
+  },
+  "interactions": [
+    {
+      "transport": "http",
+      "request": {
+        "method": "POST",
+        "url": "https://api.anthropic.com/v1/messages",
+        "headers": {
+          "content-type": "application/json"
+        },
+        "body": "{\"model\":\"claude-haiku-4-5-20251001\",\"messages\":[{\"role\":\"user\",\"content\":[{\"type\":\"document\",\"source\":{\"type\":\"base64\",\"media_type\":\"application/pdf\",\"data\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}},{\"type\":\"text\",\"text\":\"Return only the verification code from the PDF.\"}]}],\"stream\":true,\"max_tokens\":40,\"temperature\":0}"
+      },
+      "response": {
+        "status": 200,
+        "headers": {
+          "content-type": "text/event-stream; charset=utf-8"
+        },
+        "body": "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-haiku-4-5-20251001\",\"id\":\"msg_011CdHYzsayb45rgfamcjFt3\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"stop_details\":null,\"usage\":{\"input_tokens\":1602,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\",\"inference_geo\":\"not_available\"}}           }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"}         }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ORCH\"}              }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"ID-7391\"}}\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0      }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"stop_details\":null},\"usage\":{\"input_tokens\":1602,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":9}    }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\"              }\n\n"
+      }
+    }
+  ]
+}

文件差異過大導致無法顯示
+ 30 - 0
packages/ai/test/fixtures/recordings/pdf/bedrock-tool-result.json


+ 35 - 0
packages/ai/test/fixtures/recordings/pdf/bedrock-user-input.json

@@ -0,0 +1,35 @@
+{
+  "version": 1,
+  "metadata": {
+    "tags": [
+      "prefix:pdf",
+      "pdf",
+      "provider:amazon-bedrock",
+      "protocol:bedrock-converse",
+      "user-input"
+    ],
+    "name": "pdf/bedrock-user-input",
+    "recordedAt": "2026-07-22T18:15:48.408Z"
+  },
+  "interactions": [
+    {
+      "transport": "http",
+      "request": {
+        "method": "POST",
+        "url": "https://bedrock-runtime.us-east-1.amazonaws.com/model/us.anthropic.claude-haiku-4-5-20251001-v1%3A0/converse-stream",
+        "headers": {
+          "content-type": "application/json"
+        },
+        "body": "{\"modelId\":\"us.anthropic.claude-haiku-4-5-20251001-v1:0\",\"messages\":[{\"role\":\"user\",\"content\":[{\"document\":{\"format\":\"pdf\",\"name\":\"verification\",\"source\":{\"bytes\":\"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK\"}}},{\"text\":\"Return only the verification code from the PDF.\"}]}],\"inferenceConfig\":{\"maxTokens\":40,\"temperature\":0}}"
+      },
+      "response": {
+        "status": 200,
+        "headers": {
+          "content-type": "application/vnd.amazon.eventstream"
+        },
+        "body": "AAAAtgAAAFJ/wBIFCzpldmVudC10eXBlBwAMbWVzc2FnZVN0YXJ0DTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVowMTIzNCIsInJvbGUiOiJhc3Npc3RhbnQifURlAvAAAADGAAAAV/Z4BkULOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiT1JDSCJ9LCJwIjoiYWJjZGVmZ2hpamtsbW5vcHFyc3R1dnd4eXpBQkNERUZHSElKS0xNTk8ifU1V/fQAAADWAAAAV5aYkccLOmV2ZW50LXR5cGUHABFjb250ZW50QmxvY2tEZWx0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7ImNvbnRlbnRCbG9ja0luZGV4IjowLCJkZWx0YSI6eyJ0ZXh0IjoiSUQtIn0sInAiOiJhYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ekFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaMDEyMzQ1In1Rr1g8AAAAoAAAAFfgCoSoCzpldmVudC10eXBlBwARY29udGVudEJsb2NrRGVsdGENOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwiZGVsdGEiOnsidGV4dCI6IjcifSwicCI6ImFiY2RlZiJ9UwQMPQAAAM8AAABX+2hkNAs6ZXZlbnQtdHlwZQcAEWNvbnRlbnRCbG9ja0RlbHRhDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsiY29udGVudEJsb2NrSW5kZXgiOjAsImRlbHRhIjp7InRleHQiOiIzOTEifSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWSJ9ZmoyCwAAAJAAAABWNiwMuAs6ZXZlbnQtdHlwZQcAEGNvbnRlbnRCbG9ja1N0b3ANOmNvbnRlbnQtdHlwZQcAEGFwcGxpY2F0aW9uL2pzb24NOm1lc3NhZ2UtdHlwZQcABWV2ZW50eyJjb250ZW50QmxvY2tJbmRleCI6MCwicCI6ImFiY2RlZmdoaWprbCJ9wtmmXgAAAIgAAABR+NhFWAs6ZXZlbnQtdHlwZQcAC21lc3NhZ2VTdG9wDTpjb250ZW50LXR5cGUHABBhcHBsaWNhdGlvbi9qc29uDTptZXNzYWdlLXR5cGUHAAVldmVudHsicCI6ImFiY2RlZmciLCJzdG9wUmVhc29uIjoiZW5kX3R1cm4ifa8D/doAAADvAAAATl7C4/ALOmV2ZW50LXR5cGUHAAhtZXRhZGF0YQ06Y29udGVudC10eXBlBwAQYXBwbGljYXRpb24vanNvbg06bWVzc2FnZS10eXBlBwAFZXZlbnR7Im1ldHJpY3MiOnsibGF0ZW5jeU1zIjo0NTQ1fSwicCI6ImFiY2RlZmdoaWprbG1ub3BxcnN0dXYiLCJ1c2FnZSI6eyJpbnB1dFRva2VucyI6MTYxNCwib3V0cHV0VG9rZW5zIjo2LCJzZXJ2ZXJUb29sVXNhZ2UiOnt9LCJ0b3RhbFRva2VucyI6MTYyMH19db4j2Q==",
+        "bodyEncoding": "base64"
+      }
+    }
+  ]
+}

文件差異過大導致無法顯示
+ 41 - 0
packages/ai/test/fixtures/recordings/pdf/gemini-tool-result.json


文件差異過大導致無法顯示
+ 29 - 0
packages/ai/test/fixtures/recordings/pdf/gemini-user-input.json


文件差異過大導致無法顯示
+ 30 - 0
packages/ai/test/fixtures/recordings/pdf/openai-tool-result.json


文件差異過大導致無法顯示
+ 29 - 0
packages/ai/test/fixtures/recordings/pdf/openai-user-input.json


文件差異過大導致無法顯示
+ 30 - 0
packages/ai/test/fixtures/recordings/pdf/xai-tool-result.json


文件差異過大導致無法顯示
+ 29 - 0
packages/ai/test/fixtures/recordings/pdf/xai-user-input.json


+ 6 - 1
packages/ai/test/lib/tool-runtime.ts

@@ -59,7 +59,12 @@ export const runTools = <T extends Tools>(options: RunOptions<T>) =>
             ...request.messages,
             Message.assistant(state.assistantContent),
             ...dispatched.map(([call, dispatched]) =>
-              Message.tool({ id: call.id, name: call.name, result: dispatched.result }),
+              Message.tool({
+                id: call.id,
+                name: call.name,
+                result: dispatched.result,
+                providerMetadata: call.providerMetadata,
+              }),
             ),
           ],
         })

+ 9 - 5
packages/ai/test/provider/anthropic-messages.test.ts

@@ -235,9 +235,9 @@ describe("Anthropic Messages route", () => {
     }),
   )
 
-  // Regression: screenshot/read tool results must stay structured so base64
-  // image data is not JSON-stringified into `tool_result.content`.
-  it.effect("lowers image tool-result content as structured image blocks", () =>
+  // Regression: read tool results must stay structured so base64 media data is
+  // not JSON-stringified into `tool_result.content`.
+  it.effect("lowers media tool-result content as structured blocks", () =>
     Effect.gen(function* () {
       const prepared = yield* LLMClient.prepare<AnthropicMessages.AnthropicMessagesBody>(
         LLM.request({
@@ -253,6 +253,7 @@ describe("Anthropic Messages route", () => {
               result: [
                 { type: "text", text: "Image read successfully" },
                 { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png" },
+                { type: "file", uri: "data:application/pdf;base64,JVBERi0xLjQ=", mime: "application/pdf" },
               ],
             }),
           ],
@@ -263,6 +264,7 @@ describe("Anthropic Messages route", () => {
       expect(expectToolResult(prepared.body).content).toEqual([
         { type: "text", text: "Image read successfully" },
         { type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
+        { type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } },
       ])
     }),
   )
@@ -292,7 +294,7 @@ describe("Anthropic Messages route", () => {
     }),
   )
 
-  it.effect("rejects non-image media in tool-result content with a clear error", () =>
+  it.effect("rejects unsupported media in tool-result content with a clear error", () =>
     Effect.gen(function* () {
       const error = yield* LLMClient.prepare(
         LLM.request({
@@ -756,7 +758,7 @@ describe("Anthropic Messages route", () => {
     }),
   )
 
-  it.effect("continues a conversation with user image content", () =>
+  it.effect("continues a conversation with user media content", () =>
     Effect.gen(function* () {
       const response = yield* LLMClient.generate(
         LLM.request({
@@ -766,6 +768,7 @@ describe("Anthropic Messages route", () => {
             Message.user([
               { type: "text", text: "What is in this image?" },
               { type: "media", mediaType: "image/png", data: "AAECAw==" },
+              { type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=", filename: "report.pdf" },
             ]),
           ],
         }),
@@ -781,6 +784,7 @@ describe("Anthropic Messages route", () => {
                     content: [
                       { type: "text", text: "What is in this image?" },
                       { type: "image", source: { type: "base64", media_type: "image/png", data: "AAECAw==" } },
+                      { type: "document", source: { type: "base64", media_type: "application/pdf", data: "JVBERi0xLjQ=" } },
                     ],
                   },
                 ],

+ 95 - 4
packages/ai/test/provider/bedrock-converse.test.ts

@@ -549,10 +549,12 @@ describe("Bedrock Converse route", () => {
         LLM.request({
           id: "req_doc",
           model,
+          cache: "none",
           messages: [
             Message.user([
+              { type: "text", text: "Summarize these documents." },
               { type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==", filename: "report.pdf" },
-              { type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==" },
+              { type: "media", mediaType: "text/csv", data: "Q1NWREFUQQ==", filename: "data.csv" },
             ]),
           ],
         }),
@@ -563,10 +565,9 @@ describe("Bedrock Converse route", () => {
           {
             role: "user",
             content: [
-              // Filename round-trips when supplied.
+              { text: "Summarize these documents." },
               { document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } },
-              // Falls back to a stable placeholder when filename is missing.
-              { document: { format: "csv", name: "document.csv", source: { bytes: "Q1NWREFUQQ==" } } },
+              { document: { format: "csv", name: "data.csv", source: { bytes: "Q1NWREFUQQ==" } } },
             ],
           },
         ],
@@ -574,6 +575,96 @@ describe("Bedrock Converse route", () => {
     }),
   )
 
+  it.effect("requires names for document media", () =>
+    Effect.gen(function* () {
+      const error = yield* LLMClient.prepare(
+        LLM.request({
+          model,
+          messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "UERGREFUQQ==" })],
+        }),
+      ).pipe(Effect.flip)
+
+      expect(error.message).toContain("document media requires a filename")
+    }),
+  )
+
+  it.effect("passes named document-only messages through for provider validation", () =>
+    Effect.gen(function* () {
+      const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
+        LLM.request({
+          model,
+          cache: "none",
+          messages: [
+            Message.user({
+              type: "media",
+              mediaType: "application/pdf",
+              data: "UERGREFUQQ==",
+              filename: "report.pdf",
+            }),
+          ],
+        }),
+      )
+
+      expect(prepared.body.messages).toEqual([
+        {
+          role: "user",
+          content: [{ document: { format: "pdf", name: "report.pdf", source: { bytes: "UERGREFUQQ==" } } }],
+        },
+      ])
+    }),
+  )
+
+  it.effect("lowers document media in tool results", () =>
+    Effect.gen(function* () {
+      const prepared = yield* LLMClient.prepare<BedrockConverse.BedrockConverseBody>(
+        LLM.request({
+          model,
+          messages: [
+            Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: { path: "report.pdf" } })]),
+            Message.tool({
+              id: "call_1",
+              name: "read",
+              result: {
+                type: "content",
+                value: [
+                  { type: "text", text: "Read successfully" },
+                  {
+                    type: "file",
+                    uri: "data:application/pdf;base64,UERGREFUQQ==",
+                    mime: "application/pdf",
+                    name: "report",
+                  },
+                ],
+              },
+            }),
+          ],
+        }),
+      )
+
+      expect(prepared.body.messages).toEqual([
+        {
+          role: "assistant",
+          content: [{ toolUse: { toolUseId: "call_1", name: "read", input: { path: "report.pdf" } } }],
+        },
+        {
+          role: "user",
+          content: [
+            {
+              toolResult: {
+                toolUseId: "call_1",
+                status: "success",
+                content: [
+                  { text: "Read successfully" },
+                  { document: { format: "pdf", name: "report", source: { bytes: "UERGREFUQQ==" } } },
+                ],
+              },
+            },
+          ],
+        },
+      ])
+    }),
+  )
+
   it.effect("rejects unsupported image media types", () =>
     Effect.gen(function* () {
       const error = yield* LLMClient.prepare(

+ 64 - 11
packages/ai/test/provider/gemini.test.ts

@@ -70,6 +70,7 @@ describe("Gemini route", () => {
             Message.user([
               { type: "text", text: "What is in this image?" },
               { type: "media", mediaType: "image/png", data: "AAECAw==" },
+              { type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=" },
             ]),
             Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input: { query: "weather" } })]),
             Message.tool({ id: "call_1", name: "lookup", result: { forecast: "sunny" } }),
@@ -81,7 +82,11 @@ describe("Gemini route", () => {
         contents: [
           {
             role: "user",
-            parts: [{ text: "What is in this image?" }, { inlineData: { mimeType: "image/png", data: "AAECAw==" } }],
+            parts: [
+              { text: "What is in this image?" },
+              { inlineData: { mimeType: "image/png", data: "AAECAw==" } },
+              { inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
+            ],
           },
           {
             role: "model",
@@ -90,7 +95,12 @@ describe("Gemini route", () => {
           {
             role: "user",
             parts: [
-              { functionResponse: { name: "lookup", response: { name: "lookup", content: '{"forecast":"sunny"}' } } },
+              {
+                functionResponse: {
+                  name: "lookup",
+                  response: { name: "lookup", content: '{"forecast":"sunny"}' },
+                },
+              },
             ],
           },
         ],
@@ -110,7 +120,7 @@ describe("Gemini route", () => {
     }),
   )
 
-  it.effect("continues image tool results as inline vision input without base64 text", () =>
+  it.effect("continues media tool results as inline model input without base64 text", () =>
     Effect.gen(function* () {
       const prepared = yield* LLMClient.prepare<Gemini.GeminiBody>(
         LLM.request({
@@ -125,6 +135,7 @@ describe("Gemini route", () => {
                 value: [
                   { type: "text", text: "Image read successfully" },
                   { type: "file", uri: "data:image/png;base64,AAECAw==", mime: "image/png", name: "pixel.png" },
+                  { type: "file", uri: "data:application/pdf;base64,JVBERi0xLjQ=", mime: "application/pdf" },
                 ],
               },
             }),
@@ -141,9 +152,12 @@ describe("Gemini route", () => {
               functionResponse: {
                 name: "read",
                 response: { name: "read", content: "Image read successfully" },
+                parts: [
+                  { inlineData: { mimeType: "image/png", data: "AAECAw==" } },
+                  { inlineData: { mimeType: "application/pdf", data: "JVBERi0xLjQ=" } },
+                ],
               },
             },
-            { inlineData: { mimeType: "image/png", data: "AAECAw==" } },
           ],
         },
       ])
@@ -174,8 +188,13 @@ describe("Gemini route", () => {
         {
           role: "user",
           parts: [
-            { functionResponse: { name: "read", response: { name: "read", content: "" } } },
-            { inlineData: { mimeType: "image/jpeg", data: "/9j/" } },
+            {
+              functionResponse: {
+                name: "read",
+                response: { name: "read", content: "" },
+                parts: [{ inlineData: { mimeType: "image/jpeg", data: "/9j/" } }],
+              },
+            },
           ],
         },
       ])
@@ -372,7 +391,10 @@ describe("Gemini route", () => {
               parts: [
                 { text: "thinking", thought: true },
                 { text: "", thought: true, thoughtSignature: "thought_sig" },
-                { functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
+                {
+                  functionCall: { id: "provider_call", name: "lookup", args: { query: "weather" } },
+                  thoughtSignature: "tool_sig",
+                },
               ],
             },
             finishReason: "STOP",
@@ -398,7 +420,10 @@ describe("Gemini route", () => {
         id: "reasoning-0",
         providerMetadata: { google: { thoughtSignature: "thought_sig" } },
       })
-      expect(toolCall).toMatchObject({ providerMetadata: { google: { thoughtSignature: "tool_sig" } } })
+      expect(toolCall).toMatchObject({
+        id: "tool_0",
+        providerMetadata: { google: { functionCallId: "provider_call", thoughtSignature: "tool_sig" } },
+      })
       expect(response.events.findIndex((event) => event.type === "reasoning-end")).toBeLessThan(
         response.events.findIndex((event) => event.type === "tool-call"),
       )
@@ -416,6 +441,13 @@ describe("Gemini route", () => {
                 providerMetadata: toolCall?.providerMetadata,
               }),
             ]),
+            Message.tool({
+              id: "tool_0",
+              name: "lookup",
+              result: "done",
+              resultType: "text",
+              providerMetadata: toolCall?.providerMetadata,
+            }),
           ],
         }),
       )
@@ -424,7 +456,22 @@ describe("Gemini route", () => {
           role: "model",
           parts: [
             { text: "thinking", thought: true, thoughtSignature: "thought_sig" },
-            { functionCall: { name: "lookup", args: { query: "weather" } }, thoughtSignature: "tool_sig" },
+            {
+              functionCall: { id: "provider_call", name: "lookup", args: { query: "weather" } },
+              thoughtSignature: "tool_sig",
+            },
+          ],
+        },
+        {
+          role: "user",
+          parts: [
+            {
+              functionResponse: {
+                id: "provider_call",
+                name: "lookup",
+                response: { name: "lookup", content: "done" },
+              },
+            },
           ],
         },
       ])
@@ -498,7 +545,7 @@ describe("Gemini route", () => {
             content: {
               role: "model",
               parts: [
-                { functionCall: { name: "lookup", args: { query: "weather" } } },
+                { functionCall: { id: "tool_0", name: "lookup", args: { query: "weather" } } },
                 { functionCall: { name: "lookup", args: { query: "news" } } },
               ],
             },
@@ -513,7 +560,13 @@ describe("Gemini route", () => {
       ).pipe(Effect.provide(fixedResponse(body)))
 
       expect(response.toolCalls).toEqual([
-        { type: "tool-call", id: "tool_0", name: "lookup", input: { query: "weather" } },
+        {
+          type: "tool-call",
+          id: "tool_0",
+          name: "lookup",
+          input: { query: "weather" },
+          providerMetadata: { google: { functionCallId: "tool_0" } },
+        },
         { type: "tool-call", id: "tool_1", name: "lookup", input: { query: "news" } },
       ])
       expect(response.events.at(-1)).toMatchObject({ type: "finish", reason: "tool-calls" })

+ 123 - 6
packages/ai/test/provider/openai-responses.test.ts

@@ -5,6 +5,7 @@ import { LLM, LLMError, LLMEvent, Message, Model, ToolCallPart, ToolResultPart,
 import { Auth, LLMClient, RequestExecutor, WebSocketExecutor } from "../../src/route"
 import * as Azure from "../../src/providers/azure"
 import * as OpenAI from "../../src/providers/openai"
+import * as XAI from "../../src/providers/xai"
 import * as OpenAIResponses from "../../src/protocols/openai-responses"
 import * as ProviderShared from "../../src/protocols/shared"
 import { continuationRequest, nativeOpenAIResponsesContinuation } from "../continuation-scenarios"
@@ -16,6 +17,8 @@ const model = OpenAIResponses.route
   .with({ endpoint: { baseURL: "https://api.openai.test/v1/" }, auth: Auth.bearer("test") })
   .model({ id: "gpt-4.1-mini" })
 
+const xaiModel = XAI.configure({ apiKey: "test", baseURL: "https://api.x.ai/v1" }).responses("grok-4.5")
+
 const request = LLM.request({
   id: "req_1",
   model,
@@ -524,7 +527,77 @@ describe("OpenAI Responses route", () => {
     }),
   )
 
-  it.effect("rejects non-image media in tool-result content with a clear error", () =>
+  it.effect("lowers PDF tool-result content as structured input_file array", () =>
+    Effect.gen(function* () {
+      const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
+        LLM.request({
+          id: "req_tool_result_pdf",
+          model,
+          messages: [
+            Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
+            Message.tool({
+              id: "call_1",
+              name: "read",
+              resultType: "content",
+              result: [
+                {
+                  type: "file",
+                  uri: "data:application/pdf;base64,JVBERi0xLjQ=",
+                  mime: "application/pdf",
+                  name: "report.pdf",
+                },
+              ],
+            }),
+          ],
+        }),
+      )
+
+      expect(expectToolOutput(prepared.body).output).toEqual([
+        {
+          type: "input_file",
+          filename: "report.pdf",
+          file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
+        },
+      ])
+    }),
+  )
+
+  it.effect("uses xAI inline file encoding for PDF tool results", () =>
+    Effect.gen(function* () {
+      const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
+        LLM.request({
+          model: xaiModel,
+          messages: [
+            Message.assistant([ToolCallPart.make({ id: "call_1", name: "read", input: {} })]),
+            Message.tool({
+              id: "call_1",
+              name: "read",
+              resultType: "content",
+              result: [
+                {
+                  type: "file",
+                  uri: "data:application/pdf;base64,JVBERi0xLjQ=",
+                  mime: "application/pdf",
+                  name: "report.pdf",
+                },
+              ],
+            }),
+          ],
+        }),
+      )
+
+      expect(expectToolOutput(prepared.body).output).toEqual([
+        {
+          type: "input_file",
+          filename: "report.pdf",
+          file_data: "JVBERi0xLjQ=",
+          mime_type: "application/pdf",
+        },
+      ])
+    }),
+  )
+
+  it.effect("rejects unsupported media in tool-result content with a clear error", () =>
     Effect.gen(function* () {
       const error = yield* LLMClient.prepare(
         LLM.request({
@@ -1526,20 +1599,64 @@ describe("OpenAI Responses route", () => {
     }),
   )
 
-  it.effect("lowers user image content", () =>
+  it.effect("lowers user image and PDF content", () =>
     Effect.gen(function* () {
       const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
         LLM.request({
           id: "req_media",
           model,
-          messages: [Message.user({ type: "media", mediaType: "image/png", data: "AAECAw==" })],
+          messages: [
+            Message.user([
+              { type: "media", mediaType: "image/png", data: "AAECAw==" },
+              { type: "media", mediaType: "application/pdf", data: "JVBERi0xLjQ=", filename: "report.pdf" },
+            ]),
+          ],
+        }),
+      )
+
+      expect(prepared.body.input).toEqual([
+        {
+          role: "user",
+          content: [
+            { type: "input_image", image_url: "data:image/png;base64,AAECAw==" },
+            {
+              type: "input_file",
+              filename: "report.pdf",
+              file_data: "data:application/pdf;base64,JVBERi0xLjQ=",
+            },
+          ],
+        },
+      ])
+    }),
+  )
+
+  it.effect("uses xAI inline file encoding for user PDFs", () =>
+    Effect.gen(function* () {
+      const prepared = yield* LLMClient.prepare<OpenAIResponses.OpenAIResponsesBody>(
+        LLM.request({
+          model: xaiModel,
+          messages: [
+            Message.user({
+              type: "media",
+              mediaType: "application/pdf",
+              data: "data:application/pdf;base64,JVBERi0xLjQ=",
+              filename: "report.pdf",
+            }),
+          ],
         }),
       )
 
       expect(prepared.body.input).toEqual([
         {
           role: "user",
-          content: [{ type: "input_image", image_url: "data:image/png;base64,AAECAw==" }],
+          content: [
+            {
+              type: "input_file",
+              filename: "report.pdf",
+              file_data: "JVBERi0xLjQ=",
+              mime_type: "application/pdf",
+            },
+          ],
         },
       ])
     }),
@@ -1551,11 +1668,11 @@ describe("OpenAI Responses route", () => {
         LLM.request({
           id: "req_media",
           model,
-          messages: [Message.user({ type: "media", mediaType: "application/pdf", data: "AAECAw==" })],
+          messages: [Message.user({ type: "media", mediaType: "application/x-tar", data: "AAECAw==" })],
         }),
       ).pipe(Effect.flip)
 
-      expect(error.message).toContain("OpenAI Responses does not support media type application/pdf")
+      expect(error.message).toContain("OpenAI Responses does not support media type application/x-tar")
     }),
   )
 

+ 207 - 0
packages/ai/test/provider/pdf.recorded.test.ts

@@ -0,0 +1,207 @@
+import { describe, expect } from "bun:test"
+import { Effect, Schema, Stream } from "effect"
+import { LLM, LLMResponse, Message, ToolDefinition, type Model } from "../../src"
+import { AmazonBedrock, Anthropic, Google, OpenAI, XAI } from "../../src/providers"
+import { LLMClient } from "../../src/route"
+import { Tool } from "../../src/tool"
+import { runTools } from "../lib/tool-runtime"
+import { recordedTests } from "../recorded-test"
+
+const CODE = "ORCHID-7391"
+const PDF =
+  "JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA3NSA+PgpzdHJlYW0KQlQKL0YxIDE4IFRmCjcyIDcyMCBUZAooUERGIGNhc3NldHRlIHZlcmlmaWNhdGlvbiBjb2RlOiBPUkNISUQtNzM5MSkgVGoKRVQKZW5kc3RyZWFtCmVuZG9iago1IDAgb2JqCjw8IC9UeXBlIC9Gb250IC9TdWJ0eXBlIC9UeXBlMSAvQmFzZUZvbnQgL0hlbHZldGljYSA+PgplbmRvYmoKeHJlZgowIDYKMDAwMDAwMDAwMCA2NTUzNSBmIAowMDAwMDAwMDA5IDAwMDAwIG4gCjAwMDAwMDAwNTggMDAwMDAgbiAKMDAwMDAwMDExNSAwMDAwMCBuIAowMDAwMDAwMjQxIDAwMDAwIG4gCjAwMDAwMDAzNjUgMDAwMDAgbiAKdHJhaWxlcgo8PCAvU2l6ZSA2IC9Sb290IDEgMCBSID4+CnN0YXJ0eHJlZgo0MzUKJSVFT0YK"
+
+const openai = OpenAI.configure({ apiKey: process.env.OPENAI_API_KEY ?? "fixture" })
+const anthropic = Anthropic.configure({ apiKey: process.env.ANTHROPIC_API_KEY ?? "fixture" })
+const google = Google.configure({ apiKey: process.env.GOOGLE_API_KEY ?? "fixture" })
+const xai = XAI.configure({ apiKey: process.env.XAI_API_KEY ?? "fixture" })
+const bedrock = AmazonBedrock.configure({
+  apiKey: process.env.AWS_BEDROCK_API_KEY ?? "fixture",
+  region: process.env.AWS_REGION ?? "us-east-1",
+})
+
+const targets: ReadonlyArray<{
+  readonly id: string
+  readonly name: string
+  readonly provider: string
+  readonly protocol: string
+  readonly requires: string
+  readonly filename: string
+  readonly maxTokens: number
+  readonly model: Model
+}> = [
+  {
+    id: "openai",
+    name: "OpenAI Responses gpt-4o-mini",
+    provider: "openai",
+    protocol: "openai-responses",
+    requires: "OPENAI_API_KEY",
+    filename: "verification.pdf",
+    maxTokens: 40,
+    model: openai.responses("gpt-4o-mini"),
+  },
+  {
+    id: "anthropic",
+    name: "Anthropic Haiku 4.5",
+    provider: "anthropic",
+    protocol: "anthropic-messages",
+    requires: "ANTHROPIC_API_KEY",
+    filename: "verification.pdf",
+    maxTokens: 40,
+    model: anthropic.model("claude-haiku-4-5-20251001"),
+  },
+  {
+    id: "gemini",
+    name: "Gemini 3.5 Flash",
+    provider: "google",
+    protocol: "gemini",
+    requires: "GOOGLE_API_KEY",
+    filename: "verification.pdf",
+    maxTokens: 256,
+    model: google.model("gemini-3.5-flash"),
+  },
+  {
+    id: "xai",
+    name: "xAI Grok 4.5",
+    provider: "xai",
+    protocol: "openai-responses",
+    requires: "XAI_API_KEY",
+    filename: "verification.pdf",
+    maxTokens: 40,
+    model: xai.responses("grok-4.5"),
+  },
+  {
+    id: "bedrock",
+    name: "Bedrock Claude Haiku 4.5",
+    provider: "amazon-bedrock",
+    protocol: "bedrock-converse",
+    requires: "AWS_BEDROCK_API_KEY",
+    filename: "verification",
+    maxTokens: 40,
+    model: bedrock.model("us.anthropic.claude-haiku-4-5-20251001-v1:0"),
+  },
+]
+
+const recorded = recordedTests({ prefix: "pdf", tags: ["pdf"] })
+const prompt = "Return only the verification code from the PDF."
+const readPdf = ToolDefinition.make({
+  name: "read_pdf",
+  description: "Read the attached PDF.",
+  inputSchema: { type: "object", properties: {}, additionalProperties: false },
+})
+const readPdfRuntime = Tool.make({
+  description: readPdf.description,
+  parameters: Schema.Struct({ path: Schema.String }),
+  success: Schema.String,
+  execute: () => Effect.succeed("PDF read successfully"),
+  toModelOutput: () => [
+    { type: "text", text: "PDF read successfully" },
+    {
+      type: "file",
+      uri: `data:application/pdf;base64,${PDF}`,
+      mime: "application/pdf",
+      name: "verification.pdf",
+    },
+  ],
+})
+
+const expectCode = (response: LLMResponse) => {
+  expect(response.finishReason).toBe("stop")
+  expect(response.text.toUpperCase()).toContain(CODE)
+}
+
+describe("PDF recorded", () => {
+  for (const target of targets) {
+    recorded.effect.with(
+      `reads a user PDF with ${target.name}`,
+      {
+        id: `${target.id}-user-input`,
+        provider: target.provider,
+        protocol: target.protocol,
+        requires: [target.requires],
+        tags: ["user-input"],
+      },
+      Effect.gen(function* () {
+        expectCode(
+          yield* LLMClient.generate(
+            LLM.request({
+              id: `recorded_pdf_${target.id}_user_input`,
+              model: target.model,
+              cache: "none",
+              generation: { maxTokens: target.maxTokens, temperature: 0 },
+              messages: [
+                Message.user([
+                  { type: "media", mediaType: "application/pdf", data: PDF, filename: target.filename },
+                  { type: "text", text: prompt },
+                ]),
+              ],
+            }),
+          ),
+        )
+      }),
+    )
+
+    recorded.effect.with(
+      `reads a PDF tool result with ${target.name}`,
+      {
+        id: `${target.id}-tool-result`,
+        provider: target.provider,
+        protocol: target.protocol,
+        requires: [target.requires],
+        tags: ["tool", "tool-result"],
+      },
+      Effect.gen(function* () {
+        if (target.id === "gemini") {
+          const events = Array.from(
+            yield* runTools({
+              request: LLM.request({
+                id: "recorded_pdf_gemini_tool_result",
+                model: target.model,
+                system:
+                  "Call read_pdf exactly once with path verification.pdf, then reply only with the verification code from its PDF.",
+                prompt: "Use read_pdf with path verification.pdf and return the verification code.",
+                cache: "none",
+                generation: { maxTokens: target.maxTokens, temperature: 0 },
+              }),
+              tools: { read_pdf: readPdfRuntime },
+            }).pipe(Stream.runCollect),
+          )
+          expect(events.at(-1)).toMatchObject({ type: "finish", reason: "stop" })
+          expect(LLMResponse.text({ events }).toUpperCase()).toContain(CODE)
+          return
+        }
+
+        expectCode(
+          yield* LLMClient.generate(
+            LLM.request({
+              id: `recorded_pdf_${target.id}_tool_result`,
+              model: target.model,
+              system: "Read the PDF returned by the tool and follow the user's response format exactly.",
+              cache: "none",
+              generation: { maxTokens: target.maxTokens, temperature: 0 },
+              messages: [
+                Message.user(prompt),
+                Message.assistant([{ type: "tool-call", id: "call_pdf_1", name: readPdf.name, input: {} }]),
+                Message.tool({
+                  id: "call_pdf_1",
+                  name: readPdf.name,
+                  resultType: "content",
+                  result: [
+                    { type: "text", text: "PDF read successfully" },
+                    {
+                      type: "file",
+                      uri: `data:application/pdf;base64,${PDF}`,
+                      mime: "application/pdf",
+                      name: target.filename,
+                    },
+                  ],
+                }),
+              ],
+              tools: [readPdf],
+            }),
+          ),
+        )
+      }),
+    )
+  }
+})

+ 45 - 0
packages/ai/test/tool-runtime.test.ts

@@ -183,6 +183,51 @@ describe("LLMClient tools", () => {
     }),
   )
 
+  it.effect("preserves provider metadata on dispatched tool results", () =>
+    Effect.gen(function* () {
+      const tool = Tool.make({
+        description: "Return text.",
+        parameters: Schema.Struct({}),
+        success: Schema.String,
+        execute: () => Effect.succeed("hello"),
+      })
+      const providerMetadata = { google: { functionCallId: "provider_call" } }
+      const dispatched = yield* ToolRuntime.dispatch(
+        { tool },
+        LLMEvent.toolCall({ id: "call_1", name: "tool", input: {}, providerMetadata }),
+      )
+
+      expect(dispatched.events).toEqual([
+        LLMEvent.toolResult({
+          id: "call_1",
+          name: "tool",
+          result: { type: "text", value: "hello" },
+          output: { structured: "hello", content: [{ type: "text", text: "hello" }] },
+          providerMetadata,
+        }),
+      ])
+
+      const failed = yield* ToolRuntime.dispatch(
+        {},
+        LLMEvent.toolCall({ id: "call_2", name: "missing", input: {}, providerMetadata }),
+      )
+      expect(failed.events).toEqual([
+        LLMEvent.toolError({
+          id: "call_2",
+          name: "missing",
+          message: "Unknown tool: missing",
+          providerMetadata,
+        }),
+        LLMEvent.toolResult({
+          id: "call_2",
+          name: "missing",
+          result: { type: "error", value: "Unknown tool: missing" },
+          providerMetadata,
+        }),
+      ])
+    }),
+  )
+
   it.effect("uses the narrow default projection for encoded typed success", () =>
     Effect.gen(function* () {
       const text = Tool.make({

部分文件因文件數量過多而無法顯示