Skip to content
Open
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
102 changes: 101 additions & 1 deletion packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ function applyCaching(msgs: ModelMessage[], model: Provider.Model): ModelMessage

function unsupportedParts(msgs: ModelMessage[], model: Provider.Model): ModelMessage[] {
return msgs.map((msg) => {
if (msg.role !== "user" || !Array.isArray(msg.content)) return msg
if ((msg.role !== "tool" && msg.role !== "user") || !Array.isArray(msg.content)) return msg

const filtered = msg.content.map((part) => {
if (part.type !== "file" && part.type !== "image") return part
Expand Down Expand Up @@ -409,6 +409,103 @@ function unsupportedParts(msgs: ModelMessage[], model: Provider.Model): ModelMes
})
}

function isToolResultPart(part: any): part is ToolResultPart | { type: "tool_result"; tool_use_id?: string } {
return part.type === "tool-result" || part.type === "tool_result"
}

function toolCallID(part: any) {
if (part.type === "tool-call") return part.toolCallId
if (part.type === "tool_use") return part.id
return undefined
}

function toolResultID(part: any) {
if (part.type === "tool-result") return part.toolCallId
if (part.type === "tool_result") return part.tool_use_id
return undefined
}

function stubToolCallForResult(part: any) {
const id = toolResultID(part) ?? "historical_tool_result"
return {
type: "tool-call" as const,
toolCallId: id,
toolName: part.toolName ?? part.name ?? "historical_tool_result",
input: {},
}
}

function sanitizeStrictToolResultOrder(msgs: ModelMessage[]) {
let expectedToolResults = new Set<string>()

return msgs.flatMap((msg): ModelMessage[] => {
if (!Array.isArray(msg.content)) {
expectedToolResults = new Set()
return [msg]
}

if (msg.role === "assistant") {
expectedToolResults = new Set(msg.content.map(toolCallID).filter((id): id is string => !!id))
return [msg]
}

const toolResults = msg.content.filter(isToolResultPart)
if (toolResults.length === 0) {
expectedToolResults = new Set()
return [msg]
}

const output: ModelMessage[] = []
const validToolResults = [] as typeof msg.content
const trailingContent = msg.content.filter((part) => !isToolResultPart(part))

for (const part of toolResults) {
const id = toolResultID(part)
if (id && expectedToolResults.has(id)) {
validToolResults.push(part)
expectedToolResults.delete(id)
continue
}

output.push(
{
role: "assistant",
content: [stubToolCallForResult(part)],
},
{
...msg,
role: "user",
content: [part],
},
)
}

expectedToolResults = new Set()

if (validToolResults.length > 0) {
output.unshift({
...msg,
content: validToolResults,
})
}

if (trailingContent.length > 0) {
output.push({
...msg,
role: "user",
content: trailingContent,
})
}

return output
})
}

function requiresStrictToolResultOrder(model: Provider.Model) {
const id = `${model.providerID}/${model.id}/${model.api.id}`.toLowerCase()
return id.includes("minimax")
}

function mapProviderOptions(
msgs: ModelMessage[],
transform: (options: Record<string, any> | undefined) => Record<string, any> | undefined,
Expand All @@ -430,6 +527,9 @@ function mapProviderOptions(
export function message(msgs: ModelMessage[], model: Provider.Model, options: Record<string, unknown>) {
msgs = unsupportedParts(msgs, model)
msgs = normalizeMessages(msgs, model, options)
if (requiresStrictToolResultOrder(model)) {
msgs = sanitizeStrictToolResultOrder(msgs)
}
if (
(model.providerID === "anthropic" ||
model.providerID === "google-vertex-anthropic" ||
Expand Down
196 changes: 196 additions & 0 deletions packages/opencode/test/provider/transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2610,6 +2610,202 @@ describe("ProviderTransform.message - bedrock caching with non-bedrock providerI
})
})

describe("ProviderTransform.message - MiniMax tool results", () => {
const minimax = {
id: "minimax-m3",
providerID: "opencode-go",
api: {
id: "minimax-m3",
url: "https://opencode.ai/zen/go/v1/messages",
npm: "@ai-sdk/openai-compatible",
},
name: "MiniMax M3",
capabilities: {},
options: {},
headers: {},
} as any

test("inserts a stub tool call before the minimal orphan tool-result that MiniMax rejects", () => {
const toolResult = {
type: "tool-result",
toolCallId: "call_orphan",
toolName: "bash",
output: { type: "text", value: "stdout" },
}
const msgs = [
{
role: "user",
content: [toolResult],
},
] as any[]

expect(ProviderTransform.message(msgs, minimax, {})).toEqual([
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call_orphan",
toolName: "bash",
input: {},
},
],
},
{
role: "user",
content: [toolResult],
},
])
})

test("keeps valid immediately-following tool results and splits trailing text", () => {
const toolCall = {
type: "tool-call",
toolCallId: "call_1",
toolName: "bash",
input: { command: "git diff --stat" },
}
const toolResult = {
type: "tool-result",
toolCallId: "call_1",
toolName: "bash",
output: { type: "text", value: "diff output" },
}
const trailingText = {
type: "text",
text: "Continue if you have next steps.",
}
const msgs = [
{
role: "assistant",
content: [toolCall],
},
{
role: "user",
content: [toolResult, trailingText],
},
] as any[]

expect(ProviderTransform.message(msgs, minimax, {})).toEqual([
msgs[0],
{
role: "user",
content: [toolResult],
},
{
role: "user",
content: [trailingText],
},
])
})

test("inserts a stub before non-immediate tool results", () => {
const toolCall = {
type: "tool-call",
toolCallId: "call_1",
toolName: "bash",
input: { command: "git diff --stat" },
}
const toolResult = {
type: "tool-result",
toolCallId: "call_1",
toolName: "bash",
output: { type: "text", value: "late output" },
}
const msgs = [
{
role: "assistant",
content: [toolCall],
},
{
role: "user",
content: "thanks",
},
{
role: "user",
content: [toolResult],
},
] as any[]

expect(ProviderTransform.message(msgs, minimax, {})).toEqual([
msgs[0],
msgs[1],
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call_1",
toolName: "bash",
input: {},
},
],
},
{
role: "user",
content: [toolResult],
},
])
})

test("also supports provider prompt tool_result shape", () => {
const toolResult = {
type: "tool_result",
tool_use_id: "call_orphan",
content: "diff output",
}
const msgs = [
{
role: "user",
content: [toolResult],
},
] as any[]

expect(ProviderTransform.message(msgs, minimax, {})).toEqual([
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call_orphan",
toolName: "historical_tool_result",
input: {},
},
],
},
{
role: "user",
content: [toolResult],
},
])
})

test("does not change non-MiniMax tool-result messages", () => {
const msgs = [
{
role: "user",
content: [
{
type: "tool-result",
toolCallId: "call_orphan",
toolName: "bash",
output: { type: "text", value: "stdout" },
},
],
},
] as any[]

const nonMinimax = {
...minimax,
id: "deepseek-v4-flash-free",
providerID: "opencode",
api: { ...minimax.api, id: "deepseek-v4-flash-free" },
}

expect(ProviderTransform.message(msgs, nonMinimax, {})).toEqual(msgs)
})
})

describe("ProviderTransform.message - cache control on gateway", () => {
const createModel = (overrides: Partial<any> = {}) =>
({
Expand Down
Loading