fix(kie): map the Nano Banana 2 Market id to its KIE upstream id (#11225) (#11231)

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!
This commit is contained in:
小妍儿 ✨
2026-08-23 20:48:48 +08:00
committed by GitHub
parent 8d59ab363b
commit 84bd382d94
2 changed files with 243 additions and 2 deletions

View File

@@ -91,6 +91,14 @@ interface KieImageOptions {
} | null;
}
export const KIE_MARKET_UPSTREAM_MODEL_IDS: ReadonlyMap<string, string> = 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 {

View File

@@ -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<string, unknown>;
}
interface CapturedMarketGeneration {
create: CapturedCreate;
pollUrl: string;
result: Awaited<ReturnType<typeof handleImageGeneration>>;
}
async function runKieMarketGeneration(publicModel: string): Promise<CapturedMarketGeneration> {
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<string, unknown>,
};
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<string, unknown>;
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<string, unknown>;
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<string, unknown> | 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<string, unknown>;
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;
}
});