Fix image provider validation and Stability image requests (#1726)

Integrated into release/v3.7.4
This commit is contained in:
backryun
2026-04-29 01:59:03 +09:00
committed by diegosouzapw
parent 4ac8d82c81
commit fa293d3295
9 changed files with 603 additions and 55 deletions

View File

@@ -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) {

View File

@@ -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) ||

View File

@@ -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<string, string>, 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<string, string> = {
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);
}
}

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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), {

View File

@@ -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;

View File

@@ -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<string, string>)[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<string, string>).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);
});
}

View File

@@ -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");