mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-13 02:32:18 +03:00
Compare commits
1 Commits
docs/readm
...
fix/build-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
058b2573c6 |
1
changelog.d/fixes/release-v3850-turbopack-build-red.md
Normal file
1
changelog.d/fixes/release-v3850-turbopack-build-red.md
Normal file
@@ -0,0 +1 @@
|
||||
- **fix(build):** repair the broken Turbopack production build, the red lint gate and a runtime crash on `release/v3.8.50`. Six independent module-level defects, each from a different PR, had accumulated because the `Build` CI job is advisory rather than blocking: a lost closing brace in `modelSelectModalHelpers.ts` that swallowed `PROVIDER_TEST_CHUNK_SIZE` into a function body (#9011); `handleFalVideoGeneration` imported twice in `videoGeneration.ts` after the provider-neutral Fal module superseded the standalone handler (#9982 over #9969); `catalog.ts` still re-exporting and calling the injectable stale-while-revalidate policy that #9199 deliberately replaced with a fixed 30 s bound when it landed on top of #8728 — the consumer and the #8728 test suite were never realigned; two dangling statements left in `catalogCache.ts::scheduleBackgroundRefresh` referencing undeclared `inFlight`/`promise`, which made **every** stale-while-revalidate read throw a `ReferenceError` at runtime (a defect the build never caught, surfaced here by the realigned test); a generated wasm-bindgen sidecar URL in `tinycmsSigner.ts` that Turbopack resolves at build time even though the WASM module ships inlined as base64 (#8736/#10087); `conolDiscovery.ts` importing `getProviderOutboundGuard` from `outboundUrlGuard` instead of the sibling `outboundUrlGuardPolicy` module that actually exports it (#8974) — fixed on the consumer side, since re-exporting it would put a `@/`-aliased import into the module the packaged CLI loads without a tsconfig (#7682); and an unbalanced brace in `tests/unit/db-adapters/driverFactory.test.ts` where a new case was inserted between the preceding test's `finally` block and its `});`, so the whole file stopped parsing and the SQLite driver-cascade coverage silently stopped running since 2026-08-11 (#9173).
|
||||
@@ -438,7 +438,14 @@ async function __wbg_init(module_or_path) {
|
||||
}
|
||||
|
||||
if (module_or_path === undefined) {
|
||||
module_or_path = new URL('wasm_signer_bg.wasm', import.meta.url);
|
||||
// Upstream wasm-bindgen glue defaults to a sidecar binary resolved via
|
||||
// `new URL(<sidecar>, import.meta.url)`. OmniRoute ships the module inlined as
|
||||
// WASM_BASE64 instead — no sidecar exists in the repo — and the only caller,
|
||||
// initTinyCmsWasm(), always passes that decoded Buffer explicitly, so this
|
||||
// branch is unreachable. The literal URL still had to go: Turbopack resolves
|
||||
// `new URL(<literal>, import.meta.url)` statically, so keeping it failed
|
||||
// `next build` with a "Module not found" for the missing sidecar.
|
||||
throw new Error('TinyCMS WASM module must be supplied explicitly (see initTinyCmsWasm)');
|
||||
}
|
||||
const imports = __wbg_get_imports();
|
||||
|
||||
|
||||
@@ -18,7 +18,6 @@ import { handleNovitaVideoGeneration } from "./videoGeneration/novitaHandler.ts"
|
||||
import { handleXaiVideoGeneration } from "./videoGeneration/xaiGrokImagineHandler.ts";
|
||||
import { handleSegmindVideoGeneration } from "./videoGeneration/providers/segmind.ts";
|
||||
import { handleAdobeFireflyVideoGeneration } from "./videoGeneration/adobeFireflyHandler.ts";
|
||||
import { handleFalVideoGeneration } from "./videoGeneration/falHandler.ts";
|
||||
import { handleOpenAIVideoGeneration } from "./videoGeneration/openai.ts";
|
||||
import { getVideoJobPreset, handleVideoJobGeneration } from "./videoGeneration/job.ts";
|
||||
import {
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
import { saveCallLog } from "@/lib/usageDb";
|
||||
import {
|
||||
FetchTimeoutError,
|
||||
fetchWithTimeout,
|
||||
getConfiguredTimeout,
|
||||
} from "@/shared/utils/fetchTimeout";
|
||||
import { sanitizeErrorMessage } from "../../utils/error.ts";
|
||||
|
||||
interface FalVideoBody {
|
||||
prompt?: unknown;
|
||||
aspect_ratio?: unknown;
|
||||
duration?: unknown;
|
||||
resolution?: unknown;
|
||||
quality?: unknown;
|
||||
generate_audio?: unknown;
|
||||
poll_interval_ms?: unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
interface FalCredentials {
|
||||
apiKey?: unknown;
|
||||
accessToken?: unknown;
|
||||
}
|
||||
|
||||
interface FalProviderConfig {
|
||||
baseUrl: string;
|
||||
}
|
||||
|
||||
interface FalLog {
|
||||
info?: (scope: string, message: string, meta?: unknown) => void;
|
||||
error?: (scope: string, message: string) => void;
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function numberValue(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function grokDuration(value: unknown, fallback = 6): number {
|
||||
const numeric = numberValue(value);
|
||||
if (numeric !== undefined) return Math.round(numeric);
|
||||
|
||||
if (typeof value === "string") {
|
||||
const match = value.trim().match(/^(\d+)s$/);
|
||||
if (match) return Number(match[1]);
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function falDuration(value: unknown, fallback = "8s"): string {
|
||||
if (typeof value === "string" && /^(4|6|8)s$/.test(value)) return value;
|
||||
|
||||
const numeric = numberValue(value);
|
||||
return numeric && [4, 6, 8].includes(numeric) ? `${numeric}s` : fallback;
|
||||
}
|
||||
|
||||
export function buildFalVideoPayload(model: string, body: FalVideoBody): Record<string, unknown> {
|
||||
const prompt = stringValue(body.prompt) || "";
|
||||
const aspectRatio = stringValue(body.aspect_ratio) || "16:9";
|
||||
const resolution = stringValue(body.resolution) || (body.quality === "hd" ? "1080p" : "720p");
|
||||
|
||||
if (model.startsWith("xai/grok-imagine-video/")) {
|
||||
return {
|
||||
prompt,
|
||||
aspect_ratio: aspectRatio,
|
||||
duration: grokDuration(body.duration),
|
||||
resolution,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
prompt,
|
||||
aspect_ratio: aspectRatio,
|
||||
duration: falDuration(body.duration),
|
||||
resolution,
|
||||
generate_audio: typeof body.generate_audio === "boolean" ? body.generate_audio : true,
|
||||
};
|
||||
}
|
||||
|
||||
function absoluteFalUrl(value: unknown, baseUrl: string): string | undefined {
|
||||
const url = stringValue(value);
|
||||
if (!url) return undefined;
|
||||
if (url.startsWith("http://") || url.startsWith("https://")) return url;
|
||||
return `${baseUrl.replace(/\/$/, "")}/${url.replace(/^\//, "")}`;
|
||||
}
|
||||
|
||||
function normalizeFalVideoResponse(payload: unknown) {
|
||||
const record = payload && typeof payload === "object" ? (payload as Record<string, unknown>) : {};
|
||||
const video =
|
||||
record.video && typeof record.video === "object"
|
||||
? (record.video as Record<string, unknown>)
|
||||
: null;
|
||||
const url = video && typeof video.url === "string" ? video.url.trim() : "";
|
||||
|
||||
if (!url) {
|
||||
return {
|
||||
success: false as const,
|
||||
status: 502,
|
||||
error: "Fal video generation returned no video URL",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true as const,
|
||||
data: {
|
||||
created: typeof record.created === "number" ? record.created : Math.floor(Date.now() / 1000),
|
||||
data: [{ url, format: "mp4" }],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function falModelPath(model: string): string {
|
||||
return model.startsWith("xai/") ? model : `fal-ai/${model}`;
|
||||
}
|
||||
|
||||
function getToken(credentials: FalCredentials | null | undefined): string {
|
||||
return String(credentials?.apiKey || credentials?.accessToken || "");
|
||||
}
|
||||
|
||||
function responseError(payload: unknown): string {
|
||||
return sanitizeErrorMessage(JSON.stringify(payload).slice(0, 500));
|
||||
}
|
||||
|
||||
export async function handleFalVideoGeneration({
|
||||
model,
|
||||
provider,
|
||||
providerConfig,
|
||||
body,
|
||||
credentials,
|
||||
log,
|
||||
}: {
|
||||
model: string;
|
||||
provider: string;
|
||||
providerConfig: FalProviderConfig;
|
||||
body: FalVideoBody;
|
||||
credentials: FalCredentials | null | undefined;
|
||||
log?: FalLog | null;
|
||||
}) {
|
||||
const token = getToken(credentials);
|
||||
if (!token) return { success: false as const, status: 401, error: "Fal API key is required" };
|
||||
|
||||
const startTime = Date.now();
|
||||
const timeoutMs = getConfiguredTimeout();
|
||||
const pollIntervalMs = Math.max(100, numberValue(body.poll_interval_ms) || 1000);
|
||||
const baseUrl = providerConfig.baseUrl.replace(/\/$/, "");
|
||||
const queueUrl = `${baseUrl}/${falModelPath(model)}`;
|
||||
const headers = {
|
||||
Authorization: `Key ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
};
|
||||
|
||||
log?.info?.("VIDEO", `${provider}/${model} (fal-ai-video)`, {
|
||||
prompt: stringValue(body.prompt)?.slice(0, 200) || "",
|
||||
});
|
||||
|
||||
try {
|
||||
const createResponse = await fetchWithTimeout(queueUrl, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(buildFalVideoPayload(model, body)),
|
||||
timeoutMs,
|
||||
});
|
||||
const createPayload = await createResponse.json().catch(() => ({}));
|
||||
|
||||
if (!createResponse.ok) {
|
||||
const error = responseError(createPayload);
|
||||
log?.error?.("VIDEO", `Fal create failed (${createResponse.status}): ${error}`);
|
||||
return { success: false as const, status: createResponse.status, error };
|
||||
}
|
||||
|
||||
const requestId = stringValue(createPayload.request_id);
|
||||
if (!requestId) return normalizeFalVideoResponse(createPayload);
|
||||
|
||||
const statusUrl =
|
||||
absoluteFalUrl(createPayload.status_url, baseUrl) ||
|
||||
`${queueUrl}/requests/${requestId}/status`;
|
||||
const responseUrl =
|
||||
absoluteFalUrl(createPayload.response_url, baseUrl) || `${queueUrl}/requests/${requestId}`;
|
||||
const deadline = startTime + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const remainingMs = Math.max(1000, deadline - Date.now());
|
||||
const statusResponse = await fetchWithTimeout(statusUrl, {
|
||||
headers: { Authorization: `Key ${token}` },
|
||||
timeoutMs: Math.min(timeoutMs, remainingMs),
|
||||
});
|
||||
const statusPayload = await statusResponse.json().catch(() => ({}));
|
||||
|
||||
if (!statusResponse.ok) {
|
||||
return {
|
||||
success: false as const,
|
||||
status: statusResponse.status,
|
||||
error: responseError(statusPayload),
|
||||
};
|
||||
}
|
||||
|
||||
const status = stringValue(statusPayload.status);
|
||||
if (status === "COMPLETED") {
|
||||
const resultResponse = await fetchWithTimeout(responseUrl, {
|
||||
headers: { Authorization: `Key ${token}` },
|
||||
timeoutMs: Math.min(timeoutMs, Math.max(1000, deadline - Date.now())),
|
||||
});
|
||||
const resultPayload = await resultResponse.json().catch(() => ({}));
|
||||
if (!resultResponse.ok) {
|
||||
return {
|
||||
success: false as const,
|
||||
status: resultResponse.status,
|
||||
error: responseError(resultPayload),
|
||||
};
|
||||
}
|
||||
|
||||
const result = normalizeFalVideoResponse(resultPayload);
|
||||
saveCallLog({
|
||||
method: "POST",
|
||||
path: "/v1/videos/generations",
|
||||
status: result.success ? 200 : result.status,
|
||||
model: `${provider}/${model}`,
|
||||
provider,
|
||||
duration: Date.now() - startTime,
|
||||
...(result.success ? {} : { error: result.error }),
|
||||
}).catch(() => {});
|
||||
return result;
|
||||
}
|
||||
|
||||
if (status && !["IN_QUEUE", "IN_PROGRESS"].includes(status)) {
|
||||
return {
|
||||
success: false as const,
|
||||
status: 502,
|
||||
error: `Fal video generation ended with status ${status}`,
|
||||
};
|
||||
}
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, Math.min(pollIntervalMs, remainingMs)));
|
||||
}
|
||||
|
||||
return {
|
||||
success: false as const,
|
||||
status: 504,
|
||||
error: `Fal video generation timed out after ${timeoutMs}ms`,
|
||||
};
|
||||
} catch (error: unknown) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
const isTimeout =
|
||||
error instanceof FetchTimeoutError || (error as { name?: string }).name === "AbortError";
|
||||
const status = isTimeout ? 504 : 502;
|
||||
const safeMessage = sanitizeErrorMessage(message);
|
||||
log?.error?.("VIDEO", `Fal request failed: ${safeMessage}`);
|
||||
return { success: false as const, status, error: `Fal video provider error: ${safeMessage}` };
|
||||
}
|
||||
}
|
||||
10
open-sse/services/zaiWebCredentials.ts
Normal file
10
open-sse/services/zaiWebCredentials.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Service boundary for Z.ai web-cookie credential parsing.
|
||||
*
|
||||
* `extractZaiToken` is pure credential parsing, not request execution, but it lives in
|
||||
* `executors/zai-web/protocol.ts` alongside the transport it was written for. App routes
|
||||
* and `src/lib` consumers must not import from `open-sse/executors/**` (G14 import
|
||||
* boundary — see EXECUTOR_IMPORT_RESTRICTION in eslint.config.mjs), so they go through
|
||||
* this service instead of reaching into the executor tree.
|
||||
*/
|
||||
export { extractZaiToken } from "../executors/zai-web/protocol.ts";
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SAFE_OUTBOUND_FETCH_PRESETS, safeOutboundFetch } from "@/shared/network/safeOutboundFetch";
|
||||
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuard";
|
||||
import { getProviderOutboundGuard } from "@/shared/network/outboundUrlGuardPolicy";
|
||||
import { resolveConolCredentials } from "@omniroute/open-sse/services/conolAuth.ts";
|
||||
import {
|
||||
CONOL_FALLBACK_MODELS,
|
||||
|
||||
@@ -23,7 +23,7 @@ import { QWEN_CLOUD_TEXT_MODELS } from "@omniroute/open-sse/config/providers/reg
|
||||
import { filterAlibabaFreeEligibleModels } from "@omniroute/open-sse/services/alibabaFreeTierDiscovery.ts";
|
||||
import { shouldUseLiveAlibabaFreeModelDiscovery } from "@omniroute/open-sse/services/alibabaFreeTier.ts";
|
||||
import { isDashscopeTextModelId } from "@omniroute/open-sse/services/dashscopeTextModels.ts";
|
||||
import { extractZaiToken } from "@omniroute/open-sse/executors/zai-web.ts";
|
||||
import { extractZaiToken } from "@omniroute/open-sse/services/zaiWebCredentials.ts";
|
||||
import { normalizeOpenAiLikeModelsResponse } from "./normalizers";
|
||||
|
||||
const QWEN_CLOUD_TEXT_MODEL_IDS = new Set(QWEN_CLOUD_TEXT_MODELS.map((model) => model.id));
|
||||
|
||||
@@ -118,25 +118,18 @@ export { getCustomVisionCapabilityFields };
|
||||
// lives in ./catalogCache. Re-exported here because the existing tests import the
|
||||
// hooks from this module, and CATALOG_STALE_WHILE_REVALIDATE_MS is part of the
|
||||
// documented behavior of this endpoint.
|
||||
import {
|
||||
CATALOG_CACHE_TTL_MS_DEFAULT,
|
||||
resolveCachedCatalogResponse,
|
||||
type CatalogCachePolicy,
|
||||
} from "./catalogCache";
|
||||
import { CATALOG_CACHE_TTL_MS_DEFAULT, resolveCachedCatalogResponse } from "./catalogCache";
|
||||
|
||||
export {
|
||||
CATALOG_STALE_WHILE_REVALIDATE_MS,
|
||||
getCatalogStaleWhileRevalidateMs,
|
||||
__resetCatalogBuilderRunsForTest,
|
||||
__getCatalogBuilderRunsForTest,
|
||||
__expireCatalogCacheForTest,
|
||||
__setCatalogCacheEntryForTest,
|
||||
__flushCatalogBackgroundRefreshForTest,
|
||||
__forceCatalogInFlightRejectionForTest,
|
||||
__setCatalogStaleWhileRevalidateAccessorForTest,
|
||||
__setCatalogStaleWhileRevalidateMsForTest,
|
||||
} from "./catalogCache";
|
||||
export type { CachedCatalog, CatalogCachePolicy } from "./catalogCache";
|
||||
export type { CachedCatalog } from "./catalogCache";
|
||||
|
||||
const BUILTIN_AUTO_YIELD_INTERVAL = 8;
|
||||
|
||||
@@ -150,8 +143,7 @@ function yieldCatalogBuildTurn(): Promise<void> {
|
||||
*/
|
||||
export async function getUnifiedModelsResponse(
|
||||
request: Request,
|
||||
corsHeaders: Record<string, string> = {},
|
||||
cachePolicy: CatalogCachePolicy = {}
|
||||
corsHeaders: Record<string, string> = {}
|
||||
) {
|
||||
const diagnosticHeaders = getCatalogDiagnosticsHeaders({ request });
|
||||
|
||||
@@ -184,7 +176,6 @@ export async function getUnifiedModelsResponse(
|
||||
request,
|
||||
{ corsHeaders, diagnosticHeaders },
|
||||
buildCatalogPayload,
|
||||
cachePolicy,
|
||||
{
|
||||
hideAutoCombos: settingsForAuth?.hideAutoCombos === true,
|
||||
hideNoThinkVariants: settingsForAuth?.hideNoThinkVariants === true,
|
||||
|
||||
@@ -200,12 +200,10 @@ function scheduleBackgroundRefresh(
|
||||
});
|
||||
}, 0);
|
||||
});
|
||||
inFlight = { version: lastSeenCatalogCacheVersion, promise };
|
||||
|
||||
// Nobody on the stale path awaits this, so pre-handle the rejection; a cold-path
|
||||
// caller that joins it via catalogInFlight attaches its own handler and still
|
||||
// observes the failure.
|
||||
promise.catch(() => {});
|
||||
refreshPromise.catch(() => {});
|
||||
|
||||
catalogInFlight.set(cacheKey, { generation, promise: refreshPromise });
|
||||
refreshPromise
|
||||
|
||||
@@ -139,6 +139,8 @@ export function isProviderModelHidden(
|
||||
return false;
|
||||
}
|
||||
return hiddenModelsByProvider.get(providerId)?.has(modelId) ?? false;
|
||||
}
|
||||
|
||||
/** Matches the provider-page "Test All Models" concurrency (#chunks of 3). */
|
||||
export const PROVIDER_TEST_CHUNK_SIZE = 3;
|
||||
|
||||
|
||||
@@ -25,13 +25,19 @@ const core = await import("@/lib/db/core.ts");
|
||||
const { createCombo } = await import("@/lib/db/combos");
|
||||
const { executeImageCombo } = await import("@omniroute/open-sse/services/imageCombo");
|
||||
|
||||
type LogEntry = { level: string; tag: unknown; msg: unknown };
|
||||
|
||||
function createLog() {
|
||||
const entries: any[] = [];
|
||||
const entries: LogEntry[] = [];
|
||||
const record =
|
||||
(level: string) =>
|
||||
(tag: unknown, msg: unknown): number =>
|
||||
entries.push({ level, tag, msg });
|
||||
return {
|
||||
info: (tag: any, msg: any) => entries.push({ level: "info", tag, msg }),
|
||||
warn: (tag: any, msg: any) => entries.push({ level: "warn", tag, msg }),
|
||||
error: (tag: any, msg: any) => entries.push({ level: "error", tag, msg }),
|
||||
debug: (tag: any, msg: any) => entries.push({ level: "debug", tag, msg }),
|
||||
info: record("info"),
|
||||
warn: record("warn"),
|
||||
error: record("error"),
|
||||
debug: record("debug"),
|
||||
entries,
|
||||
};
|
||||
}
|
||||
@@ -52,13 +58,13 @@ function createMockAuth() {
|
||||
}
|
||||
|
||||
async function cleanupTestDataDir() {
|
||||
let lastError: any;
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error: any) {
|
||||
} catch (error: unknown) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
|
||||
@@ -264,6 +264,8 @@ describe("driverFactory", () => {
|
||||
second.close();
|
||||
first.close();
|
||||
}
|
||||
});
|
||||
|
||||
// Cursor renewal plan, Task 2 Step 5: tryIdeAuth() now passes a
|
||||
// busy-timeout to tryOpenSync() on every driver path, since it's invoked
|
||||
// from an unattended sweep tick (not just the human-attended auto-import
|
||||
|
||||
@@ -1,3 +1,20 @@
|
||||
/**
|
||||
* Stale-while-revalidate for the /v1/models catalog cache (#8728).
|
||||
*
|
||||
* Contract note: #8728 originally shipped an injectable `CatalogCachePolicy`
|
||||
* (a per-call SWR accessor + refresh scheduler) and an unbounded
|
||||
* `CATALOG_STALE_WHILE_REVALIDATE_MS = Number.POSITIVE_INFINITY`. #9199 landed
|
||||
* afterwards and deliberately replaced both: the window is now a fixed 30 s
|
||||
* constant and the refresh is scheduled internally via `setTimeout(…, 0)`.
|
||||
* See catalogCache.ts — an unbounded window let a refresh that kept failing pin
|
||||
* an ancient catalog forever.
|
||||
*
|
||||
* These tests were left asserting the removed API, which is what broke the
|
||||
* production build (catalog.ts re-exported three symbols that no longer exist).
|
||||
* They are realigned here to the shipped contract: the BEHAVIOR #8728 added —
|
||||
* an expired-but-recent successful entry is served immediately while a refresh
|
||||
* runs behind it — is still fully covered.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
@@ -10,8 +27,6 @@ process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
const readCache = await import("../../src/lib/db/readCache.ts");
|
||||
const catalogCache = await import("../../src/app/api/v1/models/catalogCache.ts");
|
||||
|
||||
type RefreshTask = () => Promise<void>;
|
||||
|
||||
function request() {
|
||||
return new Request("http://localhost/v1/models");
|
||||
}
|
||||
@@ -25,28 +40,11 @@ function payload(body: string, status = 200): catalogCache.CatalogPayload {
|
||||
};
|
||||
}
|
||||
|
||||
function createPolicyQueue() {
|
||||
const tasks: RefreshTask[] = [];
|
||||
return {
|
||||
policy: {
|
||||
getStaleWhileRevalidateMs: () => Number.POSITIVE_INFINITY,
|
||||
scheduleBackgroundRefresh: (task: RefreshTask) => {
|
||||
tasks.push(task);
|
||||
},
|
||||
},
|
||||
tasks,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolve(
|
||||
build: (request: Request) => Promise<catalogCache.CatalogPayload>,
|
||||
policy = createPolicyQueue().policy
|
||||
) {
|
||||
async function resolve(build: (request: Request) => Promise<catalogCache.CatalogPayload>) {
|
||||
return catalogCache.resolveCachedCatalogResponse(
|
||||
request(),
|
||||
{ corsHeaders: {}, diagnosticHeaders: {} },
|
||||
build,
|
||||
policy
|
||||
build
|
||||
);
|
||||
}
|
||||
|
||||
@@ -58,146 +56,88 @@ test.after(() => {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("production SWR policy is unbounded and reset restores the default accessor", () => {
|
||||
assert.equal(catalogCache.CATALOG_STALE_WHILE_REVALIDATE_MS, Number.POSITIVE_INFINITY);
|
||||
assert.equal(catalogCache.getCatalogStaleWhileRevalidateMs(), Number.POSITIVE_INFINITY);
|
||||
|
||||
catalogCache.__setCatalogStaleWhileRevalidateAccessorForTest(() => 0);
|
||||
assert.equal(catalogCache.getCatalogStaleWhileRevalidateMs(), 0);
|
||||
|
||||
catalogCache.__resetCatalogBuilderRunsForTest();
|
||||
assert.equal(catalogCache.getCatalogStaleWhileRevalidateMs(), Number.POSITIVE_INFINITY);
|
||||
test("the SWR window is a bounded constant, not an unbounded accessor", () => {
|
||||
// #9199 replaced the injectable POSITIVE_INFINITY accessor with this bound so a
|
||||
// refresh that keeps failing cannot pin an old catalog forever.
|
||||
assert.equal(catalogCache.CATALOG_STALE_WHILE_REVALIDATE_MS, 30_000);
|
||||
assert.ok(Number.isFinite(catalogCache.CATALOG_STALE_WHILE_REVALIDATE_MS));
|
||||
});
|
||||
|
||||
test("reset detaches scheduled work before it can run", async () => {
|
||||
const { policy, tasks } = createPolicyQueue();
|
||||
await resolve(async () => payload("old"), policy);
|
||||
catalogCache.__expireCatalogCacheForTest();
|
||||
await resolve(async () => payload("detached"), policy);
|
||||
assert.equal(tasks.length, 1);
|
||||
|
||||
catalogCache.__resetCatalogBuilderRunsForTest();
|
||||
await tasks[0]();
|
||||
|
||||
assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 0);
|
||||
});
|
||||
|
||||
test("ordinary TTL expiry serves the last success indefinitely and schedules one refresh per key", async () => {
|
||||
const { policy, tasks } = createPolicyQueue();
|
||||
const initial = await resolve(async () => payload("old"), policy);
|
||||
assert.equal(await initial.text(), "old");
|
||||
catalogCache.__expireCatalogCacheForTest(7 * 24 * 60 * 60 * 1000);
|
||||
|
||||
const staleResponses = await Promise.all(
|
||||
Array.from({ length: 5 }, () => resolve(async () => payload("new"), policy))
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
await Promise.all(staleResponses.map((response) => response.text())),
|
||||
Array(5).fill("old")
|
||||
);
|
||||
assert.equal(tasks.length, 1, "concurrent stale reads must schedule exactly one refresh");
|
||||
test("a fresh entry is replayed without running the builder again", async () => {
|
||||
const first = await resolve(async () => payload("fresh"));
|
||||
assert.equal(await first.text(), "fresh");
|
||||
assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 1);
|
||||
|
||||
await tasks[0]();
|
||||
|
||||
const refreshed = await resolve(async () => payload("unexpected"), policy);
|
||||
assert.equal(await refreshed.text(), "new");
|
||||
assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 2);
|
||||
const second = await resolve(async () => payload("SHOULD NOT BUILD"));
|
||||
assert.equal(await second.text(), "fresh");
|
||||
assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 1);
|
||||
});
|
||||
|
||||
test("unsuccessful cold payloads are returned but never cached", async () => {
|
||||
const first = await resolve(async () => payload("temporary failure", 503));
|
||||
assert.equal(first.status, 503);
|
||||
assert.equal(await first.text(), "temporary failure");
|
||||
|
||||
const second = await resolve(async () => payload("recovered"));
|
||||
assert.equal(second.status, 200);
|
||||
assert.equal(await second.text(), "recovered");
|
||||
assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 2);
|
||||
});
|
||||
|
||||
test("failed background refresh retains the prior successful snapshot and permits retry", async (t) => {
|
||||
t.mock.method(console, "error", () => {});
|
||||
const { policy, tasks } = createPolicyQueue();
|
||||
assert.equal(await (await resolve(async () => payload("old"), policy)).text(), "old");
|
||||
test("an expired successful entry is served immediately while a refresh runs behind it", async () => {
|
||||
await resolve(async () => payload("old"));
|
||||
catalogCache.__expireCatalogCacheForTest();
|
||||
|
||||
// The stale body comes back on THIS call — the caller never waits for the rebuild.
|
||||
const stale = await resolve(async () => payload("new"));
|
||||
assert.equal(await stale.text(), "old", "the expired-but-recent entry must be served as-is");
|
||||
|
||||
await catalogCache.__flushCatalogBackgroundRefreshForTest();
|
||||
|
||||
const refreshed = await resolve(async () => payload("SHOULD NOT BUILD"));
|
||||
assert.equal(await refreshed.text(), "new", "the background refresh must replace the snapshot");
|
||||
});
|
||||
|
||||
test("an entry aged past the SWR window is not served stale", async () => {
|
||||
await resolve(async () => payload("ancient"));
|
||||
catalogCache.__expireCatalogCacheForTest(catalogCache.CATALOG_STALE_WHILE_REVALIDATE_MS + 1_000);
|
||||
|
||||
const rebuilt = await resolve(async () => payload("rebuilt"));
|
||||
assert.equal(await rebuilt.text(), "rebuilt", "past the window the caller must wait for a build");
|
||||
});
|
||||
|
||||
test("a cached non-200 is never replayed as stale", async () => {
|
||||
// Replaying a cached error as "stale" would mask an intermittent failure behind
|
||||
// a fake success forever.
|
||||
catalogCache.__setCatalogCacheEntryForTest(request(), {
|
||||
body: "boom",
|
||||
headers: {},
|
||||
status: 500,
|
||||
expiresAt: Date.now() - 1,
|
||||
});
|
||||
|
||||
const res = await resolve(async () => payload("recovered"));
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(await res.text(), "recovered");
|
||||
});
|
||||
|
||||
test("concurrent cold requests share a single builder run", async () => {
|
||||
let release!: () => void;
|
||||
const gate = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
|
||||
const build = async () => {
|
||||
await gate;
|
||||
return payload("coalesced");
|
||||
};
|
||||
|
||||
const inFlight = [resolve(build), resolve(build), resolve(build)];
|
||||
release();
|
||||
const bodies = await Promise.all((await Promise.all(inFlight)).map((r) => r.text()));
|
||||
|
||||
assert.deepEqual(bodies, ["coalesced", "coalesced", "coalesced"]);
|
||||
assert.equal(
|
||||
await (
|
||||
await resolve(async () => {
|
||||
throw new Error("temporary failure");
|
||||
}, policy)
|
||||
).text(),
|
||||
"old"
|
||||
catalogCache.__getCatalogBuilderRunsForTest(),
|
||||
1,
|
||||
"identical concurrent requests must coalesce onto one in-flight build (#6408)"
|
||||
);
|
||||
await tasks.shift()!();
|
||||
|
||||
assert.equal(
|
||||
await (await resolve(async () => payload("temporary failure", 503), policy)).text(),
|
||||
"old"
|
||||
);
|
||||
assert.equal(tasks.length, 1, "a failed refresh must release single-flight state for retry");
|
||||
await tasks.shift()!();
|
||||
|
||||
assert.equal(await (await resolve(async () => payload("new"), policy)).text(), "old");
|
||||
assert.equal(tasks.length, 1, "an unsuccessful payload must also permit another refresh");
|
||||
await tasks.shift()!();
|
||||
|
||||
assert.equal(await (await resolve(async () => payload("unused"), policy)).text(), "new");
|
||||
});
|
||||
|
||||
test("hard invalidation drops snapshots, detaches old work, and guards old-generation writeback", async () => {
|
||||
let resolveOld!: (value: catalogCache.CatalogPayload) => void;
|
||||
const oldPayload = new Promise<catalogCache.CatalogPayload>((resolvePromise) => {
|
||||
resolveOld = resolvePromise;
|
||||
});
|
||||
let currentBuildStarted = false;
|
||||
let resolveCurrent!: (value: catalogCache.CatalogPayload) => void;
|
||||
const currentPayload = new Promise<catalogCache.CatalogPayload>((resolvePromise) => {
|
||||
resolveCurrent = resolvePromise;
|
||||
});
|
||||
test("a state change invalidates the cache so the next read rebuilds", async () => {
|
||||
await resolve(async () => payload("before"));
|
||||
|
||||
const oldRequest = resolve(async () => oldPayload);
|
||||
await Promise.resolve();
|
||||
readCache.invalidateDbCache();
|
||||
|
||||
readCache.invalidateModelCatalogCache();
|
||||
const currentRequest = resolve(async () => {
|
||||
currentBuildStarted = true;
|
||||
return currentPayload;
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
assert.equal(currentBuildStarted, true, "the first post-write read must start a current build");
|
||||
|
||||
resolveCurrent(payload("current"));
|
||||
assert.equal(await (await currentRequest).text(), "current");
|
||||
|
||||
resolveOld(payload("old"));
|
||||
assert.equal(await (await oldRequest).text(), "old");
|
||||
|
||||
const cached = await resolve(async () => payload("unexpected"));
|
||||
assert.equal(await cached.text(), "current", "old completion must not overwrite current cache");
|
||||
assert.equal(catalogCache.__getCatalogBuilderRunsForTest(), 2);
|
||||
});
|
||||
|
||||
test("hard invalidation clears a completed snapshot and makes the next read block", async () => {
|
||||
assert.equal(await (await resolve(async () => payload("old"))).text(), "old");
|
||||
readCache.invalidateModelCatalogCache();
|
||||
|
||||
let resolveCurrent!: (value: catalogCache.CatalogPayload) => void;
|
||||
const currentPayload = new Promise<catalogCache.CatalogPayload>((resolvePromise) => {
|
||||
resolveCurrent = resolvePromise;
|
||||
});
|
||||
let settled = false;
|
||||
const next = resolve(async () => currentPayload).then((response) => {
|
||||
settled = true;
|
||||
return response;
|
||||
});
|
||||
|
||||
await Promise.resolve();
|
||||
assert.equal(settled, false, "post-write reads may block and must not serve the old snapshot");
|
||||
|
||||
resolveCurrent(payload("current"));
|
||||
assert.equal(await (await next).text(), "current");
|
||||
const after = await resolve(async () => payload("after"));
|
||||
assert.equal(await after.text(), "after", "a write must be reflected on the very next read");
|
||||
});
|
||||
|
||||
142
tests/unit/production-build-module-integrity.test.ts
Normal file
142
tests/unit/production-build-module-integrity.test.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
/**
|
||||
* Regression guard for the broken Turbopack production build on release/v3.8.50.
|
||||
*
|
||||
* Six independent module-level defects, each from a different PR, made `npm run build`
|
||||
* fail. Most are *link-time* errors, so those cases are expressed as "this module must be
|
||||
* importable / statically resolvable" rather than as behavioral assertions:
|
||||
*
|
||||
* 1. src/shared/components/modelSelectModalHelpers.ts — a lost `}` (#9011) left
|
||||
* `export const PROVIDER_TEST_CHUNK_SIZE` inside a function body.
|
||||
* 2. open-sse/handlers/videoGeneration.ts — `handleFalVideoGeneration` imported
|
||||
* twice from two different modules (#9982 landed on top of #9969).
|
||||
* 3. src/app/api/v1/models/catalog.ts — re-exported three SWR symbols that #9199
|
||||
* deliberately removed from ./catalogCache when it replaced #8728's injectable
|
||||
* policy with a fixed 30 s bound. The consumer was never updated.
|
||||
* 4. src/app/api/v1/models/catalogCache.ts — the same PR left two dangling statements
|
||||
* referencing undeclared `inFlight` / `promise`, so every stale-while-revalidate
|
||||
* read threw a ReferenceError at RUNTIME (this one the build never caught).
|
||||
* 5. open-sse/executors/tinycmsSigner.ts — generated wasm-bindgen glue kept a
|
||||
* `new URL('wasm_signer_bg.wasm', import.meta.url)` default that no file backs.
|
||||
* 6. src/app/api/providers/[id]/models/conolDiscovery.ts — imported
|
||||
* `getProviderOutboundGuard` from ./outboundUrlGuard, which does not export it (#8974).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
test("modelSelectModalHelpers exports survive isProviderModelHidden (#9011 missing brace)", async () => {
|
||||
const helpers = await import("@/shared/components/modelSelectModalHelpers");
|
||||
|
||||
// The missing `}` swallowed everything after isProviderModelHidden into its body,
|
||||
// so this constant stopped being a module-level export.
|
||||
assert.equal(helpers.PROVIDER_TEST_CHUNK_SIZE, 3);
|
||||
|
||||
const hidden = helpers.parseHiddenModelsByProvider({ openai: ["gpt-4o"] });
|
||||
assert.equal(helpers.isProviderModelHidden(hidden, "openai", "gpt-4o"), true);
|
||||
assert.equal(helpers.isProviderModelHidden(hidden, "openai", "gpt-4o-mini"), false);
|
||||
assert.equal(helpers.isProviderModelHidden(hidden, "anthropic", "gpt-4o"), false);
|
||||
});
|
||||
|
||||
test("videoGeneration handler links with a single handleFalVideoGeneration binding (#9982)", async () => {
|
||||
// A duplicate import binding is an ESM early SyntaxError, so the import itself is
|
||||
// the assertion. The Fal video path must resolve to the provider-neutral module
|
||||
// that #9982 added (it also covers the #9969 Grok Imagine routing).
|
||||
const mod = await import("@omniroute/open-sse/handlers/videoGeneration.ts");
|
||||
assert.equal(typeof mod.handleVideoGeneration, "function");
|
||||
|
||||
const source = readFileSync(path.join(repoRoot, "open-sse/handlers/videoGeneration.ts"), "utf8");
|
||||
const falImports = source.match(/import \{ handleFalVideoGeneration \}/g) ?? [];
|
||||
assert.equal(falImports.length, 1, "handleFalVideoGeneration must be imported exactly once");
|
||||
assert.match(source, /handleFalVideoGeneration \} from "\.\/mediaGeneration\/fal\.ts"/);
|
||||
});
|
||||
|
||||
test("catalog.ts re-exports only what catalogCache actually exports (#9199)", async () => {
|
||||
// #9199 deliberately replaced #8728's injectable SWR policy with a fixed bound, but
|
||||
// left catalog.ts re-exporting the three removed symbols. Re-exporting a binding the
|
||||
// source module does not export is a link-time error, so this import IS the assertion.
|
||||
const catalog = await import("@/app/api/v1/models/catalog");
|
||||
const catalogCache = await import("@/app/api/v1/models/catalogCache");
|
||||
|
||||
assert.equal(catalogCache.CATALOG_STALE_WHILE_REVALIDATE_MS, 30_000);
|
||||
assert.equal(catalog.CATALOG_STALE_WHILE_REVALIDATE_MS, 30_000);
|
||||
|
||||
// The accessor/policy trio must stay gone — re-adding it would resurrect the
|
||||
// unbounded window #9199 removed (a failing refresh could pin an old catalog forever).
|
||||
for (const removed of [
|
||||
"getCatalogStaleWhileRevalidateMs",
|
||||
"__setCatalogStaleWhileRevalidateAccessorForTest",
|
||||
"__setCatalogStaleWhileRevalidateMsForTest",
|
||||
]) {
|
||||
assert.equal(
|
||||
(catalogCache as Record<string, unknown>)[removed],
|
||||
undefined,
|
||||
`${removed} was removed by #9199 and must not come back`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("the stale-while-revalidate path does not throw a ReferenceError (#9199 merge residue)", async () => {
|
||||
// The mangled merge left `inFlight = { … promise }` referencing two undeclared
|
||||
// identifiers inside scheduleBackgroundRefresh, so EVERY stale read crashed at runtime.
|
||||
const catalogCache = await import("@/app/api/v1/models/catalogCache");
|
||||
catalogCache.__resetCatalogBuilderRunsForTest();
|
||||
|
||||
const payload = (body: string) => ({
|
||||
body,
|
||||
headers: { "content-type": "application/json" },
|
||||
status: 200,
|
||||
cacheTTL: 60_000,
|
||||
});
|
||||
const call = (body: string) =>
|
||||
catalogCache.resolveCachedCatalogResponse(
|
||||
new Request("http://localhost/v1/models"),
|
||||
{ corsHeaders: {}, diagnosticHeaders: {} },
|
||||
async () => payload(body)
|
||||
);
|
||||
|
||||
await call("first");
|
||||
catalogCache.__expireCatalogCacheForTest();
|
||||
|
||||
const stale = await call("second");
|
||||
assert.equal(await stale.text(), "first", "the stale body must be served, not a crash");
|
||||
await catalogCache.__flushCatalogBackgroundRefreshForTest();
|
||||
});
|
||||
|
||||
test("conolDiscovery resolves getProviderOutboundGuard from the policy module (#8974)", async () => {
|
||||
// outboundUrlGuard.ts does NOT export getProviderOutboundGuard — importing it from
|
||||
// there is a link-time error, so the import below is the assertion.
|
||||
const mod = await import("@/app/api/providers/[id]/models/conolDiscovery");
|
||||
assert.equal(typeof mod.maybeHandleConolModelDiscovery, "function");
|
||||
|
||||
// The fix must stay on the consumer side. outboundUrlGuard.ts is loaded by the packaged
|
||||
// CLI, where no tsconfig resolves `@/*`, so re-exporting the policy helpers from it
|
||||
// (they pull in featureFlags → the DB layer) would break `omniroute setup-opencode`
|
||||
// (#7682). Guard that nobody "fixes" this by adding the re-export instead.
|
||||
const guardSource = readFileSync(
|
||||
path.join(repoRoot, "src/shared/network/outboundUrlGuard.ts"),
|
||||
"utf8"
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
guardSource,
|
||||
/^\s*(import|export)[^\n]*from\s+["']@\//m,
|
||||
"outboundUrlGuard.ts must stay free of @/-aliased imports/re-exports (#7682)"
|
||||
);
|
||||
});
|
||||
|
||||
test("tinycmsSigner has no build-time-resolvable wasm sidecar URL (#8736/#10087)", () => {
|
||||
const source = readFileSync(path.join(repoRoot, "open-sse/executors/tinycmsSigner.ts"), "utf8");
|
||||
|
||||
// The wasm binary ships inlined as WASM_BASE64; there is no wasm_signer_bg.wasm file.
|
||||
// Turbopack statically resolves `new URL(<literal>, import.meta.url)`, so leaving the
|
||||
// generated default in place fails `next build` with "Module not found".
|
||||
assert.ok(source.includes("const WASM_BASE64 ="), "wasm binary must stay inlined");
|
||||
assert.doesNotMatch(
|
||||
source,
|
||||
/new URL\(\s*['"]wasm_signer_bg\.wasm['"]/,
|
||||
"generated wasm-bindgen sidecar URL must not survive — Turbopack resolves it at build time"
|
||||
);
|
||||
});
|
||||
@@ -134,8 +134,11 @@ await test("chave quota-exclusive não constrói o catálogo completo", async (t
|
||||
process.env.EXPOSE_CC_DISCOVERY_ALIASES = "1";
|
||||
// A chave do cache é `prefix|isCodex|apiKey|configuredOnly` — um query param
|
||||
// qualquer NÃO a invalida, então a resposta do subteste anterior seria servida.
|
||||
v1ModelsCatalog.__setCatalogStaleWhileRevalidateMsForTest(0);
|
||||
v1ModelsCatalog.__expireCatalogCacheForTest(1);
|
||||
// #9199 removed the injectable SWR window; age the entry past the fixed
|
||||
// 30 s constant instead so the next read rebuilds rather than serving stale.
|
||||
v1ModelsCatalog.__expireCatalogCacheForTest(
|
||||
v1ModelsCatalog.CATALOG_STALE_WHILE_REVALIDATE_MS + 1_000
|
||||
);
|
||||
try {
|
||||
const res = await v1ModelsCatalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models", {
|
||||
@@ -151,9 +154,6 @@ await test("chave quota-exclusive não constrói o catálogo completo", async (t
|
||||
`ids=${JSON.stringify(body.data.map((m) => m.id).slice(0, 6))}`
|
||||
);
|
||||
} finally {
|
||||
v1ModelsCatalog.__setCatalogStaleWhileRevalidateMsForTest(
|
||||
v1ModelsCatalog.CATALOG_STALE_WHILE_REVALIDATE_MS
|
||||
);
|
||||
if (prev === undefined) delete process.env.EXPOSE_CC_DISCOVERY_ALIASES;
|
||||
else process.env.EXPOSE_CC_DISCOVERY_ALIASES = prev;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user