Browse Source

Share current local version

Confidence: high
Scope-risk: narrow
Tested: git commit only
Not-tested: colleague machine
handoff-20260429-1057
jiajia 3 months ago
parent
commit
45919c570f
  1. 87
      src/app/api/generate/providers/__tests__/newapiwg.test.ts
  2. 94
      src/app/api/generate/providers/newapiwg.ts
  3. 25
      src/utils/__tests__/logger.test.ts
  4. 10
      src/utils/logger.ts

87
src/app/api/generate/providers/__tests__/newapiwg.test.ts

@ -3,6 +3,7 @@ import {
fetchNewApiWGModels, fetchNewApiWGModels,
generateWithNewApiWG, generateWithNewApiWG,
inferNewApiWGCapabilities, inferNewApiWGCapabilities,
normalizeNewApiWGError,
} from "../newapiwg"; } from "../newapiwg";
import type { GenerationInput } from "@/lib/providers/types"; import type { GenerationInput } from "@/lib/providers/types";
@ -171,6 +172,7 @@ describe("NewApiWG generation payloads", () => {
afterEach(() => { afterEach(() => {
vi.unstubAllGlobals(); vi.unstubAllGlobals();
vi.useRealTimers();
}); });
it("passes schema-mapped dynamic image and audio inputs to video generation", async () => { it("passes schema-mapped dynamic image and audio inputs to video generation", async () => {
@ -257,6 +259,52 @@ describe("NewApiWG generation payloads", () => {
}); });
}); });
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 () => { it("routes sora image models through chat completions", async () => {
const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({ const result = await generateWithNewApiWG("test-key", "https://newapi.example/v1", makeInput({
model: { model: {
@ -297,4 +345,43 @@ describe("NewApiWG generation payloads", () => {
expect(capturedBody!.model).toBe("seedream-5-0-260128"); expect(capturedBody!.model).toBe("seedream-5-0-260128");
expect(capturedBody!.image).toBe("data:image/png;base64,input"); 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."
);
});
}); });

94
src/app/api/generate/providers/newapiwg.ts

@ -207,6 +207,56 @@ function firstString(value: unknown): string | undefined {
return undefined; return undefined;
} }
const NEWAPIWG_TRANSIENT_RETRY_DELAYS_MS = [500, 1500];
function isNewApiWGTransientError(message: string): boolean {
return /system\s+disk\s+overloaded/i.test(message);
}
export function normalizeNewApiWGError(message: string, modelName = "NewApiWG"): string {
const normalized = message.trim();
if (isNewApiWGTransientError(normalized)) {
return `${modelName}: provider is temporarily overloaded. Please retry in a moment or switch models.`;
}
return normalized;
}
async function wait(ms: number): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, ms));
}
async function fetchNewApiWGJsonWithRetry(
url: string,
init: RequestInit,
modelName: string,
fallbackError: string
): Promise<{ response: Response; data: unknown }> {
for (let attempt = 0; attempt <= NEWAPIWG_TRANSIENT_RETRY_DELAYS_MS.length; attempt++) {
const response = await fetch(url, init);
const data = await response.json().catch(() => ({}));
if (response.ok) {
return { response, data };
}
const message = firstString(data) || fallbackError;
const canRetry =
attempt < NEWAPIWG_TRANSIENT_RETRY_DELAYS_MS.length &&
isNewApiWGTransientError(message);
if (!canRetry) {
return { response, data };
}
console.warn(
`[NewApiWG] ${modelName} returned transient overload; retrying attempt ${attempt + 2}`
);
await wait(NEWAPIWG_TRANSIENT_RETRY_DELAYS_MS[attempt]);
}
throw new Error("unreachable");
}
async function urlToDataUrl(url: string): Promise<string> { async function urlToDataUrl(url: string): Promise<string> {
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) throw new Error(`Failed to fetch generated media: ${response.status}`); if (!response.ok) throw new Error(`Failed to fetch generated media: ${response.status}`);
@ -419,7 +469,7 @@ async function generateImageViaNewApiWGChat(
] ]
: prompt; : prompt;
const response = await fetch(`${baseUrl}/chat/completions`, { const { response, data } = await fetchNewApiWGJsonWithRetry(`${baseUrl}/chat/completions`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -432,17 +482,18 @@ async function generateImageViaNewApiWGChat(
...dynamicInputsWithoutPrompt(input), ...dynamicInputsWithoutPrompt(input),
...(input.parameters || {}), ...(input.parameters || {}),
}), }),
}); }, input.model.name, "NewApiWG chat image API error");
const data = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
const message = firstString(data) || `NewApiWG chat image API error: ${response.status}`; const message = firstString(data) || `NewApiWG chat image API error: ${response.status}`;
return { success: false, error: message }; return { success: false, error: normalizeNewApiWGError(message, input.model.name) };
} }
const image = extractNewApiWGImage(data); const image = extractNewApiWGImage(data);
if (!image) { if (!image) {
const text = data?.choices?.[0]?.message?.content; const text = data && typeof data === "object"
? firstString((data as Record<string, unknown>).choices)
: undefined;
return { return {
success: false, success: false,
error: typeof text === "string" && text.length > 0 error: typeof text === "string" && text.length > 0
@ -529,7 +580,7 @@ async function generateImageViaNewApiWGGemini(
: "2K"; : "2K";
const modelName = toGeminiNativeModelName(input.model.id); const modelName = toGeminiNativeModelName(input.model.id);
const response = await fetch(`${normalizeNewApiWGGeminiBaseUrl(baseUrl)}/models/${encodeURIComponent(modelName)}:generateContent`, { const { response, data } = await fetchNewApiWGJsonWithRetry(`${normalizeNewApiWGGeminiBaseUrl(baseUrl)}/models/${encodeURIComponent(modelName)}:generateContent`, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -546,12 +597,11 @@ async function generateImageViaNewApiWGGemini(
}, },
...dynamicInputsWithoutPrompt(input), ...dynamicInputsWithoutPrompt(input),
}), }),
}); }, input.model.name, "NewApiWG Gemini image API error");
const data = await response.json().catch(() => ({}));
if (!response.ok) { if (!response.ok) {
const message = firstString(data) || `NewApiWG Gemini image API error: ${response.status}`; const message = firstString(data) || `NewApiWG Gemini image API error: ${response.status}`;
return { success: false, error: message }; return { success: false, error: normalizeNewApiWGError(message, input.model.name) };
} }
const image = extractGeminiNativeImage(data); const image = extractGeminiNativeImage(data);
@ -611,7 +661,7 @@ export async function generateWithNewApiWG(
const endpoint = `${normalizedBaseUrl}/${isVideo ? "video" : "images"}/generations`; const endpoint = `${normalizedBaseUrl}/${isVideo ? "video" : "images"}/generations`;
const imageSize = typeof input.parameters?.size === "string" ? input.parameters.size : "1024x1024"; const imageSize = typeof input.parameters?.size === "string" ? input.parameters.size : "1024x1024";
const response = await fetch(endpoint, { const { response, data } = await fetchNewApiWGJsonWithRetry(endpoint, {
method: "POST", method: "POST",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
@ -626,15 +676,13 @@ export async function generateWithNewApiWG(
...(!isVideo ? { size: imageSize, response_format: "b64_json" } : {}), ...(!isVideo ? { size: imageSize, response_format: "b64_json" } : {}),
...(input.parameters || {}), ...(input.parameters || {}),
}), }),
}); }, input.model.name, `NewApiWG ${isVideo ? "video" : "image"} API error`);
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({})); const message = firstString(data) || `NewApiWG image API error: ${response.status}`;
const message = firstString(error) || `NewApiWG image API error: ${response.status}`; return { success: false, error: normalizeNewApiWGError(message, input.model.name) };
return { success: false, error: message };
} }
const data = await response.json();
if (isVideo) { if (isVideo) {
const immediateUrl = getNewApiWGVideoUrl(data); const immediateUrl = getNewApiWGVideoUrl(data);
if (immediateUrl) { if (immediateUrl) {
@ -647,17 +695,23 @@ export async function generateWithNewApiWG(
} }
} }
const first = data.data?.[0] ?? data.images?.[0] ?? data.videos?.[0] ?? data.output?.[0] ?? data; const dataRecord = data && typeof data === "object" ? data as Record<string, unknown> : {};
const b64 = first?.b64_json || first?.base64 || first?.image_base64; const dataItems = Array.isArray(dataRecord.data) ? dataRecord.data : [];
const url = first?.url || first?.image_url || first?.video_url || firstString(first); const imageItems = Array.isArray(dataRecord.images) ? dataRecord.images : [];
const videoItems = Array.isArray(dataRecord.videos) ? dataRecord.videos : [];
const outputItems = Array.isArray(dataRecord.output) ? dataRecord.output : [];
const first = dataItems[0] ?? imageItems[0] ?? videoItems[0] ?? outputItems[0] ?? data;
const firstRecord = first && typeof first === "object" ? first as Record<string, unknown> : {};
const b64 = firstRecord.b64_json || firstRecord.base64 || firstRecord.image_base64;
const url = firstRecord.url || firstRecord.image_url || firstRecord.video_url || firstString(first);
if (b64) { if (typeof b64 === "string" && b64.length > 0) {
const mime = isVideo ? "video/mp4" : "image/png"; const mime = isVideo ? "video/mp4" : "image/png";
const prefix = b64.startsWith("data:") ? "" : `data:${mime};base64,`; const prefix = b64.startsWith("data:") ? "" : `data:${mime};base64,`;
return { success: true, outputs: [{ type: isVideo ? "video" : "image", data: `${prefix}${b64}` }] }; return { success: true, outputs: [{ type: isVideo ? "video" : "image", data: `${prefix}${b64}` }] };
} }
if (url) { if (typeof url === "string" && url.length > 0) {
const dataUrl = url.startsWith("data:") ? url : await urlToDataUrl(url); const dataUrl = url.startsWith("data:") ? url : await urlToDataUrl(url);
return { success: true, outputs: [{ type: isVideo ? "video" : "image", data: dataUrl, url }] }; return { success: true, outputs: [{ type: isVideo ? "video" : "image", data: dataUrl, url }] };
} }

25
src/utils/__tests__/logger.test.ts

@ -0,0 +1,25 @@
import { describe, expect, it, vi, afterEach } from "vitest";
import { logger } from "../logger";
describe("logger", () => {
afterEach(() => {
vi.restoreAllMocks();
});
it("does not emit browser-side application errors via console.error", () => {
const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
logger.error("workflow.error", "Node execution failed in parallel batch", {
nodeId: "nanoBanana-2",
error: "system disk overloaded",
});
expect(errorSpy).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(
"[workflow.error] Node execution failed in parallel batch",
{ nodeId: "nanoBanana-2", error: "system disk overloaded" },
""
);
});
});

10
src/utils/logger.ts

@ -137,8 +137,14 @@ class Logger {
this.currentSession.entries.push(entry); this.currentSession.entries.push(entry);
} }
// Also log to console for development // Also log to console for development. In the browser, Next dev treats
const consoleMethod = level === 'error' ? 'error' : level === 'warn' ? 'warn' : 'log'; // console.error as a page-level error overlay, so keep application-level
// workflow failures visible without tripping the framework error UI.
const consoleMethod = level === 'error'
? (this.isClient ? 'warn' : 'error')
: level === 'warn'
? 'warn'
: 'log';
console[consoleMethod](`[${category}] ${message}`, context || '', error || ''); console[consoleMethod](`[${category}] ${message}`, context || '', error || '');
} }

Loading…
Cancel
Save