diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 92d84eac8cb0..e916c08635c5 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -26,6 +26,21 @@ export function sanitizeSurrogates(content: string) { return content.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(? 0 ? sanitized : undefined +} + // Maps npm package to the key the AI SDK expects for providerOptions function sdkKey(npm: string): string | undefined { switch (npm) { @@ -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 !== "") } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 4c462bd3dce2..10da2dc60089 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -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",