diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index f6b7ec8cbcc..134b70535c2 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -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, content2: string | Array): string | Array { + // 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, @@ -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++) { @@ -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 diff --git a/packages/opencode/test/provider/perplexity-message-alternation.test.ts b/packages/opencode/test/provider/perplexity-message-alternation.test.ts new file mode 100644 index 00000000000..f37f9a797f6 --- /dev/null +++ b/packages/opencode/test/provider/perplexity-message-alternation.test.ts @@ -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") + }) +})