diff --git a/packages/ai/test/openai-completions-tool-result-images.test.ts b/packages/ai/test/openai-completions-tool-result-images.test.ts index 6b13398be..bac431780 100644 --- a/packages/ai/test/openai-completions-tool-result-images.test.ts +++ b/packages/ai/test/openai-completions-tool-result-images.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "bun:test"; import { convertMessages } from "@oh-my-pi/pi-ai/providers/openai-completions"; import { NON_VISION_IMAGE_PLACEHOLDER } from "@oh-my-pi/pi-ai/providers/vision-guard"; import type { AssistantMessage, Context, Model, ToolResultMessage, Usage } from "@oh-my-pi/pi-ai/types"; +import { buildModel } from "@oh-my-pi/pi-catalog/build"; import { getBundledModel } from "@oh-my-pi/pi-catalog/models"; import type { ResolvedOpenAICompat } from "@oh-my-pi/pi-catalog/types"; @@ -75,6 +76,48 @@ function buildToolResult(toolCallId: string, timestamp: number): ToolResultMessa } describe("openai-completions convertMessages", () => { + it("serializes Cerebras gemma image inputs as Chat Completions data URIs", () => { + const model = buildModel({ + id: "gemma-4-31b", + name: "Gemma 4 31B", + api: "openai-completions", + provider: "cerebras", + baseUrl: "https://api.cerebras.ai/v1", + reasoning: false, + input: ["text", "image"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8_192, + }); + const context: Context = { + messages: [ + { + role: "user", + content: [ + { type: "text", text: "Identify the shapes and colors. Return JSON only." }, + { type: "image", mimeType: "image/png", data: "ZmFrZQ==" }, + ], + timestamp: 1, + }, + ], + }; + + const messages = convertMessages(model, context, compat); + + expect(messages).toEqual([ + { + role: "user", + content: [ + { type: "text", text: "Identify the shapes and colors. Return JSON only." }, + { + type: "image_url", + image_url: { url: "data:image/png;base64,ZmFrZQ==" }, + }, + ], + }, + ]); + }); + it("batches tool-result images after consecutive tool results", () => { const baseModel = getBundledModel("openai", "gpt-4o-mini") as Model<"openai-completions">; const model: Model<"openai-completions"> = { diff --git a/packages/catalog/CHANGELOG.md b/packages/catalog/CHANGELOG.md index 09b0580c6..74aa65bd5 100644 --- a/packages/catalog/CHANGELOG.md +++ b/packages/catalog/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Cerebras `gemma-4-31b` dynamic discovery to mark the model as image-capable so attached images are serialized as OpenAI Chat Completions `image_url` data URIs. ([#3854](https://github.com/can1357/oh-my-pi/issues/3854)) + ## [16.2.6] - 2026-06-29 ### Fixed diff --git a/packages/catalog/src/provider-models/openai-compat.ts b/packages/catalog/src/provider-models/openai-compat.ts index 12776ea48..79d32c162 100644 --- a/packages/catalog/src/provider-models/openai-compat.ts +++ b/packages/catalog/src/provider-models/openai-compat.ts @@ -803,6 +803,18 @@ export function groqModelManagerOptions(config?: GroqModelManagerConfig): ModelM // 3. Cerebras // --------------------------------------------------------------------------- +const CEREBRAS_IMAGE_INPUT_MODEL_IDS = new Set(["gemma-4-31b"]); + +function applyCerebrasDiscoveryOverrides(model: ModelSpec<"openai-completions">): ModelSpec<"openai-completions"> { + if (!CEREBRAS_IMAGE_INPUT_MODEL_IDS.has(model.id)) { + return model; + } + return { + ...model, + input: ["text", "image"], + }; +} + export interface CerebrasModelManagerConfig { apiKey?: string; baseUrl?: string; @@ -812,7 +824,27 @@ export interface CerebrasModelManagerConfig { export function cerebrasModelManagerOptions( config?: CerebrasModelManagerConfig, ): ModelManagerOptions<"openai-completions"> { - return createSimpleOpenAICompletionsOptions("cerebras", "https://api.cerebras.ai/v1", config); + const apiKey = config?.apiKey; + const baseUrl = config?.baseUrl ?? "https://api.cerebras.ai/v1"; + const references = createBundledReferenceMap<"openai-completions">("cerebras"); + return { + providerId: "cerebras", + ...(apiKey && { + fetchDynamicModels: () => + fetchOpenAICompatibleModels({ + api: "openai-completions", + provider: "cerebras", + baseUrl, + apiKey, + mapModel: (entry, defaults) => { + const reference = references.get(defaults.id); + const model = mapWithBundledReference(entry, defaults, reference); + return applyCerebrasDiscoveryOverrides(model); + }, + fetch: config?.fetch, + }), + }), + }; } // --------------------------------------------------------------------------- diff --git a/packages/catalog/test/cerebras-provider.test.ts b/packages/catalog/test/cerebras-provider.test.ts new file mode 100644 index 000000000..7a829d1bc --- /dev/null +++ b/packages/catalog/test/cerebras-provider.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test"; +import { cerebrasModelManagerOptions } from "@oh-my-pi/pi-catalog/provider-models/openai-compat"; +import type { FetchImpl } from "@oh-my-pi/pi-catalog/types"; + +describe("Cerebras provider discovery", () => { + test("discovers gemma-4-31b as image-capable", async () => { + const calls: Array<{ url: string; authorization: string | null }> = []; + const fetchMock: FetchImpl = async (input: string | URL | Request, init?: RequestInit) => { + const headers = new Headers(init?.headers); + calls.push({ + url: String(input), + authorization: headers.get("authorization"), + }); + return new Response( + JSON.stringify({ + data: [ + { id: "gemma-4-31b", object: "model" }, + { id: "llama3.1-8b", object: "model" }, + ], + }), + { status: 200, headers: { "content-type": "application/json" } }, + ); + }; + + const options = cerebrasModelManagerOptions({ apiKey: "cerebras-test-key", fetch: fetchMock }); + const models = await options.fetchDynamicModels?.(); + + expect(calls).toEqual([ + { + url: "https://api.cerebras.ai/v1/models", + authorization: "Bearer cerebras-test-key", + }, + ]); + expect(models?.find(model => model.id === "gemma-4-31b")).toMatchObject({ + provider: "cerebras", + api: "openai-completions", + input: ["text", "image"], + }); + expect(models?.find(model => model.id === "llama3.1-8b")?.input).toEqual(["text"]); + }); +});