mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-07-26 09:52:11 +03:00
Release v3.8.39 (#5164)
* chore(release): open v3.8.39 development cycle * docs(changelog): backfill 5 v3.8.38 bullets merged after release finalize These PRs squash-merged into release/v3.8.38 between the CHANGELOG finalize (ff57be32f) and the merge-to-main (ae6e2342d), so they shipped in the v3.8.38 tag but had no bullet: - feat(compression): Ionizer engine (lossy JSON-array sampling + CCR) (#5148) - fix(sse): preserve non-stream reasoning fields (#5155, @rdself) - fix(i18n): add missing English UI labels (#5153, @rdself) - test(combo): gated live smoke (#5151) + release-expectations refresh (#5150, @KooshaPari) (#5129 exact-host Anthropic baseUrl is already covered by the #5130 bullet — same CodeQL #674.) Synced 41 i18n CHANGELOG mirrors. * feat(compression): TOON best-of-N candidate encoder + encoder A/B table (#5163) Integrated into release/v3.8.39. TOON best-of-N candidate encoder (GCF default, fail-open). 17/17 unit tests pass on merge result; CI reds were base-stale + Quality Ratchet DRIFT. * fix(zenmux): normalize vendor-prefixed GLM system roles (#5158) Integrated into release/v3.8.39. ZenMux vendor-prefixed GLM system-role normalization; 12/12 role-normalizer tests pass on merge result. CI reds base-stale. * [codex] fix xAI OAuth test and reasoning effort (#5157) Integrated into release/v3.8.39. xAI reasoning-effort normalization (max/xhigh→high) + OAuth test config; 46/46 xai-translator tests pass on merge result. CI reds base-stale. * docs(i18n): add Traditional Chinese (zh-TW) README and update zh-CN to latest (#5162) Integrated into release/v3.8.39. Traditional Chinese (zh-TW) README + zh-CN refresh; docs-only. * test(security): guard PII redaction stays opt-in (default off) + Hard Rule #20 (#5159) Integrated into release/v3.8.39. PII opt-in regression guard + Hard Rule #20; rebased to strip base-drift (+81/-1). 5/5 guard tests pass; flip-proof verified. * test(combo): deterministic context-relay universal-handoff coverage (closes phase-2 TODO) (#5168) Integrated into release/v3.8.39. Deterministic context-relay universal-handoff coverage (3 tests); 3/3 pass on merge result. * docs(i18n): full sync zh-TW and zh-CN README with canonical English v3.8.39 (#5171) Integrated into release/v3.8.39. Full zh-TW docs tree + zh-CN sync with canonical English v3.8.39; docs-only. * fix(serve): honour HOSTNAME from .env instead of hardcoding 0.0.0.0 (#5134) (#5170) Integrated into release/v3.8.39. HOSTNAME env override in serve (#5134) + regression test (4/4, TDD flip-proof verified). * fix(sse): resolve nameless deepseek-web tool blocks via parameter-schema match (#5154) (#5173) Integrated into release/v3.8.39. Schema-based nameless deepseek-web tool-block resolution (#5154); 6/6 tests pass on merge result (incl. ambiguous/no-match negatives + named-tag no-regression). * fix(sse): normalize array user content for Command Code to avoid upstream 400 (#5166) (#5174) Integrated into release/v3.8.39. Normalize array user content for Command Code (#5166, user-array/400 symptom); 4/4 tests pass on merge result. * fix(sse): defer </think> close so it never leaks before tool_calls (#5123) (#5175) Integrated into release/v3.8.39. Defer </think> close so it never leaks before tool_calls (#5123); 4/4 tests pass (incl. #4633 no-regression). CHANGELOG synced to keep all 3 v3.8.39 fixes. * fix(dashboard): use amber for home update-step warning icon (#5176) Integrated into release/v3.8.39. Amber for home update-step warning icon; 1/1 UI test. * fix(api): LAN/Tailscale dashboard — host-aware CSP + GET-exempt version route + combo field errors (#5083) (#5177) Integrated into release/v3.8.39. Host-aware CSP (ReDoS/injection-safe host validation) + GET-exempt /api/system/version (POST/spawn stays LOCAL_ONLY, exact-match safe-methods-only) + COMBO_002 firstField. 44/44 tests + route-guard membership gate green. CHANGELOG synced to keep all 4 v3.8.39 fixes. * fix(api): replace #5083 global middleware CSP with declarative ws: scheme (#5083) Follow-up to PR #5177 (merged): that version implemented the LAN-CSP fix (Bug 1) with a new global `src/middleware.ts` + `src/server/csp.ts`, which contradicts the project's documented architecture — 'No global Next.js middleware — interception is route-specific' (CLAUDE.md / AGENTS.md) — and was merged unverified (middleware vs next.config header precedence was never confirmed in a real build). This replaces that approach with the minimal, declarative equivalent: • next.config.mjs: connect-src now permits the bare `ws:` scheme (symmetric with the bare `wss:` already allowed) so the dashboard can reach its own Live WS server from a LAN/Tailscale host. No middleware. • Removes src/middleware.ts, src/server/csp.ts, and tests/unit/csp-host-aware.test.ts. • Adds tests/unit/csp-lan-ws-5083.test.ts (incl. a guard asserting src/middleware.ts does NOT exist, so the global-middleware approach cannot silently return). Bugs 2 (GET-exempt /api/system/version) and 3 (COMBO_002 field surfacing) from #5177 are unaffected and remain in place. Co-authored-by: KooshaPari <KooshaPari@users.noreply.github.com> * test(combo): end-to-end quota-share DRR routing-decision coverage (matrix parity) (#5179) Integrated into release/v3.8.39. Quota-share DRR routing-decision coverage (matrix parity); 2/2 pass on merge result. * feat(agent-bridge): graceful cert-install fallback with manual guide for containers (#4546) (#5178) Integrated into release/v3.8.39. Agent-bridge graceful cert-install fallback + manual guide (#4546); 6/6 tests pass on merge result. * fix(antigravity): family-scoped quota lockout (gemini/claude buckets) (#5180) Integrated into release/v3.8.39 — family-scoped antigravity quota lockout. Rebased from v3.8.37 + validated (vitest 5/5, typecheck clean, full combo-matrix green, model-lockout 99/0). Same-model cross-account retry (chat.ts) deferred pending live antigravity VPS validation. * fix(cli): force NODE_ENV to match dev/start run mode in custom Next server (#5189) Integrated into release/v3.8.39. Force NODE_ENV to match dev/start run mode in custom Next server; 2/2 source-scan+ordering tests pass on merge result. * feat(compression): CCR ranged/grep/stats retrieval (ReDoS-safe, backward-compat) (#5187) Integrated into release/v3.8.39. CCR ranged/grep/stats retrieval (safe-regex ReDoS guard + length/match caps); 17/17 tests pass on merge result. * docs(combo): sync all combo/routing-strategy docs to current state + document test coverage (#5185) Integrated into release/v3.8.39. Combo/routing-strategy docs sync; docs-only. * fix(mcp): return 404 (not 400) for unknown Streamable HTTP session id (#5169) (#5191) * fix(api): respect blocked Auto (Zero-Config) provider in /v1/models catalog (#5192) (#5194) * test(combo): deterministic context-relay codex quota-handoff coverage (closes last gap) (#5195) * test(ci): wire antigravity-quota-family under test:vitest (fix test-discovery orphan) (#5196) * fix(oauth): antigravity login no longer hangs — fire-and-forget onboarding + bounded post-exchange (#5193) Antigravity OAuth hang fix (no-PKCE/no-openid + bounded post-exchange + exchange-500 fix). Includes #5200 (Koosha) revert + owner rebaseline to keep documented comments. Integrated into release/v3.8.39. * feat(oauth): remote Antigravity login via local helper + paste-credentials (#5203) Remote Antigravity login: local helper (omniroute login antigravity) + paste-credentials. Integrated into release/v3.8.39. * fix(translator): accept Claude Messages shape in non-stream malformed-200 guard (#5156) Integrated into release/v3.8.39 * fix(cli): default dev bundler to Turbopack (16.2.x panic no longer reproduces) (#5206) Integrated into release/v3.8.39 * fix(cli): auto-calibrate server V8 heap from physical RAM (#5172) (#5213) The server was spawned with a fixed --max-old-space-size=512 (omniroute serve) or no heap flag at all (Electron), so RAM-rich boxes still OOM-crashed under load (Ineffective mark-compacts near heap limit ~500MB) with many providers/ accounts and large model catalogs. New calibrateHeapFallbackMb(os.totalmem()) defaults the heap to ~35% of RAM clamped [512,4096], wired into serve.mjs and electron/main.js. Explicit OMNIROUTE_MEMORY_MB still wins (#2939 unchanged). Also addresses #5160 (same OOM root); #5152 (docker) benefits via the same knob. Closes #5172 * fix(proxy): coalesce fast-fail health probes (#5208) Integrated into release/v3.8.39 * fix(proxy): close dispatchers when clearing cache (#5202) Integrated into release/v3.8.39 * fix(cli): raise dev server Node heap limit to 8GB to prevent OOM (#5198) Integrated into release/v3.8.39 * fix(auth): allow synthetic no-auth fallback for mimocode (#5205) Integrated into release/v3.8.39 * fix(oauth): preserve Antigravity refresh_token on empty/omitted upstream response (#3850) (#5214) Google's OAuth refresh tokens are non-rotating: the refresh response usually omits refresh_token and occasionally returns it as an empty string. The Antigravity executor used `typeof tokens.refresh_token === "string" ? ... ` which accepts "" (typeof "" === "string") and overwrote the stored token with empty, nulling it on first refresh. Now treats non-string OR empty as absent and preserves credentials.refreshToken, matching refreshGoogleToken semantics. Closes #3850 * fix(responses): normalize non-array input (#5204) Integrated into release/v3.8.39 * fix(stream): normalize safety finish reasons via shared helper (#5197) Integrated into release/v3.8.39 * fix(request-logger): never render negative '(-100%)' compression badge (#5201) Integrated into release/v3.8.39 * fix(combo): reject empty responses api output (#5207) Integrated into release/v3.8.39 — combo failover now rejects empty Responses API output (validateQuality). Baseline rebaseline dropped (main-measured drift; maintainer rebaselines at release). * fix(pwa): prefer cached navigation before offline page (#5209) Integrated into release/v3.8.39 — PWA service worker prefers cached navigation before offline page (#5165). * chore(release): v3.8.39 — 2026-06-28 * chore(release): rebaseline openapi+i18n coverage ratchet drift for v3.8.39 --------- Co-authored-by: Arthur Bodera <abodera@gmail.com> Co-authored-by: Nguyen Minh <lop123thcs@gmail.com> Co-authored-by: lunkerchen <labanchen@gmail.com> Co-authored-by: Ankit <177378174+anki1kr@users.noreply.github.com> Co-authored-by: KooshaPari <KooshaPari@users.noreply.github.com> Co-authored-by: Ardem2025 <ardemb22@gmail.com> Co-authored-by: backryun <bakryun0718@proton.me> Co-authored-by: Anton <39598727+NomenAK@users.noreply.github.com> Co-authored-by: KooshaPari <42529354+KooshaPari@users.noreply.github.com> Co-authored-by: Wilson <pedbookmed@gmail.com> Co-authored-by: Randi <55005611+rdself@users.noreply.github.com>
This commit is contained in:
committed by
GitHub
parent
7b139fdb5e
commit
dc40911583
367
tests/integration/combo-matrix/context-relay-codex.test.ts
Normal file
367
tests/integration/combo-matrix/context-relay-codex.test.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
// tests/integration/combo-matrix/context-relay-codex.test.ts
|
||||
//
|
||||
// Deterministic in-process test for context-relay CODEX-SPECIFIC HANDOFF
|
||||
// (combo.ts ~2143-2183): the quota-aware handoff that fires after a successful
|
||||
// codex response when the quota exceeds the threshold.
|
||||
//
|
||||
// Code path:
|
||||
// if (strategy === "context-relay" && relayOptions?.sessionId && relayConfig &&
|
||||
// relayConfig.handoffProviders.includes(provider) && provider === "codex") {
|
||||
// const connectionId = getSessionConnection(relayOptions.sessionId);
|
||||
// if (connectionId) {
|
||||
// const quotaInfo = await fetchCodexQuota(connectionId).catch(() => null);
|
||||
// if (quotaInfo) { ... maybeGenerateHandoff({ ..., expiresAt: resetCandidates[0] }); }
|
||||
// }
|
||||
// }
|
||||
//
|
||||
// PRIMARY observable: getHandoff(sessionId, comboName).expiresAt === session-window
|
||||
// reset time from the codex quota response. This is the CODEX-SPECIFIC proof:
|
||||
// the universal handoff path does NOT set expiresAt from a codex quota fetch.
|
||||
//
|
||||
// SECONDARY observables:
|
||||
// - The codex usage URL was fetched (proves the quota path ran).
|
||||
// - The handoff record has a non-empty .summary.
|
||||
//
|
||||
// CONTROL: combo targeting openai (not codex) → codex block NEVER runs →
|
||||
// codex usage URL is never fetched → expiresAt from quota doesn't appear.
|
||||
//
|
||||
// Seams used:
|
||||
// - registerCodexConnection / unregisterCodexConnection (codexQuotaFetcher.ts)
|
||||
// - clearSessions (sessionManager.ts) — cleanup between tests
|
||||
// - buildCodexResponsesSse helper (mirrors codex-stream-false.test.ts)
|
||||
// - Custom fetch mock distinguishes quota URL / summary dispatch / main dispatch
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createComboRoutingHarness } from "../_comboRoutingHarness.ts";
|
||||
import {
|
||||
registerCodexConnection,
|
||||
unregisterCodexConnection,
|
||||
} from "../../../open-sse/services/codexQuotaFetcher.ts";
|
||||
import { clearSessions } from "../../../open-sse/services/sessionManager.ts";
|
||||
|
||||
// ── Harness setup ─────────────────────────────────────────────────────────────
|
||||
// Each harness owns an isolated DATA_DIR so DB handles don't clash.
|
||||
const h = await createComboRoutingHarness("combo-relay-codex");
|
||||
const {
|
||||
BaseExecutor,
|
||||
combosDb,
|
||||
handleChat,
|
||||
buildRequest,
|
||||
seedConnection,
|
||||
resetStorage,
|
||||
buildOpenAIResponse,
|
||||
waitFor,
|
||||
toPlainHeaders,
|
||||
} = h;
|
||||
|
||||
// Import DB helpers AFTER harness creation so they share the same DB instance
|
||||
// (DATA_DIR is set by the harness before any import triggers DB init).
|
||||
const { getHandoff } = await import("../../../src/lib/db/contextHandoffs.ts");
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const CODEX_COMBO_NAME = "m-relay-codex-quota";
|
||||
const SESSION_HEADER_VALUE = "relay-codex-quota-001";
|
||||
const SESSION_ID = `ext:${SESSION_HEADER_VALUE}`;
|
||||
|
||||
// Codex endpoint URLs (must match codexQuotaFetcher.ts and the executor config).
|
||||
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
||||
const CODEX_RESPONSES_HOST = "chatgpt.com/backend-api/codex/responses";
|
||||
|
||||
// Summary JSON that parseHandoffJSON will successfully parse.
|
||||
const CODEX_SUMMARY_JSON = JSON.stringify({
|
||||
summary:
|
||||
"User is implementing a TypeScript context-relay codex quota-handoff test using TDD.",
|
||||
keyDecisions: ["codex provider selected", "quota threshold at 90%"],
|
||||
taskProgress: "writing deterministic integration test for codex handoff",
|
||||
activeEntities: ["combo.ts", "codexQuotaFetcher.ts", "contextHandoff.ts"],
|
||||
});
|
||||
|
||||
// ── SSE builder (mirrors codex-stream-false.test.ts) ─────────────────────────
|
||||
|
||||
function buildCodexResponsesSse(text = "codex assistant reply") {
|
||||
return new Response(
|
||||
[
|
||||
"event: response.created",
|
||||
'data: {"type":"response.created","response":{"id":"resp_codex_1","model":"gpt-5.3-codex","status":"in_progress","output":[]}}',
|
||||
"",
|
||||
"event: response.output_text.delta",
|
||||
`data: ${JSON.stringify({
|
||||
type: "response.output_text.delta",
|
||||
output_index: 0,
|
||||
delta: text,
|
||||
})}`,
|
||||
"",
|
||||
"event: response.completed",
|
||||
`data: ${JSON.stringify({
|
||||
type: "response.completed",
|
||||
response: {
|
||||
id: "resp_codex_1",
|
||||
object: "response",
|
||||
model: "gpt-5.3-codex",
|
||||
status: "completed",
|
||||
output: [
|
||||
{
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text }],
|
||||
},
|
||||
],
|
||||
usage: { input_tokens: 6, output_tokens: 4 },
|
||||
},
|
||||
})}`,
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
].join("\n"),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ── Quota response builder ────────────────────────────────────────────────────
|
||||
// Produces a codex usage API response that parseCodexUsageResponse accepts.
|
||||
// primary_window → session → percentUsed = 0.90 (90%) — triggers handoff (threshold 0.85)
|
||||
// secondary_window → weekly → percentUsed = 0.85 (85%) — below threshold for weekly
|
||||
// Both are below HANDOFF_EXHAUSTION_THRESHOLD (0.95) so maybeGenerateHandoff proceeds.
|
||||
|
||||
function buildCodexUsageBody(
|
||||
sessionResetAtUnix: number,
|
||||
weeklyResetAtUnix: number
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
rate_limit: {
|
||||
primary_window: { used_percent: 90, reset_at: sessionResetAtUnix },
|
||||
secondary_window: { used_percent: 85, reset_at: weeklyResetAtUnix },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Request builder ───────────────────────────────────────────────────────────
|
||||
|
||||
function codexRequest(withSessionId = true) {
|
||||
return buildRequest({
|
||||
headers: withSessionId ? { "x-session-id": SESSION_HEADER_VALUE } : {},
|
||||
body: {
|
||||
model: CODEX_COMBO_NAME,
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Write a TypeScript hello world." }],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Fetch mock ────────────────────────────────────────────────────────────────
|
||||
// Handles three URL classes:
|
||||
// 1. CODEX_USAGE_URL → codex quota JSON
|
||||
// 2. CODEX_RESPONSES_HOST (call 1) → main request Responses API SSE
|
||||
// 3. CODEX_RESPONSES_HOST (call 2) → summary request Responses API SSE (with CODEX_SUMMARY_JSON)
|
||||
//
|
||||
// The second codex-responses call is the handoff summary generation, identified by
|
||||
// call count (the first is always the main request).
|
||||
|
||||
function installCodexHandoffFetch(
|
||||
sessionResetAtUnix: number,
|
||||
weeklyResetAtUnix: number,
|
||||
seenUrls: string[]
|
||||
) {
|
||||
let codexResponsesCallCount = 0;
|
||||
|
||||
globalThis.fetch = async (url: unknown, init: unknown = {}) => {
|
||||
const u = String(url);
|
||||
seenUrls.push(u);
|
||||
|
||||
const headers =
|
||||
init != null && typeof init === "object"
|
||||
? toPlainHeaders((init as Record<string, unknown>).headers)
|
||||
: {};
|
||||
void headers; // recorded for debugging; not needed in assertions here
|
||||
|
||||
if (u === CODEX_USAGE_URL) {
|
||||
// Codex quota endpoint — return structured usage JSON.
|
||||
return new Response(
|
||||
JSON.stringify(buildCodexUsageBody(sessionResetAtUnix, weeklyResetAtUnix)),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
if (u.includes(CODEX_RESPONSES_HOST)) {
|
||||
codexResponsesCallCount++;
|
||||
if (codexResponsesCallCount === 1) {
|
||||
// Main codex request — return a successful Responses API SSE.
|
||||
return buildCodexResponsesSse("codex assistant reply ok");
|
||||
}
|
||||
// Subsequent call(s) are from the handoff summary generation.
|
||||
// Return Responses API SSE whose output_text is CODEX_SUMMARY_JSON so that
|
||||
// generateHandoffAsync → parseHandoffJSON succeeds.
|
||||
return buildCodexResponsesSse(CODEX_SUMMARY_JSON);
|
||||
}
|
||||
|
||||
// Fallback — should not be reached in these tests.
|
||||
return new Response(JSON.stringify({ error: { message: "unexpected URL in test" } }), {
|
||||
status: 400,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
// ── Lifecycle hooks ───────────────────────────────────────────────────────────
|
||||
|
||||
test.beforeEach(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = 0;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = h.originalRetryDelayMs;
|
||||
// Clear in-memory session state so test 2 doesn't see stale connections from test 1.
|
||||
clearSessions();
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
await h.cleanup();
|
||||
});
|
||||
|
||||
// ── Test 1 (PRIMARY): codex quota handoff fires ───────────────────────────────
|
||||
//
|
||||
// Flow:
|
||||
// 1. Seed a codex DB connection + register with accessToken.
|
||||
// 2. Create context-relay combo → codex.
|
||||
// 3. Send request with x-session-id header.
|
||||
// 4. During request processing, chat.ts calls touchSession(sessionId, codexConnId).
|
||||
// 5. After the successful codex response, combo.ts enters the codex block:
|
||||
// - getSessionConnection → codexConnId (set in step 4).
|
||||
// - fetchCodexQuota(codexConnId) → hits our mock → returns 90% usage.
|
||||
// - resetCandidates[0] = session-window resetAt ISO string.
|
||||
// - maybeGenerateHandoff({ ..., expiresAt: sessionResetISO }) fires via setImmediate.
|
||||
// 6. generateHandoffAsync dispatches a second codex fetch for the summary.
|
||||
// 7. upsertHandoff writes to DB with expiresAt = sessionResetISO.
|
||||
//
|
||||
// Assertion: handoff.expiresAt === sessionResetISO (codex-specific, not TTL-derived).
|
||||
|
||||
test("context-relay codex quota handoff: fires and expiresAt matches session-window reset from quota", async () => {
|
||||
// 1. Seed codex connection.
|
||||
const conn = await seedConnection("codex", { apiKey: "sk-codex-handoff-test-1" });
|
||||
const codexConnId = conn.id;
|
||||
|
||||
// 2. Register connection meta so fetchCodexQuota can find credentials.
|
||||
// chat.ts also calls registerCodexConnection during request processing for OAuth
|
||||
// tokens; for API-key connections the accessToken may not be set there, so we
|
||||
// pre-register with the API key as the token. This is the documented test seam.
|
||||
registerCodexConnection(codexConnId, { accessToken: "sk-codex-handoff-test-1" });
|
||||
|
||||
// 3. Create a context-relay combo targeting codex.
|
||||
// Default handoffProviders: ["codex"] and handoffThreshold: 0.85 apply.
|
||||
await combosDb.createCombo({
|
||||
name: CODEX_COMBO_NAME,
|
||||
strategy: "context-relay",
|
||||
config: { maxRetries: 0, retryDelayMs: 0, stickyRoundRobinLimit: 1 },
|
||||
models: [
|
||||
{ id: "rc-codex-1", kind: "model", providerId: "codex", model: "gpt-5.3-codex" },
|
||||
],
|
||||
});
|
||||
|
||||
// 4. Compute quota reset times (future timestamps).
|
||||
const sessionResetAtUnix = Math.floor((Date.now() + 5 * 60 * 60 * 1000) / 1000); // +5h
|
||||
const weeklyResetAtUnix = Math.floor((Date.now() + 7 * 24 * 60 * 60 * 1000) / 1000); // +7d
|
||||
|
||||
// Expected expiresAt: parseWindowReset converts reset_at (unix seconds) → ISO string.
|
||||
// resetCandidates[0] = earliest ISO string = session window reset (5h < 7d).
|
||||
const expectedExpiresAt = new Date(sessionResetAtUnix * 1000).toISOString();
|
||||
|
||||
// 5. Install fetch mock and send request.
|
||||
const seenUrls: string[] = [];
|
||||
installCodexHandoffFetch(sessionResetAtUnix, weeklyResetAtUnix, seenUrls);
|
||||
|
||||
const r = await handleChat(codexRequest(true));
|
||||
assert.equal(r.status, 200, "main codex request must return 200");
|
||||
|
||||
// 6. Wait for setImmediate → generateHandoffAsync → upsertHandoff (up to 3s).
|
||||
const handoff = await waitFor(() => getHandoff(SESSION_ID, CODEX_COMBO_NAME), 3000);
|
||||
|
||||
// ── Primary assertion: handoff record exists ──────────────────────────────
|
||||
assert.ok(
|
||||
handoff !== null,
|
||||
"codex quota handoff record must be written to DB when quota ≥ threshold"
|
||||
);
|
||||
|
||||
// ── CODEX-SPECIFIC proof: expiresAt == session-window reset from quota ────
|
||||
// The universal handoff path never calls fetchCodexQuota, so it cannot produce
|
||||
// this specific expiresAt value. The value below can only come from the codex block.
|
||||
assert.equal(
|
||||
handoff!.expiresAt,
|
||||
expectedExpiresAt,
|
||||
`handoff.expiresAt must equal the session-window reset from codex quota (${expectedExpiresAt}); ` +
|
||||
`got ${handoff!.expiresAt}`
|
||||
);
|
||||
|
||||
// ── Secondary assertion: codex usage URL was fetched ─────────────────────
|
||||
assert.ok(
|
||||
seenUrls.includes(CODEX_USAGE_URL),
|
||||
`codex usage URL must have been fetched to produce the expiresAt; seen URLs: ${JSON.stringify(seenUrls)}`
|
||||
);
|
||||
|
||||
// ── Secondary assertion: summary was generated (non-empty) ───────────────
|
||||
assert.ok(
|
||||
typeof handoff!.summary === "string" && handoff!.summary.length > 0,
|
||||
`handoff.summary must be non-empty; got ${JSON.stringify(handoff!.summary)}`
|
||||
);
|
||||
|
||||
// ── Cleanup ───────────────────────────────────────────────────────────────
|
||||
unregisterCodexConnection(codexConnId);
|
||||
});
|
||||
|
||||
// ── Test 2 (CONTROL): non-codex provider never triggers the codex block ───────
|
||||
//
|
||||
// The codex block gates on `provider === "codex"`. A combo targeting openai
|
||||
// will never enter it, so the codex usage URL is never fetched and there is no
|
||||
// quota-derived expiresAt in the DB.
|
||||
//
|
||||
// This proves the primary test is not trivially green (i.e., the block is
|
||||
// actually gated on `provider === "codex"`).
|
||||
|
||||
test("context-relay codex quota handoff: does NOT fire when provider is openai (control)", async () => {
|
||||
// Seed openai (not codex).
|
||||
await seedConnection("openai", { apiKey: "sk-openai-control-no-codex-block" });
|
||||
|
||||
await combosDb.createCombo({
|
||||
name: CODEX_COMBO_NAME,
|
||||
strategy: "context-relay",
|
||||
config: { maxRetries: 0, retryDelayMs: 0, stickyRoundRobinLimit: 1 },
|
||||
models: [
|
||||
{ id: "rc-openai-ctrl", kind: "model", providerId: "openai", model: "gpt-4o-mini" },
|
||||
],
|
||||
});
|
||||
|
||||
const seenUrls: string[] = [];
|
||||
globalThis.fetch = async (url: unknown, _init: unknown = {}) => {
|
||||
seenUrls.push(String(url));
|
||||
return buildOpenAIResponse("assistant reply ok");
|
||||
};
|
||||
|
||||
const r = await handleChat(codexRequest(true));
|
||||
assert.equal(r.status, 200, "openai request must return 200");
|
||||
|
||||
// Give setImmediate time to fire if the block were incorrectly entered.
|
||||
await new Promise((res) => setTimeout(res, 400));
|
||||
|
||||
// The codex block requires provider === "codex", so it never runs for openai.
|
||||
// The codex usage URL must NOT have been fetched.
|
||||
assert.ok(
|
||||
!seenUrls.includes(CODEX_USAGE_URL),
|
||||
`codex usage URL must NOT be fetched for openai provider; seen: ${JSON.stringify(seenUrls)}`
|
||||
);
|
||||
|
||||
// No codex quota handoff record in DB.
|
||||
// (The universal handoff also does not fire because no prior model is seeded,
|
||||
// so getLastSessionModel returns null → no model switch detected.)
|
||||
const handoff = getHandoff(SESSION_ID, CODEX_COMBO_NAME);
|
||||
assert.equal(
|
||||
handoff,
|
||||
null,
|
||||
"no handoff record must exist when provider is openai (codex block never entered)"
|
||||
);
|
||||
});
|
||||
282
tests/integration/combo-matrix/context-relay-handoff.test.ts
Normal file
282
tests/integration/combo-matrix/context-relay-handoff.test.ts
Normal file
@@ -0,0 +1,282 @@
|
||||
// tests/integration/combo-matrix/context-relay-handoff.test.ts
|
||||
//
|
||||
// Deterministic in-process tests for context-relay UNIVERSAL HANDOFF behavior:
|
||||
// the session-context transfer that fires when a model switch is detected,
|
||||
// regardless of provider (provider-agnostic). This is the distinguishing
|
||||
// behavior of context-relay that was deferred as TODO(phase-2) in the
|
||||
// original combo-matrix coverage.
|
||||
//
|
||||
// What is tested here:
|
||||
// 1. Universal handoff fires (extra summary dispatch + DB record) when:
|
||||
// - universalHandoffConfig.enabled = true (default)
|
||||
// - request carries x-omniroute-session-id header
|
||||
// - session_model_history records a DIFFERENT prior model than the combo target
|
||||
// 2. Control (no model switch): prevModel === currModel — handoff must NOT fire.
|
||||
// 3. Control (no session ID): no header passed — handoff must NOT fire.
|
||||
//
|
||||
// Codex-specific block (lines 2143-2183 in combo.ts):
|
||||
// Requires strategy === "context-relay" AND provider === "codex" AND a live
|
||||
// codex session connection + quota fetcher. The seams (getSessionConnection /
|
||||
// fetchCodexQuota) are NOT exported from contextHandoff.ts as testable hooks;
|
||||
// there is no in-process mechanism equivalent to registerQuotaFetcher for the
|
||||
// codex path. This block requires a real codex provider connection (VPS) and
|
||||
// is NOT covered here. The universal-handoff path above is the user-visible,
|
||||
// provider-agnostic behavior and is now fully covered.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createComboRoutingHarness, providerFromUrl } from "../_comboRoutingHarness.ts";
|
||||
|
||||
const h = await createComboRoutingHarness("combo-relay-handoff");
|
||||
const {
|
||||
BaseExecutor,
|
||||
combosDb,
|
||||
handleChat,
|
||||
buildRequest,
|
||||
seedConnection,
|
||||
resetStorage,
|
||||
buildOpenAIResponse,
|
||||
waitFor,
|
||||
toPlainHeaders,
|
||||
} = h;
|
||||
|
||||
// Import DB helpers AFTER harness so they share the same DB instance (DATA_DIR
|
||||
// is set by the harness before any import triggers DB init).
|
||||
const { recordSessionModelUsage, getHandoff } = await import(
|
||||
"../../../src/lib/db/contextHandoffs.ts"
|
||||
);
|
||||
|
||||
// A minimal but valid handoff-JSON blob that parseHandoffJSON will accept.
|
||||
// Must have at minimum a non-empty "summary" field.
|
||||
const SCRIPTED_SUMMARY_JSON = JSON.stringify({
|
||||
summary: "User initiated a context-relay handoff test and requested a model switch.",
|
||||
keyDecisions: ["switched from gemini to openai"],
|
||||
taskProgress: "in-progress",
|
||||
activeEntities: ["context-relay-test"],
|
||||
});
|
||||
|
||||
const COMBO_NAME = "m-relay-handoff";
|
||||
// The session ID must use the external-session format: extractExternalSessionId in chat.ts
|
||||
// reads "x-session-id" (not "x-omniroute-session-id") and prefixes the result with "ext:".
|
||||
// We must seed session_model_history with this exact "ext:"-prefixed ID so that
|
||||
// getLastSessionModel(relayOptions.sessionId, comboName) returns the prior model.
|
||||
const SESSION_HEADER_VALUE = "relay-handoff-session-001";
|
||||
const SESSION_ID = `ext:${SESSION_HEADER_VALUE}`; // matches what extractExternalSessionId produces
|
||||
const PREV_MODEL = "gemini/gemini-2.5-flash"; // the "old" model — must differ from combo target
|
||||
const CURR_MODEL = "openai/gpt-4o-mini"; // the combo target model (provider/model format)
|
||||
|
||||
// Build a Request with the session-id header.
|
||||
function relayRequest(withSessionId = true) {
|
||||
const headers: Record<string, string> = withSessionId
|
||||
? { "x-session-id": SESSION_HEADER_VALUE } // x-session-id → relayOptions.sessionId = "ext:..."
|
||||
: {};
|
||||
return buildRequest({
|
||||
headers,
|
||||
body: {
|
||||
model: COMBO_NAME,
|
||||
stream: false,
|
||||
messages: [
|
||||
{ role: "user", content: "Prior turn — building something important in the test session." },
|
||||
],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Install a recording fetch that:
|
||||
// • returns a valid handoff JSON (wrapped in an OpenAI completion) for the
|
||||
// internal summary request (identified by _omnirouteInternalRequest flag in body)
|
||||
// • returns a normal OpenAI response for every other call
|
||||
function installHandoffAwareFetch() {
|
||||
h.calls.length = 0;
|
||||
|
||||
globalThis.fetch = async (url: any, init: any = {}) => {
|
||||
const u = String(url);
|
||||
const provider = providerFromUrl(u);
|
||||
const headers = toPlainHeaders(init?.headers);
|
||||
|
||||
// Parse request body to detect the internal summary request
|
||||
let bodyObj: Record<string, unknown> = {};
|
||||
try {
|
||||
bodyObj =
|
||||
typeof init?.body === "string"
|
||||
? (JSON.parse(init.body) as Record<string, unknown>)
|
||||
: ((init?.body ?? {}) as Record<string, unknown>);
|
||||
} catch {
|
||||
bodyObj = {};
|
||||
}
|
||||
|
||||
const call = {
|
||||
index: h.calls.length,
|
||||
provider,
|
||||
url: u,
|
||||
authorization: headers.authorization,
|
||||
model: typeof bodyObj.model === "string" ? bodyObj.model : undefined,
|
||||
};
|
||||
h.calls.push(call);
|
||||
|
||||
// Return valid handoff JSON for the internal summary generation request
|
||||
if (bodyObj._omnirouteInternalRequest === "universal-handoff") {
|
||||
return buildOpenAIResponse(SCRIPTED_SUMMARY_JSON);
|
||||
}
|
||||
|
||||
// Normal success response for the real request
|
||||
return buildOpenAIResponse("assistant reply ok");
|
||||
};
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = 0;
|
||||
await resetStorage();
|
||||
});
|
||||
test.afterEach(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = h.originalRetryDelayMs;
|
||||
await resetStorage();
|
||||
});
|
||||
test.after(async () => {
|
||||
await h.cleanup();
|
||||
});
|
||||
|
||||
// ── Test 1: universal handoff fires on model switch ────────────────────────────
|
||||
//
|
||||
// Seed session history so getLastSessionModel returns PREV_MODEL (gemini).
|
||||
// Combo target is CURR_MODEL (openai). The switch is detected and
|
||||
// maybeGenerateUniversalHandoff fires via setImmediate → calls handleSingleModel
|
||||
// (extra upstream dispatch) → parses response → upsertHandoff writes to DB.
|
||||
//
|
||||
// Observable 1: h.calls has ≥2 entries (main request + summary dispatch).
|
||||
// Observable 2: getHandoff(sessionId, comboName) returns a record with a
|
||||
// non-empty .summary (proves the FULL path executed, not just the
|
||||
// dispatch). The assertion FAILS if the handoff did not fire.
|
||||
test("context-relay universal handoff: fires and writes handoff record on model switch", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-handoff" });
|
||||
await seedConnection("gemini", { apiKey: "sk-gemini-handoff" });
|
||||
|
||||
await combosDb.createCombo({
|
||||
name: COMBO_NAME,
|
||||
strategy: "context-relay",
|
||||
config: { maxRetries: 0, retryDelayMs: 0, stickyRoundRobinLimit: 1 },
|
||||
models: [
|
||||
// Single target — openai/gpt-4o-mini — so modelStr = CURR_MODEL
|
||||
{ id: "rh-openai", kind: "model", providerId: "openai", model: "gpt-4o-mini" },
|
||||
],
|
||||
});
|
||||
|
||||
// Seed the prior model usage so getLastSessionModel returns PREV_MODEL.
|
||||
// universalHandoffConfig.enabled = true by DEFAULT_UNIVERSAL_HANDOFF_CONFIG.
|
||||
recordSessionModelUsage(SESSION_ID, COMBO_NAME, PREV_MODEL, "gemini", undefined);
|
||||
|
||||
installHandoffAwareFetch();
|
||||
|
||||
const r = await handleChat(relayRequest(/* withSessionId */ true));
|
||||
assert.equal(r.status, 200, "main request must succeed");
|
||||
|
||||
// Wait for the setImmediate + generateUniversalHandoffAsync to complete and
|
||||
// write the DB record. Poll for up to 2 s — typically resolves in <100 ms.
|
||||
const handoff = await waitFor(
|
||||
() => getHandoff(SESSION_ID, COMBO_NAME),
|
||||
2000
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
handoff !== null,
|
||||
"universal handoff record must be written to DB when a model switch is detected"
|
||||
);
|
||||
assert.ok(
|
||||
typeof handoff!.summary === "string" && handoff!.summary.length > 0,
|
||||
`handoff.summary must be non-empty; got: ${JSON.stringify(handoff!.summary)}`
|
||||
);
|
||||
assert.equal(
|
||||
handoff!.comboName,
|
||||
COMBO_NAME,
|
||||
"handoff must be keyed to the correct combo"
|
||||
);
|
||||
assert.equal(
|
||||
handoff!.sessionId,
|
||||
SESSION_ID,
|
||||
"handoff must be keyed to the correct session"
|
||||
);
|
||||
|
||||
// Extra dispatch observable: main (index 0) + summary (index ≥ 1).
|
||||
assert.ok(
|
||||
h.calls.length >= 2,
|
||||
`expected ≥2 upstream dispatches (main + summary); got ${h.calls.length}: ${JSON.stringify(h.calls.map((c) => ({ i: c.index, p: c.provider, m: c.model })))}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── Test 2: control — no model switch, handoff must NOT fire ──────────────────
|
||||
//
|
||||
// DO NOT seed a prior model. getLastSessionModel returns null → prevModel is
|
||||
// null → the `if (prevModel && prevModel !== modelStr)` branch is skipped →
|
||||
// no handoff. The assertion FAILS if the code incorrectly fires a handoff.
|
||||
test("context-relay universal handoff: does NOT fire when no prior model is recorded (no switch)", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-noswitch" });
|
||||
|
||||
await combosDb.createCombo({
|
||||
name: COMBO_NAME,
|
||||
strategy: "context-relay",
|
||||
config: { maxRetries: 0, retryDelayMs: 0, stickyRoundRobinLimit: 1 },
|
||||
models: [{ id: "ns-openai", kind: "model", providerId: "openai", model: "gpt-4o-mini" }],
|
||||
});
|
||||
|
||||
// No recordSessionModelUsage call → getLastSessionModel returns null → no switch.
|
||||
installHandoffAwareFetch();
|
||||
|
||||
const r = await handleChat(relayRequest(true));
|
||||
assert.equal(r.status, 200);
|
||||
|
||||
// Give setImmediate time to fire if the bug were present.
|
||||
await new Promise((res) => setTimeout(res, 250));
|
||||
|
||||
const handoff = getHandoff(SESSION_ID, COMBO_NAME);
|
||||
assert.equal(
|
||||
handoff,
|
||||
null,
|
||||
"handoff must NOT be written when no prior model exists (prevModel is null)"
|
||||
);
|
||||
|
||||
// Only the main request — no extra summary dispatch.
|
||||
assert.equal(
|
||||
h.calls.length,
|
||||
1,
|
||||
`expected exactly 1 upstream dispatch (main only); got ${h.calls.length}`
|
||||
);
|
||||
});
|
||||
|
||||
// ── Test 3: control — no session ID, handoff must NOT fire ────────────────────
|
||||
//
|
||||
// Session ID gate: `relayOptions?.sessionId` must be truthy.
|
||||
// Without the x-omniroute-session-id header, sessionId = null → block is skipped.
|
||||
test("context-relay universal handoff: does NOT fire when x-omniroute-session-id header is absent", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-nosid" });
|
||||
|
||||
await combosDb.createCombo({
|
||||
name: COMBO_NAME,
|
||||
strategy: "context-relay",
|
||||
config: { maxRetries: 0, retryDelayMs: 0, stickyRoundRobinLimit: 1 },
|
||||
models: [{ id: "sid-openai", kind: "model", providerId: "openai", model: "gpt-4o-mini" }],
|
||||
});
|
||||
|
||||
// Seed prior model — but without the header the session block won't fire.
|
||||
recordSessionModelUsage(SESSION_ID, COMBO_NAME, PREV_MODEL, "gemini", undefined);
|
||||
|
||||
installHandoffAwareFetch();
|
||||
|
||||
// Send WITHOUT the session header.
|
||||
const r = await handleChat(relayRequest(/* withSessionId */ false));
|
||||
assert.equal(r.status, 200);
|
||||
|
||||
await new Promise((res) => setTimeout(res, 250));
|
||||
|
||||
const handoff = getHandoff(SESSION_ID, COMBO_NAME);
|
||||
assert.equal(
|
||||
handoff,
|
||||
null,
|
||||
"handoff must NOT be written when x-omniroute-session-id header is absent (sessionId gate)"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
h.calls.length,
|
||||
1,
|
||||
`expected exactly 1 upstream dispatch (main only, no summary); got ${h.calls.length}`
|
||||
);
|
||||
});
|
||||
@@ -11,10 +11,10 @@
|
||||
// gemini-2.5-flash → contextWindow 1 048 576
|
||||
//
|
||||
// context-relay: does NOT appear in the sorting if-else chain of combo.ts; targets are
|
||||
// dispatched in combo-definition order. The only extra behaviour is a codex-provider
|
||||
// handoff triggered by (strategy=context-relay && provider="codex" && sessionId in
|
||||
// relayOptions), which cannot be exercised in-process with openai/gemini providers.
|
||||
// That handoff path is covered by TODO(phase-2) below.
|
||||
// dispatched in combo-definition order. Two extra behaviours exist:
|
||||
// 1. Universal handoff (provider-agnostic) — covered by context-relay-handoff.test.ts.
|
||||
// 2. Codex-specific handoff — requires provider="codex" (see context-relay-handoff.test.ts
|
||||
// for why the codex block is documented but not covered here).
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createComboRoutingHarness } from "../_comboRoutingHarness.ts";
|
||||
@@ -111,12 +111,8 @@ test("context-optimized: largest-context model (gemini-2.5-flash 1048576) dispat
|
||||
// The only extra behaviour is a codex handoff at line 2144:
|
||||
// strategy === "context-relay" && relayOptions?.sessionId && relayConfig &&
|
||||
// relayConfig.handoffProviders.includes(provider) && provider === "codex"
|
||||
// This branch requires provider === "codex" and a live relayOptions.sessionId, which
|
||||
// cannot be injected in-process via the standard combo test harness.
|
||||
// TODO(phase-2): trigger the codex handoff path live on VPS or via a dedicated codex-
|
||||
// provider connection stub that satisfies the handoff condition.
|
||||
//
|
||||
// Assertion: context-relay preserves combo-definition order (first model dispatched first).
|
||||
// Universal handoff + codex block coverage: see context-relay-handoff.test.ts.
|
||||
test("context-relay: preserves combo-definition order (openai dispatched before gemini)", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-relay" });
|
||||
await seedConnection("gemini", { apiKey: "sk-gemini-relay" });
|
||||
|
||||
202
tests/integration/combo-matrix/quota-share.test.ts
Normal file
202
tests/integration/combo-matrix/quota-share.test.ts
Normal file
@@ -0,0 +1,202 @@
|
||||
// tests/integration/combo-matrix/quota-share.test.ts
|
||||
//
|
||||
// E2E matrix test for the INTERNAL quota-share routing strategy (DRR / deficit
|
||||
// round-robin over auto-minted qtSd/ pool combos).
|
||||
//
|
||||
// Drive path: handleChat → chatCore → handleComboChat (strategy "quota-share")
|
||||
// → selectQuotaShareTarget (DRR + P2C + bucket-gating) → executor dispatch.
|
||||
//
|
||||
// Two tests:
|
||||
// 1. DRR fairness — 2 equal-weight connections, 6 requests; both must be
|
||||
// selected and balanced, proving DRR ran end-to-end (not a single-winner
|
||||
// or definition-order dispatch).
|
||||
// 2. Saturation deprioritization — one connection marked saturated via
|
||||
// recordUsage (5h window); a single request must dispatch to the clean
|
||||
// connection first, proving the bucket-gating gate runs through the real
|
||||
// wire.
|
||||
//
|
||||
// Drive path chosen: Option A — direct `combosDb.createCombo({ strategy:
|
||||
// "quota-share", ... })`. The `handleComboChat` dispatch at combo.ts:1573
|
||||
// branches on `strategy === "quota-share"` verbatim; no qtSd/ pool plumbing is
|
||||
// required to reach `selectQuotaShareTarget`.
|
||||
//
|
||||
// The preferred target is always the SECOND one in the combo definition so that
|
||||
// a pass proves the selector actually reordered targets (same discipline as the
|
||||
// 17-strategy quota-aware.test.ts matrix).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { createComboRoutingHarness } from "../_comboRoutingHarness.ts";
|
||||
|
||||
const h = await createComboRoutingHarness("combo-quota-share");
|
||||
const { BaseExecutor, combosDb, handleChat, buildRequest, seedConnection, resetStorage } = h;
|
||||
|
||||
// DRR + bucket state seams — import AFTER harness initialises the DB so the
|
||||
// module-level in-process singletons are already live.
|
||||
const { _clearDrrStateForTest } = await import(
|
||||
"../../../open-sse/services/combo/quotaShareStrategy.ts"
|
||||
);
|
||||
const { recordUsage, _clearBucketsForTest } = await import(
|
||||
"../../../src/lib/quota/accountBuckets.ts"
|
||||
);
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function body(model: string, suffix = "") {
|
||||
return {
|
||||
model,
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: `quota-share route${suffix}` }],
|
||||
};
|
||||
}
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test.beforeEach(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = 0;
|
||||
_clearDrrStateForTest();
|
||||
_clearBucketsForTest();
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = h.originalRetryDelayMs;
|
||||
_clearDrrStateForTest();
|
||||
_clearBucketsForTest();
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
_clearDrrStateForTest();
|
||||
_clearBucketsForTest();
|
||||
await h.cleanup();
|
||||
});
|
||||
|
||||
// ── Strategy: quota-share — DRR fairness ─────────────────────────────────────
|
||||
//
|
||||
// Mechanism: DRR (deficit round robin, quantum = weight / totalWeight).
|
||||
//
|
||||
// Engineered state:
|
||||
// openai (1st in definition) — equal weight 100
|
||||
// gemini (2nd in definition) — equal weight 100
|
||||
//
|
||||
// With 2 equal-weight targets DRR alternates: openai, gemini, openai, gemini, …
|
||||
// Over 6 requests both must be selected at least 2 times (proven to be exactly
|
||||
// 3 each by the deterministic math, but asserting >= 2 avoids brittleness if
|
||||
// P2C ever nudges a tie differently). The assertion fails if the pipeline
|
||||
// pin-picks the first candidate on every call — i.e. if DRR is bypassed.
|
||||
test("DRR fairness: 2 equal-weight connections alternate across 6 requests through real pipeline", async () => {
|
||||
const openaiConn = await seedConnection("openai", { apiKey: "sk-openai-qs-drr-1" });
|
||||
const geminiConn = await seedConnection("gemini", { apiKey: "sk-gemini-qs-drr-1" });
|
||||
|
||||
await combosDb.createCombo({
|
||||
name: "m-qs-drr",
|
||||
strategy: "quota-share",
|
||||
config: { maxRetries: 0, retryDelayMs: 0, stickyRoundRobinLimit: 1 },
|
||||
models: [
|
||||
// openai FIRST in definition
|
||||
{
|
||||
id: "qs-drr-openai",
|
||||
kind: "model",
|
||||
providerId: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
connectionId: openaiConn.id,
|
||||
},
|
||||
// gemini SECOND in definition — DRR must pick it on alternate rounds
|
||||
{
|
||||
id: "qs-drr-gemini",
|
||||
kind: "model",
|
||||
providerId: "gemini",
|
||||
model: "gemini-2.5-flash",
|
||||
connectionId: geminiConn.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
h.installRecordingFetch();
|
||||
|
||||
// Use a unique message suffix per request so session stickiness (message-hash
|
||||
// based) does not pin every call to the first-round winner. In real traffic
|
||||
// each conversation has distinct content; the DRR distributes across them.
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const r = await handleChat(buildRequest({ body: body("m-qs-drr", ` #${i}`) }));
|
||||
assert.equal(r.status, 200, `request ${i + 1} must succeed`);
|
||||
}
|
||||
|
||||
const seen = h.providersSeen();
|
||||
assert.equal(seen.length, 6, "all 6 requests must reach an upstream provider");
|
||||
|
||||
const openaiCount = seen.filter((p) => p === "openai").length;
|
||||
const geminiCount = seen.filter((p) => p === "gemini").length;
|
||||
|
||||
assert.ok(
|
||||
openaiCount >= 2,
|
||||
`DRR fairness: openai must be selected at least 2 times out of 6; got ${openaiCount}. ` +
|
||||
`Full sequence: [${seen.join(", ")}]. If openai was never selected, DRR was bypassed.`
|
||||
);
|
||||
assert.ok(
|
||||
geminiCount >= 2,
|
||||
`DRR fairness: gemini must be selected at least 2 times out of 6; got ${geminiCount}. ` +
|
||||
`Full sequence: [${seen.join(", ")}]. If gemini was never selected, DRR was bypassed.`
|
||||
);
|
||||
});
|
||||
|
||||
// ── Strategy: quota-share — saturation deprioritization ──────────────────────
|
||||
//
|
||||
// Mechanism: filterEligibleBySaturation (isBucketSaturated, 5h window).
|
||||
//
|
||||
// Engineered state:
|
||||
// openai (1st in definition) — 5h bucket seeded at 100 % usage via
|
||||
// recordUsage → isBucketSaturated returns true
|
||||
// → filterEligibleBySaturation demotes it
|
||||
// gemini (2nd in definition) — no bucket recorded → eligible (clean)
|
||||
//
|
||||
// Expected: gemini dispatched first despite being second in the combo
|
||||
// definition. Proves the bucket-gating runs through the real pipeline wire
|
||||
// (not merely unit-tested in isolation).
|
||||
test("saturation deprioritization: saturated connection demoted — clean second target dispatched first", async () => {
|
||||
const openaiConn = await seedConnection("openai", { apiKey: "sk-openai-qs-sat-1" });
|
||||
const geminiConn = await seedConnection("gemini", { apiKey: "sk-gemini-qs-sat-1" });
|
||||
|
||||
// Seed openai connection as saturated (5h window, 100 % usage, reset in 1 h).
|
||||
// recordUsage writes the in-process _buckets store; isBucketSaturated reads it.
|
||||
// The resetAt must be in the FUTURE so the lazy-reset guard keeps the entry live.
|
||||
const futureResetAt = new Date(Date.now() + 3_600_000).toISOString();
|
||||
recordUsage(openaiConn.id, "5h", 100, futureResetAt);
|
||||
|
||||
await combosDb.createCombo({
|
||||
name: "m-qs-sat",
|
||||
strategy: "quota-share",
|
||||
config: { maxRetries: 0, retryDelayMs: 0, stickyRoundRobinLimit: 1 },
|
||||
models: [
|
||||
// openai FIRST in definition — must be demoted (5h bucket saturated)
|
||||
{
|
||||
id: "qs-sat-openai",
|
||||
kind: "model",
|
||||
providerId: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
connectionId: openaiConn.id,
|
||||
},
|
||||
// gemini SECOND — must win (no saturation recorded → eligible)
|
||||
{
|
||||
id: "qs-sat-gemini",
|
||||
kind: "model",
|
||||
providerId: "gemini",
|
||||
model: "gemini-2.5-flash",
|
||||
connectionId: geminiConn.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
h.installRecordingFetch();
|
||||
|
||||
const r = await handleChat(buildRequest({ body: body("m-qs-sat") }));
|
||||
assert.equal(r.status, 200, "request must succeed via clean gemini target");
|
||||
|
||||
const first = h.providersSeen()[0];
|
||||
assert.equal(
|
||||
first,
|
||||
"gemini",
|
||||
`saturation deprioritization: openai 5h bucket is at 100 % (saturated), so ` +
|
||||
`filterEligibleBySaturation must demote it and dispatch gemini first despite it ` +
|
||||
`being second in the combo definition. Got: ${first}`
|
||||
);
|
||||
});
|
||||
64
tests/unit/agent-bridge-cert-install-fallback-4546.test.ts
Normal file
64
tests/unit/agent-bridge-cert-install-fallback-4546.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
// #4546 — In containers/headless, the system trust store can't be written
|
||||
// (no sudo / read-only store / no interactive auth), so the cert install
|
||||
// throws and used to abort the whole Agent Bridge start. These tests pin the
|
||||
// graceful-fallback contract: a structured result that distinguishes a
|
||||
// user-canceled auth from an environment failure, plus a platform-specific
|
||||
// manual-install guide so the operator can trust the MITM root CA themselves.
|
||||
|
||||
const { classifyCertInstallError, buildCertManualGuide, installCertResult } = await import(
|
||||
"../../src/mitm/cert/install.ts"
|
||||
);
|
||||
|
||||
const DOWNLOAD_URL = "/api/tools/agent-bridge/cert/download";
|
||||
|
||||
test("classifyCertInstallError → 'canceled' only when the message says canceled", () => {
|
||||
assert.equal(classifyCertInstallError("User canceled authorization"), "canceled");
|
||||
assert.equal(classifyCertInstallError("Operation was canceled by the user"), "canceled");
|
||||
});
|
||||
|
||||
test("classifyCertInstallError → 'environment' for trust-store / sudo failures", () => {
|
||||
assert.equal(classifyCertInstallError("Certificate install failed"), "environment");
|
||||
assert.equal(classifyCertInstallError("sudo: no tty present and no askpass program specified"), "environment");
|
||||
assert.equal(classifyCertInstallError("Certificate file not found: /x/server.crt"), "environment");
|
||||
});
|
||||
|
||||
test("buildCertManualGuide(linux) → update-ca-certificates steps + download url + cert path", () => {
|
||||
const guide = buildCertManualGuide("/data/mitm/server.crt", "linux");
|
||||
assert.equal(guide.platform, "linux");
|
||||
assert.equal(guide.certPath, "/data/mitm/server.crt");
|
||||
assert.equal(guide.downloadUrl, DOWNLOAD_URL);
|
||||
assert.ok(Array.isArray(guide.steps) && guide.steps.length > 0);
|
||||
const joined = guide.steps.join("\n");
|
||||
assert.ok(joined.includes("update-ca-"), "should mention the distro CA refresh command");
|
||||
assert.ok(joined.includes("/data/mitm/server.crt"), "should reference the cert path");
|
||||
});
|
||||
|
||||
test("buildCertManualGuide(darwin) → security add-trusted-cert", () => {
|
||||
const guide = buildCertManualGuide("/d/server.crt", "darwin");
|
||||
assert.equal(guide.platform, "darwin");
|
||||
assert.ok(guide.steps.join("\n").includes("add-trusted-cert"));
|
||||
});
|
||||
|
||||
test("buildCertManualGuide(win32) → certutil -addstore Root", () => {
|
||||
const guide = buildCertManualGuide("C:/d/server.crt", "win32");
|
||||
assert.equal(guide.platform, "win32");
|
||||
assert.ok(guide.steps.join("\n").toLowerCase().includes("certutil"));
|
||||
});
|
||||
|
||||
test("installCertResult → environment skip (not a throw) when install is impossible", async () => {
|
||||
// A non-existent cert path makes installCert() throw before any privileged
|
||||
// command runs — deterministic, no sudo. The wrapper must convert that into a
|
||||
// structured skippable result with a manual guide, never a thrown error.
|
||||
const result = await installCertResult("", "/nonexistent/omniroute-4546-server.crt");
|
||||
assert.equal(result.installed, false);
|
||||
assert.equal(result.skipped, true);
|
||||
assert.equal(result.reason, "environment");
|
||||
assert.ok(result.manualGuide, "environment skip must carry a manual guide");
|
||||
assert.equal(result.manualGuide?.downloadUrl, DOWNLOAD_URL);
|
||||
// The message must be a safe string (no stack trace leaked).
|
||||
assert.equal(typeof result.message, "string");
|
||||
assert.ok(!String(result.message).includes("\n at "), "must not leak a stack trace");
|
||||
});
|
||||
@@ -12,9 +12,14 @@
|
||||
// which is semantically a no-op and does not signal "thinking complete" to clients
|
||||
// such as Claude Code).
|
||||
//
|
||||
// The fix emits a `content: "</think>"` chunk on close — matching the convention
|
||||
// already used throughout OmniRoute (see openai-responses.ts / responsesTransformer.ts
|
||||
// which split on `</think>` to separate reasoning from final content).
|
||||
// PR #4633 added an immediate `content: "</think>"` chunk on close.
|
||||
//
|
||||
// PR #5123 refined this to DEFERRED emission: instead of emitting at content_block_stop
|
||||
// (which caused the marker to leak before tool_calls in tool-use streams), the marker is
|
||||
// now queued and flushed either:
|
||||
// • at the first text_delta that follows (preserving #4633 for Claude Code / Cursor), or
|
||||
// • at message_delta finish when there are no tool_calls (pure thinking-only responses).
|
||||
// This test covers the message_delta flush path (thinking block with no subsequent text).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
@@ -33,7 +38,7 @@ function newState() {
|
||||
};
|
||||
}
|
||||
|
||||
test("claudeToOpenAIResponse emits </think> close marker on thinking content_block_stop", () => {
|
||||
test("claudeToOpenAIResponse emits </think> close marker on message finish (deferred from content_block_stop)", () => {
|
||||
const state = newState();
|
||||
|
||||
// Open thinking block.
|
||||
@@ -56,28 +61,44 @@ test("claudeToOpenAIResponse emits </think> close marker on thinking content_blo
|
||||
state
|
||||
);
|
||||
|
||||
// Close the thinking block.
|
||||
// Close the thinking block — marker is now DEFERRED (not emitted here).
|
||||
const closeChunks = claudeToOpenAIResponse(
|
||||
{ type: "content_block_stop", index: 0 },
|
||||
state
|
||||
);
|
||||
|
||||
assert.ok(Array.isArray(closeChunks), "stop event must return an array of chunks");
|
||||
assert.ok(
|
||||
closeChunks.length >= 1,
|
||||
"stop event for thinking block must emit at least one close-marker chunk"
|
||||
// content_block_stop for a thinking block no longer emits </think> immediately
|
||||
// (the marker is deferred to prevent leaking before tool_calls — see #5123).
|
||||
const immediateClose = Array.isArray(closeChunks) ? closeChunks : [];
|
||||
const hasImmediateMarker = immediateClose.some(
|
||||
(chunk) => chunk?.choices?.[0]?.delta?.content === "</think>"
|
||||
);
|
||||
assert.equal(
|
||||
hasImmediateMarker,
|
||||
false,
|
||||
"content_block_stop must NOT emit </think> immediately (deferred — see #5123)"
|
||||
);
|
||||
|
||||
const hasCloseMarker = closeChunks.some(
|
||||
// After close, the thinking-block flag is cleared and the pending marker is queued.
|
||||
assert.equal(state.inThinkingBlock, false);
|
||||
assert.equal(state.pendingThinkClose, true, "pendingThinkClose must be set after thinking block stop");
|
||||
|
||||
// message_delta with stop_reason=end_turn and no tool_calls → marker must be flushed here.
|
||||
const finishChunks = claudeToOpenAIResponse(
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 5 } },
|
||||
state
|
||||
);
|
||||
|
||||
const arr = Array.isArray(finishChunks) ? finishChunks : [];
|
||||
const hasCloseMarker = arr.some(
|
||||
(chunk) => chunk?.choices?.[0]?.delta?.content === "</think>"
|
||||
);
|
||||
assert.ok(
|
||||
hasCloseMarker,
|
||||
`expected a chunk with delta.content === "</think>"; got ${JSON.stringify(closeChunks)}`
|
||||
`expected a chunk with delta.content === "</think>" in message_delta result; got ${JSON.stringify(arr)}`
|
||||
);
|
||||
|
||||
// After close, state flag must be cleared so subsequent thinking blocks are tracked correctly.
|
||||
assert.equal(state.inThinkingBlock, false);
|
||||
// pendingThinkClose must be cleared after flush.
|
||||
assert.equal(state.pendingThinkClose, false, "pendingThinkClose must be cleared after flush");
|
||||
});
|
||||
|
||||
test("claudeToOpenAIResponse does not emit </think> on stop of non-thinking blocks", () => {
|
||||
|
||||
34
tests/unit/antigravity-finish-reason-normalization.test.ts
Normal file
34
tests/unit/antigravity-finish-reason-normalization.test.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
|
||||
|
||||
type ChatCompletionPayload = {
|
||||
choices: Array<{
|
||||
message: { content: string };
|
||||
finish_reason: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
test("AntigravityExecutor.collectStreamToResponse normalizes prohibited content finish reasons", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const response = new Response(
|
||||
'data: {"response":{"candidates":[{"content":{"parts":[{"text":"partial text"}]},"finishReason":"PROHIBITED_CONTENT"}]}}\n\n',
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
}
|
||||
);
|
||||
|
||||
const result = await executor.collectStreamToResponse(
|
||||
response,
|
||||
"gemini-2.5-flash",
|
||||
"https://example.com",
|
||||
{ Authorization: "Bearer ag-token" },
|
||||
{ request: {} }
|
||||
);
|
||||
const payload = (await result.response.json()) as ChatCompletionPayload;
|
||||
|
||||
assert.equal(payload.choices[0].message.content, "partial text");
|
||||
assert.equal(payload.choices[0].finish_reason, "content_filter");
|
||||
});
|
||||
83
tests/unit/antigravity-oauth-no-pkce-no-openid.test.ts
Normal file
83
tests/unit/antigravity-oauth-no-pkce-no-openid.test.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// Regression guard for the Antigravity OAuth login hang on Google's consent page.
|
||||
//
|
||||
// The embedded Antigravity client is a Google "Desktop/native" OAuth client.
|
||||
// Sending a PKCE code_challenge AND the `openid` scope pushed Google into the
|
||||
// `signin/oauth/firstparty/nativeapp` consent flow, which hung and never redirected
|
||||
// back (operator report 2026-06-27). The working 9router flow uses a plain
|
||||
// authorization_code grant (client_secret, no code_challenge) and does NOT request
|
||||
// `openid`. This test pins our antigravity (and the `agy` alias) to that shape.
|
||||
//
|
||||
// Flip-proof: set flowType back to "authorization_code_pkce" → generateAuthData emits
|
||||
// code_challenge → first assertion fails. Re-add "openid" → scope assertion fails.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { generateAuthData } from "../../src/lib/oauth/providers.ts";
|
||||
import PROVIDERS from "../../src/lib/oauth/providers/index.ts";
|
||||
|
||||
const REDIRECT = "http://127.0.0.1:20128/callback";
|
||||
|
||||
for (const providerId of ["antigravity", "agy"]) {
|
||||
test(`${providerId}: no PKCE + no openid in the auth URL (matches working 9router flow)`, () => {
|
||||
assert.equal(
|
||||
PROVIDERS[providerId].flowType,
|
||||
"authorization_code",
|
||||
`${providerId} must use a plain authorization_code grant (no PKCE) for the Google native client`
|
||||
);
|
||||
|
||||
const authData = generateAuthData(providerId, REDIRECT);
|
||||
assert.ok(authData.authUrl, `${providerId} must produce an auth URL`);
|
||||
|
||||
const url = new URL(authData.authUrl);
|
||||
assert.equal(url.origin, "https://accounts.google.com");
|
||||
|
||||
// No PKCE challenge — its presence triggers the hanging nativeapp consent.
|
||||
assert.equal(
|
||||
url.searchParams.get("code_challenge"),
|
||||
null,
|
||||
`${providerId} auth URL must NOT carry a PKCE code_challenge`
|
||||
);
|
||||
assert.equal(url.searchParams.get("code_challenge_method"), null);
|
||||
|
||||
// No openid scope — only the Cloud Code / userinfo scopes 9router requests.
|
||||
const scopes = (url.searchParams.get("scope") || "").split(" ");
|
||||
assert.ok(!scopes.includes("openid"), `${providerId} must not request the openid scope`);
|
||||
assert.ok(
|
||||
scopes.includes("https://www.googleapis.com/auth/cloud-platform"),
|
||||
`${providerId} must still request the cloud-platform scope`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
test("antigravity.exchangeToken never forwards code_verifier (no PKCE → no invalid_grant 500)", async () => {
|
||||
const origFetch = globalThis.fetch;
|
||||
let sentBody = "";
|
||||
globalThis.fetch = (async (_url: unknown, init: { body?: unknown } = {}) => {
|
||||
sentBody = String(init.body ?? "");
|
||||
return new Response(
|
||||
JSON.stringify({ access_token: "t", refresh_token: "r", expires_in: 3600 }),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
// Pass a codeVerifier (as the modal does — generateAuthData always mints one).
|
||||
// It MUST be ignored: the authorize URL had no code_challenge, so forwarding a
|
||||
// code_verifier makes Google reject the exchange (invalid_grant → 500).
|
||||
await PROVIDERS.antigravity.exchangeToken(
|
||||
{
|
||||
clientId: "cid",
|
||||
clientSecret: "sec",
|
||||
tokenUrl: "https://oauth2.googleapis.com/token",
|
||||
},
|
||||
"the-code",
|
||||
"http://127.0.0.1:20128/callback",
|
||||
"should-be-ignored-verifier"
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = origFetch;
|
||||
}
|
||||
const params = new URLSearchParams(sentBody);
|
||||
assert.equal(params.get("code_verifier"), null, "must NOT forward code_verifier (no PKCE)");
|
||||
assert.equal(params.get("client_secret"), "sec", "must authenticate via client_secret");
|
||||
assert.equal(params.get("grant_type"), "authorization_code");
|
||||
});
|
||||
104
tests/unit/antigravity-oauth-postexchange-nonblocking.test.ts
Normal file
104
tests/unit/antigravity-oauth-postexchange-nonblocking.test.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Regression guard for the Antigravity OAuth login hang.
|
||||
//
|
||||
// The dashboard login "just spun forever" because postExchange `await`ed the
|
||||
// onboardUser retry loop (up to 10×5s, each fetch un-timed) inline, so a slow/
|
||||
// unreachable Antigravity upstream blocked the /exchange response indefinitely.
|
||||
//
|
||||
// Fix: onboarding is fire-and-forget (matches the 9router web flow) and every
|
||||
// blocking call is AbortSignal.timeout-bounded. This test proves postExchange
|
||||
// returns promptly regardless of onboarding, and never hangs when an upstream
|
||||
// stalls.
|
||||
//
|
||||
// Flip-proof: revert onboarding to an inline `await` loop and test 1 hangs on the
|
||||
// onboard gate → times out → fails. Drop the AbortSignal.timeout and test 2
|
||||
// hangs → fails.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { antigravity } from "../../src/lib/oauth/providers/antigravity.ts";
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function jsonRes(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
// A fetch that rejects when its AbortSignal fires, and otherwise never resolves.
|
||||
// Mirrors real fetch: an already-aborted signal rejects immediately (so a shared
|
||||
// deadline reused across fallback endpoints fails fast after the first abort).
|
||||
function stalledFetch(init?: { signal?: AbortSignal }): Promise<Response> {
|
||||
return new Promise((_resolve, reject) => {
|
||||
const abortErr = () => new DOMException("The operation was aborted.", "AbortError");
|
||||
const signal = init?.signal;
|
||||
if (signal?.aborted) {
|
||||
reject(abortErr());
|
||||
return;
|
||||
}
|
||||
signal?.addEventListener("abort", () => reject(abortErr()));
|
||||
});
|
||||
}
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("postExchange returns before onboarding finishes (fire-and-forget — never blocks login)", async () => {
|
||||
// The onboard call is gated: it does not resolve until we release it AFTER
|
||||
// postExchange has already returned. With the old inline `await` loop,
|
||||
// postExchange would block on this gate forever → the test times out. With the
|
||||
// fire-and-forget fix it returns immediately.
|
||||
let releaseOnboard: () => void = () => {};
|
||||
const onboardGate = new Promise<void>((r) => {
|
||||
releaseOnboard = r;
|
||||
});
|
||||
let onboardStarted = false;
|
||||
|
||||
globalThis.fetch = (async (url: unknown) => {
|
||||
const u = String(url);
|
||||
if (u.includes("userinfo")) return jsonRes({ email: "user@example.com" });
|
||||
if (u.includes("loadCodeAssist")) {
|
||||
return jsonRes({
|
||||
cloudaicompanionProject: "proj-123",
|
||||
allowedTiers: [{ id: "legacy-tier", isDefault: true }],
|
||||
});
|
||||
}
|
||||
if (u.includes("onboardUser")) {
|
||||
onboardStarted = true;
|
||||
await onboardGate;
|
||||
return jsonRes({ done: true });
|
||||
}
|
||||
return jsonRes({});
|
||||
}) as typeof fetch;
|
||||
|
||||
const start = Date.now();
|
||||
const result = await antigravity.postExchange({ access_token: "tok" } as never);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
assert.ok(elapsed < 3000, `postExchange must not block on onboarding; took ${elapsed}ms`);
|
||||
assert.equal(result.projectId, "proj-123", "projectId still resolved from loadCodeAssist");
|
||||
|
||||
// Let the backgrounded onboarding complete cleanly (no lingering work).
|
||||
releaseOnboard();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
assert.ok(onboardStarted, "onboarding still runs — in the background, after the response");
|
||||
});
|
||||
|
||||
test("postExchange stays timeout-bounded when loadCodeAssist/userinfo stall (no infinite hang)", async () => {
|
||||
globalThis.fetch = (async (url: unknown, init?: { signal?: AbortSignal }) => {
|
||||
const u = String(url);
|
||||
if (u.includes("userinfo") || u.includes("loadCodeAssist")) return stalledFetch(init);
|
||||
return jsonRes({});
|
||||
}) as typeof fetch;
|
||||
|
||||
const start = Date.now();
|
||||
const result = await antigravity.postExchange({ access_token: "tok" } as never);
|
||||
const elapsed = Date.now() - start;
|
||||
|
||||
// userInfo + loadCodeAssist are AbortSignal.timeout(8s)-bounded (one shared
|
||||
// deadline each), so the worst case is ~16s — never an infinite hang.
|
||||
assert.ok(elapsed < 22000, `postExchange must be timeout-bounded; took ${elapsed}ms`);
|
||||
assert.equal(result.projectId, "", "no project when loadCodeAssist times out");
|
||||
});
|
||||
75
tests/unit/antigravity-refresh-empty-token-3850.test.ts
Normal file
75
tests/unit/antigravity-refresh-empty-token-3850.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Issue #3850 — Antigravity refresh nulls the stored refresh_token.
|
||||
*
|
||||
* Google's OAuth token endpoint normally OMITS `refresh_token` on a refresh
|
||||
* (its refresh tokens are non-rotating), and occasionally returns it as an
|
||||
* EMPTY STRING. The canonical `refreshGoogleToken` preserves the existing token
|
||||
* via `tokens.refresh_token || refreshToken` (treats "" as absent), but the
|
||||
* Antigravity executor's `refreshCredentials` used
|
||||
* `typeof tokens.refresh_token === "string" ? tokens.refresh_token : credentials.refreshToken`
|
||||
* — and `typeof "" === "string"` is true, so an empty-string response
|
||||
* OVERWROTE the good token with "", effectively nulling it on first refresh.
|
||||
*
|
||||
* This regression guard asserts the executor preserves the existing refresh
|
||||
* token when the upstream returns it empty or omits it.
|
||||
*/
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
|
||||
|
||||
const OLD_REFRESH = "1//old-non-rotating-refresh-token";
|
||||
|
||||
async function withStubbedFetch<T>(
|
||||
jsonBody: Record<string, unknown>,
|
||||
fn: () => Promise<T>
|
||||
): Promise<T> {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify(jsonBody), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
})) as typeof fetch;
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
}
|
||||
|
||||
test("#3850 empty-string refresh_token from Google preserves the existing token", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const refreshed = await withStubbedFetch(
|
||||
{ access_token: "new-access", refresh_token: "", expires_in: 3600 },
|
||||
() => executor.refreshCredentials({ refreshToken: OLD_REFRESH, accessToken: "stale" })
|
||||
);
|
||||
|
||||
assert.ok(refreshed, "refreshCredentials should return credentials, not null");
|
||||
assert.equal(refreshed.accessToken, "new-access");
|
||||
assert.equal(
|
||||
refreshed.refreshToken,
|
||||
OLD_REFRESH,
|
||||
"empty-string refresh_token must NOT overwrite the stored token"
|
||||
);
|
||||
});
|
||||
|
||||
test("#3850 omitted refresh_token from Google preserves the existing token", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const refreshed = await withStubbedFetch({ access_token: "new-access", expires_in: 3600 }, () =>
|
||||
executor.refreshCredentials({ refreshToken: OLD_REFRESH, accessToken: "stale" })
|
||||
);
|
||||
|
||||
assert.ok(refreshed);
|
||||
assert.equal(refreshed.refreshToken, OLD_REFRESH);
|
||||
});
|
||||
|
||||
test("#3850 a real rotated refresh_token still replaces the stored token", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const refreshed = await withStubbedFetch(
|
||||
{ access_token: "new-access", refresh_token: "1//brand-new-token", expires_in: 3600 },
|
||||
() => executor.refreshCredentials({ refreshToken: OLD_REFRESH, accessToken: "stale" })
|
||||
);
|
||||
|
||||
assert.ok(refreshed);
|
||||
assert.equal(refreshed.refreshToken, "1//brand-new-token");
|
||||
});
|
||||
109
tests/unit/api/combo-002-first-field.test.ts
Normal file
109
tests/unit/api/combo-002-first-field.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* TDD regression guard for #5083 — Bug 3:
|
||||
* COMBO_002 validation errors only surface the generic
|
||||
* "One or more combo fields are invalid" message; the field-level reason
|
||||
* is buried inside error.details.issues.details[*].
|
||||
*
|
||||
* Fix: extract the first issue from error.details and add
|
||||
* error.details.firstField / error.details.firstMessage to the response body.
|
||||
*/
|
||||
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { buildComboErrorBody } from "@/lib/api/comboErrorResponse";
|
||||
|
||||
/**
|
||||
* Simulate what the PUT /api/combos/[id] route passes as `details`
|
||||
* for a COMBO_002 response after the fix.
|
||||
* The fix extracts the first Zod issue from validation.error.details
|
||||
* and adds firstField / firstMessage to the payload.
|
||||
*/
|
||||
describe("COMBO_002 response — firstField / firstMessage surfacing (#5083 Bug 3)", () => {
|
||||
it("details payload exposes firstField when provided", () => {
|
||||
// Simulate the fixed route calling comboErrorResponse with firstField/firstMessage
|
||||
const details = {
|
||||
issues: { message: "Invalid request", details: [{ field: "name", message: "Required" }] },
|
||||
firstField: "name",
|
||||
firstMessage: "Required",
|
||||
};
|
||||
const body = buildComboErrorBody("COMBO_002", details);
|
||||
assert.equal(body.error.code, "COMBO_002");
|
||||
assert.equal(body.error.details.firstField, "name");
|
||||
assert.equal(body.error.details.firstMessage, "Required");
|
||||
});
|
||||
|
||||
it("details payload exposes firstField for nested path (e.g. models.0.id)", () => {
|
||||
const details = {
|
||||
issues: {
|
||||
message: "Invalid request",
|
||||
details: [{ field: "models.0.id", message: "String must contain at least 1 character(s)" }],
|
||||
},
|
||||
firstField: "models.0.id",
|
||||
firstMessage: "String must contain at least 1 character(s)",
|
||||
};
|
||||
const body = buildComboErrorBody("COMBO_002", details);
|
||||
assert.equal(body.error.details.firstField, "models.0.id");
|
||||
assert.equal(body.error.details.firstMessage, "String must contain at least 1 character(s)");
|
||||
});
|
||||
|
||||
it("details payload has firstField=null when no issues are present", () => {
|
||||
// Edge case: empty issues list
|
||||
const details = {
|
||||
issues: { message: "Invalid request", details: [] },
|
||||
firstField: null,
|
||||
firstMessage: null,
|
||||
};
|
||||
const body = buildComboErrorBody("COMBO_002", details);
|
||||
assert.equal(body.error.details.firstField, null);
|
||||
assert.equal(body.error.details.firstMessage, null);
|
||||
});
|
||||
|
||||
it("the generic message key is still present (backward compat)", () => {
|
||||
const details = {
|
||||
issues: { message: "Invalid request", details: [{ field: "strategy", message: "Invalid enum value" }] },
|
||||
firstField: "strategy",
|
||||
firstMessage: "Invalid enum value",
|
||||
};
|
||||
const body = buildComboErrorBody("COMBO_002", details);
|
||||
// The top-level error.message is from the error code catalog, not the issue message
|
||||
assert.equal(body.error.message, "One or more combo fields are invalid");
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* This helper simulates the logic that the fixed route.ts uses to extract
|
||||
* firstField/firstMessage from validateBody's error payload.
|
||||
* It exercises the extraction logic independently of the heavy route harness.
|
||||
*/
|
||||
describe("COMBO_002 firstField extraction logic", () => {
|
||||
function extractFirstField(validationError: {
|
||||
message: string;
|
||||
details: Array<{ field: string; message: string }>;
|
||||
}): { firstField: string | null; firstMessage: string | null } {
|
||||
const first = validationError.details?.[0] ?? null;
|
||||
return {
|
||||
firstField: first?.field ?? null,
|
||||
firstMessage: first?.message ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
it("extracts the first field from a non-empty details array", () => {
|
||||
const error = {
|
||||
message: "Invalid request",
|
||||
details: [
|
||||
{ field: "name", message: "Required" },
|
||||
{ field: "strategy", message: "Invalid enum value" },
|
||||
],
|
||||
};
|
||||
const { firstField, firstMessage } = extractFirstField(error);
|
||||
assert.equal(firstField, "name");
|
||||
assert.equal(firstMessage, "Required");
|
||||
});
|
||||
|
||||
it("returns null/null for an empty details array", () => {
|
||||
const error = { message: "Invalid request", details: [] };
|
||||
const { firstField, firstMessage } = extractFirstField(error);
|
||||
assert.equal(firstField, null);
|
||||
assert.equal(firstMessage, null);
|
||||
});
|
||||
});
|
||||
@@ -49,6 +49,13 @@ test("#3061 opencode-zen no-auth: first selection returns synthetic noauth (happ
|
||||
assert.equal((creds as { connectionId?: string }).connectionId, "noauth");
|
||||
});
|
||||
|
||||
test("#3061 mimocode no-auth: first selection returns synthetic noauth (happy path preserved)", async () => {
|
||||
const creds = await getProviderCredentials("mimocode", null, null, "mimo-auto");
|
||||
assert.ok(creds, "mimocode must resolve to synthetic no-auth credentials on first selection");
|
||||
assert.equal((creds as { connectionId?: string }).connectionId, "noauth");
|
||||
assert.equal((creds as { apiKey?: unknown }).apiKey, null);
|
||||
});
|
||||
|
||||
// ── The fix: once "noauth" is excluded, selection MUST stop (return null) ──
|
||||
|
||||
test("#3061 opencode no-auth: excluding 'noauth' returns null (breaks the fallback loop)", async () => {
|
||||
@@ -73,3 +80,14 @@ test("#3061 opencode-zen no-auth: excluding 'noauth' returns null (breaks the fa
|
||||
"excluded synthetic noauth must not be re-selected for the opencode-zen keyless path"
|
||||
);
|
||||
});
|
||||
|
||||
test("#3061 mimocode no-auth: excluding 'noauth' returns null (breaks the fallback loop)", async () => {
|
||||
const creds = await getProviderCredentials("mimocode", null, null, "mimo-auto", {
|
||||
excludeConnectionIds: ["noauth"],
|
||||
});
|
||||
assert.equal(
|
||||
creds,
|
||||
null,
|
||||
"excluded synthetic noauth must not be re-selected for the mimocode keyless path"
|
||||
);
|
||||
});
|
||||
|
||||
97
tests/unit/authz/route-guard-version-get-exemption.test.ts
Normal file
97
tests/unit/authz/route-guard-version-get-exemption.test.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* TDD regression guard for #5083 — Bug 2:
|
||||
* GET /api/system/version is blocked from LAN/remote hosts because the entire
|
||||
* path is in LOCAL_ONLY_API_PREFIXES for all methods. Only POST spawns child
|
||||
* processes (git/npm/pm2); GET only reads package.json + npm registry.
|
||||
*
|
||||
* Fix: isLocalOnlyPath(path, method) returns false for safe HTTP methods
|
||||
* when the path exactly matches LOCAL_ONLY_API_GET_EXEMPTIONS.
|
||||
*
|
||||
* Security invariant: POST /api/system/version MUST remain local-only.
|
||||
* All OTHER local-only prefixes (/api/mcp/, /api/services/, etc.) must
|
||||
* remain local-only for GET too (exemption is exact-match only).
|
||||
*/
|
||||
|
||||
import { test, describe } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
isLocalOnlyPath,
|
||||
LOCAL_ONLY_API_GET_EXEMPTIONS,
|
||||
} from "../../../src/server/authz/routeGuard.ts";
|
||||
|
||||
describe("isLocalOnlyPath — GET exemption for /api/system/version (#5083)", () => {
|
||||
// ── EXEMPTION APPLIES ──────────────────────────────────────────────────────
|
||||
|
||||
test("GET /api/system/version is NOT local-only (no child process spawn)", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/system/version", "GET"), false);
|
||||
});
|
||||
|
||||
test("HEAD /api/system/version is NOT local-only (read-only method)", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/system/version", "HEAD"), false);
|
||||
});
|
||||
|
||||
test("OPTIONS /api/system/version is NOT local-only (CORS preflight)", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/system/version", "OPTIONS"), false);
|
||||
});
|
||||
|
||||
// ── SPAWN-CAPABLE METHODS REMAIN BLOCKED ──────────────────────────────────
|
||||
|
||||
test("POST /api/system/version STAYS local-only (spawns git/npm/pm2)", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/system/version", "POST"), true);
|
||||
});
|
||||
|
||||
test("PUT /api/system/version stays local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/system/version", "PUT"), true);
|
||||
});
|
||||
|
||||
test("PATCH /api/system/version stays local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/system/version", "PATCH"), true);
|
||||
});
|
||||
|
||||
test("DELETE /api/system/version stays local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/system/version", "DELETE"), true);
|
||||
});
|
||||
|
||||
// ── SAFE DEFAULT: no method arg → still blocked ─────────────────────────
|
||||
|
||||
test("isLocalOnlyPath('/api/system/version') with NO method arg returns true (safe default)", () => {
|
||||
// Scripts like check-route-guard-membership call without a method; safe default
|
||||
// must be true so spawn-capable paths are never accidentally unblocked.
|
||||
assert.equal(isLocalOnlyPath("/api/system/version"), true);
|
||||
});
|
||||
|
||||
// ── EXEMPTION IS EXACT-MATCH ONLY ─────────────────────────────────────────
|
||||
|
||||
test("GET /api/system/version/extra is NOT exempted (prefix would be too broad)", () => {
|
||||
// The exemption applies only to the exact path — sub-paths are NOT exempted.
|
||||
assert.equal(isLocalOnlyPath("/api/system/version/extra", "GET"), true);
|
||||
});
|
||||
|
||||
// ── OTHER LOCAL-ONLY PREFIXES UNAFFECTED BY GET EXEMPTION ─────────────────
|
||||
|
||||
test("GET /api/mcp/ still local-only — exemption is NOT applied to /api/mcp/", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/mcp/sse", "GET"), true);
|
||||
});
|
||||
|
||||
test("GET /api/services/9router/start still local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/services/9router/start", "GET"), true);
|
||||
});
|
||||
|
||||
test("GET /api/cli-tools/runtime/claude still local-only", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/cli-tools/runtime/claude", "GET"), true);
|
||||
});
|
||||
|
||||
test("GET /api/db-backups/exportAll still local-only (spawns tar)", () => {
|
||||
assert.equal(isLocalOnlyPath("/api/db-backups/exportAll", "GET"), true);
|
||||
});
|
||||
|
||||
// ── EXEMPTION SET IS EXPORTED AND CONTAINS EXACTLY /api/system/version ───
|
||||
|
||||
test("LOCAL_ONLY_API_GET_EXEMPTIONS contains /api/system/version", () => {
|
||||
assert.ok(LOCAL_ONLY_API_GET_EXEMPTIONS.has("/api/system/version"));
|
||||
});
|
||||
|
||||
test("LOCAL_ONLY_API_GET_EXEMPTIONS has exactly 1 entry", () => {
|
||||
assert.equal(LOCAL_ONLY_API_GET_EXEMPTIONS.size, 1);
|
||||
});
|
||||
});
|
||||
@@ -499,7 +499,7 @@ test("chatCore keeps Responses-native Codex payloads in native passthrough mode"
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.match(call.url, /\/responses$/);
|
||||
assert.equal(call.body.input, "ship it");
|
||||
assert.deepEqual(call.body.input, [{ type: "message", role: "user", content: [{ type: "input_text", text: "ship it" }] }]);
|
||||
assert.equal(call.body.instructions, "custom system prompt");
|
||||
assert.equal(call.body.store, false);
|
||||
assert.deepEqual(call.body.metadata, { source: "codex-client" });
|
||||
|
||||
177
tests/unit/claude-to-openai-think-close-5123.test.ts
Normal file
177
tests/unit/claude-to-openai-think-close-5123.test.ts
Normal file
@@ -0,0 +1,177 @@
|
||||
// Regression test for issue #5123 — </think> leaks as delta.content before tool_calls
|
||||
// in Claude→OpenAI streaming translation, corrupting OpenAI-compatible clients (Kimi Coding).
|
||||
//
|
||||
// Root cause: content_block_stop for a thinking block unconditionally emitted
|
||||
// createChunk(state, { content: "</think>" }) even when the next block was tool_use.
|
||||
//
|
||||
// Fix: defer </think> emission. Only flush when the next event is a text_delta
|
||||
// (preserving the #4633 behavior for Claude Code / Cursor) or at message finish
|
||||
// when there are no tool_calls.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { claudeToOpenAIResponse } = await import(
|
||||
"../../open-sse/translator/response/claude-to-openai.ts"
|
||||
);
|
||||
|
||||
function newState() {
|
||||
return {
|
||||
toolCalls: new Map(),
|
||||
toolNameMap: new Map(),
|
||||
messageId: "msg_test",
|
||||
model: "claude-3-7-sonnet",
|
||||
toolCallIndex: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function collectChunks(results: ReturnType<typeof claudeToOpenAIResponse>[]): unknown[] {
|
||||
return results.flatMap((r) => (Array.isArray(r) ? r : r ? [r] : []));
|
||||
}
|
||||
|
||||
// ─── Case (a): thinking block followed by tool_use ───────────────────────────
|
||||
// This is the regression case. Before the fix, </think> appears as a spurious
|
||||
// assistant text chunk right before the tool_calls delta, corrupting clients.
|
||||
test("thinking block followed by tool_use: </think> must NOT appear in any content chunk", () => {
|
||||
const state = newState();
|
||||
|
||||
const allResults: ReturnType<typeof claudeToOpenAIResponse>[] = [];
|
||||
|
||||
// message_start
|
||||
allResults.push(
|
||||
claudeToOpenAIResponse({ type: "message_start", message: { id: "msg_1", model: "claude-3-7-sonnet" } }, state)
|
||||
);
|
||||
|
||||
// thinking block open
|
||||
allResults.push(
|
||||
claudeToOpenAIResponse(
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } },
|
||||
state
|
||||
)
|
||||
);
|
||||
|
||||
// thinking delta
|
||||
allResults.push(
|
||||
claudeToOpenAIResponse(
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "Let me think..." } },
|
||||
state
|
||||
)
|
||||
);
|
||||
|
||||
// thinking block stop — currently emits </think> unconditionally (the bug)
|
||||
allResults.push(claudeToOpenAIResponse({ type: "content_block_stop", index: 0 }, state));
|
||||
|
||||
// tool_use block open
|
||||
allResults.push(
|
||||
claudeToOpenAIResponse(
|
||||
{
|
||||
type: "content_block_start",
|
||||
index: 1,
|
||||
content_block: { type: "tool_use", id: "toolu_01", name: "get_weather" },
|
||||
},
|
||||
state
|
||||
)
|
||||
);
|
||||
|
||||
// tool arguments delta
|
||||
allResults.push(
|
||||
claudeToOpenAIResponse(
|
||||
{ type: "content_block_delta", index: 1, delta: { type: "input_json_delta", partial_json: '{"city":"Paris"}' } },
|
||||
state
|
||||
)
|
||||
);
|
||||
|
||||
// tool_use block stop
|
||||
allResults.push(claudeToOpenAIResponse({ type: "content_block_stop", index: 1 }, state));
|
||||
|
||||
// message_delta with stop_reason=tool_use
|
||||
allResults.push(
|
||||
claudeToOpenAIResponse(
|
||||
{ type: "message_delta", delta: { stop_reason: "tool_use" }, usage: { output_tokens: 42 } },
|
||||
state
|
||||
)
|
||||
);
|
||||
|
||||
const chunks = collectChunks(allResults);
|
||||
|
||||
const spuriousThinkClose = chunks.filter(
|
||||
(chunk: any) => chunk?.choices?.[0]?.delta?.content === "</think>"
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
spuriousThinkClose.length,
|
||||
0,
|
||||
`Expected NO chunk with delta.content === "</think>" before tool_calls, but found ${spuriousThinkClose.length}:\n${JSON.stringify(spuriousThinkClose, null, 2)}`
|
||||
);
|
||||
});
|
||||
|
||||
// ─── Case (b): thinking block followed by text (pure-text response) ──────────
|
||||
// This is the #4633 happy path. </think> MUST still be emitted so Claude Code /
|
||||
// Cursor know when the thinking section ends.
|
||||
test("thinking block followed by text: </think> IS still emitted (preserves #4633)", () => {
|
||||
const state = newState();
|
||||
|
||||
const allResults: ReturnType<typeof claudeToOpenAIResponse>[] = [];
|
||||
|
||||
// message_start
|
||||
allResults.push(
|
||||
claudeToOpenAIResponse({ type: "message_start", message: { id: "msg_2", model: "claude-3-7-sonnet" } }, state)
|
||||
);
|
||||
|
||||
// thinking block open
|
||||
allResults.push(
|
||||
claudeToOpenAIResponse(
|
||||
{ type: "content_block_start", index: 0, content_block: { type: "thinking", thinking: "" } },
|
||||
state
|
||||
)
|
||||
);
|
||||
|
||||
// thinking delta
|
||||
allResults.push(
|
||||
claudeToOpenAIResponse(
|
||||
{ type: "content_block_delta", index: 0, delta: { type: "thinking_delta", thinking: "Plan..." } },
|
||||
state
|
||||
)
|
||||
);
|
||||
|
||||
// thinking block stop
|
||||
allResults.push(claudeToOpenAIResponse({ type: "content_block_stop", index: 0 }, state));
|
||||
|
||||
// text block open
|
||||
allResults.push(
|
||||
claudeToOpenAIResponse(
|
||||
{ type: "content_block_start", index: 1, content_block: { type: "text", text: "" } },
|
||||
state
|
||||
)
|
||||
);
|
||||
|
||||
// text delta (this should trigger the deferred </think> flush)
|
||||
allResults.push(
|
||||
claudeToOpenAIResponse(
|
||||
{ type: "content_block_delta", index: 1, delta: { type: "text_delta", text: "Hello!" } },
|
||||
state
|
||||
)
|
||||
);
|
||||
|
||||
// text block stop
|
||||
allResults.push(claudeToOpenAIResponse({ type: "content_block_stop", index: 1 }, state));
|
||||
|
||||
// message_delta with stop_reason=end_turn
|
||||
allResults.push(
|
||||
claudeToOpenAIResponse(
|
||||
{ type: "message_delta", delta: { stop_reason: "end_turn" }, usage: { output_tokens: 10 } },
|
||||
state
|
||||
)
|
||||
);
|
||||
|
||||
const chunks = collectChunks(allResults);
|
||||
|
||||
const hasThinkClose = chunks.some(
|
||||
(chunk: any) => chunk?.choices?.[0]?.delta?.content === "</think>"
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
hasThinkClose,
|
||||
`Expected a chunk with delta.content === "</think>" in a pure-text thinking response, but none found.\nAll chunks: ${JSON.stringify(chunks, null, 2)}`
|
||||
);
|
||||
});
|
||||
107
tests/unit/cli-login-antigravity.test.ts
Normal file
107
tests/unit/cli-login-antigravity.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
// Tests for the `omniroute login antigravity` local OAuth helper.
|
||||
//
|
||||
// The helper runs the OAuth on the user's own machine (where the Google
|
||||
// native-loopback consent can complete) and prints a credential blob to paste
|
||||
// into a remote install. We test the two pieces that, if wrong, silently break
|
||||
// the flow: the authorization request (must be a plain authorization_code grant
|
||||
// with a 127.0.0.1 loopback redirect and NO PKCE challenge) and the end-to-end
|
||||
// orchestration (state validation + exchange + blob emission), with the browser,
|
||||
// loopback server, and token exchange injected as fakes.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
buildAntigravityAuthRequest,
|
||||
runAntigravityLogin,
|
||||
} from "../../bin/cli/commands/login.mjs";
|
||||
import { decodeCredentialBlob } from "../../src/lib/oauth/credentialBlob.ts";
|
||||
|
||||
test("buildAntigravityAuthRequest: loopback redirect on 127.0.0.1 + no PKCE", async () => {
|
||||
const { authUrl, redirectUri, state } = await buildAntigravityAuthRequest(54321, () => "fixed");
|
||||
assert.equal(redirectUri, "http://127.0.0.1:54321/callback");
|
||||
assert.equal(state, "fixed");
|
||||
|
||||
const url = new URL(authUrl);
|
||||
assert.equal(url.origin, "https://accounts.google.com");
|
||||
assert.equal(url.searchParams.get("redirect_uri"), redirectUri);
|
||||
assert.equal(url.searchParams.get("response_type"), "code");
|
||||
assert.equal(url.searchParams.get("state"), "fixed");
|
||||
assert.equal(url.searchParams.get("code_challenge"), null, "must NOT carry a PKCE challenge");
|
||||
});
|
||||
|
||||
test("runAntigravityLogin: validates state, exchanges code, prints a decodable blob", async () => {
|
||||
let exchangedCode = null;
|
||||
let exchangedRedirect = null;
|
||||
|
||||
const blob = await runAntigravityLogin(
|
||||
{},
|
||||
{
|
||||
makeState: () => "S",
|
||||
openBrowser: async () => {},
|
||||
startServer: async () => ({
|
||||
port: 54321,
|
||||
waitForCallback: async () => ({ code: "the-code", state: "S" }),
|
||||
close: async () => {},
|
||||
}),
|
||||
exchange: async (code, redirectUri) => {
|
||||
exchangedCode = code;
|
||||
exchangedRedirect = redirectUri;
|
||||
return { access_token: "ya29.a", refresh_token: "1//r", expires_in: 3600, scope: "x" };
|
||||
},
|
||||
print: () => {},
|
||||
log: () => {},
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(exchangedCode, "the-code");
|
||||
assert.equal(exchangedRedirect, "http://127.0.0.1:54321/callback");
|
||||
|
||||
const decoded = decodeCredentialBlob(blob);
|
||||
assert.equal(decoded.provider, "antigravity");
|
||||
assert.equal(decoded.tokens.access_token, "ya29.a");
|
||||
assert.equal(decoded.tokens.refresh_token, "1//r");
|
||||
});
|
||||
|
||||
test("runAntigravityLogin: rejects a state mismatch (CSRF guard)", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runAntigravityLogin(
|
||||
{},
|
||||
{
|
||||
makeState: () => "expected",
|
||||
openBrowser: async () => {},
|
||||
startServer: async () => ({
|
||||
port: 1,
|
||||
waitForCallback: async () => ({ code: "c", state: "ATTACKER" }),
|
||||
close: async () => {},
|
||||
}),
|
||||
exchange: async () => ({ access_token: "x" }),
|
||||
print: () => {},
|
||||
log: () => {},
|
||||
}
|
||||
),
|
||||
/state mismatch|csrf/i
|
||||
);
|
||||
});
|
||||
|
||||
test("runAntigravityLogin: surfaces an OAuth error param", async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
runAntigravityLogin(
|
||||
{},
|
||||
{
|
||||
makeState: () => "S",
|
||||
openBrowser: async () => {},
|
||||
startServer: async () => ({
|
||||
port: 1,
|
||||
waitForCallback: async () => ({ error: "access_denied", state: "S" }),
|
||||
close: async () => {},
|
||||
}),
|
||||
exchange: async () => ({ access_token: "x" }),
|
||||
print: () => {},
|
||||
log: () => {},
|
||||
}
|
||||
),
|
||||
/access_denied|authorization failed/i
|
||||
);
|
||||
});
|
||||
29
tests/unit/cli-serve-hostname.test.ts
Normal file
29
tests/unit/cli-serve-hostname.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* Replicate the HOSTNAME resolution from bin/cli/commands/serve.mjs to verify
|
||||
* that the spawned server honours a HOSTNAME provided via env/.env instead of
|
||||
* always hardcoding "0.0.0.0" (#5134). Mirrors the in-file replication pattern
|
||||
* used by cli-serve-port.test.ts (serve.mjs spawns processes, so the logic is
|
||||
* tested in isolation rather than imported).
|
||||
*/
|
||||
function resolveHostname(envHostname: string | undefined): string {
|
||||
return envHostname || "0.0.0.0";
|
||||
}
|
||||
|
||||
test("serve hostname: honours HOSTNAME env var when set", () => {
|
||||
assert.equal(resolveHostname("127.0.0.1"), "127.0.0.1");
|
||||
});
|
||||
|
||||
test("serve hostname: honours a specific bind interface", () => {
|
||||
assert.equal(resolveHostname("192.168.0.15"), "192.168.0.15");
|
||||
});
|
||||
|
||||
test("serve hostname: falls back to 0.0.0.0 when HOSTNAME is unset", () => {
|
||||
assert.equal(resolveHostname(undefined), "0.0.0.0");
|
||||
});
|
||||
|
||||
test("serve hostname: falls back to 0.0.0.0 when HOSTNAME is an empty string", () => {
|
||||
assert.equal(resolveHostname(""), "0.0.0.0");
|
||||
});
|
||||
195
tests/unit/command-code-user-array-5166.test.ts
Normal file
195
tests/unit/command-code-user-array-5166.test.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
/**
|
||||
* Regression test for #5166 (user-content-array 400 on Command Code / deepseek-v4-pro).
|
||||
*
|
||||
* When a client sends a user message whose `content` is an array of content parts
|
||||
* (e.g. [{type:"text",text:"Hello"},{type:"text",text:"World"}]), the raw array
|
||||
* must NOT reach the Command Code upstream — it requires user content to be a plain
|
||||
* string. The executor must normalise the array to a string before posting.
|
||||
*
|
||||
* NOTE: this file covers ONLY the user-content-array/400 symptom of #5166.
|
||||
* The 0-output-token symptom on mimo-v2.5-pro (reasoning-only models) is tracked
|
||||
* separately and is NOT addressed here.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(
|
||||
path.join(os.tmpdir(), "omniroute-cmd-code-user-array-5166-")
|
||||
);
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const { getExecutor } = await import("../../open-sse/executors/index.ts");
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
function commandCodeStream(lines: unknown[]) {
|
||||
const text = lines.map((l) => JSON.stringify(l)).join("\n") + "\n";
|
||||
return new Response(text, { status: 200, headers: { "Content-Type": "application/x-ndjson" } });
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
// ── helpers ────────────────────────────────────────────────────────────────────
|
||||
|
||||
type FetchCall = { url: string; init: Record<string, unknown>; body: Record<string, unknown> };
|
||||
|
||||
function captureFetch(response: Response) {
|
||||
const calls: FetchCall[] = [];
|
||||
globalThis.fetch = async (url, init: RequestInit = {}) => {
|
||||
calls.push({ url: String(url), init: init as Record<string, unknown>, body: JSON.parse(String(init.body)) });
|
||||
return response;
|
||||
};
|
||||
return calls;
|
||||
}
|
||||
|
||||
// ── failing tests (before fix, user content is the raw array) ──────────────
|
||||
|
||||
test(
|
||||
"#5166 user message with multi-part array content is flattened to a string (#5166)",
|
||||
async () => {
|
||||
const calls = captureFetch(
|
||||
commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
|
||||
);
|
||||
|
||||
await getExecutor("command-code").execute({
|
||||
model: "deepseek/deepseek-v4-pro",
|
||||
stream: false,
|
||||
credentials: { apiKey: "cc_test_key" },
|
||||
body: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Hello" },
|
||||
{ type: "text", text: "World" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const posted = calls[0].body;
|
||||
const userMsg = (posted.params as Record<string, unknown[]>).messages[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
|
||||
// Must be a string — never an array — otherwise Command Code's upstream returns 400.
|
||||
assert.equal(
|
||||
typeof userMsg.content,
|
||||
"string",
|
||||
`user message content must be a string, got ${typeof userMsg.content}`
|
||||
);
|
||||
// Joined text parts with "\n"
|
||||
assert.equal(userMsg.content, "Hello\nWorld");
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"#5166 user message with single text-part array is flattened to a plain string",
|
||||
async () => {
|
||||
const calls = captureFetch(
|
||||
commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
|
||||
);
|
||||
|
||||
await getExecutor("command-code").execute({
|
||||
model: "deepseek/deepseek-v4-pro",
|
||||
stream: false,
|
||||
credentials: { apiKey: "cc_test_key" },
|
||||
body: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "Hi there" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const posted = calls[0].body;
|
||||
const userMsg = (posted.params as Record<string, unknown[]>).messages[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal(typeof userMsg.content, "string");
|
||||
assert.equal(userMsg.content, "Hi there");
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"#5166 user message with plain string content passes through unchanged (no regression)",
|
||||
async () => {
|
||||
const calls = captureFetch(
|
||||
commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
|
||||
);
|
||||
|
||||
await getExecutor("command-code").execute({
|
||||
model: "deepseek/deepseek-v4-pro",
|
||||
stream: false,
|
||||
credentials: { apiKey: "cc_test_key" },
|
||||
body: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: "Plain string message",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const posted = calls[0].body;
|
||||
const userMsg = (posted.params as Record<string, unknown[]>).messages[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal(typeof userMsg.content, "string");
|
||||
assert.equal(userMsg.content, "Plain string message");
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"#5166 user message with mixed parts (text + image_url) keeps only text parts",
|
||||
async () => {
|
||||
const calls = captureFetch(
|
||||
commandCodeStream([{ type: "text-delta", text: "ok" }, { type: "finish" }])
|
||||
);
|
||||
|
||||
await getExecutor("command-code").execute({
|
||||
model: "deepseek/deepseek-v4-pro",
|
||||
stream: false,
|
||||
credentials: { apiKey: "cc_test_key" },
|
||||
body: {
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "Describe this:" },
|
||||
{ type: "image_url", image_url: { url: "https://example.com/img.png" } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const posted = calls[0].body;
|
||||
const userMsg = (posted.params as Record<string, unknown[]>).messages[0] as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
assert.equal(typeof userMsg.content, "string");
|
||||
// Only text parts extracted; image_url part is dropped (not a "text" type)
|
||||
assert.equal(userMsg.content, "Describe this:");
|
||||
}
|
||||
);
|
||||
68
tests/unit/compression/ccrQuery.test.ts
Normal file
68
tests/unit/compression/ccrQuery.test.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
queryBlock,
|
||||
MAX_GREP_MATCHES,
|
||||
} from "../../../open-sse/services/compression/engines/ccr/ccrQuery.ts";
|
||||
|
||||
const block = ["l1 ok", "l2 ERROR foo", "l3 ok", "l4 ERROR bar", "l5 ok"].join("\n");
|
||||
|
||||
test("full / ausente → texto inteiro (backward-compat)", () => {
|
||||
assert.deepEqual(queryBlock(block, {}), { content: block });
|
||||
assert.deepEqual(queryBlock(block, { mode: "full" }), { content: block });
|
||||
});
|
||||
|
||||
test("head:n e tail:n", () => {
|
||||
assert.deepEqual(queryBlock(block, { mode: "head", n: 2 }), { content: "l1 ok\nl2 ERROR foo" });
|
||||
assert.deepEqual(queryBlock(block, { mode: "tail", n: 2 }), { content: "l4 ERROR bar\nl5 ok" });
|
||||
assert.deepEqual(queryBlock(block, { mode: "head", n: 99 }), { content: block });
|
||||
});
|
||||
|
||||
test("lines:start-end 1-indexed inclusive, clamp", () => {
|
||||
assert.deepEqual(queryBlock(block, { mode: "lines", start: 2, end: 3 }), {
|
||||
content: "l2 ERROR foo\nl3 ok",
|
||||
});
|
||||
assert.deepEqual(queryBlock(block, { mode: "lines", start: 4, end: 99 }), {
|
||||
content: "l4 ERROR bar\nl5 ok",
|
||||
});
|
||||
assert.ok("error" in queryBlock(block, { mode: "lines", start: 3, end: 1 }));
|
||||
});
|
||||
|
||||
test("grep casa linhas; unique deduplica", () => {
|
||||
const r = queryBlock(block, { mode: "grep", pattern: "ERROR" });
|
||||
assert.deepEqual(r, { content: "l2 ERROR foo\nl4 ERROR bar" });
|
||||
const dup = ["x ERR", "x ERR", "y ERR"].join("\n");
|
||||
const u = queryBlock(dup, { mode: "grep", pattern: "ERR", unique: true });
|
||||
assert.deepEqual(u, { content: "x ERR\ny ERR" });
|
||||
});
|
||||
|
||||
test("grep ReDoS rejeitado por safe-regex", () => {
|
||||
const r = queryBlock(block, { mode: "grep", pattern: "(a+)+$" });
|
||||
assert.ok("error" in r);
|
||||
assert.match((r as { error: string }).error, /backtrack|unsafe|reject/i);
|
||||
});
|
||||
|
||||
test("grep regex inválido → erro; pattern grande → erro", () => {
|
||||
assert.ok("error" in queryBlock(block, { mode: "grep", pattern: "[" }));
|
||||
assert.ok("error" in queryBlock(block, { mode: "grep", pattern: "a".repeat(600) }));
|
||||
});
|
||||
|
||||
test("grep cap de matches", () => {
|
||||
const big = Array.from({ length: MAX_GREP_MATCHES + 50 }, (_, i) => `row ${i} HIT`).join("\n");
|
||||
const r = queryBlock(big, { mode: "grep", pattern: "HIT" }) as { content: string };
|
||||
const lines = r.content.split("\n").filter((l) => l.includes("HIT"));
|
||||
assert.ok(lines.length <= MAX_GREP_MATCHES);
|
||||
assert.match(r.content, /truncat/i);
|
||||
});
|
||||
|
||||
test("stats → JSON lines/chars/bytes", () => {
|
||||
const r = queryBlock(block, { mode: "stats" }) as { content: string };
|
||||
const s = JSON.parse(r.content);
|
||||
assert.equal(s.lines, 5);
|
||||
assert.equal(s.chars, block.length);
|
||||
assert.equal(s.bytes, Buffer.byteLength(block, "utf8"));
|
||||
});
|
||||
|
||||
test("queryBlock nunca lança (fail-safe)", () => {
|
||||
assert.doesNotThrow(() => queryBlock(block, { mode: "head", n: -5 }));
|
||||
});
|
||||
39
tests/unit/compression/ccrRanged.test.ts
Normal file
39
tests/unit/compression/ccrRanged.test.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
storeBlock,
|
||||
handleCcrRetrieve,
|
||||
resetCcrStore,
|
||||
} from "../../../open-sse/services/compression/engines/ccr/index.ts";
|
||||
|
||||
test.beforeEach(() => resetCcrStore());
|
||||
|
||||
const block = ["a ok", "b ERROR", "c ok", "d ERROR"].join("\n");
|
||||
|
||||
test("sem mode → bloco inteiro (backward-compat)", () => {
|
||||
const h = storeBlock(block, "p1");
|
||||
assert.deepEqual(handleCcrRetrieve({ hash: h }, "p1"), { content: block });
|
||||
});
|
||||
|
||||
test("modos ranged via handler", () => {
|
||||
const h = storeBlock(block, "p1");
|
||||
assert.deepEqual(handleCcrRetrieve({ hash: h, mode: "head", n: 1 }, "p1"), { content: "a ok" });
|
||||
assert.deepEqual(handleCcrRetrieve({ hash: h, mode: "grep", pattern: "ERROR" }, "p1"), {
|
||||
content: "b ERROR\nd ERROR",
|
||||
});
|
||||
const stats = handleCcrRetrieve({ hash: h, mode: "stats" }, "p1") as { content: string };
|
||||
assert.equal(JSON.parse(stats.content).lines, 4);
|
||||
});
|
||||
|
||||
test("scoping preservado: principal errado → not found", () => {
|
||||
const h = storeBlock(block, "p1");
|
||||
const r = handleCcrRetrieve({ hash: h, mode: "head", n: 1 }, "p2");
|
||||
assert.ok("error" in r);
|
||||
assert.match((r as { error: string }).error, /not found/i);
|
||||
});
|
||||
|
||||
test("grep ReDoS via handler → erro (não crash)", () => {
|
||||
const h = storeBlock(block, "p1");
|
||||
const r = handleCcrRetrieve({ hash: h, mode: "grep", pattern: "(a+)+$" }, "p1");
|
||||
assert.ok("error" in r);
|
||||
});
|
||||
35
tests/unit/compression/encoderComparison.test.ts
Normal file
35
tests/unit/compression/encoderComparison.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { summarizeEncoderCandidates } from "../../../open-sse/services/compression/engines/headroom/encoderComparison.ts";
|
||||
|
||||
const byteLen = (s: string) => Buffer.byteLength(s, "utf8");
|
||||
|
||||
test("agrega sizes e elege winner por tokens", () => {
|
||||
const messages = [
|
||||
{
|
||||
role: "user",
|
||||
content: JSON.stringify(Array.from({ length: 20 }, (_, i) => ({ id: i, ok: true }))),
|
||||
},
|
||||
];
|
||||
const cmp = summarizeEncoderCandidates(messages, 8, byteLen);
|
||||
assert.equal(cmp.arraysCompared, 1);
|
||||
assert.ok(cmp.json.bytes > 0 && cmp.gcf.bytes > 0);
|
||||
assert.ok(["gcf", "toon", "json"].includes(cmp.winner));
|
||||
const sizes: Record<string, number> = { gcf: cmp.gcf.tokens, json: cmp.json.tokens };
|
||||
if (cmp.toonAvailable) sizes["toon"] = cmp.toon.tokens;
|
||||
const min = Math.min(...Object.values(sizes));
|
||||
assert.equal(sizes[cmp.winner], min);
|
||||
});
|
||||
|
||||
test("sem array compactável → zerado, sem winner espúrio", () => {
|
||||
const cmp = summarizeEncoderCandidates([{ role: "user", content: "oi" }], 8, byteLen);
|
||||
assert.equal(cmp.arraysCompared, 0);
|
||||
assert.equal(cmp.json.bytes, 0);
|
||||
assert.equal(cmp.gcf.bytes, 0);
|
||||
});
|
||||
|
||||
test("system messages são ignoradas (igual ao smartcrusher)", () => {
|
||||
const arr = JSON.stringify(Array.from({ length: 20 }, (_, i) => ({ id: i })));
|
||||
const cmp = summarizeEncoderCandidates([{ role: "system", content: arr }], 8, byteLen);
|
||||
assert.equal(cmp.arraysCompared, 0);
|
||||
});
|
||||
54
tests/unit/compression/previewRouteToon.test.ts
Normal file
54
tests/unit/compression/previewRouteToon.test.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// tests/unit/compression/previewRouteToon.test.ts
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "preview-toon-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "test-secret-32-chars-min-aaaaaaaa";
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const route = await import("../../../src/app/api/compression/preview/route.ts");
|
||||
function makeReq(body: unknown) {
|
||||
return new Request("http://localhost/api/compression/preview", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
test.beforeEach(() => core.resetDbInstance());
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("headroom engine carries encoderComparison with one array and a valid winner", async () => {
|
||||
const big = JSON.stringify(Array.from({ length: 20 }, (_, i) => ({ id: i, ok: true })));
|
||||
const res = await route.POST(
|
||||
makeReq({ messages: [{ role: "user", content: big }], engineId: "headroom" })
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.ok(body.encoderComparison);
|
||||
assert.equal(body.encoderComparison.arraysCompared, 1);
|
||||
assert.ok(["gcf", "toon", "json"].includes(body.encoderComparison.winner));
|
||||
});
|
||||
|
||||
test("non-headroom engine (lite) does not carry encoderComparison", async () => {
|
||||
const big = JSON.stringify(Array.from({ length: 20 }, (_, i) => ({ id: i, ok: true })));
|
||||
const res = await route.POST(
|
||||
makeReq({ messages: [{ role: "user", content: big }], engineId: "lite" })
|
||||
);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.equal(body.encoderComparison ?? null, null);
|
||||
});
|
||||
|
||||
test("error responses do not leak stack traces", async () => {
|
||||
const res = await route.POST(makeReq({ messages: [] }));
|
||||
assert.equal(res.status, 400);
|
||||
const body = await res.json();
|
||||
const details = JSON.stringify(body);
|
||||
assert.ok(!String(details).includes("at /"));
|
||||
});
|
||||
64
tests/unit/compression/retrieveRouteRanged.test.ts
Normal file
64
tests/unit/compression/retrieveRouteRanged.test.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { join } from "node:path";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
const TEST_DATA_DIR = mkdtempSync(join(tmpdir(), "retrieve-ranged-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET ?? "test-secret-32-chars-min-aaaaaaaa";
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
const core = await import("../../../src/lib/db/core.ts");
|
||||
const ccr = await import("../../../open-sse/services/compression/engines/ccr/index.ts");
|
||||
const route = await import("../../../src/app/api/compression/retrieve/route.ts");
|
||||
const BLOCK = ["x1", "x2 ERR", "x3", "x4"].join("\n");
|
||||
function makeReq(body: unknown) {
|
||||
return new Request("http://localhost/api/compression/retrieve", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
test.beforeEach(() => {
|
||||
core.resetDbInstance();
|
||||
ccr.resetCcrStore();
|
||||
});
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("mode:head n:2 returns first 2 lines", async () => {
|
||||
const hash = ccr.storeBlock(BLOCK); // management scope (no principalId)
|
||||
const res = await route.POST(makeReq({ hash, mode: "head", n: 2 }));
|
||||
assert.equal(res.status, 200);
|
||||
const json = await res.json();
|
||||
assert.equal(json.found, true);
|
||||
assert.equal(json.block, "x1\nx2 ERR");
|
||||
});
|
||||
|
||||
test("mode:stats returns correct line count", async () => {
|
||||
const hash = ccr.storeBlock(BLOCK);
|
||||
const res = await route.POST(makeReq({ hash, mode: "stats" }));
|
||||
assert.equal(res.status, 200);
|
||||
const json = await res.json();
|
||||
assert.equal(json.found, true);
|
||||
assert.equal(JSON.parse(json.block).lines, 4);
|
||||
});
|
||||
|
||||
test("mode:grep with ReDoS pattern is rejected and sanitized", async () => {
|
||||
const hash = ccr.storeBlock(BLOCK);
|
||||
const res = await route.POST(makeReq({ hash, mode: "grep", pattern: "(a+)+$" }));
|
||||
assert.equal(res.status, 200);
|
||||
const json = await res.json();
|
||||
assert.ok(json.error, "expected an error field for the rejected ReDoS pattern");
|
||||
assert.ok(!String(json.error).includes("at /"), "error must be sanitized");
|
||||
});
|
||||
|
||||
test("no mode returns the whole block (backward-compat)", async () => {
|
||||
const hash = ccr.storeBlock(BLOCK);
|
||||
const res = await route.POST(makeReq({ hash }));
|
||||
assert.equal(res.status, 200);
|
||||
const json = await res.json();
|
||||
assert.equal(json.found, true);
|
||||
assert.equal(json.block, BLOCK);
|
||||
});
|
||||
49
tests/unit/compression/smartcrusherToon.test.ts
Normal file
49
tests/unit/compression/smartcrusherToon.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
tryCompactJson,
|
||||
pickSmallestEncoding,
|
||||
} from "../../../open-sse/services/compression/engines/headroom/smartcrusher.ts";
|
||||
import { reconstructHeadroom } from "../../../open-sse/services/compression/engines/headroom/index.ts";
|
||||
import { TOON_FENCE_OPEN } from "../../../open-sse/services/compression/engines/headroom/toon.ts";
|
||||
import { GCF_FENCE_OPEN } from "../../../open-sse/services/compression/engines/headroom/tabular.ts";
|
||||
|
||||
const toonFavorable = Array.from({ length: 40 }, (_, i) => ({ id: i, ok: true }));
|
||||
|
||||
test("best-of-N: usa a fence do encoder menor e nunca regride vs JSON", () => {
|
||||
const chosen = pickSmallestEncoding(toonFavorable);
|
||||
const json = JSON.stringify(toonFavorable);
|
||||
assert.ok(chosen.length < json.length, "deve encolher vs JSON");
|
||||
assert.ok(chosen.startsWith(GCF_FENCE_OPEN) || chosen.startsWith(TOON_FENCE_OPEN));
|
||||
});
|
||||
|
||||
test("se TOON vence, tryCompactJson emite fence toon e round-trip restaura", () => {
|
||||
const json = JSON.stringify(toonFavorable);
|
||||
const compact = tryCompactJson(json, 8);
|
||||
assert.notEqual(compact, null);
|
||||
const body = { messages: [{ role: "user", content: compact as string }] };
|
||||
const restored = reconstructHeadroom(body);
|
||||
const text = (restored.messages as Array<{ content: string }>)[0].content;
|
||||
assert.deepEqual(JSON.parse(text), toonFavorable);
|
||||
});
|
||||
|
||||
test("empate/GCF-favorável: round-trip lossless via a fence escolhida", () => {
|
||||
const gcfFavorable = Array.from({ length: 10 }, (_, i) => ({
|
||||
id: i,
|
||||
deep: { a: { b: [i, i + 1] }, label: `row-${i}` },
|
||||
}));
|
||||
const chosen = pickSmallestEncoding(gcfFavorable);
|
||||
assert.ok(chosen.startsWith(GCF_FENCE_OPEN) || chosen.startsWith(TOON_FENCE_OPEN));
|
||||
const compact = tryCompactJson(JSON.stringify(gcfFavorable), 8);
|
||||
if (compact) {
|
||||
const body = { messages: [{ role: "user", content: compact }] };
|
||||
const restored = reconstructHeadroom(body);
|
||||
const text = (restored.messages as Array<{ content: string }>)[0].content;
|
||||
assert.deepEqual(JSON.parse(text), gcfFavorable);
|
||||
}
|
||||
});
|
||||
|
||||
test("nunca aumenta vs JSON (gate preservado): array < minRows → no-op", () => {
|
||||
const tiny = [{ a: 1 }];
|
||||
assert.equal(tryCompactJson(JSON.stringify(tiny), 8), null);
|
||||
});
|
||||
72
tests/unit/compression/toon.test.ts
Normal file
72
tests/unit/compression/toon.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
encodeToonBlock,
|
||||
wrapToon,
|
||||
decodeToon,
|
||||
TOON_FENCE_OPEN,
|
||||
} from "../../../open-sse/services/compression/engines/headroom/toon.ts";
|
||||
|
||||
const cases: Array<{ name: string; arr: Record<string, unknown>[] }> = [
|
||||
{
|
||||
name: "homogêneo",
|
||||
arr: [
|
||||
{ id: 1, name: "a" },
|
||||
{ id: 2, name: "b" },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "heterogêneo",
|
||||
arr: [
|
||||
{ id: 1, x: 1 },
|
||||
{ id: 2, y: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "nested",
|
||||
arr: [
|
||||
{ id: 1, meta: { a: [1, 2] } },
|
||||
{ id: 2, meta: { a: [3] } },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "nullable",
|
||||
arr: [
|
||||
{ id: 1, v: null },
|
||||
{ id: 2, v: 5 },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "strings especiais",
|
||||
arr: [
|
||||
{ id: 1, s: 'a,b "q"\nc' },
|
||||
{ id: 2, s: "plain" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
for (const c of cases) {
|
||||
test(`toon round-trips: ${c.name}`, () => {
|
||||
const inner = encodeToonBlock(c.arr);
|
||||
assert.notEqual(inner, null, "encode deve produzir string");
|
||||
const fenced = wrapToon(inner as string);
|
||||
assert.ok(fenced.startsWith(TOON_FENCE_OPEN));
|
||||
const decoded = decodeToon(fenced);
|
||||
assert.deepEqual(decoded, c.arr);
|
||||
});
|
||||
}
|
||||
|
||||
test("decodeToon aceita bloco sem fence", () => {
|
||||
const arr = [{ a: 1 }, { a: 2 }];
|
||||
const inner = encodeToonBlock(arr) as string;
|
||||
assert.deepEqual(decodeToon(inner), arr);
|
||||
});
|
||||
|
||||
test("encodeToonBlock é fail-open (não lança)", () => {
|
||||
const a: Record<string, unknown> = { id: 1 };
|
||||
a["self"] = a;
|
||||
assert.doesNotThrow(() => {
|
||||
const r = encodeToonBlock([a]);
|
||||
assert.equal(r, null);
|
||||
});
|
||||
});
|
||||
71
tests/unit/csp-lan-ws-5083.test.ts
Normal file
71
tests/unit/csp-lan-ws-5083.test.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
// Regression guard for #5083 (Bug 1): the dashboard, when reached from a LAN /
|
||||
// Tailscale / non-loopback host, opens `ws://<that-host>:<port>` to its own Live WS
|
||||
// server. The static Content-Security-Policy in next.config.mjs previously allowed
|
||||
// `ws:` only for loopback origins (`ws://localhost:*`, `ws://127.0.0.1:*`) plus a bare
|
||||
// `wss:` — so a plain-`ws:` connection to a non-loopback host was blocked by the
|
||||
// browser. The fix permits the bare `ws:` scheme (symmetric with the bare `wss:` that
|
||||
// was already allowed), without introducing any global Next.js middleware (the project
|
||||
// intentionally has none — interception is route-specific; see CLAUDE.md / AGENTS.md).
|
||||
//
|
||||
// This test pins the connect-src directive in next.config.mjs so the LAN/Tailscale WS
|
||||
// allowance cannot silently regress, while confirming the other security directives are
|
||||
// left intact.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const nextConfig = readFileSync(resolve(here, "../../next.config.mjs"), "utf8");
|
||||
|
||||
// Extract the connect-src directive string literal from the CSP array.
|
||||
function connectSrcDirective(): string {
|
||||
const match = nextConfig.match(/"(connect-src[^"]*)"/);
|
||||
assert.ok(match, "next.config.mjs must define a connect-src directive in the CSP array");
|
||||
return match![1];
|
||||
}
|
||||
|
||||
test("#5083 connect-src permits the bare ws: scheme for non-loopback dashboards", () => {
|
||||
const connectSrc = connectSrcDirective();
|
||||
// Bare `ws:` must be present as its own token (not just `ws://localhost`).
|
||||
assert.match(
|
||||
connectSrc,
|
||||
/(^|\s)ws:(\s|$)/,
|
||||
`connect-src must allow the bare ws: scheme; got: ${connectSrc}`
|
||||
);
|
||||
});
|
||||
|
||||
test("#5083 connect-src keeps the bare wss: scheme it mirrors", () => {
|
||||
const connectSrc = connectSrcDirective();
|
||||
assert.match(connectSrc, /(^|\s)wss:(\s|$)/, "connect-src must still allow bare wss:");
|
||||
});
|
||||
|
||||
test("#5083 connect-src still scopes 'self' and explicit loopback origins", () => {
|
||||
const connectSrc = connectSrcDirective();
|
||||
assert.ok(connectSrc.includes("'self'"), "connect-src must still include 'self'");
|
||||
assert.ok(
|
||||
connectSrc.includes("ws://localhost:*") && connectSrc.includes("ws://127.0.0.1:*"),
|
||||
"explicit loopback ws origins must remain listed"
|
||||
);
|
||||
});
|
||||
|
||||
test("#5083 fix does NOT introduce a global Next.js middleware", () => {
|
||||
// The project has no global middleware by design; the CSP fix must stay declarative.
|
||||
let exists = true;
|
||||
try {
|
||||
readFileSync(resolve(here, "../../src/middleware.ts"), "utf8");
|
||||
} catch {
|
||||
exists = false;
|
||||
}
|
||||
assert.equal(exists, false, "src/middleware.ts must not exist (no global middleware — see CLAUDE.md)");
|
||||
});
|
||||
|
||||
test("#5083 baseline security directives remain intact in the CSP", () => {
|
||||
// frame-ancestors 'none' is the global default; object-src 'none' and base-uri 'self'
|
||||
// must not have been weakened by the connect-src change.
|
||||
assert.ok(nextConfig.includes("frame-ancestors 'none'"), "frame-ancestors 'none' must remain");
|
||||
assert.ok(nextConfig.includes("object-src 'none'"), "object-src 'none' must remain");
|
||||
assert.ok(nextConfig.includes("base-uri 'self'"), "base-uri 'self' must remain");
|
||||
});
|
||||
151
tests/unit/deepseek-web-tools-nameless-5154.test.ts
Normal file
151
tests/unit/deepseek-web-tools-nameless-5154.test.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
// Regression test for #5154 — nameless <tool> blocks with <parameter> children
|
||||
// are silently dropped (raw XML leaks to client) when no <name> child, no JSON body,
|
||||
// and no tag-name suffix are present.
|
||||
//
|
||||
// Fix: when name resolution fails but <parameter> tags extracted params, attempt
|
||||
// a conservative fuzzy-name resolution using the requested tools' parameter schemas.
|
||||
// If exactly one requested tool's schema keys are a superset of the extracted param names,
|
||||
// adopt that tool name. If zero or >1 match, keep returning null (no misattribution).
|
||||
|
||||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { parseDeepSeekToolCalls } from "../../open-sse/translator/deepseekWebTools.ts";
|
||||
|
||||
// Tools that define parameter schemas — needed so the schema-match can fire.
|
||||
const TOOLS_WITH_SCHEMAS = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read",
|
||||
description: "Read a file",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
filePath: { type: "string" },
|
||||
offset: { type: "number" },
|
||||
limit: { type: "number" },
|
||||
},
|
||||
required: ["filePath"],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "bash",
|
||||
description: "Run a shell command",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
command: { type: "string" },
|
||||
},
|
||||
required: ["command"],
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Two tools that share parameter names — used to test the ambiguous case.
|
||||
const AMBIGUOUS_TOOLS = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read_file",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
filePath: { type: "string" },
|
||||
offset: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "write_file",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: {
|
||||
filePath: { type: "string" },
|
||||
offset: { type: "number" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
describe("deepseekWebTools — nameless <tool> blocks (#5154)", () => {
|
||||
test("nameless <tool> with <parameter> children resolves to the unique schema match", () => {
|
||||
// Reproduces issue #5154: model emits <tool> with no name, no JSON body, no tag suffix.
|
||||
// Previously: extractCall returned null → raw XML leaked to the client.
|
||||
// After fix: params filePath+offset uniquely match the 'read' tool schema → name recovered.
|
||||
const text = `<tool><parameter name="filePath">"x"</parameter><parameter name="offset">675</parameter></tool>`;
|
||||
const { toolCalls, content } = parseDeepSeekToolCalls(text, "call", TOOLS_WITH_SCHEMAS);
|
||||
|
||||
assert.ok(toolCalls && toolCalls.length === 1, "expected exactly one tool call, got null/empty");
|
||||
assert.equal(toolCalls![0].function.name, "read", "tool name resolved via schema match");
|
||||
const args = JSON.parse(toolCalls![0].function.arguments);
|
||||
assert.ok("filePath" in args, "filePath argument present");
|
||||
assert.ok("offset" in args, "offset argument present");
|
||||
assert.ok(!content.includes("<tool"), "tool block stripped from content");
|
||||
});
|
||||
|
||||
test("nameless <tool> with only one matching param resolves when that param is unique to one tool", () => {
|
||||
// 'command' uniquely identifies 'bash' tool.
|
||||
const text = `<tool><parameter name="command">echo hello</parameter></tool>`;
|
||||
const { toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS_WITH_SCHEMAS);
|
||||
|
||||
assert.ok(toolCalls && toolCalls.length === 1, "expected exactly one tool call");
|
||||
assert.equal(toolCalls![0].function.name, "bash");
|
||||
assert.deepEqual(JSON.parse(toolCalls![0].function.arguments), { command: "echo hello" });
|
||||
});
|
||||
|
||||
test("nameless <tool> is NOT resolved when params are ambiguous (>1 schema match)", () => {
|
||||
// Both 'read_file' and 'write_file' have the same filePath+offset schema.
|
||||
// The parser must NOT misattribute — it should return null (no tool call).
|
||||
const text = `<tool><parameter name="filePath">/etc/hosts</parameter><parameter name="offset">0</parameter></tool>`;
|
||||
const { toolCalls } = parseDeepSeekToolCalls(text, "call", AMBIGUOUS_TOOLS);
|
||||
|
||||
assert.ok(!toolCalls || toolCalls.length === 0, "must not emit a call when params are ambiguous");
|
||||
});
|
||||
|
||||
test("nameless <tool> is NOT resolved when no tool schema matches the extracted params", () => {
|
||||
// Param 'unknownKey' doesn't appear in any tool schema.
|
||||
const text = `<tool><parameter name="unknownKey">value</parameter></tool>`;
|
||||
const { toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS_WITH_SCHEMAS);
|
||||
|
||||
assert.ok(!toolCalls || toolCalls.length === 0, "must not emit a call when no schema matches");
|
||||
});
|
||||
|
||||
test("existing named <tool:read> blocks still work (no regression)", () => {
|
||||
// Verify the pre-existing named-tag path is unaffected.
|
||||
const tools = [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "read",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: { filePath: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
const text = `<tool:read>{"filePath": "/tmp/test.py"}</tool>`;
|
||||
const { toolCalls } = parseDeepSeekToolCalls(text, "call", tools);
|
||||
|
||||
assert.ok(toolCalls && toolCalls.length === 1);
|
||||
assert.equal(toolCalls![0].function.name, "read");
|
||||
assert.deepEqual(JSON.parse(toolCalls![0].function.arguments), { filePath: "/tmp/test.py" });
|
||||
});
|
||||
|
||||
test("existing <tool>{json}</tool> canonical blocks still work (no regression)", () => {
|
||||
const text = `<tool>{"name": "bash", "arguments": {"command": "ls -la"}}</tool>`;
|
||||
const { toolCalls } = parseDeepSeekToolCalls(text, "call", TOOLS_WITH_SCHEMAS);
|
||||
|
||||
assert.ok(toolCalls && toolCalls.length === 1);
|
||||
assert.equal(toolCalls![0].function.name, "bash");
|
||||
assert.deepEqual(JSON.parse(toolCalls![0].function.arguments), { command: "ls -la" });
|
||||
});
|
||||
});
|
||||
33
tests/unit/dev-script-heap-limit.test.ts
Normal file
33
tests/unit/dev-script-heap-limit.test.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// The dev server runs Next's bundler in-process via `node scripts/dev/run-next.mjs`.
|
||||
// `--max-old-space-size` can only be set at process startup (not from inside the
|
||||
// running process), so the heap ceiling has to live on the `node` invocation in
|
||||
// the `dev` npm script. Without it, the dev bundler runs on V8's ~4GB default and
|
||||
// OOMs while compiling heavy dashboard routes (e.g. /dashboard/providers, which
|
||||
// pulls monaco / recharts / @lobehub/icons / xyflow / mermaid). The unit test
|
||||
// suite already runs with 8192; the dev server must match.
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const pkg = JSON.parse(fs.readFileSync(path.resolve(here, "../../package.json"), "utf8"));
|
||||
|
||||
test("dev script raises the Node heap limit (matches the test suite's 8192)", () => {
|
||||
const dev = pkg.scripts?.dev ?? "";
|
||||
assert.match(
|
||||
dev,
|
||||
/node\s+--max-old-space-size=8192\b/,
|
||||
`dev script must launch node with --max-old-space-size=8192; got: ${dev}`
|
||||
);
|
||||
// Guard ordering: the flag must precede the script path so node (not the script)
|
||||
// receives it.
|
||||
const flagIdx = dev.indexOf("--max-old-space-size");
|
||||
const scriptIdx = dev.indexOf("run-next.mjs");
|
||||
assert.ok(scriptIdx !== -1, "dev script must still launch run-next.mjs");
|
||||
assert.ok(
|
||||
flagIdx !== -1 && flagIdx < scriptIdx,
|
||||
"the heap flag must come before run-next.mjs so node receives it"
|
||||
);
|
||||
});
|
||||
@@ -209,6 +209,65 @@ test("detectMalformedNonStream allows Responses API function_call items as valid
|
||||
assert.equal(detectMalformedNonStream(body), null);
|
||||
});
|
||||
|
||||
// ── (c2) detectMalformedNonStream — Claude Messages shape ────────────────────
|
||||
|
||||
test("detectMalformedNonStream returns null for Claude message with text content", () => {
|
||||
const body = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Hi!" }],
|
||||
stop_reason: "end_turn",
|
||||
};
|
||||
assert.equal(detectMalformedNonStream(body), null);
|
||||
});
|
||||
|
||||
test("detectMalformedNonStream returns null for Claude message with tool_use block", () => {
|
||||
const body = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "tool_use", id: "toolu_1", name: "search", input: {} }],
|
||||
stop_reason: "tool_use",
|
||||
};
|
||||
assert.equal(detectMalformedNonStream(body), null);
|
||||
});
|
||||
|
||||
test("detectMalformedNonStream returns null for Claude message with thinking block", () => {
|
||||
const body = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "thinking", thinking: "let me think" }],
|
||||
stop_reason: "end_turn",
|
||||
};
|
||||
assert.equal(detectMalformedNonStream(body), null);
|
||||
});
|
||||
|
||||
test("detectMalformedNonStream returns 'empty_choices' for Claude message with empty content", () => {
|
||||
const body = { type: "message", role: "assistant", content: [], stop_reason: "end_turn" };
|
||||
assert.equal(detectMalformedNonStream(body), "empty_choices");
|
||||
});
|
||||
|
||||
test("detectMalformedNonStream returns 'empty_choices' for Claude message with empty-text block", () => {
|
||||
const body = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "" }],
|
||||
stop_reason: "end_turn",
|
||||
};
|
||||
assert.equal(detectMalformedNonStream(body), "empty_choices");
|
||||
});
|
||||
|
||||
test("detectMalformedNonStream returns 'empty_choices' for Claude message with (empty response) placeholder", () => {
|
||||
// convertOpenAINonStreamingToClaude substitutes this placeholder for empty
|
||||
// upstream content; it must still be treated as malformed.
|
||||
const body = {
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "(empty response)" }],
|
||||
stop_reason: "end_turn",
|
||||
};
|
||||
assert.equal(detectMalformedNonStream(body), "empty_choices");
|
||||
});
|
||||
|
||||
// ── (d) no stack trace leakage ───────────────────────────────────────────────
|
||||
|
||||
test("synthOpenAIErrorChunk message does NOT contain stack trace path", () => {
|
||||
|
||||
50
tests/unit/gemini-finish-reason-normalization.test.ts
Normal file
50
tests/unit/gemini-finish-reason-normalization.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { geminiToOpenAIResponse } =
|
||||
await import("../../open-sse/translator/response/gemini-to-openai.ts");
|
||||
const { translateNonStreamingResponse } =
|
||||
await import("../../open-sse/handlers/responseTranslator.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
|
||||
test("Gemini non-stream: prohibited content finish reason becomes content_filter", () => {
|
||||
const result = translateNonStreamingResponse(
|
||||
{
|
||||
responseId: "resp-prohibited",
|
||||
modelVersion: "gemini-2.5-flash",
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: "partial text" }] },
|
||||
finishReason: "PROHIBITED_CONTENT",
|
||||
},
|
||||
],
|
||||
},
|
||||
FORMATS.GEMINI,
|
||||
FORMATS.OPENAI
|
||||
);
|
||||
|
||||
assert.equal((result as any).choices[0].message.content, "partial text");
|
||||
assert.equal((result as any).choices[0].finish_reason, "content_filter");
|
||||
});
|
||||
|
||||
test("Gemini stream: prohibited content finish reason becomes content_filter", () => {
|
||||
const result = geminiToOpenAIResponse(
|
||||
{
|
||||
responseId: "resp-prohibited",
|
||||
modelVersion: "gemini-2.5-flash",
|
||||
candidates: [
|
||||
{
|
||||
content: { parts: [{ text: "partial text" }] },
|
||||
finishReason: "PROHIBITED_CONTENT",
|
||||
},
|
||||
],
|
||||
},
|
||||
{ toolCalls: new Map() }
|
||||
);
|
||||
|
||||
assert.equal(result.at(-1).choices[0].finish_reason, "content_filter");
|
||||
assert.equal(
|
||||
result.some((event: any) => event.choices?.[0]?.delta?.content === "partial text"),
|
||||
true
|
||||
);
|
||||
});
|
||||
@@ -19,6 +19,10 @@ test("extracts body.input as STRING without char-splitting", () => {
|
||||
test("extracts body.input as array of strings", () => {
|
||||
assert.ok(extractMessageContents({ input: [INJ, "y"] }).join("\n").includes(INJ));
|
||||
});
|
||||
test("extracts body.input as Responses object without throwing", () => {
|
||||
const out = extractMessageContents({ input: { role: "user", content: INJ } }).join("\n");
|
||||
assert.ok(out.includes(INJ));
|
||||
});
|
||||
test("extracts body.query + body.documents (rerank)", () => {
|
||||
const out = extractMessageContents({ query: INJ, documents: ["doc1", "doc2"] }).join("\n");
|
||||
assert.ok(out.includes(INJ) && out.includes("doc1"));
|
||||
|
||||
50
tests/unit/heap-autocalibrate-5172.test.ts
Normal file
50
tests/unit/heap-autocalibrate-5172.test.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Issue #5172 / #5160 / #5152 — server OOM ("Ineffective mark-compacts near heap
|
||||
* limit ... ~500MB") on machines with plenty of RAM. Root cause: the server was
|
||||
* spawned with a FIXED 512MB heap default (`omniroute serve`) or with no
|
||||
* `--max-old-space-size` at all (Electron), so a 16GB box with 65 providers /
|
||||
* 2600 models still crashed at ~512MB.
|
||||
*
|
||||
* Fix: `calibrateHeapFallbackMb(totalmemBytes)` derives a sane default heap from
|
||||
* the host's physical RAM (~35%, clamped to [512, 4096]) so the out-of-the-box
|
||||
* ceiling scales with the machine. An explicit `OMNIROUTE_MEMORY_MB` still wins
|
||||
* (resolveMaxOldSpaceMb), and the existing #2939 contract is unchanged.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { calibrateHeapFallbackMb, resolveMaxOldSpaceMb } =
|
||||
await import("../../scripts/build/runtime-env.mjs");
|
||||
|
||||
const GB = 1024 * 1024 * 1024;
|
||||
|
||||
test("#5172 calibrates the default heap to ~35% of physical RAM", () => {
|
||||
// 8GB → 8192 * 0.35 ≈ 2867
|
||||
assert.equal(calibrateHeapFallbackMb(8 * GB), 2867);
|
||||
// 4GB → floor(4096 * 0.35) = 1433
|
||||
assert.equal(calibrateHeapFallbackMb(4 * GB), 1433);
|
||||
});
|
||||
|
||||
test("#5172 clamps the calibrated default to [512, 4096]", () => {
|
||||
// 16GB → 5734 → clamped to the 4096 ceiling (the reporter's box)
|
||||
assert.equal(calibrateHeapFallbackMb(16 * GB), 4096);
|
||||
assert.equal(calibrateHeapFallbackMb(64 * GB), 4096);
|
||||
// 1GB → 358 → floored to 512
|
||||
assert.equal(calibrateHeapFallbackMb(1 * GB), 512);
|
||||
});
|
||||
|
||||
test("#5172 falls back to 512 for missing/invalid totalmem", () => {
|
||||
assert.equal(calibrateHeapFallbackMb(0), 512);
|
||||
assert.equal(calibrateHeapFallbackMb(undefined), 512);
|
||||
assert.equal(calibrateHeapFallbackMb(null), 512);
|
||||
assert.equal(calibrateHeapFallbackMb(NaN), 512);
|
||||
assert.equal(calibrateHeapFallbackMb(-1), 512);
|
||||
});
|
||||
|
||||
test("#5172 an explicit OMNIROUTE_MEMORY_MB still wins over the calibrated default", () => {
|
||||
const calibrated = calibrateHeapFallbackMb(16 * GB); // 4096
|
||||
// explicit override (in-range) is honored verbatim, not the calibrated default
|
||||
assert.equal(resolveMaxOldSpaceMb("1536", calibrated), 1536);
|
||||
// unset → the calibrated default is used (not the old fixed 512)
|
||||
assert.equal(resolveMaxOldSpaceMb(undefined, calibrated), 4096);
|
||||
});
|
||||
@@ -155,6 +155,29 @@ describe("synthesizeOpenAiSseFromJson (#3089)", () => {
|
||||
assert.equal(toolDelta.tool_calls[0].id, "t1");
|
||||
});
|
||||
|
||||
test("normalizes prohibited content finish reasons in synthesized SSE", () => {
|
||||
const sse = synthesizeOpenAiSseFromJson(
|
||||
JSON.stringify({
|
||||
choices: [
|
||||
{
|
||||
message: { role: "assistant", content: "partial text" },
|
||||
finish_reason: "prohibited_content",
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
const events = parseDataChunks(sse)
|
||||
.filter((c) => c !== "[DONE]")
|
||||
.map((c) => JSON.parse(c));
|
||||
const finishChunk = events.at(-1);
|
||||
|
||||
assert.equal(finishChunk.choices[0].finish_reason, "content_filter");
|
||||
assert.equal(
|
||||
events.some((event) => event.choices[0].delta.content === "partial text"),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("returns empty string for non-completion JSON / invalid JSON", () => {
|
||||
assert.equal(synthesizeOpenAiSseFromJson('{"error":{"message":"x"}}'), "");
|
||||
assert.equal(synthesizeOpenAiSseFromJson("{not json"), "");
|
||||
|
||||
@@ -285,7 +285,9 @@ test("shutdownMcpHttp removes sessions created via handleMcpStreamableHTTP", asy
|
||||
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 2 }),
|
||||
});
|
||||
const staleRes = await mod.handleMcpStreamableHTTP(staleReq);
|
||||
assert.equal(staleRes.status, 400);
|
||||
// MCP spec (2025-03-26 / 2025-11-25, Session Management): a terminated/unknown
|
||||
// session id MUST return 404 Not Found so the client re-initializes (issue #5169).
|
||||
assert.equal(staleRes.status, 404);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -302,8 +304,46 @@ test("handleMcpStreamableHTTP rejects request with unknown session id", async ()
|
||||
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 1 }),
|
||||
});
|
||||
const res = await mod.handleMcpStreamableHTTP(req);
|
||||
assert.equal(res.status, 400);
|
||||
// Per MCP spec, a present-but-unknown session id MUST yield 404 (not 400), so
|
||||
// the client knows to start a fresh session rather than hard-fail (issue #5169).
|
||||
assert.equal(res.status, 404);
|
||||
const body = await res.json();
|
||||
assert.ok(body.error);
|
||||
assert.ok(body.error.message.includes("Unknown"));
|
||||
});
|
||||
|
||||
// ── Regression #5169: unknown/expired session → HTTP 404 (not 400) ───────────
|
||||
|
||||
test("handleMcpStreamableHTTP returns 404 (not 400) for an unknown session id", async () => {
|
||||
mod.shutdownMcpHttp();
|
||||
const req = new Request("http://localhost/api/mcp/stream", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"mcp-session-id": "expired-or-unknown-session",
|
||||
},
|
||||
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 7 }),
|
||||
});
|
||||
const res = await mod.handleMcpStreamableHTTP(req);
|
||||
// The 400-vs-404 distinction is the whole bug: clients only re-initialize on 404.
|
||||
assert.equal(res.status, 404);
|
||||
const body = await res.json();
|
||||
assert.equal(body.jsonrpc, "2.0");
|
||||
assert.equal(body.error.code, -32000);
|
||||
assert.ok(body.error.message.includes("Mcp-Session-Id"));
|
||||
});
|
||||
|
||||
// ── Guard: a *missing* session id on a non-initialize request stays 400 ───────
|
||||
|
||||
test("handleMcpStreamableHTTP keeps 400 for a missing session id (non-initialize)", async () => {
|
||||
mod.shutdownMcpHttp();
|
||||
const req = new Request("http://localhost/api/mcp/stream", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ jsonrpc: "2.0", method: "tools/list", id: 8 }),
|
||||
});
|
||||
const res = await mod.handleMcpStreamableHTTP(req);
|
||||
// Spec reserves 400 for a *missing* session id on non-initialize requests —
|
||||
// only the *present-but-unknown* case changed to 404. This must NOT regress.
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
75
tests/unit/models-catalog-block-auto-5192.test.ts
Normal file
75
tests/unit/models-catalog-block-auto-5192.test.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* #5192 — Blocking the "Auto (Zero-Config)" provider in Security settings must
|
||||
* remove the built-in `auto/*` combos from the `/v1/models` listing.
|
||||
*
|
||||
* The catalog advertises the built-in `auto/*` combos (#4164 / #4235) at the top
|
||||
* of `/v1/models`, but that injection loop ignored `settings.blockedProviders`,
|
||||
* so checking "Auto (Zero-Config)" in Security → Blocked Providers had no effect:
|
||||
* the model picker still showed `auto/*` entries. Blocking the system provider id
|
||||
* `auto` (its id and alias are both "auto") must suppress the whole `auto/*` block.
|
||||
*/
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-block-auto-5192-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "block-auto-5192-test-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const v1ModelsCatalog = await import("../../src/app/api/v1/models/catalog.ts");
|
||||
|
||||
type ModelsResponseBody = { data: Array<{ id: string }> };
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function getIds(): Promise<Set<string>> {
|
||||
const response = await v1ModelsCatalog.getUnifiedModelsResponse(
|
||||
new Request("http://localhost/api/v1/models")
|
||||
);
|
||||
const body = (await response.json()) as ModelsResponseBody;
|
||||
return new Set(body.data.map((item) => item.id));
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("#5192 baseline: built-in auto/* combos are listed when Auto is not blocked", async () => {
|
||||
const ids = await getIds();
|
||||
assert.ok(ids.has("auto/best-coding"), "auto/best-coding should be listed by default");
|
||||
assert.ok(ids.has("auto/coding"), "auto/coding should be listed by default");
|
||||
});
|
||||
|
||||
test("#5192: blocking the Auto (Zero-Config) provider removes all auto/* combos", async () => {
|
||||
await settingsDb.updateSettings({ blockedProviders: ["auto"] });
|
||||
const ids = await getIds();
|
||||
const leaked = [...ids].filter((id) => id === "auto" || id.startsWith("auto/"));
|
||||
assert.deepEqual(
|
||||
leaked,
|
||||
[],
|
||||
`no auto/* combos should be listed when Auto is blocked, but got: ${leaked.join(", ")}`
|
||||
);
|
||||
});
|
||||
|
||||
test("#5192: blocking via the 'auto' alias is honored too", async () => {
|
||||
// id and alias are both "auto"; the Security UI stores whichever the user clicked.
|
||||
await settingsDb.updateSettings({ blockedProviders: ["auto"] });
|
||||
const ids = await getIds();
|
||||
assert.equal(ids.has("auto/smart"), false, "auto/smart must be absent when Auto is blocked");
|
||||
});
|
||||
84
tests/unit/oauth-credential-blob.test.ts
Normal file
84
tests/unit/oauth-credential-blob.test.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
// Unit tests for the OAuth credential blob codec used by the remote login helper.
|
||||
//
|
||||
// Context (operator report 2026-06-27): Google's `firstparty/nativeapp` consent
|
||||
// for the embedded Antigravity desktop client only releases the authorization
|
||||
// code when the loopback redirect (127.0.0.1:<port>) is reachable. On a remote
|
||||
// VPS install the loopback is unreachable, so the consent hangs and never emits
|
||||
// a code — the normal "paste the callback URL" fallback has nothing to paste.
|
||||
//
|
||||
// The fix is a local helper (`omniroute login antigravity`) that runs the OAuth
|
||||
// on the user's own machine (loopback reachable → consent completes → tokens),
|
||||
// then prints a single-line, paste-safe credential blob. The user pastes it into
|
||||
// the remote dashboard, which decodes it and persists the connection.
|
||||
//
|
||||
// This codec is the contract between the helper (encoder) and the server/dashboard
|
||||
// (decoder). These tests pin: roundtrip fidelity, the human-recognizable prefix,
|
||||
// version + provider gating, and rejection of malformed/tampered input.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
CREDENTIAL_BLOB_PREFIX,
|
||||
encodeCredentialBlob,
|
||||
decodeCredentialBlob,
|
||||
} from "../../src/lib/oauth/credentialBlob.ts";
|
||||
|
||||
const SAMPLE = {
|
||||
provider: "antigravity",
|
||||
tokens: {
|
||||
access_token: "ya29.access",
|
||||
refresh_token: "1//refresh",
|
||||
id_token: "eyJ.id.token",
|
||||
expires_in: 3599,
|
||||
scope: "https://www.googleapis.com/auth/cloud-platform",
|
||||
},
|
||||
};
|
||||
|
||||
test("encode → decode roundtrips the provider and tokens", () => {
|
||||
const blob = encodeCredentialBlob(SAMPLE);
|
||||
const decoded = decodeCredentialBlob(blob);
|
||||
assert.equal(decoded.provider, "antigravity");
|
||||
assert.deepEqual(decoded.tokens, SAMPLE.tokens);
|
||||
});
|
||||
|
||||
test("blob is a single paste-safe line with the recognizable prefix", () => {
|
||||
const blob = encodeCredentialBlob(SAMPLE);
|
||||
assert.ok(blob.startsWith(CREDENTIAL_BLOB_PREFIX), "must carry the omniroute prefix");
|
||||
assert.ok(!/\s/.test(blob), "must contain no whitespace (single line, copy-paste safe)");
|
||||
// base64url only after the prefix — no +/= that break in URLs / shells.
|
||||
const payload = blob.slice(CREDENTIAL_BLOB_PREFIX.length);
|
||||
assert.match(payload, /^[A-Za-z0-9_-]+$/, "payload must be base64url");
|
||||
});
|
||||
|
||||
test("decode rejects a blob without the prefix", () => {
|
||||
const raw = Buffer.from(JSON.stringify({ v: 1, ...SAMPLE })).toString("base64url");
|
||||
assert.throws(() => decodeCredentialBlob(raw), /prefix|invalid|format/i);
|
||||
});
|
||||
|
||||
test("decode rejects an unsupported version", () => {
|
||||
// Hand-craft a v999 blob with the right prefix.
|
||||
const payload = Buffer.from(JSON.stringify({ v: 999, ...SAMPLE })).toString("base64url");
|
||||
assert.throws(() => decodeCredentialBlob(`${CREDENTIAL_BLOB_PREFIX}${payload}`), /version/i);
|
||||
});
|
||||
|
||||
test("decode rejects a blob missing an access_token", () => {
|
||||
const bad = encodeCredentialBlob({
|
||||
provider: "antigravity",
|
||||
tokens: { refresh_token: "only-refresh" },
|
||||
});
|
||||
assert.throws(() => decodeCredentialBlob(bad), /access_token|token/i);
|
||||
});
|
||||
|
||||
test("decode rejects tampered base64 (not valid JSON)", () => {
|
||||
assert.throws(
|
||||
() => decodeCredentialBlob(`${CREDENTIAL_BLOB_PREFIX}not-valid-base64-json!!!`),
|
||||
/invalid|parse|format/i
|
||||
);
|
||||
});
|
||||
|
||||
test("encode requires a provider", () => {
|
||||
assert.throws(
|
||||
() => encodeCredentialBlob({ tokens: { access_token: "x" } } as never),
|
||||
/provider/i
|
||||
);
|
||||
});
|
||||
79
tests/unit/oauth-paste-credentials-route.test.ts
Normal file
79
tests/unit/oauth-paste-credentials-route.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// Route-wiring tests for the paste-credentials OAuth action.
|
||||
//
|
||||
// These exercise the rejection paths only — they all return 400 BEFORE the
|
||||
// route touches finalizeTokens (Google APIs) or persistOAuthConnection (DB), so
|
||||
// no network is hit. The happy path's finalize+persist is the same IO as the
|
||||
// already-covered `device-complete` action; the new, security-relevant logic
|
||||
// (allowlist + provider match + blob validation) is unit-tested in
|
||||
// oauth-paste-credentials.test.ts and re-asserted through the HTTP boundary here.
|
||||
//
|
||||
// Auth is disabled via settings (requireLogin:false) so we reach the action
|
||||
// dispatch rather than a 401. DB handles are released in test.after (CLAUDE.md
|
||||
// learning: unreleased SQLite handles hang node:test).
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-paste-creds-route-"));
|
||||
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 route = await import("../../src/app/api/oauth/[provider]/paste-credentials/route.ts");
|
||||
const { encodeCredentialBlob } = await import("../../src/lib/oauth/credentialBlob.ts");
|
||||
|
||||
const tokens = { access_token: "ya29.x", refresh_token: "1//r", expires_in: 3599 };
|
||||
|
||||
test.before(async () => {
|
||||
await settingsDb.updateSettings({ requireLogin: false });
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function postPaste(provider: string, body: unknown) {
|
||||
const request = new Request(`http://localhost:20128/api/oauth/${provider}/paste-credentials`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const response = await route.POST(request, {
|
||||
params: Promise.resolve({ provider, action: "paste-credentials" }),
|
||||
} as never);
|
||||
return { status: response.status, body: await response.json() };
|
||||
}
|
||||
|
||||
test("paste-credentials: non-allowlisted provider is rejected with 400", async () => {
|
||||
const blob = encodeCredentialBlob({ provider: "openai", tokens });
|
||||
const { status, body } = await postPaste("openai", { blob });
|
||||
assert.equal(status, 400);
|
||||
assert.match(body.error, /not supported|supported/i);
|
||||
});
|
||||
|
||||
test("paste-credentials: malformed blob is rejected with 400", async () => {
|
||||
const { status, body } = await postPaste("antigravity", { blob: "totally-not-a-blob" });
|
||||
assert.equal(status, 400);
|
||||
assert.match(body.error, /invalid|format|prefix/i);
|
||||
});
|
||||
|
||||
test("paste-credentials: provider mismatch (blob for antigravity, route agy) → 400", async () => {
|
||||
const blob = encodeCredentialBlob({ provider: "antigravity", tokens });
|
||||
const { status, body } = await postPaste("agy", { blob });
|
||||
assert.equal(status, 400);
|
||||
assert.match(body.error, /match|mismatch|provider/i);
|
||||
});
|
||||
|
||||
test("paste-credentials: empty body fails schema validation with 400", async () => {
|
||||
const { status } = await postPaste("antigravity", {});
|
||||
assert.equal(status, 400);
|
||||
});
|
||||
|
||||
test("paste-credentials: error responses never leak a stack trace", async () => {
|
||||
const { body } = await postPaste("antigravity", { blob: "totally-not-a-blob" });
|
||||
assert.ok(!String(body.error).includes("at /"), "must not leak a stack trace");
|
||||
});
|
||||
49
tests/unit/oauth-paste-credentials.test.ts
Normal file
49
tests/unit/oauth-paste-credentials.test.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
// Unit tests for the server-side paste-credentials gate.
|
||||
//
|
||||
// The remote login helper (`omniroute login antigravity`) prints a credential
|
||||
// blob; the dashboard POSTs it to /api/oauth/<provider>/paste-credentials. Before
|
||||
// the server finalizes + persists the tokens it MUST validate that (a) the route
|
||||
// provider is on the paste-credentials allowlist (only Google native-loopback
|
||||
// providers, never arbitrary providers) and (b) the blob's embedded provider
|
||||
// matches the route provider — otherwise a blob minted for one provider could be
|
||||
// replayed against another. This pins that gate; the finalize/persist IO is the
|
||||
// same path as the already-tested `device-complete` action.
|
||||
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { encodeCredentialBlob } from "../../src/lib/oauth/credentialBlob.ts";
|
||||
import {
|
||||
PASTE_CREDENTIAL_PROVIDERS,
|
||||
parsePastedCredentials,
|
||||
} from "../../src/lib/oauth/pasteCredentials.ts";
|
||||
|
||||
const tokens = { access_token: "ya29.x", refresh_token: "1//r", expires_in: 3599 };
|
||||
|
||||
test("allowlist contains antigravity and its agy alias, not codex", () => {
|
||||
assert.ok(PASTE_CREDENTIAL_PROVIDERS.has("antigravity"));
|
||||
assert.ok(PASTE_CREDENTIAL_PROVIDERS.has("agy"));
|
||||
assert.ok(!PASTE_CREDENTIAL_PROVIDERS.has("codex"), "codex uses its own device-complete path");
|
||||
});
|
||||
|
||||
test("accepts a matching antigravity blob and returns the tokens", () => {
|
||||
const blob = encodeCredentialBlob({ provider: "antigravity", tokens });
|
||||
const result = parsePastedCredentials("antigravity", blob);
|
||||
assert.equal(result.provider, "antigravity");
|
||||
assert.deepEqual(result.tokens, tokens);
|
||||
});
|
||||
|
||||
test("rejects a provider not on the allowlist", () => {
|
||||
const blob = encodeCredentialBlob({ provider: "openai", tokens });
|
||||
assert.throws(() => parsePastedCredentials("openai", blob), /not supported|allowlist|supported/i);
|
||||
});
|
||||
|
||||
test("rejects a blob whose embedded provider does not match the route provider", () => {
|
||||
// Blob minted for antigravity, replayed against the agy route → must reject.
|
||||
const blob = encodeCredentialBlob({ provider: "antigravity", tokens });
|
||||
assert.throws(() => parsePastedCredentials("agy", blob), /match|mismatch|provider/i);
|
||||
});
|
||||
|
||||
test("propagates codec validation errors (e.g. missing access_token)", () => {
|
||||
const blob = encodeCredentialBlob({ provider: "antigravity", tokens: { refresh_token: "r" } });
|
||||
assert.throws(() => parsePastedCredentials("antigravity", blob), /access_token|token/i);
|
||||
});
|
||||
@@ -637,11 +637,19 @@ test("Gemini and Antigravity run mocked browser OAuth exchanges and post-exchang
|
||||
);
|
||||
const antigravityExtra = await PROVIDERS.antigravity.postExchange(antigravityTokens);
|
||||
const antigravityMapped = PROVIDERS.antigravity.mapTokens(antigravityTokens, antigravityExtra);
|
||||
// postExchange runs onboarding fire-and-forget now (it must never block the OAuth
|
||||
// login response); give the background onboard call a tick to consume its mocked
|
||||
// fetch so the sequence drains deterministically.
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
|
||||
assert.equal(geminiMapped.email, "gemini@example.com");
|
||||
assert.equal(geminiMapped.projectId, "gemini-project");
|
||||
assert.equal(antigravityMapped.email, "anti@example.com");
|
||||
assert.equal(antigravityMapped.projectId, "anti-project-final");
|
||||
// projectId comes from loadCodeAssist ("anti-project"), NOT the backgrounded
|
||||
// onboardUser response ("anti-project-final"). Onboarding is fire-and-forget, so it
|
||||
// no longer updates the returned projectId synchronously — matching the 9router web
|
||||
// flow, which also returns the loadCodeAssist project id.
|
||||
assert.equal(antigravityMapped.projectId, "anti-project");
|
||||
});
|
||||
|
||||
test("Qoder enabled mode exchanges tokens and loads profile metadata through mocked endpoints", async () => {
|
||||
|
||||
79
tests/unit/pii-opt-in-default.test.ts
Normal file
79
tests/unit/pii-opt-in-default.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
// Regression guard for Hard Rule: PII redaction/sanitization is OPT-IN.
|
||||
// OmniRoute proxies for self-hosted / local LLMs where the operator owns the
|
||||
// data; mutating request/response payloads by default would silently corrupt
|
||||
// that traffic. The two data-mutating PII feature flags MUST default to "false"
|
||||
// so a vanilla chat request passes data through untouched. Flipping either
|
||||
// default to "true" requires explicit operator approval — this test is the
|
||||
// permanent guard against an accidental on-by-default regression.
|
||||
//
|
||||
// See docs/security/GUARDRAILS.md and the PII analysis: piiMasker (request),
|
||||
// piiSanitizer (response), streamingPiiTransform (SSE) are ALL gated on these
|
||||
// two flags; with both off the guardrail runs but never mutates payloads.
|
||||
|
||||
// Isolate DB state so the resolution chain (DB override > env > default) reads
|
||||
// a clean store and we exercise the definition default, not a leaked override.
|
||||
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-test-pii-default-"));
|
||||
process.env.DATA_DIR = tmpDir;
|
||||
|
||||
const { FEATURE_FLAG_DEFINITIONS } = await import(
|
||||
"../../src/shared/constants/featureFlagDefinitions.ts"
|
||||
);
|
||||
|
||||
test("PII data-mutation flags are opt-in (default 'false')", async (t) => {
|
||||
const def = (key: string) => FEATURE_FLAG_DEFINITIONS.find((d) => d.key === key);
|
||||
|
||||
await t.test("PII_REDACTION_ENABLED definition default is 'false'", () => {
|
||||
const d = def("PII_REDACTION_ENABLED");
|
||||
assert.ok(d, "PII_REDACTION_ENABLED definition must exist");
|
||||
assert.strictEqual(
|
||||
d!.defaultValue,
|
||||
"false",
|
||||
"PII_REDACTION_ENABLED must default OFF — request-side masking is opt-in (operator owns the data)"
|
||||
);
|
||||
});
|
||||
|
||||
await t.test("PII_RESPONSE_SANITIZATION definition default is 'false'", () => {
|
||||
const d = def("PII_RESPONSE_SANITIZATION");
|
||||
assert.ok(d, "PII_RESPONSE_SANITIZATION definition must exist");
|
||||
assert.strictEqual(
|
||||
d!.defaultValue,
|
||||
"false",
|
||||
"PII_RESPONSE_SANITIZATION must default OFF — response/streaming masking is opt-in"
|
||||
);
|
||||
});
|
||||
|
||||
await t.test("effective runtime resolution is OFF with no override", async () => {
|
||||
// No env var, no DB override → the definition default must win.
|
||||
delete process.env.PII_REDACTION_ENABLED;
|
||||
delete process.env.PII_RESPONSE_SANITIZATION;
|
||||
const { clearAllFeatureFlagOverrides } = await import("@/lib/db/featureFlags");
|
||||
clearAllFeatureFlagOverrides();
|
||||
|
||||
const { isFeatureFlagEnabled } = await import("@/shared/utils/featureFlags");
|
||||
assert.strictEqual(isFeatureFlagEnabled("PII_REDACTION_ENABLED"), false);
|
||||
assert.strictEqual(isFeatureFlagEnabled("PII_RESPONSE_SANITIZATION"), false);
|
||||
});
|
||||
|
||||
await t.test("response data passes through untouched by default", async () => {
|
||||
delete process.env.PII_RESPONSE_SANITIZATION;
|
||||
const { clearAllFeatureFlagOverrides } = await import("@/lib/db/featureFlags");
|
||||
clearAllFeatureFlagOverrides();
|
||||
|
||||
const { sanitizePII, sanitizePIIResponse } = await import("@/lib/piiSanitizer");
|
||||
|
||||
const text = "contact me at jdoe@example.com or 123-45-6789";
|
||||
const result = sanitizePII(text);
|
||||
assert.strictEqual(result.redacted, false, "must NOT redact when flag is off");
|
||||
assert.strictEqual(result.text, text, "PII text must pass through unchanged by default");
|
||||
|
||||
const body = { choices: [{ message: { content: "ssn 123-45-6789, email a@b.com" } }] };
|
||||
const out = sanitizePIIResponse(JSON.parse(JSON.stringify(body)));
|
||||
assert.deepStrictEqual(out, body, "response object must pass through unchanged by default");
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
proxyConfigToUrl,
|
||||
normalizeProxyUrl,
|
||||
clearDispatcherCache,
|
||||
__cacheProxyDispatcherForTest,
|
||||
} from "../../open-sse/utils/proxyDispatcher.ts";
|
||||
|
||||
afterEach(() => clearDispatcherCache());
|
||||
@@ -77,4 +78,22 @@ describe("proxyDispatcher connection pool", () => {
|
||||
});
|
||||
assert.equal(options.connections, 256);
|
||||
});
|
||||
|
||||
it("closes cached proxy dispatchers when the proxy cache is cleared", () => {
|
||||
let closeCount = 0;
|
||||
const dispatcher = {
|
||||
dispatch() {
|
||||
return true;
|
||||
},
|
||||
close() {
|
||||
closeCount += 1;
|
||||
},
|
||||
destroy() {},
|
||||
};
|
||||
|
||||
__cacheProxyDispatcherForTest("http://proxy.example.com:8080", dispatcher as never);
|
||||
clearDispatcherCache();
|
||||
|
||||
assert.equal(closeCount, 1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -127,6 +127,73 @@ test("request log detail labels OpenAI protocol variants explicitly", () => {
|
||||
assert.notEqual(responsesHtml.indexOf(">OpenAI-Responses<"), -1);
|
||||
});
|
||||
|
||||
test("request log detail compression-summary badge shows positive saved%, never negative", () => {
|
||||
// Regression: prior code used `(-{pct}%)` which produced literal "-100%" when the entire
|
||||
// prompt was compressed (compressed=5286, totalIn=0). The fix clamps pct to [0, 100] and
|
||||
// uses "(N% saved)" so the user-facing label is always positive.
|
||||
const make = (tokensIn: number, tokensCompressed: number) =>
|
||||
renderToStaticMarkup(
|
||||
React.createElement(RequestLoggerDetail, {
|
||||
log: {
|
||||
status: 200,
|
||||
method: "POST",
|
||||
path: "/v1/chat/completions",
|
||||
timestamp: "2026-04-09T21:27:08.000Z",
|
||||
duration: 1500,
|
||||
provider: "openai-compatible-sp-openai",
|
||||
sourceFormat: "openai-chat",
|
||||
model: "gpt-5.4",
|
||||
requestedModel: "openai-compatible-sp-openai/gpt-5.4",
|
||||
cacheSource: "semantic",
|
||||
tokens: {
|
||||
in: tokensIn,
|
||||
out: 42,
|
||||
cacheRead: null,
|
||||
cacheWrite: null,
|
||||
reasoning: null,
|
||||
compressed: tokensCompressed,
|
||||
},
|
||||
},
|
||||
detail: {
|
||||
tokens: {
|
||||
in: tokensIn,
|
||||
out: 42,
|
||||
cacheRead: null,
|
||||
cacheWrite: null,
|
||||
reasoning: null,
|
||||
compressed: tokensCompressed,
|
||||
},
|
||||
},
|
||||
loading: false,
|
||||
onClose: () => {},
|
||||
onCopy: async () => true,
|
||||
})
|
||||
);
|
||||
|
||||
// Original bug repro: totalIn=0, compressed=5286 → previously rendered "(−100%)".
|
||||
const fullyCompressed = make(0, 5286);
|
||||
assert.match(fullyCompressed, /Compressed: 5,286 → 0 \(100% saved\)/);
|
||||
assert.equal(
|
||||
fullyCompressed.includes("(-100%)"),
|
||||
false,
|
||||
"literal '(-100%)' must never appear"
|
||||
);
|
||||
assert.equal(
|
||||
fullyCompressed.includes("\u2212100%"),
|
||||
false,
|
||||
"unicode minus + 100% must never appear"
|
||||
);
|
||||
|
||||
// Half-compressed case must clamp cleanly inside the [0, 100] window.
|
||||
const halfCompressed = make(2500, 2500);
|
||||
assert.match(halfCompressed, /Compressed: 5,000 → 2,500 \(50% saved\)/);
|
||||
assert.equal(halfCompressed.includes("-50%"), false);
|
||||
|
||||
// Sanity: tiny input, tiny savings still rendered as a positive percentage.
|
||||
const small = make(1000, 100);
|
||||
assert.match(small, /Compressed: 1,100 → 1,000 \(9% saved\)/);
|
||||
});
|
||||
|
||||
test("request log detail follows the email visibility setting for accounts", () => {
|
||||
const props = {
|
||||
log: {
|
||||
|
||||
@@ -5,6 +5,8 @@ const { convertResponsesApiFormat } =
|
||||
await import("../../open-sse/translator/helpers/responsesApiHelper.ts");
|
||||
const { openaiResponsesToOpenAIRequest, openaiToOpenAIResponsesRequest } =
|
||||
await import("../../open-sse/translator/request/openai-responses.ts");
|
||||
const { normalizeCodexResponsesInput, normalizeResponsesInputForChat } =
|
||||
await import("../../open-sse/utils/responsesInputNormalization.ts");
|
||||
|
||||
test("convertResponsesApiFormat filters orphaned function_call_output items", () => {
|
||||
const body = {
|
||||
@@ -57,6 +59,77 @@ test("Responses→Chat: input_image converted to image_url with detail", () => {
|
||||
assert.equal(imgPart.image_url.detail, "high");
|
||||
});
|
||||
|
||||
test("Responses→Chat: string input becomes a user message instead of an empty prompt", () => {
|
||||
const result = openaiResponsesToOpenAIRequest(
|
||||
null,
|
||||
{ model: "gpt-4", input: "Responda apenas: OK", max_output_tokens: 80 },
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
assert.equal((result as any).input, undefined);
|
||||
assert.equal((result as any).messages.length, 1);
|
||||
assert.equal((result as any).messages[0].role, "user");
|
||||
assert.deepEqual((result as any).messages[0].content, [
|
||||
{ type: "text", text: "Responda apenas: OK" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Responses→Chat: object input becomes a single user message", () => {
|
||||
const result = openaiResponsesToOpenAIRequest(
|
||||
null,
|
||||
{ model: "gpt-4", input: { text: "Ping" } },
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
assert.equal((result as any).messages.length, 1);
|
||||
assert.equal((result as any).messages[0].role, "user");
|
||||
assert.deepEqual((result as any).messages[0].content, [{ type: "text", text: "Ping" }]);
|
||||
});
|
||||
|
||||
test("Responses→Chat: role/content object input becomes a single user message", () => {
|
||||
const result = openaiResponsesToOpenAIRequest(
|
||||
null,
|
||||
{ model: "gpt-4", input: { role: "user", content: "Ping" } },
|
||||
null,
|
||||
null
|
||||
);
|
||||
|
||||
assert.equal((result as any).messages.length, 1);
|
||||
assert.equal((result as any).messages[0].role, "user");
|
||||
assert.equal((result as any).messages[0].content, "Ping");
|
||||
});
|
||||
|
||||
test("Codex Responses input: string input becomes a list-shaped user message", () => {
|
||||
const body: Record<string, unknown> = { input: "ship it" };
|
||||
normalizeCodexResponsesInput(body);
|
||||
|
||||
assert.deepEqual(body.input, [
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "ship it" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Codex Responses input: object input becomes a single item", () => {
|
||||
const body: Record<string, unknown> = { input: { role: "user", text: "ship it" } };
|
||||
normalizeCodexResponsesInput(body);
|
||||
|
||||
assert.deepEqual(body.input, [
|
||||
{ type: "message", role: "user", content: [{ type: "input_text", text: "ship it" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
test("Codex Responses input: null input normalizes to an empty list (not [null])", () => {
|
||||
const body: Record<string, unknown> = { input: null };
|
||||
normalizeCodexResponsesInput(body);
|
||||
|
||||
assert.deepEqual(body.input, []);
|
||||
});
|
||||
|
||||
test("Responses→Chat: null input normalizes to an empty list (not [null])", () => {
|
||||
assert.deepEqual(normalizeResponsesInputForChat(null), []);
|
||||
});
|
||||
|
||||
test("Responses→Chat: input_image without detail omits detail field", () => {
|
||||
const body = {
|
||||
model: "gpt-4",
|
||||
|
||||
@@ -99,6 +99,35 @@ test("normalizeSystemRole inserts a user message when no user exists and drops e
|
||||
]);
|
||||
});
|
||||
|
||||
test("normalizeSystemRole treats ZenMux z-ai/glm models as GLM even with vendor prefix", () => {
|
||||
const messages = [
|
||||
{ role: "system", content: "[Context compressed: earlier messages removed]" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: null,
|
||||
tool_calls: [
|
||||
{
|
||||
id: "call_1",
|
||||
type: "function",
|
||||
function: { name: "read", arguments: "{}" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "tool", tool_call_id: "call_1", content: "ok" },
|
||||
];
|
||||
|
||||
const result = normalizeSystemRole(messages, "zenmux", "z-ai/glm-5.2");
|
||||
|
||||
assert.deepEqual(result, [
|
||||
{
|
||||
role: "user",
|
||||
content: "[System Instructions]\n[Context compressed: earlier messages removed]",
|
||||
},
|
||||
messages[1],
|
||||
messages[2],
|
||||
]);
|
||||
});
|
||||
|
||||
test("normalizeRoles composes model, developer and system normalization in order", () => {
|
||||
const messages = [
|
||||
{ role: "model", content: "first answer" },
|
||||
|
||||
43
tests/unit/run-next-node-env.test.ts
Normal file
43
tests/unit/run-next-node-env.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
// run-next.mjs has top-level side effects (bootstrapEnv, native sqlite, server
|
||||
// listen) so it cannot be imported in-process. Guard the fix by inspecting the
|
||||
// source: the dev/start server MUST force NODE_ENV to match the run mode, after
|
||||
// the mergedEnv copy loop (which pulls NODE_ENV=production from .env/.env.example)
|
||||
// and before next() is constructed. Otherwise `npm run dev` boots the dev
|
||||
// bundler with NODE_ENV=production, which breaks Next's webpack CSS pipeline and
|
||||
// fails globals.css with "Unexpected character '@'" on `@import "tailwindcss"`.
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const source = fs.readFileSync(path.resolve(here, "../../scripts/dev/run-next.mjs"), "utf8");
|
||||
|
||||
// Quote- and whitespace-tolerant so legitimate Prettier/formatting changes
|
||||
// don't trip the guard; only a real change to the assignment shape should.
|
||||
const NODE_ENV_NORMALIZE =
|
||||
/process\.env\.NODE_ENV\s*=\s*dev\s*\?\s*['"]development['"]\s*:\s*['"]production['"]/;
|
||||
|
||||
test("run-next.mjs forces NODE_ENV to track the run mode", () => {
|
||||
assert.match(
|
||||
source,
|
||||
NODE_ENV_NORMALIZE,
|
||||
"expected NODE_ENV to be forced to development in dev mode, production otherwise"
|
||||
);
|
||||
});
|
||||
|
||||
test("NODE_ENV normalization runs after the merged-env copy and before next()", () => {
|
||||
const copyLoopIdx = source.search(/process\.env\[key\]\s*=\s*value/);
|
||||
const normalizeIdx = source.search(NODE_ENV_NORMALIZE);
|
||||
const nextCallIdx = source.search(/\bnext\s*\(\s*\{/);
|
||||
|
||||
assert.ok(copyLoopIdx !== -1, "expected the mergedEnv copy loop to exist");
|
||||
assert.ok(normalizeIdx !== -1, "expected the NODE_ENV normalization to exist");
|
||||
assert.ok(nextCallIdx !== -1, "expected the next() construction to exist");
|
||||
assert.ok(
|
||||
normalizeIdx > copyLoopIdx,
|
||||
"NODE_ENV must be normalized AFTER the env copy loop (else .env overwrites it)"
|
||||
);
|
||||
assert.ok(normalizeIdx < nextCallIdx, "NODE_ENV must be normalized BEFORE next() reads it");
|
||||
});
|
||||
123
tests/unit/service-worker-navigation-fallback.test.ts
Normal file
123
tests/unit/service-worker-navigation-fallback.test.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import vm from "node:vm";
|
||||
|
||||
type FetchEvent = {
|
||||
request: RequestLike;
|
||||
respondWith: (response: Promise<Response>) => void;
|
||||
};
|
||||
|
||||
type ServiceWorkerEvent = FetchEvent | Record<string, unknown>;
|
||||
|
||||
type RequestLike = {
|
||||
url: string;
|
||||
method: string;
|
||||
mode: string;
|
||||
destination: string;
|
||||
};
|
||||
|
||||
function createServiceWorkerHarness() {
|
||||
const cacheEntries = new Map<string, Response>();
|
||||
const listeners = new Map<string, (event: ServiceWorkerEvent) => void>();
|
||||
let fetchImpl: (request: RequestLike) => Promise<Response> = async () => {
|
||||
throw new Error("network unavailable");
|
||||
};
|
||||
|
||||
const cache = {
|
||||
addAll: async (urls: string[]) => {
|
||||
for (const url of urls) {
|
||||
cacheEntries.set(url, new Response(`cached ${url}`, { status: 200 }));
|
||||
}
|
||||
},
|
||||
delete: async (request: Request) => cacheEntries.delete(request.url),
|
||||
keys: async () => [...cacheEntries.keys()].map((url) => new Request(url)),
|
||||
put: async (request: Request, response: Response) => {
|
||||
cacheEntries.set(request.url, response);
|
||||
},
|
||||
};
|
||||
|
||||
const caches = {
|
||||
delete: async () => true,
|
||||
keys: async () => ["omniroute-pwa-v2"],
|
||||
match: async (request: Request | string) =>
|
||||
cacheEntries.get(typeof request === "string" ? request : request.url),
|
||||
open: async () => cache,
|
||||
};
|
||||
|
||||
const context = vm.createContext({
|
||||
URL,
|
||||
Request,
|
||||
Response,
|
||||
caches,
|
||||
fetch: (request: RequestLike) => fetchImpl(request),
|
||||
self: {
|
||||
clients: { claim: async () => undefined },
|
||||
location: { href: "https://app.example/sw.js", origin: "https://app.example" },
|
||||
registration: { showNotification: async () => undefined },
|
||||
skipWaiting: async () => undefined,
|
||||
addEventListener: (type: string, listener: (event: ServiceWorkerEvent) => void) => {
|
||||
listeners.set(type, listener);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
vm.runInContext(readFileSync("public/sw.js", "utf8"), context);
|
||||
|
||||
return {
|
||||
cacheEntries,
|
||||
dispatchFetch: async (request: RequestLike) => {
|
||||
const listener = listeners.get("fetch");
|
||||
assert.ok(listener, "fetch listener must be registered");
|
||||
let responsePromise: Promise<Response> | undefined;
|
||||
const event: FetchEvent = {
|
||||
request,
|
||||
respondWith: (response) => {
|
||||
responsePromise = response;
|
||||
},
|
||||
};
|
||||
listener(event);
|
||||
assert.ok(responsePromise, "navigate request must call respondWith");
|
||||
return responsePromise;
|
||||
},
|
||||
setFetch: (nextFetch: (request: Request) => Promise<Response>) => {
|
||||
fetchImpl = nextFetch;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
test("#5165: service worker returns cached navigation before offline page", async () => {
|
||||
const harness = createServiceWorkerHarness();
|
||||
const request = {
|
||||
url: "https://app.example/dashboard",
|
||||
method: "GET",
|
||||
mode: "navigate",
|
||||
destination: "document",
|
||||
};
|
||||
|
||||
harness.cacheEntries.set(request.url, new Response("cached dashboard", { status: 200 }));
|
||||
harness.cacheEntries.set("/offline", new Response("offline page", { status: 200 }));
|
||||
|
||||
const response = await harness.dispatchFetch(request);
|
||||
|
||||
assert.equal(await response.text(), "cached dashboard");
|
||||
});
|
||||
|
||||
test("#5165: successful navigations are cached for later transient failures", async () => {
|
||||
const harness = createServiceWorkerHarness();
|
||||
const request = {
|
||||
url: "https://app.example/dashboard",
|
||||
method: "GET",
|
||||
mode: "navigate",
|
||||
destination: "document",
|
||||
};
|
||||
|
||||
harness.setFetch(async () => new Response("fresh dashboard", { status: 200 }));
|
||||
assert.equal(await (await harness.dispatchFetch(request)).text(), "fresh dashboard");
|
||||
|
||||
harness.setFetch(async () => {
|
||||
throw new Error("transient network failure");
|
||||
});
|
||||
|
||||
assert.equal(await (await harness.dispatchFetch(request)).text(), "fresh dashboard");
|
||||
});
|
||||
@@ -34,9 +34,24 @@ test("#3884 streaming request still injects stream_options.include_usage", () =>
|
||||
const executor = new DefaultExecutor("openai");
|
||||
const body = { model: "gpt-4.1", messages: [{ role: "user", content: "hi" }] };
|
||||
const result = executor.transformRequest("gpt-4.1", body, true, {}) as Record<string, unknown>;
|
||||
assert.equal(result.stream, true);
|
||||
assert.deepEqual(result.stream_options, { include_usage: true });
|
||||
});
|
||||
|
||||
test("#3884 internal streaming strips stream_options when body explicitly disables stream", () => {
|
||||
const executor = new DefaultExecutor("openai-compatible-deepseek");
|
||||
const body = {
|
||||
model: "deepseek-chat",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
stream: false,
|
||||
};
|
||||
const result = executor.transformRequest("deepseek-chat", body, true, {
|
||||
providerSpecificData: { baseUrl: "https://proxy.example/v1" },
|
||||
}) as Record<string, unknown>;
|
||||
assert.equal(result.stream, false);
|
||||
assert.equal(result.stream_options, undefined);
|
||||
});
|
||||
|
||||
test("#3884 non-streaming request without stream_options stays clean", () => {
|
||||
const executor = new DefaultExecutor("openai");
|
||||
const body = { model: "gpt-4.1", messages: [{ role: "user", content: "hi" }] };
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isProxyReachable,
|
||||
getCachedProxyHealth,
|
||||
invalidateProxyHealth,
|
||||
__setProxyHealthTcpCheckForTesting,
|
||||
} from "../../src/lib/proxyHealth.ts";
|
||||
import { runWithProxyContext } from "../../open-sse/utils/proxyFetch.ts";
|
||||
|
||||
@@ -17,6 +18,37 @@ test("T14: isProxyReachable caches unreachable proxy result", async () => {
|
||||
assert.equal(getCachedProxyHealth(proxyUrl), false);
|
||||
});
|
||||
|
||||
test("#5109: concurrent proxy reachability checks share one TCP probe", async () => {
|
||||
const proxyUrl = "http://127.0.0.1:1080";
|
||||
invalidateProxyHealth(proxyUrl);
|
||||
|
||||
let probeCount = 0;
|
||||
let releaseProbe!: (healthy: boolean) => void;
|
||||
const probeStarted = new Promise<void>((resolve) => {
|
||||
__setProxyHealthTcpCheckForTesting(async () => {
|
||||
probeCount += 1;
|
||||
resolve();
|
||||
return new Promise<boolean>((resolveProbe) => {
|
||||
releaseProbe = resolveProbe;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
const checks = Array.from({ length: 50 }, () => isProxyReachable(proxyUrl, 120, 2_000));
|
||||
|
||||
await probeStarted;
|
||||
assert.equal(probeCount, 1, "concurrent requests must not fan out TCP health probes");
|
||||
|
||||
releaseProbe(true);
|
||||
assert.deepEqual(await Promise.all(checks), Array.from({ length: 50 }, () => true));
|
||||
assert.equal(getCachedProxyHealth(proxyUrl), true);
|
||||
} finally {
|
||||
__setProxyHealthTcpCheckForTesting(null);
|
||||
invalidateProxyHealth(proxyUrl);
|
||||
}
|
||||
});
|
||||
|
||||
test("T14: runWithProxyContext fast-fails when proxy is unreachable", async () => {
|
||||
const proxyUrl = "http://127.0.0.1:1";
|
||||
invalidateProxyHealth(proxyUrl);
|
||||
@@ -28,7 +60,7 @@ test("T14: runWithProxyContext fast-fails when proxy is unreachable", async () =
|
||||
executed = true;
|
||||
return "ok";
|
||||
}),
|
||||
(err) => (err as any).code === "PROXY_UNREACHABLE"
|
||||
(err) => (err as { code?: string })?.code === "PROXY_UNREACHABLE"
|
||||
);
|
||||
|
||||
assert.equal(executed, false);
|
||||
|
||||
41
tests/unit/ui/home-update-step-warning-color-amber.test.ts
Normal file
41
tests/unit/ui/home-update-step-warning-color-amber.test.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
// Regression guard for the escalated mesh report "Nobody solve this color issue 🤫":
|
||||
// the update-step status icon for the `warning` state used a bare `text-yellow-500`
|
||||
// (Tailwind #eab308, no dark: variant) while its `done`/`failed` siblings use the
|
||||
// vivid `text-green-500` / `text-red-500`. yellow-500 has poor contrast on light
|
||||
// backgrounds (~1.9:1, fails WCAG) and is inconsistent with the project's warning
|
||||
// convention, which is `amber` everywhere else in this same component (e.g. the
|
||||
// `bg-amber-500/10 text-amber-500` badge). The warning icon must use amber, not yellow.
|
||||
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const source = readFileSync(
|
||||
resolve(here, "../../../src/app/(dashboard)/dashboard/HomePageClient.tsx"),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
// Isolate the update-step status-icon block: the `warning` material symbol icon span.
|
||||
// Match the className on the span immediately preceding the `warning` glyph.
|
||||
const warningIconMatch = source.match(
|
||||
/className="material-symbols-outlined ([^"]*)"[^>]*>\s*warning\s*</
|
||||
);
|
||||
|
||||
test("update-step warning icon renders with an amber color, not bare yellow", () => {
|
||||
assert.ok(
|
||||
warningIconMatch,
|
||||
"expected to find the update-step `warning` material-symbols icon span in HomePageClient.tsx"
|
||||
);
|
||||
const className = warningIconMatch![1];
|
||||
assert.ok(
|
||||
className.includes("text-amber-500"),
|
||||
`warning icon should use the project's amber warning convention; got: "${className}"`
|
||||
);
|
||||
assert.ok(
|
||||
!className.includes("text-yellow-500"),
|
||||
"warning icon must not use the low-contrast bare `text-yellow-500` (no dark variant, fails WCAG on light backgrounds)"
|
||||
);
|
||||
});
|
||||
62
tests/unit/ui/toonEncoderTable.test.tsx
Normal file
62
tests/unit/ui/toonEncoderTable.test.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { act } from "react";
|
||||
import { EncoderComparisonTable } from "@/app/(dashboard)/dashboard/compression/studio/EncoderComparisonTable";
|
||||
import type { EncoderComparison } from "@/app/(dashboard)/dashboard/compression/studio/compressionFlowModel";
|
||||
|
||||
const cmp: EncoderComparison = {
|
||||
arraysCompared: 1,
|
||||
json: { bytes: 400, tokens: 120 },
|
||||
gcf: { bytes: 150, tokens: 40 },
|
||||
toon: { bytes: 170, tokens: 48 },
|
||||
toonAvailable: true,
|
||||
winner: "gcf",
|
||||
};
|
||||
|
||||
let container: HTMLElement;
|
||||
let root: Root;
|
||||
beforeEach(() => {
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
});
|
||||
afterEach(() => {
|
||||
act(() => root.unmount());
|
||||
container.remove();
|
||||
document.body.innerHTML = "";
|
||||
});
|
||||
|
||||
describe("EncoderComparisonTable", () => {
|
||||
it("renderiza GCF/TOON/JSON com tokens e marca o vencedor", () => {
|
||||
act(() => {
|
||||
root.render(<EncoderComparisonTable comparison={cmp} />);
|
||||
});
|
||||
const text = container.textContent ?? "";
|
||||
expect(text).toMatch(/GCF/);
|
||||
expect(text).toMatch(/TOON/);
|
||||
expect(text).toMatch(/JSON/);
|
||||
// tokens rendered
|
||||
expect(text).toContain("40");
|
||||
expect(text).toContain("48");
|
||||
expect(text).toContain("120");
|
||||
expect(container.querySelector('[data-testid="encoder-winner"]')?.textContent).toMatch(/gcf/i);
|
||||
});
|
||||
|
||||
it("mostra TOON como n/a quando indisponível", () => {
|
||||
act(() => {
|
||||
root.render(
|
||||
<EncoderComparisonTable comparison={{ ...cmp, toonAvailable: false, winner: "gcf" }} />
|
||||
);
|
||||
});
|
||||
expect(container.querySelector('[data-testid="encoder-toon-na"]')).toBeTruthy();
|
||||
});
|
||||
|
||||
it("não renderiza nada quando não há array comparado", () => {
|
||||
act(() => {
|
||||
root.render(<EncoderComparisonTable comparison={{ ...cmp, arraysCompared: 0 }} />);
|
||||
});
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -32,11 +32,16 @@ test.after(() => {
|
||||
|
||||
// ── helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
let entrySeq = 0;
|
||||
|
||||
function makeEntry(overrides: Record<string, unknown> = {}) {
|
||||
entrySeq++;
|
||||
const timestamp = new Date(Date.now() + entrySeq).toISOString();
|
||||
|
||||
return {
|
||||
provider: "test-provider",
|
||||
model: "test-model",
|
||||
connectionId: "conn-abc123",
|
||||
connectionId: `conn-abc123-${entrySeq}`,
|
||||
apiKeyId: null,
|
||||
apiKeyName: null,
|
||||
tokens: { input_tokens: 10, output_tokens: 20 },
|
||||
@@ -47,7 +52,7 @@ function makeEntry(overrides: Record<string, unknown> = {}) {
|
||||
errorCode: null,
|
||||
comboStrategy: null,
|
||||
endpoint: "/v1/chat/completions",
|
||||
timestamp: new Date().toISOString(),
|
||||
timestamp,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import test from "node:test";
|
||||
import assert from "assert";
|
||||
import { validateResponseQuality } from "../../open-sse/services/combo";
|
||||
import { validateResponseQuality } from "../../open-sse/services/combo/validateQuality.ts";
|
||||
|
||||
function makeResponse(body: string, contentType = "text/plain") {
|
||||
return {
|
||||
@@ -25,3 +25,28 @@ test("returns valid=false for non-JSON non-SSE text", async () => {
|
||||
const res = await validateResponseQuality(makeResponse("Hello world"), false, {});
|
||||
assert.strictEqual(res.valid, false);
|
||||
});
|
||||
|
||||
test("returns valid=false for Responses API bodies with no output items", async () => {
|
||||
const res = await validateResponseQuality(
|
||||
makeResponse(JSON.stringify({ object: "response", status: "completed", output: [] }), "application/json"),
|
||||
false,
|
||||
{}
|
||||
);
|
||||
assert.strictEqual(res.valid, false);
|
||||
});
|
||||
|
||||
test("returns valid=true for Responses API bodies with structural output", async () => {
|
||||
const res = await validateResponseQuality(
|
||||
makeResponse(
|
||||
JSON.stringify({
|
||||
object: "response",
|
||||
status: "completed",
|
||||
output: [{ type: "function_call", name: "lookup", arguments: "{}" }],
|
||||
}),
|
||||
"application/json"
|
||||
),
|
||||
false,
|
||||
{}
|
||||
);
|
||||
assert.strictEqual(res.valid, true);
|
||||
});
|
||||
|
||||
@@ -9,23 +9,16 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { budgetToEffort, applyThinking } = await import(
|
||||
"../../src/lib/providers/xai/thinking.ts"
|
||||
);
|
||||
const { chatRequestToXaiResponses, xaiCompletedToChatJson } = await import(
|
||||
"../../src/lib/providers/xai/translators/openai-chat.ts"
|
||||
);
|
||||
const {
|
||||
openaiResponsesRequestToXai,
|
||||
xaiCompletedToOpenaiResponses,
|
||||
xaiSseEventToOpenaiResponses,
|
||||
} = await import("../../src/lib/providers/xai/translators/openai-responses.ts");
|
||||
const { claudeRequestToXaiResponses, xaiCompletedToClaudeJson } = await import(
|
||||
"../../src/lib/providers/xai/translators/claude.ts"
|
||||
);
|
||||
const { geminiRequestToXaiResponses, xaiCompletedToGeminiJson } = await import(
|
||||
"../../src/lib/providers/xai/translators/gemini.ts"
|
||||
);
|
||||
const { budgetToEffort, applyThinking, normalizeXaiReasoningEffort } =
|
||||
await import("../../src/lib/providers/xai/thinking.ts");
|
||||
const { chatRequestToXaiResponses, xaiCompletedToChatJson } =
|
||||
await import("../../src/lib/providers/xai/translators/openai-chat.ts");
|
||||
const { openaiResponsesRequestToXai, xaiCompletedToOpenaiResponses, xaiSseEventToOpenaiResponses } =
|
||||
await import("../../src/lib/providers/xai/translators/openai-responses.ts");
|
||||
const { claudeRequestToXaiResponses, xaiCompletedToClaudeJson } =
|
||||
await import("../../src/lib/providers/xai/translators/claude.ts");
|
||||
const { geminiRequestToXaiResponses, xaiCompletedToGeminiJson } =
|
||||
await import("../../src/lib/providers/xai/translators/gemini.ts");
|
||||
|
||||
// ─── budgetToEffort ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -67,6 +60,21 @@ test("applyThinking: honors xAI-native reasoning.effort verbatim", () => {
|
||||
assert.equal((out as Record<string, unknown>).foo, 1);
|
||||
});
|
||||
|
||||
test("normalizeXaiReasoningEffort: downgrades max/xhigh to xAI-supported high", () => {
|
||||
assert.equal(normalizeXaiReasoningEffort("max"), "high");
|
||||
assert.equal(normalizeXaiReasoningEffort("xhigh"), "high");
|
||||
assert.equal(normalizeXaiReasoningEffort("HIGH"), "high");
|
||||
assert.equal(normalizeXaiReasoningEffort("ultra"), undefined);
|
||||
});
|
||||
|
||||
test("applyThinking: normalizes xAI-native max/xhigh to high", () => {
|
||||
const maxOut = applyThinking({ reasoning: { effort: "max", summary: "auto" } });
|
||||
assert.deepStrictEqual(maxOut.reasoning, { effort: "high", summary: "auto" });
|
||||
|
||||
const xhighOut = applyThinking({ reasoning: { effort: "xhigh" } });
|
||||
assert.deepStrictEqual(xhighOut.reasoning, { effort: "high" });
|
||||
});
|
||||
|
||||
test("applyThinking: rewrites OpenAI Chat reasoning_effort into reasoning.effort", () => {
|
||||
const req = { reasoning_effort: "medium" };
|
||||
const out = applyThinking(req);
|
||||
@@ -74,6 +82,13 @@ test("applyThinking: rewrites OpenAI Chat reasoning_effort into reasoning.effort
|
||||
assert.equal(out.reasoning_effort, undefined);
|
||||
});
|
||||
|
||||
test("applyThinking: rewrites OpenAI Chat max reasoning_effort into high", () => {
|
||||
const req = { reasoning_effort: "max" };
|
||||
const out = applyThinking(req);
|
||||
assert.deepStrictEqual(out.reasoning, { effort: "high" });
|
||||
assert.equal(out.reasoning_effort, undefined);
|
||||
});
|
||||
|
||||
test("applyThinking: ignores invalid reasoning_effort values", () => {
|
||||
const req = { reasoning_effort: "ultra" };
|
||||
const out = applyThinking(req);
|
||||
@@ -138,9 +153,7 @@ test("chatRequestToXaiResponses: converts system message to instructions", () =>
|
||||
test("chatRequestToXaiResponses: converts tool message to function_call_output", () => {
|
||||
const req = {
|
||||
model: "grok-4",
|
||||
messages: [
|
||||
{ role: "tool", content: "result text", tool_call_id: "call_abc" },
|
||||
],
|
||||
messages: [{ role: "tool", content: "result text", tool_call_id: "call_abc" }],
|
||||
};
|
||||
const out = chatRequestToXaiResponses(req);
|
||||
assert.equal(out.input[0].type, "function_call_output");
|
||||
@@ -154,6 +167,18 @@ test("chatRequestToXaiResponses: promotes reasoning_effort to reasoning field",
|
||||
assert.deepStrictEqual(out.reasoning, { effort: "high" });
|
||||
});
|
||||
|
||||
test("chatRequestToXaiResponses: normalizes max reasoning_effort for xAI", () => {
|
||||
const req = { model: "grok-4.3", messages: [], reasoning_effort: "max" };
|
||||
const out = chatRequestToXaiResponses(req);
|
||||
assert.deepStrictEqual(out.reasoning, { effort: "high" });
|
||||
});
|
||||
|
||||
test("chatRequestToXaiResponses: normalizes reasoning.effort for xAI", () => {
|
||||
const req = { model: "grok-4.3", messages: [], reasoning: { effort: "max", summary: "auto" } };
|
||||
const out = chatRequestToXaiResponses(req);
|
||||
assert.deepStrictEqual(out.reasoning, { effort: "high", summary: "auto" });
|
||||
});
|
||||
|
||||
test("chatRequestToXaiResponses: maps max_tokens to max_output_tokens", () => {
|
||||
const req = { model: "grok-4", messages: [], max_tokens: 512 };
|
||||
const out = chatRequestToXaiResponses(req);
|
||||
|
||||
Reference in New Issue
Block a user