Skip to content

Commit 388bd49

Browse files
committed
fix(bedrock): normalize image MIME types
- Detect image MIME types from bytes before sending vision payloads. - Correct stale base64 image media types in request history. - Bump extension version to 4.99.4.
1 parent 156e338 commit 388bd49

9 files changed

Lines changed: 253 additions & 3 deletions

File tree

src/api/transform/__tests__/bedrock-converse-format.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,34 @@ describe("convertToBedrockConverseMessages", () => {
6868
}
6969
})
7070

71+
it("uses detected image bytes instead of stale media_type for Bedrock image format", () => {
72+
const jpegData = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]).toString("base64")
73+
const messages: Anthropic.Messages.MessageParam[] = [
74+
{
75+
role: "user",
76+
content: [
77+
{
78+
type: "image",
79+
source: {
80+
type: "base64",
81+
data: jpegData,
82+
media_type: "image/png" as const,
83+
},
84+
},
85+
],
86+
},
87+
]
88+
89+
const result = convertToBedrockConverseMessages(messages)
90+
const imageBlock = result[0].content?.[0] as ContentBlock
91+
92+
if ("image" in imageBlock && imageBlock.image) {
93+
expect(imageBlock.image.format).toBe("jpeg")
94+
} else {
95+
expect.fail("Expected image block not found")
96+
}
97+
})
98+
7199
it("converts tool use messages correctly (native tools format; default)", () => {
72100
const messages: Anthropic.Messages.MessageParam[] = [
73101
{

src/api/transform/__tests__/image-cleaning.spec.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,51 @@ describe("maybeRemoveImageBlocks", () => {
103103
expect(apiHandler.getModel).toHaveBeenCalled()
104104
})
105105

106+
it("should normalize image MIME type from base64 bytes when API handler supports images", () => {
107+
const apiHandler = createMockApiHandler(true)
108+
const jpegData = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]).toString("base64")
109+
const messages: ApiMessage[] = [
110+
{
111+
role: "user",
112+
content: [
113+
{
114+
type: "text",
115+
text: "Check this screenshot:",
116+
},
117+
{
118+
type: "image",
119+
source: {
120+
type: "base64",
121+
media_type: "image/png",
122+
data: jpegData,
123+
},
124+
},
125+
],
126+
},
127+
]
128+
129+
const result = maybeRemoveImageBlocks(messages, apiHandler)
130+
131+
expect((result[0].content as any[])[1].source.media_type).toBe("image/jpeg")
132+
expect(messages[0]).toEqual({
133+
role: "user",
134+
content: [
135+
{
136+
type: "text",
137+
text: "Check this screenshot:",
138+
},
139+
{
140+
type: "image",
141+
source: {
142+
type: "base64",
143+
media_type: "image/png",
144+
data: jpegData,
145+
},
146+
},
147+
],
148+
})
149+
})
150+
106151
it("should convert image blocks to text descriptions when API handler doesn't support images", () => {
107152
const apiHandler = createMockApiHandler(false)
108153
const messages: ApiMessage[] = [

src/api/transform/bedrock-converse-format.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Anthropic } from "@anthropic-ai/sdk"
22
import { ConversationRole, Message, ContentBlock } from "@aws-sdk/client-bedrock-runtime"
33
import { sanitizeOpenAiCallId } from "../../utils/tool-id"
4+
import { detectImageMimeType } from "../../utils/imageMime"
45

56
interface BedrockMessageContent {
67
type: "text" | "image" | "video" | "tool_use" | "tool_result"
@@ -71,8 +72,9 @@ export function convertToBedrockConverseMessages(anthropicMessages: Anthropic.Me
7172
byteArray = messageBlock.source.data
7273
}
7374

75+
const mediaType = detectImageMimeType(byteArray) || messageBlock.source.media_type
7476
// Extract format from media_type (e.g., "image/jpeg" -> "jpeg")
75-
const format = messageBlock.source.media_type.split("/")[1]
77+
const format = mediaType.split("/")[1]
7678
if (!["png", "jpeg", "gif", "webp"].includes(format)) {
7779
throw new Error(`Unsupported image format: ${format}`)
7880
}

src/api/transform/image-cleaning.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,26 @@
11
import { ApiMessage } from "../../core/task-persistence/apiMessages"
22

33
import { ApiHandler } from "../index"
4+
import { detectBase64ImageMimeType } from "../../utils/imageMime"
5+
6+
function normalizeImageBlockMimeType(block: any): any {
7+
if (block?.type !== "image" || block.source?.type !== "base64" || typeof block.source.data !== "string") {
8+
return block
9+
}
10+
11+
const detectedMimeType = detectBase64ImageMimeType(block.source.data)
12+
if (!detectedMimeType || detectedMimeType === block.source.media_type) {
13+
return block
14+
}
15+
16+
return {
17+
...block,
18+
source: {
19+
...block.source,
20+
media_type: detectedMimeType,
21+
},
22+
}
23+
}
424

525
/* Removes image blocks from messages if they are not supported by the Api Handler */
626
export function maybeRemoveImageBlocks(messages: ApiMessage[], apiHandler: ApiHandler): ApiMessage[] {
@@ -25,6 +45,8 @@ export function maybeRemoveImageBlocks(messages: ApiMessage[], apiHandler: ApiHa
2545
}
2646
return block
2747
})
48+
} else {
49+
content = content.map(normalizeImageBlockMimeType)
2850
}
2951
}
3052
return { ...message, content }
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import path from "path"
2+
import * as fs from "fs/promises"
3+
import * as os from "os"
4+
5+
import { afterEach, describe, expect, it } from "vitest"
6+
7+
import { readImageAsDataUrlWithBuffer } from "../imageHelpers"
8+
9+
describe("imageHelpers", () => {
10+
let tempDir: string | undefined
11+
12+
afterEach(async () => {
13+
if (tempDir) {
14+
await fs.rm(tempDir, { recursive: true, force: true })
15+
tempDir = undefined
16+
}
17+
})
18+
19+
it("labels image data URLs using detected bytes before the file extension", async () => {
20+
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "zoo-image-helper-"))
21+
const mislabeledPngPath = path.join(tempDir, "ssm-screenshot.png")
22+
const jpegBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10])
23+
await fs.writeFile(mislabeledPngPath, jpegBytes)
24+
25+
const result = await readImageAsDataUrlWithBuffer(mislabeledPngPath)
26+
27+
expect(result.dataUrl).toBe(`data:image/jpeg;base64,${jpegBytes.toString("base64")}`)
28+
expect(result.buffer).toEqual(jpegBytes)
29+
})
30+
})

