From 84bd382d941158d301c3d251a3a206b3dbb80aa2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E5=A6=8D=E5=84=BF=20=E2=9C=A8?= Date: Sun, 23 Aug 2026 20:48:48 +0800 Subject: [PATCH] fix(kie): map the Nano Banana 2 Market id to its KIE upstream id (#11225) (#11231) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Validated on the combined batch board: gates clean, typecheck clean, focused tests green. Exactly one Market id rewritten at the createTask seam (google-imagen/nano-banana-2 → nano-banana-2); every other namespaced id forwards byte-identical — the contract is stated and tested precisely. Fixes #11225. Thank you @xiaoyaner0201! --- open-sse/handlers/imageGeneration.ts | 14 +- ...kie-market-upstream-model-id-11225.test.ts | 231 ++++++++++++++++++ 2 files changed, 243 insertions(+), 2 deletions(-) create mode 100644 tests/unit/kie-market-upstream-model-id-11225.test.ts diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index c8312ab974..fde2f4a403 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -91,6 +91,14 @@ interface KieImageOptions { } | null; } +export const KIE_MARKET_UPSTREAM_MODEL_IDS: ReadonlyMap = new Map([ + ["google-imagen/nano-banana-2", "nano-banana-2"], +]); + +export function resolveKieMarketUpstreamModelId(publicModelId: string): string { + return KIE_MARKET_UPSTREAM_MODEL_IDS.get(publicModelId) ?? publicModelId; +} + const OPENAI_IMAGE_TO_IMAGE_MODELS = new Set([ "black-forest-labs/FLUX.2-max", "black-forest-labs/FLUX.2-pro", @@ -205,7 +213,9 @@ function isCodexChatGptModelAccessError(status: number, errorText: string, model if (typeof nested === "string") detail = nested; } } - return detail === `The '${model}' model is not supported when using Codex with a ChatGPT account.`; + return ( + detail === `The '${model}' model is not supported when using Codex with a ChatGPT account.` + ); } const BFL_MODEL_ENDPOINTS = { @@ -773,7 +783,7 @@ async function handleKieImageGeneration({ input.image_url = imageUrl; } payload = { - model, + model: resolveKieMarketUpstreamModelId(model), input, }; } else { diff --git a/tests/unit/kie-market-upstream-model-id-11225.test.ts b/tests/unit/kie-market-upstream-model-id-11225.test.ts new file mode 100644 index 0000000000..bc9484494e --- /dev/null +++ b/tests/unit/kie-market-upstream-model-id-11225.test.ts @@ -0,0 +1,231 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-kie-11225-")); + +const { KIE_IMAGE_MODELS } = + await import("../../open-sse/config/providers/registry/kie/imageModels.ts"); +const { handleImageGeneration, KIE_MARKET_UPSTREAM_MODEL_IDS, resolveKieMarketUpstreamModelId } = + await import("../../open-sse/handlers/imageGeneration.ts"); + +/** + * Issue #11225 — KIE Market public model IDs are namespaced for the OmniRoute + * catalog (`kie/google-imagen/nano-banana-2`), but the KIE Market createTask + * API expects the bare upstream model ID `nano-banana-2`. Sending the + * namespaced id makes upstream reject the task. + * + * The mapping must be an explicit seam: other KIE Market ids such as + * `seedream/4.5-text-to-image` ARE the real upstream ids and must pass through + * unchanged, so a generic "strip everything before the slash" is wrong. + * + * These tests drive the real public `handleImageGeneration` entrypoint and + * capture the payload at the final executor boundary (`fetch` to + * `/api/v1/jobs/createTask`). No credentials, no network, no production data. + */ + +interface CapturedCreate { + url: string; + body: Record; +} + +interface CapturedMarketGeneration { + create: CapturedCreate; + pollUrl: string; + result: Awaited>; +} + +async function runKieMarketGeneration(publicModel: string): Promise { + const originalFetch = globalThis.fetch; + let captured: CapturedCreate | undefined; + let pollUrl = ""; + + globalThis.fetch = (async (url: unknown, options: { body?: unknown } = {}) => { + const stringUrl = String(url); + + if (stringUrl === "https://api.kie.ai/api/v1/jobs/createTask") { + captured = { + url: stringUrl, + body: JSON.parse(String(options.body ?? "{}")) as Record, + }; + return new Response(JSON.stringify({ code: 200, data: { taskId: "kie-market-task-1" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (stringUrl.startsWith("https://api.kie.ai/api/v1/jobs/recordInfo")) { + pollUrl = stringUrl; + return new Response( + JSON.stringify({ + code: 200, + data: { + state: "success", + resultJson: JSON.stringify({ + resultUrls: ["https://example.com/kie-market-image.png"], + }), + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + throw new Error(`Unexpected URL: ${stringUrl}`); + }) as typeof globalThis.fetch; + + try { + const result = await handleImageGeneration({ + body: { + model: publicModel, + prompt: "a calm harbour at sunrise", + size: "1024x1024", + n: 1, + }, + credentials: { apiKey: "test-kie-key" }, + log: null, + }); + + assert.equal(result.success, true, "KIE Market generation should succeed against the stub"); + assert.ok(captured, "expected a createTask request to be captured"); + assert.ok(pollUrl, "expected recordInfo polling to be captured"); + return { create: captured, pollUrl, result }; + } finally { + globalThis.fetch = originalFetch; + } +} + +function resolveLiveKieMarketCatalog() { + return KIE_IMAGE_MODELS.filter(({ isMarket }) => isMarket).map(({ id }) => ({ + publicModelId: id, + upstreamModelId: resolveKieMarketUpstreamModelId(id), + })); +} + +test("KIE Market resolver changes exactly one id in the live market catalog", () => { + const roundTrips = resolveLiveKieMarketCatalog(); + const changed = roundTrips.filter(({ publicModelId, upstreamModelId }) => { + return upstreamModelId !== publicModelId; + }); + + assert.deepEqual(changed, [ + { + publicModelId: "google-imagen/nano-banana-2", + upstreamModelId: "nano-banana-2", + }, + ]); +}); + +test("KIE Market resolver preserves every other live market catalog id byte-identically", () => { + for (const { publicModelId, upstreamModelId } of resolveLiveKieMarketCatalog()) { + if (publicModelId !== "google-imagen/nano-banana-2") { + assert.equal( + upstreamModelId, + publicModelId, + `${publicModelId} must round-trip byte-identically` + ); + } + } +}); + +test("KIE Market resolver keeps exactly one explicit upstream id mapping", () => { + assert.equal(KIE_MARKET_UPSTREAM_MODEL_IDS.size, 1); +}); + +test("KIE Market resolver passes an unknown namespaced id through byte-identically", () => { + const unknownModelId = "kie/foo/bar"; + let resolvedModelId = ""; + + assert.doesNotThrow(() => { + resolvedModelId = resolveKieMarketUpstreamModelId(unknownModelId); + }); + assert.equal(resolvedModelId, unknownModelId); +}); + +test("KIE Market createTask sends the bare upstream model id for Nano Banana 2 (#11225)", async () => { + const captured = await runKieMarketGeneration("kie/google-imagen/nano-banana-2"); + + assert.equal( + captured.create.body.model, + "nano-banana-2", + "KIE Market createTask must send the upstream model id, not the namespaced catalog id" + ); + + const input = captured.create.body.input as Record; + assert.equal(input.prompt, "a calm harbour at sunrise"); + assert.equal(input.aspect_ratio, "1:1"); + assert.equal(new URL(captured.pollUrl).searchParams.get("taskId"), "kie-market-task-1"); + assert.ok("data" in captured.result, "successful KIE generation must return image data"); + assert.equal(captured.result.data.data[0].url, "https://example.com/kie-market-image.png"); +}); + +test("KIE Market createTask leaves genuinely namespaced upstream ids untouched (#11225 control)", async () => { + const captured = await runKieMarketGeneration("kie/seedream/4.5-text-to-image"); + + assert.equal( + captured.create.body.model, + "seedream/4.5-text-to-image", + "seedream/4.5-text-to-image IS the upstream id and must not be stripped" + ); + + const input = captured.create.body.input as Record; + assert.equal(input.prompt, "a calm harbour at sunrise"); + assert.equal(input.aspect_ratio, "1:1"); +}); + +test("KIE direct image routing keeps the gpt4o-image endpoint and payload shape", async () => { + const originalFetch = globalThis.fetch; + let createUrl = ""; + let createBody: Record | undefined; + + globalThis.fetch = (async (url: unknown, options: { body?: unknown } = {}) => { + const stringUrl = String(url); + if (stringUrl === "https://api.kie.ai/api/v1/gpt4o-image/generate") { + createUrl = stringUrl; + createBody = JSON.parse(String(options.body ?? "{}")) as Record; + return new Response(JSON.stringify({ code: 200, data: { taskId: "kie-direct-task-1" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (stringUrl.startsWith("https://api.kie.ai/api/v1/gpt4o-image/record-info")) { + return new Response( + JSON.stringify({ + code: 200, + data: { + status: "SUCCESS", + response: { resultUrls: ["https://example.com/kie-direct-image.png"] }, + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + throw new Error(`Unexpected URL: ${stringUrl}`); + }) as typeof globalThis.fetch; + + try { + const result = await handleImageGeneration({ + body: { + model: "kie/gpt4o-image", + prompt: "a direct-path control", + size: "1024x1024", + n: 2, + }, + credentials: { apiKey: "test-kie-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal(createUrl, "https://api.kie.ai/api/v1/gpt4o-image/generate"); + assert.deepEqual(createBody, { + prompt: "a direct-path control", + size: "1:1", + nVariants: 2, + }); + } finally { + globalThis.fetch = originalFetch; + } +});