diff --git a/core/providers/bedrock/bedrock_test.go b/core/providers/bedrock/bedrock_test.go index 9d413a1f4b..8d003770fd 100644 --- a/core/providers/bedrock/bedrock_test.go +++ b/core/providers/bedrock/bedrock_test.go @@ -2,6 +2,7 @@ package bedrock_test import ( "context" + "encoding/base64" "encoding/json" "os" "strings" @@ -4552,6 +4553,216 @@ func TestDocumentFormatMapping(t *testing.T) { } } +// chatFileBlockDocument converts a single OpenAI-style file content block and +// returns the resulting Bedrock document. +func chatFileBlockDocument(t *testing.T, file *schemas.ChatInputFile) *bedrock.BedrockDocumentSource { + t.Helper() + + bifrostReq := &schemas.BifrostChatRequest{ + Provider: schemas.Bedrock, + Model: "anthropic.claude-sonnet-4-5-20250929-v1:0", + Input: []schemas.ChatMessage{ + { + Role: schemas.ChatMessageRoleUser, + Content: &schemas.ChatMessageContent{ + ContentBlocks: []schemas.ChatContentBlock{ + {Type: schemas.ChatContentBlockTypeText, Text: schemas.Ptr("Summarize this document.")}, + {Type: schemas.ChatContentBlockTypeFile, File: file}, + }, + }, + }, + }, + } + + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + result, err := bedrock.ToBedrockChatCompletionRequest(ctx, bifrostReq) + require.NoError(t, err) + require.Len(t, result.Messages, 1) + require.Len(t, result.Messages[0].Content, 2) + require.NotNil(t, result.Messages[0].Content[1].Document) + + return result.Messages[0].Content[1].Document +} + +// The standard OpenAI chat `type:"file"` part carries the document's MIME type only +// inside the file_data data URL - file_type is a Bifrost extension normal clients +// don't send. Without reading it, every non-PDF document was labeled format "pdf" +// and Bedrock rejected it with "The PDF specified was not valid". +func TestDocumentFormatFromDataURL(t *testing.T) { + t.Parallel() + + const payload = "UEsDBBQABgAI" + + tests := []struct { + name string + mediaType string + filename string + expectedFormat string + }{ + {"XLSX", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "sheet.xlsx", "xlsx"}, + {"DOCX", "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "report.docx", "docx"}, + {"XLS", "application/vnd.ms-excel", "legacy.xls", "xls"}, + {"DOC", "application/msword", "legacy.doc", "doc"}, + {"CSV", "text/csv", "rows.csv", "csv"}, + {"Markdown", "text/markdown", "notes.md", "md"}, + {"PDF", "application/pdf", "paper.pdf", "pdf"}, + {"MediaTypeWithParameter", "text/plain;charset=utf-8", "notes.txt", "txt"}, + {"UppercaseMediaType", "APPLICATION/PDF", "paper.pdf", "pdf"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + doc := chatFileBlockDocument(t, &schemas.ChatInputFile{ + Filename: schemas.Ptr(tt.filename), + FileData: schemas.Ptr("data:" + tt.mediaType + ";base64," + payload), + }) + + assert.Equal(t, tt.expectedFormat, doc.Format, + "data URL media type %q should map to format %q", tt.mediaType, tt.expectedFormat) + require.NotNil(t, doc.Source.Bytes) + assert.Equal(t, payload, *doc.Source.Bytes, "data URL prefix must be stripped from source.bytes") + }) + } +} + +// Format resolution order: file_type, then the data URL media type, then the +// filename extension, then the historical "pdf" default. +func TestDocumentFormatResolutionPrecedence(t *testing.T) { + t.Parallel() + + t.Run("FileTypeWinsOverDataURL", func(t *testing.T) { + doc := chatFileBlockDocument(t, &schemas.ChatInputFile{ + Filename: schemas.Ptr("sheet.xlsx"), + FileType: schemas.Ptr("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"), + FileData: schemas.Ptr("data:application/octet-stream;base64,UEsDBBQABgAI"), + }) + assert.Equal(t, "xlsx", doc.Format) + }) + + t.Run("FilenameExtensionWhenMediaTypeIsOpaque", func(t *testing.T) { + doc := chatFileBlockDocument(t, &schemas.ChatInputFile{ + Filename: schemas.Ptr("report.docx"), + FileData: schemas.Ptr("data:application/octet-stream;base64,UEsDBBQABgAI"), + }) + assert.Equal(t, "docx", doc.Format) + }) + + t.Run("UnidentifiableDocumentKeepsPDFDefault", func(t *testing.T) { + doc := chatFileBlockDocument(t, &schemas.ChatInputFile{ + Filename: schemas.Ptr("blob"), + FileData: schemas.Ptr("data:application/octet-stream;base64,UEsDBBQABgAI"), + }) + assert.Equal(t, "pdf", doc.Format) + }) +} + +// A non-base64 data URL carries percent-encoded text, not base64 - sending it +// verbatim as source.bytes shipped the whole "data:..." string to Bedrock. +func TestDocumentInlineTextDataURL(t *testing.T) { + t.Parallel() + + doc := chatFileBlockDocument(t, &schemas.ChatInputFile{ + Filename: schemas.Ptr("notes.txt"), + FileData: schemas.Ptr("data:text/plain,Hello%20World"), + }) + + assert.Equal(t, "txt", doc.Format) + require.NotNil(t, doc.Source.Text) + assert.Equal(t, "Hello World", *doc.Source.Text) + require.NotNil(t, doc.Source.Bytes) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("Hello World")), *doc.Source.Bytes) + + // A binary format never gets source.text, matching the raw file_data path. + doc = chatFileBlockDocument(t, &schemas.ChatInputFile{ + Filename: schemas.Ptr("paper.pdf"), + FileData: schemas.Ptr("data:application/pdf,%25PDF-1.4"), + }) + + assert.Equal(t, "pdf", doc.Format) + assert.Nil(t, doc.Source.Text, "binary documents must not carry source.text") + require.NotNil(t, doc.Source.Bytes) + assert.Equal(t, base64.StdEncoding.EncodeToString([]byte("%PDF-1.4")), *doc.Source.Bytes) +} + +// The Responses path had its own copy of the format mapping with the same defect. +func TestToBedrockResponsesRequest_DocumentFormatFromDataURL(t *testing.T) { + t.Parallel() + + const payload = "UEsDBBQABgAI" + bifrostReq := &schemas.BifrostResponsesRequest{ + Provider: schemas.Bedrock, + Model: "anthropic.claude-sonnet-4-5-20250929-v1:0", + Input: []schemas.ResponsesMessage{ + { + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + {Type: schemas.ResponsesInputMessageContentBlockTypeText, Text: schemas.Ptr("Summarize this document.")}, + { + Type: schemas.ResponsesInputMessageContentBlockTypeFile, + ResponsesInputMessageContentBlockFile: &schemas.ResponsesInputMessageContentBlockFile{ + Filename: schemas.Ptr("sheet.xlsx"), + FileData: schemas.Ptr("data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64," + payload), + }, + }, + }, + }, + }, + }, + } + + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + result, err := bedrock.ToBedrockResponsesRequest(ctx, bifrostReq) + require.NoError(t, err) + require.Len(t, result.Messages, 1) + + var doc *bedrock.BedrockDocumentSource + for _, contentBlock := range result.Messages[0].Content { + if contentBlock.Document != nil { + doc = contentBlock.Document + } + } + require.NotNil(t, doc) + assert.Equal(t, "xlsx", doc.Format) + require.NotNil(t, doc.Source.Bytes) + assert.Equal(t, payload, *doc.Source.Bytes) +} + +// The Responses path ignored file_url entirely, emitting a document block with an +// empty source. It now inlines the bytes like the chat path does, so an unreachable +// URL surfaces as an error instead of silently shipping an empty document. +func TestToBedrockResponsesRequest_DocumentFileURLIsFetched(t *testing.T) { + t.Parallel() + + bifrostReq := &schemas.BifrostResponsesRequest{ + Provider: schemas.Bedrock, + Model: "anthropic.claude-sonnet-4-5-20250929-v1:0", + Input: []schemas.ResponsesMessage{ + { + Type: schemas.Ptr(schemas.ResponsesMessageTypeMessage), + Role: schemas.Ptr(schemas.ResponsesInputMessageRoleUser), + Content: &schemas.ResponsesMessageContent{ + ContentBlocks: []schemas.ResponsesMessageContentBlock{ + {Type: schemas.ResponsesInputMessageContentBlockTypeText, Text: schemas.Ptr("Summarize this document.")}, + { + Type: schemas.ResponsesInputMessageContentBlockTypeFile, + ResponsesInputMessageContentBlockFile: &schemas.ResponsesInputMessageContentBlockFile{ + Filename: schemas.Ptr("sheet.xlsx"), + FileURL: schemas.Ptr("http://127.0.0.1:1/sheet.xlsx"), + }, + }, + }, + }, + }, + }, + } + + ctx := schemas.NewBifrostContext(context.Background(), schemas.NoDeadline) + _, err := bedrock.ToBedrockResponsesRequest(ctx, bifrostReq) + require.Error(t, err, "file_url must be fetched, not silently dropped") +} + func TestBedrockStopReasonMapping(t *testing.T) { t.Parallel() diff --git a/core/providers/bedrock/responses.go b/core/providers/bedrock/responses.go index 5288e23a9c..ca20d63619 100644 --- a/core/providers/bedrock/responses.go +++ b/core/providers/bedrock/responses.go @@ -6,6 +6,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "net/url" "strings" "sync" "time" @@ -3266,10 +3267,12 @@ func ConvertBifrostMessagesToBedrockMessages(ctx context.Context, bifrostMessage if len(bifrostMessages) == 1 && bifrostMessages[0].Role != nil && (*bifrostMessages[0].Role == schemas.ResponsesInputMessageRoleSystem || *bifrostMessages[0].Role == schemas.ResponsesInputMessageRoleDeveloper) { msg := bifrostMessages[0] msg.Role = schemas.Ptr(schemas.ResponsesInputMessageRoleUser) - if bedrockMsg := convertBifrostMessageToBedrockMessage(ctx, &msg); bedrockMsg != nil { - if len(bedrockMsg.Content) > 0 { - return []BedrockMessage{*bedrockMsg}, nil, nil - } + bedrockMsg, err := convertBifrostMessageToBedrockMessage(ctx, &msg) + if err != nil { + return nil, nil, err + } + if bedrockMsg != nil && len(bedrockMsg.Content) > 0 { + return []BedrockMessage{*bedrockMsg}, nil, nil } } @@ -3660,7 +3663,10 @@ func ConvertBifrostMessagesToBedrockMessages(ctx context.Context, bifrostMessage } } else { // Convert user/assistant text message - bedrockMsg := convertBifrostMessageToBedrockMessage(ctx, &msg) + bedrockMsg, err := convertBifrostMessageToBedrockMessage(ctx, &msg) + if err != nil { + return nil, nil, err + } if bedrockMsg != nil { // Prepend buffered server-managed tool blocks (nova_grounding / nova_code_interpreter) // to the assistant message they belong to — they're part of the same turn. @@ -3982,11 +3988,13 @@ func convertBifrostSystemReminderToBedrockUserMessage(msg *schemas.ResponsesMess } // convertBifrostMessageToBedrockMessage converts a regular Bifrost message to Bedrock message. -// The ctx is propagated to URL fetches inside content blocks. -func convertBifrostMessageToBedrockMessage(ctx context.Context, msg *schemas.ResponsesMessage) *BedrockMessage { +// The ctx is propagated to URL fetches inside content blocks. A conversion failure +// (e.g. an image or document URL that can't be fetched) is returned rather than +// swallowed - dropping the message would send Bedrock a request missing the turn. +func convertBifrostMessageToBedrockMessage(ctx context.Context, msg *schemas.ResponsesMessage) (*BedrockMessage, error) { // Ensure Content is present if msg.Content == nil { - return nil + return nil, nil } bedrockMsg := BedrockMessage{ @@ -3996,11 +4004,11 @@ func convertBifrostMessageToBedrockMessage(ctx context.Context, msg *schemas.Res // Convert content contentBlocks, err := convertBifrostResponsesMessageContentBlocksToBedrockContentBlocks(ctx, *msg.Content) if err != nil { - return nil + return nil, err } bedrockMsg.Content = contentBlocks - return &bedrockMsg + return &bedrockMsg, nil } // convertBedrockSystemMessageToBifrostMessages converts a Bedrock system message to Bifrost messages @@ -4669,7 +4677,7 @@ func convertBifrostResponsesMessageContentBlocksToBedrockContentBlocks(ctx conte // (only from/to model names), so skip it entirely. continue case schemas.ResponsesInputMessageContentBlockTypeFile: - if block.ResponsesInputMessageContentBlockFile != nil { + if file := block.ResponsesInputMessageContentBlockFile; file != nil { doc := &BedrockDocumentSource{ Name: "document", // Default Format: "pdf", // Default @@ -4677,53 +4685,75 @@ func convertBifrostResponsesMessageContentBlocksToBedrockContentBlocks(ctx conte } // Set filename (normalized for Bedrock) - if block.ResponsesInputMessageContentBlockFile.Filename != nil { - doc.Name = normalizeBedrockFilename(*block.ResponsesInputMessageContentBlockFile.Filename) + if file.Filename != nil { + doc.Name = normalizeBedrockFilename(*file.Filename) + } + + // Parse the data URL once; it carries both the payload and (for + // standard OpenAI clients, which have no file_type field) the + // document's MIME type. + dataURLMediaType, dataURLPayload := "", "" + dataURLIsBase64, isDataURL := false, false + if file.FileData != nil && strings.HasPrefix(*file.FileData, "data:") { + dataURLMediaType, dataURLIsBase64, dataURLPayload, isDataURL = schemas.ParseDataURL(*file.FileData) } - // Determine format: text or PDF based on FileType - isTextFile := false - if block.ResponsesInputMessageContentBlockFile.FileType != nil { - fileType := *block.ResponsesInputMessageContentBlockFile.FileType - // Check if it's a text type - if fileType == "text/markdown" || fileType == "md" { - doc.Format = "md" - isTextFile = true - } else if fileType == "text/html" || fileType == "html" { - doc.Format = "html" - isTextFile = true - } else if fileType == "text/csv" || fileType == "csv" { - doc.Format = "csv" - isTextFile = true - } else if strings.HasPrefix(fileType, "text/") || fileType == "txt" { - doc.Format = "txt" - isTextFile = true - } else if strings.Contains(fileType, "pdf") || fileType == "pdf" { - doc.Format = "pdf" - } else if strings.Contains(fileType, "spreadsheetml") || fileType == "xlsx" { - doc.Format = "xlsx" - } else if fileType == "application/vnd.ms-excel" || fileType == "xls" { - doc.Format = "xls" - } else if strings.Contains(fileType, "wordprocessingml") || fileType == "docx" { - doc.Format = "docx" - } else if fileType == "application/msword" || fileType == "doc" { - doc.Format = "doc" + // Resolve the document format, most authoritative hint first. Falls + // back to the "pdf" default only when nothing identifies the document. + format, isTextFile := "", false + if file.FileType != nil { + format, isTextFile, _ = bedrockDocumentFormat(*file.FileType) + } + if format == "" && isDataURL { + format, isTextFile, _ = bedrockDocumentFormat(dataURLMediaType) + } + if format == "" && file.Filename != nil { + if dot := strings.LastIndex(*file.Filename, "."); dot >= 0 { + format, isTextFile, _ = bedrockDocumentFormat((*file.Filename)[dot+1:]) } } + if format != "" { + doc.Format = format + } + + // URL-sourced document: fetch and inline the bytes (Bedrock Converse + // only accepts inline source bytes, not remote URLs). + if file.FileURL != nil && *file.FileURL != "" { + fetchedMediaType, fetchedB64, fetchErr := providerUtils.FetchAndEncodeURL(ctx, *file.FileURL) + if fetchErr != nil { + return nil, fetchErr + } + // Refine format from response Content-Type when present (more + // reliable than file extension or upstream-declared media type). + if fetchedFormat, _, ok := bedrockDocumentFormat(fetchedMediaType); ok { + doc.Format = fetchedFormat + } + doc.Source.Bytes = &fetchedB64 + bedrockBlock.Document = doc + break + } // Handle file data - if block.ResponsesInputMessageContentBlockFile.FileData != nil { - fileData := *block.ResponsesInputMessageContentBlockFile.FileData + if file.FileData != nil { + fileData := *file.FileData // Check if it's a data URL (e.g., "data:application/pdf;base64,...") - if strings.HasPrefix(fileData, "data:") { - urlInfo := schemas.ExtractURLTypeInfo(fileData) - if urlInfo.DataURLWithoutPrefix != nil { - // PDF or other binary - keep as base64 - doc.Source.Bytes = urlInfo.DataURLWithoutPrefix - bedrockBlock.Document = doc - break + if isDataURL { + if dataURLIsBase64 { + doc.Source.Bytes = &dataURLPayload + } else { + // Inline percent-encoded payload (data:text/plain,Hello%20World) + if decoded, err := url.PathUnescape(dataURLPayload); err == nil { + dataURLPayload = decoded + } + if isTextFile { + doc.Source.Text = &dataURLPayload + } + encoded := base64.StdEncoding.EncodeToString([]byte(dataURLPayload)) + doc.Source.Bytes = &encoded } + bedrockBlock.Document = doc + break } // Not a data URL - use as-is diff --git a/core/providers/bedrock/utils.go b/core/providers/bedrock/utils.go index 54c3c0d54b..b2edcc6b11 100644 --- a/core/providers/bedrock/utils.go +++ b/core/providers/bedrock/utils.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "mime" + "net/url" "regexp" "strings" @@ -188,6 +189,48 @@ func normalizeBedrockFilename(filename string) string { return normalized } +// bedrockDocumentFormat maps a MIME type or bare file extension to a Bedrock Converse +// document format. Media type parameters (e.g. "; charset=utf-8") are ignored. ok is +// false when the input maps to no format Bedrock supports, so callers can fall through +// to the next available hint. +func bedrockDocumentFormat(fileType string) (format string, isText bool, ok bool) { + fileType = strings.ToLower(strings.TrimSpace(fileType)) + if mediaType, _, err := mime.ParseMediaType(fileType); err == nil { + fileType = mediaType + } else if idx := strings.Index(fileType, ";"); idx >= 0 { + fileType = strings.TrimSpace(fileType[:idx]) + } + fileType = strings.TrimPrefix(fileType, ".") + + switch fileType { + case "text/plain", "txt": + return "txt", true, true + case "text/markdown", "md": + return "md", true, true + case "text/html", "html", "htm": + return "html", true, true + case "text/csv", "csv": + return "csv", true, true + case "application/msword", "doc": + return "doc", false, true + case "application/vnd.ms-excel", "xls": + return "xls", false, true + } + + switch { + case strings.Contains(fileType, "wordprocessingml") || fileType == "docx": + return "docx", false, true + case strings.Contains(fileType, "spreadsheetml") || fileType == "xlsx": + return "xlsx", false, true + case strings.Contains(fileType, "pdf"): + return "pdf", false, true + case strings.HasPrefix(fileType, "text/"): + return "txt", true, true + } + + return "", false, false +} + // bedrockAliasToolName returns a Bedrock-safe tool name and records a reverse mapping. func bedrockAliasToolName(ctx context.Context, name string) string { if len(name) <= 64 && !bedrockUnsafeToolNameCharRegex.MatchString(name) { @@ -1192,35 +1235,31 @@ func convertContentBlock(ctx context.Context, block schemas.ChatContentBlock) ([ documentSource.Name = normalizeBedrockFilename(*block.File.Filename) } - // Convert MIME type to Bedrock format - isText := false + // Parse the data URL once; it carries both the payload and (for standard + // OpenAI clients, which have no file_type field) the document's MIME type. + dataURLMediaType, dataURLPayload := "", "" + dataURLIsBase64, isDataURL := false, false + if block.File.FileData != nil && strings.HasPrefix(*block.File.FileData, "data:") { + dataURLMediaType, dataURLIsBase64, dataURLPayload, isDataURL = schemas.ParseDataURL(*block.File.FileData) + } + + // Resolve the document format, most authoritative hint first. Falls back to + // the "pdf" default only when nothing identifies the document. + format, isText := "", false if block.File.FileType != nil { - fileType := *block.File.FileType - switch { - case fileType == "text/plain" || fileType == "txt": - documentSource.Format = "txt" - isText = true - case fileType == "text/markdown" || fileType == "md": - documentSource.Format = "md" - isText = true - case fileType == "text/html" || fileType == "html": - documentSource.Format = "html" - isText = true - case fileType == "text/csv" || fileType == "csv": - documentSource.Format = "csv" - isText = true - case fileType == "application/msword" || fileType == "doc": - documentSource.Format = "doc" - case strings.Contains(fileType, "wordprocessingml") || fileType == "docx": - documentSource.Format = "docx" - case fileType == "application/vnd.ms-excel" || fileType == "xls": - documentSource.Format = "xls" - case strings.Contains(fileType, "spreadsheetml") || fileType == "xlsx": - documentSource.Format = "xlsx" - case strings.Contains(fileType, "pdf") || fileType == "pdf": - documentSource.Format = "pdf" + format, isText, _ = bedrockDocumentFormat(*block.File.FileType) + } + if format == "" && isDataURL { + format, isText, _ = bedrockDocumentFormat(dataURLMediaType) + } + if format == "" && block.File.Filename != nil { + if dot := strings.LastIndex(*block.File.Filename, "."); dot >= 0 { + format, isText, _ = bedrockDocumentFormat((*block.File.Filename)[dot+1:]) } } + if format != "" { + documentSource.Format = format + } // URL-sourced document: fetch and inline the bytes (Bedrock Converse only // accepts inline source bytes, not remote URLs). @@ -1230,34 +1269,9 @@ func convertContentBlock(ctx context.Context, block schemas.ChatContentBlock) ([ return nil, fetchErr } // Refine format from response Content-Type when present (more reliable - // than file extension or upstream-declared media type). Normalize to - // strip parameters (e.g. "; charset=utf-8") and lowercase the base type. - if mt, _, err := mime.ParseMediaType(fetchedMediaType); err == nil { - fetchedMediaType = mt - } - switch fetchedMediaType { - case "application/pdf": - documentSource.Format = "pdf" - case "text/plain": - documentSource.Format = "txt" - isText = true - case "text/markdown": - documentSource.Format = "md" - isText = true - case "text/html": - documentSource.Format = "html" - isText = true - case "text/csv": - documentSource.Format = "csv" - isText = true - case "application/msword": - documentSource.Format = "doc" - case "application/vnd.openxmlformats-officedocument.wordprocessingml.document": - documentSource.Format = "docx" - case "application/vnd.ms-excel": - documentSource.Format = "xls" - case "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": - documentSource.Format = "xlsx" + // than file extension or upstream-declared media type). + if fetchedFormat, _, ok := bedrockDocumentFormat(fetchedMediaType); ok { + documentSource.Format = fetchedFormat } documentSource.Source.Bytes = &fetchedB64 return []BedrockContentBlock{ @@ -1271,17 +1285,25 @@ func convertContentBlock(ctx context.Context, block schemas.ChatContentBlock) ([ if block.File.FileData != nil { fileData := *block.File.FileData - // Check if it's a data URL and extract raw base64 - if strings.HasPrefix(fileData, "data:") { - urlInfo := schemas.ExtractURLTypeInfo(fileData) - if urlInfo.DataURLWithoutPrefix != nil { - documentSource.Source.Bytes = urlInfo.DataURLWithoutPrefix - return []BedrockContentBlock{ - { - Document: documentSource, - }, - }, nil + if isDataURL { + if dataURLIsBase64 { + documentSource.Source.Bytes = &dataURLPayload + } else { + // Inline percent-encoded payload (data:text/plain,Hello%20World) + if decoded, err := url.PathUnescape(dataURLPayload); err == nil { + dataURLPayload = decoded + } + if isText { + documentSource.Source.Text = &dataURLPayload + } + encoded := base64.StdEncoding.EncodeToString([]byte(dataURLPayload)) + documentSource.Source.Bytes = &encoded } + return []BedrockContentBlock{ + { + Document: documentSource, + }, + }, nil } // Set text or bytes based on file type diff --git a/core/providers/xai/xai.go b/core/providers/xai/xai.go index 3a638378aa..79a4188463 100644 --- a/core/providers/xai/xai.go +++ b/core/providers/xai/xai.go @@ -128,7 +128,7 @@ func (provider *XAIProvider) TextCompletionStream(ctx *schemas.BifrostContext, p // ChatCompletion performs a chat completion request to the xAI API. func (provider *XAIProvider) ChatCompletion(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostChatRequest) (*schemas.BifrostChatResponse, *schemas.BifrostError) { - return openai.HandleOpenAIChatCompletionRequest( + response, bifrostErr := openai.HandleOpenAIChatCompletionRequest( ctx, provider.client, provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/chat/completions"), @@ -143,6 +143,11 @@ func (provider *XAIProvider) ChatCompletion(ctx *schemas.BifrostContext, key sch nil, provider.logger, ) + if bifrostErr != nil { + return nil, bifrostErr + } + response.Usage.NormalizeProviderCost() + return response, nil } // ChatCompletionStream performs a streaming chat completion request to the xAI API. @@ -175,7 +180,7 @@ func (provider *XAIProvider) ChatCompletionStream(ctx *schemas.BifrostContext, p // Responses performs a responses request to the xAI API. func (provider *XAIProvider) Responses(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostResponsesRequest) (*schemas.BifrostResponsesResponse, *schemas.BifrostError) { - return openai.HandleOpenAIResponsesRequest( + response, bifrostErr := openai.HandleOpenAIResponsesRequest( ctx, provider.client, provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/responses"), @@ -190,6 +195,11 @@ func (provider *XAIProvider) Responses(ctx *schemas.BifrostContext, key schemas. nil, provider.logger, ) + if bifrostErr != nil { + return nil, bifrostErr + } + response.Usage.NormalizeProviderCost() + return response, nil } // ResponsesStream performs a streaming responses request to the xAI API. @@ -253,7 +263,7 @@ func (provider *XAIProvider) TranscriptionStream(ctx *schemas.BifrostContext, po // ImageGeneration performs an image generation request to the xAI API. func (provider *XAIProvider) ImageGeneration(ctx *schemas.BifrostContext, key schemas.Key, request *schemas.BifrostImageGenerationRequest) (*schemas.BifrostImageGenerationResponse, *schemas.BifrostError) { - return openai.HandleOpenAIImageGenerationRequest( + response, bifrostErr := openai.HandleOpenAIImageGenerationRequest( ctx, provider.client, provider.networkConfig.BaseURL+providerUtils.GetPathFromContext(ctx, "/v1/images/generations"), @@ -266,6 +276,11 @@ func (provider *XAIProvider) ImageGeneration(ctx *schemas.BifrostContext, key sc providerUtils.ShouldSendBackRawResponse(ctx, provider.sendBackRawResponse), provider.logger, ) + if bifrostErr != nil { + return nil, bifrostErr + } + response.Usage.NormalizeProviderCost() + return response, nil } // ImageGenerationStream is not supported by the xAI provider. diff --git a/core/schemas/chatcompletions.go b/core/schemas/chatcompletions.go index a64e2c5ca6..9a5ab3e54b 100644 --- a/core/schemas/chatcompletions.go +++ b/core/schemas/chatcompletions.go @@ -1660,6 +1660,8 @@ type BifrostLLMUsage struct { CompletionTokensDetails *ChatCompletionTokensDetails `json:"completion_tokens_details,omitempty"` TotalTokens int `json:"total_tokens"` Cost *BifrostCost `json:"cost,omitempty"` // Only for the providers which support cost calculation + // xAI-specific usage field, normalized into Cost by NormalizeProviderCost. + CostInUsdTicks *int64 `json:"cost_in_usd_ticks,omitempty"` // Served Anthropic tier (fast mode / data residency), carried internally so // cancel/timeout billing (which reads a bare usage via BilledUsage) can apply // the tier multiplier. json:"-" keeps them out of every serialized usage payload. @@ -1790,6 +1792,27 @@ func (bc *BifrostCost) UnmarshalJSON(data []byte) error { return fmt.Errorf("cost field is neither a float nor an object") } +// xAI reports request cost as cost_in_usd_ticks, where TICKS_IN_USD_CENT = 100_000_000, so 1 USD = 1e10 ticks. +const usdTicksPerUSD = 1e10 + +// costFromUSDTicks converts a tick count to a cost object, nil for missing or non-positive ticks. +func costFromUSDTicks(ticks *int64) *BifrostCost { + if ticks == nil || *ticks <= 0 { + return nil + } + return &BifrostCost{TotalCost: float64(*ticks) / usdTicksPerUSD} +} + +// NormalizeProviderCost derives the neutral Cost object from a provider-reported +// cost_in_usd_ticks so cost calculation only ever reads Cost. No-op when the +// provider already sent a cost or reported no ticks. +func (u *BifrostLLMUsage) NormalizeProviderCost() { + if u == nil || u.Cost != nil { + return + } + u.Cost = costFromUSDTicks(u.CostInUsdTicks) +} + type SearchResult struct { Title string `json:"title"` URL string `json:"url"` diff --git a/core/schemas/images.go b/core/schemas/images.go index 34279c0aa3..4cc13cb89b 100644 --- a/core/schemas/images.go +++ b/core/schemas/images.go @@ -203,6 +203,17 @@ type ImageUsage struct { OutputTokens int `json:"output_tokens,omitempty"` // Always image tokens unless OutputTokensDetails is not nil OutputTokensDetails *ImageTokenDetails `json:"output_tokens_details,omitempty"` NumInputImages int `json:"-"` // Number of input images from the request (populated by Bifrost) + Cost *BifrostCost `json:"cost,omitempty"` // Only for the providers which support cost calculation + // xAI-specific usage field, normalized into Cost by NormalizeProviderCost. + CostInUsdTicks *int64 `json:"cost_in_usd_ticks,omitempty"` +} + +// NormalizeProviderCost mirrors BifrostLLMUsage.NormalizeProviderCost for the image path. +func (u *ImageUsage) NormalizeProviderCost() { + if u == nil || u.Cost != nil { + return + } + u.Cost = costFromUSDTicks(u.CostInUsdTicks) } type ImageTokenDetails struct { @@ -227,6 +238,12 @@ func (u *ImageUsage) DeepCopy() *ImageUsage { details := *u.OutputTokensDetails out.OutputTokensDetails = &details } + if u.CostInUsdTicks != nil { + out.CostInUsdTicks = new(*u.CostInUsdTicks) + } + if u.Cost != nil { + out.Cost = new(*u.Cost) + } return &out } diff --git a/core/schemas/mux.go b/core/schemas/mux.go index 3212d079e2..4808988c1b 100644 --- a/core/schemas/mux.go +++ b/core/schemas/mux.go @@ -1001,10 +1001,11 @@ func (cu *BifrostLLMUsage) ToResponsesResponseUsage() *ResponsesResponseUsage { } usage := &ResponsesResponseUsage{ - InputTokens: cu.PromptTokens, - OutputTokens: cu.CompletionTokens, - TotalTokens: cu.TotalTokens, - Cost: cu.Cost, + InputTokens: cu.PromptTokens, + OutputTokens: cu.CompletionTokens, + TotalTokens: cu.TotalTokens, + Cost: cu.Cost, + CostInUsdTicks: cu.CostInUsdTicks, } if cu.PromptTokensDetails != nil { @@ -1042,6 +1043,7 @@ func (ru *ResponsesResponseUsage) ToBifrostLLMUsage() *BifrostLLMUsage { CompletionTokens: ru.OutputTokens, TotalTokens: ru.TotalTokens, Cost: ru.Cost, + CostInUsdTicks: ru.CostInUsdTicks, } if ru.InputTokensDetails != nil { diff --git a/core/schemas/providercost_test.go b/core/schemas/providercost_test.go new file mode 100644 index 0000000000..d02f8c50cb --- /dev/null +++ b/core/schemas/providercost_test.go @@ -0,0 +1,141 @@ +package schemas + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// xAI reports cost as cost_in_usd_ticks (TICKS_IN_USD_CENT = 100_000_000), so the +// 200000000 ticks a grok-imagine call returns is $0.02. These tests pin both the +// factor and the rule that a provider-sent cost object always wins. + +func TestNormalizeProviderCost_TicksBecomeCost(t *testing.T) { + chat := &BifrostLLMUsage{CostInUsdTicks: new(int64(200000000))} + chat.NormalizeProviderCost() + require.NotNil(t, chat.Cost) + assert.Equal(t, 0.02, chat.Cost.TotalCost) + + responses := &ResponsesResponseUsage{CostInUsdTicks: new(int64(200000000))} + responses.NormalizeProviderCost() + require.NotNil(t, responses.Cost) + assert.Equal(t, 0.02, responses.Cost.TotalCost) + + image := &ImageUsage{CostInUsdTicks: new(int64(200000000))} + image.NormalizeProviderCost() + require.NotNil(t, image.Cost) + assert.Equal(t, 0.02, image.Cost.TotalCost) +} + +func TestNormalizeProviderCost_DoesNotOverwriteProviderCost(t *testing.T) { + usage := &BifrostLLMUsage{ + Cost: &BifrostCost{TotalCost: 0.99}, + CostInUsdTicks: new(int64(200000000)), + } + + usage.NormalizeProviderCost() + usage.NormalizeProviderCost() // idempotent + + assert.Equal(t, 0.99, usage.Cost.TotalCost) +} + +// Nil, zero and negative tick counts must leave Cost unset so billing falls +// through to datasheet pricing instead of charging nothing. +func TestNormalizeProviderCost_NoCostWithoutUsableTicks(t *testing.T) { + for name, usage := range map[string]*ImageUsage{ + "nil": {}, + "zero": {CostInUsdTicks: new(int64(0))}, + "negative": {CostInUsdTicks: new(int64(-1))}, + } { + t.Run(name, func(t *testing.T) { + usage.NormalizeProviderCost() + assert.Nil(t, usage.Cost) + }) + } +} + +func TestNormalizeProviderCost_NilReceiver(t *testing.T) { + var chat *BifrostLLMUsage + var responses *ResponsesResponseUsage + var image *ImageUsage + + assert.NotPanics(t, func() { + chat.NormalizeProviderCost() + responses.NormalizeProviderCost() + image.NormalizeProviderCost() + }) +} + +// The raw xAI field is surfaced alongside the normalized cost — this is what the +// grok-imagine response reported as an empty "usage":{} before the field existed. +func TestImageUsage_CostInUsdTicksRoundTrip(t *testing.T) { + body := []byte(`{ + "data": [{"url": "https://imgen.x.ai/xai-imgen/xai-tmp-imgen-1.jpeg"}], + "usage": {"cost_in_usd_ticks": 200000000} + }`) + + var resp BifrostImageGenerationResponse + require.NoError(t, json.Unmarshal(body, &resp)) + require.NotNil(t, resp.Usage) + require.NotNil(t, resp.Usage.CostInUsdTicks) + assert.Equal(t, int64(200000000), *resp.Usage.CostInUsdTicks) + + resp.Usage.NormalizeProviderCost() + + data, err := json.Marshal(resp.Usage) + require.NoError(t, err) + + var decoded map[string]interface{} + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.Equal(t, float64(200000000), decoded["cost_in_usd_ticks"]) + assert.Equal(t, map[string]interface{}{"total_cost": 0.02}, decoded["cost"]) +} + +// Providers that report no cost keep emitting usage without either key. +func TestImageUsage_CostKeysOmittedWhenAbsent(t *testing.T) { + data, err := json.Marshal(&ImageUsage{InputTokens: 12, OutputTokens: 4, TotalTokens: 16}) + require.NoError(t, err) + + var decoded map[string]interface{} + require.NoError(t, json.Unmarshal(data, &decoded)) + assert.NotContains(t, decoded, "cost_in_usd_ticks") + assert.NotContains(t, decoded, "cost") +} + +// DeepCopy promises no shared pointer fields; cost calculation relies on it. +func TestImageUsage_DeepCopyCostFields(t *testing.T) { + usage := &ImageUsage{CostInUsdTicks: new(int64(200000000))} + usage.NormalizeProviderCost() + + copied := usage.DeepCopy() + require.NotNil(t, copied.CostInUsdTicks) + require.NotNil(t, copied.Cost) + assert.NotSame(t, usage.CostInUsdTicks, copied.CostInUsdTicks) + assert.NotSame(t, usage.Cost, copied.Cost) + + *copied.CostInUsdTicks = 1 + copied.Cost.TotalCost = 1 + + assert.Equal(t, int64(200000000), *usage.CostInUsdTicks) + assert.Equal(t, 0.02, usage.Cost.TotalCost) +} + +// Both usage shapes must carry the pair across, or a responses-path cost is lost +// the moment it is converted for chat consumers. +func TestUsageConversions_CarryCostFields(t *testing.T) { + chat := &BifrostLLMUsage{ + Cost: &BifrostCost{TotalCost: 0.02}, + CostInUsdTicks: new(int64(200000000)), + } + converted := chat.ToResponsesResponseUsage() + require.NotNil(t, converted.CostInUsdTicks) + assert.Equal(t, int64(200000000), *converted.CostInUsdTicks) + assert.Equal(t, 0.02, converted.Cost.TotalCost) + + back := converted.ToBifrostLLMUsage() + require.NotNil(t, back.CostInUsdTicks) + assert.Equal(t, int64(200000000), *back.CostInUsdTicks) + assert.Equal(t, 0.02, back.Cost.TotalCost) +} diff --git a/core/schemas/responses.go b/core/schemas/responses.go index 86707c44cf..9b21e1a6fb 100644 --- a/core/schemas/responses.go +++ b/core/schemas/responses.go @@ -1082,6 +1082,14 @@ type ResponsesResponseUsage struct { ContextDetails *ResponsesContextDetails `json:"context_details,omitempty"` } +// NormalizeProviderCost mirrors BifrostLLMUsage.NormalizeProviderCost for the responses path. +func (u *ResponsesResponseUsage) NormalizeProviderCost() { + if u == nil || u.Cost != nil { + return + } + u.Cost = costFromUSDTicks(u.CostInUsdTicks) +} + // ResponsesServerSideToolUsageDetails holds per-tool call counts returned by xAI. type ResponsesServerSideToolUsageDetails struct { WebSearchCalls int `json:"web_search_calls"` diff --git a/core/schemas/utils.go b/core/schemas/utils.go index af9f842517..1faf5c35be 100644 --- a/core/schemas/utils.go +++ b/core/schemas/utils.go @@ -142,7 +142,9 @@ func ParseFallbacks(fallbacks []string) []Fallback { // dataURIRegex is a precompiled regex for matching data URI format patterns. // It matches patterns like: data:image/png;base64,iVBORw0KGgo... -var dataURIRegex = regexp.MustCompile(`^data:([^;]+)(;base64)?,(.+)$`) +// Group 1 is the header (media type plus any parameters, e.g. ";charset=utf-8;base64"), +// group 2 the payload. +var dataURIRegex = regexp.MustCompile(`^data:([^,]*),([\s\S]+)$`) // base64Regex is a precompiled regex for matching base64 strings. // It matches strings containing only valid base64 characters with optional padding. @@ -207,7 +209,7 @@ func sanitizeImageURL(rawURL string, allowedSchemes []string) (string, error) { // Check if it's already a proper data URL if strings.HasPrefix(rawURL, "data:") { // Validate data URL format - if !dataURIRegex.MatchString(rawURL) { + if _, _, _, ok := ParseDataURL(rawURL); !ok { return rawURL, fmt.Errorf("invalid data URL format") } return rawURL, nil @@ -267,21 +269,41 @@ func ExtractURLTypeInfo(sanitizedURL string) URLTypeInfo { return extractRegularURLInfo(sanitizedURL) } -// extractDataURLInfo extracts information from a data URL -func extractDataURLInfo(dataURL string) URLTypeInfo { - // Parse data URL: data:[][;base64], +// ParseDataURL splits a data URL (data:[][;=][;base64],) +// into its media type, base64 flag and payload. Media type parameters such as +// ";charset=utf-8" are dropped from mediaType and the payload is returned as-is +// (still base64-encoded when isBase64 is true, percent-encoded otherwise). +// ok is false when the input is not a data URL carrying both a media type and a payload. +func ParseDataURL(dataURL string) (mediaType string, isBase64 bool, payload string, ok bool) { matches := dataURIRegex.FindStringSubmatch(dataURL) + if len(matches) != 3 { + return "", false, "", false + } - if len(matches) != 4 { - return URLTypeInfo{Type: ImageContentTypeBase64} + segments := strings.Split(matches[1], ";") + mediaType = strings.ToLower(strings.TrimSpace(segments[0])) + if mediaType == "" { + return "", false, "", false + } + for _, segment := range segments[1:] { + if strings.EqualFold(strings.TrimSpace(segment), "base64") { + isBase64 = true + } } - mediaType := matches[1] - isBase64 := matches[2] == ";base64" + return mediaType, isBase64, matches[2], true +} + +// extractDataURLInfo extracts information from a data URL +func extractDataURLInfo(dataURL string) URLTypeInfo { + mediaType, isBase64, payload, ok := ParseDataURL(dataURL) + if !ok { + return URLTypeInfo{Type: ImageContentTypeBase64} + } dataURLWithoutPrefix := dataURL if isBase64 { - dataURLWithoutPrefix = dataURL[len("data:")+len(mediaType)+len(";base64,"):] + dataURLWithoutPrefix = payload } info := URLTypeInfo{ diff --git a/core/schemas/utils_test.go b/core/schemas/utils_test.go index 56db25eb33..22feb9226f 100644 --- a/core/schemas/utils_test.go +++ b/core/schemas/utils_test.go @@ -50,3 +50,57 @@ func TestSanitizeImageURLDataURLUnaffectedByAllowlist(t *testing.T) { require.NoError(t, err) assert.Equal(t, dataURL, got) } + +func TestParseDataURL(t *testing.T) { + tests := []struct { + name string + dataURL string + expectedMediaType string + expectedBase64 bool + expectedPayload string + expectedOK bool + }{ + {"Base64", "data:image/png;base64,iVBORw0KGgo=", "image/png", true, "iVBORw0KGgo=", true}, + // Browsers and OpenAI-compatible clients routinely emit a charset parameter; + // dropping the whole URL on the floor shipped "data:..." as the payload. + {"MediaTypeParameter", "data:text/plain;charset=utf-8;base64,QUJD", "text/plain", true, "QUJD", true}, + {"ParameterWithoutBase64", "data:text/plain;charset=utf-8,Hello%20World", "text/plain", false, "Hello%20World", true}, + {"Uppercase", "data:IMAGE/PNG;BASE64,iVBORw0KGgo=", "image/png", true, "iVBORw0KGgo=", true}, + {"OfficeDocument", "data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,UEsDBBQ", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", true, "UEsDBBQ", true}, + {"PayloadWithNewlines", "data:image/png;base64,iVBOR\nw0KGgo=", "image/png", true, "iVBOR\nw0KGgo=", true}, + {"MissingMediaType", "data:;base64,iVBORw0KGgo=", "", false, "", false}, + {"MissingPayload", "data:image/png;base64,", "", false, "", false}, + {"NotADataURL", "https://example.com/image.png", "", false, "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mediaType, isBase64, payload, ok := ParseDataURL(tt.dataURL) + assert.Equal(t, tt.expectedOK, ok) + assert.Equal(t, tt.expectedMediaType, mediaType) + assert.Equal(t, tt.expectedBase64, isBase64) + assert.Equal(t, tt.expectedPayload, payload) + }) + } +} + +func TestExtractURLTypeInfoDropsMediaTypeParameters(t *testing.T) { + info := ExtractURLTypeInfo("data:text/plain;charset=utf-8;base64,QUJD") + require.NotNil(t, info.MediaType) + assert.Equal(t, "text/plain", *info.MediaType) + assert.Equal(t, ImageContentTypeBase64, info.Type) + require.NotNil(t, info.DataURLWithoutPrefix) + assert.Equal(t, "QUJD", *info.DataURLWithoutPrefix) +} + +func TestSanitizeImageURLAcceptsDataURLWithParameters(t *testing.T) { + dataURL := "data:image/png;charset=binary;base64,iVBORw0KGgo=" + got, err := SanitizeImageURL(dataURL) + require.NoError(t, err) + assert.Equal(t, dataURL, got) + + // A data URL with no media type stays invalid: providers reject "data:;base64,...". + _, err = SanitizeImageURL("data:;base64,iVBORw0KGgo=") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid data URL format") +} diff --git a/framework/modelcatalog/datasheet/cost.go b/framework/modelcatalog/datasheet/cost.go index acdb3bd9e0..795e4f1466 100644 --- a/framework/modelcatalog/datasheet/cost.go +++ b/framework/modelcatalog/datasheet/cost.go @@ -222,6 +222,10 @@ func (s *Store) calculateBaseCost(result *schemas.BifrostResponse, scopes Lookup if input.usage != nil && input.usage.Cost != nil && input.usage.Cost.TotalCost > 0 { return input.usage.Cost.TotalCost } + // Image responses carry usage on imageUsage, never on input.usage. + if input.imageUsage != nil && input.imageUsage.Cost != nil && input.imageUsage.Cost.TotalCost > 0 { + return input.imageUsage.Cost.TotalCost + } // If no usage data at all, nothing to price if input.usage == nil && input.audioSeconds == nil && input.audioTokenDetails == nil && input.imageUsage == nil && input.videoSeconds == nil && input.audioTextInputChars == 0 && input.ocrProcessedPages == nil && input.containerIdentifierString == "" { diff --git a/framework/modelcatalog/datasheet/cost_test.go b/framework/modelcatalog/datasheet/cost_test.go index d1b988a57c..eaeb683620 100644 --- a/framework/modelcatalog/datasheet/cost_test.go +++ b/framework/modelcatalog/datasheet/cost_test.go @@ -1881,6 +1881,44 @@ func TestCalculateCost_ProviderComputedCostPassthrough(t *testing.T) { assert.Equal(t, 0.99, cost) } +// Image usage hangs off imageUsage, never input.usage, so it needs its own +// provider-cost short circuit. xAI reports cost_in_usd_ticks, normalized into +// Cost by the provider before billing sees it. +func TestCalculateCost_ImageProviderComputedCostPassthrough(t *testing.T) { + s := testStoreWithPricing(map[string]configstoreTables.TableModelPricing{ + makeKey("grok-imagine-image-quality", "xai", "image_generation"): { + Model: "grok-imagine-image-quality", Provider: "xai", Mode: "image_generation", + OutputCostPerImage: bifrost.Ptr(0.5), // deliberately unlike the reported cost + }, + }) + + usage := &schemas.ImageUsage{CostInUsdTicks: bifrost.Ptr(int64(200000000))} + usage.NormalizeProviderCost() + + resp := makeImageResponse(schemas.XAI, "grok-imagine-image-quality", usage) + resp.ImageGenerationResponse.Data = []schemas.ImageData{{URL: "https://imgen.x.ai/x.jpeg"}} + + assert.Equal(t, 0.02, s.CalculateCost(resp, nil)) +} + +// Without a reported cost the datasheet still prices the request. +func TestCalculateCost_ImageFallsBackToDatasheetWithoutReportedCost(t *testing.T) { + s := testStoreWithPricing(map[string]configstoreTables.TableModelPricing{ + makeKey("grok-imagine-image-quality", "xai", "image_generation"): { + Model: "grok-imagine-image-quality", Provider: "xai", Mode: "image_generation", + OutputCostPerImage: bifrost.Ptr(0.5), + }, + }) + + usage := &schemas.ImageUsage{} + usage.NormalizeProviderCost() + + resp := makeImageResponse(schemas.XAI, "grok-imagine-image-quality", usage) + resp.ImageGenerationResponse.Data = []schemas.ImageData{{URL: "https://imgen.x.ai/x.jpeg"}} + + assert.Equal(t, 0.5, s.CalculateCost(resp, nil)) +} + func TestCalculateCost_NoUsageData(t *testing.T) { s := testStoreWithPricing(map[string]configstoreTables.TableModelPricing{ makeKey("gpt-4o", "openai", "chat"): chatPricing(0.000005, 0.000015), diff --git a/tests/e2e/api/HARNESS_COVERAGE_BACKLOG.md b/tests/e2e/api/HARNESS_COVERAGE_BACKLOG.md index ef9bc5d7f0..6cf26077fd 100644 --- a/tests/e2e/api/HARNESS_COVERAGE_BACKLOG.md +++ b/tests/e2e/api/HARNESS_COVERAGE_BACKLOG.md @@ -181,7 +181,7 @@ Sources: - [x] Tool config (`toolConfig: { tools: [{ toolSpec: { name, inputSchema } }] }`) - [ ] **Streaming** (`POST /model/{modelId}/converse-stream`) - [ ] **Vision** (`content: [{ image: { format, source: { bytes } } }]`) -- [ ] **Document input** (`content: [{ document: { format, name, source: { bytes } } }]`) +- [~] **Document input** (`content: [{ document: { format, name, source: { bytes } } }]`) — the converter into this block is covered by folder 42 (#5472: OpenAI `type:"file"` / Responses `input_file` document uploads via `/v1/chat/completions` and `/v1/responses`, xlsx/docx/csv/pdf/txt + `file_url`). A native Converse-shaped `document` block posted directly at `/bedrock/model/{id}/converse` is still uncovered. - [ ] **Video input** (`content: [{ video: { format, source } }]`) - [ ] **Tool result** (`content: [{ toolResult: { toolUseId, content, status } }]`) - [ ] **Stop sequences** (`inferenceConfig: { stopSequences: [...] }`) diff --git a/tests/e2e/api/collections/provider-harness.json b/tests/e2e/api/collections/provider-harness.json index 312147d0f9..033d3a8499 100644 --- a/tests/e2e/api/collections/provider-harness.json +++ b/tests/e2e/api/collections/provider-harness.json @@ -48600,6 +48600,1060 @@ "response": [] } ] + }, + { + "name": "42. Bedrock Document Uploads via OpenAI type:\"file\" (#5472)", + "description": "Regression coverage for #5472. A standard OpenAI-compatible client sends a document as a Chat Completions type:\"file\" content part, carrying the MIME type ONLY inside the file_data data URL - file_type is a Bifrost extension normal clients (LibreChat, raw curl) never send. Bedrock's converter (core/providers/bedrock/utils.go convertContentBlock, and its twin in responses.go) initialised the Converse document format to \"pdf\" and only refined it from file_type, so every non-PDF office document reached Converse labeled format:\"pdf\" with non-PDF bytes and AWS rejected it with \"The PDF specified was not valid\" (later builds: \"The document source bytes could not be parsed as the specified format\"). PDFs worked only by coincidence of the default. The fix resolves the format by precedence - file_type, then the data URL media type, then the filename extension, then the pdf default - via a shared bedrockDocumentFormat helper used by both the chat and Responses converters, adds file_url inlining on the Responses path (previously ignored, leaving an empty document source), and fixes data URL parsing so a \";charset=\" parameter no longer causes the whole \"data:...\" string to be sent as document bytes. Cases 1-11 drive the production route from the issue (/v1/chat/completions); cases 12-14 pin the same invariants on the native /v1/responses converter, which carried its own copy of the defect. Every fixture embeds the token BIFROST7788 so the assertions prove the document was actually parsed by Claude, not merely accepted by the API. Before the fix, cases 1-3, 5-8 and 12-14 all failed with a 400 ValidationException.", + "item": [ + { + "name": "Bedrock Claude: XLSX document via type:file data URL, no file_type (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: an OpenAI type:\"file\" part carries the document MIME only inside the", + "// file_data data URL. Bedrock's converter used to ignore it and label every", + "// document format:\"pdf\", so Converse rejected non-PDF documents.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format not mislabeled as pdf', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document accepted and answered', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var c = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) ||", + " (j.output && JSON.stringify(j.output)) || '';", + " pm.expect(c).to.be.a('string').and.not.empty;", + " pm.expect(c.toUpperCase(), 'model did not read the document contents').to.include('BIFROST7788');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 256,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the verification code found in the attached document.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"report.xlsx\",\n \"file_data\": \"data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,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\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + } + }, + { + "name": "Bedrock Claude: DOCX document via type:file data URL, no file_type (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: an OpenAI type:\"file\" part carries the document MIME only inside the", + "// file_data data URL. Bedrock's converter used to ignore it and label every", + "// document format:\"pdf\", so Converse rejected non-PDF documents.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format not mislabeled as pdf', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document accepted and answered', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var c = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) ||", + " (j.output && JSON.stringify(j.output)) || '';", + " pm.expect(c).to.be.a('string').and.not.empty;", + " pm.expect(c.toUpperCase(), 'model did not read the document contents').to.include('BIFROST7788');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 256,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the verification code found in the attached document.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"report.docx\",\n \"file_data\": \"data:application/vnd.openxmlformats-officedocument.wordprocessingml.document;base64,UEsDBBQAAAAIABGDB12Y04HDIgEAAA8DAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbKWSy07DMBBF93yF5W2VOGWBEErSBY8ldFE+wLInidX4IY9b2r9nkpQuUCigbiI5c+894xmXq4Pt2R4iGu8qvswLzsApr41rK/6+ecnuOcMknZa9d1DxIyBf1Tfl5hgAGZkdVrxLKTwIgaoDKzH3ARxVGh+tTHSMrQhSbWUL4rYo7oTyLoFLWRoyeF0+QSN3fWLPB/o9NRKhR84eJ+HAqrgMoTdKJqqLvdPfKNmJkJNz1GBnAi5IwMUsYaj8DDj53mgy0WhgaxnTq7SkEh8+aqG92lly5pdjZvr0TWMUnP1DWoheASKN3Pb5uWKlcYvf+kg0cZi+y6t7GWMuIUm5jj4gbTDC/3FfKxrcGV06QEwG8E9Eir76fjBsX4OeYYvxPdefUEsDBBQAAAAIABGDB12w5ygS5wAAAE0CAAALAAAAX3JlbHMvLnJlbHOtks1KBDEMgO8+Rcl9J7MriMh29iLC3kTGBwhtZqY4/aGNuvv2VlB0YF324LFp8uVLyHZ38LN641xcDBrWTQuKg4nWhVHDc/+wugVVhIKlOQbWcOQCu+5q+8QzSa0pk0tFVUgoGiaRdIdYzMSeShMTh/ozxOxJ6jOPmMi80Mi4adsbzL8Z0C2Yam815L29BtUfE1/CjsPgDN9H8+o5yIkWyAfhYNmuUq71WVwdRvWURxYNNprHGi5IKTUVDXjaaHO50d/TomchS0JoYubzPp8Z54TW/7miZcaPzXvMFu1X+NsGF1fQfQBQSwMEFAAAAAgAEYMHXYPOct/MAAAArAEAABwAAAB3b3JkL19yZWxzL2RvY3VtZW50LnhtbC5yZWxzrZBNSwQxDIbv/oqSu83MHkRkO3sRYW8iK3gNbeYDp01ps+L+e4siurAHDx6Tl/fJQ7a797iaNy51keSgtx0YTl7CkiYHz4eH61swVSkFWiWxgxNX2A1X2ydeSVunzkuupkFSdTCr5jvE6meOVK1kTi0ZpUTSNpYJM/lXmhg3XXeD5TcDhjOm2QcHZR96MIdT5r+wZRwXz/fij5GTXjiB/lhV4ktcG5TKxOrAWgziH4u0OLKSbVDAyy6b/3TR1uUfj8/xa9l/O+DZk4cPUEsDBBQAAAAIABGDB11gwbBGpAEAAAoFAAARAAAAd29yZC9kb2N1bWVudC54bWzdVNuO0zAQfecrLL+36VYrtoqarFitKpBWApbyAa4zaSxsjzXjJJSvx2mSwoqL+govtjMnc84ce+zt/VdnRQfEBn0hb5YrKcBrrIw/FvLzfrfYSMFR+UpZ9FDIE7C8L19t+7xC3TrwUSQGz3mXwCbGkGcZ6wac4iUG8AmskZyK6ZOOmVP0pQ0LjS6oaA7GmnjK1qvVaznRYCFb8vlEsXBGEzLWcUjJsa6NhmmaM+ga3THlcSr5rJgR2FQDem5M4Jmt+5t+5+z8Xx+uka1I9WknnR0Ve6QqEGpgTtHHEbww3qyu8D5QXDKuKeGl5lyJU8bLMp3iAavTMIfz8IHKbTbNfT4O03qHPrLoc8XamEK+BdtBNFqJJ3NsokxI88bz7xHNv4azgZW/JbRTtpDr2zmy0PwymF2KOPdazkHp1GyBgIE6kOXHVlEEsidBEJCiSP7Fg6nT7kXxpA68HDjiyDQa/Nct7xsYbq1JPXHuYZGuLAjD4uHd7vn9p/3d3Wbz/7l+hg58C6JXLG7XwhlrB+8VWqvoD6fMoOPk9qfl2PbZj1es/A5QSwMEFAAAAAgAEYMHXWOB5b/fAwAAJxEAABUAAAB3b3JkL3RoZW1lL3RoZW1lMS54bWzlWEtv4zYQvvdXELzvyno5chBnsXYs9NAWReKiZ1qiJW0oSiCZOPn3HVEvyrIS78aLFqgPNkl9882LHI588+UlZ+iZCpkVfIntzzOMKI+KOOPJEv+1DT8FGElFeExYwekSv1KJv9z+ckOuVUpzikCcy2uyxKlS5bVlyQiWifxclJTDs30hcqJgKhIrFuQAtDmznNlsbuUk4xhxkgPrHd2TJ6bQtuLEty37hsEXV7JaiJh4iLTKgYgGx4929SNf5ZoJ9EzYEoOmuDhs6YvCiBGp4MESz/QHW7c3VifE1ISsIRfqTyPXCMSPjpYTya4TtENvcXXX8Ts1/xi32WzWG7vj0wASReCqPcJ6YWCvWk4DVA/H3OuZP/OGeIPfHeEXq9XKXwzwbo/3RvhgNve+OgO81+P9sf2rr+v1fID3e/x8hA+vFnNviNeglGX8cYSu8tllpoPsC/brSXgA8KDdAD3KMrZXLc/V5GbLybdChIDQ2SUq40i9lgCIALjNcirRH/SA7ouc8EoTuabEQNRLkTxaso6I84z/JC09sWV6qv3Op93eZ4w9qFdGf5PaJlmwLA5hUU+0VBfmMoVho2+ASwTRYyQK9Xem0oeUlKDH1hoS2VAnEpWFhOTiSW5dIjKu6jW/PdaAJur3Iq6XXfO4dzR6lkhTkVsRnKvMvfqYMrsGnqnN9k9r89/UZhnRhC2OSFXN7blTq0YyIozGVdxrgjYtF0+RTElMmxzZJx2x3TPDFrwfNUPbwv2YtnOSZKrzJtT5F8jSbJQla3wcGR/O0AGs8h0fo4iUS7yHEgLDvAQ+yROMCEvgvo9U48q7h/nY4dPb0p5NOjxQUQqp7ohMayn9qL0NeW+/43tVHC7jwIlqdJ4VbmD/i1ZYx6ml+z2N1MRKP22eFU+Kioc0PqAdexL3BOz26t0VZ1JBiNsJdDm+12y84clvTsHxrducDsLKlDQ1KTByX8P1uLNBzwzzrAnbf9AV94Ku+P9fV6qdSzl1Y91BQB8gCKr26BIXQqUFVKEyzaJQQOegdYFd0CmryiTEqpeIylb63NetmqMuckmq7rMEiQwqnUoFpX+qxs93yGzHvF9boqbOdObKsv7d0WfKttXpnVf+Y5S21aQJhMYdJ806dbp2Sfgf7ny8ic7n7fagV+R9Ty/iGUXfuAoWHzPhO69a57THjn/2VVsSlaLqCwp3JiJGu/52W9xD9lHXUSLYiJ+C5vh1izuwOTCcq6h+bhvVpyCYyPclm08j2O5EsN9W9+PB9k/E2n871Nb4iFrGm4yejf5MKHbfQHfzeiPr16cXJci6fQsEHqsXvf0HUEsDBBQAAAAIABGDB13ba7lZ1AAAAGwBAAARAAAAZG9jUHJvcHMvY29yZS54bWxtkE1Lw0AQhu/+irD3ZBILIiFJb54UhFbwusyO6dLsBztj0/57t0GjYI/D+8zDzNttz24qTpTYBt+rpqpVQR6DsX7s1dv+qXxUBYv2Rk/BU68uxGo73HUYWwyJXlOIlMQSF1nkucXYq4NIbAEYD+Q0V5nwOfwIyWnJYxohajzqkeC+rh/AkWijRcNVWMbVqL6VBldl/EzTIjAINJEjLwxN1cAvK5Qc31xYkj+ks3KJdBP9CVf6zHYF53mu5s2C5vsbeH953i2vltZfq0JSQwf/Chq+AFBLAwQUAAAACAARgwddWJJox5gAAADzAAAAEAAAAGRvY1Byb3BzL2FwcC54bWydzj0LwjAUheHdXxGyt6kOIqVpF3F2qO4huf0Ac29IrqX990YE3R0PLzycplv9QywQ00yo5b6spAC05GYctbz1l+IkRWKDzjwIQcsNkuzaXXONFCDyDElkAZOWE3OolUp2Am9SmTPmMlD0hvOMo6JhmC2cyT49IKtDVR0VrAzowBXhC8qPWC/8L+rIvv+le7+F7LWN+t1tX1BLAwQUAAAACAARgwddb7V0a44AAACoAAAAEQAAAGRvY1Byb3BzL21ldGEueG1sRcuxCsIwEIDh3acIt5trC7UiSToITkoXRdcjPdpCk5QkiL691sX5/37VvtwsnhzTFLyGUhYg2NvQT37QcLuetnsQKZPvaQ6eNbw5QWs2ynEm8X190jDmvBwQkx3ZUZK0LDNLGxzaYANhVRQ7XH1PmcCogT1HyiGa49q77nE53+OUOWJVN7VsFP6J+p3mA1BLAQIUABQAAAAIABGDB12Y04HDIgEAAA8DAAATAAAAAAAAAAEAAAAAAAAAAABbQ29udGVudF9UeXBlc10ueG1sUEsBAhQAFAAAAAgAEYMHXbDnKBLnAAAATQIAAAsAAAAAAAAAAQAAAAAAUwEAAF9yZWxzLy5yZWxzUEsBAhQAFAAAAAgAEYMHXYPOct/MAAAArAEAABwAAAAAAAAAAQAAAAAAYwIAAHdvcmQvX3JlbHMvZG9jdW1lbnQueG1sLnJlbHNQSwECFAAUAAAACAARgwddYMGwRqQBAAAKBQAAEQAAAAAAAAABAAAAAABpAwAAd29yZC9kb2N1bWVudC54bWxQSwECFAAUAAAACAARgwddY4Hlv98DAAAnEQAAFQAAAAAAAAABAAAAAAA8BQAAd29yZC90aGVtZS90aGVtZTEueG1sUEsBAhQAFAAAAAgAEYMHXdtruVnUAAAAbAEAABEAAAAAAAAAAQAAAAAATgkAAGRvY1Byb3BzL2NvcmUueG1sUEsBAhQAFAAAAAgAEYMHXViSaMeYAAAA8wAAABAAAAAAAAAAAQAAAAAAUQoAAGRvY1Byb3BzL2FwcC54bWxQSwECFAAUAAAACAARgwddb7V0a44AAACoAAAAEQAAAAAAAAABAAAAAAAXCwAAZG9jUHJvcHMvbWV0YS54bWxQSwUGAAAAAAgACAACAgAA1AsAAAAA\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + } + }, + { + "name": "Bedrock Claude: CSV document via type:file data URL, no file_type (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: an OpenAI type:\"file\" part carries the document MIME only inside the", + "// file_data data URL. Bedrock's converter used to ignore it and label every", + "// document format:\"pdf\", so Converse rejected non-PDF documents.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format not mislabeled as pdf', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document accepted and answered', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var c = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) ||", + " (j.output && JSON.stringify(j.output)) || '';", + " pm.expect(c).to.be.a('string').and.not.empty;", + " pm.expect(c.toUpperCase(), 'model did not read the document contents').to.include('BIFROST7788');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 256,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the verification code found in the attached document.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"report.csv\",\n \"file_data\": \"data:text/csv;base64,Y29kZSxyZXZlbnVlCkJJRlJPU1Q3Nzg4LDQyCg==\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + } + }, + { + "name": "Bedrock Claude: PDF document via type:file data URL still works, no file_type (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: an OpenAI type:\"file\" part carries the document MIME only inside the", + "// file_data data URL. Bedrock's converter used to ignore it and label every", + "// document format:\"pdf\", so Converse rejected non-PDF documents.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format not mislabeled as pdf', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document accepted and answered', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var c = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) ||", + " (j.output && JSON.stringify(j.output)) || '';", + " pm.expect(c).to.be.a('string').and.not.empty;", + " pm.expect(c.toUpperCase(), 'model did not read the document contents').to.include('BIFROST7788');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 256,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the verification code found in the attached document.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"report.pdf\",\n \"file_data\": \"data:application/pdf;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA5OSA+PgpzdHJlYW0KQlQgL0YxIDE0IFRmIDcyIDcwMCBUZCAoUXVhcnRlcmx5IHJlcG9ydC4gVmVyaWZpY2F0aW9uIGNvZGUgQklGUk9TVDc3ODguIFJldmVudWUgNDIgbWlsbGlvbi4pIFRqIEVUCmVuZHN0cmVhbQplbmRvYmoKNSAwIG9iago8PCAvVHlwZSAvRm9udCAvU3VidHlwZSAvVHlwZTEgL0Jhc2VGb250IC9IZWx2ZXRpY2EgPj4KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDI0MSAwMDAwMCBuIAowMDAwMDAwMzkwIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKNDYwCiUlRU9GCg==\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + } + }, + { + "name": "Bedrock Claude: data URL with charset parameter parsed, not sent verbatim (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: an OpenAI type:\"file\" part carries the document MIME only inside the", + "// file_data data URL. Bedrock's converter used to ignore it and label every", + "// document format:\"pdf\", so Converse rejected non-PDF documents.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format not mislabeled as pdf', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document accepted and answered', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var c = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) ||", + " (j.output && JSON.stringify(j.output)) || '';", + " pm.expect(c).to.be.a('string').and.not.empty;", + " pm.expect(c.toUpperCase(), 'model did not read the document contents').to.include('BIFROST7788');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 256,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the verification code found in the attached document.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"report.txt\",\n \"file_data\": \"data:text/plain;charset=utf-8;base64,UXVhcnRlcmx5IHJlcG9ydC4gVmVyaWZpY2F0aW9uIGNvZGUgQklGUk9TVDc3ODguIFJldmVudWUgNDIgbWlsbGlvbi4=\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + } + }, + { + "name": "Bedrock Claude: opaque media type falls back to filename extension (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: an OpenAI type:\"file\" part carries the document MIME only inside the", + "// file_data data URL. Bedrock's converter used to ignore it and label every", + "// document format:\"pdf\", so Converse rejected non-PDF documents.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format not mislabeled as pdf', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document accepted and answered', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var c = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) ||", + " (j.output && JSON.stringify(j.output)) || '';", + " pm.expect(c).to.be.a('string').and.not.empty;", + " // content-only assertion: this case pins format resolution, not document text", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 256,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Summarize this document in one sentence.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"report.xlsx\",\n \"file_data\": \"data:application/octet-stream;base64,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\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + } + }, + { + "name": "Bedrock Claude: explicit file_type wins over the data URL media type (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: an OpenAI type:\"file\" part carries the document MIME only inside the", + "// file_data data URL. Bedrock's converter used to ignore it and label every", + "// document format:\"pdf\", so Converse rejected non-PDF documents.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format not mislabeled as pdf', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document accepted and answered', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var c = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) ||", + " (j.output && JSON.stringify(j.output)) || '';", + " pm.expect(c).to.be.a('string').and.not.empty;", + " // content-only assertion: this case pins format resolution, not document text", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 256,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Summarize this document in one sentence.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"report\",\n \"file_type\": \"text/csv\",\n \"file_data\": \"data:application/octet-stream;base64,Y29kZSxyZXZlbnVlCkJJRlJPU1Q3Nzg4LDQyCg==\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + } + }, + { + "name": "Bedrock Claude: inline non-base64 text data URL percent-decoded (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: an OpenAI type:\"file\" part carries the document MIME only inside the", + "// file_data data URL. Bedrock's converter used to ignore it and label every", + "// document format:\"pdf\", so Converse rejected non-PDF documents.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format not mislabeled as pdf', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document accepted and answered', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var c = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) ||", + " (j.output && JSON.stringify(j.output)) || '';", + " pm.expect(c).to.be.a('string').and.not.empty;", + " pm.expect(c.toUpperCase(), 'model did not read the document contents').to.include('BIFROST7788');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 256,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the verification code found in the attached document.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"report.txt\",\n \"file_data\": \"data:text/plain,Quarterly%20report.%20Verification%20code%20BIFROST7788.%20Revenue%2042%20million.\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + } + }, + { + "name": "Bedrock Claude: unidentifiable document keeps the pdf default (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: an OpenAI type:\"file\" part carries the document MIME only inside the", + "// file_data data URL. Bedrock's converter used to ignore it and label every", + "// document format:\"pdf\", so Converse rejected non-PDF documents.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format not mislabeled as pdf', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document accepted and answered', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var c = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) ||", + " (j.output && JSON.stringify(j.output)) || '';", + " pm.expect(c).to.be.a('string').and.not.empty;", + " // content-only assertion: this case pins format resolution, not document text", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 256,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Summarize this document in one sentence.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"report\",\n \"file_data\": \"data:application/octet-stream;base64,JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA5OSA+PgpzdHJlYW0KQlQgL0YxIDE0IFRmIDcyIDcwMCBUZCAoUXVhcnRlcmx5IHJlcG9ydC4gVmVyaWZpY2F0aW9uIGNvZGUgQklGUk9TVDc3ODguIFJldmVudWUgNDIgbWlsbGlvbi4pIFRqIEVUCmVuZHN0cmVhbQplbmRvYmoKNSAwIG9iago8PCAvVHlwZSAvRm9udCAvU3VidHlwZSAvVHlwZTEgL0Jhc2VGb250IC9IZWx2ZXRpY2EgPj4KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDI0MSAwMDAwMCBuIAowMDAwMDAwMzkwIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKNDYwCiUlRU9GCg==\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + } + }, + { + "name": "Bedrock Claude: type:file file_url fetched and inlined (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: an OpenAI type:\"file\" part carries the document MIME only inside the", + "// file_data data URL. Bedrock's converter used to ignore it and label every", + "// document format:\"pdf\", so Converse rejected non-PDF documents.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format not mislabeled as pdf', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document accepted and answered', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var c = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) ||", + " (j.output && JSON.stringify(j.output)) || '';", + " pm.expect(c).to.be.a('string').and.not.empty;", + " // content-only assertion: this case pins format resolution, not document text", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 256,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Summarize this document in one sentence.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"letter.pdf\",\n \"file_url\": \"https://www.berkshirehathaway.com/letters/2024ltr.pdf\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + } + }, + { + "name": "Bedrock Claude: XLSX document via type:file data URL, streaming (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: an OpenAI type:\"file\" part carries the document MIME only inside the", + "// file_data data URL. Bedrock's converter used to ignore it and label every", + "// document format:\"pdf\", so Converse rejected non-PDF documents.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format not mislabeled as pdf', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('stream completed without an error frame', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " pm.expect(body).to.include('data:');", + " pm.expect(body.toLowerCase()).to.not.include('\"error\"');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_tokens\": 256,\n \"stream\": true,\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"text\",\n \"text\": \"Reply with only the verification code found in the attached document.\"\n },\n {\n \"type\": \"file\",\n \"file\": {\n \"filename\": \"report.xlsx\",\n \"file_data\": \"data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,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\"\n }\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/chat/completions", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "chat", + "completions" + ] + } + } + }, + { + "name": "Bedrock Claude: /v1/responses input_file XLSX data URL, no file_type (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: an OpenAI type:\"file\" part carries the document MIME only inside the", + "// file_data data URL. Bedrock's converter used to ignore it and label every", + "// document format:\"pdf\", so Converse rejected non-PDF documents.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format not mislabeled as pdf', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document accepted and answered', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var c = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) ||", + " (j.output && JSON.stringify(j.output)) || '';", + " pm.expect(c).to.be.a('string').and.not.empty;", + " pm.expect(c.toUpperCase(), 'model did not read the document contents').to.include('BIFROST7788');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_output_tokens\": 256,\n \"input\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"input_text\",\n \"text\": \"Reply with only the verification code found in the attached document.\"\n },\n {\n \"type\": \"input_file\",\n \"filename\": \"report.xlsx\",\n \"file_data\": \"data:application/vnd.openxmlformats-officedocument.spreadsheetml.sheet;base64,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\"\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/responses", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "responses" + ] + } + } + }, + { + "name": "Bedrock Claude: /v1/responses input_file CSV data URL, no file_type (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: an OpenAI type:\"file\" part carries the document MIME only inside the", + "// file_data data URL. Bedrock's converter used to ignore it and label every", + "// document format:\"pdf\", so Converse rejected non-PDF documents.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format not mislabeled as pdf', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document accepted and answered', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var c = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) ||", + " (j.output && JSON.stringify(j.output)) || '';", + " pm.expect(c).to.be.a('string').and.not.empty;", + " // content-only assertion: this case pins format resolution, not document text", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_output_tokens\": 256,\n \"input\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"input_text\",\n \"text\": \"Summarize this document in one sentence.\"\n },\n {\n \"type\": \"input_file\",\n \"filename\": \"report.csv\",\n \"file_data\": \"data:text/csv;base64,Y29kZSxyZXZlbnVlCkJJRlJPU1Q3Nzg4LDQyCg==\"\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/responses", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "responses" + ] + } + } + }, + { + "name": "Bedrock Claude: /v1/responses input_file file_url fetched and inlined (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: an OpenAI type:\"file\" part carries the document MIME only inside the", + "// file_data data URL. Bedrock's converter used to ignore it and label every", + "// document format:\"pdf\", so Converse rejected non-PDF documents.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format not mislabeled as pdf', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document accepted and answered', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var c = (j.choices && j.choices[0] && j.choices[0].message && j.choices[0].message.content) ||", + " (j.output && JSON.stringify(j.output)) || '';", + " pm.expect(c).to.be.a('string').and.not.empty;", + " // content-only assertion: this case pins format resolution, not document text", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"model\": \"bedrock/global.anthropic.claude-haiku-4-5-20251001-v1:0\",\n \"max_output_tokens\": 256,\n \"input\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"type\": \"input_text\",\n \"text\": \"Summarize this document in one sentence.\"\n },\n {\n \"type\": \"input_file\",\n \"filename\": \"letter.pdf\",\n \"file_url\": \"https://www.berkshirehathaway.com/letters/2024ltr.pdf\"\n }\n ]\n }\n ]\n}" + }, + "url": { + "raw": "{{baseUrl}}/v1/responses", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "v1", + "responses" + ] + } + } + } + ] + }, + { + "name": "43. Bedrock Native Converse Document Round-Trip (#5472)", + "description": "Companion to folder 42 for the native drop-in surface. POST /bedrock/model/{modelId}/converse does not passthrough: the Converse body is parsed into a BedrockConverseRequest, converted to a Bifrost Responses request (ToBifrostResponsesRequest maps each Converse document block to an input_file with file_type derived from the format and file_data as a data URL, or the raw string for a source.text document), and then converted back out by the same document converter folder 42 exercises from the OpenAI side. A document therefore crosses the format<->MIME table in BOTH directions on this route, so a mismatch in either direction lands here. These cases pin that round trip for binary bytes (xlsx, pdf), text-format bytes (csv), and a source.text document (the isTextFile branch, where the converter must emit both source.text and source.bytes and must not attach source.text to a binary format), plus the converse-stream variant. Each fixture embeds BIFROST7788 so the assertions prove Claude parsed the document rather than the API merely accepting it. These passed before the #5472 fix and must keep passing after it - they guard the native surface against collateral damage from the shared bedrockDocumentFormat helper that now serves both converters.", + "item": [ + { + "name": "Bedrock native converse: XLSX document block round-trips (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: /bedrock/model/{id}/converse parses a native Converse body into a Bifrost", + "// Responses request and converts it back out through the same document converter the", + "// OpenAI-shaped routes use, so a format/bytes mismatch would surface here too.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format survives the native Converse round trip', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document accepted and answered', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var blocks = (j.output && j.output.message && j.output.message.content) || [];", + " var text = blocks.map(function (b) { return b.text || ''; }).join(' ');", + " pm.expect(text).to.be.a('string').and.not.empty;", + " pm.expect(text.toUpperCase(), 'model did not read the document contents').to.include('BIFROST7788');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"text\": \"Reply with only the verification code found in the attached document.\"\n },\n {\n \"document\": {\n \"format\": \"xlsx\",\n \"name\": \"report\",\n \"source\": {\n \"bytes\": \"UEsDBBQAAAAIACWDB12wXVXT/gAAADMCAAATAAAAW0NvbnRlbnRfVHlwZXNdLnhtbK1RvU7DMBDeeQrLaxU7ZUAINe1QYASG8gCHfUms+E8+t6Rvj5NCB1QQA9Pp7vuVvdqMzrIDJjLBN3wpas7Qq6CN7xr+unusbjmjDF6DDR4bfkTim/XVaneMSKyIPTW8zzneSUmqRwckQkRfkDYkB7msqZMR1AAdyuu6vpEq+Iw+V3ny4MXsHlvY28wexnI/NUloibPtiTmFNRxitEZBLrg8eP0tpvqMEEU5c6g3kRaFwOXliAn6OeFL+FweJxmN7AVSfgJXaHK08j2k4S2EQfzucqFnaFujUAe1d0UiKCYETT1idlbMUzgwfvGHAjOb5DyW/9zk7H8uIuc/X38AUEsDBBQAAAAIACWDB11+b8CFsQAAACoBAAALAAAAX3JlbHMvLnJlbHONzzsOwjAMBuCdU0TeaVoGhFBDF4TUFZUDhNR9qEkcJQHa25MRKgZGy/4/22U1G82e6MNIVkCR5cDQKmpH2wu4NZftAViI0rZSk0UBCwaoTpvyilrGlAnD6AJLiA0ChhjdkfOgBjQyZOTQpk5H3siYSt9zJ9Uke+S7PN9z/2nACmV1K8DXbQGsWRz+g1PXjQrPpB4GbfyxYzWRZOl7jAJmzV/kpzvRlCUUeDqGf714egNQSwMEFAAAAAgAJYMHXRSkGmDBAAAAHgEAAA8AAAB4bC93b3JrYm9vay54bWyNj01Ow0AMhfc9xch7OimLCkVJuqmQukX0AEPGaUbN2CN7gPb2GAJ7Vv7Te35fd7jlxX2gaGLqYbdtwCGNHBNdeji/Pj88gdMaKIaFCXu4o8Jh2HSfLNc35qszPWkPc62l9V7HGXPQLRcku0wsOVQb5eK1CIaoM2LNi39smr3PIRGsDq38x4OnKY145PE9I9XVRHAJ1dLrnIqCRft5ocNaHYVssV+wsFRD+d6dopGCkzZZI6e4Az90/le26fwf2/AFUEsDBBQAAAAIACWDB11vJc8gtAAAACsBAAAaAAAAeGwvX3JlbHMvd29ya2Jvb2sueG1sLnJlbHONz80KwjAMAOC7T1Fyd9k8iMi6XUTYVeYDlC77YVtbmvqzt7d4EBUPnkIS8iXJy/s8iSt5HqyRkCUpCDLaNoPpJJzr43oHgoMyjZqsIQkLMZTFKj/RpEKc4X5wLCJiWEIfgtsjsu5pVpxYRyZ2WutnFWLqO3RKj6oj3KTpFv27AV+oqBoJvmoyEPXi6B/ctu2g6WD1ZSYTfuzAm/Uj90Qhosp3FCS8SozPkCVRBYzX4MePxQNQSwMEFAAAAAgAJYMHXZQTUAbeAAAAgAEAABgAAAB4bC93b3Jrc2hlZXRzL3NoZWV0MS54bWx1kNtqwzAMhu/3FEb3rdIw1jBsl5VS2NVg7R7AJGpjlsjB1tLt7eeU0R1Y73T49f2S9Oq979RIMfnABhbzAhRxHRrPRwMv++2sApXEceO6wGTggxKs7I0+hfiaWiJRGcDJQCsy3COmuqXepXkYiHPnEGLvJKfxiGmI5JrzUN9hWRR32DvPkGnn4saJy3EMJxXzKmB1PQUPC1BiwHPnmXYSc90nq8XmLUmjWI1TjvWXfn1NH2kkfvszgtnu27S8mJZXIOvH7fPTbr9cVtV/3hNgtLelxvEXH39eiJfn2U9QSwECFAMUAAAACAAlgwddsF1V0/4AAAAzAgAAEwAAAAAAAAAAAAAAgAEAAAAAW0NvbnRlbnRfVHlwZXNdLnhtbFBLAQIUAxQAAAAIACWDB11+b8CFsQAAACoBAAALAAAAAAAAAAAAAACAAS8BAABfcmVscy8ucmVsc1BLAQIUAxQAAAAIACWDB10UpBpgwQAAAB4BAAAPAAAAAAAAAAAAAACAAQkCAAB4bC93b3JrYm9vay54bWxQSwECFAMUAAAACAAlgwddbyXPILQAAAArAQAAGgAAAAAAAAAAAAAAgAH3AgAAeGwvX3JlbHMvd29ya2Jvb2sueG1sLnJlbHNQSwECFAMUAAAACAAlgwddlBNQBt4AAACAAQAAGAAAAAAAAAAAAAAAgAHjAwAAeGwvd29ya3NoZWV0cy9zaGVldDEueG1sUEsFBgAAAAAFAAUARQEAAPcEAAAAAA==\"\n }\n }\n }\n ]\n }\n ],\n \"inferenceConfig\": {\n \"maxTokens\": 256\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/bedrock/model/global.anthropic.claude-haiku-4-5-20251001-v1:0/converse", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "bedrock", + "model", + "global.anthropic.claude-haiku-4-5-20251001-v1:0", + "converse" + ] + } + } + }, + { + "name": "Bedrock native converse: PDF document block round-trips (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: /bedrock/model/{id}/converse parses a native Converse body into a Bifrost", + "// Responses request and converts it back out through the same document converter the", + "// OpenAI-shaped routes use, so a format/bytes mismatch would surface here too.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format survives the native Converse round trip', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document accepted and answered', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var blocks = (j.output && j.output.message && j.output.message.content) || [];", + " var text = blocks.map(function (b) { return b.text || ''; }).join(' ');", + " pm.expect(text).to.be.a('string').and.not.empty;", + " pm.expect(text.toUpperCase(), 'model did not read the document contents').to.include('BIFROST7788');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"text\": \"Reply with only the verification code found in the attached document.\"\n },\n {\n \"document\": {\n \"format\": \"pdf\",\n \"name\": \"report\",\n \"source\": {\n \"bytes\": \"JVBERi0xLjQKMSAwIG9iago8PCAvVHlwZSAvQ2F0YWxvZyAvUGFnZXMgMiAwIFIgPj4KZW5kb2JqCjIgMCBvYmoKPDwgL1R5cGUgL1BhZ2VzIC9LaWRzIFszIDAgUl0gL0NvdW50IDEgPj4KZW5kb2JqCjMgMCBvYmoKPDwgL1R5cGUgL1BhZ2UgL1BhcmVudCAyIDAgUiAvTWVkaWFCb3ggWzAgMCA2MTIgNzkyXSAvUmVzb3VyY2VzIDw8IC9Gb250IDw8IC9GMSA1IDAgUiA+PiA+PiAvQ29udGVudHMgNCAwIFIgPj4KZW5kb2JqCjQgMCBvYmoKPDwgL0xlbmd0aCA5OSA+PgpzdHJlYW0KQlQgL0YxIDE0IFRmIDcyIDcwMCBUZCAoUXVhcnRlcmx5IHJlcG9ydC4gVmVyaWZpY2F0aW9uIGNvZGUgQklGUk9TVDc3ODguIFJldmVudWUgNDIgbWlsbGlvbi4pIFRqIEVUCmVuZHN0cmVhbQplbmRvYmoKNSAwIG9iago8PCAvVHlwZSAvRm9udCAvU3VidHlwZSAvVHlwZTEgL0Jhc2VGb250IC9IZWx2ZXRpY2EgPj4KZW5kb2JqCnhyZWYKMCA2CjAwMDAwMDAwMDAgNjU1MzUgZiAKMDAwMDAwMDAwOSAwMDAwMCBuIAowMDAwMDAwMDU4IDAwMDAwIG4gCjAwMDAwMDAxMTUgMDAwMDAgbiAKMDAwMDAwMDI0MSAwMDAwMCBuIAowMDAwMDAwMzkwIDAwMDAwIG4gCnRyYWlsZXIKPDwgL1NpemUgNiAvUm9vdCAxIDAgUiA+PgpzdGFydHhyZWYKNDYwCiUlRU9GCg==\"\n }\n }\n }\n ]\n }\n ],\n \"inferenceConfig\": {\n \"maxTokens\": 256\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/bedrock/model/global.anthropic.claude-haiku-4-5-20251001-v1:0/converse", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "bedrock", + "model", + "global.anthropic.claude-haiku-4-5-20251001-v1:0", + "converse" + ] + } + } + }, + { + "name": "Bedrock native converse: CSV document block round-trips (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: /bedrock/model/{id}/converse parses a native Converse body into a Bifrost", + "// Responses request and converts it back out through the same document converter the", + "// OpenAI-shaped routes use, so a format/bytes mismatch would surface here too.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format survives the native Converse round trip', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document accepted and answered', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var blocks = (j.output && j.output.message && j.output.message.content) || [];", + " var text = blocks.map(function (b) { return b.text || ''; }).join(' ');", + " pm.expect(text).to.be.a('string').and.not.empty;", + " pm.expect(text.toUpperCase(), 'model did not read the document contents').to.include('BIFROST7788');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"text\": \"Reply with only the verification code found in the attached document.\"\n },\n {\n \"document\": {\n \"format\": \"csv\",\n \"name\": \"report\",\n \"source\": {\n \"bytes\": \"Y29kZSxyZXZlbnVlCkJJRlJPU1Q3Nzg4LDQyCg==\"\n }\n }\n }\n ]\n }\n ],\n \"inferenceConfig\": {\n \"maxTokens\": 256\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/bedrock/model/global.anthropic.claude-haiku-4-5-20251001-v1:0/converse", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "bedrock", + "model", + "global.anthropic.claude-haiku-4-5-20251001-v1:0", + "converse" + ] + } + } + }, + { + "name": "Bedrock native converse: txt document via source.text round-trips (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: /bedrock/model/{id}/converse parses a native Converse body into a Bifrost", + "// Responses request and converts it back out through the same document converter the", + "// OpenAI-shaped routes use, so a format/bytes mismatch would surface here too.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format survives the native Converse round trip', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document accepted and answered', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var j = pm.response.json();", + " var blocks = (j.output && j.output.message && j.output.message.content) || [];", + " var text = blocks.map(function (b) { return b.text || ''; }).join(' ');", + " pm.expect(text).to.be.a('string').and.not.empty;", + " pm.expect(text.toUpperCase(), 'model did not read the document contents').to.include('BIFROST7788');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"text\": \"Reply with only the verification code found in the attached document.\"\n },\n {\n \"document\": {\n \"format\": \"txt\",\n \"name\": \"report\",\n \"source\": {\n \"text\": \"Quarterly report. Verification code BIFROST7788. Revenue 42 million.\"\n }\n }\n }\n ]\n }\n ],\n \"inferenceConfig\": {\n \"maxTokens\": 256\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/bedrock/model/global.anthropic.claude-haiku-4-5-20251001-v1:0/converse", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "bedrock", + "model", + "global.anthropic.claude-haiku-4-5-20251001-v1:0", + "converse" + ] + } + } + }, + { + "name": "Bedrock native converse-stream: XLSX document block round-trips (Haiku 4.5) - #5472", + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "// #5472: /bedrock/model/{id}/converse parses a native Converse body into a Bifrost", + "// Responses request and converts it back out through the same document converter the", + "// OpenAI-shaped routes use, so a format/bytes mismatch would surface here too.", + "if ([401, 403, 429, 500, 502, 503, 504].indexOf(pm.response.code) !== -1) { return; }", + "var body = pm.response.text();", + "pm.test('document format survives the native Converse round trip', function () {", + " pm.expect(body).to.not.include('The PDF specified was not valid');", + " pm.expect(body).to.not.include('could not be parsed as the specified format');", + " pm.expect(body).to.not.include('The document source bytes');", + "});", + "pm.test('document streamed back as an event stream', function () {", + " pm.expect(pm.response.code, 'failed: ' + body).to.be.below(400);", + " var ct = pm.response.headers.get('content-type') || '';", + " pm.expect(ct, 'expected stream content-type, got ' + ct).to.match(/event-stream|vnd\\.amazon\\.eventstream/);", + " pm.expect(body, 'expected a messageStop event terminating the stream').to.include('messageStop');", + "});" + ] + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"messages\": [\n {\n \"role\": \"user\",\n \"content\": [\n {\n \"text\": \"Reply with only the verification code found in the attached document.\"\n },\n {\n \"document\": {\n \"format\": \"xlsx\",\n \"name\": \"report\",\n \"source\": {\n \"bytes\": \"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\"\n }\n }\n }\n ]\n }\n ],\n \"inferenceConfig\": {\n \"maxTokens\": 256\n }\n}" + }, + "url": { + "raw": "{{baseUrl}}/bedrock/model/global.anthropic.claude-haiku-4-5-20251001-v1:0/converse-stream", + "host": [ + "{{baseUrl}}" + ], + "path": [ + "bedrock", + "model", + "global.anthropic.claude-haiku-4-5-20251001-v1:0", + "converse-stream" + ] + } + } + } + ] } ] } \ No newline at end of file