diff --git a/src/providers/registry.ts b/src/providers/registry.ts index f72bb7650b..421498aeeb 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -444,6 +444,30 @@ const ZAI_GLM_5X_MODELS = [...ZAI_GLM_53_MODELS, ...ZAI_GLM_52_MODELS]; * `preserveReasoningContentModels`, where flash DOES belong. */ const ZAI_GLM_5X_SIDECAR_VISION_MODELS = ZAI_GLM_5X_MODELS.filter(id => id !== "glm-5.3-flash"); +/** + * Positive input-modality declaration for the Chat-path GLM rows. + * + * `noVisionModels` already keeps Flash out of the vision sidecar, but that is a NEGATIVE + * statement: it stops a detour without telling the catalog what the model can read. With + * no `modelInputModalities` entry, `configuredInputModalities` returns undefined and the + * catalog falls through to the `["text"]` floor, so every client export (ZCode, Pi, OMP) + * listed a native VLM as text-only and its picker refused to attach an image. + * + * The Responses sibling row below already declares this positively, so the same model was + * described two different ways in one registry. + * + * Authoritative source: `GET https://api.z.ai/api/v1/models` returns `input_modalities: + * ["text"]` for glm-5.3 and `["text", "image"]` for glm-5.3-flash (captured in + * devlog/_plan/260912_zcode_protocol_and_catalog/evidence/zai-responses-models.json). + * docs.z.ai/devpack/latest-model says the same in prose: "GLM-5.3 is a text-only model... + * GLM-5.3-FLASH is a multimodal model". Upstream also lists video and file for Flash; + * neither the internal vocabulary nor the export vocabulary can express them, so `image` + * is where this stops. + */ +const ZAI_GLM_5X_INPUT_MODALITIES: Record = { + ...Object.fromEntries(ZAI_GLM_5X_SIDECAR_VISION_MODELS.map(id => [id, ["text"]])), + "glm-5.3-flash": ["text", "image"], +}; const ZAI_GLM_52_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"]; /** * GLM-5.3 does NOT share 5.2's five-tier ladder. docs.z.ai/devpack/latest-model folds every @@ -2604,6 +2628,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // Z.AI's OpenAI path returns 400 code 1211 for bracketed model ids. modelSuffixBracketStrip: true, noVisionModels: ZAI_GLM_5X_SIDECAR_VISION_MODELS, + modelInputModalities: ZAI_GLM_5X_INPUT_MODALITIES, modelReasoningEfforts: ZAI_GLM_5X_REASONING_EFFORTS, modelDefaultReasoningEfforts: Object.fromEntries(ZAI_GLM_53_MODELS.map(id => [id, "max"])), modelMaxOutputTokens: Object.fromEntries(ZAI_GLM_53_MODELS.map(id => [id, 131_072])), @@ -2685,6 +2710,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ modelContextWindows: { "glm-5.3": 1_000_000, "glm-5.3[1m]": 1_000_000, "glm-5.3-flash": 1_000_000, "glm-5.2": 1_000_000, "glm-5.2[1m]": 1_000_000 }, modelSuffixBracketStrip: true, noVisionModels: ZAI_GLM_5X_SIDECAR_VISION_MODELS, + modelInputModalities: ZAI_GLM_5X_INPUT_MODALITIES, modelReasoningEfforts: ZAI_GLM_5X_REASONING_EFFORTS, modelSupportsReasoningSummaries: Object.fromEntries(ZAI_GLM_5X_MODELS.map(id => [id, true])), preserveReasoningContentModels: ZAI_GLM_5X_MODELS, diff --git a/tests/providers/provider-registry-parity.test.ts b/tests/providers/provider-registry-parity.test.ts index 586c8a7735..96be6cc6ab 100644 --- a/tests/providers/provider-registry-parity.test.ts +++ b/tests/providers/provider-registry-parity.test.ts @@ -482,6 +482,21 @@ describe("provider registry parity", () => { // The sibling it is most often confused with stays text-only, so the assertion above // cannot pass by making every GLM row a VLM. expect(zai?.noVisionModels ?? []).toContain("glm-5.3"); + // The global loop above accepts an ABSENT declaration, which is exactly how the Chat + // rows shipped: Flash was kept out of the sidecar but never told the catalog it could + // read an image, so `configuredInputModalities` returned undefined and every client + // export listed a native VLM as text-only. Pin the positive declaration so the two + // Chat rows cannot drift back to describing Flash only by what it is not. + for (const id of ["zai", "zhipu-bigmodel-coding"] as const) { + const row = PROVIDER_REGISTRY.find(entry => entry.id === id); + expect(row?.modelInputModalities?.["glm-5.3-flash"]).toEqual(["text", "image"]); + expect(row?.modelInputModalities?.["glm-5.3"]).toEqual(["text"]); + // The bracketed aliases are looked up by their exact catalog id, not a stripped one, + // so they need their own text-only entries. + expect(row?.modelInputModalities?.["glm-5.3[1m]"]).toEqual(["text"]); + expect(row?.modelInputModalities?.["glm-5.2[1m]"]).toEqual(["text"]); + expect(row?.noVisionModels ?? []).not.toContain("glm-5.3-flash"); + } // `glm-5.3-flash` belongs in all three maps. It was seeded into the model list // and the context map alone, so it advertised a 1M window with no effort ladder, // no default effort and no output cap - and this assertion pinned that gap in