mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Release v3.8.26 (#3875)
OmniRoute v3.8.26 — see CHANGELOG.md [3.8.26] for the full notes. Highlights: Vertex AI media generation (#3929), GLM-5.2 effort-tier routing (#3885), sticky round-robin combos (#3846), OpenRouter connection presets (#3878), compression prompt-cache fix (#3936/#3890), and a security pass (form-data/vite + workflow hardening, #3949). Co-authored-by: artickc <artickc@users.noreply.github.com> Co-authored-by: rdself <rdself@users.noreply.github.com> Co-authored-by: herjarsa <herjarsa@users.noreply.github.com> Co-authored-by: Jack Smith <16862258+YunyunZhai@users.noreply.github.com> Co-authored-by: dhaern <dhaern@users.noreply.github.com> Co-authored-by: adivekar-utexas <adivekar-utexas@users.noreply.github.com> Co-authored-by: megamen32 <megamen32@users.noreply.github.com> Co-authored-by: zhiru <zhiru@users.noreply.github.com> Co-authored-by: insoln <insoln@users.noreply.github.com> Co-authored-by: diego-anselmo <diego-anselmo@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
1f87a9589c
commit
81a37b67ed
@@ -4,9 +4,7 @@ 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-stream-lifecycle-")
|
||||
);
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-active-stream-lifecycle-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
@@ -123,14 +121,11 @@ function mergeDetailData(
|
||||
data: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
const dataHasPipeline =
|
||||
data?.pipelinePayloads &&
|
||||
Object.keys(data.pipelinePayloads || {}).length > 0;
|
||||
data?.pipelinePayloads && Object.keys(data.pipelinePayloads || {}).length > 0;
|
||||
return {
|
||||
...prev,
|
||||
...data,
|
||||
pipelinePayloads: dataHasPipeline
|
||||
? data.pipelinePayloads
|
||||
: prev?.pipelinePayloads,
|
||||
pipelinePayloads: dataHasPipeline ? data.pipelinePayloads : prev?.pipelinePayloads,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -144,17 +139,11 @@ test("streamChunks survive the full lifecycle: in-flight → completed → persi
|
||||
const connectionId = "conn-lifecycle-1";
|
||||
|
||||
// ── Phase 1: Track a pending request ──
|
||||
const requestId = usageHistory.trackPendingRequest(
|
||||
model,
|
||||
provider,
|
||||
connectionId,
|
||||
true,
|
||||
{
|
||||
clientRequest: { messages: [{ role: "user", content: "hello" }] },
|
||||
providerRequest: { model: "gpt-4" },
|
||||
clientEndpoint: "/v1/chat/completions",
|
||||
}
|
||||
);
|
||||
const requestId = usageHistory.trackPendingRequest(model, provider, connectionId, true, {
|
||||
clientRequest: { messages: [{ role: "user", content: "hello" }] },
|
||||
providerRequest: { model: "gpt-4" },
|
||||
clientEndpoint: "/v1/chat/completions",
|
||||
});
|
||||
|
||||
assert.ok(requestId, "trackPendingRequest should return a request ID");
|
||||
assert.ok(
|
||||
@@ -185,10 +174,7 @@ test("streamChunks survive the full lifecycle: in-flight → completed → persi
|
||||
apiResponse1!.pipelinePayloads as Record<string, unknown>
|
||||
);
|
||||
assert.ok(text1, "streamChunksText should be non-null with debugEnabled");
|
||||
assert.ok(
|
||||
text1!.includes("message_start"),
|
||||
"streamChunksText should contain the chunk content"
|
||||
);
|
||||
assert.ok(text1!.includes("message_start"), "streamChunksText should contain the chunk content");
|
||||
|
||||
// Verify frontend state merge
|
||||
const initialDetail = mergeDetailData(null, apiResponse1 as unknown as Record<string, unknown>);
|
||||
@@ -308,10 +294,7 @@ test("streamChunks survive the full lifecycle: in-flight → completed → persi
|
||||
// Verify DB has the entry
|
||||
const dbEntry = await callLogs.getCallLogById(requestId);
|
||||
assert.ok(dbEntry, "should find persisted call log by the same ID");
|
||||
assert.ok(
|
||||
dbEntry.pipelinePayloads,
|
||||
"persisted entry should have pipelinePayloads"
|
||||
);
|
||||
assert.ok(dbEntry.pipelinePayloads, "persisted entry should have pipelinePayloads");
|
||||
|
||||
// The pipelinePayloads in the DB may have been compacted/truncated.
|
||||
// streamChunks may or may not be there depending on captureStreamChunks,
|
||||
@@ -390,11 +373,7 @@ test("pooling effect state merge preserves streamChunks across updates", () => {
|
||||
const mergedChunks = (detailData!.pipelinePayloads as Record<string, unknown>)
|
||||
.streamChunks as Record<string, string[]>;
|
||||
assert.equal(mergedChunks.provider.length, 2, "merged should have 2 chunks");
|
||||
assert.equal(
|
||||
mergedChunks.provider[1],
|
||||
'data: {"b":2}\n\n',
|
||||
"merged should include new chunk"
|
||||
);
|
||||
assert.equal(mergedChunks.provider[1], 'data: {"b":2}\n\n', "merged should include new chunk");
|
||||
|
||||
// Simulate: polling response loses pipelinePayloads (null)
|
||||
// This should NOT overwrite the existing streamChunks
|
||||
@@ -476,15 +455,15 @@ test("pendingById references are live: push mutates the shared arrays visible to
|
||||
|
||||
// Verify via simulated API response
|
||||
const apiResp = buildApiResponseFromPending(requestId);
|
||||
const apiChunks = (apiResp!.pipelinePayloads as Record<string, unknown>)
|
||||
.streamChunks as Record<string, string[]>;
|
||||
const apiChunks = (apiResp!.pipelinePayloads as Record<string, unknown>).streamChunks as Record<
|
||||
string,
|
||||
string[]
|
||||
>;
|
||||
assert.equal(apiChunks.provider.length, 1, "API should see the live mutation");
|
||||
});
|
||||
|
||||
test("no connectionId in request logger does not break anything", async () => {
|
||||
const { createRequestLogger } = await import(
|
||||
"../../open-sse/utils/requestLogger.ts"
|
||||
);
|
||||
const { createRequestLogger } = await import("../../open-sse/utils/requestLogger.ts");
|
||||
|
||||
usageHistory.clearPendingRequests();
|
||||
usageHistory.trackPendingRequest("gpt-4", "openai", "conn-noop-1", true);
|
||||
@@ -527,9 +506,7 @@ test("createRequestLogger and trackPendingRequest with matching model propagate
|
||||
// The fix: chatCore.ts now passes `model` (not `effectiveModel`) to
|
||||
// createRequestLogger, so the modelKey matches what trackPendingRequest uses.
|
||||
|
||||
const { createRequestLogger } = await import(
|
||||
"../../open-sse/utils/requestLogger.ts"
|
||||
);
|
||||
const { createRequestLogger } = await import("../../open-sse/utils/requestLogger.ts");
|
||||
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
@@ -537,10 +514,9 @@ test("createRequestLogger and trackPendingRequest with matching model propagate
|
||||
const provider = "openai";
|
||||
const connectionId = "conn-match-1";
|
||||
|
||||
const requestId = usageHistory.trackPendingRequest(
|
||||
model, provider, connectionId, true,
|
||||
{ clientRequest: { messages: [] } }
|
||||
);
|
||||
const requestId = usageHistory.trackPendingRequest(model, provider, connectionId, true, {
|
||||
clientRequest: { messages: [] },
|
||||
});
|
||||
|
||||
// Use matching model (the fix)
|
||||
const logger = await createRequestLogger("openai", "openai", model, {
|
||||
@@ -564,11 +540,72 @@ test("createRequestLogger and trackPendingRequest with matching model propagate
|
||||
|
||||
const apiResp = buildApiResponseFromPending(requestId!);
|
||||
assert.ok(apiResp);
|
||||
const apiChunks = (apiResp!.pipelinePayloads as Record<string, unknown>).streamChunks as Record<string, string[]>;
|
||||
const apiChunks = (apiResp!.pipelinePayloads as Record<string, unknown>).streamChunks as Record<
|
||||
string,
|
||||
string[]
|
||||
>;
|
||||
assert.equal(apiChunks?.provider[0], 'data: {"chunk":"hello"}');
|
||||
});
|
||||
|
||||
test("streamChunks in completedDetails survives 5-second TTL window", async () => {
|
||||
test("finalizePendingRequestById completes the exact stream when same model requests overlap", () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
const model = "gpt-4";
|
||||
const provider = "openai";
|
||||
const connectionId = "conn-overlap-1";
|
||||
|
||||
const firstId = usageHistory.trackPendingRequest(model, provider, connectionId, true, {
|
||||
clientRequest: { messages: [{ role: "user", content: "first" }] },
|
||||
});
|
||||
const secondId = usageHistory.trackPendingRequest(model, provider, connectionId, true, {
|
||||
clientRequest: { messages: [{ role: "user", content: "second" }] },
|
||||
});
|
||||
|
||||
const completed = usageHistory.finalizePendingRequestById(firstId, {
|
||||
providerResponse: { id: "first-response" },
|
||||
clientResponse: { id: "first-response" },
|
||||
});
|
||||
|
||||
assert.equal(completed, true);
|
||||
assert.ok(usageHistory.getCompletedDetails().has(firstId));
|
||||
assert.equal(usageHistory.getCompletedDetails().has(secondId), false);
|
||||
assert.equal(usageHistory.getPendingById().has(firstId), false);
|
||||
assert.equal(usageHistory.getPendingById().has(secondId), true);
|
||||
|
||||
const pending = usageHistory.getPendingRequests();
|
||||
const details = pending.details[connectionId]?.[`${model} (${provider})`] ?? [];
|
||||
assert.equal(details.length, 1);
|
||||
assert.equal(details[0].id, secondId);
|
||||
assert.equal(pending.byModel[`${model} (${provider})`], 1);
|
||||
assert.equal(pending.byAccount[connectionId]?.[`${model} (${provider})`], 1);
|
||||
});
|
||||
|
||||
test("completedDetails cache evicts oldest entries when bounded", () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
const model = "gpt-4";
|
||||
const provider = "openai";
|
||||
const connectionId = "conn-completed-bound";
|
||||
const ids: string[] = [];
|
||||
|
||||
for (let i = 0; i < 260; i++) {
|
||||
const id = usageHistory.trackPendingRequest(model, provider, connectionId, true);
|
||||
ids.push(id!);
|
||||
const completed = usageHistory.finalizePendingRequestById(id, {
|
||||
clientResponse: { choices: [{ message: { content: `done ${i}` } }] },
|
||||
});
|
||||
assert.equal(completed, true);
|
||||
}
|
||||
|
||||
assert.ok(
|
||||
usageHistory.getCompletedDetails().size <= 256,
|
||||
"completedDetails should remain bounded"
|
||||
);
|
||||
assert.equal(usageHistory.getCompletedDetails().has(ids[0]), false);
|
||||
assert.equal(usageHistory.getCompletedDetails().has(ids[ids.length - 1]), true);
|
||||
});
|
||||
|
||||
test("streamChunks in completedDetails survives beyond the logs polling window", async () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
const model = "gpt-4";
|
||||
@@ -605,10 +642,9 @@ test("streamChunks in completedDetails survives 5-second TTL window", async () =
|
||||
"streamChunks should be in completedDetails"
|
||||
);
|
||||
|
||||
// The completedDetails TTL is 5000ms. We verify by saving to DB first, then
|
||||
// waiting for the TTL to expire, then verifying the data is gone from
|
||||
// completedDetails but still accessible from the DB (via getCallLogById).
|
||||
// Save to DB
|
||||
// Save to DB as the normal durable path, but keep the completedDetails cache
|
||||
// long enough that a slow Logs-page poll does not see the row disappear
|
||||
// between pending removal and DB/detail refresh.
|
||||
await callLogs.saveCallLog({
|
||||
id: requestId,
|
||||
method: "POST",
|
||||
@@ -640,18 +676,17 @@ test("streamChunks in completedDetails survives 5-second TTL window", async () =
|
||||
const dbEntry1 = await callLogs.getCallLogById(requestId);
|
||||
assert.ok(dbEntry1, "DB should have the entry after saveCallLog");
|
||||
|
||||
// Wait for TTL to expire
|
||||
// This used to expire after 5 seconds. Keep it visible beyond that window so
|
||||
// a slow client poll can still resolve the completed row/details.
|
||||
await new Promise((r) => setTimeout(r, 5100));
|
||||
|
||||
// completedDetails should be cleared
|
||||
assert.ok(
|
||||
!usageHistory.getCompletedDetails().has(requestId),
|
||||
"completedDetails should have been cleared after TTL"
|
||||
usageHistory.getCompletedDetails().has(requestId),
|
||||
"completedDetails should still be available after a 5-second polling gap"
|
||||
);
|
||||
|
||||
// But DB should still have it
|
||||
const dbEntry2 = await callLogs.getCallLogById(requestId);
|
||||
assert.ok(dbEntry2, "DB should still have the entry after TTL expiry");
|
||||
assert.ok(dbEntry2, "DB should still have the entry after the polling gap");
|
||||
assert.equal(
|
||||
(dbEntry2 as Record<string, unknown>).id,
|
||||
requestId,
|
||||
|
||||
@@ -8,22 +8,30 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
// @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
|
||||
import { parseCodeQLAlerts } from "../../../scripts/check/check-codeql-ratchet.mjs";
|
||||
import {
|
||||
parseCodeQLAlerts,
|
||||
evaluateCodeqlRatchet,
|
||||
} from "../../../scripts/check/check-codeql-ratchet.mjs";
|
||||
|
||||
type RatchetVerdict = { regressed: boolean; improved: boolean };
|
||||
const evaluate = evaluateCodeqlRatchet as (current: number, baseline: number) => RatchetVerdict;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures — synthetic GitHub code-scanning/alerts API responses
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Helper to build a minimal alert object. */
|
||||
function makeAlert(overrides: {
|
||||
number?: number;
|
||||
state?: "open" | "dismissed" | "fixed";
|
||||
tool?: string;
|
||||
ruleId?: string;
|
||||
severity?: string;
|
||||
securitySeverity?: string;
|
||||
dismissedReason?: string | null;
|
||||
} = {}) {
|
||||
function makeAlert(
|
||||
overrides: {
|
||||
number?: number;
|
||||
state?: "open" | "dismissed" | "fixed";
|
||||
tool?: string;
|
||||
ruleId?: string;
|
||||
severity?: string;
|
||||
securitySeverity?: string;
|
||||
dismissedReason?: string | null;
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
number: overrides.number ?? 1,
|
||||
state: overrides.state ?? "open",
|
||||
@@ -92,17 +100,13 @@ test("parseCodeQLAlerts: array vazio retorna alertCount=0", () => {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseCodeQLAlerts: alerta dismissed NÃO conta (Hard Rule #14)", () => {
|
||||
const alerts = [
|
||||
makeAlert({ state: "dismissed", dismissedReason: "false positive" }),
|
||||
];
|
||||
const alerts = [makeAlert({ state: "dismissed", dismissedReason: "false positive" })];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 0, "dismissed alert must not be counted");
|
||||
});
|
||||
|
||||
test("parseCodeQLAlerts: alerta dismissed com razão 'used in tests' NÃO conta", () => {
|
||||
const alerts = [
|
||||
makeAlert({ state: "dismissed", dismissedReason: "used in tests" }),
|
||||
];
|
||||
const alerts = [makeAlert({ state: "dismissed", dismissedReason: "used in tests" })];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 0);
|
||||
});
|
||||
@@ -122,9 +126,7 @@ test("parseCodeQLAlerts: alerta dismissed independente da razão NÃO conta", ()
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseCodeQLAlerts: alerta fixed NÃO conta", () => {
|
||||
const alerts = [
|
||||
makeAlert({ state: "fixed" as "open" | "dismissed" | "fixed" }),
|
||||
];
|
||||
const alerts = [makeAlert({ state: "fixed" as "open" | "dismissed" | "fixed" })];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 0, "fixed alerts are not open, must not count");
|
||||
});
|
||||
@@ -276,9 +278,50 @@ test("parseCodeQLAlerts: alerta sem rule retorna byRule com 'unknown'", () => {
|
||||
test("parseCodeQLAlerts: dismissed com mesmo ruleId que open — dismissed não aparece em byRule", () => {
|
||||
const alerts = [
|
||||
makeAlert({ number: 1, state: "open", ruleId: "js/sql-injection" }),
|
||||
makeAlert({ number: 2, state: "dismissed", ruleId: "js/sql-injection", dismissedReason: "false positive" }),
|
||||
makeAlert({
|
||||
number: 2,
|
||||
state: "dismissed",
|
||||
ruleId: "js/sql-injection",
|
||||
dismissedReason: "false positive",
|
||||
}),
|
||||
];
|
||||
const result = parseCodeQLAlerts(alerts);
|
||||
assert.equal(result.alertCount, 1);
|
||||
assert.equal(result.byRule["js/sql-injection"], 1, "only the open alert should appear in byRule");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// evaluateCodeqlRatchet — ratchet direction:down (Task 7.3 promote to blocking)
|
||||
// Mirror of evaluateDeadCode: regression when measured > baseline; the baseline
|
||||
// is 0 (clean), so ANY open CodeQL alert is a regression that blocks.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("evaluateCodeqlRatchet: equal to baseline passes (0 vs 0 — clean)", () => {
|
||||
const r = evaluate(0, 0);
|
||||
assert.equal(r.regressed, false);
|
||||
assert.equal(r.improved, false);
|
||||
});
|
||||
|
||||
test("evaluateCodeqlRatchet: one more alert than baseline 0 is a regression", () => {
|
||||
const r = evaluate(1, 0);
|
||||
assert.equal(r.regressed, true, "a single new open CodeQL alert must block");
|
||||
assert.equal(r.improved, false);
|
||||
});
|
||||
|
||||
test("evaluateCodeqlRatchet: fewer alerts than a non-zero baseline is an improvement", () => {
|
||||
const r = evaluate(2, 5);
|
||||
assert.equal(r.regressed, false);
|
||||
assert.equal(r.improved, true);
|
||||
});
|
||||
|
||||
test("evaluateCodeqlRatchet: zero alerts against a non-zero baseline is a maximum improvement", () => {
|
||||
const r = evaluate(0, 5);
|
||||
assert.equal(r.regressed, false);
|
||||
assert.equal(r.improved, true);
|
||||
});
|
||||
|
||||
test("evaluateCodeqlRatchet: strict integer comparison — any increase regresses", () => {
|
||||
assert.equal(evaluate(6, 5).regressed, true);
|
||||
assert.equal(evaluate(5, 5).regressed, false);
|
||||
assert.equal(evaluate(4, 5).regressed, false);
|
||||
});
|
||||
|
||||
174
tests/unit/build/check-openapi-breaking.test.ts
Normal file
174
tests/unit/build/check-openapi-breaking.test.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
// tests/unit/build/check-openapi-breaking.test.ts
|
||||
// TDD unit tests for scripts/check/check-openapi-breaking.mjs — Fase 8 B.4 oasdiff.
|
||||
//
|
||||
// Strategy:
|
||||
// • parseOasdiffBreaking() — pure parser, tested with synthetic oasdiff JSON
|
||||
// (the REAL shape emitted by `oasdiff breaking --format json`, verified at 1.19.1).
|
||||
// • binary-absent SKIP — spawn the gate with a PATH stripped of oasdiff and assert
|
||||
// it prints `openapiBreaking=SKIP reason=binary-absent` and exits 0 (advisory).
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
// @ts-expect-error — .mjs helper has no type declarations; runtime shape is known.
|
||||
import { parseOasdiffBreaking } from "../../../scripts/check/check-openapi-breaking.mjs";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, "../../..");
|
||||
const GATE = path.join(REPO_ROOT, "scripts/check/check-openapi-breaking.mjs");
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures — REAL shape of `oasdiff breaking --format json` (oasdiff 1.19.1).
|
||||
// e.g. removing a path emits:
|
||||
// {"id":"api-path-removed-without-deprecation","text":"api path removed...",
|
||||
// "level":3,"operation":"GET","path":"/api/bar","section":"paths",...}
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function makeBreaking(
|
||||
overrides: {
|
||||
id?: string;
|
||||
text?: string;
|
||||
level?: number;
|
||||
operation?: string;
|
||||
path?: string;
|
||||
} = {}
|
||||
) {
|
||||
return {
|
||||
id: overrides.id ?? "api-path-removed-without-deprecation",
|
||||
text: overrides.text ?? "api path removed without deprecation",
|
||||
level: overrides.level ?? 3,
|
||||
operation: overrides.operation ?? "GET",
|
||||
path: overrides.path ?? "/api/foo",
|
||||
section: "paths",
|
||||
baseSource: { file: "/tmp/base.yaml", line: 12, column: 5 },
|
||||
fingerprint: "33e8aeb41d4a",
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOasdiffBreaking — empty / invalid input
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseOasdiffBreaking: null retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking(null);
|
||||
assert.equal(r.count, 0);
|
||||
assert.deepEqual(r.byId, {});
|
||||
assert.deepEqual(r.byPath, {});
|
||||
assert.deepEqual(r.items, []);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: undefined retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking(undefined as unknown as null);
|
||||
assert.equal(r.count, 0);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: array vazio (sem breaking change) retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking([]);
|
||||
assert.equal(r.count, 0);
|
||||
assert.deepEqual(r.byId, {});
|
||||
assert.deepEqual(r.byPath, {});
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: objeto (não-array) retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking({ id: "x" } as unknown as null);
|
||||
assert.equal(r.count, 0);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: string retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking("breaking" as unknown as null);
|
||||
assert.equal(r.count, 0);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: número retorna count=0", () => {
|
||||
const r = parseOasdiffBreaking(42 as unknown as null);
|
||||
assert.equal(r.count, 0);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOasdiffBreaking — counting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseOasdiffBreaking: 1 breaking change retorna count=1", () => {
|
||||
const r = parseOasdiffBreaking([makeBreaking()]);
|
||||
assert.equal(r.count, 1);
|
||||
assert.equal(r.items.length, 1);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: 3 breaking changes retorna count=3", () => {
|
||||
const r = parseOasdiffBreaking([
|
||||
makeBreaking({ id: "api-path-removed-without-deprecation", path: "/api/a" }),
|
||||
makeBreaking({ id: "request-parameter-became-required", path: "/api/b" }),
|
||||
makeBreaking({ id: "response-property-removed", path: "/api/c" }),
|
||||
]);
|
||||
assert.equal(r.count, 3);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: ignora entradas null/não-objeto no array", () => {
|
||||
const r = parseOasdiffBreaking([makeBreaking(), null, 5, makeBreaking({ path: "/api/x" })]);
|
||||
assert.equal(r.count, 2);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// parseOasdiffBreaking — grouping
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("parseOasdiffBreaking: agrupa por id em byId", () => {
|
||||
const r = parseOasdiffBreaking([
|
||||
makeBreaking({ id: "api-path-removed-without-deprecation", path: "/api/a" }),
|
||||
makeBreaking({ id: "response-property-removed", path: "/api/b" }),
|
||||
makeBreaking({ id: "api-path-removed-without-deprecation", path: "/api/c" }),
|
||||
]);
|
||||
assert.equal(r.byId["api-path-removed-without-deprecation"], 2);
|
||||
assert.equal(r.byId["response-property-removed"], 1);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: agrupa por path em byPath", () => {
|
||||
const r = parseOasdiffBreaking([
|
||||
makeBreaking({ path: "/api/chat", operation: "POST" }),
|
||||
makeBreaking({ path: "/api/chat", operation: "GET" }),
|
||||
makeBreaking({ path: "/api/models" }),
|
||||
]);
|
||||
assert.equal(r.byPath["/api/chat"], 2);
|
||||
assert.equal(r.byPath["/api/models"], 1);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: id ausente usa 'unknown'", () => {
|
||||
const r = parseOasdiffBreaking([{ path: "/api/x", text: "weird" }]);
|
||||
assert.equal(r.count, 1);
|
||||
assert.equal(r.byId["unknown"], 1);
|
||||
});
|
||||
|
||||
test("parseOasdiffBreaking: path ausente usa 'unknown'", () => {
|
||||
const r = parseOasdiffBreaking([{ id: "some-rule", text: "weird" }]);
|
||||
assert.equal(r.count, 1);
|
||||
assert.equal(r.byPath["unknown"], 1);
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration — binary-absent SKIP (advisory, exit 0)
|
||||
//
|
||||
// Run the gate with a PATH that does NOT contain oasdiff. `findOasdiff()` then
|
||||
// returns null and the gate must SKIP gracefully with exit 0. We point HOME to a
|
||||
// temp dir too so a user-local ~/.local/bin/oasdiff cannot leak into the lookup.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
test("gate: SKIP graceful (exit 0) quando oasdiff ausente do PATH", () => {
|
||||
const res = spawnSync(process.execPath, [GATE, "--quiet"], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
env: {
|
||||
// Minimal PATH with only the dir holding the node binary — no oasdiff.
|
||||
PATH: path.dirname(process.execPath),
|
||||
HOME: "/nonexistent-home-for-oasdiff-test",
|
||||
BASE_REF: "origin/release/v3.8.26",
|
||||
},
|
||||
});
|
||||
assert.equal(res.status, 0, `expected exit 0, got ${res.status}; stderr: ${res.stderr}`);
|
||||
assert.match(
|
||||
res.stdout,
|
||||
/openapiBreaking=SKIP reason=binary-absent/,
|
||||
`expected binary-absent SKIP, got stdout: ${res.stdout}`
|
||||
);
|
||||
});
|
||||
@@ -30,6 +30,11 @@ test("checkTrackedArtifacts: quality-metrics.json is flagged", () => {
|
||||
assert.equal(result.length, 1);
|
||||
});
|
||||
|
||||
test("checkTrackedArtifacts: config/quality/quality-metrics.json is flagged", () => {
|
||||
const result = checkTrackedArtifacts(["config/quality/quality-metrics.json"]);
|
||||
assert.equal(result.length, 1);
|
||||
});
|
||||
|
||||
test("checkTrackedArtifacts: symlink mode (120000) is flagged", () => {
|
||||
const result = checkTrackedArtifacts([], ["node_modules"]);
|
||||
assert.equal(result.length, 1);
|
||||
|
||||
65
tests/unit/build/opencode-plugin-esm-only.test.ts
Normal file
65
tests/unit/build/opencode-plugin-esm-only.test.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// Regression guard for #3883: the @omniroute/opencode-plugin must ship an
|
||||
// ESM-only bundle. OpenCode's Bun-based plugin loader resolves the package
|
||||
// `main`/`exports` and applies CJS-to-ESM interop on a dual CJS bundle, which
|
||||
// turns `mod.default` into the whole exports namespace, fails V1 plugin
|
||||
// detection, and makes the loader throw "Plugin export is not a function".
|
||||
// Shipping ESM-only (with a `./runtime` subpath) keeps `mod.default` the V1
|
||||
// plugin object so the loader registers it. Do NOT re-introduce a CJS bundle.
|
||||
|
||||
const PKG_URL = new URL("../../../@omniroute/opencode-plugin/package.json", import.meta.url);
|
||||
const TSUP_URL = new URL("../../../@omniroute/opencode-plugin/tsup.config.ts", import.meta.url);
|
||||
|
||||
function readJson(url: URL): Record<string, unknown> {
|
||||
return JSON.parse(readFileSync(fileURLToPath(url), "utf8"));
|
||||
}
|
||||
|
||||
test("opencode-plugin package.json ships ESM-only (no CJS bundle)", () => {
|
||||
const pkg = readJson(PKG_URL);
|
||||
|
||||
// main must point at the ESM build, never the .cjs bundle.
|
||||
assert.equal(pkg.main, "./dist/index.js", "main must be the ESM build");
|
||||
assert.equal(
|
||||
typeof pkg.main === "string" && pkg.main.endsWith(".cjs"),
|
||||
false,
|
||||
"main must not be a .cjs bundle"
|
||||
);
|
||||
// The legacy dual-bundle `module` field should be gone.
|
||||
assert.equal("module" in pkg, false, "the dual-bundle `module` field must be removed");
|
||||
|
||||
const exports = pkg.exports as Record<string, unknown> | undefined;
|
||||
assert.ok(exports && typeof exports === "object", "exports map must exist");
|
||||
|
||||
const root = exports["."] as Record<string, unknown>;
|
||||
assert.ok(root && typeof root === "object", 'exports["."] must exist');
|
||||
assert.equal(root.import, "./dist/index.js", 'exports["."].import must be the ESM build');
|
||||
// A `require` condition would re-introduce the CJS path the loader chokes on.
|
||||
assert.equal("require" in root, false, 'exports["."] must not declare a `require` condition');
|
||||
|
||||
// The `./runtime` subpath used by the OpenCode loader must resolve to ESM.
|
||||
const runtime = exports["./runtime"] as Record<string, unknown>;
|
||||
assert.ok(runtime && typeof runtime === "object", 'exports["./runtime"] must exist');
|
||||
assert.equal(runtime.import, "./dist/index.js", 'exports["./runtime"].import must be ESM');
|
||||
});
|
||||
|
||||
test("opencode-plugin tsup config builds ESM-only with cjsInterop disabled", () => {
|
||||
const tsup = readFileSync(fileURLToPath(TSUP_URL), "utf8");
|
||||
|
||||
const formatMatch = tsup.match(/format:\s*\[([^\]]*)\]/);
|
||||
assert.ok(formatMatch, "tsup config must declare a format array");
|
||||
const formats = formatMatch[1]
|
||||
.split(",")
|
||||
.map((s) => s.trim().replace(/['"]/g, ""))
|
||||
.filter(Boolean);
|
||||
assert.deepEqual(formats, ["esm"], "tsup must build esm only (no cjs)");
|
||||
|
||||
assert.equal(
|
||||
/cjsInterop:\s*true/.test(tsup),
|
||||
false,
|
||||
"cjsInterop must not be true for an ESM-only build"
|
||||
);
|
||||
});
|
||||
@@ -40,6 +40,7 @@ const CHAT_OPENAI_COMPAT_PROVIDER_IDS = [
|
||||
"predibase",
|
||||
"bytez",
|
||||
"reka",
|
||||
"byteplus",
|
||||
];
|
||||
|
||||
test("chat-openai-compat providers are registered across provider metadata, registry and local catalog", () => {
|
||||
|
||||
@@ -18,17 +18,13 @@ test("no unapproved deps when all are allowlisted", () => {
|
||||
});
|
||||
|
||||
test("flags a dependency not on the allowlist (potential slopsquat)", () => {
|
||||
assert.deepEqual(
|
||||
findUnapprovedDeps(["react", "reactt-router"], new Set(["react"])),
|
||||
["reactt-router"]
|
||||
);
|
||||
assert.deepEqual(findUnapprovedDeps(["react", "reactt-router"], new Set(["react"])), [
|
||||
"reactt-router",
|
||||
]);
|
||||
});
|
||||
|
||||
test("flags multiple new deps, preserves order, de-dupes", () => {
|
||||
assert.deepEqual(
|
||||
findUnapprovedDeps(["a", "b", "a", "c"], new Set(["a"])),
|
||||
["b", "c"]
|
||||
);
|
||||
assert.deepEqual(findUnapprovedDeps(["a", "b", "a", "c"], new Set(["a"])), ["b", "c"]);
|
||||
});
|
||||
|
||||
// --- 6A.8: automatic workspace discovery ---
|
||||
@@ -64,7 +60,7 @@ test("6A.8: discoverManifests does NOT include node_modules, .next, or deep refe
|
||||
});
|
||||
|
||||
test("6A.8: all workspace package deps are in the allowlist (gate exits 0 with expanded scope)", () => {
|
||||
const allowlistPath = path.join(repoRoot, "dependency-allowlist.json");
|
||||
const allowlistPath = path.join(repoRoot, "config/quality/dependency-allowlist.json");
|
||||
const allowlist = new Set(JSON.parse(fs.readFileSync(allowlistPath, "utf8")).allowed || []);
|
||||
const manifests = discoverManifests(repoRoot);
|
||||
const allDeps: string[] = [];
|
||||
@@ -80,7 +76,11 @@ test("6A.8: all workspace package deps are in the allowlist (gate exits 0 with e
|
||||
);
|
||||
}
|
||||
const unapproved = findUnapprovedDeps(allDeps, allowlist);
|
||||
assert.deepEqual(unapproved, [], `expected all deps to be approved, got: ${unapproved.join(", ")}`);
|
||||
assert.deepEqual(
|
||||
unapproved,
|
||||
[],
|
||||
`expected all deps to be approved, got: ${unapproved.join(", ")}`
|
||||
);
|
||||
});
|
||||
|
||||
// --- 6A.8: stale-allowlist enforcement ---
|
||||
@@ -102,10 +102,9 @@ test("6A.8 stale: a dep removed from all manifests is detected as stale in allow
|
||||
test("7.8 evaluateDepAge: package older than 72h is OK", () => {
|
||||
const now = Date.now();
|
||||
const createdMs = now - 73 * 60 * 60 * 1000; // 73 hours ago
|
||||
const { ok, ageHours } = (evaluateDepAge as (a: number, b: number, c?: number) => { ok: boolean; ageHours: number })(
|
||||
createdMs,
|
||||
now
|
||||
);
|
||||
const { ok, ageHours } = (
|
||||
evaluateDepAge as (a: number, b: number, c?: number) => { ok: boolean; ageHours: number }
|
||||
)(createdMs, now);
|
||||
assert.ok(ok, "package older than 72h should be ok");
|
||||
assert.ok(ageHours >= 73, "ageHours should reflect elapsed time");
|
||||
});
|
||||
@@ -113,20 +112,18 @@ test("7.8 evaluateDepAge: package older than 72h is OK", () => {
|
||||
test("7.8 evaluateDepAge: package exactly at 72h boundary is OK (boundary inclusive)", () => {
|
||||
const now = Date.now();
|
||||
const createdMs = now - 72 * 60 * 60 * 1000; // exactly 72 hours ago
|
||||
const { ok } = (evaluateDepAge as (a: number, b: number, c?: number) => { ok: boolean; ageHours: number })(
|
||||
createdMs,
|
||||
now
|
||||
);
|
||||
const { ok } = (
|
||||
evaluateDepAge as (a: number, b: number, c?: number) => { ok: boolean; ageHours: number }
|
||||
)(createdMs, now);
|
||||
assert.ok(ok, "package at exactly 72h should be ok (inclusive boundary)");
|
||||
});
|
||||
|
||||
test("7.8 evaluateDepAge: package published 1h ago is NOT OK", () => {
|
||||
const now = Date.now();
|
||||
const createdMs = now - 1 * 60 * 60 * 1000; // 1 hour ago
|
||||
const { ok, ageHours } = (evaluateDepAge as (a: number, b: number, c?: number) => { ok: boolean; ageHours: number })(
|
||||
createdMs,
|
||||
now
|
||||
);
|
||||
const { ok, ageHours } = (
|
||||
evaluateDepAge as (a: number, b: number, c?: number) => { ok: boolean; ageHours: number }
|
||||
)(createdMs, now);
|
||||
assert.ok(!ok, "package published 1h ago should NOT be ok");
|
||||
assert.ok(ageHours < 72, "ageHours should reflect < 72h");
|
||||
});
|
||||
@@ -135,28 +132,23 @@ test("7.8 evaluateDepAge: respects custom minAgeHours", () => {
|
||||
const now = Date.now();
|
||||
const createdMs = now - 10 * 60 * 60 * 1000; // 10 hours ago
|
||||
// With 24h minimum, 10h-old package should fail
|
||||
const { ok: failWith24 } = (evaluateDepAge as (a: number, b: number, c?: number) => { ok: boolean; ageHours: number })(
|
||||
createdMs,
|
||||
now,
|
||||
24
|
||||
);
|
||||
const { ok: failWith24 } = (
|
||||
evaluateDepAge as (a: number, b: number, c?: number) => { ok: boolean; ageHours: number }
|
||||
)(createdMs, now, 24);
|
||||
assert.ok(!failWith24, "10h-old package should fail with 24h minimum");
|
||||
// With 6h minimum, 10h-old package should pass
|
||||
const { ok: passWith6 } = (evaluateDepAge as (a: number, b: number, c?: number) => { ok: boolean; ageHours: number })(
|
||||
createdMs,
|
||||
now,
|
||||
6
|
||||
);
|
||||
const { ok: passWith6 } = (
|
||||
evaluateDepAge as (a: number, b: number, c?: number) => { ok: boolean; ageHours: number }
|
||||
)(createdMs, now, 6);
|
||||
assert.ok(passWith6, "10h-old package should pass with 6h minimum");
|
||||
});
|
||||
|
||||
test("7.8 evaluateDepAge: future timestamp (time.created in future) is NOT OK", () => {
|
||||
const now = Date.now();
|
||||
const createdMs = now + 1000; // 1s in future (clock skew edge case)
|
||||
const { ok, ageHours } = (evaluateDepAge as (a: number, b: number, c?: number) => { ok: boolean; ageHours: number })(
|
||||
createdMs,
|
||||
now
|
||||
);
|
||||
const { ok, ageHours } = (
|
||||
evaluateDepAge as (a: number, b: number, c?: number) => { ok: boolean; ageHours: number }
|
||||
)(createdMs, now);
|
||||
assert.ok(!ok, "future timestamp should not be ok");
|
||||
assert.ok(ageHours < 0, "ageHours should be negative for future timestamp");
|
||||
});
|
||||
@@ -169,11 +161,17 @@ test("7.8 evaluateDepAge: future timestamp (time.created in future) is NOT OK",
|
||||
// - any real npm package like "react" is found and old → does NOT appear in any list
|
||||
|
||||
test("7.8 auditNewDepsRegistry: empty dep list returns all-empty results", () => {
|
||||
const result = (auditNewDepsRegistry as (deps: string[], minAge?: number, now?: number) => {
|
||||
notFound: string[];
|
||||
tooNew: Array<{ name: string; ageHours: number }>;
|
||||
offline: string[];
|
||||
})([], 72, Date.now());
|
||||
const result = (
|
||||
auditNewDepsRegistry as (
|
||||
deps: string[],
|
||||
minAge?: number,
|
||||
now?: number
|
||||
) => {
|
||||
notFound: string[];
|
||||
tooNew: Array<{ name: string; ageHours: number }>;
|
||||
offline: string[];
|
||||
}
|
||||
)([], 72, Date.now());
|
||||
assert.deepEqual(result.notFound, []);
|
||||
assert.deepEqual(result.tooNew, []);
|
||||
assert.deepEqual(result.offline, []);
|
||||
|
||||
100
tests/unit/check-docs-counts-sync.test.ts
Normal file
100
tests/unit/check-docs-counts-sync.test.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
parseProviderTotal,
|
||||
tallyDrift,
|
||||
readProviderTotal,
|
||||
countLocales,
|
||||
} from "../../scripts/check/check-docs-counts-sync.mjs";
|
||||
|
||||
// Explicit types for the .mjs exports — keep the test at 0 no-explicit-any warnings.
|
||||
const parse = parseProviderTotal as (text: string) => number;
|
||||
const tally = tallyDrift as (
|
||||
checks: {
|
||||
label: string;
|
||||
actual: number;
|
||||
docKey: string;
|
||||
strict: boolean;
|
||||
files: string[];
|
||||
}[],
|
||||
getContent: (file: string) => string | null
|
||||
) => { strict: number; soft: number; lines: string[] };
|
||||
const readTotal = readProviderTotal as () => number;
|
||||
const locales = countLocales as () => number;
|
||||
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const GATE = path.resolve(here, "../../scripts/check/check-docs-counts-sync.mjs");
|
||||
|
||||
// --- parseProviderTotal (pure) -------------------------------------------------------
|
||||
|
||||
test("parses the canonical provider total from the auto-generated catalog text", () => {
|
||||
assert.equal(parse("Total providers: **226**. See category breakdown below."), 226);
|
||||
});
|
||||
|
||||
test("returns 0 when no total marker is present", () => {
|
||||
assert.equal(parse("# Provider Reference\n\nNo total here."), 0);
|
||||
assert.equal(parse(""), 0);
|
||||
});
|
||||
|
||||
// --- tallyDrift (pure) ---------------------------------------------------------------
|
||||
|
||||
const strictCheck = {
|
||||
label: "Provider count",
|
||||
actual: 226,
|
||||
docKey: "providers",
|
||||
strict: true,
|
||||
files: ["README.md", "AGENTS.md"],
|
||||
};
|
||||
|
||||
test("no drift when every file mentions the real count", () => {
|
||||
const { strict, soft } = tally([strictCheck], () => "we have 226 providers");
|
||||
assert.equal(strict, 0);
|
||||
assert.equal(soft, 0);
|
||||
});
|
||||
|
||||
test("STRICT drift is counted when a file omits the real count", () => {
|
||||
const { strict, soft } = tally([strictCheck], (f) =>
|
||||
f === "README.md" ? "we have 226 providers" : "we have 177 providers"
|
||||
);
|
||||
assert.equal(strict, 1, "AGENTS.md (177) should register one strict drift");
|
||||
assert.equal(soft, 0);
|
||||
});
|
||||
|
||||
test("SOFT drift does not count as strict", () => {
|
||||
const softCheck = { ...strictCheck, strict: false };
|
||||
const { strict, soft } = tally([softCheck], () => "no number here");
|
||||
assert.equal(strict, 0);
|
||||
assert.equal(soft, 2, "both files miss → two soft drifts");
|
||||
});
|
||||
|
||||
test("a check with actual=0 is skipped (source count undetermined)", () => {
|
||||
const zero = { ...strictCheck, actual: 0 };
|
||||
const { strict, soft } = tally([zero], () => null);
|
||||
assert.equal(strict, 0);
|
||||
assert.equal(soft, 0);
|
||||
});
|
||||
|
||||
test("a missing file (null content) registers drift, not a crash", () => {
|
||||
const { strict } = tally([strictCheck], () => null);
|
||||
assert.equal(strict, 2);
|
||||
});
|
||||
|
||||
// --- live source readers (smoke) -----------------------------------------------------
|
||||
|
||||
test("readProviderTotal reads a real, positive total from the catalog", () => {
|
||||
assert.ok(readTotal() > 100, "provider catalog total should be > 100");
|
||||
});
|
||||
|
||||
test("countLocales reads a real, positive locale count from config/i18n.json", () => {
|
||||
assert.ok(locales() >= 40, "i18n config should define at least 40 locales");
|
||||
});
|
||||
|
||||
// --- live gate smoke -----------------------------------------------------------------
|
||||
|
||||
test("the gate exits 0 against the current (synced) repo state", () => {
|
||||
// Throws if exit code is non-zero; current docs are synced so this must pass.
|
||||
assert.doesNotThrow(() => execFileSync("node", [GATE], { encoding: "utf8", stdio: "pipe" }));
|
||||
});
|
||||
@@ -20,6 +20,13 @@ import {
|
||||
diffCloudAgents,
|
||||
type ExecutorLike,
|
||||
} from "../../scripts/check/check-known-symbols.ts";
|
||||
import { reportStaleEntries } from "../../scripts/check/lib/allowlist.mjs";
|
||||
import { ROUTING_STRATEGY_VALUES } from "../../src/shared/constants/routingStrategies.ts";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const REPO_ROOT = path.resolve(fileURLToPath(import.meta.url), "../../..");
|
||||
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
// (2) COMBO STRATEGIES — extractHandledStrategies + diffComboStrategies
|
||||
@@ -174,9 +181,57 @@ test("findNewTranslatorPairs returns [] when live is a subset of frozen", () =>
|
||||
// Allowlist / snapshot sanity (documented frozen sets stay well-formed)
|
||||
// ───────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test("IMPLICIT_DEFAULT_STRATEGIES documents `priority` with a justification", () => {
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(IMPLICIT_DEFAULT_STRATEGIES, "priority"));
|
||||
assert.ok(IMPLICIT_DEFAULT_STRATEGIES.priority.length > 20);
|
||||
test("IMPLICIT_DEFAULT_STRATEGIES: every documented key carries a non-trivial justification", () => {
|
||||
// The map may be empty (all canonical strategies are referenced in combo.ts), but any
|
||||
// entry that DOES exist must explain why the strategy has no explicit dispatch branch.
|
||||
for (const [key, justification] of Object.entries(IMPLICIT_DEFAULT_STRATEGIES)) {
|
||||
assert.ok(typeof justification === "string" && justification.length > 20, `weak/missing justification for "${key}"`);
|
||||
}
|
||||
});
|
||||
|
||||
// --- stale-allowlist enforcement (6A.3) ---
|
||||
|
||||
test("stale-enforcement: an IMPLICIT_DEFAULT_STRATEGIES key now referenced in dispatch is reported as stale", () => {
|
||||
// "priority" gained a `strategy === "priority"` reference in combo.ts, so it is now in
|
||||
// the handled set and no longer in canonicalNotHandled — an implicit-default for it would
|
||||
// suppress nothing → stale.
|
||||
const liveImplicitNeeded: string[] = []; // canonicalNotHandled WITHOUT the allowlist
|
||||
const stale = (reportStaleEntries as (a: string[], l: string[], g: string) => string[])(
|
||||
["priority"],
|
||||
liveImplicitNeeded,
|
||||
"known-symbols:combo"
|
||||
);
|
||||
assert.deepEqual(stale, ["priority"]);
|
||||
});
|
||||
|
||||
test("stale-enforcement: an IMPLICIT_DEFAULT_STRATEGIES key still uncovered by dispatch is NOT stale", () => {
|
||||
// A canonical strategy with no `strategy === "..."` reference IS still suppressed by the
|
||||
// implicit-default, so the entry is live (must stay).
|
||||
const liveImplicitNeeded = ["priority"]; // priority would be canonicalNotHandled without it
|
||||
const stale = (reportStaleEntries as (a: string[], l: string[], g: string) => string[])(
|
||||
["priority"],
|
||||
liveImplicitNeeded,
|
||||
"known-symbols:combo"
|
||||
);
|
||||
assert.deepEqual(stale, []);
|
||||
});
|
||||
|
||||
test("stale-enforcement: live repo IMPLICIT_DEFAULT_STRATEGIES has no stale entries", () => {
|
||||
// Every key must still be a canonical strategy that lacks a `strategy === "..."` reference
|
||||
// in combo.ts; otherwise the suppression is dead and must be removed.
|
||||
const comboSource = fs.readFileSync(path.join(REPO_ROOT, "open-sse/services/combo.ts"), "utf8");
|
||||
const handled = extractHandledStrategies(comboSource);
|
||||
const liveImplicitNeeded = diffComboStrategies(
|
||||
ROUTING_STRATEGY_VALUES as readonly string[],
|
||||
handled,
|
||||
{}
|
||||
).canonicalNotHandled;
|
||||
const stale = (reportStaleEntries as (a: string[], l: string[], g: string) => string[])(
|
||||
Object.keys(IMPLICIT_DEFAULT_STRATEGIES),
|
||||
liveImplicitNeeded,
|
||||
"known-symbols:combo"
|
||||
);
|
||||
assert.deepEqual(stale, [], `IMPLICIT_DEFAULT_STRATEGIES has stale entries: ${stale.join(", ")}`);
|
||||
});
|
||||
|
||||
test("KNOWN_TRANSLATOR_PAIRS is a non-empty, well-formed, deduped from:to snapshot", () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ const {
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS,
|
||||
CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA,
|
||||
CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA,
|
||||
CLAUDE_CODE_COMPATIBLE_STAINLESS_TIMEOUT_SECONDS,
|
||||
isClaudeCodeCompatibleProvider,
|
||||
stripAnthropicMessagesSuffix,
|
||||
@@ -14,6 +15,7 @@ const {
|
||||
joinClaudeCodeCompatibleUrl,
|
||||
buildClaudeCodeCompatibleHeaders,
|
||||
buildClaudeCodeCompatibleValidationPayload,
|
||||
resolveClaudeCodeCompatibleAnthropicBeta,
|
||||
resolveClaudeCodeCompatibleSessionId,
|
||||
} = await import("../../open-sse/services/claudeCodeCompatible.ts");
|
||||
|
||||
@@ -61,6 +63,10 @@ test("Claude Code compatible beta set matches the stable API-key Claude CLI prof
|
||||
assert.ok(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("claude-code-20250219"));
|
||||
assert.ok(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("interleaved-thinking-2025-05-14"));
|
||||
assert.ok(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("effort-2025-11-24"));
|
||||
assert.equal(
|
||||
CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes(CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA),
|
||||
false
|
||||
);
|
||||
assert.equal(CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("oauth-2025-04-20"), false);
|
||||
assert.equal(
|
||||
CLAUDE_CODE_COMPATIBLE_ANTHROPIC_BETA.includes("context-management-2025-06-27"),
|
||||
@@ -72,6 +78,33 @@ test("Claude Code compatible beta set matches the stable API-key Claude CLI prof
|
||||
);
|
||||
});
|
||||
|
||||
test("Claude Code compatible redacted thinking beta is explicit opt-in", () => {
|
||||
assert.equal(
|
||||
resolveClaudeCodeCompatibleAnthropicBeta({}).includes(
|
||||
CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
resolveClaudeCodeCompatibleAnthropicBeta({ redactThinking: true }).includes(
|
||||
CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
buildClaudeCodeCompatibleHeaders("sk-demo", true)["anthropic-beta"].includes(
|
||||
CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
buildClaudeCodeCompatibleHeaders("sk-demo", true, undefined, {
|
||||
redactThinking: true,
|
||||
})["anthropic-beta"].includes(CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveClaudeCodeCompatibleSessionId prefers explicit session headers and generates a fallback id", () => {
|
||||
const headers = new Headers({
|
||||
"x-session-id": "legacy-session",
|
||||
|
||||
@@ -92,6 +92,29 @@ test("expandAutoComboCandidatePool is a no-op when an explicit candidatePool exi
|
||||
assert.equal(result[0].modelStr, "openai/gpt-4o");
|
||||
});
|
||||
|
||||
test("expandAutoComboCandidatePool falls through to active connections when candidatePool is an empty array", async () => {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "OpenAI",
|
||||
apiKey: "sk-test-openai",
|
||||
defaultModel: "gpt-4o-mini",
|
||||
});
|
||||
|
||||
const expanded = await combo.expandAutoComboCandidatePool([], {
|
||||
config: { auto: { candidatePool: [] } },
|
||||
});
|
||||
|
||||
// An empty candidatePool should NOT trigger early return — the function
|
||||
// should fall through and expand from active connections instead.
|
||||
assert.ok(
|
||||
expanded.length > 0,
|
||||
"expected expansion from active connections despite empty candidatePool"
|
||||
);
|
||||
const openaiTargets = expanded.filter((t) => t.provider === "openai");
|
||||
assert.ok(openaiTargets.length > 0, "expected openai targets to be expanded");
|
||||
});
|
||||
|
||||
test("expandAutoComboCandidatePool does not duplicate an already-present modelStr", async () => {
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
|
||||
@@ -18,7 +18,8 @@ const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const { resetAllComboMetrics } = await import("../../open-sse/services/comboMetrics.ts");
|
||||
const { resetAllCircuitBreakers, getCircuitBreaker } =
|
||||
await import("../../src/shared/utils/circuitBreaker.ts");
|
||||
const { resetAll: resetAllSemaphores } = await import("../../open-sse/services/rateLimitSemaphore.ts");
|
||||
const { resetAll: resetAllSemaphores } =
|
||||
await import("../../open-sse/services/rateLimitSemaphore.ts");
|
||||
const { _resetAllDecks } = await import("../../src/shared/utils/shuffleDeck.ts");
|
||||
const { clearSessions } = await import("../../open-sse/services/sessionManager.ts");
|
||||
|
||||
@@ -290,6 +291,74 @@ test("strict-random falls back to the remaining target when the deck pick fails"
|
||||
assert.notEqual(calls[0], calls[1]);
|
||||
});
|
||||
|
||||
test("round-robin uses existing stickyRoundRobinLimit for combo target batching", async () => {
|
||||
const calls: string[] = [];
|
||||
const combo = {
|
||||
name: "rr-sticky-combo-batches",
|
||||
strategy: "round-robin",
|
||||
models: ["openai/a", "claude/b", "gemini/c"],
|
||||
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 },
|
||||
};
|
||||
|
||||
for (let i = 0; i < 10; 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: { stickyRoundRobinLimit: 3 },
|
||||
allCombos: null,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
}
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"openai/a",
|
||||
"openai/a",
|
||||
"openai/a",
|
||||
"claude/b",
|
||||
"claude/b",
|
||||
"claude/b",
|
||||
"gemini/c",
|
||||
"gemini/c",
|
||||
"gemini/c",
|
||||
"openai/a",
|
||||
]);
|
||||
});
|
||||
|
||||
test("round-robin sticky batching fallback success becomes sticky target", async () => {
|
||||
const calls: string[] = [];
|
||||
const combo = {
|
||||
name: "rr-sticky-fallback-success",
|
||||
strategy: "round-robin",
|
||||
models: ["openai/a", "claude/b", "gemini/c"],
|
||||
config: { maxRetries: 0, retryDelayMs: 0, fallbackDelayMs: 0 },
|
||||
};
|
||||
|
||||
for (let i = 0; i < 4; i += 1) {
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo,
|
||||
handleSingleModel: async (_body: any, modelStr: string) => {
|
||||
calls.push(modelStr);
|
||||
if (modelStr === "openai/a") return errorResponse(503, "a is down");
|
||||
return okResponse();
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: { stickyRoundRobinLimit: 2 },
|
||||
allCombos: null,
|
||||
});
|
||||
assert.equal(result.ok, true);
|
||||
}
|
||||
|
||||
assert.deepEqual(calls, ["openai/a", "claude/b", "claude/b", "gemini/c", "gemini/c"]);
|
||||
});
|
||||
|
||||
test("strict-random survives a stale deck entry after a target is removed", async () => {
|
||||
const comboTwoTargets = {
|
||||
name: "strict-random-stale",
|
||||
|
||||
58
tests/unit/compression/strategySelector-cache-aware.test.ts
Normal file
58
tests/unit/compression/strategySelector-cache-aware.test.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* Tests for #3890: the cache-aware `skipSystemPrompt` flag was computed by
|
||||
* getCacheAwareStrategy() but dropped by selectCompressionStrategy() (which can only
|
||||
* return a mode string). resolveCacheAwareConfig() applies it: in a caching context the
|
||||
* system prompt must stay uncompressed (it is part of the cacheable prefix) even when the
|
||||
* operator disabled preserveSystemPrompt.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { resolveCacheAwareConfig } from "../../../open-sse/services/compression/strategySelector.ts";
|
||||
import type { CompressionConfig } from "../../../open-sse/services/compression/types.ts";
|
||||
|
||||
function cfg(overrides: Partial<CompressionConfig> = {}): CompressionConfig {
|
||||
return {
|
||||
enabled: true,
|
||||
defaultMode: "standard",
|
||||
autoTriggerTokens: 0,
|
||||
cacheMinutes: 5,
|
||||
preserveSystemPrompt: true,
|
||||
comboOverrides: {},
|
||||
...overrides,
|
||||
} as CompressionConfig;
|
||||
}
|
||||
|
||||
describe("resolveCacheAwareConfig (#3890)", () => {
|
||||
it("forces preserveSystemPrompt on for a caching request that disabled it", () => {
|
||||
const out = resolveCacheAwareConfig(
|
||||
cfg({ preserveSystemPrompt: false }),
|
||||
{ messages: [{ role: "system", content: "x", cache_control: { type: "ephemeral" } }] },
|
||||
{ provider: "anthropic", targetFormat: "claude" }
|
||||
);
|
||||
assert.equal(out.preserveSystemPrompt, true);
|
||||
});
|
||||
|
||||
it("leaves a non-caching request untouched (preserveSystemPrompt stays false)", () => {
|
||||
const out = resolveCacheAwareConfig(
|
||||
cfg({ preserveSystemPrompt: false }),
|
||||
{ messages: [{ role: "system", content: "x" }] },
|
||||
{ provider: "openai" }
|
||||
);
|
||||
assert.equal(out.preserveSystemPrompt, false);
|
||||
});
|
||||
|
||||
it("returns the same config object when there is no body", () => {
|
||||
const base = cfg({ preserveSystemPrompt: false });
|
||||
assert.equal(resolveCacheAwareConfig(base), base);
|
||||
});
|
||||
|
||||
it("does not change a config that already preserves the system prompt", () => {
|
||||
const out = resolveCacheAwareConfig(
|
||||
cfg({ preserveSystemPrompt: true }),
|
||||
{ messages: [{ role: "system", content: "x", cache_control: { type: "ephemeral" } }] },
|
||||
{ provider: "anthropic", targetFormat: "claude" }
|
||||
);
|
||||
assert.equal(out.preserveSystemPrompt, true);
|
||||
});
|
||||
});
|
||||
@@ -14,6 +14,7 @@ import { PROVIDERS } from "../../open-sse/config/constants.ts";
|
||||
import {
|
||||
CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA,
|
||||
CONTEXT_1M_BETA_HEADER,
|
||||
} from "../../open-sse/services/claudeCodeCompatible.ts";
|
||||
|
||||
@@ -103,10 +104,7 @@ test("DefaultExecutor.buildUrl uses full chat endpoints for hosted OpenAI-compat
|
||||
bazaarlink.buildUrl("auto:free", true),
|
||||
"https://bazaarlink.ai/api/v1/chat/completions"
|
||||
);
|
||||
assert.equal(
|
||||
crof.buildUrl("gpt-4.1", true),
|
||||
"https://crof.ai/v1/chat/completions"
|
||||
);
|
||||
assert.equal(crof.buildUrl("gpt-4.1", true), "https://crof.ai/v1/chat/completions");
|
||||
});
|
||||
|
||||
test("DefaultExecutor.buildUrl handles openai-compatible and anthropic-compatible providers", () => {
|
||||
@@ -570,7 +568,7 @@ test("DefaultExecutor.execute uses CC-compatible connection defaults to append 1
|
||||
apiKey: "cc-key",
|
||||
providerSpecificData: {
|
||||
ccSessionId: "session-1",
|
||||
requestDefaults: { context1m: true },
|
||||
requestDefaults: { context1m: true, redactThinking: true },
|
||||
},
|
||||
},
|
||||
extendedContext: false,
|
||||
@@ -598,7 +596,15 @@ test("DefaultExecutor.execute uses CC-compatible connection defaults to append 1
|
||||
}
|
||||
|
||||
assert.equal(calls[0].headers["anthropic-beta"].includes(CONTEXT_1M_BETA_HEADER), false);
|
||||
assert.equal(
|
||||
calls[0].headers["anthropic-beta"].includes(CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA),
|
||||
false
|
||||
);
|
||||
assert.equal(calls[1].headers["anthropic-beta"].includes(CONTEXT_1M_BETA_HEADER), true);
|
||||
assert.equal(
|
||||
calls[1].headers["anthropic-beta"].includes(CLAUDE_CODE_COMPATIBLE_REDACT_THINKING_BETA),
|
||||
true
|
||||
);
|
||||
assert.equal(calls[2].headers["anthropic-beta"], undefined);
|
||||
});
|
||||
|
||||
@@ -742,6 +748,44 @@ test("DefaultExecutor.transformRequest respects disableStreamOptions for OpenAI
|
||||
assert.deepEqual((chatResultEnabled as any).stream_options, { include_usage: true });
|
||||
});
|
||||
|
||||
test("DefaultExecutor.transformRequest injects OpenRouter connection preset", () => {
|
||||
const executor = new DefaultExecutor("openrouter");
|
||||
const body = { model: "openai/gpt-4", messages: [{ role: "user", content: "hi" }] };
|
||||
|
||||
const result = executor.transformRequest("openai/gpt-4", body, true, {
|
||||
providerSpecificData: { preset: " email-copywriter " },
|
||||
});
|
||||
|
||||
assert.equal((result as any).preset, "email-copywriter");
|
||||
assert.deepEqual((result as any).stream_options, { include_usage: true });
|
||||
assert.equal((body as any).preset, undefined);
|
||||
|
||||
const explicit = executor.transformRequest(
|
||||
"openai/gpt-4",
|
||||
{ ...body, preset: "client-preset" },
|
||||
true,
|
||||
{ providerSpecificData: { preset: "connection-preset" } }
|
||||
);
|
||||
|
||||
assert.equal((explicit as any).preset, "client-preset");
|
||||
|
||||
const explicitNull = executor.transformRequest("openai/gpt-4", { ...body, preset: null }, true, {
|
||||
providerSpecificData: { preset: "connection-preset" },
|
||||
});
|
||||
assert.equal((explicitNull as any).preset, null);
|
||||
|
||||
const explicitEmpty = executor.transformRequest("openai/gpt-4", { ...body, preset: "" }, true, {
|
||||
providerSpecificData: { preset: "connection-preset" },
|
||||
});
|
||||
assert.equal((explicitEmpty as any).preset, "");
|
||||
|
||||
const blank = executor.transformRequest("openai/gpt-4", body, true, {
|
||||
providerSpecificData: { preset: " " },
|
||||
});
|
||||
|
||||
assert.equal((blank as any).preset, undefined);
|
||||
});
|
||||
|
||||
test("DefaultExecutor.transformRequest strips stream_options from Anthropic-compatible targets", () => {
|
||||
const anthropicCompat = new DefaultExecutor("anthropic-compatible-test");
|
||||
const anthropicCcCompat = new DefaultExecutor("anthropic-compatible-cc-test");
|
||||
|
||||
105
tests/unit/memory-cache-safe-injection.test.ts
Normal file
105
tests/unit/memory-cache-safe-injection.test.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Tests for #3890: prompt-cache misses when memory injection is enabled.
|
||||
*
|
||||
* When the client uses prompt caching (cache_control breakpoints), the previous behavior
|
||||
* prepended the (per-query, varying) memory message at index 0 — shifting the entire
|
||||
* cacheable prefix and forcing a cache miss on every turn. With `cacheSafe`, memory is
|
||||
* inserted just before the last user message so the cacheable prefix stays byte-stable.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { injectMemory } from "../../src/lib/memory/injection.ts";
|
||||
import type { ChatRequest } from "../../src/lib/memory/injection.ts";
|
||||
import type { Memory } from "../../src/lib/memory/types.ts";
|
||||
|
||||
function mem(content: string): Memory {
|
||||
return {
|
||||
id: `mem-${content}`,
|
||||
content,
|
||||
type: "factual" as any,
|
||||
apiKeyId: "k",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
importance: 0.5,
|
||||
};
|
||||
}
|
||||
|
||||
function multiTurn(): ChatRequest {
|
||||
return {
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
messages: [
|
||||
{ role: "system", content: "SYSTEM PROMPT", cache_control: { type: "ephemeral" } } as any,
|
||||
{ role: "user", content: "turn 1 question" },
|
||||
{ role: "assistant", content: "turn 1 answer" },
|
||||
{ role: "user", content: "turn 2 question" },
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
describe("injectMemory cache-safe positioning (#3890)", () => {
|
||||
it("default (cacheSafe off) prepends memory at index 0 — unchanged legacy behavior", () => {
|
||||
const out = injectMemory(multiTurn(), [mem("dark mode")], "anthropic");
|
||||
assert.equal(out.messages[0].role, "system");
|
||||
assert.ok(out.messages[0].content.includes("Memory context"));
|
||||
assert.equal(out.messages[1].content, "SYSTEM PROMPT");
|
||||
});
|
||||
|
||||
it("cacheSafe inserts memory just before the last user message, preserving the prefix", () => {
|
||||
const req = multiTurn();
|
||||
const prefixBefore = JSON.stringify(req.messages.slice(0, 3)); // sys, u1, a1
|
||||
const out = injectMemory(req, [mem("dark mode")], "anthropic", { cacheSafe: true });
|
||||
|
||||
// The cacheable prefix (system + prior turns up to the last assistant) is byte-identical.
|
||||
assert.equal(JSON.stringify(out.messages.slice(0, 3)), prefixBefore);
|
||||
// Memory is injected right before the last user message...
|
||||
assert.equal(out.messages[3].role, "system");
|
||||
assert.ok(out.messages[3].content.includes("Memory context"));
|
||||
// ...and the last user message is preserved at the tail.
|
||||
assert.equal(out.messages[4].content, "turn 2 question");
|
||||
assert.equal(out.messages.length, 5);
|
||||
// Memory must NOT be at index 0 (that is what broke caching).
|
||||
assert.notEqual(out.messages[0].content, out.messages[3].content);
|
||||
assert.equal(out.messages[0].content, "SYSTEM PROMPT");
|
||||
});
|
||||
|
||||
it("cacheSafe keeps the cacheable prefix identical across two turns despite different memories", () => {
|
||||
// Turn 1: [sys, u1]; Turn 2: [sys, u1, a1, u2]. Different memories retrieved per query.
|
||||
// Same conversation, observed on two consecutive turns.
|
||||
const turn1: ChatRequest = {
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
messages: [
|
||||
{ role: "system", content: "SYSTEM PROMPT", cache_control: { type: "ephemeral" } } as any,
|
||||
{ role: "user", content: "turn 1 question" },
|
||||
],
|
||||
};
|
||||
const turn2 = multiTurn();
|
||||
|
||||
const out1 = injectMemory(turn1, [mem("A")], "anthropic", { cacheSafe: true });
|
||||
const out2 = injectMemory(turn2, [mem("B")], "anthropic", { cacheSafe: true });
|
||||
|
||||
// The cache-breakpoint-bearing system message stays at the head, byte-identical, in
|
||||
// both turns (and is NOT displaced by the per-query memory) — so the prompt cache
|
||||
// created on turn 1 still matches on turn 2. (Memory is inserted before the last user
|
||||
// turn: [SYS, MEM_A, u1] and [SYS, u1, a1, MEM_B, u2] respectively.)
|
||||
assert.deepEqual(out1.messages[0], out2.messages[0]);
|
||||
assert.equal(out1.messages[0].content, "SYSTEM PROMPT");
|
||||
assert.equal((out1.messages[0] as any).cache_control?.type, "ephemeral");
|
||||
// In turn 2 the earlier turns up to the last assistant are preserved before memory.
|
||||
assert.equal(out2.messages[1].content, "turn 1 question");
|
||||
assert.equal(out2.messages[2].content, "turn 1 answer");
|
||||
assert.ok(out2.messages[3].content.includes("Memory context"));
|
||||
assert.equal(out2.messages[4].content, "turn 2 question");
|
||||
});
|
||||
|
||||
it("cacheSafe falls back to leading injection when there is no user message", () => {
|
||||
const req: ChatRequest = {
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
messages: [{ role: "system", content: "SYS" }],
|
||||
};
|
||||
const out = injectMemory(req, [mem("x")], "anthropic", { cacheSafe: true });
|
||||
assert.equal(out.messages[0].role, "system");
|
||||
assert.ok(out.messages[0].content.includes("Memory context"));
|
||||
assert.equal(out.messages[1].content, "SYS");
|
||||
});
|
||||
});
|
||||
@@ -767,7 +767,9 @@ test("Request: posts to correct Perplexity SSE endpoint", async () => {
|
||||
|
||||
assert.equal(capturedUrl, "https://www.perplexity.ai/rest/sse/perplexity_ask");
|
||||
assert.equal(capturedHeaders["Origin"], "https://www.perplexity.ai");
|
||||
assert.equal(capturedHeaders["X-App-ApiVersion"], "client-1.11.0");
|
||||
assert.equal(capturedHeaders["x-perplexity-request-endpoint"], "https://www.perplexity.ai/rest/sse/perplexity_ask");
|
||||
assert.equal(capturedHeaders["x-perplexity-request-reason"], "ask-query-state-provider");
|
||||
assert.ok(capturedHeaders["x-request-id"], "x-request-id header should be set");
|
||||
assert.equal(capturedHeaders["Accept"], "text/event-stream");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
|
||||
@@ -53,6 +53,25 @@ test("provider models helpers resolve provider IDs through aliases", () => {
|
||||
assert.deepEqual(getModelsByProviderId("provider-that-does-not-exist"), []);
|
||||
});
|
||||
|
||||
test("getProviderModels returns models for both the alias and the raw provider id", () => {
|
||||
// Pick a provider whose alias differs from its id (e.g. "github" → "gh").
|
||||
const aliased = Object.entries(PROVIDER_ID_TO_ALIAS).find(([id, a]) => id !== a) as
|
||||
| [string, string]
|
||||
| undefined;
|
||||
if (!aliased) return; // no aliased providers → trivially satisfied
|
||||
|
||||
const [rawId, alias] = aliased;
|
||||
const byAlias = getProviderModels(alias);
|
||||
const byRawId = getProviderModels(rawId);
|
||||
|
||||
assert.ok(byAlias.length > 0, `expected models under alias "${alias}"`);
|
||||
assert.deepEqual(
|
||||
byRawId,
|
||||
byAlias,
|
||||
`getProviderModels("${rawId}") should return the same models as getProviderModels("${alias}")`
|
||||
);
|
||||
});
|
||||
|
||||
test("Reka registry exposes preset models", () => {
|
||||
const rekaModels = getModelsByProviderId("reka");
|
||||
const ids = rekaModels.map((model) => model.id);
|
||||
|
||||
@@ -202,9 +202,10 @@ test("getCodexRequestDefaults returns reasoningEffort (transitive import guard)"
|
||||
assert.equal(typeof result.reasoningEffort, "string");
|
||||
});
|
||||
|
||||
test("getClaudeCodeCompatibleRequestDefaults returns context1m boolean", () => {
|
||||
test("getClaudeCodeCompatibleRequestDefaults returns CC-compatible booleans", () => {
|
||||
const result = getClaudeCodeCompatibleRequestDefaults(null);
|
||||
assert.equal(typeof result.context1m, "boolean");
|
||||
assert.equal(typeof result.redactThinking, "boolean");
|
||||
});
|
||||
|
||||
test("compatProtocolLabelKey maps protocol strings to i18n keys", () => {
|
||||
@@ -216,10 +217,7 @@ test("compatProtocolLabelKey maps protocol strings to i18n keys", () => {
|
||||
test("extractCommandCodeCredentialInput extracts from JSON/URL/raw (transitive import guard)", () => {
|
||||
assert.equal(extractCommandCodeCredentialInput(" "), "");
|
||||
assert.equal(extractCommandCodeCredentialInput("rawtoken"), "rawtoken");
|
||||
assert.equal(
|
||||
extractCommandCodeCredentialInput(JSON.stringify({ apiKey: "abc123" })),
|
||||
"abc123"
|
||||
);
|
||||
assert.equal(extractCommandCodeCredentialInput(JSON.stringify({ apiKey: "abc123" })), "abc123");
|
||||
});
|
||||
|
||||
test("normalizeAndValidateHttpBaseUrl validates http/https URLs", () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const { invalidateDbCache } = await import("../../src/lib/db/readCache.ts");
|
||||
const { invalidateCacheControlSettingsCache } =
|
||||
await import("../../src/lib/cacheControlSettings.ts");
|
||||
const { clearCache } = await import("../../src/lib/semanticCache.ts");
|
||||
@@ -17,14 +18,12 @@ const { clearIdempotency } = await import("../../src/lib/idempotencyLayer.ts");
|
||||
const { getPendingRequests, clearPendingRequests } =
|
||||
await import("../../src/lib/usage/usageHistory.ts");
|
||||
const { clearInflight } = await import("../../open-sse/services/requestDedup.ts");
|
||||
const {
|
||||
resetAll: resetAccountSemaphores,
|
||||
} = await import("../../open-sse/services/accountSemaphore.ts");
|
||||
const { resetAll: resetAccountSemaphores } =
|
||||
await import("../../open-sse/services/accountSemaphore.ts");
|
||||
const { clearModelLock } = await import("../../open-sse/services/accountFallback.ts");
|
||||
const { getCallLogs, getCallLogById } = await import("../../src/lib/usage/callLogs.ts");
|
||||
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
|
||||
const { resetPayloadRulesConfigForTests } =
|
||||
await import("../../open-sse/services/payloadRules.ts");
|
||||
const { resetPayloadRulesConfigForTests } = await import("../../open-sse/services/payloadRules.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
@@ -61,11 +60,22 @@ async function resetStorage() {
|
||||
clearInflight();
|
||||
clearModelLock();
|
||||
core.resetDbInstance();
|
||||
// A full reset must also drop the settings read-cache. Otherwise the cached
|
||||
// value (e.g. call_log_pipeline_enabled=true seeded earlier) survives the DB
|
||||
// wipe and silently masks the fact that the fresh DB has the default. In CI
|
||||
// under load this cache is evicted at unpredictable times, so tests that rely
|
||||
// on the stale cache flake. Make the reset honest and deterministic here.
|
||||
invalidateDbCache("settings");
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.before(async () => {
|
||||
// Re-seed per test, NOT once: every `afterEach` runs resetStorage(), which wipes
|
||||
// the DB and drops the settings cache. A run-once `before` only guaranteed the
|
||||
// flag for the first test; later tests depended on a stale cache surviving the
|
||||
// wipe, which flakes under CI load. beforeEach re-establishes it deterministically.
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
await settingsDb.updateSettings({ call_log_pipeline_enabled: true });
|
||||
});
|
||||
|
||||
@@ -117,22 +127,23 @@ test("network failure persisted call log includes providerRequest in pipeline pa
|
||||
const detail = await waitFor(getLatestCallLog);
|
||||
assert.ok(detail, "expected a call log to be persisted");
|
||||
|
||||
assert.ok(detail.pipelinePayloads, "expected pipeline payloads when call_log_pipeline_enabled is true");
|
||||
assert.ok(
|
||||
detail.pipelinePayloads,
|
||||
"expected pipeline payloads when call_log_pipeline_enabled is true"
|
||||
);
|
||||
assert.ok(
|
||||
detail.pipelinePayloads.providerRequest,
|
||||
"providerRequest must be present in pipeline payloads even on network failure"
|
||||
);
|
||||
const providerReqBody =
|
||||
detail.pipelinePayloads.providerRequest.body ??
|
||||
detail.pipelinePayloads.providerRequest;
|
||||
detail.pipelinePayloads.providerRequest.body ?? detail.pipelinePayloads.providerRequest;
|
||||
assert.equal(
|
||||
providerReqBody.model,
|
||||
"gpt-4o-mini",
|
||||
"providerRequest should contain the translated model"
|
||||
);
|
||||
const messages =
|
||||
providerReqBody.messages ??
|
||||
(Array.isArray(providerReqBody) ? providerReqBody : null);
|
||||
providerReqBody.messages ?? (Array.isArray(providerReqBody) ? providerReqBody : null);
|
||||
if (messages) {
|
||||
assert.equal(messages[0]?.content, "hello");
|
||||
}
|
||||
@@ -141,10 +152,7 @@ test("network failure persisted call log includes providerRequest in pipeline pa
|
||||
null,
|
||||
"providerResponse should be null/absent on network failure (no response received)"
|
||||
);
|
||||
assert.ok(
|
||||
detail.pipelinePayloads.error,
|
||||
"pipeline payloads should include the error details"
|
||||
);
|
||||
assert.ok(detail.pipelinePayloads.error, "pipeline payloads should include the error details");
|
||||
});
|
||||
|
||||
test("network timeout persisted call log includes providerRequest in pipeline payloads", async () => {
|
||||
@@ -181,10 +189,7 @@ test("network timeout persisted call log includes providerRequest in pipeline pa
|
||||
await waitForAsyncSideEffects();
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.ok(
|
||||
result.status === 504,
|
||||
`expected 504 timeout, got ${result.status}`
|
||||
);
|
||||
assert.ok(result.status === 504, `expected 504 timeout, got ${result.status}`);
|
||||
|
||||
const detail = await waitFor(getLatestCallLog);
|
||||
assert.ok(detail, "expected a call log to be persisted");
|
||||
@@ -195,12 +200,8 @@ test("network timeout persisted call log includes providerRequest in pipeline pa
|
||||
"providerRequest must be present in pipeline payloads on timeout"
|
||||
);
|
||||
const providerReqBody =
|
||||
detail.pipelinePayloads?.providerRequest?.body ??
|
||||
detail.pipelinePayloads?.providerRequest;
|
||||
assert.equal(
|
||||
providerReqBody?.model,
|
||||
"gpt-4o-mini"
|
||||
);
|
||||
detail.pipelinePayloads?.providerRequest?.body ?? detail.pipelinePayloads?.providerRequest;
|
||||
assert.equal(providerReqBody?.model, "gpt-4o-mini");
|
||||
} finally {
|
||||
if (originalGetTimeoutMs) executor.getTimeoutMs = originalGetTimeoutMs;
|
||||
}
|
||||
@@ -311,10 +312,7 @@ test("successful response includes both providerRequest and providerResponse in
|
||||
assert.ok(detail, "expected a call log to be persisted");
|
||||
|
||||
assert.ok(detail.pipelinePayloads, "expected pipeline payloads");
|
||||
assert.ok(
|
||||
detail.pipelinePayloads.providerRequest,
|
||||
"providerRequest must be present on success"
|
||||
);
|
||||
assert.ok(detail.pipelinePayloads.providerRequest, "providerRequest must be present on success");
|
||||
assert.ok(
|
||||
detail.pipelinePayloads.providerResponse,
|
||||
"providerResponse must be present on success"
|
||||
|
||||
@@ -42,7 +42,7 @@ test("provider schemas reject non-boolean openaiStoreEnabled values", () => {
|
||||
assert.equal(updated.success, false);
|
||||
});
|
||||
|
||||
test("provider schemas accept boolean requestDefaults.context1m for CC-compatible providers", () => {
|
||||
test("provider schemas accept boolean CC-compatible request defaults", () => {
|
||||
const created = createProviderSchema.safeParse({
|
||||
provider: "anthropic-compatible-cc-demo",
|
||||
apiKey: "token",
|
||||
@@ -50,6 +50,7 @@ test("provider schemas accept boolean requestDefaults.context1m for CC-compatibl
|
||||
providerSpecificData: {
|
||||
requestDefaults: {
|
||||
context1m: true,
|
||||
redactThinking: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -57,6 +58,7 @@ test("provider schemas accept boolean requestDefaults.context1m for CC-compatibl
|
||||
providerSpecificData: {
|
||||
requestDefaults: {
|
||||
context1m: false,
|
||||
redactThinking: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -65,7 +67,7 @@ test("provider schemas accept boolean requestDefaults.context1m for CC-compatibl
|
||||
assert.equal(updated.success, true);
|
||||
});
|
||||
|
||||
test("provider schemas reject non-boolean requestDefaults.context1m values", () => {
|
||||
test("provider schemas reject non-boolean CC-compatible request defaults", () => {
|
||||
const created = createProviderSchema.safeParse({
|
||||
provider: "anthropic-compatible-cc-demo",
|
||||
apiKey: "token",
|
||||
@@ -73,6 +75,7 @@ test("provider schemas reject non-boolean requestDefaults.context1m values", ()
|
||||
providerSpecificData: {
|
||||
requestDefaults: {
|
||||
context1m: "yes",
|
||||
redactThinking: "yes",
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -80,6 +83,7 @@ test("provider schemas reject non-boolean requestDefaults.context1m values", ()
|
||||
providerSpecificData: {
|
||||
requestDefaults: {
|
||||
context1m: 1,
|
||||
redactThinking: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -127,3 +131,47 @@ test("provider schemas reject unknown Codex service tiers", () => {
|
||||
assert.equal(created.success, false);
|
||||
assert.equal(updated.success, false);
|
||||
});
|
||||
|
||||
test("provider schemas accept OpenRouter preset in providerSpecificData", () => {
|
||||
const created = createProviderSchema.safeParse({
|
||||
provider: "openrouter",
|
||||
apiKey: "token",
|
||||
name: "OpenRouter",
|
||||
providerSpecificData: {
|
||||
preset: "email-copywriter",
|
||||
},
|
||||
});
|
||||
const updated = updateProviderConnectionSchema.safeParse({
|
||||
providerSpecificData: {
|
||||
preset: "code-reviewer",
|
||||
},
|
||||
});
|
||||
const padded = updateProviderConnectionSchema.safeParse({
|
||||
providerSpecificData: {
|
||||
preset: `${" ".repeat(120)}prefer${" ".repeat(120)}`,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(created.success, true);
|
||||
assert.equal(updated.success, true);
|
||||
assert.equal(padded.success, true);
|
||||
});
|
||||
|
||||
test("provider schemas reject oversized OpenRouter preset values", () => {
|
||||
const created = createProviderSchema.safeParse({
|
||||
provider: "openrouter",
|
||||
apiKey: "token",
|
||||
name: "OpenRouter",
|
||||
providerSpecificData: {
|
||||
preset: "x".repeat(201),
|
||||
},
|
||||
});
|
||||
const updated = updateProviderConnectionSchema.safeParse({
|
||||
providerSpecificData: {
|
||||
preset: 123,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(created.success, false);
|
||||
assert.equal(updated.success, false);
|
||||
});
|
||||
|
||||
@@ -1780,7 +1780,7 @@ test("specialty validator accepts Nous Research credentials on chat completions"
|
||||
const headers = init.headers as Record<string, string>;
|
||||
const body = JSON.parse(String(init.body));
|
||||
assert.equal(headers.Authorization, "Bearer nous-key");
|
||||
assert.equal(body.model, "nousresearch/hermes-4-70b");
|
||||
assert.equal(body.model, "Hermes-4-70B");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-nous",
|
||||
@@ -1802,6 +1802,33 @@ test("specialty validator accepts Nous Research credentials on chat completions"
|
||||
assert.equal(nous.method, "nous_chat_completions");
|
||||
});
|
||||
|
||||
test("BytePlus key validation reaches the Ark endpoint instead of 'not supported' (#3877)", async () => {
|
||||
// #3877: byteplus was in APIKEY_PROVIDERS but never registered in the routing
|
||||
// registry, so validation returned {unsupported:true} → UI showed "invalid" for any
|
||||
// key. With the registry entry, a valid ark-... key probes the Ark /models endpoint
|
||||
// with Bearer auth and validates.
|
||||
let probedModelsUrl: string | null = null;
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
if (target === "https://ark.ap-southeast.bytepluses.com/api/v3/models") {
|
||||
probedModelsUrl = target;
|
||||
const headers = toPlainHeaders(init.headers);
|
||||
assert.equal(headers.Authorization, "Bearer ark-test-key");
|
||||
return new Response(JSON.stringify({ data: [{ id: "kimi-k2-thinking" }] }), { status: 200 });
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "byteplus",
|
||||
apiKey: "ark-test-key",
|
||||
});
|
||||
|
||||
assert.equal(result.unsupported, undefined, "byteplus must not be 'validation not supported'");
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(probedModelsUrl, "https://ark.ap-southeast.bytepluses.com/api/v3/models");
|
||||
});
|
||||
|
||||
test("specialty validator rejects invalid Nous Research credentials", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
@@ -1823,6 +1850,34 @@ test("specialty validator rejects invalid Nous Research credentials", async () =
|
||||
assert.equal(nous.error, "Invalid API key");
|
||||
});
|
||||
|
||||
test("specialty validator accepts Nous Research key when probe model is rejected (400)", async () => {
|
||||
// #3881: a valid key whose probe model is rejected (model-not-found / bad request)
|
||||
// must still validate — the 4xx proves auth was accepted, only the request shape
|
||||
// was wrong. Mirrors the longcat/nvidia validators.
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
|
||||
if (target === "https://inference-api.nousresearch.com/v1/chat/completions") {
|
||||
const headers = init.headers as Record<string, string>;
|
||||
assert.equal(headers.Authorization, "Bearer nous-key");
|
||||
return new Response(
|
||||
JSON.stringify({ error: { message: "model not found", type: "invalid_request_error" } }),
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected fetch: ${target}`);
|
||||
};
|
||||
|
||||
const nous = await validateProviderApiKey({
|
||||
provider: "nous-research",
|
||||
apiKey: "nous-key",
|
||||
});
|
||||
|
||||
assert.equal(nous.valid, true);
|
||||
assert.equal(nous.method, "nous_chat_completions");
|
||||
});
|
||||
|
||||
test("specialty validator rejects invalid Poe credentials", async () => {
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const target = String(url);
|
||||
|
||||
@@ -4,6 +4,7 @@ import http from "node:http";
|
||||
|
||||
import proxyFetch, {
|
||||
runWithProxyContext,
|
||||
runWithProxyContextOrDirect,
|
||||
runWithTlsTracking,
|
||||
isTlsFingerprintActive,
|
||||
} from "../../open-sse/utils/proxyFetch.ts";
|
||||
@@ -165,3 +166,40 @@ test("runWithProxyContext accepts reachable HTTP proxy endpoints and returns cal
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("runWithProxyContext throws PROXY_UNREACHABLE for an unreachable proxy by default", async () => {
|
||||
// 127.0.0.1:9 (discard) refuses connections — the proxy is unreachable.
|
||||
await assert.rejects(
|
||||
runWithProxyContext({ type: "http", host: "127.0.0.1", port: "9" }, async () => "unreachable"),
|
||||
/Proxy unreachable/
|
||||
);
|
||||
});
|
||||
|
||||
test("runWithProxyContext degrades to a direct connection when directFallbackOnUnreachable is set", async () => {
|
||||
let ran = false;
|
||||
const result = await runWithProxyContext(
|
||||
{ type: "http", host: "127.0.0.1", port: "9" },
|
||||
async () => {
|
||||
ran = true;
|
||||
return "direct-ok";
|
||||
},
|
||||
{ directFallbackOnUnreachable: true }
|
||||
);
|
||||
|
||||
assert.equal(ran, true, "callback must still run via a direct connection");
|
||||
assert.equal(result, "direct-ok");
|
||||
});
|
||||
|
||||
test("runWithProxyContextOrDirect runs the callback directly when the proxy is unreachable", async () => {
|
||||
let ran = false;
|
||||
const result = await runWithProxyContextOrDirect(
|
||||
{ type: "http", host: "127.0.0.1", port: "9" },
|
||||
async () => {
|
||||
ran = true;
|
||||
return "ok";
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(ran, true);
|
||||
assert.equal(result, "ok");
|
||||
});
|
||||
|
||||
@@ -43,26 +43,30 @@ test("ensureOpenAIStoreSessionFallback injects session_id only when no stable ca
|
||||
assert.equal(withExplicitSession.session_id, "existing-session");
|
||||
});
|
||||
|
||||
test("normalizeProviderSpecificData keeps only boolean CC-compatible 1M request defaults", () => {
|
||||
test("normalizeProviderSpecificData keeps only boolean CC-compatible request defaults", () => {
|
||||
const normalized = normalizeProviderSpecificData("anthropic-compatible-cc-demo", {
|
||||
baseUrl: "https://proxy.example.com/v1/messages?beta=true",
|
||||
requestDefaults: {
|
||||
context1m: true,
|
||||
redactThinking: true,
|
||||
customFlag: "keep-me",
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(getClaudeCodeCompatibleRequestDefaults(normalized), {
|
||||
context1m: true,
|
||||
redactThinking: true,
|
||||
});
|
||||
assert.deepEqual(normalized?.requestDefaults, {
|
||||
context1m: true,
|
||||
redactThinking: true,
|
||||
customFlag: "keep-me",
|
||||
});
|
||||
|
||||
const stripped = normalizeProviderSpecificData("anthropic-compatible-cc-demo", {
|
||||
requestDefaults: {
|
||||
context1m: "yes",
|
||||
redactThinking: "yes",
|
||||
customFlag: "keep-me",
|
||||
},
|
||||
});
|
||||
@@ -70,3 +74,37 @@ test("normalizeProviderSpecificData keeps only boolean CC-compatible 1M request
|
||||
customFlag: "keep-me",
|
||||
});
|
||||
});
|
||||
|
||||
test("normalizeProviderSpecificData trims OpenRouter preset and clears empty values", () => {
|
||||
const normalized = normalizeProviderSpecificData("openrouter", {
|
||||
preset: " email-copywriter ",
|
||||
tag: "primary",
|
||||
});
|
||||
|
||||
assert.equal(normalized?.preset, "email-copywriter");
|
||||
assert.equal(normalized?.tag, "primary");
|
||||
|
||||
const stripped = normalizeProviderSpecificData("openrouter", {
|
||||
preset: " ",
|
||||
tag: "primary",
|
||||
});
|
||||
|
||||
assert.equal(stripped?.preset, undefined);
|
||||
assert.equal(stripped?.tag, "primary");
|
||||
|
||||
const oversized = normalizeProviderSpecificData("openrouter", {
|
||||
preset: "x".repeat(201),
|
||||
tag: "primary",
|
||||
});
|
||||
|
||||
assert.equal(oversized?.preset, undefined);
|
||||
assert.equal(oversized?.tag, "primary");
|
||||
|
||||
const ignored = normalizeProviderSpecificData("openai", {
|
||||
preset: "email-copywriter",
|
||||
tag: "primary",
|
||||
});
|
||||
|
||||
assert.equal(ignored?.preset, undefined);
|
||||
assert.equal(ignored?.tag, "primary");
|
||||
});
|
||||
|
||||
@@ -87,6 +87,40 @@ test("updatePendingRequestStreamChunks stores stream chunks in the detail", () =
|
||||
assert.equal(detail.streamChunks.provider[0], 'data: {"a":1}');
|
||||
});
|
||||
|
||||
test("updatePendingRequest keeps pending detail API view in sync", () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
const requestId = usageHistory.trackPendingRequest(
|
||||
"claude-sonnet-4-6",
|
||||
"cc-test",
|
||||
"conn-1",
|
||||
true,
|
||||
{
|
||||
clientRequest: { model: "cc-test/claude-sonnet-4-6", reasoning_effort: "xhigh" },
|
||||
providerRequest: { model: "claude-sonnet-4-6", reasoning_effort: "xhigh" },
|
||||
}
|
||||
);
|
||||
assert.ok(requestId, "trackPendingRequest should return an id");
|
||||
|
||||
usageHistory.updatePendingRequest("claude-sonnet-4-6", "cc-test", "conn-1", {
|
||||
providerRequest: { model: "claude-sonnet-4-6", reasoning_effort: "high" },
|
||||
stage: "provider_response_started",
|
||||
});
|
||||
|
||||
const modelKey = "claude-sonnet-4-6 (cc-test)";
|
||||
const detailFromQueue = usageHistory.getPendingRequests().details["conn-1"]?.[modelKey]?.[0];
|
||||
const detailFromId = usageHistory.getPendingById().get(requestId);
|
||||
|
||||
assert.equal(detailFromId, detailFromQueue);
|
||||
assert.deepEqual(detailFromId?.providerRequest, {
|
||||
model: "claude-sonnet-4-6",
|
||||
reasoning_effort: "high",
|
||||
});
|
||||
assert.deepEqual(detailFromId?.clientRequest, {
|
||||
model: "cc-test/claude-sonnet-4-6",
|
||||
reasoning_effort: "xhigh",
|
||||
});
|
||||
});
|
||||
|
||||
test("updatePendingRequestStreamChunks stores empty streamChunks object (not null)", () => {
|
||||
usageHistory.clearPendingRequests();
|
||||
usageHistory.trackPendingRequest("gpt-4", "openai", "conn-1", true);
|
||||
@@ -168,6 +202,75 @@ test("GET /api/usage/call-logs/[id] route exists and uses auth", () => {
|
||||
assert.ok(content.includes("getCallLogById"), "should import getCallLogById");
|
||||
});
|
||||
|
||||
test("GET /api/usage/call-logs includes completed in-memory fallback rows", async () => {
|
||||
const { buildCallLogListRows } = await import("../../src/app/api/usage/call-logs/route.ts");
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
const requestId = usageHistory.trackPendingRequest("gpt-4", "openai", "completed-conn-1", true, {
|
||||
clientEndpoint: "/v1/chat/completions",
|
||||
});
|
||||
assert.ok(requestId);
|
||||
|
||||
const completed = usageHistory.finalizePendingRequestById(requestId, {
|
||||
clientResponse: { choices: [{ message: { content: "done" } }] },
|
||||
});
|
||||
assert.equal(completed, true);
|
||||
|
||||
const completedDetail = usageHistory.getCompletedDetails().get(requestId);
|
||||
assert.ok(completedDetail);
|
||||
const rows = buildCallLogListRows({
|
||||
logs: [],
|
||||
connections: [{ id: "completed-conn-1", displayName: "Completed Account" }],
|
||||
pendingDetails: usageHistory.getPendingById().values(),
|
||||
completedDetails: usageHistory.getCompletedDetails().values(),
|
||||
now: completedDetail!.startedAt + 60_000,
|
||||
});
|
||||
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].id, requestId);
|
||||
assert.equal(rows[0].detailState, "in-memory");
|
||||
assert.equal(rows[0].completed, true);
|
||||
assert.equal(rows[0].account, "Completed Account");
|
||||
assert.equal(rows[0].duration, completedDetail!.durationMs);
|
||||
});
|
||||
|
||||
test("GET /api/usage/call-logs rows are globally timestamp ordered", async () => {
|
||||
const { buildCallLogListRows } = await import("../../src/app/api/usage/call-logs/route.ts");
|
||||
const rows = buildCallLogListRows({
|
||||
logs: [
|
||||
{
|
||||
id: "persisted-newest",
|
||||
timestamp: new Date(3000).toISOString(),
|
||||
active: false,
|
||||
},
|
||||
{
|
||||
id: "persisted-oldest",
|
||||
timestamp: new Date(1000).toISOString(),
|
||||
active: false,
|
||||
},
|
||||
],
|
||||
connections: [],
|
||||
pendingDetails: [],
|
||||
completedDetails: [
|
||||
{
|
||||
id: "completed-middle",
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
connectionId: "conn",
|
||||
startedAt: 2000,
|
||||
completedAt: 2500,
|
||||
durationMs: 500,
|
||||
},
|
||||
],
|
||||
now: 4000,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
rows.map((row) => row.id),
|
||||
["persisted-newest", "completed-middle", "persisted-oldest"]
|
||||
);
|
||||
});
|
||||
|
||||
test("getCallLogById returns null for unknown id", async () => {
|
||||
const log = await callLogs.getCallLogById("nonexistent-id-12345");
|
||||
assert.equal(log, null);
|
||||
@@ -382,6 +485,49 @@ test("createRequestLogger with connectionId/model/provider populates streamChunk
|
||||
assert.deepEqual(detail.streamChunks.client, []);
|
||||
});
|
||||
|
||||
test("createRequestLogger requestId binds streamChunks to the exact pending request", async () => {
|
||||
const { createRequestLogger } = await import("../../open-sse/utils/requestLogger.ts");
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
const firstId = usageHistory.trackPendingRequest("gpt-4", "openai", "test-conn-a", true);
|
||||
const secondId = usageHistory.trackPendingRequest("gpt-4", "openai", "test-conn-b", true);
|
||||
|
||||
const logger = await createRequestLogger("openai", "openai", "gpt-4", {
|
||||
enabled: true,
|
||||
captureStreamChunks: true,
|
||||
requestId: secondId,
|
||||
});
|
||||
|
||||
logger.appendProviderChunk('data: {"content":"second"}');
|
||||
|
||||
const first = usageHistory.getPendingById().get(firstId);
|
||||
const second = usageHistory.getPendingById().get(secondId);
|
||||
|
||||
assert.equal(first?.streamChunks, undefined);
|
||||
assert.equal(second?.streamChunks?.provider[0], 'data: {"content":"second"}');
|
||||
});
|
||||
|
||||
test("createRequestLogger fallback match does not cross connectionId boundaries", async () => {
|
||||
const { createRequestLogger } = await import("../../open-sse/utils/requestLogger.ts");
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
usageHistory.trackPendingRequest("gpt-4", "openai", "test-conn-a", true);
|
||||
const secondId = usageHistory.trackPendingRequest("gpt-4", "openai", "test-conn-b", true);
|
||||
|
||||
const logger = await createRequestLogger("openai", "openai", "gpt-4", {
|
||||
enabled: true,
|
||||
captureStreamChunks: true,
|
||||
connectionId: "test-conn-b",
|
||||
model: "gpt-4",
|
||||
provider: "openai",
|
||||
});
|
||||
|
||||
logger.appendProviderChunk('data: {"content":"conn-b"}');
|
||||
|
||||
const second = usageHistory.getPendingById().get(secondId);
|
||||
assert.equal(second?.streamChunks?.provider[0], 'data: {"content":"conn-b"}');
|
||||
});
|
||||
|
||||
test("createRequestLogger without connectionId does not populate streamChunks", async () => {
|
||||
const { createRequestLogger } = await import("../../open-sse/utils/requestLogger.ts");
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
70
tests/unit/responses-chat-assistant-role-first-chunk.test.ts
Normal file
70
tests/unit/responses-chat-assistant-role-first-chunk.test.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { openaiResponsesToOpenAIResponse } =
|
||||
await import("../../open-sse/translator/response/openai-responses.ts");
|
||||
|
||||
/**
|
||||
* Regression: Responses → Chat Completions streaming must announce the assistant
|
||||
* role on the FIRST emitted delta.
|
||||
*
|
||||
* OpenAI Chat Completions streams put `role: "assistant"` on the first chunk's
|
||||
* delta. The Responses API has no role-announcement event, so the translator must
|
||||
* synthesize it. Strict streaming clients — notably @langchain/openai's
|
||||
* `_convertDeltaToMessageChunk` (used by n8n's AI Agent) — key off the first
|
||||
* chunk's role to build an AIMessageChunk. Without it, streamed tool_call deltas
|
||||
* are dropped and the agent receives an empty response.
|
||||
*/
|
||||
|
||||
test("Responses->Chat: first tool_call chunk announces role=assistant", () => {
|
||||
const state: Record<string, unknown> = {};
|
||||
|
||||
const first = openaiResponsesToOpenAIResponse(
|
||||
{
|
||||
type: "response.output_item.added",
|
||||
item: {
|
||||
type: "function_call",
|
||||
call_id: "call_abc",
|
||||
name: "get_weather",
|
||||
arguments: "",
|
||||
},
|
||||
},
|
||||
state
|
||||
);
|
||||
|
||||
assert.ok(first, "should emit a chunk for output_item.added");
|
||||
assert.equal(
|
||||
first.choices[0].delta.role,
|
||||
"assistant",
|
||||
"first emitted delta must carry role=assistant"
|
||||
);
|
||||
assert.equal(first.choices[0].delta.tool_calls[0].function.name, "get_weather");
|
||||
|
||||
// Subsequent argument deltas must NOT repeat the role announcement.
|
||||
const next = openaiResponsesToOpenAIResponse(
|
||||
{ type: "response.function_call_arguments.delta", delta: '{"x":1}' },
|
||||
state
|
||||
);
|
||||
assert.ok(next, "should emit a chunk for arguments.delta");
|
||||
assert.equal(next.choices[0].delta.role, undefined, "only the first delta announces the role");
|
||||
});
|
||||
|
||||
test("Responses->Chat: first text chunk announces role=assistant", () => {
|
||||
const state: Record<string, unknown> = {};
|
||||
|
||||
const first = openaiResponsesToOpenAIResponse(
|
||||
{ type: "response.output_text.delta", delta: "Olá" },
|
||||
state
|
||||
);
|
||||
|
||||
assert.ok(first, "should emit a chunk for output_text.delta");
|
||||
assert.equal(first.choices[0].delta.role, "assistant");
|
||||
assert.equal(first.choices[0].delta.content, "Olá");
|
||||
|
||||
const next = openaiResponsesToOpenAIResponse(
|
||||
{ type: "response.output_text.delta", delta: " mundo" },
|
||||
state
|
||||
);
|
||||
assert.equal(next.choices[0].delta.role, undefined);
|
||||
assert.equal(next.choices[0].delta.content, " mundo");
|
||||
});
|
||||
@@ -399,6 +399,44 @@ test("pipeWithDisconnect clears pending requests when the upstream stream errors
|
||||
assert.equal(pending.details[connectionId], undefined);
|
||||
});
|
||||
|
||||
test("pipeWithDisconnect lets controller onError own pending cleanup", async () => {
|
||||
clearPendingRequests();
|
||||
const provider = "openai";
|
||||
const model = "gpt-stream-error-owned";
|
||||
const connectionId = "conn-stream-error-owned";
|
||||
const modelKey = `${model} (${provider})`;
|
||||
let errorEvent = null;
|
||||
|
||||
trackPendingRequest(model, provider, connectionId, true);
|
||||
|
||||
const source = new ReadableStream({
|
||||
start(controller) {
|
||||
controller.error(Object.assign(new Error("terminated"), { statusCode: 502 }));
|
||||
},
|
||||
});
|
||||
const stream = pipeWithDisconnect(
|
||||
new Response(source),
|
||||
new TransformStream(),
|
||||
createStreamController({
|
||||
provider,
|
||||
model,
|
||||
connectionId,
|
||||
onError(event) {
|
||||
errorEvent = event;
|
||||
return true;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const text = await readStreamText(stream);
|
||||
const pending = getPendingRequests();
|
||||
|
||||
assert.match(text, /"message":"terminated"/);
|
||||
assert.equal(errorEvent?.statusCode, 502);
|
||||
assert.equal(pending.byModel[modelKey], 1);
|
||||
assert.equal(pending.byAccount[connectionId][modelKey], 1);
|
||||
});
|
||||
|
||||
test("pipeWithDisconnect does not double-clear transform errors already accounted for", async () => {
|
||||
clearPendingRequests();
|
||||
const provider = "openai";
|
||||
|
||||
@@ -65,6 +65,78 @@ function parseJsonDataPayloads(text) {
|
||||
.map((data) => JSON.parse(data));
|
||||
}
|
||||
|
||||
function parseSillyTavernCustomOpenAIStream(text) {
|
||||
const events = [];
|
||||
let reasoning = "";
|
||||
let content = "";
|
||||
|
||||
for (const payload of parseJsonDataPayloads(text)) {
|
||||
const choices = Array.isArray(payload.choices) ? payload.choices : [];
|
||||
const reasoningDelta =
|
||||
choices.find((choice) => choice?.delta?.reasoning_content)?.delta?.reasoning_content ??
|
||||
choices.find((choice) => choice?.delta?.reasoning)?.delta?.reasoning ??
|
||||
"";
|
||||
const contentDelta =
|
||||
choices[0]?.delta?.content ?? choices[0]?.message?.content ?? choices[0]?.text ?? "";
|
||||
|
||||
reasoning += reasoningDelta;
|
||||
content += contentDelta;
|
||||
events.push({ reasoningDelta, contentDelta, reasoning, content });
|
||||
}
|
||||
|
||||
return { reasoning, content, events };
|
||||
}
|
||||
|
||||
test("createSSEStream leaves successful pending requests for onComplete finalization", async () => {
|
||||
const usageHistory = await import("../../src/lib/usage/usageHistory.ts");
|
||||
usageHistory.clearPendingRequests();
|
||||
|
||||
const model = "gpt-4";
|
||||
const provider = "openai";
|
||||
const connectionId = "stream-on-complete-finalize";
|
||||
const requestId = usageHistory.trackPendingRequest(model, provider, connectionId, true);
|
||||
|
||||
let finalizedInOnComplete = false;
|
||||
await readTransformed(
|
||||
[
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_pending_finalize",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model,
|
||||
choices: [{ index: 0, delta: { content: "hello" }, finish_reason: null }],
|
||||
})}\n\n`,
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_pending_finalize",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
|
||||
})}\n\n`,
|
||||
],
|
||||
{
|
||||
mode: "passthrough",
|
||||
sourceFormat: FORMATS.OPENAI,
|
||||
provider,
|
||||
model,
|
||||
connectionId,
|
||||
body: { messages: [{ role: "user", content: "hello" }] },
|
||||
onComplete(payload) {
|
||||
finalizedInOnComplete = usageHistory.finalizePendingRequestById(requestId, {
|
||||
providerResponse: payload.providerPayload,
|
||||
clientResponse: payload.clientPayload,
|
||||
});
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(finalizedInOnComplete, true);
|
||||
assert.ok(usageHistory.getCompletedDetails().has(requestId));
|
||||
assert.equal(usageHistory.getPendingById().has(requestId), false);
|
||||
usageHistory.clearPendingRequests();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
@@ -726,8 +798,9 @@ test("createSSEStream translate mode converts Claude SSE into OpenAI chunks and
|
||||
assert.match(text, /\[DONE\]/);
|
||||
assert.equal(onCompletePayload.status, 200);
|
||||
assert.equal(onCompletePayload.responseBody.choices[0].message.content, "Hello Claude");
|
||||
assert.equal(onCompletePayload.responseBody.usage.prompt_tokens, 3);
|
||||
assert.equal(onCompletePayload.responseBody.usage.completion_tokens, 4);
|
||||
assert.equal(onCompletePayload.responseBody.usage.total_tokens, 4);
|
||||
assert.equal(onCompletePayload.responseBody.usage.total_tokens, 7);
|
||||
});
|
||||
|
||||
test("createSSEStream translate mode preserves Claude text_delta thinking tags as content", async () => {
|
||||
@@ -1378,6 +1451,53 @@ test("createSSEStream passthrough splits mixed reasoning and content deltas and
|
||||
assert.ok(onCompletePayload.responseBody.usage.total_tokens > 0);
|
||||
});
|
||||
|
||||
test("createSSEStream passthrough output is consumable by SillyTavern-style reasoning parser", async () => {
|
||||
const text = await readTransformed(
|
||||
[
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_reasoning_st",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "gpt-4.1-mini",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {
|
||||
reasoning_content: "First think",
|
||||
content: "Then answer",
|
||||
},
|
||||
},
|
||||
],
|
||||
})}\n\n`,
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_reasoning_st",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "gpt-4.1-mini",
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
})}\n\n`,
|
||||
],
|
||||
{
|
||||
mode: "passthrough",
|
||||
sourceFormat: FORMATS.OPENAI,
|
||||
provider: "openai-compatible",
|
||||
model: "gpt-4.1-mini",
|
||||
body: {
|
||||
messages: [{ role: "user", content: "hello world" }],
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const parsed = parseSillyTavernCustomOpenAIStream(text);
|
||||
const reasoningEventIndex = parsed.events.findIndex((event) => event.reasoningDelta);
|
||||
const contentEventIndex = parsed.events.findIndex((event) => event.contentDelta);
|
||||
|
||||
assert.equal(parsed.reasoning, "First think");
|
||||
assert.equal(parsed.content, "Then answer");
|
||||
assert.ok(reasoningEventIndex >= 0);
|
||||
assert.ok(contentEventIndex > reasoningEventIndex);
|
||||
});
|
||||
|
||||
test("createSSEStream passthrough writes complete SSE events per converted chunk", async () => {
|
||||
const convertedChunks = [];
|
||||
await readTransformed(
|
||||
@@ -1688,8 +1808,9 @@ test("createSSETransformStreamWithLogger flushes a trailing Claude usage event w
|
||||
assert.match(text, /Buffered tail/);
|
||||
assert.match(text, /\[DONE\]/);
|
||||
assert.equal(onCompletePayload.responseBody.choices[0].message.content, "Buffered tail");
|
||||
assert.equal(onCompletePayload.responseBody.usage.prompt_tokens, 3);
|
||||
assert.equal(onCompletePayload.responseBody.usage.completion_tokens, 5);
|
||||
assert.equal(onCompletePayload.responseBody.usage.total_tokens, 5);
|
||||
assert.equal(onCompletePayload.responseBody.usage.total_tokens, 8);
|
||||
});
|
||||
|
||||
test("buildStreamSummaryFromEvents compacts Responses API deltas into a synthetic response", () => {
|
||||
|
||||
71
tests/unit/sync-wiki.test.ts
Normal file
71
tests/unit/sync-wiki.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Unit tests for the pure helpers in scripts/docs/sync-wiki.mjs (the GitHub wiki sync).
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
normKey,
|
||||
toWikiName,
|
||||
toWikiContent,
|
||||
syncHomeCounts,
|
||||
parseWikiPage,
|
||||
WIKI_BANNER,
|
||||
NEW_PAGE_EXCLUDE,
|
||||
} from "../../scripts/docs/sync-wiki.mjs";
|
||||
|
||||
test("normKey: case/separator-insensitive fuzzy key (matches docs basename to curated wiki name)", () => {
|
||||
// The wiki names are hand-curated and not deterministic — normKey is what lets us
|
||||
// match e.g. FLY_IO_DEPLOYMENT_GUIDE.md to the existing "Fly-io-Deployment-Guide" page.
|
||||
assert.equal(normKey("FLY_IO_DEPLOYMENT_GUIDE.md"), normKey("Fly-io-Deployment-Guide"));
|
||||
assert.equal(normKey("API_REFERENCE"), normKey("API-Reference"));
|
||||
assert.equal(normKey("A2A-SERVER.md"), "a2aserver");
|
||||
});
|
||||
|
||||
test("toWikiName: acronym-aware Title-Case-dashed name for NEW pages", () => {
|
||||
assert.equal(toWikiName("SUPPLY_CHAIN.md"), "Supply-Chain");
|
||||
assert.equal(toWikiName("API_REFERENCE"), "API-Reference");
|
||||
assert.equal(toWikiName("QUOTA_SHARE"), "Quota-Share");
|
||||
assert.equal(toWikiName("ACP"), "ACP");
|
||||
});
|
||||
|
||||
test("toWikiContent: strips YAML frontmatter and prepends the language banner", () => {
|
||||
const doc = '---\ntitle: "X"\nversion: 3.8.2\n---\n\n# Heading\n\nBody text.\n';
|
||||
const out = toWikiContent(doc);
|
||||
assert.ok(out.startsWith(WIKI_BANNER), "must start with the wiki language banner");
|
||||
assert.ok(!out.includes("version: 3.8.2"), "frontmatter must be removed");
|
||||
assert.ok(out.includes("# Heading"), "content body must be preserved");
|
||||
assert.ok(out.endsWith("Body text.\n"), "trailing newline normalized");
|
||||
});
|
||||
|
||||
test("toWikiContent: a document without frontmatter is kept intact (plus banner)", () => {
|
||||
const doc = "# No Frontmatter\n\nHello.\n";
|
||||
const out = toWikiContent(doc);
|
||||
assert.equal(out, WIKI_BANNER + "# No Frontmatter\n\nHello.\n");
|
||||
});
|
||||
|
||||
test("syncHomeCounts: rewrites the cover-page provider/strategy counts", () => {
|
||||
const home = "Connect every AI tool to 177 providers.\n**177 AI Providers** · **14 Routing Strategies**\n";
|
||||
const out = syncHomeCounts(home, { providers: 226, strategies: 15, mcpTools: null, locales: 42 });
|
||||
assert.ok(out.includes("226 providers"));
|
||||
assert.ok(out.includes("**226 AI Providers**"));
|
||||
assert.ok(out.includes("**15 Routing Strategies**"));
|
||||
assert.ok(!out.includes("177"));
|
||||
});
|
||||
|
||||
test("syncHomeCounts: leaves text untouched when a count is null (best-effort MCP)", () => {
|
||||
const home = "| **MCP Server** | 87 tools |\n";
|
||||
const out = syncHomeCounts(home, { providers: null, strategies: null, mcpTools: null, locales: null });
|
||||
assert.equal(out, home);
|
||||
});
|
||||
|
||||
test("parseWikiPage: splits the locale prefix (U+2010) from the page name", () => {
|
||||
assert.deepEqual(parseWikiPage("Architecture"), { locale: null, name: "Architecture" });
|
||||
// U+2010 HYPHEN separator used by the localized mirrors (e.g. "pt-BR‐Architecture").
|
||||
assert.deepEqual(parseWikiPage("pt-BR‐Architecture"), { locale: "pt-BR", name: "Architecture" });
|
||||
assert.deepEqual(parseWikiPage("phi‐API-Reference"), { locale: "phi", name: "API-Reference" });
|
||||
});
|
||||
|
||||
test("NEW_PAGE_EXCLUDE: internal reports/plans never become public wiki pages", () => {
|
||||
assert.ok(NEW_PAGE_EXCLUDE.has("DOCUMENTATION_AUDIT_REPORT"));
|
||||
assert.ok(NEW_PAGE_EXCLUDE.has("README"));
|
||||
assert.ok(!NEW_PAGE_EXCLUDE.has("SUPPLY_CHAIN"));
|
||||
});
|
||||
171
tests/unit/vertex-media.test.ts
Normal file
171
tests/unit/vertex-media.test.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { Buffer } from "node:buffer";
|
||||
|
||||
import {
|
||||
pcmToWav,
|
||||
vertexGenerateSpeech,
|
||||
vertexTranscribe,
|
||||
vertexGenerateMusic,
|
||||
vertexGenerateVideo,
|
||||
} from "../../open-sse/executors/vertexMedia.ts";
|
||||
|
||||
// Service Account credential with a pre-set accessToken so resolveVertexAuth never
|
||||
// performs a real OAuth token exchange (getAccessToken is skipped when accessToken is present).
|
||||
function saCredentials(region = "us-central1") {
|
||||
return {
|
||||
apiKey: JSON.stringify({ project_id: "proj-test", client_email: "svc@x.iam", private_key: "x" }),
|
||||
accessToken: "test-bearer-token",
|
||||
providerSpecificData: { region },
|
||||
};
|
||||
}
|
||||
|
||||
function expressCredentials() {
|
||||
return { apiKey: "express-key-abc", accessToken: null, providerSpecificData: {} };
|
||||
}
|
||||
|
||||
interface FetchCall {
|
||||
url: string;
|
||||
init: any;
|
||||
}
|
||||
|
||||
function installFetch(responders: Array<(call: FetchCall) => unknown>) {
|
||||
const calls: FetchCall[] = [];
|
||||
let i = 0;
|
||||
(globalThis as any).fetch = async (url: string, init: any) => {
|
||||
const call = { url: String(url), init };
|
||||
calls.push(call);
|
||||
const payload = responders[Math.min(i, responders.length - 1)](call);
|
||||
i += 1;
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => payload,
|
||||
text: async () => JSON.stringify(payload),
|
||||
};
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
||||
test("pcmToWav writes a valid RIFF/WAVE header with correct sizes", () => {
|
||||
const pcm = Buffer.from([1, 2, 3, 4, 5, 6, 7, 8]);
|
||||
const wav = pcmToWav(pcm, 24000);
|
||||
assert.equal(wav.subarray(0, 4).toString("ascii"), "RIFF");
|
||||
assert.equal(wav.subarray(8, 12).toString("ascii"), "WAVE");
|
||||
assert.equal(wav.readUInt32LE(4), 36 + pcm.length); // RIFF chunk size
|
||||
assert.equal(wav.readUInt32LE(24), 24000); // sample rate
|
||||
assert.equal(wav.readUInt32LE(40), pcm.length); // data chunk size
|
||||
assert.equal(wav.length, 44 + pcm.length);
|
||||
});
|
||||
|
||||
test("vertexGenerateSpeech posts generateContent with AUDIO modality and returns WAV", async () => {
|
||||
const pcmB64 = Buffer.from([10, 20, 30, 40]).toString("base64");
|
||||
const calls = installFetch([
|
||||
() => ({
|
||||
candidates: [
|
||||
{ content: { parts: [{ inlineData: { data: pcmB64, mimeType: "audio/L16;codec=pcm;rate=24000" } }] } },
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const { audio, contentType } = await vertexGenerateSpeech(saCredentials("europe-west4"), {
|
||||
model: "gemini-2.5-flash-preview-tts",
|
||||
input: "Hello world",
|
||||
voice: "Puck",
|
||||
});
|
||||
|
||||
assert.equal(contentType, "audio/wav");
|
||||
assert.equal(audio.subarray(0, 4).toString("ascii"), "RIFF");
|
||||
assert.equal(
|
||||
calls[0].url,
|
||||
"https://europe-west4-aiplatform.googleapis.com/v1/projects/proj-test/locations/europe-west4/publishers/google/models/gemini-2.5-flash-preview-tts:generateContent"
|
||||
);
|
||||
const body = JSON.parse(calls[0].init.body);
|
||||
assert.deepEqual(body.generationConfig.responseModalities, ["AUDIO"]);
|
||||
assert.equal(body.generationConfig.speechConfig.voiceConfig.prebuiltVoiceConfig.voiceName, "Puck");
|
||||
assert.equal(calls[0].init.headers.Authorization, "Bearer test-bearer-token");
|
||||
});
|
||||
|
||||
test("vertexGenerateSpeech defaults the voice to Kore", async () => {
|
||||
const pcmB64 = Buffer.from([1, 2]).toString("base64");
|
||||
const calls = installFetch([
|
||||
() => ({ candidates: [{ content: { parts: [{ inlineData: { data: pcmB64, mimeType: "audio/L16;rate=16000" } }] } }] }),
|
||||
]);
|
||||
await vertexGenerateSpeech(saCredentials(), { model: "gemini-2.5-flash-preview-tts", input: "hi" });
|
||||
const body = JSON.parse(calls[0].init.body);
|
||||
assert.equal(body.generationConfig.speechConfig.voiceConfig.prebuiltVoiceConfig.voiceName, "Kore");
|
||||
});
|
||||
|
||||
test("vertexTranscribe posts audio inlineData and returns the joined text", async () => {
|
||||
const calls = installFetch([
|
||||
() => ({ candidates: [{ content: { parts: [{ text: "the quick brown fox" }] } }] }),
|
||||
]);
|
||||
const text = await vertexTranscribe(saCredentials(), {
|
||||
model: "gemini-2.5-flash",
|
||||
audioBase64: "QUJD",
|
||||
mimeType: "audio/mpeg",
|
||||
prompt: "Transcribe please",
|
||||
});
|
||||
assert.equal(text, "the quick brown fox");
|
||||
const body = JSON.parse(calls[0].init.body);
|
||||
const parts = body.contents[0].parts;
|
||||
assert.equal(parts[0].text, "Transcribe please");
|
||||
assert.equal(parts[1].inlineData.mimeType, "audio/mpeg");
|
||||
assert.equal(parts[1].inlineData.data, "QUJD");
|
||||
assert.ok(calls[0].url.endsWith("/gemini-2.5-flash:generateContent"));
|
||||
});
|
||||
|
||||
test("vertexGenerateMusic posts predict to lyria and returns base64 WAV", async () => {
|
||||
const calls = installFetch([() => ({ predictions: [{ bytesBase64Encoded: "TY9MUA==" }] })]);
|
||||
const { base64, format } = await vertexGenerateMusic(saCredentials(), {
|
||||
model: "lyria-002",
|
||||
prompt: "relaxing sax",
|
||||
});
|
||||
assert.equal(base64, "TY9MUA==");
|
||||
assert.equal(format, "wav");
|
||||
assert.ok(calls[0].url.endsWith("/lyria-002:predict"));
|
||||
const body = JSON.parse(calls[0].init.body);
|
||||
assert.equal(body.instances[0].prompt, "relaxing sax");
|
||||
});
|
||||
|
||||
test("vertexGenerateVideo submits predictLongRunning then polls fetchPredictOperation", async () => {
|
||||
const calls = installFetch([
|
||||
() => ({ name: "projects/proj-test/.../operations/op-1" }), // submit
|
||||
() => ({ name: "projects/proj-test/.../operations/op-1" }), // poll #1 (not done)
|
||||
() => ({
|
||||
done: true,
|
||||
response: { videos: [{ bytesBase64Encoded: "TVA0VklERU8=" }] },
|
||||
}), // poll #2 (done)
|
||||
]);
|
||||
|
||||
const result = await vertexGenerateVideo(saCredentials(), {
|
||||
model: "veo-3.0-fast-generate-001",
|
||||
prompt: "a cat playing piano",
|
||||
aspectRatio: "16:9",
|
||||
durationSeconds: 4,
|
||||
pollIntervalMs: 1,
|
||||
maxWaitMs: 5000,
|
||||
});
|
||||
|
||||
assert.equal(result.base64, "TVA0VklERU8=");
|
||||
assert.equal(result.format, "mp4");
|
||||
assert.ok(calls[0].url.endsWith("/veo-3.0-fast-generate-001:predictLongRunning"));
|
||||
assert.ok(calls[1].url.endsWith("/veo-3.0-fast-generate-001:fetchPredictOperation"));
|
||||
const submitBody = JSON.parse(calls[0].init.body);
|
||||
assert.equal(submitBody.parameters.aspectRatio, "16:9");
|
||||
assert.equal(submitBody.parameters.durationSeconds, 4);
|
||||
assert.equal(submitBody.instances[0].prompt, "a cat playing piano");
|
||||
});
|
||||
|
||||
test("Express API key uses the project-less publisher endpoint with ?key=", async () => {
|
||||
const pcmB64 = Buffer.from([1]).toString("base64");
|
||||
const calls = installFetch([
|
||||
() => ({ candidates: [{ content: { parts: [{ inlineData: { data: pcmB64, mimeType: "audio/L16;rate=24000" } }] } }] }),
|
||||
]);
|
||||
await vertexGenerateSpeech(expressCredentials(), { model: "gemini-2.5-flash-preview-tts", input: "hi" });
|
||||
assert.equal(
|
||||
calls[0].url,
|
||||
"https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-2.5-flash-preview-tts:generateContent?key=express-key-abc"
|
||||
);
|
||||
assert.equal(calls[0].init.headers.Authorization, undefined);
|
||||
});
|
||||
31
tests/unit/vertex-oauth-scopes.test.ts
Normal file
31
tests/unit/vertex-oauth-scopes.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #3922 — the Vertex SA access token must carry BOTH cloud-platform (chat/image
|
||||
// execution on aiplatform.googleapis.com) AND generative-language.retriever
|
||||
// (live model discovery on generativelanguage.googleapis.com). Dropping the
|
||||
// second scope makes discovery return 403 ACCESS_TOKEN_SCOPE_INSUFFICIENT and
|
||||
// silently fall back to the static ~10-model registry list. This guards both.
|
||||
const { VERTEX_OAUTH_SCOPES } = await import("../../open-sse/executors/vertex.ts");
|
||||
|
||||
test("Vertex OAuth scopes include cloud-platform (execution)", () => {
|
||||
assert.ok(
|
||||
VERTEX_OAUTH_SCOPES.includes("https://www.googleapis.com/auth/cloud-platform"),
|
||||
"cloud-platform scope is required for Vertex AI execution"
|
||||
);
|
||||
});
|
||||
|
||||
test("Vertex OAuth scopes include generative-language.retriever (discovery)", () => {
|
||||
assert.ok(
|
||||
VERTEX_OAUTH_SCOPES.includes("https://www.googleapis.com/auth/generative-language.retriever"),
|
||||
"generative-language.retriever scope is required for live model discovery"
|
||||
);
|
||||
});
|
||||
|
||||
test("Vertex OAuth scopes serialize as a space-delimited string for the JWT", () => {
|
||||
const serialized = VERTEX_OAUTH_SCOPES.join(" ");
|
||||
assert.equal(
|
||||
serialized,
|
||||
"https://www.googleapis.com/auth/cloud-platform https://www.googleapis.com/auth/generative-language.retriever"
|
||||
);
|
||||
});
|
||||
226
tests/unit/zai-validator.test.ts
Normal file
226
tests/unit/zai-validator.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import http from "node:http";
|
||||
|
||||
// #3905 — Z.AI (glm) provider validation must use directHttpsRequest (native HTTPS,
|
||||
// bypass the undici pool) for the same reason NVIDIA uses it in #3226: api.z.ai
|
||||
// silently drops idle keep-alive sockets without TCP RST after 502 responses,
|
||||
// causing undici to reuse dead sockets and hang for up to headersTimeout (600 s).
|
||||
//
|
||||
// Test design (same approach as nvidia-nim-validator.test.ts):
|
||||
// - Import at FILE LOAD so proxyFetch captures the unpatched globalThis.fetch.
|
||||
// - Redirect the validator at a local HTTP server via providerSpecificData.baseUrl.
|
||||
// The safeOutboundFetch guard is "none" for validation calls, so 127.0.0.1 is
|
||||
// reachable. directHttpsRequest accepts plain HTTP URLs in test environments.
|
||||
// - Assert that (a) the correct auth header is used, (b) 401/403 → "Invalid API key",
|
||||
// (c) any other status → valid (including 502 which is z.ai's queue timeout, not auth).
|
||||
const { validateProviderApiKey } = await import("../../src/lib/providers/validation.ts");
|
||||
|
||||
async function withMockServer(
|
||||
handler: (req: http.IncomingMessage, res: http.ServerResponse) => void,
|
||||
fn: (baseUrl: string) => Promise<void>
|
||||
): Promise<void> {
|
||||
const server = http.createServer(handler);
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
const port = typeof address === "object" && address ? address.port : 0;
|
||||
// Omit the path — the validator appends ?beta=true itself via the baseUrl override.
|
||||
const baseUrl = `http://127.0.0.1:${port}`;
|
||||
try {
|
||||
await fn(baseUrl);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
|
||||
test("zai validator returns Invalid API key on 401", async () => {
|
||||
await withMockServer(
|
||||
(_req, res) => {
|
||||
res.writeHead(401, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: { message: "token expired or incorrect", type: "401" } }));
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "zai",
|
||||
apiKey: "bad-key",
|
||||
providerSpecificData: { baseUrl },
|
||||
});
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.error, "Invalid API key");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("zai validator returns Invalid API key on 403", async () => {
|
||||
await withMockServer(
|
||||
(_req, res) => {
|
||||
res.writeHead(403, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: { message: "forbidden" } }));
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "zai",
|
||||
apiKey: "bad-key",
|
||||
providerSpecificData: { baseUrl },
|
||||
});
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.error, "Invalid API key");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("zai validator accepts a successful 200 probe", async () => {
|
||||
await withMockServer(
|
||||
(_req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ content: [{ text: "x" }] }));
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "zai",
|
||||
apiKey: "valid-key",
|
||||
providerSpecificData: { baseUrl },
|
||||
});
|
||||
assert.equal(result.valid, true);
|
||||
assert.equal(result.error, null);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("zai validator treats 502 as valid (z.ai queue timeout is not an auth error)", async () => {
|
||||
await withMockServer(
|
||||
(_req, res) => {
|
||||
res.writeHead(502, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: { message: "job timed out after 120s" } }));
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "zai",
|
||||
apiKey: "valid-key",
|
||||
providerSpecificData: { baseUrl },
|
||||
});
|
||||
assert.equal(result.valid, true);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("zai validator returns error on 404 (wrong endpoint)", async () => {
|
||||
await withMockServer(
|
||||
(_req, res) => {
|
||||
res.writeHead(404, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "not found" }));
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "zai",
|
||||
apiKey: "any-key",
|
||||
providerSpecificData: { baseUrl },
|
||||
});
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.error, "Provider validation endpoint not supported");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("zai validator returns error on 5xx other than 502 (provider down)", async () => {
|
||||
await withMockServer(
|
||||
(_req, res) => {
|
||||
res.writeHead(503, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: "service unavailable" }));
|
||||
},
|
||||
async (baseUrl) => {
|
||||
const result = await validateProviderApiKey({
|
||||
provider: "zai",
|
||||
apiKey: "any-key",
|
||||
providerSpecificData: { baseUrl },
|
||||
});
|
||||
assert.equal(result.valid, false);
|
||||
assert.equal(result.error, "Provider unavailable (503)");
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test("zai validator sends x-api-key header (Anthropic wire format, not Bearer)", async () => {
|
||||
let capturedHeaders: http.IncomingHttpHeaders = {};
|
||||
let capturedMethod = "";
|
||||
let capturedBody = "";
|
||||
|
||||
await withMockServer(
|
||||
(req, res) => {
|
||||
capturedHeaders = req.headers;
|
||||
capturedMethod = req.method ?? "";
|
||||
let body = "";
|
||||
req.on("data", (chunk) => {
|
||||
body += String(chunk);
|
||||
});
|
||||
req.on("end", () => {
|
||||
capturedBody = body;
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({}));
|
||||
});
|
||||
},
|
||||
async (baseUrl) => {
|
||||
await validateProviderApiKey({
|
||||
provider: "zai",
|
||||
apiKey: "test-zai-key-123",
|
||||
providerSpecificData: { baseUrl },
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(capturedMethod, "POST");
|
||||
assert.equal(
|
||||
capturedHeaders["x-api-key"],
|
||||
"test-zai-key-123",
|
||||
"must use x-api-key, not Authorization: Bearer"
|
||||
);
|
||||
assert.ok(
|
||||
!capturedHeaders["authorization"],
|
||||
`must not send Authorization header, got: ${capturedHeaders["authorization"]}`
|
||||
);
|
||||
assert.equal(capturedHeaders["anthropic-version"], "2023-06-01");
|
||||
assert.equal(capturedHeaders["content-type"], "application/json");
|
||||
|
||||
const body = JSON.parse(capturedBody);
|
||||
assert.equal(body.model, "glm-5.1");
|
||||
assert.equal(body.max_tokens, 1);
|
||||
assert.deepEqual(body.messages, [{ role: "user", content: "test" }]);
|
||||
});
|
||||
|
||||
test("zai validator uses directHttpsRequest (does not proxy through undici pool)", async () => {
|
||||
// The key invariant: directHttpsRequest calls safeOutboundFetch with
|
||||
// bypassProxyPatch:true, which uses the original (pre-patch) native fetch.
|
||||
// This means patching globalThis.fetch AFTER module load must NOT intercept it.
|
||||
// If the validator were using validationWrite (undici), globalThis.fetch would
|
||||
// still be the patched version at call time and we would see our mock called.
|
||||
let mockCalled = false;
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (...args: Parameters<typeof fetch>) => {
|
||||
mockCalled = true;
|
||||
return originalFetch(...args);
|
||||
};
|
||||
|
||||
try {
|
||||
await withMockServer(
|
||||
(_req, res) => {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end("{}");
|
||||
},
|
||||
async (baseUrl) => {
|
||||
await validateProviderApiKey({
|
||||
provider: "zai",
|
||||
apiKey: "key",
|
||||
providerSpecificData: { baseUrl },
|
||||
});
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
assert.equal(
|
||||
mockCalled,
|
||||
false,
|
||||
"zai validator must use bypassProxyPatch path, not the patched globalThis.fetch"
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user