mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-09 00:32:13 +03:00
Compare commits
9 Commits
fix/9626-p
...
release/v3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
00b79b5507 | ||
|
|
b5e17bdbde | ||
|
|
aefa2b665b | ||
|
|
df1ea5bd77 | ||
|
|
a90c5e5aba | ||
|
|
c88b96244f | ||
|
|
93ee4dce9f | ||
|
|
6c95e2b354 | ||
|
|
3835f318d0 |
@@ -52,8 +52,11 @@ export class ServerSupervisor {
|
||||
// silently, so a boot that never becomes ready looked like a dead hang with zero
|
||||
// output even at APP_LOG_LEVEL=debug. Pipe stdout too and buffer it alongside
|
||||
// stderr so a readiness timeout can surface what the child actually printed.
|
||||
// #9156: macOS launchd cannot resolve bare "node" because its PATH is
|
||||
// minimal. Always use process.execPath (the absolute path to the running
|
||||
// Node.js binary) so the supervisor never depends on PATH resolution.
|
||||
this.child = spawn(
|
||||
process.versions.bun ? process.execPath : "node",
|
||||
process.execPath,
|
||||
process.versions.bun
|
||||
? [this.serverPath]
|
||||
: buildNodeRuntimeArgs(process.env, this.memoryLimit, this.serverPath),
|
||||
|
||||
1
changelog.d/fixes/7754-best-free-fallback.md
Normal file
1
changelog.d/fixes/7754-best-free-fallback.md
Normal file
@@ -0,0 +1 @@
|
||||
- test(combo): guard auto/best-free never leaks the combo name as a model (#7754)
|
||||
1
changelog.d/fixes/8847-bun-prebuilds.md
Normal file
1
changelog.d/fixes/8847-bun-prebuilds.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(build): include better-sqlite3 prebuilds in standalone bun bundle
|
||||
1
changelog.d/fixes/9156-macos-autostart-execpath.md
Normal file
1
changelog.d/fixes/9156-macos-autostart-execpath.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(cli): use process.execPath for macOS launchd autostart
|
||||
1
changelog.d/fixes/9486-claude-400-quota.md
Normal file
1
changelog.d/fixes/9486-claude-400-quota.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(providers): classify 400 out of extra usage as quota_exhausted for Anthropic OAuth
|
||||
1
changelog.d/fixes/9623-connection-test-recovery.md
Normal file
1
changelog.d/fixes/9623-connection-test-recovery.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(resilience): failed connection test now sets a short cooldown so connections recover after transient outages (#9623)
|
||||
1
changelog.d/fixes/9624-telemetry-cleanup-wiring.md
Normal file
1
changelog.d/fixes/9624-telemetry-cleanup-wiring.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): wire telemetry cleanup scheduler in Next.js startup path (#9624)
|
||||
1
changelog.d/fixes/9625-domain-cost-ms.md
Normal file
1
changelog.d/fixes/9625-domain-cost-ms.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(db): align domain_cost_history cleanup cutoff with millisecond column (#9625)
|
||||
1
changelog.d/fixes/9633-npm-build-files.md
Normal file
1
changelog.d/fixes/9633-npm-build-files.md
Normal file
@@ -0,0 +1 @@
|
||||
- fix(build): add build-next-isolated.mjs sibling imports to package.json files array
|
||||
@@ -149,6 +149,18 @@ export const ERROR_RULES: ErrorRule[] = [
|
||||
backoff: true,
|
||||
reason: "quota_exhausted",
|
||||
},
|
||||
{
|
||||
id: "out_of_extra_usage",
|
||||
text: "out of extra usage",
|
||||
backoff: true,
|
||||
reason: "quota_exhausted",
|
||||
},
|
||||
{
|
||||
id: "extra_usage_required",
|
||||
text: "extra usage required",
|
||||
backoff: true,
|
||||
reason: "quota_exhausted",
|
||||
},
|
||||
{ id: "capacity", text: "capacity", backoff: true, reason: "model_capacity" },
|
||||
{ id: "overloaded", text: "overloaded", backoff: true, reason: "model_capacity" },
|
||||
{ id: "high_demand", text: "high demand", backoff: true, reason: "model_capacity" },
|
||||
|
||||
@@ -34,8 +34,11 @@
|
||||
"scripts/dev/tls-options.mjs",
|
||||
"scripts/check/check-supported-node-runtime.ts",
|
||||
"scripts/dev/sync-env.mjs",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
"scripts/build/assembleStandalone.mjs",
|
||||
"scripts/build/backendOnlyPages.mjs",
|
||||
"scripts/build/build-next-isolated.mjs",
|
||||
"scripts/build/build-tproxy-native.mjs",
|
||||
"scripts/build/native-binary-compat.mjs",
|
||||
"scripts/build/runtime-env.mjs",
|
||||
"README.md",
|
||||
"LICENSE",
|
||||
|
||||
@@ -89,6 +89,15 @@ const NATIVE_ASSET_ENTRIES = [
|
||||
src: ["node_modules", "better-sqlite3", "build"],
|
||||
dest: ["node_modules", "better-sqlite3", "build"],
|
||||
},
|
||||
{
|
||||
// #8847: Bun (and npx -g global installs) resolve better-sqlite3's native
|
||||
// binary from prebuilds/ instead of build/Release/, so the compiled build/
|
||||
// copy alone leaves a hollow package that falls back to sql.js (OOM under
|
||||
// Bun). Ship the prebuilds alongside the compiled binary.
|
||||
label: "better-sqlite3 prebuilds (Bun / global installs)",
|
||||
src: ["node_modules", "better-sqlite3", "prebuilds"],
|
||||
dest: ["node_modules", "better-sqlite3", "prebuilds"],
|
||||
},
|
||||
{
|
||||
// TPROXY IP_TRANSPARENT addon (Fase 3 / Epic A). Built by build-tproxy-native
|
||||
// before assembly; Linux-only + opt-in, so the source is absent on non-Linux
|
||||
|
||||
@@ -701,6 +701,17 @@ export async function testSingleConnection(connectionId: string, validationModel
|
||||
? makeDiagnosis("ok", "local", null, null)
|
||||
: classifyFailure({ error: result.error, statusCode: result.statusCode, provider }));
|
||||
|
||||
// #9623: a failed connection test must not paint the connection permanently red.
|
||||
// Previously a non-terminal failure wrote `testStatus: "error"` with
|
||||
// `rateLimitedUntil: null` — since the cooldown filter only ever skips entries
|
||||
// whose rateLimitedUntil is in the future, a null cooldown left the connection
|
||||
// permanently unavailable after a transient outage. Give non-terminal test
|
||||
// failures a short cooldown so the lazy-recovery path retries them.
|
||||
const terminalTestStatuses = new Set(["banned", "expired", "credits_exhausted"]);
|
||||
const isTerminalFailure =
|
||||
!result.valid && terminalTestStatuses.has(String(diagnosis.code ?? diagnosis.type ?? "").toLowerCase());
|
||||
const testFailureCooldownMs = result.valid ? 0 : 30_000; // 30s retry window
|
||||
|
||||
const updateData: Record<string, any> = {
|
||||
testStatus: result.valid ? "active" : "error",
|
||||
lastError: result.valid ? null : result.error,
|
||||
@@ -709,7 +720,12 @@ export async function testSingleConnection(connectionId: string, validationModel
|
||||
lastErrorType: result.valid ? null : diagnosis.type,
|
||||
lastErrorSource: result.valid ? null : diagnosis.source,
|
||||
errorCode: result.valid ? null : diagnosis.code || result.statusCode || null,
|
||||
rateLimitedUntil: result.valid ? null : connection.rateLimitedUntil || null,
|
||||
rateLimitedUntil:
|
||||
result.valid || isTerminalFailure
|
||||
? result.valid
|
||||
? null
|
||||
: connection.rateLimitedUntil || null
|
||||
: new Date(Date.now() + testFailureCooldownMs).toISOString(),
|
||||
};
|
||||
|
||||
if (result.valid) {
|
||||
|
||||
@@ -306,6 +306,7 @@ export async function registerNodejs(): Promise<void> {
|
||||
{ applyRuntimeSettings },
|
||||
{ startRuntimeConfigHotReload },
|
||||
{ startSpendBatchWriter },
|
||||
{ startCleanupScheduler },
|
||||
{ registerDefaultGuardrails },
|
||||
{ ensurePersistentManagementPasswordHash },
|
||||
{ skillExecutor },
|
||||
@@ -320,6 +321,7 @@ export async function registerNodejs(): Promise<void> {
|
||||
import("@/lib/config/runtimeSettings"),
|
||||
import("@/lib/config/hotReload"),
|
||||
import("@/lib/spend/batchWriter"),
|
||||
import("@/lib/db/cleanup"),
|
||||
import("@/lib/guardrails"),
|
||||
import("@/lib/auth/managementPassword"),
|
||||
import("@/lib/skills/executor"),
|
||||
@@ -489,6 +491,17 @@ export async function registerNodejs(): Promise<void> {
|
||||
console.warn("[STARTUP] Could not initialize vacuum scheduler (non-fatal):", msg);
|
||||
}
|
||||
|
||||
// Retention cleanup scheduler (#4691/#6988, #9624): runs the general retention
|
||||
// cleanup once after startup and then every 6 hours. Previously this was only
|
||||
// wired into the unused src/server-init.ts, so telemetry tables grew unboundedly
|
||||
// even with retention.autoCleanupEnabled=true. Idempotent (guarded internally).
|
||||
try {
|
||||
startCleanupScheduler();
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.warn("[STARTUP] Could not start cleanup scheduler (non-fatal):", msg);
|
||||
}
|
||||
|
||||
// Warm the model catalog's durable, apiKey-independent sub-caches at
|
||||
// startup — see warmModelCatalogCache() for why the top-level Response
|
||||
// cache alone doesn't deliver this. Fire-and-forget, non-fatal.
|
||||
|
||||
@@ -253,14 +253,15 @@ export async function cleanupMemoryEntries(): Promise<CleanupResult> {
|
||||
|
||||
/**
|
||||
* Clean up old domain_cost_history based on retention settings. (#6848)
|
||||
* Uses unix-epoch `timestamp` column (INTEGER).
|
||||
* The `timestamp` column stores epoch milliseconds (saveCostEntry default
|
||||
* is Date.now()), so the cutoff must be in milliseconds to match. (#9625)
|
||||
*/
|
||||
export async function cleanupDomainCostHistory(): Promise<CleanupResult> {
|
||||
const db = getDbInstance();
|
||||
const retention = getRetentionSettings();
|
||||
|
||||
const retentionDays = retention.domainCostHistory;
|
||||
const cutoffEpoch = Math.floor(Date.now() / 1000) - retentionDays * 86_400;
|
||||
const cutoffEpoch = Date.now() - retentionDays * 86_400_000;
|
||||
|
||||
const result: CleanupResult = { deleted: 0, errors: 0 };
|
||||
|
||||
|
||||
57
tests/unit/repro-7754.test.ts
Normal file
57
tests/unit/repro-7754.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createBuiltinAutoCombo } from "@omniroute/open-sse/services/autoCombo/builtinCatalog.ts";
|
||||
|
||||
// #7754: `auto/best-free` combo name must never leak downstream as the model id.
|
||||
// When the free-tier candidate pool resolves non-empty, every model in the combo
|
||||
// must carry a concrete `<provider>/<model>` id — never the literal combo name.
|
||||
|
||||
test("#7754 auto/best-free never leaks the combo name as a model", async () => {
|
||||
const combo = await createBuiltinAutoCombo("auto/best-free", "best-free");
|
||||
const models = combo.models || [];
|
||||
|
||||
// The combo id is the modelStr by design (routing resolves it back), but the
|
||||
// models array must never contain it as a target model.
|
||||
const leak = models.filter(
|
||||
(m: any) =>
|
||||
(m.id || "") === "auto/best-free" ||
|
||||
(m.model || "") === "auto/best-free" ||
|
||||
(m.modelStr || "") === "auto/best-free"
|
||||
);
|
||||
assert.equal(
|
||||
leak.length,
|
||||
0,
|
||||
`combo name leaked as a target model: ${JSON.stringify(leak)}`
|
||||
);
|
||||
});
|
||||
|
||||
test("#7754 every auto/best-free model carries a concrete provider/model", async () => {
|
||||
const combo = await createBuiltinAutoCombo("auto/best-free", "best-free");
|
||||
const models = combo.models || [];
|
||||
for (const m of models as any[]) {
|
||||
assert.ok(
|
||||
m.model && m.model !== "auto/best-free",
|
||||
`model missing concrete id: ${JSON.stringify(m)}`
|
||||
);
|
||||
assert.ok(
|
||||
m.providerId && m.providerId !== "auto",
|
||||
`model missing concrete provider: ${JSON.stringify(m)}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("#7754 empty free-tier pool degrades with a clear 503, not a name leak", async () => {
|
||||
// When NO free-tier candidate exists, createBuiltinAutoCombo must either
|
||||
// return an empty models[] (which the #6458 route check converts to a clear
|
||||
// 503) or throw — never synthesize a target whose model is the combo name.
|
||||
const combo = await createBuiltinAutoCombo("auto/best-free", "best-free");
|
||||
const models = combo.models || [];
|
||||
if (models.length === 0) {
|
||||
// Empty pool is fine — the route layer (#6458) converts it to a clear 503.
|
||||
assert.equal(combo.candidatePool?.length || 0, 0);
|
||||
} else {
|
||||
// Non-empty pool must not leak.
|
||||
const leak = models.filter((m: any) => (m.model || "") === "auto/best-free");
|
||||
assert.equal(leak.length, 0);
|
||||
}
|
||||
});
|
||||
70
tests/unit/repro-8847.test.ts
Normal file
70
tests/unit/repro-8847.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { syncStandaloneNativeAssets } from "../../scripts/build/assembleStandalone.mjs";
|
||||
|
||||
/**
|
||||
* Repro #8847: better-sqlite3 prebuilds are not included in the standalone
|
||||
* bundle, so the bundled app fails when the platform's prebuild is needed
|
||||
* (e.g. under Bun, which resolves the native binary via prebuilds/ rather
|
||||
* than build/Release/).
|
||||
*
|
||||
* The test creates a synthetic node_modules/better-sqlite3/ tree with both
|
||||
* the compiled build/Release/ binary AND the prebuilds/ directory, then
|
||||
* confirms that syncStandaloneNativeAssets copies both into the standalone
|
||||
* output. On the unfixed code this fails because NATIVE_ASSET_ENTRIES only
|
||||
* lists better-sqlite3/build/.
|
||||
*/
|
||||
test("repro-8847: better-sqlite3 prebuilds are bundled alongside the compiled binary", async () => {
|
||||
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "repro-8847-"));
|
||||
const projectRoot = path.join(tmp, "src-root");
|
||||
|
||||
// Seed better-sqlite3 with both build/Release/ and prebuilds/.
|
||||
const bsqlDir = path.join(projectRoot, "node_modules", "better-sqlite3");
|
||||
fs.mkdirSync(path.join(bsqlDir, "build", "Release"), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(bsqlDir, "build", "Release", "better_sqlite3.node"),
|
||||
"// native binary placeholder"
|
||||
);
|
||||
fs.mkdirSync(path.join(bsqlDir, "prebuilds"), { recursive: true });
|
||||
for (const target of [
|
||||
"darwin-arm64.node",
|
||||
"darwin-x64.node",
|
||||
"linux-arm64.node",
|
||||
"linux-x64.node",
|
||||
"linuxmusl-arm64.node",
|
||||
"linuxmusl-x64.node",
|
||||
"win32-arm64.node",
|
||||
"win32-x64.node",
|
||||
]) {
|
||||
fs.writeFileSync(path.join(bsqlDir, "prebuilds", target), `// ${target}`);
|
||||
}
|
||||
|
||||
const outDir = path.join(tmp, "standalone");
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
// Act: copy native assets into the standalone output.
|
||||
await syncStandaloneNativeAssets(projectRoot, fs.promises, { log() {} }, outDir);
|
||||
|
||||
// Assert: the compiled build/Release/ binary was copied.
|
||||
assert.ok(
|
||||
fs.existsSync(
|
||||
path.join(outDir, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node")
|
||||
),
|
||||
"compiled native binary (build/Release/) must be in the standalone bundle"
|
||||
);
|
||||
|
||||
// Assert: the prebuilds/ directory was also copied.
|
||||
const prebuildsDir = path.join(outDir, "node_modules", "better-sqlite3", "prebuilds");
|
||||
assert.ok(fs.existsSync(prebuildsDir), "prebuilds/ directory must be in the standalone bundle");
|
||||
|
||||
// Assert: at least one prebuild file was copied.
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(prebuildsDir, "linux-x64.node")),
|
||||
"linux-x64 prebuild must be in the standalone bundle"
|
||||
);
|
||||
|
||||
fs.rmSync(tmp, { recursive: true, force: true });
|
||||
});
|
||||
111
tests/unit/repro-9156.test.ts
Normal file
111
tests/unit/repro-9156.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
// #9156: macOS launchd autostart fails because the supervisor spawns the child
|
||||
// with bare "node", but launchd's PATH cannot resolve it. process.execPath is
|
||||
// always the absolute path to the running Node.js binary and is always resolvable.
|
||||
//
|
||||
// We verify the fix via:
|
||||
// 1. Static source analysis — the spawn() call must use process.execPath
|
||||
// unconditionally (no fallback to bare "node"). This runs without any
|
||||
// experimental flags so it serves as the permanent regression guard.
|
||||
// 2. Runtime test via mock.module (requires --experimental-test-module-mocks)
|
||||
// that captures the actual spawn arguments.
|
||||
|
||||
const __filename = new URL(import.meta.url).pathname;
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const SUPERVISOR_PATH = path.resolve(
|
||||
__dirname,
|
||||
"../../bin/cli/runtime/processSupervisor.mjs"
|
||||
);
|
||||
const supervisorSrc = fs.readFileSync(SUPERVISOR_PATH, "utf8");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 1. Source-level verification (no experimental flag required)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("spawn() uses process.execPath unconditionally, no bare 'node' fallback (#9156)", () => {
|
||||
// Must NOT contain the old conditional that falls back to bare "node"
|
||||
assert.ok(
|
||||
!supervisorSrc.includes('process.versions.bun ? process.execPath : "node"'),
|
||||
"must NOT have a conditional fallback to bare 'node'"
|
||||
);
|
||||
|
||||
// Must use process.execPath as the first argument to spawn()
|
||||
const execPathPattern = /spawn\(\s*process\.execPath\s*,/;
|
||||
assert.ok(
|
||||
execPathPattern.test(supervisorSrc),
|
||||
"spawn() must receive process.execPath as first argument"
|
||||
);
|
||||
});
|
||||
|
||||
test("process.execPath is an absolute path to the running Node.js binary", () => {
|
||||
assert.ok(
|
||||
path.isAbsolute(process.execPath),
|
||||
`process.execPath must be absolute, got: ${process.execPath}`
|
||||
);
|
||||
assert.ok(
|
||||
fs.existsSync(process.execPath),
|
||||
`process.execPath must exist: ${process.execPath}`
|
||||
);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 2. Runtime test via mock.module (requires --experimental-test-module-mocks)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Run manually: node --experimental-test-module-mocks --import tsx/esm --test tests/unit/repro-9156.test.ts
|
||||
|
||||
import { mock } from "node:test";
|
||||
|
||||
if (typeof mock.module === "function") {
|
||||
test("(runtime) ServerSupervisor.start() spawns with process.execPath (#9156)", async () => {
|
||||
let spawnExecutable: string | undefined;
|
||||
const { EventEmitter } = await import("node:events");
|
||||
|
||||
const mockChild = Object.assign(new EventEmitter(), {
|
||||
pid: 12345,
|
||||
stdout: null,
|
||||
stderr: null,
|
||||
kill: () => {},
|
||||
});
|
||||
|
||||
mock.module("node:child_process", {
|
||||
exports: {
|
||||
spawn: (...args: unknown[]) => {
|
||||
spawnExecutable = args[0] as string;
|
||||
return mockChild;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
process.env.PORT = "0";
|
||||
|
||||
const { ServerSupervisor } = await import(
|
||||
"../../bin/cli/runtime/processSupervisor.mjs"
|
||||
);
|
||||
|
||||
const supervisor = new ServerSupervisor({
|
||||
serverPath: "/fake/server.js",
|
||||
env: {},
|
||||
maxRestarts: 0,
|
||||
});
|
||||
|
||||
spawnExecutable = undefined;
|
||||
supervisor.start();
|
||||
|
||||
assert.ok(spawnExecutable, "spawn() must have been called");
|
||||
assert.equal(
|
||||
spawnExecutable,
|
||||
process.execPath,
|
||||
`expected process.execPath, got: ${spawnExecutable}`
|
||||
);
|
||||
assert.notEqual(spawnExecutable, "node", "must not be bare 'node'");
|
||||
|
||||
mockChild.removeAllListeners();
|
||||
delete process.env.PORT;
|
||||
});
|
||||
}
|
||||
71
tests/unit/repro-9486.test.ts
Normal file
71
tests/unit/repro-9486.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Issue #9486 — Anthropic OAuth returns HTTP 400 with "out of extra usage" in
|
||||
* the error body when a tool-carrying request exceeds the account's usage quota.
|
||||
* This should be classified as quota_exhausted (not generic bad_request), so the
|
||||
* account fallback mechanism applies a proper cooldown and combo routing can
|
||||
* skip to another target.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { matchErrorRuleByText, findMatchingErrorRule, ERROR_RULES } =
|
||||
await import("../../open-sse/config/errorConfig.ts");
|
||||
const { checkFallbackError, classifyErrorText } =
|
||||
await import("../../open-sse/services/accountFallback.ts");
|
||||
const { RateLimitReason } = await import("../../open-sse/config/constants.ts");
|
||||
|
||||
test("#9486 ERROR_RULES has a text rule for 'out of extra usage' → quota_exhausted", () => {
|
||||
const rule = ERROR_RULES.find((r) => r.text === "out of extra usage");
|
||||
assert.ok(rule, "expected a rule for 'out of extra usage'");
|
||||
assert.equal(rule!.reason, "quota_exhausted");
|
||||
// Should use backoff so the fallback path applies exponential scaling
|
||||
assert.equal(rule!.backoff, true);
|
||||
});
|
||||
|
||||
test("#9486 matchErrorRuleByText finds 'out of extra usage' rule", () => {
|
||||
const rule = matchErrorRuleByText("out of extra usage");
|
||||
assert.ok(rule, "expected a matching rule");
|
||||
assert.equal(rule!.reason, "quota_exhausted");
|
||||
});
|
||||
|
||||
test("#9486 matchErrorRuleByText finds rule in a longer error message", () => {
|
||||
const rule = matchErrorRuleByText(
|
||||
"Error: 400 - out of extra usage. You have exceeded your usage quota for this billing period."
|
||||
);
|
||||
assert.ok(rule, "expected a matching rule from longer message");
|
||||
assert.equal(rule!.reason, "quota_exhausted");
|
||||
});
|
||||
|
||||
test("#9486 findMatchingErrorRule with 400 + 'out of extra usage' returns quota_exhausted", () => {
|
||||
const rule = findMatchingErrorRule(400, "out of extra usage");
|
||||
assert.ok(rule, "expected a matching rule");
|
||||
assert.equal(rule!.reason, "quota_exhausted");
|
||||
});
|
||||
|
||||
test("#9486 checkFallbackError returns quota_exhausted for 400 + 'out of extra usage'", () => {
|
||||
const out = checkFallbackError(400, "out of extra usage", 0, null, "claude");
|
||||
assert.equal(out.shouldFallback, true);
|
||||
assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
// Should get a non-zero cooldown (quota exhaustion is not transient)
|
||||
assert.ok(out.cooldownMs > 0, `expected positive cooldown, got ${out.cooldownMs}ms`);
|
||||
});
|
||||
|
||||
test("#9486 checkFallbackError handles 'Extra usage required' (same class)", () => {
|
||||
// Anthropic sometimes returns "Extra usage required" instead of "out of extra usage"
|
||||
const out = checkFallbackError(400, "Extra usage required", 0, null, "claude");
|
||||
assert.equal(out.shouldFallback, true);
|
||||
assert.equal(out.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
});
|
||||
|
||||
test("#9486 classifyErrorText flags 'out of extra usage' as QUOTA_EXHAUSTED", () => {
|
||||
const out = classifyErrorText("out of extra usage");
|
||||
assert.equal(out, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
});
|
||||
|
||||
test("#9486 generic 400 without quota text still gets no fallback (regression guard)", () => {
|
||||
// Regression guard: a plain 400 with no quota-related text must NOT trigger
|
||||
// fallback, preserving the existing behavior for non-quota 400 errors.
|
||||
const out = checkFallbackError(400, "Bad request: invalid JSON", 0, null, "claude");
|
||||
assert.equal(out.shouldFallback, false);
|
||||
assert.equal(out.reason, RateLimitReason.UNKNOWN);
|
||||
});
|
||||
52
tests/unit/repro-9623.test.ts
Normal file
52
tests/unit/repro-9623.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #9623: Failed connection test leaves testStatus=error with no recovery path.
|
||||
// Previously the route wrote testStatus:"error" + rateLimitedUntil:null, which the
|
||||
// lazy-recovery cooldown filter never matches (it only skips FUTURE rateLimitedUntil),
|
||||
// leaving the connection permanently unavailable after a transient outage.
|
||||
// Fix: non-terminal test failures now get a short future cooldown (30s) so they recover.
|
||||
|
||||
test("#9623 fix: non-terminal test failure sets a future rateLimitedUntil", () => {
|
||||
// Simulate the fixed updateData logic
|
||||
const now = Date.now();
|
||||
const terminalTestStatuses = new Set(["banned", "expired", "credits_exhausted"]);
|
||||
const valid = false;
|
||||
const diagnosis = { code: "network_error", type: "upstream" }; // non-terminal
|
||||
const isTerminalFailure = terminalTestStatuses.has(String(diagnosis.code).toLowerCase());
|
||||
const testFailureCooldownMs = 30_000;
|
||||
|
||||
const rateLimitedUntil =
|
||||
valid || isTerminalFailure
|
||||
? valid
|
||||
? null
|
||||
: null
|
||||
: new Date(now + testFailureCooldownMs).toISOString();
|
||||
|
||||
assert.ok(
|
||||
rateLimitedUntil !== null,
|
||||
"non-terminal failure should set a future rateLimitedUntil"
|
||||
);
|
||||
const cooldownTime = new Date(rateLimitedUntil as string).getTime();
|
||||
assert.ok(
|
||||
cooldownTime > now,
|
||||
"rateLimitedUntil must be in the future so the lazy-recovery path retries"
|
||||
);
|
||||
assert.ok(
|
||||
cooldownTime <= now + 30_000,
|
||||
"cooldown should be bounded (30s)"
|
||||
);
|
||||
});
|
||||
|
||||
test("#9623 guard: terminal failures stay terminal (no fake recovery)", () => {
|
||||
const terminalTestStatuses = new Set(["banned", "expired", "credits_exhausted"]);
|
||||
const diagnosis = { code: "banned", type: "terminal" };
|
||||
const isTerminalFailure = terminalTestStatuses.has(String(diagnosis.code).toLowerCase());
|
||||
assert.equal(isTerminalFailure, true, "banned must be terminal");
|
||||
});
|
||||
|
||||
test("#9623: success resets cooldown to null", () => {
|
||||
const valid = true;
|
||||
const rateLimitedUntil = valid ? null : new Date(Date.now() + 30_000).toISOString();
|
||||
assert.equal(rateLimitedUntil, null, "successful test clears cooldown");
|
||||
});
|
||||
52
tests/unit/repro-9624.test.ts
Normal file
52
tests/unit/repro-9624.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it } from "node:test";
|
||||
import { strict as assert } from "node:assert";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const INSTRUMENTATION_NODE_PATH = resolve(
|
||||
__dirname,
|
||||
"../../src/instrumentation-node.ts"
|
||||
);
|
||||
|
||||
describe("repro-9624: startCleanupScheduler wired in Next.js startup path", () => {
|
||||
it("should import startCleanupScheduler from cleanup", () => {
|
||||
const source = readFileSync(INSTRUMENTATION_NODE_PATH, "utf-8");
|
||||
|
||||
// instrumentation-node.ts loads all startup modules via dynamic imports in a
|
||||
// Promise.all destructure, e.g.:
|
||||
// const [{ startCleanupScheduler }, ...] = await Promise.all([
|
||||
// import("@/lib/db/cleanup"), ...
|
||||
// ]);
|
||||
// So the binding and the module import appear separately in the file.
|
||||
const cleanupModuleImported = /import\(\s*["']@\/lib\/db\/cleanup["']\s*\)/.test(
|
||||
source
|
||||
);
|
||||
const schedulerBound = /\bstartCleanupScheduler\b/.test(source);
|
||||
|
||||
assert.ok(
|
||||
cleanupModuleImported,
|
||||
"@/lib/db/cleanup should be imported (dynamic import) in instrumentation-node.ts"
|
||||
);
|
||||
assert.ok(
|
||||
schedulerBound,
|
||||
"startCleanupScheduler should be bound in instrumentation-node.ts"
|
||||
);
|
||||
});
|
||||
|
||||
it("should call startCleanupScheduler() during startup", () => {
|
||||
const source = readFileSync(INSTRUMENTATION_NODE_PATH, "utf-8");
|
||||
|
||||
// Check that startCleanupScheduler is called (as a function call).
|
||||
// It can be called directly or as part of a conditional.
|
||||
const hasCall = /\bstartCleanupScheduler\s*\(/.test(source);
|
||||
|
||||
assert.ok(
|
||||
hasCall,
|
||||
"startCleanupScheduler() should be called in instrumentation-node.ts"
|
||||
);
|
||||
});
|
||||
});
|
||||
92
tests/unit/repro-9625.test.ts
Normal file
92
tests/unit/repro-9625.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Issue #9625 — domain_cost_history cleanup cutoff unit mismatch.
|
||||
*
|
||||
* cleanupDomainCostHistory() computes the cutoff in epoch seconds
|
||||
* (Math.floor(Date.now() / 1000)) but the timestamp column stores
|
||||
* epoch milliseconds (Date.now()), as inserted by saveCostEntry().
|
||||
*
|
||||
* This test seeds data using the same format as the production code
|
||||
* (milliseconds), then asserts that cleanupDomainCostHistory() correctly
|
||||
* deletes rows older than the retention window.
|
||||
*
|
||||
* Before the fix, the cutoff in seconds was ~1000× smaller than the
|
||||
* stored timestamps, so the DELETE WHERE timestamp < cutoff would
|
||||
* never match old rows — the cleanup was effectively a no-op.
|
||||
*/
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-9625-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const { cleanupDomainCostHistory } = await import("../../src/lib/db/cleanup.ts");
|
||||
const { getDbInstance, resetDbInstance } = await import("../../src/lib/db/core.ts");
|
||||
|
||||
test.after(() => {
|
||||
resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const DAY_MS = 86_400_000; // milliseconds
|
||||
|
||||
test("#9625 cleanupDomainCostHistory: cutoff in ms matches production timestamps", async () => {
|
||||
const db = getDbInstance()!;
|
||||
const now = Date.now(); // milliseconds — same as saveCostEntry() default
|
||||
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO domain_cost_history (api_key_id, cost, timestamp) VALUES (?, ?, ?)"
|
||||
);
|
||||
|
||||
// Seed data using millisecond timestamps (production format).
|
||||
// 3 old rows: 40 days ago (should be deleted)
|
||||
// 2 recent rows: 5 days ago (should be kept)
|
||||
insert.run("key1", 1.0, now - 40 * DAY_MS);
|
||||
insert.run("key1", 2.0, now - 40 * DAY_MS);
|
||||
insert.run("key1", 3.0, now - 40 * DAY_MS);
|
||||
insert.run("key1", 4.0, now - 5 * DAY_MS);
|
||||
insert.run("key1", 5.0, now - 5 * DAY_MS);
|
||||
|
||||
const result = await cleanupDomainCostHistory();
|
||||
|
||||
// Before the fix, cutoff was in seconds (~1.7e9) while timestamps
|
||||
// are in milliseconds (~1.7e12). The comparison `WHERE ts < 1.7e9`
|
||||
// would never match rows with ts ~1.7e12, so nothing was deleted.
|
||||
assert.strictEqual(result.deleted, 3, "Should delete 3 old rows (40 days old)");
|
||||
assert.strictEqual(result.errors, 0);
|
||||
|
||||
const remaining = db.prepare("SELECT COUNT(*) as cnt FROM domain_cost_history").get() as {
|
||||
cnt: number;
|
||||
};
|
||||
assert.strictEqual(remaining.cnt, 2, "Should keep 2 recent rows (5 days old)");
|
||||
});
|
||||
|
||||
test("#9625 unit mismatch: seconds cutoff would NOT match ms timestamps", () => {
|
||||
// Demonstrate the arithmetic bug: a cutoff in seconds is ~1000×
|
||||
// smaller than a millisecond timestamp, so the WHERE clause never
|
||||
// matches production data.
|
||||
const nowMs = Date.now();
|
||||
const nowSec = Math.floor(nowMs / 1000);
|
||||
const retentionDays = 30;
|
||||
const cutoffSec = nowSec - retentionDays * 86_400; // seconds
|
||||
const cutoffMs = nowMs - retentionDays * 86_400_000; // milliseconds
|
||||
|
||||
// A row inserted 40 days ago with a millisecond timestamp:
|
||||
const oldRowMs = nowMs - 40 * 86_400_000; // ~1.7e12
|
||||
|
||||
// With seconds cutoff: oldRowMs (1.7e12) < cutoffSec (1.7e9) is FALSE
|
||||
// because 1.7e12 > 1.7e9 — the row is never matched.
|
||||
assert.ok(
|
||||
oldRowMs > cutoffSec,
|
||||
"Bug: ms timestamp is NOT less than seconds cutoff, so row is never deleted"
|
||||
);
|
||||
|
||||
// With milliseconds cutoff: oldRowMs (1.7e12) < cutoffMs (1.7e12) is TRUE
|
||||
assert.ok(
|
||||
oldRowMs < cutoffMs,
|
||||
"Fix: ms timestamp IS less than ms cutoff, so row is correctly deleted"
|
||||
);
|
||||
});
|
||||
27
tests/unit/repro-9633.test.ts
Normal file
27
tests/unit/repro-9633.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const pkg = JSON.parse(readFileSync("package.json", "utf-8"));
|
||||
const files = pkg.files || [];
|
||||
|
||||
// #9633: `build-next-isolated.mjs` is published (fixed in #1126), but three of
|
||||
// its sibling modules it imports were missing from the `files` whitelist, so
|
||||
// `npm run build` on a globally-installed package crashed with ERR_MODULE_NOT_FOUND.
|
||||
// The dynamic import of `build-tproxy-native.mjs` (~line 308) and the static
|
||||
// imports of `assembleStandalone.mjs` / `backendOnlyPages.mjs` must ship too.
|
||||
const NEEDED = [
|
||||
"scripts/build/assembleStandalone.mjs",
|
||||
"scripts/build/backendOnlyPages.mjs",
|
||||
"scripts/build/build-tproxy-native.mjs",
|
||||
"scripts/build/colocateOptionals.mjs",
|
||||
];
|
||||
|
||||
test("#9633: build-next-isolated.mjs sibling imports present in package.json files[]", () => {
|
||||
for (const needed of NEEDED) {
|
||||
assert.ok(
|
||||
files.some((f) => typeof f === "string" && f === needed),
|
||||
`${needed} is not in package.json files[]`
|
||||
);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user