mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Release v3.8.33 (#4515)
Release v3.8.33 — full CHANGELOG in the PR body. Blocking gates green (Build, Lint, Unit Tests 8/8, Package Artifact, Quality Gates, Quality Ratchet, Docs Sync, PR Test Policy, test-vitest). Admin-merged over a Node 26 future-compat timer flake (1/4) + an E2E UI flake (3/9) — both verified non-deterministic; full test:unit validated locally (16936 pass).
This commit is contained in:
committed by
GitHub
parent
bfaf459f3c
commit
ee24eb52d4
@@ -1184,8 +1184,9 @@ test("chat pipeline allows unauthenticated requests through to provider resoluti
|
||||
|
||||
// handleChat does not enforce REQUIRE_API_KEY — that's the authz pipeline's job.
|
||||
// Without provider credentials seeded, the request falls through to the "no credentials" path.
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(json.error.message, /No credentials for provider/i);
|
||||
// Upstream port decolua/9router#336: 400 → 404 so combo routing can fall through.
|
||||
assert.equal(response.status, 404);
|
||||
assert.match(json.error.message, /No active credentials for provider/i);
|
||||
});
|
||||
|
||||
test("chat pipeline returns 400 when the model field is omitted", async () => {
|
||||
@@ -1298,8 +1299,9 @@ test("chat pipeline returns current no-credentials contract when no provider con
|
||||
);
|
||||
|
||||
const json = (await response.json()) as any;
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(json.error.message, /No credentials for provider: openai/);
|
||||
// Upstream port decolua/9router#336: 400 → 404 so combo routing can fall through.
|
||||
assert.equal(response.status, 404);
|
||||
assert.match(json.error.message, /No active credentials for provider: openai/);
|
||||
});
|
||||
|
||||
test("chat pipeline surfaces upstream 500 responses as structured errors", async () => {
|
||||
|
||||
@@ -364,8 +364,10 @@ test("unmapped custom model requests fail after combo resolution falls through",
|
||||
);
|
||||
const json = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(json.error.message, /No credentials for provider: tenant/);
|
||||
// Upstream port decolua/9router#336: 400 → 404 so combo routing can fall through
|
||||
// to the next target when a provider has zero usable credentials.
|
||||
assert.equal(response.status, 404);
|
||||
assert.match(json.error.message, /No active credentials for provider: tenant/);
|
||||
});
|
||||
|
||||
test("strategy updates take effect for later requests on the same combo name", async () => {
|
||||
|
||||
@@ -171,7 +171,8 @@ test("llama-cpp provider: alias matching works via model catalog prefix", async
|
||||
assert.equal(json.choices[0].message.content, "42");
|
||||
});
|
||||
|
||||
test("llama-cpp provider: returns 400 when no connection exists", async () => {
|
||||
test("llama-cpp provider: returns 404 when no connection exists", async () => {
|
||||
// Upstream port decolua/9router#336: 400 → 404 so combo routing can fall through.
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
@@ -182,7 +183,7 @@ test("llama-cpp provider: returns 400 when no connection exists", async () => {
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(response.status, 404);
|
||||
const json = (await response.json()) as any;
|
||||
assert.match(json.error.message, /No credentials for provider/);
|
||||
assert.match(json.error.message, /No active credentials for provider/);
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ const harness = await createChatPipelineHarness("memory-pipeline");
|
||||
// The harness sets DATA_DIR before importing DB modules, so these must resolve after that.
|
||||
const { extractFactsFromText } = await import("../../src/lib/memory/extraction.ts");
|
||||
const { retrieveMemories } = await import("../../src/lib/memory/retrieval.ts");
|
||||
const { invalidateMemorySettingsCache } = await import("../../src/lib/memory/settings.ts");
|
||||
const { injectMemory, formatMemoryContext } = await import("../../src/lib/memory/injection.ts");
|
||||
const {
|
||||
BaseExecutor,
|
||||
@@ -45,12 +46,14 @@ function dropFts5Artifacts() {
|
||||
test.beforeEach(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = 0;
|
||||
await resetStorage();
|
||||
invalidateMemorySettingsCache();
|
||||
dropFts5Artifacts();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = harness.originalRetryDelayMs;
|
||||
await resetStorage();
|
||||
invalidateMemorySettingsCache();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
|
||||
54
tests/unit/antigravity-strip-thinking-fields-1926.test.ts
Normal file
54
tests/unit/antigravity-strip-thinking-fields-1926.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
|
||||
|
||||
// Regression for #1926: Claude/OpenAI "thinking" fields leaked into the Antigravity
|
||||
// (Google Cloud Code) request envelope, which Google rejects at the top level with
|
||||
// `400 Bad input: Error: oneOf at '/' not met` (and, for named fields, `Unknown name
|
||||
// "thinking"`). This broke every reasoning/thinking model served via Antigravity
|
||||
// (e.g. `claude-opus-4-x-thinking`). The envelope spreads `...passthroughFields`
|
||||
// (every top-level body field except a known few), so a top-level thinking field set
|
||||
// by the unified thinking adapter leaked straight into the envelope Google validates.
|
||||
//
|
||||
// The #1944 fix only dropped `output_config`/`output_format`; the thinking family
|
||||
// (`thinking`, `reasoning_effort`, `reasoning`, `enable_thinking`, `thinking_budget`)
|
||||
// still passed through. This test guards that the whole family is stripped.
|
||||
|
||||
const THINKING_FIELDS = [
|
||||
"thinking",
|
||||
"reasoning_effort",
|
||||
"reasoning",
|
||||
"enable_thinking",
|
||||
"thinking_budget",
|
||||
] as const;
|
||||
|
||||
test("transformRequest drops Claude/OpenAI thinking fields from the Antigravity envelope (#1926)", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const body = {
|
||||
thinking: { type: "enabled", budget_tokens: 4096 },
|
||||
reasoning_effort: "high",
|
||||
reasoning: { effort: "high" },
|
||||
enable_thinking: true,
|
||||
thinking_budget: 4096,
|
||||
request: {
|
||||
contents: [{ role: "user", parts: [{ text: "hi" }] }],
|
||||
generationConfig: {},
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executor.transformRequest("antigravity/claude-opus-4-8-thinking", body, true, {
|
||||
projectId: "project-1",
|
||||
});
|
||||
|
||||
if (result instanceof Response) throw new Error("Unexpected Response from transformRequest");
|
||||
const envelope = result as Record<string, unknown>;
|
||||
|
||||
for (const field of THINKING_FIELDS) {
|
||||
assert.equal(
|
||||
envelope[field],
|
||||
undefined,
|
||||
`top-level "${field}" must not reach the Google Cloud Code envelope`
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -138,26 +138,152 @@ test("getApiKeyUsageLimitStatus aligns weekly USD spend with provider resetAt wh
|
||||
assert.equal(status.weeklySpentUsd, 5);
|
||||
assert.equal(status.dailyLimitUsd, 10);
|
||||
assert.equal(status.weeklyLimitUsd, 20);
|
||||
assert.equal(status.dailyResetAtIso, "2026-06-20T03:00:00.000Z");
|
||||
assert.equal(status.weeklyWindowStartIso, "2026-06-18T20:00:00.000Z");
|
||||
assert.equal(status.weeklyResetAtIso, weeklyResetAt);
|
||||
assert.equal(status.dailyExceeded, false);
|
||||
assert.equal(status.weeklyExceeded, false);
|
||||
});
|
||||
|
||||
test("buildApiKeyUsageLimitText returns only the quota and spent USD lines", async () => {
|
||||
const text = usageLimits.buildApiKeyUsageLimitText({
|
||||
enabled: true,
|
||||
dailyLimitUsd: 10,
|
||||
weeklyLimitUsd: 50,
|
||||
dailySpentUsd: 2,
|
||||
weeklySpentUsd: 5.25,
|
||||
dailyWindowStartIso: "2026-06-19T03:00:00.000Z",
|
||||
weeklyWindowStartIso: "2026-06-12T20:00:00.000Z",
|
||||
weeklyResetAtIso: "2026-06-19T20:00:00.000Z",
|
||||
dailyExceeded: false,
|
||||
weeklyExceeded: false,
|
||||
test("getApiKeyUsageLimitStatus cuts weekly USD spend at observed provider quota reset", async () => {
|
||||
await localDb.updatePricing({
|
||||
claude: {
|
||||
"claude-opus-4-8": {
|
||||
input: 1,
|
||||
cached: 1,
|
||||
output: 1,
|
||||
reasoning: 1,
|
||||
cache_creation: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const created = await apiKeysDb.createApiKey("Reset Cut Key", "machine-limit-reset");
|
||||
await apiKeysDb.updateApiKeyPermissions(created.id, {
|
||||
usageLimitEnabled: true,
|
||||
dailyUsageLimitUsd: 10,
|
||||
weeklyUsageLimitUsd: 20,
|
||||
});
|
||||
|
||||
await usageHistory.saveRequestUsage({
|
||||
provider: "claude",
|
||||
model: "claude-opus-4-8",
|
||||
apiKeyId: created.id,
|
||||
apiKeyName: "Reset Cut Key",
|
||||
tokens: { input: 7_000_000, output: 0 },
|
||||
success: true,
|
||||
timestamp: "2026-06-19T23:30:00.000Z",
|
||||
});
|
||||
await usageHistory.saveRequestUsage({
|
||||
provider: "claude",
|
||||
model: "claude-opus-4-8",
|
||||
apiKeyId: created.id,
|
||||
apiKeyName: "Reset Cut Key",
|
||||
tokens: { input: 2_000_000, output: 0 },
|
||||
success: true,
|
||||
timestamp: "2026-06-20T02:00:00.000Z",
|
||||
});
|
||||
|
||||
const db = core.getDbInstance();
|
||||
const insertSnapshot = db.prepare(`
|
||||
INSERT INTO quota_snapshots (
|
||||
provider,
|
||||
connection_id,
|
||||
window_key,
|
||||
remaining_percentage,
|
||||
is_exhausted,
|
||||
next_reset_at,
|
||||
window_duration_ms,
|
||||
raw_data,
|
||||
created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insertSnapshot.run(
|
||||
"claude",
|
||||
"conn-claude",
|
||||
"weekly (7d)",
|
||||
72,
|
||||
0,
|
||||
"2026-06-25T23:00:00.000Z",
|
||||
null,
|
||||
null,
|
||||
"2026-06-19T23:55:00.000Z"
|
||||
);
|
||||
insertSnapshot.run(
|
||||
"claude",
|
||||
"conn-claude",
|
||||
"weekly (7d)",
|
||||
100,
|
||||
0,
|
||||
"2026-06-25T23:00:00.000Z",
|
||||
null,
|
||||
null,
|
||||
"2026-06-20T01:42:52.590Z"
|
||||
);
|
||||
insertSnapshot.run(
|
||||
"claude",
|
||||
"conn-claude",
|
||||
"weekly (7d)",
|
||||
99,
|
||||
0,
|
||||
"2026-06-25T23:00:00.000Z",
|
||||
null,
|
||||
null,
|
||||
"2026-06-20T02:10:00.000Z"
|
||||
);
|
||||
|
||||
const metadata = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
assert.ok(metadata);
|
||||
|
||||
const weeklyResetAt = "2026-06-25T23:00:00.000Z";
|
||||
const status = await usageLimits.getApiKeyUsageLimitStatus(
|
||||
{ ...metadata, allowedConnections: ["conn-claude"] },
|
||||
{
|
||||
now: () => Date.parse("2026-06-20T14:30:00.000Z"),
|
||||
getProviderConnectionById: async () => ({
|
||||
id: "conn-claude",
|
||||
provider: "claude",
|
||||
isActive: true,
|
||||
}),
|
||||
getProviderConnections: async () => [],
|
||||
getProviderLimitsCache: () => ({
|
||||
plan: "Claude Max",
|
||||
quotas: {
|
||||
"weekly (7d)": {
|
||||
used: 5,
|
||||
total: 100,
|
||||
resetAt: weeklyResetAt,
|
||||
},
|
||||
},
|
||||
message: null,
|
||||
fetchedAt: "2026-06-20T14:30:00.000Z",
|
||||
}),
|
||||
getAllProviderLimitsCache: () => ({}),
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(status.weeklyWindowStartIso, "2026-06-20T01:42:52.590Z");
|
||||
assert.equal(status.weeklySpentUsd, 2);
|
||||
});
|
||||
|
||||
test("buildApiKeyUsageLimitText returns API-key quota spend percentage and reset lines", async () => {
|
||||
const text = usageLimits.buildApiKeyUsageLimitText(
|
||||
{
|
||||
enabled: true,
|
||||
dailyLimitUsd: 10,
|
||||
weeklyLimitUsd: 50,
|
||||
dailySpentUsd: 2,
|
||||
weeklySpentUsd: 5.25,
|
||||
dailyWindowStartIso: "2026-06-19T03:00:00.000Z",
|
||||
dailyResetAtIso: "2026-06-20T03:00:00.000Z",
|
||||
weeklyWindowStartIso: "2026-06-12T20:00:00.000Z",
|
||||
weeklyResetAtIso: "2026-06-25T20:00:00.000Z",
|
||||
dailyExceeded: false,
|
||||
weeklyExceeded: false,
|
||||
},
|
||||
Date.parse("2026-06-19T20:00:00.000Z")
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
text,
|
||||
[
|
||||
@@ -165,15 +291,50 @@ test("buildApiKeyUsageLimitText returns only the quota and spent USD lines", asy
|
||||
"$10.00",
|
||||
"Gasto diario",
|
||||
"$2.00",
|
||||
"Uso diario",
|
||||
"20%",
|
||||
"Resets in 7h",
|
||||
"",
|
||||
"Cota semanal",
|
||||
"$50.00",
|
||||
"Gasto semanal",
|
||||
"$5.25",
|
||||
"Uso semanal",
|
||||
"11%",
|
||||
"Resets in 6d",
|
||||
].join("\n")
|
||||
);
|
||||
});
|
||||
|
||||
test("buildApiKeyUsageLimitRejection includes over-quota percentage and reset hint", async () => {
|
||||
const response = usageLimits.buildApiKeyUsageLimitRejection(
|
||||
new Request("http://localhost/v1/messages", {
|
||||
headers: { "anthropic-version": "2023-06-01" },
|
||||
}),
|
||||
{
|
||||
enabled: true,
|
||||
dailyLimitUsd: 10,
|
||||
weeklyLimitUsd: 1,
|
||||
dailySpentUsd: 0.25,
|
||||
weeklySpentUsd: 1.09,
|
||||
dailyWindowStartIso: "2026-06-19T03:00:00.000Z",
|
||||
dailyResetAtIso: "2026-06-20T03:00:00.000Z",
|
||||
weeklyWindowStartIso: "2026-06-12T20:00:00.000Z",
|
||||
weeklyResetAtIso: "2026-06-25T20:00:00.000Z",
|
||||
dailyExceeded: false,
|
||||
weeklyExceeded: true,
|
||||
},
|
||||
Date.parse("2026-06-19T20:00:00.000Z")
|
||||
);
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
const body = (await response.json()) as { error: { message: string } };
|
||||
assert.equal(
|
||||
body.error.message,
|
||||
"This API key reached its weekly USD usage quota ($1.09 of $1.00, 109%). Resets in 6d. Choose another allowed model after reset."
|
||||
);
|
||||
});
|
||||
|
||||
test("buildApiKeyUsageLimitRejection uses 400 so Claude Code does not trigger login", () => {
|
||||
const response = usageLimits.buildApiKeyUsageLimitRejection(
|
||||
new Request("http://localhost/v1/messages", {
|
||||
@@ -186,6 +347,7 @@ test("buildApiKeyUsageLimitRejection uses 400 so Claude Code does not trigger lo
|
||||
dailySpentUsd: 12,
|
||||
weeklySpentUsd: 20,
|
||||
dailyWindowStartIso: "2026-06-19T03:00:00.000Z",
|
||||
dailyResetAtIso: "2026-06-20T03:00:00.000Z",
|
||||
weeklyWindowStartIso: "2026-06-12T20:00:00.000Z",
|
||||
weeklyResetAtIso: "2026-06-19T20:00:00.000Z",
|
||||
dailyExceeded: true,
|
||||
|
||||
51
tests/unit/api-manager-key-visibility.test.ts
Normal file
51
tests/unit/api-manager-key-visibility.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
maskKey,
|
||||
toggleKeyVisibility,
|
||||
} from "../../src/app/(dashboard)/dashboard/api-manager/apiManagerPageUtils.js";
|
||||
|
||||
describe("maskKey", () => {
|
||||
it("returns empty string when key is missing or empty", () => {
|
||||
assert.equal(maskKey(""), "");
|
||||
assert.equal(maskKey(null), "");
|
||||
assert.equal(maskKey(undefined), "");
|
||||
});
|
||||
|
||||
it("returns the key untouched when it fits the visible budget (<=8 chars)", () => {
|
||||
assert.equal(maskKey("sk"), "sk");
|
||||
assert.equal(maskKey("sk-12345"), "sk-12345");
|
||||
});
|
||||
|
||||
it("keeps the first 8 chars and appends an ellipsis when the key is longer", () => {
|
||||
const full = "sk-or-1234567890abcdef";
|
||||
const masked = maskKey(full);
|
||||
assert.equal(masked.startsWith("sk-or-12"), true);
|
||||
assert.equal(masked.endsWith("..."), true);
|
||||
// Must not leak the tail
|
||||
assert.equal(masked.includes("90abcdef"), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("toggleKeyVisibility", () => {
|
||||
it("adds an id when it is not present", () => {
|
||||
const next = toggleKeyVisibility(new Set<string>(), "k1");
|
||||
assert.equal(next.has("k1"), true);
|
||||
assert.equal(next.size, 1);
|
||||
});
|
||||
|
||||
it("removes an id when it is already present", () => {
|
||||
const next = toggleKeyVisibility(new Set<string>(["k1", "k2"]), "k1");
|
||||
assert.equal(next.has("k1"), false);
|
||||
assert.equal(next.has("k2"), true);
|
||||
assert.equal(next.size, 1);
|
||||
});
|
||||
|
||||
it("returns a NEW Set (does not mutate the input — React state safety)", () => {
|
||||
const input = new Set<string>(["k1"]);
|
||||
const output = toggleKeyVisibility(input, "k2");
|
||||
assert.notEqual(output, input);
|
||||
assert.equal(input.has("k2"), false);
|
||||
assert.equal(output.has("k2"), true);
|
||||
});
|
||||
});
|
||||
@@ -158,11 +158,7 @@ describe("settings/compression route — engines + activeComboId", () => {
|
||||
assert.equal(getRes.status, 200);
|
||||
const body = await getRes.json();
|
||||
|
||||
assert.equal(
|
||||
body.engines?.rtk?.enabled,
|
||||
true,
|
||||
"engines.rtk.enabled should be true after PUT"
|
||||
);
|
||||
assert.equal(body.engines?.rtk?.enabled, true, "engines.rtk.enabled should be true after PUT");
|
||||
assert.equal(
|
||||
body.engines?.rtk?.level,
|
||||
"standard",
|
||||
@@ -199,15 +195,29 @@ describe("settings/compression route — engines + activeComboId", () => {
|
||||
|
||||
it("PUT with invalid engines shape is rejected by schema validation (400)", async () => {
|
||||
// engines values must have an `enabled` boolean — passing a string should fail the schema.
|
||||
const putRes = await route.PUT(
|
||||
makeRequest("PUT", { engines: { rtk: { enabled: "yes" } } })
|
||||
);
|
||||
const putRes = await route.PUT(makeRequest("PUT", { engines: { rtk: { enabled: "yes" } } }));
|
||||
assert.equal(putRes.status, 400);
|
||||
const body = await putRes.json();
|
||||
// Validation failures use { error: { message, details } } via validateBody helper.
|
||||
assert.ok(body.error !== null && typeof body.error === "object", "error should be an object");
|
||||
const errorMessage: string =
|
||||
typeof body.error === "string" ? body.error : (body.error?.message ?? JSON.stringify(body.error));
|
||||
typeof body.error === "string"
|
||||
? body.error
|
||||
: (body.error?.message ?? JSON.stringify(body.error));
|
||||
assert.ok(!errorMessage.includes("at /"), "error must not contain a stack trace");
|
||||
});
|
||||
|
||||
it("PUT accepts enginesExplicit (round-tripped from GET response)", async () => {
|
||||
// Regression: the GET handler injects `enginesExplicit` (compression.ts:632) so the
|
||||
// hub/panel can round-trip the full settings object. The previous .strict() PUT schema
|
||||
// rejected it with 400 ("Unrecognized key: enginesExplicit"), causing every toggle on
|
||||
// the Compression Hub / Panel to revert. Allow it through.
|
||||
const putRes = await route.PUT(makeRequest("PUT", { enabled: true, enginesExplicit: true }));
|
||||
assert.equal(putRes.status, 200);
|
||||
|
||||
core.resetDbInstance();
|
||||
|
||||
const putRes2 = await route.PUT(makeRequest("PUT", { enabled: false, enginesExplicit: false }));
|
||||
assert.equal(putRes2.status, 200);
|
||||
});
|
||||
});
|
||||
|
||||
81
tests/unit/auto-combo-hidden-models-4558.test.ts
Normal file
81
tests/unit/auto-combo-hidden-models-4558.test.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* #4558 — Auto-combo must respect model visibility (isHidden).
|
||||
*
|
||||
* When an operator hides a model with the EYE/visibility toggle
|
||||
* (`mergeModelCompatOverride(provider, model, { isHidden: true })`, written to
|
||||
* the `modelCompatOverrides` key_value namespace), that model must be excluded
|
||||
* from the AUTO-combo candidate pool. Both auto paths consume the same seam:
|
||||
* - `open-sse/services/combo.ts::buildAutoCandidates` (combo.ts:322,520-521)
|
||||
* - `open-sse/services/autoCombo/virtualFactory.ts` (virtualFactory.ts:239,256-257)
|
||||
* via the new bulk `getHiddenModelsByProvider()` map (single query, not N+1).
|
||||
*
|
||||
* This guards that seam: a hidden model is present in the map's per-provider set
|
||||
* while a visible sibling is not, and that toggling visibility back off
|
||||
* (`isHidden: null`) removes it from the map again. Without the fix the map is
|
||||
* empty and the auto pool would keep serving hidden models.
|
||||
*/
|
||||
import test, { before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
|
||||
// Hermetic DB: this test writes overrides into the `modelCompatOverrides`
|
||||
// key_value namespace. Without an isolated DATA_DIR it would leak that state
|
||||
// into the shared dev/CI database. Point DATA_DIR at a throwaway dir before any
|
||||
// import that opens the SQLite handle (CLAUDE.md "Database Handles in Tests").
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-hidden-4558-"));
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
|
||||
const { mergeModelCompatOverride, getHiddenModelsByProvider, getModelIsHidden } = await import(
|
||||
"../../src/lib/localDb.ts"
|
||||
);
|
||||
const { resetDbInstance } = await import("../../src/lib/db/core.ts");
|
||||
|
||||
before(() => {
|
||||
resetDbInstance();
|
||||
});
|
||||
|
||||
after(() => {
|
||||
resetDbInstance();
|
||||
fs.rmSync(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const PROVIDER = "openai";
|
||||
const HIDDEN_MODEL = "gpt-hidden-preview";
|
||||
const VISIBLE_MODEL = "gpt-visible-4o";
|
||||
|
||||
test("getHiddenModelsByProvider: empty before any model is hidden", () => {
|
||||
const map = getHiddenModelsByProvider();
|
||||
assert.equal(map.get(PROVIDER)?.has(HIDDEN_MODEL) ?? false, false);
|
||||
});
|
||||
|
||||
test("a hidden model lands in the provider's hidden set; a visible sibling does not", () => {
|
||||
// Hide one model, leave a sibling visible (overridden for an unrelated reason).
|
||||
mergeModelCompatOverride(PROVIDER, HIDDEN_MODEL, { isHidden: true });
|
||||
mergeModelCompatOverride(PROVIDER, VISIBLE_MODEL, { normalizeToolCallId: true });
|
||||
|
||||
// Sanity: the per-model read agrees.
|
||||
assert.equal(getModelIsHidden(PROVIDER, HIDDEN_MODEL), true);
|
||||
assert.equal(getModelIsHidden(PROVIDER, VISIBLE_MODEL), false);
|
||||
|
||||
const map = getHiddenModelsByProvider();
|
||||
const hiddenForProvider = map.get(PROVIDER);
|
||||
assert.ok(hiddenForProvider, "expected an entry for the provider");
|
||||
assert.equal(hiddenForProvider.has(HIDDEN_MODEL), true, "hidden model must be in the set");
|
||||
assert.equal(
|
||||
hiddenForProvider.has(VISIBLE_MODEL),
|
||||
false,
|
||||
"visible model must NOT be in the hidden set"
|
||||
);
|
||||
});
|
||||
|
||||
test("un-hiding a model (isHidden: null) removes it from the map", () => {
|
||||
mergeModelCompatOverride(PROVIDER, HIDDEN_MODEL, { isHidden: null });
|
||||
const map = getHiddenModelsByProvider();
|
||||
assert.equal(
|
||||
map.get(PROVIDER)?.has(HIDDEN_MODEL) ?? false,
|
||||
false,
|
||||
"un-hidden model must drop out of the hidden set"
|
||||
);
|
||||
});
|
||||
76
tests/unit/bazaarlink-provider-integrity.test.ts
Normal file
76
tests/unit/bazaarlink-provider-integrity.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert";
|
||||
|
||||
describe("bazaarlink provider entry in APIKEY_PROVIDERS", () => {
|
||||
it("exists with required fields", async () => {
|
||||
const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
|
||||
const p = (APIKEY_PROVIDERS as Record<string, Record<string, unknown>>)["bazaarlink"];
|
||||
assert.ok(p, "bazaarlink must exist in APIKEY_PROVIDERS");
|
||||
assert.equal(p.id, "bazaarlink");
|
||||
assert.equal(p.alias, "bzl");
|
||||
assert.equal(p.name, "BazaarLink");
|
||||
assert.equal(p.icon, "storefront");
|
||||
assert.equal(p.color, "#6366F1");
|
||||
assert.equal(p.textIcon, "BZ");
|
||||
assert.equal(p.website, "https://bazaarlink.ai");
|
||||
});
|
||||
|
||||
it("advertises the free tier and includes authHint", async () => {
|
||||
const { APIKEY_PROVIDERS } = await import("../../src/shared/constants/providers.ts");
|
||||
const p = (
|
||||
APIKEY_PROVIDERS as Record<
|
||||
string,
|
||||
{ hasFree?: boolean; authHint?: string; freeNote?: string; apiHint?: string }
|
||||
>
|
||||
)["bazaarlink"];
|
||||
assert.equal(p.hasFree, true, "bazaarlink should advertise a free tier");
|
||||
assert.ok(p.authHint, "bazaarlink must have an authHint field");
|
||||
assert.ok(p.authHint.includes("sk-bl-"), "authHint should mention the sk-bl- key prefix");
|
||||
assert.ok(p.freeNote, "bazaarlink must have a freeNote describing the free tier");
|
||||
assert.ok(p.apiHint, "bazaarlink must have an apiHint");
|
||||
assert.ok(p.apiHint.includes("bazaarlink.ai"), "apiHint should reference bazaarlink.ai");
|
||||
});
|
||||
|
||||
it("is registered in the execution registry", async () => {
|
||||
const { getRegistryEntry } = await import("../../open-sse/config/providerRegistry.ts");
|
||||
const entry = getRegistryEntry("bazaarlink");
|
||||
assert.ok(entry, "bazaarlink must have a registry entry");
|
||||
assert.equal(entry.format, "openai");
|
||||
assert.equal(entry.authType, "apikey");
|
||||
assert.ok(entry.baseUrl, "registry entry must have a baseUrl");
|
||||
assert.ok(entry.modelsUrl, "registry entry must have a modelsUrl");
|
||||
assert.ok(
|
||||
entry.models && entry.models.length > 0,
|
||||
"registry entry must list at least one model"
|
||||
);
|
||||
assert.ok(
|
||||
entry.models.some((m: { id: string }) => m.id === "auto:free"),
|
||||
"registry must include auto:free model"
|
||||
);
|
||||
assert.ok(
|
||||
entry.models.some((m: { id: string }) => m.id === "mimo-v2.5-pro"),
|
||||
"registry must include mimo-v2.5-pro"
|
||||
);
|
||||
});
|
||||
|
||||
it("is resolvable through the static catalog for managed connections", async () => {
|
||||
const { resolveStaticProviderCatalogEntry } =
|
||||
await import("../../src/lib/providers/catalog.ts");
|
||||
const entry = resolveStaticProviderCatalogEntry("bazaarlink");
|
||||
assert.ok(entry, "bazaarlink must resolve through the static provider catalog");
|
||||
assert.ok(entry.id, "bazaarlink", "resolved entry id must match");
|
||||
assert.equal(entry.category, "apikey", "bazaarlink must be in the apikey category");
|
||||
});
|
||||
|
||||
it("passes isManagedProviderConnectionId check so connections can be created", async () => {
|
||||
const { isManagedProviderConnectionId } = await import("../../src/lib/providers/catalog.ts");
|
||||
const result = isManagedProviderConnectionId("bazaarlink");
|
||||
assert.equal(result, true, "bazaarlink must pass isManagedProviderConnectionId");
|
||||
});
|
||||
|
||||
it("supports bulk API key add (not excluded)", async () => {
|
||||
const { supportsBulkApiKey } = await import("../../src/shared/constants/providers.ts");
|
||||
const result = supportsBulkApiKey("bazaarlink");
|
||||
assert.equal(result, true, "bazaarlink must support bulk API key add");
|
||||
});
|
||||
});
|
||||
@@ -149,3 +149,33 @@ test("Every Codex registry model resolves a non-zero pricing row (alias: cx)", a
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("Every Qwen registry model resolves a non-zero pricing row (alias: qw)", async () => {
|
||||
const { getPricingForModel } = await import("../../src/shared/constants/pricing.ts");
|
||||
const models = getModelsByProviderId("qwen");
|
||||
assert.ok(models.length > 0, "qwen must expose models");
|
||||
|
||||
for (const model of models) {
|
||||
// Qwen pricing lives under the "qw" alias (its DEFAULT_PRICING key).
|
||||
const pricing = getPricingForModel("qw", model.id) as {
|
||||
input?: number;
|
||||
output?: number;
|
||||
} | null;
|
||||
assert.ok(pricing, `qw pricing must include qwen model "${model.id}"`);
|
||||
assert.equal(
|
||||
typeof pricing?.input === "number" && typeof pricing?.output === "number",
|
||||
true,
|
||||
`qw pricing for "${model.id}" must have numeric input/output`
|
||||
);
|
||||
}
|
||||
|
||||
// Regression guard: the "coder-model" id (Qwen3.5/3.6 Coder Model, ported from
|
||||
// upstream 9router PR #156) must be priced like the other Qwen coder tier.
|
||||
const coderModel = getPricingForModel("qw", "coder-model") as {
|
||||
input: number;
|
||||
output: number;
|
||||
} | null;
|
||||
assert.ok(coderModel, "qw pricing must include coder-model");
|
||||
assert.equal(typeof coderModel?.input, "number");
|
||||
assert.equal(typeof coderModel?.output, "number");
|
||||
});
|
||||
|
||||
@@ -247,6 +247,19 @@ test("checkPipelineGates reapplies runtime breaker settings to existing breakers
|
||||
});
|
||||
|
||||
test("handleNoCredentials reports missing provider credentials and exhausted accounts", async () => {
|
||||
// Ported from upstream decolua/9router#336 (Ibrahim Ryan): when a provider has
|
||||
// zero usable connections (all disabled, or none configured at all), the
|
||||
// historical 400 BAD_REQUEST classified the failure as non-fallbackable, so a
|
||||
// combo like `antigravity/opus → github/opus` died on the first leg with a
|
||||
// hard 400 even though the next combo target was perfectly healthy.
|
||||
//
|
||||
// The combo target loop (open-sse/services/combo.ts) deliberately breaks on
|
||||
// 400 to prevent infinite fallback loops with body-specific 4xx errors
|
||||
// (#4279/PR#4316). 404 NOT_FOUND, by contrast, flows through checkFallbackError
|
||||
// as `shouldFallback: true` (generic-error catch-all path,
|
||||
// open-sse/services/accountFallback.ts:1593-1599) so the next combo target is
|
||||
// tried. We surface "no active credentials" as 404 so combo can skip past a
|
||||
// disabled-credentials provider instead of failing the whole request.
|
||||
const missing = handleNoCredentials(null, null, "openai", "gpt-4o-mini", null, null);
|
||||
const exhausted = handleNoCredentials(
|
||||
null,
|
||||
@@ -260,8 +273,8 @@ test("handleNoCredentials reports missing provider credentials and exhausted acc
|
||||
const missingJson = (await missing.json()) as any;
|
||||
const exhaustedJson = (await exhausted.json()) as any;
|
||||
|
||||
assert.equal(missing.status, 400);
|
||||
assert.match(missingJson.error.message, /No credentials for provider: openai/);
|
||||
assert.equal(missing.status, 404);
|
||||
assert.match(missingJson.error.message, /No active credentials for provider: openai/);
|
||||
assert.equal(exhausted.status, 500);
|
||||
assert.match(exhaustedJson.error.message, /Primary account failed/);
|
||||
});
|
||||
|
||||
@@ -300,7 +300,11 @@ test("handleChat keeps the combo error when the global fallback throws", async (
|
||||
assert.match(json.error.message, /primary combo failed/i);
|
||||
});
|
||||
|
||||
test("handleChat returns 400 when no provider credentials exist", async () => {
|
||||
test("handleChat returns 404 when no provider credentials exist", async () => {
|
||||
// Upstream port decolua/9router#336 (Ibrahim Ryan): the no-credentials branch
|
||||
// of handleNoCredentials now surfaces 404 NOT_FOUND so combo routing can fall
|
||||
// through to the next target instead of being killed by the combo 400-hard-stop
|
||||
// guard (open-sse/services/combo.ts, PR #4316 / issue #4279).
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
@@ -312,8 +316,8 @@ test("handleChat returns 400 when no provider credentials exist", async () => {
|
||||
);
|
||||
const json = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(json.error.message, /No credentials for provider: openai/);
|
||||
assert.equal(response.status, 404);
|
||||
assert.match(json.error.message, /No active credentials for provider: openai/);
|
||||
});
|
||||
|
||||
test("handleChat returns 503 for cooled-down connections and 503 for open circuit breakers", async () => {
|
||||
|
||||
69
tests/unit/chatcore-background-redirect.test.ts
Normal file
69
tests/unit/chatcore-background-redirect.test.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
// tests/unit/chatcore-background-redirect.test.ts
|
||||
// Characterization of resolveBackgroundTaskRedirect — the Background Task Redirection (T41)
|
||||
// decision extracted from handleChatCore (chatCore god-file decomposition, #3501). Decides whether
|
||||
// a request should be downgraded to a cheaper model: only when the feature is enabled, the request
|
||||
// looks like a background task, AND the model has a degradation mapping. The handler keeps the
|
||||
// effects (log, model/body mutation, audit). Uses the real backgroundTaskDetector config via its
|
||||
// setter so the decision is exercised end-to-end.
|
||||
import { test, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveBackgroundTaskRedirect } from "../../open-sse/handlers/chatCore/backgroundRedirect.ts";
|
||||
import { setBackgroundDegradationConfig } from "../../open-sse/services/backgroundTaskDetector.ts";
|
||||
|
||||
afterEach(() => {
|
||||
setBackgroundDegradationConfig({ enabled: false, degradationMap: {} });
|
||||
});
|
||||
|
||||
test("disabled config → no detection, no redirect (even for a clear background task)", () => {
|
||||
setBackgroundDegradationConfig({ enabled: false, degradationMap: { "gpt-5": "gpt-5-mini" } });
|
||||
assert.deepEqual(
|
||||
resolveBackgroundTaskRedirect({ body: { max_tokens: 10 }, headers: null, model: "gpt-5" }),
|
||||
{ backgroundReason: null, redirect: null }
|
||||
);
|
||||
});
|
||||
|
||||
test("enabled + low-max-tokens background + mapped model → detection + redirect", () => {
|
||||
setBackgroundDegradationConfig({ enabled: true, degradationMap: { "gpt-5": "gpt-5-mini" } });
|
||||
const r = resolveBackgroundTaskRedirect({
|
||||
body: { max_tokens: 10 },
|
||||
headers: null,
|
||||
model: "gpt-5",
|
||||
});
|
||||
assert.deepEqual(r, {
|
||||
backgroundReason: "low_max_tokens",
|
||||
redirect: { degradedModel: "gpt-5-mini", reason: "low_max_tokens" },
|
||||
});
|
||||
});
|
||||
|
||||
test("enabled + x-task-type:background header → reason header_background", () => {
|
||||
setBackgroundDegradationConfig({ enabled: true, degradationMap: { "gpt-5": "gpt-5-mini" } });
|
||||
const r = resolveBackgroundTaskRedirect({
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
headers: { "x-task-type": "background" },
|
||||
model: "gpt-5",
|
||||
});
|
||||
assert.deepEqual(r, {
|
||||
backgroundReason: "header_background",
|
||||
redirect: { degradedModel: "gpt-5-mini", reason: "header_background" },
|
||||
});
|
||||
});
|
||||
|
||||
test("enabled but the request is not a background task → no detection, no redirect", () => {
|
||||
setBackgroundDegradationConfig({ enabled: true, degradationMap: { "gpt-5": "gpt-5-mini" } });
|
||||
assert.deepEqual(
|
||||
resolveBackgroundTaskRedirect({
|
||||
body: { max_tokens: 4096, messages: [{ role: "user", content: "write an essay" }] },
|
||||
headers: null,
|
||||
model: "gpt-5",
|
||||
}),
|
||||
{ backgroundReason: null, redirect: null }
|
||||
);
|
||||
});
|
||||
|
||||
test("enabled + background but the model has no degradation mapping → detection but no redirect", () => {
|
||||
setBackgroundDegradationConfig({ enabled: true, degradationMap: { "gpt-5": "gpt-5-mini" } });
|
||||
assert.deepEqual(
|
||||
resolveBackgroundTaskRedirect({ body: { max_tokens: 10 }, headers: null, model: "claude-opus-4" }),
|
||||
{ backgroundReason: "low_max_tokens", redirect: null }
|
||||
);
|
||||
});
|
||||
107
tests/unit/chatcore-claude-effort-variant.test.ts
Normal file
107
tests/unit/chatcore-claude-effort-variant.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
// tests/unit/chatcore-claude-effort-variant.test.ts
|
||||
// Characterization of applyClaudeEffortVariant — the Claude effort-suffix normalization extracted
|
||||
// from handleChatCore (chatCore god-file decomposition, #3501). The VS Code "Effort" slider
|
||||
// advertises claude-...-{low,medium,high,xhigh,max}; Anthropic has no such model, so the suffix is
|
||||
// stripped to the base id and surfaced as reasoning_effort. Locks: the provider gate (claude /
|
||||
// claude-code-compatible only), the in-place body mutation (model + reasoning_effort), the
|
||||
// sourceFormat==="claude" skip, the explicit-effort-wins rule, and the returned effectiveModel/log.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { applyClaudeEffortVariant } from "../../open-sse/handlers/chatCore/claudeEffortVariant.ts";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.ts";
|
||||
|
||||
test("claude provider + effort suffix → strips to base, mutates body model + reasoning_effort, returns log", () => {
|
||||
const body: Record<string, unknown> = { model: "claude-sonnet-4-high", messages: [] };
|
||||
const r = applyClaudeEffortVariant({
|
||||
provider: "claude",
|
||||
effectiveModel: "claude-sonnet-4-high",
|
||||
body,
|
||||
sourceFormat: FORMATS.OPENAI,
|
||||
});
|
||||
assert.equal(r.effectiveModel, "claude-sonnet-4");
|
||||
assert.equal(body.model, "claude-sonnet-4");
|
||||
assert.equal(body.reasoning_effort, "high");
|
||||
assert.match(String(r.log), /stripped "-high" → claude-sonnet-4 \(reasoning_effort=high\)/);
|
||||
});
|
||||
|
||||
test("claude-code-compatible provider triggers the same stripping", () => {
|
||||
const body: Record<string, unknown> = { model: "claude-opus-4-xhigh", messages: [] };
|
||||
const r = applyClaudeEffortVariant({
|
||||
provider: "anthropic-compatible-cc-default",
|
||||
effectiveModel: "claude-opus-4-xhigh",
|
||||
body,
|
||||
sourceFormat: FORMATS.OPENAI,
|
||||
});
|
||||
assert.equal(r.effectiveModel, "claude-opus-4");
|
||||
assert.equal(body.model, "claude-opus-4");
|
||||
assert.equal(body.reasoning_effort, "xhigh");
|
||||
});
|
||||
|
||||
test("sourceFormat 'claude' strips the model but does NOT inject reasoning_effort", () => {
|
||||
const body: Record<string, unknown> = { model: "claude-sonnet-4-medium", messages: [] };
|
||||
const r = applyClaudeEffortVariant({
|
||||
provider: "claude",
|
||||
effectiveModel: "claude-sonnet-4-medium",
|
||||
body,
|
||||
sourceFormat: FORMATS.CLAUDE,
|
||||
});
|
||||
assert.equal(r.effectiveModel, "claude-sonnet-4");
|
||||
assert.equal(body.model, "claude-sonnet-4");
|
||||
assert.equal(body.reasoning_effort, undefined);
|
||||
});
|
||||
|
||||
test("an explicit client reasoning_effort wins (not overwritten)", () => {
|
||||
const body: Record<string, unknown> = { model: "claude-sonnet-4-low", reasoning_effort: "high", messages: [] };
|
||||
const r = applyClaudeEffortVariant({
|
||||
provider: "claude",
|
||||
effectiveModel: "claude-sonnet-4-low",
|
||||
body,
|
||||
sourceFormat: FORMATS.OPENAI,
|
||||
});
|
||||
assert.equal(r.effectiveModel, "claude-sonnet-4");
|
||||
assert.equal(body.reasoning_effort, "high"); // unchanged
|
||||
});
|
||||
|
||||
test("explicit effort nested under reasoning.effort also wins", () => {
|
||||
const body: Record<string, unknown> = {
|
||||
model: "claude-sonnet-4-low",
|
||||
reasoning: { effort: "medium" },
|
||||
messages: [],
|
||||
};
|
||||
const r = applyClaudeEffortVariant({
|
||||
provider: "claude",
|
||||
effectiveModel: "claude-sonnet-4-low",
|
||||
body,
|
||||
sourceFormat: FORMATS.OPENAI,
|
||||
});
|
||||
assert.equal(body.reasoning_effort, undefined); // explicit reasoning.effort present → no injection
|
||||
assert.equal(r.effectiveModel, "claude-sonnet-4");
|
||||
});
|
||||
|
||||
test("no effort suffix → no change, no log", () => {
|
||||
const body: Record<string, unknown> = { model: "claude-sonnet-4", messages: [] };
|
||||
const r = applyClaudeEffortVariant({
|
||||
provider: "claude",
|
||||
effectiveModel: "claude-sonnet-4",
|
||||
body,
|
||||
sourceFormat: FORMATS.OPENAI,
|
||||
});
|
||||
assert.equal(r.effectiveModel, "claude-sonnet-4");
|
||||
assert.equal(body.model, "claude-sonnet-4");
|
||||
assert.equal(body.reasoning_effort, undefined);
|
||||
assert.equal(r.log, null);
|
||||
});
|
||||
|
||||
test("non-claude provider is a no-op even with an effort suffix", () => {
|
||||
const body: Record<string, unknown> = { model: "gpt-5-high", messages: [] };
|
||||
const r = applyClaudeEffortVariant({
|
||||
provider: "openai",
|
||||
effectiveModel: "gpt-5-high",
|
||||
body,
|
||||
sourceFormat: FORMATS.OPENAI,
|
||||
});
|
||||
assert.equal(r.effectiveModel, "gpt-5-high");
|
||||
assert.equal(body.model, "gpt-5-high");
|
||||
assert.equal(body.reasoning_effort, undefined);
|
||||
assert.equal(r.log, null);
|
||||
});
|
||||
106
tests/unit/chatcore-codex-quota.test.ts
Normal file
106
tests/unit/chatcore-codex-quota.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
// tests/unit/chatcore-codex-quota.test.ts
|
||||
// Characterization of buildCodexQuotaPersistence — the pure core of handleChatCore's
|
||||
// persistCodexQuotaState, extracted during the chatCore god-file decomposition (#3501). Locks the
|
||||
// shape of the persisted providerSpecificData: the codexQuotaState snapshot, the existing-data
|
||||
// passthrough, and the 429 dual-window exhaustion fields (codexScopeRateLimitedUntil /
|
||||
// codexExhaustedWindow) plus the debug-log message. The handler keeps the DB write, the
|
||||
// preflight-cache invalidation, and the log emission; this function only builds the data.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildCodexQuotaPersistence } from "../../open-sse/handlers/chatCore/codexQuota.ts";
|
||||
import { getCodexModelScope } from "../../open-sse/executors/codex.ts";
|
||||
|
||||
const MODEL = "gpt-5-codex";
|
||||
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
||||
|
||||
function quotaHeaders(over: Record<string, string> = {}) {
|
||||
return {
|
||||
"x-codex-5h-usage": "50",
|
||||
"x-codex-5h-limit": "100",
|
||||
"x-codex-5h-reset-at": "2999-01-01T00:00:00.000Z",
|
||||
"x-codex-7d-usage": "10",
|
||||
"x-codex-7d-limit": "100",
|
||||
"x-codex-7d-reset-at": "2999-01-08T00:00:00.000Z",
|
||||
...over,
|
||||
};
|
||||
}
|
||||
|
||||
test("returns null when the response carries no codex quota headers", () => {
|
||||
assert.equal(
|
||||
buildCodexQuotaPersistence({ headers: {}, existingProviderData: {}, modelForScope: MODEL, status: 200 }),
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
buildCodexQuotaPersistence({ headers: { "content-type": "application/json" }, existingProviderData: {}, modelForScope: MODEL, status: 200 }),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("builds codexQuotaState (parsed numbers + scope + updatedAt) and preserves existing provider data", () => {
|
||||
const built = buildCodexQuotaPersistence({
|
||||
headers: quotaHeaders(),
|
||||
existingProviderData: { keepMe: "yes", apiKeyHealth: { primary: {} } },
|
||||
modelForScope: MODEL,
|
||||
status: 200,
|
||||
});
|
||||
assert.ok(built);
|
||||
const qs = built.nextProviderData.codexQuotaState as Record<string, unknown>;
|
||||
assert.equal(qs.usage5h, 50);
|
||||
assert.equal(qs.limit5h, 100);
|
||||
assert.equal(qs.usage7d, 10);
|
||||
assert.equal(qs.limit7d, 100);
|
||||
assert.equal(qs.scope, getCodexModelScope(MODEL));
|
||||
assert.match(String(qs.updatedAt), ISO);
|
||||
// existing keys passed through, not dropped
|
||||
assert.equal(built.nextProviderData.keepMe, "yes");
|
||||
assert.deepEqual(built.nextProviderData.apiKeyHealth, { primary: {} });
|
||||
// non-429 → no exhaustion fields, no log
|
||||
assert.equal(built.exhaustionLog, null);
|
||||
assert.equal(built.nextProviderData.codexScopeRateLimitedUntil, undefined);
|
||||
assert.equal(built.nextProviderData.codexExhaustedWindow, undefined);
|
||||
});
|
||||
|
||||
test("429 with a near-exhausted 5h window records the per-scope cooldown + window + log", () => {
|
||||
const built = buildCodexQuotaPersistence({
|
||||
headers: quotaHeaders({ "x-codex-5h-usage": "100" }), // ratio 1.0 >= 0.95, reset far in the future
|
||||
existingProviderData: {},
|
||||
modelForScope: MODEL,
|
||||
status: 429,
|
||||
});
|
||||
assert.ok(built);
|
||||
assert.equal(built.nextProviderData.codexExhaustedWindow, "5h");
|
||||
const scope = getCodexModelScope(MODEL);
|
||||
const scopeMap = built.nextProviderData.codexScopeRateLimitedUntil as Record<string, string>;
|
||||
assert.ok(scopeMap[scope]?.startsWith("2999-01-01T00:00:00"));
|
||||
assert.match(
|
||||
String(built.exhaustionLog),
|
||||
/^Quota exhaustion on 5h window, cooldown until 2999-01-01T00:00:00/
|
||||
);
|
||||
});
|
||||
|
||||
test("429 merges into an existing codexScopeRateLimitedUntil map without dropping other scopes", () => {
|
||||
const built = buildCodexQuotaPersistence({
|
||||
headers: quotaHeaders({ "x-codex-5h-usage": "100" }),
|
||||
existingProviderData: { codexScopeRateLimitedUntil: { "other-scope": "2999-12-31T00:00:00.000Z" } },
|
||||
modelForScope: MODEL,
|
||||
status: 429,
|
||||
});
|
||||
assert.ok(built);
|
||||
const scopeMap = built.nextProviderData.codexScopeRateLimitedUntil as Record<string, string>;
|
||||
assert.equal(scopeMap["other-scope"], "2999-12-31T00:00:00.000Z");
|
||||
assert.ok(scopeMap[getCodexModelScope(MODEL)]);
|
||||
});
|
||||
|
||||
test("429 below the exhaustion threshold builds the snapshot but no cooldown / no log", () => {
|
||||
const built = buildCodexQuotaPersistence({
|
||||
headers: quotaHeaders({ "x-codex-5h-usage": "1", "x-codex-7d-usage": "1" }), // ratios well under 0.95
|
||||
existingProviderData: {},
|
||||
modelForScope: MODEL,
|
||||
status: 429,
|
||||
});
|
||||
assert.ok(built);
|
||||
assert.ok(built.nextProviderData.codexQuotaState);
|
||||
assert.equal(built.exhaustionLog, null);
|
||||
assert.equal(built.nextProviderData.codexScopeRateLimitedUntil, undefined);
|
||||
assert.equal(built.nextProviderData.codexExhaustedWindow, undefined);
|
||||
});
|
||||
79
tests/unit/chatcore-failure-usage.test.ts
Normal file
79
tests/unit/chatcore-failure-usage.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// tests/unit/chatcore-failure-usage.test.ts
|
||||
// Characterization of buildFailureUsageRecord — the failed-request usage payload builder extracted
|
||||
// from handleChatCore's persistFailureUsage closure (chatCore god-file decomposition, #3501). Pure:
|
||||
// the handler keeps the fire-and-forget saveRequestUsage(...).catch() call and computes latencyMs;
|
||||
// this builds the record. Locks the unknown/undefined fallbacks, the zeroed token/timing fields,
|
||||
// the combo-strategy gate, and the ISO timestamp.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildFailureUsageRecord } from "../../open-sse/handlers/chatCore/failureUsage.ts";
|
||||
|
||||
const ISO = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
||||
|
||||
test("maps a fully-populated failure into the usage record", () => {
|
||||
const r = buildFailureUsageRecord({
|
||||
provider: "openai",
|
||||
model: "gpt-4o",
|
||||
connectionId: "conn-1",
|
||||
apiKeyInfo: { id: "key-1", name: "My Key" },
|
||||
effectiveServiceTier: "priority",
|
||||
isCombo: true,
|
||||
comboStrategy: "weighted",
|
||||
statusCode: 429,
|
||||
errorCode: "rate_limited",
|
||||
latencyMs: 1234,
|
||||
});
|
||||
assert.equal(r.provider, "openai");
|
||||
assert.equal(r.model, "gpt-4o");
|
||||
assert.deepEqual(r.tokens, { input: 0, output: 0, cacheRead: 0, cacheCreation: 0, reasoning: 0 });
|
||||
assert.equal(r.status, "429");
|
||||
assert.equal(r.success, false);
|
||||
assert.equal(r.latencyMs, 1234);
|
||||
assert.equal(r.timeToFirstTokenMs, 0);
|
||||
assert.equal(r.errorCode, "rate_limited");
|
||||
assert.match(String(r.timestamp), ISO);
|
||||
assert.equal(r.connectionId, "conn-1");
|
||||
assert.equal(r.apiKeyId, "key-1");
|
||||
assert.equal(r.apiKeyName, "My Key");
|
||||
assert.equal(r.serviceTier, "priority");
|
||||
assert.equal(r.comboStrategy, "weighted");
|
||||
});
|
||||
|
||||
test("applies the unknown/undefined fallbacks", () => {
|
||||
const r = buildFailureUsageRecord({
|
||||
provider: null,
|
||||
model: undefined,
|
||||
connectionId: null,
|
||||
apiKeyInfo: null,
|
||||
effectiveServiceTier: "standard",
|
||||
isCombo: false,
|
||||
comboStrategy: "priority",
|
||||
statusCode: 502,
|
||||
errorCode: null,
|
||||
latencyMs: 7,
|
||||
});
|
||||
assert.equal(r.provider, "unknown");
|
||||
assert.equal(r.model, "unknown");
|
||||
assert.equal(r.errorCode, "502"); // falls back to String(statusCode)
|
||||
assert.equal(r.connectionId, undefined);
|
||||
assert.equal(r.apiKeyId, undefined);
|
||||
assert.equal(r.apiKeyName, undefined);
|
||||
// isCombo false → comboStrategy is dropped even when supplied
|
||||
assert.equal(r.comboStrategy, undefined);
|
||||
});
|
||||
|
||||
test("combo strategy is included only for combo requests", () => {
|
||||
const combo = buildFailureUsageRecord({
|
||||
provider: "x", model: "y", connectionId: null, apiKeyInfo: null,
|
||||
effectiveServiceTier: "standard", isCombo: true, comboStrategy: "round-robin",
|
||||
statusCode: 500, errorCode: "boom", latencyMs: 1,
|
||||
});
|
||||
assert.equal(combo.comboStrategy, "round-robin");
|
||||
|
||||
const comboNoStrategy = buildFailureUsageRecord({
|
||||
provider: "x", model: "y", connectionId: null, apiKeyInfo: null,
|
||||
effectiveServiceTier: "standard", isCombo: true, comboStrategy: null,
|
||||
statusCode: 500, errorCode: "boom", latencyMs: 1,
|
||||
});
|
||||
assert.equal(comboNoStrategy.comboStrategy, undefined);
|
||||
});
|
||||
70
tests/unit/chatcore-key-health.test.ts
Normal file
70
tests/unit/chatcore-key-health.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// tests/unit/chatcore-key-health.test.ts
|
||||
// Characterization of recordKeyHealthStatus — the per-request API-key health updater extracted
|
||||
// from handleChatCore (chatCore god-file decomposition, #3501). Locks the observable in-memory
|
||||
// transitions driven through apiKeyRotator: 401 → failure (warning, then invalid at the threshold),
|
||||
// 2xx → success/recovery, selectedKeyId scoping, and the no-op paths (missing connectionId, and
|
||||
// non-401/non-2xx statuses). The DB persistence side effect (updateProviderConnection) is moved
|
||||
// byte-identically and is fire-and-forget; these tests assert the synchronous health mutations.
|
||||
import { test, afterEach } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { recordKeyHealthStatus } from "../../open-sse/handlers/chatCore/keyHealth.ts";
|
||||
import { getAllKeyHealth, removeConnectionHealth } from "../../open-sse/services/apiKeyRotator.ts";
|
||||
|
||||
const noopLog = { warn: () => {}, error: () => {} };
|
||||
const touched: string[] = [];
|
||||
|
||||
function creds(connectionId: string, psd: Record<string, unknown> = {}) {
|
||||
touched.push(connectionId);
|
||||
return { connectionId, providerSpecificData: psd };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const c of touched.splice(0)) removeConnectionHealth(c);
|
||||
});
|
||||
|
||||
test("missing connectionId is a no-op (no health entry created)", () => {
|
||||
const before = Object.keys(getAllKeyHealth()).length;
|
||||
const r = recordKeyHealthStatus(200, { providerSpecificData: {} }, noopLog);
|
||||
assert.equal(r, undefined);
|
||||
assert.equal(Object.keys(getAllKeyHealth()).length, before);
|
||||
});
|
||||
|
||||
test("401 marks the selected key as failed → warning after the first failure", () => {
|
||||
const conn = "kh-401-warning";
|
||||
recordKeyHealthStatus(401, creds(conn), noopLog);
|
||||
const h = getAllKeyHealth()[`${conn}:primary`];
|
||||
assert.equal(h?.failures, 1);
|
||||
assert.equal(h?.status, "warning");
|
||||
});
|
||||
|
||||
test("401 reaches invalid at the failure threshold (2 consecutive)", () => {
|
||||
const conn = "kh-401-invalid";
|
||||
recordKeyHealthStatus(401, creds(conn), noopLog);
|
||||
recordKeyHealthStatus(401, creds(conn), noopLog);
|
||||
const h = getAllKeyHealth()[`${conn}:primary`];
|
||||
assert.equal(h?.failures, 2);
|
||||
assert.equal(h?.status, "invalid");
|
||||
});
|
||||
|
||||
test("2xx after a failure resets the key to active with 0 failures", () => {
|
||||
const conn = "kh-2xx-recover";
|
||||
recordKeyHealthStatus(401, creds(conn), noopLog);
|
||||
recordKeyHealthStatus(204, creds(conn), noopLog);
|
||||
const h = getAllKeyHealth()[`${conn}:primary`];
|
||||
assert.equal(h?.failures, 0);
|
||||
assert.equal(h?.status, "active");
|
||||
});
|
||||
|
||||
test("honors selectedKeyId — scopes the update to the active extra key, not primary", () => {
|
||||
const conn = "kh-selected-key";
|
||||
recordKeyHealthStatus(401, creds(conn, { selectedKeyId: "extra_1" }), noopLog);
|
||||
const all = getAllKeyHealth();
|
||||
assert.equal(all[`${conn}:extra_1`]?.status, "warning");
|
||||
assert.equal(all[`${conn}:primary`], undefined);
|
||||
});
|
||||
|
||||
test("non-401 / non-2xx status does not touch key health", () => {
|
||||
const conn = "kh-5xx-noop";
|
||||
recordKeyHealthStatus(500, creds(conn), noopLog);
|
||||
assert.equal(getAllKeyHealth()[`${conn}:primary`], undefined);
|
||||
});
|
||||
103
tests/unit/chatcore-request-format.test.ts
Normal file
103
tests/unit/chatcore-request-format.test.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// tests/unit/chatcore-request-format.test.ts
|
||||
// Characterization of resolveChatCoreRequestFormat — the endpoint/format resolution slice extracted
|
||||
// from the top of handleChatCore (chatCore god-file decomposition, #3501). Locks the endpointPath
|
||||
// construction, the /responses detection, the nativeCodexPassthrough + isDroidCLI + copilot wiring,
|
||||
// and the clientResponseFormat downgrade (OpenAI Responses shape off a non-/responses, non-Droid
|
||||
// endpoint collapses to plain OpenAI).
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveChatCoreRequestFormat } from "../../open-sse/handlers/chatCore/requestFormat.ts";
|
||||
import { shouldUseNativeCodexPassthrough } from "../../open-sse/handlers/chatCore/passthroughHelpers.ts";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.ts";
|
||||
|
||||
const base = { body: { messages: [{ role: "user", content: "hi" }] }, provider: "openai", userAgent: "unit-test" };
|
||||
|
||||
test("chat/completions endpoint → openai source, not a responses endpoint, no downgrade", () => {
|
||||
const r = resolveChatCoreRequestFormat({
|
||||
...base,
|
||||
clientRawRequest: { endpoint: "/v1/chat/completions", headers: new Headers() },
|
||||
});
|
||||
assert.equal(r.endpointPath, "/v1/chat/completions");
|
||||
assert.equal(r.sourceFormat, FORMATS.OPENAI);
|
||||
assert.equal(r.isResponsesEndpoint, false);
|
||||
assert.equal(r.clientResponseFormat, FORMATS.OPENAI);
|
||||
assert.equal(r.nativeCodexPassthrough, false); // provider !== codex
|
||||
assert.equal(r.isDroidCLI, false);
|
||||
assert.equal(r.copilotCompatibleReasoning, false);
|
||||
});
|
||||
|
||||
test("/responses endpoint → openai-responses source + isResponsesEndpoint, kept (no downgrade)", () => {
|
||||
const r = resolveChatCoreRequestFormat({
|
||||
body: { input: "x" },
|
||||
provider: "openai",
|
||||
userAgent: "unit-test",
|
||||
clientRawRequest: { endpoint: "/v1/responses", headers: new Headers() },
|
||||
});
|
||||
assert.equal(r.sourceFormat, FORMATS.OPENAI_RESPONSES);
|
||||
assert.equal(r.isResponsesEndpoint, true);
|
||||
assert.equal(r.clientResponseFormat, FORMATS.OPENAI_RESPONSES);
|
||||
});
|
||||
|
||||
test("Responses-shaped body on a /chat/completions endpoint downgrades clientResponseFormat to openai", () => {
|
||||
const r = resolveChatCoreRequestFormat({
|
||||
body: { input: "describe" }, // input + no messages → openai-responses via body
|
||||
provider: "openai",
|
||||
userAgent: "unit-test",
|
||||
clientRawRequest: { endpoint: "/v1/chat/completions", headers: new Headers() },
|
||||
});
|
||||
assert.equal(r.sourceFormat, FORMATS.OPENAI_RESPONSES);
|
||||
assert.equal(r.isResponsesEndpoint, false);
|
||||
assert.equal(r.clientResponseFormat, FORMATS.OPENAI); // downgraded
|
||||
});
|
||||
|
||||
test("Droid CLI suppresses the downgrade (clientResponseFormat stays openai-responses)", () => {
|
||||
const r = resolveChatCoreRequestFormat({
|
||||
body: { input: "describe" },
|
||||
provider: "openai",
|
||||
userAgent: "Droid/1.2 codex-cli",
|
||||
clientRawRequest: { endpoint: "/v1/chat/completions", headers: new Headers() },
|
||||
});
|
||||
assert.equal(r.isDroidCLI, true);
|
||||
assert.equal(r.sourceFormat, FORMATS.OPENAI_RESPONSES);
|
||||
assert.equal(r.clientResponseFormat, FORMATS.OPENAI_RESPONSES); // !isDroidCLI is false → no downgrade
|
||||
});
|
||||
|
||||
test("copilotCompatibleReasoning detects copilot via header or user-agent", () => {
|
||||
const viaUa = resolveChatCoreRequestFormat({
|
||||
...base,
|
||||
userAgent: "GitHubCopilotChat/0.1",
|
||||
clientRawRequest: { endpoint: "/v1/chat/completions", headers: new Headers() },
|
||||
});
|
||||
assert.equal(viaUa.copilotCompatibleReasoning, true);
|
||||
|
||||
const viaHeader = resolveChatCoreRequestFormat({
|
||||
...base,
|
||||
clientRawRequest: {
|
||||
endpoint: "/v1/chat/completions",
|
||||
headers: new Headers({ "x-client": "copilot-vscode" }),
|
||||
},
|
||||
});
|
||||
assert.equal(viaHeader.copilotCompatibleReasoning, true);
|
||||
});
|
||||
|
||||
test("nativeCodexPassthrough delegates to shouldUseNativeCodexPassthrough (codex + responses)", () => {
|
||||
const r = resolveChatCoreRequestFormat({
|
||||
body: { input: "x" },
|
||||
provider: "codex",
|
||||
userAgent: "unit-test",
|
||||
clientRawRequest: { endpoint: "/v1/responses", headers: new Headers() },
|
||||
});
|
||||
assert.equal(
|
||||
r.nativeCodexPassthrough,
|
||||
shouldUseNativeCodexPassthrough({
|
||||
provider: "codex",
|
||||
sourceFormat: r.sourceFormat,
|
||||
endpointPath: r.endpointPath,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
test("missing clientRawRequest → empty endpointPath", () => {
|
||||
const r = resolveChatCoreRequestFormat({ ...base, clientRawRequest: null });
|
||||
assert.equal(r.endpointPath, "");
|
||||
});
|
||||
89
tests/unit/chatcore-target-format.test.ts
Normal file
89
tests/unit/chatcore-target-format.test.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
// tests/unit/chatcore-target-format.test.ts
|
||||
// Characterization of resolveChatCoreTargetFormat — the wire target-format resolution extracted
|
||||
// from handleChatCore (chatCore god-file decomposition, #3501). Resolves the provider alias and the
|
||||
// upstream target format: apiFormat==="responses" forces OpenAI Responses; otherwise the model's
|
||||
// registry target format, then the custom-model override, then the provider default. Returns both
|
||||
// `alias` (reused downstream when stripping the alias/ prefix off the upstream model) and
|
||||
// `targetFormat`. Asserted against the inline composition so the delegation stays byte-identical.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveChatCoreTargetFormat } from "../../open-sse/handlers/chatCore/targetFormat.ts";
|
||||
import { PROVIDER_ID_TO_ALIAS, getModelTargetFormat } from "../../open-sse/config/providerModels.ts";
|
||||
import { getTargetFormat } from "../../open-sse/services/provider.ts";
|
||||
import { FORMATS } from "../../open-sse/translator/formats.ts";
|
||||
|
||||
function expected(
|
||||
provider: string,
|
||||
resolvedModel: string,
|
||||
apiFormat: string | undefined,
|
||||
customModelTargetFormat: string | undefined,
|
||||
providerSpecificData: unknown
|
||||
) {
|
||||
const alias = PROVIDER_ID_TO_ALIAS[provider] || provider;
|
||||
const modelTargetFormat = getModelTargetFormat(alias, resolvedModel);
|
||||
const targetFormat =
|
||||
apiFormat === "responses"
|
||||
? FORMATS.OPENAI_RESPONSES
|
||||
: modelTargetFormat || customModelTargetFormat || getTargetFormat(provider, providerSpecificData);
|
||||
return { alias, targetFormat };
|
||||
}
|
||||
|
||||
test("apiFormat='responses' short-circuits to OPENAI_RESPONSES (alias still resolved)", () => {
|
||||
const r = resolveChatCoreTargetFormat({
|
||||
provider: "openai",
|
||||
resolvedModel: "gpt-4o",
|
||||
apiFormat: "responses",
|
||||
customModelTargetFormat: undefined,
|
||||
providerSpecificData: undefined,
|
||||
});
|
||||
assert.equal(r.targetFormat, FORMATS.OPENAI_RESPONSES);
|
||||
assert.equal(r.alias, PROVIDER_ID_TO_ALIAS["openai"] || "openai");
|
||||
});
|
||||
|
||||
test("delegates byte-identically for a normal model (no apiFormat / no custom override)", () => {
|
||||
const r = resolveChatCoreTargetFormat({
|
||||
provider: "openai",
|
||||
resolvedModel: "gpt-4o",
|
||||
apiFormat: undefined,
|
||||
customModelTargetFormat: undefined,
|
||||
providerSpecificData: undefined,
|
||||
});
|
||||
assert.deepEqual(r, expected("openai", "gpt-4o", undefined, undefined, undefined));
|
||||
});
|
||||
|
||||
test("customModelTargetFormat is used when the model has no registry target format", () => {
|
||||
const customModel = "totally-unknown-custom-model-xyz";
|
||||
// precondition: the registry has no target format for this unknown model
|
||||
assert.ok(!getModelTargetFormat(PROVIDER_ID_TO_ALIAS["openai"] || "openai", customModel));
|
||||
const r = resolveChatCoreTargetFormat({
|
||||
provider: "openai",
|
||||
resolvedModel: customModel,
|
||||
apiFormat: undefined,
|
||||
customModelTargetFormat: "claude",
|
||||
providerSpecificData: undefined,
|
||||
});
|
||||
assert.equal(r.targetFormat, "claude");
|
||||
});
|
||||
|
||||
test("falls back to getTargetFormat(provider) when neither model nor custom format apply", () => {
|
||||
const customModel = "totally-unknown-custom-model-xyz";
|
||||
const r = resolveChatCoreTargetFormat({
|
||||
provider: "openai",
|
||||
resolvedModel: customModel,
|
||||
apiFormat: undefined,
|
||||
customModelTargetFormat: undefined,
|
||||
providerSpecificData: undefined,
|
||||
});
|
||||
assert.equal(r.targetFormat, getTargetFormat("openai", undefined));
|
||||
});
|
||||
|
||||
test("unmapped provider → alias falls back to the provider id", () => {
|
||||
const r = resolveChatCoreTargetFormat({
|
||||
provider: "some-unmapped-provider",
|
||||
resolvedModel: "x",
|
||||
apiFormat: "responses",
|
||||
customModelTargetFormat: undefined,
|
||||
providerSpecificData: undefined,
|
||||
});
|
||||
assert.equal(r.alias, "some-unmapped-provider");
|
||||
});
|
||||
70
tests/unit/chatcore-upstream-execute-headers.test.ts
Normal file
70
tests/unit/chatcore-upstream-execute-headers.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// tests/unit/chatcore-upstream-execute-headers.test.ts
|
||||
// Characterization of buildUpstreamHeadersForExecute — the per-model upstream extra-header builder
|
||||
// extracted from handleChatCore (chatCore god-file decomposition, #3501). Locks: the
|
||||
// connection custom User-Agent override, the Claude Fast Mode opt-in (claude provider + enabled
|
||||
// settings + supported model only), and the modelToCall===effectiveModel vs alias branches.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildUpstreamHeadersForExecute } from "../../open-sse/handlers/chatCore/upstreamExecuteHeaders.ts";
|
||||
import { CPA_FORCE_FAST_MODE_HEADER } from "../../src/lib/providers/claudeFastMode.ts";
|
||||
|
||||
const base = {
|
||||
modelToCall: "some-model",
|
||||
effectiveModel: "some-model",
|
||||
provider: "openai",
|
||||
model: "some-model",
|
||||
resolvedModel: "some-model",
|
||||
sourceFormat: "openai",
|
||||
connectionCustomUserAgent: "",
|
||||
settings: {},
|
||||
};
|
||||
|
||||
test("no custom UA / non-claude / no fast settings → no UA and no fast-mode header", () => {
|
||||
const h = buildUpstreamHeadersForExecute({ ...base });
|
||||
assert.equal(h["User-Agent"], undefined);
|
||||
assert.equal(h[CPA_FORCE_FAST_MODE_HEADER], undefined);
|
||||
});
|
||||
|
||||
test("connection custom User-Agent overrides the upstream User-Agent", () => {
|
||||
const h = buildUpstreamHeadersForExecute({ ...base, connectionCustomUserAgent: "MyAgent/1.0" });
|
||||
assert.equal(h["User-Agent"], "MyAgent/1.0");
|
||||
});
|
||||
|
||||
test("claude provider + enabled fast-mode settings + supported model → CPA header set", () => {
|
||||
const h = buildUpstreamHeadersForExecute({
|
||||
...base,
|
||||
provider: "claude",
|
||||
modelToCall: "claude-fast-x",
|
||||
effectiveModel: "claude-fast-x",
|
||||
settings: { claudeFastMode: { enabled: true, supportedModels: ["claude-fast-x"] } },
|
||||
});
|
||||
assert.equal(h[CPA_FORCE_FAST_MODE_HEADER], "1");
|
||||
});
|
||||
|
||||
test("fast-mode header is NOT set when the model is not in the supported list", () => {
|
||||
const h = buildUpstreamHeadersForExecute({
|
||||
...base,
|
||||
provider: "claude",
|
||||
modelToCall: "claude-other",
|
||||
effectiveModel: "claude-other",
|
||||
settings: { claudeFastMode: { enabled: true, supportedModels: ["claude-fast-x"] } },
|
||||
});
|
||||
assert.equal(h[CPA_FORCE_FAST_MODE_HEADER], undefined);
|
||||
});
|
||||
|
||||
test("fast-mode header is NOT set for non-claude providers even with fast settings", () => {
|
||||
const h = buildUpstreamHeadersForExecute({
|
||||
...base,
|
||||
provider: "openai",
|
||||
modelToCall: "claude-fast-x",
|
||||
effectiveModel: "claude-fast-x",
|
||||
settings: { claudeFastMode: { enabled: true, supportedModels: ["claude-fast-x"] } },
|
||||
});
|
||||
assert.equal(h[CPA_FORCE_FAST_MODE_HEADER], undefined);
|
||||
});
|
||||
|
||||
test("returns a plain object (no per-model extra headers configured for unknown models)", () => {
|
||||
const h = buildUpstreamHeadersForExecute({ ...base, modelToCall: "totally-unknown", effectiveModel: "x" });
|
||||
assert.equal(typeof h, "object");
|
||||
assert.equal(h[CPA_FORCE_FAST_MODE_HEADER], undefined);
|
||||
});
|
||||
@@ -99,7 +99,6 @@ const TYPE_ONLY = new Set(["_rowTypes"]);
|
||||
// They remain in INTENTIONALLY_INTERNAL for schema-reservation reasons.
|
||||
// Flag them but do NOT fail — a separate decision is needed to remove them.
|
||||
const DOCUMENTED_DEAD = new Set([
|
||||
"compressionScheduler", // DEAD?: 0 production importers as of 2026-06-11
|
||||
"discovery", // DEAD?: 0 importers; lib/discovery/index.ts is independent
|
||||
"pluginMetrics", // DEAD? (production): write path not yet wired (self-documented)
|
||||
"prompts", // DEAD? (production): zero production callers; integration test only verifies interface shape
|
||||
@@ -122,7 +121,7 @@ test("INTENTIONALLY_INTERNAL is exported from check-db-rules.mjs", () => {
|
||||
assert.ok(INTENTIONALLY_INTERNAL.size > 0, "INTENTIONALLY_INTERNAL must not be empty");
|
||||
});
|
||||
|
||||
test("INTENTIONALLY_INTERNAL contains the expected 28 audited modules", () => {
|
||||
test("INTENTIONALLY_INTERNAL contains the expected 29 audited modules", () => {
|
||||
const expected = [
|
||||
"_rowTypes",
|
||||
"accessTokens",
|
||||
@@ -133,7 +132,6 @@ test("INTENTIONALLY_INTERNAL contains the expected 28 audited modules", () => {
|
||||
"comboForecast",
|
||||
"commandCodeAuth",
|
||||
"compression",
|
||||
"compressionScheduler",
|
||||
"detailedLogs",
|
||||
"discovery",
|
||||
"domainState",
|
||||
@@ -145,6 +143,7 @@ test("INTENTIONALLY_INTERNAL contains the expected 28 audited modules", () => {
|
||||
"obsidian",
|
||||
"pluginMetrics",
|
||||
"prompts",
|
||||
"providerNodeSelect",
|
||||
"providerStats",
|
||||
"recovery",
|
||||
"secrets",
|
||||
@@ -152,6 +151,7 @@ test("INTENTIONALLY_INTERNAL contains the expected 28 audited modules", () => {
|
||||
"stateReset",
|
||||
"stats",
|
||||
"tierConfig",
|
||||
"vacuumScheduler",
|
||||
];
|
||||
for (const mod of expected) {
|
||||
assert.ok(
|
||||
|
||||
36
tests/unit/cli-fingerprints.test.ts
Normal file
36
tests/unit/cli-fingerprints.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { applyFingerprint } = await import("../../open-sse/config/cliFingerprints.ts");
|
||||
|
||||
test("Codex CLI fingerprint orders prompt_cache_key before include", () => {
|
||||
const body = {
|
||||
model: "gpt-5.5-low",
|
||||
stream: true,
|
||||
input: [{ role: "user", content: "hello" }],
|
||||
instructions: "You are Codex.",
|
||||
store: false,
|
||||
reasoning: { effort: "low" },
|
||||
tools: [],
|
||||
tool_choice: "auto",
|
||||
include: ["reasoning.encrypted_content"],
|
||||
prompt_cache_key: "conv-codex",
|
||||
service_tier: "priority",
|
||||
};
|
||||
|
||||
const result = applyFingerprint("codex", {}, body);
|
||||
const orderedKeys = Object.keys(JSON.parse(result.bodyString));
|
||||
|
||||
assert.deepEqual(orderedKeys.slice(0, 10), [
|
||||
"model",
|
||||
"stream",
|
||||
"input",
|
||||
"instructions",
|
||||
"store",
|
||||
"reasoning",
|
||||
"prompt_cache_key",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"include",
|
||||
]);
|
||||
});
|
||||
@@ -2,6 +2,10 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
// #4425: the supervisor now waits for the listen port to free up before respawning.
|
||||
// Point that probe at the no-op port 0 so the restart tests don't open real sockets.
|
||||
process.env.PORT = "0";
|
||||
|
||||
// Stub para o processSupervisor: testa a lógica de restart/backoff/MITM sem processos reais.
|
||||
|
||||
class StubChild extends EventEmitter {
|
||||
@@ -41,9 +45,13 @@ test("detectMitmCrash retorna false com menos de 2 sinais", async () => {
|
||||
|
||||
// --- ServerSupervisor: lógica de restart ---
|
||||
|
||||
test("ServerSupervisor.handleExit com code=0 chama process.exit(0)", async () => {
|
||||
test("ServerSupervisor.handleExit com code=0 espontâneo reinicia em vez de sair (#4425)", async () => {
|
||||
const { ServerSupervisor } = await import("../../bin/cli/runtime/processSupervisor.mjs");
|
||||
|
||||
// waitUntilPortFree no-ops on port 0, so the scheduled restart fires fast and leaks no timer.
|
||||
const origPort = process.env.PORT;
|
||||
process.env.PORT = "0";
|
||||
|
||||
const exits: number[] = [];
|
||||
const origExit = process.exit.bind(process);
|
||||
// @ts-ignore
|
||||
@@ -56,11 +64,27 @@ test("ServerSupervisor.handleExit com code=0 chama process.exit(0)", async () =>
|
||||
env: {},
|
||||
maxRestarts: 2,
|
||||
});
|
||||
let started = 0;
|
||||
// Stub start() so the scheduled restart never spawns the fake server.
|
||||
supervisor.start = () => {
|
||||
started++;
|
||||
return null as any;
|
||||
};
|
||||
supervisor.handleExit(0);
|
||||
|
||||
// #4425: a spontaneous code-0 exit (e.g. a systemd MemoryMax cgroup kill, which reports
|
||||
// a clean exit) must NOT terminate the supervisor — it schedules a restart instead.
|
||||
assert.equal(exits.length, 0, "must not process.exit on a spontaneous code-0 exit");
|
||||
assert.equal(supervisor.restartCount, 1);
|
||||
|
||||
// Let the scheduled restart fire so no timer leaks past the test.
|
||||
await new Promise((r) => setTimeout(r, 1100));
|
||||
assert.equal(started, 1, "restart should fire after the backoff delay");
|
||||
|
||||
// @ts-ignore
|
||||
process.exit = origExit;
|
||||
assert.equal(exits[0], 0);
|
||||
if (origPort === undefined) delete process.env.PORT;
|
||||
else process.env.PORT = origPort;
|
||||
});
|
||||
|
||||
test("ServerSupervisor.handleExit com isShuttingDown=true chama process.exit imediato", async () => {
|
||||
@@ -125,6 +149,7 @@ test("ServerSupervisor.handleExit exibe crash log ao reiniciar", async () => {
|
||||
|
||||
console.error = origErr;
|
||||
assert.ok(logs.some((l) => l.includes("line1") || l.includes("crash log")));
|
||||
await new Promise((r) => setTimeout(r, 1100)); // drain the scheduled restart timer
|
||||
});
|
||||
|
||||
test("ServerSupervisor chama onCrashCallback após maxRestarts atingido", async () => {
|
||||
@@ -179,7 +204,7 @@ test("ServerSupervisor retorna 'disable-mitm-and-retry' chama start() novamente"
|
||||
assert.equal(supervisor.restartCount, 0); // foi resetado
|
||||
});
|
||||
|
||||
test("ServerSupervisor reseta restartCount após processo viver >=30s", async () => {
|
||||
test("ServerSupervisor reseta restartCount após viver >= RESTART_RESET_MS (#4425: 60s)", async () => {
|
||||
const { ServerSupervisor } = await import("../../bin/cli/runtime/processSupervisor.mjs");
|
||||
|
||||
const supervisor = new ServerSupervisor({
|
||||
@@ -189,10 +214,11 @@ test("ServerSupervisor reseta restartCount após processo viver >=30s", async ()
|
||||
});
|
||||
supervisor.start = () => null as any;
|
||||
supervisor.restartCount = 2;
|
||||
supervisor.startedAt = Date.now() - 31_000; // viveu 31s
|
||||
supervisor.startedAt = Date.now() - 61_000; // #4425: reset window bumped 30s→60s
|
||||
supervisor.handleExit(1);
|
||||
|
||||
assert.equal(supervisor.restartCount, 1); // reset p/ 0, depois incrementado p/ 1
|
||||
await new Promise((r) => setTimeout(r, 1100)); // drain the scheduled restart timer
|
||||
});
|
||||
|
||||
// --- Node.js v24 compat: process.exit() must receive a number (#3748) ---
|
||||
|
||||
123
tests/unit/codex-tool-card-button-state.test.ts
Normal file
123
tests/unit/codex-tool-card-button-state.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { isApplyDisabled, isResetDisabled } = await import(
|
||||
"../../src/app/(dashboard)/dashboard/cli-code/components/codexButtonState.ts"
|
||||
);
|
||||
|
||||
test("Codex tool card — Apply / Reset disabled state", async (t) => {
|
||||
// ──────────────────────────── Apply ────────────────────────────
|
||||
|
||||
await t.test("Apply is disabled when no model is selected (regardless of key)", () => {
|
||||
assert.equal(
|
||||
isApplyDisabled({
|
||||
selectedModel: "",
|
||||
selectedApiKey: "key-1",
|
||||
cloudEnabled: true,
|
||||
apiKeys: ["key-1"],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
isApplyDisabled({
|
||||
selectedModel: null,
|
||||
selectedApiKey: "",
|
||||
cloudEnabled: false,
|
||||
apiKeys: [],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
await t.test(
|
||||
"Apply is ENABLED with model + no key when cloud is disabled (default sk_omniroute path)",
|
||||
() => {
|
||||
// This is the central case the upstream port fixes: in local-mode the
|
||||
// sk_omniroute default kicks in, so an empty selectedApiKey must not
|
||||
// disable Apply.
|
||||
assert.equal(
|
||||
isApplyDisabled({
|
||||
selectedModel: "gpt-5.5",
|
||||
selectedApiKey: "",
|
||||
cloudEnabled: false,
|
||||
apiKeys: ["key-1"], // even with keys configured, local mode wins
|
||||
}),
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test("Apply is ENABLED with model + no key when no keys exist at all", () => {
|
||||
// Fresh install / cloud on but no keys created yet — the user should still
|
||||
// be able to apply with the default key.
|
||||
assert.equal(
|
||||
isApplyDisabled({
|
||||
selectedModel: "gpt-5.5",
|
||||
selectedApiKey: "",
|
||||
cloudEnabled: true,
|
||||
apiKeys: [],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
await t.test(
|
||||
"Apply IS disabled when cloud is on AND keys exist AND none selected (user must pick one)",
|
||||
() => {
|
||||
assert.equal(
|
||||
isApplyDisabled({
|
||||
selectedModel: "gpt-5.5",
|
||||
selectedApiKey: "",
|
||||
cloudEnabled: true,
|
||||
apiKeys: ["key-1", "key-2"],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
await t.test("Apply is ENABLED when a model and a key are both selected", () => {
|
||||
assert.equal(
|
||||
isApplyDisabled({
|
||||
selectedModel: "gpt-5.5",
|
||||
selectedApiKey: "key-1",
|
||||
cloudEnabled: true,
|
||||
apiKeys: ["key-1"],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
await t.test("Apply tolerates null/undefined apiKeys (treats as empty)", () => {
|
||||
assert.equal(
|
||||
isApplyDisabled({
|
||||
selectedModel: "gpt-5.5",
|
||||
selectedApiKey: "",
|
||||
cloudEnabled: true,
|
||||
apiKeys: null,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isApplyDisabled({
|
||||
selectedModel: "gpt-5.5",
|
||||
selectedApiKey: "",
|
||||
cloudEnabled: true,
|
||||
apiKeys: undefined,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
// ──────────────────────────── Reset ────────────────────────────
|
||||
|
||||
await t.test("Reset is ENABLED by default when the CLI is installed", () => {
|
||||
// The card only renders this control in the installed branch, so the only
|
||||
// thing that should ever block it is an in-flight reset.
|
||||
assert.equal(isResetDisabled({ restoring: false }), false);
|
||||
});
|
||||
|
||||
await t.test("Reset is DISABLED only while the reset request is in flight", () => {
|
||||
assert.equal(isResetDisabled({ restoring: true }), true);
|
||||
});
|
||||
});
|
||||
109
tests/unit/codex-usage-quotas-review-window.test.ts
Normal file
109
tests/unit/codex-usage-quotas-review-window.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Regression test for Codex review-quota plumbing.
|
||||
*
|
||||
* Upstream parity: decolua/9router PR #836 surfaces the SECONDARY window of
|
||||
* `code_review_rate_limit` and supports descriptors that arrive via the
|
||||
* `additional_rate_limits` array (some ChatGPT plans report the review limit
|
||||
* there rather than in the dedicated `code_review_rate_limit` block).
|
||||
*
|
||||
* Before this fix:
|
||||
* - `buildCodexUsageQuotas` only emitted `quotas.code_review` from the
|
||||
* primary window of `code_review_rate_limit`, so the WEEKLY review window
|
||||
* was invisible to the dashboard.
|
||||
* - Review limits surfaced under `additional_rate_limits` (with
|
||||
* `limit_name`/`metered_feature` containing "review") were dropped.
|
||||
*
|
||||
* After this fix:
|
||||
* - The secondary window of `code_review_rate_limit` is emitted as
|
||||
* `quotas.code_review_weekly` (parallel to `session`/`weekly`).
|
||||
* - Review entries inside `additional_rate_limits` populate the same
|
||||
* `code_review`/`code_review_weekly` keys when the dedicated block is
|
||||
* absent.
|
||||
* - The primary `code_review` key is preserved (backward-compat for the
|
||||
* existing dashboard rendering & the usage-service-hardening regression).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { buildCodexUsageQuotas } from "../../open-sse/services/codexUsageQuotas.ts";
|
||||
|
||||
test("buildCodexUsageQuotas surfaces the secondary code_review window", () => {
|
||||
const { quotas } = buildCodexUsageQuotas({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 25 },
|
||||
secondary_window: { used_percent: 50 },
|
||||
},
|
||||
code_review_rate_limit: {
|
||||
primary_window: { used_percent: 40, reset_after_seconds: 45 },
|
||||
secondary_window: { used_percent: 70, reset_after_seconds: 6000 },
|
||||
},
|
||||
});
|
||||
|
||||
// Pre-existing primary window stays under `code_review` (back-compat).
|
||||
assert.equal(quotas.code_review?.used, 40);
|
||||
assert.equal(quotas.code_review?.remaining, 60);
|
||||
|
||||
// New: secondary window exposed as `code_review_weekly`.
|
||||
assert.equal(quotas.code_review_weekly?.used, 70);
|
||||
assert.equal(quotas.code_review_weekly?.remaining, 30);
|
||||
assert.equal(quotas.code_review_weekly?.total, 100);
|
||||
assert.equal(quotas.code_review_weekly?.unlimited, false);
|
||||
});
|
||||
|
||||
test("buildCodexUsageQuotas reads review windows from additional_rate_limits", () => {
|
||||
const { quotas } = buildCodexUsageQuotas({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 10 },
|
||||
},
|
||||
additional_rate_limits: [
|
||||
{
|
||||
limit_name: "Code Review",
|
||||
metered_feature: "code_review",
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 33 },
|
||||
secondary_window: { used_percent: 55 },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(quotas.code_review?.used, 33);
|
||||
assert.equal(quotas.code_review?.remaining, 67);
|
||||
assert.equal(quotas.code_review_weekly?.used, 55);
|
||||
assert.equal(quotas.code_review_weekly?.remaining, 45);
|
||||
});
|
||||
|
||||
test("buildCodexUsageQuotas leaves review windows undefined when payload is silent", () => {
|
||||
const { quotas } = buildCodexUsageQuotas({
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 10 },
|
||||
secondary_window: { used_percent: 20 },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(quotas.session?.used, 10);
|
||||
assert.equal(quotas.weekly?.used, 20);
|
||||
assert.equal(quotas.code_review, undefined);
|
||||
assert.equal(quotas.code_review_weekly, undefined);
|
||||
});
|
||||
|
||||
test("buildCodexUsageQuotas prefers dedicated block over additional_rate_limits fallback", () => {
|
||||
const { quotas } = buildCodexUsageQuotas({
|
||||
code_review_rate_limit: {
|
||||
primary_window: { used_percent: 11 },
|
||||
secondary_window: { used_percent: 22 },
|
||||
},
|
||||
additional_rate_limits: [
|
||||
{
|
||||
limit_name: "review",
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 99 },
|
||||
secondary_window: { used_percent: 99 },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(quotas.code_review?.used, 11);
|
||||
assert.equal(quotas.code_review_weekly?.used, 22);
|
||||
});
|
||||
@@ -26,6 +26,7 @@ test("getDefaultComboConfig returns a fresh copy of the defaults", () => {
|
||||
assert.equal(first.handoffThreshold, 0.85);
|
||||
assert.equal(first.maxMessagesForSummary, 30);
|
||||
assert.deepEqual(first.handoffProviders, ["codex"]);
|
||||
assert.equal(first.nestedComboMode, "flatten");
|
||||
assert.equal(first.failoverBeforeRetry, true);
|
||||
assert.equal(first.maxSetRetries, 0);
|
||||
assert.equal(first.setRetryDelayMs, 2000);
|
||||
@@ -531,6 +532,30 @@ test("createComboSchema accepts failoverBeforeRetry, maxSetRetries and setRetryD
|
||||
assert.equal(parsed.config.setRetryDelayMs, 1500);
|
||||
});
|
||||
|
||||
test("createComboSchema accepts nestedComboMode and rejects invalid values", () => {
|
||||
const parsed = createComboSchema.parse({
|
||||
name: "nested-execute",
|
||||
models: [{ kind: "combo-ref", comboName: "child" }],
|
||||
strategy: "priority",
|
||||
config: { nestedComboMode: "execute" },
|
||||
});
|
||||
assert.equal(parsed.config.nestedComboMode, "execute");
|
||||
|
||||
const flatten = createComboSchema.parse({
|
||||
name: "nested-flatten",
|
||||
models: ["openai/gpt-4o-mini"],
|
||||
config: { nestedComboMode: "flatten" },
|
||||
});
|
||||
assert.equal(flatten.config.nestedComboMode, "flatten");
|
||||
|
||||
const invalid = createComboSchema.safeParse({
|
||||
name: "nested-invalid",
|
||||
models: ["openai/gpt-4o-mini"],
|
||||
config: { nestedComboMode: "redirect" },
|
||||
});
|
||||
assert.equal(invalid.success, false);
|
||||
});
|
||||
|
||||
test("createComboSchema accepts per-combo stickyRoundRobinLimit and rejects out-of-range", () => {
|
||||
const parsed = createComboSchema.parse({
|
||||
name: "sticky-override",
|
||||
@@ -549,6 +574,24 @@ test("createComboSchema accepts per-combo stickyRoundRobinLimit and rejects out-
|
||||
assert.equal(tooHigh.success, false);
|
||||
});
|
||||
|
||||
test("createComboSchema accepts per-combo stickyWeightedLimit and rejects out-of-range", () => {
|
||||
const parsed = createComboSchema.parse({
|
||||
name: "sticky-weighted",
|
||||
models: [{ model: "openai/gpt-4o-mini", weight: 100 }],
|
||||
strategy: "weighted",
|
||||
config: { stickyWeightedLimit: 2 },
|
||||
});
|
||||
assert.equal(parsed.config.stickyWeightedLimit, 2);
|
||||
|
||||
const tooHigh = createComboSchema.safeParse({
|
||||
name: "sticky-weighted-too-high",
|
||||
models: [{ model: "openai/gpt-4o-mini", weight: 100 }],
|
||||
strategy: "weighted",
|
||||
config: { stickyWeightedLimit: 1001 },
|
||||
});
|
||||
assert.equal(tooHigh.success, false);
|
||||
});
|
||||
|
||||
test("createComboSchema coerces string numbers for maxSetRetries and setRetryDelayMs", () => {
|
||||
const parsed = createComboSchema.parse({
|
||||
name: "coerce-test",
|
||||
@@ -600,6 +643,17 @@ test("createComboSchema rejects setRetryDelayMs out of range", () => {
|
||||
assert.equal(negative.success, false);
|
||||
});
|
||||
|
||||
test("resolveComboConfig cascades nestedComboMode", () => {
|
||||
const result = resolveComboConfig(
|
||||
{ config: { nestedComboMode: "execute" } },
|
||||
{ comboDefaults: { nestedComboMode: "flatten" } }
|
||||
);
|
||||
assert.equal(result.nestedComboMode, "execute");
|
||||
|
||||
const defaulted = resolveComboConfig({ config: {} }, { comboDefaults: {} });
|
||||
assert.equal(defaulted.nestedComboMode, "flatten");
|
||||
});
|
||||
|
||||
test("resolveComboConfig cascades failoverBeforeRetry, maxSetRetries and setRetryDelayMs", () => {
|
||||
const result = resolveComboConfig(
|
||||
{
|
||||
|
||||
60
tests/unit/combo-param-validation-fallback-4519.test.ts
Normal file
60
tests/unit/combo-param-validation-fallback-4519.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #4519 — allow combo fallback on context-overflow / param-validation 400s and
|
||||
// preserve upstream error semantics. Three regression guards:
|
||||
// 1. accountFallback.checkFallbackError classifies param-validation 400s as
|
||||
// fallback-worthy with zero cooldown.
|
||||
// 2. combo guard predicates (isContextOverflow400 / isParamValidation400) let
|
||||
// those 400s fall through to the next target instead of short-circuiting.
|
||||
// 3. openai-responses.normalizeUpstreamFailure keeps context_length_exceeded as
|
||||
// 400 and rate-limit as 429 (instead of rewriting everything to 502).
|
||||
|
||||
const { checkFallbackError } = await import("../../open-sse/services/accountFallback.ts");
|
||||
const { isContextOverflow400, isParamValidation400 } = await import(
|
||||
"../../open-sse/services/combo.ts"
|
||||
);
|
||||
const { normalizeUpstreamFailure } = await import(
|
||||
"../../open-sse/translator/response/openai-responses.ts"
|
||||
);
|
||||
|
||||
test("#4519 checkFallbackError treats per-model max_tokens 400 as fallback-worthy with zero cooldown", () => {
|
||||
const res = checkFallbackError(
|
||||
400,
|
||||
"The max_tokens parameter is illegal.:限制数值范围[1,131072]"
|
||||
);
|
||||
assert.equal(res.shouldFallback, true);
|
||||
assert.equal(res.cooldownMs, 0);
|
||||
});
|
||||
|
||||
test("#4519 combo guard: context-overflow and param-validation 400 texts are recognized", () => {
|
||||
assert.equal(isContextOverflow400("This model's context_length_exceeded for the input"), true);
|
||||
assert.equal(isContextOverflow400("your input exceeds the allowed size"), true);
|
||||
assert.equal(isParamValidation400("max_tokens must be in range [1,131072]"), true);
|
||||
assert.equal(isParamValidation400("The parameter is illegal"), true);
|
||||
});
|
||||
|
||||
test("#4519 combo guard: a genuinely body-specific 400 is NOT classified as overflow/param", () => {
|
||||
const malformed = "Invalid JSON: unexpected token at position 12";
|
||||
assert.equal(isContextOverflow400(malformed), false);
|
||||
assert.equal(isParamValidation400(malformed), false);
|
||||
});
|
||||
|
||||
test("#4519 normalizeUpstreamFailure preserves context_length_exceeded as 400", () => {
|
||||
const out = normalizeUpstreamFailure({
|
||||
error: { code: "context_length_exceeded", message: "too long" },
|
||||
});
|
||||
assert.equal(out.status, 400);
|
||||
assert.equal(out.type, "invalid_request_error");
|
||||
});
|
||||
|
||||
test("#4519 normalizeUpstreamFailure keeps rate-limit as 429 and unknown as 502", () => {
|
||||
const rl = normalizeUpstreamFailure({
|
||||
error: { code: "rate_limit_exceeded", message: "slow down" },
|
||||
});
|
||||
assert.equal(rl.status, 429);
|
||||
assert.equal(rl.type, "rate_limit_error");
|
||||
|
||||
const unknown = normalizeUpstreamFailure({ error: { code: "weird_error", message: "boom" } });
|
||||
assert.equal(unknown.status, 502);
|
||||
});
|
||||
348
tests/unit/combo-provider-wildcard.test.ts
Normal file
348
tests/unit/combo-provider-wildcard.test.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
/**
|
||||
* Unit tests for provider-wildcard combo expansion (#2562).
|
||||
*
|
||||
* Tests cover:
|
||||
* - Detection of wildcard notation in combo models
|
||||
* - Expansion against static providerRegistry models
|
||||
* - Expansion against synced DB models (mocked)
|
||||
* - Glob pattern filtering (`prefix*`)
|
||||
* - Preservation of step metadata (weight, label, connectionId, allowedConnectionIds)
|
||||
* - Graceful no-op when no models found (keeps original entry)
|
||||
* - Non-wildcard entries pass through unchanged
|
||||
* - Object-form `{ kind: "provider-wildcard", ... }` syntax
|
||||
* - Collection-level expansion
|
||||
*/
|
||||
|
||||
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-provider-wildcard-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
// ── Imports ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const {
|
||||
isProviderWildcardEntry,
|
||||
expandProviderWildcardsInCombo,
|
||||
expandProviderWildcardsInCollection,
|
||||
} = await import("../../open-sse/services/combo/providerWildcard.ts");
|
||||
|
||||
const { replaceSyncedAvailableModelsForConnection, getSyncedAvailableModels } =
|
||||
await import("../../src/lib/db/models.ts");
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
core.getDbInstance(); // initialise DB + run migrations
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeCombo(models: unknown[], name = "test-combo") {
|
||||
return { name, models, id: "test-id" };
|
||||
}
|
||||
|
||||
// Seed synced models for a provider into the DB.
|
||||
async function seedSyncedModels(providerId: string, connectionId: string, modelIds: string[]) {
|
||||
await replaceSyncedAvailableModelsForConnection(
|
||||
providerId,
|
||||
connectionId,
|
||||
modelIds.map((id) => ({ id, name: id, source: "imported" as const }))
|
||||
);
|
||||
}
|
||||
|
||||
// ── isProviderWildcardEntry ───────────────────────────────────────────────────
|
||||
|
||||
test("isProviderWildcardEntry: detects string wildcard `provider/*`", () => {
|
||||
assert.equal(isProviderWildcardEntry("fta/*"), true);
|
||||
assert.equal(isProviderWildcardEntry("openai/*"), true);
|
||||
assert.equal(isProviderWildcardEntry("opc/deepseek*"), true);
|
||||
assert.equal(isProviderWildcardEntry("openai/gpt-4*"), true);
|
||||
});
|
||||
|
||||
test("isProviderWildcardEntry: rejects plain model strings", () => {
|
||||
assert.equal(isProviderWildcardEntry("fta/some-model"), false);
|
||||
assert.equal(isProviderWildcardEntry("openai/gpt-4o"), false);
|
||||
assert.equal(isProviderWildcardEntry("openai"), false);
|
||||
assert.equal(isProviderWildcardEntry(""), false);
|
||||
assert.equal(isProviderWildcardEntry(null), false);
|
||||
assert.equal(isProviderWildcardEntry(42), false);
|
||||
});
|
||||
|
||||
test("isProviderWildcardEntry: detects object form `{ kind: 'provider-wildcard' }`", () => {
|
||||
assert.equal(
|
||||
isProviderWildcardEntry({ kind: "provider-wildcard", providerId: "fta", modelPattern: "*" }),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("isProviderWildcardEntry: rejects object without kind=provider-wildcard", () => {
|
||||
assert.equal(isProviderWildcardEntry({ kind: "model", model: "fta/some-model" }), false);
|
||||
assert.equal(isProviderWildcardEntry({ kind: "provider-wildcard" }), false); // missing providerId
|
||||
});
|
||||
|
||||
// ── expandProviderWildcardsInCombo — static registry ─────────────────────────
|
||||
|
||||
test("expandProviderWildcardsInCombo: expands `openai/*` against static registry", async () => {
|
||||
// `openai` is a built-in provider with models in providerRegistry
|
||||
const combo = makeCombo(["openai/*"]);
|
||||
const result = await expandProviderWildcardsInCombo(combo);
|
||||
|
||||
// Should have expanded to at least 1 model
|
||||
assert.ok(result.models.length > 0, "should have expanded to ≥1 model");
|
||||
|
||||
// Every expanded entry should be a model step object with `kind: "model"`
|
||||
for (const entry of result.models) {
|
||||
assert.equal(typeof entry, "object");
|
||||
assert.equal((entry as any).kind, "model");
|
||||
assert.ok(
|
||||
typeof (entry as any).model === "string" && (entry as any).model.startsWith("openai/"),
|
||||
`model should start with openai/, got: ${(entry as any).model}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("expandProviderWildcardsInCombo: `_expandedFromWildcard` tag is set on expanded entries", async () => {
|
||||
const combo = makeCombo(["openai/*"]);
|
||||
const result = await expandProviderWildcardsInCombo(combo);
|
||||
for (const entry of result.models) {
|
||||
assert.equal((entry as any)._expandedFromWildcard, "openai/*");
|
||||
}
|
||||
});
|
||||
|
||||
// ── expandProviderWildcardsInCombo — synced DB models ────────────────────────
|
||||
|
||||
test("expandProviderWildcardsInCombo: expands against synced DB models", async () => {
|
||||
const providerId = "test-custom-provider-" + Date.now();
|
||||
await seedSyncedModels(providerId, "conn-1", ["model-alpha", "model-beta", "model-gamma"]);
|
||||
|
||||
const combo = makeCombo([`${providerId}/*`]);
|
||||
const result = await expandProviderWildcardsInCombo(combo);
|
||||
|
||||
assert.equal(result.models.length, 3);
|
||||
const modelStrs = result.models.map((e) => (e as any).model);
|
||||
assert.ok(modelStrs.includes(`${providerId}/model-alpha`));
|
||||
assert.ok(modelStrs.includes(`${providerId}/model-beta`));
|
||||
assert.ok(modelStrs.includes(`${providerId}/model-gamma`));
|
||||
});
|
||||
|
||||
test("expandProviderWildcardsInCombo: glob prefix filter `provider/pre*`", async () => {
|
||||
const providerId = "test-prefix-provider-" + Date.now();
|
||||
await seedSyncedModels(providerId, "conn-1", [
|
||||
"deepseek-v4-pro",
|
||||
"deepseek-v4-flash",
|
||||
"qwen3-free",
|
||||
"minimax-m3",
|
||||
]);
|
||||
|
||||
const combo = makeCombo([`${providerId}/deepseek*`]);
|
||||
const result = await expandProviderWildcardsInCombo(combo);
|
||||
|
||||
assert.equal(result.models.length, 2);
|
||||
const ids = result.models.map((e) => (e as any).model);
|
||||
assert.ok(ids.includes(`${providerId}/deepseek-v4-pro`));
|
||||
assert.ok(ids.includes(`${providerId}/deepseek-v4-flash`));
|
||||
assert.ok(!ids.includes(`${providerId}/qwen3-free`));
|
||||
assert.ok(!ids.includes(`${providerId}/minimax-m3`));
|
||||
});
|
||||
|
||||
// ── Step metadata preservation ────────────────────────────────────────────────
|
||||
|
||||
test("expandProviderWildcardsInCombo: weight is inherited on expanded entries", async () => {
|
||||
const providerId = "test-weight-provider-" + Date.now();
|
||||
await seedSyncedModels(providerId, "conn-1", ["model-a", "model-b"]);
|
||||
|
||||
const combo = makeCombo([
|
||||
{ kind: "provider-wildcard", providerId, modelPattern: "*", weight: 50, label: "fast" },
|
||||
]);
|
||||
const result = await expandProviderWildcardsInCombo(combo);
|
||||
|
||||
for (const entry of result.models) {
|
||||
assert.equal((entry as any).weight, 50, "weight should be inherited");
|
||||
assert.equal((entry as any).label, "fast", "label should be inherited");
|
||||
}
|
||||
});
|
||||
|
||||
test("expandProviderWildcardsInCombo: connectionId is inherited on expanded entries", async () => {
|
||||
const providerId = "test-conn-provider-" + Date.now();
|
||||
await seedSyncedModels(providerId, "conn-42", ["model-x"]);
|
||||
|
||||
const combo = makeCombo([
|
||||
{ kind: "provider-wildcard", providerId, modelPattern: "*", connectionId: "conn-42" },
|
||||
]);
|
||||
const result = await expandProviderWildcardsInCombo(combo);
|
||||
|
||||
assert.equal(result.models.length, 1);
|
||||
assert.equal((result.models[0] as any).connectionId, "conn-42");
|
||||
});
|
||||
|
||||
test("expandProviderWildcardsInCombo: allowedConnectionIds is inherited", async () => {
|
||||
const providerId = "test-acl-provider-" + Date.now();
|
||||
await seedSyncedModels(providerId, "conn-a", ["model-m"]);
|
||||
|
||||
const combo = makeCombo([
|
||||
{
|
||||
kind: "provider-wildcard",
|
||||
providerId,
|
||||
modelPattern: "*",
|
||||
allowedConnectionIds: ["conn-a", "conn-b"],
|
||||
},
|
||||
]);
|
||||
const result = await expandProviderWildcardsInCombo(combo);
|
||||
|
||||
assert.equal(result.models.length, 1);
|
||||
assert.deepEqual((result.models[0] as any).allowedConnectionIds, ["conn-a", "conn-b"]);
|
||||
});
|
||||
|
||||
// ── Non-wildcard pass-through ─────────────────────────────────────────────────
|
||||
|
||||
test("expandProviderWildcardsInCombo: non-wildcard string entries pass through unchanged", async () => {
|
||||
const combo = makeCombo(["anthropic/claude-opus-4", "openai/gpt-4o"]);
|
||||
const result = await expandProviderWildcardsInCombo(combo);
|
||||
|
||||
assert.equal(result.models.length, 2);
|
||||
assert.equal(result.models[0], "anthropic/claude-opus-4");
|
||||
assert.equal(result.models[1], "openai/gpt-4o");
|
||||
});
|
||||
|
||||
test("expandProviderWildcardsInCombo: mixed combo — wildcards expand, explicit entries preserved", async () => {
|
||||
const providerId = "test-mixed-provider-" + Date.now();
|
||||
await seedSyncedModels(providerId, "conn-1", ["model-1", "model-2"]);
|
||||
|
||||
const combo = makeCombo(["anthropic/claude-opus-4", `${providerId}/*`, "openai/gpt-4o"]);
|
||||
const result = await expandProviderWildcardsInCombo(combo);
|
||||
|
||||
// anthropic/claude-opus-4, model-1, model-2, openai/gpt-4o
|
||||
assert.equal(result.models.length, 4);
|
||||
assert.equal(result.models[0], "anthropic/claude-opus-4");
|
||||
assert.equal((result.models[1] as any).model, `${providerId}/model-1`);
|
||||
assert.equal((result.models[2] as any).model, `${providerId}/model-2`);
|
||||
assert.equal(result.models[3], "openai/gpt-4o");
|
||||
});
|
||||
|
||||
// ── No models found — graceful fallback ──────────────────────────────────────
|
||||
|
||||
test("expandProviderWildcardsInCombo: keeps original entry when no models found for provider", async () => {
|
||||
const combo = makeCombo(["nonexistent-provider-xyz/*"]);
|
||||
const result = await expandProviderWildcardsInCombo(combo);
|
||||
|
||||
// Should not silently drop the step
|
||||
assert.equal(result.models.length, 1);
|
||||
assert.equal(result.models[0], "nonexistent-provider-xyz/*");
|
||||
});
|
||||
|
||||
test("expandProviderWildcardsInCombo: keeps original entry when pattern matches nothing", async () => {
|
||||
const providerId = "test-nomatch-provider-" + Date.now();
|
||||
await seedSyncedModels(providerId, "conn-1", ["some-model"]);
|
||||
|
||||
const combo = makeCombo([`${providerId}/zzzz*`]);
|
||||
const result = await expandProviderWildcardsInCombo(combo);
|
||||
|
||||
// Pattern matches nothing — original entry preserved
|
||||
assert.equal(result.models.length, 1);
|
||||
});
|
||||
|
||||
// ── Collection expansion ──────────────────────────────────────────────────────
|
||||
|
||||
test("expandProviderWildcardsInCollection: expands wildcards in every combo in the collection", async () => {
|
||||
const p1 = "test-col-p1-" + Date.now();
|
||||
const p2 = "test-col-p2-" + Date.now();
|
||||
await seedSyncedModels(p1, "c1", ["m1", "m2"]);
|
||||
await seedSyncedModels(p2, "c2", ["m3"]);
|
||||
|
||||
const combos = [
|
||||
makeCombo([`${p1}/*`], "combo-a"),
|
||||
makeCombo([`${p2}/*`, "openai/gpt-4o"], "combo-b"),
|
||||
makeCombo(["anthropic/claude-opus-4"], "combo-c"), // no wildcard
|
||||
];
|
||||
|
||||
const results = await expandProviderWildcardsInCollection(combos);
|
||||
|
||||
assert.equal(results[0].models.length, 2); // m1, m2
|
||||
assert.equal(results[1].models.length, 2); // m3, gpt-4o
|
||||
assert.equal(results[2].models.length, 1); // unchanged
|
||||
assert.equal(results[2].models[0], "anthropic/claude-opus-4");
|
||||
});
|
||||
|
||||
// ── Return identity when no wildcards ────────────────────────────────────────
|
||||
|
||||
test("expandProviderWildcardsInCombo: returns same object when no wildcards", async () => {
|
||||
const combo = makeCombo(["openai/gpt-4o"]);
|
||||
const result = await expandProviderWildcardsInCombo(combo);
|
||||
// Same reference — no allocation when nothing to expand
|
||||
assert.strictEqual(result, combo);
|
||||
});
|
||||
|
||||
test("expandProviderWildcardsInCombo: returns same object for empty models array", async () => {
|
||||
const combo = makeCombo([]);
|
||||
const result = await expandProviderWildcardsInCombo(combo);
|
||||
assert.strictEqual(result, combo);
|
||||
});
|
||||
|
||||
// ── Multi-provider wildcard combo ─────────────────────────────────────────────
|
||||
// Validates the primary use-case from issue #2562:
|
||||
// a single combo that spans multiple providers, each expressed as a wildcard.
|
||||
// e.g. freetheai/* + deepseek-web/* (or any other provider)
|
||||
// The expanded model list is the ordered union of all providers' model catalogs.
|
||||
|
||||
test("expandProviderWildcardsInCombo: two provider wildcards expand independently and maintain order", async () => {
|
||||
const p1 = "test-multi-p1-" + Date.now();
|
||||
const p2 = "test-multi-p2-" + Date.now();
|
||||
await seedSyncedModels(p1, "conn-1", ["fast-model", "smart-model"]);
|
||||
await seedSyncedModels(p2, "conn-2", ["web-search-model", "reasoning-model"]);
|
||||
|
||||
// Simulate: freetheai/* + deepseek-web/*
|
||||
const combo = makeCombo([`${p1}/*`, `${p2}/*`]);
|
||||
const result = await expandProviderWildcardsInCombo(combo);
|
||||
|
||||
// Should have all 4 models, p1 first then p2 (insertion order preserved)
|
||||
assert.equal(result.models.length, 4);
|
||||
const models = result.models.map((e) => (e as any).model);
|
||||
assert.ok(
|
||||
models.indexOf(`${p1}/fast-model`) < models.indexOf(`${p2}/web-search-model`),
|
||||
"p1 models should come before p2 models"
|
||||
);
|
||||
assert.ok(models.includes(`${p1}/fast-model`));
|
||||
assert.ok(models.includes(`${p1}/smart-model`));
|
||||
assert.ok(models.includes(`${p2}/web-search-model`));
|
||||
assert.ok(models.includes(`${p2}/reasoning-model`));
|
||||
});
|
||||
|
||||
test("expandProviderWildcardsInCombo: two providers with prefix filters", async () => {
|
||||
const p1 = "test-prefix-p1-" + Date.now();
|
||||
const p2 = "test-prefix-p2-" + Date.now();
|
||||
await seedSyncedModels(p1, "conn-1", ["free-fast", "free-smart", "paid-pro"]);
|
||||
await seedSyncedModels(p2, "conn-2", ["web-basic", "web-turbo", "local-model"]);
|
||||
|
||||
// Only free models from p1, only web models from p2
|
||||
const combo = makeCombo([`${p1}/free*`, `${p2}/web*`]);
|
||||
const result = await expandProviderWildcardsInCombo(combo);
|
||||
|
||||
assert.equal(result.models.length, 4); // free-fast, free-smart, web-basic, web-turbo
|
||||
const models = result.models.map((e) => (e as any).model);
|
||||
assert.ok(models.includes(`${p1}/free-fast`));
|
||||
assert.ok(models.includes(`${p1}/free-smart`));
|
||||
assert.ok(!models.includes(`${p1}/paid-pro`)); // filtered out
|
||||
assert.ok(models.includes(`${p2}/web-basic`));
|
||||
assert.ok(models.includes(`${p2}/web-turbo`));
|
||||
assert.ok(!models.includes(`${p2}/local-model`)); // filtered out
|
||||
});
|
||||
|
||||
test("expandProviderWildcardsInCombo: three providers mixed with explicit entries", async () => {
|
||||
const p1 = "test-three-p1-" + Date.now();
|
||||
const p2 = "test-three-p2-" + Date.now();
|
||||
await seedSyncedModels(p1, "conn-1", ["m-a", "m-b"]);
|
||||
await seedSyncedModels(p2, "conn-2", ["m-c"]);
|
||||
|
||||
// anchor explicit entry first, then two wildcards, then another explicit
|
||||
const combo = makeCombo(["anthropic/claude-opus-4", `${p1}/*`, `${p2}/*`, "openai/gpt-4o"]);
|
||||
const result = await expandProviderWildcardsInCombo(combo);
|
||||
|
||||
// anthropic + 2 (p1) + 1 (p2) + openai = 5
|
||||
assert.equal(result.models.length, 5);
|
||||
assert.equal(result.models[0], "anthropic/claude-opus-4");
|
||||
assert.equal((result.models[1] as any).model, `${p1}/m-a`);
|
||||
assert.equal((result.models[2] as any).model, `${p1}/m-b`);
|
||||
assert.equal((result.models[3] as any).model, `${p2}/m-c`);
|
||||
assert.equal(result.models[4], "openai/gpt-4o");
|
||||
});
|
||||
379
tests/unit/combo-selected-connection-success.test.ts
Normal file
379
tests/unit/combo-selected-connection-success.test.ts
Normal file
@@ -0,0 +1,379 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import test, { describe, beforeEach } 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-combo-sel-conn-"));
|
||||
const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
|
||||
const {
|
||||
recordModelLockoutFailure,
|
||||
getModelLockoutInfo,
|
||||
clearAllModelLockouts,
|
||||
decayModelFailureCount,
|
||||
} = await import("../../open-sse/services/accountFallback.ts");
|
||||
const { recordProviderCooldown, isProviderInCooldown, recordProviderSuccess, clearCooldownState } =
|
||||
await import("../../open-sse/services/providerCooldownTracker.ts");
|
||||
const { resolveResilienceSettings } = await import("../../src/lib/resilience/settings.ts");
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
|
||||
const settings = resolveResilienceSettings({
|
||||
resilienceSettings: {
|
||||
providerCooldown: {
|
||||
enabled: true,
|
||||
minRetryCooldownMs: 5000,
|
||||
maxRetryCooldownMs: 300000,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function createLog() {
|
||||
return {
|
||||
info: (tag: any, msg: any) => console.log(`[INFO][${tag}] ${msg}`),
|
||||
warn: (tag: any, msg: any) => console.log(`[WARN][${tag}] ${msg}`),
|
||||
error: (tag: any, msg: any) => console.log(`[ERROR][${tag}] ${msg}`),
|
||||
debug: (tag: any, msg: any) => console.log(`[DEBUG][${tag}] ${msg}`),
|
||||
};
|
||||
}
|
||||
|
||||
async function cleanupTestDataDir() {
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
try {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
return;
|
||||
} catch (error: any) {
|
||||
lastError = error;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
}
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
}
|
||||
|
||||
test.after(async () => {
|
||||
await cleanupTestDataDir();
|
||||
process.env.DATA_DIR = ORIGINAL_DATA_DIR;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
clearAllModelLockouts();
|
||||
clearCooldownState();
|
||||
settingsDb.clearAllLKGP();
|
||||
});
|
||||
|
||||
describe("combo selected connection success handling", () => {
|
||||
test("priority strategy correctly extracts dynamic connection ID from success response headers and decays lockout, resets provider cooldown, and updates LKGP", async () => {
|
||||
const comboName = "test-combo-priority";
|
||||
const modelStr = "openai/gpt-4";
|
||||
const provider = "openai";
|
||||
const dynamicConnId = "conn-dynamic-123";
|
||||
|
||||
await providersDb.createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
name: "OpenAI Test",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
|
||||
// 1. Populate lockout failure count = 4
|
||||
recordModelLockoutFailure(
|
||||
provider,
|
||||
dynamicConnId,
|
||||
"gpt-4",
|
||||
"rate_limit_exceeded",
|
||||
429,
|
||||
120_000,
|
||||
null,
|
||||
{ exactCooldownMs: 60_000 }
|
||||
);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
recordModelLockoutFailure(
|
||||
provider,
|
||||
dynamicConnId,
|
||||
"gpt-4",
|
||||
"rate_limit_exceeded",
|
||||
429,
|
||||
120_000,
|
||||
null,
|
||||
{ exactCooldownMs: 60_000 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify initial failureCount is 4
|
||||
const initialLockout = getModelLockoutInfo(provider, dynamicConnId, "gpt-4");
|
||||
assert.equal(initialLockout?.failureCount, 4);
|
||||
|
||||
// 2. Record provider cooldown
|
||||
recordProviderCooldown(provider, dynamicConnId, settings);
|
||||
assert.ok(isProviderInCooldown(provider, dynamicConnId, settings));
|
||||
|
||||
// 3. Invoke handleComboChat
|
||||
const result = await handleComboChat({
|
||||
body: { stream: false },
|
||||
combo: {
|
||||
name: comboName,
|
||||
strategy: "priority",
|
||||
models: [modelStr],
|
||||
config: { maxRetries: 0, concurrencyPerModel: 1, queueTimeoutMs: 1000 },
|
||||
},
|
||||
handleSingleModel: async () => {
|
||||
return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"X-OmniRoute-Selected-Connection-Id": dynamicConnId,
|
||||
},
|
||||
});
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
|
||||
// 4. Assertions
|
||||
// A. Dynamic connection-level failure count decay (halved to 2)
|
||||
const decayCheck = decayModelFailureCount(provider, dynamicConnId, "gpt-4");
|
||||
assert.equal(
|
||||
decayCheck.newFailureCount,
|
||||
1,
|
||||
"failure count should decay from 4 to 2, and now to 1"
|
||||
);
|
||||
|
||||
// B. Dynamic connection-level provider success tracking (not in cooldown anymore)
|
||||
assert.equal(
|
||||
isProviderInCooldown(provider, dynamicConnId, settings),
|
||||
false,
|
||||
"provider cooldown should be cleared on success"
|
||||
);
|
||||
|
||||
// C. Correct LKGP record updated with the dynamic connection ID (setLKGP is called)
|
||||
let persisted: any = null;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
persisted = await settingsDb.getLKGP(comboName, comboName);
|
||||
if (persisted?.connectionId === dynamicConnId) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.equal(persisted?.provider, provider);
|
||||
assert.equal(
|
||||
persisted?.connectionId,
|
||||
dynamicConnId,
|
||||
"LKGP connectionId must be the dynamic connection ID"
|
||||
);
|
||||
});
|
||||
|
||||
test("priority strategy with lowercase selected connection ID header correctly decays lockout, resets provider cooldown, and updates LKGP", async () => {
|
||||
const comboName = "test-combo-priority-lc";
|
||||
const modelStr = "openai/gpt-4";
|
||||
const provider = "openai";
|
||||
const dynamicConnId = "conn-dynamic-123-lc";
|
||||
|
||||
await providersDb.createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
name: "OpenAI Test LC",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
|
||||
// 1. Populate lockout failure count = 4
|
||||
recordModelLockoutFailure(
|
||||
provider,
|
||||
dynamicConnId,
|
||||
"gpt-4",
|
||||
"rate_limit_exceeded",
|
||||
429,
|
||||
120_000,
|
||||
null,
|
||||
{ exactCooldownMs: 60_000 }
|
||||
);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
recordModelLockoutFailure(
|
||||
provider,
|
||||
dynamicConnId,
|
||||
"gpt-4",
|
||||
"rate_limit_exceeded",
|
||||
429,
|
||||
120_000,
|
||||
null,
|
||||
{ exactCooldownMs: 60_000 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify initial failureCount is 4
|
||||
const initialLockout = getModelLockoutInfo(provider, dynamicConnId, "gpt-4");
|
||||
assert.equal(initialLockout?.failureCount, 4);
|
||||
|
||||
// 2. Record provider cooldown
|
||||
recordProviderCooldown(provider, dynamicConnId, settings);
|
||||
assert.ok(isProviderInCooldown(provider, dynamicConnId, settings));
|
||||
|
||||
// 3. Invoke handleComboChat
|
||||
const result = await handleComboChat({
|
||||
body: { stream: false },
|
||||
combo: {
|
||||
name: comboName,
|
||||
strategy: "priority",
|
||||
models: [modelStr],
|
||||
config: { maxRetries: 0, concurrencyPerModel: 1, queueTimeoutMs: 1000 },
|
||||
},
|
||||
handleSingleModel: async () => {
|
||||
return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"x-omniroute-selected-connection-id": dynamicConnId,
|
||||
},
|
||||
});
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
|
||||
// 4. Assertions
|
||||
const decayCheck = decayModelFailureCount(provider, dynamicConnId, "gpt-4");
|
||||
assert.equal(
|
||||
decayCheck.newFailureCount,
|
||||
1,
|
||||
"failure count should decay from 4 to 2, and now to 1"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isProviderInCooldown(provider, dynamicConnId, settings),
|
||||
false,
|
||||
"provider cooldown should be cleared on success"
|
||||
);
|
||||
|
||||
let persisted: any = null;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
persisted = await settingsDb.getLKGP(comboName, comboName);
|
||||
if (persisted?.connectionId === dynamicConnId) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.equal(persisted?.provider, provider);
|
||||
assert.equal(
|
||||
persisted?.connectionId,
|
||||
dynamicConnId,
|
||||
"LKGP connectionId must be the dynamic connection ID"
|
||||
);
|
||||
});
|
||||
|
||||
test("round-robin strategy correctly extracts dynamic connection ID from success response headers and decays lockout, resets provider cooldown, and updates LKGP", async () => {
|
||||
const comboName = "test-combo-rr";
|
||||
const modelStr = "openai/gpt-4";
|
||||
const provider = "openai";
|
||||
const dynamicConnId = "conn-dynamic-123-rr";
|
||||
|
||||
await providersDb.createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
name: "OpenAI Test RR",
|
||||
apiKey: "sk-test",
|
||||
});
|
||||
|
||||
// 1. Populate lockout failure count = 4
|
||||
recordModelLockoutFailure(
|
||||
provider,
|
||||
dynamicConnId,
|
||||
"gpt-4",
|
||||
"rate_limit_exceeded",
|
||||
429,
|
||||
120_000,
|
||||
null,
|
||||
{ exactCooldownMs: 60_000 }
|
||||
);
|
||||
for (let i = 0; i < 3; i++) {
|
||||
recordModelLockoutFailure(
|
||||
provider,
|
||||
dynamicConnId,
|
||||
"gpt-4",
|
||||
"rate_limit_exceeded",
|
||||
429,
|
||||
120_000,
|
||||
null,
|
||||
{ exactCooldownMs: 60_000 }
|
||||
);
|
||||
}
|
||||
|
||||
// Verify initial failureCount is 4
|
||||
const initialLockout = getModelLockoutInfo(provider, dynamicConnId, "gpt-4");
|
||||
assert.equal(initialLockout?.failureCount, 4);
|
||||
|
||||
// 2. Record provider cooldown
|
||||
recordProviderCooldown(provider, dynamicConnId, settings);
|
||||
assert.ok(isProviderInCooldown(provider, dynamicConnId, settings));
|
||||
|
||||
// 3. Invoke handleComboChat with round-robin strategy
|
||||
const result = await handleComboChat({
|
||||
body: { stream: false },
|
||||
combo: {
|
||||
name: comboName,
|
||||
strategy: "round-robin",
|
||||
models: [modelStr],
|
||||
config: { maxRetries: 0, concurrencyPerModel: 1, queueTimeoutMs: 1000 },
|
||||
},
|
||||
handleSingleModel: async () => {
|
||||
return new Response(JSON.stringify({ choices: [{ message: { content: "ok" } }] }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"X-OmniRoute-Selected-Connection-Id": dynamicConnId,
|
||||
},
|
||||
});
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
|
||||
// 4. Assertions
|
||||
const decayCheck = decayModelFailureCount(provider, dynamicConnId, "gpt-4");
|
||||
assert.equal(
|
||||
decayCheck.newFailureCount,
|
||||
1,
|
||||
"failure count should decay from 4 to 2, and now to 1"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
isProviderInCooldown(provider, dynamicConnId, settings),
|
||||
false,
|
||||
"provider cooldown should be cleared on success"
|
||||
);
|
||||
|
||||
let persisted: any = null;
|
||||
for (let i = 0; i < 20; i++) {
|
||||
persisted = await settingsDb.getLKGP(comboName, comboName);
|
||||
if (persisted?.connectionId === dynamicConnId) {
|
||||
break;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.equal(persisted?.provider, provider);
|
||||
assert.equal(
|
||||
persisted?.connectionId,
|
||||
dynamicConnId,
|
||||
"LKGP connectionId must be the dynamic connection ID"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,8 @@ const ORIGINAL_DATA_DIR = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const { handleComboChat, preScreenTargets } = await import("../../open-sse/services/combo.ts");
|
||||
const { weightedStickyTargets, rrStickyTargets } =
|
||||
await import("../../open-sse/services/combo/rrState.ts");
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const { resetAllComboMetrics } = await import("../../open-sse/services/comboMetrics.ts");
|
||||
@@ -69,6 +71,8 @@ test.beforeEach(async () => {
|
||||
resetAllSemaphores();
|
||||
_resetAllDecks();
|
||||
clearSessions();
|
||||
weightedStickyTargets.clear();
|
||||
rrStickyTargets.clear();
|
||||
await cleanupTestDataDir();
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
await settingsDb.resetAllPricing();
|
||||
@@ -80,6 +84,8 @@ test.after(async () => {
|
||||
resetAllCircuitBreakers();
|
||||
resetAllSemaphores();
|
||||
_resetAllDecks();
|
||||
weightedStickyTargets.clear();
|
||||
rrStickyTargets.clear();
|
||||
settingsDb.clearAllLKGP();
|
||||
if (ORIGINAL_DATA_DIR === undefined) {
|
||||
delete process.env.DATA_DIR;
|
||||
@@ -385,6 +391,172 @@ test("round-robin sticky batching fallback success becomes sticky target", async
|
||||
assert.deepEqual(calls, ["openai/a", "claude/b", "claude/b", "gemini/c", "gemini/c"]);
|
||||
});
|
||||
|
||||
test("weighted sticky limit batches successful weighted selections", async () => {
|
||||
const calls: string[] = [];
|
||||
const secureRandom = await import("../../src/shared/utils/secureRandom.ts");
|
||||
// First draw targets openai/a (cumulative weight < 0.5), second targets claude/b
|
||||
// (cumulative weight >= 0.5). With sticky=2, openai/a runs for 2 calls then claude/b for 2.
|
||||
secureRandom._setSecureRandomFloatSource(() => (calls.length < 2 ? 0.1 : 0.9));
|
||||
try {
|
||||
const combo = {
|
||||
name: "weighted-sticky-batches",
|
||||
strategy: "weighted",
|
||||
models: [
|
||||
{ model: "openai/a", weight: 50 },
|
||||
{ model: "claude/b", weight: 50 },
|
||||
],
|
||||
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0, stickyWeightedLimit: 2 },
|
||||
};
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo,
|
||||
handleSingleModel: async (_body: any, modelStr: string) => {
|
||||
calls.push(modelStr);
|
||||
return okResponse();
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
}
|
||||
} finally {
|
||||
secureRandom._setSecureRandomFloatSource(null);
|
||||
}
|
||||
assert.deepEqual(calls, ["openai/a", "openai/a", "claude/b", "claude/b"]);
|
||||
});
|
||||
|
||||
test("weighted sticky limit clears unavailable sticky steps before sampling", async () => {
|
||||
const calls: string[] = [];
|
||||
const step0Key = "weighted-sticky-clears-model-1-openai-a";
|
||||
const step1Key = "weighted-sticky-clears-model-2-claude-b";
|
||||
weightedStickyTargets.set("weighted-sticky-clears", { executionKey: step0Key, successCount: 1 });
|
||||
const combo = {
|
||||
name: "weighted-sticky-clears",
|
||||
strategy: "weighted",
|
||||
models: [
|
||||
{ model: "openai/a", weight: 50 },
|
||||
{ model: "claude/b", weight: 50 },
|
||||
],
|
||||
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0, stickyWeightedLimit: 10 },
|
||||
};
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo,
|
||||
handleSingleModel: async (_body: any, modelStr: string) => {
|
||||
calls.push(modelStr);
|
||||
return okResponse();
|
||||
},
|
||||
isModelAvailable: async (modelStr: string) => modelStr !== "openai/a",
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, ["claude/b"]);
|
||||
assert.equal(weightedStickyTargets.get("weighted-sticky-clears")?.executionKey, step1Key);
|
||||
});
|
||||
|
||||
test("weighted sticky limit follows fallback success", async () => {
|
||||
const calls: string[] = [];
|
||||
const secureRandom = await import("../../src/shared/utils/secureRandom.ts");
|
||||
// Always draw claude/b (the failing target). The combo retries within claude/b,
|
||||
// exhausts its leaves, falls through to openai/a, succeeds, and sticky migrates.
|
||||
secureRandom._setSecureRandomFloatSource(() => 0.9);
|
||||
try {
|
||||
const combo = {
|
||||
name: "weighted-sticky-fallback-success",
|
||||
strategy: "weighted",
|
||||
models: [
|
||||
{ model: "openai/a", weight: 50 },
|
||||
{ model: "claude/b", weight: 50 },
|
||||
],
|
||||
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0, stickyWeightedLimit: 2 },
|
||||
};
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo,
|
||||
handleSingleModel: async (_body: any, modelStr: string) => {
|
||||
calls.push(modelStr);
|
||||
return modelStr === "claude/b" ? errorResponse(503, "b is down") : okResponse();
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
}
|
||||
} finally {
|
||||
secureRandom._setSecureRandomFloatSource(null);
|
||||
}
|
||||
assert.deepEqual(calls, ["claude/b", "openai/a", "openai/a", "claude/b", "openai/a", "openai/a"]);
|
||||
});
|
||||
|
||||
test("weighted sticky keeps a top-level step if one nested leaf remains available", async () => {
|
||||
const calls: string[] = [];
|
||||
const step0Key = "weighted-nested-sticky-ref-1-minimax";
|
||||
weightedStickyTargets.set("weighted-nested-sticky", { executionKey: step0Key, successCount: 1 });
|
||||
const combo = {
|
||||
name: "weighted-nested-sticky",
|
||||
strategy: "weighted",
|
||||
models: [{ kind: "combo-ref", comboName: "minimax", weight: 100 }],
|
||||
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0, stickyWeightedLimit: 10 },
|
||||
};
|
||||
const allCombos = [
|
||||
combo,
|
||||
{ name: "minimax", strategy: "priority", models: ["ollama/m3", "oc/m3"] },
|
||||
];
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo,
|
||||
handleSingleModel: async (_body: any, modelStr: string) => {
|
||||
calls.push(modelStr);
|
||||
return okResponse();
|
||||
},
|
||||
isModelAvailable: async (modelStr: string) => modelStr !== "ollama/m3",
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, ["oc/m3"]);
|
||||
assert.equal(weightedStickyTargets.get("weighted-nested-sticky")?.executionKey, step0Key);
|
||||
});
|
||||
|
||||
test("round-robin sticky clears unavailable sticky target before rotation", async () => {
|
||||
const calls: string[] = [];
|
||||
const step0Key = "rr-sticky-clears-unavailable-model-1-openai-a";
|
||||
rrStickyTargets.set("rr-sticky-clears-unavailable", { executionKey: step0Key, successCount: 1 });
|
||||
const combo = {
|
||||
name: "rr-sticky-clears-unavailable",
|
||||
strategy: "round-robin",
|
||||
models: ["openai/a", "claude/b", "gemini/c"],
|
||||
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0, stickyRoundRobinLimit: 10 },
|
||||
};
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo,
|
||||
handleSingleModel: async (_body: any, modelStr: string) => {
|
||||
calls.push(modelStr);
|
||||
return okResponse();
|
||||
},
|
||||
isModelAvailable: async (modelStr: string) => modelStr !== "openai/a",
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, ["claude/b"]);
|
||||
assert.equal(
|
||||
rrStickyTargets.get("rr-sticky-clears-unavailable")?.executionKey,
|
||||
"rr-sticky-clears-unavailable-model-2-claude-b"
|
||||
);
|
||||
});
|
||||
|
||||
test("strict-random survives a stale deck entry after a target is removed", async () => {
|
||||
const comboTwoTargets = {
|
||||
name: "strict-random-stale",
|
||||
@@ -428,6 +600,164 @@ test("strict-random survives a stale deck entry after a target is removed", asyn
|
||||
assert.deepEqual(calls, ["claude/sonnet"]);
|
||||
});
|
||||
|
||||
test("nested execute mode treats combo refs as black-box priority targets", async () => {
|
||||
const calls: string[] = [];
|
||||
const outer = {
|
||||
name: "outer-execute-priority",
|
||||
strategy: "priority",
|
||||
models: [{ kind: "combo-ref", comboName: "child-priority" }, "gemini/fallback"],
|
||||
config: { nestedComboMode: "execute", maxRetries: 0, retryDelayMs: 0 },
|
||||
};
|
||||
const child = {
|
||||
name: "child-priority",
|
||||
strategy: "priority",
|
||||
models: ["openai/a", "claude/b"],
|
||||
config: { maxRetries: 0, retryDelayMs: 0 },
|
||||
};
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo: outer,
|
||||
handleSingleModel: async (_body: any, modelStr: string) => {
|
||||
calls.push(modelStr);
|
||||
return okResponse();
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: [outer, child],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, ["openai/a"]);
|
||||
});
|
||||
|
||||
test("nested execute mode falls back to the next parent target after a child combo fails", async () => {
|
||||
const calls: string[] = [];
|
||||
const outer = {
|
||||
name: "outer-execute-fallback",
|
||||
strategy: "priority",
|
||||
models: [{ kind: "combo-ref", comboName: "child-down" }, "gemini/fallback"],
|
||||
config: { nestedComboMode: "execute", maxRetries: 0, retryDelayMs: 0 },
|
||||
};
|
||||
const child = {
|
||||
name: "child-down",
|
||||
strategy: "priority",
|
||||
models: ["openai/a", "claude/b"],
|
||||
config: { maxRetries: 0, retryDelayMs: 0 },
|
||||
};
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo: outer,
|
||||
handleSingleModel: async (_body: any, modelStr: string) => {
|
||||
calls.push(modelStr);
|
||||
return modelStr === "gemini/fallback" ? okResponse() : errorResponse(503);
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: [outer, child],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, ["openai/a", "claude/b", "gemini/fallback"]);
|
||||
});
|
||||
|
||||
test("nested execute mode supports 3-level nesting", async () => {
|
||||
const calls: string[] = [];
|
||||
const a = {
|
||||
name: "level-a",
|
||||
strategy: "priority",
|
||||
models: [{ kind: "combo-ref", comboName: "level-b" }],
|
||||
config: { nestedComboMode: "execute", maxRetries: 0 },
|
||||
};
|
||||
const b = {
|
||||
name: "level-b",
|
||||
strategy: "priority",
|
||||
models: [{ kind: "combo-ref", comboName: "level-c" }],
|
||||
config: { nestedComboMode: "execute", maxRetries: 0 },
|
||||
};
|
||||
const c = {
|
||||
name: "level-c",
|
||||
strategy: "priority",
|
||||
models: ["openai/leaf"],
|
||||
config: { maxRetries: 0 },
|
||||
};
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo: a,
|
||||
handleSingleModel: async (_body: any, modelStr: string) => {
|
||||
calls.push(modelStr);
|
||||
return okResponse();
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: [a, b, c],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, ["openai/leaf"]);
|
||||
});
|
||||
|
||||
test("nested execute mode fails cleanly on a circular combo reference", async () => {
|
||||
const a = {
|
||||
name: "cycle-a",
|
||||
strategy: "priority",
|
||||
models: [{ kind: "combo-ref", comboName: "cycle-b" }],
|
||||
config: { nestedComboMode: "execute", maxRetries: 0 },
|
||||
};
|
||||
const b = {
|
||||
name: "cycle-b",
|
||||
strategy: "priority",
|
||||
models: [{ kind: "combo-ref", comboName: "cycle-a" }],
|
||||
config: { nestedComboMode: "execute", maxRetries: 0 },
|
||||
};
|
||||
await assert.rejects(
|
||||
async () =>
|
||||
handleComboChat({
|
||||
body: {},
|
||||
combo: a,
|
||||
handleSingleModel: async () => okResponse(),
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: [a, b],
|
||||
}),
|
||||
/[Cc]ircular/
|
||||
);
|
||||
});
|
||||
|
||||
test("flatten mode (default) expands nested combo refs into leaf targets", async () => {
|
||||
const calls: string[] = [];
|
||||
const outer = {
|
||||
name: "outer-flatten-default",
|
||||
strategy: "priority",
|
||||
models: [{ kind: "combo-ref", comboName: "child-flat" }],
|
||||
config: { maxRetries: 0 },
|
||||
};
|
||||
const child = {
|
||||
name: "child-flat",
|
||||
strategy: "priority",
|
||||
models: ["openai/a", "claude/b"],
|
||||
config: { maxRetries: 0 },
|
||||
};
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo: outer,
|
||||
handleSingleModel: async (_body: any, modelStr: string) => {
|
||||
calls.push(modelStr);
|
||||
return modelStr === "openai/a" ? errorResponse(503) : okResponse();
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: [outer, child],
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, ["openai/a", "claude/b"]);
|
||||
});
|
||||
|
||||
test("unknown strategy value normalizes to priority order", async () => {
|
||||
const calls: string[] = [];
|
||||
const result = await handleComboChat({
|
||||
|
||||
123
tests/unit/combo/auto-quota-cutoff.test.ts
Normal file
123
tests/unit/combo/auto-quota-cutoff.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
// tests/unit/combo/auto-quota-cutoff.test.ts
|
||||
// Regression coverage for auto routing quota cutoff: hard-cutoff candidates must be
|
||||
// removed before scoring/fallback so an exhausted account cannot win by latency/model fit.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { scoreAutoTargets } from "../../../open-sse/services/combo/autoStrategy.ts";
|
||||
import type {
|
||||
AutoProviderCandidate,
|
||||
ResolvedComboTarget,
|
||||
} from "../../../open-sse/services/combo/types.ts";
|
||||
import type { ScoringWeights } from "../../../open-sse/services/autoCombo/scoring.ts";
|
||||
|
||||
const latencyOnlyWeights: ScoringWeights = {
|
||||
quota: 0,
|
||||
health: 0,
|
||||
costInv: 0,
|
||||
latencyInv: 1,
|
||||
taskFit: 0,
|
||||
stability: 0,
|
||||
tierPriority: 0,
|
||||
tierAffinity: 0,
|
||||
specificityMatch: 0,
|
||||
contextAffinity: 0,
|
||||
resetWindowAffinity: 0,
|
||||
connectionDensity: 0,
|
||||
};
|
||||
|
||||
function target(provider: string, model: string, connectionId: string): ResolvedComboTarget {
|
||||
return {
|
||||
kind: "model",
|
||||
stepId: `${provider}-${model}-${connectionId}`,
|
||||
executionKey: `${provider}/${model}@${connectionId}`,
|
||||
modelStr: `${provider}/${model}`,
|
||||
provider,
|
||||
providerId: null,
|
||||
connectionId,
|
||||
} as ResolvedComboTarget;
|
||||
}
|
||||
|
||||
function candidate(
|
||||
provider: string,
|
||||
model: string,
|
||||
connectionId: string,
|
||||
overrides: Partial<AutoProviderCandidate> = {}
|
||||
): AutoProviderCandidate {
|
||||
return {
|
||||
provider,
|
||||
model,
|
||||
stepId: `${provider}-${model}-${connectionId}`,
|
||||
executionKey: `${provider}/${model}@${connectionId}`,
|
||||
modelStr: `${provider}/${model}`,
|
||||
connectionId,
|
||||
quotaRemaining: 100,
|
||||
quotaTotal: 100,
|
||||
circuitBreakerState: "CLOSED",
|
||||
costPer1MTokens: 1,
|
||||
p95LatencyMs: 1000,
|
||||
latencyStdDev: 10,
|
||||
errorRate: 0,
|
||||
resetWindowAffinity: 0.5,
|
||||
connectionPoolSize: 1,
|
||||
...overrides,
|
||||
} as AutoProviderCandidate;
|
||||
}
|
||||
|
||||
test("auto scoring skips GLM when its 2% remaining quota hit the hard cutoff", () => {
|
||||
const targets = [target("glm", "glm-5.2", "glm-empty"), target("mcode", "mimo-auto", "mcode-ok")];
|
||||
const ranked = scoreAutoTargets(
|
||||
targets,
|
||||
[
|
||||
candidate("glm", "glm-5.2", "glm-empty", {
|
||||
quotaRemaining: 2,
|
||||
p95LatencyMs: 10,
|
||||
quotaCutoffBlocked: true,
|
||||
quotaCutoffReason: "quota_exhausted",
|
||||
}),
|
||||
candidate("mcode", "mimo-auto", "mcode-ok", {
|
||||
quotaRemaining: 100,
|
||||
p95LatencyMs: 5000,
|
||||
}),
|
||||
],
|
||||
"coding",
|
||||
latencyOnlyWeights
|
||||
);
|
||||
|
||||
assert.equal(ranked.length, 1);
|
||||
assert.equal(ranked[0]?.target.provider, "mcode");
|
||||
assert.equal(ranked[0]?.target.connectionId, "mcode-ok");
|
||||
});
|
||||
|
||||
test("blocked quota candidates are not included in the scoring pool", () => {
|
||||
const targets = [
|
||||
target("fast", "healthy", "fast-ok"),
|
||||
target("slow", "healthy", "slow-ok"),
|
||||
target("glm", "glm-5.2", "glm-empty"),
|
||||
];
|
||||
const ranked = scoreAutoTargets(
|
||||
targets,
|
||||
[
|
||||
candidate("fast", "healthy", "fast-ok", { p95LatencyMs: 100 }),
|
||||
candidate("slow", "healthy", "slow-ok", { p95LatencyMs: 1000 }),
|
||||
candidate("glm", "glm-5.2", "glm-empty", {
|
||||
quotaRemaining: 0,
|
||||
p95LatencyMs: 10000,
|
||||
quotaCutoffBlocked: true,
|
||||
quotaCutoffReason: "quota_exhausted",
|
||||
}),
|
||||
],
|
||||
"coding",
|
||||
latencyOnlyWeights
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
ranked.map((entry) => entry.target.provider),
|
||||
["fast", "slow"]
|
||||
);
|
||||
const slowScore = ranked.find((entry) => entry.target.provider === "slow")?.score;
|
||||
assert.equal(
|
||||
slowScore,
|
||||
0,
|
||||
"the blocked GLM latency must not inflate surviving candidates' scores"
|
||||
);
|
||||
});
|
||||
@@ -294,6 +294,58 @@ test("Command Code executor surfaces upstream and streamed errors", async () =>
|
||||
}, /boom/);
|
||||
});
|
||||
|
||||
test("Command Code executor caps max_tokens to the registered per-model limit (GLM-5.x)", async () => {
|
||||
const calls: FetchCall[] = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
calls.push({ url: String(url), init, body: JSON.parse(String(init.body)) });
|
||||
return commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }]);
|
||||
};
|
||||
|
||||
// GLM-5 and GLM-5.1 are registered with maxOutputTokens: 131072.
|
||||
// Without per-model capping, the upstream rejects with
|
||||
// "限制数值范围[1,131072]".
|
||||
await getExecutor("command-code").execute({
|
||||
model: "zai-org/GLM-5.1",
|
||||
stream: false,
|
||||
credentials: { apiKey: "cc_test_key" },
|
||||
body: { messages: [{ role: "user", content: "Hi" }] },
|
||||
});
|
||||
assert.equal(calls[0].body.params.max_tokens, 131072);
|
||||
});
|
||||
|
||||
test("Command Code executor caps max_tokens to the registered per-model limit (DeepSeek v4)", async () => {
|
||||
const calls: FetchCall[] = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
calls.push({ url: String(url), init, body: JSON.parse(String(init.body)) });
|
||||
return commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }]);
|
||||
};
|
||||
|
||||
// DeepSeek v4 pro is registered with maxOutputTokens: 384000.
|
||||
await getExecutor("command-code").execute({
|
||||
model: "deepseek/deepseek-v4-pro",
|
||||
stream: false,
|
||||
credentials: { apiKey: "cc_test_key" },
|
||||
body: { messages: [{ role: "user", content: "Hi" }] },
|
||||
});
|
||||
assert.equal(calls[0].body.params.max_tokens, 384000);
|
||||
});
|
||||
|
||||
test("Command Code executor honors a smaller client-provided max_tokens under the per-model cap", async () => {
|
||||
const calls: FetchCall[] = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
calls.push({ url: String(url), init, body: JSON.parse(String(init.body)) });
|
||||
return commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }]);
|
||||
};
|
||||
|
||||
await getExecutor("command-code").execute({
|
||||
model: "zai-org/GLM-5.1",
|
||||
stream: false,
|
||||
credentials: { apiKey: "cc_test_key" },
|
||||
body: { messages: [{ role: "user", content: "Hi" }], max_tokens: 2048 },
|
||||
});
|
||||
assert.equal(calls[0].body.params.max_tokens, 2048);
|
||||
});
|
||||
|
||||
test("Command Code non-stream aggregation throws when the final error event lacks a trailing newline", async () => {
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
|
||||
50
tests/unit/compression/active-combo-dispatch.test.ts
Normal file
50
tests/unit/compression/active-combo-dispatch.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
selectCompressionStrategy,
|
||||
selectCompressionPlan,
|
||||
activeComboResolves,
|
||||
} from "../../../open-sse/services/compression/strategySelector.ts";
|
||||
import {
|
||||
DEFAULT_COMPRESSION_CONFIG,
|
||||
type CompressionConfig,
|
||||
} from "../../../open-sse/services/compression/types.ts";
|
||||
|
||||
const combos = { c1: [{ engine: "rtk", intensity: "standard" }, { engine: "caveman", intensity: "full" }] };
|
||||
|
||||
function cfg(overrides: Partial<CompressionConfig> = {}): CompressionConfig {
|
||||
return { ...DEFAULT_COMPRESSION_CONFIG, enabled: true, ...overrides };
|
||||
}
|
||||
|
||||
describe("active named combo resolution (Phase 2)", () => {
|
||||
it("activeComboId + combo present => that combo's stacked pipeline (regardless of enginesExplicit)", () => {
|
||||
const config = cfg({ activeComboId: "c1", enginesExplicit: false });
|
||||
const plan = selectCompressionPlan(config, null, 0, undefined, undefined, combos);
|
||||
assert.equal(plan.mode, "stacked");
|
||||
assert.deepEqual(plan.stackedPipeline, combos.c1);
|
||||
});
|
||||
it("activeComboId null => falls through to derived default (not the combo)", () => {
|
||||
const config = cfg({ activeComboId: null, enginesExplicit: true, engines: { rtk: { enabled: true } } });
|
||||
assert.equal(selectCompressionStrategy(config, null, 0, undefined, undefined, combos), "rtk");
|
||||
});
|
||||
it("activeComboId set but combo missing => graceful fall-through to default", () => {
|
||||
const config = cfg({ activeComboId: "ghost", defaultMode: "lite", enginesExplicit: false });
|
||||
assert.equal(selectCompressionStrategy(config, null, 0, undefined, undefined, combos), "lite");
|
||||
});
|
||||
it("routing-combo override wins over the active profile", () => {
|
||||
const config = cfg({ activeComboId: "c1", comboOverrides: { "my-combo": "off" } });
|
||||
assert.equal(selectCompressionStrategy(config, "my-combo", 0, undefined, undefined, combos), "off");
|
||||
});
|
||||
it("active profile wins over auto-trigger", () => {
|
||||
const config = cfg({ activeComboId: "c1", autoTriggerTokens: 1000, autoTriggerMode: "aggressive" });
|
||||
assert.equal(selectCompressionStrategy(config, null, 5000, undefined, undefined, combos), "stacked");
|
||||
});
|
||||
});
|
||||
|
||||
describe("activeComboResolves", () => {
|
||||
it("true only when activeComboId is set AND present in combos", () => {
|
||||
assert.equal(activeComboResolves(cfg({ activeComboId: "c1" }), combos), true);
|
||||
assert.equal(activeComboResolves(cfg({ activeComboId: "ghost" }), combos), false);
|
||||
assert.equal(activeComboResolves(cfg({ activeComboId: null }), combos), false);
|
||||
});
|
||||
});
|
||||
43
tests/unit/compression/active-combo-integration.test.ts
Normal file
43
tests/unit/compression/active-combo-integration.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { test, after } 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-active-combo-"));
|
||||
const ORIGINAL = process.env.DATA_DIR;
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const { getDbInstance, resetDbInstance } = await import("../../../src/lib/db/core.ts");
|
||||
const combosDb = await import("../../../src/lib/db/compressionCombos.ts");
|
||||
const { updateCompressionSettings } = await import("../../../src/lib/db/compression.ts");
|
||||
const { selectCompressionPlan } = await import("../../../open-sse/services/compression/strategySelector.ts");
|
||||
const { DEFAULT_COMPRESSION_CONFIG } = await import("../../../open-sse/services/compression/types.ts");
|
||||
|
||||
after(() => {
|
||||
resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (ORIGINAL === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = ORIGINAL;
|
||||
});
|
||||
|
||||
test("an active named combo's pipeline is what selectCompressionPlan resolves, fed from the DB combos map", async () => {
|
||||
resetDbInstance();
|
||||
getDbInstance();
|
||||
const created = combosDb.createCompressionCombo({
|
||||
name: "RTK only",
|
||||
pipeline: [{ engine: "rtk", intensity: "standard" }],
|
||||
});
|
||||
await updateCompressionSettings({ enabled: true, activeComboId: created.id });
|
||||
|
||||
// Mirror chatCore's load: build the combos map from the DB.
|
||||
const combos = Object.fromEntries(combosDb.listCompressionCombos().map((c) => [c.id, c.pipeline]));
|
||||
const config = { ...DEFAULT_COMPRESSION_CONFIG, enabled: true, activeComboId: created.id };
|
||||
const plan = selectCompressionPlan(config, null, 5000, undefined, undefined, combos);
|
||||
assert.equal(plan.mode, "stacked");
|
||||
assert.deepEqual(plan.stackedPipeline, [{ engine: "rtk", intensity: "standard" }]);
|
||||
|
||||
// Setting activeComboId did NOT change which combo is is_default (legacy untouched).
|
||||
const def = combosDb.getDefaultCompressionCombo();
|
||||
assert.notEqual(def?.id, created.id);
|
||||
});
|
||||
96
tests/unit/compression/rtk-cache-control-preserve.test.ts
Normal file
96
tests/unit/compression/rtk-cache-control-preserve.test.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { applyRtkCompression } from "../../../open-sse/services/compression/engines/rtk/index.ts";
|
||||
|
||||
// Regression: a tool_result block the client marked with `cache_control` is an explicit
|
||||
// prompt-cache breakpoint — the upstream caches the prefix up to and INCLUDING that block.
|
||||
// RTK (since the Anthropic tool_result support added in v3.8.32) rewrote the block's inner
|
||||
// content, so the cached prefix no longer matched byte-for-byte → guaranteed cache miss at
|
||||
// that breakpoint (reported as "provider cache again broken" after upgrading). Compression
|
||||
// must never alter a block carrying `cache_control`. Mirrors #3936's invariant: under
|
||||
// caching, only ever preserve more of the prefix — never rewrite a declared breakpoint.
|
||||
|
||||
const GIT_STATUS = `On branch main
|
||||
Your branch is up to date with 'origin/main'.
|
||||
|
||||
Changes not staged for commit:
|
||||
(use "git add <file>..." to update what will be committed)
|
||||
(use "git restore <file>..." to discard changes in working directory)
|
||||
modified: src/app.ts
|
||||
modified: src/lib/util.ts
|
||||
|
||||
Untracked files:
|
||||
(use "git add <file>..." to include in what will be committed)
|
||||
src/new.ts
|
||||
|
||||
no changes added to commit (use "git add" and/or "git commit -a")`;
|
||||
|
||||
function anthropicBody(toolResultBlock: Record<string, unknown>) {
|
||||
return {
|
||||
model: "anthropic/claude-sonnet-4",
|
||||
messages: [
|
||||
{ role: "user", content: "run git status" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "tool_use", id: "toolu_1", name: "bash", input: { command: "git status" } },
|
||||
],
|
||||
},
|
||||
{ role: "user", content: [toolResultBlock] },
|
||||
],
|
||||
} as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Pull the single tool_result block out of message[2].content[0] with a typed shape (no `any`).
|
||||
function toolResultBlock(body: Record<string, unknown>): Record<string, unknown> {
|
||||
const messages = body.messages as Array<{ content: Array<Record<string, unknown>> }>;
|
||||
return messages[2].content[0];
|
||||
}
|
||||
|
||||
test("RTK preserves a tool_result block carrying cache_control byte-for-byte (string content)", () => {
|
||||
const block = {
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_1",
|
||||
content: GIT_STATUS,
|
||||
cache_control: { type: "ephemeral" },
|
||||
};
|
||||
const body = anthropicBody(block);
|
||||
const before = JSON.stringify(toolResultBlock(body));
|
||||
|
||||
const res = applyRtkCompression(body, { config: { enabled: true, applyToToolResults: true } });
|
||||
const after = JSON.stringify(toolResultBlock(res.body as Record<string, unknown>));
|
||||
|
||||
// The marked breakpoint block must be untouched — content identical, marker intact.
|
||||
assert.equal(after, before, "cache_control-marked tool_result must not be rewritten");
|
||||
assert.deepEqual(toolResultBlock(res.body as Record<string, unknown>).cache_control, { type: "ephemeral" });
|
||||
});
|
||||
|
||||
test("RTK preserves an inner text sub-block carrying cache_control (array content)", () => {
|
||||
const block = {
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_1",
|
||||
content: [{ type: "text", text: GIT_STATUS, cache_control: { type: "ephemeral" } }],
|
||||
};
|
||||
const body = anthropicBody(block);
|
||||
const before = JSON.stringify(toolResultBlock(body));
|
||||
|
||||
const res = applyRtkCompression(body, { config: { enabled: true, applyToToolResults: true } });
|
||||
const after = JSON.stringify(toolResultBlock(res.body as Record<string, unknown>));
|
||||
|
||||
assert.equal(after, before, "cache_control-marked text sub-block must not be rewritten");
|
||||
});
|
||||
|
||||
test("RTK still compresses tool_result blocks WITHOUT cache_control (no over-protection)", () => {
|
||||
const block = {
|
||||
type: "tool_result",
|
||||
tool_use_id: "toolu_1",
|
||||
content: GIT_STATUS,
|
||||
};
|
||||
const body = anthropicBody(block);
|
||||
|
||||
const res = applyRtkCompression(body, { config: { enabled: true, applyToToolResults: true } });
|
||||
const after = toolResultBlock(res.body as Record<string, unknown>).content as string;
|
||||
|
||||
assert.equal(res.compressed, true, "unmarked tool_result should still compress");
|
||||
assert.ok(!after.includes('(use "git add'), "hint lines should be dropped when uncached");
|
||||
});
|
||||
158
tests/unit/cursor-composer-thinking.test.ts
Normal file
158
tests/unit/cursor-composer-thinking.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
newStreamCtx,
|
||||
processFrame,
|
||||
isComposerModel,
|
||||
visibleComposerContentFromThinking,
|
||||
composerReasoningRemainder,
|
||||
type StreamCtx,
|
||||
} from "../../open-sse/executors/cursor";
|
||||
|
||||
// ─── Wire-format helpers (mirror cursor-streaming.test.ts) ────────────────────
|
||||
|
||||
function v(n: number): Buffer {
|
||||
const out: number[] = [];
|
||||
while (n > 0x7f) {
|
||||
out.push((n & 0x7f) | 0x80);
|
||||
n >>>= 7;
|
||||
}
|
||||
out.push(n);
|
||||
return Buffer.from(out);
|
||||
}
|
||||
function tag(field: number, wireType: number): Buffer {
|
||||
return v((field << 3) | wireType);
|
||||
}
|
||||
function lenPrefixed(field: number, payload: Buffer): Buffer {
|
||||
return Buffer.concat([tag(field, 2), v(payload.length), payload]);
|
||||
}
|
||||
|
||||
// AgentServerMessage { interaction_update (1): { thinking_delta (4): { text (1): str } } }
|
||||
function buildThinkingDeltaPayload(text: string): Buffer {
|
||||
const tdu = lenPrefixed(1, Buffer.from(text, "utf8"));
|
||||
const iu = lenPrefixed(4, tdu);
|
||||
return lenPrefixed(1, iu);
|
||||
}
|
||||
|
||||
function parseSSE(text: string): Array<Record<string, unknown>> {
|
||||
return text
|
||||
.split("\n\n")
|
||||
.filter((c) => c.startsWith("data: "))
|
||||
.map((c) => c.slice("data: ".length))
|
||||
.filter((d) => d !== "[DONE]")
|
||||
.map((d) => JSON.parse(d));
|
||||
}
|
||||
|
||||
// ─── Pure helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
test("isComposerModel matches composer + composer-* (case-insensitive, vendor prefix tolerated)", () => {
|
||||
assert.equal(isComposerModel("composer"), true);
|
||||
assert.equal(isComposerModel("composer-2.5"), true);
|
||||
assert.equal(isComposerModel("composer-2.5-fast"), true);
|
||||
assert.equal(isComposerModel("cu/composer-2.5"), true);
|
||||
assert.equal(isComposerModel("CURSOR/Composer-2.5"), true);
|
||||
assert.equal(isComposerModel("gpt-5.3-codex"), false);
|
||||
assert.equal(isComposerModel("claude-4-sonnet"), false);
|
||||
assert.equal(isComposerModel("composer2"), false);
|
||||
assert.equal(isComposerModel(""), false);
|
||||
});
|
||||
|
||||
test("visibleComposerContentFromThinking returns suffix after last </think> (trim-start)", () => {
|
||||
assert.equal(
|
||||
visibleComposerContentFromThinking("private reasoning</think>OK"),
|
||||
"OK"
|
||||
);
|
||||
assert.equal(
|
||||
visibleComposerContentFromThinking("a</think>b</think> final"),
|
||||
"final"
|
||||
);
|
||||
assert.equal(visibleComposerContentFromThinking("no marker yet"), "");
|
||||
assert.equal(visibleComposerContentFromThinking(""), "");
|
||||
assert.equal(visibleComposerContentFromThinking("ends with</think>"), "");
|
||||
});
|
||||
|
||||
test("composerReasoningRemainder returns only the hidden portion before last </think>", () => {
|
||||
assert.equal(
|
||||
composerReasoningRemainder("private reasoning</think>OK"),
|
||||
"private reasoning"
|
||||
);
|
||||
assert.equal(
|
||||
composerReasoningRemainder("just hidden, no marker"),
|
||||
"just hidden, no marker"
|
||||
);
|
||||
assert.equal(composerReasoningRemainder(""), "");
|
||||
});
|
||||
|
||||
// ─── Composer thinking handling via processFrame ─────────────────────────────
|
||||
|
||||
test("Composer streaming: emits visible suffix after </think> as content deltas; hidden never leaks as content", () => {
|
||||
const chunks: string[] = [];
|
||||
const ctx: StreamCtx = newStreamCtx("composer-2.5-fast", (c) => chunks.push(c));
|
||||
processFrame(buildThinkingDeltaPayload("private reasoning"), ctx, new Set());
|
||||
processFrame(
|
||||
buildThinkingDeltaPayload(" that must not leak</think>O"),
|
||||
ctx,
|
||||
new Set()
|
||||
);
|
||||
processFrame(buildThinkingDeltaPayload("K"), ctx, new Set());
|
||||
|
||||
const sseText = chunks.join("");
|
||||
const events = parseSSE(sseText);
|
||||
const content = events
|
||||
.map((e) => {
|
||||
const choices = (e as { choices?: Array<{ delta?: { content?: string } }> }).choices;
|
||||
return choices?.[0]?.delta?.content ?? "";
|
||||
})
|
||||
.join("");
|
||||
assert.equal(content, "OK");
|
||||
// Aggregated ctx.totalText must mirror the visible content so the
|
||||
// non-streaming aggregator surfaces it as message.content unchanged.
|
||||
assert.equal(ctx.totalText, "OK");
|
||||
// Composer must NOT emit reasoning_content for the visible suffix portion
|
||||
// — the hidden reasoning may still appear as reasoning_content deltas, but
|
||||
// the literal post-</think> text must never appear as reasoning_content.
|
||||
const reasoningStream = events
|
||||
.map((e) => {
|
||||
const choices = (e as { choices?: Array<{ delta?: { reasoning_content?: string } }> })
|
||||
.choices;
|
||||
return choices?.[0]?.delta?.reasoning_content ?? "";
|
||||
})
|
||||
.join("");
|
||||
assert.ok(
|
||||
!reasoningStream.includes("OK"),
|
||||
"visible suffix must not be duplicated into reasoning_content"
|
||||
);
|
||||
});
|
||||
|
||||
test("Composer non-streaming aggregation: thinking with </think> populates totalText with visible suffix", () => {
|
||||
const chunks: string[] = [];
|
||||
const ctx: StreamCtx = newStreamCtx("cu/composer-2.5", (c) => chunks.push(c));
|
||||
processFrame(
|
||||
buildThinkingDeltaPayload("private reasoning that must not leak</think>OK"),
|
||||
ctx,
|
||||
new Set()
|
||||
);
|
||||
assert.equal(ctx.totalText, "OK");
|
||||
});
|
||||
|
||||
test("Non-Composer model: thinking field stays in reasoning_content (unchanged contract)", () => {
|
||||
const chunks: string[] = [];
|
||||
const ctx: StreamCtx = newStreamCtx("gpt-5.3-codex", (c) => chunks.push(c));
|
||||
processFrame(
|
||||
buildThinkingDeltaPayload("hidden</think>SHOULD_NOT_APPEAR"),
|
||||
ctx,
|
||||
new Set()
|
||||
);
|
||||
|
||||
const sseText = chunks.join("");
|
||||
assert.ok(sseText.includes("reasoning_content"), "reasoning_content delta missing");
|
||||
const events = parseSSE(sseText);
|
||||
const content = events
|
||||
.map((e) => {
|
||||
const choices = (e as { choices?: Array<{ delta?: { content?: string } }> }).choices;
|
||||
return choices?.[0]?.delta?.content ?? "";
|
||||
})
|
||||
.join("");
|
||||
assert.equal(content, "", "non-Composer must not surface thinking as content");
|
||||
assert.equal(ctx.totalText, "", "non-Composer must not populate totalText from thinking");
|
||||
});
|
||||
@@ -415,7 +415,10 @@ test("local sqlite configuration enables WAL and sane pragmas", serial, async ()
|
||||
const db = core.getDbInstance();
|
||||
|
||||
assert.equal(db.pragma("journal_mode", { simple: true }), "wal");
|
||||
assert.equal(db.pragma("busy_timeout", { simple: true }), 5000);
|
||||
// v3.8.32 intentionally capped busy_timeout at 2s (was 5s) so a contended
|
||||
// synchronous write cannot park the Node event loop past the host watchdog's
|
||||
// 6s liveness probe — see src/lib/db/core.ts.
|
||||
assert.equal(db.pragma("busy_timeout", { simple: true }), 2000);
|
||||
assert.equal(db.pragma("synchronous", { simple: true }), 1);
|
||||
assert.equal(core.closeDbInstance({ checkpointMode: null }), true);
|
||||
});
|
||||
|
||||
@@ -115,6 +115,21 @@ test("#3500 buildUnifiedSource — raw-only branch when sinceIso is recent", ()
|
||||
assert.ok("since" in result.unifiedParams, "unifiedParams has since key");
|
||||
});
|
||||
|
||||
test("#3500 buildUnifiedSource — raw-only branch for same cutoff date ISO", () => {
|
||||
const rawCutoffDate = "2026-06-21";
|
||||
const result = mod.buildUnifiedSource({
|
||||
sinceIso: "2026-06-21T01:00:00.000Z",
|
||||
untilIso: null,
|
||||
rawCutoffDate,
|
||||
apiKeyWhere: "",
|
||||
apiKeyParams: {},
|
||||
});
|
||||
|
||||
assert.ok(!result.unifiedSource.includes("daily_usage_summary"), "no same-day agg table");
|
||||
assert.equal(result.unifiedParams.since, "2026-06-21T01:00:00.000Z");
|
||||
assert.ok(!("rawCutoffDate" in result.unifiedParams), "rawCutoffDate not needed");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildUnifiedSource — UNION branch (agg needed)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -154,7 +169,10 @@ test("#3500 buildUnifiedSource — api_key filter suppresses daily_usage_summary
|
||||
apiKeyParams: { apiKey0: "key-abc" },
|
||||
});
|
||||
|
||||
assert.ok(!result.unifiedSource.includes("daily_usage_summary"), "agg leg suppressed with api_key filter");
|
||||
assert.ok(
|
||||
!result.unifiedSource.includes("daily_usage_summary"),
|
||||
"agg leg suppressed with api_key filter"
|
||||
);
|
||||
assert.ok(result.unifiedSource.includes("usage_history"), "raw leg still present");
|
||||
assert.equal(result.unifiedParams.apiKey0, "key-abc", "apiKey0 propagated");
|
||||
});
|
||||
@@ -167,8 +185,20 @@ test("#3500 getUsageSummary — returns correct scalar aggregations", () => {
|
||||
const rawCutoffDate = "2020-01-01";
|
||||
const ts = new Date().toISOString();
|
||||
|
||||
insertUsageHistory({ tokens_input: 50, tokens_output: 80, success: 1, latency_ms: 200, timestamp: ts });
|
||||
insertUsageHistory({ tokens_input: 30, tokens_output: 40, success: 0, latency_ms: 400, timestamp: ts });
|
||||
insertUsageHistory({
|
||||
tokens_input: 50,
|
||||
tokens_output: 80,
|
||||
success: 1,
|
||||
latency_ms: 200,
|
||||
timestamp: ts,
|
||||
});
|
||||
insertUsageHistory({
|
||||
tokens_input: 30,
|
||||
tokens_output: 40,
|
||||
success: 0,
|
||||
latency_ms: 400,
|
||||
timestamp: ts,
|
||||
});
|
||||
|
||||
const { unifiedSource, unifiedParams } = mod.buildUnifiedSource({
|
||||
sinceIso: null,
|
||||
@@ -233,8 +263,22 @@ test("#3500 getDailyCostRows — groups by date+provider+model+serviceTier", ()
|
||||
const rawCutoffDate = "2020-01-01";
|
||||
const ts = "2025-04-01T12:00:00.000Z";
|
||||
|
||||
insertUsageHistory({ timestamp: ts, provider: "anthropic", model: "claude-3-5-sonnet", tokens_input: 100, tokens_output: 200, service_tier: "priority" });
|
||||
insertUsageHistory({ timestamp: ts, provider: "anthropic", model: "claude-3-5-sonnet", tokens_input: 50, tokens_output: 100, service_tier: "priority" });
|
||||
insertUsageHistory({
|
||||
timestamp: ts,
|
||||
provider: "anthropic",
|
||||
model: "claude-3-5-sonnet",
|
||||
tokens_input: 100,
|
||||
tokens_output: 200,
|
||||
service_tier: "priority",
|
||||
});
|
||||
insertUsageHistory({
|
||||
timestamp: ts,
|
||||
provider: "anthropic",
|
||||
model: "claude-3-5-sonnet",
|
||||
tokens_input: 50,
|
||||
tokens_output: 100,
|
||||
service_tier: "priority",
|
||||
});
|
||||
|
||||
const { unifiedSource, unifiedParams } = mod.buildUnifiedSource({
|
||||
sinceIso: "2025-04-01T00:00:00.000Z",
|
||||
@@ -262,10 +306,9 @@ test("#3500 getHeatmapRows — groups by date, respects conditions", () => {
|
||||
const ts = "2025-05-15T10:00:00.000Z";
|
||||
insertUsageHistory({ timestamp: ts, tokens_input: 40, tokens_output: 60 });
|
||||
|
||||
const rows = mod.getHeatmapRows(
|
||||
["timestamp >= @heatmapStart"],
|
||||
{ heatmapStart: "2025-05-15T00:00:00.000Z" }
|
||||
);
|
||||
const rows = mod.getHeatmapRows(["timestamp >= @heatmapStart"], {
|
||||
heatmapStart: "2025-05-15T00:00:00.000Z",
|
||||
});
|
||||
|
||||
const may15 = rows.find((r) => r.date === "2025-05-15");
|
||||
assert.ok(may15, "2025-05-15 row present");
|
||||
@@ -280,8 +323,24 @@ test("#3500 getModelUsageRows — returns per-model aggregates", () => {
|
||||
const rawCutoffDate = "2020-01-01";
|
||||
const ts = "2025-06-01T10:00:00.000Z";
|
||||
|
||||
insertUsageHistory({ timestamp: ts, provider: "openai", model: "gpt-5", tokens_input: 100, tokens_output: 200, success: 1, latency_ms: 150 });
|
||||
insertUsageHistory({ timestamp: ts, provider: "openai", model: "gpt-5", tokens_input: 50, tokens_output: 100, success: 1, latency_ms: 250 });
|
||||
insertUsageHistory({
|
||||
timestamp: ts,
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
tokens_input: 100,
|
||||
tokens_output: 200,
|
||||
success: 1,
|
||||
latency_ms: 150,
|
||||
});
|
||||
insertUsageHistory({
|
||||
timestamp: ts,
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
tokens_input: 50,
|
||||
tokens_output: 100,
|
||||
success: 1,
|
||||
latency_ms: 250,
|
||||
});
|
||||
|
||||
const { unifiedSource, unifiedParams } = mod.buildUnifiedSource({
|
||||
sinceIso: "2025-06-01T00:00:00.000Z",
|
||||
@@ -310,7 +369,14 @@ test("#3500 getProviderCostRows — groups by provider+model+serviceTier", () =>
|
||||
const rawCutoffDate = "2020-01-01";
|
||||
const ts = "2025-07-01T12:00:00.000Z";
|
||||
|
||||
insertUsageHistory({ timestamp: ts, provider: "gemini", model: "gemini-2.5-flash", tokens_input: 200, tokens_output: 300, tokens_cache_read: 50 });
|
||||
insertUsageHistory({
|
||||
timestamp: ts,
|
||||
provider: "gemini",
|
||||
model: "gemini-2.5-flash",
|
||||
tokens_input: 200,
|
||||
tokens_output: 300,
|
||||
tokens_cache_read: 50,
|
||||
});
|
||||
|
||||
const { unifiedSource, unifiedParams } = mod.buildUnifiedSource({
|
||||
sinceIso: "2025-07-01T00:00:00.000Z",
|
||||
@@ -335,8 +401,22 @@ test("#3500 getProviderUsageRows — aggregates per provider", () => {
|
||||
const rawCutoffDate = "2020-01-01";
|
||||
const ts = "2025-08-01T12:00:00.000Z";
|
||||
|
||||
insertUsageHistory({ timestamp: ts, provider: "mistral", tokens_input: 100, tokens_output: 100, success: 1, latency_ms: 200 });
|
||||
insertUsageHistory({ timestamp: ts, provider: "mistral", tokens_input: 100, tokens_output: 100, success: 0, latency_ms: 400 });
|
||||
insertUsageHistory({
|
||||
timestamp: ts,
|
||||
provider: "mistral",
|
||||
tokens_input: 100,
|
||||
tokens_output: 100,
|
||||
success: 1,
|
||||
latency_ms: 200,
|
||||
});
|
||||
insertUsageHistory({
|
||||
timestamp: ts,
|
||||
provider: "mistral",
|
||||
tokens_input: 100,
|
||||
tokens_output: 100,
|
||||
success: 0,
|
||||
latency_ms: 400,
|
||||
});
|
||||
|
||||
const { unifiedSource, unifiedParams } = mod.buildUnifiedSource({
|
||||
sinceIso: "2025-08-01T00:00:00.000Z",
|
||||
@@ -365,8 +445,22 @@ test("#3500 getApiKeyUsageRows — groups by api_key identity", () => {
|
||||
const apiKeyWhereClause =
|
||||
"WHERE (api_key_id IS NOT NULL AND api_key_id != '') OR (api_key_name IS NOT NULL AND api_key_name != '')";
|
||||
|
||||
insertUsageHistory({ timestamp: ts, provider: "openai", model: "gpt-4.1", api_key_id: "key-xyz", tokens_input: 50, tokens_output: 80 });
|
||||
insertUsageHistory({ timestamp: ts, provider: "openai", model: "gpt-4.1", api_key_id: "key-xyz", tokens_input: 30, tokens_output: 40 });
|
||||
insertUsageHistory({
|
||||
timestamp: ts,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1",
|
||||
api_key_id: "key-xyz",
|
||||
tokens_input: 50,
|
||||
tokens_output: 80,
|
||||
});
|
||||
insertUsageHistory({
|
||||
timestamp: ts,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1",
|
||||
api_key_id: "key-xyz",
|
||||
tokens_input: 30,
|
||||
tokens_output: 40,
|
||||
});
|
||||
|
||||
const rows = mod.getApiKeyUsageRows(apiKeyWhereClause, {});
|
||||
const row = rows.find((r) => r.apiKeyId === "key-xyz");
|
||||
@@ -399,9 +493,30 @@ test("#3500 getServiceTierUsageRows — groups by serviceTier+provider+model", (
|
||||
const rawCutoffDate = "2020-01-01";
|
||||
const ts = "2025-09-01T12:00:00.000Z";
|
||||
|
||||
insertUsageHistory({ timestamp: ts, provider: "openai", model: "gpt-5", service_tier: "flex", tokens_input: 100, tokens_output: 150 });
|
||||
insertUsageHistory({ timestamp: ts, provider: "openai", model: "gpt-5", service_tier: "flex", tokens_input: 50, tokens_output: 75 });
|
||||
insertUsageHistory({ timestamp: ts, provider: "openai", model: "gpt-5", service_tier: "standard", tokens_input: 200, tokens_output: 300 });
|
||||
insertUsageHistory({
|
||||
timestamp: ts,
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
service_tier: "flex",
|
||||
tokens_input: 100,
|
||||
tokens_output: 150,
|
||||
});
|
||||
insertUsageHistory({
|
||||
timestamp: ts,
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
service_tier: "flex",
|
||||
tokens_input: 50,
|
||||
tokens_output: 75,
|
||||
});
|
||||
insertUsageHistory({
|
||||
timestamp: ts,
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
service_tier: "standard",
|
||||
tokens_input: 200,
|
||||
tokens_output: 300,
|
||||
});
|
||||
|
||||
const { unifiedSource, unifiedParams } = mod.buildUnifiedSource({
|
||||
sinceIso: "2025-09-01T00:00:00.000Z",
|
||||
@@ -430,7 +545,11 @@ test("#3500 getServiceTierUsageRows — groups by serviceTier+provider+model", (
|
||||
test("#3500 getWeeklyPatternRows — groups by day of week, ascending", () => {
|
||||
const rawCutoffDate = "2020-01-01";
|
||||
// Monday 2025-06-02
|
||||
insertUsageHistory({ timestamp: "2025-06-02T10:00:00.000Z", tokens_input: 10, tokens_output: 20 });
|
||||
insertUsageHistory({
|
||||
timestamp: "2025-06-02T10:00:00.000Z",
|
||||
tokens_input: 10,
|
||||
tokens_output: 20,
|
||||
});
|
||||
insertUsageHistory({ timestamp: "2025-06-02T11:00:00.000Z", tokens_input: 5, tokens_output: 10 });
|
||||
|
||||
const { unifiedSource, unifiedParams } = mod.buildUnifiedSource({
|
||||
@@ -493,7 +612,16 @@ test("#3500 getPresetCostModelRows — groups by model+provider+serviceTier", ()
|
||||
const rawCutoffDate = "2020-01-01";
|
||||
const ts = "2025-10-01T12:00:00.000Z";
|
||||
|
||||
insertUsageHistory({ timestamp: ts, provider: "cohere", model: "command-r-plus", tokens_input: 200, tokens_output: 300, tokens_cache_read: 10, tokens_cache_creation: 5, tokens_reasoning: 0 });
|
||||
insertUsageHistory({
|
||||
timestamp: ts,
|
||||
provider: "cohere",
|
||||
model: "command-r-plus",
|
||||
tokens_input: 200,
|
||||
tokens_output: 300,
|
||||
tokens_cache_read: 10,
|
||||
tokens_cache_creation: 5,
|
||||
tokens_reasoning: 0,
|
||||
});
|
||||
|
||||
const { unifiedSource, unifiedParams } = mod.buildPresetUnifiedSource({
|
||||
sinceIso: "2025-10-01T00:00:00.000Z",
|
||||
|
||||
138
tests/unit/db/vacuum-scheduler.test.ts
Normal file
138
tests/unit/db/vacuum-scheduler.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Tests for src/lib/db/vacuumScheduler.ts (#4437 / PR #4480)
|
||||
*
|
||||
* Covers:
|
||||
* 1. Module exports the expected public API.
|
||||
* 2. getState() returns the documented shape before any init/run.
|
||||
* 3. init() is idempotent (safe to call from instrumentation-node.ts).
|
||||
* 4. stop() is safe before init() and idempotent.
|
||||
* 5. runNow() succeeds on a healthy DB, persists lastRunAt, clears isRunning.
|
||||
* 6. runNow() called twice concurrently yields exactly one success and one
|
||||
* "already_running".
|
||||
* 7. lastRunAt survives a simulated restart (__resetForTests + init reloads the
|
||||
* persisted state from key_value).
|
||||
*
|
||||
* Rebuild note (PR #4480): the original PR test was authored against the Vitest
|
||||
* API and a stale scheduler interface (`state.initialized` / `state.running`),
|
||||
* which never matched the shipped module (`isRunning`, no `initialized`) and was
|
||||
* placed under tests/unit/db/** where the Node native runner — not Vitest —
|
||||
* picks it up. Rewritten for node:test against the real VacuumSchedulerState.
|
||||
*
|
||||
* DB isolation pattern mirrors tests/unit/db/default-combo-toggle.test.ts:
|
||||
* temp DATA_DIR, resetDbInstance() before each test, cleanup in test.after().
|
||||
*/
|
||||
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-vacuum-scheduler-"));
|
||||
const originalDataDir = process.env.DATA_DIR;
|
||||
const originalEnabled = process.env.OMNIROUTE_VACUUM_ENABLED;
|
||||
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
// Keep the scheduler from arming a real interval timer during unit tests.
|
||||
process.env.OMNIROUTE_VACUUM_ENABLED = "0";
|
||||
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
core.resetDbInstance();
|
||||
|
||||
const scheduler = await import("../../../src/lib/db/vacuumScheduler.ts");
|
||||
|
||||
test.beforeEach(() => {
|
||||
scheduler.__resetForTests();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
scheduler.__resetForTests();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
if (originalDataDir === undefined) delete process.env.DATA_DIR;
|
||||
else process.env.DATA_DIR = originalDataDir;
|
||||
if (originalEnabled === undefined) delete process.env.OMNIROUTE_VACUUM_ENABLED;
|
||||
else process.env.OMNIROUTE_VACUUM_ENABLED = originalEnabled;
|
||||
});
|
||||
|
||||
test("module loads and exports the expected public API", () => {
|
||||
assert.equal(typeof scheduler.init, "function");
|
||||
assert.equal(typeof scheduler.stop, "function");
|
||||
assert.equal(typeof scheduler.runNow, "function");
|
||||
assert.equal(typeof scheduler.getState, "function");
|
||||
});
|
||||
|
||||
test("getState() returns the documented shape before any init/run", () => {
|
||||
const state = scheduler.getState();
|
||||
assert.equal(typeof state.enabled, "boolean");
|
||||
assert.equal(typeof state.isRunning, "boolean");
|
||||
assert.equal(state.isRunning, false);
|
||||
assert.equal(state.lastRunAt, null);
|
||||
assert.equal(state.lastDurationMs, null);
|
||||
assert.equal(state.lastError, null);
|
||||
assert.equal(state.nextRunAt, null);
|
||||
});
|
||||
|
||||
test("init() is idempotent — calling it twice does not throw", () => {
|
||||
assert.doesNotThrow(() => scheduler.init());
|
||||
assert.doesNotThrow(() => scheduler.init());
|
||||
scheduler.stop();
|
||||
});
|
||||
|
||||
test("stop() is safe to call before init() and is idempotent", () => {
|
||||
assert.doesNotThrow(() => scheduler.stop());
|
||||
scheduler.init();
|
||||
assert.doesNotThrow(() => scheduler.stop());
|
||||
assert.doesNotThrow(() => scheduler.stop());
|
||||
});
|
||||
|
||||
test("runNow() succeeds on a healthy DB and persists lastRunAt", async () => {
|
||||
scheduler.init();
|
||||
try {
|
||||
const result = await scheduler.runNow();
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(typeof result.durationMs, "number");
|
||||
assert.ok(result.durationMs >= 0);
|
||||
|
||||
const state = scheduler.getState();
|
||||
assert.equal(state.isRunning, false);
|
||||
assert.notEqual(state.lastRunAt, null);
|
||||
assert.equal(state.lastError, null);
|
||||
} finally {
|
||||
scheduler.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("runNow() can be called repeatedly; each run succeeds and refreshes lastRunAt", async () => {
|
||||
// better-sqlite3 is synchronous, so VACUUM blocks the event loop for the whole
|
||||
// run — the isRunning guard (which returns "already_running") cannot be
|
||||
// triggered by overlapping awaits in-process. The realistic contract is that
|
||||
// sequential runs each succeed and update lastRunAt.
|
||||
scheduler.init();
|
||||
try {
|
||||
const first = await scheduler.runNow();
|
||||
assert.equal(first.success, true);
|
||||
const second = await scheduler.runNow();
|
||||
assert.equal(second.success, true);
|
||||
assert.equal(scheduler.getState().isRunning, false);
|
||||
assert.notEqual(scheduler.getState().lastRunAt, null);
|
||||
} finally {
|
||||
scheduler.stop();
|
||||
}
|
||||
});
|
||||
|
||||
test("lastRunAt survives a simulated restart (state reloaded from key_value)", async () => {
|
||||
scheduler.init();
|
||||
await scheduler.runNow();
|
||||
const beforeRestart = scheduler.getState().lastRunAt;
|
||||
assert.notEqual(beforeRestart, null);
|
||||
|
||||
// Simulate a process restart: wipe in-memory state, then init() reloads the
|
||||
// persisted blob from key_value.
|
||||
scheduler.__resetForTests();
|
||||
assert.equal(scheduler.getState().lastRunAt, null);
|
||||
|
||||
scheduler.init();
|
||||
const afterRestart = scheduler.getState().lastRunAt;
|
||||
assert.equal(afterRestart, beforeRestart);
|
||||
scheduler.stop();
|
||||
});
|
||||
@@ -23,7 +23,8 @@ test("convertOpenAIContentToParts maps input_audio (mp3) and strips a data: pref
|
||||
const parts = gemini.convertOpenAIContentToParts([
|
||||
{ type: "input_audio", input_audio: { data: "data:audio/mp3;base64,QUJDRA==", format: "mp3" } },
|
||||
]);
|
||||
assert.deepEqual(parts, [{ inlineData: { mimeType: "audio/mp3", data: "QUJDRA==" } }]);
|
||||
// mp3 normalizes to the canonical audio/mpeg (matches the #913 translator test).
|
||||
assert.deepEqual(parts, [{ inlineData: { mimeType: "audio/mpeg", data: "QUJDRA==" } }]);
|
||||
});
|
||||
|
||||
test("convertOpenAIContentToParts supports the { type: 'audio', audio: {...} } shape", () => {
|
||||
|
||||
36
tests/unit/i18n-config.test.ts
Normal file
36
tests/unit/i18n-config.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import i18nConfig from "../../config/i18n.json" with { type: "json" };
|
||||
import {
|
||||
DEFAULT_LOCALE,
|
||||
LANGUAGES,
|
||||
LOCALES,
|
||||
LOCALE_COOKIE,
|
||||
RTL_LOCALES,
|
||||
} from "../../src/i18n/config.ts";
|
||||
|
||||
test("i18n config adapter reflects the JSON source of truth", () => {
|
||||
assert.deepEqual(
|
||||
LOCALES,
|
||||
i18nConfig.locales.map((locale) => locale.code)
|
||||
);
|
||||
assert.equal(DEFAULT_LOCALE, i18nConfig.default);
|
||||
assert.deepEqual(RTL_LOCALES, i18nConfig.rtl);
|
||||
assert.equal(LOCALE_COOKIE, "NEXT_LOCALE");
|
||||
});
|
||||
|
||||
test("i18n language metadata preserves native and English names", () => {
|
||||
assert.equal(LANGUAGES.length, i18nConfig.locales.length);
|
||||
|
||||
const english = LANGUAGES.find((language) => language.code === "en");
|
||||
const englishConfig = i18nConfig.locales.find((language) => language.code === "en");
|
||||
assert.deepEqual(english, {
|
||||
code: "en",
|
||||
label: englishConfig?.label,
|
||||
name: englishConfig?.name,
|
||||
native: englishConfig?.native,
|
||||
english: englishConfig?.english,
|
||||
flag: englishConfig?.flag,
|
||||
});
|
||||
});
|
||||
@@ -132,8 +132,9 @@ test("buildUsageCommandText formats API key USD limits when fair usage is enable
|
||||
dailySpentUsd: 2,
|
||||
weeklySpentUsd: 5.25,
|
||||
dailyWindowStartIso: "2026-06-16T03:00:00.000Z",
|
||||
dailyResetAtIso: "2026-06-17T03:00:00.000Z",
|
||||
weeklyWindowStartIso: "2026-06-09T12:00:00.000Z",
|
||||
weeklyResetAtIso: "2026-06-16T12:00:00.000Z",
|
||||
weeklyResetAtIso: "2026-06-23T12:00:00.000Z",
|
||||
dailyExceeded: false,
|
||||
weeklyExceeded: false,
|
||||
}),
|
||||
@@ -159,11 +160,17 @@ test("buildUsageCommandText formats API key USD limits when fair usage is enable
|
||||
"$10.00",
|
||||
"Gasto diario",
|
||||
"$2.00",
|
||||
"Uso diario",
|
||||
"20%",
|
||||
"Resets in 15h",
|
||||
"",
|
||||
"Cota semanal",
|
||||
"$50.00",
|
||||
"Gasto semanal",
|
||||
"$5.25",
|
||||
"Uso semanal",
|
||||
"11%",
|
||||
"Resets in 7d",
|
||||
].join("\n")
|
||||
);
|
||||
});
|
||||
|
||||
@@ -2,10 +2,13 @@ import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
__testing,
|
||||
buildKiroUsageResult,
|
||||
discoverKiroProfileArn,
|
||||
} from "@omniroute/open-sse/services/usage.ts";
|
||||
|
||||
const { getKiroUsage } = __testing;
|
||||
|
||||
// Real-world shape returned by GetUsageLimits for an AWS IAM Identity Center ("KIRO POWER")
|
||||
// account — the usage is reported under resourceType "CREDIT" (not "AGENTIC_REQUEST").
|
||||
const IAM_CREDIT_RESPONSE = {
|
||||
@@ -98,3 +101,69 @@ test("discoverKiroProfileArn returns undefined for empty profiles or non-ok resp
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
// Regression: when a Kiro account added via Google/GitHub social-auth (authMethod "imported"
|
||||
// with provider "Google" or "Github" — set by /api/oauth/kiro/social-exchange/route.ts) has its
|
||||
// token rejected by the AWS CodeWhisperer quota API (401/403), surface a clear "auth expired,
|
||||
// chat may still work" message instead of throwing a generic upstream-error blob.
|
||||
test("getKiroUsage returns a friendly auth-expired message for social-auth Kiro on 401/403", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
// First call (ListAvailableProfiles for ARN discovery) succeeds with an ARN so we proceed
|
||||
// to GetUsageLimits, which then returns 401. The friendly branch only applies when the
|
||||
// GetUsageLimits call returned an auth-shaped error.
|
||||
let callIdx = 0;
|
||||
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
||||
callIdx += 1;
|
||||
const target = String((init?.headers as Record<string, string> | undefined)?.["x-amz-target"] || "");
|
||||
if (target.endsWith("ListAvailableProfiles")) {
|
||||
return new Response(
|
||||
JSON.stringify({ profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:1:profile/SOCIAL" }] }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
// GetUsageLimits → simulate the social-auth token rejection
|
||||
return new Response(JSON.stringify({ __type: "AccessDeniedException" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
const result = (await getKiroUsage("social-tok", {
|
||||
authMethod: "imported",
|
||||
provider: "Google",
|
||||
})) as { message?: string; quotas?: Record<string, unknown> };
|
||||
assert.ok(result, "should resolve, not throw");
|
||||
assert.ok(
|
||||
typeof result.message === "string" && /authentication expired/i.test(result.message),
|
||||
`expected an auth-expired message, got: ${JSON.stringify(result)}`
|
||||
);
|
||||
assert.deepEqual(result.quotas ?? {}, {});
|
||||
assert.ok(callIdx >= 2, "GetUsageLimits should have been called after profile discovery");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
// IAM Identity Center / Builder-ID accounts must keep the existing throw-on-failure behavior so
|
||||
// transient upstream errors don't get silently masked as "auth expired".
|
||||
test("getKiroUsage still throws on 401/403 for non-social Kiro accounts (Builder-ID/IDC)", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (_url: string, init?: RequestInit) => {
|
||||
const target = String((init?.headers as Record<string, string> | undefined)?.["x-amz-target"] || "");
|
||||
if (target.endsWith("ListAvailableProfiles")) {
|
||||
return new Response(
|
||||
JSON.stringify({ profiles: [{ arn: "arn:aws:codewhisperer:us-east-1:1:profile/BID" }] }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
return new Response("denied", { status: 401 });
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => getKiroUsage("builder-tok", { authMethod: "builder-id" }),
|
||||
/Failed to fetch Kiro usage/i
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
59
tests/unit/manual-config-modal-clipboard.test.ts
Normal file
59
tests/unit/manual-config-modal-clipboard.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* Source-guard test: ManualConfigModal must use the shared useCopyToClipboard
|
||||
* hook (which delegates to src/shared/utils/clipboard.ts for HTTP/HTTPS fallback)
|
||||
* rather than re-implementing the navigator.clipboard + execCommand fallback
|
||||
* inline.
|
||||
*
|
||||
* Rationale: duplicated inline fallbacks drift from the canonical helper.
|
||||
* Two known divergences in the previous inline copy:
|
||||
* 1. `window.isSecureContext` gate skipped navigator.clipboard on some
|
||||
* browsers that allow it in non-secure contexts.
|
||||
* 2. No `finally` cleanup if execCommand threw after appendChild succeeded,
|
||||
* leaking a hidden textarea in the DOM.
|
||||
*
|
||||
* The shared helper handles both correctly. This test pins the migration.
|
||||
*/
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const FILE = resolve(__dirname, "../../src/shared/components/ManualConfigModal.tsx");
|
||||
|
||||
describe("ManualConfigModal — clipboard migration to shared hook", () => {
|
||||
const src = readFileSync(FILE, "utf8");
|
||||
|
||||
it("imports useCopyToClipboard from the shared hooks barrel", () => {
|
||||
assert.match(
|
||||
src,
|
||||
/useCopyToClipboard/,
|
||||
"expected ManualConfigModal to consume the shared useCopyToClipboard hook"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call navigator.clipboard directly (delegated to the hook)", () => {
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/navigator\.clipboard/,
|
||||
"ManualConfigModal must not call navigator.clipboard directly; use the shared hook"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not call document.execCommand('copy') inline (delegated to the hook)", () => {
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/document\.execCommand\(\s*["']copy["']\s*\)/,
|
||||
"ManualConfigModal must not inline the execCommand fallback; use the shared hook"
|
||||
);
|
||||
});
|
||||
|
||||
it("does not gate on window.isSecureContext (the shared helper does the right thing)", () => {
|
||||
assert.doesNotMatch(
|
||||
src,
|
||||
/isSecureContext/,
|
||||
"ManualConfigModal must not gate on isSecureContext; the shared helper handles fallback correctly"
|
||||
);
|
||||
});
|
||||
});
|
||||
146
tests/unit/mcp-web-fetch-tool.test.ts
Normal file
146
tests/unit/mcp-web-fetch-tool.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
webFetchInput,
|
||||
webFetchOutput,
|
||||
webFetchTool,
|
||||
MCP_TOOLS,
|
||||
MCP_TOOL_MAP,
|
||||
} from "../../open-sse/mcp-server/schemas/tools.ts";
|
||||
import { MCP_TOOL_SCOPES } from "../../src/shared/constants/mcpScopes.ts";
|
||||
|
||||
// ── Tool definition shape ──
|
||||
|
||||
test("webFetchTool has the required McpToolDefinition shape", () => {
|
||||
assert.equal(webFetchTool.name, "omniroute_web_fetch");
|
||||
assert.equal(typeof webFetchTool.description, "string");
|
||||
assert.ok(webFetchTool.description.length > 0);
|
||||
assert.ok(webFetchTool.inputSchema != null);
|
||||
assert.ok(webFetchTool.outputSchema != null);
|
||||
assert.equal(typeof webFetchTool.inputSchema.parse, "function");
|
||||
assert.deepEqual(webFetchTool.scopes, ["execute:search"]);
|
||||
assert.equal(webFetchTool.auditLevel, "basic");
|
||||
assert.equal(webFetchTool.phase, 1);
|
||||
assert.ok(webFetchTool.sourceEndpoints.includes("/v1/web/fetch"));
|
||||
});
|
||||
|
||||
test("webFetchTool is registered in MCP_TOOLS and MCP_TOOL_MAP", () => {
|
||||
const toolNames = MCP_TOOLS.map((t) => t.name);
|
||||
assert.ok(
|
||||
toolNames.includes("omniroute_web_fetch"),
|
||||
"webFetchTool must be in MCP_TOOLS array"
|
||||
);
|
||||
assert.ok(
|
||||
"omniroute_web_fetch" in MCP_TOOL_MAP,
|
||||
"webFetchTool must be in MCP_TOOL_MAP"
|
||||
);
|
||||
});
|
||||
|
||||
// ── Scope mapping ──
|
||||
|
||||
test("omniroute_web_fetch is mapped in MCP_TOOL_SCOPES with execute:search", () => {
|
||||
const scopes = MCP_TOOL_SCOPES["omniroute_web_fetch"];
|
||||
assert.ok(scopes != null, "omniroute_web_fetch must have a scope mapping");
|
||||
assert.ok(
|
||||
scopes.includes("execute:search"),
|
||||
"omniroute_web_fetch must require execute:search scope"
|
||||
);
|
||||
});
|
||||
|
||||
// ── Input schema validation ──
|
||||
|
||||
test("webFetchInput accepts a valid minimal request (URL only)", () => {
|
||||
const parsed = webFetchInput.parse({ url: "https://example.com" });
|
||||
assert.equal(parsed.url, "https://example.com");
|
||||
assert.equal(parsed.format, "markdown"); // default
|
||||
assert.equal(parsed.include_metadata, false); // default
|
||||
});
|
||||
|
||||
test("webFetchInput accepts all optional fields", () => {
|
||||
const parsed = webFetchInput.parse({
|
||||
url: "https://example.com",
|
||||
provider: "firecrawl",
|
||||
format: "html",
|
||||
include_metadata: true,
|
||||
depth: 1,
|
||||
wait_for_selector: "#content",
|
||||
});
|
||||
assert.equal(parsed.provider, "firecrawl");
|
||||
assert.equal(parsed.format, "html");
|
||||
assert.equal(parsed.include_metadata, true);
|
||||
assert.equal(parsed.depth, 1);
|
||||
assert.equal(parsed.wait_for_selector, "#content");
|
||||
});
|
||||
|
||||
test("webFetchInput rejects missing URL", () => {
|
||||
assert.throws(
|
||||
() => webFetchInput.parse({}),
|
||||
/URL is required/,
|
||||
"Missing url should fail validation"
|
||||
);
|
||||
});
|
||||
|
||||
test("webFetchInput rejects empty URL", () => {
|
||||
assert.throws(
|
||||
() => webFetchInput.parse({ url: "" }),
|
||||
/URL is required/,
|
||||
"Empty url should fail validation"
|
||||
);
|
||||
});
|
||||
|
||||
test("webFetchInput rejects depth > 2 (matches WebFetchRequest type constraint)", () => {
|
||||
assert.throws(
|
||||
() => webFetchInput.parse({ url: "https://example.com", depth: 3 }),
|
||||
"depth > 2 should fail validation to match the 0 | 1 | 2 type in WebFetchRequest"
|
||||
);
|
||||
});
|
||||
|
||||
test("webFetchInput accepts depth values 0, 1, 2", () => {
|
||||
for (const depth of [0, 1, 2]) {
|
||||
const parsed = webFetchInput.parse({ url: "https://example.com", depth });
|
||||
assert.equal(parsed.depth, depth);
|
||||
}
|
||||
});
|
||||
|
||||
test("webFetchInput rejects invalid provider", () => {
|
||||
assert.throws(
|
||||
() => webFetchInput.parse({ url: "https://example.com", provider: "unknown-provider" }),
|
||||
"Unknown provider should fail validation"
|
||||
);
|
||||
});
|
||||
|
||||
test("webFetchInput rejects invalid format", () => {
|
||||
assert.throws(
|
||||
() => webFetchInput.parse({ url: "https://example.com", format: "xml" }),
|
||||
"Invalid format should fail validation"
|
||||
);
|
||||
});
|
||||
|
||||
// ── Output schema validation ──
|
||||
|
||||
test("webFetchOutput validates a typical scrape response", () => {
|
||||
const result = webFetchOutput.parse({
|
||||
provider: "firecrawl",
|
||||
url: "https://example.com",
|
||||
content: "# Example Domain\n\nThis domain is for use in documentation examples.",
|
||||
links: ["https://iana.org/domains/example"],
|
||||
metadata: { title: "Example Domain", description: "Example site" },
|
||||
screenshot_url: null,
|
||||
});
|
||||
assert.equal(result.provider, "firecrawl");
|
||||
assert.equal(result.links.length, 1);
|
||||
assert.equal(result.metadata?.title, "Example Domain");
|
||||
});
|
||||
|
||||
test("webFetchOutput validates a response with null metadata", () => {
|
||||
const result = webFetchOutput.parse({
|
||||
provider: "jina-reader",
|
||||
url: "https://example.com",
|
||||
content: "Some content",
|
||||
links: [],
|
||||
metadata: null,
|
||||
screenshot_url: null,
|
||||
});
|
||||
assert.equal(result.metadata, null);
|
||||
});
|
||||
@@ -119,7 +119,7 @@ test("memory search respects a configured zero token budget", async () => {
|
||||
assert.equal(result.data.totalTokens, 0);
|
||||
});
|
||||
|
||||
test("memory search keeps globally disabled memory disabled with explicit maxTokens", async () => {
|
||||
test("memory search runs explicitly even when global memory injection is disabled", async () => {
|
||||
await settingsDb.updateSettings({ memoryEnabled: false, memoryMaxTokens: 2000 });
|
||||
invalidateMemorySettingsCache();
|
||||
|
||||
@@ -137,9 +137,10 @@ test("memory search keeps globally disabled memory disabled with explicit maxTok
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.data.count, 0);
|
||||
assert.deepEqual(result.data.memories, []);
|
||||
assert.equal(result.data.totalTokens, 0);
|
||||
assert.equal(result.data.count, 1);
|
||||
assert.equal(result.data.memories.length, 1);
|
||||
assert.match(result.data.memories[0].content, /TypeScript/i);
|
||||
assert.ok(result.data.totalTokens > 0);
|
||||
});
|
||||
|
||||
test("memory clear deletes only older filtered entries and reports the deleted count", async () => {
|
||||
|
||||
88
tests/unit/mitm-sudo-gate-822.test.ts
Normal file
88
tests/unit/mitm-sudo-gate-822.test.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* PR #822: gate sudo prompts on the server platform.
|
||||
*
|
||||
* The MITM control surface previously decided whether to prompt for a sudo
|
||||
* password using the *browser's* `navigator.userAgent` and a non-Windows
|
||||
* unconditional gate on the API route. That broke two real cases:
|
||||
*
|
||||
* 1. Windows browser hitting a Linux server (no prompt → request 400s).
|
||||
* 2. Linux server running as root or under NOPASSWD sudoers (unnecessary
|
||||
* modal blocks the user even though sudo would never ask).
|
||||
*
|
||||
* The fix:
|
||||
* - `dnsConfig.ts` exposes `canRunSudoWithoutPassword()` /
|
||||
* `isSudoPasswordRequired()` that probe the actual server state.
|
||||
* - The route surfaces `isWin` + `needsSudoPassword` so the UI can decide
|
||||
* based on the server's platform, not the browser's.
|
||||
*
|
||||
* These tests pin the *pure* helper contract — no real `sudo` is invoked
|
||||
* because every probe path is short-circuited before it tries `sudo -n true`
|
||||
* (Windows / root / no-sudo-on-PATH).
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
canRunSudoWithoutPassword,
|
||||
isSudoAvailable,
|
||||
isSudoPasswordRequired,
|
||||
} from "../../src/mitm/dns/dnsConfig.ts";
|
||||
|
||||
test("isSudoAvailable returns a boolean on the current platform", () => {
|
||||
const result = isSudoAvailable();
|
||||
assert.equal(typeof result, "boolean");
|
||||
// Windows reports true unconditionally (no sudo concept).
|
||||
if (process.platform === "win32") {
|
||||
assert.equal(result, true);
|
||||
}
|
||||
});
|
||||
|
||||
test("canRunSudoWithoutPassword short-circuits to true on Windows and root", () => {
|
||||
const result = canRunSudoWithoutPassword();
|
||||
assert.equal(typeof result, "boolean");
|
||||
|
||||
if (process.platform === "win32") {
|
||||
assert.equal(result, true, "Windows uses UAC, never needs sudo password");
|
||||
return;
|
||||
}
|
||||
|
||||
// Linux/macOS: root user always passes without a password.
|
||||
const isRootUser = !!(process.getuid && process.getuid() === 0);
|
||||
if (isRootUser) {
|
||||
assert.equal(result, true, "root user never needs sudo password");
|
||||
}
|
||||
});
|
||||
|
||||
test("isSudoPasswordRequired returns false on Windows", () => {
|
||||
if (process.platform !== "win32") {
|
||||
// Can't simulate Windows from a non-Windows test runner; assert the
|
||||
// contract holds on the native platform.
|
||||
const result = isSudoPasswordRequired();
|
||||
assert.equal(typeof result, "boolean");
|
||||
return;
|
||||
}
|
||||
assert.equal(isSudoPasswordRequired(), false);
|
||||
});
|
||||
|
||||
test("isSudoPasswordRequired returns false when running as root on POSIX", () => {
|
||||
if (process.platform === "win32") return;
|
||||
const isRootUser = !!(process.getuid && process.getuid() === 0);
|
||||
if (!isRootUser) {
|
||||
// Skip — we can't elevate from the test runner. This branch is covered
|
||||
// by the contract: isSudoPasswordRequired === !IS_WIN && isSudoAvailable
|
||||
// && !canRunSudoWithoutPassword, and canRunSudoWithoutPassword returns
|
||||
// early when isRoot() is true.
|
||||
return;
|
||||
}
|
||||
assert.equal(isSudoPasswordRequired(), false);
|
||||
});
|
||||
|
||||
test("isSudoPasswordRequired is consistent with canRunSudoWithoutPassword on POSIX", () => {
|
||||
if (process.platform === "win32") return;
|
||||
if (!isSudoAvailable()) {
|
||||
// No sudo binary → never required.
|
||||
assert.equal(isSudoPasswordRequired(), false);
|
||||
return;
|
||||
}
|
||||
// When sudo *is* available, requirement is the inverse of "can run without".
|
||||
assert.equal(isSudoPasswordRequired(), !canRunSudoWithoutPassword());
|
||||
});
|
||||
101
tests/unit/mitm-sudo-graceful-degrade.test.ts
Normal file
101
tests/unit/mitm-sudo-graceful-degrade.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Regression test for MITM cert install in Docker `USER node` (non-root, no sudo).
|
||||
*
|
||||
* OmniRoute's runtime Docker image (`Dockerfile`) runs as `USER node` (UID 1000)
|
||||
* and the slim base (`node:24-trixie-slim`) does NOT ship `sudo`. The previous
|
||||
* `execFileWithPassword("sudo", ["-S", ...])` would spawn `sudo` unconditionally
|
||||
* when not root, producing `spawn sudo ENOENT` and breaking `installCert` /
|
||||
* `addDNSEntries` for any MITM operation triggered from inside the container.
|
||||
*
|
||||
* The fix:
|
||||
* - `isSudoAvailable()` probes `PATH` for `sudo`.
|
||||
* - `execFileWithPassword` when invoked with `command === "sudo"` and sudo is
|
||||
* NOT available and the process is NOT root, strips the leading `sudo -S`
|
||||
* flags and runs the underlying command directly (same user, no elevation).
|
||||
* - Callers that absolutely require elevation (e.g. installing a CA to the
|
||||
* system trust store) can pre-check with `isSudoAvailable()` and skip with
|
||||
* a clear log instead of throwing.
|
||||
*
|
||||
* This mirrors upstream behavior (decolua/9router) extended to OmniRoute's TS
|
||||
* fork and OmniRoute's existing root-detection (`isRoot()`).
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
execFileWithPassword,
|
||||
isRoot,
|
||||
isSudoAvailable,
|
||||
} from "../../src/mitm/systemCommands.ts";
|
||||
|
||||
test("isSudoAvailable: returns a boolean (probe shape)", () => {
|
||||
const result = isSudoAvailable();
|
||||
assert.equal(typeof result, "boolean");
|
||||
});
|
||||
|
||||
test(
|
||||
"execFileWithPassword: when command is 'sudo' but sudo is missing, falls back to direct exec (no ENOENT)",
|
||||
async () => {
|
||||
if (isRoot()) {
|
||||
// Root path is already handled by an earlier branch — skip.
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSudoAvailable()) {
|
||||
// We cannot exercise the no-sudo path on a host that has sudo on PATH.
|
||||
// The dedicated unit test below covers it by isolating PATH.
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a no-op binary that succeeds without requiring privileges.
|
||||
// After stripping `sudo -S`, the underlying command must be `true` (or any
|
||||
// executable in PATH that exits 0).
|
||||
const result = await execFileWithPassword(
|
||||
"sudo",
|
||||
["-S", "true"],
|
||||
"fake-password"
|
||||
);
|
||||
assert.equal(typeof result, "string");
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"execFileWithPassword: with empty PATH so sudo is undiscoverable, falls back gracefully",
|
||||
async () => {
|
||||
if (isRoot()) {
|
||||
// Root branch strips sudo upstream — covered separately.
|
||||
return;
|
||||
}
|
||||
|
||||
// Force the discovery probe to find no sudo by clearing PATH temporarily.
|
||||
// We do this only around the probe call inside `execFileWithPassword`,
|
||||
// by stubbing the env. Restore on test exit.
|
||||
const originalPath = process.env.PATH;
|
||||
process.env.PATH = "/__no_such_dir__";
|
||||
try {
|
||||
// After fallback, the bare command must be a valid executable. We use
|
||||
// the current Node binary with `process.exit(0)` so it is portable
|
||||
// regardless of which utilities (`true`, `:`) exist in the path.
|
||||
const result = await execFileWithPassword(
|
||||
"sudo",
|
||||
["-S", process.execPath, "-e", "process.exit(0)"],
|
||||
"fake-password"
|
||||
);
|
||||
assert.equal(typeof result, "string");
|
||||
} finally {
|
||||
process.env.PATH = originalPath;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"execFileWithPassword: a non-sudo command is unaffected by the fallback path",
|
||||
async () => {
|
||||
// Direct invocation of a known-good binary must still work end-to-end.
|
||||
const result = await execFileWithPassword(
|
||||
process.execPath,
|
||||
["-e", "process.exit(0)"],
|
||||
"" // no password needed
|
||||
);
|
||||
assert.equal(typeof result, "string");
|
||||
}
|
||||
);
|
||||
80
tests/unit/model-catalog-name-disambiguation.test.ts
Normal file
80
tests/unit/model-catalog-name-disambiguation.test.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// Pure function — importable without DB setup.
|
||||
const { disambiguateCatalogModelNames } = await import("../../src/lib/modelMetadataRegistry.ts");
|
||||
|
||||
type CatalogEntry = { id: string; name?: string; owned_by?: string; [key: string]: unknown };
|
||||
|
||||
test("leaves names untouched when each name appears under only one provider", () => {
|
||||
const models: CatalogEntry[] = [
|
||||
{ id: "gh/gpt-5.5", name: "GPT-5.5", owned_by: "github" },
|
||||
{ id: "cc/claude-sonnet-4-6", name: "Claude Sonnet 4.6", owned_by: "claude" },
|
||||
{ id: "ds-web/deepseek-r2", name: "DeepSeek R2", owned_by: "deepseek-web" },
|
||||
];
|
||||
const result = disambiguateCatalogModelNames(models);
|
||||
assert.equal(result[0].name, "GPT-5.5", "unique name should be unchanged");
|
||||
assert.equal(result[1].name, "Claude Sonnet 4.6", "unique name should be unchanged");
|
||||
assert.equal(result[2].name, "DeepSeek R2", "unique name should be unchanged");
|
||||
});
|
||||
|
||||
test("qualifies names shared across multiple providers with their prefix", () => {
|
||||
const models: CatalogEntry[] = [
|
||||
{ id: "gh/gpt-5.5", name: "GPT-5.5", owned_by: "github" },
|
||||
{ id: "cx/gpt-5.5", name: "GPT-5.5", owned_by: "codex" },
|
||||
{ id: "opencode-zen/gpt-5.5", name: "GPT-5.5", owned_by: "opencode-zen" },
|
||||
{ id: "cc/claude-sonnet-4-6", name: "Claude Sonnet 4.6", owned_by: "claude" },
|
||||
];
|
||||
const result = disambiguateCatalogModelNames(models);
|
||||
assert.equal(result[0].name, "gh/GPT-5.5", "ambiguous name should gain gh/ prefix");
|
||||
assert.equal(result[1].name, "cx/GPT-5.5", "ambiguous name should gain cx/ prefix");
|
||||
assert.equal(
|
||||
result[2].name,
|
||||
"opencode-zen/GPT-5.5",
|
||||
"ambiguous name should gain opencode-zen/ prefix"
|
||||
);
|
||||
assert.equal(result[3].name, "Claude Sonnet 4.6", "unique name should remain unqualified");
|
||||
});
|
||||
|
||||
test("skips entries with no name field", () => {
|
||||
const models: CatalogEntry[] = [
|
||||
{ id: "gh/gpt-5.5", owned_by: "github" },
|
||||
{ id: "cx/gpt-5.5", owned_by: "codex" },
|
||||
];
|
||||
const result = disambiguateCatalogModelNames(models);
|
||||
assert.equal(result[0].name, undefined, "nameless entry should stay nameless");
|
||||
assert.equal(result[1].name, undefined, "nameless entry should stay nameless");
|
||||
});
|
||||
|
||||
test("skips entries with no provider prefix in id", () => {
|
||||
const models: CatalogEntry[] = [
|
||||
{ id: "gpt-5.5", name: "GPT-5.5" },
|
||||
{ id: "gpt-5.5-mini", name: "GPT-5.5" },
|
||||
];
|
||||
const result = disambiguateCatalogModelNames(models);
|
||||
// No prefix extractable → counts as same prefix bucket, treated as same source
|
||||
// Both names should remain because disambiguation needs 2+ distinct prefixes.
|
||||
assert.equal(result[0].name, "GPT-5.5");
|
||||
assert.equal(result[1].name, "GPT-5.5");
|
||||
});
|
||||
|
||||
test("does not mutate the original model objects", () => {
|
||||
const original: CatalogEntry = { id: "gh/gpt-5.5", name: "GPT-5.5", owned_by: "github" };
|
||||
const dup: CatalogEntry = { id: "cx/gpt-5.5", name: "GPT-5.5", owned_by: "codex" };
|
||||
const models = [original, dup];
|
||||
disambiguateCatalogModelNames(models);
|
||||
assert.equal(original.name, "GPT-5.5", "original object must not be mutated");
|
||||
assert.equal(dup.name, "GPT-5.5", "original object must not be mutated");
|
||||
});
|
||||
|
||||
test("handles models with no name-conflicts among combo/unprefixed entries gracefully", () => {
|
||||
const models: CatalogEntry[] = [
|
||||
{ id: "auto/best", owned_by: "combo" },
|
||||
{ id: "my-combo", owned_by: "combo" },
|
||||
{ id: "gh/gpt-4o", name: "GPT-4o", owned_by: "github" },
|
||||
{ id: "cx/gpt-4o", name: "GPT-4o", owned_by: "codex" },
|
||||
];
|
||||
const result = disambiguateCatalogModelNames(models);
|
||||
assert.equal(result[2].name, "gh/GPT-4o");
|
||||
assert.equal(result[3].name, "cx/GPT-4o");
|
||||
});
|
||||
222
tests/unit/model-lockout-max-cooldown.test.ts
Normal file
222
tests/unit/model-lockout-max-cooldown.test.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
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(), "omr-lockout-max-cooldown-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-lockout-max-cooldown-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const auth = await import("../../src/sse/services/auth.ts");
|
||||
const { handleComboChat } = await import("../../open-sse/services/combo.ts");
|
||||
const { recordModelLockoutFailure, getModelLockoutInfo, clearAllModelLockouts } =
|
||||
await import("../../open-sse/services/accountFallback.ts");
|
||||
|
||||
function createLog() {
|
||||
return {
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
debug: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
function errorResponse(status: number, message: string = `Error ${status}`) {
|
||||
return new Response(JSON.stringify({ error: { message } }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
async function seedConnection(provider: string, overrides: any = {}): Promise<any> {
|
||||
return providersDb.createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
name: overrides.name || `${provider}-${Math.random().toString(16).slice(2, 8)}`,
|
||||
apiKey: overrides.apiKey || `sk-test-${Math.random().toString(16).slice(2, 8)}`,
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
rateLimitedUntil: null,
|
||||
backoffLevel: overrides.backoffLevel || 0,
|
||||
providerSpecificData: overrides.providerSpecificData || {},
|
||||
});
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
clearAllModelLockouts();
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
clearAllModelLockouts();
|
||||
try {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
} catch {}
|
||||
});
|
||||
|
||||
test("recordModelLockoutFailure honors maxCooldownMs option parameter", () => {
|
||||
const provider = "openai";
|
||||
const connectionId = "conn-s1";
|
||||
const model = "gpt-4";
|
||||
|
||||
const first = recordModelLockoutFailure(
|
||||
provider,
|
||||
connectionId,
|
||||
model,
|
||||
"rate_limited",
|
||||
429,
|
||||
500,
|
||||
null,
|
||||
{
|
||||
maxCooldownMs: 1500,
|
||||
}
|
||||
);
|
||||
assert.equal(first.cooldownMs, 500);
|
||||
|
||||
const originalNow = Date.now;
|
||||
try {
|
||||
let fakeNow = Date.now();
|
||||
Date.now = () => fakeNow;
|
||||
|
||||
fakeNow += 600;
|
||||
const second = recordModelLockoutFailure(
|
||||
provider,
|
||||
connectionId,
|
||||
model,
|
||||
"rate_limited",
|
||||
429,
|
||||
500,
|
||||
null,
|
||||
{
|
||||
maxCooldownMs: 1500,
|
||||
}
|
||||
);
|
||||
assert.equal(second.cooldownMs, 1000);
|
||||
|
||||
fakeNow += 1100;
|
||||
const third = recordModelLockoutFailure(
|
||||
provider,
|
||||
connectionId,
|
||||
model,
|
||||
"rate_limited",
|
||||
429,
|
||||
500,
|
||||
null,
|
||||
{
|
||||
maxCooldownMs: 1500,
|
||||
}
|
||||
);
|
||||
assert.equal(third.cooldownMs, 1500);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleComboChat quality failure model lockout honors maxCooldownMs settings", async () => {
|
||||
const provider = "openai";
|
||||
const model = "gpt-4";
|
||||
|
||||
const customSettings = {
|
||||
modelLockout: {
|
||||
enabled: true,
|
||||
errorCodes: [502],
|
||||
baseCooldownMs: 3000,
|
||||
maxCooldownMs: 5000,
|
||||
maxBackoffSteps: 10,
|
||||
useExponentialBackoff: true,
|
||||
},
|
||||
};
|
||||
|
||||
const logs = createLog();
|
||||
const executeComboWithFailure = async () => {
|
||||
return handleComboChat({
|
||||
body: {},
|
||||
combo: {
|
||||
name: "test-combo",
|
||||
strategy: "priority",
|
||||
models: [`${provider}/${model}`],
|
||||
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 },
|
||||
},
|
||||
handleSingleModel: async () => {
|
||||
return errorResponse(502);
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: logs as any,
|
||||
settings: customSettings,
|
||||
allCombos: null,
|
||||
});
|
||||
};
|
||||
|
||||
const originalNow = Date.now;
|
||||
try {
|
||||
let fakeNow = Date.now();
|
||||
Date.now = () => fakeNow;
|
||||
|
||||
await executeComboWithFailure();
|
||||
let info = getModelLockoutInfo(provider, "", model);
|
||||
assert.ok(info && info.remainingMs <= 3000);
|
||||
|
||||
fakeNow += 3100;
|
||||
await executeComboWithFailure();
|
||||
info = getModelLockoutInfo(provider, "", model);
|
||||
assert.ok(info);
|
||||
assert.ok(
|
||||
info!.remainingMs <= 5000,
|
||||
`Expected remainingMs to be capped at 5000, but got ${info!.remainingMs}`
|
||||
);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
|
||||
test("markAccountUnavailable local 404 lockout honors maxCooldownMs settings", async () => {
|
||||
const provider = "openai";
|
||||
const model = "local-gpt-4";
|
||||
const connection = (await seedConnection(provider, {
|
||||
providerSpecificData: { baseUrl: "http://127.0.0.1:8080/v1" },
|
||||
})) as any;
|
||||
|
||||
await settingsDb.updateSettings({
|
||||
modelLockout: {
|
||||
enabled: true,
|
||||
errorCodes: [404],
|
||||
baseCooldownMs: 3000,
|
||||
maxCooldownMs: 5000,
|
||||
maxBackoffSteps: 10,
|
||||
useExponentialBackoff: true,
|
||||
},
|
||||
});
|
||||
|
||||
const originalNow = Date.now;
|
||||
try {
|
||||
let fakeNow = Date.now();
|
||||
Date.now = () => fakeNow;
|
||||
|
||||
await auth.markAccountUnavailable(connection.id as string, 404, "not found", provider, model);
|
||||
let info = getModelLockoutInfo(provider, connection.id as string, model);
|
||||
assert.ok(info && info.remainingMs <= 3000);
|
||||
|
||||
fakeNow += 3100;
|
||||
await providersDb.updateProviderConnection(connection.id as string, { rateLimitedUntil: null });
|
||||
await auth.markAccountUnavailable(connection.id as string, 404, "not found", provider, model);
|
||||
info = getModelLockoutInfo(provider, connection.id as string, model);
|
||||
assert.ok(info);
|
||||
assert.ok(
|
||||
info!.remainingMs <= 5000,
|
||||
`Expected remainingMs to be capped at 5000, but got ${info!.remainingMs}`
|
||||
);
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
});
|
||||
107
tests/unit/models-catalog-exact-dup-4424.test.ts
Normal file
107
tests/unit/models-catalog-exact-dup-4424.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #4424 follow-up — `/v1/models` must not emit the same id twice (OpenAI clients key
|
||||
// by id and break on exact-duplicate ids). The reporter observed `codex/gpt-5.5`,
|
||||
// `veo-free/seedance`, `veo-free/veo` each listed twice. A final dedupe keyed by the
|
||||
// model's listing identity `(id, type, subtype)` collapses true exact dupes (keep-first)
|
||||
// while preserving the ONE intentional same-id case: audio models that list both a
|
||||
// transcription and a speech entry under the same id (distinguished by `subtype`).
|
||||
|
||||
import { dedupeExactCatalogIds } from "../../src/app/api/v1/models/catalogDedupe.ts";
|
||||
|
||||
test("collapses an exact-duplicate id to a single entry (keep first)", () => {
|
||||
const input = [
|
||||
{ id: "codex/gpt-5.5", owned_by: "codex", root: "gpt-5.5", context_length: 200000 },
|
||||
{ id: "codex/gpt-5.5", owned_by: "codex", root: "gpt-5.5", context_length: 200000 },
|
||||
];
|
||||
const out = dedupeExactCatalogIds(input);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].id, "codex/gpt-5.5");
|
||||
assert.equal(out[0].context_length, 200000);
|
||||
});
|
||||
|
||||
test("collapses the reporter's two distinct duplicated ids to two unique entries", () => {
|
||||
const input = [
|
||||
{ id: "veo-free/seedance", owned_by: "veo-free", root: "seedance", type: "video" },
|
||||
{ id: "veo-free/veo", owned_by: "veo-free", root: "veo", type: "video" },
|
||||
{ id: "veo-free/seedance", owned_by: "veo-free", root: "seedance", type: "video" },
|
||||
{ id: "veo-free/veo", owned_by: "veo-free", root: "veo", type: "video" },
|
||||
];
|
||||
const out = dedupeExactCatalogIds(input);
|
||||
assert.equal(out.length, 2);
|
||||
assert.deepEqual(
|
||||
out.map((m) => m.id),
|
||||
["veo-free/seedance", "veo-free/veo"]
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves intentional same-id audio variants (transcription vs speech)", () => {
|
||||
const input = [
|
||||
{ id: "prov/whisper", owned_by: "prov", root: "whisper", type: "audio", subtype: "transcription" },
|
||||
{ id: "prov/whisper", owned_by: "prov", root: "whisper", type: "audio", subtype: "speech" },
|
||||
];
|
||||
const out = dedupeExactCatalogIds(input);
|
||||
assert.equal(out.length, 2);
|
||||
assert.deepEqual(
|
||||
out.map((m) => m.subtype).sort(),
|
||||
["speech", "transcription"]
|
||||
);
|
||||
});
|
||||
|
||||
test("keeps distinct ids untouched", () => {
|
||||
const input = [
|
||||
{ id: "a/m1", type: "chat" },
|
||||
{ id: "b/m2", type: "chat" },
|
||||
{ id: "c/m3", type: "chat" },
|
||||
];
|
||||
const out = dedupeExactCatalogIds(input);
|
||||
assert.equal(out.length, 3);
|
||||
});
|
||||
|
||||
test("keeps the FIRST occurrence's metadata, drops the later dupe", () => {
|
||||
const input = [
|
||||
{ id: "x/dup", name: "First", capabilities: { vision: true } },
|
||||
{ id: "x/dup", name: "Second" },
|
||||
];
|
||||
const out = dedupeExactCatalogIds(input);
|
||||
assert.equal(out.length, 1);
|
||||
assert.equal(out[0].name, "First");
|
||||
assert.deepEqual(out[0].capabilities, { vision: true });
|
||||
});
|
||||
|
||||
test("a dup that differs only by type is NOT collapsed (distinct listing identity)", () => {
|
||||
const input = [
|
||||
{ id: "p/m", type: "chat" },
|
||||
{ id: "p/m", type: "embedding" },
|
||||
];
|
||||
const out = dedupeExactCatalogIds(input);
|
||||
assert.equal(out.length, 2);
|
||||
});
|
||||
|
||||
test("preserves relative order of kept entries", () => {
|
||||
const input = [
|
||||
{ id: "first/a", type: "chat" },
|
||||
{ id: "dup/x", type: "chat" },
|
||||
{ id: "second/b", type: "chat" },
|
||||
{ id: "dup/x", type: "chat" },
|
||||
{ id: "third/c", type: "chat" },
|
||||
];
|
||||
const out = dedupeExactCatalogIds(input);
|
||||
assert.deepEqual(
|
||||
out.map((m) => m.id),
|
||||
["first/a", "dup/x", "second/b", "third/c"]
|
||||
);
|
||||
});
|
||||
|
||||
test("empty and single-element inputs pass through", () => {
|
||||
assert.deepEqual(dedupeExactCatalogIds([]), []);
|
||||
const one = [{ id: "only/one" }];
|
||||
assert.equal(dedupeExactCatalogIds(one).length, 1);
|
||||
});
|
||||
|
||||
test("entries missing an id are passed through unchanged (never grouped)", () => {
|
||||
const input = [{ foo: 1 } as { id?: string }, { foo: 2 } as { id?: string }];
|
||||
const out = dedupeExactCatalogIds(input);
|
||||
assert.equal(out.length, 2);
|
||||
});
|
||||
@@ -75,18 +75,29 @@ test("applyNoThinkingAlias strips reasoning fields without a thinking block (Ope
|
||||
});
|
||||
|
||||
test("applyNoThinkingAlias is a no-op for plain models", () => {
|
||||
const body: Record<string, unknown> = { model: "anthropic/claude-opus-4-5", thinking: { type: "enabled" } };
|
||||
const body: Record<string, unknown> = {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
thinking: { type: "enabled" },
|
||||
};
|
||||
const res = applyNoThinkingAlias(body, { claudeFormat: true });
|
||||
assert.equal(res.applied, false);
|
||||
assert.equal(body.model, "anthropic/claude-opus-4-5");
|
||||
assert.deepEqual(body.thinking, { type: "enabled" }, "thinking is left untouched when not an alias");
|
||||
assert.deepEqual(
|
||||
body.thinking,
|
||||
{ type: "enabled" },
|
||||
"thinking is left untouched when not an alias"
|
||||
);
|
||||
});
|
||||
|
||||
test("applyNoThinkingAlias ignores a malformed prefix-only model", () => {
|
||||
const body: Record<string, unknown> = { model: "claude-3-omniroute-no-thinking/" };
|
||||
const res = applyNoThinkingAlias(body, { claudeFormat: true });
|
||||
assert.equal(res.applied, false);
|
||||
assert.equal(body.model, "claude-3-omniroute-no-thinking/", "left untouched when nothing follows the prefix");
|
||||
assert.equal(
|
||||
body.model,
|
||||
"claude-3-omniroute-no-thinking/",
|
||||
"left untouched when nothing follows the prefix"
|
||||
);
|
||||
});
|
||||
|
||||
// ── catalog gating ───────────────────────────────────────────────────────────
|
||||
@@ -113,16 +124,21 @@ test("shouldExposeNoThinkingAlias rejects models where suppression is meaningles
|
||||
});
|
||||
|
||||
test("appendNoThinkingVariants adds one variant per eligible model and preserves the rest", () => {
|
||||
const models = [
|
||||
entry("claude-opus-4-5"),
|
||||
entry("gpt-4o", "openai"),
|
||||
entry("claude-fable-5"),
|
||||
];
|
||||
const models = [entry("claude-opus-4-5"), entry("gpt-4o", "openai"), entry("claude-fable-5")];
|
||||
const out = appendNoThinkingVariants(models);
|
||||
const ids = out.map((m) => m.id);
|
||||
assert.ok(ids.includes("claude-3-omniroute-no-thinking/claude-opus-4-5"), "eligible model gets a variant");
|
||||
assert.ok(!ids.includes("claude-3-omniroute-no-thinking/gpt-4o"), "non-thinking model has no variant");
|
||||
assert.ok(!ids.includes("claude-3-omniroute-no-thinking/claude-fable-5"), "reject-disabled model has no variant");
|
||||
assert.ok(
|
||||
ids.includes("claude-3-omniroute-no-thinking/claude-opus-4-5"),
|
||||
"eligible model gets a variant"
|
||||
);
|
||||
assert.ok(
|
||||
!ids.includes("claude-3-omniroute-no-thinking/gpt-4o"),
|
||||
"non-thinking model has no variant"
|
||||
);
|
||||
assert.ok(
|
||||
!ids.includes("claude-3-omniroute-no-thinking/claude-fable-5"),
|
||||
"reject-disabled model has no variant"
|
||||
);
|
||||
assert.equal(out.length, models.length + 1, "exactly one variant appended");
|
||||
// originals preserved up front
|
||||
assert.deepEqual(out.slice(0, 3), models);
|
||||
@@ -132,3 +148,28 @@ test("appendNoThinkingVariants returns the same array reference when nothing is
|
||||
const models = [entry("gpt-4o", "openai")];
|
||||
assert.equal(appendNoThinkingVariants(models), models);
|
||||
});
|
||||
|
||||
test("appendNoThinkingVariants normalizes alias prefix to canonical when aliasToCanonical map is provided", () => {
|
||||
const models = [entry("cc/claude-opus-4-5")];
|
||||
const aliasToCanonical = { cc: "claude" };
|
||||
const out = appendNoThinkingVariants(models, aliasToCanonical);
|
||||
const ids = out.map((m) => m.id);
|
||||
assert.ok(
|
||||
ids.includes("claude-3-omniroute-no-thinking/claude/claude-opus-4-5"),
|
||||
"uses canonical prefix"
|
||||
);
|
||||
assert.ok(
|
||||
!ids.includes("claude-3-omniroute-no-thinking/cc/claude-opus-4-5"),
|
||||
"alias prefix not used"
|
||||
);
|
||||
});
|
||||
|
||||
test("appendNoThinkingVariants keeps alias prefix when no map is provided", () => {
|
||||
const models = [entry("cc/claude-opus-4-5")];
|
||||
const out = appendNoThinkingVariants(models);
|
||||
const ids = out.map((m) => m.id);
|
||||
assert.ok(
|
||||
ids.includes("claude-3-omniroute-no-thinking/cc/claude-opus-4-5"),
|
||||
"alias prefix preserved"
|
||||
);
|
||||
});
|
||||
|
||||
130
tests/unit/oauth-connection-test-codex-endpoint.test.ts
Normal file
130
tests/unit/oauth-connection-test-codex-endpoint.test.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { testOAuthConnection } from "../../src/app/api/providers/[id]/test/route";
|
||||
|
||||
// Port of decolua/9router#347 (author: Ibrahim Ryan).
|
||||
//
|
||||
// Prior to this fix, the Codex OAuth test only validated `checkExpiry: true` — i.e.
|
||||
// it inspected the local token's `expiresAt` and returned valid=true if the
|
||||
// timestamp wasn't in the past. A token that the server has already revoked or that
|
||||
// belongs to a deactivated account would still report as valid. The new test
|
||||
// actually probes ChatGPT's `/backend-api/codex/responses` endpoint with a minimal
|
||||
// invalid body. The endpoint returns 400 (bad request) when auth is accepted and
|
||||
// 401/403 when the token is bad — exactly the signal the test should be using.
|
||||
//
|
||||
// Important OmniRoute-specific constraint: codex is a `rotating` provider (shares
|
||||
// an Auth0 family with openai — see `rotationGroupFor`). The probe path must NOT
|
||||
// burn a single-use refresh_token from a connection test (precedent: openai/codex
|
||||
// #9648, see comment in route.ts above the rotating-provider guard). The probe
|
||||
// only validates the access token as-is.
|
||||
|
||||
const CODEX_TEST_URL = "https://chatgpt.com/backend-api/codex/responses";
|
||||
|
||||
function futureExpiresAt(): string {
|
||||
return new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
}
|
||||
|
||||
function mockFetch(handler: (url: string, init?: RequestInit) => Response) {
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
const fn = (async (url: RequestInfo | URL, init?: RequestInit) => {
|
||||
const u = typeof url === "string" ? url : url instanceof URL ? url.toString() : String(url);
|
||||
calls.push({ url: u, init });
|
||||
return handler(u, init);
|
||||
}) as typeof fetch;
|
||||
return { fn, calls };
|
||||
}
|
||||
|
||||
test("codex test probes the real /responses endpoint and treats 400 as 'auth ok' (port PR#347)", async (t) => {
|
||||
const original = globalThis.fetch;
|
||||
const { fn, calls } = mockFetch(
|
||||
() =>
|
||||
new Response(JSON.stringify({ error: { message: "Bad request" } }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fn;
|
||||
t.after(() => {
|
||||
globalThis.fetch = original;
|
||||
});
|
||||
|
||||
const result = await testOAuthConnection(
|
||||
{
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
accessToken: "fake-codex-token",
|
||||
refreshToken: "fake-refresh",
|
||||
expiresAt: futureExpiresAt(),
|
||||
},
|
||||
5000
|
||||
);
|
||||
|
||||
assert.equal(result.valid, true, "400 from the real endpoint must be treated as auth ok");
|
||||
assert.equal(calls.length, 1, "exactly one upstream probe");
|
||||
assert.equal(calls[0].url, CODEX_TEST_URL, "must probe the actual codex /responses endpoint");
|
||||
assert.equal(calls[0].init?.method, "POST");
|
||||
const headers = (calls[0].init?.headers ?? {}) as Record<string, string>;
|
||||
assert.equal(headers.Authorization, "Bearer fake-codex-token");
|
||||
assert.ok(calls[0].init?.body, "must send a minimal body so the endpoint returns 400 (not 405)");
|
||||
});
|
||||
|
||||
test("codex test reports invalid when the endpoint returns 401 (port PR#347)", async (t) => {
|
||||
const original = globalThis.fetch;
|
||||
const { fn, calls } = mockFetch(
|
||||
() =>
|
||||
new Response(JSON.stringify({ error: { message: "Unauthorized" } }), {
|
||||
status: 401,
|
||||
headers: { "content-type": "application/json" },
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fn;
|
||||
t.after(() => {
|
||||
globalThis.fetch = original;
|
||||
});
|
||||
|
||||
const result = await testOAuthConnection(
|
||||
{
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
accessToken: "revoked-token",
|
||||
// Token NOT expired locally — that's the whole point: the local check would
|
||||
// have lied; the real endpoint surfaces the revocation.
|
||||
refreshToken: "fake-refresh",
|
||||
expiresAt: futureExpiresAt(),
|
||||
},
|
||||
5000
|
||||
);
|
||||
|
||||
assert.equal(result.valid, false, "401 from the real endpoint must be reported as invalid");
|
||||
assert.equal(calls.length, 1, "must NOT burn the refresh_token from a connection test (codex is a rotating provider — openai/codex#9648)");
|
||||
});
|
||||
|
||||
test("codex test reports invalid when the endpoint returns 403 (port PR#347)", async (t) => {
|
||||
const original = globalThis.fetch;
|
||||
const { fn } = mockFetch(
|
||||
() =>
|
||||
new Response("Forbidden", {
|
||||
status: 403,
|
||||
headers: { "content-type": "text/plain" },
|
||||
})
|
||||
);
|
||||
globalThis.fetch = fn;
|
||||
t.after(() => {
|
||||
globalThis.fetch = original;
|
||||
});
|
||||
|
||||
const result = await testOAuthConnection(
|
||||
{
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
accessToken: "fake-token",
|
||||
refreshToken: "fake-refresh",
|
||||
expiresAt: futureExpiresAt(),
|
||||
},
|
||||
5000
|
||||
);
|
||||
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.statusCode, 403);
|
||||
});
|
||||
140
tests/unit/oauth-cursor-auto-import.test.ts
Normal file
140
tests/unit/oauth-cursor-auto-import.test.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
normalizeVscDbValue,
|
||||
extractCursorTokensFromRows,
|
||||
fuzzyExtractCursorTokensFromRows,
|
||||
cursorDbCandidatePaths,
|
||||
} from "../../src/app/api/oauth/cursor/auto-import/route";
|
||||
|
||||
describe("normalizeVscDbValue", () => {
|
||||
it("unwraps a JSON-encoded string", () => {
|
||||
assert.equal(normalizeVscDbValue('"abc"'), "abc");
|
||||
});
|
||||
|
||||
it("returns the raw string when JSON parse fails", () => {
|
||||
assert.equal(normalizeVscDbValue("not-json"), "not-json");
|
||||
});
|
||||
|
||||
it("returns the raw string when JSON parses to non-string", () => {
|
||||
assert.equal(normalizeVscDbValue("123"), "123");
|
||||
assert.equal(normalizeVscDbValue("{}"), "{}");
|
||||
});
|
||||
|
||||
it("passes non-strings through unchanged", () => {
|
||||
assert.equal(normalizeVscDbValue(42 as unknown as string), 42);
|
||||
assert.equal(normalizeVscDbValue(null as unknown as string), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("extractCursorTokensFromRows", () => {
|
||||
it("extracts tokens using exact primary keys", () => {
|
||||
const tokens = extractCursorTokensFromRows([
|
||||
{ key: "cursorAuth/accessToken", value: "tok-1" },
|
||||
{ key: "storage.serviceMachineId", value: "machine-1" },
|
||||
]);
|
||||
assert.equal(tokens.accessToken, "tok-1");
|
||||
assert.equal(tokens.machineId, "machine-1");
|
||||
});
|
||||
|
||||
it("accepts the alternative `cursorAuth/token` key", () => {
|
||||
const tokens = extractCursorTokensFromRows([
|
||||
{ key: "cursorAuth/token", value: "tok-2" },
|
||||
{ key: "storage.machineId", value: "machine-2" },
|
||||
]);
|
||||
assert.equal(tokens.accessToken, "tok-2");
|
||||
assert.equal(tokens.machineId, "machine-2");
|
||||
});
|
||||
|
||||
it("accepts the alternative `telemetry.machineId` key", () => {
|
||||
const tokens = extractCursorTokensFromRows([
|
||||
{ key: "cursorAuth/accessToken", value: "tok-3" },
|
||||
{ key: "telemetry.machineId", value: "machine-3" },
|
||||
]);
|
||||
assert.equal(tokens.machineId, "machine-3");
|
||||
});
|
||||
|
||||
it("prefers the first match and ignores duplicates", () => {
|
||||
const tokens = extractCursorTokensFromRows([
|
||||
{ key: "cursorAuth/accessToken", value: "first" },
|
||||
{ key: "cursorAuth/token", value: "second" },
|
||||
]);
|
||||
assert.equal(tokens.accessToken, "first");
|
||||
});
|
||||
|
||||
it("normalizes JSON-encoded values", () => {
|
||||
const tokens = extractCursorTokensFromRows([
|
||||
{ key: "cursorAuth/accessToken", value: '"json-token"' },
|
||||
{ key: "storage.serviceMachineId", value: '"json-machine"' },
|
||||
]);
|
||||
assert.equal(tokens.accessToken, "json-token");
|
||||
assert.equal(tokens.machineId, "json-machine");
|
||||
});
|
||||
|
||||
it("returns empty on no matches", () => {
|
||||
const tokens = extractCursorTokensFromRows([{ key: "irrelevant", value: "x" }]);
|
||||
assert.equal(tokens.accessToken, undefined);
|
||||
assert.equal(tokens.machineId, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe("fuzzyExtractCursorTokensFromRows", () => {
|
||||
it("matches keys by substring containing `accesstoken` and `machineid`", () => {
|
||||
const tokens = fuzzyExtractCursorTokensFromRows([
|
||||
{ key: "cursorAuth/someOtherAccessTokenKey", value: "fallback-token" },
|
||||
{ key: "storage.someMachineId", value: "fallback-machine" },
|
||||
]);
|
||||
assert.equal(tokens.accessToken, "fallback-token");
|
||||
assert.equal(tokens.machineId, "fallback-machine");
|
||||
});
|
||||
|
||||
it("preserves already-found tokens (passes existing through)", () => {
|
||||
const tokens = fuzzyExtractCursorTokensFromRows(
|
||||
[
|
||||
{ key: "cursorAuth/someOtherAccessTokenKey", value: "fallback-token" },
|
||||
{ key: "storage.someMachineId", value: "fallback-machine" },
|
||||
],
|
||||
{ accessToken: "already-have-it" }
|
||||
);
|
||||
assert.equal(tokens.accessToken, "already-have-it");
|
||||
assert.equal(tokens.machineId, "fallback-machine");
|
||||
});
|
||||
|
||||
it("is case-insensitive on the key match", () => {
|
||||
const tokens = fuzzyExtractCursorTokensFromRows([
|
||||
{ key: "Some.ACCESSTOKEN.suffix", value: "tok" },
|
||||
{ key: "Some.MACHINEID.suffix", value: "mid" },
|
||||
]);
|
||||
assert.equal(tokens.accessToken, "tok");
|
||||
assert.equal(tokens.machineId, "mid");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cursorDbCandidatePaths", () => {
|
||||
it("returns standard + Insiders paths on macOS", () => {
|
||||
const paths = cursorDbCandidatePaths("darwin", { home: "/Users/test" });
|
||||
assert.equal(paths.length, 2);
|
||||
assert.ok(paths[0].includes("Cursor/User/globalStorage/state.vscdb"));
|
||||
assert.ok(paths[1].includes("Cursor - Insiders/User/globalStorage/state.vscdb"));
|
||||
});
|
||||
|
||||
it("returns a single path on Linux", () => {
|
||||
const paths = cursorDbCandidatePaths("linux", { home: "/home/test" });
|
||||
assert.deepEqual(paths, [
|
||||
"/home/test/.config/Cursor/User/globalStorage/state.vscdb",
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a single path on Windows using APPDATA", () => {
|
||||
const paths = cursorDbCandidatePaths("win32", {
|
||||
home: "C:/Users/test",
|
||||
appdata: "C:/Users/test/AppData/Roaming",
|
||||
});
|
||||
assert.equal(paths.length, 1);
|
||||
assert.ok(paths[0].includes("Cursor/User/globalStorage/state.vscdb"));
|
||||
});
|
||||
|
||||
it("returns empty array for unsupported platforms", () => {
|
||||
assert.deepEqual(cursorDbCandidatePaths("freebsd" as NodeJS.Platform, { home: "/x" }), []);
|
||||
});
|
||||
});
|
||||
42
tests/unit/pricing-ag-flash-tiers.test.ts
Normal file
42
tests/unit/pricing-ag-flash-tiers.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { getDefaultPricing } from "../../src/shared/constants/pricing.ts";
|
||||
|
||||
// Antigravity exposes Gemini 3.5 Flash via three public client IDs in
|
||||
// ANTIGRAVITY_PUBLIC_MODELS (`open-sse/config/antigravityModelAliases.ts`):
|
||||
// - gemini-3-flash-agent → "Gemini 3.5 Flash (High)" — upstream High tier
|
||||
// - gemini-3.5-flash-low → "Gemini 3.5 Flash (Medium)" — upstream Medium tier
|
||||
// - gemini-pro-agent → "Gemini 3.1 Pro (High)" — upstream Pro High alias
|
||||
// All three were missing pricing rows in `ag` (DEFAULT_PRICING.ag), so
|
||||
// getPricingForModel("ag", id) returned null and downstream cost / quota
|
||||
// calculations silently fell back to $0. The same pricing schedule used for
|
||||
// the legacy `gemini-3-flash` and `gemini-3.1-pro-high` rows applies (same
|
||||
// per-MTok rates as the upstream quota tier they map to).
|
||||
|
||||
test("ag/gemini-3-flash-agent matches the Gemini 3.5 Flash (High) tier", () => {
|
||||
const p = getDefaultPricing().ag["gemini-3-flash-agent"];
|
||||
assert.equal(p.input, 0.5);
|
||||
assert.equal(p.output, 3.0);
|
||||
assert.equal(p.cached, 0.03);
|
||||
assert.equal(p.reasoning, 4.5);
|
||||
assert.equal(p.cache_creation, 0.5);
|
||||
});
|
||||
|
||||
test("ag/gemini-3.5-flash-low matches the Gemini 3.5 Flash (Medium) tier", () => {
|
||||
const p = getDefaultPricing().ag["gemini-3.5-flash-low"];
|
||||
assert.equal(p.input, 0.5);
|
||||
assert.equal(p.output, 3.0);
|
||||
assert.equal(p.cached, 0.03);
|
||||
assert.equal(p.reasoning, 4.5);
|
||||
assert.equal(p.cache_creation, 0.5);
|
||||
});
|
||||
|
||||
test("ag/gemini-pro-agent matches the Gemini 3.1 Pro (High) tier", () => {
|
||||
const p = getDefaultPricing().ag["gemini-pro-agent"];
|
||||
assert.equal(p.input, 4.0);
|
||||
assert.equal(p.output, 18.0);
|
||||
assert.equal(p.cached, 0.5);
|
||||
assert.equal(p.reasoning, 27.0);
|
||||
assert.equal(p.cache_creation, 4.0);
|
||||
});
|
||||
87
tests/unit/provider-limits-provider-filter.test.ts
Normal file
87
tests/unit/provider-limits-provider-filter.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// Port of decolua/9router PR #769 — provider dropdown filter for the quota
|
||||
// dashboard. The upstream PR added the dropdown plus an "Expiring first" sort
|
||||
// toggle; OmniRoute already always sorts by soonest reset within each status
|
||||
// group (see ProviderLimits/index.tsx `visibleConnections`), so only the
|
||||
// dropdown is genuinely new here. These tests guard the pure helpers that
|
||||
// back the dropdown so regressions in the inline `useMemo` predicates fail
|
||||
// loudly instead of silently widening / breaking the filter.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
buildProviderOptions,
|
||||
matchesProviderFilter,
|
||||
} from "../../src/app/(dashboard)/dashboard/usage/components/ProviderLimits/utils.tsx";
|
||||
|
||||
test("PR#769: matchesProviderFilter — 'all' lets every connection through", () => {
|
||||
assert.equal(matchesProviderFilter({ provider: "openai" }, "all"), true);
|
||||
assert.equal(matchesProviderFilter({ provider: "anthropic" }, "all"), true);
|
||||
// Defensive: even a malformed connection still matches the "all" sentinel
|
||||
// so the dropdown's default never collapses the list to zero.
|
||||
assert.equal(matchesProviderFilter({}, "all"), true);
|
||||
assert.equal(matchesProviderFilter(null, "all"), true);
|
||||
});
|
||||
|
||||
test("PR#769: matchesProviderFilter — exact match on a specific provider key", () => {
|
||||
assert.equal(matchesProviderFilter({ provider: "openai" }, "openai"), true);
|
||||
assert.equal(matchesProviderFilter({ provider: "openai" }, "anthropic"), false);
|
||||
// Case-sensitive: provider keys are normalized upstream, so the filter must
|
||||
// not match "OpenAI" against "openai" — that would silently union two
|
||||
// distinct registry rows.
|
||||
assert.equal(matchesProviderFilter({ provider: "OpenAI" }, "openai"), false);
|
||||
});
|
||||
|
||||
test("PR#769: matchesProviderFilter — non-string provider is excluded under a specific filter", () => {
|
||||
// Connections with a missing or non-string provider field cannot match a
|
||||
// specific provider; only the "all" sentinel accepts them. This keeps
|
||||
// dropdown filtering deterministic when the API returns partial rows.
|
||||
assert.equal(matchesProviderFilter({}, "openai"), false);
|
||||
assert.equal(matchesProviderFilter({ provider: undefined }, "openai"), false);
|
||||
assert.equal(matchesProviderFilter({ provider: 42 as unknown }, "openai"), false);
|
||||
assert.equal(matchesProviderFilter(null, "openai"), false);
|
||||
});
|
||||
|
||||
test("PR#769: matchesProviderFilter — empty filter string is treated like 'all'", () => {
|
||||
// Defensive: if the persisted localStorage value is somehow blanked the
|
||||
// dropdown should keep listing everything instead of hiding all rows.
|
||||
assert.equal(matchesProviderFilter({ provider: "openai" }, ""), true);
|
||||
});
|
||||
|
||||
test("PR#769: buildProviderOptions — de-duplicates and sorts alphabetically", () => {
|
||||
const connections = [
|
||||
{ provider: "openai" },
|
||||
{ provider: "anthropic" },
|
||||
{ provider: "openai" },
|
||||
{ provider: "gemini" },
|
||||
{ provider: "anthropic" },
|
||||
];
|
||||
assert.deepEqual(buildProviderOptions(connections), ["anthropic", "gemini", "openai"]);
|
||||
});
|
||||
|
||||
test("PR#769: buildProviderOptions — drops missing/empty/non-string provider keys", () => {
|
||||
const connections = [
|
||||
{ provider: "openai" },
|
||||
{ provider: "" },
|
||||
{ provider: undefined },
|
||||
{ provider: null as unknown },
|
||||
{ provider: 42 as unknown },
|
||||
{},
|
||||
{ provider: "anthropic" },
|
||||
];
|
||||
assert.deepEqual(buildProviderOptions(connections), ["anthropic", "openai"]);
|
||||
});
|
||||
|
||||
test("PR#769: buildProviderOptions — honors a custom comparator (e.g. Turkish-aware)", () => {
|
||||
// The production call site passes `compareTr` so locale-aware ordering
|
||||
// applies. The helper must thread the comparator through Array#sort
|
||||
// unchanged — verify with a reverse comparator instead of pulling in the
|
||||
// real i18n helper.
|
||||
const reverse = (a: string, b: string) => b.localeCompare(a);
|
||||
const out = buildProviderOptions([{ provider: "a" }, { provider: "c" }, { provider: "b" }], reverse);
|
||||
assert.deepEqual(out, ["c", "b", "a"]);
|
||||
});
|
||||
|
||||
test("PR#769: buildProviderOptions — returns [] for an empty connection list", () => {
|
||||
assert.deepEqual(buildProviderOptions([]), []);
|
||||
});
|
||||
@@ -146,6 +146,7 @@ test("quota labels normalize session and weekly windows while preserving readabl
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel("weekly (7d)"), "Weekly");
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel("weekly sonnet (7d)"), "Weekly Sonnet");
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel("code_review"), "Code Review");
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel("code_review_weekly"), "Code Review Weekly");
|
||||
assert.equal(providerLimitUtils.formatQuotaLabel("mcp_monthly"), "Monthly");
|
||||
});
|
||||
|
||||
|
||||
53
tests/unit/provider-node-select-4421.test.ts
Normal file
53
tests/unit/provider-node-select-4421.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #4421: creating a connection with `provider: "openai-compatible-responses"` (the bare
|
||||
// derived type) returned 404 "OpenAI Compatible node not found" even though a node of
|
||||
// that type existed — the handler did an exact-id lookup, but node ids carry a UUID
|
||||
// suffix ("openai-compatible-responses-<uuid>"). selectProviderNodeForConnection now
|
||||
// also resolves the bare type to the sole matching node.
|
||||
|
||||
const { selectProviderNodeForConnection, nodeTypeFromId } = await import(
|
||||
"../../src/lib/db/providerNodeSelect.ts"
|
||||
);
|
||||
|
||||
const UUID = "1715ed0f-1111-2222-3333-444455556666";
|
||||
const UUID2 = "2222aaaa-1111-2222-3333-444455556666";
|
||||
|
||||
test("#4421 resolves by exact node id (dashboard path, unchanged)", () => {
|
||||
const nodes = [{ id: `openai-compatible-responses-${UUID}`, name: "fox" }];
|
||||
const r = selectProviderNodeForConnection(`openai-compatible-responses-${UUID}`, nodes);
|
||||
assert.equal(r?.name, "fox");
|
||||
});
|
||||
|
||||
test("#4421 resolves the bare base type to the sole matching node (direct-API path)", () => {
|
||||
// Before the fix the handler called getProviderNodeById("openai-compatible-responses")
|
||||
// — an exact-id lookup that returned null → 404.
|
||||
const nodes = [{ id: `openai-compatible-responses-${UUID}`, name: "fox" }];
|
||||
const r = selectProviderNodeForConnection("openai-compatible-responses", nodes);
|
||||
assert.equal(r?.name, "fox");
|
||||
});
|
||||
|
||||
test("#4421 returns null when the base type is ambiguous (more than one node)", () => {
|
||||
const nodes = [
|
||||
{ id: `openai-compatible-responses-${UUID}`, name: "a" },
|
||||
{ id: `openai-compatible-responses-${UUID2}`, name: "b" },
|
||||
];
|
||||
assert.equal(selectProviderNodeForConnection("openai-compatible-responses", nodes), null);
|
||||
});
|
||||
|
||||
test("#4421 a base type does not match a more-specific node type", () => {
|
||||
// "openai-compatible" (chat) must NOT resolve an "openai-compatible-responses" node.
|
||||
const nodes = [{ id: `openai-compatible-responses-${UUID}`, name: "fox" }];
|
||||
assert.equal(selectProviderNodeForConnection("openai-compatible", nodes), null);
|
||||
});
|
||||
|
||||
test("#4421 nodeTypeFromId strips a trailing UUID", () => {
|
||||
assert.equal(nodeTypeFromId(`openai-compatible-responses-${UUID}`), "openai-compatible-responses");
|
||||
assert.equal(nodeTypeFromId(`openai-compatible-${UUID}`), "openai-compatible");
|
||||
assert.equal(nodeTypeFromId("no-uuid-here"), "no-uuid-here");
|
||||
});
|
||||
|
||||
test("#4421 returns null when no node matches", () => {
|
||||
assert.equal(selectProviderNodeForConnection("anthropic-compatible", []), null);
|
||||
});
|
||||
45
tests/unit/provider-registry-github-copilot-gpt-4o.test.ts
Normal file
45
tests/unit/provider-registry-github-copilot-gpt-4o.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* Port of 9router PR #98 — add GPT-4o to GitHub Copilot (`github`/alias `gh`).
|
||||
*
|
||||
* Copilot still serves the original `gpt-4o` chat model via its chat/completions
|
||||
* endpoint. The OmniRoute registry only ships the GPT-5.x family, so apps that
|
||||
* explicitly request `gpt-4o` against the `gh` alias get an "unknown model"
|
||||
* error. Adding the entry restores parity with the upstream Copilot catalog
|
||||
* without disturbing the GPT-5.x / Claude / Gemini lineups already curated.
|
||||
*
|
||||
* GPT-4o is a chat/completions model — it must NOT use `openai-responses`.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { REGISTRY } = await import("../../open-sse/config/providerRegistry.ts");
|
||||
const { getModelsByProviderId } = await import("../../open-sse/config/providerModels.ts");
|
||||
|
||||
type ModelEntry = { id: string; name?: string; targetFormat?: string; [k: string]: unknown };
|
||||
|
||||
function githubModel(id: string): ModelEntry | undefined {
|
||||
const provider = (REGISTRY as Record<string, { models?: ModelEntry[] }>)["github"];
|
||||
return provider?.models?.find((m) => m.id === id);
|
||||
}
|
||||
|
||||
test("9router#98 github/gpt-4o is registered under the gh provider", () => {
|
||||
const model = githubModel("gpt-4o");
|
||||
assert.ok(model, "gpt-4o must be registered under the github (gh) provider");
|
||||
assert.equal(typeof model?.name, "string");
|
||||
});
|
||||
|
||||
test("9router#98 github/gpt-4o routes via chat/completions (no openai-responses)", () => {
|
||||
const model = githubModel("gpt-4o");
|
||||
assert.ok(model);
|
||||
assert.notEqual(
|
||||
model.targetFormat,
|
||||
"openai-responses",
|
||||
"GPT-4o on GitHub Copilot is a chat/completions model — Responses API would reject it"
|
||||
);
|
||||
});
|
||||
|
||||
test("9router#98 getModelsByProviderId(github) exposes gpt-4o", () => {
|
||||
const models = getModelsByProviderId("github") as ModelEntry[];
|
||||
const gpt4o = models.find((m) => m.id === "gpt-4o");
|
||||
assert.ok(gpt4o, "gpt-4o resolvable via getModelsByProviderId(github)");
|
||||
});
|
||||
@@ -372,12 +372,12 @@ test("web-cookie provider validators surface auth and subscription failures", as
|
||||
__setPplxTlsFetchOverride(async () => {
|
||||
return { status: 403, headers: new Headers(), text: null, body: null };
|
||||
});
|
||||
__setGrokTlsFetchOverride(async () => {
|
||||
return { status: 401, headers: new Headers(), text: "Unauthorized", body: null };
|
||||
});
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
if (target.includes("grok.com/rest/app-chat/conversations/new")) {
|
||||
return new Response(JSON.stringify({ error: "unauthorized" }), { status: 401 });
|
||||
}
|
||||
if (target.includes("app.blackbox.ai/api/auth/session")) {
|
||||
const cookie = (init.headers as Record<string, string>)?.Cookie || "";
|
||||
if (cookie.includes("expired-cookie")) {
|
||||
|
||||
35
tests/unit/quota-cache-snapshot-dedup-4438.test.ts
Normal file
35
tests/unit/quota-cache-snapshot-dedup-4438.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { quotaSnapshotChanged } from "@/domain/quotaCache";
|
||||
|
||||
// Regression guard for #4438: quota_snapshots generated 400K+ rows/day because
|
||||
// setQuotaCache wrote a snapshot row for EVERY window of EVERY connection on each
|
||||
// 60s background refresh, even for idle connections whose quota never changed.
|
||||
// quotaSnapshotChanged() gates the write so unchanged idle connections stop
|
||||
// generating rows, while the first observation and every real change still persist.
|
||||
|
||||
test("#4438 writes when there is no prior cache entry (baseline row)", () => {
|
||||
assert.equal(quotaSnapshotChanged(null, "daily", 100, false), true);
|
||||
assert.equal(quotaSnapshotChanged(undefined, "daily", 42, false), true);
|
||||
});
|
||||
|
||||
test("#4438 writes when the window was never seen before", () => {
|
||||
const prior = { quotas: { weekly: { remainingPercentage: 80 } }, exhausted: false };
|
||||
assert.equal(quotaSnapshotChanged(prior, "daily", 80, false), true);
|
||||
});
|
||||
|
||||
test("#4438 skips when remaining_percentage and is_exhausted are unchanged (idle connection)", () => {
|
||||
const prior = { quotas: { daily: { remainingPercentage: 73 } }, exhausted: false };
|
||||
assert.equal(quotaSnapshotChanged(prior, "daily", 73, false), false);
|
||||
});
|
||||
|
||||
test("#4438 writes when remaining_percentage changed", () => {
|
||||
const prior = { quotas: { daily: { remainingPercentage: 73 } }, exhausted: false };
|
||||
assert.equal(quotaSnapshotChanged(prior, "daily", 72, false), true);
|
||||
});
|
||||
|
||||
test("#4438 writes when is_exhausted flipped even if percentage matches", () => {
|
||||
const prior = { quotas: { daily: { remainingPercentage: 0 } }, exhausted: false };
|
||||
assert.equal(quotaSnapshotChanged(prior, "daily", 0, true), true);
|
||||
});
|
||||
@@ -108,6 +108,29 @@ test("providerWindowDefaults: out-of-range values are clamped, garbage is pruned
|
||||
);
|
||||
});
|
||||
|
||||
test("#4483: auto-routing quota cutoff is OFF by default (opt-in)", () => {
|
||||
// The hard cutoff overlaps the existing soft penalty + cooldown, so it must not
|
||||
// change auto-routing behavior unless an operator explicitly turns it on.
|
||||
const settings = cloneDefaults();
|
||||
assert.equal(settings.quotaPreflight.enabled, false);
|
||||
assert.equal(resolveResilienceSettings({}).quotaPreflight.enabled, false);
|
||||
});
|
||||
|
||||
test("#4483: enabling the quota cutoff round-trips and preserves the other thresholds", () => {
|
||||
const next = mergeResilienceSettings(cloneDefaults(), {
|
||||
quotaPreflight: { enabled: true },
|
||||
});
|
||||
assert.equal(next.quotaPreflight.enabled, true);
|
||||
// Toggling the switch must not disturb the threshold defaults.
|
||||
assert.equal(next.quotaPreflight.defaultThresholdPercent, 2);
|
||||
assert.equal(next.quotaPreflight.warnThresholdPercent, 20);
|
||||
|
||||
const resolved = resolveResilienceSettings({
|
||||
resilienceSettings: { quotaPreflight: { enabled: true } },
|
||||
});
|
||||
assert.equal(resolved.quotaPreflight.enabled, true);
|
||||
});
|
||||
|
||||
test("resolveResilienceSettings round-trips a stored providerWindowDefaults map", () => {
|
||||
const stored = {
|
||||
resilienceSettings: {
|
||||
|
||||
54
tests/unit/supervisor-policy-4425.test.ts
Normal file
54
tests/unit/supervisor-policy-4425.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import net from "node:net";
|
||||
|
||||
// #4425: the supervisor treated a clean exit (code 0) as intentional and exited instead
|
||||
// of restarting — but a systemd MemoryMax cgroup kill reports code 0, so the OOM'd gateway
|
||||
// stayed dead. And it restarted immediately after a crash, hitting EADDRINUSE before the
|
||||
// OS released the port. supervisorPolicy centralizes the restart decision + a port-wait.
|
||||
|
||||
const {
|
||||
RESTART_RESET_MS,
|
||||
DEFAULT_MAX_RESTARTS,
|
||||
shouldExitInsteadOfRestart,
|
||||
computeRestartDelayMs,
|
||||
isPortFree,
|
||||
waitUntilPortFree,
|
||||
} = await import("../../bin/cli/runtime/supervisorPolicy.mjs");
|
||||
|
||||
test("#4425 spontaneous code-0 exit restarts (only shutdown exits)", () => {
|
||||
assert.equal(shouldExitInsteadOfRestart(false), false); // OOM cgroup code-0 → restart
|
||||
assert.equal(shouldExitInsteadOfRestart(true), true); // operator stop() → exit
|
||||
});
|
||||
|
||||
test("#4425 tuned recovery constants", () => {
|
||||
assert.equal(RESTART_RESET_MS, 60_000);
|
||||
assert.equal(DEFAULT_MAX_RESTARTS, 3);
|
||||
});
|
||||
|
||||
test("#4425 restart backoff is 1s,2s,4s… capped at 10s", () => {
|
||||
assert.equal(computeRestartDelayMs(1), 1000);
|
||||
assert.equal(computeRestartDelayMs(2), 2000);
|
||||
assert.equal(computeRestartDelayMs(3), 4000);
|
||||
assert.equal(computeRestartDelayMs(10), 10_000);
|
||||
});
|
||||
|
||||
test("#4425 isPortFree detects a bound port and waitUntilPortFree resolves once released", async () => {
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
||||
const port = (server.address() as net.AddressInfo).port;
|
||||
|
||||
assert.equal(await isPortFree(port), false, "bound port is not free");
|
||||
|
||||
// waitUntilPortFree should time out (return false) while the port stays bound.
|
||||
assert.equal(await waitUntilPortFree(port, 300, 50), false);
|
||||
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
assert.equal(await isPortFree(port), true, "released port is free");
|
||||
assert.equal(await waitUntilPortFree(port, 1000, 50), true);
|
||||
});
|
||||
|
||||
test("#4425 waitUntilPortFree no-ops on an invalid port", async () => {
|
||||
assert.equal(await waitUntilPortFree(undefined), true);
|
||||
assert.equal(await waitUntilPortFree(0), true);
|
||||
});
|
||||
84
tests/unit/translator-gemini-audio-input.test.ts
Normal file
84
tests/unit/translator-gemini-audio-input.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Regression for upstream PR decolua/9router#913 — OpenAI `input_audio` and
|
||||
// `audio_url` content parts must be forwarded as Gemini `inlineData` audio parts
|
||||
// (instead of being silently dropped) so that callers can send audio (WAV/MP3/etc.)
|
||||
// to Gemini models via the Antigravity / Gemini / Gemini-CLI translation paths.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { convertOpenAIContentToParts } = await import(
|
||||
"../../open-sse/translator/helpers/geminiHelper.ts"
|
||||
);
|
||||
const { VALID_OPENAI_CONTENT_TYPES, filterToOpenAIFormat } = await import(
|
||||
"../../open-sse/translator/helpers/openaiHelper.ts"
|
||||
);
|
||||
|
||||
type Part = { inlineData?: { mimeType: string; data: string } };
|
||||
|
||||
test("convertOpenAIContentToParts handles input_audio with explicit wav format (#913)", () => {
|
||||
const parts = convertOpenAIContentToParts([
|
||||
{ type: "text", text: "Transcribe this" },
|
||||
{ type: "input_audio", input_audio: { data: "UklGRiQ", format: "wav" } },
|
||||
]) as Part[];
|
||||
const inline = parts.find((p) => p.inlineData);
|
||||
assert.ok(inline, "input_audio must produce an inlineData part");
|
||||
assert.equal(inline!.inlineData!.mimeType, "audio/wav");
|
||||
assert.equal(inline!.inlineData!.data, "UklGRiQ");
|
||||
});
|
||||
|
||||
test("convertOpenAIContentToParts maps input_audio mp3 -> audio/mpeg mime (#913)", () => {
|
||||
const parts = convertOpenAIContentToParts([
|
||||
{ type: "input_audio", input_audio: { data: "SUQzBAA", format: "mp3" } },
|
||||
]) as Part[];
|
||||
const inline = parts.find((p) => p.inlineData);
|
||||
assert.ok(inline, "input_audio mp3 must produce an inlineData part");
|
||||
assert.equal(
|
||||
inline!.inlineData!.mimeType,
|
||||
"audio/mpeg",
|
||||
"mp3 must canonicalize to audio/mpeg per RFC 3003"
|
||||
);
|
||||
});
|
||||
|
||||
test("convertOpenAIContentToParts defaults input_audio without format to audio/wav (#913)", () => {
|
||||
const parts = convertOpenAIContentToParts([
|
||||
{ type: "input_audio", input_audio: { data: "AAAA" } },
|
||||
]) as Part[];
|
||||
const inline = parts.find((p) => p.inlineData);
|
||||
assert.ok(inline, "input_audio without format must still produce an inlineData part");
|
||||
assert.equal(inline!.inlineData!.mimeType, "audio/wav");
|
||||
});
|
||||
|
||||
test("convertOpenAIContentToParts handles audio_url data URI (#913)", () => {
|
||||
const parts = convertOpenAIContentToParts([
|
||||
{ type: "audio_url", audio_url: { url: "data:audio/wav;base64,UklGRiQ" } },
|
||||
]) as Part[];
|
||||
const inline = parts.find((p) => p.inlineData);
|
||||
assert.ok(inline, "audio_url data URI must produce an inlineData part");
|
||||
assert.equal(inline!.inlineData!.mimeType, "audio/wav");
|
||||
assert.equal(inline!.inlineData!.data, "UklGRiQ");
|
||||
});
|
||||
|
||||
test("input_audio and audio_url are preserved by filterToOpenAIFormat (#913)", () => {
|
||||
// For OpenAI-target routes (passthrough), audio parts must not be stripped
|
||||
// out by the content-type allowlist.
|
||||
assert.ok(VALID_OPENAI_CONTENT_TYPES.includes("input_audio"));
|
||||
assert.ok(VALID_OPENAI_CONTENT_TYPES.includes("audio_url"));
|
||||
|
||||
const body = {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "What is this?" },
|
||||
{ type: "input_audio", input_audio: { data: "AAAA", format: "wav" } },
|
||||
{ type: "audio_url", audio_url: { url: "data:audio/wav;base64,AAAA" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const filtered = filterToOpenAIFormat(body);
|
||||
const content = filtered.messages[0].content as Array<Record<string, unknown>>;
|
||||
const types = content.map((c) => c.type);
|
||||
assert.ok(types.includes("input_audio"), "input_audio survives passthrough filter");
|
||||
assert.ok(types.includes("audio_url"), "audio_url survives passthrough filter");
|
||||
});
|
||||
37
tests/unit/ui/AntigravityToolCard-model-aliases.test.tsx
Normal file
37
tests/unit/ui/AntigravityToolCard-model-aliases.test.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
// Source-level parity check (#241): AntigravityToolCard must surface
|
||||
// API-Key-compatible / custom OpenAI-compatible providers in its model picker.
|
||||
// Those provider groups in <ModelSelectModal> are derived from `modelAliases`
|
||||
// — without the prop, custom-keyed providers are silently hidden even when
|
||||
// active. The fix mirrors the pattern already used by every sibling CLI tool
|
||||
// card (Codex, Claude, Cline, Kilo, Droid, OpenClaw, HermesAgent).
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CARD_PATH = resolve(
|
||||
__dirname,
|
||||
"../../../src/app/(dashboard)/dashboard/cli-code/components/AntigravityToolCard.tsx"
|
||||
);
|
||||
|
||||
describe("AntigravityToolCard model alias wiring", () => {
|
||||
const source = readFileSync(CARD_PATH, "utf8");
|
||||
|
||||
it("declares modelAliases state", () => {
|
||||
expect(source).toMatch(/useState\(\{\}\)/);
|
||||
expect(source).toMatch(/setModelAliases/);
|
||||
});
|
||||
|
||||
it("fetches /api/models/alias when expanded", () => {
|
||||
expect(source).toContain('fetch("/api/models/alias")');
|
||||
expect(source).toMatch(/fetchModelAliases\s*\(\s*\)/);
|
||||
});
|
||||
|
||||
it("passes modelAliases prop to ModelSelectModal", () => {
|
||||
// Regression guard for upstream parity: the prop is what unlocks the
|
||||
// API-Key-compatible / passthrough provider groups in the picker.
|
||||
expect(source).toMatch(/modelAliases=\{modelAliases\}/);
|
||||
});
|
||||
});
|
||||
66
tests/unit/ui/combo-form-deselect-handler.test.tsx
Normal file
66
tests/unit/ui/combo-form-deselect-handler.test.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
// Pure unit test for the deselect filter semantics used by ComboFormModal in
|
||||
// src/app/(dashboard)/dashboard/combos/page.tsx — ported from upstream PR
|
||||
// decolua/9router#889 (Fajar Hidayat).
|
||||
//
|
||||
// The page-level handler removes every step whose qualified `model` matches
|
||||
// the value sent from the ModelSelectModal, matching upstream JS behavior
|
||||
// (`setModels(models.filter((m) => m !== model.value))`). Duplicates with
|
||||
// different providerId/weight pointing at the same model id are all stripped.
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
type Step = { model: string; providerId?: string; weight?: number };
|
||||
|
||||
// Mirror of src/app/(dashboard)/dashboard/combos/page.tsx::handleDeselectModel.
|
||||
// Kept in lockstep so the same filter behavior is checked here, leaf-only.
|
||||
function deselectModel(models: Step[], model: { value?: string } | string): Step[] {
|
||||
const value =
|
||||
typeof (model as any)?.value === "string"
|
||||
? (model as any).value
|
||||
: typeof model === "string"
|
||||
? model
|
||||
: "";
|
||||
if (!value) return models;
|
||||
return models.filter((m) => m.model !== value);
|
||||
}
|
||||
|
||||
describe("ComboFormModal deselect handler (upstream PR #889)", () => {
|
||||
it("removes the single matching step when called with { value }", () => {
|
||||
const models: Step[] = [
|
||||
{ model: "openai/gpt-4o", weight: 50 },
|
||||
{ model: "anthropic/claude-3-5-sonnet", weight: 50 },
|
||||
];
|
||||
const next = deselectModel(models, { value: "openai/gpt-4o" });
|
||||
expect(next).toEqual([{ model: "anthropic/claude-3-5-sonnet", weight: 50 }]);
|
||||
});
|
||||
|
||||
it("strips every duplicate of the same qualified model", () => {
|
||||
const models: Step[] = [
|
||||
{ model: "openai/gpt-4o", providerId: "openai-a", weight: 30 },
|
||||
{ model: "openai/gpt-4o", providerId: "openai-b", weight: 70 },
|
||||
{ model: "anthropic/claude-3-5-sonnet", weight: 0 },
|
||||
];
|
||||
const next = deselectModel(models, { value: "openai/gpt-4o" });
|
||||
expect(next).toEqual([{ model: "anthropic/claude-3-5-sonnet", weight: 0 }]);
|
||||
});
|
||||
|
||||
it("is a no-op when the value is not in the list", () => {
|
||||
const models: Step[] = [{ model: "openai/gpt-4o", weight: 100 }];
|
||||
const next = deselectModel(models, { value: "openai/gpt-3.5" });
|
||||
expect(next).toEqual(models);
|
||||
});
|
||||
|
||||
it("is a no-op for empty/missing value (defensive guard)", () => {
|
||||
const models: Step[] = [{ model: "openai/gpt-4o", weight: 100 }];
|
||||
expect(deselectModel(models, { value: "" })).toEqual(models);
|
||||
expect(deselectModel(models, {})).toEqual(models);
|
||||
});
|
||||
|
||||
it("accepts a raw string model identifier (legacy upstream call shape)", () => {
|
||||
const models: Step[] = [
|
||||
{ model: "openai/gpt-4o", weight: 50 },
|
||||
{ model: "anthropic/claude-3-5-sonnet", weight: 50 },
|
||||
];
|
||||
const next = deselectModel(models, "openai/gpt-4o");
|
||||
expect(next).toEqual([{ model: "anthropic/claude-3-5-sonnet", weight: 50 }]);
|
||||
});
|
||||
});
|
||||
148
tests/unit/ui/compressionHub-active-selector.test.tsx
Normal file
148
tests/unit/ui/compressionHub-active-selector.test.tsx
Normal file
@@ -0,0 +1,148 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
const roots: Array<{ unmount: () => void }> = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await act(async () => {
|
||||
while (roots.length > 0) roots.pop()?.unmount();
|
||||
});
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
while (containers.length > 0) containers.pop()?.remove();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
interface CapturedPut {
|
||||
url: string;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function setupFetchMock(): { puts: CapturedPut[] } {
|
||||
const puts: CapturedPut[] = [];
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
|
||||
const initialConfig = {
|
||||
enabled: true,
|
||||
defaultMode: "off",
|
||||
autoTriggerTokens: 0,
|
||||
cacheMinutes: 5,
|
||||
preserveSystemPrompt: true,
|
||||
comboOverrides: {},
|
||||
activeComboId: null,
|
||||
contextEditing: { enabled: false },
|
||||
};
|
||||
const combos = [{ id: "c1", name: "RTK only", pipeline: [{ engine: "rtk" }] }];
|
||||
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(
|
||||
async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
const method = (init?.method ?? "GET").toUpperCase();
|
||||
if (url.includes("/api/context/combos/default")) return json({}, 404);
|
||||
if (url.includes("/api/context/combos")) return json({ combos });
|
||||
if (url.includes("/api/compression/engines")) return json({ engines: [] });
|
||||
if (url.includes("/api/settings/compression")) {
|
||||
if (method === "PUT") {
|
||||
const body = JSON.parse(String(init?.body ?? "{}"));
|
||||
puts.push({ url, body });
|
||||
return json({ ...initialConfig, ...body });
|
||||
}
|
||||
return json(initialConfig);
|
||||
}
|
||||
return json({}, 404);
|
||||
}
|
||||
);
|
||||
return { puts };
|
||||
}
|
||||
|
||||
function setSelectValue(select: HTMLSelectElement, value: string) {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLSelectElement.prototype, "value")!.set!;
|
||||
setter.call(select, value);
|
||||
select.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}
|
||||
|
||||
describe("CompressionHub — active-profile selector", () => {
|
||||
async function render() {
|
||||
const { default: CompressionHub } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/context/combos/CompressionHub"
|
||||
);
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mount(<CompressionHub />);
|
||||
});
|
||||
await flush();
|
||||
return container;
|
||||
}
|
||||
|
||||
it("renders the active-profile select with Default + each named combo", async () => {
|
||||
setupFetchMock();
|
||||
const container = await render();
|
||||
const select = container.querySelector('[data-testid="active-profile-select"]') as HTMLSelectElement | null;
|
||||
expect(select).toBeTruthy();
|
||||
expect(container.textContent).toContain("Default (from panel)");
|
||||
expect(container.textContent).toContain("RTK only");
|
||||
});
|
||||
|
||||
it("changing the select to a combo PUTs activeComboId === that id", async () => {
|
||||
const { puts } = setupFetchMock();
|
||||
const container = await render();
|
||||
const select = container.querySelector('[data-testid="active-profile-select"]') as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
setSelectValue(select, "c1");
|
||||
});
|
||||
await flush();
|
||||
const settingsPuts = puts.filter((p) => p.url.includes("/api/settings/compression"));
|
||||
expect(settingsPuts.length).toBeGreaterThan(0);
|
||||
expect(settingsPuts.pop()!.body.activeComboId).toBe("c1");
|
||||
});
|
||||
|
||||
it("preview shows the Default fallback initially, and the combo engines once a combo is active", async () => {
|
||||
setupFetchMock();
|
||||
const container = await render();
|
||||
const preview = () => container.querySelector('[data-testid="active-profile-preview"]');
|
||||
expect(preview()).toBeTruthy();
|
||||
expect(preview()!.textContent).toContain("Default");
|
||||
const select = container.querySelector('[data-testid="active-profile-select"]') as HTMLSelectElement;
|
||||
await act(async () => {
|
||||
setSelectValue(select, "c1");
|
||||
});
|
||||
await flush();
|
||||
expect(preview()!.textContent).toContain("rtk");
|
||||
});
|
||||
|
||||
it("no longer renders the master Token Saver toggle, the mode selector, or reorder buttons", async () => {
|
||||
setupFetchMock();
|
||||
const container = await render();
|
||||
expect(container.querySelector('[aria-label="Toggle Token Saver"]')).toBeNull();
|
||||
expect(container.querySelector('[aria-label="Move up"]')).toBeNull();
|
||||
expect(container.querySelector('[aria-label="Move down"]')).toBeNull();
|
||||
// The Aggressive mode button's hint text is gone with the mode selector.
|
||||
expect(container.textContent).not.toContain("Summary plus aging");
|
||||
});
|
||||
});
|
||||
202
tests/unit/ui/model-select-modal-deselect.test.tsx
Normal file
202
tests/unit/ui/model-select-modal-deselect.test.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Port of decolua/9router PR #889 (Fajar Hidayat <fajarhide@gmail.com>):
|
||||
// "Add model deselection functionality in ComboFormModal and ComboDetailPage".
|
||||
//
|
||||
// Upstream UX: clicking an already-added (highlighted) model in ModelSelectModal
|
||||
// should TOGGLE — invoke onDeselect instead of onSelect — and the modal must stay
|
||||
// open when keepOpenOnSelect so the user can add/remove several models in one
|
||||
// session. OmniRoute already had the visual highlight (addedModelValues) but no
|
||||
// deselect callback nor an opt-out for the auto-close — added here.
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { default: ModelSelectModal } = await import("@/shared/components/ModelSelectModal");
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
async function renderModal(props: Partial<React.ComponentProps<typeof ModelSelectModal>> = {}) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ModelSelectModal
|
||||
isOpen={true}
|
||||
onClose={() => {}}
|
||||
onSelect={() => {}}
|
||||
showCombos={false}
|
||||
activeProviders={[{ provider: "openai" }]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {});
|
||||
return { container, root };
|
||||
}
|
||||
|
||||
function findModelButton(container: HTMLElement, label: string): HTMLButtonElement | null {
|
||||
const buttons = Array.from(container.querySelectorAll("button"));
|
||||
return (buttons.find((b) => (b.textContent || "").includes(label)) as HTMLButtonElement) || null;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (url === "/api/provider-nodes") {
|
||||
return { ok: true, json: async () => ({ nodes: [] }) };
|
||||
}
|
||||
if (url === "/api/provider-models") {
|
||||
return { ok: true, json: async () => ({ models: {} }) };
|
||||
}
|
||||
if (url === "/api/combos") {
|
||||
return { ok: true, json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ModelSelectModal — deselect / toggle behavior (upstream PR #889)", () => {
|
||||
it("calls onDeselect (not onSelect) when clicking a model already in addedModelValues", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onDeselect = vi.fn();
|
||||
|
||||
// We don't know exactly which OpenAI model labels the system catalog ships at
|
||||
// any given time, so probe the rendered DOM for one and use its value.
|
||||
const { container } = await renderModal({
|
||||
onSelect,
|
||||
onDeselect,
|
||||
addedModelValues: [],
|
||||
});
|
||||
|
||||
// Find the first OpenAI model rendered (it must exist — openai is an active provider).
|
||||
const firstModelButton = container.querySelector(
|
||||
"button[class*='hover:border-primary']"
|
||||
) as HTMLButtonElement | null;
|
||||
expect(firstModelButton, "expected at least one openai model button to render").not.toBeNull();
|
||||
|
||||
const modelName = (firstModelButton!.textContent || "").trim();
|
||||
expect(modelName.length).toBeGreaterThan(0);
|
||||
|
||||
// First click — model is NOT yet added → must call onSelect, not onDeselect.
|
||||
await act(async () => {
|
||||
firstModelButton!.click();
|
||||
});
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(onDeselect).not.toHaveBeenCalled();
|
||||
|
||||
// The handler we got back from onSelect should be a model object with .value.
|
||||
const selectedArg = onSelect.mock.calls[0][0];
|
||||
expect(selectedArg).toMatchObject({ value: expect.any(String) });
|
||||
});
|
||||
|
||||
it("toggles to onDeselect when the model is already in addedModelValues", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onDeselect = vi.fn();
|
||||
|
||||
// Render once to discover the model value
|
||||
const probe = await renderModal({ onSelect: vi.fn(), onDeselect: vi.fn() });
|
||||
const probeButton = probe.container.querySelector(
|
||||
"button[class*='hover:border-primary']"
|
||||
) as HTMLButtonElement | null;
|
||||
expect(probeButton).not.toBeNull();
|
||||
// The on-click handler embeds the model value; trigger once to capture it.
|
||||
const tempSelect = vi.fn();
|
||||
const probe2 = await renderModal({ onSelect: tempSelect, addedModelValues: [] });
|
||||
const probeButton2 = probe2.container.querySelector(
|
||||
"button[class*='hover:border-primary']"
|
||||
) as HTMLButtonElement | null;
|
||||
await act(async () => {
|
||||
probeButton2!.click();
|
||||
});
|
||||
const capturedValue = tempSelect.mock.calls[0][0].value as string;
|
||||
expect(typeof capturedValue).toBe("string");
|
||||
expect(capturedValue.length).toBeGreaterThan(0);
|
||||
|
||||
// Now render with that value pre-added and click again — must invoke onDeselect.
|
||||
const { container } = await renderModal({
|
||||
onSelect,
|
||||
onDeselect,
|
||||
addedModelValues: [capturedValue],
|
||||
keepOpenOnSelect: true,
|
||||
});
|
||||
|
||||
// The already-added model now renders with the emerald/added class — look for ✓.
|
||||
const addedButton = Array.from(container.querySelectorAll("button")).find((b) =>
|
||||
(b.textContent || "").includes("✓")
|
||||
) as HTMLButtonElement | undefined;
|
||||
expect(addedButton, "expected an added (✓) button to render").toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
addedButton!.click();
|
||||
});
|
||||
|
||||
expect(onDeselect).toHaveBeenCalledTimes(1);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
expect(onDeselect.mock.calls[0][0]).toMatchObject({ value: capturedValue });
|
||||
});
|
||||
|
||||
it("does NOT auto-close the modal when keepOpenOnSelect is true", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
const { container } = await renderModal({
|
||||
onSelect,
|
||||
onClose,
|
||||
keepOpenOnSelect: true,
|
||||
});
|
||||
|
||||
const firstModelButton = container.querySelector(
|
||||
"button[class*='hover:border-primary']"
|
||||
) as HTMLButtonElement | null;
|
||||
expect(firstModelButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
firstModelButton!.click();
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still auto-closes by default (keepOpenOnSelect defaults to false, backward-compat)", async () => {
|
||||
const onSelect = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
|
||||
const { container } = await renderModal({ onSelect, onClose });
|
||||
|
||||
const firstModelButton = container.querySelector(
|
||||
"button[class*='hover:border-primary']"
|
||||
) as HTMLButtonElement | null;
|
||||
expect(firstModelButton).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
firstModelButton!.click();
|
||||
});
|
||||
|
||||
expect(onSelect).toHaveBeenCalledTimes(1);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
119
tests/unit/ui/model-select-modal-keep-open.test.tsx
Normal file
119
tests/unit/ui/model-select-modal-keep-open.test.tsx
Normal file
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Regression coverage for the "keepOpenOnSelect" prop added in feat/port-pr-1031.
|
||||
// Mirrors the UX of upstream PR decolua/9router#1031: when a caller (e.g. combo
|
||||
// creation) opts out of the auto-close-on-select behaviour, the modal renders a
|
||||
// "Done" button in the footer so the user has a clear way to confirm they are
|
||||
// finished adding entries.
|
||||
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("next-intl", () => ({
|
||||
useTranslations: () => (key: string) => key,
|
||||
}));
|
||||
|
||||
const { default: ModelSelectModal } = await import("@/shared/components/ModelSelectModal");
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
|
||||
async function renderModal(props: Partial<React.ComponentProps<typeof ModelSelectModal>> = {}) {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<ModelSelectModal
|
||||
isOpen={true}
|
||||
onClose={() => {}}
|
||||
onSelect={() => {}}
|
||||
showCombos={false}
|
||||
activeProviders={[]}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
await act(async () => {});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string) => {
|
||||
if (url === "/api/provider-nodes") {
|
||||
return { ok: true, json: async () => ({ nodes: [] }) };
|
||||
}
|
||||
if (url === "/api/provider-models") {
|
||||
return { ok: true, json: async () => ({ models: {} }) };
|
||||
}
|
||||
if (url === "/api/combos") {
|
||||
return { ok: true, json: async () => ({ combos: [] }) };
|
||||
}
|
||||
return { ok: true, json: async () => ({}) };
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (containers.length > 0) {
|
||||
containers.pop()?.remove();
|
||||
}
|
||||
document.body.innerHTML = "";
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("ModelSelectModal keepOpenOnSelect", () => {
|
||||
it("does not render the Done button by default (auto-close behaviour preserved)", async () => {
|
||||
const container = await renderModal();
|
||||
const doneButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "done"
|
||||
);
|
||||
expect(doneButton).toBeUndefined();
|
||||
});
|
||||
|
||||
it("renders the Done button when keepOpenOnSelect is true", async () => {
|
||||
const container = await renderModal({ keepOpenOnSelect: true });
|
||||
const doneButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "done"
|
||||
);
|
||||
expect(doneButton).toBeDefined();
|
||||
});
|
||||
|
||||
it("clicking Done triggers onClose without invoking onSelect again", async () => {
|
||||
const onClose = vi.fn();
|
||||
const onSelect = vi.fn();
|
||||
const container = await renderModal({ keepOpenOnSelect: true, onClose, onSelect });
|
||||
|
||||
const doneButton = Array.from(container.querySelectorAll("button")).find(
|
||||
(b) => b.textContent?.trim() === "done"
|
||||
);
|
||||
expect(doneButton).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
doneButton!.click();
|
||||
});
|
||||
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render the Done button when multiSelect is true (multiSelect owns its own footer)", async () => {
|
||||
// multiSelect already ships a Clear + Done footer driven by selectedModels.
|
||||
// keepOpenOnSelect must defer to it to avoid two competing Done buttons.
|
||||
const container = await renderModal({ keepOpenOnSelect: true, multiSelect: true });
|
||||
const doneButtons = Array.from(container.querySelectorAll("button")).filter(
|
||||
(b) => b.textContent?.trim() === "done"
|
||||
);
|
||||
// Exactly one Done button — the one inside the multiSelect footer, not a duplicate.
|
||||
expect(doneButtons.length).toBe(1);
|
||||
});
|
||||
});
|
||||
95
tests/unit/ui/namedCombos-active-badge.test.tsx
Normal file
95
tests/unit/ui/namedCombos-active-badge.test.tsx
Normal file
@@ -0,0 +1,95 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
||||
|
||||
const containers: HTMLElement[] = [];
|
||||
const roots: Array<{ unmount: () => void }> = [];
|
||||
|
||||
function mount(ui: React.ReactElement): HTMLElement {
|
||||
const container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
containers.push(container);
|
||||
const root = createRoot(container);
|
||||
roots.push(root);
|
||||
act(() => {
|
||||
root.render(ui);
|
||||
});
|
||||
return container;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
await act(async () => {
|
||||
while (roots.length > 0) roots.pop()?.unmount();
|
||||
});
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
while (containers.length > 0) containers.pop()?.remove();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
async function flush() {
|
||||
await act(async () => {
|
||||
for (let i = 0; i < 10; i++) await Promise.resolve();
|
||||
});
|
||||
}
|
||||
|
||||
function combo(id: string, name: string) {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: `${name} desc`,
|
||||
pipeline: [{ engine: "rtk", intensity: "standard" }],
|
||||
languagePacks: ["en"],
|
||||
outputMode: false,
|
||||
outputModeIntensity: "full",
|
||||
isDefault: false,
|
||||
};
|
||||
}
|
||||
|
||||
function setupFetchMock() {
|
||||
const json = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } });
|
||||
const combos = [combo("c1", "Alpha"), combo("c2", "Bravo")];
|
||||
vi.spyOn(globalThis, "fetch").mockImplementation(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = input.toString();
|
||||
void init;
|
||||
if (url.includes("/api/context/combos/") && url.includes("/assignments")) return json({ assignments: [] });
|
||||
if (url.includes("/api/context/combos")) return json({ combos });
|
||||
if (url.includes("/api/combos")) return json({ combos: [] });
|
||||
if (url.includes("/api/compression/language-packs")) return json({ packs: [] });
|
||||
if (url.includes("/api/settings/compression")) return json({ activeComboId: "c2", enabled: true });
|
||||
return json({}, 404);
|
||||
});
|
||||
}
|
||||
|
||||
describe("NamedCombosManager — active badge, no set-as-default", () => {
|
||||
async function render() {
|
||||
const { default: CompressionCombosPageClient } = await import(
|
||||
"../../../src/app/(dashboard)/dashboard/context/combos/CompressionCombosPageClient"
|
||||
);
|
||||
let container!: HTMLElement;
|
||||
await act(async () => {
|
||||
container = mount(<CompressionCombosPageClient />);
|
||||
});
|
||||
await flush();
|
||||
return container;
|
||||
}
|
||||
|
||||
it("renders no 'Set as default' button", async () => {
|
||||
setupFetchMock();
|
||||
const container = await render();
|
||||
expect(container.textContent).not.toContain("Set as default");
|
||||
});
|
||||
|
||||
it("shows the '● Active' badge only on the combo whose id === activeComboId", async () => {
|
||||
setupFetchMock();
|
||||
const container = await render();
|
||||
expect(container.querySelector('[data-testid="active-badge-c2"]')).toBeTruthy();
|
||||
expect(container.querySelector('[data-testid="active-badge-c1"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
130
tests/unit/ui/provider-breaker-health-hook.test.tsx
Normal file
130
tests/unit/ui/provider-breaker-health-hook.test.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
// @vitest-environment jsdom
|
||||
import React, { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useProviderBreakerHealth } from "../../../src/hooks/useProviderBreakerHealth";
|
||||
|
||||
type Deferred<T> = {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
reject: (error: unknown) => void;
|
||||
};
|
||||
|
||||
function deferred<T>(): Deferred<T> {
|
||||
let resolve!: (value: T) => void;
|
||||
let reject!: (error: unknown) => void;
|
||||
const promise = new Promise<T>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
function okResponse(body: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => body,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
describe("useProviderBreakerHealth", () => {
|
||||
let root: Root | null = null;
|
||||
let container: HTMLDivElement | null = null;
|
||||
|
||||
beforeEach(() => {
|
||||
(
|
||||
globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
||||
).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
vi.useFakeTimers();
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root?.unmount());
|
||||
}
|
||||
root = null;
|
||||
container?.remove();
|
||||
container = null;
|
||||
document.body.innerHTML = "";
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("skips overlapping health polls while a request is in flight", async () => {
|
||||
const first = deferred<Response>();
|
||||
const second = deferred<Response>();
|
||||
const fetchMock = vi
|
||||
.fn<typeof fetch>()
|
||||
.mockReturnValueOnce(first.promise)
|
||||
.mockReturnValueOnce(second.promise);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
function Probe() {
|
||||
const snapshot = useProviderBreakerHealth(5000);
|
||||
return <output>{Object.keys(snapshot.providerHealth).join(",")}</output>;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root = createRoot(container!);
|
||||
root.render(<Probe />);
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toMatchObject({ cache: "no-store" });
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
await act(async () => {
|
||||
first.resolve(
|
||||
okResponse({
|
||||
providerHealth: { openai: { state: "closed" } },
|
||||
connectionHealth: {},
|
||||
})
|
||||
);
|
||||
await first.promise;
|
||||
});
|
||||
|
||||
expect(container!.textContent).toBe("openai");
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(5000);
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
second.resolve(okResponse({ providerHealth: {}, connectionHealth: {} }));
|
||||
});
|
||||
|
||||
it("aborts an active health poll on unmount", async () => {
|
||||
let signal: AbortSignal | undefined;
|
||||
const fetchMock = vi.fn<typeof fetch>((_input, init) => {
|
||||
signal = init?.signal ?? undefined;
|
||||
return new Promise<Response>(() => {});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
function Probe() {
|
||||
useProviderBreakerHealth(5000);
|
||||
return null;
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
root = createRoot(container!);
|
||||
root.render(<Probe />);
|
||||
});
|
||||
|
||||
expect(signal?.aborted).toBe(false);
|
||||
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
root = null;
|
||||
});
|
||||
|
||||
expect(signal?.aborted).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -60,6 +60,21 @@ describe("parseResetTime", () => {
|
||||
it("returns null for invalid date strings", () => {
|
||||
assert.equal(__testing.parseResetTime("not-a-date"), null);
|
||||
});
|
||||
|
||||
// Inspired-by upstream decolua/9router#768 — provider APIs sometimes return
|
||||
// the reset timestamp as a numeric string. Without explicit detection,
|
||||
// `new Date("1700000000")` returns Invalid Date and the value is lost.
|
||||
it("parses a numeric string in seconds (value < 1e12)", () => {
|
||||
const sec = "1700000000";
|
||||
const out = __testing.parseResetTime(sec);
|
||||
assert.equal(out, new Date(Number(sec) * 1000).toISOString());
|
||||
});
|
||||
|
||||
it("parses a numeric string already in milliseconds (value >= 1e12)", () => {
|
||||
const ms = "1700000000000";
|
||||
const out = __testing.parseResetTime(ms);
|
||||
assert.equal(out, new Date(Number(ms)).toISOString());
|
||||
});
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
@@ -1115,9 +1115,12 @@ test("vscode tokenized /chat/completions route applies the path token and codex
|
||||
);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(body.error?.code, "bad_request");
|
||||
assert.equal(body.error?.message, "No credentials for provider: codex");
|
||||
// Upstream port decolua/9router#336: zero-active-credentials now surfaces as
|
||||
// 404 (combo-fallbackable) instead of 400 (combo hard-stop). The 404 OpenAI
|
||||
// error code mapping is "model_not_found" (open-sse/config/errorConfig.ts:29).
|
||||
assert.equal(response.status, 404);
|
||||
assert.equal(body.error?.code, "model_not_found");
|
||||
assert.equal(body.error?.message, "No active credentials for provider: codex");
|
||||
});
|
||||
|
||||
test("vscode tokenized /responses route applies the path token and codex tier rewrite", async () => {
|
||||
@@ -1142,9 +1145,10 @@ test("vscode tokenized /responses route applies the path token and codex tier re
|
||||
);
|
||||
const body = (await response.json()) as any;
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(body.error?.code, "bad_request");
|
||||
assert.equal(body.error?.message, "No credentials for provider: codex");
|
||||
// Upstream port decolua/9router#336: see chat/completions sibling test above.
|
||||
assert.equal(response.status, 404);
|
||||
assert.equal(body.error?.code, "model_not_found");
|
||||
assert.equal(body.error?.message, "No active credentials for provider: codex");
|
||||
});
|
||||
|
||||
test("vscode tokenized api/show route preserves the selected reasoning effort for codex variants", async () => {
|
||||
|
||||
151
tests/unit/web-search-tool-routing-4481.test.ts
Normal file
151
tests/unit/web-search-tool-routing-4481.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #4481 layer 2 — CCR-style `Router.webSearch`. When a request carries a NATIVE
|
||||
// web-search server tool (`web_search`, `web_search_preview`, or Anthropic's versioned
|
||||
// `web_search_20250305`) and an operator configured `webSearchRouteModel`, route the
|
||||
// whole request to that model instead of the default — so a provider that doesn't
|
||||
// implement the server tool (e.g. MiniMax) isn't asked to run a tool it 400s on.
|
||||
// Pure helpers, no DB.
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
import {
|
||||
hasNativeWebSearchTool,
|
||||
resolveWebSearchRouteOverride,
|
||||
} from "../../open-sse/services/webSearchRouting.ts";
|
||||
|
||||
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
|
||||
|
||||
// ── Wiring source-guard (RED on base: chat.ts doesn't call the router yet) ─
|
||||
|
||||
test("chat.ts wires the web-search router at the request entrypoint", () => {
|
||||
const chat = readFileSync(join(REPO_ROOT, "src/sse/handlers/chat.ts"), "utf8");
|
||||
assert.match(chat, /from "@omniroute\/open-sse\/services\/webSearchRouting\.ts"/);
|
||||
assert.match(chat, /hasNativeWebSearchTool\(body\)/);
|
||||
assert.match(chat, /resolveWebSearchRouteOverride\(/);
|
||||
});
|
||||
|
||||
test("webSearchRouteModel is registered in the settings Zod schema", () => {
|
||||
const schema = readFileSync(join(REPO_ROOT, "src/shared/validation/settingsSchemas.ts"), "utf8");
|
||||
assert.match(schema, /webSearchRouteModel:\s*z\.string\(\)/);
|
||||
});
|
||||
|
||||
test("the Routing settings tab exposes a webSearchRouteModel field", () => {
|
||||
const tab = readFileSync(
|
||||
join(REPO_ROOT, "src/app/(dashboard)/dashboard/settings/components/RoutingTab.tsx"),
|
||||
"utf8"
|
||||
);
|
||||
assert.match(tab, /settings\.webSearchRouteModel/);
|
||||
assert.match(tab, /updateSetting\(\{ webSearchRouteModel:/);
|
||||
assert.match(tab, /webSearchRouteTitle/);
|
||||
});
|
||||
|
||||
test("en.json defines the web-search-routing UI strings", () => {
|
||||
const en = JSON.parse(readFileSync(join(REPO_ROOT, "src/i18n/messages/en.json"), "utf8"));
|
||||
const s = en.settings || {};
|
||||
assert.equal(typeof s.webSearchRouteTitle, "string");
|
||||
assert.equal(typeof s.webSearchRouteDesc, "string");
|
||||
assert.equal(typeof s.webSearchRoutePlaceholder, "string");
|
||||
});
|
||||
|
||||
// ── hasNativeWebSearchTool ───────────────────────────────────────────────
|
||||
|
||||
test("detects the plain and preview native web-search tool types", () => {
|
||||
assert.equal(hasNativeWebSearchTool({ tools: [{ type: "web_search" }] }), true);
|
||||
assert.equal(hasNativeWebSearchTool({ tools: [{ type: "web_search_preview" }] }), true);
|
||||
});
|
||||
|
||||
test("detects Anthropic's versioned web_search_20250305 (and future dated names)", () => {
|
||||
assert.equal(
|
||||
hasNativeWebSearchTool({ tools: [{ type: "web_search_20250305", name: "web_search" }] }),
|
||||
true
|
||||
);
|
||||
assert.equal(hasNativeWebSearchTool({ tools: [{ type: "web_search_20251201" }] }), true);
|
||||
});
|
||||
|
||||
test("detects a native web-search tool anywhere in a mixed tools array", () => {
|
||||
assert.equal(
|
||||
hasNativeWebSearchTool({
|
||||
tools: [{ type: "function", function: { name: "x" } }, { type: "web_search_20250305" }],
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("ignores a custom FUNCTION tool merely named web_search (has a function field)", () => {
|
||||
assert.equal(
|
||||
hasNativeWebSearchTool({
|
||||
tools: [{ type: "function", function: { name: "web_search", parameters: {} } }],
|
||||
}),
|
||||
false
|
||||
);
|
||||
// A bare web_search type WITH a function field is also not the native server tool.
|
||||
assert.equal(
|
||||
hasNativeWebSearchTool({ tools: [{ type: "web_search", function: { name: "x" } }] }),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("returns false for no tools / non-web-search tools / malformed input", () => {
|
||||
assert.equal(hasNativeWebSearchTool({ tools: [] }), false);
|
||||
assert.equal(hasNativeWebSearchTool({ tools: [{ type: "code_interpreter" }] }), false);
|
||||
assert.equal(hasNativeWebSearchTool({}), false);
|
||||
assert.equal(hasNativeWebSearchTool({ tools: "nope" }), false);
|
||||
assert.equal(hasNativeWebSearchTool(null), false);
|
||||
assert.equal(hasNativeWebSearchTool(undefined), false);
|
||||
});
|
||||
|
||||
// ── resolveWebSearchRouteOverride ────────────────────────────────────────
|
||||
|
||||
const bodyWithSearch = { tools: [{ type: "web_search_20250305", name: "web_search" }] };
|
||||
|
||||
test("routes to the configured model when a native web-search tool is present", () => {
|
||||
const r = resolveWebSearchRouteOverride("minimax,MiniMax-M3", bodyWithSearch, {
|
||||
webSearchRouteModel: "openrouter,anthropic/claude-3.5-sonnet",
|
||||
});
|
||||
assert.deepEqual(r, { wasRouted: true, model: "openrouter,anthropic/claude-3.5-sonnet" });
|
||||
});
|
||||
|
||||
test("does NOT route when the request has no native web-search tool", () => {
|
||||
const r = resolveWebSearchRouteOverride("minimax,MiniMax-M3", { tools: [{ type: "function" }] }, {
|
||||
webSearchRouteModel: "openrouter,anthropic/claude-3.5-sonnet",
|
||||
});
|
||||
assert.deepEqual(r, { wasRouted: false, model: "minimax,MiniMax-M3" });
|
||||
});
|
||||
|
||||
test("does NOT route when no route model is configured (or it is blank)", () => {
|
||||
assert.deepEqual(resolveWebSearchRouteOverride("minimax,MiniMax-M3", bodyWithSearch, {}), {
|
||||
wasRouted: false,
|
||||
model: "minimax,MiniMax-M3",
|
||||
});
|
||||
assert.deepEqual(
|
||||
resolveWebSearchRouteOverride("minimax,MiniMax-M3", bodyWithSearch, {
|
||||
webSearchRouteModel: " ",
|
||||
}),
|
||||
{ wasRouted: false, model: "minimax,MiniMax-M3" }
|
||||
);
|
||||
});
|
||||
|
||||
test("does NOT route (no-op) when the configured model equals the current model", () => {
|
||||
const r = resolveWebSearchRouteOverride("openrouter,claude", bodyWithSearch, {
|
||||
webSearchRouteModel: " openrouter,claude ",
|
||||
});
|
||||
assert.deepEqual(r, { wasRouted: false, model: "openrouter,claude" });
|
||||
});
|
||||
|
||||
test("trims the configured route model", () => {
|
||||
const r = resolveWebSearchRouteOverride("minimax,M3", bodyWithSearch, {
|
||||
webSearchRouteModel: " anthropic/claude-opus-4-8 ",
|
||||
});
|
||||
assert.deepEqual(r, { wasRouted: true, model: "anthropic/claude-opus-4-8" });
|
||||
});
|
||||
|
||||
test("ignores a non-string route config", () => {
|
||||
const r = resolveWebSearchRouteOverride("minimax,M3", bodyWithSearch, {
|
||||
webSearchRouteModel: 123 as unknown as string,
|
||||
});
|
||||
assert.deepEqual(r, { wasRouted: false, model: "minimax,M3" });
|
||||
});
|
||||
@@ -36,6 +36,41 @@ test("buildTelegramPayload — request.failed includes model and event label", (
|
||||
assert.equal(payload.parse_mode, "Markdown");
|
||||
});
|
||||
|
||||
test("buildTelegramPayload — request.completed includes provider, account, combo, and metrics", () => {
|
||||
const payload = buildTelegramPayload(
|
||||
"request.completed",
|
||||
{
|
||||
model: "codex/gpt-5.5",
|
||||
provider: "codex",
|
||||
account: "Workspace Principal",
|
||||
combo: "auto-fallback",
|
||||
latencyMs: 1421,
|
||||
fallbackCount: 2,
|
||||
},
|
||||
"-100123"
|
||||
);
|
||||
|
||||
assert.ok(payload.text.includes("Model: `codex/gpt-5.5`"));
|
||||
assert.ok(payload.text.includes("Provider: `codex`"));
|
||||
assert.ok(payload.text.includes("Account: `Workspace Principal`"));
|
||||
assert.ok(payload.text.includes("Combo: `auto-fallback`"));
|
||||
assert.ok(payload.text.includes("Latency: `1421ms`"));
|
||||
assert.ok(payload.text.includes("Fallbacks: `2`"));
|
||||
});
|
||||
|
||||
test("buildTelegramPayload — accountId falls back to short account label", () => {
|
||||
const payload = buildTelegramPayload(
|
||||
"request.completed",
|
||||
{
|
||||
provider: "codex",
|
||||
accountId: "12345678-abcd-efgh-ijkl-1234567890ab",
|
||||
},
|
||||
"-100123"
|
||||
);
|
||||
|
||||
assert.ok(payload.text.includes("Account: `Account #123456`"));
|
||||
});
|
||||
|
||||
test("buildTelegramPayload — chat_id matches provided value for groups", () => {
|
||||
const payload = buildTelegramPayload("test.ping", { message: "ping" }, "-1001234567890");
|
||||
assert.equal(payload.chat_id, "-1001234567890");
|
||||
|
||||
Reference in New Issue
Block a user