Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ export function sanitizeSurrogates(content: string) {
return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD")
}

// Bedrock's Converse API only allows document names containing alphanumerics,
// whitespace, hyphens, parentheses, and square brackets, with no more than one
// consecutive whitespace character. File parts can carry a filesystem path or a
// synthetic URI (e.g. an MCP "slack-file://..." attachment), which the SDK turns
// into the document name verbatim and Bedrock then rejects. Map disallowed
// characters to a space and collapse, returning undefined when nothing valid
// remains so the SDK falls back to its generated document name.
function sanitizeBedrockDocumentName(filename: string): string | undefined {
const sanitized = filename
.replace(/[^a-zA-Z0-9\s()[\]-]/g, " ")
.replace(/\s+/g, " ")
.trim()
return sanitized.length > 0 ? sanitized : undefined
}

// Maps npm package to the key the AI SDK expects for providerOptions
function sdkKey(npm: string): string | undefined {
switch (npm) {
Expand Down Expand Up @@ -181,7 +196,14 @@ function normalizeMessages(
return true
})
if (filtered.length === 0) return undefined
return { ...msg, content: filtered }
return {
...msg,
content: filtered.map((part) =>
part.type === "file" && typeof part.filename === "string"
? { ...part, filename: sanitizeBedrockDocumentName(part.filename) }
: part,
),
}
})
.filter((msg): msg is ModelMessage => msg !== undefined && msg.content !== "")
}
Expand Down
68 changes: 68 additions & 0 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1908,6 +1908,74 @@ describe("ProviderTransform.message - surrogate sanitization", () => {
})
})

describe("ProviderTransform.message - Bedrock document name sanitization", () => {
const bedrockModel = {
id: "amazon-bedrock/amazon.nova-pro-v1",
providerID: "amazon-bedrock",
api: {
id: "amazon.nova-pro-v1:0",
url: "https://bedrock-runtime.us-east-1.amazonaws.com",
npm: "@ai-sdk/amazon-bedrock",
},
name: "Amazon Nova Pro",
capabilities: {
temperature: true,
reasoning: false,
attachment: true,
toolcall: true,
input: { text: true, audio: false, image: true, video: false, pdf: true },
output: { text: true, audio: false, image: false, video: false, pdf: false },
interleaved: false,
},
cost: { input: 0.001, output: 0.002, cache: { read: 0.0001, write: 0.0002 } },
limit: { context: 300000, output: 8192 },
status: "active",
options: {},
headers: {},
} as any

const fileMessage = (filename: string) =>
[
{
role: "user",
content: [{ type: "file", data: "data:application/pdf;base64,AAAA", mediaType: "application/pdf", filename }],
},
] as any[]

// Matches any character Bedrock's Converse document name rejects.
const invalid = /[^a-zA-Z0-9\s()[\]-]/

test("sanitizes a synthetic MCP attachment URI (issue #37191)", () => {
const result = ProviderTransform.message(fileMessage("slack-file://F0ABC123"), bedrockModel, {}) as any[]
const filename = result[0].content[0].filename
expect(filename).toBe("slack-file F0ABC123")
expect(invalid.test(filename)).toBe(false)
})

test("sanitizes an absolute filesystem path (issue #36113)", () => {
const result = ProviderTransform.message(fileMessage("/Users/foo/Downloads/SOP.pdf"), bedrockModel, {}) as any[]
const filename = result[0].content[0].filename
expect(filename).toBe("Users foo Downloads SOP pdf")
expect(invalid.test(filename)).toBe(false)
})

test("drops the filename when nothing valid remains so the SDK generates one", () => {
const result = ProviderTransform.message(fileMessage("://:/"), bedrockModel, {}) as any[]
expect(result[0].content[0].filename).toBeUndefined()
})

test("leaves filenames untouched for non-Bedrock providers", () => {
const anthropicModel = {
...bedrockModel,
id: "anthropic/claude-sonnet-4",
providerID: "anthropic",
api: { id: "claude-sonnet-4", url: "https://api.anthropic.com", npm: "@ai-sdk/anthropic" },
}
const result = ProviderTransform.message(fileMessage("slack-file://F0ABC123"), anthropicModel, {}) as any[]
expect(result[0].content[0].filename).toBe("slack-file://F0ABC123")
})
})

describe("ProviderTransform.message - empty image handling", () => {
const mockModel = {
id: "anthropic/claude-3-5-sonnet",
Expand Down
Loading