diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 4f8c167c3..75935a267 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -13517,7 +13517,8 @@ export function AdminSettingsClient() { 系统智能选择(根据意图路由) {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) => ( {model.displayName} diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 9c21ae70a..27da35417 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -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, diff --git a/components/image-params-bar.tsx b/components/image-params-bar.tsx index f3be904db..95d1b09ac 100644 --- a/components/image-params-bar.tsx +++ b/components/image-params-bar.tsx @@ -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; diff --git a/lib/ai/providers/alibaba-image.ts b/lib/ai/providers/alibaba-image.ts index 71ca6017e..64dec556b 100644 --- a/lib/ai/providers/alibaba-image.ts +++ b/lib/ai/providers/alibaba-image.ts @@ -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, ): Promise { 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; requestURL: string; }): Promise { + 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(); 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 = { "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`); diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index cb6ea31ad..04c87148c 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -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()))); } diff --git a/lib/ai/runtime/image-generation-error.ts b/lib/ai/runtime/image-generation-error.ts index 547757359..f0b433d76 100644 --- a/lib/ai/runtime/image-generation-error.ts +++ b/lib/ai/runtime/image-generation-error.ts @@ -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" ? "系统未自动切换。你希望切换生图模型,还是稍后重试?切换前请在模型选择器中选择其他模型或自动选择。" : "请稍后重试或联系管理员处理生图通道。"; diff --git a/tests/routes/alibaba-task-errors.test.ts b/tests/routes/alibaba-task-errors.test.ts index fb2534493..f1e55f997 100644 --- a/tests/routes/alibaba-task-errors.test.ts +++ b/tests/routes/alibaba-task-errors.test.ts @@ -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 = {}) => { diff --git a/tests/routes/vip98k-reference-routing.test.ts b/tests/routes/vip98k-reference-routing.test.ts index 7640898d6..887c1e532 100644 --- a/tests/routes/vip98k-reference-routing.test.ts +++ b/tests/routes/vip98k-reference-routing.test.ts @@ -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",