Compare commits

...
1 Commits
Author SHA1 Message Date
rekram1-node cae5f310c1 fix(opencode): route Bedrock tool-result media 2026-09-15 17:12:00 +00:00
3 changed files with 146 additions and 3 deletions
@@ -4,6 +4,7 @@ import type { JSONSchema7 } from "@ai-sdk/provider"
import type * as Provider from "./provider"
import type * as ModelsDev from "@opencode-ai/core/models-dev"
import { iife } from "@/util/iife"
import { isMedia } from "@/util/media"
type Modality = NonNullable<ModelsDev.Model["modalities"]>["input"][number]
@@ -432,6 +433,7 @@ function unsupportedParts(msgs: ModelMessage[], model: Provider.Model): ModelMes
const modality = mimeToModality(mime)
if (!modality) return part
if (model.capabilities.input[modality]) return part
if (isMedia(mime) && model.capabilities.attachment) return part
const name = filename ? `"${filename}"` : modality
return {
+13 -2
View File
@@ -31,7 +31,7 @@ import { MessageTable, PartTable, SessionTable } from "@opencode-ai/core/session
import { ProviderError } from "@/provider/error"
import { iife } from "@/util/iife"
import { errorMessage } from "@/util/error"
import { isMedia } from "@/util/media"
import { isMedia, isPdfAttachment } from "@/util/media"
import type { SystemError } from "bun"
import type { Provider } from "@/provider/provider"
import { Effect, Schema } from "effect"
@@ -144,11 +144,22 @@ export const toModelMessagesEffect = Effect.fnUntraced(function* (
//
// Only apply this workaround if the model actually supports that media input -
// otherwise unsupportedParts() will turn it into a user-visible error.
const bedrockHoistsToolMedia =
model.api.npm === "@ai-sdk/amazon-bedrock" &&
/(^|\.)(moonshotai|nvidia|openai|qwen|xai)\./.test(model.api.id.toLowerCase())
const supportsMediaInToolResult = (attachment: { mime: string }) => {
const supportsMedia =
model.capabilities.attachment ||
(isPdfAttachment(attachment.mime) ? model.capabilities.input.pdf : model.capabilities.input.image)
if (isMedia(attachment.mime) && !supportsMedia) return false
if (model.api.npm === "@ai-sdk/anthropic") return true
if (model.api.npm === "@ai-sdk/openai") return true
if (model.api.npm === "@ai-sdk/amazon-bedrock/mantle") return true
if (model.api.npm === "@ai-sdk/amazon-bedrock") return attachment.mime.startsWith("image/")
if (model.api.npm === "@ai-sdk/amazon-bedrock") {
// Converse accepts these families' images as user content but not inside tool results.
if (bedrockHoistsToolMedia) return false
return attachment.mime.startsWith("image/")
}
if (model.api.npm === "@ai-sdk/xai") return attachment.mime.startsWith("image/")
if (model.api.npm === "@ai-sdk/google-vertex/anthropic") return true
if (model.api.npm === "@ai-sdk/google") {
@@ -322,6 +322,13 @@ describe("session.message-v2.toModelMessage", () => {
test("converts assistant tool completion into tool-call + tool-result messages with attachments", async () => {
const userID = "m-user"
const assistantID = "m-assistant"
const imageModel: Provider.Model = {
...model,
capabilities: {
...model.capabilities,
input: { ...model.capabilities.input, image: true },
},
}
const input: SessionV1.WithParts[] = [
{
@@ -371,7 +378,7 @@ describe("session.message-v2.toModelMessage", () => {
},
]
expect(await MessageV2.toModelMessages(input, model)).toStrictEqual([
expect(await MessageV2.toModelMessages(input, imageModel)).toStrictEqual([
{
role: "user",
content: [{ type: "text", text: "run tool" }],
@@ -409,6 +416,42 @@ describe("session.message-v2.toModelMessage", () => {
],
},
])
expect(ProviderTransform.message(await MessageV2.toModelMessages(input, model), model, {})).toMatchObject([
{ role: "user" },
{ role: "assistant" },
{ role: "tool", content: [{ output: { type: "text", value: "ok" } }] },
{
role: "user",
content: [
{ type: "text", text: MessageV2.SYNTHETIC_ATTACHMENT_PROMPT },
{
type: "text",
text: 'ERROR: Cannot read "attachment.png" (this model does not support image input). Inform the user.',
},
],
},
])
const legacyModel: Provider.Model = {
...model,
api: { ...model.api, npm: "@ai-sdk/openai-compatible" },
capabilities: { ...model.capabilities, attachment: true },
}
expect(
ProviderTransform.message(await MessageV2.toModelMessages(input, legacyModel), legacyModel, {}),
).toMatchObject([
{ role: "user" },
{ role: "assistant" },
{ role: "tool" },
{
role: "user",
content: [
{ type: "text", text: MessageV2.SYNTHETIC_ATTACHMENT_PROMPT },
{ type: "file", mediaType: "image/png" },
],
},
])
})
test("preserves jpeg tool-result media for anthropic models", async () => {
@@ -494,6 +537,93 @@ describe("session.message-v2.toModelMessage", () => {
})
})
test.each([
["global.openai.gpt-6-astra", "@ai-sdk/amazon-bedrock", true],
["global.openai.gpt-5.6-sol", "@ai-sdk/amazon-bedrock", true],
["global.xai.grok-4.6", "@ai-sdk/amazon-bedrock", true],
["moonshotai.kimi-k2.5", "@ai-sdk/amazon-bedrock", true],
["nvidia.nemotron-nano-12b-v2", "@ai-sdk/amazon-bedrock", true],
["qwen.qwen3-vl-235b-a22b", "@ai-sdk/amazon-bedrock", true],
["global.anthropic.claude-sonnet-4-6", "@ai-sdk/amazon-bedrock", false],
["amazon.nova-lite-v1:0", "@ai-sdk/amazon-bedrock", false],
["us.meta.llama4-maverick-17b-instruct-v1:0", "@ai-sdk/amazon-bedrock", false],
["openai.gpt-6-astra", "@ai-sdk/amazon-bedrock/mantle", false],
])("places %s tool-result images correctly via %s", async (id, npm, hoist) => {
const bedrockModel: Provider.Model = {
...model,
id: ModelV2.ID.make("custom-alias"),
providerID: ProviderV2.ID.make("amazon-bedrock"),
api: { id, npm, url: "https://bedrock-runtime.us-east-2.amazonaws.com" },
capabilities: {
...model.capabilities,
attachment: true,
input: { ...model.capabilities.input, image: true },
},
}
const userID = "m-user-bedrock-image"
const assistantID = "m-assistant-bedrock-image"
const input: SessionV1.WithParts[] = [
{
info: userInfo(userID),
parts: [{ ...basePart(userID, "u1-bedrock-image"), type: "text", text: "read image" }],
},
{
info: assistantInfo(assistantID, userID),
parts: [
{
...basePart(assistantID, "a1-bedrock-image"),
type: "tool",
callID: "call-bedrock-image-1",
tool: "read",
state: {
status: "completed",
input: { filePath: "/tmp/example.png" },
output: "Image read successfully",
title: "Read",
metadata: {},
time: { start: 0, end: 1 },
attachments: [
{
...basePart(assistantID, "file-bedrock-image-1"),
type: "file",
mime: "image/png",
url: "data:image/png;base64,Zm9v",
},
],
},
},
],
},
]
const result = await MessageV2.toModelMessages(input, bedrockModel)
expect(result).toHaveLength(hoist ? 4 : 3)
expect(result[2]).toMatchObject({
role: "tool",
content: [
{
output: hoist
? { type: "text", value: "Image read successfully" }
: {
type: "content",
value: [
{ type: "text", text: "Image read successfully" },
{ type: "media", mediaType: "image/png", data: "Zm9v" },
],
},
},
],
})
if (hoist)
expect(result[3]).toMatchObject({
role: "user",
content: [
{ type: "text", text: "Attached media from tool result:" },
{ type: "file", mediaType: "image/png", data: "data:image/png;base64,Zm9v" },
],
})
})
test("moves bedrock pdf tool-result media into a separate user message", async () => {
const bedrockModel: Provider.Model = {
...model,