From 16d0ce8b3ef58bc85fc688805de110236cd9c309 Mon Sep 17 00:00:00 2001 From: 404oops Date: Tue, 13 Oct 2026 11:13:00 +0200 Subject: [PATCH] Advertise supported image input for ZAI Flash models --- .../ai-chat/providers/zai/ZAIProvider.test.ts | 79 +++++++++++++++++++ .../drivers/ai-chat/providers/zai/models.ts | 9 ++- 2 files changed, 87 insertions(+), 1 deletion(-) diff --git a/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.test.ts b/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.test.ts index 1fa0b6a4d..993343d92 100644 --- a/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.test.ts +++ b/src/backend/drivers/ai-chat/providers/zai/ZAIProvider.test.ts @@ -51,6 +51,10 @@ import { AIChatStream } from '../../utils/Streaming.js'; import { ZAI_MODELS } from './models.js'; import { ZAIProvider } from './ZAIProvider.js'; import { modelLookupNames } from '../../utils/modelRouting.js'; +import { + modelSupportsModality, + requiredInputModalities, +} from '../../utils/mediaParts.js'; // ── OpenAI SDK mock ───────────────────────────────────────────────── // @@ -211,6 +215,47 @@ describe('ZAIProvider model catalog', () => { cached_tokens: 7.5, }); }); + + it('advertises image input, and no video or file input, only on the GLM-5.3 flash models', async () => { + const { provider } = makeProvider(); + const models = await provider.models(); + + const imageIds = models + .filter((m) => modelSupportsModality(m, 'image')) + .map((m) => m.id); + expect(imageIds).toEqual(['glm-5.3-flash', 'glm-5.3-flashx']); + for (const m of models) { + expect(m.modalities?.output).toEqual(['text']); + expect(modelSupportsModality(m, 'video')).toBe(false); + expect(modelSupportsModality(m, 'file')).toBe(false); + } + }); + + it('passes the driver input gate for a puter.js image message on the flash models only', async () => { + const { provider } = makeProvider(); + const models = await provider.models(); + // The shape `puter.ai.chat(prompt, imageURL)` sends. + const required = requiredInputModalities([ + { + content: [ + 'what is this?', + { image_url: { url: 'https://cdn.test/cat.png' } }, + ], + }, + ]); + expect(required).toEqual(['image']); + + const accepts = (id: string) => + required.every((modality) => + modelSupportsModality( + models.find((m) => m.id === id)!, + modality, + ), + ); + expect(accepts('glm-5.3-flash')).toBe(true); + expect(accepts('glm-5.3-flashx')).toBe(true); + expect(accepts('glm-5.3')).toBe(false); + }); }); // ── Request shape (OpenAI-compat quirks specific to GLM) ──────────── @@ -348,6 +393,40 @@ describe('ZAIProvider.complete request shape', () => { expect(args.response_format).toEqual({ type: 'json_object' }); }); + it.each(['glm-5.3-flash', 'zai/glm-5.3-flashx'])( + 'forwards image_url parts for %s unchanged, by URL and by data URL', + async (requested) => { + const { provider } = makeProvider(); + createMock.mockResolvedValueOnce(baseCompletion); + const content = [ + { type: 'text', text: 'compare these' }, + { + type: 'image_url', + image_url: { url: 'https://cdn.test/cat.png' }, + }, + { + type: 'image_url', + image_url: { url: 'data:image/png;base64,iVBORw0KGgo=' }, + }, + ]; + + await withTestActor(() => + provider.complete({ + model: requested, + messages: [ + { role: 'user', content: structuredClone(content) }, + ], + }), + ); + + const [args] = createMock.mock.calls[0]!; + expect(args.model).toBe(requested.replace(/^zai\//, '')); + // Z.AI reads http(s) and data URLs itself, so neither is fetched or + // rewritten on the way through. + expect(args.messages).toEqual([{ role: 'user', content }]); + }, + ); + it('strips Anthropic-style cache_control from messages before sending', async () => { const { provider } = makeProvider(); createMock.mockResolvedValueOnce(baseCompletion); diff --git a/src/backend/drivers/ai-chat/providers/zai/models.ts b/src/backend/drivers/ai-chat/providers/zai/models.ts index daa85adf0..5aed4d244 100644 --- a/src/backend/drivers/ai-chat/providers/zai/models.ts +++ b/src/backend/drivers/ai-chat/providers/zai/models.ts @@ -22,18 +22,23 @@ import { usdPerMToken } from '../../utils/pricing.js'; const K = 1_000; +// The flash models also read video and files upstream; only images are +// advertised, as the one input with a verified wire shape we forward as-is. +const IMAGE_INPUT = ['text', 'image']; + const textModel = ( id: string, name: string, context: number, maxTokens: number, costs: IChatModel['costs'], + input: string[] = ['text'], ): IChatModel => ({ puterId: `z-ai:z-ai/${id}`, id, name, aliases: [`z-ai/${id}`, `zai/${id}`], - modalities: { input: ['text'], output: ['text'] }, + modalities: { input, output: ['text'] }, open_weights: false, tool_call: true, context, @@ -62,6 +67,7 @@ export const ZAI_MODELS: IChatModel[] = [ 1_000 * K, 128 * K, usdPerMToken(0.15, 0.5, 0.03), + IMAGE_INPUT, ), textModel( 'glm-5.3-flashx', @@ -69,6 +75,7 @@ export const ZAI_MODELS: IChatModel[] = [ 1_000 * K, 128 * K, usdPerMToken(0.37, 1.25, 0.075), + IMAGE_INPUT, ), textModel( 'glm-5.2',