diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index a780479..c7de122 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -15,7 +15,6 @@ import { z } from "zod" import { getAIModel, SINGLE_SYSTEM_PROVIDERS, - supportsImageInput, supportsPromptCaching, } from "@/lib/ai-providers" import { findCachedResponse } from "@/lib/cached-responses" @@ -266,16 +265,10 @@ async function handleChatRequest(req: Request): Promise { lastUserMessage?.parts?.filter((part: any) => part.type === "file") || [] - // Check if user is sending images to a model that doesn't support them - // AI SDK silently drops unsupported parts, so we need to catch this early - if (fileParts.length > 0 && !supportsImageInput(modelId)) { - return Response.json( - { - error: `The model "${modelId}" does not support image input. Please use a vision-capable model (e.g., GPT-4o, Claude, Gemini) or remove the image.`, - }, - { status: 400 }, - ) - } + // Note: we used to pre-emptively reject images for models we guessed were + // text-only (by name matching). That heuristic misfired on newer models + // (see issue #874), so we now let the request through and surface the real + // provider error if the model genuinely can't accept images. // User input only - XML is now in a separate cached system message const formattedUserInput = `User input: diff --git a/lib/ai-providers.ts b/lib/ai-providers.ts index 38735be..14798f5 100644 --- a/lib/ai-providers.ts +++ b/lib/ai-providers.ts @@ -1419,77 +1419,14 @@ export function supportsPromptCaching(modelId: string): boolean { ) } -/** - * Check if a model supports image/vision input. - * Some models silently drop image parts without error (AI SDK warning only). - */ -export function supportsImageInput(modelId: string): boolean { - const lowerModelId = modelId.toLowerCase() - - // Helper to check if model has vision capability indicator - const hasVisionIndicator = - lowerModelId.includes("vision") || lowerModelId.includes("vl") - - // Models that DON'T support image/vision input (unless vision variant) - // Kimi K2 doesn't support images, but K2.5 does - // Only block kimi-k2 specifically, not other Kimi models - if ( - (lowerModelId.includes("kimi-k2") || - lowerModelId.includes("kimi_k2")) && - !hasVisionIndicator && - !lowerModelId.includes("2.5") && - !lowerModelId.includes("k2.5") - ) { - return false - } - - // Moonshot text models (moonshot-v1 series are text-only) - if (lowerModelId.includes("moonshot-v1") && !hasVisionIndicator) { - return false - } - - // MiniMax text models (MiniMax-M2.x series are text-only; M3 supports image input) - if ( - lowerModelId.includes("minimax") && - !hasVisionIndicator && - !lowerModelId.includes("m3") - ) { - return false - } - - // DeepSeek text models (not vision variants) - if (lowerModelId.includes("deepseek") && !hasVisionIndicator) { - return false - } - - // Qwen text models (not vision variants like qwen-vl) - // Qwen3.5 series (qwen3.5, qwen3.5-plus, qwen3.5-flash) natively support image input - // QvQ (Qwen Visual QA) models are vision models — exclude them even when prefixed with "qwen/" - if ( - lowerModelId.includes("qwen") && - !hasVisionIndicator && - !lowerModelId.includes("qwen3.5") && - !lowerModelId.includes("qvq") - ) { - return false - } - - // GLM text models (not vision variants) - // GLM vision models: glm-4v, glm-4v-9b, glm-4.1v-9b-thinking - if (lowerModelId.includes("glm") && !hasVisionIndicator) { - if (!/[\d.]v/.test(lowerModelId)) { - return false - } - } - - // Default: assume model supports images - return true -} - /** * Get the AI model for diagram validation. * Uses VALIDATION_MODEL env var if set, otherwise falls back to AI_MODEL. - * Throws if the model doesn't support image input. + * + * Note: we no longer guess whether the model supports image input from its + * name — that heuristic misfired on newer models (see issue #874). If a + * configured validation model can't handle images, the API call simply errors + * and the validate-diagram route falls back to "valid". */ export function getValidationModel(): ReturnType["model"] { // AI_MODEL may be comma-separated (multi-model fallback); pick the first. @@ -1502,12 +1439,6 @@ export function getValidationModel(): ReturnType["model"] { ) } - if (!supportsImageInput(modelId)) { - throw new Error( - `Validation requires a vision-capable model. Model "${modelId}" does not support image input.`, - ) - } - const { model } = getAIModel({ modelId }) return model } diff --git a/tests/unit/ai-providers.test.ts b/tests/unit/ai-providers.test.ts index 46b4684..826cbb0 100644 --- a/tests/unit/ai-providers.test.ts +++ b/tests/unit/ai-providers.test.ts @@ -3,7 +3,6 @@ import { getAIModel, isAihubmixStandardBaseURL, resolveBaseURL, - supportsImageInput, supportsPromptCaching, } from "@/lib/ai-providers" import { extractAihubmixModelIds } from "@/lib/aihubmix-models" @@ -183,89 +182,6 @@ describe("supportsPromptCaching", () => { }) }) -describe("supportsImageInput", () => { - it("returns true for models with vision capability", () => { - expect(supportsImageInput("gpt-4-vision")).toBe(true) - expect(supportsImageInput("qwen-vl")).toBe(true) - expect(supportsImageInput("deepseek-vl")).toBe(true) - }) - - it("returns false for Kimi K2 models without vision", () => { - expect(supportsImageInput("kimi-k2")).toBe(false) - expect(supportsImageInput("moonshot/kimi-k2")).toBe(false) - }) - - it("returns true for Kimi K2.5 models (supports vision)", () => { - expect(supportsImageInput("kimi-k2.5")).toBe(true) - expect(supportsImageInput("moonshotai/kimi-k2.5")).toBe(true) - }) - - it("returns false for Moonshot v1 text models", () => { - expect(supportsImageInput("moonshot-v1-8k")).toBe(false) - expect(supportsImageInput("moonshot-v1-32k")).toBe(false) - expect(supportsImageInput("moonshot-v1-128k")).toBe(false) - }) - - it("returns false for MiniMax M2 text models", () => { - expect(supportsImageInput("MiniMax-M2.7")).toBe(false) - expect(supportsImageInput("MiniMax-M2.7-highspeed")).toBe(false) - expect(supportsImageInput("MiniMax-M2")).toBe(false) - }) - - it("returns true for MiniMax M3 (supports image input)", () => { - expect(supportsImageInput("MiniMax-M3")).toBe(true) - }) - - it("returns false for DeepSeek text models", () => { - expect(supportsImageInput("deepseek-chat")).toBe(false) - expect(supportsImageInput("deepseek-coder")).toBe(false) - }) - - it("returns false for Qwen text models", () => { - expect(supportsImageInput("qwen-turbo")).toBe(false) - expect(supportsImageInput("qwen-plus")).toBe(false) - expect(supportsImageInput("qwen3-max")).toBe(false) - }) - - it("returns true for Qwen vision models", () => { - expect(supportsImageInput("qwen-vl")).toBe(true) - expect(supportsImageInput("Qwen3.5")).toBe(true) - expect(supportsImageInput("qwen3.5")).toBe(true) - expect(supportsImageInput("qwen3.5-plus")).toBe(true) - expect(supportsImageInput("qwen3.5-flash")).toBe(true) - expect(supportsImageInput("qwen3-vl-plus")).toBe(true) - expect(supportsImageInput("qwen3-vl-flash")).toBe(true) - }) - - it("returns true for QvQ (Qwen Visual QA) models including OpenRouter-prefixed names", () => { - expect(supportsImageInput("qvq-72b-preview")).toBe(true) - expect(supportsImageInput("qvq-max")).toBe(true) - expect(supportsImageInput("qwen/qvq-72b-preview")).toBe(true) - expect(supportsImageInput("qwen/qvq-max")).toBe(true) - }) - - it("returns false for GLM text models", () => { - expect(supportsImageInput("glm-4")).toBe(false) - expect(supportsImageInput("glm-4-plus")).toBe(false) - expect(supportsImageInput("glm-4-flash")).toBe(false) - expect(supportsImageInput("glm-4-long")).toBe(false) - expect(supportsImageInput("glm-4.7")).toBe(false) - expect(supportsImageInput("glm-5")).toBe(false) - }) - - it("returns true for GLM vision models", () => { - expect(supportsImageInput("glm-4v")).toBe(true) - expect(supportsImageInput("glm-4v-9b")).toBe(true) - expect(supportsImageInput("glm-4.1v-9b-thinking")).toBe(true) - }) - - it("returns true for Claude and GPT models by default", () => { - expect(supportsImageInput("claude-sonnet-4-5")).toBe(true) - expect(supportsImageInput("gpt-4o")).toBe(true) - expect(supportsImageInput("gemini-pro")).toBe(true) - }) -}) - vi.mock("ollama-ai-provider-v2", () => { const mockModel = { modelId: "test-model" } const mockProviderFn = vi.fn(() => mockModel)