mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-09-22 23:02:18 +03:00
fix: remove hardcoded localhost default arg from GET /api/keys, unify coverage to single coverage/ dir, fix test to pass explicit Request
- Remove `new Request('http://localhost/api/keys')` default arg from GET handler in src/app/api/keys/route.ts (line 26)
- Fix api-key-reveal-route.test.mjs to pass explicit Request instead of calling GET() with no args
- Add --output-dir coverage to all c8 scripts in package.json
- Add coverage.reportsDirectory: 'coverage' to vitest.config.ts and vitest.mcp.config.ts
- Fix CHANGELOG.md structure (# Changelog + [Unreleased] to top)
- Remove 30+ stale coverage-* directories from project root
- Coverage: Statements 78.76% | Branches 72.75% | Functions 80.93% | Lines 78.76% (all thresholds passed)
This commit is contained in:
37
.github/workflows/ci.yml
vendored
37
.github/workflows/ci.yml
vendored
@@ -71,21 +71,50 @@ jobs:
|
||||
name: i18n-${{ matrix.lang }}
|
||||
path: result.txt
|
||||
|
||||
security:
|
||||
name: Security Audit
|
||||
advanced-security:
|
||||
name: Advanced Security Scans
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# 1. TRUFFLEHOG OSS
|
||||
- name: TruffleHog Secret Scan
|
||||
uses: trufflesecurity/trufflehog@main
|
||||
with:
|
||||
path: ./
|
||||
base: ${{ github.event.repository.default_branch }}
|
||||
head: HEAD
|
||||
extra_args: --only-verified
|
||||
|
||||
# 2. SONARQUBE SCAN
|
||||
- name: SonarQube Scan
|
||||
uses: SonarSource/sonarqube-scan-action@v4
|
||||
env:
|
||||
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
|
||||
SONAR_HOST_URL: ${{ secrets.SONAR_HOST_URL }}
|
||||
|
||||
# 3. SNYK SCAN E VERIFICAÇÕES NATIVAS
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
|
||||
# Mantendo as verificações nativas originais
|
||||
- name: Dependency audit
|
||||
run: npm audit --audit-level=high --omit=dev || true
|
||||
- name: Check for known vulnerabilities
|
||||
run: npx is-my-node-vulnerable || true
|
||||
|
||||
- name: Run Snyk Vulnerability checks
|
||||
uses: snyk/actions/node@master
|
||||
env:
|
||||
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
|
||||
with:
|
||||
args: --severity-threshold=high
|
||||
|
||||
build:
|
||||
name: Build
|
||||
runs-on: ubuntu-latest
|
||||
@@ -195,7 +224,7 @@ jobs:
|
||||
if: always()
|
||||
needs:
|
||||
- lint
|
||||
- security
|
||||
- advanced-security
|
||||
- build
|
||||
- test-unit
|
||||
- test-coverage
|
||||
@@ -229,7 +258,7 @@ jobs:
|
||||
echo "| Job | Status |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "|-----|--------|" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Lint | $(status '${{ needs.lint.result }}') |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Security Audit | $(status '${{ needs.security.result }}') |" >> $GITHUB_STEP_SUMMARY
|
||||
echo "| Advanced Security | $(status '${{ needs.advanced-security.result }}') |" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# 🔹 BUILD
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -21,9 +21,11 @@ node_modules/
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
.data/
|
||||
.next-playwright/
|
||||
|
||||
# testing
|
||||
coverage/
|
||||
coverage**
|
||||
|
||||
# next.js
|
||||
.next/
|
||||
|
||||
10
CHANGELOG.md
10
CHANGELOG.md
@@ -1,13 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [3.5.3] - 2026-04-05
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Middleware:** Resolved infinite redirect loop on dashboard for fresh instances when requireLogin is disabled.
|
||||
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
---
|
||||
|
||||
## [3.5.2] — 2026-04-05
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import createNextIntlPlugin from "next-intl/plugin";
|
||||
|
||||
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
||||
const distDir = process.env.NEXT_DIST_DIR || ".next";
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
distDir,
|
||||
// Turbopack config: redirect native modules to stubs at build time
|
||||
turbopack: {
|
||||
resolveAlias: {
|
||||
|
||||
@@ -109,6 +109,53 @@ export function getCodexResetTime(quota: CodexQuotaSnapshot): number | null {
|
||||
return Math.max(...times); // Use furthest-out reset to avoid premature unblock
|
||||
}
|
||||
|
||||
/**
|
||||
* T03 (Item 3): Compute the minimum-necessary cooldown based on which window
|
||||
* is actually exhausted. Prevents over-blocking the account:
|
||||
*
|
||||
* - If 7d window >= threshold: cooldown until 7d reset (weekly window exhausted)
|
||||
* - If 5h window >= threshold: cooldown until 5h reset only (short-term limit)
|
||||
* - Otherwise: 0 (account is healthy, no cooldown needed)
|
||||
*
|
||||
* Called after parsing quota headers from a successful/429 response to
|
||||
* mark the account accordingly without overly long cooldowns.
|
||||
*
|
||||
* @param quota - Parsed quota snapshot from response headers
|
||||
* @param threshold - Fraction (0-1) that triggers cooldown (default: 0.95)
|
||||
* @returns Cooldown duration in milliseconds (0 = no cooldown needed)
|
||||
*/
|
||||
export function getCodexDualWindowCooldownMs(
|
||||
quota: CodexQuotaSnapshot,
|
||||
threshold = 0.95
|
||||
): { cooldownMs: number; window: "7d" | "5h" | "none" } {
|
||||
const now = Date.now();
|
||||
|
||||
// Compute per-window usage ratios (0..1)
|
||||
const ratio7d =
|
||||
quota.limit7d > 0 && Number.isFinite(quota.limit7d) ? quota.usage7d / quota.limit7d : 0;
|
||||
const ratio5h =
|
||||
quota.limit5h > 0 && Number.isFinite(quota.limit5h) ? quota.usage5h / quota.limit5h : 0;
|
||||
|
||||
// 7d window takes priority — if the weekly budget is near-exhausted,
|
||||
// we must wait until the weekly reset (not just 5h).
|
||||
if (ratio7d >= threshold && quota.resetAt7d) {
|
||||
const resetTime = new Date(quota.resetAt7d).getTime();
|
||||
if (resetTime > now) {
|
||||
return { cooldownMs: resetTime - now, window: "7d" };
|
||||
}
|
||||
}
|
||||
|
||||
// 5h window (primary short-term rate limit)
|
||||
if (ratio5h >= threshold && quota.resetAt5h) {
|
||||
const resetTime = new Date(quota.resetAt5h).getTime();
|
||||
if (resetTime > now) {
|
||||
return { cooldownMs: resetTime - now, window: "5h" };
|
||||
}
|
||||
}
|
||||
|
||||
return { cooldownMs: 0, window: "none" };
|
||||
}
|
||||
|
||||
// Ordered list of effort levels from lowest to highest
|
||||
const EFFORT_ORDER = ["none", "low", "medium", "high", "xhigh"] as const;
|
||||
type EffortLevel = (typeof EFFORT_ORDER)[number];
|
||||
|
||||
@@ -112,9 +112,9 @@ export class DefaultExecutor extends BaseExecutor {
|
||||
|
||||
if (stream) headers["Accept"] = "text/event-stream";
|
||||
|
||||
// Qwen header cleanup: Remove X-Dashscope-* headers since Qwen uses an OpenAI-compatible endpoint
|
||||
// (e.g. portal.qwen.ai) via its DefaultExecutor buildUrl override, which rejects native DashScope headers.
|
||||
if (this.provider === "qwen") {
|
||||
// Qwen header cleanup: Remove X-Dashscope-* headers if using an API key (DashScope compatible mode).
|
||||
// If using OAuth (Qwen Code), we MUST keep them for portal.qwen.ai to accept the request.
|
||||
if (this.provider === "qwen" && effectiveKey) {
|
||||
for (const key of Object.keys(headers)) {
|
||||
if (key.toLowerCase().startsWith("x-dashscope-")) {
|
||||
delete headers[key];
|
||||
|
||||
@@ -64,7 +64,9 @@ import {
|
||||
parseCodexQuotaHeaders,
|
||||
getCodexResetTime,
|
||||
getCodexModelScope,
|
||||
getCodexDualWindowCooldownMs,
|
||||
} from "../executors/codex.ts";
|
||||
import { invalidateCodexQuotaCache } from "../services/codexQuotaFetcher.ts";
|
||||
import { translateNonStreamingResponse } from "./responseTranslator.ts";
|
||||
import { extractUsageFromResponse } from "./usageExtractor.ts";
|
||||
import {
|
||||
@@ -107,6 +109,7 @@ import {
|
||||
import { resolveStreamFlag, stripMarkdownCodeFence } from "../utils/aiSdkCompat.ts";
|
||||
import { generateRequestId } from "@/shared/utils/requestId";
|
||||
import { normalizePayloadForLog } from "@/lib/logPayloads";
|
||||
import { extractFacts } from "@/lib/memory/extraction";
|
||||
import { injectMemory, shouldInjectMemory } from "@/lib/memory/injection";
|
||||
import { retrieveMemories } from "@/lib/memory/retrieval";
|
||||
import {
|
||||
@@ -114,12 +117,42 @@ import {
|
||||
getMemorySettings,
|
||||
toMemoryRetrievalConfig,
|
||||
} from "@/lib/memory/settings";
|
||||
import { injectSkills } from "@/lib/skills/injection";
|
||||
import { handleToolCallExecution } from "@/lib/skills/interception";
|
||||
import {
|
||||
buildClaudeCodeCompatibleRequest,
|
||||
isClaudeCodeCompatibleProvider,
|
||||
resolveClaudeCodeCompatibleSessionId,
|
||||
} from "../services/claudeCodeCompatible.ts";
|
||||
|
||||
function extractMemoryTextFromResponse(
|
||||
response: Record<string, unknown> | null | undefined
|
||||
): string {
|
||||
if (!response || typeof response !== "object") return "";
|
||||
|
||||
const openAIText = response?.choices?.[0]?.message?.content;
|
||||
if (typeof openAIText === "string") {
|
||||
return openAIText.trim();
|
||||
}
|
||||
|
||||
if (Array.isArray(response?.content)) {
|
||||
const contentText = response.content
|
||||
.filter(
|
||||
(part: Record<string, unknown>) => part?.type === "text" && typeof part?.text === "string"
|
||||
)
|
||||
.map((part: Record<string, unknown>) => String(part.text).trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
if (contentText) return contentText;
|
||||
}
|
||||
|
||||
if (typeof response?.output_text === "string") {
|
||||
return response.output_text.trim();
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
export function shouldUseNativeCodexPassthrough({
|
||||
provider,
|
||||
sourceFormat,
|
||||
@@ -182,6 +215,53 @@ function restoreClaudePassthroughToolNames(
|
||||
};
|
||||
}
|
||||
|
||||
function materializeDeduplicatedExecutionResult<T extends Record<string, unknown>>(result: T): T {
|
||||
const snapshot =
|
||||
result && typeof result === "object"
|
||||
? ((result as Record<string, unknown>)._dedupSnapshot as
|
||||
| {
|
||||
status: number;
|
||||
statusText: string;
|
||||
headers: [string, string][];
|
||||
payload: string;
|
||||
}
|
||||
| undefined)
|
||||
: undefined;
|
||||
|
||||
if (!snapshot) return result;
|
||||
|
||||
return {
|
||||
...result,
|
||||
response: new Response(snapshot.payload, {
|
||||
status: snapshot.status,
|
||||
statusText: snapshot.statusText,
|
||||
headers: snapshot.headers,
|
||||
}),
|
||||
} as T;
|
||||
}
|
||||
|
||||
function getSkillsProviderForFormat(format: string): "openai" | "anthropic" | "google" | "other" {
|
||||
switch (format) {
|
||||
case FORMATS.CLAUDE:
|
||||
return "anthropic";
|
||||
case FORMATS.GEMINI:
|
||||
return "google";
|
||||
default:
|
||||
return "openai";
|
||||
}
|
||||
}
|
||||
|
||||
function getSkillsModelIdForFormat(format: string): string {
|
||||
switch (format) {
|
||||
case FORMATS.CLAUDE:
|
||||
return "claude";
|
||||
case FORMATS.GEMINI:
|
||||
return "gemini";
|
||||
default:
|
||||
return "openai";
|
||||
}
|
||||
}
|
||||
|
||||
function getHeaderValueCaseInsensitive(
|
||||
headers: Record<string, unknown> | null | undefined,
|
||||
targetName: string
|
||||
@@ -440,10 +520,11 @@ export async function handleChatCore({
|
||||
};
|
||||
|
||||
// T03/T09: on 429, persist exact reset time per scope to avoid global over-blocking.
|
||||
// Item 3: Use dual-window cooldown to distinguish 5h vs 7d exhaustion.
|
||||
if (status === 429) {
|
||||
const resetTimeMs = getCodexResetTime(quota);
|
||||
if (resetTimeMs && resetTimeMs > Date.now()) {
|
||||
const scopeUntil = new Date(resetTimeMs).toISOString();
|
||||
const { cooldownMs, window: exhaustedWindow } = getCodexDualWindowCooldownMs(quota);
|
||||
if (cooldownMs > 0) {
|
||||
const scopeUntil = new Date(Date.now() + cooldownMs).toISOString();
|
||||
const scopeMapRaw =
|
||||
existingProviderData &&
|
||||
typeof existingProviderData === "object" &&
|
||||
@@ -456,6 +537,17 @@ export async function handleChatCore({
|
||||
...(scopeMapRaw as Record<string, unknown>),
|
||||
[scope]: scopeUntil,
|
||||
};
|
||||
nextProviderData.codexExhaustedWindow = exhaustedWindow;
|
||||
log?.debug?.(
|
||||
"CODEX",
|
||||
`Quota exhaustion on ${exhaustedWindow} window, cooldown until ${scopeUntil}`
|
||||
);
|
||||
}
|
||||
|
||||
// Invalidate the preflight cache for this connection so the next
|
||||
// isModelAvailable check fetches fresh quota data.
|
||||
if (connectionId) {
|
||||
invalidateCodexQuotaCache(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -563,6 +655,7 @@ export async function handleChatCore({
|
||||
const targetFormat = modelTargetFormat || getTargetFormat(provider);
|
||||
const noLogEnabled = apiKeyInfo?.noLog === true;
|
||||
const detailedLoggingEnabled = !noLogEnabled && (await isDetailedLoggingEnabled());
|
||||
const skillRequestId = generateRequestId();
|
||||
const persistAttemptLogs = ({
|
||||
status,
|
||||
tokens,
|
||||
@@ -716,6 +809,14 @@ export async function handleChatCore({
|
||||
log?.debug?.("FORMAT", `${sourceFormat} → ${targetFormat} | stream=${stream}`);
|
||||
|
||||
// ── Common input sanitization (runs for ALL paths including passthrough) ──
|
||||
// #994: Normalize max_output_tokens to max_tokens for universal compatibility
|
||||
if (body.max_output_tokens !== undefined) {
|
||||
if (body.max_tokens === undefined) {
|
||||
body.max_tokens = body.max_output_tokens;
|
||||
}
|
||||
delete body.max_output_tokens;
|
||||
}
|
||||
|
||||
// #291: Strip empty name fields from messages/input items
|
||||
// Upstream providers (OpenAI, Codex) reject name:"" with 400 errors.
|
||||
if (Array.isArray(body.messages)) {
|
||||
@@ -780,6 +881,23 @@ export async function handleChatCore({
|
||||
}
|
||||
}
|
||||
|
||||
if (apiKeyInfo?.id && memorySettings?.skillsEnabled) {
|
||||
const existingTools = Array.isArray(body.tools) ? body.tools : [];
|
||||
const mergedTools = injectSkills({
|
||||
provider: getSkillsProviderForFormat(sourceFormat),
|
||||
existingTools,
|
||||
apiKeyId: apiKeyInfo.id,
|
||||
});
|
||||
|
||||
if (mergedTools.length > existingTools.length) {
|
||||
body = {
|
||||
...body,
|
||||
tools: mergedTools,
|
||||
};
|
||||
log?.debug?.("SKILLS", `Injected ${mergedTools.length - existingTools.length} skills`);
|
||||
}
|
||||
}
|
||||
|
||||
// Translate request (pass reqLogger for intermediate logging)
|
||||
let translatedBody = body;
|
||||
const isClaudePassthrough = sourceFormat === FORMATS.CLAUDE && targetFormat === FORMATS.CLAUDE;
|
||||
@@ -933,22 +1051,33 @@ export async function handleChatCore({
|
||||
if (msg.role === "user" && Array.isArray(msg.content)) {
|
||||
msg.content = (msg.content as Record<string, unknown>[]).flatMap(
|
||||
(block: Record<string, unknown>) => {
|
||||
if (block.type === "text" || block.type === "image_url" || block.type === "image") {
|
||||
return [block];
|
||||
}
|
||||
// file / document → extract text content
|
||||
if (block.type === "file" || block.type === "document") {
|
||||
const fileContent =
|
||||
(block.file as Record<string, unknown>)?.content ??
|
||||
(block.file as Record<string, unknown>)?.text ??
|
||||
block.content ??
|
||||
block.text;
|
||||
const fileName =
|
||||
(block.file as Record<string, unknown>)?.name ?? block.name ?? "attachment";
|
||||
if (typeof fileContent === "string" && fileContent.length > 0) {
|
||||
return [{ type: "text", text: `[${fileName}]\n${fileContent}` }];
|
||||
if (
|
||||
block.type === "text" ||
|
||||
block.type === "image_url" ||
|
||||
block.type === "image" ||
|
||||
block.type === "file_url" ||
|
||||
block.type === "file" ||
|
||||
block.type === "document"
|
||||
) {
|
||||
// Only extract text if it's explicitly a text-only representation without data
|
||||
const fileData = (block.file_url ?? block.file ?? block.document) as any;
|
||||
if (
|
||||
(block.type === "file" || block.type === "document") &&
|
||||
!fileData?.url &&
|
||||
!fileData?.data
|
||||
) {
|
||||
const fileContent =
|
||||
(block.file as Record<string, unknown>)?.content ??
|
||||
(block.file as Record<string, unknown>)?.text ??
|
||||
block.content ??
|
||||
block.text;
|
||||
const fileName =
|
||||
(block.file as Record<string, unknown>)?.name ?? block.name ?? "attachment";
|
||||
if (typeof fileContent === "string" && fileContent.length > 0) {
|
||||
return [{ type: "text", text: `[${fileName}]\n${fileContent}` }];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
return [block];
|
||||
}
|
||||
// (#527) tool_result → convert to text instead of dropping.
|
||||
// When Claude Code + superpowers routes through Codex, it sends tool_result
|
||||
@@ -1238,6 +1367,12 @@ export async function handleChatCore({
|
||||
return {
|
||||
...rawResult,
|
||||
response: new Response(payload, { status, statusText, headers }),
|
||||
_dedupSnapshot: {
|
||||
status,
|
||||
statusText,
|
||||
headers,
|
||||
payload,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1246,7 +1381,7 @@ export async function handleChatCore({
|
||||
if (dedupResult.wasDeduplicated) {
|
||||
log?.debug?.("DEDUP", `Joined in-flight request hash=${dedupHash}`);
|
||||
}
|
||||
return dedupResult.result;
|
||||
return materializeDeduplicatedExecutionResult(dedupResult.result);
|
||||
}
|
||||
|
||||
return execute();
|
||||
@@ -1302,11 +1437,16 @@ export async function handleChatCore({
|
||||
);
|
||||
} catch (error) {
|
||||
trackPendingRequest(model, provider, connectionId, false);
|
||||
const failureStatus = error.name === "AbortError" ? 499 : HTTP_STATUS.BAD_GATEWAY;
|
||||
const failureStatus =
|
||||
error.name === "AbortError"
|
||||
? 499
|
||||
: error.name === "TimeoutError"
|
||||
? HTTP_STATUS.GATEWAY_TIMEOUT
|
||||
: HTTP_STATUS.BAD_GATEWAY;
|
||||
const failureMessage =
|
||||
error.name === "AbortError"
|
||||
? "Request aborted"
|
||||
: formatProviderError(error, provider, model, HTTP_STATUS.BAD_GATEWAY);
|
||||
: formatProviderError(error, provider, model, failureStatus);
|
||||
appendRequestLog({
|
||||
model,
|
||||
provider,
|
||||
@@ -1325,11 +1465,11 @@ export async function handleChatCore({
|
||||
return createErrorResult(499, "Request aborted");
|
||||
}
|
||||
persistFailureUsage(
|
||||
HTTP_STATUS.BAD_GATEWAY,
|
||||
failureStatus,
|
||||
error instanceof Error && error.name ? error.name : "upstream_error"
|
||||
);
|
||||
console.log(`${COLORS.red}[ERROR] ${failureMessage}${COLORS.reset}`);
|
||||
return createErrorResult(HTTP_STATUS.BAD_GATEWAY, failureMessage);
|
||||
return createErrorResult(failureStatus, failureMessage);
|
||||
}
|
||||
// We need to peek at the error text if it's 400 for Qwen
|
||||
let upstreamErrorParsed = false;
|
||||
@@ -2029,6 +2169,35 @@ export async function handleChatCore({
|
||||
}
|
||||
}
|
||||
|
||||
const pipelineSessionId =
|
||||
(clientRawRequest?.headers && typeof clientRawRequest.headers.get === "function"
|
||||
? clientRawRequest.headers.get("x-omniroute-session-id")
|
||||
: getHeaderValueCaseInsensitive(
|
||||
clientRawRequest?.headers ?? null,
|
||||
"x-omniroute-session-id"
|
||||
)) || skillRequestId;
|
||||
|
||||
if (apiKeyInfo?.id && memorySettings?.enabled && memorySettings.maxTokens > 0) {
|
||||
const memoryText = extractMemoryTextFromResponse(translatedResponse);
|
||||
if (memoryText) {
|
||||
extractFacts(memoryText, apiKeyInfo.id, pipelineSessionId);
|
||||
}
|
||||
}
|
||||
|
||||
if (apiKeyInfo?.id && memorySettings?.skillsEnabled) {
|
||||
const skillSessionId = pipelineSessionId;
|
||||
|
||||
translatedResponse = await handleToolCallExecution(
|
||||
translatedResponse,
|
||||
getSkillsModelIdForFormat(sourceFormat),
|
||||
{
|
||||
apiKeyId: apiKeyInfo.id,
|
||||
sessionId: skillSessionId,
|
||||
requestId: skillRequestId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// ── Phase 9.1: Cache store (non-streaming, temp=0) ──
|
||||
if (isCacheable(body, clientRawRequest?.headers)) {
|
||||
const signature = generateSignature(model, body.messages, body.temperature, body.top_p);
|
||||
|
||||
@@ -226,85 +226,135 @@ export function translateNonStreamingResponse(
|
||||
const root = toRecord(responseBody);
|
||||
const response = toRecord(root.response ?? root);
|
||||
const candidates = Array.isArray(response.candidates) ? response.candidates : [];
|
||||
if (candidates[0]) {
|
||||
const candidate = toRecord(candidates[0]);
|
||||
const content = toRecord(candidate.content);
|
||||
const usage = toRecord(response.usageMetadata ?? root.usageMetadata);
|
||||
|
||||
let textContent = "";
|
||||
const toolCalls: JsonRecord[] = [];
|
||||
let reasoningContent = "";
|
||||
|
||||
if (Array.isArray(content.parts)) {
|
||||
for (const part of content.parts) {
|
||||
const partObj = toRecord(part);
|
||||
if (partObj.thought === true && typeof partObj.text === "string") {
|
||||
reasoningContent += partObj.text;
|
||||
} else if (typeof partObj.text === "string") {
|
||||
textContent += partObj.text;
|
||||
}
|
||||
if (partObj.functionCall) {
|
||||
const fn = toRecord(partObj.functionCall);
|
||||
toolCalls.push({
|
||||
id: `call_${toString(fn.name, "unknown")}_${Date.now()}_${toolCalls.length}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: toString(fn.name),
|
||||
arguments: JSON.stringify(fn.args || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message: JsonRecord = { role: "assistant" };
|
||||
if (textContent) {
|
||||
message.content = textContent;
|
||||
}
|
||||
if (reasoningContent) {
|
||||
message.reasoning_content = reasoningContent;
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
message.tool_calls = toolCalls;
|
||||
}
|
||||
if (!message.content && !message.tool_calls) {
|
||||
message.content = "";
|
||||
}
|
||||
|
||||
let finishReason = toString(candidate.finishReason, "stop").toLowerCase();
|
||||
if (finishReason === "stop" && toolCalls.length > 0) {
|
||||
finishReason = "tool_calls";
|
||||
}
|
||||
|
||||
const usage = toRecord(response.usageMetadata ?? root.usageMetadata);
|
||||
const promptFeedback = toRecord(response.promptFeedback ?? root.promptFeedback);
|
||||
if (candidates.length > 0 || Object.keys(promptFeedback).length > 0) {
|
||||
const createdMs = Date.parse(toString(response.createTime));
|
||||
const created = Number.isFinite(createdMs)
|
||||
? Math.floor(createdMs / 1000)
|
||||
: Math.floor(Date.now() / 1000);
|
||||
|
||||
const choices =
|
||||
candidates.length > 0
|
||||
? candidates.map((candidateValue, index) => {
|
||||
const candidate = toRecord(candidateValue);
|
||||
const content = toRecord(candidate.content);
|
||||
|
||||
let textContent = "";
|
||||
const contentParts: JsonRecord[] = [];
|
||||
const toolCalls: JsonRecord[] = [];
|
||||
let reasoningContent = "";
|
||||
|
||||
if (Array.isArray(content.parts)) {
|
||||
for (const part of content.parts) {
|
||||
const partObj = toRecord(part);
|
||||
if (partObj.thought === true && typeof partObj.text === "string") {
|
||||
reasoningContent += partObj.text;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof partObj.text === "string") {
|
||||
textContent += partObj.text;
|
||||
contentParts.push({ type: "text", text: partObj.text });
|
||||
}
|
||||
|
||||
const inlineData = toRecord(partObj.inlineData ?? partObj.inline_data);
|
||||
if (typeof inlineData.data === "string" && inlineData.data.length > 0) {
|
||||
const mimeType = toString(
|
||||
inlineData.mimeType ?? inlineData.mime_type,
|
||||
"image/png"
|
||||
);
|
||||
contentParts.push({
|
||||
type: "image_url",
|
||||
image_url: { url: `data:${mimeType};base64,${inlineData.data}` },
|
||||
});
|
||||
}
|
||||
|
||||
if (partObj.functionCall) {
|
||||
const fn = toRecord(partObj.functionCall);
|
||||
toolCalls.push({
|
||||
id: `call_${toString(fn.name, "unknown")}_${Date.now()}_${toolCalls.length}`,
|
||||
type: "function",
|
||||
function: {
|
||||
name: toString(fn.name),
|
||||
arguments: JSON.stringify(fn.args || {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message: JsonRecord = { role: "assistant" };
|
||||
if (contentParts.length === 1 && contentParts[0].type === "text") {
|
||||
message.content = contentParts[0].text;
|
||||
} else if (contentParts.length > 0) {
|
||||
message.content = contentParts;
|
||||
} else if (textContent) {
|
||||
message.content = textContent;
|
||||
}
|
||||
if (reasoningContent) {
|
||||
message.reasoning_content = reasoningContent;
|
||||
}
|
||||
if (toolCalls.length > 0) {
|
||||
message.tool_calls = toolCalls;
|
||||
}
|
||||
if (!message.content && !message.tool_calls) {
|
||||
message.content = "";
|
||||
}
|
||||
|
||||
let finishReason = toString(candidate.finishReason, "stop").toLowerCase();
|
||||
if (finishReason === "max_tokens") {
|
||||
finishReason = "length";
|
||||
} else if (
|
||||
finishReason === "safety" ||
|
||||
finishReason === "recitation" ||
|
||||
finishReason === "blocklist"
|
||||
) {
|
||||
finishReason = "content_filter";
|
||||
} else if (finishReason === "stop" && toolCalls.length > 0) {
|
||||
finishReason = "tool_calls";
|
||||
}
|
||||
|
||||
return {
|
||||
index,
|
||||
message,
|
||||
finish_reason: finishReason,
|
||||
};
|
||||
})
|
||||
: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "" },
|
||||
finish_reason: "content_filter",
|
||||
},
|
||||
];
|
||||
|
||||
const result: JsonRecord = {
|
||||
id: `chatcmpl-${toString(response.responseId, String(Date.now()))}`,
|
||||
object: "chat.completion",
|
||||
created,
|
||||
model: toString(response.modelVersion, "gemini"),
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message,
|
||||
finish_reason: finishReason,
|
||||
},
|
||||
],
|
||||
choices,
|
||||
};
|
||||
|
||||
if (Object.keys(usage).length > 0) {
|
||||
const promptTokens = toNumber(usage.promptTokenCount, 0);
|
||||
const reasoningTokens = toNumber(usage.thoughtsTokenCount, 0);
|
||||
const completionTokens = toNumber(usage.candidatesTokenCount, 0) + reasoningTokens;
|
||||
|
||||
result.usage = {
|
||||
prompt_tokens:
|
||||
toNumber(usage.promptTokenCount, 0) + toNumber(usage.thoughtsTokenCount, 0),
|
||||
completion_tokens: toNumber(usage.candidatesTokenCount, 0),
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: completionTokens,
|
||||
total_tokens: toNumber(usage.totalTokenCount, 0),
|
||||
};
|
||||
if (toNumber(usage.thoughtsTokenCount, 0) > 0) {
|
||||
if (reasoningTokens > 0) {
|
||||
(result.usage as JsonRecord).completion_tokens_details = {
|
||||
reasoning_tokens: toNumber(usage.thoughtsTokenCount, 0),
|
||||
reasoning_tokens: reasoningTokens,
|
||||
};
|
||||
}
|
||||
if (toNumber(usage.cachedContentTokenCount, 0) > 0) {
|
||||
(result.usage as JsonRecord).prompt_tokens_details = {
|
||||
cached_tokens: toNumber(usage.cachedContentTokenCount, 0),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,11 @@
|
||||
*/
|
||||
export function extractUsageFromResponse(responseBody, provider) {
|
||||
if (!responseBody || typeof responseBody !== "object") return null;
|
||||
const providerId = typeof provider === "string" ? provider.toLowerCase() : "";
|
||||
const isClaudeProvider =
|
||||
providerId === "claude" ||
|
||||
providerId === "anthropic" ||
|
||||
providerId.startsWith("anthropic-compatible");
|
||||
|
||||
// OpenAI format (has prompt_tokens / completion_tokens)
|
||||
if (
|
||||
@@ -19,6 +24,29 @@ export function extractUsageFromResponse(responseBody, provider) {
|
||||
};
|
||||
}
|
||||
|
||||
// Claude format
|
||||
if (
|
||||
isClaudeProvider &&
|
||||
responseBody.usage &&
|
||||
typeof responseBody.usage === "object" &&
|
||||
(responseBody.usage.input_tokens !== undefined ||
|
||||
responseBody.usage.output_tokens !== undefined)
|
||||
) {
|
||||
const inputTokens = responseBody.usage.input_tokens || 0;
|
||||
const cacheRead = responseBody.usage.cache_read_input_tokens || 0;
|
||||
const cacheCreation = responseBody.usage.cache_creation_input_tokens || 0;
|
||||
|
||||
// Total prompt tokens = input + cache_read + cache_creation (per Claude API docs)
|
||||
const promptTokens = inputTokens + cacheRead + cacheCreation;
|
||||
|
||||
return {
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: responseBody.usage.output_tokens || 0,
|
||||
cache_read_input_tokens: cacheRead,
|
||||
cache_creation_input_tokens: cacheCreation,
|
||||
};
|
||||
}
|
||||
|
||||
// OpenAI Responses API format (input_tokens / output_tokens)
|
||||
const responsesUsage = responseBody.response?.usage || responseBody.usage;
|
||||
if (
|
||||
@@ -39,28 +67,6 @@ export function extractUsageFromResponse(responseBody, provider) {
|
||||
};
|
||||
}
|
||||
|
||||
// Claude format
|
||||
if (
|
||||
responseBody.usage &&
|
||||
typeof responseBody.usage === "object" &&
|
||||
(responseBody.usage.input_tokens !== undefined ||
|
||||
responseBody.usage.output_tokens !== undefined)
|
||||
) {
|
||||
const inputTokens = responseBody.usage.input_tokens || 0;
|
||||
const cacheRead = responseBody.usage.cache_read_input_tokens || 0;
|
||||
const cacheCreation = responseBody.usage.cache_creation_input_tokens || 0;
|
||||
|
||||
// Total prompt tokens = input + cache_read + cache_creation (per Claude API docs)
|
||||
const promptTokens = inputTokens + cacheRead + cacheCreation;
|
||||
|
||||
return {
|
||||
prompt_tokens: promptTokens,
|
||||
completion_tokens: responseBody.usage.output_tokens || 0,
|
||||
cache_read_input_tokens: cacheRead,
|
||||
cache_creation_input_tokens: cacheCreation,
|
||||
};
|
||||
}
|
||||
|
||||
// Gemini format
|
||||
if (responseBody.usageMetadata && typeof responseBody.usageMetadata === "object") {
|
||||
return {
|
||||
|
||||
@@ -40,6 +40,7 @@ export const memoryTools = {
|
||||
persistAcrossModels: false,
|
||||
retentionDays: 30,
|
||||
scope: "apiKey" as const,
|
||||
query: args.query,
|
||||
};
|
||||
|
||||
const memories = await retrieveMemories(args.apiKeyId, config);
|
||||
|
||||
266
open-sse/services/codexQuotaFetcher.ts
Normal file
266
open-sse/services/codexQuotaFetcher.ts
Normal file
@@ -0,0 +1,266 @@
|
||||
/**
|
||||
* codexQuotaFetcher.ts — Codex Dual-Window Quota Fetcher
|
||||
*
|
||||
* Implements QuotaFetcher for the Codex provider (quotaPreflight.ts + quotaMonitor.ts).
|
||||
*
|
||||
* Codex has TWO independent quota windows:
|
||||
* - Primary (5h): short-term rate limit, resets every 5 hours
|
||||
* - Secondary (7d): weekly limit, resets every 7 days
|
||||
*
|
||||
* We return percentUsed = max(5h%, 7d%) so the system switches accounts when
|
||||
* EITHER window approaches exhaustion (95% threshold).
|
||||
*
|
||||
* Cache: in-memory TTL (60s) to avoid hammering the usage API on every request.
|
||||
* The connection pool is keyed by connectionId (providerConnection.id from DB).
|
||||
*
|
||||
* Registration: call registerCodexQuotaFetcher() once at server startup.
|
||||
*/
|
||||
|
||||
import { registerQuotaFetcher, type QuotaInfo } from "./quotaPreflight.ts";
|
||||
import { registerMonitorFetcher } from "./quotaMonitor.ts";
|
||||
|
||||
// Codex usage endpoint (same as usage.ts CODEX_CONFIG)
|
||||
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
||||
|
||||
// Cache TTL — short enough to be reactive, long enough to avoid rate limits
|
||||
const CACHE_TTL_MS = 60_000; // 60 seconds
|
||||
|
||||
// Per-account quota window info (richer than QuotaInfo — includes both windows)
|
||||
export interface CodexDualWindowQuota extends QuotaInfo {
|
||||
window5h: { percentUsed: number; resetAt: string | null };
|
||||
window7d: { percentUsed: number; resetAt: string | null };
|
||||
limitReached: boolean;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
quota: CodexDualWindowQuota;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
// In-memory cache: connectionId → { quota, fetchedAt }
|
||||
const quotaCache = new Map<string, CacheEntry>();
|
||||
|
||||
// Auto-cleanup stale entries every 5 minutes
|
||||
const _cacheCleanup = setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [key, entry] of quotaCache) {
|
||||
if (now - entry.fetchedAt > CACHE_TTL_MS * 5) {
|
||||
quotaCache.delete(key);
|
||||
}
|
||||
}
|
||||
}, 5 * 60_000);
|
||||
|
||||
if (typeof _cacheCleanup === "object" && "unref" in _cacheCleanup) {
|
||||
(_cacheCleanup as { unref?: () => void }).unref?.();
|
||||
}
|
||||
|
||||
// ─── Connection registry ─────────────────────────────────────────────────────
|
||||
// We need the accessToken + workspaceId to call the API.
|
||||
// chatCore.ts registers connection metadata here before requests.
|
||||
|
||||
interface CodexConnectionMeta {
|
||||
accessToken: string;
|
||||
workspaceId?: string;
|
||||
}
|
||||
|
||||
const connectionRegistry = new Map<string, CodexConnectionMeta>();
|
||||
|
||||
/**
|
||||
* Register Codex connection metadata for quota fetching.
|
||||
* Called by chatCore.ts when a Codex connection is resolved.
|
||||
*
|
||||
* @param connectionId - The connection ID from the DB (providerConnection.id)
|
||||
* @param meta - Access token and optional workspace ID
|
||||
*/
|
||||
export function registerCodexConnection(connectionId: string, meta: CodexConnectionMeta): void {
|
||||
connectionRegistry.set(connectionId, meta);
|
||||
}
|
||||
|
||||
// ─── Core Fetcher ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Fetch current quota for a Codex connection.
|
||||
* Returns percentUsed = max(5h%, 7d%) — worst-case across both windows.
|
||||
*
|
||||
* @param connectionId - Connection ID from the DB (used to look up credentials)
|
||||
* @returns QuotaInfo or null if fetch fails / no credentials
|
||||
*/
|
||||
export async function fetchCodexQuota(connectionId: string): Promise<QuotaInfo | null> {
|
||||
// Check cache first
|
||||
const cached = quotaCache.get(connectionId);
|
||||
if (cached && Date.now() - cached.fetchedAt < CACHE_TTL_MS) {
|
||||
return cached.quota;
|
||||
}
|
||||
|
||||
// Look up credentials
|
||||
const meta = connectionRegistry.get(connectionId);
|
||||
if (!meta?.accessToken) {
|
||||
// No credentials registered — skip preflight gracefully
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${meta.accessToken}`,
|
||||
"Content-Type": "application/json",
|
||||
Accept: "application/json",
|
||||
};
|
||||
|
||||
if (meta.workspaceId) {
|
||||
headers["chatgpt-account-id"] = meta.workspaceId;
|
||||
}
|
||||
|
||||
const response = await fetch(CODEX_USAGE_URL, {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: AbortSignal.timeout(8_000),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Non-2xx: could be token expired or quota API down.
|
||||
// Return null to proceed (fail-open — don't block on API errors).
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
// Token expired — remove from cache so next call re-fetches
|
||||
quotaCache.delete(connectionId);
|
||||
connectionRegistry.delete(connectionId);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const quota = parseCodexUsageResponse(data);
|
||||
|
||||
if (!quota) return null;
|
||||
|
||||
// Store in cache
|
||||
quotaCache.set(connectionId, { quota, fetchedAt: Date.now() });
|
||||
return quota;
|
||||
} catch {
|
||||
// Network error, timeout, etc. — fail open
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Response Parser ─────────────────────────────────────────────────────────
|
||||
|
||||
function toNumber(value: unknown, fallback = 0): number {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string") {
|
||||
const parsed = parseFloat(value);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function toRecord(value: unknown): Record<string, unknown> {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function parseWindowReset(window: Record<string, unknown>): string | null {
|
||||
const resetAt = toNumber(window["reset_at"] ?? window["resetAt"], 0);
|
||||
if (resetAt > 0) {
|
||||
return new Date(resetAt * 1000).toISOString();
|
||||
}
|
||||
const resetAfterSeconds = toNumber(
|
||||
window["reset_after_seconds"] ?? window["resetAfterSeconds"],
|
||||
0
|
||||
);
|
||||
if (resetAfterSeconds > 0) {
|
||||
return new Date(Date.now() + resetAfterSeconds * 1000).toISOString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseCodexUsageResponse(data: unknown): CodexDualWindowQuota | null {
|
||||
const obj = toRecord(data);
|
||||
const rateLimit = toRecord(obj["rate_limit"] ?? obj["rateLimit"]);
|
||||
const primaryWindow = toRecord(rateLimit["primary_window"] ?? rateLimit["primaryWindow"]);
|
||||
const secondaryWindow = toRecord(rateLimit["secondary_window"] ?? rateLimit["secondaryWindow"]);
|
||||
|
||||
// Require at least one window to be present
|
||||
const hasPrimary = Object.keys(primaryWindow).length > 0;
|
||||
const hasSecondary = Object.keys(secondaryWindow).length > 0;
|
||||
if (!hasPrimary && !hasSecondary) return null;
|
||||
|
||||
// Parse 5h window
|
||||
const usedPercent5h = hasPrimary
|
||||
? toNumber(primaryWindow["used_percent"] ?? primaryWindow["usedPercent"], 0)
|
||||
: 0;
|
||||
const resetAt5h = hasPrimary ? parseWindowReset(primaryWindow) : null;
|
||||
|
||||
// Parse 7d window
|
||||
const usedPercent7d = hasSecondary
|
||||
? toNumber(secondaryWindow["used_percent"] ?? secondaryWindow["usedPercent"], 0)
|
||||
: 0;
|
||||
const resetAt7d = hasSecondary ? parseWindowReset(secondaryWindow) : null;
|
||||
|
||||
// Worst-case across both windows (triggers switch when EITHER is at 95%)
|
||||
const worstPercentUsed = Math.max(usedPercent5h, usedPercent7d);
|
||||
const percentUsedNormalized = worstPercentUsed / 100; // QuotaInfo uses 0..1
|
||||
|
||||
const limitReached = Boolean(rateLimit["limit_reached"] ?? rateLimit["limitReached"]);
|
||||
|
||||
return {
|
||||
used: worstPercentUsed,
|
||||
total: 100,
|
||||
percentUsed: percentUsedNormalized,
|
||||
window5h: { percentUsed: usedPercent5h / 100, resetAt: resetAt5h },
|
||||
window7d: { percentUsed: usedPercent7d / 100, resetAt: resetAt7d },
|
||||
limitReached,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Quota-Aware Reset Time ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Get the cooldown duration (ms) for a Codex account based on its quota state.
|
||||
*
|
||||
* Logic:
|
||||
* - If 7d window >= threshold → cooldown until 7d reset (longer)
|
||||
* - If 5h window >= threshold → cooldown until 5h reset (shorter)
|
||||
* - Otherwise → 0 (no cooldown)
|
||||
*
|
||||
* @param quota - The dual-window quota snapshot
|
||||
* @param threshold - The fraction (0-1) that triggers a switch (default: 0.95)
|
||||
* @returns Cooldown duration in milliseconds
|
||||
*/
|
||||
export function getCodexQuotaCooldownMs(quota: CodexDualWindowQuota, threshold = 0.95): number {
|
||||
const now = Date.now();
|
||||
|
||||
// 7d window takes priority (if exhausted, must wait longer)
|
||||
if (quota.window7d.percentUsed >= threshold && quota.window7d.resetAt) {
|
||||
const resetTime = new Date(quota.window7d.resetAt).getTime();
|
||||
if (resetTime > now) return resetTime - now;
|
||||
}
|
||||
|
||||
// 5h window
|
||||
if (quota.window5h.percentUsed >= threshold && quota.window5h.resetAt) {
|
||||
const resetTime = new Date(quota.window5h.resetAt).getTime();
|
||||
if (resetTime > now) return resetTime - now;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
// ─── Invalidation ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Force-invalidate the cache for a connection (e.g., after receiving quota headers).
|
||||
* Ensures the next preflight call fetches fresh data.
|
||||
*/
|
||||
export function invalidateCodexQuotaCache(connectionId: string): void {
|
||||
quotaCache.delete(connectionId);
|
||||
}
|
||||
|
||||
// ─── Registration ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Register the Codex quota fetcher with the preflight and monitor systems.
|
||||
* Call this once at server startup (in chatCore.ts or app entry point).
|
||||
*/
|
||||
export function registerCodexQuotaFetcher(): void {
|
||||
registerQuotaFetcher("codex", fetchCodexQuota);
|
||||
registerMonitorFetcher("codex", fetchCodexQuota);
|
||||
}
|
||||
@@ -961,6 +961,7 @@ interface RefreshLoggerLike {
|
||||
export function isProviderBlocked(provider: string): boolean {
|
||||
const state = _circuitBreaker[provider];
|
||||
if (!state) return false;
|
||||
if (!state.blockedUntil) return false;
|
||||
if (state.blockedUntil > Date.now()) return true;
|
||||
// Cooldown expired — reset
|
||||
delete _circuitBreaker[provider];
|
||||
@@ -1016,10 +1017,23 @@ function recordFailure(provider: string, log: RefreshLoggerLike | null = null) {
|
||||
* Execute a function with a timeout.
|
||||
*/
|
||||
async function withTimeout<T>(fn: () => Promise<T>, timeoutMs: number): Promise<T | null> {
|
||||
return Promise.race([
|
||||
fn(),
|
||||
new Promise<null>((resolve) => setTimeout(() => resolve(null), timeoutMs)),
|
||||
]);
|
||||
return await new Promise<T | null>((resolve, reject) => {
|
||||
const timer = setTimeout(() => resolve(null), timeoutMs);
|
||||
if (typeof timer === "object" && "unref" in timer) {
|
||||
(timer as { unref?: () => void }).unref?.();
|
||||
}
|
||||
|
||||
fn().then(
|
||||
(result) => {
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
},
|
||||
(error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function refreshWithRetry(
|
||||
|
||||
@@ -75,17 +75,20 @@ export function convertOpenAIContentToParts(content) {
|
||||
for (const item of content) {
|
||||
if (item.type === "text") {
|
||||
parts.push({ text: item.text });
|
||||
} else if (item.type === "image_url" && item.image_url?.url?.startsWith("data:")) {
|
||||
const url = item.image_url.url;
|
||||
const commaIndex = url.indexOf(",");
|
||||
if (commaIndex !== -1) {
|
||||
const mimePart = url.substring(5, commaIndex); // skip "data:"
|
||||
const data = url.substring(commaIndex + 1);
|
||||
const mimeType = mimePart.split(";")[0];
|
||||
} else {
|
||||
const fileData =
|
||||
item.image_url?.url || item.file_url?.url || item.file?.url || item.document?.url;
|
||||
if (typeof fileData === "string" && fileData.startsWith("data:")) {
|
||||
const commaIndex = fileData.indexOf(",");
|
||||
if (commaIndex !== -1) {
|
||||
const mimePart = fileData.substring(5, commaIndex); // skip "data:"
|
||||
const data = fileData.substring(commaIndex + 1);
|
||||
const mimeType = mimePart.split(";")[0];
|
||||
|
||||
parts.push({
|
||||
inlineData: { mimeType, data },
|
||||
});
|
||||
parts.push({
|
||||
inlineData: { mimeType, data },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import { DEFAULT_MAX_TOKENS, DEFAULT_MIN_TOKENS } from "../../config/constants.t
|
||||
* @returns {number} Adjusted max_tokens
|
||||
*/
|
||||
export function adjustMaxTokens(body) {
|
||||
let maxTokens = body.max_tokens || DEFAULT_MAX_TOKENS;
|
||||
const requestedMaxTokens = body.max_tokens ?? body.max_completion_tokens;
|
||||
let maxTokens = requestedMaxTokens || DEFAULT_MAX_TOKENS;
|
||||
|
||||
// Auto-increase for tool calling to prevent truncated arguments
|
||||
// Tool calls with large content (like writing files) need more tokens
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
// OpenAI helper functions for translator
|
||||
|
||||
// Valid OpenAI content block types
|
||||
export const VALID_OPENAI_CONTENT_TYPES = ["text", "image_url", "image"];
|
||||
export const VALID_OPENAI_CONTENT_TYPES = [
|
||||
"text",
|
||||
"image_url",
|
||||
"image",
|
||||
"file_url",
|
||||
"file",
|
||||
"document",
|
||||
];
|
||||
export const VALID_OPENAI_MESSAGE_TYPES = [
|
||||
"text",
|
||||
"image_url",
|
||||
"image",
|
||||
"file_url",
|
||||
"file",
|
||||
"document",
|
||||
"image",
|
||||
"tool_calls",
|
||||
"tool_result",
|
||||
];
|
||||
|
||||
@@ -27,6 +27,12 @@ export function claudeToOpenAIRequest(model, body, stream) {
|
||||
if (body.temperature !== undefined) {
|
||||
result.temperature = body.temperature;
|
||||
}
|
||||
if (body.top_p !== undefined) {
|
||||
result.top_p = body.top_p;
|
||||
}
|
||||
if (body.stop_sequences !== undefined) {
|
||||
result.stop = body.stop_sequences;
|
||||
}
|
||||
|
||||
// System message
|
||||
if (body.system) {
|
||||
@@ -149,6 +155,7 @@ function convertClaudeMessage(msg) {
|
||||
const parts = [];
|
||||
const toolCalls = [];
|
||||
const toolResults = [];
|
||||
let reasoningContent = null;
|
||||
|
||||
for (const block of msg.content) {
|
||||
switch (block.type) {
|
||||
@@ -164,6 +171,23 @@ function convertClaudeMessage(msg) {
|
||||
url: `data:${block.source.media_type};base64,${block.source.data}`,
|
||||
},
|
||||
});
|
||||
} else if (block.source?.type === "url" && typeof block.source.url === "string") {
|
||||
parts.push({
|
||||
type: "image_url",
|
||||
image_url: {
|
||||
url: block.source.url,
|
||||
},
|
||||
});
|
||||
}
|
||||
break;
|
||||
|
||||
case "thinking":
|
||||
reasoningContent = block.thinking || block.text || "";
|
||||
break;
|
||||
|
||||
case "redacted_thinking":
|
||||
if (reasoningContent == null) {
|
||||
reasoningContent = "";
|
||||
}
|
||||
break;
|
||||
|
||||
@@ -217,20 +241,35 @@ function convertClaudeMessage(msg) {
|
||||
result.content = parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts;
|
||||
}
|
||||
result.tool_calls = toolCalls;
|
||||
if (reasoningContent !== null) {
|
||||
result.reasoning_content = reasoningContent;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Return content
|
||||
if (parts.length > 0) {
|
||||
return {
|
||||
const result: JsonRecord = {
|
||||
role,
|
||||
content: parts.length === 1 && parts[0].type === "text" ? parts[0].text : parts,
|
||||
};
|
||||
if (reasoningContent !== null && role === "assistant") {
|
||||
result.reasoning_content = reasoningContent;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Empty content array
|
||||
if (msg.content.length === 0) {
|
||||
return { role, content: "" };
|
||||
const result: JsonRecord = { role, content: "" };
|
||||
if (reasoningContent !== null && role === "assistant") {
|
||||
result.reasoning_content = reasoningContent;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
if (reasoningContent !== null && role === "assistant") {
|
||||
return { role, content: "", reasoning_content: reasoningContent };
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -111,6 +111,12 @@ export function openaiToClaudeRequest(model, body, stream) {
|
||||
if (body.temperature !== undefined) {
|
||||
result.temperature = body.temperature;
|
||||
}
|
||||
if (body.top_p !== undefined) {
|
||||
result.top_p = body.top_p;
|
||||
}
|
||||
if (body.stop !== undefined) {
|
||||
result.stop_sequences = Array.isArray(body.stop) ? body.stop : [body.stop];
|
||||
}
|
||||
|
||||
// Messages
|
||||
const systemParts = [];
|
||||
@@ -232,49 +238,42 @@ export function openaiToClaudeRequest(model, body, stream) {
|
||||
}
|
||||
}
|
||||
|
||||
// System with Claude Code prompt and cache_control
|
||||
const claudeCodePrompt = { type: "text", text: CLAUDE_SYSTEM_PROMPT };
|
||||
|
||||
if (systemParts.length > 0) {
|
||||
const systemText = systemParts.join("\n");
|
||||
result.system = [
|
||||
claudeCodePrompt,
|
||||
{ type: "text", text: systemText, cache_control: { type: "ephemeral", ttl: "1h" } },
|
||||
];
|
||||
} else {
|
||||
result.system = [claudeCodePrompt];
|
||||
}
|
||||
|
||||
// Tools - convert from OpenAI format to Claude format with prefix for OAuth
|
||||
if (body.tools && Array.isArray(body.tools)) {
|
||||
result.tools = body.tools.map((tool) => {
|
||||
const toolData = tool.type === "function" && tool.function ? tool.function : tool;
|
||||
const originalName = toolData.name;
|
||||
result.tools = body.tools
|
||||
.map((tool) => {
|
||||
const toolData = tool.type === "function" && tool.function ? tool.function : tool;
|
||||
const originalName = typeof toolData.name === "string" ? toolData.name.trim() : "";
|
||||
|
||||
// Claude OAuth requires prefixed tool names to avoid conflicts
|
||||
// When prefix is disabled (non-Claude backends), use original name
|
||||
const toolName = disableToolPrefix ? originalName : CLAUDE_OAUTH_TOOL_PREFIX + originalName;
|
||||
if (!originalName) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Store mapping for response translation (prefixed → original)
|
||||
if (!disableToolPrefix) {
|
||||
toolNameMap.set(toolName, originalName);
|
||||
}
|
||||
// Claude OAuth requires prefixed tool names to avoid conflicts
|
||||
// When prefix is disabled (non-Claude backends), use original name
|
||||
const toolName = disableToolPrefix ? originalName : CLAUDE_OAUTH_TOOL_PREFIX + originalName;
|
||||
|
||||
// Normalize input_schema: Anthropic requires `properties` when type is "object" (#595).
|
||||
// MCP tools (e.g. pencil, computer_use) may omit properties on object-type schemas.
|
||||
const rawSchema: Record<string, unknown> = toolData.parameters ||
|
||||
toolData.input_schema || { type: "object", properties: {}, required: [] };
|
||||
const normalizedSchema =
|
||||
rawSchema.type === "object" && !rawSchema.properties
|
||||
? { ...rawSchema, properties: {} }
|
||||
: rawSchema;
|
||||
// Store mapping for response translation (prefixed → original)
|
||||
if (!disableToolPrefix) {
|
||||
toolNameMap.set(toolName, originalName);
|
||||
}
|
||||
|
||||
return {
|
||||
name: toolName,
|
||||
description: toolData.description || "",
|
||||
input_schema: normalizedSchema,
|
||||
};
|
||||
});
|
||||
// Normalize input_schema: Anthropic requires `properties` when type is "object" (#595).
|
||||
// MCP tools (e.g. pencil, computer_use) may omit properties on object-type schemas.
|
||||
const rawSchema: Record<string, unknown> = toolData.parameters ||
|
||||
toolData.input_schema || { type: "object", properties: {}, required: [] };
|
||||
const normalizedSchema =
|
||||
rawSchema.type === "object" && !rawSchema.properties
|
||||
? { ...rawSchema, properties: {} }
|
||||
: rawSchema;
|
||||
|
||||
return {
|
||||
name: toolName,
|
||||
description: toolData.description || "",
|
||||
input_schema: normalizedSchema,
|
||||
};
|
||||
})
|
||||
.filter((tool): tool is ClaudeTool => Boolean(tool));
|
||||
|
||||
// Filter out tools with empty names (would cause Claude 400 error)
|
||||
result.tools = result.tools.filter((tool) => tool.name && tool.name?.trim());
|
||||
@@ -311,6 +310,19 @@ export function openaiToClaudeRequest(model, body, stream) {
|
||||
}
|
||||
}
|
||||
|
||||
// System with Claude Code prompt and cache_control
|
||||
const claudeCodePrompt = { type: "text", text: CLAUDE_SYSTEM_PROMPT };
|
||||
|
||||
if (systemParts.length > 0) {
|
||||
const systemText = systemParts.join("\n");
|
||||
result.system = [
|
||||
claudeCodePrompt,
|
||||
{ type: "text", text: systemText, cache_control: { type: "ephemeral", ttl: "1h" } },
|
||||
];
|
||||
} else {
|
||||
result.system = [claudeCodePrompt];
|
||||
}
|
||||
|
||||
// Thinking configuration
|
||||
if (body.thinking) {
|
||||
result.thinking = {
|
||||
@@ -399,6 +411,11 @@ function getContentBlocksFromMessage(msg, toolNameMap = new Map(), disableToolPr
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: match[1], data: match[2] },
|
||||
});
|
||||
} else if (typeof url === "string" && url.trim()) {
|
||||
blocks.push({
|
||||
type: "image",
|
||||
source: { type: "url", url },
|
||||
});
|
||||
}
|
||||
} else if (part.type === "image" && part.source) {
|
||||
blocks.push({ type: "image", source: part.source });
|
||||
|
||||
@@ -90,7 +90,7 @@ function openaiToGeminiBase(model, body, stream) {
|
||||
model: model,
|
||||
contents: [],
|
||||
generationConfig: {},
|
||||
safetySettings: DEFAULT_SAFETY_SETTINGS,
|
||||
safetySettings: body.safetySettings || DEFAULT_SAFETY_SETTINGS,
|
||||
};
|
||||
|
||||
// Preserve cachedContent if provided by client (for explicit Gemini caching)
|
||||
@@ -108,8 +108,12 @@ function openaiToGeminiBase(model, body, stream) {
|
||||
if (body.top_k !== undefined) {
|
||||
result.generationConfig.topK = body.top_k;
|
||||
}
|
||||
if (body.max_tokens !== undefined) {
|
||||
result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, body.max_tokens);
|
||||
if (body.stop !== undefined) {
|
||||
result.generationConfig.stopSequences = Array.isArray(body.stop) ? body.stop : [body.stop];
|
||||
}
|
||||
const requestedMaxOutputTokens = body.max_tokens ?? body.max_completion_tokens;
|
||||
if (requestedMaxOutputTokens !== undefined) {
|
||||
result.generationConfig.maxOutputTokens = capMaxOutputTokens(model, requestedMaxOutputTokens);
|
||||
} else {
|
||||
result.generationConfig.maxOutputTokens = capMaxOutputTokens(model);
|
||||
}
|
||||
@@ -146,10 +150,17 @@ function openaiToGeminiBase(model, body, stream) {
|
||||
const content = msg.content;
|
||||
|
||||
if (role === "system" && body.messages.length > 1) {
|
||||
result.systemInstruction = {
|
||||
role: "user",
|
||||
parts: [{ text: typeof content === "string" ? content : extractTextContent(content) }],
|
||||
};
|
||||
const systemText = typeof content === "string" ? content : extractTextContent(content);
|
||||
if (systemText) {
|
||||
if (!result.systemInstruction) {
|
||||
result.systemInstruction = {
|
||||
role: "user",
|
||||
parts: [{ text: systemText }],
|
||||
};
|
||||
} else {
|
||||
result.systemInstruction.parts.push({ text: systemText });
|
||||
}
|
||||
}
|
||||
} else if (role === "user" || (role === "system" && body.messages.length === 1)) {
|
||||
const parts = convertOpenAIContentToParts(content);
|
||||
if (parts.length > 0) {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import { register } from "../registry.ts";
|
||||
import { FORMATS } from "../formats.ts";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { v4 as uuidv4, v5 as uuidv5 } from "uuid";
|
||||
|
||||
/**
|
||||
* Convert OpenAI messages to Kiro format
|
||||
@@ -252,7 +252,7 @@ function convertMessages(messages, tools, model) {
|
||||
export function buildKiroPayload(model, body, stream, credentials) {
|
||||
const messages = body.messages || [];
|
||||
const tools = body.tools || [];
|
||||
const maxTokens = 32000;
|
||||
const maxTokens = body.max_tokens ?? body.max_completion_tokens ?? 32000;
|
||||
const temperature = body.temperature;
|
||||
const topP = body.top_p;
|
||||
|
||||
@@ -310,7 +310,6 @@ export function buildKiroPayload(model, body, stream, credentials) {
|
||||
: finalContent;
|
||||
|
||||
// Use uuidv5 with the hash of the system prompt / first message to maintain AWS Builder ID context cache
|
||||
const { v5: uuidv5 } = require("uuid");
|
||||
payload.conversationState.conversationId = uuidv5(
|
||||
(firstContent || "").substring(0, 4000),
|
||||
NAMESPACE_KIRO
|
||||
|
||||
@@ -8,10 +8,50 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
|
||||
// Handle Antigravity wrapper
|
||||
const response = chunk.response || chunk;
|
||||
if (!response || !response.candidates?.[0]) return null;
|
||||
if (!response) return null;
|
||||
|
||||
const results = [];
|
||||
const candidate = response.candidates[0];
|
||||
const candidate = response.candidates?.[0];
|
||||
|
||||
if (!candidate) {
|
||||
const promptFeedback = response.promptFeedback || chunk.promptFeedback;
|
||||
if (!promptFeedback) return null;
|
||||
|
||||
if (!state.messageId) {
|
||||
state.messageId = response.responseId || `msg_${Date.now()}`;
|
||||
state.model = response.modelVersion || "gemini";
|
||||
results.push({
|
||||
id: `chatcmpl-${state.messageId}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: state.model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { role: "assistant" },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: `chatcmpl-${state.messageId}`,
|
||||
object: "chat.completion.chunk",
|
||||
created: Math.floor(Date.now() / 1000),
|
||||
model: state.model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: "content_filter",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
const content = candidate.content;
|
||||
|
||||
// Initialize state
|
||||
@@ -238,6 +278,8 @@ export function geminiToOpenAIResponse(chunk, state) {
|
||||
let finishReason = candidate.finishReason.toLowerCase();
|
||||
if (finishReason === "stop" && state.toolCalls.size > 0) {
|
||||
finishReason = "tool_calls";
|
||||
} else if (finishReason === "max_tokens") {
|
||||
finishReason = "length";
|
||||
}
|
||||
// Content blocked by Gemini safety filters — pass through as "content_filter"
|
||||
// so downstream clients can distinguish from normal completion.
|
||||
|
||||
@@ -20,7 +20,8 @@ import { formatSSE } from "./stream.ts";
|
||||
* @returns {object|null} Bypass response or null to proceed normally
|
||||
*/
|
||||
export function handleBypassRequest(body, model, userAgent = "") {
|
||||
if (!userAgent.includes("claude-cli")) return null;
|
||||
const normalizedUserAgent = typeof userAgent === "string" ? userAgent : "";
|
||||
if (!normalizedUserAgent.includes("claude-cli")) return null;
|
||||
if (!body.messages?.length) return null;
|
||||
|
||||
const messages = body.messages;
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.5.2",
|
||||
"version": "3.5.3",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "omniroute",
|
||||
"version": "3.5.2",
|
||||
"version": "3.5.3",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
|
||||
10
package.json
10
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "omniroute",
|
||||
"version": "3.5.2",
|
||||
"version": "3.5.3",
|
||||
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
|
||||
"type": "module",
|
||||
"bin": {
|
||||
@@ -72,10 +72,10 @@
|
||||
"test:protocols:e2e": "node scripts/run-protocol-clients-tests.mjs",
|
||||
"test:vitest": "vitest run --config vitest.mcp.config.ts",
|
||||
"test:ecosystem": "node scripts/run-ecosystem-tests.mjs",
|
||||
"test:coverage": "c8 --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 55 --lines 55 --functions 55 --branches 60 node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:coverage:legacy": "c8 --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"coverage:report": "c8 report --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov",
|
||||
"coverage:report:legacy": "c8 report --exclude=open-sse --reporter=text --reporter=text-summary",
|
||||
"test:coverage": "c8 --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov --check-coverage --statements 55 --lines 55 --functions 55 --branches 60 node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"test:coverage:legacy": "c8 --output-dir coverage --exclude=open-sse --check-coverage --lines 50 --functions 50 --branches 50 node --import tsx/esm --test tests/unit/*.test.mjs",
|
||||
"coverage:report": "c8 report --output-dir coverage --exclude=tests/** --exclude=**/*.test.* --reporter=text --reporter=text-summary --reporter=html --reporter=json-summary --reporter=lcov",
|
||||
"coverage:report:legacy": "c8 report --output-dir coverage --exclude=open-sse --reporter=text --reporter=text-summary",
|
||||
"test:all": "npm run test:unit && npm run test:vitest && npm run test:ecosystem && npm run test:e2e",
|
||||
"check": "npm run lint && npm run test",
|
||||
"prepublishOnly": "npm run build:cli",
|
||||
|
||||
@@ -3,18 +3,20 @@ import { defineConfig, devices } from "@playwright/test";
|
||||
const dashboardPort = process.env.DASHBOARD_PORT || process.env.PORT || "20128";
|
||||
const dashboardBaseUrl = `http://localhost:${dashboardPort}`;
|
||||
const webServerReadyUrl = `${dashboardBaseUrl}/api/monitoring/health`;
|
||||
const playwrightServerMode = process.env.OMNIROUTE_PLAYWRIGHT_SERVER_MODE || "start";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
testMatch: ["**/*.spec.ts"],
|
||||
fullyParallel: false,
|
||||
timeout: 120_000,
|
||||
timeout: 600_000,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI ? "github" : "html",
|
||||
use: {
|
||||
baseURL: dashboardBaseUrl,
|
||||
navigationTimeout: 300_000,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
@@ -25,11 +27,9 @@ export default defineConfig({
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: process.env.CI
|
||||
? "node scripts/run-next-playwright.mjs start"
|
||||
: "node scripts/run-next-playwright.mjs dev",
|
||||
command: `node scripts/run-next-playwright.mjs ${playwrightServerMode}`,
|
||||
url: webServerReadyUrl,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
timeout: 300_000,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync, renameSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
@@ -8,6 +9,7 @@ import {
|
||||
spawnWithForwardedSignals,
|
||||
withRuntimePortEnv,
|
||||
} from "./runtime-env.mjs";
|
||||
import { bootstrapEnv } from "./bootstrap-env.mjs";
|
||||
|
||||
const mode = process.argv[2] === "start" ? "start" : "dev";
|
||||
const cwd = process.cwd();
|
||||
@@ -15,9 +17,16 @@ const appDir = join(cwd, "app");
|
||||
const srcAppDir = join(cwd, "src", "app");
|
||||
const appPage = join(appDir, "page.tsx");
|
||||
const backupDir = join(cwd, "app.__qa_backup");
|
||||
const buildScript = join(cwd, "scripts", "build-next-isolated.mjs");
|
||||
const standaloneServer = join(cwd, testDistDir(), "standalone", "server.js");
|
||||
const buildIdFile = join(cwd, testDistDir(), "BUILD_ID");
|
||||
|
||||
let appDirMoved = false;
|
||||
|
||||
function testDistDir() {
|
||||
return process.env.NEXT_DIST_DIR || ".next";
|
||||
}
|
||||
|
||||
function shouldMoveAppDir() {
|
||||
return existsSync(appDir) && !existsSync(appPage) && existsSync(srcAppDir);
|
||||
}
|
||||
@@ -54,23 +63,93 @@ process.on("uncaughtException", (error) => {
|
||||
|
||||
prepareAppDir();
|
||||
|
||||
const runtimePorts = resolveRuntimePorts();
|
||||
const bootstrapEnvVars = bootstrapEnv({ quiet: true });
|
||||
const runtimePorts = resolveRuntimePorts(bootstrapEnvVars);
|
||||
const testServerEnv = {
|
||||
...sanitizeColorEnv(bootstrapEnvVars),
|
||||
...sanitizeColorEnv(process.env),
|
||||
NEXT_PUBLIC_OMNIROUTE_E2E_MODE: process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE || "1",
|
||||
OMNIROUTE_DISABLE_BACKGROUND_SERVICES: process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES || "1",
|
||||
OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK || "1",
|
||||
OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: process.env.OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK || "1",
|
||||
OMNIROUTE_HIDE_HEALTHCHECK_LOGS: process.env.OMNIROUTE_HIDE_HEALTHCHECK_LOGS || "1",
|
||||
};
|
||||
const args = [
|
||||
"./node_modules/next/dist/bin/next",
|
||||
mode,
|
||||
"--port",
|
||||
String(runtimePorts.dashboardPort),
|
||||
];
|
||||
if (mode === "dev") {
|
||||
args.splice(2, 0, "--webpack");
|
||||
|
||||
function runChild(command, args, env) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: "inherit",
|
||||
env,
|
||||
});
|
||||
|
||||
const forward = (signal) => {
|
||||
if (!child.killed) child.kill(signal);
|
||||
};
|
||||
|
||||
process.on("SIGINT", forward);
|
||||
process.on("SIGTERM", forward);
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
process.off("SIGINT", forward);
|
||||
process.off("SIGTERM", forward);
|
||||
resolve({ code: code ?? 1, signal: signal ?? null });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
spawnWithForwardedSignals(process.execPath, args, {
|
||||
stdio: "inherit",
|
||||
env: withRuntimePortEnv(testServerEnv, runtimePorts),
|
||||
});
|
||||
async function runBuildForStart() {
|
||||
if (mode !== "start") return;
|
||||
if (process.env.OMNIROUTE_PLAYWRIGHT_SKIP_BUILD === "1") return;
|
||||
if (existsSync(buildIdFile)) return;
|
||||
|
||||
const buildEnv = withRuntimePortEnv(testServerEnv, runtimePorts);
|
||||
const result = await runChild(process.execPath, [buildScript], buildEnv);
|
||||
|
||||
if (result.signal) {
|
||||
process.kill(process.pid, result.signal);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.code !== 0) {
|
||||
process.exit(result.code);
|
||||
}
|
||||
}
|
||||
|
||||
await runBuildForStart();
|
||||
if (mode === "start") {
|
||||
if (existsSync(standaloneServer)) {
|
||||
spawnWithForwardedSignals(process.execPath, [standaloneServer], {
|
||||
stdio: "inherit",
|
||||
env: {
|
||||
...withRuntimePortEnv(testServerEnv, runtimePorts),
|
||||
PORT: String(runtimePorts.dashboardPort),
|
||||
HOSTNAME: process.env.HOSTNAME || "127.0.0.1",
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const args = [
|
||||
"./node_modules/next/dist/bin/next",
|
||||
"start",
|
||||
"--port",
|
||||
String(runtimePorts.dashboardPort),
|
||||
];
|
||||
|
||||
spawnWithForwardedSignals(process.execPath, args, {
|
||||
stdio: "inherit",
|
||||
env: withRuntimePortEnv(testServerEnv, runtimePorts),
|
||||
});
|
||||
}
|
||||
} else {
|
||||
const args = [
|
||||
"./node_modules/next/dist/bin/next",
|
||||
mode,
|
||||
"--webpack",
|
||||
"--port",
|
||||
String(runtimePorts.dashboardPort),
|
||||
];
|
||||
|
||||
spawnWithForwardedSignals(process.execPath, args, {
|
||||
stdio: "inherit",
|
||||
env: withRuntimePortEnv(testServerEnv, runtimePorts),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -102,7 +102,7 @@ export default function MemorySkillsTab() {
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Card>
|
||||
<Card data-testid="memory-settings-card">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2 rounded-lg bg-violet-500/10 text-violet-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
@@ -148,6 +148,7 @@ export default function MemorySkillsTab() {
|
||||
<p className="text-xs text-text-muted mt-0.5">{t("memoryEnabledDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
data-testid="memory-enabled-switch"
|
||||
onClick={() => save({ enabled: !config.enabled })}
|
||||
disabled={saving}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
@@ -176,6 +177,7 @@ export default function MemorySkillsTab() {
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
data-testid="memory-max-tokens-slider"
|
||||
type="range"
|
||||
min="0"
|
||||
max="16000"
|
||||
@@ -201,6 +203,7 @@ export default function MemorySkillsTab() {
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
data-testid="memory-retention-slider"
|
||||
type="range"
|
||||
min="1"
|
||||
max="90"
|
||||
@@ -221,6 +224,7 @@ export default function MemorySkillsTab() {
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{STRATEGIES.map((s) => (
|
||||
<button
|
||||
data-testid={`memory-strategy-${s.value}`}
|
||||
key={s.value}
|
||||
onClick={() => save({ strategy: s.value as "recent" | "semantic" | "hybrid" })}
|
||||
disabled={loading || saving}
|
||||
@@ -244,7 +248,7 @@ export default function MemorySkillsTab() {
|
||||
</Card>
|
||||
|
||||
{/* Skills Settings (placeholder) */}
|
||||
<Card>
|
||||
<Card data-testid="skills-settings-card">
|
||||
<div className="flex items-center gap-3 mb-5">
|
||||
<div className="p-2 rounded-lg bg-amber-500/10 text-amber-500">
|
||||
<span className="material-symbols-outlined text-[20px]" aria-hidden="true">
|
||||
@@ -263,6 +267,7 @@ export default function MemorySkillsTab() {
|
||||
<p className="text-xs text-text-muted mt-0.5">{t("skillsEnabledDesc")}</p>
|
||||
</div>
|
||||
<button
|
||||
data-testid="skills-enabled-switch"
|
||||
onClick={() => save({ skillsEnabled: !config.skillsEnabled })}
|
||||
disabled={saving}
|
||||
className={`relative w-11 h-6 rounded-full transition-colors ${
|
||||
|
||||
@@ -137,7 +137,7 @@ export default function SkillsPage() {
|
||||
if (!res.ok) {
|
||||
setMpError(data.error || "Search failed");
|
||||
} else {
|
||||
setMpResults(data.skills || []);
|
||||
setMpResults(Array.isArray(data) ? data : data.skills || []);
|
||||
}
|
||||
} catch (err) {
|
||||
setMpError(err instanceof Error ? err.message : "Search failed");
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeyById } from "@/lib/localDb";
|
||||
import { isApiKeyRevealEnabled } from "@/lib/apiKeyExposure";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
// GET /api/keys/[id]/reveal - Reveal full API key for explicit copy actions
|
||||
export async function GET(_request, { params }) {
|
||||
export async function GET(request, { params }) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
if (!isApiKeyRevealEnabled()) {
|
||||
return NextResponse.json({ error: "API key reveal is disabled" }, { status: 403 });
|
||||
|
||||
@@ -9,9 +9,13 @@ import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { updateKeyPermissionsSchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
// GET /api/keys/[id] - Get single API key
|
||||
export async function GET(request, { params }) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
const key = await getApiKeyById(id);
|
||||
@@ -34,6 +38,9 @@ export async function GET(request, { params }) {
|
||||
|
||||
// PATCH /api/keys/[id] - Update API key permissions/privacy controls
|
||||
export async function PATCH(request, { params }) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
let rawBody;
|
||||
try {
|
||||
rawBody = await request.json();
|
||||
@@ -103,6 +110,9 @@ export async function PATCH(request, { params }) {
|
||||
|
||||
// DELETE /api/keys/[id] - Delete API key
|
||||
export async function DELETE(request, { params }) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const { id } = await params;
|
||||
|
||||
|
||||
@@ -1,20 +1,47 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { getApiKeys, createApiKey, isCloudEnabled } from "@/lib/localDb";
|
||||
import { getApiKeys, createApiKey, isCloudEnabled, updateApiKeyPermissions } from "@/lib/localDb";
|
||||
import { getConsistentMachineId } from "@/shared/utils/machineId";
|
||||
import { syncToCloud } from "@/lib/cloudSync";
|
||||
import { createKeySchema } from "@/shared/validation/schemas";
|
||||
import { isValidationFailure, validateBody } from "@/shared/validation/helpers";
|
||||
import { isApiKeyRevealEnabled, maskStoredApiKey } from "@/lib/apiKeyExposure";
|
||||
import { requireManagementAuth } from "@/lib/api/requireManagementAuth";
|
||||
|
||||
function parsePagination(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const limitValue = url.searchParams.get("limit");
|
||||
const offsetValue = url.searchParams.get("offset");
|
||||
|
||||
const parsedLimit = limitValue ? Number.parseInt(limitValue, 10) : undefined;
|
||||
const parsedOffset = offsetValue ? Number.parseInt(offsetValue, 10) : 0;
|
||||
|
||||
const limit =
|
||||
Number.isInteger(parsedLimit) && parsedLimit && parsedLimit > 0 ? parsedLimit : null;
|
||||
const offset = Number.isInteger(parsedOffset) && parsedOffset > 0 ? parsedOffset : 0;
|
||||
|
||||
return { limit, offset };
|
||||
}
|
||||
|
||||
// GET /api/keys - List API keys
|
||||
export async function GET() {
|
||||
export async function GET(request: Request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const keys = await getApiKeys();
|
||||
const maskedKeys = keys.map((k) => ({
|
||||
...k,
|
||||
key: maskStoredApiKey(k.key),
|
||||
}));
|
||||
return NextResponse.json({ keys: maskedKeys, allowKeyReveal: isApiKeyRevealEnabled() });
|
||||
const { limit, offset } = parsePagination(request);
|
||||
const pagedKeys =
|
||||
limit === null ? maskedKeys.slice(offset) : maskedKeys.slice(offset, offset + limit);
|
||||
|
||||
return NextResponse.json({
|
||||
keys: pagedKeys,
|
||||
total: maskedKeys.length,
|
||||
allowKeyReveal: isApiKeyRevealEnabled(),
|
||||
});
|
||||
} catch (error) {
|
||||
console.log("Error fetching keys:", error);
|
||||
return NextResponse.json({ error: "Failed to fetch keys" }, { status: 500 });
|
||||
@@ -23,6 +50,9 @@ export async function GET() {
|
||||
|
||||
// POST /api/keys - Create new API key
|
||||
export async function POST(request) {
|
||||
const authError = await requireManagementAuth(request);
|
||||
if (authError) return authError;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
@@ -31,11 +61,14 @@ export async function POST(request) {
|
||||
if (isValidationFailure(validation)) {
|
||||
return NextResponse.json({ error: validation.error }, { status: 400 });
|
||||
}
|
||||
const { name } = validation.data;
|
||||
const { name, noLog } = validation.data;
|
||||
|
||||
// Always get machineId from server
|
||||
const machineId = await getConsistentMachineId();
|
||||
const apiKey = await createApiKey(name, machineId);
|
||||
if (noLog === true) {
|
||||
await updateApiKeyPermissions(apiKey.id, { noLog: true });
|
||||
}
|
||||
|
||||
// Auto sync to Cloud if enabled
|
||||
await syncKeysToCloudIfEnabled();
|
||||
@@ -46,6 +79,7 @@ export async function POST(request) {
|
||||
name: apiKey.name,
|
||||
id: apiKey.id,
|
||||
machineId: apiKey.machineId,
|
||||
noLog: noLog === true,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
|
||||
@@ -20,6 +20,12 @@ function toHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
function isBackgroundServicesDisabled(): boolean {
|
||||
const raw = process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES;
|
||||
if (!raw) return false;
|
||||
return new Set(["1", "true", "yes", "on"]).has(raw.trim().toLowerCase());
|
||||
}
|
||||
|
||||
async function ensureSecrets(): Promise<void> {
|
||||
let getPersistedSecret = (_key: string): string | null => null;
|
||||
let persistSecret = (_key: string, _value: string): void => {};
|
||||
@@ -91,10 +97,12 @@ export async function registerNodejs(): Promise<void> {
|
||||
|
||||
initGracefulShutdown();
|
||||
initApiBridgeServer();
|
||||
startBackgroundRefresh();
|
||||
console.log("[STARTUP] Quota cache background refresh started");
|
||||
startProviderLimitsSyncScheduler();
|
||||
console.log("[STARTUP] Provider limits sync scheduler started");
|
||||
if (!isBackgroundServicesDisabled()) {
|
||||
startBackgroundRefresh();
|
||||
console.log("[STARTUP] Quota cache background refresh started");
|
||||
startProviderLimitsSyncScheduler();
|
||||
console.log("[STARTUP] Provider limits sync scheduler started");
|
||||
}
|
||||
|
||||
try {
|
||||
const [{ setCustomAliases }, { setDefaultFastServiceTierEnabled }] = await Promise.all([
|
||||
|
||||
@@ -10,9 +10,13 @@ export async function requireManagementAuth(request: Request): Promise<Response
|
||||
return null;
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get("authorization");
|
||||
const hasBearerToken =
|
||||
typeof authHeader === "string" && authHeader.trim().toLowerCase().startsWith("bearer ");
|
||||
|
||||
return createErrorResponse({
|
||||
status: 401,
|
||||
message: "Authentication required",
|
||||
status: hasBearerToken ? 403 : 401,
|
||||
message: hasBearerToken ? "Invalid management token" : "Authentication required",
|
||||
type: "invalid_request",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,16 @@ import "@/lib/tokenHealthCheck"; // Proactive token health-check scheduler
|
||||
// Initialize background sync services when this module is imported
|
||||
let initialized = false;
|
||||
|
||||
function isBackgroundServicesDisabled(): boolean {
|
||||
const raw = process.env.OMNIROUTE_DISABLE_BACKGROUND_SERVICES;
|
||||
if (!raw) return false;
|
||||
return new Set(["1", "true", "yes", "on"]).has(raw.trim().toLowerCase());
|
||||
}
|
||||
|
||||
export async function ensureCloudSyncInitialized() {
|
||||
if (isBackgroundServicesDisabled()) {
|
||||
return false;
|
||||
}
|
||||
if (!initialized) {
|
||||
try {
|
||||
await initializeCloudSync();
|
||||
|
||||
@@ -31,6 +31,7 @@ const BACKOFF_SCHEDULE = [30_000, 60_000, 120_000, 300_000];
|
||||
const CHECK_TIMEOUT_MS = 5_000;
|
||||
const INITIAL_DELAY_MS = 15_000; // Wait for server boot before first sweep
|
||||
const LOG_PREFIX = "[LocalHealthCheck]";
|
||||
const TRUE_ENV_VALUES = new Set(["1", "true", "yes", "on"]);
|
||||
|
||||
// ── State (globalThis survives HMR re-evaluation) ───────────────────────
|
||||
|
||||
@@ -61,6 +62,16 @@ const healthCache = getLHCState().healthCache;
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
function isEnvFlagEnabled(name: string): boolean {
|
||||
const value = process.env[name];
|
||||
if (!value) return false;
|
||||
return TRUE_ENV_VALUES.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function isLocalHealthCheckDisabled(): boolean {
|
||||
return isEnvFlagEnabled("OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK") || process.env.NODE_ENV === "test";
|
||||
}
|
||||
|
||||
function isLocalhostUrl(baseUrl: string): boolean {
|
||||
try {
|
||||
const u = new URL(baseUrl);
|
||||
@@ -212,7 +223,7 @@ export function getAllHealthStatuses(): Record<string, HealthStatus> {
|
||||
/** Start the health check scheduler (idempotent). */
|
||||
export function initLocalHealthCheck(): void {
|
||||
const state = getLHCState();
|
||||
if (state.initialized) return;
|
||||
if (state.initialized || isLocalHealthCheckDisabled()) return;
|
||||
state.initialized = true;
|
||||
|
||||
console.log(
|
||||
|
||||
@@ -1,42 +1,50 @@
|
||||
import { getDbInstance } from "../db/core";
|
||||
|
||||
interface MemoryCache {
|
||||
key: string;
|
||||
value: any;
|
||||
timestamp: number;
|
||||
ttl: number;
|
||||
value: unknown;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
class MemoryCachingLayer {
|
||||
private cache: Map<string, MemoryCache> = new Map();
|
||||
private maxSize: number = 1000;
|
||||
private defaultTtl: number = 300000;
|
||||
private hits: number = 0;
|
||||
private misses: number = 0;
|
||||
|
||||
async get(key: string): Promise<any | null> {
|
||||
async get(key: string): Promise<unknown | null> {
|
||||
const entry = this.cache.get(key);
|
||||
if (!entry) return null;
|
||||
|
||||
if (Date.now() - entry.timestamp > entry.ttl) {
|
||||
this.cache.delete(key);
|
||||
if (!entry) {
|
||||
this.misses += 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() > entry.expiresAt) {
|
||||
this.cache.delete(key);
|
||||
this.misses += 1;
|
||||
return null;
|
||||
}
|
||||
|
||||
this.hits += 1;
|
||||
this.cache.delete(key);
|
||||
this.cache.set(key, entry);
|
||||
return entry.value;
|
||||
}
|
||||
|
||||
async set(key: string, value: any, ttl?: number): Promise<void> {
|
||||
if (this.cache.size >= this.maxSize) {
|
||||
const oldest = Array.from(this.cache.entries()).sort(
|
||||
(a, b) => a[1].timestamp - b[1].timestamp
|
||||
)[0];
|
||||
this.cache.delete(oldest[0]);
|
||||
async set(key: string, value: unknown, ttl?: number): Promise<void> {
|
||||
if (this.cache.has(key)) {
|
||||
this.cache.delete(key);
|
||||
} else if (this.cache.size >= this.maxSize) {
|
||||
const oldestKey = this.cache.keys().next().value;
|
||||
if (oldestKey) {
|
||||
this.cache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
this.cache.set(key, {
|
||||
key,
|
||||
value,
|
||||
timestamp: Date.now(),
|
||||
ttl: ttl || this.defaultTtl,
|
||||
expiresAt: now + (ttl ?? this.defaultTtl),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,12 +59,16 @@ class MemoryCachingLayer {
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this.cache.clear();
|
||||
this.hits = 0;
|
||||
this.misses = 0;
|
||||
}
|
||||
|
||||
stats() {
|
||||
return {
|
||||
size: this.cache.size,
|
||||
maxSize: this.maxSize,
|
||||
hits: this.hits,
|
||||
misses: this.misses,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,29 @@ import { getDbInstance } from "../db/core";
|
||||
import { Memory, MemoryConfig, MemoryType } from "./types";
|
||||
import { MemoryConfigSchema } from "./schemas";
|
||||
|
||||
interface MemoryRow {
|
||||
id: string;
|
||||
api_key_id?: string;
|
||||
apiKeyId?: string;
|
||||
session_id?: string | null;
|
||||
sessionId?: string | null;
|
||||
type: MemoryType;
|
||||
key?: string | null;
|
||||
content: string;
|
||||
metadata?: string | null;
|
||||
created_at?: string;
|
||||
createdAt?: string;
|
||||
updated_at?: string;
|
||||
updatedAt?: string;
|
||||
expires_at?: string | null;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
interface RetrievalOptions extends Partial<MemoryConfig> {
|
||||
query?: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple token estimation function (roughly 1 token per 4 characters)
|
||||
*/
|
||||
@@ -10,12 +33,82 @@ export function estimateTokens(text: string): number {
|
||||
return Math.ceil(text.length / 4);
|
||||
}
|
||||
|
||||
function hasTable(tableName: string): boolean {
|
||||
const db = getDbInstance();
|
||||
const row = db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get(tableName) as { name?: string } | undefined;
|
||||
return row?.name === tableName;
|
||||
}
|
||||
|
||||
function parseMetadata(raw: unknown): Record<string, unknown> {
|
||||
if (!raw || typeof raw !== "string") return {};
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function rowToMemory(row: MemoryRow): Memory {
|
||||
const createdAt = row.created_at || row.createdAt || new Date().toISOString();
|
||||
const updatedAt = row.updated_at || row.updatedAt || createdAt;
|
||||
const expiresAt = row.expires_at ?? row.expiresAt ?? null;
|
||||
|
||||
return {
|
||||
id: String(row.id),
|
||||
apiKeyId: String(row.api_key_id || row.apiKeyId || ""),
|
||||
sessionId: String(row.session_id ?? row.sessionId ?? ""),
|
||||
type: row.type as MemoryType,
|
||||
key: String(row.key || ""),
|
||||
content: String(row.content || ""),
|
||||
metadata: parseMetadata(row.metadata),
|
||||
createdAt: new Date(createdAt),
|
||||
updatedAt: new Date(updatedAt),
|
||||
expiresAt: expiresAt ? new Date(String(expiresAt)) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function getRelevanceScore(memory: Memory, query: string): number {
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
if (!normalizedQuery) return 0;
|
||||
|
||||
const haystacks = [
|
||||
memory.content.toLowerCase(),
|
||||
memory.key.toLowerCase(),
|
||||
JSON.stringify(memory.metadata).toLowerCase(),
|
||||
];
|
||||
const tokens = normalizedQuery.split(/\s+/).filter(Boolean);
|
||||
|
||||
let score = 0;
|
||||
for (const haystack of haystacks) {
|
||||
if (haystack.includes(normalizedQuery)) {
|
||||
score += 20;
|
||||
}
|
||||
|
||||
for (const token of tokens) {
|
||||
if (!token) continue;
|
||||
|
||||
if (haystack === memory.key.toLowerCase() && haystack.includes(token)) {
|
||||
score += 6;
|
||||
continue;
|
||||
}
|
||||
|
||||
const matches = haystack.match(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"));
|
||||
score += (matches?.length || 0) * 3;
|
||||
}
|
||||
}
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve memories with token budget enforcement
|
||||
*/
|
||||
export async function retrieveMemories(
|
||||
apiKeyId: string,
|
||||
config: Partial<MemoryConfig> = {}
|
||||
config: RetrievalOptions = {}
|
||||
): Promise<Memory[]> {
|
||||
// Validate and normalize config
|
||||
const normalizedConfig = MemoryConfigSchema.parse({
|
||||
@@ -33,23 +126,45 @@ export async function retrieveMemories(
|
||||
return [];
|
||||
}
|
||||
|
||||
const maxTokens = Math.min(Math.max(normalizedConfig.maxTokens, 100), 8000);
|
||||
const maxTokens = Math.min(Math.max(normalizedConfig.maxTokens, 1), 8000);
|
||||
const strategy = normalizedConfig.retrievalStrategy;
|
||||
|
||||
const db = getDbInstance();
|
||||
const memories: Memory[] = [];
|
||||
const memories: Array<{ memory: Memory; score: number }> = [];
|
||||
let totalTokens = 0;
|
||||
|
||||
const useModernTable = hasTable("memories");
|
||||
const tableName = useModernTable ? "memories" : "memory";
|
||||
const columns = useModernTable
|
||||
? {
|
||||
apiKeyId: "api_key_id",
|
||||
sessionId: "session_id",
|
||||
createdAt: "created_at",
|
||||
expiresAt: "expires_at",
|
||||
}
|
||||
: {
|
||||
apiKeyId: "apiKeyId",
|
||||
sessionId: "sessionId",
|
||||
createdAt: "createdAt",
|
||||
expiresAt: "expiresAt",
|
||||
};
|
||||
|
||||
// Build base query
|
||||
let query =
|
||||
"SELECT * FROM memory WHERE apiKeyId = ? AND (expiresAt IS NULL OR datetime(expiresAt) > datetime('now'))";
|
||||
`SELECT * FROM ${tableName} WHERE ${columns.apiKeyId} = ? ` +
|
||||
`AND (${columns.expiresAt} IS NULL OR datetime(${columns.expiresAt}) > datetime('now'))`;
|
||||
const params: any[] = [apiKeyId];
|
||||
|
||||
if (normalizedConfig.scope === "session" && config.sessionId) {
|
||||
query += ` AND ${columns.sessionId} = ?`;
|
||||
params.push(config.sessionId);
|
||||
}
|
||||
|
||||
if (normalizedConfig.retentionDays > 0) {
|
||||
const cutoff = new Date(
|
||||
Date.now() - normalizedConfig.retentionDays * 24 * 60 * 60 * 1000
|
||||
).toISOString();
|
||||
query += " AND datetime(createdAt) >= datetime(?)";
|
||||
query += ` AND datetime(${columns.createdAt}) >= datetime(?)`;
|
||||
params.push(cutoff);
|
||||
}
|
||||
|
||||
@@ -57,15 +172,15 @@ export async function retrieveMemories(
|
||||
switch (strategy) {
|
||||
case "semantic":
|
||||
// For now, semantic search is same as exact (FTS5 not implemented yet)
|
||||
query += " ORDER BY createdAt DESC";
|
||||
query += ` ORDER BY ${columns.createdAt} DESC`;
|
||||
break;
|
||||
case "hybrid":
|
||||
// Hybrid is same as exact for now
|
||||
query += " ORDER BY createdAt DESC";
|
||||
query += ` ORDER BY ${columns.createdAt} DESC`;
|
||||
break;
|
||||
case "exact":
|
||||
default:
|
||||
query += " ORDER BY createdAt DESC";
|
||||
query += ` ORDER BY ${columns.createdAt} DESC`;
|
||||
}
|
||||
|
||||
// Add limit for performance
|
||||
@@ -73,29 +188,23 @@ export async function retrieveMemories(
|
||||
|
||||
// Execute query
|
||||
const stmt = db.prepare(query);
|
||||
const rows = stmt.all(...params);
|
||||
const rows = stmt.all(...params) as MemoryRow[];
|
||||
|
||||
const rankedRows = rows
|
||||
.map((row) => {
|
||||
const memory = rowToMemory(row);
|
||||
const score = config.query ? getRelevanceScore(memory, config.query) : 0;
|
||||
return { memory, score };
|
||||
})
|
||||
.filter((entry) => !config.query || entry.score > 0)
|
||||
.sort((a, b) => {
|
||||
if (b.score !== a.score) return b.score - a.score;
|
||||
return b.memory.createdAt.getTime() - a.memory.createdAt.getTime();
|
||||
});
|
||||
|
||||
// Process memories until budget exceeded
|
||||
for (const row of rows) {
|
||||
const memory: Memory = {
|
||||
id: String((row as any).id),
|
||||
apiKeyId: String((row as any).apiKeyId),
|
||||
sessionId: String((row as any).sessionId),
|
||||
type: (row as any).type as MemoryType,
|
||||
key: String((row as any).key),
|
||||
content: String((row as any).content),
|
||||
metadata: (() => {
|
||||
try {
|
||||
return JSON.parse(String((row as any).metadata));
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
})(),
|
||||
createdAt: new Date(String((row as any).createdAt)),
|
||||
updatedAt: new Date(String((row as any).updatedAt)),
|
||||
expiresAt: (row as any).expiresAt ? new Date(String((row as any).expiresAt)) : null,
|
||||
};
|
||||
|
||||
for (const entry of rankedRows) {
|
||||
const memory = entry.memory;
|
||||
// Estimate tokens for this memory
|
||||
const memoryTokens = estimateTokens(memory.content);
|
||||
|
||||
@@ -103,16 +212,16 @@ export async function retrieveMemories(
|
||||
if (totalTokens + memoryTokens > maxTokens) {
|
||||
// If we haven't added any memories yet, add this one anyway
|
||||
if (memories.length === 0) {
|
||||
memories.push(memory);
|
||||
memories.push(entry);
|
||||
totalTokens += memoryTokens;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
// Add memory to results
|
||||
memories.push(memory);
|
||||
memories.push(entry);
|
||||
totalTokens += memoryTokens;
|
||||
}
|
||||
|
||||
return memories;
|
||||
return memories.map((entry) => entry.memory);
|
||||
}
|
||||
|
||||
@@ -2,13 +2,27 @@
|
||||
* Memory store - CRUD operations with prepared statements and caching
|
||||
*/
|
||||
|
||||
import { getDbInstance, rowToCamel } from "../db/core";
|
||||
import { getDbInstance } from "../db/core";
|
||||
import { Memory, MemoryType } from "./types";
|
||||
|
||||
interface CacheEntry<T> {
|
||||
value: T;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface MemoryRow {
|
||||
id: string;
|
||||
api_key_id: string;
|
||||
session_id: string | null;
|
||||
type: MemoryType;
|
||||
key: string | null;
|
||||
content: string;
|
||||
metadata: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
expires_at: string | null;
|
||||
}
|
||||
|
||||
// Memory cache configuration
|
||||
const MEMORY_CACHE_TTL = 300_000; // 5 minutes
|
||||
const MEMORY_MAX_CACHE_SIZE = 10_000;
|
||||
@@ -29,14 +43,10 @@ function parseJSON(value: unknown): Record<string, unknown> {
|
||||
}
|
||||
}
|
||||
|
||||
// Cache invalidation strategy
|
||||
function invalidateMemoryCache(key: string) {
|
||||
_memoryCache.delete(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Memory cache management with size control
|
||||
*/
|
||||
function evictIfNeeded<TKey, TValue>(cache: Map<TKey, TValue>) {
|
||||
if (cache.size > MEMORY_MAX_CACHE_SIZE) {
|
||||
// Remove oldest entries first
|
||||
@@ -48,57 +58,19 @@ function evictIfNeeded<TKey, TValue>(cache: Map<TKey, TValue>) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or compile regex for wildcard pattern
|
||||
*/
|
||||
function getWildcardRegex(pattern: string): RegExp {
|
||||
// This function is copied from apiKeys.ts pattern
|
||||
let regex = _regexCache.get(pattern);
|
||||
if (!regex) {
|
||||
const regexStr = pattern.replace(/\*/g, ".*");
|
||||
regex = new RegExp(`^${regexStr}$`);
|
||||
_regexCache.set(pattern, regex);
|
||||
// Prevent unbounded growth
|
||||
if (_regexCache.size > 100) {
|
||||
const firstKey = _regexCache.keys().next().value;
|
||||
if (firstKey) _regexCache.delete(firstKey);
|
||||
}
|
||||
}
|
||||
return regex;
|
||||
}
|
||||
|
||||
// Compiled regex cache for wildcard patterns
|
||||
const _regexCache = new Map<string, RegExp>();
|
||||
|
||||
// Cache for memory validation (similar to apiKeys)
|
||||
const _memoryValidationCache = new Map<string, { exists: boolean; timestamp: number }>();
|
||||
const MEMORY_VALIDATION_CACHE_TTL = 60 * 1000; // 1 minute TTL
|
||||
|
||||
/**
|
||||
* Check if memory exists with caching
|
||||
*/
|
||||
async function memoryExists(id: string): Promise<boolean> {
|
||||
if (!id || typeof id !== "string") return false;
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
// Check cache first
|
||||
const cached = _memoryValidationCache.get(id);
|
||||
if (cached && now - cached.timestamp < MEMORY_VALIDATION_CACHE_TTL) {
|
||||
return cached.exists;
|
||||
}
|
||||
|
||||
const db = getDbInstance();
|
||||
const stmt = db.prepare("SELECT 1 FROM memory WHERE id = ?");
|
||||
const row = stmt.get(id);
|
||||
const exists = !!row;
|
||||
|
||||
// Cache the result to prevent cache pollution
|
||||
if (exists) {
|
||||
_memoryValidationCache.set(id, { exists: true, timestamp: now });
|
||||
}
|
||||
|
||||
return exists;
|
||||
function rowToMemory(row: MemoryRow): Memory {
|
||||
return {
|
||||
id: String(row.id),
|
||||
apiKeyId: String(row.api_key_id),
|
||||
sessionId: typeof row.session_id === "string" ? row.session_id : "",
|
||||
type: row.type as MemoryType,
|
||||
key: typeof row.key === "string" ? row.key : "",
|
||||
content: String(row.content),
|
||||
metadata: parseJSON(row.metadata),
|
||||
createdAt: new Date(String(row.created_at)),
|
||||
updatedAt: new Date(String(row.updated_at)),
|
||||
expiresAt: row.expires_at ? new Date(String(row.expires_at)) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -112,7 +84,7 @@ export async function createMemory(
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const stmt = db.prepare(
|
||||
"INSERT INTO memory (id, apiKeyId, sessionId, type, key, content, metadata, createdAt, updatedAt, expiresAt) " +
|
||||
"INSERT INTO memories (id, api_key_id, session_id, type, key, content, metadata, created_at, updated_at, expires_at) " +
|
||||
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
@@ -123,7 +95,7 @@ export async function createMemory(
|
||||
memory.type,
|
||||
memory.key,
|
||||
memory.content,
|
||||
JSON.stringify(memory.metadata),
|
||||
JSON.stringify(memory.metadata ?? {}),
|
||||
now,
|
||||
now,
|
||||
memory.expiresAt?.toISOString() ?? null
|
||||
@@ -163,8 +135,8 @@ export async function getMemory(id: string): Promise<Memory | null> {
|
||||
}
|
||||
|
||||
const db = getDbInstance();
|
||||
const stmt = db.prepare("SELECT * FROM memory WHERE id = ?");
|
||||
const row = stmt.get(id) as any;
|
||||
const stmt = db.prepare("SELECT * FROM memories WHERE id = ?");
|
||||
const row = stmt.get(id) as MemoryRow | undefined;
|
||||
|
||||
if (!row) {
|
||||
// Cache negative result briefly to prevent repeated DB hits
|
||||
@@ -173,18 +145,7 @@ export async function getMemory(id: string): Promise<Memory | null> {
|
||||
return null;
|
||||
}
|
||||
|
||||
const memory: Memory = {
|
||||
id: String(row.id),
|
||||
apiKeyId: String(row.apiKeyId),
|
||||
sessionId: String(row.sessionId),
|
||||
type: row.type as MemoryType,
|
||||
key: String(row.key),
|
||||
content: String(row.content),
|
||||
metadata: parseJSON(row.metadata),
|
||||
createdAt: new Date(String(row.createdAt)),
|
||||
updatedAt: new Date(String(row.updatedAt)),
|
||||
expiresAt: row.expiresAt ? new Date(String(row.expiresAt)) : null,
|
||||
};
|
||||
const memory = rowToMemory(row);
|
||||
|
||||
// Cache the result
|
||||
evictIfNeeded(_memoryCache);
|
||||
@@ -207,7 +168,7 @@ export async function updateMemory(
|
||||
|
||||
// Build dynamic update query
|
||||
const fields: string[] = [];
|
||||
const values: any[] = [];
|
||||
const values: unknown[] = [];
|
||||
|
||||
if (updates.type !== undefined) {
|
||||
fields.push("type = ?");
|
||||
@@ -226,21 +187,17 @@ export async function updateMemory(
|
||||
values.push(JSON.stringify(updates.metadata));
|
||||
}
|
||||
if (updates.expiresAt !== undefined) {
|
||||
fields.push("expiresAt = ?");
|
||||
fields.push("expires_at = ?");
|
||||
values.push(updates.expiresAt?.toISOString() ?? null);
|
||||
}
|
||||
|
||||
// Always update the updatedAt timestamp
|
||||
fields.push("updatedAt = ?");
|
||||
fields.push("updated_at = ?");
|
||||
values.push(now);
|
||||
|
||||
if (fields.length === 0) {
|
||||
return false; // No updates to apply
|
||||
}
|
||||
|
||||
values.push(id); // For WHERE clause
|
||||
|
||||
const stmt = db.prepare(`UPDATE memory SET ${fields.join(", ")} WHERE id = ?`);
|
||||
const stmt = db.prepare(`UPDATE memories SET ${fields.join(", ")} WHERE id = ?`);
|
||||
|
||||
const result = stmt.run(...values);
|
||||
|
||||
@@ -261,7 +218,7 @@ export async function deleteMemory(id: string): Promise<boolean> {
|
||||
if (!id || typeof id !== "string") return false;
|
||||
|
||||
const db = getDbInstance();
|
||||
const stmt = db.prepare("DELETE FROM memory WHERE id = ?");
|
||||
const stmt = db.prepare("DELETE FROM memories WHERE id = ?");
|
||||
const result = stmt.run(id);
|
||||
|
||||
if (result.changes === 0) {
|
||||
@@ -287,12 +244,12 @@ export async function listMemories(filters: {
|
||||
const db = getDbInstance();
|
||||
|
||||
// Build dynamic query
|
||||
let query = "SELECT * FROM memory";
|
||||
const params: any[] = [];
|
||||
let query = "SELECT * FROM memories";
|
||||
const params: unknown[] = [];
|
||||
const whereClauses: string[] = [];
|
||||
|
||||
if (filters.apiKeyId) {
|
||||
whereClauses.push("apiKeyId = ?");
|
||||
whereClauses.push("api_key_id = ?");
|
||||
params.push(filters.apiKeyId);
|
||||
}
|
||||
|
||||
@@ -302,7 +259,7 @@ export async function listMemories(filters: {
|
||||
}
|
||||
|
||||
if (filters.sessionId) {
|
||||
whereClauses.push("sessionId = ?");
|
||||
whereClauses.push("session_id = ?");
|
||||
params.push(filters.sessionId);
|
||||
}
|
||||
|
||||
@@ -311,7 +268,7 @@ export async function listMemories(filters: {
|
||||
}
|
||||
|
||||
// Add ordering and pagination
|
||||
query += " ORDER BY createdAt DESC";
|
||||
query += " ORDER BY created_at DESC";
|
||||
|
||||
if (filters.limit !== undefined) {
|
||||
query += " LIMIT ?";
|
||||
@@ -319,6 +276,9 @@ export async function listMemories(filters: {
|
||||
}
|
||||
|
||||
if (filters.offset !== undefined) {
|
||||
if (filters.limit === undefined) {
|
||||
query += " LIMIT -1";
|
||||
}
|
||||
query += " OFFSET ?";
|
||||
params.push(filters.offset);
|
||||
}
|
||||
@@ -326,16 +286,5 @@ export async function listMemories(filters: {
|
||||
const stmt = db.prepare(query);
|
||||
const rows = stmt.all(...params);
|
||||
|
||||
return (rows as any[]).map((row: any) => ({
|
||||
id: String(row.id),
|
||||
apiKeyId: String(row.apiKeyId),
|
||||
sessionId: String(row.sessionId),
|
||||
type: row.type as MemoryType,
|
||||
key: String(row.key),
|
||||
content: String(row.content),
|
||||
metadata: parseJSON(row.metadata),
|
||||
createdAt: new Date(String(row.createdAt)),
|
||||
updatedAt: new Date(String(row.updatedAt)),
|
||||
expiresAt: row.expiresAt ? new Date(String(row.expiresAt)) : null,
|
||||
}));
|
||||
return (rows as MemoryRow[]).map(rowToMemory);
|
||||
}
|
||||
|
||||
@@ -91,7 +91,10 @@ function estimateTokens(text: string): number {
|
||||
}
|
||||
|
||||
function generateSummary(content: string): string {
|
||||
const sentences = content.split(/[.!?]+/).filter((s) => s.trim().length > 0);
|
||||
const sentences = content
|
||||
.split(/[.!?]+/)
|
||||
.map((sentence) => sentence.trim())
|
||||
.filter((sentence) => sentence.length > 0);
|
||||
if (sentences.length <= 3) {
|
||||
return content;
|
||||
}
|
||||
|
||||
@@ -235,6 +235,7 @@ export const PROVIDERS = {
|
||||
QWEN: "qwen",
|
||||
QODER: "qoder",
|
||||
ANTIGRAVITY: "antigravity",
|
||||
KIMI_CODING: "kimi-coding",
|
||||
OPENAI: "openai",
|
||||
GITHUB: "github",
|
||||
KIRO: "kiro",
|
||||
|
||||
@@ -31,9 +31,15 @@ export async function interceptToolCalls(
|
||||
sessionId: context.sessionId,
|
||||
});
|
||||
|
||||
const result =
|
||||
execution.output ??
|
||||
(execution.errorMessage
|
||||
? { error: execution.errorMessage }
|
||||
: { error: "Skill execution returned no output" });
|
||||
|
||||
return {
|
||||
id: call.id,
|
||||
result: execution.output,
|
||||
result,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
@@ -51,12 +57,21 @@ export function extractToolCalls(response: any, modelId: string): ToolCall[] {
|
||||
const provider = detectProvider(modelId);
|
||||
|
||||
switch (provider) {
|
||||
case "openai":
|
||||
return (response.tool_calls || []).map((tc: any) => ({
|
||||
case "openai": {
|
||||
const rootToolCalls = Array.isArray(response?.tool_calls) ? response.tool_calls : [];
|
||||
const choiceToolCalls = Array.isArray(response?.choices)
|
||||
? response.choices.flatMap((choice: any) =>
|
||||
Array.isArray(choice?.message?.tool_calls) ? choice.message.tool_calls : []
|
||||
)
|
||||
: [];
|
||||
const toolCalls = rootToolCalls.length > 0 ? rootToolCalls : choiceToolCalls;
|
||||
|
||||
return toolCalls.map((tc: any) => ({
|
||||
id: tc.id || `call_${Date.now()}`,
|
||||
name: tc.function?.name || "",
|
||||
arguments: parseArguments(tc.function?.arguments || "{}"),
|
||||
}));
|
||||
}
|
||||
|
||||
case "anthropic":
|
||||
return (response.content || [])
|
||||
|
||||
@@ -7,14 +7,88 @@
|
||||
* @module middleware/promptInjectionGuard
|
||||
*/
|
||||
|
||||
import { sanitizeRequest } from "../shared/utils/inputSanitizer";
|
||||
import { extractMessageContents, sanitizeRequest } from "../shared/utils/inputSanitizer";
|
||||
|
||||
/**
|
||||
* @typedef {Object} GuardOptions
|
||||
* @property {"block"|"warn"|"log"} [mode="warn"] - Action on detection
|
||||
* @property {boolean} [enabled=true] - Whether the guard is active
|
||||
* @property {"low"|"medium"|"high"} [blockThreshold="high"] - Minimum severity to block
|
||||
* @property {Array<string|RegExp|{name?: string, pattern: string|RegExp, severity?: "low"|"medium"|"high"}>} [customPatterns]
|
||||
* @property {Object} [logger] - Logger instance (defaults to console)
|
||||
*/
|
||||
|
||||
const DEFAULT_GUARD_PATTERNS = [
|
||||
{
|
||||
name: "system_override_inline",
|
||||
pattern: /\bsystem\s*:\s*override\b/i,
|
||||
severity: "high",
|
||||
},
|
||||
{
|
||||
name: "markdown_system_block",
|
||||
pattern: /```+\s*system\b/i,
|
||||
severity: "high",
|
||||
},
|
||||
];
|
||||
|
||||
const SEVERITY_SCORES = {
|
||||
low: 1,
|
||||
medium: 2,
|
||||
high: 3,
|
||||
};
|
||||
|
||||
function normalizePatternEntry(entry: any, index: number) {
|
||||
if (entry instanceof RegExp) {
|
||||
return {
|
||||
name: `custom_${index}`,
|
||||
pattern: entry,
|
||||
severity: "high",
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof entry === "string") {
|
||||
return {
|
||||
name: `custom_${index}`,
|
||||
pattern: new RegExp(entry, "i"),
|
||||
severity: "high",
|
||||
};
|
||||
}
|
||||
|
||||
if (!entry || (!(entry.pattern instanceof RegExp) && typeof entry.pattern !== "string")) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
name: entry.name || `custom_${index}`,
|
||||
pattern: entry.pattern instanceof RegExp ? entry.pattern : new RegExp(entry.pattern, "i"),
|
||||
severity: entry.severity || "high",
|
||||
};
|
||||
}
|
||||
|
||||
function detectWithPatterns(text: string, patterns: any[]) {
|
||||
const detections = [];
|
||||
|
||||
for (const rule of patterns) {
|
||||
const match = text.match(rule.pattern);
|
||||
if (match) {
|
||||
detections.push({
|
||||
pattern: rule.name,
|
||||
severity: rule.severity,
|
||||
match: match[0].slice(0, 50),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return detections;
|
||||
}
|
||||
|
||||
function shouldBlock(detections: any[], threshold: string) {
|
||||
const minimumSeverity = SEVERITY_SCORES[threshold as keyof typeof SEVERITY_SCORES] || 3;
|
||||
return detections.some(
|
||||
(d) => (SEVERITY_SCORES[d.severity as keyof typeof SEVERITY_SCORES] || 0) >= minimumSeverity
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a prompt injection guard middleware.
|
||||
*
|
||||
@@ -22,8 +96,14 @@ import { sanitizeRequest } from "../shared/utils/inputSanitizer";
|
||||
* @returns {(req: Request) => { blocked: boolean, result: Object }|null}
|
||||
*/
|
||||
export function createInjectionGuard(options: any = {}) {
|
||||
const mode = options.mode || process.env.INJECTION_GUARD_MODE || "warn";
|
||||
const mode =
|
||||
options.mode || process.env.INJECTION_GUARD_MODE || process.env.INPUT_SANITIZER_MODE || "warn";
|
||||
const enabled = options.enabled ?? process.env.INPUT_SANITIZER_ENABLED !== "false";
|
||||
const blockThreshold = options.blockThreshold || options.threshold || "high";
|
||||
const logger = options.logger || console;
|
||||
const customPatterns = [...DEFAULT_GUARD_PATTERNS, ...(options.customPatterns || [])]
|
||||
.map(normalizePatternEntry)
|
||||
.filter(Boolean);
|
||||
|
||||
/**
|
||||
* Check a request body for prompt injection.
|
||||
@@ -32,28 +112,43 @@ export function createInjectionGuard(options: any = {}) {
|
||||
* @returns {{ blocked: boolean, result: Object }}
|
||||
*/
|
||||
return function guardRequest(body: any) {
|
||||
if (!body || typeof body !== "object") {
|
||||
if (!enabled || !body || typeof body !== "object") {
|
||||
return { blocked: false, result: { flagged: false, detections: [], piiDetections: [] } };
|
||||
}
|
||||
|
||||
const result: any = sanitizeRequest(body, logger);
|
||||
const contents = extractMessageContents(body);
|
||||
const customDetections = detectWithPatterns(contents.join("\n"), customPatterns);
|
||||
|
||||
if (customDetections.length > 0) {
|
||||
const existingDetections = new Set(
|
||||
result.detections.map((d) => `${d.pattern}:${d.match}:${d.severity}`)
|
||||
);
|
||||
|
||||
for (const detection of customDetections) {
|
||||
const key = `${detection.pattern}:${detection.match}:${detection.severity}`;
|
||||
if (!existingDetections.has(key)) {
|
||||
result.detections.push(detection);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.flagged = result.detections.length > 0 || result.piiDetections.length > 0;
|
||||
|
||||
// Check if any detections were found (sanitizeRequest returns .detections, NOT .flagged)
|
||||
if (result.detections.length === 0 && result.piiDetections.length === 0) {
|
||||
if (!result.flagged) {
|
||||
return { blocked: false, result };
|
||||
}
|
||||
|
||||
const highSeverity = result.detections.filter((d) => d.severity === "high");
|
||||
|
||||
if (mode === "block" && highSeverity.length > 0) {
|
||||
logger.warn("[InjectionGuard] Blocked request with high-severity injection:", {
|
||||
if (mode === "block" && shouldBlock(result.detections, blockThreshold)) {
|
||||
logger.warn?.("[InjectionGuard] Blocked request with prompt injection:", {
|
||||
detections: result.detections.map((d) => ({ pattern: d.pattern, severity: d.severity })),
|
||||
});
|
||||
return { blocked: true, result };
|
||||
}
|
||||
|
||||
if (mode === "warn" || mode === "log") {
|
||||
logger[mode === "warn" ? "warn" : "info"](
|
||||
logger[mode === "warn" ? "warn" : "info"]?.(
|
||||
"[InjectionGuard] Detected potential injection patterns:",
|
||||
{
|
||||
detections: result.detections.map((d) => ({ pattern: d.pattern, severity: d.severity })),
|
||||
|
||||
@@ -19,6 +19,8 @@ import {
|
||||
ANTHROPIC_COMPATIBLE_PREFIX,
|
||||
} from "@/shared/constants/providers";
|
||||
|
||||
const isE2EMode = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1";
|
||||
|
||||
function usePageInfo(pathname: string | null): {
|
||||
title: string;
|
||||
description: string;
|
||||
@@ -213,8 +215,8 @@ export default function Header({ onMenuClick, showMenuButton = true }) {
|
||||
<ThemeToggle />
|
||||
|
||||
{/* Degradation & Token health */}
|
||||
<DegradationBadge />
|
||||
<TokenHealthBadge />
|
||||
{!isE2EMode && <DegradationBadge />}
|
||||
{!isE2EMode && <TokenHealthBadge />}
|
||||
|
||||
{/* Logout button */}
|
||||
<button
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
normalizeHiddenSidebarItems,
|
||||
} from "@/shared/constants/sidebarVisibility";
|
||||
|
||||
const isE2EMode = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1";
|
||||
|
||||
export default function Sidebar({
|
||||
onClose,
|
||||
collapsed = false,
|
||||
@@ -282,7 +284,7 @@ export default function Sidebar({
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<CloudSyncStatus collapsed={collapsed} />
|
||||
{!isE2EMode && <CloudSyncStatus collapsed={collapsed} />}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
|
||||
@@ -8,6 +8,7 @@ import NotificationToast from "../NotificationToast";
|
||||
import MaintenanceBanner from "../MaintenanceBanner";
|
||||
|
||||
const SIDEBAR_COLLAPSED_KEY = "sidebar-collapsed";
|
||||
const isE2EMode = process.env.NEXT_PUBLIC_OMNIROUTE_E2E_MODE === "1";
|
||||
|
||||
export default function DashboardLayout({ children }) {
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
@@ -56,7 +57,7 @@ export default function DashboardLayout({ children }) {
|
||||
className="flex flex-col flex-1 h-full min-w-0 relative transition-colors duration-300"
|
||||
>
|
||||
<Header onMenuClick={() => setSidebarOpen(true)} />
|
||||
<MaintenanceBanner />
|
||||
{!isE2EMode && <MaintenanceBanner />}
|
||||
<div className="flex-1 overflow-y-auto overflow-x-hidden custom-scrollbar p-4 sm:p-6 lg:p-10">
|
||||
<div className="max-w-7xl mx-auto w-full">
|
||||
<Breadcrumbs />
|
||||
|
||||
@@ -58,6 +58,7 @@ export const createProviderSchema = z.object({
|
||||
|
||||
export const createKeySchema = z.object({
|
||||
name: z.string().min(1, "Name is required").max(200),
|
||||
noLog: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// ──── Combo Schemas ────
|
||||
|
||||
@@ -62,6 +62,16 @@ import {
|
||||
isFallbackDecision,
|
||||
shouldUseFallback,
|
||||
} from "@omniroute/open-sse/services/emergencyFallback.ts";
|
||||
import {
|
||||
registerCodexQuotaFetcher,
|
||||
registerCodexConnection,
|
||||
fetchCodexQuota,
|
||||
} from "@omniroute/open-sse/services/codexQuotaFetcher.ts";
|
||||
|
||||
// Register Codex quota fetcher at module load (once per server start).
|
||||
// This hooks into the quotaPreflight + quotaMonitor systems so that combos
|
||||
// can proactively switch accounts before the 5h or 7d quota is exhausted.
|
||||
registerCodexQuotaFetcher();
|
||||
|
||||
/**
|
||||
* Handle chat completion request
|
||||
@@ -262,6 +272,35 @@ export async function handleChat(request: any, clientRawRequest: any = null) {
|
||||
modelInfo.model || modelString
|
||||
);
|
||||
if (!creds || creds.allRateLimited) return false;
|
||||
|
||||
// ── Codex Quota Preflight (Item 1-2) ──────────────────────────────────
|
||||
// Proactively skip Codex accounts that have consumed >= 95% of either
|
||||
// their 5h or 7d quota window. This prevents requests from failing with
|
||||
// a 429 and then retrying — we switch accounts early instead.
|
||||
if (provider === "codex" && creds.connectionId) {
|
||||
// Register connection metadata so the fetcher can call the usage API
|
||||
if (creds.accessToken) {
|
||||
registerCodexConnection(creds.connectionId, {
|
||||
accessToken: creds.accessToken,
|
||||
workspaceId:
|
||||
typeof creds.providerSpecificData?.workspaceId === "string"
|
||||
? creds.providerSpecificData.workspaceId
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const quotaInfo = await fetchCodexQuota(creds.connectionId);
|
||||
if (quotaInfo && quotaInfo.percentUsed >= 0.95) {
|
||||
const pct = (quotaInfo.percentUsed * 100).toFixed(1);
|
||||
log.info(
|
||||
"QUOTA_PREFLIGHT",
|
||||
`Skipping Codex account ${creds.connectionId.slice(0, 8)}...: quota at ${pct}% (preflight)`
|
||||
);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
|
||||
236
tests/e2e/api-keys-flow.spec.ts
Normal file
236
tests/e2e/api-keys-flow.spec.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const NAVIGATION_TIMEOUT_MS = 300_000;
|
||||
const UI_STABILITY_TIMEOUT_MS = 120_000;
|
||||
|
||||
type ApiKeyRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
key: string;
|
||||
fullKey: string;
|
||||
allowedModels: string[] | null;
|
||||
allowedConnections: string[] | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function installClipboardMock(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
let clipboardValue = "";
|
||||
Object.defineProperty(window, "__clipboardValue", {
|
||||
configurable: true,
|
||||
get: () => clipboardValue,
|
||||
set: (value) => {
|
||||
clipboardValue = typeof value === "string" ? value : String(value ?? "");
|
||||
},
|
||||
});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
configurable: true,
|
||||
value: {
|
||||
writeText: async (value: string) => {
|
||||
(window as Window & { __clipboardValue?: string }).__clipboardValue = value;
|
||||
},
|
||||
readText: async () => clipboardValue,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function readClipboard(page: Page) {
|
||||
return page.evaluate(() => (window as Window & { __clipboardValue?: string }).__clipboardValue);
|
||||
}
|
||||
|
||||
async function gotoOrSkip(page: Page, url: string) {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS });
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
try {
|
||||
await page.waitForURL(/\/(login|dashboard)(\/.*)?$/, { timeout: NAVIGATION_TIMEOUT_MS });
|
||||
await page.locator("body").waitFor({ state: "visible", timeout: NAVIGATION_TIMEOUT_MS });
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
const redirectedToLogin = page.url().includes("/login");
|
||||
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
|
||||
}
|
||||
|
||||
async function waitForPageToSettle(page: Page) {
|
||||
try {
|
||||
await page.waitForLoadState("networkidle", { timeout: 15_000 });
|
||||
} catch {
|
||||
// Some dashboard pages keep background requests alive; visibility assertions below
|
||||
// are the authoritative readiness check for these E2E flows.
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForNextDevCompileToFinish(page: Page) {
|
||||
const nextDevToolsButton = page.getByRole("button", { name: /open next\.js dev tools/i });
|
||||
if ((await nextDevToolsButton.count()) === 0) return;
|
||||
await expect(nextDevToolsButton).not.toContainText(/compiling/i, { timeout: 120_000 });
|
||||
}
|
||||
|
||||
test.describe("API keys flow", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("creates, copies, reveals, revokes, and returns to the empty state", async ({ page }) => {
|
||||
const state: {
|
||||
keys: ApiKeyRecord[];
|
||||
nextId: number;
|
||||
revealCalls: number;
|
||||
deleteCalls: number;
|
||||
} = {
|
||||
keys: [],
|
||||
nextId: 1,
|
||||
revealCalls: 0,
|
||||
deleteCalls: 0,
|
||||
};
|
||||
|
||||
await installClipboardMock(page);
|
||||
|
||||
await page.route("**/v1/models", async (route) => {
|
||||
await fulfillJson(route, { data: [] });
|
||||
});
|
||||
|
||||
await page.route("**/api/settings", async (route) => {
|
||||
await fulfillJson(route, {});
|
||||
});
|
||||
|
||||
await page.route("**/api/providers", async (route) => {
|
||||
await fulfillJson(route, {
|
||||
connections: [
|
||||
{ id: "conn-openai", name: "OpenAI Main", provider: "openai", isActive: true },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/api\/usage\/call-logs(?:\?.*)?$/, async (route) => {
|
||||
await fulfillJson(route, []);
|
||||
});
|
||||
|
||||
await page.route("**/api/sessions", async (route) => {
|
||||
await fulfillJson(route, { byApiKey: {} });
|
||||
});
|
||||
|
||||
await page.route(/\/api\/keys\/[^/]+\/reveal$/, async (route) => {
|
||||
state.revealCalls += 1;
|
||||
const keyId = route.request().url().split("/").slice(-2)[0];
|
||||
const record = state.keys.find((key) => key.id === keyId);
|
||||
await fulfillJson(route, { key: record?.fullKey ?? "" });
|
||||
});
|
||||
|
||||
await page.route(/\/api\/keys\/[^/]+$/, async (route) => {
|
||||
if (route.request().method() === "DELETE") {
|
||||
state.deleteCalls += 1;
|
||||
const keyId = route.request().url().split("/").pop() || "";
|
||||
state.keys = state.keys.filter((key) => key.id !== keyId);
|
||||
await fulfillJson(route, { success: true });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, { error: "Method not allowed in api key detail stub" }, 405);
|
||||
});
|
||||
|
||||
await page.route("**/api/keys", async (route) => {
|
||||
const method = route.request().method();
|
||||
|
||||
if (method === "GET") {
|
||||
await fulfillJson(route, {
|
||||
keys: state.keys.map(({ fullKey, ...record }) => record),
|
||||
allowKeyReveal: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST") {
|
||||
const payload = (route.request().postDataJSON() as { name?: string }) || {};
|
||||
const id = `key-${state.nextId++}`;
|
||||
const suffix = String(1000 + state.nextId);
|
||||
const fullKey = `sk-live-${suffix}-demo-secret`;
|
||||
const maskedKey = `sk-live-****${suffix}`;
|
||||
state.keys.push({
|
||||
id,
|
||||
name: payload.name || "New Key",
|
||||
key: maskedKey,
|
||||
fullKey,
|
||||
allowedModels: null,
|
||||
allowedConnections: null,
|
||||
createdAt: new Date("2026-04-05T20:00:00.000Z").toISOString(),
|
||||
});
|
||||
|
||||
await fulfillJson(route, { key: fullKey, id });
|
||||
return;
|
||||
}
|
||||
|
||||
await fulfillJson(route, { error: "Method not allowed in api keys stub" }, 405);
|
||||
});
|
||||
|
||||
await gotoOrSkip(page, "/dashboard/api-manager");
|
||||
await waitForPageToSettle(page);
|
||||
await waitForNextDevCompileToFinish(page);
|
||||
|
||||
const createFirstKeyButton = page.getByRole("button", {
|
||||
name: /create (your )?first key/i,
|
||||
});
|
||||
await expect(createFirstKeyButton).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
await waitForPageToSettle(page);
|
||||
await waitForNextDevCompileToFinish(page);
|
||||
await createFirstKeyButton.click();
|
||||
|
||||
const createDialog = page.getByRole("dialog", { name: /create api key/i });
|
||||
await expect(createDialog).toBeVisible({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
await createDialog.locator("input").first().fill("Team Key");
|
||||
const createKeyButton = createDialog.getByRole("button", { name: /create api key/i });
|
||||
await expect(createKeyButton).toBeEnabled({ timeout: UI_STABILITY_TIMEOUT_MS });
|
||||
await createKeyButton.click({ force: true });
|
||||
await expect.poll(() => state.keys.length).toBe(1);
|
||||
|
||||
const createdDialog = page.getByRole("dialog", { name: /api key created/i });
|
||||
const createdKeyInput = createdDialog.locator("input[readonly]").first();
|
||||
await expect(createdKeyInput).toHaveValue(/sk-live-/);
|
||||
await createdDialog.getByRole("button", { name: /copy/i }).click();
|
||||
|
||||
await expect.poll(() => readClipboard(page)).toBeTruthy();
|
||||
const createdClipboardValue = await readClipboard(page);
|
||||
await expect(createdKeyInput).toHaveValue(createdClipboardValue || "");
|
||||
|
||||
await createdDialog.getByRole("button", { name: /done/i }).click();
|
||||
|
||||
await expect(page.getByText("Team Key")).toBeVisible();
|
||||
await expect(page.getByText("sk-live-****1002")).toBeVisible();
|
||||
|
||||
const keyRow = page
|
||||
.locator("div")
|
||||
.filter({ has: page.getByText("Team Key", { exact: true }) })
|
||||
.filter({ has: page.getByText("sk-live-****1002", { exact: true }) })
|
||||
.first();
|
||||
|
||||
await keyRow.getByRole("button", { name: /copy/i }).click();
|
||||
await expect.poll(() => state.revealCalls).toBe(1);
|
||||
await expect.poll(() => readClipboard(page)).toBe("sk-live-1002-demo-secret");
|
||||
|
||||
page.once("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
});
|
||||
await keyRow.locator("button[title]").last().click({ force: true });
|
||||
|
||||
await expect.poll(() => state.deleteCalls).toBe(1);
|
||||
await expect(page.getByText("Team Key")).toHaveCount(0);
|
||||
await expect(createFirstKeyButton).toBeVisible();
|
||||
});
|
||||
});
|
||||
197
tests/e2e/memory-settings.spec.ts
Normal file
197
tests/e2e/memory-settings.spec.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const NAVIGATION_TIMEOUT_MS = 300_000;
|
||||
|
||||
type MemoryConfig = {
|
||||
enabled: boolean;
|
||||
maxTokens: number;
|
||||
retentionDays: number;
|
||||
strategy: "recent" | "semantic" | "hybrid";
|
||||
skillsEnabled: boolean;
|
||||
};
|
||||
|
||||
type MemoryEntry = {
|
||||
id: string;
|
||||
apiKeyId: string;
|
||||
sessionId: string | null;
|
||||
type: "factual" | "episodic" | "procedural" | "semantic";
|
||||
key: string;
|
||||
content: string;
|
||||
metadata: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
expiresAt: string | null;
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function gotoOrSkip(page: Page, url: string) {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS });
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
try {
|
||||
await page.waitForURL(/\/(login|dashboard)(\/.*)?$/, { timeout: NAVIGATION_TIMEOUT_MS });
|
||||
await page.locator("body").waitFor({ state: "visible", timeout: NAVIGATION_TIMEOUT_MS });
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
const redirectedToLogin = page.url().includes("/login");
|
||||
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
|
||||
}
|
||||
|
||||
async function setRangeValue(page: Page, testId: string, value: number) {
|
||||
await page.getByTestId(testId).evaluate((element, nextValue) => {
|
||||
const input = element as HTMLInputElement;
|
||||
const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
|
||||
valueSetter?.call(input, String(nextValue));
|
||||
input.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
input.dispatchEvent(new Event("change", { bubbles: true }));
|
||||
}, value);
|
||||
}
|
||||
|
||||
test.describe("Memory settings", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("updates memory config in settings and deletes stored memory entries", async ({ page }) => {
|
||||
const state: {
|
||||
config: MemoryConfig;
|
||||
settings: { skillsmpApiKey: string };
|
||||
memories: MemoryEntry[];
|
||||
updateCalls: number;
|
||||
deleteCalls: number;
|
||||
} = {
|
||||
config: {
|
||||
enabled: false,
|
||||
maxTokens: 2000,
|
||||
retentionDays: 30,
|
||||
strategy: "hybrid",
|
||||
skillsEnabled: false,
|
||||
},
|
||||
settings: {
|
||||
skillsmpApiKey: "",
|
||||
},
|
||||
memories: [
|
||||
{
|
||||
id: "mem-1",
|
||||
apiKeyId: "key-1",
|
||||
sessionId: "session-a",
|
||||
type: "factual",
|
||||
key: "preferred_language",
|
||||
content: "The user prefers answers in Portuguese.",
|
||||
metadata: {},
|
||||
createdAt: new Date("2026-04-05T20:00:00.000Z").toISOString(),
|
||||
updatedAt: new Date("2026-04-05T20:00:00.000Z").toISOString(),
|
||||
expiresAt: null,
|
||||
},
|
||||
],
|
||||
updateCalls: 0,
|
||||
deleteCalls: 0,
|
||||
};
|
||||
|
||||
await page.route("**/api/settings", async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
await fulfillJson(route, state.settings);
|
||||
return;
|
||||
}
|
||||
if (method === "PATCH") {
|
||||
const payload = (route.request().postDataJSON() as Record<string, unknown>) || {};
|
||||
state.settings = {
|
||||
...state.settings,
|
||||
...(typeof payload.skillsmpApiKey === "string"
|
||||
? { skillsmpApiKey: payload.skillsmpApiKey }
|
||||
: {}),
|
||||
};
|
||||
await fulfillJson(route, state.settings);
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { error: "Method not allowed in settings stub" }, 405);
|
||||
});
|
||||
|
||||
await page.route("**/api/settings/memory", async (route) => {
|
||||
const method = route.request().method();
|
||||
if (method === "GET") {
|
||||
await fulfillJson(route, state.config);
|
||||
return;
|
||||
}
|
||||
if (method === "PUT") {
|
||||
state.updateCalls += 1;
|
||||
const payload = (route.request().postDataJSON() as Partial<MemoryConfig>) || {};
|
||||
state.config = {
|
||||
...state.config,
|
||||
...payload,
|
||||
};
|
||||
await fulfillJson(route, state.config);
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { error: "Method not allowed in memory settings stub" }, 405);
|
||||
});
|
||||
|
||||
await page.route("**/api/memory", async (route) => {
|
||||
await fulfillJson(route, {
|
||||
memories: state.memories,
|
||||
stats: {
|
||||
totalEntries: state.memories.length,
|
||||
tokensUsed: state.memories.length * 24,
|
||||
hitRate: state.memories.length > 0 ? 0.75 : 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/api\/memory\/[^/]+$/, async (route) => {
|
||||
state.deleteCalls += 1;
|
||||
const memoryId = route.request().url().split("/").pop() || "";
|
||||
state.memories = state.memories.filter((memory) => memory.id !== memoryId);
|
||||
await fulfillJson(route, { success: true });
|
||||
});
|
||||
|
||||
await gotoOrSkip(page, "/dashboard/settings?tab=ai");
|
||||
|
||||
await expect(page.getByTestId("memory-settings-card")).toBeVisible();
|
||||
await expect(page.getByTestId("memory-enabled-switch")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false"
|
||||
);
|
||||
|
||||
await page.getByTestId("memory-enabled-switch").click();
|
||||
await expect(page.getByTestId("memory-enabled-switch")).toHaveAttribute("aria-checked", "true");
|
||||
await expect.poll(() => state.config.enabled).toBe(true);
|
||||
|
||||
await setRangeValue(page, "memory-retention-slider", 45);
|
||||
await expect.poll(() => state.config.retentionDays).toBe(45);
|
||||
|
||||
await page.getByTestId("memory-strategy-recent").click();
|
||||
await expect.poll(() => state.config.strategy).toBe("recent");
|
||||
await expect.poll(() => state.updateCalls).toBeGreaterThanOrEqual(3);
|
||||
|
||||
await page.getByTestId("memory-enabled-switch").click();
|
||||
await expect(page.getByTestId("memory-enabled-switch")).toHaveAttribute(
|
||||
"aria-checked",
|
||||
"false"
|
||||
);
|
||||
await expect.poll(() => state.config.enabled).toBe(false);
|
||||
|
||||
await gotoOrSkip(page, "/dashboard/memory");
|
||||
|
||||
await expect(page.getByText("preferred_language")).toBeVisible();
|
||||
await page.getByRole("button", { name: /delete/i }).click();
|
||||
|
||||
await expect.poll(() => state.deleteCalls).toBe(1);
|
||||
await expect(page.getByText("preferred_language")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
330
tests/e2e/providers-management.spec.ts
Normal file
330
tests/e2e/providers-management.spec.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const NAVIGATION_TIMEOUT_MS = 300_000;
|
||||
|
||||
type ProviderConnection = {
|
||||
id: string;
|
||||
provider: string;
|
||||
name: string;
|
||||
authType: "api_key";
|
||||
isActive: boolean;
|
||||
testStatus: string;
|
||||
priority: number;
|
||||
providerSpecificData: Record<string, unknown>;
|
||||
lastError: string | null;
|
||||
lastErrorAt: string | null;
|
||||
lastErrorType: string | null;
|
||||
lastErrorSource: string | null;
|
||||
errorCode: string | null;
|
||||
rateLimitedUntil: string | null;
|
||||
};
|
||||
|
||||
async function installProviderFetchMock(page: Page) {
|
||||
await page.addInitScript(() => {
|
||||
const state = {
|
||||
connections: [] as ProviderConnection[],
|
||||
nextId: 1,
|
||||
retestCalls: 0,
|
||||
deleteCalls: 0,
|
||||
validationCalls: 0,
|
||||
forceInvalidValidation: false,
|
||||
};
|
||||
|
||||
const clone = <T>(value: T): T => JSON.parse(JSON.stringify(value));
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const readJsonBody = async (request: Request): Promise<Record<string, unknown>> => {
|
||||
try {
|
||||
const rawBody = await request.clone().text();
|
||||
if (!rawBody) return {};
|
||||
const parsed = JSON.parse(rawBody);
|
||||
return parsed && typeof parsed === "object" ? parsed : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
Object.defineProperty(window, "__providersTestState", {
|
||||
configurable: true,
|
||||
value: state,
|
||||
});
|
||||
|
||||
const originalFetch = window.fetch.bind(window);
|
||||
window.fetch = async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init);
|
||||
const url = new URL(request.url, window.location.origin);
|
||||
const method = request.method.toUpperCase();
|
||||
const path = url.pathname;
|
||||
|
||||
if (path === "/api/providers/expiration") {
|
||||
return jsonResponse({
|
||||
summary: { expired: 0, expiringSoon: 0 },
|
||||
list: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "/api/provider-nodes") {
|
||||
return jsonResponse({
|
||||
nodes: [],
|
||||
ccCompatibleProviderEnabled: false,
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "/api/models/alias") {
|
||||
if (method === "GET") {
|
||||
return jsonResponse({ aliases: {} });
|
||||
}
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
|
||||
if (path === "/api/settings/proxy") {
|
||||
if (url.searchParams.has("resolve")) {
|
||||
return jsonResponse({ proxy: null, level: null });
|
||||
}
|
||||
return jsonResponse({ providers: {} });
|
||||
}
|
||||
|
||||
if (path === "/api/provider-models") {
|
||||
return jsonResponse({
|
||||
models: [],
|
||||
modelCompatOverrides: [],
|
||||
});
|
||||
}
|
||||
|
||||
if (path === "/api/rate-limits") {
|
||||
return jsonResponse({ providers: [] });
|
||||
}
|
||||
|
||||
if (path === "/api/providers/validate") {
|
||||
state.validationCalls += 1;
|
||||
const valid = !state.forceInvalidValidation;
|
||||
return jsonResponse({ valid }, valid ? 200 : 400);
|
||||
}
|
||||
|
||||
const testMatch = path.match(/^\/api\/providers\/([^/]+)\/test$/);
|
||||
if (testMatch && method === "POST") {
|
||||
state.retestCalls += 1;
|
||||
const connectionId = testMatch[1];
|
||||
state.connections = state.connections.map((connection) =>
|
||||
connection.id === connectionId
|
||||
? {
|
||||
...connection,
|
||||
testStatus: "active",
|
||||
lastError: null,
|
||||
lastErrorAt: null,
|
||||
lastErrorType: null,
|
||||
lastErrorSource: null,
|
||||
errorCode: null,
|
||||
rateLimitedUntil: null,
|
||||
}
|
||||
: connection
|
||||
);
|
||||
return jsonResponse({ valid: true });
|
||||
}
|
||||
|
||||
const detailMatch = path.match(/^\/api\/providers\/([^/]+)$/);
|
||||
if (detailMatch) {
|
||||
const connectionId = detailMatch[1];
|
||||
|
||||
if (method === "PUT") {
|
||||
const payload = await readJsonBody(request);
|
||||
state.connections = state.connections.map((connection) =>
|
||||
connection.id === connectionId
|
||||
? {
|
||||
...connection,
|
||||
name:
|
||||
typeof payload.name === "string" && payload.name.trim()
|
||||
? payload.name.trim()
|
||||
: connection.name,
|
||||
priority:
|
||||
typeof payload.priority === "number" ? payload.priority : connection.priority,
|
||||
isActive:
|
||||
typeof payload.isActive === "boolean" ? payload.isActive : connection.isActive,
|
||||
providerSpecificData: {
|
||||
...connection.providerSpecificData,
|
||||
...(payload.providerSpecificData as Record<string, unknown> | undefined),
|
||||
...(typeof payload.tag === "string" ? { tag: payload.tag } : {}),
|
||||
...(typeof payload.validationModelId === "string"
|
||||
? { validationModelId: payload.validationModelId }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: connection
|
||||
);
|
||||
const updated = state.connections.find((connection) => connection.id === connectionId);
|
||||
return jsonResponse({ connection: clone(updated) });
|
||||
}
|
||||
|
||||
if (method === "DELETE") {
|
||||
state.deleteCalls += 1;
|
||||
state.connections = state.connections.filter(
|
||||
(connection) => connection.id !== connectionId
|
||||
);
|
||||
return jsonResponse({ success: true });
|
||||
}
|
||||
}
|
||||
|
||||
if (path === "/api/providers") {
|
||||
if (method === "GET") {
|
||||
return jsonResponse({ connections: clone(state.connections) });
|
||||
}
|
||||
|
||||
if (method === "POST") {
|
||||
const payload = await readJsonBody(request);
|
||||
const apiKey = typeof payload.apiKey === "string" ? payload.apiKey : "";
|
||||
|
||||
if (!apiKey || apiKey.includes("invalid")) {
|
||||
return jsonResponse({ error: "Invalid API key" }, 400);
|
||||
}
|
||||
|
||||
const connection: ProviderConnection = {
|
||||
id: `conn-openai-${state.nextId++}`,
|
||||
provider: String(payload.provider || "openai"),
|
||||
name: String(payload.name || `OpenAI ${state.nextId}`),
|
||||
authType: "api_key",
|
||||
isActive: true,
|
||||
testStatus: "active",
|
||||
priority: typeof payload.priority === "number" ? payload.priority : 1,
|
||||
providerSpecificData: {
|
||||
tag: typeof payload.tag === "string" ? payload.tag : "",
|
||||
validationModelId:
|
||||
typeof payload.validationModelId === "string" ? payload.validationModelId : "",
|
||||
},
|
||||
lastError: null,
|
||||
lastErrorAt: null,
|
||||
lastErrorType: null,
|
||||
lastErrorSource: null,
|
||||
errorCode: null,
|
||||
rateLimitedUntil: null,
|
||||
};
|
||||
|
||||
state.connections.push(connection);
|
||||
return jsonResponse({ connection: clone(connection) });
|
||||
}
|
||||
}
|
||||
|
||||
return originalFetch(input, init);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function readProviderMockState(page: Page) {
|
||||
return page.evaluate(
|
||||
() =>
|
||||
(
|
||||
window as Window & {
|
||||
__providersTestState: {
|
||||
connections: ProviderConnection[];
|
||||
nextId: number;
|
||||
retestCalls: number;
|
||||
deleteCalls: number;
|
||||
validationCalls: number;
|
||||
forceInvalidValidation: boolean;
|
||||
};
|
||||
}
|
||||
).__providersTestState
|
||||
);
|
||||
}
|
||||
|
||||
async function gotoOrSkip(page: Page, url: string) {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS });
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
try {
|
||||
await page.waitForURL(/\/(login|dashboard)(\/.*)?$/, { timeout: NAVIGATION_TIMEOUT_MS });
|
||||
await page.locator("body").waitFor({ state: "visible", timeout: NAVIGATION_TIMEOUT_MS });
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
const redirectedToLogin = page.url().includes("/login");
|
||||
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
|
||||
}
|
||||
|
||||
test.describe("Providers management", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("adds, edits, retests, deletes, and validates provider connections through the UI", async ({
|
||||
page,
|
||||
}) => {
|
||||
await installProviderFetchMock(page);
|
||||
|
||||
await gotoOrSkip(page, "/dashboard/providers");
|
||||
|
||||
const openAiCard = page.locator('a[href="/dashboard/providers/openai"]').first();
|
||||
await expect(openAiCard).toBeVisible();
|
||||
await openAiCard.click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard\/providers\/openai$/);
|
||||
await page.getByRole("button", { name: /^add$/i }).first().click();
|
||||
|
||||
const addDialog = page.getByRole("dialog");
|
||||
await expect(addDialog).toBeVisible();
|
||||
await addDialog.getByLabel(/name/i).fill("Primary OpenAI");
|
||||
await addDialog.getByLabel(/api key/i).fill("sk-openai-valid");
|
||||
await addDialog.getByRole("button", { name: /^save$/i }).click();
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readProviderMockState(page)).validationCalls)
|
||||
.toBeGreaterThan(0);
|
||||
await expect(page.getByText("Primary OpenAI")).toBeVisible();
|
||||
await expect.poll(async () => (await readProviderMockState(page)).connections.length).toBe(1);
|
||||
|
||||
await page.getByTitle(/^edit$/i).click();
|
||||
const editDialog = page.getByRole("dialog");
|
||||
await editDialog.getByLabel(/name/i).fill("Primary OpenAI Edited");
|
||||
await editDialog.getByLabel(/priority/i).fill("3");
|
||||
await editDialog.getByRole("button", { name: /^save$/i }).click();
|
||||
|
||||
await expect(page.getByText("Primary OpenAI Edited")).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await readProviderMockState(page)).connections[0]?.name)
|
||||
.toBe("Primary OpenAI Edited");
|
||||
|
||||
await page.getByRole("button", { name: /retest/i }).click();
|
||||
await expect.poll(async () => (await readProviderMockState(page)).retestCalls).toBe(1);
|
||||
|
||||
await page.evaluate(() => {
|
||||
(
|
||||
window as Window & { __providersTestState: { forceInvalidValidation: boolean } }
|
||||
).__providersTestState.forceInvalidValidation = true;
|
||||
});
|
||||
await page.getByRole("button", { name: /^add$/i }).first().click();
|
||||
const invalidDialog = page.getByRole("dialog");
|
||||
await invalidDialog.getByLabel(/name/i).fill("Broken OpenAI");
|
||||
await invalidDialog.getByLabel(/api key/i).fill("invalid-key");
|
||||
await invalidDialog.getByRole("button", { name: /^save$/i }).click();
|
||||
|
||||
await expect(invalidDialog.getByText(/api key validation failed/i)).toBeVisible();
|
||||
await expect
|
||||
.poll(async () => (await readProviderMockState(page)).validationCalls)
|
||||
.toBeGreaterThan(1);
|
||||
await invalidDialog.getByRole("button", { name: /cancel/i }).click();
|
||||
await page.evaluate(() => {
|
||||
(
|
||||
window as Window & { __providersTestState: { forceInvalidValidation: boolean } }
|
||||
).__providersTestState.forceInvalidValidation = false;
|
||||
});
|
||||
|
||||
page.once("dialog", async (dialog) => {
|
||||
await dialog.accept();
|
||||
});
|
||||
await page.getByTitle(/^delete$/i).click();
|
||||
|
||||
await expect.poll(async () => (await readProviderMockState(page)).deleteCalls).toBe(1);
|
||||
await expect(page.getByText("Primary OpenAI Edited")).toHaveCount(0);
|
||||
await expect(page.getByText(/no connections yet/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
224
tests/e2e/skills-marketplace.spec.ts
Normal file
224
tests/e2e/skills-marketplace.spec.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
|
||||
const NAVIGATION_TIMEOUT_MS = 300_000;
|
||||
|
||||
type SkillRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
version: string;
|
||||
description: string;
|
||||
enabled: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
type MarketplaceSkill = {
|
||||
name: string;
|
||||
description: string;
|
||||
version: string;
|
||||
sourceUrl: string;
|
||||
skillMdContent: string;
|
||||
};
|
||||
|
||||
async function fulfillJson(route: Route, body: unknown, status = 200) {
|
||||
await route.fulfill({
|
||||
status,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function gotoOrSkip(page: Page, url: string) {
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
await page.goto(url, { waitUntil: "commit", timeout: NAVIGATION_TIMEOUT_MS });
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
try {
|
||||
await page.waitForURL(/\/(login|dashboard)(\/.*)?$/, { timeout: NAVIGATION_TIMEOUT_MS });
|
||||
await page.locator("body").waitFor({ state: "visible", timeout: NAVIGATION_TIMEOUT_MS });
|
||||
lastError = null;
|
||||
break;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await page.waitForTimeout(1000);
|
||||
}
|
||||
if (lastError) throw lastError;
|
||||
const redirectedToLogin = page.url().includes("/login");
|
||||
test.skip(redirectedToLogin, "Authentication enabled without a login fixture.");
|
||||
}
|
||||
|
||||
test.describe("Skills marketplace", () => {
|
||||
test.setTimeout(600_000);
|
||||
|
||||
test("searches, installs, toggles, and scrolls through skills in the dashboard", async ({
|
||||
page,
|
||||
}) => {
|
||||
const state: {
|
||||
skills: SkillRecord[];
|
||||
marketplace: MarketplaceSkill[];
|
||||
nextId: number;
|
||||
toggleCalls: number;
|
||||
marketplaceInstalls: number;
|
||||
customInstalls: number;
|
||||
} = {
|
||||
skills: [
|
||||
{
|
||||
id: "skill-weather",
|
||||
name: "lookupWeather",
|
||||
version: "1.0.0",
|
||||
description: "Returns current weather conditions.",
|
||||
enabled: false,
|
||||
createdAt: new Date("2026-04-05T20:00:00.000Z").toISOString(),
|
||||
},
|
||||
],
|
||||
marketplace: Array.from({ length: 12 }, (_, index) => ({
|
||||
name: index === 0 ? "Weather Pro" : `Skill Page ${index + 1}`,
|
||||
description:
|
||||
index === 0
|
||||
? "Extended weather reports with severe alert support."
|
||||
: `Marketplace skill result ${index + 1}.`,
|
||||
version: `1.${index}.0`,
|
||||
sourceUrl: `https://skillsmp.example/${index + 1}`,
|
||||
skillMdContent: `# Skill ${index + 1}\n\nMarketplace content`,
|
||||
})),
|
||||
nextId: 2,
|
||||
toggleCalls: 0,
|
||||
marketplaceInstalls: 0,
|
||||
customInstalls: 0,
|
||||
};
|
||||
|
||||
await page.route("**/api/skills/executions", async (route) => {
|
||||
await fulfillJson(route, { executions: [] });
|
||||
});
|
||||
|
||||
await page.route(/\/api\/skills\/marketplace(?:\?.*)?$/, async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
const query = url.searchParams.get("q")?.toLowerCase() || "";
|
||||
const results = state.marketplace.filter((skill) =>
|
||||
query
|
||||
? skill.name.toLowerCase().includes(query) ||
|
||||
skill.description.toLowerCase().includes(query)
|
||||
: true
|
||||
);
|
||||
await fulfillJson(route, { skills: results });
|
||||
});
|
||||
|
||||
await page.route("**/api/skills/marketplace/install", async (route) => {
|
||||
state.marketplaceInstalls += 1;
|
||||
const payload = (route.request().postDataJSON() as Partial<MarketplaceSkill>) || {};
|
||||
state.skills.push({
|
||||
id: `skill-${state.nextId++}`,
|
||||
name: payload.name || "marketplace-skill",
|
||||
version: payload.version || "1.0.0",
|
||||
description: payload.description || "Installed from marketplace",
|
||||
enabled: true,
|
||||
createdAt: new Date("2026-04-05T20:10:00.000Z").toISOString(),
|
||||
});
|
||||
await fulfillJson(route, { success: true });
|
||||
});
|
||||
|
||||
await page.route("**/api/skills/install", async (route) => {
|
||||
state.customInstalls += 1;
|
||||
const payload = (route.request().postDataJSON() as Partial<SkillRecord>) || {};
|
||||
state.skills.push({
|
||||
id: `skill-${state.nextId++}`,
|
||||
name: payload.name || "custom-skill",
|
||||
version: payload.version || "1.0.0",
|
||||
description: payload.description || "Custom installed skill",
|
||||
enabled: true,
|
||||
createdAt: new Date("2026-04-05T20:20:00.000Z").toISOString(),
|
||||
});
|
||||
await fulfillJson(route, {
|
||||
success: true,
|
||||
id: `skill-${state.nextId}`,
|
||||
});
|
||||
});
|
||||
|
||||
await page.route(/\/api\/skills\/skill-[^/?]+(?:\?.*)?$/, async (route) => {
|
||||
if (route.request().method() !== "PUT") {
|
||||
await fulfillJson(route, { error: "Method not allowed in skill detail stub" }, 405);
|
||||
return;
|
||||
}
|
||||
|
||||
state.toggleCalls += 1;
|
||||
const skillId = route.request().url().split("/").pop() || "";
|
||||
state.skills = state.skills.map((skill) =>
|
||||
skill.id === skillId ? { ...skill, enabled: !skill.enabled } : skill
|
||||
);
|
||||
await fulfillJson(route, { success: true });
|
||||
});
|
||||
|
||||
await page.route("**/api/skills", async (route) => {
|
||||
await fulfillJson(route, { skills: state.skills });
|
||||
});
|
||||
|
||||
await gotoOrSkip(page, "/dashboard/skills");
|
||||
|
||||
await expect(page.getByText("lookupWeather")).toBeVisible();
|
||||
const weatherCard = page
|
||||
.locator("div")
|
||||
.filter({ has: page.getByText("lookupWeather") })
|
||||
.first();
|
||||
const weatherSwitch = weatherCard.getByRole("switch");
|
||||
await expect(weatherSwitch).toHaveAttribute("aria-checked", "false");
|
||||
await weatherSwitch.click();
|
||||
await expect(weatherSwitch).toHaveAttribute("aria-checked", "true");
|
||||
await expect.poll(() => state.toggleCalls).toBe(1);
|
||||
|
||||
await page.getByRole("button", { name: /marketplace/i }).click();
|
||||
await expect(page.getByPlaceholder("Search skills...")).toBeVisible();
|
||||
|
||||
await page.getByPlaceholder("Search skills...").fill("weather");
|
||||
await page.getByRole("button", { name: /search skillsmp/i }).click();
|
||||
await expect(page.getByText("Weather Pro")).toBeVisible();
|
||||
await page.getByRole("button", { name: /^install$/i }).click();
|
||||
await expect.poll(() => state.marketplaceInstalls).toBe(1);
|
||||
|
||||
await page.getByPlaceholder("Search skills...").fill("");
|
||||
await page.getByRole("button", { name: /search skillsmp/i }).click();
|
||||
const lastMarketplaceSkill = page.getByText("Skill Page 12").last();
|
||||
await lastMarketplaceSkill.scrollIntoViewIfNeeded();
|
||||
await expect(lastMarketplaceSkill).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /^skills$/i }).click();
|
||||
await expect(page.getByText("Weather Pro")).toBeVisible();
|
||||
|
||||
await page.getByRole("button", { name: /^install skill$/i }).click();
|
||||
const installDialog = page
|
||||
.locator("div.fixed.inset-0.z-50")
|
||||
.filter({ has: page.getByRole("heading", { name: /^install skill$/i }) });
|
||||
await expect(installDialog).toBeVisible();
|
||||
await installDialog.locator("textarea").fill(
|
||||
JSON.stringify(
|
||||
{
|
||||
name: "customMath",
|
||||
version: "1.0.0",
|
||||
description: "Custom calculator skill",
|
||||
schema: { input: {}, output: {} },
|
||||
handlerCode: "export default async () => ({ ok: true });",
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
await installDialog.getByRole("button", { name: /^install$/i }).click();
|
||||
|
||||
await expect.poll(() => state.customInstalls).toBe(1);
|
||||
await expect(installDialog.getByText(/skill installed/i)).toBeVisible();
|
||||
await installDialog.getByRole("button", { name: /cancel/i }).click();
|
||||
|
||||
await expect(page.getByText("customMath")).toBeVisible();
|
||||
|
||||
const installedSwitch = page
|
||||
.getByRole("heading", { name: "Weather Pro" })
|
||||
.locator('xpath=ancestor::div[contains(@class, "flex items-center justify-between")][1]')
|
||||
.getByRole("switch");
|
||||
await expect(installedSwitch).toHaveAttribute("aria-checked", "true");
|
||||
await installedSwitch.click();
|
||||
await expect(installedSwitch).toHaveAttribute("aria-checked", "false");
|
||||
await expect.poll(() => state.toggleCalls).toBe(2);
|
||||
});
|
||||
});
|
||||
292
tests/integration/_chatPipelineHarness.mjs
Normal file
292
tests/integration/_chatPipelineHarness.mjs
Normal file
@@ -0,0 +1,292 @@
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export async function createChatPipelineHarness(prefix) {
|
||||
const testDataDir = fs.mkdtempSync(path.join(os.tmpdir(), `omniroute-${prefix}-`));
|
||||
process.env.DATA_DIR = testDataDir;
|
||||
process.env.REQUIRE_API_KEY = "false";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const combosDb = await import("../../src/lib/db/combos.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const modelComboMappingsDb = await import("../../src/lib/db/modelComboMappings.ts");
|
||||
const readCacheDb = await import("../../src/lib/db/readCache.ts");
|
||||
const memoryStore = await import("../../src/lib/memory/store.ts");
|
||||
const memoryToolsModule = await import("../../open-sse/mcp-server/tools/memoryTools.ts");
|
||||
const { invalidateMemorySettingsCache } = await import("../../src/lib/memory/settings.ts");
|
||||
const { skillRegistry } = await import("../../src/lib/skills/registry.ts");
|
||||
const { skillExecutor } = await import("../../src/lib/skills/executor.ts");
|
||||
const builtinsModule = await import("../../src/lib/skills/builtins.ts");
|
||||
const sandboxModule = await import("../../src/lib/skills/sandbox.ts");
|
||||
const skillsRouteModule = await import("../../src/app/api/skills/route.ts");
|
||||
const skillByIdRouteModule = await import("../../src/app/api/skills/[id]/route.ts");
|
||||
const { handleChat } = await import("../../src/sse/handlers/chat.ts");
|
||||
const { initTranslators } = await import("../../open-sse/translator/index.ts");
|
||||
const { clearInflight } = await import("../../open-sse/services/requestDedup.ts");
|
||||
const { BaseExecutor } = await import("../../open-sse/executors/base.ts");
|
||||
const { resetAllAvailability, setModelUnavailable } =
|
||||
await import("../../src/domain/modelAvailability.ts");
|
||||
const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalRetryDelayMs = BaseExecutor.RETRY_CONFIG.delayMs;
|
||||
|
||||
function clearSkillState() {
|
||||
skillRegistry.registeredSkills?.clear?.();
|
||||
skillRegistry.versionCache?.clear?.();
|
||||
skillExecutor.handlers?.clear?.();
|
||||
}
|
||||
|
||||
function toPlainHeaders(headers) {
|
||||
if (!headers) return {};
|
||||
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).map(([key, value]) => [key, value == null ? "" : String(value)])
|
||||
);
|
||||
}
|
||||
|
||||
function buildRequest({
|
||||
url = "http://localhost/v1/chat/completions",
|
||||
body,
|
||||
authKey = null,
|
||||
headers = {},
|
||||
} = {}) {
|
||||
const requestHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
};
|
||||
|
||||
if (authKey) {
|
||||
requestHeaders.Authorization = `Bearer ${authKey}`;
|
||||
}
|
||||
|
||||
return new Request(url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function buildOpenAIResponse(text = "ok", model = "gpt-4o-mini", usage = null) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl_json",
|
||||
object: "chat.completion",
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: text },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: usage || {
|
||||
prompt_tokens: 4,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 6,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildOpenAIToolCallResponse({
|
||||
model = "gpt-4o-mini",
|
||||
toolName = "lookupWeather@1.0.0",
|
||||
toolCallId = "call_weather",
|
||||
argumentsObject = { location: "Sao Paulo" },
|
||||
} = {}) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl_tool",
|
||||
object: "chat.completion",
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: toolCallId,
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolName,
|
||||
arguments: JSON.stringify(argumentsObject),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 6,
|
||||
completion_tokens: 4,
|
||||
total_tokens: 10,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildClaudeResponse(text = "ok", model = "claude-3-5-sonnet-20241022") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "msg_json",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model,
|
||||
content: [{ type: "text", text }],
|
||||
stop_reason: "end_turn",
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 4,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildGeminiResponse(text = "ok", model = "gemini-2.5-flash") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
responseId: "resp_gemini",
|
||||
modelVersion: model,
|
||||
createTime: "2026-04-05T12:00:00.000Z",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text }],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 5,
|
||||
candidatesTokenCount: 7,
|
||||
totalTokenCount: 12,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function waitFor(fn, timeoutMs = 1500) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const result = await fn();
|
||||
if (result) return result;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
globalThis.fetch = originalFetch;
|
||||
clearInflight();
|
||||
resetAllAvailability();
|
||||
resetAllCircuitBreakers();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
readCacheDb.invalidateDbCache();
|
||||
invalidateMemorySettingsCache();
|
||||
clearSkillState();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(testDataDir, { recursive: true });
|
||||
initTranslators();
|
||||
}
|
||||
|
||||
async function cleanup() {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = originalRetryDelayMs;
|
||||
globalThis.fetch = originalFetch;
|
||||
clearInflight();
|
||||
clearSkillState();
|
||||
resetAllAvailability();
|
||||
resetAllCircuitBreakers();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(testDataDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function seedConnection(provider, overrides = {}) {
|
||||
return providersDb.createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
name: overrides.name || `${provider}-primary`,
|
||||
apiKey: overrides.apiKey || `sk-${provider}-${Math.random().toString(16).slice(2, 10)}`,
|
||||
isActive: overrides.isActive ?? true,
|
||||
testStatus: overrides.testStatus || "active",
|
||||
priority: overrides.priority,
|
||||
rateLimitedUntil: overrides.rateLimitedUntil,
|
||||
providerSpecificData: overrides.providerSpecificData || {},
|
||||
});
|
||||
}
|
||||
|
||||
async function seedApiKey({
|
||||
name = `${prefix}-key`,
|
||||
noLog = false,
|
||||
allowedConnections,
|
||||
allowedModels,
|
||||
} = {}) {
|
||||
const key = await apiKeysDb.createApiKey(name, "machine-test");
|
||||
const updates = {};
|
||||
if (noLog) updates.noLog = true;
|
||||
if (allowedConnections) updates.allowedConnections = allowedConnections;
|
||||
if (allowedModels) updates.allowedModels = allowedModels;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await apiKeysDb.updateApiKeyPermissions(key.id, updates);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
initTranslators();
|
||||
|
||||
return {
|
||||
TEST_DATA_DIR: testDataDir,
|
||||
BaseExecutor,
|
||||
apiKeysDb,
|
||||
buildClaudeResponse,
|
||||
buildGeminiResponse,
|
||||
buildOpenAIResponse,
|
||||
buildOpenAIToolCallResponse,
|
||||
buildRequest,
|
||||
builtinsModule,
|
||||
cleanup,
|
||||
combosDb,
|
||||
core,
|
||||
handleChat,
|
||||
memoryStore,
|
||||
memoryTools: memoryToolsModule.memoryTools,
|
||||
modelComboMappingsDb,
|
||||
originalRetryDelayMs,
|
||||
resetStorage,
|
||||
sandboxModule,
|
||||
seedApiKey,
|
||||
seedConnection,
|
||||
setModelUnavailable,
|
||||
settingsDb,
|
||||
skillByIdRouteModule,
|
||||
skillExecutor,
|
||||
skillRegistry,
|
||||
skillsRouteModule,
|
||||
toPlainHeaders,
|
||||
waitFor,
|
||||
};
|
||||
}
|
||||
275
tests/integration/api-keys.test.mjs
Normal file
275
tests/integration/api-keys.test.mjs
Normal file
@@ -0,0 +1,275 @@
|
||||
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-api-keys-route-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-api-key-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
const compliance = await import("../../src/lib/compliance/index.ts");
|
||||
const listRoute = await import("../../src/app/api/keys/route.ts");
|
||||
const keyRoute = await import("../../src/app/api/keys/[id]/route.ts");
|
||||
const revealRoute = await import("../../src/app/api/keys/[id]/reveal/route.ts");
|
||||
|
||||
const MACHINE_ID = "1234567890abcdef";
|
||||
|
||||
async function resetStorage() {
|
||||
delete process.env.ALLOW_API_KEY_REVEAL;
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function enableManagementAuth() {
|
||||
process.env.INITIAL_PASSWORD = "bootstrap-password";
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
}
|
||||
|
||||
async function createManagementKey() {
|
||||
return apiKeysDb.createApiKey("management", MACHINE_ID);
|
||||
}
|
||||
|
||||
function makeRequest(url, { method = "GET", token, body } = {}) {
|
||||
const headers = new Headers();
|
||||
if (token) {
|
||||
headers.set("authorization", `Bearer ${token}`);
|
||||
}
|
||||
if (body !== undefined) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
|
||||
return new Request(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("API keys routes require management auth when login protection is enabled", async () => {
|
||||
await enableManagementAuth();
|
||||
|
||||
const unauthenticated = await listRoute.GET(new Request("http://localhost/api/keys"));
|
||||
const invalidToken = await listRoute.GET(
|
||||
new Request("http://localhost/api/keys", {
|
||||
headers: { authorization: "Bearer sk-invalid" },
|
||||
})
|
||||
);
|
||||
|
||||
const unauthenticatedBody = await unauthenticated.json();
|
||||
const invalidTokenBody = await invalidToken.json();
|
||||
|
||||
assert.equal(unauthenticated.status, 401);
|
||||
assert.equal(unauthenticatedBody.error.message, "Authentication required");
|
||||
assert.equal(invalidToken.status, 403);
|
||||
assert.equal(invalidTokenBody.error.message, "Invalid management token");
|
||||
});
|
||||
|
||||
test("POST /api/keys creates a key, preserves special characters, and persists noLog", async () => {
|
||||
await enableManagementAuth();
|
||||
const authKey = await createManagementKey();
|
||||
const response = await listRoute.POST(
|
||||
makeRequest("http://localhost/api/keys", {
|
||||
method: "POST",
|
||||
token: authKey.key,
|
||||
body: { name: "Key / Prod #1", noLog: true },
|
||||
})
|
||||
);
|
||||
const body = await response.json();
|
||||
const stored = await apiKeysDb.getApiKeyById(body.id);
|
||||
|
||||
assert.equal(response.status, 201);
|
||||
assert.equal(body.name, "Key / Prod #1");
|
||||
assert.equal(body.noLog, true);
|
||||
assert.match(body.key, /^sk-[a-z0-9-]+/i);
|
||||
assert.equal(stored?.noLog, true);
|
||||
assert.equal(compliance.isNoLog(body.id), true);
|
||||
});
|
||||
|
||||
test("POST /api/keys validates missing and oversized names", async () => {
|
||||
await enableManagementAuth();
|
||||
const authKey = await createManagementKey();
|
||||
|
||||
const missingName = await listRoute.POST(
|
||||
makeRequest("http://localhost/api/keys", {
|
||||
method: "POST",
|
||||
token: authKey.key,
|
||||
body: {},
|
||||
})
|
||||
);
|
||||
const oversizedName = await listRoute.POST(
|
||||
makeRequest("http://localhost/api/keys", {
|
||||
method: "POST",
|
||||
token: authKey.key,
|
||||
body: { name: "x".repeat(201) },
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(missingName.status, 400);
|
||||
assert.equal(oversizedName.status, 400);
|
||||
});
|
||||
|
||||
test("GET /api/keys lists masked keys with pagination and GET /api/keys/[id] stays masked", async () => {
|
||||
await enableManagementAuth();
|
||||
const authKey = await createManagementKey();
|
||||
const createdA = await apiKeysDb.createApiKey("Alpha", MACHINE_ID);
|
||||
const createdB = await apiKeysDb.createApiKey("Beta", MACHINE_ID);
|
||||
|
||||
const listResponse = await listRoute.GET(
|
||||
makeRequest("http://localhost/api/keys?limit=1&offset=1", {
|
||||
token: authKey.key,
|
||||
})
|
||||
);
|
||||
const getResponse = await keyRoute.GET(
|
||||
makeRequest(`http://localhost/api/keys/${createdB.id}`, { token: authKey.key }),
|
||||
{ params: Promise.resolve({ id: createdB.id }) }
|
||||
);
|
||||
|
||||
const listBody = await listResponse.json();
|
||||
const getBody = await getResponse.json();
|
||||
|
||||
assert.equal(listResponse.status, 200);
|
||||
assert.equal(listBody.total, 3);
|
||||
assert.equal(listBody.keys.length, 1);
|
||||
assert.equal(listBody.keys[0].id, createdA.id);
|
||||
assert.notEqual(listBody.keys[0].key, createdA.key);
|
||||
assert.match(listBody.keys[0].key, /\*{4}/);
|
||||
|
||||
assert.equal(getResponse.status, 200);
|
||||
assert.equal(getBody.id, createdB.id);
|
||||
assert.notEqual(getBody.key, createdB.key);
|
||||
assert.match(getBody.key, /\*{4}/);
|
||||
});
|
||||
|
||||
test("GET /api/keys/[id] returns 404 for an unknown key and reveal is gated by the feature flag", async () => {
|
||||
await enableManagementAuth();
|
||||
const authKey = await createManagementKey();
|
||||
const created = await apiKeysDb.createApiKey("Reveal Target", MACHINE_ID);
|
||||
|
||||
const missingResponse = await keyRoute.GET(
|
||||
makeRequest("http://localhost/api/keys/missing", { token: authKey.key }),
|
||||
{ params: Promise.resolve({ id: "missing" }) }
|
||||
);
|
||||
const revealDisabled = await revealRoute.GET(
|
||||
makeRequest(`http://localhost/api/keys/${created.id}/reveal`, { token: authKey.key }),
|
||||
{ params: Promise.resolve({ id: created.id }) }
|
||||
);
|
||||
|
||||
process.env.ALLOW_API_KEY_REVEAL = "true";
|
||||
const revealEnabled = await revealRoute.GET(
|
||||
makeRequest(`http://localhost/api/keys/${created.id}/reveal`, { token: authKey.key }),
|
||||
{ params: Promise.resolve({ id: created.id }) }
|
||||
);
|
||||
|
||||
const missingBody = await missingResponse.json();
|
||||
const revealDisabledBody = await revealDisabled.json();
|
||||
const revealEnabledBody = await revealEnabled.json();
|
||||
|
||||
assert.equal(missingResponse.status, 404);
|
||||
assert.equal(missingBody.error, "Key not found");
|
||||
assert.equal(revealDisabled.status, 403);
|
||||
assert.equal(revealDisabledBody.error, "API key reveal is disabled");
|
||||
assert.equal(revealEnabled.status, 200);
|
||||
assert.equal(revealEnabledBody.key, created.key);
|
||||
});
|
||||
|
||||
test("PATCH /api/keys/[id] updates permissions and rejects invalid payloads", async () => {
|
||||
await enableManagementAuth();
|
||||
const authKey = await createManagementKey();
|
||||
const created = await apiKeysDb.createApiKey("Mutable", MACHINE_ID);
|
||||
const patchResponse = await keyRoute.PATCH(
|
||||
makeRequest(`http://localhost/api/keys/${created.id}`, {
|
||||
method: "PATCH",
|
||||
token: authKey.key,
|
||||
body: {
|
||||
noLog: true,
|
||||
allowedModels: ["gpt-4.1-mini"],
|
||||
allowedConnections: [],
|
||||
isActive: false,
|
||||
maxSessions: 2,
|
||||
},
|
||||
}),
|
||||
{ params: Promise.resolve({ id: created.id }) }
|
||||
);
|
||||
const invalidJsonResponse = await keyRoute.PATCH(
|
||||
new Request(`http://localhost/api/keys/${created.id}`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
authorization: `Bearer ${authKey.key}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: "{",
|
||||
}),
|
||||
{ params: Promise.resolve({ id: created.id }) }
|
||||
);
|
||||
const missingKeyResponse = await keyRoute.PATCH(
|
||||
makeRequest("http://localhost/api/keys/missing", {
|
||||
method: "PATCH",
|
||||
token: authKey.key,
|
||||
body: { noLog: false },
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "missing" }) }
|
||||
);
|
||||
|
||||
const patchBody = await patchResponse.json();
|
||||
const invalidJsonBody = await invalidJsonResponse.json();
|
||||
const missingKeyBody = await missingKeyResponse.json();
|
||||
const updated = await apiKeysDb.getApiKeyById(created.id);
|
||||
|
||||
assert.equal(patchResponse.status, 200);
|
||||
assert.equal(patchBody.noLog, true);
|
||||
assert.equal(patchBody.isActive, false);
|
||||
assert.equal(patchBody.maxSessions, 2);
|
||||
assert.deepEqual(updated?.allowedModels, ["gpt-4.1-mini"]);
|
||||
assert.equal(updated?.noLog, true);
|
||||
assert.equal(updated?.isActive, false);
|
||||
assert.equal(invalidJsonResponse.status, 400);
|
||||
assert.equal(invalidJsonBody.error.message, "Invalid request");
|
||||
assert.equal(missingKeyResponse.status, 404);
|
||||
assert.equal(missingKeyBody.error, "Key not found");
|
||||
});
|
||||
|
||||
test("DELETE /api/keys/[id] removes keys and reports missing resources", async () => {
|
||||
await enableManagementAuth();
|
||||
const authKey = await createManagementKey();
|
||||
const created = await apiKeysDb.createApiKey("Disposable", MACHINE_ID);
|
||||
|
||||
const deleteResponse = await keyRoute.DELETE(
|
||||
makeRequest(`http://localhost/api/keys/${created.id}`, {
|
||||
method: "DELETE",
|
||||
token: authKey.key,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: created.id }) }
|
||||
);
|
||||
const missingDeleteResponse = await keyRoute.DELETE(
|
||||
makeRequest("http://localhost/api/keys/missing", {
|
||||
method: "DELETE",
|
||||
token: authKey.key,
|
||||
}),
|
||||
{ params: Promise.resolve({ id: "missing" }) }
|
||||
);
|
||||
|
||||
const deleteBody = await deleteResponse.json();
|
||||
const missingDeleteBody = await missingDeleteResponse.json();
|
||||
|
||||
assert.equal(deleteResponse.status, 200);
|
||||
assert.equal(deleteBody.message, "Key deleted successfully");
|
||||
assert.equal(await apiKeysDb.getApiKeyById(created.id), null);
|
||||
assert.equal(missingDeleteResponse.status, 404);
|
||||
assert.equal(missingDeleteBody.error, "Key not found");
|
||||
});
|
||||
302
tests/integration/api-routes-critical.test.mjs
Normal file
302
tests/integration/api-routes-critical.test.mjs
Normal file
@@ -0,0 +1,302 @@
|
||||
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-api-critical-routes-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = "test-api-key-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const localDb = await import("../../src/lib/localDb.ts");
|
||||
const proxiesRoute = await import("../../src/app/api/v1/management/proxies/route.ts");
|
||||
const settingsProxyRoute = await import("../../src/app/api/settings/proxy/route.ts");
|
||||
const v1ModelsRoute = await import("../../src/app/api/v1/models/route.ts");
|
||||
|
||||
const MACHINE_ID = "1234567890abcdef";
|
||||
|
||||
async function resetStorage() {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
delete process.env.ENABLE_SOCKS5_PROXY;
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function enableManagementAuth() {
|
||||
process.env.INITIAL_PASSWORD = "bootstrap-password";
|
||||
await localDb.updateSettings({ requireLogin: true, password: "" });
|
||||
}
|
||||
|
||||
async function createManagementKey() {
|
||||
return apiKeysDb.createApiKey("management", MACHINE_ID);
|
||||
}
|
||||
|
||||
function makeRequest(url, { method = "GET", token, body } = {}) {
|
||||
const headers = new Headers();
|
||||
if (token) {
|
||||
headers.set("authorization", `Bearer ${token}`);
|
||||
}
|
||||
if (body !== undefined) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
|
||||
return new Request(url, {
|
||||
method,
|
||||
headers,
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("critical routes: v1 management proxies covers auth, lookup, where-used, patch, and delete branches", async () => {
|
||||
await enableManagementAuth();
|
||||
const authKey = await createManagementKey();
|
||||
|
||||
const unauthenticated = await proxiesRoute.GET(
|
||||
new Request("http://localhost/api/v1/management/proxies")
|
||||
);
|
||||
const invalidToken = await proxiesRoute.GET(
|
||||
new Request("http://localhost/api/v1/management/proxies", {
|
||||
headers: { authorization: "Bearer sk-invalid" },
|
||||
})
|
||||
);
|
||||
const createResponse = await proxiesRoute.POST(
|
||||
makeRequest("http://localhost/api/v1/management/proxies", {
|
||||
method: "POST",
|
||||
token: authKey.key,
|
||||
body: {
|
||||
name: "Branch Proxy",
|
||||
type: "http",
|
||||
host: "branch.local",
|
||||
port: 8080,
|
||||
},
|
||||
})
|
||||
);
|
||||
const created = await createResponse.json();
|
||||
|
||||
await localDb.assignProxyToScope("provider", "openai", created.id);
|
||||
|
||||
const getById = await proxiesRoute.GET(
|
||||
makeRequest(`http://localhost/api/v1/management/proxies?id=${created.id}`, {
|
||||
token: authKey.key,
|
||||
})
|
||||
);
|
||||
const whereUsed = await proxiesRoute.GET(
|
||||
makeRequest(`http://localhost/api/v1/management/proxies?id=${created.id}&where_used=1`, {
|
||||
token: authKey.key,
|
||||
})
|
||||
);
|
||||
const missingGet = await proxiesRoute.GET(
|
||||
makeRequest("http://localhost/api/v1/management/proxies?id=missing", { token: authKey.key })
|
||||
);
|
||||
const invalidJsonPatch = await proxiesRoute.PATCH(
|
||||
new Request("http://localhost/api/v1/management/proxies", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
authorization: `Bearer ${authKey.key}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: "{",
|
||||
})
|
||||
);
|
||||
const invalidPatch = await proxiesRoute.PATCH(
|
||||
makeRequest("http://localhost/api/v1/management/proxies", {
|
||||
method: "PATCH",
|
||||
token: authKey.key,
|
||||
body: {},
|
||||
})
|
||||
);
|
||||
const validPatch = await proxiesRoute.PATCH(
|
||||
makeRequest("http://localhost/api/v1/management/proxies", {
|
||||
method: "PATCH",
|
||||
token: authKey.key,
|
||||
body: { id: created.id, host: "patched.local", notes: "updated" },
|
||||
})
|
||||
);
|
||||
const missingDelete = await proxiesRoute.DELETE(
|
||||
makeRequest("http://localhost/api/v1/management/proxies", {
|
||||
method: "DELETE",
|
||||
token: authKey.key,
|
||||
})
|
||||
);
|
||||
const conflictDelete = await proxiesRoute.DELETE(
|
||||
makeRequest(`http://localhost/api/v1/management/proxies?id=${created.id}`, {
|
||||
method: "DELETE",
|
||||
token: authKey.key,
|
||||
})
|
||||
);
|
||||
const forcedDelete = await proxiesRoute.DELETE(
|
||||
makeRequest(`http://localhost/api/v1/management/proxies?id=${created.id}&force=1`, {
|
||||
method: "DELETE",
|
||||
token: authKey.key,
|
||||
})
|
||||
);
|
||||
|
||||
const unauthenticatedBody = await unauthenticated.json();
|
||||
const invalidTokenBody = await invalidToken.json();
|
||||
const getByIdBody = await getById.json();
|
||||
const whereUsedBody = await whereUsed.json();
|
||||
const missingGetBody = await missingGet.json();
|
||||
const invalidJsonPatchBody = await invalidJsonPatch.json();
|
||||
const invalidPatchBody = await invalidPatch.json();
|
||||
const validPatchBody = await validPatch.json();
|
||||
const missingDeleteBody = await missingDelete.json();
|
||||
const conflictDeleteBody = await conflictDelete.json();
|
||||
const forcedDeleteBody = await forcedDelete.json();
|
||||
|
||||
assert.equal(unauthenticated.status, 401);
|
||||
assert.equal(unauthenticatedBody.error.message, "Authentication required");
|
||||
assert.equal(invalidToken.status, 403);
|
||||
assert.equal(invalidTokenBody.error.message, "Invalid management token");
|
||||
assert.equal(createResponse.status, 201);
|
||||
assert.equal(getById.status, 200);
|
||||
assert.equal(getByIdBody.id, created.id);
|
||||
assert.equal(whereUsed.status, 200);
|
||||
assert.equal(whereUsedBody.count, 1);
|
||||
assert.equal(whereUsedBody.assignments[0].proxyId, created.id);
|
||||
assert.equal(missingGet.status, 404);
|
||||
assert.equal(missingGetBody.error.message, "Proxy not found");
|
||||
assert.equal(invalidJsonPatch.status, 400);
|
||||
assert.equal(invalidJsonPatchBody.error.message, "Invalid JSON body");
|
||||
assert.equal(invalidPatch.status, 400);
|
||||
assert.equal(validPatch.status, 200);
|
||||
assert.equal(validPatchBody.host, "patched.local");
|
||||
assert.equal(missingDelete.status, 400);
|
||||
assert.equal(missingDeleteBody.error.message, "id is required");
|
||||
assert.equal(conflictDelete.status, 409);
|
||||
assert.match(conflictDeleteBody.error.message, /force=true/i);
|
||||
assert.equal(forcedDelete.status, 200);
|
||||
assert.equal(forcedDeleteBody.success, true);
|
||||
});
|
||||
|
||||
test("critical routes: settings proxy resolves config, validates payloads, and deletes scoped entries", async () => {
|
||||
const connection = await localDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "critical-proxy-conn",
|
||||
apiKey: "sk-critical",
|
||||
});
|
||||
|
||||
const invalidJson = await settingsProxyRoute.PUT(
|
||||
new Request("http://localhost/api/settings/proxy", {
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: "{",
|
||||
})
|
||||
);
|
||||
const invalidProviders = await settingsProxyRoute.PUT(
|
||||
makeRequest("http://localhost/api/settings/proxy", {
|
||||
method: "PUT",
|
||||
body: { providers: "not-an-object" },
|
||||
})
|
||||
);
|
||||
const setProviderProxy = await settingsProxyRoute.PUT(
|
||||
makeRequest("http://localhost/api/settings/proxy", {
|
||||
method: "PUT",
|
||||
body: {
|
||||
level: "provider",
|
||||
id: "openai",
|
||||
proxy: {
|
||||
type: "HTTP",
|
||||
host: "provider.proxy.local",
|
||||
port: 9000,
|
||||
username: "alice",
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
const getProviderProxy = await settingsProxyRoute.GET(
|
||||
new Request("http://localhost/api/settings/proxy?level=provider&id=openai")
|
||||
);
|
||||
const resolveProxy = await settingsProxyRoute.GET(
|
||||
new Request(`http://localhost/api/settings/proxy?resolve=${connection.id}`)
|
||||
);
|
||||
const missingLevelDelete = await settingsProxyRoute.DELETE(
|
||||
new Request("http://localhost/api/settings/proxy")
|
||||
);
|
||||
const deleteProviderProxy = await settingsProxyRoute.DELETE(
|
||||
new Request("http://localhost/api/settings/proxy?level=provider&id=openai", {
|
||||
method: "DELETE",
|
||||
})
|
||||
);
|
||||
const getFullConfig = await settingsProxyRoute.GET(
|
||||
new Request("http://localhost/api/settings/proxy")
|
||||
);
|
||||
|
||||
const invalidJsonBody = await invalidJson.json();
|
||||
const invalidProvidersBody = await invalidProviders.json();
|
||||
const setProviderProxyBody = await setProviderProxy.json();
|
||||
const getProviderProxyBody = await getProviderProxy.json();
|
||||
const resolveProxyBody = await resolveProxy.json();
|
||||
const missingLevelDeleteBody = await missingLevelDelete.json();
|
||||
const deleteProviderProxyBody = await deleteProviderProxy.json();
|
||||
const getFullConfigBody = await getFullConfig.json();
|
||||
|
||||
assert.equal(invalidJson.status, 400);
|
||||
assert.equal(invalidJsonBody.error.message, "Invalid JSON body");
|
||||
assert.equal(invalidProviders.status, 400);
|
||||
assert.equal(invalidProvidersBody.error.message, "Invalid request");
|
||||
assert.equal(setProviderProxy.status, 200);
|
||||
assert.equal(setProviderProxyBody.providers.openai.type, "http");
|
||||
assert.equal(getProviderProxy.status, 200);
|
||||
assert.equal(getProviderProxyBody.proxy.type, "http");
|
||||
assert.equal(resolveProxy.status, 200);
|
||||
assert.equal(resolveProxyBody.level, "provider");
|
||||
assert.equal(resolveProxyBody.proxy.host, "provider.proxy.local");
|
||||
assert.equal(missingLevelDelete.status, 400);
|
||||
assert.equal(missingLevelDeleteBody.error.message, "level is required");
|
||||
assert.equal(deleteProviderProxy.status, 200);
|
||||
assert.equal(deleteProviderProxyBody.providers.openai, undefined);
|
||||
assert.equal(getFullConfig.status, 200);
|
||||
assert.ok(Object.prototype.hasOwnProperty.call(getFullConfigBody, "providers"));
|
||||
});
|
||||
|
||||
test("critical routes: settings proxy prefers registry assignment for global lookups", async () => {
|
||||
const proxy = await localDb.createProxy({
|
||||
name: "Global Registry Proxy",
|
||||
type: "https",
|
||||
host: "registry.proxy.local",
|
||||
port: 443,
|
||||
username: "global-user",
|
||||
password: "global-pass",
|
||||
});
|
||||
|
||||
await localDb.assignProxyToScope("global", null, proxy.id);
|
||||
|
||||
const response = await settingsProxyRoute.GET(
|
||||
new Request("http://localhost/api/settings/proxy?level=global")
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.level, "global");
|
||||
assert.equal(body.proxy.type, "https");
|
||||
assert.equal(body.proxy.host, "registry.proxy.local");
|
||||
assert.equal(body.proxy.username, "global-user");
|
||||
});
|
||||
|
||||
test("critical routes: v1 models route exposes CORS and list contracts", async () => {
|
||||
const options = await v1ModelsRoute.OPTIONS();
|
||||
const response = await v1ModelsRoute.GET(
|
||||
new Request("http://localhost/api/v1/models", { method: "GET" })
|
||||
);
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(options.status, 200);
|
||||
assert.match(options.headers.get("Access-Control-Allow-Methods") || "", /GET/);
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(body.object, "list");
|
||||
assert.ok(Array.isArray(body.data));
|
||||
});
|
||||
967
tests/integration/chat-pipeline.test.mjs
Normal file
967
tests/integration/chat-pipeline.test.mjs
Normal file
@@ -0,0 +1,967 @@
|
||||
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-chat-pipeline-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.REQUIRE_API_KEY = "false";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const combosDb = await import("../../src/lib/db/combos.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
const callLogsDb = await import("../../src/lib/usage/callLogs.ts");
|
||||
const readCacheDb = await import("../../src/lib/db/readCache.ts");
|
||||
const { invalidateMemorySettingsCache } = await import("../../src/lib/memory/settings.ts");
|
||||
const { skillRegistry } = await import("../../src/lib/skills/registry.ts");
|
||||
const { skillExecutor } = await import("../../src/lib/skills/executor.ts");
|
||||
const { handleChat } = await import("../../src/sse/handlers/chat.ts");
|
||||
const { initTranslators } = await import("../../open-sse/translator/index.ts");
|
||||
const { clearInflight } = await import("../../open-sse/services/requestDedup.ts");
|
||||
const { BaseExecutor } = await import("../../open-sse/executors/base.ts");
|
||||
const { resetAllAvailability, setModelUnavailable } =
|
||||
await import("../../src/domain/modelAvailability.ts");
|
||||
const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalRetryDelayMs = BaseExecutor.RETRY_CONFIG.delayMs;
|
||||
|
||||
function toPlainHeaders(headers) {
|
||||
if (!headers) return {};
|
||||
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).map(([key, value]) => [key, value == null ? "" : String(value)])
|
||||
);
|
||||
}
|
||||
|
||||
function buildRequest({
|
||||
url = "http://localhost/v1/chat/completions",
|
||||
body,
|
||||
authKey = null,
|
||||
headers = {},
|
||||
} = {}) {
|
||||
const requestHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
...headers,
|
||||
};
|
||||
|
||||
if (authKey) {
|
||||
requestHeaders.Authorization = `Bearer ${authKey}`;
|
||||
}
|
||||
|
||||
return new Request(url, {
|
||||
method: "POST",
|
||||
headers: requestHeaders,
|
||||
body: typeof body === "string" ? body : JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function buildOpenAIResponse(text = "ok", model = "gpt-4o-mini", usage = null) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl_json",
|
||||
object: "chat.completion",
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: text },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: usage || {
|
||||
prompt_tokens: 4,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 6,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildOpenAIToolCallResponse({
|
||||
model = "gpt-4o-mini",
|
||||
toolName = "lookupWeather@1.0.0",
|
||||
toolCallId = "call_weather",
|
||||
argumentsObject = { location: "Sao Paulo" },
|
||||
} = {}) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl_tool",
|
||||
object: "chat.completion",
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: "",
|
||||
tool_calls: [
|
||||
{
|
||||
id: toolCallId,
|
||||
type: "function",
|
||||
function: {
|
||||
name: toolName,
|
||||
arguments: JSON.stringify(argumentsObject),
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
finish_reason: "tool_calls",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 6,
|
||||
completion_tokens: 4,
|
||||
total_tokens: 10,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildClaudeResponse(text = "ok", model = "claude-3-5-sonnet-20241022") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "msg_json",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model,
|
||||
content: [{ type: "text", text }],
|
||||
stop_reason: "end_turn",
|
||||
usage: {
|
||||
input_tokens: 10,
|
||||
output_tokens: 4,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildClaudeStreamResponse(text = "streamed from claude", model = "claude-sonnet-4-6") {
|
||||
return new Response(
|
||||
[
|
||||
"event: message_start",
|
||||
`data: ${JSON.stringify({
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_stream",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model,
|
||||
usage: { input_tokens: 12, output_tokens: 0 },
|
||||
},
|
||||
})}`,
|
||||
"",
|
||||
"event: content_block_start",
|
||||
`data: ${JSON.stringify({
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
})}`,
|
||||
"",
|
||||
"event: content_block_delta",
|
||||
`data: ${JSON.stringify({
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text },
|
||||
})}`,
|
||||
"",
|
||||
"event: message_delta",
|
||||
`data: ${JSON.stringify({
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn" },
|
||||
usage: { output_tokens: 3 },
|
||||
})}`,
|
||||
"",
|
||||
"event: message_stop",
|
||||
`data: ${JSON.stringify({ type: "message_stop" })}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildGeminiResponse(text = "ok", model = "gemini-2.5-flash") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
responseId: "resp_gemini",
|
||||
modelVersion: model,
|
||||
createTime: "2026-04-05T12:00:00.000Z",
|
||||
candidates: [
|
||||
{
|
||||
content: {
|
||||
parts: [{ text }],
|
||||
},
|
||||
finishReason: "STOP",
|
||||
},
|
||||
],
|
||||
usageMetadata: {
|
||||
promptTokenCount: 5,
|
||||
candidatesTokenCount: 7,
|
||||
totalTokenCount: 12,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildOpenAIStreamResponse(text = "streamed from openai") {
|
||||
return new Response(
|
||||
[
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl_stream",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ index: 0, delta: { role: "assistant", content: text } }],
|
||||
})}`,
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
].join("\n"),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
globalThis.fetch = originalFetch;
|
||||
clearInflight();
|
||||
resetAllAvailability();
|
||||
resetAllCircuitBreakers();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
readCacheDb.invalidateDbCache();
|
||||
invalidateMemorySettingsCache();
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
initTranslators();
|
||||
}
|
||||
|
||||
async function seedConnection(provider, overrides = {}) {
|
||||
return providersDb.createProviderConnection({
|
||||
provider,
|
||||
authType: "apikey",
|
||||
name: overrides.name || `${provider}-primary`,
|
||||
apiKey: overrides.apiKey || `sk-${provider}-${Math.random().toString(16).slice(2, 10)}`,
|
||||
isActive: overrides.isActive ?? true,
|
||||
testStatus: overrides.testStatus || "active",
|
||||
priority: overrides.priority,
|
||||
rateLimitedUntil: overrides.rateLimitedUntil,
|
||||
providerSpecificData: overrides.providerSpecificData || {},
|
||||
});
|
||||
}
|
||||
|
||||
async function seedApiKey({
|
||||
name = "chat-pipeline-key",
|
||||
noLog = false,
|
||||
allowedConnections,
|
||||
allowedModels,
|
||||
} = {}) {
|
||||
const key = await apiKeysDb.createApiKey(name, "machine-test");
|
||||
const updates = {};
|
||||
if (noLog) updates.noLog = true;
|
||||
if (allowedConnections) updates.allowedConnections = allowedConnections;
|
||||
if (allowedModels) updates.allowedModels = allowedModels;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await apiKeysDb.updateApiKeyPermissions(key.id, updates);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function ensureLegacyMemoryTable() {
|
||||
const db = core.getDbInstance();
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS memory (
|
||||
id TEXT PRIMARY KEY,
|
||||
apiKeyId TEXT NOT NULL,
|
||||
sessionId TEXT,
|
||||
type TEXT NOT NULL,
|
||||
key TEXT,
|
||||
content TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
expiresAt TEXT
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
function insertLegacyMemory(apiKeyId, content) {
|
||||
ensureLegacyMemoryTable();
|
||||
const db = core.getDbInstance();
|
||||
const now = new Date().toISOString();
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO memory (
|
||||
id, apiKeyId, sessionId, type, key, content, metadata, createdAt, updatedAt, expiresAt
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
).run(
|
||||
`mem_${Math.random().toString(16).slice(2, 10)}`,
|
||||
apiKeyId,
|
||||
"",
|
||||
"factual",
|
||||
"pref",
|
||||
content,
|
||||
"{}",
|
||||
now,
|
||||
now,
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
async function waitFor(fn, timeoutMs = 1500) {
|
||||
const startedAt = Date.now();
|
||||
while (Date.now() - startedAt < timeoutMs) {
|
||||
const result = await fn();
|
||||
if (result) return result;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function getLatestCallLog() {
|
||||
const rows = await callLogsDb.getCallLogs({ limit: 5 });
|
||||
if (!Array.isArray(rows) || rows.length === 0) return null;
|
||||
return callLogsDb.getCallLogById(rows[0].id);
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = 0;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = originalRetryDelayMs;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = originalRetryDelayMs;
|
||||
globalThis.fetch = originalFetch;
|
||||
clearInflight();
|
||||
resetAllAvailability();
|
||||
resetAllCircuitBreakers();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("chat pipeline handles OpenAI passthrough with valid API key auth", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-primary" });
|
||||
const apiKey = await seedApiKey();
|
||||
const fetchCalls = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
method: init.method || "GET",
|
||||
headers: toPlainHeaders(init.headers),
|
||||
body: init.body ? JSON.parse(String(init.body)) : null,
|
||||
});
|
||||
return buildOpenAIResponse("OpenAI passthrough");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
authKey: apiKey.key,
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Hello OpenAI" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.match(fetchCalls[0].url, /\/chat\/completions$/);
|
||||
assert.equal(fetchCalls[0].headers.Authorization, "Bearer sk-openai-primary");
|
||||
assert.equal(fetchCalls[0].body.messages[0].content, "Hello OpenAI");
|
||||
assert.equal(json.choices[0].message.content, "OpenAI passthrough");
|
||||
});
|
||||
|
||||
test("chat pipeline translates OpenAI requests to Claude and returns OpenAI-shaped responses", async () => {
|
||||
await seedConnection("claude", { apiKey: "sk-claude-primary" });
|
||||
const fetchCalls = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
body: init.body ? JSON.parse(String(init.body)) : null,
|
||||
});
|
||||
return buildClaudeResponse("Claude translated reply");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "claude/claude-3-5-sonnet-20241022",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Hello Claude" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.match(fetchCalls[0].url, /\?beta=true$/);
|
||||
assert.equal(fetchCalls[0].headers["x-api-key"], "sk-claude-primary");
|
||||
assert.equal(fetchCalls[0].body.messages[0].role, "user");
|
||||
assert.equal(fetchCalls[0].body.messages[0].content[0].text, "Hello Claude");
|
||||
assert.equal(json.object, "chat.completion");
|
||||
assert.equal(json.choices[0].message.content, "Claude translated reply");
|
||||
});
|
||||
|
||||
test("chat pipeline translates OpenAI requests to Gemini and returns OpenAI-shaped responses", async () => {
|
||||
await seedConnection("gemini", { apiKey: "sk-gemini-primary" });
|
||||
const fetchCalls = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
body: init.body ? JSON.parse(String(init.body)) : null,
|
||||
});
|
||||
return buildGeminiResponse("Gemini translated reply");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "gemini/gemini-2.5-flash",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Hello Gemini" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.match(fetchCalls[0].url, /generateContent$/);
|
||||
assert.equal(fetchCalls[0].headers["x-goog-api-key"], "sk-gemini-primary");
|
||||
assert.equal(fetchCalls[0].body.contents[0].role, "user");
|
||||
assert.equal(fetchCalls[0].body.contents[0].parts[0].text, "Hello Gemini");
|
||||
assert.equal(json.object, "chat.completion");
|
||||
assert.equal(json.choices[0].message.content, "Gemini translated reply");
|
||||
});
|
||||
|
||||
test("chat pipeline translates Claude-format requests into OpenAI upstream and back to Claude", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-claude-route" });
|
||||
const fetchCalls = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
body: init.body ? JSON.parse(String(init.body)) : null,
|
||||
});
|
||||
return buildOpenAIResponse("OpenAI answered Claude client");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
url: "http://localhost/v1/messages",
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
max_tokens: 128,
|
||||
system: [{ text: "Be brief" }],
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "Hello from Claude client" }] }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.match(fetchCalls[0].url, /\/chat\/completions$/);
|
||||
assert.equal(fetchCalls[0].body.messages[0].role, "system");
|
||||
assert.equal(fetchCalls[0].body.messages[0].content, "Be brief");
|
||||
assert.equal(fetchCalls[0].body.messages[1].content, "Hello from Claude client");
|
||||
assert.equal(json.type, "message");
|
||||
assert.equal(json.role, "assistant");
|
||||
assert.equal(json.content[0].text, "OpenAI answered Claude client");
|
||||
});
|
||||
|
||||
test("chat pipeline converts Claude SSE streams into OpenAI SSE output", async () => {
|
||||
await seedConnection("claude", { apiKey: "sk-claude-stream" });
|
||||
|
||||
globalThis.fetch = async () => buildClaudeStreamResponse("Streamed Claude chunk");
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "claude/claude-sonnet-4-6",
|
||||
stream: true,
|
||||
messages: [{ role: "user", content: "Stream this" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const raw = await response.text();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("Content-Type"), "text/event-stream");
|
||||
assert.match(raw, /chat\.completion\.chunk/);
|
||||
assert.match(raw, /Streamed Claude chunk/);
|
||||
assert.match(raw, /\[DONE\]/);
|
||||
});
|
||||
|
||||
test("chat pipeline rejects invalid API keys and malformed JSON bodies", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-invalid-key-path" });
|
||||
|
||||
const invalidKeyResponse = await handleChat(
|
||||
buildRequest({
|
||||
authKey: "does-not-exist",
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
const invalidKeyJson = await invalidKeyResponse.json();
|
||||
|
||||
const invalidJsonResponse = await handleChat(
|
||||
new Request("http://localhost/v1/chat/completions", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{bad-json",
|
||||
})
|
||||
);
|
||||
const invalidJson = await invalidJsonResponse.json();
|
||||
|
||||
assert.equal(invalidKeyResponse.status, 401);
|
||||
assert.match(invalidKeyJson.error.message, /Invalid API key/i);
|
||||
assert.equal(invalidJsonResponse.status, 400);
|
||||
assert.match(invalidJson.error.message, /Invalid JSON body/i);
|
||||
});
|
||||
|
||||
test("chat pipeline supports local mode without Authorization on explicit combos", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-local-combo" });
|
||||
await combosDb.createCombo({
|
||||
name: "local-router",
|
||||
strategy: "priority",
|
||||
models: ["openai/gpt-4o-mini"],
|
||||
});
|
||||
const fetchCalls = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
});
|
||||
return buildOpenAIResponse("Local combo route");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "local-router",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "No auth header here" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(json.choices[0].message.content, "Local combo route");
|
||||
});
|
||||
|
||||
test("chat pipeline honors noLog by redacting persisted call log payloads", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-no-log" });
|
||||
const apiKey = await seedApiKey({ noLog: true });
|
||||
|
||||
globalThis.fetch = async () => buildOpenAIResponse("No-log reply");
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
authKey: apiKey.key,
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Do not persist payloads" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
const callLog = await waitFor(() => getLatestCallLog());
|
||||
assert.ok(callLog, "expected a call log row to be created");
|
||||
assert.equal(callLog.apiKeyId, apiKey.id);
|
||||
assert.equal(callLog.requestBody, null);
|
||||
assert.equal(callLog.responseBody, null);
|
||||
assert.equal(callLog.artifactRelPath, null);
|
||||
});
|
||||
|
||||
test("chat pipeline returns current no-credentials contract when no provider connection exists", async () => {
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(json.error.message, /No credentials for provider: openai/);
|
||||
});
|
||||
|
||||
test("chat pipeline returns 503 when the requested model is temporarily unavailable", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-unavailable" });
|
||||
setModelUnavailable("openai", "gpt-4o-mini", 60000, "test cooldown");
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Provider unavailable" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
assert.equal(response.status, 503);
|
||||
assert.ok(Number(response.headers.get("Retry-After")) >= 1);
|
||||
assert.match(json.error.message, /temporarily unavailable/i);
|
||||
});
|
||||
|
||||
test("chat pipeline surfaces upstream 500 responses as structured errors", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-500" });
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(JSON.stringify({ error: { message: "provider exploded" } }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Trigger 500" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
assert.equal(response.status, 500);
|
||||
assert.match(json.error.message, /\[500\]: provider exploded/);
|
||||
});
|
||||
|
||||
test("chat pipeline returns 429 with Retry-After when the upstream rate-limits the only account", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-429" });
|
||||
let attempts = 0;
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
attempts += 1;
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: {
|
||||
message: "Rate limit exceeded. Your quota will reset after 30s.",
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Trigger 429" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
assert.equal(response.status, 429);
|
||||
assert.ok(attempts >= 1, "expected at least one upstream attempt");
|
||||
assert.ok(Number(response.headers.get("Retry-After")) >= 1);
|
||||
assert.match(json.error.message, /\[openai\/gpt-4o-mini\]/);
|
||||
});
|
||||
|
||||
test("chat pipeline maps upstream timeouts to 504 responses", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-timeout" });
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
const error = new Error("upstream timed out");
|
||||
error.name = "TimeoutError";
|
||||
throw error;
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Trigger timeout" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
assert.equal(response.status, 504);
|
||||
assert.match(json.error.message, /\[504\]: upstream timed out/);
|
||||
});
|
||||
|
||||
test("chat pipeline injects memory context before sending the upstream request", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-memory" });
|
||||
const apiKey = await seedApiKey();
|
||||
await settingsDb.updateSettings({
|
||||
memoryEnabled: true,
|
||||
memoryMaxTokens: 400,
|
||||
memoryRetentionDays: 30,
|
||||
memoryStrategy: "recent",
|
||||
});
|
||||
invalidateMemorySettingsCache();
|
||||
insertLegacyMemory(apiKey.id, "User prefers concise answers.");
|
||||
|
||||
const fetchCalls = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
body: init.body ? JSON.parse(String(init.body)) : null,
|
||||
});
|
||||
return buildOpenAIResponse("Memory-aware reply");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
authKey: apiKey.key,
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Summarize my preference" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(fetchCalls[0].body.messages[0].role, "system");
|
||||
assert.match(fetchCalls[0].body.messages[0].content, /User prefers concise answers/);
|
||||
assert.equal(json.choices[0].message.content, "Memory-aware reply");
|
||||
});
|
||||
|
||||
test("chat pipeline injects skills into tools and intercepts tool calls with skill output", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-skills" });
|
||||
const apiKey = await seedApiKey();
|
||||
await settingsDb.updateSettings({ skillsEnabled: true });
|
||||
invalidateMemorySettingsCache();
|
||||
|
||||
const handlerName = `weather-handler-${Date.now()}`;
|
||||
skillExecutor.registerHandler(handlerName, async (input) => ({
|
||||
forecast: `Sunny in ${input.location}`,
|
||||
}));
|
||||
|
||||
await skillRegistry.register({
|
||||
apiKeyId: apiKey.id,
|
||||
name: "lookupWeather",
|
||||
version: "1.0.0",
|
||||
description: "Return a canned forecast",
|
||||
schema: {
|
||||
input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
location: { type: "string" },
|
||||
},
|
||||
},
|
||||
output: {
|
||||
type: "object",
|
||||
},
|
||||
},
|
||||
handler: handlerName,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const fetchCalls = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
body: init.body ? JSON.parse(String(init.body)) : null,
|
||||
});
|
||||
return buildOpenAIToolCallResponse();
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
authKey: apiKey.key,
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Check the weather" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.ok(Array.isArray(fetchCalls[0].body.tools));
|
||||
assert.equal(fetchCalls[0].body.tools[0].function.name, "lookupWeather@1.0.0");
|
||||
assert.equal(json.choices[0].finish_reason, "tool_calls");
|
||||
assert.equal(json.tool_results[0].tool_call_id, "call_weather");
|
||||
assert.equal(JSON.parse(json.tool_results[0].output).forecast, "Sunny in Sao Paulo");
|
||||
});
|
||||
|
||||
test("chat pipeline falls back to the next account after a provider failure", async () => {
|
||||
await seedConnection("openai", {
|
||||
name: "openai-primary",
|
||||
apiKey: "sk-openai-primary-fallback",
|
||||
priority: 1,
|
||||
});
|
||||
await seedConnection("openai", {
|
||||
name: "openai-secondary",
|
||||
apiKey: "sk-openai-secondary-fallback",
|
||||
priority: 2,
|
||||
});
|
||||
const seenAuthHeaders = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const headers = toPlainHeaders(init.headers);
|
||||
seenAuthHeaders.push(headers.Authorization);
|
||||
if (seenAuthHeaders.length === 1) {
|
||||
return new Response(JSON.stringify({ error: { message: "first account failed" } }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return buildOpenAIResponse("Second account succeeded");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Use account fallback" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(seenAuthHeaders, [
|
||||
"Bearer sk-openai-primary-fallback",
|
||||
"Bearer sk-openai-secondary-fallback",
|
||||
]);
|
||||
assert.equal(json.choices[0].message.content, "Second account succeeded");
|
||||
});
|
||||
|
||||
test("chat pipeline falls back across combo models when the first provider fails", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-combo-fail" });
|
||||
await seedConnection("claude", { apiKey: "sk-claude-combo-fail" });
|
||||
await combosDb.createCombo({
|
||||
name: "combo-fallback",
|
||||
strategy: "priority",
|
||||
config: { maxRetries: 0, retryDelayMs: 0 },
|
||||
models: ["openai/gpt-4o-mini", "claude/claude-3-5-sonnet-20241022"],
|
||||
});
|
||||
const attempts = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const call = {
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
};
|
||||
attempts.push(call);
|
||||
if (attempts.length === 1) {
|
||||
return new Response(JSON.stringify({ error: { message: "openai combo miss" } }), {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return buildClaudeResponse("Claude combo fallback");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: {
|
||||
model: "combo-fallback",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Use combo fallback" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const json = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(attempts.length, 2);
|
||||
assert.match(attempts[0].url, /\/chat\/completions$/);
|
||||
assert.match(attempts[1].url, /\?beta=true$/);
|
||||
assert.equal(json.choices[0].message.content, "Claude combo fallback");
|
||||
});
|
||||
|
||||
test("chat pipeline deduplicates concurrent identical non-stream requests", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-dedup" });
|
||||
let fetchCount = 0;
|
||||
|
||||
globalThis.fetch = async () => {
|
||||
fetchCount += 1;
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
return buildOpenAIResponse("Deduplicated response");
|
||||
};
|
||||
|
||||
const requestA = buildRequest({
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
temperature: 0,
|
||||
messages: [{ role: "user", content: "Deduplicate this request" }],
|
||||
},
|
||||
});
|
||||
const requestB = buildRequest({
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
temperature: 0,
|
||||
messages: [{ role: "user", content: "Deduplicate this request" }],
|
||||
},
|
||||
});
|
||||
|
||||
const [responseA, responseB] = await Promise.all([handleChat(requestA), handleChat(requestB)]);
|
||||
const [jsonA, jsonB] = await Promise.all([responseA.json(), responseB.json()]);
|
||||
|
||||
assert.equal(responseA.status, 200);
|
||||
assert.equal(responseB.status, 200);
|
||||
assert.equal(fetchCount, 1);
|
||||
assert.equal(jsonA.choices[0].message.content, "Deduplicated response");
|
||||
assert.equal(jsonB.choices[0].message.content, "Deduplicated response");
|
||||
});
|
||||
302
tests/integration/combo-routing-e2e.test.mjs
Normal file
302
tests/integration/combo-routing-e2e.test.mjs
Normal file
@@ -0,0 +1,302 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createChatPipelineHarness } from "./_chatPipelineHarness.mjs";
|
||||
|
||||
const harness = await createChatPipelineHarness("combo-routing");
|
||||
const {
|
||||
BaseExecutor,
|
||||
buildClaudeResponse,
|
||||
buildGeminiResponse,
|
||||
buildOpenAIResponse,
|
||||
buildRequest,
|
||||
combosDb,
|
||||
handleChat,
|
||||
modelComboMappingsDb,
|
||||
resetStorage,
|
||||
seedConnection,
|
||||
toPlainHeaders,
|
||||
} = harness;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = 0;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = harness.originalRetryDelayMs;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
await harness.cleanup();
|
||||
});
|
||||
|
||||
function buildOpenAIChatBody(model, content = `Route ${model}`) {
|
||||
return {
|
||||
model,
|
||||
stream: false,
|
||||
messages: [{ role: "user", content }],
|
||||
};
|
||||
}
|
||||
|
||||
test("combo routes requests by exact combo name", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-combo-exact" });
|
||||
await combosDb.createCombo({
|
||||
name: "router-priority",
|
||||
strategy: "priority",
|
||||
models: ["openai/gpt-4o-mini"],
|
||||
});
|
||||
|
||||
const fetchCalls = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
});
|
||||
return buildOpenAIResponse("Exact combo route");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: buildOpenAIChatBody("router-priority"),
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.match(fetchCalls[0].url, /\/chat\/completions$/);
|
||||
assert.equal(fetchCalls[0].headers.Authorization, "Bearer sk-openai-combo-exact");
|
||||
assert.equal(json.choices[0].message.content, "Exact combo route");
|
||||
});
|
||||
|
||||
test("round-robin combo cycles through three providers", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-rr" });
|
||||
await seedConnection("claude", { apiKey: "sk-claude-rr" });
|
||||
await seedConnection("gemini", { apiKey: "sk-gemini-rr" });
|
||||
await combosDb.createCombo({
|
||||
name: "router-rr",
|
||||
strategy: "round-robin",
|
||||
config: { maxRetries: 0, retryDelayMs: 0 },
|
||||
models: ["openai/gpt-4o-mini", "claude/claude-3-5-sonnet-20241022", "gemini/gemini-2.5-flash"],
|
||||
});
|
||||
|
||||
const seenProviders = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
const target = String(url);
|
||||
if (target.includes("/chat/completions")) {
|
||||
seenProviders.push("openai");
|
||||
return buildOpenAIResponse("OpenAI round-robin");
|
||||
}
|
||||
if (target.includes("?beta=true")) {
|
||||
seenProviders.push("claude");
|
||||
return buildClaudeResponse("Claude round-robin");
|
||||
}
|
||||
seenProviders.push("gemini");
|
||||
return buildGeminiResponse("Gemini round-robin");
|
||||
};
|
||||
|
||||
const first = await handleChat(buildRequest({ body: buildOpenAIChatBody("router-rr") }));
|
||||
const second = await handleChat(buildRequest({ body: buildOpenAIChatBody("router-rr") }));
|
||||
const third = await handleChat(buildRequest({ body: buildOpenAIChatBody("router-rr") }));
|
||||
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(second.status, 200);
|
||||
assert.equal(third.status, 200);
|
||||
assert.deepEqual(seenProviders, ["openai", "claude", "gemini"]);
|
||||
});
|
||||
|
||||
test("priority combo sticks to the primary model while healthy", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-priority" });
|
||||
await seedConnection("claude", { apiKey: "sk-claude-priority" });
|
||||
await combosDb.createCombo({
|
||||
name: "router-priority-healthy",
|
||||
strategy: "priority",
|
||||
models: ["openai/gpt-4o-mini", "claude/claude-3-5-sonnet-20241022"],
|
||||
});
|
||||
|
||||
const seenTargets = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
seenTargets.push(String(url));
|
||||
return buildOpenAIResponse("Primary stayed active");
|
||||
};
|
||||
|
||||
const first = await handleChat(
|
||||
buildRequest({
|
||||
body: buildOpenAIChatBody("router-priority-healthy", "Route priority first"),
|
||||
})
|
||||
);
|
||||
const second = await handleChat(
|
||||
buildRequest({
|
||||
body: buildOpenAIChatBody("router-priority-healthy", "Route priority second"),
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(first.status, 200);
|
||||
assert.equal(second.status, 200);
|
||||
assert.equal(seenTargets.length, 2);
|
||||
assert.ok(seenTargets.every((target) => target.includes("/chat/completions")));
|
||||
});
|
||||
|
||||
test("priority combo falls back to the secondary model when the first one fails", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-fallback" });
|
||||
await seedConnection("claude", { apiKey: "sk-claude-fallback" });
|
||||
await combosDb.createCombo({
|
||||
name: "router-fallback",
|
||||
strategy: "priority",
|
||||
config: { maxRetries: 0, retryDelayMs: 0 },
|
||||
models: ["openai/gpt-4o-mini", "claude/claude-3-5-sonnet-20241022"],
|
||||
});
|
||||
|
||||
const attempts = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
const target = String(url);
|
||||
attempts.push(target);
|
||||
if (attempts.length === 1) {
|
||||
return new Response(JSON.stringify({ error: { message: "primary down" } }), {
|
||||
status: 503,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
return buildClaudeResponse("Fallback answered");
|
||||
};
|
||||
|
||||
const response = await handleChat(buildRequest({ body: buildOpenAIChatBody("router-fallback") }));
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(attempts.length, 2);
|
||||
assert.match(attempts[0], /\/chat\/completions$/);
|
||||
assert.match(attempts[1], /\?beta=true$/);
|
||||
assert.equal(json.choices[0].message.content, "Fallback answered");
|
||||
});
|
||||
|
||||
test("model combo mappings route explicit model ids through the configured combo", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-mapped" });
|
||||
const combo = await combosDb.createCombo({
|
||||
name: "mapped-router",
|
||||
strategy: "priority",
|
||||
models: ["openai/gpt-4o-mini"],
|
||||
});
|
||||
await modelComboMappingsDb.createModelComboMapping({
|
||||
pattern: "tenant/mapped-model",
|
||||
comboId: combo.id,
|
||||
priority: 100,
|
||||
});
|
||||
|
||||
const fetchCalls = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
});
|
||||
return buildOpenAIResponse("Mapped combo route");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: buildOpenAIChatBody("tenant/mapped-model"),
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(fetchCalls[0].headers.Authorization, "Bearer sk-openai-mapped");
|
||||
assert.equal(json.choices[0].message.content, "Mapped combo route");
|
||||
});
|
||||
|
||||
test("wildcard model combo mappings resolve arbitrary matching models", async () => {
|
||||
await seedConnection("gemini", { apiKey: "sk-gemini-wild" });
|
||||
const combo = await combosDb.createCombo({
|
||||
name: "wild-router",
|
||||
strategy: "priority",
|
||||
models: ["gemini/gemini-2.5-flash"],
|
||||
});
|
||||
await modelComboMappingsDb.createModelComboMapping({
|
||||
pattern: "tenant/*",
|
||||
comboId: combo.id,
|
||||
priority: 10,
|
||||
});
|
||||
|
||||
const fetchCalls = [];
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
fetchCalls.push({
|
||||
url: String(url),
|
||||
headers: toPlainHeaders(init.headers),
|
||||
});
|
||||
return buildGeminiResponse("Wildcard combo route");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: buildOpenAIChatBody("tenant/any-model-name"),
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.match(fetchCalls[0].url, /generateContent$/);
|
||||
assert.equal(fetchCalls[0].headers["x-goog-api-key"], "sk-gemini-wild");
|
||||
assert.equal(json.choices[0].message.content, "Wildcard combo route");
|
||||
});
|
||||
|
||||
test("unmapped custom model requests fail after combo resolution falls through", async () => {
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
body: buildOpenAIChatBody("tenant/unmapped-model"),
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.match(json.error.message, /No credentials for provider: tenant/);
|
||||
});
|
||||
|
||||
test("strategy updates take effect for later requests on the same combo name", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-update" });
|
||||
await seedConnection("claude", { apiKey: "sk-claude-update" });
|
||||
const combo = await combosDb.createCombo({
|
||||
name: "router-dynamic",
|
||||
strategy: "priority",
|
||||
models: ["openai/gpt-4o-mini", "claude/claude-3-5-sonnet-20241022"],
|
||||
});
|
||||
|
||||
const seenProviders = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
const target = String(url);
|
||||
if (target.includes("?beta=true")) {
|
||||
seenProviders.push("claude");
|
||||
return buildClaudeResponse("Claude after update");
|
||||
}
|
||||
seenProviders.push("openai");
|
||||
return buildOpenAIResponse("OpenAI before update");
|
||||
};
|
||||
|
||||
const initial = await handleChat(
|
||||
buildRequest({
|
||||
body: buildOpenAIChatBody("router-dynamic", "Route dynamic initial"),
|
||||
})
|
||||
);
|
||||
await combosDb.updateCombo(combo.id, {
|
||||
strategy: "round-robin",
|
||||
config: { maxRetries: 0, retryDelayMs: 0 },
|
||||
});
|
||||
const second = await handleChat(
|
||||
buildRequest({
|
||||
body: buildOpenAIChatBody("router-dynamic", "Route dynamic second"),
|
||||
})
|
||||
);
|
||||
const third = await handleChat(
|
||||
buildRequest({
|
||||
body: buildOpenAIChatBody("router-dynamic", "Route dynamic third"),
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(initial.status, 200);
|
||||
assert.equal(second.status, 200);
|
||||
assert.equal(third.status, 200);
|
||||
assert.deepEqual(seenProviders, ["openai", "openai", "claude"]);
|
||||
});
|
||||
344
tests/integration/memory-pipeline.test.mjs
Normal file
344
tests/integration/memory-pipeline.test.mjs
Normal file
@@ -0,0 +1,344 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createChatPipelineHarness } from "./_chatPipelineHarness.mjs";
|
||||
|
||||
const harness = await createChatPipelineHarness("memory-pipeline");
|
||||
const {
|
||||
BaseExecutor,
|
||||
buildOpenAIResponse,
|
||||
buildRequest,
|
||||
handleChat,
|
||||
memoryStore,
|
||||
memoryTools,
|
||||
resetStorage,
|
||||
seedApiKey,
|
||||
seedConnection,
|
||||
settingsDb,
|
||||
waitFor,
|
||||
} = harness;
|
||||
|
||||
const { createMemory, listMemories } = memoryStore;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = 0;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = harness.originalRetryDelayMs;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
await harness.cleanup();
|
||||
});
|
||||
|
||||
async function enableMemory(maxTokens = 400) {
|
||||
await settingsDb.updateSettings({
|
||||
memoryEnabled: true,
|
||||
memoryMaxTokens: maxTokens,
|
||||
memoryRetentionDays: 30,
|
||||
memoryStrategy: "recent",
|
||||
});
|
||||
}
|
||||
|
||||
test("first request proceeds without injected context when the store is empty", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-memory-empty" });
|
||||
const apiKey = await seedApiKey();
|
||||
await enableMemory();
|
||||
|
||||
const fetchCalls = [];
|
||||
globalThis.fetch = async (_url, init = {}) => {
|
||||
fetchCalls.push(init.body ? JSON.parse(String(init.body)) : null);
|
||||
return buildOpenAIResponse("No memory yet");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
authKey: apiKey.key,
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "First turn" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(fetchCalls[0].messages[0].role, "user");
|
||||
assert.equal(fetchCalls[0].messages[0].content, "First turn");
|
||||
});
|
||||
|
||||
test("successful responses extract facts and persist them as memories", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-extract" });
|
||||
const apiKey = await seedApiKey();
|
||||
await enableMemory();
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
buildOpenAIResponse("I prefer concise answers. I usually answer in bullet points.");
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
authKey: apiKey.key,
|
||||
headers: { "x-omniroute-session-id": "session-extract" },
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Remember my preferences" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const memories = await waitFor(async () => {
|
||||
const rows = await listMemories({ apiKeyId: apiKey.id });
|
||||
return rows.length >= 2 ? rows : null;
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(memories, "expected extracted memories to be stored");
|
||||
assert.ok(memories.some((memory) => /concise answers/i.test(memory.content)));
|
||||
assert.ok(memories.some((memory) => /bullet points/i.test(memory.content)));
|
||||
assert.ok(memories.every((memory) => memory.sessionId === "session-extract"));
|
||||
});
|
||||
|
||||
test("later requests inject retrieved memories into upstream messages", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-inject" });
|
||||
const apiKey = await seedApiKey();
|
||||
await enableMemory();
|
||||
|
||||
await createMemory({
|
||||
apiKeyId: apiKey.id,
|
||||
sessionId: "session-inject",
|
||||
type: "factual",
|
||||
key: "preference:concise",
|
||||
content: "User prefers concise answers.",
|
||||
metadata: {},
|
||||
expiresAt: null,
|
||||
});
|
||||
|
||||
const fetchCalls = [];
|
||||
globalThis.fetch = async (_url, init = {}) => {
|
||||
fetchCalls.push(init.body ? JSON.parse(String(init.body)) : null);
|
||||
return buildOpenAIResponse("Memory injected");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
authKey: apiKey.key,
|
||||
headers: { "x-omniroute-session-id": "session-inject" },
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "What do you remember?" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.equal(fetchCalls[0].messages[0].role, "system");
|
||||
assert.match(fetchCalls[0].messages[0].content, /User prefers concise answers/);
|
||||
});
|
||||
|
||||
test("memory search ranks query-relevant memories first", async () => {
|
||||
const apiKey = await seedApiKey();
|
||||
|
||||
await memoryTools.omniroute_memory_add.handler({
|
||||
apiKeyId: apiKey.id,
|
||||
sessionId: "search",
|
||||
type: "factual",
|
||||
key: "pref:language",
|
||||
content: "The user writes TypeScript services every day.",
|
||||
metadata: {},
|
||||
});
|
||||
await memoryTools.omniroute_memory_add.handler({
|
||||
apiKeyId: apiKey.id,
|
||||
sessionId: "search",
|
||||
type: "factual",
|
||||
key: "pref:hobby",
|
||||
content: "The user enjoys gardening on weekends.",
|
||||
metadata: {},
|
||||
});
|
||||
await memoryTools.omniroute_memory_add.handler({
|
||||
apiKeyId: apiKey.id,
|
||||
sessionId: "search",
|
||||
type: "factual",
|
||||
key: "pref:stack",
|
||||
content: "TypeScript and Node.js are the preferred backend stack.",
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
const result = await memoryTools.omniroute_memory_search.handler({
|
||||
apiKeyId: apiKey.id,
|
||||
query: "typescript backend",
|
||||
limit: 2,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.data.count, 2);
|
||||
assert.match(result.data.memories[0].content, /TypeScript/i);
|
||||
assert.ok(result.data.memories.every((memory) => /TypeScript|backend/i.test(memory.content)));
|
||||
});
|
||||
|
||||
test("memory injection respects the configured token budget", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-budget" });
|
||||
const apiKey = await seedApiKey();
|
||||
await enableMemory(20);
|
||||
|
||||
await createMemory({
|
||||
apiKeyId: apiKey.id,
|
||||
sessionId: "budget",
|
||||
type: "factual",
|
||||
key: "older",
|
||||
content: "Older preference that should be trimmed when the context budget is tight.",
|
||||
metadata: {},
|
||||
expiresAt: null,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
await createMemory({
|
||||
apiKeyId: apiKey.id,
|
||||
sessionId: "budget",
|
||||
type: "factual",
|
||||
key: "newer",
|
||||
content: "Newest preference should fit first.",
|
||||
metadata: {},
|
||||
expiresAt: null,
|
||||
});
|
||||
|
||||
const fetchCalls = [];
|
||||
globalThis.fetch = async (_url, init = {}) => {
|
||||
fetchCalls.push(init.body ? JSON.parse(String(init.body)) : null);
|
||||
return buildOpenAIResponse("Budget respected");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
authKey: apiKey.key,
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Use only the relevant memory." }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls.length, 1);
|
||||
assert.match(fetchCalls[0].messages[0].content, /Newest preference should fit first/);
|
||||
assert.doesNotMatch(fetchCalls[0].messages[0].content, /Older preference that should be trimmed/);
|
||||
});
|
||||
|
||||
test("disabled memory skips both extraction and injection", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-memory-off" });
|
||||
const apiKey = await seedApiKey();
|
||||
await settingsDb.updateSettings({
|
||||
memoryEnabled: false,
|
||||
memoryMaxTokens: 400,
|
||||
memoryRetentionDays: 30,
|
||||
memoryStrategy: "recent",
|
||||
});
|
||||
|
||||
const fetchCalls = [];
|
||||
globalThis.fetch = async (_url, init = {}) => {
|
||||
fetchCalls.push(init.body ? JSON.parse(String(init.body)) : null);
|
||||
return buildOpenAIResponse("I prefer dark mode.");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
authKey: apiKey.key,
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "This should not be remembered." }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const memories = await waitFor(async () => {
|
||||
const rows = await listMemories({ apiKeyId: apiKey.id });
|
||||
return rows.length > 0 ? rows : [];
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(fetchCalls[0].messages[0].role, "user");
|
||||
assert.deepEqual(memories, []);
|
||||
});
|
||||
|
||||
test("memory clear removes all stored memories for an API key", async () => {
|
||||
const apiKey = await seedApiKey();
|
||||
|
||||
await memoryTools.omniroute_memory_add.handler({
|
||||
apiKeyId: apiKey.id,
|
||||
sessionId: "clear",
|
||||
type: "factual",
|
||||
key: "pref:one",
|
||||
content: "First memory",
|
||||
metadata: {},
|
||||
});
|
||||
await memoryTools.omniroute_memory_add.handler({
|
||||
apiKeyId: apiKey.id,
|
||||
sessionId: "clear",
|
||||
type: "episodic",
|
||||
key: "event:two",
|
||||
content: "Second memory",
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
const cleared = await memoryTools.omniroute_memory_clear.handler({
|
||||
apiKeyId: apiKey.id,
|
||||
});
|
||||
const remaining = await listMemories({ apiKeyId: apiKey.id });
|
||||
|
||||
assert.equal(cleared.success, true);
|
||||
assert.equal(cleared.data.deletedCount, 2);
|
||||
assert.equal(remaining.length, 0);
|
||||
});
|
||||
|
||||
test("extracted memories remain isolated by session id", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-session-memory" });
|
||||
const apiKey = await seedApiKey();
|
||||
await enableMemory();
|
||||
|
||||
globalThis.fetch = async () => buildOpenAIResponse("I prefer tea.");
|
||||
await handleChat(
|
||||
buildRequest({
|
||||
authKey: apiKey.key,
|
||||
headers: { "x-omniroute-session-id": "session-a" },
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Remember drink A" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
globalThis.fetch = async () => buildOpenAIResponse("I prefer coffee.");
|
||||
await handleChat(
|
||||
buildRequest({
|
||||
authKey: apiKey.key,
|
||||
headers: { "x-omniroute-session-id": "session-b" },
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Remember drink B" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const sessionAMemories = await waitFor(async () => {
|
||||
const rows = await listMemories({ apiKeyId: apiKey.id, sessionId: "session-a" });
|
||||
return rows.length > 0 ? rows : null;
|
||||
});
|
||||
const sessionBMemories = await waitFor(async () => {
|
||||
const rows = await listMemories({ apiKeyId: apiKey.id, sessionId: "session-b" });
|
||||
return rows.length > 0 ? rows : null;
|
||||
});
|
||||
|
||||
assert.ok(sessionAMemories, "expected session A memories");
|
||||
assert.ok(sessionBMemories, "expected session B memories");
|
||||
assert.ok(sessionAMemories.every((memory) => /tea/i.test(memory.content)));
|
||||
assert.ok(sessionBMemories.every((memory) => /coffee/i.test(memory.content)));
|
||||
});
|
||||
362
tests/integration/skills-pipeline.test.mjs
Normal file
362
tests/integration/skills-pipeline.test.mjs
Normal file
@@ -0,0 +1,362 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { createChatPipelineHarness } from "./_chatPipelineHarness.mjs";
|
||||
|
||||
const harness = await createChatPipelineHarness("skills-pipeline");
|
||||
const {
|
||||
BaseExecutor,
|
||||
buildOpenAIResponse,
|
||||
buildOpenAIToolCallResponse,
|
||||
buildRequest,
|
||||
builtinsModule,
|
||||
handleChat,
|
||||
resetStorage,
|
||||
sandboxModule,
|
||||
seedApiKey,
|
||||
seedConnection,
|
||||
settingsDb,
|
||||
skillByIdRouteModule,
|
||||
skillExecutor,
|
||||
skillRegistry,
|
||||
skillsRouteModule,
|
||||
} = harness;
|
||||
|
||||
const { registerBuiltinSkills } = builtinsModule;
|
||||
const { sandboxRunner } = sandboxModule;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = 0;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.afterEach(async () => {
|
||||
BaseExecutor.RETRY_CONFIG.delayMs = harness.originalRetryDelayMs;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
await harness.cleanup();
|
||||
});
|
||||
|
||||
async function enableSkills() {
|
||||
await settingsDb.updateSettings({ skillsEnabled: true });
|
||||
}
|
||||
|
||||
async function registerSkill({
|
||||
apiKeyId,
|
||||
name,
|
||||
version = "1.0.0",
|
||||
handler,
|
||||
enabled = true,
|
||||
description = "Test skill",
|
||||
}) {
|
||||
return skillRegistry.register({
|
||||
apiKeyId,
|
||||
name,
|
||||
version,
|
||||
description,
|
||||
schema: {
|
||||
input: {
|
||||
type: "object",
|
||||
properties: {
|
||||
location: { type: "string" },
|
||||
path: { type: "string" },
|
||||
},
|
||||
},
|
||||
output: {
|
||||
type: "object",
|
||||
},
|
||||
},
|
||||
handler,
|
||||
enabled,
|
||||
});
|
||||
}
|
||||
|
||||
test("skills API lists registered skills", async () => {
|
||||
const apiKey = await seedApiKey();
|
||||
await registerSkill({
|
||||
apiKeyId: apiKey.id,
|
||||
name: "lookupWeather",
|
||||
handler: "weather-handler-list",
|
||||
});
|
||||
|
||||
const response = await skillsRouteModule.GET();
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(Array.isArray(json.skills));
|
||||
assert.ok(json.skills.some((skill) => skill.name === "lookupWeather"));
|
||||
});
|
||||
|
||||
test("enabling a disabled skill makes it available in the request pipeline", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-skill-enable" });
|
||||
const apiKey = await seedApiKey();
|
||||
await enableSkills();
|
||||
|
||||
const skill = await registerSkill({
|
||||
apiKeyId: apiKey.id,
|
||||
name: "lookupWeather",
|
||||
handler: "weather-handler-enable",
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
const updateResponse = await skillByIdRouteModule.PUT(
|
||||
new Request("http://localhost/api/skills/id", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: true }),
|
||||
}),
|
||||
{ params: Promise.resolve({ id: skill.id }) }
|
||||
);
|
||||
|
||||
const fetchBodies = [];
|
||||
globalThis.fetch = async (_url, init = {}) => {
|
||||
fetchBodies.push(init.body ? JSON.parse(String(init.body)) : null);
|
||||
return buildOpenAIResponse("Skill enabled");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
authKey: apiKey.key,
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "What tools do you have?" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(updateResponse.status, 200);
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(Array.isArray(fetchBodies[0].tools));
|
||||
assert.ok(fetchBodies[0].tools.some((tool) => tool.function.name === "lookupWeather@1.0.0"));
|
||||
});
|
||||
|
||||
test("matching tool calls execute the registered skill and return tool results", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-skill-exec" });
|
||||
const apiKey = await seedApiKey();
|
||||
await enableSkills();
|
||||
|
||||
skillExecutor.registerHandler("weather-handler-exec", async (input) => ({
|
||||
forecast: `Sunny in ${input.location}`,
|
||||
}));
|
||||
await registerSkill({
|
||||
apiKeyId: apiKey.id,
|
||||
name: "lookupWeather",
|
||||
handler: "weather-handler-exec",
|
||||
});
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
buildOpenAIToolCallResponse({
|
||||
toolName: "lookupWeather@1.0.0",
|
||||
argumentsObject: { location: "Recife" },
|
||||
});
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
authKey: apiKey.key,
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Check the weather" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(json.choices[0].finish_reason, "tool_calls");
|
||||
assert.equal(json.tool_results[0].tool_call_id, "call_weather");
|
||||
assert.equal(JSON.parse(json.tool_results[0].output).forecast, "Sunny in Recife");
|
||||
});
|
||||
|
||||
test("non-matching responses fall through the pipeline normally", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-skill-pass" });
|
||||
const apiKey = await seedApiKey();
|
||||
await enableSkills();
|
||||
|
||||
skillExecutor.registerHandler("noop-handler", async () => ({ ok: true }));
|
||||
await registerSkill({
|
||||
apiKeyId: apiKey.id,
|
||||
name: "noopSkill",
|
||||
handler: "noop-handler",
|
||||
});
|
||||
|
||||
globalThis.fetch = async () => buildOpenAIResponse("Normal pipeline response");
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
authKey: apiKey.key,
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Just answer normally" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(json.choices[0].message.content, "Normal pipeline response");
|
||||
assert.equal(json.tool_results, undefined);
|
||||
});
|
||||
|
||||
test("sandbox-backed skill execution can be mocked through the executor", async () => {
|
||||
const apiKey = await seedApiKey();
|
||||
const originalRun = sandboxRunner.run.bind(sandboxRunner);
|
||||
|
||||
skillExecutor.registerHandler("sandbox-handler", async () => {
|
||||
const result = await sandboxRunner.run("alpine", ["echo", "sandbox"], {});
|
||||
return { stdout: result.stdout, exitCode: result.exitCode };
|
||||
});
|
||||
await registerSkill({
|
||||
apiKeyId: apiKey.id,
|
||||
name: "sandboxedSkill",
|
||||
handler: "sandbox-handler",
|
||||
});
|
||||
|
||||
sandboxRunner.run = async () => ({
|
||||
id: "sandbox-1",
|
||||
exitCode: 0,
|
||||
stdout: "sandbox ok",
|
||||
stderr: "",
|
||||
duration: 12,
|
||||
killed: false,
|
||||
});
|
||||
|
||||
const execution = await skillExecutor.execute(
|
||||
"sandboxedSkill@1.0.0",
|
||||
{ command: "echo sandbox" },
|
||||
{ apiKeyId: apiKey.id, sessionId: "sandbox-session" }
|
||||
);
|
||||
|
||||
sandboxRunner.run = originalRun;
|
||||
|
||||
assert.equal(execution.status, "success");
|
||||
assert.equal(execution.output.stdout, "sandbox ok");
|
||||
assert.equal(execution.output.exitCode, 0);
|
||||
});
|
||||
|
||||
test("skill execution errors are returned gracefully in tool results", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-skill-error" });
|
||||
const apiKey = await seedApiKey();
|
||||
await enableSkills();
|
||||
|
||||
skillExecutor.registerHandler("broken-handler", async () => {
|
||||
throw new Error("sandbox policy denied execution");
|
||||
});
|
||||
await registerSkill({
|
||||
apiKeyId: apiKey.id,
|
||||
name: "brokenSkill",
|
||||
handler: "broken-handler",
|
||||
});
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
buildOpenAIToolCallResponse({
|
||||
toolName: "brokenSkill@1.0.0",
|
||||
toolCallId: "call_broken",
|
||||
});
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
authKey: apiKey.key,
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Run the protected tool" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(json.tool_results[0].tool_call_id, "call_broken");
|
||||
assert.match(JSON.parse(json.tool_results[0].output).error, /sandbox policy denied/);
|
||||
});
|
||||
|
||||
test("disabling a skill removes it from request tool injection", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-skill-disable" });
|
||||
const apiKey = await seedApiKey();
|
||||
await enableSkills();
|
||||
|
||||
const skill = await registerSkill({
|
||||
apiKeyId: apiKey.id,
|
||||
name: "lookupWeather",
|
||||
handler: "weather-handler-disable",
|
||||
});
|
||||
|
||||
const updateResponse = await skillByIdRouteModule.PUT(
|
||||
new Request("http://localhost/api/skills/id", {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enabled: false }),
|
||||
}),
|
||||
{ params: Promise.resolve({ id: skill.id }) }
|
||||
);
|
||||
|
||||
const fetchBodies = [];
|
||||
globalThis.fetch = async (_url, init = {}) => {
|
||||
fetchBodies.push(init.body ? JSON.parse(String(init.body)) : null);
|
||||
return buildOpenAIResponse("Skill disabled");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
authKey: apiKey.key,
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "What tools do you have now?" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(updateResponse.status, 200);
|
||||
assert.equal(response.status, 200);
|
||||
assert.ok(!fetchBodies[0].tools || fetchBodies[0].tools.length === 0);
|
||||
});
|
||||
|
||||
test("builtin and custom skills coexist in the injected tool list", async () => {
|
||||
await seedConnection("openai", { apiKey: "sk-openai-skill-builtin" });
|
||||
const apiKey = await seedApiKey();
|
||||
await enableSkills();
|
||||
|
||||
registerBuiltinSkills(skillExecutor);
|
||||
skillExecutor.registerHandler("custom-weather-handler", async () => ({
|
||||
forecast: "Cloudy",
|
||||
}));
|
||||
|
||||
await registerSkill({
|
||||
apiKeyId: apiKey.id,
|
||||
name: "webSearch",
|
||||
handler: "web_search",
|
||||
});
|
||||
await registerSkill({
|
||||
apiKeyId: apiKey.id,
|
||||
name: "lookupWeather",
|
||||
handler: "custom-weather-handler",
|
||||
});
|
||||
|
||||
const fetchBodies = [];
|
||||
globalThis.fetch = async (_url, init = {}) => {
|
||||
fetchBodies.push(init.body ? JSON.parse(String(init.body)) : null);
|
||||
return buildOpenAIResponse("Both skills available");
|
||||
};
|
||||
|
||||
const response = await handleChat(
|
||||
buildRequest({
|
||||
authKey: apiKey.key,
|
||||
body: {
|
||||
model: "openai/gpt-4o-mini",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "List my tools" }],
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const toolNames = (fetchBodies[0].tools || []).map((tool) => tool.function.name).sort();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(toolNames, ["lookupWeather@1.0.0", "webSearch@1.0.0"]);
|
||||
});
|
||||
186
tests/unit/account-fallback-service.test.mjs
Normal file
186
tests/unit/account-fallback-service.test.mjs
Normal file
@@ -0,0 +1,186 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const accountFallback = await import("../../open-sse/services/accountFallback.ts");
|
||||
const accountSelector = await import("../../open-sse/services/accountSelector.ts");
|
||||
const { RateLimitReason, COOLDOWN_MS, PROVIDER_PROFILES } =
|
||||
await import("../../open-sse/config/constants.ts");
|
||||
|
||||
const {
|
||||
isOAuthInvalidToken,
|
||||
parseRetryFromErrorText,
|
||||
checkFallbackError,
|
||||
filterAvailableAccounts,
|
||||
getEarliestRateLimitedUntil,
|
||||
formatRetryAfter,
|
||||
applyErrorState,
|
||||
lockModelIfPerModelQuota,
|
||||
isModelLocked,
|
||||
hasPerModelQuota,
|
||||
getProviderProfile,
|
||||
} = accountFallback;
|
||||
|
||||
const { selectAccount } = accountSelector;
|
||||
|
||||
function withMockedNow(now, fn) {
|
||||
const originalNow = Date.now;
|
||||
Date.now = () => now;
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
}
|
||||
|
||||
test("isOAuthInvalidToken detects refreshable oauth failures", () => {
|
||||
assert.equal(
|
||||
isOAuthInvalidToken("Invalid authentication credentials for this OAuth 2 session"),
|
||||
true
|
||||
);
|
||||
assert.equal(isOAuthInvalidToken("plain rate limit"), false);
|
||||
});
|
||||
|
||||
test("parseRetryFromErrorText parses both compact reset formats", () => {
|
||||
assert.equal(parseRetryFromErrorText("Your quota will reset after 2h30m14s"), 9_014_000);
|
||||
assert.equal(parseRetryFromErrorText("The pool will reset after 45m"), 2_700_000);
|
||||
assert.equal(parseRetryFromErrorText("This will reset after 30s"), 30_000);
|
||||
assert.equal(parseRetryFromErrorText("No reset metadata"), null);
|
||||
});
|
||||
|
||||
test("checkFallbackError marks deactivated accounts as permanent auth failures", () => {
|
||||
const result = checkFallbackError(401, "This account has been deactivated");
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.reason, RateLimitReason.AUTH_ERROR);
|
||||
assert.equal(result.permanent, true);
|
||||
assert.ok(result.cooldownMs >= 300 * 24 * 60 * 60 * 1000);
|
||||
});
|
||||
|
||||
test("checkFallbackError treats exhausted credits as long quota cooldowns", () => {
|
||||
const result = checkFallbackError(429, "credit_balance_too_low");
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.reason, RateLimitReason.QUOTA_EXHAUSTED);
|
||||
assert.equal(result.creditsExhausted, true);
|
||||
assert.equal(result.cooldownMs, COOLDOWN_MS.paymentRequired ?? 3600 * 1000);
|
||||
});
|
||||
|
||||
test("checkFallbackError honors Retry-After header for rate limits", () => {
|
||||
withMockedNow(1_700_000_000_000, () => {
|
||||
const headers = new Headers({ "retry-after": "120" });
|
||||
const result = checkFallbackError(429, "Rate limit hit", 3, null, "openai", headers);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.reason, RateLimitReason.RATE_LIMIT_EXCEEDED);
|
||||
assert.equal(result.newBackoffLevel, 0);
|
||||
assert.equal(result.cooldownMs, 120_000);
|
||||
});
|
||||
});
|
||||
|
||||
test("checkFallbackError honors x-ratelimit-reset for transient 5xx errors", () => {
|
||||
withMockedNow(1_700_000_000_000, () => {
|
||||
const resetSeconds = Math.floor((Date.now() + 90_000) / 1000);
|
||||
const headers = new Headers({ "x-ratelimit-reset": String(resetSeconds) });
|
||||
const result = checkFallbackError(503, "upstream unavailable", 1, null, "openai", headers);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.reason, RateLimitReason.SERVER_ERROR);
|
||||
assert.equal(result.newBackoffLevel, 0);
|
||||
assert.ok(result.cooldownMs >= 89_000);
|
||||
assert.ok(result.cooldownMs <= 90_000);
|
||||
});
|
||||
});
|
||||
|
||||
test("checkFallbackError keeps generic 400 client errors terminal", () => {
|
||||
const result = checkFallbackError(400, "bad request payload");
|
||||
assert.deepEqual(result, {
|
||||
shouldFallback: false,
|
||||
cooldownMs: 0,
|
||||
reason: RateLimitReason.UNKNOWN,
|
||||
});
|
||||
});
|
||||
|
||||
test("filterAvailableAccounts skips exclusion and active cooldowns but keeps recovered ones", () => {
|
||||
withMockedNow(1_700_000_000_000, () => {
|
||||
const accounts = [
|
||||
{ id: "exclude-me" },
|
||||
{ id: "cooling", rateLimitedUntil: new Date(Date.now() + 60_000).toISOString() },
|
||||
{ id: "recovered", rateLimitedUntil: new Date(Date.now() - 1_000).toISOString() },
|
||||
{ id: "healthy" },
|
||||
];
|
||||
|
||||
const available = filterAvailableAccounts(accounts, "exclude-me");
|
||||
assert.deepEqual(
|
||||
available.map((account) => account.id),
|
||||
["recovered", "healthy"]
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("getEarliestRateLimitedUntil returns the shortest future cooldown and formatRetryAfter humanizes it", () => {
|
||||
withMockedNow(1_700_000_000_000, () => {
|
||||
const earliest = getEarliestRateLimitedUntil([
|
||||
{ id: "expired", rateLimitedUntil: new Date(Date.now() - 5_000).toISOString() },
|
||||
{ id: "later", rateLimitedUntil: new Date(Date.now() + 90_000).toISOString() },
|
||||
{ id: "earliest", rateLimitedUntil: new Date(Date.now() + 30_000).toISOString() },
|
||||
]);
|
||||
|
||||
assert.equal(earliest, new Date(Date.now() + 30_000).toISOString());
|
||||
assert.equal(formatRetryAfter(earliest), "reset after 30s");
|
||||
});
|
||||
});
|
||||
|
||||
test("applyErrorState and selectAccount advance to the next account after an auth failure", () => {
|
||||
withMockedNow(1_700_000_000_000, () => {
|
||||
const accounts = [
|
||||
{ id: "conn-a", backoffLevel: 0 },
|
||||
{ id: "conn-b", backoffLevel: 0 },
|
||||
];
|
||||
|
||||
const firstSelection = selectAccount(accounts, "fill-first");
|
||||
assert.equal(firstSelection.account.id, "conn-a");
|
||||
|
||||
const failedFirst = applyErrorState(firstSelection.account, 401, "Unauthorized", "claude");
|
||||
assert.equal(failedFirst.status, "error");
|
||||
assert.equal(failedFirst.lastError.reason, RateLimitReason.AUTH_ERROR);
|
||||
|
||||
const candidates = filterAvailableAccounts([failedFirst, accounts[1]], failedFirst.id);
|
||||
const nextSelection = selectAccount(candidates, "fill-first");
|
||||
assert.equal(nextSelection.account.id, "conn-b");
|
||||
});
|
||||
});
|
||||
|
||||
test("lockModelIfPerModelQuota only locks supported providers and real models", () => {
|
||||
const geminiConnectionId = `gemini-${Date.now()}`;
|
||||
const openAiConnectionId = `openai-${Date.now()}`;
|
||||
|
||||
assert.equal(hasPerModelQuota("gemini"), true);
|
||||
assert.equal(hasPerModelQuota("openai"), false);
|
||||
|
||||
assert.equal(
|
||||
lockModelIfPerModelQuota(
|
||||
"gemini",
|
||||
geminiConnectionId,
|
||||
"gemini-2.5-pro",
|
||||
RateLimitReason.RATE_LIMIT_EXCEEDED,
|
||||
30_000
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(isModelLocked("gemini", geminiConnectionId, "gemini-2.5-pro"), true);
|
||||
|
||||
assert.equal(
|
||||
lockModelIfPerModelQuota(
|
||||
"openai",
|
||||
openAiConnectionId,
|
||||
"gpt-5-mini",
|
||||
RateLimitReason.RATE_LIMIT_EXCEEDED,
|
||||
30_000
|
||||
),
|
||||
false
|
||||
);
|
||||
assert.equal(isModelLocked("openai", openAiConnectionId, "gpt-5-mini"), false);
|
||||
});
|
||||
|
||||
test("getProviderProfile differentiates oauth and api-key providers", () => {
|
||||
assert.deepEqual(getProviderProfile("claude"), PROVIDER_PROFILES.oauth);
|
||||
assert.deepEqual(getProviderProfile("openai"), PROVIDER_PROFILES.apikey);
|
||||
});
|
||||
@@ -42,7 +42,7 @@ test("GET /api/keys stays masked even when reveal is enabled", async () => {
|
||||
process.env.ALLOW_API_KEY_REVEAL = "true";
|
||||
const created = await apiKeysDb.createApiKey("Primary Key", MACHINE_ID);
|
||||
|
||||
const response = await listRoute.GET();
|
||||
const response = await listRoute.GET(new Request("http://localhost/api/keys"));
|
||||
const body = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
|
||||
209
tests/unit/audio-speech-handler.test.mjs
Normal file
209
tests/unit/audio-speech-handler.test.mjs
Normal file
@@ -0,0 +1,209 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { handleAudioSpeech } = await import("../../open-sse/handlers/audioSpeech.ts");
|
||||
|
||||
test("handleAudioSpeech requires model", async () => {
|
||||
const response = await handleAudioSpeech({
|
||||
body: { input: "hello" },
|
||||
credentials: { apiKey: "x" },
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(payload.error.message, "model is required");
|
||||
});
|
||||
|
||||
test("handleAudioSpeech requires input text", async () => {
|
||||
const response = await handleAudioSpeech({
|
||||
body: { model: "openai/tts-1" },
|
||||
credentials: { apiKey: "x" },
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(payload.error.message, "input is required");
|
||||
});
|
||||
|
||||
test("handleAudioSpeech proxies OpenAI-compatible providers with defaults", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured;
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
captured = {
|
||||
url: String(url),
|
||||
headers: options.headers,
|
||||
body: JSON.parse(String(options.body || "{}")),
|
||||
};
|
||||
|
||||
return new Response(new Uint8Array([1, 2, 3]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "audio/opus" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await handleAudioSpeech({
|
||||
body: {
|
||||
model: "openai/tts-1",
|
||||
input: "hello world",
|
||||
},
|
||||
credentials: { apiKey: "openai-key" },
|
||||
});
|
||||
|
||||
assert.equal(captured.url, "https://api.openai.com/v1/audio/speech");
|
||||
assert.equal(captured.headers.Authorization, "Bearer openai-key");
|
||||
assert.deepEqual(captured.body, {
|
||||
model: "tts-1",
|
||||
input: "hello world",
|
||||
voice: "alloy",
|
||||
response_format: "mp3",
|
||||
speed: 1,
|
||||
});
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("content-type"), "audio/opus");
|
||||
assert.ok(response.headers.get("access-control-allow-origin"));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleAudioSpeech routes Deepgram with Token auth and model query parameter", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedUrl;
|
||||
let capturedHeaders;
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
capturedUrl = String(url);
|
||||
capturedHeaders = options.headers;
|
||||
const body = JSON.parse(String(options.body || "{}"));
|
||||
assert.deepEqual(body, { text: "deepgram text" });
|
||||
|
||||
return new Response(new Uint8Array([9, 8, 7]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "audio/mpeg" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await handleAudioSpeech({
|
||||
body: {
|
||||
model: "deepgram/aura-asteria-en",
|
||||
input: "deepgram text",
|
||||
},
|
||||
credentials: { apiKey: "dg-key" },
|
||||
});
|
||||
|
||||
const url = new URL(capturedUrl);
|
||||
assert.equal(url.origin + url.pathname, "https://api.deepgram.com/v1/speak");
|
||||
assert.equal(url.searchParams.get("model"), "aura-asteria-en");
|
||||
assert.equal(capturedHeaders.Authorization, "Token dg-key");
|
||||
assert.equal(response.status, 200);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleAudioSpeech rejects invalid ElevenLabs voice identifiers", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let called = false;
|
||||
globalThis.fetch = async () => {
|
||||
called = true;
|
||||
throw new Error("should not fetch");
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await handleAudioSpeech({
|
||||
body: {
|
||||
model: "elevenlabs/eleven_turbo_v2_5",
|
||||
input: "bad voice",
|
||||
voice: "../secret",
|
||||
},
|
||||
credentials: { apiKey: "xi-key" },
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(payload.error.message, "Invalid voice ID");
|
||||
assert.equal(called, false);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleAudioSpeech maps Cartesia voice and wav output settings", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured;
|
||||
|
||||
globalThis.fetch = async (_url, options = {}) => {
|
||||
captured = {
|
||||
headers: options.headers,
|
||||
body: JSON.parse(String(options.body || "{}")),
|
||||
};
|
||||
return new Response(new Uint8Array([4, 5, 6]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "audio/wav" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await handleAudioSpeech({
|
||||
body: {
|
||||
model: "cartesia/sonic-2",
|
||||
input: "cartesia text",
|
||||
voice: "voice-123",
|
||||
response_format: "wav",
|
||||
},
|
||||
credentials: { apiKey: "cartesia-key" },
|
||||
});
|
||||
|
||||
assert.equal(captured.headers["X-API-Key"], "cartesia-key");
|
||||
assert.equal(captured.headers["Cartesia-Version"], "2024-06-10");
|
||||
assert.deepEqual(captured.body, {
|
||||
model_id: "sonic-2",
|
||||
transcript: "cartesia text",
|
||||
voice: { mode: "id", id: "voice-123" },
|
||||
output_format: { container: "wav", sample_rate: 44100 },
|
||||
});
|
||||
assert.equal(response.headers.get("content-type"), "audio/wav");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleAudioSpeech maps PlayHT credentials, output format, and speed", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured;
|
||||
|
||||
globalThis.fetch = async (_url, options = {}) => {
|
||||
captured = {
|
||||
headers: options.headers,
|
||||
body: JSON.parse(String(options.body || "{}")),
|
||||
};
|
||||
return new Response(new Uint8Array([7, 7, 7]), {
|
||||
status: 200,
|
||||
headers: { "content-type": "audio/mpeg" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await handleAudioSpeech({
|
||||
body: {
|
||||
model: "playht/Play3.0-mini",
|
||||
input: "playht text",
|
||||
response_format: "aac",
|
||||
speed: 1.25,
|
||||
},
|
||||
credentials: { apiKey: "user-1:api-key-1" },
|
||||
});
|
||||
|
||||
assert.equal(captured.headers["X-USER-ID"], "user-1");
|
||||
assert.equal(captured.headers.Authorization, "Bearer api-key-1");
|
||||
assert.equal(captured.body.voice_engine, "Play3.0-mini");
|
||||
assert.equal(captured.body.output_format, "aac");
|
||||
assert.equal(captured.body.speed, 1.25);
|
||||
assert.equal(response.status, 200);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
227
tests/unit/audio-transcription-handler.test.mjs
Normal file
227
tests/unit/audio-transcription-handler.test.mjs
Normal file
@@ -0,0 +1,227 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { handleAudioTranscription } = await import("../../open-sse/handlers/audioTranscription.ts");
|
||||
|
||||
function buildFile(contents, name, type) {
|
||||
return new File([Buffer.from(contents)], name, { type });
|
||||
}
|
||||
|
||||
test("handleAudioTranscription requires model", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", buildFile("abc", "audio.wav", "audio/wav"));
|
||||
|
||||
const response = await handleAudioTranscription({ formData, credentials: { apiKey: "x" } });
|
||||
const payload = await response.json();
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(payload.error.message, "model is required");
|
||||
});
|
||||
|
||||
test("handleAudioTranscription requires a file upload", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("model", "openai/whisper-1");
|
||||
|
||||
const response = await handleAudioTranscription({ formData, credentials: { apiKey: "x" } });
|
||||
const payload = await response.json();
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(payload.error.message, "file is required");
|
||||
});
|
||||
|
||||
test("handleAudioTranscription proxies OpenAI-compatible multipart requests and forwards optional params", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured;
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
const upstreamEntries = Array.from(options.body.entries());
|
||||
captured = {
|
||||
url: String(url),
|
||||
headers: options.headers,
|
||||
entries: upstreamEntries.map(([key, value]) => [
|
||||
key,
|
||||
value instanceof File ? { name: value.name, type: value.type } : value,
|
||||
]),
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify({ text: "hello" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("model", "openai/whisper-1");
|
||||
formData.append("file", buildFile("abc", "clip.webm", "audio/webm"));
|
||||
formData.append("language", "pt");
|
||||
formData.append("prompt", "meeting");
|
||||
formData.append("response_format", "verbose_json");
|
||||
formData.append("temperature", "0.1");
|
||||
formData.append("timestamp_granularities[]", "word");
|
||||
|
||||
const response = await handleAudioTranscription({
|
||||
formData,
|
||||
credentials: { apiKey: "openai-key" },
|
||||
});
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(captured.url, "https://api.openai.com/v1/audio/transcriptions");
|
||||
assert.equal(captured.headers.Authorization, "Bearer openai-key");
|
||||
assert.deepEqual(captured.entries, [
|
||||
["file", { name: "clip.webm", type: "audio/webm" }],
|
||||
["model", "whisper-1"],
|
||||
["language", "pt"],
|
||||
["prompt", "meeting"],
|
||||
["response_format", "verbose_json"],
|
||||
["temperature", "0.1"],
|
||||
["timestamp_granularities[]", "word"],
|
||||
]);
|
||||
assert.deepEqual(await response.json(), { text: "hello" });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleAudioTranscription routes Deepgram with binary upload and language passthrough", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let capturedUrl;
|
||||
let capturedHeaders;
|
||||
let capturedBody;
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
capturedUrl = String(url);
|
||||
capturedHeaders = options.headers;
|
||||
capturedBody = options.body;
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
results: {
|
||||
channels: [{ alternatives: [{ transcript: "ola mundo" }] }],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("model", "deepgram/nova-3");
|
||||
formData.append("file", buildFile("abc", "clip.mp4", "video/mp4"));
|
||||
formData.append("language", "pt-BR");
|
||||
|
||||
const response = await handleAudioTranscription({
|
||||
formData,
|
||||
credentials: { apiKey: "dg-key" },
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
const url = new URL(capturedUrl);
|
||||
assert.equal(url.origin + url.pathname, "https://api.deepgram.com/v1/listen");
|
||||
assert.equal(url.searchParams.get("model"), "nova-3");
|
||||
assert.equal(url.searchParams.get("language"), "pt-BR");
|
||||
assert.equal(url.searchParams.get("detect_language"), null);
|
||||
assert.equal(capturedHeaders.Authorization, "Token dg-key");
|
||||
assert.equal(capturedHeaders["Content-Type"], "audio/mp4");
|
||||
assert.ok(capturedBody instanceof ArrayBuffer);
|
||||
assert.deepEqual(payload, { text: "ola mundo", noSpeechDetected: false });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleAudioTranscription marks noSpeechDetected when Deepgram returns no transcript", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
results: {
|
||||
channels: [{ alternatives: [{ transcript: "" }] }],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("model", "deepgram/nova-3");
|
||||
formData.append("file", buildFile("abc", "clip.wav", "audio/wav"));
|
||||
|
||||
const response = await handleAudioTranscription({
|
||||
formData,
|
||||
credentials: { apiKey: "dg-key" },
|
||||
});
|
||||
|
||||
assert.deepEqual(await response.json(), { text: "", noSpeechDetected: true });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleAudioTranscription normalizes Nvidia responses to text", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured;
|
||||
|
||||
globalThis.fetch = async (_url, options = {}) => {
|
||||
captured = {
|
||||
headers: options.headers,
|
||||
entries: Array.from(options.body.entries()).map(([key, value]) => [
|
||||
key,
|
||||
value instanceof File ? { name: value.name, type: value.type } : value,
|
||||
]),
|
||||
};
|
||||
|
||||
return new Response(JSON.stringify({ transcript: "nvidia text" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append("model", "nvidia/nvidia/parakeet-ctc-1.1b-asr");
|
||||
formData.append("file", buildFile("abc", "clip.wav", "audio/wav"));
|
||||
|
||||
const response = await handleAudioTranscription({
|
||||
formData,
|
||||
credentials: { apiKey: "nvidia-key" },
|
||||
});
|
||||
|
||||
assert.equal(captured.headers.Authorization, "Bearer nvidia-key");
|
||||
assert.deepEqual(captured.entries, [
|
||||
["file", { name: "clip.wav", type: "audio/wav" }],
|
||||
["model", "nvidia/parakeet-ctc-1.1b-asr"],
|
||||
]);
|
||||
assert.deepEqual(await response.json(), { text: "nvidia text" });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleAudioTranscription rejects invalid HuggingFace model paths", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("model", "huggingface/../escape");
|
||||
formData.append("file", buildFile("abc", "clip.wav", "audio/wav"));
|
||||
|
||||
const response = await handleAudioTranscription({
|
||||
formData,
|
||||
credentials: { apiKey: "hf-key" },
|
||||
});
|
||||
const payload = await response.json();
|
||||
|
||||
assert.equal(response.status, 400);
|
||||
assert.equal(payload.error.message, "Invalid model ID");
|
||||
});
|
||||
|
||||
test("handleAudioTranscription requires credentials for authenticated providers", async () => {
|
||||
const formData = new FormData();
|
||||
formData.append("model", "openai/whisper-1");
|
||||
formData.append("file", buildFile("abc", "clip.wav", "audio/wav"));
|
||||
|
||||
const response = await handleAudioTranscription({ formData, credentials: null });
|
||||
const payload = await response.json();
|
||||
|
||||
assert.equal(response.status, 401);
|
||||
assert.equal(payload.error.message, "No credentials for transcription provider: openai");
|
||||
});
|
||||
85
tests/unit/bypass-handler.test.mjs
Normal file
85
tests/unit/bypass-handler.test.mjs
Normal file
@@ -0,0 +1,85 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { handleBypassRequest } = await import("../../open-sse/utils/bypassHandler.ts");
|
||||
|
||||
test("handleBypassRequest returns null for non-Claude clients or missing messages", () => {
|
||||
assert.equal(handleBypassRequest({ messages: [] }, "gpt-5", "claude-cli/2.1.89"), null);
|
||||
assert.equal(
|
||||
handleBypassRequest(
|
||||
{
|
||||
messages: [{ role: "user", content: "Warmup" }],
|
||||
},
|
||||
"gpt-5",
|
||||
{ broken: true }
|
||||
),
|
||||
null
|
||||
);
|
||||
assert.equal(
|
||||
handleBypassRequest(
|
||||
{
|
||||
messages: [{ role: "user", content: "Warmup" }],
|
||||
},
|
||||
"gpt-5",
|
||||
"curl/8.0"
|
||||
),
|
||||
null
|
||||
);
|
||||
});
|
||||
|
||||
test("handleBypassRequest returns a canned JSON response for warmup bypasses", async () => {
|
||||
const result = handleBypassRequest(
|
||||
{
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Warmup" }],
|
||||
},
|
||||
"gpt-5-mini",
|
||||
"claude-cli/2.1.89"
|
||||
);
|
||||
|
||||
assert.ok(result);
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.response.headers.get("content-type"), "application/json");
|
||||
|
||||
const payload = await result.response.json();
|
||||
assert.equal(payload.model, "gpt-5-mini");
|
||||
assert.equal(payload.choices[0].message.role, "assistant");
|
||||
assert.match(payload.choices[0].message.content, /clear terminal/i);
|
||||
});
|
||||
|
||||
test("handleBypassRequest returns an SSE response for title extraction bypasses", async () => {
|
||||
const result = handleBypassRequest(
|
||||
{
|
||||
messages: [
|
||||
{ role: "user", content: "ignored" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "{" }] },
|
||||
],
|
||||
},
|
||||
"gpt-5",
|
||||
"claude-cli/2.1.89"
|
||||
);
|
||||
|
||||
assert.ok(result);
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(result.response.headers.get("content-type"), "text/event-stream");
|
||||
|
||||
const body = await result.response.text();
|
||||
assert.match(body, /data:/);
|
||||
assert.match(body, /\[DONE\]/);
|
||||
});
|
||||
|
||||
test("handleBypassRequest bypasses single-message count probes", async () => {
|
||||
const result = handleBypassRequest(
|
||||
{
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "count" }] }],
|
||||
},
|
||||
"gpt-4.1-mini",
|
||||
"claude-cli/2.1.89"
|
||||
);
|
||||
|
||||
assert.ok(result);
|
||||
const payload = await result.response.json();
|
||||
assert.equal(payload.usage.total_tokens, 2);
|
||||
assert.equal(payload.choices[0].finish_reason, "stop");
|
||||
});
|
||||
397
tests/unit/chatcore-sanitization.test.mjs
Normal file
397
tests/unit/chatcore-sanitization.test.mjs
Normal file
@@ -0,0 +1,397 @@
|
||||
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-chatcore-sanitization-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
const { createMemory } = await import("../../src/lib/memory/store.ts");
|
||||
const { invalidateMemorySettingsCache } = await import("../../src/lib/memory/settings.ts");
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
|
||||
function noopLog() {
|
||||
return {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
}
|
||||
|
||||
function toPlainHeaders(headers) {
|
||||
if (!headers) return {};
|
||||
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).map(([key, value]) => [key, value == null ? "" : String(value)])
|
||||
);
|
||||
}
|
||||
|
||||
function buildUpstreamResponse(stream) {
|
||||
if (stream) {
|
||||
return new Response(
|
||||
'data: {"id":"chatcmpl-stream","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"role":"assistant","content":"ok"}}]}\n\ndata: [DONE]\n\n',
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-json",
|
||||
object: "chat.completion",
|
||||
model: "gpt-4o-mini",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: "ok" },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 1,
|
||||
completion_tokens: 1,
|
||||
total_tokens: 2,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function ensureLegacyMemoryTable() {
|
||||
const db = core.getDbInstance();
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS memory (
|
||||
id TEXT PRIMARY KEY,
|
||||
apiKeyId TEXT NOT NULL,
|
||||
sessionId TEXT,
|
||||
type TEXT NOT NULL,
|
||||
key TEXT,
|
||||
content TEXT NOT NULL,
|
||||
metadata TEXT,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
expiresAt TEXT
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
async function invokeChatCore({
|
||||
body,
|
||||
accept = "application/json",
|
||||
provider = "openai",
|
||||
model = "gpt-4o-mini",
|
||||
endpoint = "/v1/chat/completions",
|
||||
credentials = { apiKey: "sk-test", providerSpecificData: {} },
|
||||
apiKeyInfo = null,
|
||||
userAgent = "unit-test",
|
||||
responseFactory,
|
||||
} = {}) {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls = [];
|
||||
const resolvedStream =
|
||||
body?.stream === true ||
|
||||
(body?.stream === undefined && String(accept).toLowerCase().includes("text/event-stream"));
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const parsedBody = init.body ? JSON.parse(String(init.body)) : null;
|
||||
const captured = {
|
||||
url: String(url),
|
||||
method: init.method || "GET",
|
||||
headers: toPlainHeaders(init.headers),
|
||||
body: parsedBody,
|
||||
};
|
||||
calls.push(captured);
|
||||
return responseFactory ? responseFactory(captured) : buildUpstreamResponse(resolvedStream);
|
||||
};
|
||||
|
||||
try {
|
||||
const requestBody = structuredClone(body);
|
||||
const result = await handleChatCore({
|
||||
body: requestBody,
|
||||
modelInfo: { provider, model, extendedContext: false },
|
||||
credentials: structuredClone(credentials),
|
||||
log: noopLog(),
|
||||
clientRawRequest: {
|
||||
endpoint,
|
||||
body: structuredClone(body),
|
||||
headers: new Headers({ accept }),
|
||||
},
|
||||
apiKeyInfo,
|
||||
userAgent,
|
||||
});
|
||||
|
||||
return { result, call: calls.at(-1), calls };
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
try {
|
||||
const db = core.getDbInstance();
|
||||
db.close();
|
||||
} catch {}
|
||||
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("chatCore sanitization normalizes max_output_tokens into max_tokens", async () => {
|
||||
const copied = await invokeChatCore({
|
||||
body: {
|
||||
model: "gpt-4o-mini",
|
||||
max_output_tokens: 0,
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
},
|
||||
});
|
||||
const preserved = await invokeChatCore({
|
||||
body: {
|
||||
model: "gpt-4o-mini",
|
||||
max_output_tokens: 64,
|
||||
max_tokens: 7,
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
},
|
||||
});
|
||||
const untouched = await invokeChatCore({
|
||||
body: {
|
||||
model: "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(copied.call.body.max_tokens, 0);
|
||||
assert.equal("max_output_tokens" in copied.call.body, false);
|
||||
assert.equal(preserved.call.body.max_tokens, 7);
|
||||
assert.equal("max_output_tokens" in preserved.call.body, false);
|
||||
assert.equal("max_tokens" in untouched.call.body, false);
|
||||
});
|
||||
|
||||
test("chatCore sanitization strips empty message and input names and filters empty tool names", async () => {
|
||||
const { call } = await invokeChatCore({
|
||||
body: {
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{ role: "user", content: "hello", name: "" },
|
||||
{ role: "assistant", content: "world", name: "valid-name" },
|
||||
],
|
||||
input: [
|
||||
{ role: "user", content: "input-1", name: "" },
|
||||
{ role: "user", content: "input-2", name: "still-valid" },
|
||||
],
|
||||
tools: [
|
||||
{ type: "function", function: { name: "lookup_weather", parameters: { type: "object" } } },
|
||||
{ type: "function", function: { name: "", parameters: { type: "object" } } },
|
||||
{ type: "function", function: { name: " ", parameters: { type: "object" } } },
|
||||
{ name: "anthropic_lookup", input_schema: { type: "object" } },
|
||||
{ name: "", input_schema: { type: "object" } },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(call.body.messages[0].name, undefined);
|
||||
assert.equal(call.body.messages[1].name, "valid-name");
|
||||
assert.equal(call.body.input[0].name, undefined);
|
||||
assert.equal(call.body.input[1].name, "still-valid");
|
||||
assert.equal(call.body.tools.length, 2);
|
||||
assert.equal(call.body.tools[0].function.name, "lookup_weather");
|
||||
assert.equal(call.body.tools[1].function.name, "anthropic_lookup");
|
||||
});
|
||||
|
||||
test("chatCore sanitization normalizes mixed content blocks and removes unsupported or empty ones", async () => {
|
||||
const { call } = await invokeChatCore({
|
||||
body: {
|
||||
model: "gpt-4o-mini",
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "keep me" },
|
||||
{ type: "text", text: "" },
|
||||
{ type: "image_url", image_url: { url: "https://example.com/image.png" } },
|
||||
{ type: "image", source: { type: "base64", media_type: "image/png", data: "abc" } },
|
||||
{ type: "file_url", file_url: { url: "data:text/plain;base64,SGk=" } },
|
||||
{ type: "file", file: { name: "README.md", content: "Read me please." } },
|
||||
{ type: "file", file: { name: "blob.bin", data: "AAEC" } },
|
||||
{ type: "document", name: "notes.txt", text: "Meeting notes" },
|
||||
{ type: "document", document: { url: "data:text/plain;base64,SGVsbG8=" } },
|
||||
{ type: "tool_result", tool_use_id: "tool-1", content: "done" },
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool-2",
|
||||
content: [{ type: "text", text: "structured result" }],
|
||||
},
|
||||
{ type: "unknown_block", value: "drop me" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const content = call.body.messages[0].content;
|
||||
const textBlocks = content.filter((block) => block.type === "text");
|
||||
|
||||
assert.equal(
|
||||
content.some((block) => block.type === "text" && block.text === ""),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
content.some((block) => block.type === "unknown_block"),
|
||||
false
|
||||
);
|
||||
assert.equal(
|
||||
content.some((block) => block.type === "image_url"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
content.some((block) => block.type === "image"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
content.some(
|
||||
(block) => block.type === "file_url" && block.file_url.url.startsWith("data:text/plain")
|
||||
),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
content.some((block) => block.type === "file" && block.file?.data === "AAEC"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
content.some((block) => block.type === "document" && block.document?.url.startsWith("data:")),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
textBlocks.some((block) => block.text === "[README.md]\nRead me please."),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
textBlocks.some((block) => block.text === "[notes.txt]\nMeeting notes"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
textBlocks.some((block) => block.text === "[Tool Result: tool-1]\ndone"),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
textBlocks.some((block) => block.text === "[Tool Result: tool-2]\nstructured result"),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
test("chatCore resolves stream mode from body.stream and Accept header", async () => {
|
||||
const explicitTrue = await invokeChatCore({
|
||||
accept: "application/json",
|
||||
body: { model: "gpt-4o-mini", stream: true, messages: [{ role: "user", content: "hello" }] },
|
||||
});
|
||||
const explicitFalse = await invokeChatCore({
|
||||
accept: "text/event-stream",
|
||||
body: { model: "gpt-4o-mini", stream: false, messages: [{ role: "user", content: "hello" }] },
|
||||
});
|
||||
const acceptDriven = await invokeChatCore({
|
||||
accept: "text/event-stream",
|
||||
body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "hello" }] },
|
||||
});
|
||||
const jsonDefault = await invokeChatCore({
|
||||
accept: "application/json",
|
||||
body: { model: "gpt-4o-mini", messages: [{ role: "user", content: "hello" }] },
|
||||
});
|
||||
|
||||
assert.equal(explicitTrue.call.headers.Accept, "text/event-stream");
|
||||
assert.equal(explicitFalse.call.headers.Accept, undefined);
|
||||
assert.equal(acceptDriven.call.headers.Accept, "text/event-stream");
|
||||
assert.equal(jsonDefault.call.headers.Accept, undefined);
|
||||
});
|
||||
|
||||
test("chatCore injects memories when enabled and memories are found", async () => {
|
||||
await settingsDb.updateSettings({
|
||||
memoryEnabled: true,
|
||||
memoryMaxTokens: 1024,
|
||||
memoryRetentionDays: 30,
|
||||
memoryStrategy: "recent",
|
||||
});
|
||||
invalidateMemorySettingsCache();
|
||||
ensureLegacyMemoryTable();
|
||||
|
||||
const apiKeyId = `key-memory-${Date.now()}`;
|
||||
await createMemory({
|
||||
apiKeyId,
|
||||
sessionId: "session-1",
|
||||
type: "factual",
|
||||
key: "preference",
|
||||
content: "User prefers concise Rust examples.",
|
||||
metadata: {},
|
||||
expiresAt: null,
|
||||
});
|
||||
|
||||
const { call } = await invokeChatCore({
|
||||
apiKeyInfo: { id: apiKeyId, name: "Memory Key" },
|
||||
body: {
|
||||
model: "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Give me a snippet." }],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(call.body.messages[0].role, "system");
|
||||
assert.match(
|
||||
call.body.messages[0].content,
|
||||
/Memory context: User prefers concise Rust examples\./
|
||||
);
|
||||
assert.equal(call.body.messages[1].role, "user");
|
||||
});
|
||||
|
||||
test("chatCore skips memory injection when memory is disabled or apiKeyInfo is missing", async () => {
|
||||
await settingsDb.updateSettings({
|
||||
memoryEnabled: false,
|
||||
memoryMaxTokens: 0,
|
||||
memoryRetentionDays: 30,
|
||||
memoryStrategy: "recent",
|
||||
});
|
||||
invalidateMemorySettingsCache();
|
||||
|
||||
const disabled = await invokeChatCore({
|
||||
apiKeyInfo: { id: `key-disabled-${Date.now()}`, name: "Disabled Key" },
|
||||
body: {
|
||||
model: "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
},
|
||||
});
|
||||
const noApiKey = await invokeChatCore({
|
||||
body: {
|
||||
model: "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "Hello" }],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(disabled.call.body.messages[0].role, "user");
|
||||
assert.equal(disabled.call.body.messages[0].content, "Hello");
|
||||
assert.equal(noApiKey.call.body.messages[0].role, "user");
|
||||
assert.equal(noApiKey.call.body.messages[0].content, "Hello");
|
||||
});
|
||||
|
||||
test("chatCore skips memory injection when shouldInjectMemory returns false for empty message lists", async () => {
|
||||
await settingsDb.updateSettings({
|
||||
memoryEnabled: true,
|
||||
memoryMaxTokens: 1024,
|
||||
memoryRetentionDays: 30,
|
||||
memoryStrategy: "recent",
|
||||
});
|
||||
invalidateMemorySettingsCache();
|
||||
|
||||
const { call } = await invokeChatCore({
|
||||
apiKeyInfo: { id: `key-empty-${Date.now()}`, name: "Empty Key" },
|
||||
body: {
|
||||
model: "gpt-4o-mini",
|
||||
messages: [],
|
||||
},
|
||||
});
|
||||
|
||||
assert.deepEqual(call.body.messages, []);
|
||||
});
|
||||
698
tests/unit/chatcore-translation-paths.test.mjs
Normal file
698
tests/unit/chatcore-translation-paths.test.mjs
Normal file
@@ -0,0 +1,698 @@
|
||||
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-chatcore-translation-"));
|
||||
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 upstreamProxyDb = await import("../../src/lib/db/upstreamProxy.ts");
|
||||
const { invalidateCacheControlSettingsCache } =
|
||||
await import("../../src/lib/cacheControlSettings.ts");
|
||||
const { handleChatCore } = await import("../../open-sse/handlers/chatCore.ts");
|
||||
const { FORMATS } = await import("../../open-sse/translator/formats.ts");
|
||||
const { register, getRequestTranslator } = await import("../../open-sse/translator/registry.ts");
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalResponsesToOpenAI = getRequestTranslator(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI);
|
||||
|
||||
function noopLog() {
|
||||
return {
|
||||
debug() {},
|
||||
info() {},
|
||||
warn() {},
|
||||
error() {},
|
||||
};
|
||||
}
|
||||
|
||||
function toPlainHeaders(headers) {
|
||||
if (!headers) return {};
|
||||
if (headers instanceof Headers) return Object.fromEntries(headers.entries());
|
||||
return Object.fromEntries(
|
||||
Object.entries(headers).map(([key, value]) => [key, value == null ? "" : String(value)])
|
||||
);
|
||||
}
|
||||
|
||||
function buildOpenAIResponse(stream, text = "ok") {
|
||||
if (stream) {
|
||||
return new Response(
|
||||
`data: ${JSON.stringify({
|
||||
id: "chatcmpl-stream",
|
||||
object: "chat.completion.chunk",
|
||||
choices: [{ index: 0, delta: { role: "assistant", content: text } }],
|
||||
})}\n\ndata: [DONE]\n\n`,
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-json",
|
||||
object: "chat.completion",
|
||||
model: "gpt-4o-mini",
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: "assistant", content: text },
|
||||
finish_reason: "stop",
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 4,
|
||||
completion_tokens: 2,
|
||||
total_tokens: 6,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildClaudeResponse(stream, text = "ok") {
|
||||
if (stream) {
|
||||
return new Response(
|
||||
[
|
||||
"event: message_start",
|
||||
`data: ${JSON.stringify({
|
||||
type: "message_start",
|
||||
message: {
|
||||
id: "msg_stream",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
usage: { input_tokens: 12, output_tokens: 0 },
|
||||
},
|
||||
})}`,
|
||||
"",
|
||||
"event: content_block_start",
|
||||
`data: ${JSON.stringify({
|
||||
type: "content_block_start",
|
||||
index: 0,
|
||||
content_block: { type: "text", text: "" },
|
||||
})}`,
|
||||
"",
|
||||
"event: content_block_delta",
|
||||
`data: ${JSON.stringify({
|
||||
type: "content_block_delta",
|
||||
index: 0,
|
||||
delta: { type: "text_delta", text },
|
||||
})}`,
|
||||
"",
|
||||
"event: message_delta",
|
||||
`data: ${JSON.stringify({
|
||||
type: "message_delta",
|
||||
delta: { stop_reason: "end_turn" },
|
||||
usage: { output_tokens: 3 },
|
||||
})}`,
|
||||
"",
|
||||
"event: message_stop",
|
||||
`data: ${JSON.stringify({ type: "message_stop" })}`,
|
||||
"",
|
||||
].join("\n"),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "msg_json",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
model: "claude-sonnet-4-6",
|
||||
content: [{ type: "text", text }],
|
||||
stop_reason: "end_turn",
|
||||
usage: {
|
||||
input_tokens: 12,
|
||||
output_tokens: 3,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function buildResponsesResponse(text = "ok") {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
status: "completed",
|
||||
model: "gpt-5.1-codex",
|
||||
output: [
|
||||
{
|
||||
id: "msg_123",
|
||||
type: "message",
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text, annotations: [] }],
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
input_tokens: 4,
|
||||
output_tokens: 2,
|
||||
total_tokens: 6,
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function hasCacheControl(value) {
|
||||
if (!value || typeof value !== "object") return false;
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => hasCacheControl(item));
|
||||
}
|
||||
if (Object.hasOwn(value, "cache_control")) return true;
|
||||
return Object.values(value).some((item) => hasCacheControl(item));
|
||||
}
|
||||
|
||||
function collectTextBlocks(messages) {
|
||||
if (!Array.isArray(messages)) return [];
|
||||
return messages.flatMap((message) =>
|
||||
Array.isArray(message.content) ? message.content.filter((block) => block?.type === "text") : []
|
||||
);
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
register(FORMATS.OPENAI_RESPONSES, FORMATS.OPENAI, originalResponsesToOpenAI, null);
|
||||
invalidateCacheControlSettingsCache();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
async function invokeChatCore({
|
||||
body,
|
||||
provider = "openai",
|
||||
model = "gpt-4o-mini",
|
||||
endpoint = "/v1/chat/completions",
|
||||
accept = "application/json",
|
||||
userAgent = "unit-test",
|
||||
credentials,
|
||||
apiKeyInfo = null,
|
||||
responseFormat = "openai",
|
||||
responseFactory,
|
||||
isCombo = false,
|
||||
comboStrategy = null,
|
||||
} = {}) {
|
||||
const calls = [];
|
||||
|
||||
globalThis.fetch = async (url, init = {}) => {
|
||||
const headers = toPlainHeaders(init.headers);
|
||||
const captured = {
|
||||
url: String(url),
|
||||
method: init.method || "GET",
|
||||
headers,
|
||||
body: init.body ? JSON.parse(String(init.body)) : null,
|
||||
};
|
||||
calls.push(captured);
|
||||
|
||||
if (responseFactory) {
|
||||
return responseFactory(captured, calls);
|
||||
}
|
||||
|
||||
const upstreamStream = String(headers.accept || "")
|
||||
.toLowerCase()
|
||||
.includes("text/event-stream");
|
||||
if (responseFormat === "claude") return buildClaudeResponse(upstreamStream);
|
||||
if (responseFormat === "openai-responses") return buildResponsesResponse();
|
||||
return buildOpenAIResponse(upstreamStream);
|
||||
};
|
||||
|
||||
try {
|
||||
const requestBody = structuredClone(body);
|
||||
const result = await handleChatCore({
|
||||
body: requestBody,
|
||||
modelInfo: { provider, model, extendedContext: false },
|
||||
credentials: credentials || {
|
||||
apiKey: "sk-test",
|
||||
providerSpecificData: {},
|
||||
},
|
||||
log: noopLog(),
|
||||
clientRawRequest: {
|
||||
endpoint,
|
||||
body: structuredClone(body),
|
||||
headers: new Headers({ accept }),
|
||||
},
|
||||
apiKeyInfo,
|
||||
userAgent,
|
||||
isCombo,
|
||||
comboStrategy,
|
||||
});
|
||||
|
||||
return { result, calls, call: calls.at(-1) };
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
}
|
||||
|
||||
test.afterEach(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
globalThis.fetch = originalFetch;
|
||||
await resetStorage();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("chatCore keeps Responses-native Codex payloads in native passthrough mode", async () => {
|
||||
const { call, result } = await invokeChatCore({
|
||||
provider: "codex",
|
||||
model: "gpt-5.1-codex",
|
||||
endpoint: "/v1/responses",
|
||||
credentials: { accessToken: "codex-token", providerSpecificData: {} },
|
||||
body: {
|
||||
model: "gpt-5.1-codex",
|
||||
input: "ship it",
|
||||
instructions: "custom system prompt",
|
||||
store: true,
|
||||
metadata: { source: "codex-client" },
|
||||
stream: false,
|
||||
},
|
||||
responseFormat: "openai-responses",
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.match(call.url, /\/responses$/);
|
||||
assert.equal(call.body.input, "ship it");
|
||||
assert.equal(call.body.instructions, "custom system prompt");
|
||||
assert.equal(call.body.store, false);
|
||||
assert.deepEqual(call.body.metadata, { source: "codex-client" });
|
||||
assert.equal("messages" in call.body, false);
|
||||
});
|
||||
|
||||
test("chatCore builds Claude Code-compatible upstream requests for CC providers", async () => {
|
||||
const { call, result } = await invokeChatCore({
|
||||
provider: "anthropic-compatible-cc-test",
|
||||
model: "claude-sonnet-4-6",
|
||||
endpoint: "/v1/chat/completions",
|
||||
credentials: {
|
||||
apiKey: "sk-test",
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://proxy.example.com/v1/messages?beta=true",
|
||||
chatPath: "/v1/messages?beta=true",
|
||||
},
|
||||
},
|
||||
body: {
|
||||
model: "claude-sonnet-4-6",
|
||||
stream: false,
|
||||
messages: [{ role: "user", content: "Ping" }],
|
||||
},
|
||||
responseFormat: "claude",
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(call.headers.Accept ?? call.headers.accept, "text/event-stream");
|
||||
assert.equal(call.body.stream, true);
|
||||
assert.equal(call.body.context_management.edits[0].type, "clear_thinking_20251015");
|
||||
assert.equal(typeof call.body.metadata.user_id, "string");
|
||||
assert.equal(call.body.messages[0].role, "user");
|
||||
assert.equal(call.body.messages[0].content[0].text, "Ping");
|
||||
});
|
||||
|
||||
test("chatCore preserves cache_control automatically for Claude Code single-model requests", async () => {
|
||||
await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" });
|
||||
invalidateCacheControlSettingsCache();
|
||||
|
||||
const claudeBody = {
|
||||
model: "claude-sonnet-4-6",
|
||||
max_tokens: 64,
|
||||
system: [{ type: "text", text: "system", cache_control: { type: "ephemeral", ttl: "5m" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "u1", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "a1", cache_control: { type: "ephemeral", ttl: "10m" } }],
|
||||
},
|
||||
{ role: "user", content: [{ type: "text", text: "u2" }] },
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
name: "lookup_weather",
|
||||
description: "Fetch weather",
|
||||
input_schema: { type: "object" },
|
||||
cache_control: { type: "ephemeral", ttl: "30m" },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const { call } = await invokeChatCore({
|
||||
provider: "claude",
|
||||
model: "claude-sonnet-4-6",
|
||||
endpoint: "/v1/messages",
|
||||
credentials: { apiKey: "claude-key", providerSpecificData: {} },
|
||||
body: claudeBody,
|
||||
userAgent: "Claude-Code/1.0.0",
|
||||
responseFormat: "claude",
|
||||
});
|
||||
|
||||
assert.equal(hasCacheControl(call.body), true);
|
||||
assert.deepEqual(call.body.system[0].cache_control, { type: "ephemeral", ttl: "5m" });
|
||||
assert.deepEqual(call.body.messages[0].content[0].cache_control, { type: "ephemeral" });
|
||||
assert.deepEqual(call.body.tools[0].cache_control, { type: "ephemeral", ttl: "30m" });
|
||||
});
|
||||
|
||||
test("chatCore auto cache policy becomes false for nondeterministic combos", async () => {
|
||||
await settingsDb.updateSettings({ alwaysPreserveClientCache: "auto" });
|
||||
invalidateCacheControlSettingsCache();
|
||||
|
||||
const { call } = await invokeChatCore({
|
||||
provider: "claude",
|
||||
model: "claude-sonnet-4-6",
|
||||
endpoint: "/v1/messages",
|
||||
credentials: { apiKey: "claude-key", providerSpecificData: {} },
|
||||
body: {
|
||||
model: "claude-sonnet-4-6",
|
||||
max_tokens: 64,
|
||||
system: [{ type: "text", text: "system", cache_control: { type: "ephemeral", ttl: "5m" } }],
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "u1" }] }],
|
||||
},
|
||||
userAgent: "Claude-Code/1.0.0",
|
||||
isCombo: true,
|
||||
comboStrategy: "latency-optimized",
|
||||
responseFormat: "claude",
|
||||
});
|
||||
|
||||
assert.equal(call.body.system[0].text.includes("You are Claude Code"), true);
|
||||
assert.equal(
|
||||
call.body.system.some((block) => block.cache_control?.ttl === "5m"),
|
||||
false
|
||||
);
|
||||
assert.equal(call.body.system.at(-1).cache_control?.ttl, "1h");
|
||||
});
|
||||
|
||||
test("chatCore always-preserve mode keeps cache_control even without Claude Code user-agent", async () => {
|
||||
await settingsDb.updateSettings({ alwaysPreserveClientCache: "always" });
|
||||
invalidateCacheControlSettingsCache();
|
||||
|
||||
const { call } = await invokeChatCore({
|
||||
provider: "claude",
|
||||
model: "claude-sonnet-4-6",
|
||||
endpoint: "/v1/messages",
|
||||
credentials: { apiKey: "claude-key", providerSpecificData: {} },
|
||||
body: {
|
||||
model: "claude-sonnet-4-6",
|
||||
max_tokens: 64,
|
||||
system: [{ type: "text", text: "system", cache_control: { type: "ephemeral", ttl: "5m" } }],
|
||||
messages: [{ role: "user", content: [{ type: "text", text: "u1" }] }],
|
||||
},
|
||||
responseFormat: "claude",
|
||||
});
|
||||
|
||||
assert.equal(hasCacheControl(call.body), true);
|
||||
assert.deepEqual(call.body.system[0].cache_control, { type: "ephemeral", ttl: "5m" });
|
||||
});
|
||||
|
||||
test("chatCore disables raw Claude passthrough when cache preservation is off and normalizes through OpenAI", async () => {
|
||||
await settingsDb.updateSettings({ alwaysPreserveClientCache: "never" });
|
||||
invalidateCacheControlSettingsCache();
|
||||
|
||||
const { call } = await invokeChatCore({
|
||||
provider: "claude",
|
||||
model: "claude-sonnet-4-6",
|
||||
endpoint: "/v1/messages",
|
||||
credentials: { apiKey: "claude-key", providerSpecificData: {} },
|
||||
body: {
|
||||
model: "claude-sonnet-4-6",
|
||||
max_tokens: 64,
|
||||
system: [{ type: "text", text: "system", cache_control: { type: "ephemeral", ttl: "5m" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "u1", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
userAgent: "Claude-Code/1.0.0",
|
||||
responseFormat: "claude",
|
||||
});
|
||||
|
||||
assert.equal(call.body.system[0].text.includes("You are Claude Code"), true);
|
||||
assert.equal(call.body.system.at(-1).cache_control?.ttl, "1h");
|
||||
assert.equal(call.body.messages[0].content[0].cache_control, undefined);
|
||||
assert.equal("_disableToolPrefix" in call.body, false);
|
||||
});
|
||||
|
||||
test("chatCore default translation converts Claude requests to OpenAI and strips cache markers for non-Claude providers", async () => {
|
||||
const { call } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
endpoint: "/v1/messages",
|
||||
body: {
|
||||
model: "claude-sonnet-4-6",
|
||||
max_tokens: 64,
|
||||
system: [{ type: "text", text: "system", cache_control: { type: "ephemeral", ttl: "5m" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "u1", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
},
|
||||
userAgent: "Claude-Code/1.0.0",
|
||||
responseFormat: "openai",
|
||||
});
|
||||
|
||||
assert.equal(call.body.model, "gpt-4o-mini");
|
||||
assert.equal(Array.isArray(call.body.messages), true);
|
||||
assert.equal(call.body.messages[0].role, "system");
|
||||
assert.equal(JSON.stringify(call.body).includes("cache_control"), false);
|
||||
});
|
||||
|
||||
test("chatCore sets Claude tool prefix disabling, strips empty Anthropic text blocks, and cleans helper flags", async () => {
|
||||
const { call } = await invokeChatCore({
|
||||
provider: "claude",
|
||||
model: "claude-sonnet-4-6",
|
||||
endpoint: "/v1/chat/completions",
|
||||
credentials: { apiKey: "claude-key", providerSpecificData: {} },
|
||||
body: {
|
||||
model: "ignored-client-model",
|
||||
_toolNameMap: new Map([["proxy_Bash", "Bash"]]),
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "" },
|
||||
{ type: "text", text: "hello" },
|
||||
],
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "Bash",
|
||||
description: "Execute bash",
|
||||
parameters: { type: "object" },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
responseFormat: "claude",
|
||||
});
|
||||
|
||||
assert.equal(call.body.model, "claude-sonnet-4-6");
|
||||
assert.equal(call.body.tools[0].name, "Bash");
|
||||
assert.equal(call.body.tools[0].name.startsWith("proxy_"), false);
|
||||
assert.equal(call.body._toolNameMap, undefined);
|
||||
assert.equal(call.body._disableToolPrefix, undefined);
|
||||
assert.deepEqual(
|
||||
collectTextBlocks(call.body.messages).map((block) => block.text),
|
||||
["hello"]
|
||||
);
|
||||
});
|
||||
|
||||
test("chatCore strips unsupported reasoning params and caps provider token fields", async () => {
|
||||
const { call } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
model: "o3",
|
||||
endpoint: "/v1/chat/completions",
|
||||
body: {
|
||||
model: "o3",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
temperature: 0.7,
|
||||
presence_penalty: 1,
|
||||
max_tokens: 99999,
|
||||
max_completion_tokens: 77777,
|
||||
},
|
||||
responseFormat: "openai",
|
||||
});
|
||||
|
||||
assert.equal(call.body.temperature, undefined);
|
||||
assert.equal(call.body.presence_penalty, undefined);
|
||||
assert.equal(call.body.max_tokens, 16384);
|
||||
assert.equal(call.body.max_completion_tokens, 16384);
|
||||
});
|
||||
|
||||
test("chatCore surfaces translation errors with explicit status codes", async () => {
|
||||
register(
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.OPENAI,
|
||||
() => {
|
||||
const error = new Error("responses translator rejected the payload");
|
||||
error.statusCode = 409;
|
||||
throw error;
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
const { result } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
endpoint: "/v1/responses",
|
||||
body: {
|
||||
model: "gpt-4o-mini",
|
||||
input: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 409);
|
||||
assert.equal(result.error, "responses translator rejected the payload");
|
||||
});
|
||||
|
||||
test("chatCore surfaces typed translation errors with the declared error type", async () => {
|
||||
register(
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.OPENAI,
|
||||
() => {
|
||||
const error = new Error("typed translator failure");
|
||||
error.statusCode = 422;
|
||||
error.errorType = "unsupported_feature";
|
||||
throw error;
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
const { result } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
endpoint: "/v1/responses",
|
||||
body: {
|
||||
model: "gpt-4o-mini",
|
||||
input: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 422);
|
||||
|
||||
const payload = await result.response.json();
|
||||
assert.equal(payload.error.type, "unsupported_feature");
|
||||
assert.equal(payload.error.code, "unsupported_feature");
|
||||
});
|
||||
|
||||
test("chatCore returns 500 when translation throws a generic error", async () => {
|
||||
register(
|
||||
FORMATS.OPENAI_RESPONSES,
|
||||
FORMATS.OPENAI,
|
||||
() => {
|
||||
throw new Error("unexpected translator crash");
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
const { result } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
endpoint: "/v1/responses",
|
||||
body: {
|
||||
model: "gpt-4o-mini",
|
||||
input: "hello",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 500);
|
||||
assert.equal(result.error, "unexpected translator crash");
|
||||
});
|
||||
|
||||
test("chatCore uses the native executor when no upstream proxy mode is enabled", async () => {
|
||||
const { call } = await invokeChatCore({
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini",
|
||||
body: {
|
||||
model: "gpt-4o-mini",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
},
|
||||
responseFormat: "openai",
|
||||
});
|
||||
|
||||
assert.match(call.url, /^https:\/\/api\.openai\.com\/v1\/chat\/completions$/);
|
||||
});
|
||||
|
||||
test("chatCore routes providers through CLIProxyAPI in passthrough mode", async () => {
|
||||
await upstreamProxyDb.upsertUpstreamProxyConfig({
|
||||
providerId: "qoder",
|
||||
mode: "cliproxyapi",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const { call } = await invokeChatCore({
|
||||
provider: "qoder",
|
||||
model: "qoder-rome-30ba3b",
|
||||
credentials: { apiKey: "qoder-token", providerSpecificData: {} },
|
||||
body: {
|
||||
model: "qoder-rome-30ba3b",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
},
|
||||
responseFormat: "openai",
|
||||
});
|
||||
|
||||
assert.match(call.url, /^http:\/\/127\.0\.0\.1:8317\/v1\/chat\/completions$/);
|
||||
assert.equal(call.headers.Authorization ?? call.headers.authorization, "Bearer qoder-token");
|
||||
});
|
||||
|
||||
test("chatCore fallback proxy mode retries through CLIProxyAPI after retryable native failures", async () => {
|
||||
await upstreamProxyDb.upsertUpstreamProxyConfig({
|
||||
providerId: "github",
|
||||
mode: "fallback",
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const { calls, result } = await invokeChatCore({
|
||||
provider: "github",
|
||||
model: "gpt-4o",
|
||||
credentials: { accessToken: "gh-token", providerSpecificData: {} },
|
||||
body: {
|
||||
model: "gpt-4o",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
},
|
||||
responseFormat: "openai",
|
||||
responseFactory(captured, seenCalls) {
|
||||
if (seenCalls.length === 1) {
|
||||
return new Response(JSON.stringify({ error: { message: "native failed" } }), {
|
||||
status: 500,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
}
|
||||
assert.match(captured.url, /^http:\/\/127\.0\.0\.1:8317\/v1\/chat\/completions$/);
|
||||
return buildOpenAIResponse(false, "retried");
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(calls.length, 2);
|
||||
assert.match(calls[0].url, /^https:\/\/api\.githubcopilot\.com\/chat\/completions$/);
|
||||
assert.match(calls[1].url, /^http:\/\/127\.0\.0\.1:8317\/v1\/chat\/completions$/);
|
||||
});
|
||||
82
tests/unit/claude-code-compatible-helpers.test.mjs
Normal file
82
tests/unit/claude-code-compatible-helpers.test.mjs
Normal file
@@ -0,0 +1,82 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS,
|
||||
isClaudeCodeCompatibleProvider,
|
||||
stripAnthropicMessagesSuffix,
|
||||
stripClaudeCodeCompatibleEndpointSuffix,
|
||||
joinBaseUrlAndPath,
|
||||
joinClaudeCodeCompatibleUrl,
|
||||
buildClaudeCodeCompatibleHeaders,
|
||||
buildClaudeCodeCompatibleValidationPayload,
|
||||
resolveClaudeCodeCompatibleSessionId,
|
||||
} = await import("../../open-sse/services/claudeCodeCompatible.ts");
|
||||
|
||||
const { isClaudeCodeCompatible } = await import("../../open-sse/services/provider.ts");
|
||||
|
||||
test("Claude Code compatible provider detection matches the shared prefix contract", () => {
|
||||
assert.equal(isClaudeCodeCompatibleProvider("anthropic-compatible-cc-demo"), true);
|
||||
assert.equal(isClaudeCodeCompatible("anthropic-compatible-cc-demo"), true);
|
||||
assert.equal(isClaudeCodeCompatibleProvider("anthropic-compatible-demo"), false);
|
||||
assert.equal(isClaudeCodeCompatible(null), false);
|
||||
});
|
||||
|
||||
test("base URL helpers strip messages suffixes and join canonical paths", () => {
|
||||
const baseUrl = "https://cc.example.com/v1/messages?beta=true";
|
||||
|
||||
assert.equal(stripAnthropicMessagesSuffix(baseUrl), "https://cc.example.com/v1");
|
||||
assert.equal(stripClaudeCodeCompatibleEndpointSuffix(baseUrl), "https://cc.example.com");
|
||||
assert.equal(
|
||||
joinBaseUrlAndPath(baseUrl, CLAUDE_CODE_COMPATIBLE_DEFAULT_MODELS_PATH),
|
||||
"https://cc.example.com/v1/models"
|
||||
);
|
||||
assert.equal(
|
||||
joinClaudeCodeCompatibleUrl(baseUrl, CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH),
|
||||
"https://cc.example.com/v1/messages?beta=true"
|
||||
);
|
||||
});
|
||||
|
||||
test("buildClaudeCodeCompatibleHeaders emits stream-aware auth headers and session id", () => {
|
||||
const streamHeaders = buildClaudeCodeCompatibleHeaders("sk-demo", true, "session-123");
|
||||
const jsonHeaders = buildClaudeCodeCompatibleHeaders("sk-demo", false);
|
||||
|
||||
assert.equal(streamHeaders.Accept, "text/event-stream");
|
||||
assert.equal(streamHeaders["x-api-key"], "sk-demo");
|
||||
assert.equal(streamHeaders["X-Claude-Code-Session-Id"], "session-123");
|
||||
assert.equal(jsonHeaders.Accept, "application/json");
|
||||
assert.equal(jsonHeaders["X-Claude-Code-Session-Id"], undefined);
|
||||
});
|
||||
|
||||
test("resolveClaudeCodeCompatibleSessionId prefers explicit session headers and generates a fallback id", () => {
|
||||
const headers = new Headers({
|
||||
"x-session-id": "legacy-session",
|
||||
"x-claude-code-session-id": "preferred-session",
|
||||
});
|
||||
|
||||
assert.equal(resolveClaudeCodeCompatibleSessionId(headers), "preferred-session");
|
||||
assert.match(
|
||||
resolveClaudeCodeCompatibleSessionId({}),
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
|
||||
);
|
||||
});
|
||||
|
||||
test("buildClaudeCodeCompatibleValidationPayload produces the expected smoke-test request", () => {
|
||||
const payload = buildClaudeCodeCompatibleValidationPayload("claude-sonnet-4-6");
|
||||
|
||||
assert.equal(payload.model, "claude-sonnet-4-6");
|
||||
assert.equal(payload.stream, true);
|
||||
assert.equal(payload.max_tokens, 1);
|
||||
assert.equal(payload.output_config.effort, "high");
|
||||
assert.equal(payload.messages.length, 1);
|
||||
assert.deepEqual(payload.messages[0], {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
});
|
||||
assert.equal(payload.tools.length, 0);
|
||||
assert.ok(JSON.parse(payload.metadata.user_id).session_id);
|
||||
assert.ok(payload.system.some((block) => String(block.text).includes(process.cwd())));
|
||||
assert.ok(CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS > payload.max_tokens);
|
||||
});
|
||||
242
tests/unit/claude-code-compatible-request.test.mjs
Normal file
242
tests/unit/claude-code-compatible-request.test.mjs
Normal file
@@ -0,0 +1,242 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS,
|
||||
stripClaudeCodeCompatibleEndpointSuffix,
|
||||
joinBaseUrlAndPath,
|
||||
joinClaudeCodeCompatibleUrl,
|
||||
resolveClaudeCodeCompatibleSessionId,
|
||||
resolveClaudeCodeCompatibleEffort,
|
||||
resolveClaudeCodeCompatibleMaxTokens,
|
||||
buildClaudeCodeCompatibleRequest,
|
||||
} = await import("../../open-sse/services/claudeCodeCompatible.ts");
|
||||
|
||||
test("Claude Code compatible URL helpers cover empty values, version trimming and legacy session headers", () => {
|
||||
assert.equal(stripClaudeCodeCompatibleEndpointSuffix(""), "");
|
||||
assert.equal(
|
||||
stripClaudeCodeCompatibleEndpointSuffix("https://api.example.com/v1/messages"),
|
||||
"https://api.example.com"
|
||||
);
|
||||
assert.equal(
|
||||
joinBaseUrlAndPath("https://api.example.com/v1", "v1/messages"),
|
||||
"https://api.example.com/v1/messages"
|
||||
);
|
||||
assert.equal(
|
||||
joinClaudeCodeCompatibleUrl("https://api.example.com/v1/messages", "models"),
|
||||
"https://api.example.com/models"
|
||||
);
|
||||
assert.equal(
|
||||
resolveClaudeCodeCompatibleSessionId({ "x-omniroute-session": " session-from-proxy " }),
|
||||
"session-from-proxy"
|
||||
);
|
||||
});
|
||||
|
||||
test("Claude Code compatible effort and max token helpers cover priority fallbacks", () => {
|
||||
assert.equal(resolveClaudeCodeCompatibleEffort({ reasoning_effort: "medium" }), "medium");
|
||||
assert.equal(resolveClaudeCodeCompatibleEffort({ reasoning: { effort: "none" } }), "low");
|
||||
assert.equal(resolveClaudeCodeCompatibleEffort({ output_config: { effort: "xhigh" } }), "high");
|
||||
assert.equal(
|
||||
resolveClaudeCodeCompatibleEffort({ output_config: { effort: "unexpected" } }),
|
||||
"high"
|
||||
);
|
||||
|
||||
assert.equal(resolveClaudeCodeCompatibleMaxTokens({ max_completion_tokens: "17" }), 17);
|
||||
assert.equal(
|
||||
resolveClaudeCodeCompatibleMaxTokens({ max_output_tokens: -1 }, { max_tokens: 9 }),
|
||||
9
|
||||
);
|
||||
assert.equal(resolveClaudeCodeCompatibleMaxTokens({}, { max_output_tokens: "31.9" }), 31);
|
||||
assert.equal(
|
||||
resolveClaudeCodeCompatibleMaxTokens({}, {}),
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_MAX_TOKENS
|
||||
);
|
||||
});
|
||||
|
||||
test("buildClaudeCodeCompatibleRequest covers normalized OpenAI-style messages, source tools and fallback text", () => {
|
||||
const payload = buildClaudeCodeCompatibleRequest({
|
||||
sourceBody: {
|
||||
tools: [
|
||||
null,
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "lookup_account",
|
||||
description: "Find account data",
|
||||
parameters: { type: "object" },
|
||||
defer_loading: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
tool_choice: { type: "function", function: { name: "lookup_account" } },
|
||||
reasoning: { effort: "disabled" },
|
||||
max_completion_tokens: 19,
|
||||
},
|
||||
normalizedBody: {
|
||||
messages: [
|
||||
{ role: "assistant", content: [{ type: "text", text: "draft answer" }] },
|
||||
{ role: "model", content: { text: "alternate answer" } },
|
||||
{ role: "system", content: "system note" },
|
||||
{ role: "developer", content: [{ type: "text", text: "developer note" }] },
|
||||
{ role: "tool", content: "ignored" },
|
||||
],
|
||||
},
|
||||
model: "claude-sonnet-4-6",
|
||||
cwd: "/tmp/claude-code-compatible",
|
||||
now: new Date("2026-01-02T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
assert.deepEqual(payload.messages, [
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "draft answer\nalternate answer" }],
|
||||
},
|
||||
]);
|
||||
assert.equal(payload.system[3].text, "system note");
|
||||
assert.equal(payload.system[4].text, "developer note");
|
||||
assert.equal(payload.tools.length, 1);
|
||||
assert.deepEqual(payload.tools[0], {
|
||||
name: "lookup_account",
|
||||
description: "Find account data",
|
||||
input_schema: { type: "object", properties: {} },
|
||||
defer_loading: true,
|
||||
});
|
||||
assert.deepEqual(payload.tool_choice, { type: "tool", name: "lookup_account" });
|
||||
assert.equal(payload.output_config.effort, "low");
|
||||
assert.equal(payload.max_tokens, 19);
|
||||
});
|
||||
|
||||
test("buildClaudeCodeCompatibleRequest covers Claude-native bodies and cache-control stripping", () => {
|
||||
const stripped = buildClaudeCodeCompatibleRequest({
|
||||
claudeBody: {
|
||||
system: [{ type: "text", text: "sys", cache_control: { type: "ephemeral" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "prefill", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "ask", cache_control: { type: "ephemeral" } },
|
||||
{
|
||||
type: "image",
|
||||
source: { type: "base64", media_type: "image/png", data: "abc" },
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "tail", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{ name: "toolA", input_schema: { type: "object" }, cache_control: { type: "ephemeral" } },
|
||||
],
|
||||
thinking: { type: "enabled", budget_tokens: 12 },
|
||||
},
|
||||
model: "claude-sonnet-4-6",
|
||||
preserveCacheControl: false,
|
||||
cwd: "/tmp/claude-code-compatible",
|
||||
now: new Date("2026-01-02T12:00:00.000Z"),
|
||||
stream: true,
|
||||
sessionId: "explicit-session",
|
||||
});
|
||||
|
||||
const preserved = buildClaudeCodeCompatibleRequest({
|
||||
claudeBody: {
|
||||
system: [{ type: "text", text: "sys", cache_control: { type: "ephemeral" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "prefill", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "ask", cache_control: { type: "ephemeral" } }],
|
||||
},
|
||||
],
|
||||
tools: [
|
||||
{ name: "toolA", input_schema: { type: "object" }, cache_control: { type: "ephemeral" } },
|
||||
],
|
||||
},
|
||||
model: "claude-sonnet-4-6",
|
||||
preserveCacheControl: true,
|
||||
cwd: "/tmp/claude-code-compatible",
|
||||
now: new Date("2026-01-02T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
assert.equal(stripped.stream, true);
|
||||
assert.equal(JSON.parse(stripped.metadata.user_id).session_id, "explicit-session");
|
||||
assert.equal(stripped.messages.at(-1).role, "user");
|
||||
assert.equal(stripped.messages[0].content[0].cache_control, undefined);
|
||||
assert.equal(stripped.system.at(-1).cache_control, undefined);
|
||||
assert.equal(stripped.tools[0].cache_control, undefined);
|
||||
assert.equal(preserved.messages[0].content[0].cache_control.type, "ephemeral");
|
||||
assert.equal(preserved.system.at(-1).cache_control.type, "ephemeral");
|
||||
assert.equal(preserved.tools[0].cache_control.type, "ephemeral");
|
||||
});
|
||||
|
||||
test("buildClaudeCodeCompatibleRequest omits tool choice when there are no tools", () => {
|
||||
const payload = buildClaudeCodeCompatibleRequest({
|
||||
normalizedBody: {
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
tool_choice: "required",
|
||||
reasoning_effort: "high",
|
||||
},
|
||||
model: "claude-sonnet-4-6",
|
||||
cwd: "/tmp/claude-code-compatible",
|
||||
now: new Date("2026-01-02T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
assert.equal(payload.tools.length, 0);
|
||||
assert.equal("tool_choice" in payload, false);
|
||||
assert.equal(payload.output_config.effort, "high");
|
||||
});
|
||||
|
||||
test("buildClaudeCodeCompatibleRequest covers string system input, non-array Claude fields and tool choice variants", () => {
|
||||
const anyChoice = buildClaudeCodeCompatibleRequest({
|
||||
normalizedBody: {
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
tools: [
|
||||
{
|
||||
name: "direct_tool",
|
||||
input_schema: { type: "object", properties: { q: { type: "string" } } },
|
||||
},
|
||||
{
|
||||
type: "function",
|
||||
function: {
|
||||
name: "missing_description",
|
||||
parameters: { type: "object", properties: { x: { type: "string" } } },
|
||||
},
|
||||
},
|
||||
{ type: "function", function: { parameters: { type: "object" } } },
|
||||
],
|
||||
tool_choice: { type: "any" },
|
||||
},
|
||||
model: "claude-sonnet-4-6",
|
||||
cwd: "/tmp/claude-code-compatible",
|
||||
now: new Date("2026-01-02T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
const stringSystem = buildClaudeCodeCompatibleRequest({
|
||||
claudeBody: {
|
||||
system: " custom system ",
|
||||
messages: "not-an-array",
|
||||
tools: "not-an-array",
|
||||
thinking: "disabled",
|
||||
},
|
||||
model: "claude-sonnet-4-6",
|
||||
cwd: "/tmp/claude-code-compatible",
|
||||
now: new Date("2026-01-02T12:00:00.000Z"),
|
||||
});
|
||||
|
||||
assert.deepEqual(anyChoice.tool_choice, { type: "any" });
|
||||
assert.equal(anyChoice.tools.length, 2);
|
||||
assert.equal(anyChoice.tools[0].input_schema.properties.q.type, "string");
|
||||
assert.equal(anyChoice.tools[1].description, "");
|
||||
assert.equal(stringSystem.messages.length, 0);
|
||||
assert.equal(stringSystem.tools.length, 0);
|
||||
assert.equal(stringSystem.system.at(-1).text, "custom system");
|
||||
});
|
||||
97
tests/unit/combo-config.test.mjs
Normal file
97
tests/unit/combo-config.test.mjs
Normal file
@@ -0,0 +1,97 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { resolveComboConfig, getDefaultComboConfig } =
|
||||
await import("../../open-sse/services/comboConfig.ts");
|
||||
|
||||
test("getDefaultComboConfig returns a fresh copy of the defaults", () => {
|
||||
const first = getDefaultComboConfig();
|
||||
const second = getDefaultComboConfig();
|
||||
|
||||
assert.notEqual(first, second);
|
||||
assert.equal(first.strategy, "priority");
|
||||
assert.equal(first.maxRetries, 1);
|
||||
assert.equal(first.timeoutMs, 600000);
|
||||
|
||||
first.strategy = "weighted";
|
||||
assert.equal(second.strategy, "priority");
|
||||
});
|
||||
|
||||
test("resolveComboConfig applies the full cascade from defaults to combo overrides", () => {
|
||||
const result = resolveComboConfig(
|
||||
{
|
||||
config: {
|
||||
maxRetries: 4,
|
||||
timeoutMs: 45000,
|
||||
},
|
||||
},
|
||||
{
|
||||
comboDefaults: {
|
||||
strategy: "round-robin",
|
||||
timeoutMs: 120000,
|
||||
},
|
||||
providerOverrides: {
|
||||
openai: {
|
||||
timeoutMs: 60000,
|
||||
retryDelayMs: 500,
|
||||
},
|
||||
},
|
||||
},
|
||||
"openai"
|
||||
);
|
||||
|
||||
assert.equal(result.strategy, "round-robin");
|
||||
assert.equal(result.retryDelayMs, 500);
|
||||
assert.equal(result.timeoutMs, 45000);
|
||||
assert.equal(result.maxRetries, 4);
|
||||
assert.equal(result.healthCheckEnabled, true);
|
||||
});
|
||||
|
||||
test("resolveComboConfig ignores null and undefined overrides", () => {
|
||||
const result = resolveComboConfig(
|
||||
{
|
||||
config: {
|
||||
timeoutMs: null,
|
||||
trackMetrics: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
comboDefaults: {
|
||||
timeoutMs: undefined,
|
||||
queueTimeoutMs: 15000,
|
||||
},
|
||||
providerOverrides: {
|
||||
openai: {
|
||||
strategy: null,
|
||||
concurrencyPerModel: 9,
|
||||
},
|
||||
},
|
||||
},
|
||||
"openai"
|
||||
);
|
||||
|
||||
assert.equal(result.timeoutMs, 600000);
|
||||
assert.equal(result.queueTimeoutMs, 15000);
|
||||
assert.equal(result.concurrencyPerModel, 9);
|
||||
assert.equal(result.trackMetrics, false);
|
||||
assert.equal(result.strategy, "priority");
|
||||
});
|
||||
|
||||
test("resolveComboConfig skips provider overrides when provider is absent", () => {
|
||||
const result = resolveComboConfig(
|
||||
{ config: {} },
|
||||
{
|
||||
comboDefaults: { strategy: "random" },
|
||||
providerOverrides: {
|
||||
openai: { strategy: "weighted" },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(result.strategy, "random");
|
||||
});
|
||||
|
||||
test("resolveComboConfig tolerates invalid or missing inputs and falls back to defaults", () => {
|
||||
assert.deepEqual(resolveComboConfig(null, null, "openai"), getDefaultComboConfig());
|
||||
assert.deepEqual(resolveComboConfig({}, { comboDefaults: null }, null), getDefaultComboConfig());
|
||||
});
|
||||
632
tests/unit/combo-routing-engine.test.mjs
Normal file
632
tests/unit/combo-routing-engine.test.mjs
Normal file
@@ -0,0 +1,632 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const {
|
||||
getComboFromData,
|
||||
getComboModelsFromData,
|
||||
validateComboDAG,
|
||||
resolveNestedComboModels,
|
||||
handleComboChat,
|
||||
} = await import("../../open-sse/services/combo.ts");
|
||||
const { getComboMetrics, recordComboRequest, resetAllComboMetrics } =
|
||||
await import("../../open-sse/services/comboMetrics.ts");
|
||||
const { resetAllCircuitBreakers } = await import("../../src/shared/utils/circuitBreaker.ts");
|
||||
const { acquire: acquireSemaphore, resetAll: resetAllSemaphores } =
|
||||
await import("../../open-sse/services/rateLimitSemaphore.ts");
|
||||
|
||||
function createLog() {
|
||||
const entries = [];
|
||||
return {
|
||||
info: (tag, msg) => entries.push({ level: "info", tag, msg }),
|
||||
warn: (tag, msg) => entries.push({ level: "warn", tag, msg }),
|
||||
error: (tag, msg) => entries.push({ level: "error", tag, msg }),
|
||||
entries,
|
||||
};
|
||||
}
|
||||
|
||||
function okResponse(body = { choices: [{ message: { content: "ok" } }] }) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function errorResponse(status, message = `Error ${status}`) {
|
||||
return new Response(JSON.stringify({ error: { message } }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetAllComboMetrics();
|
||||
resetAllCircuitBreakers();
|
||||
resetAllSemaphores();
|
||||
});
|
||||
|
||||
test("getComboFromData and getComboModelsFromData resolve combos from array and object containers", () => {
|
||||
const combos = [
|
||||
{ name: "alpha", models: ["openai/gpt-4o-mini", { model: "claude/sonnet", weight: 2 }] },
|
||||
];
|
||||
|
||||
const fromArray = getComboFromData("alpha", combos);
|
||||
const fromObject = getComboFromData("alpha", { combos });
|
||||
const models = getComboModelsFromData("alpha", { combos });
|
||||
|
||||
assert.equal(fromArray.name, "alpha");
|
||||
assert.equal(fromObject.name, "alpha");
|
||||
assert.deepEqual(models, ["openai/gpt-4o-mini", "claude/sonnet"]);
|
||||
});
|
||||
|
||||
test("validateComboDAG rejects circular references and resolveNestedComboModels expands nested combos", () => {
|
||||
const combos = [
|
||||
{ name: "root", models: ["child-a", "openai/gpt-4o-mini"] },
|
||||
{ name: "child-a", models: ["child-b", "claude/sonnet"] },
|
||||
{ name: "child-b", models: ["groq/llama-3.3-70b"] },
|
||||
];
|
||||
|
||||
validateComboDAG("root", combos);
|
||||
assert.deepEqual(resolveNestedComboModels(combos[0], combos), [
|
||||
"groq/llama-3.3-70b",
|
||||
"claude/sonnet",
|
||||
"openai/gpt-4o-mini",
|
||||
]);
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
validateComboDAG("loop-a", [
|
||||
{ name: "loop-a", models: ["loop-b"] },
|
||||
{ name: "loop-b", models: ["loop-a"] },
|
||||
]),
|
||||
/Circular combo reference detected/
|
||||
);
|
||||
});
|
||||
|
||||
test("validateComboDAG enforces maximum nesting depth", () => {
|
||||
const combos = [
|
||||
{ name: "c1", models: ["c2"] },
|
||||
{ name: "c2", models: ["c3"] },
|
||||
{ name: "c3", models: ["c4"] },
|
||||
{ name: "c4", models: ["c5"] },
|
||||
{ name: "c5", models: ["openai/gpt-4o-mini"] },
|
||||
];
|
||||
|
||||
assert.throws(() => validateComboDAG("c1", combos), /Max combo nesting depth/);
|
||||
});
|
||||
|
||||
test("handleComboChat priority strategy defaults to first model and records success metrics", async () => {
|
||||
const calls = [];
|
||||
const combo = {
|
||||
name: "priority-default",
|
||||
models: ["openai/gpt-4o-mini", "claude/sonnet"],
|
||||
};
|
||||
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo,
|
||||
handleSingleModel: async (_body, modelStr) => {
|
||||
calls.push(modelStr);
|
||||
return okResponse();
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
const metrics = getComboMetrics("priority-default");
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, ["openai/gpt-4o-mini"]);
|
||||
assert.equal(metrics.totalRequests, 1);
|
||||
assert.equal(metrics.totalSuccesses, 1);
|
||||
assert.equal(metrics.byModel["openai/gpt-4o-mini"].requests, 1);
|
||||
assert.equal(metrics.strategy, "priority");
|
||||
});
|
||||
|
||||
test("handleComboChat weighted strategy selects by weight and falls back in descending weight order", async () => {
|
||||
const originalRandom = Math.random;
|
||||
const calls = [];
|
||||
|
||||
Math.random = () => 0.95;
|
||||
|
||||
try {
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo: {
|
||||
name: "weighted-selection",
|
||||
strategy: "weighted",
|
||||
models: [
|
||||
{ model: "openai/gpt-4o-mini", weight: 1 },
|
||||
{ model: "claude/sonnet", weight: 9 },
|
||||
],
|
||||
config: { maxRetries: 0 },
|
||||
},
|
||||
handleSingleModel: async (_body, modelStr) => {
|
||||
calls.push(modelStr);
|
||||
if (modelStr === "claude/sonnet") return errorResponse(500, "temporary");
|
||||
return okResponse();
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, ["claude/sonnet", "openai/gpt-4o-mini"]);
|
||||
} finally {
|
||||
Math.random = originalRandom;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleComboChat random strategy uses shuffled model order", async () => {
|
||||
const originalRandom = Math.random;
|
||||
const calls = [];
|
||||
const sequence = [0.99, 0.0];
|
||||
let idx = 0;
|
||||
Math.random = () => sequence[idx++] ?? 0;
|
||||
|
||||
try {
|
||||
await handleComboChat({
|
||||
body: {},
|
||||
combo: {
|
||||
name: "random-order",
|
||||
strategy: "random",
|
||||
models: ["model-a", "model-b", "model-c"],
|
||||
},
|
||||
handleSingleModel: async (_body, modelStr) => {
|
||||
calls.push(modelStr);
|
||||
return okResponse();
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.notEqual(calls[0], "model-a");
|
||||
} finally {
|
||||
Math.random = originalRandom;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleComboChat least-used strategy prefers the model with fewer recorded requests", async () => {
|
||||
recordComboRequest("least-used-combo", "model-a", {
|
||||
success: true,
|
||||
latencyMs: 100,
|
||||
strategy: "least-used",
|
||||
});
|
||||
recordComboRequest("least-used-combo", "model-a", {
|
||||
success: true,
|
||||
latencyMs: 100,
|
||||
strategy: "least-used",
|
||||
});
|
||||
recordComboRequest("least-used-combo", "model-b", {
|
||||
success: true,
|
||||
latencyMs: 100,
|
||||
strategy: "least-used",
|
||||
});
|
||||
|
||||
const calls = [];
|
||||
|
||||
await handleComboChat({
|
||||
body: {},
|
||||
combo: {
|
||||
name: "least-used-combo",
|
||||
strategy: "least-used",
|
||||
models: ["model-a", "model-b", "model-c"],
|
||||
},
|
||||
handleSingleModel: async (_body, modelStr) => {
|
||||
calls.push(modelStr);
|
||||
return okResponse();
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
assert.equal(calls[0], "model-c");
|
||||
});
|
||||
|
||||
test("handleComboChat skips unavailable models and falls through to the next active target", async () => {
|
||||
const calls = [];
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo: {
|
||||
name: "availability-skip",
|
||||
strategy: "priority",
|
||||
models: ["model-a", "model-b"],
|
||||
},
|
||||
handleSingleModel: async (_body, modelStr) => {
|
||||
calls.push(modelStr);
|
||||
return okResponse();
|
||||
},
|
||||
isModelAvailable: async (modelStr) => modelStr !== "model-a",
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, ["model-b"]);
|
||||
});
|
||||
|
||||
test("handleComboChat falls through empty successful responses and records failure metrics before succeeding", async () => {
|
||||
const calls = [];
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo: {
|
||||
name: "quality-fallback",
|
||||
strategy: "priority",
|
||||
models: ["model-a", "model-b"],
|
||||
config: { maxRetries: 0 },
|
||||
},
|
||||
handleSingleModel: async (_body, modelStr) => {
|
||||
calls.push(modelStr);
|
||||
if (modelStr === "model-a") {
|
||||
return okResponse({ choices: [{ message: { content: "" } }] });
|
||||
}
|
||||
return okResponse({ choices: [{ message: { content: "fallback ok" } }] });
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
const metrics = getComboMetrics("quality-fallback");
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, ["model-a", "model-b"]);
|
||||
assert.equal(metrics.totalRequests, 2);
|
||||
assert.equal(metrics.totalFailures, 1);
|
||||
assert.equal(metrics.totalSuccesses, 1);
|
||||
assert.equal(metrics.byModel["model-a"].lastStatus, "error");
|
||||
assert.equal(metrics.byModel["model-b"].lastStatus, "ok");
|
||||
});
|
||||
|
||||
test("handleComboChat preserves the first failure status but surfaces the last error message", async () => {
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo: {
|
||||
name: "all-fail",
|
||||
strategy: "priority",
|
||||
models: ["model-a", "model-b"],
|
||||
config: { maxRetries: 0 },
|
||||
},
|
||||
handleSingleModel: async (_body, modelStr) => {
|
||||
return errorResponse(modelStr === "model-a" ? 500 : 429, `fail:${modelStr}`);
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
const payload = await result.json();
|
||||
|
||||
assert.equal(result.status, 500);
|
||||
assert.equal(payload.error.message, "fail:model-b");
|
||||
});
|
||||
|
||||
test("handleComboChat round-robin rotates sequentially across requests", async () => {
|
||||
const calls = [];
|
||||
const combo = {
|
||||
name: "rr-sequence",
|
||||
strategy: "round-robin",
|
||||
models: ["model-a", "model-b"],
|
||||
config: { maxRetries: 0, concurrencyPerModel: 1, queueTimeoutMs: 1000 },
|
||||
};
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo,
|
||||
handleSingleModel: async (_body, modelStr) => {
|
||||
calls.push(modelStr);
|
||||
return okResponse();
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
}
|
||||
|
||||
assert.deepEqual(calls, ["model-a", "model-b", "model-a"]);
|
||||
});
|
||||
|
||||
test("combo helpers short-circuit safely for missing combos, cycles, and excessive depth", () => {
|
||||
assert.equal(getComboFromData("missing", null), null);
|
||||
assert.equal(getComboModelsFromData("missing", { combos: [] }), null);
|
||||
|
||||
assert.doesNotThrow(() =>
|
||||
validateComboDAG("ghost", {
|
||||
combos: [{ name: "alpha", models: ["openai/gpt-4o-mini"] }],
|
||||
})
|
||||
);
|
||||
assert.doesNotThrow(() => validateComboDAG("empty", [{ name: "empty" }]));
|
||||
|
||||
assert.deepEqual(
|
||||
resolveNestedComboModels(
|
||||
{ name: "loop", models: ["model-a", "model-b"] },
|
||||
[],
|
||||
new Set(["loop"])
|
||||
),
|
||||
[]
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
resolveNestedComboModels(
|
||||
{ name: "deep", models: ["model-a", { model: "model-b", weight: 2 }] },
|
||||
[],
|
||||
new Set(),
|
||||
99
|
||||
),
|
||||
["model-a", "model-b"]
|
||||
);
|
||||
});
|
||||
|
||||
test("handleComboChat accepts binary and Responses-style 200 bodies but falls through malformed success payloads", async () => {
|
||||
const binaryResult = await handleComboChat({
|
||||
body: {},
|
||||
combo: {
|
||||
name: "quality-binary",
|
||||
strategy: "priority",
|
||||
models: ["model-a"],
|
||||
config: { maxRetries: 0 },
|
||||
},
|
||||
handleSingleModel: async () =>
|
||||
new Response("binary-payload", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/octet-stream" },
|
||||
}),
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
assert.equal(binaryResult.ok, true);
|
||||
assert.equal(await binaryResult.text(), "binary-payload");
|
||||
|
||||
const responsesResult = await handleComboChat({
|
||||
body: {},
|
||||
combo: {
|
||||
name: "quality-responses",
|
||||
strategy: "priority",
|
||||
models: ["model-a"],
|
||||
config: { maxRetries: 0 },
|
||||
},
|
||||
handleSingleModel: async () =>
|
||||
okResponse({
|
||||
output: [{ type: "output_text", text: "done" }],
|
||||
}),
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
assert.equal(responsesResult.ok, true);
|
||||
|
||||
const calls = [];
|
||||
const malformedResult = await handleComboChat({
|
||||
body: {},
|
||||
combo: {
|
||||
name: "quality-malformed",
|
||||
strategy: "priority",
|
||||
models: ["model-a", "model-b", "model-c"],
|
||||
config: { maxRetries: 0 },
|
||||
},
|
||||
handleSingleModel: async (_body, modelStr) => {
|
||||
calls.push(modelStr);
|
||||
if (modelStr === "model-a") {
|
||||
return new Response("", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
if (modelStr === "model-b") {
|
||||
return okResponse({ choices: [{}] });
|
||||
}
|
||||
return okResponse({ choices: [{ message: { content: "recovered" } }] });
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: null,
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
assert.equal(malformedResult.ok, true);
|
||||
assert.deepEqual(calls, ["model-a", "model-b", "model-c"]);
|
||||
});
|
||||
|
||||
test("handleComboChat returns the earliest retry-after when all priority targets are rate-limited", async () => {
|
||||
const soon = new Date(Date.now() + 1_000).toISOString();
|
||||
const later = new Date(Date.now() + 5_000).toISOString();
|
||||
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo: {
|
||||
name: "priority-retry-after",
|
||||
strategy: "priority",
|
||||
models: ["model-a", "model-b"],
|
||||
},
|
||||
handleSingleModel: async (_body, modelStr) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: { message: `limited:${modelStr}` },
|
||||
retryAfter: modelStr === "model-a" ? later : soon,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { "content-type": "application/json" },
|
||||
}
|
||||
),
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: {
|
||||
comboDefaults: { maxRetries: 0, retryDelayMs: 1 },
|
||||
},
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
const payload = await result.json();
|
||||
|
||||
assert.equal(result.status, 429);
|
||||
assert.match(payload.error.message, /limited:model-b/);
|
||||
assert.ok(Number(result.headers.get("Retry-After")) >= 1);
|
||||
});
|
||||
|
||||
test("handleComboChat round-robin returns 503 when no models are configured", async () => {
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo: {
|
||||
name: "rr-empty",
|
||||
strategy: "round-robin",
|
||||
models: [],
|
||||
},
|
||||
handleSingleModel: async () => {
|
||||
throw new Error("handleSingleModel should not run for empty round-robin combos");
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: {
|
||||
comboDefaults: {
|
||||
concurrencyPerModel: 1,
|
||||
queueTimeoutMs: 5,
|
||||
maxRetries: 0,
|
||||
retryDelayMs: 1,
|
||||
},
|
||||
},
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
assert.equal(result.status, 503);
|
||||
assert.match((await result.json()).error.message, /Round-robin combo has no models/);
|
||||
});
|
||||
|
||||
test("handleComboChat round-robin falls through semaphore timeouts and malformed success payloads", async () => {
|
||||
const release = await acquireSemaphore("model-a", { maxConcurrency: 1, timeoutMs: 100 });
|
||||
const calls = [];
|
||||
|
||||
try {
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo: {
|
||||
name: "rr-timeout-fallback",
|
||||
strategy: "round-robin",
|
||||
models: ["model-a", "model-b", "model-c"],
|
||||
},
|
||||
handleSingleModel: async (_body, modelStr) => {
|
||||
calls.push(modelStr);
|
||||
if (modelStr === "model-b") {
|
||||
return okResponse({ choices: [{}] });
|
||||
}
|
||||
return okResponse({ choices: [{ message: { content: "rr ok" } }] });
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: {
|
||||
comboDefaults: {
|
||||
concurrencyPerModel: 1,
|
||||
queueTimeoutMs: 5,
|
||||
maxRetries: 0,
|
||||
retryDelayMs: 1,
|
||||
},
|
||||
},
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
assert.equal(result.ok, true);
|
||||
assert.deepEqual(calls, ["model-b", "model-c"]);
|
||||
} finally {
|
||||
release();
|
||||
}
|
||||
});
|
||||
|
||||
test("handleComboChat round-robin surfaces retry-after metadata after exhausting all models", async () => {
|
||||
const sooner = new Date(Date.now() + 1_500).toISOString();
|
||||
const later = new Date(Date.now() + 7_000).toISOString();
|
||||
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo: {
|
||||
name: "rr-retry-after",
|
||||
strategy: "round-robin",
|
||||
models: ["model-a", "model-b"],
|
||||
},
|
||||
handleSingleModel: async (_body, modelStr) =>
|
||||
new Response(
|
||||
JSON.stringify({
|
||||
error: { message: `rr-limited:${modelStr}` },
|
||||
retryAfter: modelStr === "model-a" ? later : sooner,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { "content-type": "application/json" },
|
||||
}
|
||||
),
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: {
|
||||
comboDefaults: {
|
||||
concurrencyPerModel: 1,
|
||||
queueTimeoutMs: 5,
|
||||
maxRetries: 0,
|
||||
retryDelayMs: 1,
|
||||
},
|
||||
},
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
const payload = await result.json();
|
||||
|
||||
assert.equal(result.status, 429);
|
||||
assert.match(payload.error.message, /rr-limited:model-b/);
|
||||
assert.ok(Number(result.headers.get("Retry-After")) >= 1);
|
||||
});
|
||||
|
||||
test("handleComboChat round-robin keeps generic 400 errors terminal", async () => {
|
||||
const calls = [];
|
||||
|
||||
const result = await handleComboChat({
|
||||
body: {},
|
||||
combo: {
|
||||
name: "rr-terminal-400",
|
||||
strategy: "round-robin",
|
||||
models: ["model-a", "model-b"],
|
||||
},
|
||||
handleSingleModel: async (_body, modelStr) => {
|
||||
calls.push(modelStr);
|
||||
if (modelStr === "model-a") {
|
||||
return new Response(JSON.stringify({ error: { message: "generic bad request" } }), {
|
||||
status: 400,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
return okResponse();
|
||||
},
|
||||
isModelAvailable: async () => true,
|
||||
log: createLog(),
|
||||
settings: {
|
||||
comboDefaults: {
|
||||
concurrencyPerModel: 1,
|
||||
queueTimeoutMs: 5,
|
||||
maxRetries: 0,
|
||||
retryDelayMs: 1,
|
||||
},
|
||||
},
|
||||
allCombos: null,
|
||||
});
|
||||
|
||||
assert.equal(result.status, 400);
|
||||
assert.deepEqual(calls, ["model-a"]);
|
||||
assert.match((await result.json()).error.message, /generic bad request/);
|
||||
});
|
||||
222
tests/unit/compliance-index.test.mjs
Normal file
222
tests/unit/compliance-index.test.mjs
Normal file
@@ -0,0 +1,222 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-compliance-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.APP_LOG_RETENTION_DAYS = "10";
|
||||
process.env.CALL_LOG_RETENTION_DAYS = "5";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const compliance = await import("../../src/lib/compliance/index.ts");
|
||||
|
||||
function resetDb() {
|
||||
core.resetDbInstance();
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
resetDb();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("compliance audit log initialization, writes and filtered reads work end to end", () => {
|
||||
compliance.initAuditLog();
|
||||
compliance.logAuditEvent({
|
||||
action: "settings.update",
|
||||
actor: "admin",
|
||||
target: "system-settings",
|
||||
details: { changed: ["theme"] },
|
||||
ipAddress: "127.0.0.1",
|
||||
});
|
||||
compliance.logAuditEvent({
|
||||
action: "apiKey.create",
|
||||
details: '"manual note"',
|
||||
});
|
||||
|
||||
const all = compliance.getAuditLog();
|
||||
const filtered = compliance.getAuditLog({
|
||||
action: "settings.update",
|
||||
actor: "admin",
|
||||
limit: 1,
|
||||
offset: 0,
|
||||
});
|
||||
|
||||
assert.equal(all.length, 2);
|
||||
assert.equal(all[0].action, "apiKey.create");
|
||||
assert.equal(all[0].actor, "system");
|
||||
assert.equal(all[0].details, "manual note");
|
||||
assert.deepEqual(filtered, [
|
||||
{
|
||||
...filtered[0],
|
||||
action: "settings.update",
|
||||
actor: "admin",
|
||||
target: "system-settings",
|
||||
details: { changed: ["theme"] },
|
||||
ip_address: "127.0.0.1",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("compliance noLog helpers cover missing ids, in-memory overrides and persisted DB values", () => {
|
||||
const db = core.getDbInstance();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
db.prepare(
|
||||
"INSERT INTO api_keys (id, name, key, machine_id, allowed_models, no_log, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
).run("persisted-no-log", "Persisted", "sk-persisted", null, "[]", 1, now);
|
||||
|
||||
assert.equal(compliance.isNoLog(""), false);
|
||||
assert.equal(compliance.isNoLog("persisted-no-log"), true);
|
||||
|
||||
compliance.setNoLog("manual-no-log", true);
|
||||
assert.equal(compliance.isNoLog("manual-no-log"), true);
|
||||
|
||||
compliance.setNoLog("manual-no-log", false);
|
||||
assert.equal(compliance.isNoLog("manual-no-log"), false);
|
||||
assert.deepEqual(compliance.getRetentionDays(), { app: 10, call: 5 });
|
||||
});
|
||||
|
||||
test("cleanupExpiredLogs removes stale rows across all log tables and records an audit entry", () => {
|
||||
compliance.initAuditLog();
|
||||
const db = core.getDbInstance();
|
||||
|
||||
const oldCallTs = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const oldAppTs = new Date(Date.now() - 12 * 24 * 60 * 60 * 1000).toISOString();
|
||||
const freshTs = new Date().toISOString();
|
||||
|
||||
db.prepare("INSERT INTO usage_history (provider, model, timestamp) VALUES (?, ?, ?)").run(
|
||||
"openai",
|
||||
"gpt-4o",
|
||||
oldCallTs
|
||||
);
|
||||
db.prepare("INSERT INTO usage_history (provider, model, timestamp) VALUES (?, ?, ?)").run(
|
||||
"openai",
|
||||
"gpt-4o",
|
||||
freshTs
|
||||
);
|
||||
|
||||
db.prepare("INSERT INTO call_logs (id, timestamp, method, path) VALUES (?, ?, ?, ?)").run(
|
||||
"call-old",
|
||||
oldCallTs,
|
||||
"POST",
|
||||
"/v1/chat/completions"
|
||||
);
|
||||
db.prepare("INSERT INTO call_logs (id, timestamp, method, path) VALUES (?, ?, ?, ?)").run(
|
||||
"call-new",
|
||||
freshTs,
|
||||
"POST",
|
||||
"/v1/chat/completions"
|
||||
);
|
||||
|
||||
db.prepare("INSERT INTO proxy_logs (id, timestamp, status, proxy_type) VALUES (?, ?, ?, ?)").run(
|
||||
"proxy-old",
|
||||
oldCallTs,
|
||||
"ok",
|
||||
"http"
|
||||
);
|
||||
db.prepare("INSERT INTO proxy_logs (id, timestamp, status, proxy_type) VALUES (?, ?, ?, ?)").run(
|
||||
"proxy-new",
|
||||
freshTs,
|
||||
"ok",
|
||||
"http"
|
||||
);
|
||||
|
||||
db.prepare("INSERT INTO request_detail_logs (id, call_log_id, timestamp) VALUES (?, ?, ?)").run(
|
||||
"rdl-old",
|
||||
"call-old",
|
||||
oldCallTs
|
||||
);
|
||||
db.prepare("INSERT INTO request_detail_logs (id, call_log_id, timestamp) VALUES (?, ?, ?)").run(
|
||||
"rdl-new",
|
||||
"call-new",
|
||||
freshTs
|
||||
);
|
||||
|
||||
db.prepare("INSERT INTO mcp_tool_audit (tool_name, created_at) VALUES (?, ?)").run(
|
||||
"memory_search",
|
||||
oldAppTs
|
||||
);
|
||||
db.prepare("INSERT INTO mcp_tool_audit (tool_name, created_at) VALUES (?, ?)").run(
|
||||
"memory_add",
|
||||
freshTs
|
||||
);
|
||||
|
||||
compliance.logAuditEvent({
|
||||
action: "admin.cleanup.seed",
|
||||
actor: "admin",
|
||||
details: { seeded: true },
|
||||
});
|
||||
db.prepare("UPDATE audit_log SET timestamp = ? WHERE action = ?").run(
|
||||
oldAppTs,
|
||||
"admin.cleanup.seed"
|
||||
);
|
||||
|
||||
const result = compliance.cleanupExpiredLogs();
|
||||
const usageCount = db.prepare("SELECT COUNT(*) as count FROM usage_history").get().count;
|
||||
const callCount = db.prepare("SELECT COUNT(*) as count FROM call_logs").get().count;
|
||||
const proxyCount = db.prepare("SELECT COUNT(*) as count FROM proxy_logs").get().count;
|
||||
const requestDetailCount = db
|
||||
.prepare("SELECT COUNT(*) as count FROM request_detail_logs")
|
||||
.get().count;
|
||||
const mcpAuditCount = db.prepare("SELECT COUNT(*) as count FROM mcp_tool_audit").get().count;
|
||||
const auditActions = compliance.getAuditLog().map((entry) => entry.action);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
deletedUsage: 1,
|
||||
deletedCallLogs: 1,
|
||||
deletedProxyLogs: 1,
|
||||
deletedRequestDetailLogs: 1,
|
||||
deletedAuditLogs: 1,
|
||||
deletedMcpAuditLogs: 1,
|
||||
appRetentionDays: 10,
|
||||
callRetentionDays: 5,
|
||||
});
|
||||
assert.equal(usageCount, 1);
|
||||
assert.equal(callCount, 1);
|
||||
assert.equal(proxyCount, 1);
|
||||
assert.equal(requestDetailCount, 1);
|
||||
assert.equal(mcpAuditCount, 1);
|
||||
assert.ok(auditActions.includes("compliance.cleanup"));
|
||||
});
|
||||
|
||||
test("cleanupExpiredLogs tolerates missing tables and logAuditEvent failures without breaking", () => {
|
||||
compliance.initAuditLog();
|
||||
const db = core.getDbInstance();
|
||||
|
||||
db.exec(`
|
||||
DROP TABLE usage_history;
|
||||
DROP TABLE call_logs;
|
||||
DROP TABLE proxy_logs;
|
||||
DROP TABLE request_detail_logs;
|
||||
DROP TABLE audit_log;
|
||||
DROP TABLE mcp_tool_audit;
|
||||
`);
|
||||
|
||||
compliance.logAuditEvent({
|
||||
action: "will.fail.silently",
|
||||
details: { reason: "table dropped" },
|
||||
});
|
||||
|
||||
const result = compliance.cleanupExpiredLogs();
|
||||
|
||||
assert.deepEqual(result, {
|
||||
deletedUsage: 0,
|
||||
deletedCallLogs: 0,
|
||||
deletedProxyLogs: 0,
|
||||
deletedRequestDetailLogs: 0,
|
||||
deletedAuditLogs: 0,
|
||||
deletedMcpAuditLogs: 0,
|
||||
appRetentionDays: 10,
|
||||
callRetentionDays: 5,
|
||||
});
|
||||
});
|
||||
@@ -23,7 +23,7 @@ test("getTokenLimit: detects claude", () => {
|
||||
});
|
||||
|
||||
test("getTokenLimit: detects gemini", () => {
|
||||
assert.equal(getTokenLimit("gemini", "gemini-2.5-pro"), 1048576);
|
||||
assert.equal(getTokenLimit("gemini", "gemini-2.5-pro"), 1000000);
|
||||
});
|
||||
|
||||
test("getTokenLimit: default fallback", () => {
|
||||
|
||||
150
tests/unit/db-apikeys-crud.test.mjs
Normal file
150
tests/unit/db-apikeys-crud.test.mjs
Normal file
@@ -0,0 +1,150 @@
|
||||
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-db-apikeys-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "task-303-api-key-secret";
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const apiKeysDb = await import("../../src/lib/db/apiKeys.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("createApiKey requires machineId and returns a persisted key with defaults", async () => {
|
||||
await assert.rejects(
|
||||
() => apiKeysDb.createApiKey("missing-machine", ""),
|
||||
/machineId is required/
|
||||
);
|
||||
|
||||
const created = await apiKeysDb.createApiKey("Primary Key", "machine-303");
|
||||
const allKeys = await apiKeysDb.getApiKeys();
|
||||
const byId = await apiKeysDb.getApiKeyById(created.id);
|
||||
|
||||
assert.match(created.key, /^sk-machine-303-/);
|
||||
assert.equal(allKeys.length, 1);
|
||||
assert.equal(allKeys[0].name, "Primary Key");
|
||||
assert.deepEqual(allKeys[0].allowedModels, []);
|
||||
assert.deepEqual(byId.allowedConnections, []);
|
||||
assert.equal(byId.noLog, false);
|
||||
assert.equal(byId.autoResolve, false);
|
||||
assert.equal(byId.isActive, true);
|
||||
assert.equal(byId.maxSessions, 0);
|
||||
});
|
||||
|
||||
test("updateApiKeyPermissions persists settings, schedule and rate limits", async () => {
|
||||
const created = await apiKeysDb.createApiKey("Scoped Key", "machine-303");
|
||||
const schedule = {
|
||||
enabled: true,
|
||||
from: "09:00",
|
||||
until: "18:00",
|
||||
days: [1, 2, 3, 4, 5],
|
||||
tz: "America/Sao_Paulo",
|
||||
};
|
||||
|
||||
const updated = await apiKeysDb.updateApiKeyPermissions(created.id, {
|
||||
name: "Scoped Key v2",
|
||||
allowedModels: ["openai/*", "anthropic/claude-*"],
|
||||
allowedConnections: ["550e8400-e29b-41d4-a716-446655440000"],
|
||||
noLog: true,
|
||||
autoResolve: true,
|
||||
isActive: false,
|
||||
accessSchedule: schedule,
|
||||
maxRequestsPerDay: 1000,
|
||||
maxRequestsPerMinute: 15,
|
||||
maxSessions: -3,
|
||||
});
|
||||
const row = await apiKeysDb.getApiKeyById(created.id);
|
||||
const metadata = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
|
||||
assert.equal(updated, true);
|
||||
assert.equal(row.name, "Scoped Key v2");
|
||||
assert.deepEqual(row.allowedModels, ["openai/*", "anthropic/claude-*"]);
|
||||
assert.deepEqual(row.allowedConnections, ["550e8400-e29b-41d4-a716-446655440000"]);
|
||||
assert.equal(row.noLog, true);
|
||||
assert.equal(row.autoResolve, true);
|
||||
assert.equal(row.isActive, false);
|
||||
assert.deepEqual(row.accessSchedule, schedule);
|
||||
assert.equal(metadata.maxRequestsPerDay, 1000);
|
||||
assert.equal(metadata.maxRequestsPerMinute, 15);
|
||||
assert.equal(metadata.maxSessions, 0);
|
||||
});
|
||||
|
||||
test("validateApiKey and deleteApiKey stay consistent after cache invalidation", async () => {
|
||||
const created = await apiKeysDb.createApiKey("Delete Me", "machine-303");
|
||||
|
||||
assert.equal(await apiKeysDb.validateApiKey(created.key), true);
|
||||
assert.equal(await apiKeysDb.deleteApiKey("missing-id"), false);
|
||||
assert.equal(await apiKeysDb.deleteApiKey(created.id), true);
|
||||
assert.equal(await apiKeysDb.validateApiKey(created.key), false);
|
||||
assert.equal(await apiKeysDb.getApiKeyById(created.id), null);
|
||||
assert.equal(await apiKeysDb.getApiKeyMetadata(created.key), null);
|
||||
});
|
||||
|
||||
test("isModelAllowedForKey supports exact, prefix and wildcard rules", async () => {
|
||||
const unrestricted = await apiKeysDb.createApiKey("Unrestricted", "machine-303");
|
||||
const restricted = await apiKeysDb.createApiKey("Restricted", "machine-303");
|
||||
|
||||
await apiKeysDb.updateApiKeyPermissions(restricted.id, {
|
||||
allowedModels: ["openai/*", "anthropic/claude-*", "o*-mini"],
|
||||
});
|
||||
|
||||
assert.equal(await apiKeysDb.isModelAllowedForKey(null, "any/model"), true);
|
||||
assert.equal(await apiKeysDb.isModelAllowedForKey(restricted.key, null), false);
|
||||
assert.equal(await apiKeysDb.isModelAllowedForKey("sk-invalid", "openai/gpt-4.1"), false);
|
||||
assert.equal(await apiKeysDb.isModelAllowedForKey(unrestricted.key, "provider/any-model"), true);
|
||||
assert.equal(await apiKeysDb.isModelAllowedForKey(restricted.key, "openai/gpt-4.1"), true);
|
||||
assert.equal(
|
||||
await apiKeysDb.isModelAllowedForKey(restricted.key, "anthropic/claude-3-7-sonnet"),
|
||||
true
|
||||
);
|
||||
assert.equal(await apiKeysDb.isModelAllowedForKey(restricted.key, "o3-mini"), true);
|
||||
assert.equal(
|
||||
await apiKeysDb.isModelAllowedForKey(restricted.key, "gemini/gemini-2.5-pro"),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("getApiKeyMetadata ignores malformed stored schedule payloads", async () => {
|
||||
const created = await apiKeysDb.createApiKey("Malformed Schedule", "machine-303");
|
||||
const db = core.getDbInstance();
|
||||
|
||||
db.prepare("UPDATE api_keys SET access_schedule = ? WHERE id = ?").run("not-json", created.id);
|
||||
apiKeysDb.clearApiKeyCaches();
|
||||
|
||||
const metadata = await apiKeysDb.getApiKeyMetadata(created.key);
|
||||
|
||||
assert.equal(metadata.accessSchedule, null);
|
||||
});
|
||||
103
tests/unit/db-combos-crud.test.mjs
Normal file
103
tests/unit/db-combos-crud.test.mjs
Normal file
@@ -0,0 +1,103 @@
|
||||
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-db-combos-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const combosDb = await import("../../src/lib/db/combos.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("createCombo stores default strategy and supports lookup by id and name", async () => {
|
||||
const combo = await combosDb.createCombo({
|
||||
name: "Priority Combo",
|
||||
models: [{ provider: "openai", model: "gpt-4.1" }],
|
||||
});
|
||||
|
||||
assert.equal(combo.strategy, "priority");
|
||||
assert.deepEqual(await combosDb.getComboById(combo.id), combo);
|
||||
assert.deepEqual(await combosDb.getComboByName("Priority Combo"), combo);
|
||||
});
|
||||
|
||||
test("getCombos returns parsed combos sorted by name", async () => {
|
||||
await combosDb.createCombo({
|
||||
name: "Zulu",
|
||||
models: [{ provider: "openai", model: "gpt-4.1" }],
|
||||
});
|
||||
await combosDb.createCombo({
|
||||
name: "Alpha",
|
||||
models: [{ provider: "anthropic", model: "claude-3-7-sonnet" }],
|
||||
});
|
||||
|
||||
const combos = await combosDb.getCombos();
|
||||
|
||||
assert.deepEqual(
|
||||
combos.map((combo) => combo.name),
|
||||
["Alpha", "Zulu"]
|
||||
);
|
||||
});
|
||||
|
||||
test("updateCombo merges fields while preserving immutable data", async () => {
|
||||
const combo = await combosDb.createCombo({
|
||||
name: "Routing Combo",
|
||||
models: [{ provider: "openai", model: "gpt-4.1" }],
|
||||
config: { retries: 1 },
|
||||
});
|
||||
|
||||
const updated = await combosDb.updateCombo(combo.id, {
|
||||
strategy: "round-robin",
|
||||
config: { retries: 3, timeoutMs: 2000 },
|
||||
isHidden: true,
|
||||
});
|
||||
|
||||
assert.equal(updated.id, combo.id);
|
||||
assert.equal(updated.name, "Routing Combo");
|
||||
assert.deepEqual(updated.models, combo.models);
|
||||
assert.deepEqual(updated.config, { retries: 3, timeoutMs: 2000 });
|
||||
assert.equal(updated.strategy, "round-robin");
|
||||
assert.equal(updated.isHidden, true);
|
||||
assert.deepEqual(await combosDb.getComboById(combo.id), updated);
|
||||
});
|
||||
|
||||
test("deleteCombo reports missing ids and removes existing rows", async () => {
|
||||
const combo = await combosDb.createCombo({
|
||||
name: "Delete Me",
|
||||
models: [{ provider: "openai", model: "gpt-4.1-mini" }],
|
||||
});
|
||||
|
||||
assert.equal(await combosDb.deleteCombo("missing-combo"), false);
|
||||
assert.equal(await combosDb.deleteCombo(combo.id), true);
|
||||
assert.equal(await combosDb.getComboById(combo.id), null);
|
||||
});
|
||||
401
tests/unit/db-core-init.test.mjs
Normal file
401
tests/unit/db-core-init.test.mjs
Normal file
@@ -0,0 +1,401 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const serial = { concurrency: false };
|
||||
const originalEnv = {
|
||||
DATA_DIR: process.env.DATA_DIR,
|
||||
NEXT_PHASE: process.env.NEXT_PHASE,
|
||||
HOME: process.env.HOME,
|
||||
XDG_CONFIG_HOME: process.env.XDG_CONFIG_HOME,
|
||||
APPDATA: process.env.APPDATA,
|
||||
};
|
||||
|
||||
function restoreEnv() {
|
||||
for (const [key, value] of Object.entries(originalEnv)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanupGlobalDb() {
|
||||
try {
|
||||
if (globalThis.__omnirouteDb?.open) {
|
||||
globalThis.__omnirouteDb.close();
|
||||
}
|
||||
} catch {}
|
||||
|
||||
delete globalThis.__omnirouteDb;
|
||||
}
|
||||
|
||||
function makeTempDir(prefix) {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
|
||||
}
|
||||
|
||||
function removePath(targetPath) {
|
||||
fs.rmSync(targetPath, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
async function importFresh(modulePath) {
|
||||
const url = pathToFileURL(path.resolve(modulePath)).href;
|
||||
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
}
|
||||
|
||||
async function withEnv(overrides, fn) {
|
||||
const snapshot = {};
|
||||
for (const key of Object.keys(overrides)) {
|
||||
snapshot[key] = process.env[key];
|
||||
const value = overrides[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await fn();
|
||||
} finally {
|
||||
for (const [key, value] of Object.entries(snapshot)) {
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createLegacySchemaDb(sqliteFile, { withData = false } = {}) {
|
||||
const seedDb = new Database(sqliteFile);
|
||||
seedDb.exec(`
|
||||
CREATE TABLE schema_migrations (version TEXT);
|
||||
CREATE TABLE provider_connections (
|
||||
id TEXT PRIMARY KEY,
|
||||
provider TEXT NOT NULL,
|
||||
auth_type TEXT,
|
||||
name TEXT,
|
||||
email TEXT,
|
||||
priority INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
access_token TEXT,
|
||||
refresh_token TEXT,
|
||||
expires_at TEXT,
|
||||
token_expires_at TEXT,
|
||||
scope TEXT,
|
||||
project_id TEXT,
|
||||
test_status TEXT,
|
||||
error_code TEXT,
|
||||
last_error TEXT,
|
||||
last_error_at TEXT,
|
||||
last_error_type TEXT,
|
||||
last_error_source TEXT,
|
||||
backoff_level INTEGER DEFAULT 0,
|
||||
rate_limited_until TEXT,
|
||||
health_check_interval INTEGER,
|
||||
last_health_check_at TEXT,
|
||||
last_tested TEXT,
|
||||
api_key TEXT,
|
||||
id_token TEXT,
|
||||
provider_specific_data TEXT,
|
||||
expires_in INTEGER,
|
||||
display_name TEXT,
|
||||
global_priority INTEGER,
|
||||
default_model TEXT,
|
||||
token_type TEXT,
|
||||
consecutive_use_count INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_pc_provider ON provider_connections(provider);
|
||||
CREATE INDEX idx_pc_active ON provider_connections(is_active);
|
||||
CREATE INDEX idx_pc_priority ON provider_connections(provider, priority);
|
||||
`);
|
||||
|
||||
if (withData) {
|
||||
const now = new Date().toISOString();
|
||||
seedDb
|
||||
.prepare(
|
||||
"INSERT INTO provider_connections (id, provider, auth_type, name, is_active, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
|
||||
)
|
||||
.run("legacy-openai", "openai", "apikey", "Legacy", 1, now, now);
|
||||
}
|
||||
|
||||
seedDb.close();
|
||||
}
|
||||
|
||||
test.beforeEach(() => {
|
||||
restoreEnv();
|
||||
cleanupGlobalDb();
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
cleanupGlobalDb();
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
cleanupGlobalDb();
|
||||
restoreEnv();
|
||||
});
|
||||
|
||||
test("getDbInstance creates sqlite schema, metadata and applies migrations", serial, async () => {
|
||||
const dataDir = makeTempDir("omniroute-db-core-");
|
||||
|
||||
try {
|
||||
await withEnv({ DATA_DIR: dataDir, NEXT_PHASE: undefined }, async () => {
|
||||
const core = await importFresh("src/lib/db/core.ts");
|
||||
const db = core.getDbInstance();
|
||||
|
||||
assert.equal(fs.existsSync(core.SQLITE_FILE), true);
|
||||
assert.ok(
|
||||
db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("provider_connections")
|
||||
);
|
||||
assert.deepEqual(db.prepare("SELECT value FROM db_meta WHERE key = 'schema_version'").get(), {
|
||||
value: "1",
|
||||
});
|
||||
|
||||
const versions = db
|
||||
.prepare("SELECT version FROM _omniroute_migrations ORDER BY version")
|
||||
.all()
|
||||
.map((row) => row.version);
|
||||
|
||||
assert.equal(versions[0], "001");
|
||||
assert.ok(versions.includes("017"));
|
||||
assert.ok(
|
||||
db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("version_manager")
|
||||
);
|
||||
|
||||
core.resetDbInstance();
|
||||
});
|
||||
} finally {
|
||||
removePath(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("getDbInstance reuses the singleton and closeDbInstance resets it", serial, async () => {
|
||||
const dataDir = makeTempDir("omniroute-db-core-");
|
||||
|
||||
try {
|
||||
await withEnv({ DATA_DIR: dataDir, NEXT_PHASE: undefined }, async () => {
|
||||
const core = await importFresh("src/lib/db/core.ts");
|
||||
const firstDb = core.getDbInstance();
|
||||
const secondDb = core.getDbInstance();
|
||||
|
||||
assert.strictEqual(secondDb, firstDb);
|
||||
assert.equal(core.closeDbInstance(), true);
|
||||
assert.equal(firstDb.open, false);
|
||||
assert.equal(core.closeDbInstance(), false);
|
||||
|
||||
const reopenedDb = core.getDbInstance();
|
||||
assert.notStrictEqual(reopenedDb, firstDb);
|
||||
|
||||
core.resetDbInstance();
|
||||
});
|
||||
} finally {
|
||||
removePath(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("local sqlite configuration enables WAL and sane pragmas", serial, async () => {
|
||||
const dataDir = makeTempDir("omniroute-db-core-");
|
||||
|
||||
try {
|
||||
await withEnv({ DATA_DIR: dataDir, NEXT_PHASE: undefined }, async () => {
|
||||
const core = await importFresh("src/lib/db/core.ts");
|
||||
const db = core.getDbInstance();
|
||||
|
||||
assert.equal(db.pragma("journal_mode", { simple: true }), "wal");
|
||||
assert.equal(db.pragma("busy_timeout", { simple: true }), 5000);
|
||||
assert.equal(db.pragma("synchronous", { simple: true }), 1);
|
||||
assert.equal(core.closeDbInstance({ checkpointMode: null }), true);
|
||||
});
|
||||
} finally {
|
||||
removePath(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("module exports honor DATA_DIR from the environment", serial, async () => {
|
||||
const dataDir = makeTempDir("omniroute-db-core-env-");
|
||||
|
||||
try {
|
||||
await withEnv({ DATA_DIR: dataDir }, async () => {
|
||||
const core = await importFresh("src/lib/db/core.ts");
|
||||
|
||||
assert.equal(core.DATA_DIR, path.resolve(dataDir));
|
||||
assert.equal(core.SQLITE_FILE, path.join(path.resolve(dataDir), "storage.sqlite"));
|
||||
assert.equal(core.DB_BACKUPS_DIR, path.join(path.resolve(dataDir), "db_backups"));
|
||||
});
|
||||
} finally {
|
||||
removePath(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
"module falls back to the default home data directory when DATA_DIR is absent",
|
||||
serial,
|
||||
async () => {
|
||||
const fakeHome = makeTempDir("omniroute-home-");
|
||||
|
||||
try {
|
||||
await withEnv(
|
||||
{
|
||||
DATA_DIR: undefined,
|
||||
XDG_CONFIG_HOME: undefined,
|
||||
HOME: fakeHome,
|
||||
APPDATA: undefined,
|
||||
},
|
||||
async () => {
|
||||
const core = await importFresh("src/lib/db/core.ts");
|
||||
const expectedDir =
|
||||
process.platform === "win32"
|
||||
? path.join(fakeHome, "AppData", "Roaming", "omniroute")
|
||||
: path.join(fakeHome, ".omniroute");
|
||||
|
||||
assert.equal(core.DATA_DIR, expectedDir);
|
||||
assert.equal(core.SQLITE_FILE, path.join(expectedDir, "storage.sqlite"));
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
removePath(fakeHome);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test("build phase uses an in-memory database without creating sqlite files", serial, async () => {
|
||||
const dataDir = makeTempDir("omniroute-db-build-");
|
||||
|
||||
try {
|
||||
await withEnv(
|
||||
{
|
||||
DATA_DIR: dataDir,
|
||||
NEXT_PHASE: "phase-production-build",
|
||||
},
|
||||
async () => {
|
||||
const core = await importFresh("src/lib/db/core.ts");
|
||||
const db = core.getDbInstance();
|
||||
|
||||
assert.ok(
|
||||
db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("provider_connections")
|
||||
);
|
||||
assert.equal(fs.existsSync(path.join(dataDir, "storage.sqlite")), false);
|
||||
assert.equal(db.pragma("journal_mode", { simple: true }), "memory");
|
||||
|
||||
core.resetDbInstance();
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
removePath(dataDir);
|
||||
}
|
||||
});
|
||||
|
||||
test("getDbInstance surfaces invalid DATA_DIR paths as sqlite open failures", serial, async () => {
|
||||
const sandboxDir = makeTempDir("omniroute-db-bad-path-");
|
||||
const fileAsDir = path.join(sandboxDir, "not-a-directory");
|
||||
fs.writeFileSync(fileAsDir, "blocked");
|
||||
|
||||
try {
|
||||
await withEnv({ DATA_DIR: fileAsDir }, async () => {
|
||||
const core = await importFresh("src/lib/db/core.ts");
|
||||
|
||||
assert.throws(
|
||||
() => core.getDbInstance(),
|
||||
/unable to open database file|ENOTDIR|not a directory/i
|
||||
);
|
||||
assert.equal(core.closeDbInstance(), false);
|
||||
});
|
||||
} finally {
|
||||
removePath(sandboxDir);
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
"legacy empty schema databases are renamed before a fresh sqlite database is created",
|
||||
serial,
|
||||
async () => {
|
||||
const dataDir = makeTempDir("omniroute-db-legacy-empty-");
|
||||
const sqliteFile = path.join(dataDir, "storage.sqlite");
|
||||
createLegacySchemaDb(sqliteFile);
|
||||
|
||||
try {
|
||||
await withEnv({ DATA_DIR: dataDir }, async () => {
|
||||
const core = await importFresh("src/lib/db/core.ts");
|
||||
const db = core.getDbInstance();
|
||||
|
||||
assert.equal(fs.existsSync(`${sqliteFile}.old-schema`), true);
|
||||
assert.ok(
|
||||
db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("_omniroute_migrations")
|
||||
);
|
||||
assert.equal(
|
||||
db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("schema_migrations"),
|
||||
undefined
|
||||
);
|
||||
|
||||
core.resetDbInstance();
|
||||
});
|
||||
} finally {
|
||||
removePath(dataDir);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"legacy databases with data preserve rows while removing the old migration table",
|
||||
serial,
|
||||
async () => {
|
||||
const dataDir = makeTempDir("omniroute-db-legacy-data-");
|
||||
const sqliteFile = path.join(dataDir, "storage.sqlite");
|
||||
createLegacySchemaDb(sqliteFile, { withData: true });
|
||||
|
||||
try {
|
||||
await withEnv({ DATA_DIR: dataDir }, async () => {
|
||||
const core = await importFresh("src/lib/db/core.ts");
|
||||
const db = core.getDbInstance();
|
||||
|
||||
assert.deepEqual(
|
||||
db
|
||||
.prepare("SELECT id, provider FROM provider_connections WHERE id = ?")
|
||||
.get("legacy-openai"),
|
||||
{ id: "legacy-openai", provider: "openai" }
|
||||
);
|
||||
assert.equal(
|
||||
db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("schema_migrations"),
|
||||
undefined
|
||||
);
|
||||
assert.ok(
|
||||
db
|
||||
.prepare("SELECT name FROM pragma_table_info('provider_connections') WHERE name = ?")
|
||||
.get("rate_limit_protection")
|
||||
);
|
||||
assert.ok(
|
||||
db
|
||||
.prepare("SELECT name FROM pragma_table_info('provider_connections') WHERE name = ?")
|
||||
.get("last_used_at")
|
||||
);
|
||||
|
||||
core.resetDbInstance();
|
||||
});
|
||||
} finally {
|
||||
removePath(dataDir);
|
||||
}
|
||||
}
|
||||
);
|
||||
193
tests/unit/db-detailed-logs.test.mjs
Normal file
193
tests/unit/db-detailed-logs.test.mjs
Normal file
@@ -0,0 +1,193 @@
|
||||
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-db-detailed-logs-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const ORIGINAL_PII_ENABLED = process.env.PII_RESPONSE_SANITIZATION;
|
||||
const ORIGINAL_PII_MODE = process.env.PII_RESPONSE_SANITIZATION_MODE;
|
||||
process.env.PII_RESPONSE_SANITIZATION = "true";
|
||||
process.env.PII_RESPONSE_SANITIZATION_MODE = "redact";
|
||||
process.env.API_KEY_SECRET = process.env.API_KEY_SECRET || "task-303-detailed-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 detailedLogsDb = await import("../../src/lib/db/detailedLogs.ts");
|
||||
const { createStructuredSSECollector } =
|
||||
await import("../../open-sse/utils/streamPayloadCollector.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
apiKeysDb.resetApiKeyState();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
|
||||
if (ORIGINAL_PII_ENABLED === undefined) {
|
||||
delete process.env.PII_RESPONSE_SANITIZATION;
|
||||
} else {
|
||||
process.env.PII_RESPONSE_SANITIZATION = ORIGINAL_PII_ENABLED;
|
||||
}
|
||||
|
||||
if (ORIGINAL_PII_MODE === undefined) {
|
||||
delete process.env.PII_RESPONSE_SANITIZATION_MODE;
|
||||
} else {
|
||||
process.env.PII_RESPONSE_SANITIZATION_MODE = ORIGINAL_PII_MODE;
|
||||
}
|
||||
});
|
||||
|
||||
test("isDetailedLoggingEnabled follows the stored setting", async () => {
|
||||
assert.equal(await detailedLogsDb.isDetailedLoggingEnabled(), false);
|
||||
|
||||
await settingsDb.updateSettings({ call_log_pipeline_enabled: "true" });
|
||||
|
||||
assert.equal(await detailedLogsDb.isDetailedLoggingEnabled(), true);
|
||||
});
|
||||
|
||||
test("saveRequestDetailLog persists protected payloads and compacted stream summaries", () => {
|
||||
const collector = createStructuredSSECollector({ stage: "provider-response" });
|
||||
collector.push({
|
||||
type: "response.output_text.delta",
|
||||
delta: "Hello",
|
||||
});
|
||||
const providerStream = collector.build({
|
||||
id: "resp_123",
|
||||
object: "response",
|
||||
output_text: "Hello world",
|
||||
});
|
||||
|
||||
detailedLogsDb.saveRequestDetailLog({
|
||||
id: "detail-1",
|
||||
call_log_id: "call-1",
|
||||
timestamp: "2026-04-05T18:00:00.000Z",
|
||||
client_request: { email: "john@example.com", token: "super-secret" },
|
||||
translated_request: '{"message":"hello"}',
|
||||
provider_response: providerStream,
|
||||
client_response: "plain text response",
|
||||
provider: "openai",
|
||||
model: "gpt-4.1",
|
||||
source_format: "openai",
|
||||
target_format: "gemini",
|
||||
duration_ms: 321,
|
||||
});
|
||||
|
||||
const row = detailedLogsDb.getRequestDetailLogById("detail-1");
|
||||
|
||||
assert.equal(row.call_log_id, "call-1");
|
||||
assert.deepEqual(row.client_request, {
|
||||
email: "[EMAIL_REDACTED]",
|
||||
token: "[REDACTED]",
|
||||
});
|
||||
assert.deepEqual(row.translated_request, { message: "hello" });
|
||||
assert.equal(row.provider_response.id, "resp_123");
|
||||
assert.equal(row.provider_response.output_text, "Hello world");
|
||||
assert.deepEqual(row.provider_response._omniroute_stream, {
|
||||
format: "sse-json",
|
||||
stage: "provider-response",
|
||||
eventCount: 1,
|
||||
});
|
||||
assert.deepEqual(row.client_response, { _rawText: "plain text response" });
|
||||
assert.equal(row.duration_ms, 321);
|
||||
});
|
||||
|
||||
test("latest log lookup by call_log_id and paginated listing use newest-first ordering", () => {
|
||||
detailedLogsDb.saveRequestDetailLog({
|
||||
id: "older",
|
||||
call_log_id: "call-2",
|
||||
timestamp: "2026-04-05T18:00:00.000Z",
|
||||
provider: "openai",
|
||||
model: "gpt-4.1",
|
||||
});
|
||||
detailedLogsDb.saveRequestDetailLog({
|
||||
id: "newer",
|
||||
call_log_id: "call-2",
|
||||
timestamp: "2026-04-05T18:00:02.000Z",
|
||||
provider: "anthropic",
|
||||
model: "claude-3-7-sonnet",
|
||||
});
|
||||
detailedLogsDb.saveRequestDetailLog({
|
||||
id: "latest",
|
||||
call_log_id: "call-3",
|
||||
timestamp: "2026-04-05T18:00:03.000Z",
|
||||
provider: "gemini",
|
||||
model: "gemini-2.5-pro",
|
||||
});
|
||||
|
||||
const firstPage = detailedLogsDb.getRequestDetailLogs(2, 0);
|
||||
const secondPage = detailedLogsDb.getRequestDetailLogs(1, 1);
|
||||
|
||||
assert.equal(detailedLogsDb.getRequestDetailLogByCallLogId("call-2").id, "newer");
|
||||
assert.deepEqual(
|
||||
firstPage.map((row) => row.id),
|
||||
["latest", "newer"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
secondPage.map((row) => row.id),
|
||||
["newer"]
|
||||
);
|
||||
assert.equal(detailedLogsDb.getRequestDetailLogCount(), 3);
|
||||
});
|
||||
|
||||
test("logs are skipped when the associated API key is marked as no_log", async () => {
|
||||
const apiKey = await apiKeysDb.createApiKey("No Log Key", "machine-303");
|
||||
await apiKeysDb.updateApiKeyPermissions(apiKey.id, { noLog: true });
|
||||
|
||||
detailedLogsDb.saveRequestDetailLog({
|
||||
id: "should-not-persist",
|
||||
api_key_id: apiKey.id,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1",
|
||||
no_log: false,
|
||||
});
|
||||
|
||||
assert.equal(detailedLogsDb.getRequestDetailLogCount(), 0);
|
||||
assert.equal(detailedLogsDb.getRequestDetailLogById("should-not-persist"), null);
|
||||
});
|
||||
|
||||
test("request_detail_logs trigger keeps only the latest 500 rows", () => {
|
||||
for (let i = 0; i < 505; i += 1) {
|
||||
detailedLogsDb.saveRequestDetailLog({
|
||||
id: `ring-${i}`,
|
||||
timestamp: new Date(Date.UTC(2026, 3, 5, 18, 0, 0, i)).toISOString(),
|
||||
provider: "openai",
|
||||
model: "gpt-4.1",
|
||||
});
|
||||
}
|
||||
|
||||
const rows = detailedLogsDb.getRequestDetailLogs(600, 0);
|
||||
|
||||
assert.equal(detailedLogsDb.getRequestDetailLogCount(), 500);
|
||||
assert.equal(detailedLogsDb.getRequestDetailLogById("ring-0"), null);
|
||||
assert.equal(detailedLogsDb.getRequestDetailLogById("ring-4"), null);
|
||||
assert.equal(detailedLogsDb.getRequestDetailLogById("ring-5")?.id, "ring-5");
|
||||
assert.equal(rows[0].id, "ring-504");
|
||||
assert.equal(rows.at(-1)?.id, "ring-5");
|
||||
});
|
||||
79
tests/unit/db-encryption.test.mjs
Normal file
79
tests/unit/db-encryption.test.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const ORIGINAL_STORAGE_KEY = process.env.STORAGE_ENCRYPTION_KEY;
|
||||
|
||||
async function importFresh(modulePath) {
|
||||
const url = pathToFileURL(path.resolve(modulePath)).href;
|
||||
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
}
|
||||
|
||||
test.after(() => {
|
||||
if (ORIGINAL_STORAGE_KEY === undefined) {
|
||||
delete process.env.STORAGE_ENCRYPTION_KEY;
|
||||
} else {
|
||||
process.env.STORAGE_ENCRYPTION_KEY = ORIGINAL_STORAGE_KEY;
|
||||
}
|
||||
});
|
||||
|
||||
test("encryption stays in passthrough mode when no storage key is configured", async () => {
|
||||
delete process.env.STORAGE_ENCRYPTION_KEY;
|
||||
const encryption = await importFresh("src/lib/db/encryption.ts");
|
||||
|
||||
assert.equal(encryption.isEncryptionEnabled(), false);
|
||||
assert.equal(encryption.encrypt("plain-text"), "plain-text");
|
||||
assert.equal(encryption.decrypt("plain-text"), "plain-text");
|
||||
assert.equal(encryption.encrypt(""), "");
|
||||
assert.equal(encryption.decrypt(null), null);
|
||||
assert.equal(encryption.decrypt(undefined), undefined);
|
||||
});
|
||||
|
||||
test("encrypt/decrypt round-trip uses the expected serialized format", async () => {
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "task-304-secret-a";
|
||||
const encryption = await importFresh("src/lib/db/encryption.ts");
|
||||
|
||||
const encrypted = encryption.encrypt("hello world");
|
||||
const decrypted = encryption.decrypt(encrypted);
|
||||
|
||||
assert.equal(encryption.isEncryptionEnabled(), true);
|
||||
assert.match(encrypted, /^enc:v1:[0-9a-f]+:[0-9a-f]+:[0-9a-f]+$/);
|
||||
assert.equal(decrypted, "hello world");
|
||||
assert.equal(encryption.encrypt(encrypted), encrypted);
|
||||
});
|
||||
|
||||
test("connection field helpers encrypt and decrypt all supported credential fields", async () => {
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "task-304-secret-b";
|
||||
const encryption = await importFresh("src/lib/db/encryption.ts");
|
||||
|
||||
const connection = {
|
||||
apiKey: "sk-123",
|
||||
accessToken: "access-123",
|
||||
refreshToken: "refresh-123",
|
||||
idToken: "id-123",
|
||||
untouched: "keep-me",
|
||||
};
|
||||
|
||||
const encrypted = encryption.encryptConnectionFields({ ...connection });
|
||||
const decrypted = encryption.decryptConnectionFields(encrypted);
|
||||
|
||||
assert.notEqual(encrypted.apiKey, connection.apiKey);
|
||||
assert.match(encrypted.apiKey, /^enc:v1:/);
|
||||
assert.match(encrypted.accessToken, /^enc:v1:/);
|
||||
assert.match(encrypted.refreshToken, /^enc:v1:/);
|
||||
assert.match(encrypted.idToken, /^enc:v1:/);
|
||||
assert.deepEqual(decrypted, connection);
|
||||
});
|
||||
|
||||
test("decrypt returns the original ciphertext when the value is malformed or the key is wrong", async () => {
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "task-304-secret-c";
|
||||
const firstModule = await importFresh("src/lib/db/encryption.ts");
|
||||
const encrypted = firstModule.encrypt("top-secret");
|
||||
|
||||
process.env.STORAGE_ENCRYPTION_KEY = "task-304-secret-d";
|
||||
const secondModule = await importFresh("src/lib/db/encryption.ts");
|
||||
|
||||
assert.equal(secondModule.decrypt(encrypted), encrypted);
|
||||
assert.equal(secondModule.decrypt("enc:v1:not-valid"), "enc:v1:not-valid");
|
||||
});
|
||||
348
tests/unit/db-migration-runner.test.mjs
Normal file
348
tests/unit/db-migration-runner.test.mjs
Normal file
@@ -0,0 +1,348 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import Database from "better-sqlite3";
|
||||
|
||||
const serial = { concurrency: false };
|
||||
|
||||
async function importFresh(modulePath) {
|
||||
const url = pathToFileURL(path.resolve(modulePath)).href;
|
||||
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
}
|
||||
|
||||
function withMockedMigrationFs(files, fn) {
|
||||
const originalExistsSync = fs.existsSync;
|
||||
const originalReaddirSync = fs.readdirSync;
|
||||
const originalReadFileSync = fs.readFileSync;
|
||||
|
||||
const isMigrationDir = (target) =>
|
||||
String(target).replaceAll("\\", "/").endsWith("/src/lib/db/migrations") ||
|
||||
String(target).replaceAll("\\", "/").endsWith("/migrations");
|
||||
|
||||
fs.existsSync = (target) => {
|
||||
if (files === null && isMigrationDir(target)) return false;
|
||||
if (files && isMigrationDir(target)) return true;
|
||||
|
||||
const fileName = path.basename(String(target));
|
||||
if (files && Object.hasOwn(files, fileName)) return true;
|
||||
|
||||
return originalExistsSync(target);
|
||||
};
|
||||
|
||||
fs.readdirSync = (target, options) => {
|
||||
if (files && isMigrationDir(target)) {
|
||||
return Object.keys(files);
|
||||
}
|
||||
|
||||
return originalReaddirSync(target, options);
|
||||
};
|
||||
|
||||
fs.readFileSync = (target, options) => {
|
||||
const fileName = path.basename(String(target));
|
||||
if (files && Object.hasOwn(files, fileName)) {
|
||||
return files[fileName];
|
||||
}
|
||||
|
||||
return originalReadFileSync(target, options);
|
||||
};
|
||||
|
||||
try {
|
||||
return fn();
|
||||
} finally {
|
||||
fs.existsSync = originalExistsSync;
|
||||
fs.readdirSync = originalReaddirSync;
|
||||
fs.readFileSync = originalReadFileSync;
|
||||
}
|
||||
}
|
||||
|
||||
function createDb() {
|
||||
return new Database(":memory:");
|
||||
}
|
||||
|
||||
test("runMigrations applies pending files sequentially in version order", serial, async () => {
|
||||
const runner = await importFresh("src/lib/db/migrationRunner.ts");
|
||||
const db = createDb();
|
||||
|
||||
try {
|
||||
const appliedCount = withMockedMigrationFs(
|
||||
{
|
||||
"010_last.sql": "CREATE TABLE migration_last (id INTEGER);",
|
||||
"002_middle.sql": "CREATE TABLE migration_middle (id INTEGER);",
|
||||
"001_first.sql": "CREATE TABLE migration_first (id INTEGER);",
|
||||
},
|
||||
() => runner.runMigrations(db)
|
||||
);
|
||||
|
||||
assert.equal(appliedCount, 3);
|
||||
assert.deepEqual(
|
||||
db.prepare("SELECT version FROM _omniroute_migrations ORDER BY version").all(),
|
||||
[{ version: "001" }, { version: "002" }, { version: "010" }]
|
||||
);
|
||||
assert.ok(
|
||||
db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("migration_first")
|
||||
);
|
||||
assert.ok(
|
||||
db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("migration_last")
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("runMigrations skips versions that are already tracked as applied", serial, async () => {
|
||||
const runner = await importFresh("src/lib/db/migrationRunner.ts");
|
||||
const db = createDb();
|
||||
|
||||
try {
|
||||
withMockedMigrationFs(
|
||||
{
|
||||
"001_first.sql": "CREATE TABLE skip_first (id INTEGER);",
|
||||
"002_second.sql": "CREATE TABLE skip_second (id INTEGER);",
|
||||
},
|
||||
() => runner.runMigrations(db)
|
||||
);
|
||||
|
||||
const secondRun = withMockedMigrationFs(
|
||||
{
|
||||
"001_first.sql": "CREATE TABLE skip_first (id INTEGER);",
|
||||
"002_second.sql": "CREATE TABLE skip_second (id INTEGER);",
|
||||
},
|
||||
() => runner.runMigrations(db)
|
||||
);
|
||||
|
||||
assert.equal(secondRun, 0);
|
||||
assert.equal(
|
||||
db.prepare("SELECT COUNT(*) AS count FROM _omniroute_migrations WHERE version = ?").get("001")
|
||||
.count,
|
||||
1
|
||||
);
|
||||
assert.equal(
|
||||
db.prepare("SELECT COUNT(*) AS count FROM _omniroute_migrations WHERE version = ?").get("002")
|
||||
.count,
|
||||
1
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("getMigrationStatus reports applied and pending migrations", serial, async () => {
|
||||
const runner = await importFresh("src/lib/db/migrationRunner.ts");
|
||||
const db = createDb();
|
||||
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE _omniroute_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
db.prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)").run(
|
||||
"001",
|
||||
"first"
|
||||
);
|
||||
|
||||
const status = withMockedMigrationFs(
|
||||
{
|
||||
"001_first.sql": "CREATE TABLE status_first (id INTEGER);",
|
||||
"002_second.sql": "CREATE TABLE status_second (id INTEGER);",
|
||||
"003_third.sql": "CREATE TABLE status_third (id INTEGER);",
|
||||
},
|
||||
() => runner.getMigrationStatus(db)
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
status.applied.map((row) => row.version),
|
||||
["001"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
status.pending.map((row) => row.version),
|
||||
["002", "003"]
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
"failed migrations roll back their transaction and do not record the version",
|
||||
serial,
|
||||
async () => {
|
||||
const runner = await importFresh("src/lib/db/migrationRunner.ts");
|
||||
const db = createDb();
|
||||
|
||||
try {
|
||||
assert.throws(
|
||||
() =>
|
||||
withMockedMigrationFs(
|
||||
{
|
||||
"001_ok.sql": "CREATE TABLE rollback_ok (id INTEGER);",
|
||||
"002_broken.sql":
|
||||
"CREATE TABLE rollback_broken (id INTEGER); INSERT INTO missing_table VALUES (1);",
|
||||
},
|
||||
() => runner.runMigrations(db)
|
||||
),
|
||||
/missing_table/i
|
||||
);
|
||||
|
||||
assert.ok(
|
||||
db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("rollback_ok")
|
||||
);
|
||||
assert.equal(
|
||||
db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("rollback_broken"),
|
||||
undefined
|
||||
);
|
||||
assert.equal(
|
||||
db
|
||||
.prepare("SELECT COUNT(*) AS count FROM _omniroute_migrations WHERE version = ?")
|
||||
.get("002").count,
|
||||
0
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test("missing or empty migration directories are treated as a no-op", serial, async () => {
|
||||
const runner = await importFresh("src/lib/db/migrationRunner.ts");
|
||||
const missingDb = createDb();
|
||||
const emptyDb = createDb();
|
||||
|
||||
try {
|
||||
assert.equal(
|
||||
withMockedMigrationFs(null, () => runner.runMigrations(missingDb)),
|
||||
0
|
||||
);
|
||||
assert.equal(
|
||||
withMockedMigrationFs({}, () => runner.runMigrations(emptyDb)),
|
||||
0
|
||||
);
|
||||
assert.deepEqual(
|
||||
withMockedMigrationFs({}, () => runner.getMigrationStatus(emptyDb)),
|
||||
{
|
||||
applied: [],
|
||||
pending: [],
|
||||
}
|
||||
);
|
||||
} finally {
|
||||
missingDb.close();
|
||||
emptyDb.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("invalid file names are ignored while valid migrations still run", serial, async () => {
|
||||
const runner = await importFresh("src/lib/db/migrationRunner.ts");
|
||||
const db = createDb();
|
||||
|
||||
try {
|
||||
const count = withMockedMigrationFs(
|
||||
{
|
||||
"README.md": "# ignored",
|
||||
"not-a-migration.sql": "CREATE TABLE should_not_exist (id INTEGER);",
|
||||
"003_valid.sql": "CREATE TABLE valid_migration (id INTEGER);",
|
||||
},
|
||||
() => runner.runMigrations(db)
|
||||
);
|
||||
|
||||
assert.equal(count, 1);
|
||||
assert.deepEqual(
|
||||
db.prepare("SELECT version, name FROM _omniroute_migrations ORDER BY version").all(),
|
||||
[{ version: "003", name: "valid" }]
|
||||
);
|
||||
assert.equal(
|
||||
db
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?")
|
||||
.get("should_not_exist"),
|
||||
undefined
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
});
|
||||
|
||||
test(
|
||||
"new migrations are detected on subsequent runs without replaying old ones",
|
||||
serial,
|
||||
async () => {
|
||||
const runner = await importFresh("src/lib/db/migrationRunner.ts");
|
||||
const db = createDb();
|
||||
|
||||
try {
|
||||
withMockedMigrationFs(
|
||||
{
|
||||
"001_first.sql": "CREATE TABLE rerun_first (id INTEGER);",
|
||||
"002_second.sql": "CREATE TABLE rerun_second (id INTEGER);",
|
||||
},
|
||||
() => runner.runMigrations(db)
|
||||
);
|
||||
|
||||
const count = withMockedMigrationFs(
|
||||
{
|
||||
"001_first.sql": "CREATE TABLE rerun_first (id INTEGER);",
|
||||
"002_second.sql": "CREATE TABLE rerun_second (id INTEGER);",
|
||||
"003_third.sql": "CREATE TABLE rerun_third (id INTEGER);",
|
||||
},
|
||||
() => runner.runMigrations(db)
|
||||
);
|
||||
|
||||
assert.equal(count, 1);
|
||||
assert.deepEqual(
|
||||
db.prepare("SELECT version FROM _omniroute_migrations ORDER BY version").all(),
|
||||
[{ version: "001" }, { version: "002" }, { version: "003" }]
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
test(
|
||||
"unknown rows in the migration table do not block pending real migrations",
|
||||
serial,
|
||||
async () => {
|
||||
const runner = await importFresh("src/lib/db/migrationRunner.ts");
|
||||
const db = createDb();
|
||||
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE _omniroute_migrations (
|
||||
version TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
`);
|
||||
db.prepare("INSERT INTO _omniroute_migrations (version, name) VALUES (?, ?)").run(
|
||||
"999",
|
||||
"ghost"
|
||||
);
|
||||
|
||||
const count = withMockedMigrationFs(
|
||||
{
|
||||
"001_first.sql": "CREATE TABLE recover_first (id INTEGER);",
|
||||
"002_second.sql": "CREATE TABLE recover_second (id INTEGER);",
|
||||
},
|
||||
() => runner.runMigrations(db)
|
||||
);
|
||||
|
||||
assert.equal(count, 2);
|
||||
assert.deepEqual(
|
||||
db.prepare("SELECT version FROM _omniroute_migrations ORDER BY version").all(),
|
||||
[{ version: "001" }, { version: "002" }, { version: "999" }]
|
||||
);
|
||||
} finally {
|
||||
db.close();
|
||||
}
|
||||
}
|
||||
);
|
||||
217
tests/unit/db-models-crud.test.mjs
Normal file
217
tests/unit/db-models-crud.test.mjs
Normal file
@@ -0,0 +1,217 @@
|
||||
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-db-models-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const modelsDb = await import("../../src/lib/db/models.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("model aliases can be created, listed and deleted", async () => {
|
||||
await modelsDb.setModelAlias("fast-default", { provider: "openai", model: "gpt-4.1-mini" });
|
||||
await modelsDb.setModelAlias("reasoning", { provider: "anthropic", model: "claude-3-7-sonnet" });
|
||||
|
||||
const aliases = await modelsDb.getModelAliases();
|
||||
|
||||
assert.deepEqual(aliases["fast-default"], {
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
});
|
||||
assert.deepEqual(aliases.reasoning, {
|
||||
provider: "anthropic",
|
||||
model: "claude-3-7-sonnet",
|
||||
});
|
||||
|
||||
await modelsDb.deleteModelAlias("fast-default");
|
||||
|
||||
assert.equal((await modelsDb.getModelAliases())["fast-default"], undefined);
|
||||
});
|
||||
|
||||
test("MITM aliases support per-tool lookup and aggregated reads", async () => {
|
||||
await modelsDb.setMitmAliasAll("cursor", {
|
||||
"gpt-4.1": "cursor/gpt-4.1",
|
||||
});
|
||||
await modelsDb.setMitmAliasAll("codex", {
|
||||
"gpt-4.1-mini": "codex/gpt-4.1-mini",
|
||||
});
|
||||
|
||||
assert.deepEqual(await modelsDb.getMitmAlias("cursor"), {
|
||||
"gpt-4.1": "cursor/gpt-4.1",
|
||||
});
|
||||
assert.deepEqual(await modelsDb.getMitmAlias(), {
|
||||
cursor: { "gpt-4.1": "cursor/gpt-4.1" },
|
||||
codex: { "gpt-4.1-mini": "codex/gpt-4.1-mini" },
|
||||
});
|
||||
});
|
||||
|
||||
test("custom models can be added once and queried by provider", async () => {
|
||||
const created = await modelsDb.addCustomModel(
|
||||
"openrouter",
|
||||
"anthropic/claude-3.7-sonnet",
|
||||
"Claude 3.7 Sonnet",
|
||||
"manual",
|
||||
"responses",
|
||||
["chat", "responses"]
|
||||
);
|
||||
const duplicate = await modelsDb.addCustomModel(
|
||||
"openrouter",
|
||||
"anthropic/claude-3.7-sonnet",
|
||||
"Claude 3.7 Sonnet"
|
||||
);
|
||||
const providerModels = await modelsDb.getCustomModels("openrouter");
|
||||
const allModels = await modelsDb.getAllCustomModels();
|
||||
|
||||
assert.equal(duplicate.id, created.id);
|
||||
assert.equal(providerModels.length, 1);
|
||||
assert.deepEqual(providerModels[0], created);
|
||||
assert.equal(allModels.openrouter.length, 1);
|
||||
});
|
||||
|
||||
test("replaceCustomModels preserves compat fields and respects the empty-list guard", async () => {
|
||||
await modelsDb.addCustomModel("openai", "gpt-4.1", "GPT-4.1");
|
||||
await modelsDb.updateCustomModel("openai", "gpt-4.1", {
|
||||
normalizeToolCallId: true,
|
||||
preserveOpenAIDeveloperRole: false,
|
||||
upstreamHeaders: {
|
||||
"X-Test": " enabled ",
|
||||
Host: "should-be-removed",
|
||||
},
|
||||
});
|
||||
|
||||
const replaced = await modelsDb.replaceCustomModels("openai", [
|
||||
{
|
||||
id: "gpt-4.1",
|
||||
name: "GPT-4.1 Refreshed",
|
||||
source: "api-sync",
|
||||
supportsThinking: true,
|
||||
},
|
||||
]);
|
||||
const guarded = await modelsDb.replaceCustomModels("openai", []);
|
||||
|
||||
assert.equal(replaced[0].normalizeToolCallId, true);
|
||||
assert.equal(replaced[0].preserveOpenAIDeveloperRole, false);
|
||||
assert.deepEqual(replaced[0].upstreamHeaders, { "X-Test": "enabled" });
|
||||
assert.equal(replaced[0].supportsThinking, true);
|
||||
assert.equal(guarded.length, 1);
|
||||
|
||||
await modelsDb.replaceCustomModels("openai", [], { allowEmpty: true });
|
||||
|
||||
assert.deepEqual(await modelsDb.getCustomModels("openai"), []);
|
||||
});
|
||||
|
||||
test("removing a custom model also removes its compat override", async () => {
|
||||
await modelsDb.addCustomModel("anthropic", "claude-3-haiku", "Claude 3 Haiku");
|
||||
modelsDb.mergeModelCompatOverride("anthropic", "claude-3-haiku", {
|
||||
normalizeToolCallId: true,
|
||||
isHidden: true,
|
||||
});
|
||||
|
||||
assert.equal(await modelsDb.removeCustomModel("anthropic", "claude-3-haiku"), true);
|
||||
assert.equal(await modelsDb.removeCustomModel("anthropic", "claude-3-haiku"), false);
|
||||
assert.deepEqual(await modelsDb.getCustomModels("anthropic"), []);
|
||||
assert.deepEqual(modelsDb.getModelCompatOverrides("anthropic"), []);
|
||||
});
|
||||
|
||||
test("synced available models are unioned across connections and cleaned per connection", async () => {
|
||||
await modelsDb.replaceSyncedAvailableModelsForConnection("openai", "conn-a", [
|
||||
{ id: "gpt-4.1", name: "GPT-4.1", source: "api-sync" },
|
||||
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "api-sync" },
|
||||
]);
|
||||
const union = await modelsDb.replaceSyncedAvailableModelsForConnection("openai", "conn-b", [
|
||||
{ id: "gpt-4.1-mini", name: "GPT-4.1 Mini", source: "api-sync" },
|
||||
{ id: "o3-mini", name: "o3-mini", source: "api-sync" },
|
||||
]);
|
||||
const remaining = await modelsDb.deleteSyncedAvailableModelsForConnection("openai", "conn-a");
|
||||
const allProviders = await modelsDb.getAllSyncedAvailableModels();
|
||||
|
||||
assert.deepEqual(union.map((model) => model.id).sort(), ["gpt-4.1", "gpt-4.1-mini", "o3-mini"]);
|
||||
assert.deepEqual(remaining.map((model) => model.id).sort(), ["gpt-4.1-mini", "o3-mini"]);
|
||||
assert.deepEqual(allProviders.openai.map((model) => model.id).sort(), [
|
||||
"gpt-4.1-mini",
|
||||
"o3-mini",
|
||||
]);
|
||||
});
|
||||
|
||||
test("compat overrides expose per-protocol getters and removable extra headers", async () => {
|
||||
modelsDb.mergeModelCompatOverride("openai", "gpt-4.1", {
|
||||
normalizeToolCallId: true,
|
||||
preserveOpenAIDeveloperRole: false,
|
||||
isHidden: true,
|
||||
upstreamHeaders: {
|
||||
"X-Top": "1",
|
||||
"bad header": "skip",
|
||||
},
|
||||
compatByProtocol: {
|
||||
openai: {
|
||||
normalizeToolCallId: false,
|
||||
preserveOpenAIDeveloperRole: true,
|
||||
upstreamHeaders: {
|
||||
"X-Proto": "yes",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(modelsDb.getModelNormalizeToolCallId("openai", "gpt-4.1"), true);
|
||||
assert.equal(modelsDb.getModelNormalizeToolCallId("openai", "gpt-4.1", "openai"), false);
|
||||
assert.equal(modelsDb.getModelPreserveOpenAIDeveloperRole("openai", "gpt-4.1"), false);
|
||||
assert.equal(modelsDb.getModelPreserveOpenAIDeveloperRole("openai", "gpt-4.1", "openai"), true);
|
||||
assert.equal(modelsDb.getModelIsHidden("openai", "gpt-4.1"), true);
|
||||
assert.deepEqual(modelsDb.getModelUpstreamExtraHeaders("openai", "gpt-4.1", "openai"), {
|
||||
"X-Top": "1",
|
||||
"X-Proto": "yes",
|
||||
});
|
||||
|
||||
modelsDb.removeModelCompatOverride("openai", "gpt-4.1");
|
||||
|
||||
assert.equal(modelsDb.getModelNormalizeToolCallId("openai", "gpt-4.1"), false);
|
||||
assert.deepEqual(modelsDb.getModelCompatOverrides("openai"), []);
|
||||
});
|
||||
|
||||
test("sanitizeUpstreamHeadersMap keeps only safe trimmed headers", () => {
|
||||
const sanitized = modelsDb.sanitizeUpstreamHeadersMap({
|
||||
"X-First": " one ",
|
||||
Host: "blocked",
|
||||
"Bad Header": "blocked",
|
||||
"X-Newline": "bad\nvalue",
|
||||
"X-Second": 42,
|
||||
});
|
||||
|
||||
assert.deepEqual(sanitized, {
|
||||
"X-First": "one",
|
||||
"X-Second": "42",
|
||||
});
|
||||
});
|
||||
293
tests/unit/db-providers-crud.test.mjs
Normal file
293
tests/unit/db-providers-crud.test.mjs
Normal file
@@ -0,0 +1,293 @@
|
||||
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-db-providers-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("createProviderConnection assigns provider-scoped priorities and supports filtered reads", async () => {
|
||||
const first = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "Primary",
|
||||
apiKey: "sk-primary",
|
||||
group: "team-b",
|
||||
});
|
||||
const second = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "Secondary",
|
||||
apiKey: "sk-secondary",
|
||||
isActive: false,
|
||||
group: "team-a",
|
||||
});
|
||||
|
||||
const openAiConnections = await providersDb.getProviderConnections({ provider: "openai" });
|
||||
const activeConnections = await providersDb.getProviderConnections({
|
||||
provider: "openai",
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
openAiConnections.map((connection) => ({
|
||||
name: connection.name,
|
||||
priority: connection.priority,
|
||||
})),
|
||||
[
|
||||
{ name: "Primary", priority: 1 },
|
||||
{ name: "Secondary", priority: 2 },
|
||||
]
|
||||
);
|
||||
assert.deepEqual(
|
||||
activeConnections.map((connection) => connection.id),
|
||||
[first.id]
|
||||
);
|
||||
assert.deepEqual(await providersDb.getDistinctGroups(), ["team-a", "team-b"]);
|
||||
assert.equal(second.isActive, false);
|
||||
});
|
||||
|
||||
test("oauth connections upsert by provider and email instead of duplicating rows", async () => {
|
||||
const original = await providersDb.createProviderConnection({
|
||||
provider: "claude",
|
||||
authType: "oauth",
|
||||
email: "dev@example.com",
|
||||
accessToken: "token-a",
|
||||
refreshToken: "refresh-a",
|
||||
testStatus: "ok",
|
||||
});
|
||||
|
||||
const updated = await providersDb.createProviderConnection({
|
||||
provider: "claude",
|
||||
authType: "oauth",
|
||||
email: "dev@example.com",
|
||||
accessToken: "token-b",
|
||||
refreshToken: "refresh-b",
|
||||
lastError: "expired",
|
||||
testStatus: "retrying",
|
||||
});
|
||||
|
||||
const rows = await providersDb.getProviderConnections({ provider: "claude" });
|
||||
|
||||
assert.equal(updated.id, original.id);
|
||||
assert.equal(rows.length, 1);
|
||||
assert.equal(rows[0].accessToken, "token-b");
|
||||
assert.equal(rows[0].lastError, "expired");
|
||||
assert.equal(rows[0].testStatus, "retrying");
|
||||
});
|
||||
|
||||
test("codex workspace uniqueness uses workspaceId alongside email", async () => {
|
||||
const workspaceA = await providersDb.createProviderConnection({
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
email: "workspace@example.com",
|
||||
providerSpecificData: { workspaceId: "ws-a" },
|
||||
});
|
||||
const workspaceAUpdate = await providersDb.createProviderConnection({
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
email: "workspace@example.com",
|
||||
providerSpecificData: { workspaceId: "ws-a" },
|
||||
accessToken: "updated-token",
|
||||
});
|
||||
const workspaceB = await providersDb.createProviderConnection({
|
||||
provider: "codex",
|
||||
authType: "oauth",
|
||||
email: "workspace@example.com",
|
||||
providerSpecificData: { workspaceId: "ws-b" },
|
||||
});
|
||||
|
||||
const rows = await providersDb.getProviderConnections({ provider: "codex" });
|
||||
|
||||
assert.equal(workspaceAUpdate.id, workspaceA.id);
|
||||
assert.notEqual(workspaceB.id, workspaceA.id);
|
||||
assert.equal(rows.length, 2);
|
||||
assert.deepEqual(rows.map((row) => row.providerSpecificData.workspaceId).sort(), [
|
||||
"ws-a",
|
||||
"ws-b",
|
||||
]);
|
||||
});
|
||||
|
||||
test("updateProviderConnection reorders priorities and returns decrypted payloads", async () => {
|
||||
const first = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "First",
|
||||
apiKey: "first-key",
|
||||
});
|
||||
const second = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "Second",
|
||||
apiKey: "second-key",
|
||||
});
|
||||
const third = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "Third",
|
||||
apiKey: "third-key",
|
||||
});
|
||||
|
||||
const updated = await providersDb.updateProviderConnection(third.id, {
|
||||
priority: 0,
|
||||
providerSpecificData: { region: "us-east-1" },
|
||||
rateLimitProtection: true,
|
||||
});
|
||||
|
||||
const ordered = await providersDb.getProviderConnections({ provider: "openai" });
|
||||
|
||||
assert.equal(updated.providerSpecificData.region, "us-east-1");
|
||||
assert.equal(updated.rateLimitProtection, true);
|
||||
assert.deepEqual(
|
||||
ordered.map((connection) => ({
|
||||
id: connection.id,
|
||||
priority: connection.priority,
|
||||
})),
|
||||
[
|
||||
{ id: third.id, priority: 1 },
|
||||
{ id: first.id, priority: 2 },
|
||||
{ id: second.id, priority: 3 },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test("deleteProviderConnection reorders remaining rows and bulk delete reports changes", async () => {
|
||||
const first = await providersDb.createProviderConnection({
|
||||
provider: "anthropic",
|
||||
authType: "apikey",
|
||||
name: "One",
|
||||
apiKey: "one",
|
||||
});
|
||||
const second = await providersDb.createProviderConnection({
|
||||
provider: "anthropic",
|
||||
authType: "apikey",
|
||||
name: "Two",
|
||||
apiKey: "two",
|
||||
});
|
||||
const third = await providersDb.createProviderConnection({
|
||||
provider: "anthropic",
|
||||
authType: "apikey",
|
||||
name: "Three",
|
||||
apiKey: "three",
|
||||
});
|
||||
|
||||
assert.equal(await providersDb.deleteProviderConnection(second.id), true);
|
||||
|
||||
const reordered = await providersDb.getProviderConnections({ provider: "anthropic" });
|
||||
const deletedCount = await providersDb.deleteProviderConnectionsByProvider("anthropic");
|
||||
|
||||
assert.deepEqual(
|
||||
reordered.map((connection) => ({
|
||||
id: connection.id,
|
||||
priority: connection.priority,
|
||||
})),
|
||||
[
|
||||
{ id: first.id, priority: 1 },
|
||||
{ id: third.id, priority: 2 },
|
||||
]
|
||||
);
|
||||
assert.equal(deletedCount, 2);
|
||||
assert.deepEqual(await providersDb.getProviderConnections({ provider: "anthropic" }), []);
|
||||
});
|
||||
|
||||
test("provider node CRUD supports filter, update and delete", async () => {
|
||||
const customNode = await providersDb.createProviderNode({
|
||||
type: "custom",
|
||||
name: "Custom Gateway",
|
||||
prefix: "custom-",
|
||||
baseUrl: "https://custom.example.com",
|
||||
});
|
||||
const openAiNode = await providersDb.createProviderNode({
|
||||
type: "openai",
|
||||
name: "OpenAI Native",
|
||||
baseUrl: "https://api.openai.com",
|
||||
});
|
||||
|
||||
const filtered = await providersDb.getProviderNodes({ type: "custom" });
|
||||
const updated = await providersDb.updateProviderNode(customNode.id, {
|
||||
name: "Custom Gateway v2",
|
||||
chatPath: "/v1/chat/completions",
|
||||
modelsPath: "/v1/models",
|
||||
});
|
||||
const deleted = await providersDb.deleteProviderNode(openAiNode.id);
|
||||
|
||||
assert.deepEqual(
|
||||
filtered.map((node) => node.id),
|
||||
[customNode.id]
|
||||
);
|
||||
assert.equal(updated.name, "Custom Gateway v2");
|
||||
assert.equal(updated.chatPath, "/v1/chat/completions");
|
||||
assert.deepEqual(await providersDb.getProviderNodeById(customNode.id), updated);
|
||||
assert.equal(deleted.id, openAiNode.id);
|
||||
assert.equal(await providersDb.getProviderNodeById(openAiNode.id), null);
|
||||
});
|
||||
|
||||
test("rate-limit helpers persist cooldown state in the database", async () => {
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "Rate Limited",
|
||||
apiKey: "rate-key",
|
||||
});
|
||||
const future = Date.now() + 90_000;
|
||||
|
||||
providersDb.setConnectionRateLimitUntil(connection.id, future);
|
||||
|
||||
assert.equal(providersDb.isConnectionRateLimited(connection.id), true);
|
||||
assert.deepEqual(
|
||||
providersDb
|
||||
.getRateLimitedConnections("openai")
|
||||
.map((entry) => ({ ...entry, rateLimitedUntil: Number(entry.rateLimitedUntil) })),
|
||||
[{ id: connection.id, rateLimitedUntil: future }]
|
||||
);
|
||||
|
||||
providersDb.setConnectionRateLimitUntil(connection.id, null);
|
||||
|
||||
assert.equal(providersDb.isConnectionRateLimited(connection.id), false);
|
||||
assert.deepEqual(providersDb.getRateLimitedConnections("openai"), []);
|
||||
});
|
||||
|
||||
test("quota helpers zero stale windows and format countdowns", () => {
|
||||
const past = Date.now() - 1_000;
|
||||
const future = Date.now() + 65_000;
|
||||
|
||||
assert.equal(providersDb.getEffectiveQuotaUsage(120, past), 0);
|
||||
assert.equal(providersDb.getEffectiveQuotaUsage(120, "not-a-date"), 120);
|
||||
assert.equal(providersDb.getEffectiveQuotaUsage(120, null), 120);
|
||||
assert.match(providersDb.formatResetCountdown(future), /1m \d+s/);
|
||||
assert.equal(providersDb.formatResetCountdown(past), null);
|
||||
});
|
||||
207
tests/unit/db-proxies-crud.test.mjs
Normal file
207
tests/unit/db-proxies-crud.test.mjs
Normal file
@@ -0,0 +1,207 @@
|
||||
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-db-proxies-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const providersDb = await import("../../src/lib/db/providers.ts");
|
||||
const proxiesDb = await import("../../src/lib/db/proxies.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("proxy CRUD redacts secrets by default and preserves stored credentials on blank update", async () => {
|
||||
const created = await proxiesDb.createProxy({
|
||||
name: "Primary Proxy",
|
||||
type: "http",
|
||||
host: "proxy.local",
|
||||
port: 8080,
|
||||
username: "user-a",
|
||||
password: "pass-a",
|
||||
region: "sa-east-1",
|
||||
});
|
||||
|
||||
assert.equal(created.username, "***");
|
||||
assert.equal(created.password, "***");
|
||||
|
||||
const withSecrets = await proxiesDb.getProxyById(created.id, { includeSecrets: true });
|
||||
const updated = await proxiesDb.updateProxy(created.id, {
|
||||
host: "proxy-updated.local",
|
||||
username: "",
|
||||
password: "",
|
||||
notes: "updated",
|
||||
});
|
||||
const updatedWithSecrets = await proxiesDb.getProxyById(created.id, { includeSecrets: true });
|
||||
const listed = await proxiesDb.listProxies();
|
||||
|
||||
assert.equal(withSecrets.username, "user-a");
|
||||
assert.equal(withSecrets.password, "pass-a");
|
||||
assert.equal(updated.host, "proxy-updated.local");
|
||||
assert.equal(updated.notes, "updated");
|
||||
assert.equal(updatedWithSecrets.username, "user-a");
|
||||
assert.equal(updatedWithSecrets.password, "pass-a");
|
||||
assert.equal(listed.length, 1);
|
||||
assert.equal(listed[0].username, "***");
|
||||
assert.equal(listed[0].password, "***");
|
||||
});
|
||||
|
||||
test("proxy assignments resolve by account, provider and global scope", async () => {
|
||||
const connection = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "Proxy Target",
|
||||
apiKey: "sk-proxy",
|
||||
});
|
||||
const globalProxy = await proxiesDb.createProxy({
|
||||
name: "Global",
|
||||
type: "http",
|
||||
host: "global.local",
|
||||
port: 8080,
|
||||
});
|
||||
const providerProxy = await proxiesDb.createProxy({
|
||||
name: "Provider",
|
||||
type: "https",
|
||||
host: "provider.local",
|
||||
port: 443,
|
||||
});
|
||||
const accountProxy = await proxiesDb.createProxy({
|
||||
name: "Account",
|
||||
type: "socks5",
|
||||
host: "account.local",
|
||||
port: 1080,
|
||||
});
|
||||
|
||||
await proxiesDb.assignProxyToScope("global", null, globalProxy.id);
|
||||
await proxiesDb.assignProxyToScope("provider", "openai", providerProxy.id);
|
||||
|
||||
const providerResolved = await proxiesDb.resolveProxyForProvider("openai");
|
||||
const beforeAccount = await proxiesDb.resolveProxyForConnectionFromRegistry(connection.id);
|
||||
|
||||
await proxiesDb.assignProxyToScope("key", connection.id, accountProxy.id);
|
||||
|
||||
const assignmentsForAccountProxy = await proxiesDb.getProxyAssignments({
|
||||
proxyId: accountProxy.id,
|
||||
});
|
||||
const accountResolved = await proxiesDb.resolveProxyForConnectionFromRegistry(connection.id);
|
||||
const usage = await proxiesDb.getProxyWhereUsed(accountProxy.id);
|
||||
|
||||
assert.equal(providerResolved.host, "provider.local");
|
||||
assert.equal(beforeAccount.level, "provider");
|
||||
assert.equal(assignmentsForAccountProxy.length, 1);
|
||||
assert.equal(assignmentsForAccountProxy[0].scope, "account");
|
||||
assert.equal(accountResolved.level, "account");
|
||||
assert.equal(accountResolved.proxy.host, "account.local");
|
||||
assert.equal(usage.count, 1);
|
||||
});
|
||||
|
||||
test("bulk assignment deduplicates scope ids and reports failures for missing proxies", async () => {
|
||||
const first = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "Bulk One",
|
||||
apiKey: "sk-bulk-1",
|
||||
});
|
||||
const second = await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "Bulk Two",
|
||||
apiKey: "sk-bulk-2",
|
||||
});
|
||||
const proxy = await proxiesDb.createProxy({
|
||||
name: "Bulk Proxy",
|
||||
type: "http",
|
||||
host: "bulk.local",
|
||||
port: 8080,
|
||||
});
|
||||
|
||||
const success = await proxiesDb.bulkAssignProxyToScope(
|
||||
"account",
|
||||
[first.id, second.id, first.id, " "],
|
||||
proxy.id
|
||||
);
|
||||
const failure = await proxiesDb.bulkAssignProxyToScope(
|
||||
"account",
|
||||
[first.id, second.id],
|
||||
"missing-proxy"
|
||||
);
|
||||
|
||||
assert.equal(success.updated, 2);
|
||||
assert.deepEqual(success.failed, []);
|
||||
assert.equal(failure.updated, 0);
|
||||
assert.equal(failure.failed.length, 2);
|
||||
assert.match(failure.failed[0].reason, /Proxy not found/);
|
||||
});
|
||||
|
||||
test("proxy health stats aggregate proxy_logs and force delete removes assignments", async () => {
|
||||
const proxy = await proxiesDb.createProxy({
|
||||
name: "Stats Proxy",
|
||||
type: "http",
|
||||
host: "stats.local",
|
||||
port: 8080,
|
||||
});
|
||||
|
||||
await proxiesDb.assignProxyToScope("global", null, proxy.id);
|
||||
|
||||
const db = core.getDbInstance();
|
||||
const now = new Date().toISOString();
|
||||
const insertLog = db.prepare(`
|
||||
INSERT INTO proxy_logs (
|
||||
id, timestamp, status, proxy_type, proxy_host, proxy_port, latency_ms
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
insertLog.run("proxy-log-1", now, "success", "http", "stats.local", 8080, 100);
|
||||
insertLog.run("proxy-log-2", now, "error", "http", "stats.local", 8080, 250);
|
||||
insertLog.run("proxy-log-3", now, "timeout", "http", "stats.local", 8080, 400);
|
||||
|
||||
const stats = await proxiesDb.getProxyHealthStats({ hours: 2 });
|
||||
|
||||
assert.deepEqual(stats[0], {
|
||||
proxyId: proxy.id,
|
||||
name: "Stats Proxy",
|
||||
type: "http",
|
||||
host: "stats.local",
|
||||
port: 8080,
|
||||
totalRequests: 3,
|
||||
successCount: 1,
|
||||
errorCount: 1,
|
||||
timeoutCount: 1,
|
||||
successRate: 33.33,
|
||||
avgLatencyMs: 250,
|
||||
lastSeenAt: now,
|
||||
});
|
||||
|
||||
assert.equal(await proxiesDb.deleteProxyById(proxy.id, { force: true }), true);
|
||||
assert.equal((await proxiesDb.getProxyAssignments()).length, 0);
|
||||
assert.equal(await proxiesDb.getProxyById(proxy.id), null);
|
||||
});
|
||||
165
tests/unit/db-read-cache.test.mjs
Normal file
165
tests/unit/db-read-cache.test.mjs
Normal file
@@ -0,0 +1,165 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const TEST_DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), "omniroute-db-read-cache-"));
|
||||
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 providersDb = await import("../../src/lib/db/providers.ts");
|
||||
|
||||
async function importFresh(modulePath) {
|
||||
const url = pathToFileURL(path.resolve(modulePath)).href;
|
||||
return import(`${url}?test=${Date.now()}-${Math.random().toString(16).slice(2)}`);
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("getCachedSettings returns cached data until TTL expires or cache is invalidated", async () => {
|
||||
const readCache = await importFresh("src/lib/db/readCache.ts");
|
||||
const db = core.getDbInstance();
|
||||
|
||||
await settingsDb.updateSettings({ label: "initial" });
|
||||
assert.equal((await readCache.getCachedSettings()).label, "initial");
|
||||
|
||||
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
||||
"settings",
|
||||
"label",
|
||||
JSON.stringify("stale-write")
|
||||
);
|
||||
|
||||
assert.equal((await readCache.getCachedSettings()).label, "initial");
|
||||
|
||||
const originalNow = Date.now;
|
||||
try {
|
||||
Date.now = () => originalNow() + 6_000;
|
||||
assert.equal((await readCache.getCachedSettings()).label, "stale-write");
|
||||
} finally {
|
||||
Date.now = originalNow;
|
||||
}
|
||||
|
||||
db.prepare("INSERT OR REPLACE INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
||||
"settings",
|
||||
"label",
|
||||
JSON.stringify("after-invalidate")
|
||||
);
|
||||
readCache.invalidateDbCache("settings");
|
||||
|
||||
assert.equal((await readCache.getCachedSettings()).label, "after-invalidate");
|
||||
});
|
||||
|
||||
test("getCachedPricing caches results and refreshes after invalidation", async () => {
|
||||
const readCache = await importFresh("src/lib/db/readCache.ts");
|
||||
const db = core.getDbInstance();
|
||||
|
||||
db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
||||
"pricing",
|
||||
"cache-provider",
|
||||
JSON.stringify({
|
||||
"model-a": { prompt: 1 },
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal((await readCache.getCachedPricing())["cache-provider"]["model-a"].prompt, 1);
|
||||
|
||||
db.prepare("UPDATE key_value SET value = ? WHERE namespace = ? AND key = ?").run(
|
||||
JSON.stringify({
|
||||
"model-a": { prompt: 9 },
|
||||
}),
|
||||
"pricing",
|
||||
"cache-provider"
|
||||
);
|
||||
|
||||
assert.equal((await readCache.getCachedPricing())["cache-provider"]["model-a"].prompt, 1);
|
||||
|
||||
readCache.invalidateDbCache("pricing");
|
||||
|
||||
assert.equal((await readCache.getCachedPricing())["cache-provider"]["model-a"].prompt, 9);
|
||||
});
|
||||
|
||||
test("getCachedProviderConnections caches only the unfiltered query", async () => {
|
||||
const readCache = await importFresh("src/lib/db/readCache.ts");
|
||||
const db = core.getDbInstance();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await providersDb.createProviderConnection({
|
||||
provider: "openai",
|
||||
authType: "apikey",
|
||||
name: "Primary",
|
||||
apiKey: "sk-primary",
|
||||
});
|
||||
const firstRead = await readCache.getCachedProviderConnections();
|
||||
|
||||
db.prepare(
|
||||
`
|
||||
INSERT INTO provider_connections (
|
||||
id, provider, auth_type, name, is_active, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
).run("direct-insert", "openai", "apikey", "Secondary", 1, now, now);
|
||||
|
||||
const cachedAll = await readCache.getCachedProviderConnections();
|
||||
const filtered = await readCache.getCachedProviderConnections({ provider: "openai" });
|
||||
|
||||
assert.equal(firstRead.length, 1);
|
||||
assert.equal(cachedAll.length, 1);
|
||||
assert.equal(filtered.length, 2);
|
||||
|
||||
readCache.invalidateDbCache("connections");
|
||||
|
||||
assert.equal((await readCache.getCachedProviderConnections()).length, 2);
|
||||
});
|
||||
|
||||
test("cached LKGP values refresh only after the specific key is invalidated", async () => {
|
||||
const readCache = await importFresh("src/lib/db/readCache.ts");
|
||||
const db = core.getDbInstance();
|
||||
const comboName = `combo-${Date.now()}`;
|
||||
const modelId = `model-${Date.now()}`;
|
||||
const lkgpKey = `${comboName}:${modelId}`;
|
||||
|
||||
await settingsDb.setLKGP(comboName, modelId, "openai");
|
||||
assert.equal(await readCache.getCachedLKGP(comboName, modelId), "openai");
|
||||
|
||||
db.prepare("UPDATE key_value SET value = ? WHERE namespace = 'lkgp' AND key = ?").run(
|
||||
JSON.stringify("anthropic"),
|
||||
lkgpKey
|
||||
);
|
||||
|
||||
assert.equal(await readCache.getCachedLKGP(comboName, modelId), "openai");
|
||||
|
||||
await readCache.setCachedLKGP(comboName, modelId, "gemini");
|
||||
|
||||
assert.equal(await readCache.getCachedLKGP(comboName, modelId), "gemini");
|
||||
});
|
||||
69
tests/unit/db-secrets.test.mjs
Normal file
69
tests/unit/db-secrets.test.mjs
Normal file
@@ -0,0 +1,69 @@
|
||||
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-db-secrets-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const secretsDb = await import("../../src/lib/db/secrets.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("getPersistedSecret returns null for missing keys", () => {
|
||||
assert.equal(secretsDb.getPersistedSecret("missing"), null);
|
||||
});
|
||||
|
||||
test("persistSecret stores and reads secrets from the key_value table", () => {
|
||||
secretsDb.persistSecret("oauth_token", "secret-value");
|
||||
|
||||
assert.equal(secretsDb.getPersistedSecret("oauth_token"), "secret-value");
|
||||
});
|
||||
|
||||
test("persistSecret does not overwrite an existing secret because storage is insert-only", () => {
|
||||
secretsDb.persistSecret("api_token", "first-value");
|
||||
secretsDb.persistSecret("api_token", "second-value");
|
||||
|
||||
assert.equal(secretsDb.getPersistedSecret("api_token"), "first-value");
|
||||
});
|
||||
|
||||
test("malformed persisted rows are treated as missing secrets", () => {
|
||||
const db = core.getDbInstance();
|
||||
db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
||||
"secrets",
|
||||
"broken",
|
||||
"not-json"
|
||||
);
|
||||
|
||||
assert.equal(secretsDb.getPersistedSecret("broken"), null);
|
||||
});
|
||||
273
tests/unit/db-settings-crud.test.mjs
Normal file
273
tests/unit/db-settings-crud.test.mjs
Normal file
@@ -0,0 +1,273 @@
|
||||
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-db-settings-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const ORIGINAL_INITIAL_PASSWORD = process.env.INITIAL_PASSWORD;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const settingsDb = await import("../../src/lib/db/settings.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
|
||||
if (ORIGINAL_INITIAL_PASSWORD === undefined) {
|
||||
delete process.env.INITIAL_PASSWORD;
|
||||
} else {
|
||||
process.env.INITIAL_PASSWORD = ORIGINAL_INITIAL_PASSWORD;
|
||||
}
|
||||
});
|
||||
|
||||
test("getSettings exposes defaults and updateSettings persists typed values", async () => {
|
||||
const defaults = await settingsDb.getSettings();
|
||||
const updated = await settingsDb.updateSettings({
|
||||
requireLogin: false,
|
||||
cloudEnabled: true,
|
||||
stickyRoundRobinLimit: 7,
|
||||
label: "task-303",
|
||||
});
|
||||
|
||||
assert.equal(defaults.cloudEnabled, false);
|
||||
assert.equal(defaults.requireLogin, true);
|
||||
assert.deepEqual(defaults.hiddenSidebarItems, []);
|
||||
assert.equal(defaults.idempotencyWindowMs, 5000);
|
||||
assert.equal(updated.requireLogin, false);
|
||||
assert.equal(updated.cloudEnabled, true);
|
||||
assert.equal(updated.stickyRoundRobinLimit, 7);
|
||||
assert.equal(updated.label, "task-303");
|
||||
assert.equal(await settingsDb.isCloudEnabled(), true);
|
||||
});
|
||||
|
||||
test("INITIAL_PASSWORD marks onboarding as complete on first read", async () => {
|
||||
process.env.INITIAL_PASSWORD = "bootstrap-secret";
|
||||
|
||||
const settings = await settingsDb.getSettings();
|
||||
const stored = await settingsDb.getSettings();
|
||||
|
||||
assert.equal(settings.setupComplete, true);
|
||||
assert.equal(settings.requireLogin, true);
|
||||
assert.equal(stored.setupComplete, true);
|
||||
});
|
||||
|
||||
test("pricing layers merge synced, models.dev and user overrides", async () => {
|
||||
const db = core.getDbInstance();
|
||||
|
||||
db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
||||
"pricing_synced",
|
||||
"layered-provider",
|
||||
JSON.stringify({
|
||||
"model-a": { prompt: 1, completion: 2 },
|
||||
})
|
||||
);
|
||||
db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
||||
"models_dev_pricing",
|
||||
"layered-provider",
|
||||
JSON.stringify({
|
||||
"model-a": { completion: 5, cached: 3 },
|
||||
})
|
||||
);
|
||||
|
||||
await settingsDb.updatePricing({
|
||||
"layered-provider": {
|
||||
"model-a": { prompt: 9, custom: 42 },
|
||||
"model-b": { prompt: 7 },
|
||||
},
|
||||
});
|
||||
|
||||
const pricing = await settingsDb.getPricing();
|
||||
const direct = await settingsDb.getPricingForModel("layered-provider", "model-a");
|
||||
const cnFallback = await settingsDb.getPricingForModel("openai-cn", "gpt-4o");
|
||||
|
||||
assert.deepEqual(pricing["layered-provider"]["model-a"], {
|
||||
prompt: 9,
|
||||
completion: 5,
|
||||
cached: 3,
|
||||
custom: 42,
|
||||
});
|
||||
assert.deepEqual(direct, {
|
||||
prompt: 9,
|
||||
completion: 5,
|
||||
cached: 3,
|
||||
custom: 42,
|
||||
});
|
||||
assert.ok(cnFallback);
|
||||
|
||||
const afterModelReset = await settingsDb.resetPricing("layered-provider", "model-a");
|
||||
assert.equal(afterModelReset["layered-provider"]["model-a"], undefined);
|
||||
|
||||
const afterProviderReset = await settingsDb.resetPricing("layered-provider");
|
||||
assert.equal(afterProviderReset["layered-provider"], undefined);
|
||||
|
||||
await settingsDb.updatePricing({
|
||||
temp: { model: { prompt: 1 } },
|
||||
});
|
||||
assert.deepEqual(await settingsDb.resetAllPricing(), {});
|
||||
});
|
||||
|
||||
test("LKGP values can be set, read and cleared", async () => {
|
||||
assert.equal(await settingsDb.getLKGP("combo-a", "model-a"), null);
|
||||
|
||||
await settingsDb.setLKGP("combo-a", "model-a", "openai");
|
||||
await settingsDb.setLKGP("combo-a", "model-b", "anthropic");
|
||||
|
||||
assert.equal(await settingsDb.getLKGP("combo-a", "model-a"), "openai");
|
||||
assert.equal(await settingsDb.getLKGP("combo-a", "model-b"), "anthropic");
|
||||
|
||||
settingsDb.clearAllLKGP();
|
||||
|
||||
assert.equal(await settingsDb.getLKGP("combo-a", "model-a"), null);
|
||||
});
|
||||
|
||||
test("proxy config migrates legacy strings and supports bulk merge updates", async () => {
|
||||
const db = core.getDbInstance();
|
||||
|
||||
db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
||||
"proxyConfig",
|
||||
"global",
|
||||
JSON.stringify("http://user:pass@global.local:8080")
|
||||
);
|
||||
db.prepare("INSERT INTO key_value (namespace, key, value) VALUES (?, ?, ?)").run(
|
||||
"proxyConfig",
|
||||
"providers",
|
||||
JSON.stringify({
|
||||
openai: "https://provider.local:8443",
|
||||
})
|
||||
);
|
||||
|
||||
const migrated = await settingsDb.getProxyConfig();
|
||||
assert.deepEqual(migrated.global, {
|
||||
type: "http",
|
||||
host: "global.local",
|
||||
port: "8080",
|
||||
username: "user",
|
||||
password: "pass",
|
||||
});
|
||||
assert.deepEqual(migrated.providers.openai, {
|
||||
type: "https",
|
||||
host: "provider.local",
|
||||
port: "8443",
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const merged = await settingsDb.setProxyConfig({
|
||||
providers: {
|
||||
openai: null,
|
||||
anthropic: {
|
||||
type: "http",
|
||||
host: "anthropic.local",
|
||||
port: 9000,
|
||||
},
|
||||
},
|
||||
keys: {
|
||||
key123: {
|
||||
type: "socks5",
|
||||
host: "key.local",
|
||||
port: 1080,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(merged.providers.openai, undefined);
|
||||
assert.equal(merged.providers.anthropic.host, "anthropic.local");
|
||||
assert.equal((await settingsDb.getProxyForLevel("key", "key123")).host, "key.local");
|
||||
|
||||
await settingsDb.deleteProxyForLevel("key", "key123");
|
||||
|
||||
assert.equal(await settingsDb.getProxyForLevel("key", "key123"), null);
|
||||
});
|
||||
|
||||
test("cache metrics, trend and no-op update/reset methods read from usage_history", async () => {
|
||||
const db = core.getDbInstance();
|
||||
const now = new Date().toISOString();
|
||||
const oneHourAgo = new Date(Date.now() - 60 * 60 * 1000).toISOString();
|
||||
|
||||
const insertUsage = db.prepare(`
|
||||
INSERT INTO usage_history (
|
||||
provider, model, connection_id, api_key_id, api_key_name,
|
||||
tokens_input, tokens_output, tokens_cache_read, tokens_cache_creation,
|
||||
tokens_reasoning, status, success, latency_ms, ttft_ms, error_code, timestamp
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`);
|
||||
|
||||
insertUsage.run(
|
||||
"openai",
|
||||
"gpt-4.1",
|
||||
"conn-1",
|
||||
"key-1",
|
||||
"Primary",
|
||||
1000,
|
||||
400,
|
||||
300,
|
||||
120,
|
||||
0,
|
||||
"200",
|
||||
1,
|
||||
100,
|
||||
40,
|
||||
null,
|
||||
oneHourAgo
|
||||
);
|
||||
insertUsage.run(
|
||||
"anthropic",
|
||||
"claude-3-7-sonnet",
|
||||
"conn-2",
|
||||
"key-2",
|
||||
"Secondary",
|
||||
700,
|
||||
280,
|
||||
200,
|
||||
80,
|
||||
0,
|
||||
"200",
|
||||
1,
|
||||
90,
|
||||
30,
|
||||
null,
|
||||
now
|
||||
);
|
||||
|
||||
const metrics = await settingsDb.getCacheMetrics();
|
||||
const trend = await settingsDb.getCacheTrend(4);
|
||||
const updateNoOp = await settingsDb.updateCacheMetrics({ anything: true });
|
||||
const resetNoOp = await settingsDb.resetCacheMetrics();
|
||||
|
||||
assert.ok(metrics.totalRequests >= 2);
|
||||
assert.ok(metrics.requestsWithCacheControl >= 2);
|
||||
assert.ok(metrics.byProvider.openai);
|
||||
assert.ok(metrics.byProvider.anthropic);
|
||||
assert.ok(trend.length >= 1);
|
||||
assert.equal(updateNoOp.totalCachedTokens, metrics.totalCachedTokens);
|
||||
assert.equal(resetNoOp.totalCachedTokens, metrics.totalCachedTokens);
|
||||
});
|
||||
506
tests/unit/domain-branch-hardening.test.mjs
Normal file
506
tests/unit/domain-branch-hardening.test.mjs
Normal file
@@ -0,0 +1,506 @@
|
||||
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-domain-hardening-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const costRules = await import("../../src/domain/costRules.ts");
|
||||
const fallbackPolicy = await import("../../src/domain/fallbackPolicy.ts");
|
||||
const lockoutPolicy = await import("../../src/domain/lockoutPolicy.ts");
|
||||
const modelAvailability = await import("../../src/domain/modelAvailability.ts");
|
||||
const providerExpiration = await import("../../src/domain/providerExpiration.ts");
|
||||
const quotaCache = await import("../../src/domain/quotaCache.ts");
|
||||
const comboResolver = await import("../../src/domain/comboResolver.ts");
|
||||
const policyEngineModule = await import("../../src/domain/policyEngine.ts");
|
||||
const domainState = await import("../../src/lib/db/domainState.ts");
|
||||
|
||||
const originalDateNow = Date.now;
|
||||
const originalMathRandom = Math.random;
|
||||
|
||||
function isoFromNow(offsetMs) {
|
||||
return new Date(Date.now() + offsetMs).toISOString();
|
||||
}
|
||||
|
||||
async function resetStorage() {
|
||||
costRules.resetCostData();
|
||||
fallbackPolicy.resetAllFallbacks();
|
||||
modelAvailability.resetAllAvailability();
|
||||
providerExpiration.resetExpirations();
|
||||
quotaCache.stopBackgroundRefresh();
|
||||
core.resetDbInstance();
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
Date.now = originalDateNow;
|
||||
Math.random = originalMathRandom;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
Date.now = originalDateNow;
|
||||
Math.random = originalMathRandom;
|
||||
quotaCache.stopBackgroundRefresh();
|
||||
costRules.resetCostData();
|
||||
fallbackPolicy.resetAllFallbacks();
|
||||
modelAvailability.resetAllAvailability();
|
||||
providerExpiration.resetExpirations();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("resolveComboModel covers empty combos, priority, round-robin, random, least-used and default fallback", () => {
|
||||
assert.throws(
|
||||
() => comboResolver.resolveComboModel({ name: "empty", models: [] }),
|
||||
/has no models configured/
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
comboResolver.resolveComboModel({
|
||||
id: "priority-combo",
|
||||
models: ["model-a", "model-b"],
|
||||
strategy: "priority",
|
||||
}),
|
||||
{ model: "model-a", index: 0 }
|
||||
);
|
||||
|
||||
const rrCombo = {
|
||||
id: "rr-combo",
|
||||
models: ["m1", "m2"],
|
||||
strategy: "round-robin",
|
||||
};
|
||||
assert.deepEqual(comboResolver.resolveComboModel(rrCombo), { model: "m1", index: 0 });
|
||||
assert.deepEqual(comboResolver.resolveComboModel(rrCombo), { model: "m2", index: 1 });
|
||||
assert.deepEqual(comboResolver.resolveComboModel(rrCombo), { model: "m1", index: 0 });
|
||||
|
||||
Math.random = () => 0.9;
|
||||
assert.deepEqual(
|
||||
comboResolver.resolveComboModel({
|
||||
name: "random-combo",
|
||||
strategy: "random",
|
||||
models: [
|
||||
{ model: "small", weight: 1 },
|
||||
{ model: "large", weight: 9 },
|
||||
],
|
||||
}),
|
||||
{ model: "large", index: 1 }
|
||||
);
|
||||
|
||||
Math.random = () => Number.NaN;
|
||||
assert.deepEqual(
|
||||
comboResolver.resolveComboModel({
|
||||
name: "random-fallback",
|
||||
strategy: "random",
|
||||
models: ["fallback-a", "fallback-b"],
|
||||
}),
|
||||
{ model: "fallback-a", index: 0 }
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
comboResolver.resolveComboModel(
|
||||
{
|
||||
name: "least-used",
|
||||
strategy: "least-used",
|
||||
models: ["used-a", "used-b", "used-c"],
|
||||
},
|
||||
{ modelUsageCounts: { "used-a": 5, "used-b": 1 } }
|
||||
),
|
||||
{ model: "used-c", index: 2 }
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
comboResolver.resolveComboModel({
|
||||
name: "unknown-strategy",
|
||||
strategy: "not-real",
|
||||
models: [
|
||||
{ model: "default-a", weight: 2 },
|
||||
{ model: "default-b", weight: 1 },
|
||||
],
|
||||
}),
|
||||
{ model: "default-a", index: 0 }
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
comboResolver.getComboFallbacks(
|
||||
{
|
||||
models: ["alpha", { model: "beta" }, "gamma"],
|
||||
},
|
||||
1
|
||||
),
|
||||
["gamma", "alpha"]
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveComboModel also covers implicit defaults and missing optional fields", () => {
|
||||
assert.throws(() => comboResolver.resolveComboModel({}), /has no models configured/);
|
||||
|
||||
assert.deepEqual(comboResolver.resolveComboModel({ models: ["implicit-a", "implicit-b"] }), {
|
||||
model: "implicit-a",
|
||||
index: 0,
|
||||
});
|
||||
|
||||
const anonymousRoundRobin = {
|
||||
strategy: "round-robin",
|
||||
models: ["rr-a", "rr-b"],
|
||||
};
|
||||
assert.deepEqual(comboResolver.resolveComboModel(anonymousRoundRobin), {
|
||||
model: "rr-a",
|
||||
index: 0,
|
||||
});
|
||||
assert.deepEqual(comboResolver.resolveComboModel(anonymousRoundRobin), {
|
||||
model: "rr-b",
|
||||
index: 1,
|
||||
});
|
||||
|
||||
Math.random = () => 0.9;
|
||||
assert.deepEqual(
|
||||
comboResolver.resolveComboModel({
|
||||
strategy: "random",
|
||||
models: [{ model: "weighted-default-a" }, { model: "weighted-default-b" }],
|
||||
}),
|
||||
{ model: "weighted-default-b", index: 1 }
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
comboResolver.resolveComboModel({
|
||||
strategy: "least-used",
|
||||
models: ["least-default-a", "least-default-b"],
|
||||
}),
|
||||
{ model: "least-default-a", index: 0 }
|
||||
);
|
||||
|
||||
assert.deepEqual(comboResolver.getComboFallbacks({}, 0), []);
|
||||
});
|
||||
|
||||
test("modelAvailability tracks missing, active and expired cooldowns", () => {
|
||||
let now = 1_000;
|
||||
Date.now = () => now;
|
||||
|
||||
assert.equal(modelAvailability.isModelAvailable("openai", "gpt-4o"), true);
|
||||
|
||||
modelAvailability.setModelUnavailable("openai", "gpt-4o", 100, undefined);
|
||||
modelAvailability.setModelUnavailable("anthropic", "claude-sonnet", 500, "capacity");
|
||||
|
||||
assert.equal(modelAvailability.isModelAvailable("openai", "gpt-4o"), false);
|
||||
assert.equal(modelAvailability.getUnavailableCount(), 2);
|
||||
|
||||
const report = modelAvailability.getAvailabilityReport();
|
||||
assert.equal(report.length, 2);
|
||||
assert.equal(report[0].reason, "unknown");
|
||||
assert.equal(report[1].reason, "capacity");
|
||||
|
||||
now += 150;
|
||||
assert.equal(modelAvailability.isModelAvailable("openai", "gpt-4o"), true);
|
||||
assert.equal(modelAvailability.clearModelUnavailability("openai", "gpt-4o"), false);
|
||||
assert.equal(modelAvailability.clearModelUnavailability("anthropic", "claude-sonnet"), true);
|
||||
assert.equal(modelAvailability.getUnavailableCount(), 0);
|
||||
});
|
||||
|
||||
test("providerExpiration derives status, sorting, summary and header-based expiration hints", () => {
|
||||
const expired = providerExpiration.setExpiration(
|
||||
"conn-expired",
|
||||
"claude",
|
||||
"Claude",
|
||||
isoFromNow(-60_000),
|
||||
"oauth_token"
|
||||
);
|
||||
const soon = providerExpiration.setExpiration(
|
||||
"conn-soon",
|
||||
"openai",
|
||||
"OpenAI",
|
||||
isoFromNow(2 * 24 * 60 * 60 * 1000),
|
||||
"subscription",
|
||||
{ alertDays: 7 }
|
||||
);
|
||||
const active = providerExpiration.setExpiration(
|
||||
"conn-active",
|
||||
"gemini",
|
||||
"Gemini",
|
||||
isoFromNow(20 * 24 * 60 * 60 * 1000),
|
||||
"api_credits",
|
||||
{ alertDays: 3, note: "healthy" }
|
||||
);
|
||||
providerExpiration.setExpiration("conn-unknown", "cursor", "Cursor", null, "subscription");
|
||||
providerExpiration.setExpiration("conn-invalid", "kimi", "Kimi", "not-a-date", "free_tier_reset");
|
||||
|
||||
assert.equal(expired.status, "expired");
|
||||
assert.equal(soon.status, "expiring_soon");
|
||||
assert.equal(active.status, "active");
|
||||
assert.equal(providerExpiration.getExpiration("missing-connection"), null);
|
||||
assert.equal(providerExpiration.getExpiration("conn-active")?.note, "healthy");
|
||||
|
||||
assert.deepEqual(
|
||||
providerExpiration.getAllExpirations().map((entry) => entry.connectionId),
|
||||
["conn-expired", "conn-soon", "conn-active", "conn-unknown", "conn-invalid"]
|
||||
);
|
||||
assert.deepEqual(
|
||||
providerExpiration.getExpiringSoon().map((entry) => entry.connectionId),
|
||||
["conn-expired", "conn-soon"]
|
||||
);
|
||||
|
||||
const summary = providerExpiration.getExpirationSummary();
|
||||
assert.equal(summary.total, 5);
|
||||
assert.equal(summary.active, 1);
|
||||
assert.equal(summary.expiringSoon, 1);
|
||||
assert.equal(summary.expired, 1);
|
||||
assert.equal(summary.unknown, 2);
|
||||
assert.equal(summary.nextExpiration?.connectionId, "conn-soon");
|
||||
|
||||
const detected401 = providerExpiration.detectExpirationFromResponse("claude", 401, {});
|
||||
assert.equal(detected401.expiryType, "oauth_token");
|
||||
|
||||
const detected402 = providerExpiration.detectExpirationFromResponse("openai", 402, {});
|
||||
assert.equal(detected402.expiryType, "subscription");
|
||||
|
||||
const retryAfterSeconds = providerExpiration.detectExpirationFromResponse("gemini", 429, {
|
||||
"retry-after": "120",
|
||||
});
|
||||
assert.equal(retryAfterSeconds.expiryType, "free_tier_reset");
|
||||
assert.ok(new Date(retryAfterSeconds.expiresAt).getTime() > Date.now());
|
||||
|
||||
const epochSeconds = Math.floor((Date.now() + 60_000) / 1000);
|
||||
const ratelimitReset = providerExpiration.detectExpirationFromResponse("openai", 429, {
|
||||
"x-ratelimit-reset": String(epochSeconds),
|
||||
});
|
||||
assert.equal(ratelimitReset.expiryType, "free_tier_reset");
|
||||
|
||||
assert.equal(
|
||||
providerExpiration.detectExpirationFromResponse("openai", 429, { "retry-after": "nope" }),
|
||||
null
|
||||
);
|
||||
assert.equal(providerExpiration.detectExpirationFromResponse("openai", 500, {}), null);
|
||||
|
||||
assert.equal(providerExpiration.removeExpiration("conn-active"), true);
|
||||
assert.equal(providerExpiration.removeExpiration("conn-active"), false);
|
||||
providerExpiration.resetExpirations();
|
||||
assert.deepEqual(providerExpiration.getAllExpirations(), []);
|
||||
});
|
||||
|
||||
test("quotaCache covers normalized windows, stale exhaustion, stats and refresh timer lifecycle", () => {
|
||||
let now = 10_000;
|
||||
Date.now = () => now;
|
||||
|
||||
const activeConnectionId = "quota-active-connection";
|
||||
quotaCache.setQuotaCache(activeConnectionId, "cursor", {
|
||||
daily: { remainingPercentage: 125, resetAt: isoFromNow(60_000) },
|
||||
"weekly (7d)": { total: 100, used: 90, resetAt: isoFromNow(120_000) },
|
||||
ignored: null,
|
||||
});
|
||||
|
||||
assert.equal(quotaCache.getQuotaCache("missing"), null);
|
||||
assert.equal(quotaCache.isAccountQuotaExhausted("missing"), false);
|
||||
assert.equal(quotaCache.getQuotaWindowStatus("missing", "daily"), null);
|
||||
|
||||
const weekly = quotaCache.getQuotaWindowStatus(activeConnectionId, "weekly", 80);
|
||||
assert.deepEqual(weekly, {
|
||||
remainingPercentage: 10,
|
||||
usedPercentage: 90,
|
||||
resetAt: isoFromNow(120_000),
|
||||
reachedThreshold: true,
|
||||
});
|
||||
|
||||
const daily = quotaCache.getQuotaWindowStatus(activeConnectionId, "daily", 99);
|
||||
assert.deepEqual(daily, {
|
||||
remainingPercentage: 100,
|
||||
usedPercentage: 0,
|
||||
resetAt: isoFromNow(60_000),
|
||||
reachedThreshold: false,
|
||||
});
|
||||
|
||||
const expiredWindowId = "quota-expired-window";
|
||||
quotaCache.setQuotaCache(expiredWindowId, "cursor", {
|
||||
session: { remainingPercentage: 5, resetAt: isoFromNow(-1_000) },
|
||||
});
|
||||
assert.deepEqual(quotaCache.getQuotaWindowStatus(expiredWindowId, "session", 90), {
|
||||
remainingPercentage: 5,
|
||||
usedPercentage: 95,
|
||||
resetAt: null,
|
||||
reachedThreshold: false,
|
||||
});
|
||||
assert.equal(quotaCache.getQuotaWindowStatus(expiredWindowId, "", 90), null);
|
||||
|
||||
const exhaustedWithResetId = "quota-exhausted-reset";
|
||||
quotaCache.setQuotaCache(exhaustedWithResetId, "cursor", {
|
||||
daily: { remainingPercentage: 0, resetAt: isoFromNow(60_000) },
|
||||
});
|
||||
assert.equal(quotaCache.isAccountQuotaExhausted(exhaustedWithResetId), true);
|
||||
|
||||
now += 61_000;
|
||||
assert.equal(quotaCache.isAccountQuotaExhausted(exhaustedWithResetId), false);
|
||||
|
||||
const exhausted429Id = "quota-exhausted-429";
|
||||
quotaCache.markAccountExhaustedFrom429(exhausted429Id, "cursor");
|
||||
assert.equal(quotaCache.isAccountQuotaExhausted(exhausted429Id), true);
|
||||
|
||||
now += 5 * 60 * 1000 + 1;
|
||||
assert.equal(quotaCache.isAccountQuotaExhausted(exhausted429Id), false);
|
||||
|
||||
const stats = quotaCache.getQuotaCacheStats();
|
||||
assert.ok(stats.total >= 3);
|
||||
assert.ok(stats.entries.some((entry) => entry.connectionId === "quota-ac..."));
|
||||
|
||||
quotaCache.startBackgroundRefresh();
|
||||
quotaCache.startBackgroundRefresh();
|
||||
quotaCache.stopBackgroundRefresh();
|
||||
quotaCache.stopBackgroundRefresh();
|
||||
});
|
||||
|
||||
test("quotaCache covers empty quotas, invalid dates and fallback percentage normalization", () => {
|
||||
let now = 100_000;
|
||||
Date.now = () => now;
|
||||
|
||||
quotaCache.setQuotaCache("quota-empty", "cursor", {});
|
||||
assert.equal(quotaCache.getQuotaCache("quota-empty")?.exhausted, false);
|
||||
assert.equal(quotaCache.isAccountQuotaExhausted("quota-empty"), false);
|
||||
|
||||
quotaCache.setQuotaCache("quota-zero-total", "cursor", {
|
||||
daily: { total: 0, used: 25 },
|
||||
"###": { remainingPercentage: 50 },
|
||||
});
|
||||
assert.deepEqual(quotaCache.getQuotaWindowStatus("quota-zero-total", "daily", 10), {
|
||||
remainingPercentage: 0,
|
||||
usedPercentage: 100,
|
||||
resetAt: null,
|
||||
reachedThreshold: true,
|
||||
});
|
||||
assert.equal(quotaCache.getQuotaWindowStatus("quota-zero-total", "unknown"), null);
|
||||
|
||||
quotaCache.setQuotaCache("quota-invalid-reset", "cursor", {
|
||||
daily: { remainingPercentage: Number.POSITIVE_INFINITY, resetAt: "not-a-date" },
|
||||
});
|
||||
assert.deepEqual(quotaCache.getQuotaWindowStatus("quota-invalid-reset", "daily", 10), {
|
||||
remainingPercentage: 0,
|
||||
usedPercentage: 100,
|
||||
resetAt: "not-a-date",
|
||||
reachedThreshold: true,
|
||||
});
|
||||
|
||||
quotaCache.setQuotaCache("quota-invalid-exhausted", "cursor", {
|
||||
daily: { remainingPercentage: 0, resetAt: "still-not-a-date" },
|
||||
});
|
||||
assert.equal(quotaCache.isAccountQuotaExhausted("quota-invalid-exhausted"), true);
|
||||
});
|
||||
|
||||
test("policyEngine evaluates lockout, budget, fallback chains and policy class actions", () => {
|
||||
const lockConfig = {
|
||||
maxAttempts: 1,
|
||||
lockoutDurationMs: 500,
|
||||
attemptWindowMs: 1_000,
|
||||
};
|
||||
let now = 50_000;
|
||||
Date.now = () => now;
|
||||
|
||||
lockoutPolicy.recordFailedAttempt("10.0.0.1", lockConfig);
|
||||
const locked = policyEngineModule.evaluateRequest({
|
||||
model: "claude-sonnet",
|
||||
clientIp: "10.0.0.1",
|
||||
});
|
||||
assert.equal(locked.allowed, false);
|
||||
assert.equal(locked.policyPhase, "lockout");
|
||||
|
||||
lockoutPolicy.recordSuccess("10.0.0.1");
|
||||
Date.now = originalDateNow;
|
||||
costRules.setBudget("key-budget", { dailyLimitUsd: 5, warningThreshold: 0.5 });
|
||||
costRules.recordCost("key-budget", 6);
|
||||
|
||||
const overBudget = policyEngineModule.evaluateRequest({
|
||||
model: "claude-sonnet",
|
||||
apiKeyId: "key-budget",
|
||||
});
|
||||
assert.equal(overBudget.allowed, false);
|
||||
assert.equal(overBudget.policyPhase, "budget");
|
||||
|
||||
fallbackPolicy.registerFallback("claude-sonnet", [
|
||||
{ provider: "vertex", priority: 2 },
|
||||
{ provider: "bedrock", priority: 3 },
|
||||
]);
|
||||
const passed = policyEngineModule.evaluateRequest({
|
||||
model: "claude-sonnet",
|
||||
apiKeyId: "missing-key",
|
||||
});
|
||||
assert.equal(passed.allowed, true);
|
||||
assert.deepEqual(passed.adjustments.fallbackChain, [
|
||||
{ provider: "vertex", priority: 2, enabled: true },
|
||||
{ provider: "bedrock", priority: 3, enabled: true },
|
||||
]);
|
||||
|
||||
const firstAllowed = policyEngineModule.evaluateFirstAllowed(["claude-sonnet", "gpt-4o"], {
|
||||
clientIp: "10.0.0.2",
|
||||
});
|
||||
assert.equal(firstAllowed.model, "claude-sonnet");
|
||||
assert.equal(firstAllowed.verdict.allowed, true);
|
||||
|
||||
const engine = new policyEngineModule.PolicyEngine();
|
||||
engine.loadPolicies([
|
||||
{
|
||||
id: "disabled",
|
||||
name: "Disabled Policy",
|
||||
type: "routing",
|
||||
enabled: false,
|
||||
priority: 1,
|
||||
actions: { prefer_provider: ["ignored"] },
|
||||
},
|
||||
{
|
||||
id: "routing",
|
||||
name: "Prefer Vertex",
|
||||
type: "routing",
|
||||
enabled: true,
|
||||
priority: 2,
|
||||
conditions: { model_pattern: "claude-*" },
|
||||
actions: { prefer_provider: ["vertex", "bedrock"] },
|
||||
},
|
||||
{
|
||||
id: "budget",
|
||||
name: "Token Cap",
|
||||
type: "budget",
|
||||
enabled: true,
|
||||
priority: 3,
|
||||
conditions: { model_pattern: "claude-*" },
|
||||
actions: { max_tokens: 4096 },
|
||||
},
|
||||
]);
|
||||
|
||||
const routed = engine.evaluate({ model: "claude-sonnet-4" });
|
||||
assert.deepEqual(routed.preferredProviders, ["vertex", "bedrock"]);
|
||||
assert.equal(routed.maxTokens, 4096);
|
||||
assert.deepEqual(routed.appliedPolicies, ["Prefer Vertex", "Token Cap"]);
|
||||
|
||||
engine.addPolicy({
|
||||
id: "access",
|
||||
name: "Block Claude Sonnet",
|
||||
type: "access",
|
||||
enabled: true,
|
||||
priority: 0,
|
||||
actions: { block_model: ["claude-sonnet-*"] },
|
||||
});
|
||||
const blocked = engine.evaluate({ model: "claude-sonnet-4" });
|
||||
assert.equal(blocked.allowed, false);
|
||||
assert.match(blocked.reason, /blocked by policy/);
|
||||
assert.deepEqual(blocked.appliedPolicies, ["Block Claude Sonnet"]);
|
||||
|
||||
engine.removePolicy("access");
|
||||
assert.equal(
|
||||
engine.getPolicies().some((policy) => policy.id === "access"),
|
||||
false
|
||||
);
|
||||
});
|
||||
161
tests/unit/domain-cost-rules.test.mjs
Normal file
161
tests/unit/domain-cost-rules.test.mjs
Normal file
@@ -0,0 +1,161 @@
|
||||
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-domain-cost-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const costRules = await import("../../src/domain/costRules.ts");
|
||||
const domainState = await import("../../src/lib/db/domainState.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
costRules.resetCostData();
|
||||
core.resetDbInstance();
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
costRules.resetCostData();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("setBudget normalizes defaults and getBudget returns the stored config", () => {
|
||||
costRules.setBudget("key-budget", { dailyLimitUsd: 12.5 });
|
||||
|
||||
assert.deepEqual(costRules.getBudget("key-budget"), {
|
||||
dailyLimitUsd: 12.5,
|
||||
monthlyLimitUsd: 0,
|
||||
warningThreshold: 0.8,
|
||||
});
|
||||
assert.equal(costRules.getBudget("missing-key"), null);
|
||||
});
|
||||
|
||||
test("checkBudget reports warning and blocks when projected spend exceeds the daily cap", () => {
|
||||
costRules.setBudget("key-warning", {
|
||||
dailyLimitUsd: 10,
|
||||
warningThreshold: 0.6,
|
||||
});
|
||||
costRules.recordCost("key-warning", 5);
|
||||
|
||||
const warning = costRules.checkBudget("key-warning", 1);
|
||||
const denied = costRules.checkBudget("key-warning", 6);
|
||||
|
||||
assert.deepEqual(warning, {
|
||||
allowed: true,
|
||||
dailyUsed: 5,
|
||||
dailyLimit: 10,
|
||||
warningReached: true,
|
||||
});
|
||||
assert.equal(denied.allowed, false);
|
||||
assert.equal(denied.warningReached, true);
|
||||
assert.match(denied.reason, /Daily budget exceeded/);
|
||||
});
|
||||
|
||||
test("getDailyTotal and getCostSummary split daily and monthly totals correctly", () => {
|
||||
costRules.setBudget("key-summary", {
|
||||
dailyLimitUsd: 50,
|
||||
monthlyLimitUsd: 100,
|
||||
warningThreshold: 0.75,
|
||||
});
|
||||
|
||||
const now = Date.now();
|
||||
const today = now - 1_000;
|
||||
const yesterday = now - 24 * 60 * 60 * 1000;
|
||||
const lastMonth = new Date();
|
||||
lastMonth.setMonth(lastMonth.getMonth() - 1);
|
||||
|
||||
domainState.saveCostEntry("key-summary", 2.5, today);
|
||||
domainState.saveCostEntry("key-summary", 1.5, yesterday);
|
||||
domainState.saveCostEntry("key-summary", 9.9, lastMonth.getTime());
|
||||
|
||||
assert.equal(costRules.getDailyTotal("key-summary"), 2.5);
|
||||
assert.deepEqual(costRules.getCostSummary("key-summary"), {
|
||||
dailyTotal: 2.5,
|
||||
monthlyTotal: 4,
|
||||
totalEntries: 2,
|
||||
budget: {
|
||||
dailyLimitUsd: 50,
|
||||
monthlyLimitUsd: 100,
|
||||
warningThreshold: 0.75,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("costRules covers DB-loaded budgets, malformed entries and storage failure fallbacks", () => {
|
||||
domainState.saveBudget("db-loaded", {
|
||||
dailyLimitUsd: 7,
|
||||
monthlyLimitUsd: 21,
|
||||
warningThreshold: 0.7,
|
||||
});
|
||||
assert.deepEqual(costRules.getBudget("db-loaded"), {
|
||||
dailyLimitUsd: 7,
|
||||
monthlyLimitUsd: 21,
|
||||
warningThreshold: 0.7,
|
||||
});
|
||||
|
||||
assert.deepEqual(costRules.checkBudget("missing-budget"), {
|
||||
allowed: true,
|
||||
dailyUsed: 0,
|
||||
dailyLimit: 0,
|
||||
warningReached: false,
|
||||
});
|
||||
|
||||
const db = core.getDbInstance();
|
||||
const now = Date.now();
|
||||
db.prepare("INSERT INTO domain_cost_history (api_key_id, cost, timestamp) VALUES (?, ?, ?)").run(
|
||||
"malformed-costs",
|
||||
"2.25",
|
||||
String(now)
|
||||
);
|
||||
db.prepare("INSERT INTO domain_cost_history (api_key_id, cost, timestamp) VALUES (?, ?, ?)").run(
|
||||
"malformed-costs",
|
||||
"not-a-number",
|
||||
now
|
||||
);
|
||||
db.prepare("INSERT INTO domain_cost_history (api_key_id, cost, timestamp) VALUES (?, ?, ?)").run(
|
||||
"malformed-costs",
|
||||
4,
|
||||
"not-a-timestamp"
|
||||
);
|
||||
|
||||
assert.equal(costRules.getDailyTotal("malformed-costs"), 2.25);
|
||||
assert.deepEqual(costRules.getCostSummary("malformed-costs"), {
|
||||
dailyTotal: 2.25,
|
||||
monthlyTotal: 2.25,
|
||||
totalEntries: 1,
|
||||
budget: null,
|
||||
});
|
||||
|
||||
db.exec("DROP TABLE domain_cost_history");
|
||||
assert.equal(costRules.getDailyTotal("malformed-costs"), 0);
|
||||
assert.deepEqual(costRules.getCostSummary("malformed-costs"), {
|
||||
dailyTotal: 0,
|
||||
monthlyTotal: 0,
|
||||
totalEntries: 0,
|
||||
budget: null,
|
||||
});
|
||||
});
|
||||
252
tests/unit/domain-degradation.test.mjs
Normal file
252
tests/unit/domain-degradation.test.mjs
Normal file
@@ -0,0 +1,252 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const degradation = await import("../../src/domain/degradation.ts");
|
||||
|
||||
test.beforeEach(() => {
|
||||
degradation.resetDegradationRegistry();
|
||||
});
|
||||
|
||||
test.after(() => {
|
||||
degradation.resetDegradationRegistry();
|
||||
});
|
||||
|
||||
test("withDegradation returns full capability when the primary path succeeds", async () => {
|
||||
const result = await degradation.withDegradation(
|
||||
"semantic-search",
|
||||
async () => "primary-result",
|
||||
async () => "fallback-result",
|
||||
"safe-default"
|
||||
);
|
||||
|
||||
assert.equal(result.result, "primary-result");
|
||||
assert.deepEqual(result.status, {
|
||||
level: "full",
|
||||
feature: "semantic-search",
|
||||
capability: "Full capability",
|
||||
reason: "",
|
||||
since: result.status.since,
|
||||
});
|
||||
assert.deepEqual(degradation.getFeatureStatus("semantic-search"), result.status);
|
||||
assert.equal(degradation.hasAnyDegradation(), false);
|
||||
assert.deepEqual(degradation.getDegradationSummary(), {
|
||||
full: 1,
|
||||
reduced: 0,
|
||||
minimal: 0,
|
||||
default: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("withDegradation reports reduced capability, calls onDegrade, and preserves since across repeated degradation", async () => {
|
||||
const seen = [];
|
||||
|
||||
const first = await degradation.withDegradation(
|
||||
"rate-limit-cache",
|
||||
async () => {
|
||||
throw new Error("redis unavailable");
|
||||
},
|
||||
async () => "memory-fallback",
|
||||
"safe-default",
|
||||
{
|
||||
reducedCapability: "In-memory fallback",
|
||||
onDegrade: (status) => seen.push(status),
|
||||
}
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
|
||||
const second = await degradation.withDegradation(
|
||||
"rate-limit-cache",
|
||||
async () => {
|
||||
throw new Error("redis unavailable");
|
||||
},
|
||||
async () => "memory-fallback-again",
|
||||
"safe-default",
|
||||
{
|
||||
reducedCapability: "In-memory fallback",
|
||||
onDegrade: (status) => seen.push(status),
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(first.result, "memory-fallback");
|
||||
assert.equal(first.status.level, "reduced");
|
||||
assert.equal(first.status.capability, "In-memory fallback");
|
||||
assert.equal(first.status.reason, "redis unavailable");
|
||||
assert.equal(second.status.level, "reduced");
|
||||
assert.equal(second.status.since, first.status.since);
|
||||
assert.equal(seen.length, 2);
|
||||
assert.equal(degradation.hasAnyDegradation(), true);
|
||||
});
|
||||
|
||||
test("withDegradation falls back to the safe default when both implementations fail and the report sorts worst-first", async () => {
|
||||
await degradation.withDegradation(
|
||||
"cache-layer",
|
||||
async () => {
|
||||
throw new Error("cache offline");
|
||||
},
|
||||
async () => "memory-fallback",
|
||||
"safe-default"
|
||||
);
|
||||
|
||||
const finalFallback = await degradation.withDegradation(
|
||||
"billing-export",
|
||||
async () => {
|
||||
throw new Error("primary offline");
|
||||
},
|
||||
async () => {
|
||||
throw new Error("fallback offline");
|
||||
},
|
||||
{ exported: false },
|
||||
{
|
||||
defaultCapability: "Disabled export",
|
||||
}
|
||||
);
|
||||
|
||||
assert.deepEqual(finalFallback.result, { exported: false });
|
||||
assert.equal(finalFallback.status.level, "default");
|
||||
assert.equal(finalFallback.status.capability, "Disabled export");
|
||||
assert.equal(finalFallback.status.reason, "primary offline → fallback offline");
|
||||
assert.deepEqual(
|
||||
degradation.getDegradationReport().map((entry) => ({
|
||||
feature: entry.feature,
|
||||
level: entry.level,
|
||||
})),
|
||||
[
|
||||
{ feature: "billing-export", level: "default" },
|
||||
{ feature: "cache-layer", level: "reduced" },
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test("withDegradationSync supports reduced and default modes and reset clears the registry", () => {
|
||||
const reduced = degradation.withDegradationSync(
|
||||
"sync-feature",
|
||||
() => {
|
||||
throw new Error("primary failed");
|
||||
},
|
||||
() => "fallback-result",
|
||||
"safe-default",
|
||||
{
|
||||
reducedCapability: "Fallback mode",
|
||||
}
|
||||
);
|
||||
|
||||
const fallback = degradation.withDegradationSync(
|
||||
"sync-default",
|
||||
() => {
|
||||
throw new Error("primary failed");
|
||||
},
|
||||
() => {
|
||||
throw new Error("fallback failed");
|
||||
},
|
||||
"safe-default"
|
||||
);
|
||||
|
||||
assert.equal(reduced.result, "fallback-result");
|
||||
assert.equal(reduced.status.level, "reduced");
|
||||
assert.equal(reduced.status.capability, "Fallback mode");
|
||||
assert.equal(fallback.result, "safe-default");
|
||||
assert.equal(fallback.status.level, "default");
|
||||
assert.match(fallback.status.reason, /primary failed/);
|
||||
|
||||
degradation.resetDegradationRegistry();
|
||||
assert.equal(degradation.getFeatureStatus("sync-feature"), null);
|
||||
assert.deepEqual(degradation.getDegradationReport(), []);
|
||||
});
|
||||
|
||||
test("degradation helpers cover custom capabilities and primitive error values", async () => {
|
||||
const seen = [];
|
||||
|
||||
const full = await degradation.withDegradation(
|
||||
"custom-full",
|
||||
async () => "primary",
|
||||
async () => "fallback",
|
||||
"safe-default",
|
||||
{
|
||||
fullCapability: "Primary path available",
|
||||
}
|
||||
);
|
||||
|
||||
const reduced = await degradation.withDegradation(
|
||||
"custom-reduced",
|
||||
async () => {
|
||||
throw "primary-string-error";
|
||||
},
|
||||
async () => "fallback",
|
||||
"safe-default",
|
||||
{
|
||||
reducedCapability: "Secondary path available",
|
||||
onDegrade: (status) => seen.push(status.feature),
|
||||
}
|
||||
);
|
||||
|
||||
const fallback = await degradation.withDegradation(
|
||||
"custom-default",
|
||||
async () => {
|
||||
throw "primary-primitive";
|
||||
},
|
||||
async () => {
|
||||
throw "fallback-primitive";
|
||||
},
|
||||
"safe-default",
|
||||
{
|
||||
defaultCapability: "Static safe mode",
|
||||
onDegrade: (status) => seen.push(status.feature),
|
||||
}
|
||||
);
|
||||
|
||||
const fullSync = degradation.withDegradationSync(
|
||||
"sync-custom-full",
|
||||
() => "sync-primary",
|
||||
() => "sync-fallback",
|
||||
"sync-safe",
|
||||
{
|
||||
fullCapability: "Sync primary path",
|
||||
}
|
||||
);
|
||||
|
||||
const reducedSync = degradation.withDegradationSync(
|
||||
"sync-custom-reduced",
|
||||
() => {
|
||||
throw "sync-primary-error";
|
||||
},
|
||||
() => "sync-fallback",
|
||||
"sync-safe",
|
||||
{
|
||||
reducedCapability: "Sync fallback path",
|
||||
onDegrade: (status) => seen.push(status.feature),
|
||||
}
|
||||
);
|
||||
|
||||
const defaultSync = degradation.withDegradationSync(
|
||||
"sync-custom-default",
|
||||
() => {
|
||||
throw "sync-primary-primitive";
|
||||
},
|
||||
() => {
|
||||
throw "sync-fallback-primitive";
|
||||
},
|
||||
"sync-safe",
|
||||
{
|
||||
defaultCapability: "Sync safe mode",
|
||||
onDegrade: (status) => seen.push(status.feature),
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(full.status.capability, "Primary path available");
|
||||
assert.equal(reduced.status.capability, "Secondary path available");
|
||||
assert.equal(reduced.status.reason, "primary-string-error");
|
||||
assert.equal(fallback.status.capability, "Static safe mode");
|
||||
assert.equal(fallback.status.reason, "primary-primitive → fallback-primitive");
|
||||
assert.equal(fullSync.status.capability, "Sync primary path");
|
||||
assert.equal(reducedSync.status.capability, "Sync fallback path");
|
||||
assert.equal(reducedSync.status.reason, "sync-primary-error");
|
||||
assert.equal(defaultSync.status.capability, "Sync safe mode");
|
||||
assert.equal(defaultSync.status.reason, "sync-primary-primitive → sync-fallback-primitive");
|
||||
assert.deepEqual(seen, [
|
||||
"custom-reduced",
|
||||
"custom-default",
|
||||
"sync-custom-reduced",
|
||||
"sync-custom-default",
|
||||
]);
|
||||
});
|
||||
87
tests/unit/domain-fallback-policy.test.mjs
Normal file
87
tests/unit/domain-fallback-policy.test.mjs
Normal file
@@ -0,0 +1,87 @@
|
||||
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-domain-fallback-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const fallbackPolicy = await import("../../src/domain/fallbackPolicy.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
fallbackPolicy.resetAllFallbacks();
|
||||
core.resetDbInstance();
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
test.beforeEach(async () => {
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
fallbackPolicy.resetAllFallbacks();
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("registerFallback sorts by priority and defaults missing flags to enabled", () => {
|
||||
fallbackPolicy.registerFallback("gpt-4o-mini", [
|
||||
{ provider: "azure", priority: 20 },
|
||||
{ provider: "openai" },
|
||||
{ provider: "github", priority: 10, enabled: false },
|
||||
]);
|
||||
|
||||
assert.deepEqual(fallbackPolicy.resolveFallbackChain("gpt-4o-mini"), [
|
||||
{ provider: "openai", priority: 0, enabled: true },
|
||||
{ provider: "azure", priority: 20, enabled: true },
|
||||
]);
|
||||
assert.equal(fallbackPolicy.hasFallback("gpt-4o-mini"), true);
|
||||
});
|
||||
|
||||
test("resolveFallbackChain and getNextFallback respect exclusions and disabled providers", () => {
|
||||
fallbackPolicy.registerFallback("claude-sonnet", [
|
||||
{ provider: "anthropic", priority: 1, enabled: false },
|
||||
{ provider: "vertex", priority: 2, enabled: true },
|
||||
{ provider: "bedrock", priority: 3, enabled: true },
|
||||
]);
|
||||
|
||||
assert.deepEqual(fallbackPolicy.resolveFallbackChain("claude-sonnet", ["vertex"]), [
|
||||
{ provider: "bedrock", priority: 3, enabled: true },
|
||||
]);
|
||||
assert.equal(fallbackPolicy.getNextFallback("claude-sonnet"), "vertex");
|
||||
assert.equal(fallbackPolicy.getNextFallback("claude-sonnet", ["vertex", "bedrock"]), null);
|
||||
});
|
||||
|
||||
test("removeFallback and resetAllFallbacks clear registered chains", () => {
|
||||
fallbackPolicy.registerFallback("model-a", [{ provider: "provider-a", priority: 1 }]);
|
||||
fallbackPolicy.registerFallback("model-b", [{ provider: "provider-b", priority: 1 }]);
|
||||
|
||||
assert.deepEqual(Object.keys(fallbackPolicy.getAllFallbackChains()).sort(), [
|
||||
"model-a",
|
||||
"model-b",
|
||||
]);
|
||||
assert.equal(fallbackPolicy.removeFallback("model-a"), true);
|
||||
assert.equal(fallbackPolicy.removeFallback("model-a"), false);
|
||||
assert.equal(fallbackPolicy.hasFallback("model-a"), false);
|
||||
|
||||
fallbackPolicy.resetAllFallbacks();
|
||||
assert.deepEqual(fallbackPolicy.getAllFallbackChains(), {});
|
||||
});
|
||||
154
tests/unit/domain-lockout-policy.test.mjs
Normal file
154
tests/unit/domain-lockout-policy.test.mjs
Normal file
@@ -0,0 +1,154 @@
|
||||
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-domain-lockout-"));
|
||||
process.env.DATA_DIR = TEST_DATA_DIR;
|
||||
|
||||
const core = await import("../../src/lib/db/core.ts");
|
||||
const lockoutPolicy = await import("../../src/domain/lockoutPolicy.ts");
|
||||
|
||||
async function resetStorage() {
|
||||
core.resetDbInstance();
|
||||
|
||||
for (let attempt = 0; attempt < 10; attempt++) {
|
||||
try {
|
||||
if (fs.existsSync(TEST_DATA_DIR)) {
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
}
|
||||
break;
|
||||
} catch (error) {
|
||||
if ((error?.code === "EBUSY" || error?.code === "EPERM") && attempt < 9) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 50 * (attempt + 1)));
|
||||
} else {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.mkdirSync(TEST_DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const originalDateNow = Date.now;
|
||||
|
||||
test.beforeEach(async () => {
|
||||
Date.now = originalDateNow;
|
||||
await resetStorage();
|
||||
});
|
||||
|
||||
test.after(async () => {
|
||||
Date.now = originalDateNow;
|
||||
core.resetDbInstance();
|
||||
fs.rmSync(TEST_DATA_DIR, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
test("checkLockout starts unlocked and locks after reaching the configured threshold", () => {
|
||||
let now = 1_000;
|
||||
Date.now = () => now;
|
||||
|
||||
const id = "lockout-threshold";
|
||||
const config = {
|
||||
maxAttempts: 3,
|
||||
lockoutDurationMs: 500,
|
||||
attemptWindowMs: 200,
|
||||
};
|
||||
|
||||
assert.deepEqual(lockoutPolicy.checkLockout(id, config), {
|
||||
locked: false,
|
||||
attempts: 0,
|
||||
});
|
||||
|
||||
assert.deepEqual(lockoutPolicy.recordFailedAttempt(id, config), { locked: false });
|
||||
now += 50;
|
||||
assert.deepEqual(lockoutPolicy.recordFailedAttempt(id, config), { locked: false });
|
||||
now += 50;
|
||||
assert.deepEqual(lockoutPolicy.recordFailedAttempt(id, config), {
|
||||
locked: true,
|
||||
remainingMs: 500,
|
||||
});
|
||||
|
||||
const locked = lockoutPolicy.checkLockout(id, config);
|
||||
assert.equal(locked.locked, true);
|
||||
assert.equal(locked.attempts, 3);
|
||||
assert.equal(locked.remainingMs, 500);
|
||||
});
|
||||
|
||||
test("expired lockouts are cleared and stale attempts outside the window are pruned", () => {
|
||||
let now = 10_000;
|
||||
Date.now = () => now;
|
||||
|
||||
const id = "lockout-expiry";
|
||||
const config = {
|
||||
maxAttempts: 2,
|
||||
lockoutDurationMs: 100,
|
||||
attemptWindowMs: 50,
|
||||
};
|
||||
|
||||
lockoutPolicy.recordFailedAttempt(id, config);
|
||||
now += 60;
|
||||
assert.deepEqual(lockoutPolicy.recordFailedAttempt(id, config), { locked: false });
|
||||
|
||||
now += 10;
|
||||
assert.deepEqual(lockoutPolicy.recordFailedAttempt(id, config), {
|
||||
locked: true,
|
||||
remainingMs: 100,
|
||||
});
|
||||
|
||||
now += 120;
|
||||
assert.deepEqual(lockoutPolicy.checkLockout(id, config), {
|
||||
locked: false,
|
||||
attempts: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("recordSuccess and forceUnlock remove tracked identifiers", () => {
|
||||
const config = {
|
||||
maxAttempts: 1,
|
||||
lockoutDurationMs: 1_000,
|
||||
attemptWindowMs: 1_000,
|
||||
};
|
||||
|
||||
lockoutPolicy.recordFailedAttempt("unlock-success", config);
|
||||
assert.equal(lockoutPolicy.checkLockout("unlock-success", config).locked, true);
|
||||
lockoutPolicy.recordSuccess("unlock-success");
|
||||
assert.deepEqual(lockoutPolicy.checkLockout("unlock-success", config), {
|
||||
locked: false,
|
||||
attempts: 0,
|
||||
});
|
||||
|
||||
lockoutPolicy.recordFailedAttempt("unlock-force", config);
|
||||
assert.equal(lockoutPolicy.checkLockout("unlock-force", config).locked, true);
|
||||
lockoutPolicy.forceUnlock("unlock-force");
|
||||
assert.deepEqual(lockoutPolicy.checkLockout("unlock-force", config), {
|
||||
locked: false,
|
||||
attempts: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test("getLockedIdentifiers returns active lockouts and filters expired ones", () => {
|
||||
let now = 20_000;
|
||||
Date.now = () => now;
|
||||
|
||||
const shortConfig = {
|
||||
maxAttempts: 1,
|
||||
lockoutDurationMs: 50,
|
||||
attemptWindowMs: 500,
|
||||
};
|
||||
const longConfig = {
|
||||
maxAttempts: 1,
|
||||
lockoutDurationMs: 500,
|
||||
attemptWindowMs: 500,
|
||||
};
|
||||
|
||||
lockoutPolicy.recordFailedAttempt("expired-id", shortConfig);
|
||||
lockoutPolicy.recordFailedAttempt("active-id", longConfig);
|
||||
now += 100;
|
||||
|
||||
const locked = lockoutPolicy.getLockedIdentifiers();
|
||||
|
||||
assert.ok(locked.some((entry) => entry.identifier === "active-id"));
|
||||
assert.ok(!locked.some((entry) => entry.identifier === "expired-id"));
|
||||
assert.ok(locked.find((entry) => entry.identifier === "active-id").remainingMs > 0);
|
||||
});
|
||||
@@ -14,6 +14,24 @@
|
||||
import { describe, it } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
function raceDelays(firstMs, secondMs) {
|
||||
return new Promise((resolve) => {
|
||||
let settled = false;
|
||||
const first = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(second);
|
||||
resolve(firstMs);
|
||||
}, firstMs);
|
||||
const second = setTimeout(() => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(first);
|
||||
resolve(secondMs);
|
||||
}, secondMs);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── URL Validation Tests ────────────────────────────────────
|
||||
|
||||
describe("Electron URL Validation", () => {
|
||||
@@ -207,20 +225,14 @@ describe("Restart Timeout Logic", () => {
|
||||
it("should resolve even if process doesn't exit", async () => {
|
||||
// Simulate the timeout race
|
||||
const start = Date.now();
|
||||
await Promise.race([
|
||||
new Promise((r) => setTimeout(r, 100000)), // simulates hung process
|
||||
new Promise((r) => setTimeout(r, 50)), // timeout
|
||||
]);
|
||||
await raceDelays(100000, 50);
|
||||
const elapsed = Date.now() - start;
|
||||
assert.ok(elapsed < 200, "Should resolve in ~50ms via timeout");
|
||||
});
|
||||
|
||||
it("should resolve immediately if process exits first", async () => {
|
||||
const start = Date.now();
|
||||
await Promise.race([
|
||||
new Promise((r) => setTimeout(r, 10)), // simulates fast exit
|
||||
new Promise((r) => setTimeout(r, 5000)), // timeout
|
||||
]);
|
||||
await raceDelays(10, 5000);
|
||||
const elapsed = Date.now() - start;
|
||||
assert.ok(elapsed < 200, "Should resolve in ~10ms via exit");
|
||||
});
|
||||
|
||||
174
tests/unit/embeddings-handler.test.mjs
Normal file
174
tests/unit/embeddings-handler.test.mjs
Normal file
@@ -0,0 +1,174 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
process.env.DATA_DIR = mkdtempSync(join(tmpdir(), "omniroute-embeddings-"));
|
||||
|
||||
const { handleEmbedding } = await import("../../open-sse/handlers/embeddings.ts");
|
||||
|
||||
test("handleEmbedding routes prefixed models and forwards optional fields", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls = [];
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
calls.push({
|
||||
url: String(url),
|
||||
headers: options.headers,
|
||||
body: JSON.parse(String(options.body || "{}")),
|
||||
});
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [{ object: "embedding", embedding: [0.1, 0.2], index: 0 }],
|
||||
usage: { prompt_tokens: 3, total_tokens: 3 },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await handleEmbedding({
|
||||
body: {
|
||||
model: "openai/text-embedding-3-large",
|
||||
input: "hello world",
|
||||
dimensions: 512,
|
||||
encoding_format: "float",
|
||||
user: "user-123",
|
||||
},
|
||||
credentials: { apiKey: "openai-key" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(calls[0].url, "https://api.openai.com/v1/embeddings");
|
||||
assert.equal(calls[0].headers.Authorization, "Bearer openai-key");
|
||||
assert.deepEqual(calls[0].body, {
|
||||
model: "text-embedding-3-large",
|
||||
input: "hello world",
|
||||
dimensions: 512,
|
||||
encoding_format: "float",
|
||||
user: "user-123",
|
||||
});
|
||||
assert.equal(result.data.model, "openai/text-embedding-3-large");
|
||||
assert.deepEqual(result.data.usage, { prompt_tokens: 3, total_tokens: 3 });
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleEmbedding supports resolved local providers without auth and preserves array input", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured;
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
captured = {
|
||||
url: String(url),
|
||||
headers: options.headers,
|
||||
body: JSON.parse(String(options.body || "{}")),
|
||||
};
|
||||
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
data: [
|
||||
{ object: "embedding", embedding: [1, 2], index: 0 },
|
||||
{ object: "embedding", embedding: [3, 4], index: 1 },
|
||||
],
|
||||
usage: { total_tokens: 10 },
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await handleEmbedding({
|
||||
body: {
|
||||
model: "localembed/my-model",
|
||||
input: ["alpha", "beta"],
|
||||
},
|
||||
credentials: null,
|
||||
resolvedProvider: {
|
||||
id: "localembed",
|
||||
baseUrl: "http://localhost:11434/embeddings",
|
||||
authType: "none",
|
||||
authHeader: "none",
|
||||
models: [],
|
||||
},
|
||||
resolvedModel: "my-model",
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, true);
|
||||
assert.equal(captured.url, "http://localhost:11434/embeddings");
|
||||
assert.equal(captured.headers.Authorization, undefined);
|
||||
assert.deepEqual(captured.body, {
|
||||
model: "my-model",
|
||||
input: ["alpha", "beta"],
|
||||
});
|
||||
assert.equal(result.data.usage.total_tokens, 10);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("handleEmbedding rejects invalid model strings without provider prefix", async () => {
|
||||
const result = await handleEmbedding({
|
||||
body: { model: "not-a-known-embedding-model", input: "hello" },
|
||||
credentials: { apiKey: "x" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 400);
|
||||
assert.match(result.error, /Invalid embedding model/);
|
||||
});
|
||||
|
||||
test("handleEmbedding rejects unknown providers", async () => {
|
||||
const result = await handleEmbedding({
|
||||
body: { model: "mystery/model-1", input: "hello" },
|
||||
credentials: { apiKey: "x" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 400);
|
||||
assert.match(result.error, /Unknown embedding provider: mystery/);
|
||||
});
|
||||
|
||||
test("handleEmbedding requires credentials for authenticated providers", async () => {
|
||||
const result = await handleEmbedding({
|
||||
body: { model: "openai/text-embedding-3-small", input: "hello" },
|
||||
credentials: null,
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 401);
|
||||
assert.match(result.error, /No valid authentication token/);
|
||||
});
|
||||
|
||||
test("handleEmbedding surfaces upstream failures", async () => {
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async () =>
|
||||
new Response("provider unavailable", {
|
||||
status: 503,
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await handleEmbedding({
|
||||
body: { model: "mistral/mistral-embed", input: "hello" },
|
||||
credentials: { apiKey: "mistral-key" },
|
||||
log: null,
|
||||
});
|
||||
|
||||
assert.equal(result.success, false);
|
||||
assert.equal(result.status, 503);
|
||||
assert.equal(result.error, "provider unavailable");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
79
tests/unit/emergency-fallback-service.test.mjs
Normal file
79
tests/unit/emergency-fallback-service.test.mjs
Normal file
@@ -0,0 +1,79 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const { EMERGENCY_FALLBACK_CONFIG, shouldUseFallback, isFallbackDecision } =
|
||||
await import("../../open-sse/services/emergencyFallback.ts");
|
||||
|
||||
test("shouldUseFallback returns disabled when the feature flag is off", () => {
|
||||
const result = shouldUseFallback(402, "payment required", false, {
|
||||
...EMERGENCY_FALLBACK_CONFIG,
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
shouldFallback: false,
|
||||
reason: "emergency fallback disabled",
|
||||
});
|
||||
assert.equal(isFallbackDecision(result), false);
|
||||
});
|
||||
|
||||
test("shouldUseFallback skips tool requests when configured", () => {
|
||||
const result = shouldUseFallback(402, "payment required", true);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
shouldFallback: false,
|
||||
reason: "skipped: request has tools",
|
||||
});
|
||||
});
|
||||
|
||||
test("shouldUseFallback triggers on HTTP 402", () => {
|
||||
const result = shouldUseFallback(402, "", false);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.equal(result.provider, EMERGENCY_FALLBACK_CONFIG.provider);
|
||||
assert.equal(result.model, EMERGENCY_FALLBACK_CONFIG.model);
|
||||
assert.equal(result.maxOutputTokens, EMERGENCY_FALLBACK_CONFIG.maxOutputTokens);
|
||||
assert.equal(isFallbackDecision(result), true);
|
||||
});
|
||||
|
||||
test("shouldUseFallback matches budget keywords case-insensitively", () => {
|
||||
const result = shouldUseFallback(500, "Billing hard stop: OUT OF CREDITS", false);
|
||||
|
||||
assert.equal(result.shouldFallback, true);
|
||||
assert.match(result.reason, /budget error detected/i);
|
||||
assert.match(result.reason, /(billing|out of credits)/i);
|
||||
});
|
||||
|
||||
test("shouldUseFallback ignores keywords when keyword matching is disabled", () => {
|
||||
const result = shouldUseFallback(500, "quota_exceeded", false, {
|
||||
...EMERGENCY_FALLBACK_CONFIG,
|
||||
triggerOnBudgetKeywords: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
shouldFallback: false,
|
||||
reason: "no budget error detected",
|
||||
});
|
||||
});
|
||||
|
||||
test("shouldUseFallback ignores HTTP 402 when status-trigger fallback is disabled", () => {
|
||||
const result = shouldUseFallback(402, "", false, {
|
||||
...EMERGENCY_FALLBACK_CONFIG,
|
||||
triggerOn402: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
shouldFallback: false,
|
||||
reason: "no budget error detected",
|
||||
});
|
||||
});
|
||||
|
||||
test("shouldUseFallback returns no fallback for unrelated errors", () => {
|
||||
const result = shouldUseFallback(500, "temporary upstream timeout", false);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
shouldFallback: false,
|
||||
reason: "no budget error detected",
|
||||
});
|
||||
assert.equal(isFallbackDecision(result), false);
|
||||
});
|
||||
155
tests/unit/executor-antigravity.test.mjs
Normal file
155
tests/unit/executor-antigravity.test.mjs
Normal file
@@ -0,0 +1,155 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { AntigravityExecutor } from "../../open-sse/executors/antigravity.ts";
|
||||
|
||||
test("AntigravityExecutor.buildUrl always targets the streaming endpoint", () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
assert.match(
|
||||
executor.buildUrl("gemini-2.5-flash", true),
|
||||
/\/v1internal:streamGenerateContent\?alt=sse$/
|
||||
);
|
||||
assert.equal(
|
||||
executor.buildUrl("gemini-2.5-flash", false),
|
||||
executor.buildUrl("gemini-2.5-flash", true)
|
||||
);
|
||||
});
|
||||
|
||||
test("AntigravityExecutor.buildHeaders includes auth and SSE accept", () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const headers = executor.buildHeaders({ accessToken: "ag-token" }, false);
|
||||
|
||||
assert.equal(headers.Authorization, "Bearer ag-token");
|
||||
assert.equal(headers.Accept, "text/event-stream");
|
||||
assert.equal(headers["X-OmniRoute-Source"], "omniroute");
|
||||
});
|
||||
|
||||
test("AntigravityExecutor.transformRequest normalizes model, project and contents", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const body = {
|
||||
request: {
|
||||
contents: [
|
||||
{
|
||||
role: "model",
|
||||
parts: [
|
||||
{ thought: true, text: "skip me" },
|
||||
{ thoughtSignature: "sig-only" },
|
||||
{ text: "keep me" },
|
||||
],
|
||||
},
|
||||
{
|
||||
role: "model",
|
||||
parts: [{ functionResponse: { name: "read_file", response: {} } }],
|
||||
},
|
||||
],
|
||||
tools: [{ functionDeclarations: [{ name: "read_file" }] }],
|
||||
},
|
||||
};
|
||||
|
||||
const result = await executor.transformRequest("antigravity/gemini-3.1-pro", body, true, {
|
||||
projectId: "project-1",
|
||||
});
|
||||
|
||||
assert.equal(result.project, "project-1");
|
||||
assert.equal(result.model, "gemini-3.1-pro-low");
|
||||
assert.equal(result.userAgent, "antigravity");
|
||||
assert.ok(result.request.sessionId);
|
||||
assert.deepEqual(result.request.toolConfig, {
|
||||
functionCallingConfig: { mode: "VALIDATED" },
|
||||
});
|
||||
assert.deepEqual(result.request.contents[0].parts, [{ text: "keep me" }]);
|
||||
assert.equal(result.request.contents[1].role, "user");
|
||||
});
|
||||
|
||||
test("AntigravityExecutor.transformRequest returns a structured error response when projectId is missing", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const result = await executor.transformRequest(
|
||||
"gemini-2.5-flash",
|
||||
{ request: { contents: [] } },
|
||||
true,
|
||||
{}
|
||||
);
|
||||
const payload = await result.json();
|
||||
|
||||
assert.equal(result.status, 422);
|
||||
assert.equal(payload.error.code, "missing_project_id");
|
||||
assert.match(payload.error.message, /Missing Google projectId/);
|
||||
});
|
||||
|
||||
test("AntigravityExecutor parses retry timing from headers and error strings", () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const headers = new Headers({
|
||||
"retry-after": "120",
|
||||
"x-ratelimit-reset-after": "30",
|
||||
});
|
||||
|
||||
assert.equal(executor.parseRetryHeaders(headers), 120_000);
|
||||
assert.equal(
|
||||
executor.parseRetryFromErrorMessage("Your quota will reset after 2h7m23s"),
|
||||
7_643_000
|
||||
);
|
||||
});
|
||||
|
||||
test("AntigravityExecutor.collectStreamToResponse turns SSE Gemini chunks into a chat completion", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const response = new Response(
|
||||
[
|
||||
'data: {"response":{"candidates":[{"content":{"parts":[{"text":"Hello "}]},"finishReason":"STOP"}]}}\n\n',
|
||||
'data: {"response":{"candidates":[{"content":{"parts":[{"text":"world"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":3,"totalTokenCount":8}}}\n\n',
|
||||
].join(""),
|
||||
{
|
||||
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();
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.equal(payload.object, "chat.completion");
|
||||
assert.equal(payload.choices[0].message.content, "Hello world");
|
||||
assert.equal(payload.choices[0].finish_reason, "stop");
|
||||
assert.deepEqual(payload.usage, {
|
||||
prompt_tokens: 5,
|
||||
completion_tokens: 3,
|
||||
total_tokens: 8,
|
||||
});
|
||||
});
|
||||
|
||||
test("AntigravityExecutor.refreshCredentials refreshes Google OAuth tokens", async () => {
|
||||
const executor = new AntigravityExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
assert.match(String(url), /oauth2\.googleapis\.com\/token$/);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "new-token",
|
||||
refresh_token: "new-refresh",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.refreshCredentials(
|
||||
{ refreshToken: "refresh", projectId: "project-1" },
|
||||
null
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
accessToken: "new-token",
|
||||
refreshToken: "new-refresh",
|
||||
expiresIn: 3600,
|
||||
projectId: "project-1",
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
132
tests/unit/executor-cloudflare-ai.test.mjs
Normal file
132
tests/unit/executor-cloudflare-ai.test.mjs
Normal file
@@ -0,0 +1,132 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { CloudflareAIExecutor } from "../../open-sse/executors/cloudflare-ai.ts";
|
||||
|
||||
test("CloudflareAIExecutor.buildUrl prefers providerSpecificData.accountId", () => {
|
||||
const executor = new CloudflareAIExecutor();
|
||||
const url = executor.buildUrl("@cf/meta/llama-3.3-70b-instruct", true, 0, {
|
||||
accountId: "top-level-id",
|
||||
providerSpecificData: { accountId: "provider-id" },
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
url,
|
||||
"https://api.cloudflare.com/client/v4/accounts/provider-id/ai/v1/chat/completions"
|
||||
);
|
||||
});
|
||||
|
||||
test("CloudflareAIExecutor.buildUrl falls back to top-level credentials and environment", () => {
|
||||
const executor = new CloudflareAIExecutor();
|
||||
const originalAccountId = process.env.CLOUDFLARE_ACCOUNT_ID;
|
||||
process.env.CLOUDFLARE_ACCOUNT_ID = "env-account-id";
|
||||
|
||||
try {
|
||||
const fromTopLevel = executor.buildUrl("@cf/meta/llama-3.3-70b-instruct", false, 0, {
|
||||
accountId: "top-level-id",
|
||||
});
|
||||
const fromEnv = executor.buildUrl("@cf/meta/llama-3.3-70b-instruct", false, 0, {});
|
||||
|
||||
assert.equal(
|
||||
fromTopLevel,
|
||||
"https://api.cloudflare.com/client/v4/accounts/top-level-id/ai/v1/chat/completions"
|
||||
);
|
||||
assert.equal(
|
||||
fromEnv,
|
||||
"https://api.cloudflare.com/client/v4/accounts/env-account-id/ai/v1/chat/completions"
|
||||
);
|
||||
} finally {
|
||||
if (originalAccountId === undefined) delete process.env.CLOUDFLARE_ACCOUNT_ID;
|
||||
else process.env.CLOUDFLARE_ACCOUNT_ID = originalAccountId;
|
||||
}
|
||||
});
|
||||
|
||||
test("CloudflareAIExecutor.buildUrl throws when account ID is missing", () => {
|
||||
const executor = new CloudflareAIExecutor();
|
||||
const originalAccountId = process.env.CLOUDFLARE_ACCOUNT_ID;
|
||||
delete process.env.CLOUDFLARE_ACCOUNT_ID;
|
||||
|
||||
try {
|
||||
assert.throws(
|
||||
() => executor.buildUrl("@cf/meta/llama-3.3-70b-instruct", true, 0, {}),
|
||||
/Account ID/
|
||||
);
|
||||
} finally {
|
||||
if (originalAccountId === undefined) delete process.env.CLOUDFLARE_ACCOUNT_ID;
|
||||
else process.env.CLOUDFLARE_ACCOUNT_ID = originalAccountId;
|
||||
}
|
||||
});
|
||||
|
||||
test("CloudflareAIExecutor.buildHeaders uses API key or access token and stream accept", () => {
|
||||
const executor = new CloudflareAIExecutor();
|
||||
const apiKeyHeaders = executor.buildHeaders({ apiKey: "cf-api-token" }, true);
|
||||
const accessTokenHeaders = executor.buildHeaders({ accessToken: "cf-access-token" }, false);
|
||||
|
||||
assert.deepEqual(apiKeyHeaders, {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer cf-api-token",
|
||||
Accept: "text/event-stream",
|
||||
});
|
||||
assert.deepEqual(accessTokenHeaders, {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer cf-access-token",
|
||||
});
|
||||
});
|
||||
|
||||
test("CloudflareAIExecutor.transformRequest is a passthrough for full model paths", () => {
|
||||
const executor = new CloudflareAIExecutor();
|
||||
const body = {
|
||||
model: "@cf/meta/llama-3.3-70b-instruct",
|
||||
messages: [{ role: "user", content: "hi" }],
|
||||
};
|
||||
|
||||
assert.equal(executor.transformRequest("@cf/meta/llama-3.3-70b-instruct", body, true, {}), body);
|
||||
});
|
||||
|
||||
test("CloudflareAIExecutor.execute uses inherited BaseExecutor flow successfully", async () => {
|
||||
const executor = new CloudflareAIExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured;
|
||||
|
||||
globalThis.fetch = async (url, options) => {
|
||||
captured = { url: String(url), options };
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
id: "chatcmpl-cf",
|
||||
object: "chat.completion",
|
||||
choices: [{ index: 0, message: { role: "assistant", content: "ok" } }],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const body = {
|
||||
model: "@cf/meta/llama-3.3-70b-instruct",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
};
|
||||
const result = await executor.execute({
|
||||
model: "@cf/meta/llama-3.3-70b-instruct",
|
||||
body,
|
||||
stream: false,
|
||||
credentials: {
|
||||
apiKey: "cf-api-token",
|
||||
providerSpecificData: { accountId: "account-123" },
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.equal(
|
||||
result.url,
|
||||
"https://api.cloudflare.com/client/v4/accounts/account-123/ai/v1/chat/completions"
|
||||
);
|
||||
assert.equal(result.transformedBody, body);
|
||||
assert.equal(captured.options.headers.Authorization, "Bearer cf-api-token");
|
||||
assert.equal(captured.options.body, JSON.stringify(body));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
153
tests/unit/executor-codex.test.mjs
Normal file
153
tests/unit/executor-codex.test.mjs
Normal file
@@ -0,0 +1,153 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
CodexExecutor,
|
||||
getCodexModelScope,
|
||||
getCodexRateLimitKey,
|
||||
getCodexResetTime,
|
||||
parseCodexQuotaHeaders,
|
||||
setDefaultFastServiceTierEnabled,
|
||||
} from "../../open-sse/executors/codex.ts";
|
||||
|
||||
test("Codex helper functions isolate rate-limit scopes and parse quota headers", () => {
|
||||
const quota = parseCodexQuotaHeaders(
|
||||
new Headers({
|
||||
"x-codex-5h-usage": "100",
|
||||
"x-codex-5h-limit": "500",
|
||||
"x-codex-5h-reset-at": new Date(Date.now() + 60_000).toISOString(),
|
||||
"x-codex-7d-usage": "1000",
|
||||
"x-codex-7d-limit": "5000",
|
||||
"x-codex-7d-reset-at": new Date(Date.now() + 120_000).toISOString(),
|
||||
})
|
||||
);
|
||||
|
||||
assert.equal(getCodexModelScope("codex-spark-mini"), "spark");
|
||||
assert.equal(getCodexModelScope("gpt-5.3-codex"), "codex");
|
||||
assert.equal(getCodexRateLimitKey("acct-1", "codex-spark-mini"), "acct-1:spark");
|
||||
assert.equal(quota.usage5h, 100);
|
||||
assert.equal(quota.limit7d, 5000);
|
||||
assert.ok(getCodexResetTime(quota) >= new Date(quota.resetAt7d).getTime());
|
||||
});
|
||||
|
||||
test("CodexExecutor.buildUrl honors /responses subpaths and compact mode", () => {
|
||||
const executor = new CodexExecutor();
|
||||
|
||||
assert.equal(
|
||||
executor.buildUrl("gpt-5.3-codex", true, 0, {}),
|
||||
"https://chatgpt.com/backend-api/codex/responses"
|
||||
);
|
||||
assert.equal(
|
||||
executor.buildUrl("gpt-5.3-codex", true, 0, { requestEndpointPath: "/responses" }),
|
||||
"https://chatgpt.com/backend-api/codex/responses"
|
||||
);
|
||||
assert.equal(
|
||||
executor.buildUrl("gpt-5.3-codex", true, 0, { requestEndpointPath: "/responses/compact" }),
|
||||
"https://chatgpt.com/backend-api/codex/responses/compact"
|
||||
);
|
||||
});
|
||||
|
||||
test("CodexExecutor.buildHeaders binds workspace ids and disables SSE accept for compact responses", () => {
|
||||
const executor = new CodexExecutor();
|
||||
const standardHeaders = executor.buildHeaders(
|
||||
{
|
||||
accessToken: "codex-token",
|
||||
providerSpecificData: { workspaceId: "workspace-1" },
|
||||
},
|
||||
true
|
||||
);
|
||||
const compactHeaders = executor.buildHeaders(
|
||||
{
|
||||
accessToken: "codex-token",
|
||||
requestEndpointPath: "/responses/compact",
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(standardHeaders.Authorization, "Bearer codex-token");
|
||||
assert.equal(standardHeaders.Accept, "text/event-stream");
|
||||
assert.equal(standardHeaders["chatgpt-account-id"], "workspace-1");
|
||||
assert.equal(compactHeaders.Accept, undefined);
|
||||
});
|
||||
|
||||
test("CodexExecutor.transformRequest injects default instructions, clamps reasoning and strips unsupported fields", () => {
|
||||
const executor = new CodexExecutor();
|
||||
const body = {
|
||||
model: "gpt-5-mini",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
prompt: "legacy",
|
||||
stream_options: { include_usage: true },
|
||||
instructions: "",
|
||||
reasoning_effort: "xhigh",
|
||||
service_tier: "fast",
|
||||
temperature: 0.4,
|
||||
user: "cursor",
|
||||
};
|
||||
|
||||
const result = executor.transformRequest("gpt-5-mini-xhigh", body, false, {
|
||||
requestEndpointPath: "/responses",
|
||||
});
|
||||
|
||||
assert.equal(result.stream, true);
|
||||
assert.equal(result.store, false);
|
||||
assert.equal(result.instructions.length > 0, true);
|
||||
assert.equal(result.reasoning.effort, "high");
|
||||
assert.equal(result.service_tier, "priority");
|
||||
assert.equal(result.messages, undefined);
|
||||
assert.equal(result.prompt, undefined);
|
||||
assert.equal(result.temperature, undefined);
|
||||
assert.equal(result.user, undefined);
|
||||
assert.equal(result.stream_options, undefined);
|
||||
});
|
||||
|
||||
test("CodexExecutor.transformRequest preserves compact requests and native passthrough semantics", () => {
|
||||
const executor = new CodexExecutor();
|
||||
setDefaultFastServiceTierEnabled(true);
|
||||
|
||||
try {
|
||||
const body = {
|
||||
_nativeCodexPassthrough: true,
|
||||
instructions: "keep this",
|
||||
stream: false,
|
||||
};
|
||||
const result = executor.transformRequest("gpt-5.3-codex", body, false, {
|
||||
requestEndpointPath: "/responses/compact",
|
||||
});
|
||||
|
||||
assert.equal(result._nativeCodexPassthrough, undefined);
|
||||
assert.equal(result.stream, undefined);
|
||||
assert.equal(result.service_tier, "priority");
|
||||
assert.equal(result.store, false);
|
||||
assert.equal(result.instructions, "keep this");
|
||||
} finally {
|
||||
setDefaultFastServiceTierEnabled(false);
|
||||
}
|
||||
});
|
||||
|
||||
test("CodexExecutor.refreshCredentials refreshes OAuth tokens and returns null without a refresh token", async () => {
|
||||
const executor = new CodexExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
assert.match(String(url), /auth\.openai\.com\/oauth\/token$/);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "new-token",
|
||||
refresh_token: "new-refresh",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
assert.equal(await executor.refreshCredentials({}, null), null);
|
||||
const refreshed = await executor.refreshCredentials({ refreshToken: "refresh-me" }, null);
|
||||
assert.deepEqual(refreshed, {
|
||||
accessToken: "new-token",
|
||||
refreshToken: "new-refresh",
|
||||
expiresIn: 3600,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
320
tests/unit/executor-cursor-extended.test.mjs
Normal file
320
tests/unit/executor-cursor-extended.test.mjs
Normal file
@@ -0,0 +1,320 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { CursorExecutor } from "../../open-sse/executors/cursor.ts";
|
||||
import {
|
||||
decodeMessage,
|
||||
encodeField,
|
||||
parseConnectRPCFrame,
|
||||
wrapConnectRPCFrame,
|
||||
} from "../../open-sse/utils/cursorProtobuf.ts";
|
||||
import {
|
||||
generateCursorChecksum,
|
||||
generateHashed64Hex,
|
||||
generateSessionId,
|
||||
} from "../../open-sse/utils/cursorChecksum.ts";
|
||||
|
||||
const LEN = 2;
|
||||
const VARINT = 0;
|
||||
const TOP_LEVEL_TOOL_CALL = 1;
|
||||
const TOP_LEVEL_RESPONSE = 2;
|
||||
const RESPONSE_TEXT = 1;
|
||||
const TOOL_ID = 3;
|
||||
const TOOL_NAME = 9;
|
||||
const TOOL_RAW_ARGS = 10;
|
||||
const TOOL_IS_LAST = 11;
|
||||
|
||||
function concatArrays(...arrays) {
|
||||
const total = arrays.reduce((sum, array) => sum + array.length, 0);
|
||||
const result = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
|
||||
for (const array of arrays) {
|
||||
result.set(array, offset);
|
||||
offset += array.length;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildTextFrame(text) {
|
||||
return Buffer.from(
|
||||
wrapConnectRPCFrame(
|
||||
encodeField(TOP_LEVEL_RESPONSE, LEN, encodeField(RESPONSE_TEXT, LEN, text)),
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function buildToolCallFrame({ id, name, args, isLast }) {
|
||||
return Buffer.from(
|
||||
wrapConnectRPCFrame(
|
||||
encodeField(
|
||||
TOP_LEVEL_TOOL_CALL,
|
||||
LEN,
|
||||
concatArrays(
|
||||
encodeField(TOOL_ID, LEN, id),
|
||||
encodeField(TOOL_NAME, LEN, name),
|
||||
encodeField(TOOL_RAW_ARGS, LEN, args),
|
||||
encodeField(TOOL_IS_LAST, VARINT, isLast ? 1 : 0)
|
||||
)
|
||||
),
|
||||
false
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function buildJsonErrorFrame(error) {
|
||||
return Buffer.from(wrapConnectRPCFrame(new TextEncoder().encode(JSON.stringify(error)), false));
|
||||
}
|
||||
|
||||
test("CursorExecutor.buildUrl uses the configured Cursor endpoint", () => {
|
||||
const executor = new CursorExecutor();
|
||||
assert.equal(
|
||||
executor.buildUrl(),
|
||||
"https://api2.cursor.sh/aiserver.v1.ChatService/StreamUnifiedChatWithTools"
|
||||
);
|
||||
});
|
||||
|
||||
test("CursorExecutor.buildHeaders strips token prefixes and derives checksum/session headers", () => {
|
||||
const executor = new CursorExecutor();
|
||||
const originalDateNow = Date.now;
|
||||
Date.now = () => 1_700_000_000_000;
|
||||
|
||||
try {
|
||||
const headers = executor.buildHeaders({
|
||||
accessToken: "prefix::real-token",
|
||||
providerSpecificData: { machineId: "machine-1", ghostMode: false },
|
||||
});
|
||||
|
||||
assert.equal(headers.authorization, "Bearer real-token");
|
||||
assert.equal(headers["x-client-key"], generateHashed64Hex("real-token"));
|
||||
assert.equal(headers["x-session-id"], generateSessionId("real-token"));
|
||||
assert.equal(headers["x-cursor-checksum"], generateCursorChecksum("machine-1"));
|
||||
assert.equal(headers["x-ghost-mode"], "false");
|
||||
assert.equal(headers["connect-protocol-version"], "1");
|
||||
assert.match(headers["x-amzn-trace-id"], /^Root=/);
|
||||
assert.ok(headers["x-request-id"]);
|
||||
} finally {
|
||||
Date.now = originalDateNow;
|
||||
}
|
||||
});
|
||||
|
||||
test("CursorExecutor.buildHeaders requires a machine ID", () => {
|
||||
const executor = new CursorExecutor();
|
||||
assert.throws(
|
||||
() => executor.buildHeaders({ accessToken: "real-token", providerSpecificData: {} }),
|
||||
/Machine ID is required/
|
||||
);
|
||||
});
|
||||
|
||||
test("CursorExecutor.transformRequest produces a framed protobuf payload", () => {
|
||||
const executor = new CursorExecutor();
|
||||
const transformed = executor.transformRequest(
|
||||
"claude-3.5-sonnet",
|
||||
{ messages: [{ role: "user", content: "Hello" }], tools: [] },
|
||||
true,
|
||||
{}
|
||||
);
|
||||
const frame = parseConnectRPCFrame(transformed);
|
||||
const fields = decodeMessage(frame.payload);
|
||||
|
||||
assert.ok(transformed instanceof Uint8Array);
|
||||
assert.equal(frame.flags, 0);
|
||||
assert.equal(frame.consumed, transformed.length);
|
||||
assert.equal(fields.has(1), true);
|
||||
});
|
||||
|
||||
test("CursorExecutor.transformProtobufToJSON aggregates text and split tool call arguments", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const body = { messages: [{ role: "user", content: "hi" }] };
|
||||
const buffer = Buffer.concat([
|
||||
buildTextFrame("Hello "),
|
||||
buildToolCallFrame({
|
||||
id: "call_1",
|
||||
name: "read_file",
|
||||
args: '{"path":',
|
||||
isLast: false,
|
||||
}),
|
||||
buildToolCallFrame({
|
||||
id: "call_1",
|
||||
name: "read_file",
|
||||
args: '"/tmp/a"}',
|
||||
isLast: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const response = executor.transformProtobufToJSON(buffer, "cursor-small", body);
|
||||
const payload = await response.json();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.object, "chat.completion");
|
||||
assert.equal(payload.model, "cursor-small");
|
||||
assert.equal(payload.choices[0].message.content, "Hello ");
|
||||
assert.equal(payload.choices[0].finish_reason, "tool_calls");
|
||||
assert.equal(payload.choices[0].message.tool_calls[0].function.name, "read_file");
|
||||
assert.equal(payload.choices[0].message.tool_calls[0].function.arguments, '{"path":"/tmp/a"}');
|
||||
assert.equal(payload.usage.estimated, true);
|
||||
});
|
||||
|
||||
test("CursorExecutor.transformProtobufToSSE emits assistant chunks, tool deltas and DONE marker", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const body = { messages: [{ role: "user", content: "hi" }] };
|
||||
const buffer = Buffer.concat([
|
||||
buildTextFrame("Hello "),
|
||||
buildToolCallFrame({
|
||||
id: "call_1",
|
||||
name: "read_file",
|
||||
args: '{"path":',
|
||||
isLast: false,
|
||||
}),
|
||||
buildToolCallFrame({
|
||||
id: "call_1",
|
||||
name: "read_file",
|
||||
args: '"/tmp/a"}',
|
||||
isLast: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const response = executor.transformProtobufToSSE(buffer, "cursor-small", body);
|
||||
const text = await response.text();
|
||||
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(response.headers.get("Content-Type"), "text/event-stream");
|
||||
assert.match(text, /"role":"assistant","content":"Hello "/);
|
||||
assert.match(text, /"tool_calls":\[/);
|
||||
assert.match(text, /"name":"read_file"/);
|
||||
assert.match(text, /"finish_reason":"tool_calls"/);
|
||||
assert.match(text, /\[DONE\]/);
|
||||
});
|
||||
|
||||
test("CursorExecutor.transformProtobufToSSE converts JSON error frames into rate-limit responses", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const response = executor.transformProtobufToSSE(
|
||||
buildJsonErrorFrame({
|
||||
error: {
|
||||
code: "resource_exhausted",
|
||||
message: "rate limited",
|
||||
details: [{ debug: { error: "LIMIT", details: { title: "Limit", detail: "Slow down" } } }],
|
||||
},
|
||||
}),
|
||||
"cursor-small",
|
||||
{ messages: [{ role: "user", content: "hi" }] }
|
||||
);
|
||||
const payload = await response.json();
|
||||
|
||||
assert.equal(response.status, 429);
|
||||
assert.equal(payload.error.type, "rate_limit_error");
|
||||
assert.equal(payload.error.message, "Limit");
|
||||
assert.equal(payload.error.code, "LIMIT");
|
||||
});
|
||||
|
||||
test("CursorExecutor.execute returns transformed JSON for non-stream responses", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const body = { messages: [{ role: "user", content: "hi" }] };
|
||||
const responseBuffer = Buffer.concat([buildTextFrame("Hello from Cursor")]);
|
||||
executor.makeHttp2Request = async () => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: responseBuffer,
|
||||
});
|
||||
executor.makeFetchRequest = executor.makeHttp2Request;
|
||||
|
||||
const result = await executor.execute({
|
||||
model: "cursor-small",
|
||||
body,
|
||||
stream: false,
|
||||
credentials: {
|
||||
accessToken: "token",
|
||||
providerSpecificData: { machineId: "machine-1" },
|
||||
},
|
||||
});
|
||||
const payload = await result.response.json();
|
||||
|
||||
assert.equal(
|
||||
result.url,
|
||||
"https://api2.cursor.sh/aiserver.v1.ChatService/StreamUnifiedChatWithTools"
|
||||
);
|
||||
assert.equal(result.transformedBody, body);
|
||||
assert.equal(result.headers.authorization, "Bearer token");
|
||||
assert.equal(payload.object, "chat.completion");
|
||||
assert.equal(payload.choices[0].message.content, "Hello from Cursor");
|
||||
assert.equal(payload.choices[0].finish_reason, "stop");
|
||||
});
|
||||
|
||||
test("CursorExecutor.execute returns transformed SSE for stream responses", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const body = { messages: [{ role: "user", content: "hi" }] };
|
||||
const responseBuffer = Buffer.concat([buildTextFrame("Hello stream")]);
|
||||
executor.makeHttp2Request = async () => ({
|
||||
status: 200,
|
||||
headers: {},
|
||||
body: responseBuffer,
|
||||
});
|
||||
executor.makeFetchRequest = executor.makeHttp2Request;
|
||||
|
||||
const result = await executor.execute({
|
||||
model: "cursor-small",
|
||||
body,
|
||||
stream: true,
|
||||
credentials: {
|
||||
accessToken: "token",
|
||||
providerSpecificData: { machineId: "machine-1" },
|
||||
},
|
||||
});
|
||||
const text = await result.response.text();
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.match(text, /"content":"Hello stream"/);
|
||||
assert.match(text, /\[DONE\]/);
|
||||
});
|
||||
|
||||
test("CursorExecutor.execute maps non-200 upstream responses to OpenAI-style errors", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
const body = { messages: [{ role: "user", content: "hi" }] };
|
||||
executor.makeHttp2Request = async () => ({
|
||||
status: 403,
|
||||
headers: {},
|
||||
body: Buffer.from("denied"),
|
||||
});
|
||||
executor.makeFetchRequest = executor.makeHttp2Request;
|
||||
|
||||
const result = await executor.execute({
|
||||
model: "cursor-small",
|
||||
body,
|
||||
stream: false,
|
||||
credentials: {
|
||||
accessToken: "token",
|
||||
providerSpecificData: { machineId: "machine-1" },
|
||||
},
|
||||
});
|
||||
const payload = await result.response.json();
|
||||
|
||||
assert.equal(result.response.status, 403);
|
||||
assert.equal(payload.error.type, "invalid_request_error");
|
||||
assert.match(payload.error.message, /\[403\]: denied/);
|
||||
});
|
||||
|
||||
test("CursorExecutor.execute maps transport failures to connection_error and refreshCredentials returns null", async () => {
|
||||
const executor = new CursorExecutor();
|
||||
executor.makeHttp2Request = async () => {
|
||||
throw new Error("socket hang up");
|
||||
};
|
||||
executor.makeFetchRequest = executor.makeHttp2Request;
|
||||
|
||||
const result = await executor.execute({
|
||||
model: "cursor-small",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: false,
|
||||
credentials: {
|
||||
accessToken: "token",
|
||||
providerSpecificData: { machineId: "machine-1" },
|
||||
},
|
||||
});
|
||||
const payload = await result.response.json();
|
||||
|
||||
assert.equal(result.response.status, 500);
|
||||
assert.equal(payload.error.type, "connection_error");
|
||||
assert.equal(payload.error.message, "socket hang up");
|
||||
assert.equal(await executor.refreshCredentials(), null);
|
||||
});
|
||||
470
tests/unit/executor-default-base.test.mjs
Normal file
470
tests/unit/executor-default-base.test.mjs
Normal file
@@ -0,0 +1,470 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
applyConfiguredUserAgent,
|
||||
BaseExecutor,
|
||||
getCustomUserAgent,
|
||||
mergeAbortSignals,
|
||||
mergeUpstreamExtraHeaders,
|
||||
setUserAgentHeader,
|
||||
} from "../../open-sse/executors/base.ts";
|
||||
import { DefaultExecutor } from "../../open-sse/executors/default.ts";
|
||||
import { PROVIDERS } from "../../open-sse/config/constants.ts";
|
||||
import {
|
||||
CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION,
|
||||
CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH,
|
||||
} from "../../open-sse/services/claudeCodeCompatible.ts";
|
||||
|
||||
class TestExecutor extends BaseExecutor {
|
||||
constructor(config = {}) {
|
||||
super("test-provider", {
|
||||
baseUrls: [
|
||||
"https://primary.example/v1/chat/completions",
|
||||
"https://fallback.example/v1/chat/completions",
|
||||
],
|
||||
headers: { "X-Test-Header": "base" },
|
||||
...config,
|
||||
});
|
||||
}
|
||||
|
||||
async transformRequest(model, body, stream) {
|
||||
return { ...body, transformed: true, model, stream };
|
||||
}
|
||||
}
|
||||
|
||||
test("BaseExecutor: openai-compatible buildUrl sanitizes custom chat paths", () => {
|
||||
const executor = new BaseExecutor("openai-compatible-test", {});
|
||||
const valid = executor.buildUrl("gpt-4.1", true, 0, {
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://proxy.example/v1/",
|
||||
chatPath: "/custom/chat/completions",
|
||||
},
|
||||
});
|
||||
const invalid = executor.buildUrl("gpt-4.1", true, 0, {
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://proxy.example/v1/",
|
||||
chatPath: "../evil",
|
||||
},
|
||||
});
|
||||
const invalidNullByte = executor.buildUrl("gpt-4.1", true, 0, {
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://proxy.example/v1/",
|
||||
chatPath: "/ok\0evil",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(valid, "https://proxy.example/v1/custom/chat/completions");
|
||||
assert.equal(invalid, "https://proxy.example/v1/chat/completions");
|
||||
assert.equal(invalidNullByte, "https://proxy.example/v1/chat/completions");
|
||||
});
|
||||
|
||||
test("DefaultExecutor.buildUrl handles Gemini, Claude and Qwen variants", () => {
|
||||
const gemini = new DefaultExecutor("gemini");
|
||||
const claude = new DefaultExecutor("claude");
|
||||
const qwen = new DefaultExecutor("qwen");
|
||||
|
||||
assert.equal(
|
||||
gemini.buildUrl("gemini-2.5-flash", false),
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent"
|
||||
);
|
||||
assert.equal(
|
||||
gemini.buildUrl("gemini-2.5-flash", true),
|
||||
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse"
|
||||
);
|
||||
assert.equal(claude.buildUrl("claude-sonnet-4", true), `${PROVIDERS.claude.baseUrl}?beta=true`);
|
||||
assert.equal(qwen.buildUrl("qwen3-coder", true), "https://portal.qwen.ai/v1/chat/completions");
|
||||
assert.equal(
|
||||
qwen.buildUrl("qwen3-coder", true, 0, {
|
||||
providerSpecificData: { resourceUrl: "custom.qwen.ai" },
|
||||
}),
|
||||
"https://custom.qwen.ai/v1/chat/completions"
|
||||
);
|
||||
});
|
||||
|
||||
test("DefaultExecutor.buildUrl handles openai-compatible and anthropic-compatible providers", () => {
|
||||
const openAICompat = new DefaultExecutor("openai-compatible-test");
|
||||
const openAIResponsesCompat = new DefaultExecutor("openai-compatible-responses-test");
|
||||
const anthropicCompat = new DefaultExecutor("anthropic-compatible-test");
|
||||
const anthropicCcCompat = new DefaultExecutor("anthropic-compatible-cc-test");
|
||||
|
||||
assert.equal(
|
||||
openAICompat.buildUrl("gpt-4.1", true, 0, {
|
||||
providerSpecificData: { baseUrl: "https://proxy.example/v1/" },
|
||||
}),
|
||||
"https://proxy.example/v1/chat/completions"
|
||||
);
|
||||
assert.equal(
|
||||
openAICompat.buildUrl("gpt-4.1", true, 0, {
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://proxy.example/v1/",
|
||||
chatPath: "/custom/chat",
|
||||
},
|
||||
}),
|
||||
"https://proxy.example/v1/custom/chat"
|
||||
);
|
||||
assert.equal(
|
||||
openAIResponsesCompat.buildUrl("gpt-4.1", true, 0, {
|
||||
providerSpecificData: { baseUrl: "https://proxy.example/v1/" },
|
||||
}),
|
||||
"https://proxy.example/v1/responses"
|
||||
);
|
||||
assert.equal(
|
||||
anthropicCompat.buildUrl("claude-sonnet-4", true, 0, {
|
||||
providerSpecificData: { baseUrl: "https://anthropic.example/v1/" },
|
||||
}),
|
||||
"https://anthropic.example/v1/messages"
|
||||
);
|
||||
assert.equal(
|
||||
anthropicCompat.buildUrl("claude-sonnet-4", true, 0, {
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://anthropic.example/v1/",
|
||||
chatPath: "/custom/messages",
|
||||
},
|
||||
}),
|
||||
"https://anthropic.example/v1/custom/messages"
|
||||
);
|
||||
assert.equal(
|
||||
anthropicCcCompat.buildUrl("claude-sonnet-4", true, 0, {
|
||||
providerSpecificData: {
|
||||
baseUrl: "https://cc.example/v1/messages",
|
||||
},
|
||||
}),
|
||||
`https://cc.example${CLAUDE_CODE_COMPATIBLE_DEFAULT_CHAT_PATH}`
|
||||
);
|
||||
});
|
||||
|
||||
test("DefaultExecutor.buildUrl falls back to OpenAI config for unknown providers", () => {
|
||||
const executor = new DefaultExecutor("unknown-provider");
|
||||
assert.equal(executor.config.baseUrl, PROVIDERS.openai.baseUrl);
|
||||
assert.equal(executor.buildUrl("gpt-4.1", true), PROVIDERS.openai.baseUrl);
|
||||
});
|
||||
|
||||
test("DefaultExecutor.buildHeaders handles Gemini and Claude auth modes", () => {
|
||||
const gemini = new DefaultExecutor("gemini");
|
||||
const claude = new DefaultExecutor("claude");
|
||||
|
||||
const geminiApiKeyHeaders = gemini.buildHeaders({ apiKey: "gem-key" }, true);
|
||||
const geminiOAuthHeaders = gemini.buildHeaders({ accessToken: "gem-token" }, false);
|
||||
const claudeApiKeyHeaders = claude.buildHeaders({ apiKey: "claude-key" }, true);
|
||||
const claudeOAuthHeaders = claude.buildHeaders({ accessToken: "claude-token" }, false);
|
||||
|
||||
assert.equal(geminiApiKeyHeaders["x-goog-api-key"], "gem-key");
|
||||
assert.equal(geminiApiKeyHeaders.Accept, "text/event-stream");
|
||||
assert.equal(geminiApiKeyHeaders.Authorization, undefined);
|
||||
assert.equal(geminiOAuthHeaders.Authorization, "Bearer gem-token");
|
||||
assert.equal(claudeApiKeyHeaders["x-api-key"], "claude-key");
|
||||
assert.equal(claudeApiKeyHeaders.Accept, "text/event-stream");
|
||||
assert.equal(claudeOAuthHeaders.Authorization, "Bearer claude-token");
|
||||
assert.equal(claudeOAuthHeaders["x-api-key"], undefined);
|
||||
});
|
||||
|
||||
test("DefaultExecutor.buildHeaders handles GLM, default auth and anthropic-compatible headers", () => {
|
||||
const glm = new DefaultExecutor("glm");
|
||||
const openai = new DefaultExecutor("openai");
|
||||
const anthropicCompat = new DefaultExecutor("anthropic-compatible-test");
|
||||
|
||||
const glmHeaders = glm.buildHeaders({ accessToken: "glm-token" }, false);
|
||||
const openaiHeaders = openai.buildHeaders({ apiKey: "sk-openai" }, true);
|
||||
const anthropicHeaders = anthropicCompat.buildHeaders({ apiKey: "anth-key" }, true);
|
||||
|
||||
assert.equal(glmHeaders["x-api-key"], "glm-token");
|
||||
assert.equal(openaiHeaders.Authorization, "Bearer sk-openai");
|
||||
assert.equal(openaiHeaders.Accept, "text/event-stream");
|
||||
assert.equal(anthropicHeaders["x-api-key"], "anth-key");
|
||||
assert.equal(anthropicHeaders["anthropic-version"], "2023-06-01");
|
||||
assert.equal(anthropicHeaders.Accept, "text/event-stream");
|
||||
});
|
||||
|
||||
test("DefaultExecutor.buildHeaders strips DashScope headers for Qwen API keys and preserves them for OAuth", () => {
|
||||
const executor = new DefaultExecutor("qwen");
|
||||
|
||||
const apiKeyHeaders = executor.buildHeaders({ apiKey: "dash-key" }, true);
|
||||
const oauthHeaders = executor.buildHeaders({ accessToken: "oauth-token" }, true);
|
||||
|
||||
assert.equal(apiKeyHeaders.Authorization, "Bearer dash-key");
|
||||
assert.equal(
|
||||
Object.keys(apiKeyHeaders).some((key) => key.toLowerCase().startsWith("x-dashscope-")),
|
||||
false
|
||||
);
|
||||
assert.equal(oauthHeaders.Authorization, "Bearer oauth-token");
|
||||
assert.equal(oauthHeaders["X-Dashscope-AuthType"], "qwen-oauth");
|
||||
assert.equal(oauthHeaders["X-Dashscope-CacheControl"], "enable");
|
||||
});
|
||||
|
||||
test("DefaultExecutor.buildHeaders rotates extra API keys and builds Claude Code compatible headers", () => {
|
||||
const openai = new DefaultExecutor("openai");
|
||||
const cc = new DefaultExecutor("anthropic-compatible-cc-test");
|
||||
|
||||
const first = openai.buildHeaders(
|
||||
{
|
||||
apiKey: "primary",
|
||||
connectionId: "conn-rotation",
|
||||
providerSpecificData: { extraApiKeys: ["extra-1", "extra-2"] },
|
||||
},
|
||||
false
|
||||
);
|
||||
const second = openai.buildHeaders(
|
||||
{
|
||||
apiKey: "primary",
|
||||
connectionId: "conn-rotation",
|
||||
providerSpecificData: { extraApiKeys: ["extra-1", "extra-2"] },
|
||||
},
|
||||
false
|
||||
);
|
||||
const ccHeaders = cc.buildHeaders(
|
||||
{
|
||||
apiKey: "cc-key",
|
||||
providerSpecificData: { ccSessionId: "session-1" },
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(first.Authorization, "Bearer primary");
|
||||
assert.equal(second.Authorization, "Bearer extra-1");
|
||||
assert.equal(ccHeaders["x-api-key"], "cc-key");
|
||||
assert.equal(ccHeaders["anthropic-version"], CLAUDE_CODE_COMPATIBLE_ANTHROPIC_VERSION);
|
||||
assert.equal(ccHeaders["X-Claude-Code-Session-Id"], "session-1");
|
||||
assert.equal(ccHeaders.Accept, "text/event-stream");
|
||||
});
|
||||
|
||||
test("DefaultExecutor.transformRequest is a passthrough and preserves model ids with slashes", () => {
|
||||
const executor = new DefaultExecutor("openai");
|
||||
const body = { model: "zai-org/GLM-5-FP8", messages: [{ role: "user", content: "hi" }] };
|
||||
const result = executor.transformRequest("zai-org/GLM-5-FP8", body, true, {});
|
||||
|
||||
assert.equal(result, body);
|
||||
assert.equal(result.model, "zai-org/GLM-5-FP8");
|
||||
});
|
||||
|
||||
test("BaseExecutor helpers manage custom user agents and upstream extra headers", () => {
|
||||
const headers = { "user-agent": "old", Authorization: "Bearer old" };
|
||||
|
||||
assert.equal(getCustomUserAgent({ customUserAgent: " MyAgent/1.0 " }), "MyAgent/1.0");
|
||||
assert.equal(getCustomUserAgent({ customUserAgent: " " }), null);
|
||||
|
||||
setUserAgentHeader(headers, "MyAgent/2.0");
|
||||
assert.equal(headers["User-Agent"], "MyAgent/2.0");
|
||||
assert.equal(headers["user-agent"], "MyAgent/2.0");
|
||||
|
||||
applyConfiguredUserAgent(headers, { customUserAgent: "MyAgent/3.0" });
|
||||
assert.equal(headers["User-Agent"], "MyAgent/3.0");
|
||||
|
||||
mergeUpstreamExtraHeaders(headers, {
|
||||
Authorization: "Bearer override",
|
||||
"user-agent": "Merged/4.0",
|
||||
"X-Upstream": "1",
|
||||
});
|
||||
assert.equal(headers.Authorization, "Bearer override");
|
||||
assert.equal(headers["User-Agent"], "Merged/4.0");
|
||||
assert.equal(headers["user-agent"], "Merged/4.0");
|
||||
assert.equal(headers["X-Upstream"], "1");
|
||||
});
|
||||
|
||||
test("BaseExecutor.mergeAbortSignals aborts when either source signal aborts", () => {
|
||||
const primary = new AbortController();
|
||||
const secondary = new AbortController();
|
||||
const merged = mergeAbortSignals(primary.signal, secondary.signal);
|
||||
|
||||
assert.equal(merged.aborted, false);
|
||||
primary.abort();
|
||||
assert.equal(merged.aborted, true);
|
||||
|
||||
const otherPrimary = new AbortController();
|
||||
const otherSecondary = new AbortController();
|
||||
const merged2 = mergeAbortSignals(otherPrimary.signal, otherSecondary.signal);
|
||||
otherSecondary.abort();
|
||||
assert.equal(merged2.aborted, true);
|
||||
});
|
||||
|
||||
test("BaseExecutor.needsRefresh returns true only when expiry is near", () => {
|
||||
const executor = new TestExecutor();
|
||||
const soon = new Date(Date.now() + 60_000).toISOString();
|
||||
const later = new Date(Date.now() + 60 * 60 * 1000).toISOString();
|
||||
|
||||
assert.equal(executor.needsRefresh({ expiresAt: soon }), true);
|
||||
assert.equal(executor.needsRefresh({ expiresAt: later }), false);
|
||||
assert.equal(executor.needsRefresh({}), false);
|
||||
});
|
||||
|
||||
test("DefaultExecutor.refreshCredentials returns null without refresh token", async () => {
|
||||
const executor = new DefaultExecutor("gemini");
|
||||
const result = await executor.refreshCredentials({}, null);
|
||||
assert.equal(result, null);
|
||||
});
|
||||
|
||||
test("DefaultExecutor.refreshCredentials delegates to OAuth refresh and returns new tokens", async () => {
|
||||
const executor = new DefaultExecutor("gemini");
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, options) => {
|
||||
assert.match(String(url), /oauth2\.googleapis\.com/);
|
||||
assert.equal(options.method, "POST");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "new-access-token",
|
||||
refresh_token: "new-refresh-token",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.refreshCredentials({ refreshToken: "refresh-me" }, null);
|
||||
assert.deepEqual(result, {
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
expiresIn: 3600,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("DefaultExecutor.refreshCredentials swallows refresh errors and logs them", async () => {
|
||||
const executor = new DefaultExecutor("gemini");
|
||||
const originalFetch = globalThis.fetch;
|
||||
const messages = [];
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("network down");
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.refreshCredentials(
|
||||
{ refreshToken: "refresh-me" },
|
||||
{ error: (tag, message) => messages.push({ tag, message }) }
|
||||
);
|
||||
assert.equal(result, null);
|
||||
assert.equal(messages.length, 1);
|
||||
assert.match(messages[0].message, /refresh error: network down/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("BaseExecutor.execute returns response metadata and merges headers", async () => {
|
||||
const executor = new TestExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured;
|
||||
globalThis.fetch = async (url, options) => {
|
||||
captured = { url, options };
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "gpt-4.1",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: true,
|
||||
credentials: {
|
||||
apiKey: "base-key",
|
||||
providerSpecificData: { customUserAgent: "CredsAgent/1.0" },
|
||||
},
|
||||
upstreamExtraHeaders: {
|
||||
Authorization: "Bearer override",
|
||||
"user-agent": "UpstreamAgent/2.0",
|
||||
"X-Trace-Id": "trace-1",
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.url, "https://primary.example/v1/chat/completions");
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.equal(result.transformedBody.transformed, true);
|
||||
assert.equal(result.transformedBody.model, "gpt-4.1");
|
||||
assert.equal(result.headers.Authorization, "Bearer override");
|
||||
assert.equal(result.headers["User-Agent"], "UpstreamAgent/2.0");
|
||||
assert.equal(result.headers["user-agent"], undefined);
|
||||
assert.equal(result.headers["X-Trace-Id"], "trace-1");
|
||||
assert.equal(result.headers.Accept, "text/event-stream");
|
||||
assert.equal(captured.options.body.includes('"transformed":true'), true);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("BaseExecutor.execute falls back to the next base URL after a transport error", async () => {
|
||||
const executor = new TestExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls = [];
|
||||
globalThis.fetch = async (url) => {
|
||||
calls.push(String(url));
|
||||
if (calls.length === 1) {
|
||||
throw new Error("first node down");
|
||||
}
|
||||
return new Response("ok", { status: 200 });
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "gpt-4.1",
|
||||
body: { hello: "world" },
|
||||
stream: false,
|
||||
credentials: {},
|
||||
});
|
||||
|
||||
assert.deepEqual(calls, [
|
||||
"https://primary.example/v1/chat/completions",
|
||||
"https://fallback.example/v1/chat/completions",
|
||||
]);
|
||||
assert.equal(result.url, "https://fallback.example/v1/chat/completions");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("BaseExecutor.execute throws the last error when all URLs fail", async () => {
|
||||
const executor = new TestExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => {
|
||||
throw new Error("still down");
|
||||
};
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
executor.execute({
|
||||
model: "gpt-4.1",
|
||||
body: {},
|
||||
stream: false,
|
||||
credentials: {},
|
||||
}),
|
||||
/still down/
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("BaseExecutor.execute propagates aborted requests through the merged signal", async () => {
|
||||
const executor = new TestExecutor({ baseUrls: ["https://single.example/v1/chat/completions"] });
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
const originalFetch = globalThis.fetch;
|
||||
|
||||
globalThis.fetch = async (url, options) => {
|
||||
assert.equal(options.signal.aborted, true);
|
||||
const error = new Error(`aborted ${url}`);
|
||||
error.name = "AbortError";
|
||||
throw error;
|
||||
};
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
executor.execute({
|
||||
model: "gpt-4.1",
|
||||
body: {},
|
||||
stream: false,
|
||||
credentials: {},
|
||||
signal: controller.signal,
|
||||
}),
|
||||
/aborted/
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
97
tests/unit/executor-gemini-cli.test.mjs
Normal file
97
tests/unit/executor-gemini-cli.test.mjs
Normal file
@@ -0,0 +1,97 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { GeminiCLIExecutor } from "../../open-sse/executors/gemini-cli.ts";
|
||||
|
||||
test("GeminiCLIExecutor.buildUrl and buildHeaders match the native Gemini CLI fingerprint", () => {
|
||||
const executor = new GeminiCLIExecutor();
|
||||
|
||||
assert.equal(
|
||||
executor.buildUrl("gemini-2.5-flash", true),
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:streamGenerateContent?alt=sse"
|
||||
);
|
||||
assert.equal(
|
||||
executor.buildUrl("gemini-2.5-flash", false),
|
||||
"https://cloudcode-pa.googleapis.com/v1internal:generateContent"
|
||||
);
|
||||
|
||||
const headers = executor.buildHeaders({ accessToken: "gcli-token" }, true);
|
||||
assert.equal(headers.Authorization, "Bearer gcli-token");
|
||||
assert.equal(headers.Accept, "text/event-stream");
|
||||
assert.match(headers["User-Agent"], /GeminiCLI/);
|
||||
assert.match(headers["X-Goog-Api-Client"], /google-genai-sdk/);
|
||||
});
|
||||
|
||||
test("GeminiCLIExecutor.refreshProject caches loadCodeAssist lookups and transformRequest updates body.project", async () => {
|
||||
const executor = new GeminiCLIExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
let calls = 0;
|
||||
|
||||
globalThis.fetch = async (url) => {
|
||||
calls += 1;
|
||||
assert.match(String(url), /loadCodeAssist$/);
|
||||
return new Response(JSON.stringify({ cloudaicompanionProject: "fresh-project-id" }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const first = await executor.refreshProject("access-token-1");
|
||||
const second = await executor.refreshProject("access-token-1");
|
||||
const body = { project: "stale-project", request: { contents: [] } };
|
||||
const transformed = await executor.transformRequest("gemini-2.5-flash", body, true, {
|
||||
accessToken: "access-token-1",
|
||||
});
|
||||
|
||||
assert.equal(first, "fresh-project-id");
|
||||
assert.equal(second, "fresh-project-id");
|
||||
assert.equal(calls, 1);
|
||||
assert.equal(transformed.project, "fresh-project-id");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GeminiCLIExecutor.refreshProject returns null on failed loadCodeAssist responses", async () => {
|
||||
const executor = new GeminiCLIExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response("forbidden", { status: 403 });
|
||||
|
||||
try {
|
||||
assert.equal(await executor.refreshProject("access-token-2"), null);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GeminiCLIExecutor.refreshCredentials exchanges refresh tokens via Google OAuth", async () => {
|
||||
const executor = new GeminiCLIExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
assert.match(String(url), /oauth2\.googleapis\.com\/token$/);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "new-access-token",
|
||||
refresh_token: "new-refresh-token",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.refreshCredentials(
|
||||
{ refreshToken: "refresh", projectId: "project-1" },
|
||||
null
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
expiresIn: 3600,
|
||||
projectId: "project-1",
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
221
tests/unit/executor-github.test.mjs
Normal file
221
tests/unit/executor-github.test.mjs
Normal file
@@ -0,0 +1,221 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { GithubExecutor } from "../../open-sse/executors/github.ts";
|
||||
import { PROVIDER_MODELS } from "../../open-sse/config/providerModels.ts";
|
||||
|
||||
function registerModel(provider, model) {
|
||||
PROVIDER_MODELS[provider] = [...(PROVIDER_MODELS[provider] || []), model];
|
||||
}
|
||||
|
||||
test("GithubExecutor.buildUrl routes response-format models to /responses", () => {
|
||||
const originalModels = [...(PROVIDER_MODELS.gh || [])];
|
||||
registerModel("gh", {
|
||||
id: "gpt-4.1-responses",
|
||||
name: "GPT 4.1 Responses",
|
||||
targetFormat: "openai-responses",
|
||||
});
|
||||
|
||||
try {
|
||||
const executor = new GithubExecutor();
|
||||
const url = executor.buildUrl("gpt-4.1-responses", true);
|
||||
assert.equal(url, "https://api.githubcopilot.com/responses");
|
||||
} finally {
|
||||
PROVIDER_MODELS.gh = originalModels;
|
||||
}
|
||||
});
|
||||
|
||||
test("GithubExecutor.transformRequest injects JSON response instructions for Claude and strips reasoning fields", () => {
|
||||
const executor = new GithubExecutor();
|
||||
const body = {
|
||||
response_format: {
|
||||
type: "json_object",
|
||||
},
|
||||
messages: [
|
||||
{ role: "user", content: "Return JSON" },
|
||||
{
|
||||
role: "assistant",
|
||||
content: "draft",
|
||||
reasoning_text: "internal",
|
||||
reasoning_content: "internal",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const result = executor.transformRequest("claude-sonnet-4", body, true, {});
|
||||
|
||||
assert.equal(result.response_format, undefined);
|
||||
assert.equal(result.messages[0].role, "system");
|
||||
assert.match(result.messages[0].content, /Respond only with valid JSON/);
|
||||
assert.equal(result.messages[2].reasoning_text, undefined);
|
||||
assert.equal(result.messages[2].reasoning_content, undefined);
|
||||
});
|
||||
|
||||
test("GithubExecutor.buildHeaders prefers Copilot token and sets GitHub-specific headers", () => {
|
||||
const executor = new GithubExecutor();
|
||||
const headers = executor.buildHeaders(
|
||||
{
|
||||
accessToken: "gh-access-token",
|
||||
providerSpecificData: { copilotToken: "copilot-token" },
|
||||
},
|
||||
true
|
||||
);
|
||||
|
||||
assert.equal(headers.Authorization, "Bearer copilot-token");
|
||||
assert.equal(headers.Accept, "text/event-stream");
|
||||
assert.equal(headers["x-github-api-version"], "2025-04-01");
|
||||
assert.equal(headers["openai-intent"], "conversation-panel");
|
||||
assert.ok(headers["x-request-id"]);
|
||||
});
|
||||
|
||||
test("GithubExecutor.refreshCredentials returns Copilot token directly when available", async () => {
|
||||
const executor = new GithubExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url, options) => {
|
||||
assert.match(String(url), /copilot_internal\/v2\/token$/);
|
||||
assert.equal(options.headers.Authorization, "token gh-access-token");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
token: "copilot-token",
|
||||
expires_at: 1_777_777_777,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.refreshCredentials({ accessToken: "gh-access-token" }, null);
|
||||
assert.deepEqual(result, {
|
||||
accessToken: "gh-access-token",
|
||||
refreshToken: undefined,
|
||||
copilotToken: "copilot-token",
|
||||
copilotTokenExpiresAt: 1_777_777_777,
|
||||
providerSpecificData: {
|
||||
copilotToken: "copilot-token",
|
||||
copilotTokenExpiresAt: 1_777_777_777,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GithubExecutor.refreshCredentials falls back to GitHub OAuth refresh before retrying Copilot", async () => {
|
||||
const executor = new GithubExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const calls = [];
|
||||
|
||||
globalThis.fetch = async (url, options = {}) => {
|
||||
calls.push(String(url));
|
||||
|
||||
if (String(url).includes("/copilot_internal/v2/token") && calls.length === 1) {
|
||||
return new Response("unauthorized", { status: 401 });
|
||||
}
|
||||
|
||||
if (String(url).includes("/oauth/access_token")) {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: "new-gh-token",
|
||||
refresh_token: "new-refresh-token",
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
if (String(url).includes("/copilot_internal/v2/token")) {
|
||||
assert.equal(options.headers.Authorization, "token new-gh-token");
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
token: "new-copilot-token",
|
||||
expires_at: 1_888_888_888,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
}
|
||||
|
||||
throw new Error(`unexpected url: ${url}`);
|
||||
};
|
||||
|
||||
try {
|
||||
const result = await executor.refreshCredentials(
|
||||
{
|
||||
accessToken: "old-gh-token",
|
||||
refreshToken: "refresh-token",
|
||||
},
|
||||
null
|
||||
);
|
||||
|
||||
assert.deepEqual(result, {
|
||||
accessToken: "new-gh-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
expiresIn: 3600,
|
||||
copilotToken: "new-copilot-token",
|
||||
copilotTokenExpiresAt: 1_888_888_888,
|
||||
providerSpecificData: {
|
||||
copilotToken: "new-copilot-token",
|
||||
copilotTokenExpiresAt: 1_888_888_888,
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("GithubExecutor.needsRefresh checks missing and expiring Copilot tokens", () => {
|
||||
const executor = new GithubExecutor();
|
||||
|
||||
assert.equal(executor.needsRefresh({}), true);
|
||||
assert.equal(
|
||||
executor.needsRefresh({
|
||||
providerSpecificData: {
|
||||
copilotToken: "copilot-token",
|
||||
copilotTokenExpiresAt: Math.floor((Date.now() + 60_000) / 1000),
|
||||
},
|
||||
}),
|
||||
true
|
||||
);
|
||||
assert.equal(
|
||||
executor.needsRefresh({
|
||||
providerSpecificData: {
|
||||
copilotToken: "copilot-token",
|
||||
copilotTokenExpiresAt: Math.floor((Date.now() + 60 * 60 * 1000) / 1000),
|
||||
},
|
||||
}),
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
test("GithubExecutor.execute strips terminal [DONE] frames from SSE responses", async () => {
|
||||
const executor = new GithubExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode('data: {"chunk":"one"}\n\n'));
|
||||
controller.enqueue(new TextEncoder().encode("data: [DONE]\n\n"));
|
||||
controller.close();
|
||||
},
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
const result = await executor.execute({
|
||||
model: "gpt-4.1",
|
||||
body: { messages: [{ role: "user", content: "hi" }] },
|
||||
stream: true,
|
||||
credentials: { accessToken: "gh-access-token" },
|
||||
});
|
||||
const text = await result.response.text();
|
||||
|
||||
assert.match(text, /"chunk":"one"/);
|
||||
assert.doesNotMatch(text, /\[DONE\]/);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
109
tests/unit/executor-kiro.test.mjs
Normal file
109
tests/unit/executor-kiro.test.mjs
Normal file
@@ -0,0 +1,109 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { KiroExecutor } from "../../open-sse/executors/kiro.ts";
|
||||
|
||||
test("KiroExecutor.buildHeaders includes Kiro-specific auth and metadata", () => {
|
||||
const executor = new KiroExecutor();
|
||||
const headers = executor.buildHeaders({ accessToken: "kiro-token" }, true);
|
||||
|
||||
assert.equal(headers.Authorization, "Bearer kiro-token");
|
||||
assert.equal(headers["anthropic-beta"], "prompt-caching-2024-07-31");
|
||||
assert.equal(headers["x-amzn-bedrock-cache-control"], "enable");
|
||||
assert.ok(headers["Amz-Sdk-Invocation-Id"]);
|
||||
});
|
||||
|
||||
test("KiroExecutor.transformRequest removes the top-level model field", () => {
|
||||
const executor = new KiroExecutor();
|
||||
const body = {
|
||||
model: "kiro-model",
|
||||
conversationState: {
|
||||
currentMessage: {
|
||||
userInputMessage: {
|
||||
modelId: "kiro-model",
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = executor.transformRequest("kiro-model", body, true, {});
|
||||
assert.equal("model" in result, false);
|
||||
assert.equal(result.conversationState.currentMessage.userInputMessage.modelId, "kiro-model");
|
||||
});
|
||||
|
||||
test("KiroExecutor.execute returns upstream errors directly and transforms successful streams", async () => {
|
||||
const executor = new KiroExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
const rawResponse = new Response("ok", { status: 200 });
|
||||
let transformed = null;
|
||||
executor.transformEventStreamToSSE = (response, model) => {
|
||||
transformed = { response, model };
|
||||
return new Response("data: [DONE]\n\n", {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "text/event-stream" },
|
||||
});
|
||||
};
|
||||
|
||||
globalThis.fetch = async () => new Response("upstream error", { status: 429 });
|
||||
try {
|
||||
const errorResult = await executor.execute({
|
||||
model: "kiro-model",
|
||||
body: { conversationState: {} },
|
||||
stream: true,
|
||||
credentials: { accessToken: "kiro-token" },
|
||||
});
|
||||
assert.equal(errorResult.response.status, 429);
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
|
||||
globalThis.fetch = async () => rawResponse;
|
||||
try {
|
||||
const successResult = await executor.execute({
|
||||
model: "kiro-model",
|
||||
body: { conversationState: {} },
|
||||
stream: true,
|
||||
credentials: { accessToken: "kiro-token" },
|
||||
});
|
||||
|
||||
assert.equal(successResult.response.status, 200);
|
||||
assert.equal(transformed.response, rawResponse);
|
||||
assert.equal(transformed.model, "kiro-model");
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
|
||||
test("KiroExecutor.refreshCredentials handles missing and AWS-style refresh tokens", async () => {
|
||||
const executor = new KiroExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = async (url) => {
|
||||
assert.match(String(url), /oidc\.us-east-1\.amazonaws\.com\/token$/);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
expiresIn: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { "Content-Type": "application/json" } }
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
assert.equal(await executor.refreshCredentials({}, null), null);
|
||||
const result = await executor.refreshCredentials(
|
||||
{
|
||||
refreshToken: "refresh",
|
||||
providerSpecificData: { clientId: "client", clientSecret: "secret" },
|
||||
},
|
||||
null
|
||||
);
|
||||
assert.deepEqual(result, {
|
||||
accessToken: "new-access-token",
|
||||
refreshToken: "new-refresh-token",
|
||||
expiresIn: 3600,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
34
tests/unit/executor-pollinations.test.mjs
Normal file
34
tests/unit/executor-pollinations.test.mjs
Normal file
@@ -0,0 +1,34 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { PollinationsExecutor } from "../../open-sse/executors/pollinations.ts";
|
||||
|
||||
test("PollinationsExecutor.buildUrl uses the free Pollinations endpoint", () => {
|
||||
const executor = new PollinationsExecutor();
|
||||
assert.equal(
|
||||
executor.buildUrl("openai", true),
|
||||
"https://text.pollinations.ai/openai/chat/completions"
|
||||
);
|
||||
});
|
||||
|
||||
test("PollinationsExecutor.buildHeaders omits auth when no key is present", () => {
|
||||
const executor = new PollinationsExecutor();
|
||||
assert.deepEqual(executor.buildHeaders({}, true), {
|
||||
"Content-Type": "application/json",
|
||||
Accept: "text/event-stream",
|
||||
});
|
||||
});
|
||||
|
||||
test("PollinationsExecutor.buildHeaders supports optional API auth", () => {
|
||||
const executor = new PollinationsExecutor();
|
||||
assert.deepEqual(executor.buildHeaders({ apiKey: "poll-key" }, false), {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer poll-key",
|
||||
});
|
||||
});
|
||||
|
||||
test("PollinationsExecutor.transformRequest is a passthrough for alias models", () => {
|
||||
const executor = new PollinationsExecutor();
|
||||
const body = { model: "claude", messages: [{ role: "user", content: "hello" }] };
|
||||
assert.equal(executor.transformRequest("claude", body, true, {}), body);
|
||||
});
|
||||
71
tests/unit/executor-puter.test.mjs
Normal file
71
tests/unit/executor-puter.test.mjs
Normal file
@@ -0,0 +1,71 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { PuterExecutor } from "../../open-sse/executors/puter.ts";
|
||||
|
||||
test("PuterExecutor.buildUrl always uses Puter OpenAI endpoint", () => {
|
||||
const executor = new PuterExecutor();
|
||||
assert.equal(
|
||||
executor.buildUrl("gpt-4.1", true),
|
||||
"https://api.puter.com/puterai/openai/v1/chat/completions"
|
||||
);
|
||||
});
|
||||
|
||||
test("PuterExecutor.buildHeaders supports API key, access token and optional SSE accept", () => {
|
||||
const executor = new PuterExecutor();
|
||||
const apiKeyHeaders = executor.buildHeaders({ apiKey: "puter-key" }, true);
|
||||
const accessTokenHeaders = executor.buildHeaders({ accessToken: "puter-token" }, false);
|
||||
|
||||
assert.deepEqual(apiKeyHeaders, {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer puter-key",
|
||||
Accept: "text/event-stream",
|
||||
});
|
||||
assert.deepEqual(accessTokenHeaders, {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer puter-token",
|
||||
});
|
||||
});
|
||||
|
||||
test("PuterExecutor.transformRequest is a passthrough", () => {
|
||||
const executor = new PuterExecutor();
|
||||
const body = {
|
||||
model: "google/gemini-2.5-pro",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
};
|
||||
assert.equal(executor.transformRequest(body.model, body, true, {}), body);
|
||||
});
|
||||
|
||||
test("PuterExecutor.execute uses inherited BaseExecutor flow", async () => {
|
||||
const executor = new PuterExecutor();
|
||||
const originalFetch = globalThis.fetch;
|
||||
let captured;
|
||||
globalThis.fetch = async (url, options) => {
|
||||
captured = { url: String(url), options };
|
||||
return new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
try {
|
||||
const body = {
|
||||
model: "google/gemini-2.5-pro",
|
||||
messages: [{ role: "user", content: "hello" }],
|
||||
};
|
||||
const result = await executor.execute({
|
||||
model: body.model,
|
||||
body,
|
||||
stream: false,
|
||||
credentials: { apiKey: "puter-key" },
|
||||
});
|
||||
|
||||
assert.equal(result.response.status, 200);
|
||||
assert.equal(result.transformedBody, body);
|
||||
assert.equal(result.url, "https://api.puter.com/puterai/openai/v1/chat/completions");
|
||||
assert.equal(captured.options.headers.Authorization, "Bearer puter-key");
|
||||
assert.equal(captured.options.body, JSON.stringify(body));
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch;
|
||||
}
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user