mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-03 05:45:04 +03:00
Fix image provider validation and Stability image requests (#1726)
Integrated into release/v3.7.4
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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) ||
|
||||
|
||||
164
src/lib/providers/imageValidation.ts
Normal file
164
src/lib/providers/imageValidation.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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), {
|
||||
|
||||
@@ -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;
|
||||
|
||||
147
tests/unit/provider-validation-image-only.test.ts
Normal file
147
tests/unit/provider-validation-image-only.test.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user