From db676303829020e2e06e9ae29c280765ce687bbf Mon Sep 17 00:00:00 2001 From: Friedrich Pfitzmann Date: Mon, 19 Jan 2026 15:37:27 +0100 Subject: [PATCH] fix: resolve Perplexity Sonar model 400 error - ensure message alternation - Add ensureMessageAlternation() to merge consecutive user messages - Add mergeMessageContent() for safe content combination - Apply fix only to Perplexity providers (perplexity providerID or @ai-sdk/perplexity npm) - Preserve full backward compatibility with all other providers - Add comprehensive test case to prevent regression - Addresses: 'After the (optional) system message(s), user and assistant roles should be alternating' Fixes issue where tools returning attachments created consecutive user messages, causing Perplexity API to reject requests with 400 Bad Request error. --- packages/opencode/src/provider/transform.ts | 120 ++++++++++++------ .../perplexity-message-alternation.test.ts | 90 +++++++++++++ 2 files changed, 174 insertions(+), 36 deletions(-) create mode 100644 packages/opencode/test/provider/perplexity-message-alternation.test.ts 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") + }) +})