From d10068874e110a63d7f52d20b3a8bba98e30633a Mon Sep 17 00:00:00 2001 From: 134 Date: Mon, 7 Sep 2026 20:48:55 +0800 Subject: [PATCH] perf: cache vip98k reference staging --- lib/ai/providers/vip98k.ts | 329 ++++++++++++++++-- lib/ai/vip98k-reference-staging.ts | 22 ++ lib/ai/vip98k-runtime-config.ts | 105 ++++++ scripts/configure-image-provider-allowlist.ts | 13 + tests/routes/vip98k-reference-routing.test.ts | 48 +++ 5 files changed, 481 insertions(+), 36 deletions(-) create mode 100644 lib/ai/vip98k-runtime-config.ts diff --git a/lib/ai/providers/vip98k.ts b/lib/ai/providers/vip98k.ts index 39df4a7b1..a9b25a3a4 100644 --- a/lib/ai/providers/vip98k.ts +++ b/lib/ai/providers/vip98k.ts @@ -1,10 +1,14 @@ import "server-only"; import { randomUUID } from "node:crypto"; -import { GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3"; +import { + GetObjectCommand, + HeadObjectCommand, + PutObjectCommand, +} from "@aws-sdk/client-s3"; import { createOpenAI } from "@ai-sdk/openai"; import sharp from "sharp"; -import { getDefaultStorageConfig } from "@/lib/db/queries"; +import { getDefaultStorageConfig, getSystemSetting } from "@/lib/db/queries"; import { bindVip98kImages, resolveVip98kImageTaskSize, @@ -13,12 +17,22 @@ import { } from "@/lib/ai/vip98k-task-invocation"; import { VIP98K_BASE_URL, normalizeVip98kBaseUrl } from "@/lib/ai/vip98k-models"; import { shouldResolveVip98kResultDelivery } from "@/lib/ai/vip98k-result-delivery"; +import { + defaultVip98kRuntimeConfig, + normalizeVip98kRuntimeConfig, + resolveVip98kSubmissionTimeoutMs, + VIP98K_RUNTIME_CONFIG_KEY, + type Vip98kRuntimeConfig, +} from "@/lib/ai/vip98k-runtime-config"; import { addVip98kReferenceRetryToken, getStorageObjectKeyFromPublicUrl, + getVip98kReferenceStagingObjectKey, getVip98kStagedMediaRetryDelay, getVip98kSubmissionRetryDelay, + isVip98kStagedReferenceUrl, shouldStageVip98kReferenceUrl, + VIP98K_REFERENCE_STAGING_KEY_PREFIX, } from "@/lib/ai/vip98k-reference-staging"; import { createLogger } from "@/lib/observability/logger"; import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; @@ -71,10 +85,10 @@ const DEFAULT_POLL_INTERVAL_SECONDS = 8; const DEFAULT_MAX_POLL_SECONDS = 600; const REFERENCE_DOWNLOAD_ATTEMPTS = 3; const REFERENCE_DOWNLOAD_TIMEOUT_MS = 120_000; +const REFERENCE_CACHE_LOOKUP_TIMEOUT_MS = 15_000; const MAX_REFERENCE_DOWNLOAD_BYTES = IMAGE_UPLOAD_MAX_BYTES; const REFERENCE_STAGING_CACHE_MS = 60_000; -const REFERENCE_STAGING_KEY_PREFIX = "uploads/vip98k-"; -const MAX_SUBMISSION_ATTEMPTS = 3; +const RUNTIME_CONFIG_CACHE_MS = 60_000; type ReferenceStagingConfig = { sourcePublicUrl: string; @@ -91,7 +105,14 @@ class Vip98kReferenceTooLargeError extends Error { let referenceStagingConfigCache: | { expiresAt: number; value: ReferenceStagingConfig | null } | undefined; +let runtimeConfigCache: + | { expiresAt: number; value: Vip98kRuntimeConfig } + | undefined; const referenceStagingTasks = new Map>(); +const referenceStagingResultCache = new Map< + string, + { cachedAt: number; url: string } +>(); function toRecord(value: unknown): Record | null { if (!value || typeof value !== "object" || Array.isArray(value)) return null; @@ -522,6 +543,28 @@ async function resolveReferenceStagingConfig() { return value; } +async function getVip98kRuntimeConfig() { + const now = Date.now(); + if (runtimeConfigCache?.expiresAt && runtimeConfigCache.expiresAt > now) { + return runtimeConfigCache.value; + } + + let value = defaultVip98kRuntimeConfig; + try { + const setting = await getSystemSetting({ key: VIP98K_RUNTIME_CONFIG_KEY }); + value = normalizeVip98kRuntimeConfig(setting?.value); + } catch (error) { + logger.warn("runtime config lookup failed; using defaults", { + message: error instanceof Error ? error.message : String(error), + }); + } + runtimeConfigCache = { + expiresAt: now + RUNTIME_CONFIG_CACHE_MS, + value, + }; + return value; +} + function getUrlHost(value: string) { try { return new URL(value).host; @@ -530,11 +573,96 @@ function getUrlHost(value: string) { } } -function isStagedReferenceUrl(value: string) { +function getReferenceStagingCacheKey( + mediaUrl: string, + stagingConfig: ReferenceStagingConfig, +) { + return [ + stagingConfig.storageConfig.endpoint, + stagingConfig.storageConfig.bucket, + mediaUrl, + ].join("|"); +} + +function getCachedStagedReference( + cacheKey: string, + runtimeConfig: Vip98kRuntimeConfig, +) { + const cached = referenceStagingResultCache.get(cacheKey); + if (!cached) return null; + if (cached.cachedAt + runtimeConfig.referenceCacheTtlMs <= Date.now()) { + referenceStagingResultCache.delete(cacheKey); + return null; + } + referenceStagingResultCache.delete(cacheKey); + referenceStagingResultCache.set(cacheKey, cached); + return cached.url; +} + +function cacheStagedReference( + cacheKey: string, + stagedUrl: string, + runtimeConfig: Vip98kRuntimeConfig, +) { + referenceStagingResultCache.delete(cacheKey); + while ( + referenceStagingResultCache.size >= runtimeConfig.referenceCacheMaxEntries + ) { + const oldestKey = referenceStagingResultCache.keys().next().value; + if (typeof oldestKey !== "string") break; + referenceStagingResultCache.delete(oldestKey); + } + referenceStagingResultCache.set(cacheKey, { + cachedAt: Date.now(), + url: stagedUrl, + }); +} + +function isObjectNotFound(error: unknown) { + if (!error || typeof error !== "object") return false; + const record = error as { + name?: unknown; + Code?: unknown; + code?: unknown; + $metadata?: { httpStatusCode?: unknown }; + }; + return ( + record.$metadata?.httpStatusCode === 404 || + [record.name, record.Code, record.code].some((value) => + /notfound|nosuchkey|no such key/i.test(String(value || "")), + ) + ); +} + +async function findStagedReference( + key: string, + stagingConfig: ReferenceStagingConfig, + runtimeConfig: Vip98kRuntimeConfig, +) { try { - return new URL(value).pathname.startsWith(`/${REFERENCE_STAGING_KEY_PREFIX}`); - } catch { - return false; + const response = await createS3Client(stagingConfig.storageConfig).send( + new HeadObjectCommand({ + Bucket: stagingConfig.storageConfig.bucket, + Key: key, + }), + { abortSignal: AbortSignal.timeout(REFERENCE_CACHE_LOOKUP_TIMEOUT_MS) }, + ); + if ( + response.LastModified && + Date.now() - response.LastModified.getTime() > + runtimeConfig.referenceCacheTtlMs + ) { + return null; + } + return buildS3PublicUrl(stagingConfig.storageConfig, key); + } catch (error) { + if (!isObjectNotFound(error)) { + logger.warn("reference staging cache lookup failed; rebuilding", { + targetHost: getUrlHost(stagingConfig.sourcePublicUrl), + message: error instanceof Error ? error.message : String(error), + }); + } + return null; } } @@ -647,10 +775,13 @@ async function downloadReferenceImageFromStorage( async function uploadReferenceImage( buffer: Buffer, stagingConfig: ReferenceStagingConfig, + objectKey?: string, ) { - const key = `${REFERENCE_STAGING_KEY_PREFIX}${Date.now()}-${Math.random() - .toString(36) - .slice(2, 8)}.jpg`; + const key = + objectKey || + `${VIP98K_REFERENCE_STAGING_KEY_PREFIX}${Date.now()}-${Math.random() + .toString(36) + .slice(2, 8)}.jpg`; await createS3Client(stagingConfig.storageConfig).send( new PutObjectCommand({ Bucket: stagingConfig.storageConfig.bucket, @@ -690,25 +821,67 @@ async function normalizeReferenceImage(buffer: Buffer, mimeType: string) { async function stageReferenceImage( mediaUrl: string, stagingConfig: ReferenceStagingConfig, + runtimeConfig: Vip98kRuntimeConfig, + cacheKey: string, ) { + const startedAt = Date.now(); + const objectKey = getVip98kReferenceStagingObjectKey(mediaUrl); + const cacheLookupStartedAt = Date.now(); + const existingUrl = await findStagedReference( + objectKey, + stagingConfig, + runtimeConfig, + ); + const cacheLookupMs = Date.now() - cacheLookupStartedAt; + if (existingUrl) { + cacheStagedReference(cacheKey, existingUrl, runtimeConfig); + logger.info("reference staging object cache hit", { + sourceHost: getUrlHost(mediaUrl), + targetHost: getUrlHost(existingUrl), + cacheLookupMs, + totalMs: Date.now() - startedAt, + }); + return existingUrl; + } + + const downloadStartedAt = Date.now(); const downloaded = (await downloadReferenceImageFromStorage(mediaUrl, stagingConfig)) || (await downloadReferenceImage(mediaUrl)); + const downloadMs = Date.now() - downloadStartedAt; + const normalizeStartedAt = Date.now(); const normalized = await normalizeReferenceImage( downloaded.buffer, downloaded.mimeType, ); - const uploadedUrl = await uploadReferenceImage(normalized.buffer, stagingConfig); + const normalizeMs = Date.now() - normalizeStartedAt; + const uploadStartedAt = Date.now(); + const uploadedUrl = await uploadReferenceImage( + normalized.buffer, + stagingConfig, + objectKey, + ); + const uploadMs = Date.now() - uploadStartedAt; + cacheStagedReference(cacheKey, uploadedUrl, runtimeConfig); logger.info("reference image staged", { sourceHost: getUrlHost(mediaUrl), targetHost: getUrlHost(uploadedUrl), sourceBytes: downloaded.buffer.byteLength, stagedBytes: normalized.buffer.byteLength, + cacheLookupMs, + downloadMs, + normalizeMs, + uploadMs, + totalMs: Date.now() - startedAt, }); return uploadedUrl; } -async function ensurePublicMediaUrl(mediaUrl: string, mediaKind: "image" | "video") { +async function ensurePublicMediaUrl( + mediaUrl: string, + mediaKind: "image" | "video", + runtimeConfig: Vip98kRuntimeConfig, +) { const normalized = String(mediaUrl || "").trim(); if (/^https?:\/\//i.test(normalized)) { if (mediaKind !== "image") return normalized; @@ -718,7 +891,7 @@ async function ensurePublicMediaUrl(mediaUrl: string, mediaKind: "image" | "vide : null; if ( !stagingConfig || - sourceObjectKey?.startsWith(REFERENCE_STAGING_KEY_PREFIX) || + sourceObjectKey?.startsWith(VIP98K_REFERENCE_STAGING_KEY_PREFIX) || !shouldStageVip98kReferenceUrl({ mediaUrl: normalized, sourcePublicUrl: stagingConfig.sourcePublicUrl, @@ -727,12 +900,26 @@ async function ensurePublicMediaUrl(mediaUrl: string, mediaKind: "image" | "vide return normalized; } - const existingTask = referenceStagingTasks.get(normalized); + const cacheKey = getReferenceStagingCacheKey(normalized, stagingConfig); + const cachedUrl = getCachedStagedReference(cacheKey, runtimeConfig); + if (cachedUrl) { + logger.info("reference staging memory cache hit", { + sourceHost: getUrlHost(normalized), + targetHost: getUrlHost(cachedUrl), + }); + return cachedUrl; + } + const existingTask = referenceStagingTasks.get(cacheKey); if (existingTask) return existingTask; - const task = stageReferenceImage(normalized, stagingConfig).finally(() => { - referenceStagingTasks.delete(normalized); + const task = stageReferenceImage( + normalized, + stagingConfig, + runtimeConfig, + cacheKey, + ).finally(() => { + referenceStagingTasks.delete(cacheKey); }); - referenceStagingTasks.set(normalized, task); + referenceStagingTasks.set(cacheKey, task); return task; } if (!normalized.startsWith("data:")) return normalized; @@ -824,10 +1011,14 @@ function getConfiguredPollSeconds(config: Vip98kModelRuntimeConfig, key: string, async function submitTask(params: { apiKey: string; baseURL?: string | null; + modelId: string; path?: string | null; method?: string | null; contentType?: string | null; body: Record; + submissionTimeoutMs: number; + maxSubmissionAttempts: number; + submissionRetryBaseDelayMs: number; }) { const baseURL = normalizeVip98kBaseUrl(params.baseURL); const path = String(params.path || "/api/external/task/generate").trim(); @@ -845,14 +1036,17 @@ async function submitTask(params: { const candidates = Array.isArray(value) ? value : [value]; return candidates.filter( (candidate): candidate is string => - typeof candidate === "string" && isStagedReferenceUrl(candidate), + typeof candidate === "string" && + isVip98kStagedReferenceUrl(candidate), ); }); - const maxAttempts = MAX_SUBMISSION_ATTEMPTS; + const maxAttempts = params.maxSubmissionAttempts; let refreshStagedReferences = false; let idempotencyKey = randomUUID(); + const submissionStartedAt = Date.now(); for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { + const attemptStartedAt = Date.now(); const requestBody = refreshStagedReferences ? Object.fromEntries( Object.entries(params.body).map(([key, value]) => { @@ -860,7 +1054,8 @@ async function submitTask(params: { return [key, value]; } const addRetryToken = (candidate: unknown) => - typeof candidate === "string" && isStagedReferenceUrl(candidate) + typeof candidate === "string" && + isVip98kStagedReferenceUrl(candidate) ? addVip98kReferenceRetryToken( candidate, `${Date.now()}-${attempt}`, @@ -883,13 +1078,28 @@ async function submitTask(params: { "Idempotency-Key": idempotencyKey, }, body: JSON.stringify(requestBody), - signal: AbortSignal.timeout(120_000), + signal: AbortSignal.timeout(params.submissionTimeoutMs), }); } catch (error) { - if (attempt >= maxAttempts) throw error; - const retryDelayMs = attempt * 5_000; + if (attempt >= maxAttempts) { + logger.error("provider submission transport failed", { + modelId: params.modelId, + path, + attempt, + attemptDurationMs: Date.now() - attemptStartedAt, + totalDurationMs: Date.now() - submissionStartedAt, + submissionTimeoutMs: params.submissionTimeoutMs, + message: error instanceof Error ? error.message : String(error), + }); + throw error; + } + const retryDelayMs = attempt * params.submissionRetryBaseDelayMs; logger.warn("provider submission transport failed; retrying idempotently", { + modelId: params.modelId, + path, attempt, + attemptDurationMs: Date.now() - attemptStartedAt, + submissionTimeoutMs: params.submissionTimeoutMs, retryDelayMs, message: error instanceof Error ? error.message : String(error), }); @@ -898,6 +1108,14 @@ async function submitTask(params: { continue; } const payload = await parseJsonResponse(response); + logger.info("provider submission response received", { + modelId: params.modelId, + path, + attempt, + status: response.status, + attemptDurationMs: Date.now() - attemptStartedAt, + totalDurationMs: Date.now() - submissionStartedAt, + }); if (response.ok) { return { payload, @@ -917,6 +1135,8 @@ async function submitTask(params: { }); if (stagedMediaRetryDelayMs !== null) { logger.warn("staged reference not ready for provider; retrying submission", { + modelId: params.modelId, + path, attempt, retryDelayMs: stagedMediaRetryDelayMs, stagedReferenceCount: stagedReferences.length, @@ -939,6 +1159,8 @@ async function submitTask(params: { }); if (reconciliationRetryDelayMs !== null) { logger.warn("provider is reconciling task submission; retrying", { + modelId: params.modelId, + path, attempt, retryDelayMs: reconciliationRetryDelayMs, }); @@ -980,14 +1202,26 @@ export async function submitVip98kExternalTask(params: { imageParams?: Record; modelConfig?: Vip98kModelRuntimeConfig; }): Promise { + const runtimeConfig = await getVip98kRuntimeConfig(); + const referencePreparationStartedAt = Date.now(); const [imageUrls, videoUrls] = await Promise.all([ Promise.all( - (params.imageUrls || []).map((url) => ensurePublicMediaUrl(url, "image")), + (params.imageUrls || []).map((url) => + ensurePublicMediaUrl(url, "image", runtimeConfig), + ), ), Promise.all( - (params.videoUrls || []).map((url) => ensurePublicMediaUrl(url, "video")), + (params.videoUrls || []).map((url) => + ensurePublicMediaUrl(url, "video", runtimeConfig), + ), ), ]); + logger.info("reference preparation completed", { + modelId: params.modelId, + imageCount: imageUrls.length, + videoCount: videoUrls.length, + durationMs: Date.now() - referencePreparationStartedAt, + }); const taskParams: Vip98kTaskParams = { apiKey: params.apiKey, baseURL: params.baseURL, @@ -1025,15 +1259,27 @@ export async function submitVip98kExternalTask(params: { videoCount: videoUrls.length, imageBindingKeys, outputConstraintFallbacks, + submissionTimeoutMs: resolveVip98kSubmissionTimeoutMs( + params.modelId, + runtimeConfig, + ), + maxSubmissionAttempts: runtimeConfig.maxSubmissionAttempts, bodyKeys: Object.keys(body).sort(), }); const submitted = await submitTask({ apiKey: params.apiKey, baseURL: params.baseURL, + modelId: params.modelId, path: invocation?.path, method: invocation?.method, contentType: invocation?.contentType, body, + submissionTimeoutMs: resolveVip98kSubmissionTimeoutMs( + params.modelId, + runtimeConfig, + ), + maxSubmissionAttempts: runtimeConfig.maxSubmissionAttempts, + submissionRetryBaseDelayMs: runtimeConfig.submissionRetryBaseDelayMs, }); const resultPath = submitted.resultPath || invocation?.resultPath || ""; return { @@ -1116,6 +1362,13 @@ async function waitForTask(params: { ["succeeded", "success", "completed", "done", "finished"].includes(status) || (done && outputs.length > 0) ) { + logger.info("task polling completed", { + taskId: params.taskId, + modelId: params.modelId, + status, + outputCount: outputs.length, + durationMs: Date.now() - startedAt, + }); return { payload, outputs }; } if (["failed", "error", "timeout", "canceled", "cancelled"].includes(status)) { @@ -1134,6 +1387,12 @@ async function waitForTask(params: { } } + logger.error("task polling timed out", { + taskId: params.taskId, + modelId: params.modelId, + durationMs: Date.now() - startedAt, + timeoutMs, + }); throw new Error("VIP98K task timed out; it may still be running"); } @@ -1365,7 +1624,13 @@ async function runTask(params: Vip98kTaskParams) { throw new Error("VIP98K task completed but returned no image URL"); } const imageSize = String(params.imageParams?.imageSize || "2K").trim() || "2K"; + const archiveStartedAt = Date.now(); const archived = await Promise.all(outputUrls.slice(0, 1).map(archiveOutputImage)); + logger.info("result archive completed", { + modelId: params.modelId, + outputCount: archived.length, + durationMs: Date.now() - archiveStartedAt, + }); return formatImageMarkdown(archived, imageSize); } @@ -1415,22 +1680,14 @@ export function createVip98k({ apiKey, baseURL }: Vip98kConfig) { const { prompt, imageUrls, videoUrls } = extractPromptAndMedia( options.messages, ); - const [publicImageUrls, publicVideoUrls] = await Promise.all([ - Promise.all( - imageUrls.map((url) => ensurePublicMediaUrl(url, "image")), - ), - Promise.all( - videoUrls.map((url) => ensurePublicMediaUrl(url, "video")), - ), - ]); const text = await runTask({ apiKey, baseURL: rootBaseURL, modelId: activeModelId, kind: activeKind, prompt, - imageUrls: publicImageUrls, - videoUrls: publicVideoUrls, + imageUrls, + videoUrls, videoParams: options.videoParams, imageParams: options.imageParams, modelConfig: runtimeConfig, diff --git a/lib/ai/vip98k-reference-staging.ts b/lib/ai/vip98k-reference-staging.ts index 0986ba3ad..80f30a8c6 100644 --- a/lib/ai/vip98k-reference-staging.ts +++ b/lib/ai/vip98k-reference-staging.ts @@ -1,5 +1,9 @@ +import { createHash } from "node:crypto"; import { isMediaInputFailure } from "@/lib/ai/runtime/request-failure-classification"; +export const VIP98K_REFERENCE_STAGING_KEY_PREFIX = "uploads/vip98k-"; +const VIP98K_REFERENCE_CACHE_OBJECT_PREFIX = "uploads/vip98k-cache"; + function parseUrl(value: string) { try { return new URL(String(value || "").trim()); @@ -8,6 +12,24 @@ function parseUrl(value: string) { } } +export function isVip98kStagedReferenceUrl(value: string) { + const parsed = parseUrl(value); + return Boolean( + parsed?.pathname.includes(`/${VIP98K_REFERENCE_STAGING_KEY_PREFIX}`), + ); +} + +export function getVip98kReferenceStagingObjectKey(mediaUrl: string) { + const parsed = parseUrl(mediaUrl); + if (parsed) { + parsed.hash = ""; + parsed.searchParams.delete("sg_media_retry"); + } + const cacheIdentity = parsed?.toString() || mediaUrl; + const digest = createHash("sha256").update(cacheIdentity).digest("hex"); + return `${VIP98K_REFERENCE_CACHE_OBJECT_PREFIX}/${digest}.jpg`; +} + export function isUrlWithinPublicBase(mediaUrl: string, publicBaseUrl: string) { const media = parseUrl(mediaUrl); const base = parseUrl(publicBaseUrl); diff --git a/lib/ai/vip98k-runtime-config.ts b/lib/ai/vip98k-runtime-config.ts new file mode 100644 index 000000000..940cb3077 --- /dev/null +++ b/lib/ai/vip98k-runtime-config.ts @@ -0,0 +1,105 @@ +export const VIP98K_RUNTIME_CONFIG_KEY = "vip98k_runtime_config"; + +export type Vip98kRuntimeConfig = { + submissionTimeoutMs: number; + modelSubmissionTimeoutMs: Record; + maxSubmissionAttempts: number; + submissionRetryBaseDelayMs: number; + referenceCacheTtlMs: number; + referenceCacheMaxEntries: number; +}; + +export const defaultVip98kRuntimeConfig: Vip98kRuntimeConfig = { + submissionTimeoutMs: 120_000, + modelSubmissionTimeoutMs: { + "nano-banana-pro": 240_000, + }, + maxSubmissionAttempts: 3, + submissionRetryBaseDelayMs: 5_000, + referenceCacheTtlMs: 7 * 24 * 60 * 60 * 1000, + referenceCacheMaxEntries: 2_000, +}; + +function clampInteger( + value: unknown, + fallback: number, + minimum: number, + maximum: number, +) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.min(maximum, Math.max(minimum, Math.round(numeric))); +} + +function normalizeModelTimeouts(value: unknown) { + const configured = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + const normalized: Record = { + ...defaultVip98kRuntimeConfig.modelSubmissionTimeoutMs, + }; + for (const [modelId, timeout] of Object.entries(configured)) { + const normalizedModelId = modelId.trim().toLowerCase(); + if (!normalizedModelId || normalizedModelId.length > 160) continue; + normalized[normalizedModelId] = clampInteger( + timeout, + defaultVip98kRuntimeConfig.submissionTimeoutMs, + 30_000, + 10 * 60_000, + ); + } + return normalized; +} + +export function normalizeVip98kRuntimeConfig(value: unknown): Vip98kRuntimeConfig { + const raw = + value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : {}; + return { + submissionTimeoutMs: clampInteger( + raw.submissionTimeoutMs, + defaultVip98kRuntimeConfig.submissionTimeoutMs, + 30_000, + 10 * 60_000, + ), + modelSubmissionTimeoutMs: normalizeModelTimeouts( + raw.modelSubmissionTimeoutMs, + ), + maxSubmissionAttempts: clampInteger( + raw.maxSubmissionAttempts, + defaultVip98kRuntimeConfig.maxSubmissionAttempts, + 1, + 5, + ), + submissionRetryBaseDelayMs: clampInteger( + raw.submissionRetryBaseDelayMs, + defaultVip98kRuntimeConfig.submissionRetryBaseDelayMs, + 1_000, + 60_000, + ), + referenceCacheTtlMs: clampInteger( + raw.referenceCacheTtlMs, + defaultVip98kRuntimeConfig.referenceCacheTtlMs, + 5 * 60_000, + 30 * 24 * 60 * 60_000, + ), + referenceCacheMaxEntries: clampInteger( + raw.referenceCacheMaxEntries, + defaultVip98kRuntimeConfig.referenceCacheMaxEntries, + 100, + 10_000, + ), + }; +} + +export function resolveVip98kSubmissionTimeoutMs( + modelId: string, + config: Vip98kRuntimeConfig, +) { + return ( + config.modelSubmissionTimeoutMs[modelId.trim().toLowerCase()] || + config.submissionTimeoutMs + ); +} diff --git a/scripts/configure-image-provider-allowlist.ts b/scripts/configure-image-provider-allowlist.ts index 58ddab507..7844c4e05 100644 --- a/scripts/configure-image-provider-allowlist.ts +++ b/scripts/configure-image-provider-allowlist.ts @@ -4,6 +4,10 @@ import { fetchVip98kModelPresets, type Vip98kModelPreset, } from "../lib/ai/vip98k-models"; +import { + defaultVip98kRuntimeConfig, + VIP98K_RUNTIME_CONFIG_KEY, +} from "../lib/ai/vip98k-runtime-config"; dotenv.config({ path: ".env.local" }); dotenv.config(); @@ -140,6 +144,15 @@ async function main() { if (!apply) return; await sql.begin(async (tx) => { + await tx` + insert into "SystemSettings" (key, value, "updatedAt") + values ( + ${VIP98K_RUNTIME_CONFIG_KEY}, + ${tx.json(toJsonValue(defaultVip98kRuntimeConfig))}, + now() + ) + on conflict (key) do nothing + `; await tx` update "AiProvider" set "baseUrl" = 'https://api.vip98k.com', "apiKey" = ${vip98kApiKey}, diff --git a/tests/routes/vip98k-reference-routing.test.ts b/tests/routes/vip98k-reference-routing.test.ts index 97b2a4b1d..8b7cb05a0 100644 --- a/tests/routes/vip98k-reference-routing.test.ts +++ b/tests/routes/vip98k-reference-routing.test.ts @@ -13,11 +13,17 @@ import { import { addVip98kReferenceRetryToken, getStorageObjectKeyFromPublicUrl, + getVip98kReferenceStagingObjectKey, getVip98kStagedMediaRetryDelay, getVip98kSubmissionRetryDelay, + isVip98kStagedReferenceUrl, shouldStageVip98kReferenceUrl, } from "@/lib/ai/vip98k-reference-staging"; import { shouldResolveVip98kResultDelivery } from "@/lib/ai/vip98k-result-delivery"; +import { + normalizeVip98kRuntimeConfig, + resolveVip98kSubmissionTimeoutMs, +} from "@/lib/ai/vip98k-runtime-config"; const textToImageInvocation: Vip98kTaskInvocation = { kind: "tool", @@ -31,6 +37,48 @@ const textToImageInvocation: Vip98kTaskInvocation = { }; test.describe("VIP98K reference-image routing", () => { + test("uses a stable cross-process cache key for staged references", () => { + const source = "https://storage.example/uploads/reference.png"; + const key = getVip98kReferenceStagingObjectKey(source); + + expect(key).toMatch(/^uploads\/vip98k-cache\/[a-f0-9]{64}\.jpg$/); + expect( + getVip98kReferenceStagingObjectKey(`${source}?sg_media_retry=retry-2`), + ).toBe(key); + expect( + isVip98kStagedReferenceUrl( + `https://minio.example/bucket/${key}`, + ), + ).toBe(true); + }); + + test("loads bounded database runtime settings with a Pro timeout override", () => { + const defaults = normalizeVip98kRuntimeConfig(null); + expect(resolveVip98kSubmissionTimeoutMs("nano-banana-2", defaults)).toBe( + 120_000, + ); + expect(resolveVip98kSubmissionTimeoutMs("nano-banana-pro", defaults)).toBe( + 240_000, + ); + expect(defaults.referenceCacheTtlMs).toBe(7 * 24 * 60 * 60 * 1000); + + const config = normalizeVip98kRuntimeConfig({ + submissionTimeoutMs: 90_000, + modelSubmissionTimeoutMs: { "nano-banana-pro": 300_000 }, + maxSubmissionAttempts: 20, + referenceCacheTtlMs: 1, + }); + + expect(config.maxSubmissionAttempts).toBe(5); + expect(config.referenceCacheTtlMs).toBe(5 * 60_000); + expect(resolveVip98kSubmissionTimeoutMs("nano-banana-2", config)).toBe( + 90_000, + ); + expect(resolveVip98kSubmissionTimeoutMs("NANO-BANANA-PRO", config)).toBe( + 300_000, + ); + }); + test("identifies default-storage references that need provider staging", () => { expect( shouldStageVip98kReferenceUrl({