修复自动生图模型选择与提交结果确认 #82
@ -13517,7 +13517,8 @@ export function AdminSettingsClient() {
|
||||
系统智能选择(根据意图路由)
|
||||
</SelectItem>
|
||||
{modelsData?.models
|
||||
?.filter((m: any) => m.supportsImageGen)
|
||||
?.filter((m: any) => m.supportsImageGen && m.isEnabled && !m.isSubModel &&
|
||||
providersData?.providers?.some((provider: any) => provider.id === m.providerId && provider.isEnabled))
|
||||
.map((model: any) => (
|
||||
<SelectItem key={model.id} value={model.id}>
|
||||
{model.displayName}
|
||||
|
||||
@ -4,6 +4,7 @@ import { createHash } from "node:crypto";
|
||||
import { z } from "zod";
|
||||
import { db } from "@/lib/db";
|
||||
import {
|
||||
aiModel,
|
||||
aiProvider,
|
||||
siteSettings,
|
||||
systemSettingHistory,
|
||||
@ -2212,6 +2213,14 @@ export async function PUT(request: NextRequest) {
|
||||
}
|
||||
}
|
||||
const data = updateSchema.parse(body);
|
||||
if (data.canvasAutoModeDefaultModelId) {
|
||||
const [available] = await db.select({ id: aiModel.id }).from(aiModel)
|
||||
.innerJoin(aiProvider, eq(aiProvider.id, aiModel.providerId))
|
||||
.where(and(eq(aiModel.id, data.canvasAutoModeDefaultModelId),
|
||||
eq(aiModel.isEnabled, true), eq(aiProvider.isEnabled, true),
|
||||
eq(aiModel.isSubModel, false), eq(aiModel.supportsImageGen, true))).limit(1);
|
||||
if (!available) return NextResponse.json({ error: "默认生图模型或其上游已停用,请选择可用的生图模型,或改为系统智能选择。" }, { status: 400 });
|
||||
}
|
||||
const {
|
||||
surpriseCategories,
|
||||
aiDetailGenerationEnabled,
|
||||
|
||||
@ -207,7 +207,7 @@ export function ImageParamsBar({
|
||||
normalizeImageSizeValue(size, "")
|
||||
)
|
||||
: []
|
||||
);
|
||||
).filter((size) => providerType !== "alibaba-image" || size === "1K" || size === "2K");
|
||||
|
||||
const buildImageSizeOptions = () => {
|
||||
if (imageGenConfig?.supportsSize === false) {
|
||||
@ -220,6 +220,7 @@ export function ImageParamsBar({
|
||||
|
||||
return DEFAULT_IMAGE_SIZE_OPTIONS.filter((option) => {
|
||||
if (option.disabled) return false;
|
||||
if (providerType === "alibaba-image" && option.value === "4K") return false;
|
||||
if (!imageGenConfig) return true;
|
||||
if (option.value === "1K") return imageGenConfig.supports1K !== false;
|
||||
if (option.value === "2K") return imageGenConfig.supports2K !== false;
|
||||
|
||||
@ -13,6 +13,7 @@ import { constrainImageDimensions } from "@/lib/ai/image-size-constraints";
|
||||
import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification";
|
||||
import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order";
|
||||
import { ImageReferenceReadTimeoutError } from "@/lib/ai/runtime/image-generation-error";
|
||||
import { getDefaultStorageConfig } from "@/lib/db/queries";
|
||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||
import { uploadFile } from "@/lib/storage/service";
|
||||
import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage";
|
||||
@ -70,7 +71,7 @@ interface AlibabaImageResponse {
|
||||
|
||||
const ALIBABA_IMAGE_FETCH_TIMEOUT_MS = 20000;
|
||||
const ALIBABA_IMAGE_GENERATION_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
const ALIBABA_IMAGE_SUBMIT_TIMEOUT_MS = 60 * 1000;
|
||||
const ALIBABA_IMAGE_SUBMIT_TIMEOUT_MS = 120 * 1000;
|
||||
const ALIBABA_IMAGE_POLL_TIMEOUT_MS = 30 * 1000;
|
||||
const ALIBABA_IMAGE_POLL_INTERVAL_MS = 5 * 1000;
|
||||
const ALIBABA_MIN_SIZE = 512;
|
||||
@ -91,9 +92,9 @@ class AlibabaImageRequestError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" | "4K" {
|
||||
function normalizeImageSizeLabel(imageSize?: string): "1K" | "2K" {
|
||||
const normalized = String(imageSize || "").trim().toUpperCase();
|
||||
if (normalized === "1K" || normalized === "2K" || normalized === "4K") return normalized;
|
||||
if (normalized === "1K") return normalized;
|
||||
return "2K";
|
||||
}
|
||||
|
||||
@ -291,6 +292,7 @@ async function prepareAlibabaInputImageUrl(
|
||||
modelId: string,
|
||||
inputRole?: AlibabaImageInputRole,
|
||||
original?: Buffer,
|
||||
preparedBuffers?: Map<string, Buffer>,
|
||||
): Promise<string> {
|
||||
const normalizedUrl = String(imageUrl || "").trim();
|
||||
if (!/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl;
|
||||
@ -324,10 +326,27 @@ async function prepareAlibabaInputImageUrl(
|
||||
maxSide: ALIBABA_MAX_INPUT_SIDE,
|
||||
modelId,
|
||||
});
|
||||
preparedBuffers?.set(normalizedUrl, prepared.buffer);
|
||||
if (isAlibabaQwenImage3ModelId(modelId)) {
|
||||
if (prepared.buffer.length > maxInputBytes) {
|
||||
throw new Error("输入图片超过模型大小限制,请先压缩图片后重试");
|
||||
}
|
||||
if (!prepared.transformed) {
|
||||
const storage = await getDefaultStorageConfig();
|
||||
if (storage?.isEnabled && storage.type === "qiniu-kodo" && storage.publicUrl) {
|
||||
const source = new URL(normalizedUrl);
|
||||
const publicBase = new URL(storage.publicUrl);
|
||||
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
|
||||
const isStoredOriginal = !source.search && !source.hash &&
|
||||
source.pathname.startsWith(publicBase.pathname.replace(/\/$/, "") + "/uploads/") &&
|
||||
(source.origin === publicBase.origin || (origin && source.origin === new URL(origin).origin));
|
||||
if (publicBase.protocol === "https:" && isStoredOriginal) {
|
||||
source.host = publicBase.host;
|
||||
source.protocol = publicBase.protocol;
|
||||
return source.toString();
|
||||
}
|
||||
}
|
||||
}
|
||||
const format = prepared.transformed ? prepared.extension : metadata.format;
|
||||
const mime = format === "jpg" || format === "jpeg" ? "image/jpeg" : `image/${format}`;
|
||||
return `data:${mime};base64,${prepared.buffer.toString("base64")}`;
|
||||
@ -561,6 +580,7 @@ async function sendAlibabaImageAsyncRequest(params: {
|
||||
requestBody: Record<string, unknown>;
|
||||
requestURL: string;
|
||||
}): Promise<AlibabaImageResponse> {
|
||||
const submissionStartedAt = Date.now();
|
||||
const submitted = await fetchAlibabaImageJson({
|
||||
apiKey: params.apiKey,
|
||||
asyncRequest: true,
|
||||
@ -568,6 +588,14 @@ async function sendAlibabaImageAsyncRequest(params: {
|
||||
method: "POST",
|
||||
timeoutMs: ALIBABA_IMAGE_SUBMIT_TIMEOUT_MS,
|
||||
url: params.requestURL,
|
||||
}).catch((error) => {
|
||||
if (error instanceof AlibabaImageRequestError && error.status >= 400 && error.status < 500 && error.status !== 408) throw error;
|
||||
console.error("[Alibaba Image] Submission outcome unknown:", {
|
||||
elapsedMs: Date.now() - submissionStartedAt,
|
||||
timeoutMs: ALIBABA_IMAGE_SUBMIT_TIMEOUT_MS,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
throw Object.assign(new Error("阿里图片提交响应未确认,可能已受理,请先核对原任务,避免重复生成。", { cause: error }), { providerOutcomeUnknown: true });
|
||||
});
|
||||
const taskId = String(submitted.output?.task_id || "").trim();
|
||||
if (!taskId) throw new Error("阿里图片异步任务未返回 task_id,结果尚未确认");
|
||||
@ -575,7 +603,7 @@ async function sendAlibabaImageAsyncRequest(params: {
|
||||
const taskURL = resolveAlibabaImageTaskEndpoint(params.requestURL, taskId);
|
||||
const startedAt = Date.now();
|
||||
let lastPollError: unknown = null;
|
||||
console.log("[Alibaba Image] Async task submitted:", taskId);
|
||||
console.log("[Alibaba Image] Async task submitted:", taskId, { elapsedMs: Date.now() - submissionStartedAt, requestId: submitted.request_id });
|
||||
|
||||
while (Date.now() - startedAt < ALIBABA_IMAGE_GENERATION_TIMEOUT_MS) {
|
||||
await new Promise((resolve) =>
|
||||
@ -672,19 +700,13 @@ async function processResponse(
|
||||
function formatImageResponse(
|
||||
images: Array<{ thumbnail: string; original: string }>,
|
||||
size?: string,
|
||||
requestedSizeLabel?: string,
|
||||
): string {
|
||||
if (images.length === 0) {
|
||||
return "图片生成失败,请重试。";
|
||||
}
|
||||
|
||||
// 将分辨率转换为简短格式(如 "1024*1024" -> "1K")
|
||||
const normalizedRequestedSize = normalizeImageSizeLabel(requestedSizeLabel);
|
||||
const sizeLabel = requestedSizeLabel
|
||||
? normalizedRequestedSize
|
||||
: size
|
||||
? getSizeLabel(size)
|
||||
: "2K";
|
||||
const sizeLabel = size ? getSizeLabel(size) : "2K";
|
||||
|
||||
return images
|
||||
.map((img) => {
|
||||
@ -703,7 +725,7 @@ function getSizeLabel(size: string): string {
|
||||
"1024*1024": "1K",
|
||||
"1280*1280": "1K",
|
||||
"1536*1536": "2K",
|
||||
"2048*2048": "4K",
|
||||
"2048*2048": "2K",
|
||||
};
|
||||
if (sizeMap[size]) return sizeMap[size];
|
||||
|
||||
@ -712,7 +734,6 @@ function getSizeLabel(size: string): string {
|
||||
const width = Number.parseInt(matched[1], 10);
|
||||
const height = Number.parseInt(matched[2], 10);
|
||||
const maxSide = Math.max(width, height);
|
||||
if (maxSide >= 1900) return "4K";
|
||||
if (maxSide >= 1450) return "2K";
|
||||
if (maxSide >= 950) return "1K";
|
||||
}
|
||||
@ -802,6 +823,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
||||
}
|
||||
|
||||
const selectedImageUrls = selectAlibabaInputImageUrls(imageUrls, 3);
|
||||
const preparedBuffers = new Map<string, Buffer>();
|
||||
const preparedImageUrls = await Promise.all(
|
||||
selectedImageUrls.map((imageUrl) =>
|
||||
prepareAlibabaInputImageUrl(
|
||||
@ -809,6 +831,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
||||
activeModelId,
|
||||
options.imageParams?.inputImageRoleByUrl?.[imageUrl],
|
||||
options.referenceImageBuffers?.get(imageUrl),
|
||||
preparedBuffers,
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -840,7 +863,6 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
||||
const baseSizeMap: Record<string, number> = {
|
||||
"1K": 1024,
|
||||
"2K": isQwenImage3 ? 2048 : 1536,
|
||||
"4K": 2048,
|
||||
};
|
||||
const baseSize = baseSizeMap[imageSize] || 1536;
|
||||
|
||||
@ -876,8 +898,7 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
||||
console.log(`[Alibaba Image] Using user-specified size: ${params.size}`);
|
||||
} else {
|
||||
if (aspectRatio === "auto") {
|
||||
// 阿里文档在 auto 下默认接近 1024*1024,这会让 4K 选择失效。
|
||||
// 这里改为根据参考图比例计算 size,确保 1K/2K/4K 档位真正生效。
|
||||
// 按参考图比例计算输出尺寸,复用准备阶段读取的图片。
|
||||
const referenceImageUrl = preparedImageUrls[0];
|
||||
if (!referenceImageUrl) {
|
||||
params.size = `${baseSize}*${baseSize}`;
|
||||
@ -885,7 +906,11 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
||||
`[Alibaba Image] Text-to-image auto mode resolved size: imageSize=${imageSize} => size=${params.size}`
|
||||
);
|
||||
} else {
|
||||
const inferredDimensions = await inferImageDimensionsFromImageUrl(referenceImageUrl);
|
||||
const preparedBuffer = preparedBuffers.get(selectedImageUrls[0]);
|
||||
const metadata = preparedBuffer ? await sharp(preparedBuffer).metadata() : null;
|
||||
const inferredDimensions = metadata?.width && metadata.height
|
||||
? { width: metadata.width, height: metadata.height, aspectRatio: metadata.width / metadata.height }
|
||||
: await inferImageDimensionsFromImageUrl(referenceImageUrl);
|
||||
if (inferredDimensions) {
|
||||
params.size = calculateAlibabaSizeFromDimensions(
|
||||
inferredDimensions.width,
|
||||
@ -952,7 +977,9 @@ export function createAlibabaImage({ apiKey, baseURL }: AlibabaImageConfig) {
|
||||
const images = await processResponse(response).catch((error) => {
|
||||
throw new Error("阿里图片任务已提交,结果尚未确认。", { cause: error });
|
||||
});
|
||||
const markdownText = formatImageResponse(images, params.size, imageSize);
|
||||
const actualSize = response.usage?.width && response.usage?.height
|
||||
? `${response.usage.width}*${response.usage.height}` : params.size;
|
||||
const markdownText = formatImageResponse(images, actualSize);
|
||||
|
||||
console.log(`[Alibaba Image] Generated ${images.length} images successfully`);
|
||||
|
||||
|
||||
@ -49,6 +49,7 @@ import {
|
||||
} from "@/lib/ai/vip98k-reference-staging";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { getUserFacingImageGenerationErrorMessage } from "@/lib/request-error";
|
||||
import { AgentImageGenerationError } from "@/lib/ai/runtime/image-generation-error";
|
||||
import { createDeferredUploadedImage } from "@/lib/storage/image-archive";
|
||||
import {
|
||||
IMAGE_UPLOAD_MAX_BYTES,
|
||||
@ -1075,6 +1076,14 @@ async function recoverImageSubmission(params: {
|
||||
if ([401, 403, 404, 410].includes(lookup.status)) return null;
|
||||
if (lookup.ok) {
|
||||
const state = toRecord(await parseJsonResponse(lookup));
|
||||
if (state?.status === "failed" && state.request_id && state.result_available === false) {
|
||||
logger.warn("original image request confirmed failed", {
|
||||
modelId: params.modelId,
|
||||
providerRequestId: state.request_id,
|
||||
durationMs: Date.now() - startedAt,
|
||||
});
|
||||
throw new AgentImageGenerationError("生图服务已确认本次生成失败,未生成图片。请稍后重试或手动选择其他模型。", true);
|
||||
}
|
||||
if (!state || !["processing", "succeeded"].includes(String(state.status))) return null;
|
||||
if (state.status === "succeeded") {
|
||||
if (state.result_available !== true || !state.request_id) return null;
|
||||
@ -1104,7 +1113,7 @@ async function recoverImageSubmission(params: {
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ProviderTaskUncertainError) throw error;
|
||||
if (error instanceof ProviderTaskUncertainError || error instanceof AgentImageGenerationError) throw error;
|
||||
}
|
||||
await delay(Math.max(0, Math.min(params.pollIntervalMs, deadline - Date.now())));
|
||||
}
|
||||
|
||||
@ -16,6 +16,7 @@ export class ImageReferenceReadTimeoutError extends AgentImageGenerationError {
|
||||
|
||||
/** Preserve outcome evidence when replacing upstream diagnostics with safe UI text. */
|
||||
export function agentImageGenerationError(error: unknown, mode: "auto" | "pinned") {
|
||||
if (error instanceof AgentImageGenerationError) return error;
|
||||
const rejected = error instanceof ImageSubmissionRejectedError;
|
||||
const message = error instanceof Error ? error.message : "";
|
||||
const suffix = mode === "pinned" ? "系统未自动切换。你希望切换生图模型,还是稍后重试?切换前请在模型选择器中选择其他模型或自动选择。" : "请稍后重试或联系管理员处理生图通道。";
|
||||
|
||||
@ -2,9 +2,57 @@ import { expect, test } from "@playwright/test";
|
||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||
import sharp from "sharp";
|
||||
|
||||
test("Qwen uses the public stored original without a second download and reports actual 2K output", async () => {
|
||||
const png = await sharp({ create: { width: 384, height: 512, channels: 3, background: "blue" } }).png().toBuffer();
|
||||
const source = "https://origin.example/uploads/fixture.png";
|
||||
let submitted: any;
|
||||
const methods: string[] = [];
|
||||
const loader = serverLoader({
|
||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => ({ isEnabled: true, type: "qiniu-kodo", publicUrl: "https://cdn.example" }) },
|
||||
"@/lib/storage/service": {},
|
||||
"@/lib/storage/image-archive": { createDeferredUploadedImage: async () => ({ thumbnail: "https://cdn.example/thumb.png", original: "https://cdn.example/result.png" }) },
|
||||
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
||||
fetch: async (url: string, init: RequestInit = {}) => {
|
||||
methods.push(init.method || "GET");
|
||||
if (init.method === "POST") {
|
||||
submitted = JSON.parse(String(init.body));
|
||||
return Response.json({ output: { task_id: "public-reference-task" } });
|
||||
}
|
||||
if (!url.includes("/tasks/")) throw new Error("Reference bytes must be reused");
|
||||
return Response.json({ output: { task_status: "SUCCEEDED", choices: [{ message: { content: [{ image: "https://result.example/output.png" }] } }] }, usage: { width: 1536, height: 2048 } });
|
||||
},
|
||||
}, { CANVAS_QINIU_ORIGIN_URL: "https://origin.example" });
|
||||
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
|
||||
const result = await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [
|
||||
{ type: "text", text: "fixture" }, { type: "image", image: source },
|
||||
] }], imageParams: { aspectRatio: "auto", imageSize: "4K" }, referenceImageBuffers: new Map([[source, png]]) });
|
||||
expect(methods).toEqual(["POST", "GET"]);
|
||||
expect(submitted.input.messages[0].content.find((part: any) => part.image).image).toBe("https://cdn.example/uploads/fixture.png");
|
||||
expect(submitted.parameters.size).toBe("1536*2048");
|
||||
expect(result.text).toContain("|||2K]");
|
||||
expect(result.text).not.toContain("4K");
|
||||
});
|
||||
|
||||
test("Qwen submission timeout remains unknown and never sends another POST", async () => {
|
||||
const methods: string[] = [];
|
||||
const loader = serverLoader({
|
||||
"@/lib/db/queries": {}, "@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
|
||||
fetch: async (_url: string, init: RequestInit) => {
|
||||
methods.push(init.method || "GET");
|
||||
throw new DOMException("The operation was aborted", "AbortError");
|
||||
},
|
||||
});
|
||||
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
|
||||
const error = await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: "fixture" }] }).catch((error: unknown) => error);
|
||||
expect(methods).toEqual(["POST"]);
|
||||
expect(error).toMatchObject({ providerOutcomeUnknown: true });
|
||||
expect(loader("lib/request-error.ts").getRequestFailureDetails(error)).toMatchObject({ code: "RESULT_UNCONFIRMED", outcome: "unknown" });
|
||||
});
|
||||
|
||||
test("Alibaba terminal task errors preserve the nested provider reason and do not resubmit", async () => {
|
||||
const methods: string[] = [];
|
||||
const loader = serverLoader({
|
||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => null },
|
||||
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
|
||||
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
||||
fetch: async (_url: string, init: RequestInit) => {
|
||||
@ -36,6 +84,7 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
|
||||
let submitted: any;
|
||||
let posts = 0;
|
||||
const loader = serverLoader({
|
||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => null },
|
||||
"@/lib/storage/image-archive": {}, "@/lib/storage/service": { uploadFile() { throw new Error("Reference transport must not upload an intermediate file"); } },
|
||||
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
||||
fetch: async (url: string, init: RequestInit = {}) => {
|
||||
@ -64,6 +113,7 @@ test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto r
|
||||
test("a timed out reference read is safe to retry because no generation was submitted", async () => {
|
||||
const methods: string[] = [];
|
||||
const loader = serverLoader({
|
||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => null },
|
||||
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
|
||||
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 500 ? 0 : ms),
|
||||
fetch: async (_url: string, init: RequestInit = {}) => {
|
||||
@ -85,6 +135,7 @@ test("a timed out reference read is safe to retry because no generation was subm
|
||||
test("a failed status lookup after Alibaba accepts the request keeps its outcome unknown", async () => {
|
||||
const methods: string[] = [];
|
||||
const loader = serverLoader({
|
||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => null },
|
||||
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
|
||||
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
||||
fetch: async (_url: string, init: RequestInit = {}) => {
|
||||
|
||||
@ -42,6 +42,58 @@ import { BANANAIMAGE_MODEL_PRESETS } from "@/lib/ai/bananaimage-models";
|
||||
import { buildTaskBody } from "@/lib/ai/providers/vip98k";
|
||||
import { getSceneRunResult } from "@/lib/agents/scene-run-recovery";
|
||||
import { extractImageUrlsFromText } from "@/lib/image-url";
|
||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||
|
||||
for (const status of ["failed", "unresolved", "unknown", "missing"]) {
|
||||
test(`a synchronous 502 reconciles ${status} without repeating submission`, async () => {
|
||||
const methods: string[] = [];
|
||||
const loader = serverLoader({
|
||||
"@/lib/db/queries": { getSystemSetting: async () => null },
|
||||
"@/lib/storage/image-archive": {},
|
||||
"@/lib/storage/service": {},
|
||||
"@/lib/observability/logger": { createLogger: () => ({ info() {}, warn() {}, error() {} }) },
|
||||
fetch: async (url: string, init: RequestInit) => {
|
||||
methods.push(init.method || "GET");
|
||||
if (init.method === "POST") return Response.json({ error: { message: "generate failed" } }, { status: 502 });
|
||||
expect(url).toContain("/api/external/requests/by-idempotency-key");
|
||||
return status === "missing" ? new Response(null, { status: 404 }) : Response.json({ status, request_id: "original-request", result_available: false });
|
||||
},
|
||||
});
|
||||
const provider = loader("lib/ai/providers/vip98k.ts").createVip98k({ apiKey: "fixture", baseURL: "https://provider.test" });
|
||||
const error = await provider.chat("gpt-image-2.5").doGenerate({ messages: [{ role: "user", content: "a blue cup" }] }).catch((error: unknown) => error);
|
||||
expect(methods).toEqual(["POST", "GET"]);
|
||||
if (status === "failed") {
|
||||
expect(error).toMatchObject({ confirmedNoResult: true });
|
||||
expect(loader("lib/ai/runtime/image-generation-error.ts").agentImageGenerationError(error, "pinned")).toBe(error);
|
||||
expect(loader("lib/request-error.ts").getRequestFailureDetails(error).outcome).toBe("failed");
|
||||
} else {
|
||||
expect(loader("lib/request-error.ts").getRequestFailureDetails(error).outcome).toBe("unknown");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("a synchronous 502 recovers the original completed image through GET only", async () => {
|
||||
const calls: string[] = [];
|
||||
let lookups = 0;
|
||||
const loader = serverLoader({
|
||||
"@/lib/db/queries": { getSystemSetting: async () => null },
|
||||
"@/lib/storage/image-archive": { createDeferredUploadedImage: async (url: string) => ({ original: url, thumbnail: url }) },
|
||||
"@/lib/storage/service": {},
|
||||
"@/lib/observability/logger": { createLogger: () => ({ info() {}, warn() {}, error() {} }) },
|
||||
setTimeout: (callback: () => void) => setTimeout(callback, 0),
|
||||
fetch: async (url: string, init: RequestInit) => {
|
||||
calls.push(init.method || "GET");
|
||||
if (init.method === "POST") return Response.json({ error: { message: "bad gateway" } }, { status: 502 });
|
||||
if (url.endsWith("/by-idempotency-key")) return Response.json({ status: ++lookups === 1 ? "processing" : "succeeded", request_id: "original-request", result_available: true });
|
||||
expect(url).toContain("/original-request/result");
|
||||
return Response.json({ data: [{ url: "https://assets.test/original.png" }] });
|
||||
},
|
||||
});
|
||||
const provider = loader("lib/ai/providers/vip98k.ts").createVip98k({ apiKey: "fixture", baseURL: "https://provider.test" });
|
||||
const result = await provider.chat("gpt-image-2.5").doGenerate({ messages: [{ role: "user", content: "a blue cup" }] });
|
||||
expect(calls).toEqual(["POST", "GET", "GET", "GET"]);
|
||||
expect(result.text).toContain("https://assets.test/original.png");
|
||||
});
|
||||
|
||||
const textToImageInvocation: Vip98kTaskInvocation = {
|
||||
kind: "tool",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user