src/core/tools/helpers/imageHelpers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import * as fs from "fs/promises"
33
import { t } from "../../../i18n"
44
import prettyBytes from "pretty-bytes"
55

6+
import { detectImageMimeType } from "../../../utils/imageMime"
7+
68
/**
79
* Default maximum allowed image file size in bytes (5MB)
810
*/
@@ -77,7 +79,7 @@ export async function readImageAsDataUrlWithBuffer(filePath: string): Promise<{
7779
const base64 = fileBuffer.toString("base64")
7880
const ext = path.extname(filePath).toLowerCase()
7981

80-
const mimeType = IMAGE_MIME_TYPES[ext] || "image/png"
82+
const mimeType = detectImageMimeType(fileBuffer) || IMAGE_MIME_TYPES[ext] || "image/png"
8183
const dataUrl = `data:${mimeType};base64,${base64}`
8284

8385
return { dataUrl, buffer: fileBuffer }

src/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
"displayName": "%extension.displayName%",
44
"description": "%extension.description%",
55
"publisher": "allquixotic",
6-
"version": "4.99.3",
6+
"version": "4.99.4",
77
"icon": "assets/icons/icon.png",
88
"galleryBanner": {
99
"color": "#617A91",
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { describe, expect, it } from "vitest"
2+
3+
import { detectBase64ImageMimeType, detectImageMimeType } from "../imageMime"
4+
5+
describe("imageMime", () => {
6+
it("detects JPEG bytes even when the caller expected PNG", () => {
7+
const bytes = Uint8Array.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10])
8+
9+
expect(detectImageMimeType(bytes)).toBe("image/jpeg")
10+
expect(detectBase64ImageMimeType(Buffer.from(bytes).toString("base64"))).toBe("image/jpeg")
11+
})
12+
13+
it("detects PNG bytes", () => {
14+
const bytes = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
15+
16+
expect(detectImageMimeType(bytes)).toBe("image/png")
17+
})
18+
19+
it("returns undefined for unknown bytes", () => {
20+
expect(detectImageMimeType(Buffer.from("not an image"))).toBeUndefined()
21+
})
22+
})

