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
120 changes: 84 additions & 36 deletions packages/opencode/src/provider/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,47 @@ export namespace ProviderTransform {
return undefined
}

function ensureMessageAlternation(msgs: ModelMessage[]): ModelMessage[] {
const result: ModelMessage[] = []

for (let i = 0; i < msgs.length; i++) {
const currentMsg = msgs[i]
const prevMsg = result[result.length - 1]

// If this is a consecutive user message, merge it with the previous user message
if (currentMsg.role === "user" && prevMsg && prevMsg.role === "user") {
// Merge the content of consecutive user messages
const mergedContent = mergeMessageContent(prevMsg.content, currentMsg.content)
result[result.length - 1] = {
...prevMsg,
content: mergedContent,
}
} else {
result.push(currentMsg)
}
}

return result
}

function mergeMessageContent(content1: string | Array<any>, content2: string | Array<any>): string | Array<any> {
// Handle string content
if (typeof content1 === "string" && typeof content2 === "string") {
return content1 + "\n\n" + content2
}

// Handle array content
if (Array.isArray(content1) && Array.isArray(content2)) {
return [...content1, ...content2]
}

// Handle mixed types - convert both to array
const arr1 = Array.isArray(content1) ? content1 : [{ type: "text", text: content1 }]
const arr2 = Array.isArray(content2) ? content2 : [{ type: "text", text: content2 }]

return [...arr1, ...arr2]
}

function normalizeMessages(
msgs: ModelMessage[],
model: Provider.Model,
Expand Down Expand Up @@ -81,6 +122,45 @@ export namespace ProviderTransform {
return msg
})
}

if (
model.capabilities.interleaved &&
typeof model.capabilities.interleaved === "object" &&
model.capabilities.interleaved.field === "reasoning_content"
) {
return msgs.map((msg) => {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning")
const reasoningText = reasoningParts.map((part: any) => part.text).join("")

// Filter out reasoning parts from content
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")

// Include reasoning_content directly on the message for all assistant messages
if (reasoningText) {
return {
...msg,
content: filteredContent,
providerOptions: {
...msg.providerOptions,
openaiCompatible: {
...(msg.providerOptions as any)?.openaiCompatible,
reasoning_content: reasoningText,
},
},
}
}

return {
...msg,
content: filteredContent,
}
}

return msg
})
}

if (model.providerID === "mistral" || model.api.id.toLowerCase().includes("mistral")) {
const result: ModelMessage[] = []
for (let i = 0; i < msgs.length; i++) {
Expand Down Expand Up @@ -123,42 +203,10 @@ export namespace ProviderTransform {
return result
}

if (
model.capabilities.interleaved &&
typeof model.capabilities.interleaved === "object" &&
model.capabilities.interleaved.field === "reasoning_content"
) {
return msgs.map((msg) => {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning")
const reasoningText = reasoningParts.map((part: any) => part.text).join("")

// Filter out reasoning parts from content
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")

// Include reasoning_content directly on the message for all assistant messages
if (reasoningText) {
return {
...msg,
content: filteredContent,
providerOptions: {
...msg.providerOptions,
openaiCompatible: {
...(msg.providerOptions as any)?.openaiCompatible,
reasoning_content: reasoningText,
},
},
}
}

return {
...msg,
content: filteredContent,
}
}

return msg
})
// Perplexity requires alternating user/assistant roles after system messages
// Merge consecutive user messages to prevent 400 errors
if (model.providerID === "perplexity" || model.api.npm === "@ai-sdk/perplexity") {
msgs = ensureMessageAlternation(msgs)
}

return msgs
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { describe, test, expect } from "bun:test"
import { ProviderTransform } from "../../src/provider/transform"
import type { ModelMessage } from "ai"

describe("ProviderTransform.message - Perplexity message alternation", () => {
test("should ensure user-assistant alternation for Perplexity providers", () => {
const perplexityModel = {
id: "perplexity/sonar-pro",
providerID: "perplexity",
api: {
id: "sonar-pro",
url: "https://api.perplexity.ai",
npm: "@ai-sdk/perplexity",
},
name: "Sonar Pro",
capabilities: {
temperature: true,
reasoning: true,
attachment: false,
toolcall: true,
input: { text: true, audio: false, image: false, video: false, pdf: false },
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: 128000,
output: 4096,
},
status: "active" as const,
options: {},
headers: {},
release_date: "2024-01-01",
}

// This simulates the problematic message sequence that could occur
// when tools return attachments, creating consecutive user messages
const messagesWithConsecutiveUsers: ModelMessage[] = [
{ role: "system", content: "You are a helpful assistant." },
{ role: "user", content: "Please analyze this file" },
{ role: "assistant", content: "I'll analyze the file for you." },
{
role: "user",
content: "The tool read-file returned following attachments:",
},
{
role: "user", // This consecutive user message violates Perplexity's requirements
content: "Now please summarize the findings",
},
]

const result = ProviderTransform.message(messagesWithConsecutiveUsers, perplexityModel, {})

// Check that consecutive user messages are properly handled
const roles = result.map((msg) => msg.role)

// Find consecutive user roles
let hasConsecutiveUsers = false
for (let i = 1; i < roles.length; i++) {
if (roles[i] === "user" && roles[i - 1] === "user") {
hasConsecutiveUsers = true
break
}
}

// After implementing fix, consecutive user messages should be merged
expect(hasConsecutiveUsers).toBe(false)

// Verify that the sequence now properly alternates (no consecutive users)
const actualSequence = roles
expect(actualSequence).toEqual(["system", "user", "assistant", "user"])

// Verify that consecutive user messages were merged properly
const userMessages = result.filter((msg) => msg.role === "user")
expect(userMessages).toHaveLength(2) // First user message, then merged user message

// The first user message should remain unchanged
const firstUserMessage = userMessages[0]
expect(firstUserMessage.content.toString()).toBe("Please analyze this file")

// The second user message should contain merged content from both consecutive original user messages
const secondUserMessage = userMessages[1]
expect(secondUserMessage.content.toString()).toContain("The tool read-file returned following attachments:")
expect(secondUserMessage.content.toString()).toContain("Now please summarize the findings")
})
})