Browse Source

Prevent chat models from entering media generation

NewApiWG model discovery was allowing Doubao text models to appear in media-generation contexts, which made image nodes call a model with no generation channel. The change filters Doubao chat/vision model names out of generation capability inference, bumps model caches, and guards execution for stale saved workflows.

Constraint: NewApiWG /models may return chat models without explicit capability metadata

Rejected: Keep all unknown models visible | it lets text-only models fail inside media nodes

Confidence: high

Scope-risk: narrow

Directive: Do not classify chat/VLM models as media generators without explicit generation capability metadata

Tested: npm test -- src/app/api/generate/providers/__tests__/newapiwg.test.ts

Tested: npm test -- src/components/__tests__/ModelSearchDialog.test.tsx

Not-tested: Full tsc remains blocked by existing unrelated test type errors
feature/canvas-chatbot-copilot
jiajia 2 months ago
parent
commit
ed6bb21ebc
  1. 33
      src/app/api/generate/providers/__tests__/newapiwg.test.ts
  2. 14
      src/app/api/generate/providers/newapiwg.ts
  3. 2
      src/app/api/models/route.ts
  4. 2
      src/components/modals/ModelSearchDialog.tsx

33
src/app/api/generate/providers/__tests__/newapiwg.test.ts

@ -57,6 +57,19 @@ describe("NewApiWG model capability inference", () => {
supported_endpoint_types: ["openai"],
})).toEqual(["text-to-video", "image-to-video"]);
expect(inferNewApiWGCapabilities({
id: "doubao-seed-2-0-pro-260215",
name: "Doubao Seed 2.0 Pro",
owned_by: "doubao",
supported_endpoint_types: ["openai"],
})).toEqual([]);
expect(inferNewApiWGCapabilities({
id: "doubao-1-5-vision-pro",
owned_by: "doubao",
supported_endpoint_types: ["openai"],
})).toEqual([]);
expect(inferNewApiWGCapabilities({
id: "speech-2.8-hd",
owned_by: "minimax",
@ -89,6 +102,7 @@ describe("NewApiWG model capability inference", () => {
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
data: [
{ id: "kimi-k2.6", owned_by: "moonshot", supported_endpoint_types: ["openai"] },
{ id: "doubao-seed-2-0-pro-260215", owned_by: "doubao", supported_endpoint_types: ["openai"] },
{ id: "doubao-seedance-2-0-260128", owned_by: "doubao-video", supported_endpoint_types: ["openai"] },
{ id: "apiyi_sora_image", owned_by: "custom", supported_endpoint_types: ["openai"] },
],
@ -361,6 +375,25 @@ describe("NewApiWG generation payloads", () => {
expect(capturedBody!.image).toBe("data:image/png;base64,input");
});
it("blocks cached Doubao chat models before calling media generation endpoints", async () => {
const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
model: {
id: "doubao-seed-2-0-pro-260215",
name: "Doubao Seed 2.0 Pro",
description: null,
provider: "newapiwg",
capabilities: ["text-to-image", "image-to-image"],
},
prompt: "draw a banana",
}));
expect(result.success).toBe(false);
expect(result.error).toBe(
"Doubao Seed 2.0 Pro: this is a text/chat model, not an image, video, audio, or 3D generation model. Choose a generation model such as PopiArt Nano Pro, Seedream, or Seedance."
);
expect(capturedBody).toBeNull();
});
it("normalizes transient provider overload errors", () => {
expect(normalizeNewApiWGError("system disk overloaded", "Nano Banana")).toBe(
"Nano Banana: provider is temporarily overloaded. Please retry in a moment or switch models."

14
src/app/api/generate/providers/newapiwg.ts

@ -71,6 +71,10 @@ export function normalizeNewApiWGBaseUrl(baseUrl?: string | null): string {
return (baseUrl || process.env.NEWAPIWG_BASE_URL || DEFAULT_NEWAPIWG_BASE_URL).replace(/\/+$/, "");
}
function isDoubaoChatModelName(text: string): boolean {
return /(^|[\s_\-/])doubao[\s_\-/](?!seedance|seedream)(?:seed|lite|pro|vision|flash|thinking|1|[0-9])/i.test(text);
}
function flattenTags(value: unknown): string[] {
if (!value) return [];
if (Array.isArray(value)) return value.flatMap(flattenTags);
@ -119,6 +123,9 @@ export function inferNewApiWGCapabilities(model: NewApiWGModelObject): ModelCapa
...flattenTags(model.supported_endpoint_types),
...rawTags,
].filter(Boolean).join(" ").toLowerCase();
const modelNameText = [model.id, model.name].filter(Boolean).join(" ").toLowerCase();
const isDoubaoChatOnly = isDoubaoChatModelName(modelNameText);
if (isDoubaoChatOnly) return [];
const isImageNamedSora = /(^|[\s_\-/])sora[_\-/]?image(?=$|[\s_\-/])/.test(text);
const hasImage = /image|img|vision|picture|photo|flux|sdxl|stable-diffusion|midjourney|dall-e|gpt-image|imagen|seedream|banana|ernie-image/.test(text);
@ -738,6 +745,13 @@ export async function generateWithNewApiWG(
input: GenerationInput
): Promise<GenerationOutput> {
const normalizedBaseUrl = normalizeNewApiWGBaseUrl(baseUrl);
if (isDoubaoChatModelName(`${input.model.id} ${input.model.name}`)) {
return {
success: false,
error: `${input.model.name}: this is a text/chat model, not an image, video, audio, or 3D generation model. Choose a generation model such as PopiArt Nano Pro, Seedream, or Seedance.`,
};
}
const isVideo = input.model.capabilities.some((cap) => cap.includes("video"));
const isAudio = !isVideo && input.model.capabilities.includes("text-to-audio");
const prompt = promptFromInput(input);

2
src/app/api/models/route.ts

@ -1241,7 +1241,7 @@ export async function GET(
// For fal.ai, include search in cache key since their API supports search
const cacheKey =
provider === "newapiwg"
? `${getCacheKey(provider)}:${newApiWGBaseUrl}:${newApiWGKey?.slice(-8) ?? "no-key"}`
? `${getCacheKey(provider)}:cap-v2:${newApiWGBaseUrl}:${newApiWGKey?.slice(-8) ?? "no-key"}`
: provider === "replicate" || provider === "wavespeed"
? getCacheKey(provider)
: getCacheKey(provider, searchQuery);

2
src/components/modals/ModelSearchDialog.tsx

@ -10,7 +10,7 @@ import { ProviderModel, ModelCapability } from "@/lib/providers/types";
import { useI18n } from "@/i18n";
// localStorage cache for models (persists across dev server restarts)
const MODELS_CACHE_KEY = "node-banana-models-cache";
const MODELS_CACHE_KEY = "node-banana-models-cache-v2";
const MODELS_CACHE_TTL = 48 * 60 * 60 * 1000; // 48 hours
interface ModelsCacheEntry {

Loading…
Cancel
Save