diff --git a/open-sse/handlers/imageGeneration.ts b/open-sse/handlers/imageGeneration.ts index be6fe9dac4..f030375346 100644 --- a/open-sse/handlers/imageGeneration.ts +++ b/open-sse/handlers/imageGeneration.ts @@ -99,6 +99,21 @@ const STABILITY_EDIT_ENDPOINTS = { const STABILITY_CONTROL_MODELS = new Set(["sketch", "structure", "style", "style-transfer"]); +function appendOptionalFormValue(formData, key, value) { + if (value === undefined || value === null || value === "") return; + formData.append(key, String(value)); +} + +function appendImageFormValue(formData, key, source, filename) { + formData.append( + key, + new Blob([source.buffer], { + type: source.contentType || "application/octet-stream", + }), + filename + ); +} + const FAL_PRESET_SIZES = { "1024x1024": "square_hd", "512x512": "square", @@ -1054,52 +1069,107 @@ async function handleStabilityAIImageGeneration({ ? normalizeRequestedImageFormat(body, "png", ["png", "webp"]) : normalizeRequestedImageFormat(body, "png"), }; + const formData = new FormData(); - if (body.prompt) upstreamBody.prompt = body.prompt; - if (body.negative_prompt) upstreamBody.negative_prompt = body.negative_prompt; - if (body.seed !== undefined) upstreamBody.seed = body.seed; + appendOptionalFormValue(formData, "output_format", upstreamBody.output_format); + if (body.prompt) { + upstreamBody.prompt = body.prompt; + appendOptionalFormValue(formData, "prompt", body.prompt); + } + if (body.negative_prompt) { + upstreamBody.negative_prompt = body.negative_prompt; + appendOptionalFormValue(formData, "negative_prompt", body.negative_prompt); + } + if (body.seed !== undefined) { + upstreamBody.seed = body.seed; + appendOptionalFormValue(formData, "seed", body.seed); + } try { if (STABILITY_GENERATION_ENDPOINTS[model]) { if (model.startsWith("sd3.5")) { upstreamBody.model = model; + appendOptionalFormValue(formData, "model", model); } if (imageUrl) { + const imageSource = await resolveImageSource(imageUrl); upstreamBody.mode = "image-to-image"; - upstreamBody.image = (await resolveImageSource(imageUrl)).base64; - if (body.strength !== undefined) upstreamBody.strength = body.strength; + appendOptionalFormValue(formData, "mode", "image-to-image"); + upstreamBody.image = imageSource.base64; + appendImageFormValue(formData, "image", imageSource, "image"); + if (body.strength !== undefined) { + upstreamBody.strength = body.strength; + appendOptionalFormValue(formData, "strength", body.strength); + } } else { upstreamBody.mode = "text-to-image"; + appendOptionalFormValue(formData, "mode", "text-to-image"); } if (!model.startsWith("sd3.5") || !imageUrl) { - upstreamBody.aspect_ratio = body.aspect_ratio || mapImageSize(body.size); + const aspectRatio = body.aspect_ratio || mapImageSize(body.size); + upstreamBody.aspect_ratio = aspectRatio; + appendOptionalFormValue(formData, "aspect_ratio", aspectRatio); } - if (body.style_preset) upstreamBody.style_preset = body.style_preset; + if (body.style_preset) { + upstreamBody.style_preset = body.style_preset; + appendOptionalFormValue(formData, "style_preset", body.style_preset); + } } else { if (imageUrl) { - upstreamBody.image = (await resolveImageSource(imageUrl)).base64; + const imageSource = await resolveImageSource(imageUrl); + upstreamBody.image = imageSource.base64; + appendImageFormValue(formData, "image", imageSource, "image"); } if (maskUrl && shouldIncludeStabilityMask(model)) { - upstreamBody.mask = (await resolveImageSource(maskUrl)).base64; + const maskSource = await resolveImageSource(maskUrl); + upstreamBody.mask = maskSource.base64; + appendImageFormValue(formData, "mask", maskSource, "mask"); } - if (body.search_prompt) upstreamBody.search_prompt = body.search_prompt; - if (body.grow_mask !== undefined) upstreamBody.grow_mask = body.grow_mask; - if (body.control_strength !== undefined) + if (body.search_prompt) { + upstreamBody.search_prompt = body.search_prompt; + appendOptionalFormValue(formData, "search_prompt", body.search_prompt); + } + if (body.grow_mask !== undefined) { + upstreamBody.grow_mask = body.grow_mask; + appendOptionalFormValue(formData, "grow_mask", body.grow_mask); + } + if (body.control_strength !== undefined) { upstreamBody.control_strength = body.control_strength; - if (body.creativity !== undefined) upstreamBody.creativity = body.creativity; - if (body.left !== undefined) upstreamBody.left = body.left; - if (body.right !== undefined) upstreamBody.right = body.right; - if (body.up !== undefined) upstreamBody.up = body.up; - if (body.down !== undefined) upstreamBody.down = body.down; - if (body.style_preset) upstreamBody.style_preset = body.style_preset; + appendOptionalFormValue(formData, "control_strength", body.control_strength); + } + if (body.creativity !== undefined) { + upstreamBody.creativity = body.creativity; + appendOptionalFormValue(formData, "creativity", body.creativity); + } + if (body.left !== undefined) { + upstreamBody.left = body.left; + appendOptionalFormValue(formData, "left", body.left); + } + if (body.right !== undefined) { + upstreamBody.right = body.right; + appendOptionalFormValue(formData, "right", body.right); + } + if (body.up !== undefined) { + upstreamBody.up = body.up; + appendOptionalFormValue(formData, "up", body.up); + } + if (body.down !== undefined) { + upstreamBody.down = body.down; + appendOptionalFormValue(formData, "down", body.down); + } + if (body.style_preset) { + upstreamBody.style_preset = body.style_preset; + appendOptionalFormValue(formData, "style_preset", body.style_preset); + } if (STABILITY_CONTROL_MODELS.has(model) && !upstreamBody.prompt) { upstreamBody.prompt = body.prompt || ""; + appendOptionalFormValue(formData, "prompt", body.prompt || ""); } } @@ -1111,11 +1181,10 @@ async function handleStabilityAIImageGeneration({ const response = await fetch(`${providerConfig.baseUrl.replace(/\/$/, "")}${endpoint}`, { method: "POST", headers: { - "Content-Type": "application/json", Accept: "application/json", Authorization: `Bearer ${token}`, }, - body: JSON.stringify(upstreamBody), + body: formData, }); if (!response.ok) { diff --git a/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx b/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx index 333fa67186..917b94077d 100644 --- a/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx +++ b/src/app/(dashboard)/dashboard/cache/media/MediaPageClient.tsx @@ -281,10 +281,35 @@ function getVoiceList(providerId: string) { /** Parse a human-readable error from the API error response */ function parseApiError(raw: any, statusCode: number): { message: string; isCredentials: boolean } { + const readErrorMessage = (value: any): string | null => { + if (!value) return null; + if (typeof value === "string") return value; + if (Array.isArray(value)) { + const messages = value + .map((entry: any) => readErrorMessage(entry)) + .filter((entry: string | null): entry is string => Boolean(entry)); + if (messages.length > 0) return messages.join(", "); + return null; + } + if (typeof value.message === "string") return value.message; + if (typeof value.detail === "string") return value.detail; + if (Array.isArray(value.errors)) { + const messages = value.errors + .map((entry: any) => readErrorMessage(entry)) + .filter((entry: string | null): entry is string => Boolean(entry)); + if (messages.length > 0) return messages.join(", "); + } + try { + return JSON.stringify(value); + } catch { + return null; + } + }; + const msg = - raw?.error?.message || + readErrorMessage(raw?.error) || + readErrorMessage(raw?.errors) || raw?.err_msg || - raw?.error || raw?.message || raw?.detail || (typeof raw === "string" ? raw : null) || diff --git a/src/lib/providers/imageValidation.ts b/src/lib/providers/imageValidation.ts new file mode 100644 index 0000000000..150a3a1804 --- /dev/null +++ b/src/lib/providers/imageValidation.ts @@ -0,0 +1,164 @@ +import { getImageProvider } from "@omniroute/open-sse/config/imageRegistry"; + +import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard"; +import { + SAFE_OUTBOUND_FETCH_PRESETS, + SafeOutboundFetchError, + getSafeOutboundFetchErrorStatus, + safeOutboundFetch, +} from "@/shared/network/safeOutboundFetch"; + +const IMAGE_PROVIDER_VALIDATION_ENDPOINTS: Record< + string, + { baseUrl?: string; path: string; method?: string } +> = { + nanobanana: { + baseUrl: "https://api.nanobananaapi.ai", + path: "/api/v1/common/credit", + }, + "fal-ai": { + baseUrl: "https://api.fal.ai", + path: "/v1/models?limit=1", + }, + "stability-ai": { + path: "/v1/user/account", + }, + "black-forest-labs": { + path: "/v1/credits", + }, + recraft: { + path: "/v1/users/me", + }, + topaz: { + path: "/account/v1/credits/balance", + }, +}; + +function normalizeBaseUrl(baseUrl: string) { + return (baseUrl || "").trim().replace(/\/$/, ""); +} + +function applyCustomUserAgent(headers: Record, providerSpecificData: any = {}) { + const customUserAgent = + typeof providerSpecificData?.customUserAgent === "string" + ? providerSpecificData.customUserAgent.trim() + : ""; + if (customUserAgent) { + headers["user-agent"] = customUserAgent; + } + return headers; +} + +function toValidationErrorResult(error: unknown) { + const message = error instanceof Error ? error.message : String(error || "Validation failed"); + const statusCode = getSafeOutboundFetchErrorStatus(error); + + return { + valid: false, + error: message || "Validation failed", + unsupported: false, + ...(statusCode ? { statusCode } : {}), + ...(error instanceof SafeOutboundFetchError && error.code === "TIMEOUT" + ? { timeout: true } + : {}), + ...(statusCode === 400 ? { securityBlocked: true } : {}), + }; +} + +function buildImageProviderValidationHeaders( + imageProvider: any, + apiKey: string, + providerSpecificData: any = {} +) { + const headers: Record = { + Accept: "application/json", + }; + + if (apiKey) { + switch (String(imageProvider?.authHeader || "").toLowerCase()) { + case "bearer": + headers.Authorization = `Bearer ${apiKey}`; + break; + case "key": + headers.Authorization = `Key ${apiKey}`; + break; + case "x-key": + headers["x-key"] = apiKey; + break; + case "x-api-key": + headers["X-API-Key"] = apiKey; + break; + case "none": + break; + default: + headers.Authorization = `Bearer ${apiKey}`; + break; + } + } + + return applyCustomUserAgent(headers, providerSpecificData); +} + +async function validationRead(url: string, init: RequestInit) { + return safeOutboundFetch(url, { + ...SAFE_OUTBOUND_FETCH_PRESETS.validationRead, + guard: getProviderOutboundGuard(), + ...init, + }); +} + +export async function validateImageProviderApiKey({ + provider, + apiKey, + providerSpecificData = {}, +}: any) { + const imageProvider = getImageProvider(provider); + const validationConfig = IMAGE_PROVIDER_VALIDATION_ENDPOINTS[provider]; + + if (!imageProvider || !validationConfig) { + return { valid: false, error: "Provider validation not supported", unsupported: true }; + } + + try { + const baseUrl = normalizeBaseUrl( + providerSpecificData?.baseUrl || validationConfig.baseUrl || imageProvider.baseUrl + ); + const url = `${baseUrl}${validationConfig.path}`; + const response = await validationRead(url, { + method: validationConfig.method || "GET", + headers: buildImageProviderValidationHeaders(imageProvider, apiKey, providerSpecificData), + }); + + if (response.ok) { + return { valid: true, error: null, method: "image-provider" }; + } + + if (response.status === 401 || response.status === 403) { + return { valid: false, error: "Invalid API key", method: "image-provider" }; + } + + if (response.status === 429) { + return { + valid: false, + error: "Validation rate limited (429)", + method: "image-provider", + }; + } + + if (response.status >= 500) { + return { + valid: false, + error: `Provider unavailable (${response.status})`, + method: "image-provider", + }; + } + + return { + valid: false, + error: `Validation failed: ${response.status}`, + method: "image-provider", + }; + } catch (error: any) { + return toValidationErrorResult(error); + } +} diff --git a/src/lib/providers/validation.ts b/src/lib/providers/validation.ts index b3003c8535..dcff7864ec 100644 --- a/src/lib/providers/validation.ts +++ b/src/lib/providers/validation.ts @@ -68,6 +68,7 @@ import { } from "@omniroute/open-sse/config/runway.ts"; import { PETALS_DEFAULT_MODEL, normalizePetalsBaseUrl } from "@omniroute/open-sse/config/petals.ts"; import { signAwsRequest } from "@omniroute/open-sse/utils/awsSigV4.ts"; +import { validateImageProviderApiKey } from "@/lib/providers/imageValidation"; const OPENAI_LIKE_FORMATS = new Set(["openai", "openai-responses"]); const GEMINI_LIKE_FORMATS = new Set(["gemini", "gemini-cli"]); @@ -672,34 +673,7 @@ async function validateAssemblyAIProvider({ apiKey, providerSpecificData = {} }: } async function validateNanoBananaProvider({ apiKey, providerSpecificData = {} }: any) { - try { - // NanoBanana doesn't expose a lightweight validation endpoint, - // so we send a minimal generate request that will succeed or fail on auth. - const response = await validationWrite( - "https://api.nanobananaapi.ai/api/v1/nanobanana/generate", - { - method: "POST", - headers: applyCustomUserAgent( - { - Authorization: `Bearer ${apiKey}`, - "Content-Type": "application/json", - }, - providerSpecificData - ), - body: JSON.stringify({ - prompt: "test", - model: "nanobanana-flash", - }), - } - ); - // Auth errors → 401/403; anything else (even 400 bad request) means auth passed - if (response.status === 401 || response.status === 403) { - return { valid: false, error: "Invalid API key" }; - } - return { valid: true, error: null }; - } catch (error: any) { - return toValidationErrorResult(error); - } + return validateImageProviderApiKey({ provider: "nanobanana", apiKey, providerSpecificData }); } async function validateElevenLabsProvider({ apiKey, providerSpecificData = {} }: any) { @@ -2722,6 +2696,16 @@ export async function validateProviderApiKey({ provider, apiKey, providerSpecifi deepgram: validateDeepgramProvider, assemblyai: validateAssemblyAIProvider, nanobanana: validateNanoBananaProvider, + "fal-ai": ({ apiKey, providerSpecificData }: any) => + validateImageProviderApiKey({ provider: "fal-ai", apiKey, providerSpecificData }), + "stability-ai": ({ apiKey, providerSpecificData }: any) => + validateImageProviderApiKey({ provider: "stability-ai", apiKey, providerSpecificData }), + "black-forest-labs": ({ apiKey, providerSpecificData }: any) => + validateImageProviderApiKey({ provider: "black-forest-labs", apiKey, providerSpecificData }), + recraft: ({ apiKey, providerSpecificData }: any) => + validateImageProviderApiKey({ provider: "recraft", apiKey, providerSpecificData }), + topaz: ({ apiKey, providerSpecificData }: any) => + validateImageProviderApiKey({ provider: "topaz", apiKey, providerSpecificData }), elevenlabs: validateElevenLabsProvider, inworld: validateInworldProvider, "aws-polly": validateAwsPollyProvider, diff --git a/src/shared/utils/upstreamError.ts b/src/shared/utils/upstreamError.ts index 5d2ae949a9..d3fc4e2616 100644 --- a/src/shared/utils/upstreamError.ts +++ b/src/shared/utils/upstreamError.ts @@ -23,8 +23,32 @@ export function toJsonErrorPayload(rawError, fallbackMessage = "Upstream provide }; } if (errorObj && typeof errorObj === "object") { + const nestedMessage = extractErrorMessage(errorObj); + if (!("message" in errorObj) && nestedMessage) { + return { + error: { + ...errorObj, + message: nestedMessage, + type: errorObj.type || "upstream_error", + code: errorObj.code || "upstream_error", + }, + }; + } return rawError; } + if (!("message" in rawError)) { + const message = extractErrorMessage(rawError); + if (message) { + return { + error: { + message, + type: rawError.type || "upstream_error", + code: rawError.code || "upstream_error", + details: rawError, + }, + }; + } + } return { error: rawError }; } @@ -50,3 +74,34 @@ export function toJsonErrorPayload(rawError, fallbackMessage = "Upstream provide return fallback; } + +function extractErrorMessage(value) { + if (!value || typeof value !== "object") return null; + + if (typeof value.message === "string" && value.message.trim()) { + return value.message.trim(); + } + + if (typeof value.detail === "string" && value.detail.trim()) { + return value.detail.trim(); + } + + if (Array.isArray(value.errors)) { + const messages = value.errors + .map((entry) => { + if (typeof entry === "string") return entry.trim(); + if (entry && typeof entry === "object") { + return extractErrorMessage(entry) || JSON.stringify(entry); + } + return ""; + }) + .filter(Boolean); + if (messages.length > 0) return messages.join(", "); + } + + if (typeof value.name === "string" && value.name.trim()) { + return value.name.trim(); + } + + return null; +} diff --git a/tests/unit/display-and-error-utils.test.ts b/tests/unit/display-and-error-utils.test.ts index 55b89c1de1..a2ad1ed3c4 100644 --- a/tests/unit/display-and-error-utils.test.ts +++ b/tests/unit/display-and-error-utils.test.ts @@ -37,6 +37,55 @@ test("toJsonErrorPayload: wraps plain objects under error key", () => { }); }); +test("toJsonErrorPayload: extracts provider errors arrays into message strings", () => { + assert.deepEqual( + toJsonErrorPayload({ + errors: ["content-type must be multipart/form-data"], + name: "bad request", + }), + { + error: { + message: "content-type must be multipart/form-data", + type: "upstream_error", + code: "upstream_error", + details: { + errors: ["content-type must be multipart/form-data"], + name: "bad request", + }, + }, + } + ); +}); + +test("toJsonErrorPayload: normalizes object entries in provider errors arrays", () => { + assert.deepEqual( + toJsonErrorPayload({ + errors: [ + { message: "first provider error" }, + { detail: "second provider error" }, + { code: "invalid_request", field: "prompt" }, + ], + name: "bad request", + }), + { + error: { + message: + 'first provider error, second provider error, {"code":"invalid_request","field":"prompt"}', + type: "upstream_error", + code: "upstream_error", + details: { + errors: [ + { message: "first provider error" }, + { detail: "second provider error" }, + { code: "invalid_request", field: "prompt" }, + ], + name: "bad request", + }, + }, + } + ); +}); + test("toJsonErrorPayload: parses JSON strings recursively", () => { const raw = JSON.stringify({ error: { message: "nested json", code: "bad_request" } }); assert.deepEqual(toJsonErrorPayload(raw), { diff --git a/tests/unit/image-generation-handler.test.ts b/tests/unit/image-generation-handler.test.ts index 6e7ff3ca1e..a043b53394 100644 --- a/tests/unit/image-generation-handler.test.ts +++ b/tests/unit/image-generation-handler.test.ts @@ -354,7 +354,7 @@ test("handleImageGeneration routes Stability AI edit models to native endpoints" requestCapture = { url: stringUrl, headers: options.headers, - body: JSON.parse(String(options.body || "{}")), + body: options.body, }; return new Response(JSON.stringify({ image: "c3RhYmlsaXR5LWltYWdl" }), { @@ -383,15 +383,70 @@ test("handleImageGeneration routes Stability AI edit models to native endpoints" assert.equal(result.success, true); assert.equal(requestCapture.url, "https://api.stability.ai/v2beta/stable-image/edit/inpaint"); assert.equal(requestCapture.headers.Authorization, "Bearer stability-key"); - assert.equal(requestCapture.body.image, "BAU="); - assert.equal(requestCapture.body.mask, "AA=="); - assert.equal(requestCapture.body.output_format, "png"); + assert.equal(requestCapture.headers.Accept, "application/json"); + assert.equal(requestCapture.headers["Content-Type"], undefined); + assert.ok(requestCapture.body instanceof FormData); + assert.equal(requestCapture.body.get("prompt"), "replace the sky with aurora"); + assert.equal(requestCapture.body.get("negative_prompt"), "rain"); + assert.equal(requestCapture.body.get("output_format"), "png"); + assert.equal((requestCapture.body.get("image") as Blob).size, 2); + assert.equal((requestCapture.body.get("mask") as Blob).size, 1); assert.equal(result.data.data[0].b64_json, "c3RhYmlsaXR5LWltYWdl"); } finally { globalThis.fetch = originalFetch; } }); +test("handleImageGeneration sends Stability AI text generation as multipart form data", async () => { + const originalFetch = globalThis.fetch; + let requestCapture; + + globalThis.fetch = async (url, options = {}) => { + const stringUrl = String(url); + if (stringUrl === "https://api.stability.ai/v2beta/stable-image/generate/core") { + requestCapture = { + url: stringUrl, + headers: options.headers, + body: options.body, + }; + + return new Response(JSON.stringify({ image: "c3RhYmlsaXR5LWNvcmU=" }), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + throw new Error(`Unexpected URL: ${stringUrl}`); + }; + + try { + const result = await handleImageGeneration({ + body: { + model: "stability-ai/stable-image-core", + prompt: "city near beach", + size: "1024x1024", + response_format: "b64_json", + }, + credentials: { apiKey: "stability-key" }, + log: null, + }); + + assert.equal(result.success, true); + assert.equal(requestCapture.url, "https://api.stability.ai/v2beta/stable-image/generate/core"); + assert.equal(requestCapture.headers.Authorization, "Bearer stability-key"); + assert.equal(requestCapture.headers.Accept, "application/json"); + assert.equal(requestCapture.headers["Content-Type"], undefined); + assert.ok(requestCapture.body instanceof FormData); + assert.equal(requestCapture.body.get("prompt"), "city near beach"); + assert.equal(requestCapture.body.get("mode"), "text-to-image"); + assert.equal(requestCapture.body.get("aspect_ratio"), "1:1"); + assert.equal(requestCapture.body.get("output_format"), "png"); + assert.equal(result.data.data[0].b64_json, "c3RhYmlsaXR5LWNvcmU="); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("handleImageGeneration polls Black Forest Labs results and sends base64 input images", async () => { const originalFetch = globalThis.fetch; const originalSetTimeout = globalThis.setTimeout; diff --git a/tests/unit/provider-validation-image-only.test.ts b/tests/unit/provider-validation-image-only.test.ts new file mode 100644 index 0000000000..a5ab43f027 --- /dev/null +++ b/tests/unit/provider-validation-image-only.test.ts @@ -0,0 +1,147 @@ +import test from "node:test"; +import assert from "node:assert/strict"; + +const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts"); + +const originalFetch = globalThis.fetch; + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +const imageOnlyProviders = { + "fal-ai": { + url: "https://api.fal.ai/v1/models?limit=1", + header: "Authorization", + value: "Key fal-ai-key", + }, + "stability-ai": { + url: "https://api.stability.ai/v1/user/account", + header: "Authorization", + value: "Bearer stability-ai-key", + }, + "black-forest-labs": { + url: "https://api.bfl.ai/v1/credits", + header: "x-key", + value: "black-forest-labs-key", + }, + recraft: { + url: "https://external.api.recraft.ai/v1/users/me", + header: "Authorization", + value: "Bearer recraft-key", + }, + topaz: { + url: "https://api.topazlabs.com/account/v1/credits/balance", + header: "X-API-Key", + value: "topaz-key", + }, +}; + +const expectedValidationError = (status: number) => + status === 429 ? "Validation rate limited (429)" : `Validation failed: ${status}`; + +for (const [provider, config] of Object.entries(imageOnlyProviders)) { + test(`${provider} API key validator returns valid on 200`, async () => { + let fetchCalled = false; + globalThis.fetch = async (url, init = {}) => { + fetchCalled = true; + assert.equal(String(url), config.url); + assert.equal((init.headers as Record)[config.header], config.value); + return new Response(JSON.stringify({ ok: true }), { status: 200 }); + }; + + const result = await validateProviderApiKey({ provider, apiKey: `${provider}-key` }); + + assert.equal(result.valid, true, `${provider} should validate a 200 response`); + assert.equal(result.error, null, `${provider} should not return an error for 200`); + assert.equal(fetchCalled, true, `${provider} should call its validation endpoint`); + }); +} + +for (const provider of Object.keys(imageOnlyProviders)) { + for (const status of [401, 403]) { + test(`${provider} API key validator returns invalid on ${status}`, async () => { + let fetchCalled = false; + globalThis.fetch = async () => { + fetchCalled = true; + return new Response(JSON.stringify({ error: "unauthorized" }), { status }); + }; + + const result = await validateProviderApiKey({ provider, apiKey: `${provider}-key` }); + + assert.equal(result.valid, false, `${provider} should reject ${status}`); + assert.equal(result.error, "Invalid API key", `${provider} should surface auth failure`); + assert.equal(fetchCalled, true, `${provider} should call its validation endpoint`); + }); + } + + for (const status of [400, 404, 429]) { + test(`${provider} API key validator returns validation failed on ${status}`, async () => { + let fetchCalled = false; + globalThis.fetch = async () => { + fetchCalled = true; + return new Response(JSON.stringify({ error: "validation failed" }), { status }); + }; + + const result = await validateProviderApiKey({ provider, apiKey: `${provider}-key` }); + + assert.equal(result.valid, false, `${provider} should reject ${status}`); + assert.equal( + result.error, + expectedValidationError(status), + `${provider} should surface validation failure` + ); + assert.equal(fetchCalled, true, `${provider} should call its validation endpoint`); + }); + } +} + +test("NanoBanana API key validator returns valid on 200", async () => { + let fetchCalled = false; + globalThis.fetch = async (url, init = {}) => { + fetchCalled = true; + assert.match(String(url), /nanobanana/i); + assert.equal((init.headers as Record).Authorization, "Bearer nb-key"); + return new Response(JSON.stringify({ taskId: "task-1" }), { status: 200 }); + }; + + const result = await validateProviderApiKey({ provider: "nanobanana", apiKey: "nb-key" }); + + assert.equal(result.valid, true); + assert.equal(result.error, null); + assert.equal(fetchCalled, true); +}); + +for (const status of [401, 403]) { + test(`NanoBanana API key validator returns invalid on ${status}`, async () => { + let fetchCalled = false; + globalThis.fetch = async (url) => { + fetchCalled = true; + assert.match(String(url), /nanobanana/i); + return new Response(JSON.stringify({ error: "unauthorized" }), { status }); + }; + + const result = await validateProviderApiKey({ provider: "nanobanana", apiKey: "nb-key" }); + + assert.equal(result.valid, false, `NanoBanana should reject ${status}`); + assert.equal(result.error, "Invalid API key"); + assert.equal(fetchCalled, true); + }); +} + +for (const status of [400, 404, 429]) { + test(`NanoBanana API key validator returns validation failed on ${status}`, async () => { + let fetchCalled = false; + globalThis.fetch = async (url) => { + fetchCalled = true; + assert.match(String(url), /nanobanana/i); + return new Response(JSON.stringify({ error: "validation failed" }), { status }); + }; + + const result = await validateProviderApiKey({ provider: "nanobanana", apiKey: "nb-key" }); + + assert.equal(result.valid, false, `NanoBanana should reject ${status}`); + assert.equal(result.error, expectedValidationError(status)); + assert.equal(fetchCalled, true); + }); +} diff --git a/tests/unit/provider-validation-specialty.test.ts b/tests/unit/provider-validation-specialty.test.ts index 18fca180f9..6d9223e8fb 100644 --- a/tests/unit/provider-validation-specialty.test.ts +++ b/tests/unit/provider-validation-specialty.test.ts @@ -799,7 +799,7 @@ test("specialty validators cover remaining status branches for Deepgram, Assembl assert.equal(deepgram.error, "Validation failed: 500"); assert.equal(assembly.valid, true); - assert.equal(banana.valid, true); + assert.equal(banana.error, "Validation failed: 400"); assert.equal(eleven.error, "Invalid API key"); assert.equal(inworld.error, "inworld offline"); assert.equal(bailian.error, "Validation failed: 500");