src/utils/imageMime.ts

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
2+
const JPEG_SIGNATURE = [0xff, 0xd8, 0xff]
3+
const GIF87A_SIGNATURE = "GIF87a"
4+
const GIF89A_SIGNATURE = "GIF89a"
5+
const RIFF_SIGNATURE = "RIFF"
6+
const WEBP_SIGNATURE = "WEBP"
7+
const BMP_SIGNATURE = "BM"
8+
const ICO_SIGNATURE = [0x00, 0x00, 0x01, 0x00]
9+
const TIFF_LE_SIGNATURE = [0x49, 0x49, 0x2a, 0x00]
10+
const TIFF_BE_SIGNATURE = [0x4d, 0x4d, 0x00, 0x2a]
11+
const ISO_BMFF_FILE_TYPE_BOX = "ftyp"
12+
const AVIF_BRANDS = new Set(["avif", "avis"])
13+
14+
function hasBytes(bytes: Uint8Array, signature: readonly number[], offset = 0): boolean {
15+
if (bytes.length < offset + signature.length) {
16+
return false
17+
}
18+
19+
return signature.every((value, index) => bytes[offset + index] === value)
20+
}
21+
22+
function hasAscii(bytes: Uint8Array, signature: string, offset = 0): boolean {
23+
if (bytes.length < offset + signature.length) {
24+
return false
25+
}
26+
27+
for (let i = 0; i < signature.length; i++) {
28+
if (bytes[offset + i] !== signature.charCodeAt(i)) {
29+
return false
30+
}
31+
}
32+
33+
return true
34+
}
35+
36+
function asciiSlice(bytes: Uint8Array, start: number, end: number): string {
37+
if (bytes.length < end) {
38+
return ""
39+
}
40+
41+
return String.fromCharCode(...bytes.slice(start, end))
42+
}
43+
44+
function looksLikeSvg(bytes: Uint8Array): boolean {
45+
const sample = new TextDecoder("utf-8", { fatal: false }).decode(bytes.slice(0, 512)).trimStart()
46+
return (
47+
sample.startsWith("<svg") ||
48+
sample.startsWith("<!DOCTYPE svg") ||
49+
(sample.startsWith("<?xml") && sample.includes("<svg"))
50+
)
51+
}
52+
53+
export function detectImageMimeType(bytes: Uint8Array): string | undefined {
54+
if (hasBytes(bytes, PNG_SIGNATURE)) {
55+
return "image/png"
56+
}
57+
58+
if (hasBytes(bytes, JPEG_SIGNATURE)) {
59+
return "image/jpeg"
60+
}
61+
62+
if (hasAscii(bytes, GIF87A_SIGNATURE) || hasAscii(bytes, GIF89A_SIGNATURE)) {
63+
return "image/gif"
64+
}
65+
66+
if (hasAscii(bytes, RIFF_SIGNATURE) && hasAscii(bytes, WEBP_SIGNATURE, 8)) {
67+
return "image/webp"
68+
}
69+
70+
if (hasAscii(bytes, BMP_SIGNATURE)) {
71+
return "image/bmp"
72+
}
73+
74+
if (hasBytes(bytes, ICO_SIGNATURE)) {
75+
return "image/x-icon"
76+
}
77+
78+
if (hasBytes(bytes, TIFF_LE_SIGNATURE) || hasBytes(bytes, TIFF_BE_SIGNATURE)) {
79+
return "image/tiff"
80+
}
81+
82+
if (hasAscii(bytes, ISO_BMFF_FILE_TYPE_BOX, 4) && AVIF_BRANDS.has(asciiSlice(bytes, 8, 12))) {
83+
return "image/avif"
84+
}
85+
86+
if (looksLikeSvg(bytes)) {
87+
return "image/svg+xml"
88+
}
89+
90+
return undefined
91+
}
92+
93+
export function detectBase64ImageMimeType(data: string): string | undefined {
94+
try {
95+
return detectImageMimeType(Buffer.from(data, "base64"))
96+
} catch {
97+
return undefined
98+
}
99+
}

0 commit comments

Comments
 (0)