diff --git a/changelog.d/fixes/11296-kie-flux-kontext-dedicated-endpoint.md b/changelog.d/fixes/11296-kie-flux-kontext-dedicated-endpoint.md new file mode 100644 index 0000000000..f6ece679ae --- /dev/null +++ b/changelog.d/fixes/11296-kie-flux-kontext-dedicated-endpoint.md @@ -0,0 +1 @@ +- **fix(kie):** reroute `flux/kontext` off the KIE Market `createTask` flow — it is catalogued with `isMarket: true` but has no Market catalog page, so KIE rejected it with "model name not supported"; it now hits the dedicated `POST /api/v1/flux/kontext/generate` / `GET /api/v1/flux/kontext/record-info` endpoints instead (#11296). diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index f6134f6c05..6e0405d7fa 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -119,20 +119,21 @@ interface KieImageOptions { // ideogram/v3-reframe has no dedicated docs.kie.ai page as of this sweep // (its 3 siblings above are all direct id matches, so it is assumed // correct by pattern, not independently confirmed). -// Two catalog entries remain UNRESOLVED after this sweep and are -// deliberately left untouched pending a follow-up (see #11296 discussion): +// One catalog entry remains UNRESOLVED after this sweep and is deliberately +// left untouched pending a follow-up (see #11296 discussion): // - z-image/4.0-text-to-image and z-image/4.5-text-to-image: the only // documented Z-Image Market page (docs.kie.ai/market/z-image/z-image) // shows a single fixed `model` enum value `"z-image"` with no // version-specific id or "version" input field found — unclear whether // both catalog ids should collapse to the same upstream call. -// - flux/kontext: no `docs.kie.ai/market/flux2/kontext` (or similar) -// Market page exists; Flux Kontext is documented under the separate -// `/flux-kontext-api/*` docs tree with its own endpoint -// (`POST /api/v1/flux/kontext/generate`, models `flux-kontext-pro`/ -// `flux-kontext-max`), not the Market `createTask` flow this map feeds. -// This entry may be miscatalogued as `isMarket: true` and need a -// dedicated reroute rather than an id rewrite. +// flux/kontext is RESOLVED (#11296): it is catalogued with `isMarket: true` +// but has no `docs.kie.ai/market/flux2/kontext` (or similar) Market page — +// Flux Kontext is documented under the separate `/flux-kontext-api/*` docs +// tree with its own endpoint (`POST /api/v1/flux/kontext/generate`, poll +// `GET /api/v1/flux/kontext/record-info`, models `flux-kontext-pro`/ +// `flux-kontext-max`), not the Market `createTask` flow this map feeds. It is +// NOT in KIE_MARKET_UPSTREAM_MODEL_IDS below on purpose — handleKieImageGeneration +// reroutes it to the dedicated endpoint instead of rewriting its id. export const KIE_MARKET_UPSTREAM_MODEL_IDS: ReadonlyMap = new Map([ ["google-imagen/nano-banana", "google/nano-banana"], ["google-imagen/nano-banana-2", "nano-banana-2"], @@ -815,13 +816,29 @@ async function handleKieImageGeneration({ // Check if model is a Market model (unified API) const fullRegistry = getImageProvider(provider); const modelEntry = fullRegistry?.models?.find((m) => m.id === model); - const isMarket = modelEntry?.isMarket || model.includes("/"); + // #11296 — flux/kontext is catalogued with `isMarket: true`, but KIE does not + // expose it through the Market catalog at all: it lives under a dedicated API + // tree (POST /api/v1/flux/kontext/generate, poll .../flux/kontext/record-info) + // that rejects the Market createTask flow with "model name not supported". Route + // it there instead of treating it as a Market entry (see KIE_MARKET_UPSTREAM_MODEL_IDS + // comment above for the same finding). + const isFluxKontext = model === "flux/kontext"; + const isMarket = !isFluxKontext && (modelEntry?.isMarket || model.includes("/")); const { imageUrl } = extractImageInputs(body); let baseUrl = ""; let payload: Record = {}; - if (isMarket) { + if (isFluxKontext) { + // Dedicated Flux Kontext API endpoint (not part of the Market catalog). + baseUrl = `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/flux/kontext/generate`; + payload = { + prompt, + aspectRatio: mapImageSize(size), + model: "flux-kontext-pro", + ...(imageUrl ? { inputImage: imageUrl } : {}), + }; + } else if (isMarket) { // Unified Market API endpoint baseUrl = `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/createTask`; const input: Record = { @@ -853,13 +870,18 @@ async function handleKieImageGeneration({ const promptPreview = String(body.prompt ?? "").slice(0, 60); log.info( "IMAGE", - `${provider}/${model} (${isMarket ? "market" : "direct"}) | prompt: "${promptPreview}..."` + `${provider}/${model} (${isFluxKontext ? "flux-kontext" : isMarket ? "market" : "direct"}) | prompt: "${promptPreview}..."` ); } try { - const endpoint = isMarket ? "/api/v1/jobs/createTask" : new URL(baseUrl).pathname; - const createBaseUrl = isMarket ? providerConfig.baseUrl : baseUrl.replace(endpoint, ""); + const endpoint = isFluxKontext + ? "/api/v1/flux/kontext/generate" + : isMarket + ? "/api/v1/jobs/createTask" + : new URL(baseUrl).pathname; + const createBaseUrl = + isFluxKontext || isMarket ? providerConfig.baseUrl : baseUrl.replace(endpoint, ""); const createData = await kieExecutor.createTask({ baseUrl: createBaseUrl, token, @@ -888,11 +910,13 @@ async function handleKieImageGeneration({ } // Use statusUrl from providerConfig if available, fallback to dynamic derivation - const statusUrl = isMarket - ? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/recordInfo` - : providerConfig.statusUrl && !providerConfig.statusUrl.includes("jobs/recordInfo") - ? providerConfig.statusUrl - : baseUrl.replace(/\/generate$/, "/record-info"); + const statusUrl = isFluxKontext + ? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/flux/kontext/record-info` + : isMarket + ? `${providerConfig.baseUrl.replace(/\/$/, "")}/api/v1/jobs/recordInfo` + : providerConfig.statusUrl && !providerConfig.statusUrl.includes("jobs/recordInfo") + ? providerConfig.statusUrl + : baseUrl.replace(/\/generate$/, "/record-info"); const { data: recordData, state } = await kieExecutor.pollTask({ statusUrl, diff --git a/tests/unit/kie-market-upstream-model-id-11225.test.ts b/tests/unit/kie-market-upstream-model-id-11225.test.ts index df81dc414d..64be480d4c 100644 --- a/tests/unit/kie-market-upstream-model-id-11225.test.ts +++ b/tests/unit/kie-market-upstream-model-id-11225.test.ts @@ -410,3 +410,137 @@ test("KIE direct image routing keeps the gpt4o-image endpoint and payload shape" globalThis.fetch = originalFetch; } }); + +// #11296 — flux/kontext is catalogued with `isMarket: true`, but KIE does not +// expose it through the Market catalog: it lives under a dedicated API tree +// (POST /api/v1/flux/kontext/generate, GET /api/v1/flux/kontext/record-info). +// Sending it through the Market createTask flow gets rejected with "model +// name not supported" -- these tests lock in the dedicated-endpoint reroute +// and guard against a future regression back to the Market flow. + +test("KIE flux/kontext routes to the dedicated Flux Kontext endpoint, never the Market createTask endpoint (#11296)", async () => { + const originalFetch = globalThis.fetch; + let createUrl = ""; + let createBody: Record | undefined; + let pollUrl = ""; + + globalThis.fetch = (async (url: unknown, options: { body?: unknown } = {}) => { + const stringUrl = String(url); + + if (stringUrl === "https://api.kie.ai/api/v1/flux/kontext/generate") { + createUrl = stringUrl; + createBody = JSON.parse(String(options.body ?? "{}")) as Record; + return new Response(JSON.stringify({ code: 200, data: { taskId: "kie-flux-kontext-1" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (stringUrl.startsWith("https://api.kie.ai/api/v1/flux/kontext/record-info")) { + pollUrl = stringUrl; + return new Response( + JSON.stringify({ + code: 200, + data: { + state: "success", + resultJson: JSON.stringify({ + resultUrls: ["https://example.com/kie-flux-kontext-image.png"], + }), + }, + }), + { status: 200, headers: { "content-type": "application/json" } } + ); + } + + // Any other URL (in particular the Market createTask/recordInfo endpoints) + // is the regression this test guards against. + throw new Error(`Unexpected URL: ${stringUrl}`); + }) as typeof globalThis.fetch; + + try { + const result = await handleImageGeneration({ + body: { + model: "kie/flux/kontext", + prompt: "a calm harbour at sunrise", + size: "1024x1024", + n: 1, + }, + credentials: { apiKey: "test-kie-key" }, + log: null, + }); + + assert.equal(result.success, true, "KIE flux/kontext generation should succeed"); + assert.equal(createUrl, "https://api.kie.ai/api/v1/flux/kontext/generate"); + assert.deepEqual(createBody, { + prompt: "a calm harbour at sunrise", + aspectRatio: "1:1", + model: "flux-kontext-pro", + }); + assert.equal(new URL(pollUrl).searchParams.get("taskId"), "kie-flux-kontext-1"); + assert.ok("data" in result, "successful KIE generation must return image data"); + assert.equal(result.data.data[0].url, "https://example.com/kie-flux-kontext-image.png"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("KIE flux/kontext forwards an input image as 'inputImage' for edit calls (#11296)", async () => { + const originalFetch = globalThis.fetch; + let createBody: Record | undefined; + + globalThis.fetch = (async (url: unknown, options: { body?: unknown } = {}) => { + const stringUrl = String(url); + + if (stringUrl === "https://api.kie.ai/api/v1/flux/kontext/generate") { + createBody = JSON.parse(String(options.body ?? "{}")) as Record; + return new Response(JSON.stringify({ code: 200, data: { taskId: "kie-flux-kontext-2" } }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + if (stringUrl.startsWith("https://api.kie.ai/api/v1/flux/kontext/record-info")) { + return new Response( + JSON.stringify({ + code: 200, + data: { + state: "success", + resultJson: JSON.stringify({ + resultUrls: ["https://example.com/kie-flux-kontext-edit.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/flux/kontext", + prompt: "add a lighthouse", + size: "1024x1024", + n: 1, + image: "https://example.com/source.png", + }, + credentials: { apiKey: "test-kie-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal(createBody?.inputImage, "https://example.com/source.png"); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("flux/kontext is not part of the KIE Market upstream id map (#11296)", () => { + assert.equal( + KIE_MARKET_UPSTREAM_MODEL_IDS.has("flux/kontext"), + false, + "flux/kontext is rerouted to a dedicated endpoint, not id-rewritten through the Market map" + ); +});