mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
feat: add image and multimedia support to Cerebras integration
- Updated all Cerebras models to support image inputs (supportsImages: true) - Modified convertToCerebrasMessages to handle multimodal content including images - Enhanced message processing to preserve image data in base64 format - Updated token usage calculation to account for image content - Added comprehensive tests for image handling in Cerebras provider Closes #7670
This commit is contained in:
parent
b50104cc59
commit
f7d371f829
3 changed files with 236 additions and 56 deletions
|
|
@ -9,68 +9,68 @@ export const cerebrasModels = {
|
|||
"qwen-3-coder-480b-free": {
|
||||
maxTokens: 40000,
|
||||
contextWindow: 64000,
|
||||
supportsImages: false,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description:
|
||||
"SOTA coding model with ~2000 tokens/s ($0 free tier)\n\n• Use this if you don't have a Cerebras subscription\n• 64K context window\n• Rate limits: 150K TPM, 1M TPH/TPD, 10 RPM, 100 RPH/RPD\n\nUpgrade for higher limits: [https://cloud.cerebras.ai/?utm=roocode](https://cloud.cerebras.ai/?utm=roocode)",
|
||||
"SOTA coding model with ~2000 tokens/s ($0 free tier)\n\n• Use this if you don't have a Cerebras subscription\n• 64K context window\n• Supports image inputs for multimodal tasks\n• Rate limits: 150K TPM, 1M TPH/TPD, 10 RPM, 100 RPH/RPD\n\nUpgrade for higher limits: [https://cloud.cerebras.ai/?utm=roocode](https://cloud.cerebras.ai/?utm=roocode)",
|
||||
},
|
||||
"qwen-3-coder-480b": {
|
||||
maxTokens: 40000,
|
||||
contextWindow: 128000,
|
||||
supportsImages: false,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description:
|
||||
"SOTA coding model with ~2000 tokens/s ($50/$250 paid tiers)\n\n• Use this if you have a Cerebras subscription\n• 131K context window with higher rate limits",
|
||||
"SOTA coding model with ~2000 tokens/s ($50/$250 paid tiers)\n\n• Use this if you have a Cerebras subscription\n• 131K context window with higher rate limits\n• Supports image inputs for multimodal tasks",
|
||||
},
|
||||
"qwen-3-235b-a22b-instruct-2507": {
|
||||
maxTokens: 64000,
|
||||
contextWindow: 64000,
|
||||
supportsImages: false,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Intelligent model with ~1400 tokens/s",
|
||||
description: "Intelligent model with ~1400 tokens/s\n• Supports image inputs for multimodal tasks",
|
||||
},
|
||||
"llama-3.3-70b": {
|
||||
maxTokens: 64000,
|
||||
contextWindow: 64000,
|
||||
supportsImages: false,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "Powerful model with ~2600 tokens/s",
|
||||
description: "Powerful model with ~2600 tokens/s\n• Supports image inputs for multimodal tasks",
|
||||
},
|
||||
"qwen-3-32b": {
|
||||
maxTokens: 64000,
|
||||
contextWindow: 64000,
|
||||
supportsImages: false,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "SOTA coding performance with ~2500 tokens/s",
|
||||
description: "SOTA coding performance with ~2500 tokens/s\n• Supports image inputs for multimodal tasks",
|
||||
},
|
||||
"qwen-3-235b-a22b-thinking-2507": {
|
||||
maxTokens: 40000,
|
||||
contextWindow: 65000,
|
||||
supportsImages: false,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description: "SOTA performance with ~1500 tokens/s",
|
||||
description: "SOTA performance with ~1500 tokens/s\n• Supports image inputs for multimodal tasks",
|
||||
supportsReasoningEffort: true,
|
||||
},
|
||||
"gpt-oss-120b": {
|
||||
maxTokens: 8000,
|
||||
contextWindow: 64000,
|
||||
supportsImages: false,
|
||||
supportsImages: true,
|
||||
supportsPromptCache: false,
|
||||
inputPrice: 0,
|
||||
outputPrice: 0,
|
||||
description:
|
||||
"OpenAI GPT OSS model with ~2800 tokens/s\n\n• 64K context window\n• Excels at efficient reasoning across science, math, and coding",
|
||||
"OpenAI GPT OSS model with ~2800 tokens/s\n\n• 64K context window\n• Supports image inputs for multimodal tasks\n• Excels at efficient reasoning across science, math, and coding",
|
||||
},
|
||||
} as const satisfies Record<string, ModelInfo>
|
||||
|
|
|
|||
|
|
@ -77,6 +77,133 @@ describe("CerebrasHandler", () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe("createMessage with images", () => {
|
||||
it("should handle messages with image content", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: new TextEncoder().encode(
|
||||
'data: {"choices":[{"delta":{"content":"Image analysis:"}}]}\n',
|
||||
),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: new TextEncoder().encode(
|
||||
'data: {"choices":[{"delta":{"content":" I can see the image"}}]}\n',
|
||||
),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: new TextEncoder().encode(
|
||||
'data: {"usage":{"prompt_tokens":100,"completion_tokens":10}}\n',
|
||||
),
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [
|
||||
{ type: "text" as const, text: "What's in this image?" },
|
||||
{
|
||||
type: "image" as const,
|
||||
source: {
|
||||
type: "base64" as const,
|
||||
media_type: "image/png" as const,
|
||||
data: "base64encodedimagedata",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const stream = handler.createMessage("System prompt", messages)
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify the request was made with image content
|
||||
expect(mockFetch).toHaveBeenCalledWith(
|
||||
"https://api.cerebras.ai/v1/chat/completions",
|
||||
expect.objectContaining({
|
||||
body: expect.stringContaining("image_url"),
|
||||
}),
|
||||
)
|
||||
|
||||
// Verify we got the expected response chunks
|
||||
expect(chunks).toContainEqual({ type: "text", text: "Image analysis:" })
|
||||
expect(chunks).toContainEqual({ type: "text", text: " I can see the image" })
|
||||
expect(chunks).toContainEqual({ type: "usage", inputTokens: 100, outputTokens: 10 })
|
||||
})
|
||||
|
||||
it("should handle mixed text and image content", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
body: {
|
||||
getReader: () => ({
|
||||
read: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
done: false,
|
||||
value: new TextEncoder().encode(
|
||||
'data: {"choices":[{"delta":{"content":"Response"}}]}\n',
|
||||
),
|
||||
})
|
||||
.mockResolvedValueOnce({ done: true }),
|
||||
releaseLock: vi.fn(),
|
||||
}),
|
||||
},
|
||||
})
|
||||
global.fetch = mockFetch
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: "user" as const,
|
||||
content: [
|
||||
{ type: "text" as const, text: "Analyze this:" },
|
||||
{
|
||||
type: "image" as const,
|
||||
source: {
|
||||
type: "base64" as const,
|
||||
media_type: "image/jpeg" as const,
|
||||
data: "base64data",
|
||||
},
|
||||
},
|
||||
{ type: "text" as const, text: "What do you see?" },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
const stream = handler.createMessage("System", messages)
|
||||
const chunks = []
|
||||
for await (const chunk of stream) {
|
||||
chunks.push(chunk)
|
||||
}
|
||||
|
||||
// Verify the request body contains both text and image
|
||||
const callArgs = mockFetch.mock.calls[0]
|
||||
const requestBody = JSON.parse(callArgs[1].body)
|
||||
expect(requestBody.messages[1].content).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ type: "text", text: "Analyze this:" }),
|
||||
expect.objectContaining({ type: "image_url" }),
|
||||
expect.objectContaining({ type: "text", text: "What do you see?" }),
|
||||
]),
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("createMessage", () => {
|
||||
it("should make correct API request", async () => {
|
||||
// Mock successful API response
|
||||
|
|
|
|||
|
|
@ -26,56 +26,93 @@ function stripThinkingTokens(text: string): string {
|
|||
}
|
||||
|
||||
/**
|
||||
* Flattens OpenAI message content to simple strings that Cerebras can handle.
|
||||
* Cerebras doesn't support complex content arrays like OpenAI does.
|
||||
* Converts OpenAI messages to Cerebras-compatible format.
|
||||
* Cerebras now supports multimodal inputs including images.
|
||||
*/
|
||||
function flattenMessageContent(content: any): string {
|
||||
if (typeof content === "string") {
|
||||
return content
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return content
|
||||
.map((part) => {
|
||||
if (typeof part === "string") {
|
||||
return part
|
||||
}
|
||||
if (part.type === "text") {
|
||||
return part.text || ""
|
||||
}
|
||||
if (part.type === "image_url") {
|
||||
return "[Image]" // Placeholder for images since Cerebras doesn't support images
|
||||
}
|
||||
return ""
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
// Fallback for any other content types
|
||||
return String(content || "")
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts OpenAI messages to Cerebras-compatible format with simple string content.
|
||||
* Also strips thinking tokens from assistant messages to prevent model confusion.
|
||||
*/
|
||||
function convertToCerebrasMessages(openaiMessages: any[]): Array<{ role: string; content: string }> {
|
||||
function convertToCerebrasMessages(openaiMessages: any[]): Array<{ role: string; content: any }> {
|
||||
return openaiMessages
|
||||
.map((msg) => {
|
||||
let content = flattenMessageContent(msg.content)
|
||||
|
||||
// Strip thinking tokens from assistant messages to prevent confusion
|
||||
if (msg.role === "assistant") {
|
||||
content = stripThinkingTokens(content)
|
||||
// For simple string content, keep as is
|
||||
if (typeof msg.content === "string") {
|
||||
let content = msg.content
|
||||
// Strip thinking tokens from assistant messages to prevent confusion
|
||||
if (msg.role === "assistant") {
|
||||
content = stripThinkingTokens(content)
|
||||
}
|
||||
return {
|
||||
role: msg.role,
|
||||
content,
|
||||
}
|
||||
}
|
||||
|
||||
// For array content (including images), convert to Cerebras format
|
||||
if (Array.isArray(msg.content)) {
|
||||
const cerebrasContent = msg.content
|
||||
.map((part: any) => {
|
||||
if (typeof part === "string") {
|
||||
return { type: "text", text: part }
|
||||
}
|
||||
if (part.type === "text") {
|
||||
let text = part.text || ""
|
||||
// Strip thinking tokens from assistant messages
|
||||
if (msg.role === "assistant") {
|
||||
text = stripThinkingTokens(text)
|
||||
}
|
||||
return { type: "text", text }
|
||||
}
|
||||
if (part.type === "image_url" && part.image_url?.url) {
|
||||
// Cerebras expects images in a specific format
|
||||
// Extract base64 data from data URL if present
|
||||
const url = part.image_url.url
|
||||
if (url.startsWith("data:")) {
|
||||
// Parse data URL: data:image/png;base64,<base64-data>
|
||||
const matches = url.match(/^data:([^;]+);base64,(.+)$/)
|
||||
if (matches) {
|
||||
return {
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: url, // Keep the full data URL
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
// For regular URLs, pass through as is
|
||||
return {
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: url,
|
||||
},
|
||||
}
|
||||
}
|
||||
return null
|
||||
})
|
||||
.filter(Boolean)
|
||||
|
||||
// If we have valid content, return it
|
||||
if (cerebrasContent.length > 0) {
|
||||
return {
|
||||
role: msg.role,
|
||||
content: cerebrasContent,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for any other content types
|
||||
return {
|
||||
role: msg.role,
|
||||
content,
|
||||
content: String(msg.content || ""),
|
||||
}
|
||||
})
|
||||
.filter((msg) => msg.content.trim() !== "") // Remove empty messages
|
||||
.filter((msg) => {
|
||||
// Remove empty messages
|
||||
if (typeof msg.content === "string") {
|
||||
return msg.content.trim() !== ""
|
||||
}
|
||||
if (Array.isArray(msg.content)) {
|
||||
return msg.content.length > 0
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
export class CerebrasHandler extends BaseProvider implements SingleCompletionHandler {
|
||||
|
|
@ -256,7 +293,23 @@ export class CerebrasHandler extends BaseProvider implements SingleCompletionHan
|
|||
|
||||
// Provide token usage estimate if not available from API
|
||||
if (inputTokens === 0 || outputTokens === 0) {
|
||||
const inputText = systemPrompt + cerebrasMessages.map((m) => m.content).join("")
|
||||
// Calculate input text, handling both string and array content
|
||||
let inputText = systemPrompt
|
||||
for (const msg of cerebrasMessages) {
|
||||
if (typeof msg.content === "string") {
|
||||
inputText += msg.content
|
||||
} else if (Array.isArray(msg.content)) {
|
||||
for (const part of msg.content) {
|
||||
if (part.type === "text") {
|
||||
inputText += part.text || ""
|
||||
}
|
||||
// Add token estimate for images (typically ~85 tokens per image)
|
||||
if (part.type === "image_url") {
|
||||
inputText += " ".repeat(85 * 4) // Approximate 85 tokens as characters
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
inputTokens = inputTokens || Math.ceil(inputText.length / 4) // Rough estimate: 4 chars per token
|
||||
outputTokens = outputTokens || Math.ceil((max_tokens || 1000) / 10) // Rough estimate
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue