You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
387 lines
13 KiB
387 lines
13 KiB
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import {
|
|
fetchNewApiWGModels,
|
|
generateWithNewApiWG,
|
|
inferNewApiWGCapabilities,
|
|
normalizeNewApiWGError,
|
|
} from "../newapiwg";
|
|
import type { GenerationInput } from "@/lib/providers/types";
|
|
|
|
let capturedBody: Record<string, unknown> | null = null;
|
|
|
|
function makeInput(overrides: Partial<GenerationInput> = {}): GenerationInput {
|
|
return {
|
|
model: {
|
|
id: "doubao-seedance-2-0-260128",
|
|
name: "Seedance",
|
|
description: null,
|
|
provider: "newapiwg",
|
|
capabilities: ["text-to-video", "image-to-video", "audio-to-video"],
|
|
},
|
|
prompt: "a dancing robot",
|
|
images: [],
|
|
parameters: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("NewApiWG model capability inference", () => {
|
|
it("classifies real NewAPI gateway model ids without treating chat models as image generators", () => {
|
|
expect(inferNewApiWGCapabilities({
|
|
id: "apiyi_sora_image",
|
|
owned_by: "custom",
|
|
supported_endpoint_types: ["openai"],
|
|
})).toEqual(["text-to-image"]);
|
|
|
|
expect(inferNewApiWGCapabilities({
|
|
id: "gpt-image-2-all",
|
|
owned_by: "openai",
|
|
supported_endpoint_types: ["openai"],
|
|
})).toEqual(["text-to-image", "image-to-image"]);
|
|
|
|
expect(inferNewApiWGCapabilities({
|
|
id: "apiyi_nano_banana_pro",
|
|
owned_by: "custom",
|
|
supported_endpoint_types: ["gemini", "openai"],
|
|
})).toEqual(["text-to-image", "image-to-image"]);
|
|
|
|
expect(inferNewApiWGCapabilities({
|
|
id: "gemini-3-pro-image-preview",
|
|
owned_by: "custom",
|
|
supported_endpoint_types: ["gemini", "openai"],
|
|
})).toEqual(["text-to-image", "image-to-image"]);
|
|
|
|
expect(inferNewApiWGCapabilities({
|
|
id: "doubao-seedance-2-0-260128",
|
|
owned_by: "doubao-video",
|
|
supported_endpoint_types: ["openai"],
|
|
})).toEqual(["text-to-video", "image-to-video"]);
|
|
|
|
expect(inferNewApiWGCapabilities({
|
|
id: "speech-2.8-hd",
|
|
owned_by: "minimax",
|
|
supported_endpoint_types: ["openai"],
|
|
})).toEqual(["text-to-audio"]);
|
|
|
|
expect(inferNewApiWGCapabilities({
|
|
id: "kimi-k2.6",
|
|
owned_by: "moonshot",
|
|
supported_endpoint_types: ["openai"],
|
|
})).toEqual([]);
|
|
|
|
expect(inferNewApiWGCapabilities({
|
|
id: "popiart-custom-model",
|
|
owned_by: "popiart",
|
|
supported_endpoint_types: ["openai"],
|
|
})).toEqual([
|
|
"text-to-image",
|
|
"image-to-image",
|
|
"text-to-video",
|
|
"image-to-video",
|
|
"text-to-3d",
|
|
"image-to-3d",
|
|
"text-to-audio",
|
|
"audio-to-video",
|
|
]);
|
|
});
|
|
|
|
it("omits pure chat models from generation model discovery", async () => {
|
|
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({
|
|
data: [
|
|
{ id: "kimi-k2.6", owned_by: "moonshot", supported_endpoint_types: ["openai"] },
|
|
{ id: "doubao-seedance-2-0-260128", owned_by: "doubao-video", supported_endpoint_types: ["openai"] },
|
|
{ id: "apiyi_sora_image", owned_by: "custom", supported_endpoint_types: ["openai"] },
|
|
],
|
|
}), { status: 200 })));
|
|
|
|
const models = await fetchNewApiWGModels("test-key", "https://newapi.example/v1");
|
|
|
|
expect(models.map((model) => model.id)).toEqual([
|
|
"doubao-seedance-2-0-260128",
|
|
"apiyi_sora_image",
|
|
]);
|
|
});
|
|
});
|
|
|
|
describe("NewApiWG generation payloads", () => {
|
|
beforeEach(() => {
|
|
capturedBody = null;
|
|
vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
|
|
|
|
if (urlStr === "https://cdn.example.com/seedream.png") {
|
|
return new Response(Buffer.from("abc"), {
|
|
status: 200,
|
|
headers: { "content-type": "image/png" },
|
|
});
|
|
}
|
|
|
|
if (urlStr.includes("/v1beta/models/") && urlStr.includes(":generateContent") && init?.method === "POST") {
|
|
capturedBody = JSON.parse(init.body as string);
|
|
return new Response(JSON.stringify({
|
|
candidates: [{
|
|
content: {
|
|
parts: [{
|
|
inlineData: {
|
|
mimeType: "image/png",
|
|
data: "gemini123",
|
|
},
|
|
}],
|
|
},
|
|
}],
|
|
}), {
|
|
status: 200,
|
|
});
|
|
}
|
|
|
|
if (urlStr.includes("/chat/completions") && init?.method === "POST") {
|
|
capturedBody = JSON.parse(init.body as string);
|
|
return new Response(JSON.stringify({
|
|
choices: [{
|
|
message: {
|
|
content: [{
|
|
type: "image_url",
|
|
image_url: { url: "data:image/png;base64,abc123" },
|
|
}],
|
|
},
|
|
}],
|
|
}), {
|
|
status: 200,
|
|
});
|
|
}
|
|
|
|
if (urlStr.includes("/video/generations") && init?.method === "POST") {
|
|
capturedBody = JSON.parse(init.body as string);
|
|
return new Response(JSON.stringify({ video_url: "https://cdn.example.com/video.mp4" }), {
|
|
status: 200,
|
|
});
|
|
}
|
|
|
|
if (urlStr.includes("/images/generations") && init?.method === "POST") {
|
|
capturedBody = JSON.parse(init.body as string);
|
|
return new Response(JSON.stringify({
|
|
data: [{ url: "https://cdn.example.com/seedream.png" }],
|
|
}), {
|
|
status: 200,
|
|
});
|
|
}
|
|
|
|
return new Response("Not Found", { status: 404 });
|
|
}));
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("passes schema-mapped dynamic image and audio inputs to video generation", async () => {
|
|
const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
|
|
images: ["data:image/png;base64,abc"],
|
|
parameters: { duration: 5 },
|
|
dynamicInputs: {
|
|
first_frame_url: "data:image/png;base64,first",
|
|
reference_audio_urls: ["data:audio/mpeg;base64,audio"],
|
|
},
|
|
}));
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(capturedBody).not.toBeNull();
|
|
expect(capturedBody!.prompt).toBe("a dancing robot");
|
|
expect(capturedBody!.images).toEqual(["data:image/png;base64,abc"]);
|
|
expect(capturedBody!.first_frame_url).toBe("data:image/png;base64,first");
|
|
expect(capturedBody!.reference_audio_urls).toEqual(["data:audio/mpeg;base64,audio"]);
|
|
expect(capturedBody!.duration).toBe(5);
|
|
});
|
|
|
|
it("routes nano banana image models through Gemini native generation", async () => {
|
|
const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
|
|
model: {
|
|
id: "apiyi_nano_banana_2",
|
|
name: "apiyi_nano_banana_2",
|
|
description: null,
|
|
provider: "newapiwg",
|
|
capabilities: ["text-to-image", "image-to-image"],
|
|
},
|
|
prompt: "draw a banana",
|
|
images: ["data:image/png;base64,input"],
|
|
}));
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.outputs?.[0]).toEqual({ type: "image", data: "data:image/png;base64,gemini123" });
|
|
expect(capturedBody!.contents).toEqual([
|
|
{
|
|
parts: [
|
|
{ text: "draw a banana" },
|
|
{ inlineData: { mimeType: "image/png", data: "input" } },
|
|
]
|
|
},
|
|
]);
|
|
expect(capturedBody!.generationConfig).toMatchObject({
|
|
responseModalities: ["IMAGE"],
|
|
imageConfig: { aspectRatio: "1:1", imageSize: "2K" },
|
|
});
|
|
});
|
|
|
|
it("accepts Gemini native fileData URLs without image file extensions", async () => {
|
|
vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
capturedBody = init?.body ? JSON.parse(init.body as string) : null;
|
|
return new Response(JSON.stringify({
|
|
candidates: [{
|
|
content: {
|
|
parts: [{
|
|
fileData: {
|
|
mimeType: "image/png",
|
|
fileUri: "https://cdn.example.com/generated/abc123?signature=test",
|
|
},
|
|
}],
|
|
},
|
|
}],
|
|
}), { status: 200 });
|
|
}));
|
|
|
|
const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
|
|
model: {
|
|
id: "apiyi_nano_banana_2",
|
|
name: "apiyi_nano_banana_2",
|
|
description: null,
|
|
provider: "newapiwg",
|
|
capabilities: ["text-to-image", "image-to-image"],
|
|
},
|
|
prompt: "draw a banana",
|
|
}));
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.outputs?.[0]).toEqual({
|
|
type: "image",
|
|
data: "",
|
|
url: "https://cdn.example.com/generated/abc123?signature=test",
|
|
});
|
|
});
|
|
|
|
it("retries transient overload errors from Gemini native image generation", async () => {
|
|
vi.useFakeTimers();
|
|
const fetchMock = vi.fn(async () => {
|
|
if (fetchMock.mock.calls.length === 1) {
|
|
return new Response(JSON.stringify({ error: { message: "system disk overloaded" } }), {
|
|
status: 500,
|
|
});
|
|
}
|
|
return new Response(JSON.stringify({
|
|
candidates: [{
|
|
content: {
|
|
parts: [{
|
|
inlineData: {
|
|
mimeType: "image/png",
|
|
data: "retry-success",
|
|
},
|
|
}],
|
|
},
|
|
}],
|
|
}), { status: 200 });
|
|
});
|
|
vi.stubGlobal("fetch", fetchMock);
|
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
const promise = generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
|
|
model: {
|
|
id: "apiyi_nano_banana_2",
|
|
name: "APIYI Nano Banana 2",
|
|
description: null,
|
|
provider: "newapiwg",
|
|
capabilities: ["text-to-image", "image-to-image"],
|
|
},
|
|
prompt: "draw a banana",
|
|
}));
|
|
|
|
await vi.advanceTimersByTimeAsync(500);
|
|
const result = await promise;
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
|
expect(result.success).toBe(true);
|
|
expect(result.outputs?.[0]).toEqual({
|
|
type: "image",
|
|
data: "data:image/png;base64,retry-success",
|
|
});
|
|
});
|
|
|
|
it("routes sora image models through chat completions", async () => {
|
|
const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
|
|
model: {
|
|
id: "sora_image",
|
|
name: "sora_image",
|
|
description: null,
|
|
provider: "newapiwg",
|
|
capabilities: ["text-to-image"],
|
|
},
|
|
prompt: "draw a banana",
|
|
}));
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.outputs?.[0]).toEqual({ type: "image", data: "data:image/png;base64,abc123" });
|
|
expect(capturedBody!.model).toBe("sora_image");
|
|
expect(capturedBody!.messages).toEqual([{ role: "user", content: "draw a banana" }]);
|
|
});
|
|
|
|
it("routes Seedream image models through images generations", async () => {
|
|
const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
|
|
model: {
|
|
id: "seedream-5-0-260128",
|
|
name: "seedream-5-0-260128",
|
|
description: null,
|
|
provider: "newapiwg",
|
|
capabilities: ["text-to-image", "image-to-image"],
|
|
},
|
|
prompt: "draw a banana",
|
|
images: ["data:image/png;base64,input"],
|
|
}));
|
|
|
|
expect(result.success).toBe(true);
|
|
expect(result.outputs?.[0]).toEqual({
|
|
type: "image",
|
|
data: "data:image/png;base64,YWJj",
|
|
url: "https://cdn.example.com/seedream.png",
|
|
});
|
|
expect(capturedBody!.model).toBe("seedream-5-0-260128");
|
|
expect(capturedBody!.image).toBe("data:image/png;base64,input");
|
|
});
|
|
|
|
it("normalizes transient provider overload errors", () => {
|
|
expect(normalizeNewApiWGError("system disk overloaded", "Nano Banana")).toBe(
|
|
"Nano Banana: provider is temporarily overloaded. Please retry in a moment or switch models."
|
|
);
|
|
});
|
|
|
|
it("returns normalized overload errors from image generation", async () => {
|
|
vi.useFakeTimers();
|
|
vi.stubGlobal("fetch", vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
|
const urlStr = typeof url === "string" ? url : url instanceof URL ? url.toString() : url.url;
|
|
if (urlStr.includes("/images/generations") && init?.method === "POST") {
|
|
return new Response(JSON.stringify({ error: { message: "system disk overloaded" } }), {
|
|
status: 500,
|
|
});
|
|
}
|
|
return new Response("Not Found", { status: 404 });
|
|
}));
|
|
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
|
|
const promise = generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
|
|
model: {
|
|
id: "seedream-5-0-260128",
|
|
name: "Seedream",
|
|
description: null,
|
|
provider: "newapiwg",
|
|
capabilities: ["text-to-image"],
|
|
},
|
|
prompt: "draw a banana",
|
|
}));
|
|
|
|
await vi.advanceTimersByTimeAsync(2000);
|
|
const result = await promise;
|
|
|
|
expect(result.success).toBe(false);
|
|
expect(result.error).toBe(
|
|
"Seedream: provider is temporarily overloaded. Please retry in a moment or switch models."
|
|
);
|
|
});
|
|
});
|
|
|