Skip to content
This repository was archived by the owner on May 15, 2026. It is now read-only.

Commit aa1e3c9

Browse files
committed
fix: flatten message content to string for SambaNova DeepSeek models
DeepSeek models on SambaNova expect string content in messages, not array content. This fix adds: 1. flattenAiSdkMessagesToStringContent utility that converts text-only messages to string format 2. transform option to convertToAiSdkMessages for flexible message transformation The SambaNova handler passes the flattening function to convertToAiSdkMessages for models that don't support images. Fixes: 400 'Invalid content type' error for DeepSeek-R1-0528 model
1 parent 29724d9 commit aa1e3c9

4 files changed

Lines changed: 276 additions & 7 deletions

File tree

src/api/providers/__tests__/sambanova.spec.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -259,7 +259,7 @@ describe("SambaNovaHandler", () => {
259259
expect(usageChunks[0].cacheWriteTokens).toBeUndefined()
260260
})
261261

262-
it("should pass correct temperature (0.5 default) to streamText", async () => {
262+
it("should pass correct temperature (0.7 default) to streamText", async () => {
263263
async function* mockFullStream() {
264264
yield { type: "text-delta", text: "Test" }
265265
}
@@ -282,7 +282,7 @@ describe("SambaNovaHandler", () => {
282282

283283
expect(mockStreamText).toHaveBeenCalledWith(
284284
expect.objectContaining({
285-
temperature: 0.5,
285+
temperature: 0.7,
286286
}),
287287
)
288288
})
@@ -369,7 +369,7 @@ describe("SambaNovaHandler", () => {
369369

370370
expect(mockGenerateText).toHaveBeenCalledWith(
371371
expect.objectContaining({
372-
temperature: 0.5,
372+
temperature: 0.7,
373373
}),
374374
)
375375
})

src/api/providers/sambanova.ts

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
processAiSdkStreamPart,
1313
mapToolChoice,
1414
handleAiSdkError,
15+
flattenAiSdkMessagesToStringContent,
1516
} from "../transform/ai-sdk"
1617
import { ApiStream, ApiStreamUsageChunk } from "../transform/stream"
1718
import { getModelParams } from "../transform/model-params"
@@ -20,7 +21,7 @@ import { DEFAULT_HEADERS } from "./constants"
2021
import { BaseProvider } from "./base-provider"
2122
import type { SingleCompletionHandler, ApiHandlerCreateMessageMetadata } from "../index"
2223

23-
const SAMBANOVA_DEFAULT_TEMPERATURE = 0.5
24+
const SAMBANOVA_DEFAULT_TEMPERATURE = 0.7
2425

2526
/**
2627
* SambaNova provider using the dedicated sambanova-ai-provider package.
@@ -112,11 +113,15 @@ export class SambaNovaHandler extends BaseProvider implements SingleCompletionHa
112113
messages: Anthropic.Messages.MessageParam[],
113114
metadata?: ApiHandlerCreateMessageMetadata,
114115
): ApiStream {
115-
const { temperature } = this.getModel()
116+
const { temperature, info } = this.getModel()
116117
const languageModel = this.getLanguageModel()
117118

118119
// Convert messages to AI SDK format
119-
const aiSdkMessages = convertToAiSdkMessages(messages)
120+
// For models that don't support multi-part content (like DeepSeek), flatten messages to string content
121+
// SambaNova's DeepSeek models expect string content, not array content
122+
const aiSdkMessages = convertToAiSdkMessages(messages, {
123+
transform: info.supportsImages ? undefined : flattenAiSdkMessagesToStringContent,
124+
})
120125

121126
// Convert tools to OpenAI format first, then to AI SDK format
122127
const openAiTools = this.convertToolsForOpenAI(metadata?.tools)

src/api/transform/__tests__/ai-sdk.spec.ts

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
mapToolChoice,
88
extractAiSdkErrorMessage,
99
handleAiSdkError,
10+
flattenAiSdkMessagesToStringContent,
1011
} from "../ai-sdk"
1112

1213
vitest.mock("ai", () => ({
@@ -644,4 +645,177 @@ describe("AI SDK conversion utilities", () => {
644645
expect((result as any).cause).toBe(originalError)
645646
})
646647
})
648+
649+
describe("flattenAiSdkMessagesToStringContent", () => {
650+
it("should return messages unchanged if content is already a string", () => {
651+
const messages = [
652+
{ role: "user" as const, content: "Hello" },
653+
{ role: "assistant" as const, content: "Hi there" },
654+
]
655+
656+
const result = flattenAiSdkMessagesToStringContent(messages)
657+
658+
expect(result).toEqual(messages)
659+
})
660+
661+
it("should flatten user messages with only text parts to string", () => {
662+
const messages = [
663+
{
664+
role: "user" as const,
665+
content: [
666+
{ type: "text" as const, text: "Hello" },
667+
{ type: "text" as const, text: "World" },
668+
],
669+
},
670+
]
671+
672+
const result = flattenAiSdkMessagesToStringContent(messages)
673+
674+
expect(result).toHaveLength(1)
675+
expect(result[0].role).toBe("user")
676+
expect(result[0].content).toBe("Hello\nWorld")
677+
})
678+
679+
it("should flatten assistant messages with only text parts to string", () => {
680+
const messages = [
681+
{
682+
role: "assistant" as const,
683+
content: [{ type: "text" as const, text: "I am an assistant" }],
684+
},
685+
]
686+
687+
const result = flattenAiSdkMessagesToStringContent(messages)
688+
689+
expect(result).toHaveLength(1)
690+
expect(result[0].role).toBe("assistant")
691+
expect(result[0].content).toBe("I am an assistant")
692+
})
693+
694+
it("should not flatten user messages with image parts", () => {
695+
const messages = [
696+
{
697+
role: "user" as const,
698+
content: [
699+
{ type: "text" as const, text: "Look at this" },
700+
{ type: "image" as const, image: "data:image/png;base64,abc123" },
701+
],
702+
},
703+
]
704+
705+
const result = flattenAiSdkMessagesToStringContent(messages)
706+
707+
expect(result).toEqual(messages)
708+
})
709+
710+
it("should not flatten assistant messages with tool calls", () => {
711+
const messages = [
712+
{
713+
role: "assistant" as const,
714+
content: [
715+
{ type: "text" as const, text: "Let me use a tool" },
716+
{
717+
type: "tool-call" as const,
718+
toolCallId: "123",
719+
toolName: "read_file",
720+
input: { path: "test.txt" },
721+
},
722+
],
723+
},
724+
]
725+
726+
const result = flattenAiSdkMessagesToStringContent(messages)
727+
728+
expect(result).toEqual(messages)
729+
})
730+
731+
it("should not flatten tool role messages", () => {
732+
const messages = [
733+
{
734+
role: "tool" as const,
735+
content: [
736+
{
737+
type: "tool-result" as const,
738+
toolCallId: "123",
739+
toolName: "test",
740+
output: { type: "text" as const, value: "result" },
741+
},
742+
],
743+
},
744+
] as any
745+
746+
const result = flattenAiSdkMessagesToStringContent(messages)
747+
748+
expect(result).toEqual(messages)
749+
})
750+
751+
it("should respect flattenUserMessages option", () => {
752+
const messages = [
753+
{
754+
role: "user" as const,
755+
content: [{ type: "text" as const, text: "Hello" }],
756+
},
757+
]
758+
759+
const result = flattenAiSdkMessagesToStringContent(messages, { flattenUserMessages: false })
760+
761+
expect(result).toEqual(messages)
762+
})
763+
764+
it("should respect flattenAssistantMessages option", () => {
765+
const messages = [
766+
{
767+
role: "assistant" as const,
768+
content: [{ type: "text" as const, text: "Hi" }],
769+
},
770+
]
771+
772+
const result = flattenAiSdkMessagesToStringContent(messages, { flattenAssistantMessages: false })
773+
774+
expect(result).toEqual(messages)
775+
})
776+
777+
it("should handle mixed message types correctly", () => {
778+
const messages = [
779+
{ role: "user" as const, content: "Simple string" },
780+
{
781+
role: "user" as const,
782+
content: [{ type: "text" as const, text: "Text parts" }],
783+
},
784+
{
785+
role: "assistant" as const,
786+
content: [{ type: "text" as const, text: "Assistant text" }],
787+
},
788+
{
789+
role: "assistant" as const,
790+
content: [
791+
{ type: "text" as const, text: "With tool" },
792+
{ type: "tool-call" as const, toolCallId: "456", toolName: "test", input: {} },
793+
],
794+
},
795+
]
796+
797+
const result = flattenAiSdkMessagesToStringContent(messages)
798+
799+
expect(result[0].content).toBe("Simple string") // unchanged
800+
expect(result[1].content).toBe("Text parts") // flattened
801+
expect(result[2].content).toBe("Assistant text") // flattened
802+
expect(result[3]).toEqual(messages[3]) // unchanged (has tool call)
803+
})
804+
805+
it("should handle empty text parts", () => {
806+
const messages = [
807+
{
808+
role: "user" as const,
809+
content: [
810+
{ type: "text" as const, text: "" },
811+
{ type: "text" as const, text: "Hello" },
812+
],
813+
},
814+
]
815+
816+
const result = flattenAiSdkMessagesToStringContent(messages)
817+
818+
expect(result[0].content).toBe("\nHello")
819+
})
820+
})
647821
})

src/api/transform/ai-sdk.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,29 @@ import OpenAI from "openai"
88
import { tool as createTool, jsonSchema, type ModelMessage, type TextStreamPart } from "ai"
99
import type { ApiStreamChunk } from "./stream"
1010

11+
/**
12+
* Options for converting Anthropic messages to AI SDK format.
13+
*/
14+
export interface ConvertToAiSdkMessagesOptions {
15+
/**
16+
* Optional function to transform the converted messages.
17+
* Useful for transformations like flattening message content for models that require string content.
18+
*/
19+
transform?: (messages: ModelMessage[]) => ModelMessage[]
20+
}
21+
1122
/**
1223
* Convert Anthropic messages to AI SDK ModelMessage format.
1324
* Handles text, images, tool uses, and tool results.
1425
*
1526
* @param messages - Array of Anthropic message parameters
27+
* @param options - Optional conversion options including post-processing function
1628
* @returns Array of AI SDK ModelMessage objects
1729
*/
18-
export function convertToAiSdkMessages(messages: Anthropic.Messages.MessageParam[]): ModelMessage[] {
30+
export function convertToAiSdkMessages(
31+
messages: Anthropic.Messages.MessageParam[],
32+
options?: ConvertToAiSdkMessagesOptions,
33+
): ModelMessage[] {
1934
const modelMessages: ModelMessage[] = []
2035

2136
// First pass: build a map of tool call IDs to tool names from assistant messages
@@ -149,9 +164,84 @@ export function convertToAiSdkMessages(messages: Anthropic.Messages.MessageParam
149164
}
150165
}
151166

167+
// Apply transform if provided
168+
if (options?.transform) {
169+
return options.transform(modelMessages)
170+
}
171+
152172
return modelMessages
153173
}
154174

175+
/**
176+
* Options for flattening AI SDK messages.
177+
*/
178+
export interface FlattenMessagesOptions {
179+
/**
180+
* If true, flattens user messages with only text parts to string content.
181+
* Default: true
182+
*/
183+
flattenUserMessages?: boolean
184+
/**
185+
* If true, flattens assistant messages with only text (no tool calls) to string content.
186+
* Default: true
187+
*/
188+
flattenAssistantMessages?: boolean
189+
}
190+
191+
/**
192+
* Flatten AI SDK messages to use string content where possible.
193+
* Some models (like DeepSeek on SambaNova) require string content instead of array content.
194+
* This function converts messages that contain only text parts to use simple string content.
195+
*
196+
* @param messages - Array of AI SDK ModelMessage objects
197+
* @param options - Options for controlling which message types to flatten
198+
* @returns Array of AI SDK ModelMessage objects with flattened content where applicable
199+
*/
200+
export function flattenAiSdkMessagesToStringContent(
201+
messages: ModelMessage[],
202+
options: FlattenMessagesOptions = {},
203+
): ModelMessage[] {
204+
const { flattenUserMessages = true, flattenAssistantMessages = true } = options
205+
206+
return messages.map((message) => {
207+
// Skip if content is already a string
208+
if (typeof message.content === "string") {
209+
return message
210+
}
211+
212+
// Handle user messages
213+
if (message.role === "user" && flattenUserMessages && Array.isArray(message.content)) {
214+
const parts = message.content as Array<{ type: string; text?: string }>
215+
// Only flatten if all parts are text
216+
const allText = parts.every((part) => part.type === "text")
217+
if (allText && parts.length > 0) {
218+
const textContent = parts.map((part) => part.text || "").join("\n")
219+
return {
220+
...message,
221+
content: textContent,
222+
}
223+
}
224+
}
225+
226+
// Handle assistant messages
227+
if (message.role === "assistant" && flattenAssistantMessages && Array.isArray(message.content)) {
228+
const parts = message.content as Array<{ type: string; text?: string }>
229+
// Only flatten if all parts are text (no tool calls)
230+
const allText = parts.every((part) => part.type === "text")
231+
if (allText && parts.length > 0) {
232+
const textContent = parts.map((part) => part.text || "").join("\n")
233+
return {
234+
...message,
235+
content: textContent,
236+
}
237+
}
238+
}
239+
240+
// Return unchanged for tool role and messages with non-text content
241+
return message
242+
})
243+
}
244+
155245
/**
156246
* Convert OpenAI-style function tool definitions to AI SDK tool format.
157247
*

0 commit comments

Comments
 (0)