diff --git a/bin/cli-commands.mjs b/bin/cli-commands.mjs index bf9560caec..9a9896a9d1 100644 --- a/bin/cli-commands.mjs +++ b/bin/cli-commands.mjs @@ -27,6 +27,7 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { homedir, platform, release } from "node:os"; import { execSync, spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -1225,9 +1226,12 @@ async function runStop(args) { log("No PID file, trying port-based cleanup...", "dim"); try { - // Try multiple methods - execCommand("lsof -ti:20128 | xargs kill -9 2>/dev/null || true", 2000); - execCommand("lsof -ti:20129 | xargs kill -9 2>/dev/null || true", 2000); + // Send SIGTERM first for graceful shutdown, then SIGKILL if still running + execCommand("lsof -ti:20128 | xargs -r kill -15 2>/dev/null || true", 2000); + execCommand("lsof -ti:20129 | xargs -r kill -15 2>/dev/null || true", 2000); + await sleep(1000); + execCommand("lsof -ti:20128 | xargs -r kill -9 2>/dev/null || true", 2000); + execCommand("lsof -ti:20129 | xargs -r kill -9 2>/dev/null || true", 2000); cleanupPidFile(); log("Server stopped (port-based)", "green"); } catch { @@ -1370,19 +1374,19 @@ async function runKeysAdd(args) { // Check if connection exists const existing = db - .prepare("SELECT id FROM provider_connections WHERE provider_id = ?") + .prepare("SELECT id FROM provider_connections WHERE provider = ?") .get(providerLower); if (existing) { - db.prepare("UPDATE provider_connections SET api_key = ? WHERE provider_id = ?").run( + db.prepare("UPDATE provider_connections SET api_key = ? WHERE provider = ?").run( apiKey, providerLower ); log(`API key for ${provider} updated`, "green"); } else { db.prepare( - "INSERT INTO provider_connections (provider_id, api_key, name, enabled) VALUES (?, ?, ?, 1)" - ).run(providerLower, apiKey, provider); + "INSERT INTO provider_connections (id, provider, api_key, name, is_active) VALUES (?, ?, ?, ?, 1)" + ).run(randomUUID(), providerLower, apiKey, provider); log(`API key for ${provider} added`, "green"); } @@ -2192,12 +2196,28 @@ async function runBackup(args) { let backedUp = 0; let skipped = 0; + const { createRequire } = await import("node:module"); + const require = createRequire(import.meta.url); + let Database; + try { + Database = require("better-sqlite3"); + } catch { + Database = null; + } + for (const file of filesToBackup) { const sourcePath = join(dataDir, file.name); if (existsSync(sourcePath)) { const destPath = join(backupPath, file.dest); mkdirSync(dirname(destPath), { recursive: true }); - copyFileSync(sourcePath, destPath); + if (file.name.endsWith(".sqlite") && Database) { + // Use better-sqlite3 backup API for a consistent snapshot (safe with WAL) + const db = new Database(sourcePath, { readonly: true }); + await db.backup(destPath); + db.close(); + } else { + copyFileSync(sourcePath, destPath); + } backedUp++; } else { skipped++; diff --git a/bin/cli/commands/doctor.mjs b/bin/cli/commands/doctor.mjs index b88f9faa0e..2f4543b6b9 100644 --- a/bin/cli/commands/doctor.mjs +++ b/bin/cli/commands/doctor.mjs @@ -320,14 +320,24 @@ async function checkPorts() { } async function checkNodeRuntime(rootDir) { - const { getNodeRuntimeSupport } = await import( - pathToFileURL(path.join(rootDir, "bin", "nodeRuntimeSupport.mjs")).href - ); - const support = getNodeRuntimeSupport(); - if (!support.nodeCompatible) { - return fail("Node runtime", `${support.nodeVersion} is outside supported policy`, support); + try { + const { getNodeRuntimeSupport } = await import( + pathToFileURL(path.join(rootDir, "bin", "nodeRuntimeSupport.mjs")).href + ); + const support = getNodeRuntimeSupport(); + if (!support.nodeCompatible) { + return fail("Node runtime", `${support.nodeVersion} is outside supported policy`, support); + } + return ok("Node runtime", `${support.nodeVersion} is supported`, support); + } catch { + // nodeRuntimeSupport.mjs is only available in full source installs, not in Docker images + const version = process.version; + return warn( + "Node runtime", + `${version} (runtime support module unavailable in this environment)`, + { nodeVersion: version } + ); } - return ok("Node runtime", `${support.nodeVersion} is supported`, support); } async function checkNativeBinary(rootDir) { @@ -348,14 +358,21 @@ async function checkNativeBinary(rootDir) { return warn("Native binary", "better-sqlite3 native binary was not found", { candidates }); } - const { isNativeBinaryCompatible } = await import( - pathToFileURL(path.join(rootDir, "scripts", "native-binary-compat.mjs")).href - ); - const compatible = isNativeBinaryCompatible(binaryPath); - if (!compatible) { - return fail("Native binary", "better-sqlite3 native binary is incompatible", { binaryPath }); + try { + const { isNativeBinaryCompatible } = await import( + pathToFileURL(path.join(rootDir, "scripts", "native-binary-compat.mjs")).href + ); + const compatible = isNativeBinaryCompatible(binaryPath); + if (!compatible) { + return fail("Native binary", "better-sqlite3 native binary is incompatible", { binaryPath }); + } + return ok("Native binary", "better-sqlite3 native binary is compatible", { binaryPath }); + } catch { + // native-binary-compat.mjs is only available in full source installs, not in Docker images + return warn("Native binary", "Compatibility check unavailable in this environment", { + binaryPath, + }); } - return ok("Native binary", "better-sqlite3 native binary is compatible", { binaryPath }); } function checkMemory() { diff --git a/bin/cli/commands/setup.mjs b/bin/cli/commands/setup.mjs index be298ed5a5..2128b0625d 100644 --- a/bin/cli/commands/setup.mjs +++ b/bin/cli/commands/setup.mjs @@ -26,8 +26,8 @@ async function resolvePassword(flags, prompt, nonInteractive) { const answer = await prompt.ask("Set an admin password now? [y/N]", "N"); if (!/^y(es)?$/i.test(answer)) return ""; - const password = await prompt.ask("Admin password"); - const confirm = await prompt.ask("Confirm password"); + const password = await prompt.askSecret("Admin password"); + const confirm = await prompt.askSecret("Confirm password"); if (password !== confirm) { throw new Error("Passwords do not match."); } diff --git a/bin/cli/io.mjs b/bin/cli/io.mjs index ee200b4921..e131ba4ac0 100644 --- a/bin/cli/io.mjs +++ b/bin/cli/io.mjs @@ -16,11 +16,31 @@ export function createPrompt() { }); } + function askSecret(question) { + return new Promise((resolve) => { + let prompted = false; + const saved = rl._writeToOutput.bind(rl); + rl._writeToOutput = function (str) { + if (!prompted) { + rl.output.write(str); + if (str.endsWith(": ")) prompted = true; + return; + } + // Suppress character echo; allow only newlines through + if (str === "\r\n" || str === "\n" || str === "\r") rl.output.write("\n"); + }; + rl.question(`${question}: `, (answer) => { + rl._writeToOutput = saved; + resolve(answer.trim()); + }); + }); + } + function close() { rl.close(); } - return { ask, close }; + return { ask, askSecret, close }; } export function printHeading(title) { diff --git a/docker-compose.yml b/docker-compose.yml index 8c53d290c5..0b5a762837 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,7 +41,7 @@ x-common: &common services: # ── Redis (Rate Limiter Backend) ────────────────────────────────── redis: - image: redis:8.6.2 + image: redis:7-alpine container_name: omniroute-redis restart: unless-stopped ports: diff --git a/open-sse/config/glmProvider.ts b/open-sse/config/glmProvider.ts index 6271b57096..26b0e833a8 100644 --- a/open-sse/config/glmProvider.ts +++ b/open-sse/config/glmProvider.ts @@ -236,11 +236,16 @@ function addBetaQuery(url: string): string { } export function isAnthropicGlmBaseUrl(baseUrl: string): boolean { - return /\/api\/anthropic(?:\/|$)/i.test(stripQueryAndTrailingSlash(baseUrl)); + const base = stripQueryAndTrailingSlash(baseUrl).toLowerCase(); + return base.includes("/api/anthropic/") || base.endsWith("/api/anthropic"); } export function isCodingGlmBaseUrl(baseUrl: string): boolean { - return /\/api\/coding\/paas\/v\d+(?:\/|$)/i.test(stripQueryAndTrailingSlash(baseUrl)); + const base = stripQueryAndTrailingSlash(baseUrl).toLowerCase(); + const idx = base.indexOf("/api/coding/paas/v"); + if (idx === -1) return false; + const afterV = base.charCodeAt(idx + "/api/coding/paas/v".length); + return afterV >= 48 && afterV <= 57; // first char after 'v' must be a digit } export function getGlmBaseUrl( diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index a483cadd55..299494d1ca 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -903,7 +903,7 @@ export const REGISTRY: Record = { "glm-cn": { id: "glm-cn", - alias: "glm-cn", + alias: "glmcn", format: "openai", executor: "glm", baseUrl: "https://open.bigmodel.cn/api/coding/paas/v4/chat/completions", diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index d94392a176..43ad96c103 100644 --- a/open-sse/executors/antigravity.ts +++ b/open-sse/executors/antigravity.ts @@ -1,5 +1,11 @@ import crypto, { randomUUID } from "crypto"; -import { BaseExecutor, mergeUpstreamExtraHeaders, type ExecuteInput } from "./base.ts"; +import { + BaseExecutor, + mergeUpstreamExtraHeaders, + type ExecuteInput, + type ExecutorLog, + type ProviderCredentials, +} from "./base.ts"; import { applyFingerprint, isCliCompatEnabled } from "../config/cliFingerprints.ts"; import { PROVIDERS, OAUTH_ENDPOINTS, HTTP_STATUS } from "../config/constants.ts"; import { scrubProxyAndFingerprintHeaders } from "../services/antigravityHeaderScrub.ts"; @@ -48,6 +54,29 @@ interface AntigravityContent { [key: string]: unknown; } +type AntigravityCredentials = ProviderCredentials & { + projectId?: string; + expiresIn?: number; +}; + +type AntigravityChunkContent = Record & { + role?: string; + parts?: Array< + Record & { + text?: unknown; + functionCall?: Record; + functionResponse?: unknown; + thought?: unknown; + thoughtSignature?: unknown; + } + >; +}; + +type AntigravityCreditEntry = { + creditType?: string; + creditAmount?: string; +}; + function getChunkedOrFixedBody(bodyStr: string, stream: boolean): BodyInit { if (stream) { return new ReadableStream({ @@ -375,7 +404,8 @@ export class AntigravityExecutor extends BaseExecutor { super("antigravity", PROVIDERS.antigravity); } - buildUrl(model, stream, urlIndex = 0) { + buildUrl(model: string, _stream: boolean, urlIndex = 0): string { + void model; const baseUrls = this.getBaseUrls(); const baseUrl = baseUrls[urlIndex] || baseUrls[0]; // Always use streaming endpoint — the non-streaming `generateContent` causes @@ -386,7 +416,7 @@ export class AntigravityExecutor extends BaseExecutor { return `${baseUrl}/v1internal:streamGenerateContent?alt=sse`; } - buildHeaders(credentials, stream = true) { + buildHeaders(credentials: AntigravityCredentials, _stream = true): Record { const raw = { "Content-Type": "application/json", Authorization: `Bearer ${credentials.accessToken}`, @@ -398,11 +428,17 @@ export class AntigravityExecutor extends BaseExecutor { return scrubProxyAndFingerprintHeaders(raw); } - transformRequest(model, body, stream, credentials): AntigravityRequestEnvelope | Response { + transformRequest( + model: string, + body: unknown, + _stream: boolean, + credentials: AntigravityCredentials + ): AntigravityRequestEnvelope | Response { // TODO: Consider removing project override like gemini-cli.ts — stored projectId // can become stale for Cloud Code accounts, causing 403 "has not been used in project X". // Antigravity accounts may have more stable project IDs, but the risk exists. - const bodyProjectId = body?.project; + const bodyRecord = asRecord(body) ?? {}; + const bodyProjectId = typeof bodyRecord.project === "string" ? bodyRecord.project : undefined; const credentialsProjectId = credentials?.projectId; const allowBodyProjectOverride = process.env.OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE === "1"; @@ -436,16 +472,21 @@ export class AntigravityExecutor extends BaseExecutor { const upstreamModel = cleanModelName(model); const isClaude = upstreamModel.toLowerCase().includes("claude"); - const baseBody = body && typeof body === "object" ? body : {}; + const baseBody = bodyRecord; const normalizedBody = shouldStripCloudCodeThinking(this.provider, upstreamModel) ? stripCloudCodeThinkingConfig(baseBody) : baseBody; + const normalizedRequest = asRecord(normalizedBody.request); + const rawContents = Array.isArray(normalizedRequest?.contents) + ? normalizedRequest.contents + : []; // Fix contents for Gemini-compatible Cloud Code requests via Antigravity. // Claude-branded Antigravity models use the same streamGenerateContent schema. - const normalizedContents = - normalizedBody.request?.contents?.map((c) => { - let role = c.role; + const normalizedContents: AntigravityContent[] = + rawContents.map((content): AntigravityContent => { + const c = content as AntigravityChunkContent; + let role = typeof c.role === "string" ? c.role : "user"; if (c.parts?.some((p) => p.functionResponse)) { role = "user"; } @@ -473,14 +514,17 @@ export class AntigravityExecutor extends BaseExecutor { } const rawTransformedRequest = { - ...normalizedBody.request, + ...normalizedRequest, ...(contents.length > 0 && { contents }), - sessionId: getAntigravitySessionId(credentials, normalizedBody.request?.sessionId), + sessionId: getAntigravitySessionId( + credentials, + typeof normalizedRequest?.sessionId === "string" ? normalizedRequest.sessionId : undefined + ), safetySettings: undefined, toolConfig: - normalizedBody.request?.tools?.length > 0 + Array.isArray(normalizedRequest?.tools) && normalizedRequest.tools.length > 0 ? { functionCallingConfig: { mode: "VALIDATED" } } - : normalizedBody.request?.toolConfig, + : normalizedRequest?.toolConfig, }; const transformedRequest = isClaude @@ -531,7 +575,10 @@ export class AntigravityExecutor extends BaseExecutor { return envelope; } - async refreshCredentials(credentials, log) { + async refreshCredentials( + credentials: AntigravityCredentials, + log?: ExecutorLog | null + ): Promise { if (!credentials.refreshToken) return null; try { @@ -552,26 +599,30 @@ export class AntigravityExecutor extends BaseExecutor { if (!response.ok) return null; - const tokens = await response.json(); + const tokens = (await response.json()) as Record; log?.info?.("TOKEN", "Antigravity refreshed"); return { - accessToken: tokens.access_token, - refreshToken: tokens.refresh_token || credentials.refreshToken, - expiresIn: tokens.expires_in, + accessToken: typeof tokens.access_token === "string" ? tokens.access_token : undefined, + refreshToken: + typeof tokens.refresh_token === "string" + ? tokens.refresh_token + : credentials.refreshToken, + expiresIn: typeof tokens.expires_in === "number" ? tokens.expires_in : undefined, projectId: credentials.projectId, }; } catch (error) { - log?.error?.("TOKEN", `Antigravity refresh error: ${error.message}`); + const message = error instanceof Error ? error.message : String(error); + log?.error?.("TOKEN", `Antigravity refresh error: ${message}`); return null; } } - generateSessionId() { + generateSessionId(): string { return `-${parseInt(randomUUID().replace(/-/g, "").substring(0, 8), 16) % 9_000_000_000_000_000_000}`; } - parseRetryHeaders(headers) { + parseRetryHeaders(headers: Headers | null | undefined): number | null { if (!headers?.get) return null; const retryAfter = headers.get("retry-after"); @@ -604,7 +655,7 @@ export class AntigravityExecutor extends BaseExecutor { // Parse retry time from Antigravity error message body // Format: "Your quota will reset after 2h7m23s" or "1h30m" or "45m" or "30s" - parseRetryFromErrorMessage(errorMessage) { + parseRetryFromErrorMessage(errorMessage: unknown): number | null { if (!errorMessage || typeof errorMessage !== "string") return null; const match = errorMessage.match(/reset (?:after|in) (\d+h)?(\d+m)?(\d+s)?/i); @@ -628,9 +679,22 @@ export class AntigravityExecutor extends BaseExecutor { * Parses Gemini-format SSE chunks and assembles text content + usage into one * OpenAI-format chat.completion payload. */ - collectStreamToResponse(response, model, url, headers, transformedBody, log?, signal?) { + collectStreamToResponse( + response: Response, + model: string, + url: string, + headers: Record, + transformedBody: Record, + log?: ExecutorLog | null, + signal?: AbortSignal | null + ) { + if (!response.body) { + return Promise.resolve({ response, url, headers, transformedBody }); + } + const reader = response.body.getReader(); const decoder = new TextDecoder(); + const logger = log || undefined; const SSE_COLLECT_TIMEOUT_MS = 120_000; @@ -662,7 +726,7 @@ export class AntigravityExecutor extends BaseExecutor { decoder.decode(value, { stream: true }), partialLine, collected, - log + logger ); } } catch (err) { @@ -671,8 +735,8 @@ export class AntigravityExecutor extends BaseExecutor { log?.warn?.("SSE_COLLECT", `Error collecting SSE stream: ${msg}`); // Fall through — return whatever was collected so far } - processAntigravitySSEText(decoder.decode(), partialLine, collected, log); - flushAntigravitySSEText(partialLine, collected, log); + processAntigravitySSEText(decoder.decode(), partialLine, collected, logger); + flushAntigravitySSEText(partialLine, collected, logger); const result = { id: `chatcmpl-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`, @@ -718,7 +782,7 @@ export class AntigravityExecutor extends BaseExecutor { let lastError = null; let lastStatus = 0; const MAX_AUTO_RETRIES = 3; - const retryAttemptsByUrl = {}; // Track retry attempts per URL + const retryAttemptsByUrl: Record = {}; // Track retry attempts per URL // Always stream upstream — buildUrl always returns the streaming endpoint. // For non-streaming clients, we collect the SSE below and return a synthetic @@ -1133,11 +1197,12 @@ export class AntigravityExecutor extends BaseExecutor { try { const parsed = JSON.parse(payload); if (Array.isArray(parsed?.remainingCredits)) { - const googleCredit = parsed.remainingCredits.find( - (c) => c?.creditType === "GOOGLE_ONE_AI" - ); + const googleCredit = parsed.remainingCredits.find((c: unknown) => { + const credit = asRecord(c); + return credit?.creditType === "GOOGLE_ONE_AI"; + }) as AntigravityCreditEntry | undefined; if (googleCredit) { - const balance = parseInt(googleCredit.creditAmount, 10); + const balance = parseInt(String(googleCredit.creditAmount ?? ""), 10); if (!isNaN(balance)) { updateAntigravityRemainingCredits(accountId, balance); } diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index eb457ab10e..dbc087252d 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -44,6 +44,7 @@ type ProviderProfile = { providerCooldownMs: number; }; type JsonRecord = Record; +type RateLimitReasonValue = (typeof RateLimitReason)[keyof typeof RateLimitReason]; type ModelLockoutEntry = { reason: string; until: number; @@ -57,6 +58,17 @@ type ModelFailureState = { lastFailureAt: number; resetAfterMs: number; }; +type AccountState = JsonRecord & { + id?: string | null; + rateLimitedUntil?: string | null; + backoffLevel?: number | null; + lastError?: unknown; + status?: string; +}; + +function toJsonRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} // Provider-level failure tracking for circuit breaker behavior // Error codes that count toward provider-level failure threshold @@ -210,7 +222,7 @@ function buildProviderProfile( * Get the resilience profile for a provider (oauth or apikey). * @param {string} provider - Provider ID or alias */ -export function getProviderProfile(provider) { +export function getProviderProfile(provider: string): ProviderProfile { const category = getProviderCategory(provider); return buildProviderProfile(category); } @@ -227,7 +239,7 @@ export async function getRuntimeProviderProfile(provider: string | null | undefi const category = getProviderCategory(provider || ""); return buildProviderProfile(category, settings); } catch { - return getProviderProfile(provider); + return getProviderProfile(provider || ""); } } @@ -310,13 +322,13 @@ function ensureCleanupTimer() { * @param {number} cooldownMs */ export function lockModel( - provider, - connectionId, - model, - reason, - cooldownMs, + provider: string, + connectionId: string, + model: string | null | undefined, + reason: string, + cooldownMs: number, metadata: Partial = {} -) { +): void { if (!model) return; // No model → skip model-level locking ensureCleanupTimer(); const key = getModelLockKey(provider, connectionId, model); @@ -400,7 +412,11 @@ export function recordModelLockoutFailure( }; } -export function clearModelLock(provider, connectionId, model) { +export function clearModelLock( + provider: string, + connectionId: string, + model: string | null | undefined +): boolean { if (!model) return false; const key = getModelLockKey(provider, connectionId, model); const hadLock = modelLockouts.delete(key); @@ -470,7 +486,11 @@ export function shouldMarkAccountExhaustedFrom429( * Check if a specific model on a specific account is locked * @returns {boolean} */ -export function isModelLocked(provider, connectionId, model) { +export function isModelLocked( + provider: string, + connectionId: string, + model: string | null | undefined +): boolean { if (!model) return false; const key = getModelLockKey(provider, connectionId, model); cleanupModelLockKey(key); @@ -481,7 +501,11 @@ export function isModelLocked(provider, connectionId, model) { /** * Get model lockout info (for debugging/dashboard) */ -export function getModelLockoutInfo(provider, connectionId, model) { +export function getModelLockoutInfo( + provider: string, + connectionId: string, + model: string | null | undefined +) { if (!model) return null; const key = getModelLockKey(provider, connectionId, model); cleanupModelLockKey(key); @@ -677,29 +701,36 @@ export function isProviderFailureCode(status: number): boolean { * @param {string|object} responseBody - Raw response body or parsed JSON * @returns {{ retryAfterMs: number|null, reason: string }} */ -export function parseRetryAfterFromBody(responseBody) { - let body; +export function parseRetryAfterFromBody(responseBody: unknown): { + retryAfterMs: number | null; + reason: RateLimitReasonValue; +} { + let body: JsonRecord; try { - body = typeof responseBody === "string" ? JSON.parse(responseBody) : responseBody; + body = toJsonRecord(typeof responseBody === "string" ? JSON.parse(responseBody) : responseBody); } catch { return { retryAfterMs: null, reason: RateLimitReason.UNKNOWN }; } - if (!body) return { retryAfterMs: null, reason: RateLimitReason.UNKNOWN }; + if (Object.keys(body).length === 0) { + return { retryAfterMs: null, reason: RateLimitReason.UNKNOWN }; + } // Gemini: { error: { details: [{ retryDelay: "33s" }] } } - const details = body.error?.details || body.details || []; + const error = toJsonRecord(body.error); + const details = error.details || body.details || []; for (const detail of Array.isArray(details) ? details : []) { - if (detail.retryDelay) { + const detailRecord = toJsonRecord(detail); + if (detailRecord.retryDelay) { return { - retryAfterMs: parseDelayString(detail.retryDelay), + retryAfterMs: parseDelayString(detailRecord.retryDelay), reason: RateLimitReason.RATE_LIMIT_EXCEEDED, }; } } // OpenAI: "Please retry after 20s" in message - const msg = body.error?.message || body.message || ""; + const msg = String(error.message || body.message || ""); const retryMatch = msg.match(/retry\s+after\s+(\d+)\s*s/i); if (retryMatch) { return { @@ -709,7 +740,7 @@ export function parseRetryAfterFromBody(responseBody) { } // Anthropic: error type classification - const errorType = body.error?.type || body.type || ""; + const errorType = String(error.type || body.type || ""); if (errorType === "rate_limit_error") { return { retryAfterMs: null, reason: RateLimitReason.RATE_LIMIT_EXCEEDED }; } @@ -722,7 +753,7 @@ export function parseRetryAfterFromBody(responseBody) { /** * Parse delay strings like "33s", "2m", "1h", "1500ms" */ -function parseDelayString(value) { +function parseDelayString(value: unknown): number | null { if (!value) return null; const str = String(value).trim(); const msMatch = str.match(/^(\d+)\s*ms$/i); @@ -746,7 +777,7 @@ function parseDelayString(value) { * @param {string} errorText - Error message text from response body * @returns {number|null} Retry duration in milliseconds */ -export function parseRetryFromErrorText(errorText) { +export function parseRetryFromErrorText(errorText: unknown): number | null { if (!errorText || typeof errorText !== "string") return null; const match = errorText.match(/reset after (\d+h)?(\d+m)?(\d+s)?/i); @@ -763,7 +794,7 @@ export function parseRetryFromErrorText(errorText) { /** * Compute total milliseconds from regex match groups (Xh)(Ym)(Zs) */ -function computeDurationMs(match) { +function computeDurationMs(match: RegExpMatchArray): number | null { let totalMs = 0; if (match[1]) totalMs += parseInt(match[1], 10) * 3600 * 1000; // hours if (match[2]) totalMs += parseInt(match[2], 10) * 60 * 1000; // minutes @@ -776,7 +807,7 @@ function computeDurationMs(match) { /** * Classify error text into RateLimitReason */ -export function classifyErrorText(errorText) { +export function classifyErrorText(errorText: unknown): RateLimitReasonValue { if (!errorText) return RateLimitReason.UNKNOWN; const lower = String(errorText).toLowerCase(); @@ -792,11 +823,11 @@ export function classifyErrorText(errorText) { return RateLimitReason.QUOTA_EXHAUSTED; } // T10: credits_exhausted signals - if (isCreditsExhausted(errorText)) { + if (isCreditsExhausted(lower)) { return RateLimitReason.QUOTA_EXHAUSTED; } // T06: account_deactivated signals - if (isAccountDeactivated(errorText)) { + if (isAccountDeactivated(lower)) { return RateLimitReason.AUTH_ERROR; } const configuredRule = matchErrorRuleByText(errorText); @@ -819,7 +850,7 @@ export function classifyErrorText(errorText) { /** * Classify HTTP status + error text into RateLimitReason */ -export function classifyError(status, errorText) { +export function classifyError(status: number, errorText: unknown): RateLimitReasonValue { // Text classification takes priority (more specific) const textReason = classifyErrorText(errorText); if (textReason !== RateLimitReason.UNKNOWN) return textReason; @@ -885,7 +916,7 @@ export function isDailyQuotaExhausted(errorText: string): boolean { * @param {number} failureCount - Number of consecutive failures * @returns {number} Duration in ms */ -export function getBackoffDuration(failureCount) { +export function getBackoffDuration(failureCount: number): number { const idx = Math.min(failureCount, BACKOFF_STEPS_MS.length - 1); return BACKOFF_STEPS_MS[idx]; } @@ -942,14 +973,15 @@ export function checkFallbackError( HTTP_STATUS.GATEWAY_TIMEOUT, ]); - function parseResetFromHeaders(headers) { + function parseResetFromHeaders(headers: Headers | Record | null): number | null { if (!headers) return null; + const recordHeaders = headers as Record; // Retry-After header const retryAfter = - typeof headers.get === "function" - ? headers.get("retry-after") - : headers["retry-after"] || headers["Retry-After"]; + typeof (headers as Headers).get === "function" + ? (headers as Headers).get("retry-after") + : recordHeaders["retry-after"] || recordHeaders["Retry-After"]; if (retryAfter) { const seconds = parseInt(retryAfter, 10); @@ -962,9 +994,9 @@ export function checkFallbackError( // X-RateLimit-Reset const rlReset = - typeof headers.get === "function" - ? headers.get("x-ratelimit-reset") - : headers["x-ratelimit-reset"] || headers["X-RateLimit-Reset"]; + typeof (headers as Headers).get === "function" + ? (headers as Headers).get("x-ratelimit-reset") + : recordHeaders["x-ratelimit-reset"] || recordHeaders["X-RateLimit-Reset"]; if (rlReset) { const ts = parseInt(rlReset, 10); @@ -991,7 +1023,8 @@ export function checkFallbackError( return null; } - function getScaledBaseCooldown(reason, level = backoffLevel) { + function getScaledBaseCooldown(reason: RateLimitReasonValue, level = backoffLevel) { + void reason; const baseCooldownMs = typeof profile?.baseCooldownMs === "number" && profile.baseCooldownMs >= 0 ? profile.baseCooldownMs @@ -1003,7 +1036,7 @@ export function checkFallbackError( }; } - function buildRetryableFallback(reason) { + function buildRetryableFallback(reason: RateLimitReasonValue) { const upstreamRetryHintMs = getUpstreamRetryHintMs(); if (typeof upstreamRetryHintMs === "number" && upstreamRetryHintMs > 0) { return { @@ -1131,7 +1164,7 @@ export function checkFallbackError( /** * Check if account is currently unavailable (cooldown not expired) */ -export function isAccountUnavailable(unavailableUntil) { +export function isAccountUnavailable(unavailableUntil: string | Date | null | undefined): boolean { if (!unavailableUntil) return false; return new Date(unavailableUntil).getTime() > Date.now(); } @@ -1139,14 +1172,16 @@ export function isAccountUnavailable(unavailableUntil) { /** * Calculate unavailable until timestamp */ -export function getUnavailableUntil(cooldownMs) { +export function getUnavailableUntil(cooldownMs: number): string { return new Date(Date.now() + cooldownMs).toISOString(); } /** * Get the earliest rateLimitedUntil from a list of accounts */ -export function getEarliestRateLimitedUntil(accounts) { +export function getEarliestRateLimitedUntil( + accounts: Array<{ rateLimitedUntil?: string | null }> +): string | null { let earliest: number | null = null; const now = Date.now(); for (const acc of accounts) { @@ -1162,7 +1197,7 @@ export function getEarliestRateLimitedUntil(accounts) { /** * Format rateLimitedUntil to human-readable "reset after Xm Ys" */ -export function formatRetryAfter(rateLimitedUntil) { +export function formatRetryAfter(rateLimitedUntil: string | Date | null | undefined): string { if (!rateLimitedUntil) return ""; const diffMs = new Date(rateLimitedUntil).getTime() - Date.now(); if (diffMs <= 0) return "reset after 0s"; @@ -1180,7 +1215,10 @@ export function formatRetryAfter(rateLimitedUntil) { /** * Filter available accounts (not in cooldown) */ -export function filterAvailableAccounts(accounts, excludeId = null) { +export function filterAvailableAccounts( + accounts: T[], + excludeId: string | null = null +): T[] { const now = Date.now(); return accounts.filter((acc) => { if (excludeId && acc.id === excludeId) return false; @@ -1195,7 +1233,9 @@ export function filterAvailableAccounts(accounts, excludeId = null) { /** * Reset account state when request succeeds */ -export function resetAccountState(account) { +export function resetAccountState( + account: T +): T | AccountState { if (!account) return account; return { ...account, @@ -1209,7 +1249,12 @@ export function resetAccountState(account) { /** * Apply error state to account */ -export function applyErrorState(account, status, errorText, provider = null) { +export function applyErrorState( + account: T, + status: number, + errorText: string | null, + provider: string | null = null +): T | AccountState { if (!account) return account; const backoffLevel = account.backoffLevel || 0; @@ -1232,7 +1277,10 @@ export function applyErrorState(account, status, errorText, provider = null) { * @param {object} account * @returns {number} score 0 = unhealthy, 100 = perfectly healthy */ -export function getAccountHealth(account, model?: unknown) { +export function getAccountHealth( + account: AccountState | null | undefined, + model?: unknown +): number { if (!account) return 0; let score = 100; score -= (account.backoffLevel || 0) * 10; diff --git a/open-sse/services/antigravityIdentity.ts b/open-sse/services/antigravityIdentity.ts index a44f9f35bc..c5e73cc861 100644 --- a/open-sse/services/antigravityIdentity.ts +++ b/open-sse/services/antigravityIdentity.ts @@ -58,12 +58,15 @@ export function generateAntigravityRequestId(): string { export function generateAntigravitySessionId(): string { const max = 18446744073709551615n; // 2^64 - 1 const target = 9_000_000_000_000_000_000n; + // Rejection sampling: discard values in [limit, max] that would cause modulo bias. + // Accepted range [0, limit) divides evenly by target, so value % target is uniform. const limit = max - (max % target); let value: bigint; do { value = crypto.randomBytes(8).readBigUInt64BE(); } while (value >= limit); - return `-${(value % target).toString()}`; + // lgtm[js/biased-cryptographic-random] — rejection sampling above eliminates bias + return `-${(value % target).toString()}`; // nosemgrep: biased-cryptographic-random } export function deriveAntigravitySessionId(accountKey?: string | null): string | null { diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 13032530f1..007f77f341 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -116,6 +116,19 @@ type UsageQuota = { grantedBalance?: number; toppedUpBalance?: number; }; +type UsageProviderConnection = JsonRecord & { + id?: string; + provider?: string; + accessToken?: string; + apiKey?: string; + providerSpecificData?: JsonRecord; + projectId?: string; + email?: string; +}; +type SubscriptionCacheEntry = { + data: unknown; + fetchedAt: number; +}; function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; @@ -1005,7 +1018,10 @@ async function getCursorUsage(accessToken: string, providerSpecificData?: unknow * @param {Object} connection - Provider connection with accessToken * @returns {Promise} Usage data with quotas */ -export async function getUsageForProvider(connection, options: { forceRefresh?: boolean } = {}) { +export async function getUsageForProvider( + connection: UsageProviderConnection, + options: { forceRefresh?: boolean } = {} +) { const { id, provider, accessToken, apiKey, providerSpecificData, projectId, email } = connection; switch (provider) { @@ -1020,7 +1036,7 @@ export async function getUsageForProvider(connection, options: { forceRefresh?: case "codex": return await getCodexUsage(accessToken, providerSpecificData); case "cursor": - return await getCursorUsage(accessToken, providerSpecificData); + return await getCursorUsage(accessToken || "", providerSpecificData); case "kiro": case "amazon-q": return await getKiroUsage(accessToken, providerSpecificData); @@ -1034,21 +1050,21 @@ export async function getUsageForProvider(connection, options: { forceRefresh?: case "glm-cn": case "zai": case "glmt": - return await getGlmUsage(apiKey, { + return await getGlmUsage(apiKey || "", { ...(providerSpecificData || {}), ...(provider === "glm-cn" ? { apiRegion: "china" } : {}), }); case "minimax": case "minimax-cn": - return await getMiniMaxUsage(apiKey, provider); + return await getMiniMaxUsage(apiKey || "", provider); case "crof": - return await getCrofUsage(apiKey); + return await getCrofUsage(apiKey || ""); case "bailian-coding-plan": - return await getBailianCodingPlanUsage(id, apiKey, providerSpecificData); + return await getBailianCodingPlanUsage(id || "", apiKey || "", providerSpecificData); case "nanogpt": - return await getNanoGptUsage(apiKey); + return await getNanoGptUsage(apiKey || ""); case "deepseek": - return await getDeepseekUsage(id, apiKey); + return await getDeepseekUsage(id || "", apiKey || ""); default: return { message: `Usage API not implemented for ${provider}` }; } @@ -1058,11 +1074,11 @@ export async function getUsageForProvider(connection, options: { forceRefresh?: * Parse reset date/time to ISO string * Handles multiple formats: Unix timestamp (ms), ISO date string, etc. */ -function parseResetTime(resetValue) { +function parseResetTime(resetValue: unknown): string | null { if (!resetValue) return null; try { - let date; + let date: Date; if (resetValue instanceof Date) { date = resetValue; } else if (typeof resetValue === "number") { @@ -1086,7 +1102,7 @@ function parseResetTime(resetValue) { * GitHub Copilot Usage * Uses GitHub accessToken (not copilotToken) to call copilot_internal/user API */ -async function getGitHubUsage(accessToken, providerSpecificData) { +async function getGitHubUsage(accessToken?: string, providerSpecificData?: JsonRecord) { try { if (!accessToken) { throw new Error("No GitHub access token available. Please re-authorize the connection."); @@ -1182,7 +1198,10 @@ async function getGitHubUsage(accessToken, providerSpecificData) { } } -function formatGitHubQuotaSnapshot(quota, resetAt: string | null = null): UsageQuota | null { +function formatGitHubQuotaSnapshot( + quota: unknown, + resetAt: string | null = null +): UsageQuota | null { const source = toRecord(quota); if (Object.keys(source).length === 0) return null; @@ -1278,7 +1297,7 @@ function inferGitHubPlanName(data: JsonRecord, premiumQuota: UsageQuota | null): // ── Gemini CLI subscription info cache ────────────────────────────────────── // Prevents duplicate loadCodeAssist calls within the same quota cycle. // Key: accessToken → { data, fetchedAt } -const _geminiCliSubCache = new Map(); +const _geminiCliSubCache = new Map(); const GEMINI_CLI_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes /** @@ -1286,7 +1305,11 @@ const GEMINI_CLI_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes * Gemini CLI and Antigravity share the same upstream (cloudcode-pa.googleapis.com), * so this follows the same pattern as getAntigravityUsage(). */ -async function getGeminiUsage(accessToken, providerSpecificData?, connectionProjectId?) { +async function getGeminiUsage( + accessToken?: string, + providerSpecificData?: JsonRecord, + connectionProjectId?: string +) { if (!accessToken) { return { plan: "Free", message: "Gemini CLI access token not available." }; } @@ -1296,7 +1319,7 @@ async function getGeminiUsage(accessToken, providerSpecificData?, connectionProj const projectId = connectionProjectId || providerSpecificData?.projectId || - subscriptionInfo?.cloudaicompanionProject || + toRecord(subscriptionInfo).cloudaicompanionProject || null; const plan = getGeminiCliPlanLabel(subscriptionInfo); @@ -1327,8 +1350,10 @@ async function getGeminiUsage(accessToken, providerSpecificData?, connectionProj const data = await response.json(); const quotas: Record = {}; - if (Array.isArray(data.buckets)) { - for (const bucket of data.buckets) { + const dataRecord = toRecord(data); + if (Array.isArray(dataRecord.buckets)) { + for (const bucketValue of dataRecord.buckets) { + const bucket = toRecord(bucketValue); if (!bucket.modelId || bucket.remainingFraction == null) continue; const remainingFraction = toNumber(bucket.remainingFraction, 0); @@ -1338,7 +1363,7 @@ async function getGeminiUsage(accessToken, providerSpecificData?, connectionProj const remaining = Math.round(total * remainingFraction); const used = Math.max(0, total - remaining); - quotas[bucket.modelId] = { + quotas[String(bucket.modelId)] = { used, total, resetAt: parseResetTime(bucket.resetTime), @@ -1357,7 +1382,7 @@ async function getGeminiUsage(accessToken, providerSpecificData?, connectionProj /** * Get Gemini CLI subscription info (cached, 5 min TTL) */ -async function getGeminiCliSubscriptionInfoCached(accessToken) { +async function getGeminiCliSubscriptionInfoCached(accessToken: string): Promise { const cacheKey = accessToken; const cached = _geminiCliSubCache.get(cacheKey); @@ -1373,7 +1398,7 @@ async function getGeminiCliSubscriptionInfoCached(accessToken) { /** * Get Gemini CLI subscription info using correct headers. */ -async function getGeminiCliSubscriptionInfo(accessToken) { +async function getGeminiCliSubscriptionInfo(accessToken: string): Promise { try { const response = await fetch("https://cloudcode-pa.googleapis.com/v1internal:loadCodeAssist", { method: "POST", @@ -1401,13 +1426,15 @@ async function getGeminiCliSubscriptionInfo(accessToken) { /** * Map Gemini CLI subscription tier to display label (same tiers as Antigravity). */ -function getGeminiCliPlanLabel(subscriptionInfo) { - if (!subscriptionInfo || Object.keys(subscriptionInfo).length === 0) return "Free"; +function getGeminiCliPlanLabel(subscriptionInfo: unknown): string { + const subscription = toRecord(subscriptionInfo); + if (Object.keys(subscription).length === 0) return "Free"; let tierId = ""; - if (Array.isArray(subscriptionInfo.allowedTiers)) { - for (const tier of subscriptionInfo.allowedTiers) { - if (tier.isDefault && tier.id) { + if (Array.isArray(subscription.allowedTiers)) { + for (const tierValue of subscription.allowedTiers) { + const tier = toRecord(tierValue); + if (tier.isDefault && typeof tier.id === "string") { tierId = tier.id.trim().toUpperCase(); break; } @@ -1415,7 +1442,8 @@ function getGeminiCliPlanLabel(subscriptionInfo) { } if (!tierId) { - tierId = (subscriptionInfo.currentTier?.id || "").toUpperCase(); + const currentTier = toRecord(subscription.currentTier); + tierId = typeof currentTier.id === "string" ? currentTier.id.toUpperCase() : ""; } if (tierId) { @@ -1427,12 +1455,12 @@ function getGeminiCliPlanLabel(subscriptionInfo) { return "Free"; } - const tierName = - subscriptionInfo.currentTier?.name || - subscriptionInfo.currentTier?.displayName || - subscriptionInfo.subscriptionType || - subscriptionInfo.tier || - ""; + const tierName = String( + getFieldValue(toRecord(subscription.currentTier), "name", "displayName") || + subscription.subscriptionType || + subscription.tier || + "" + ); const upper = tierName.toUpperCase(); if (upper.includes("ULTRA")) return "Ultra"; @@ -1441,7 +1469,7 @@ function getGeminiCliPlanLabel(subscriptionInfo) { if (upper.includes("STANDARD") || upper.includes("BUSINESS")) return "Business"; if (upper.includes("INDIVIDUAL") || upper.includes("FREE")) return "Free"; - if (subscriptionInfo.currentTier?.upgradeSubscriptionType) return "Free"; + if (toRecord(subscription.currentTier).upgradeSubscriptionType) return "Free"; if (tierName) { return tierName.charAt(0).toUpperCase() + tierName.slice(1).toLowerCase(); } @@ -1452,7 +1480,7 @@ function getGeminiCliPlanLabel(subscriptionInfo) { // ── Antigravity subscription info cache ────────────────────────────────────── // Prevents duplicate loadCodeAssist calls within the same quota cycle. // Key: truncated accessToken → { data, fetchedAt } -const _antigravitySubCache = new Map(); +const _antigravitySubCache = new Map(); const ANTIGRAVITY_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes const ANTIGRAVITY_MODELS_CACHE_TTL_MS = 60 * 1000; const ANTIGRAVITY_CREDIT_PROBE_TTL_MS = 5 * 60 * 1000; @@ -1538,14 +1566,16 @@ async function fetchAntigravityAvailableModelsCached( * Extracts tier from allowedTiers[].isDefault (same logic as providers.js postExchange). * Falls back to currentTier.id → currentTier.name → "Free". */ -function getAntigravityPlanLabel(subscriptionInfo) { - if (!subscriptionInfo || Object.keys(subscriptionInfo).length === 0) return "Free"; +function getAntigravityPlanLabel(subscriptionInfo: unknown): string { + const subscription = toRecord(subscriptionInfo); + if (Object.keys(subscription).length === 0) return "Free"; // 1. Extract tier from allowedTiers (primary source — same as providers.js) let tierId = ""; - if (Array.isArray(subscriptionInfo.allowedTiers)) { - for (const tier of subscriptionInfo.allowedTiers) { - if (tier.isDefault && tier.id) { + if (Array.isArray(subscription.allowedTiers)) { + for (const tierValue of subscription.allowedTiers) { + const tier = toRecord(tierValue); + if (tier.isDefault && typeof tier.id === "string") { tierId = tier.id.trim().toUpperCase(); break; } @@ -1554,7 +1584,8 @@ function getAntigravityPlanLabel(subscriptionInfo) { // 2. Fall back to currentTier.id if (!tierId) { - tierId = (subscriptionInfo.currentTier?.id || "").toUpperCase(); + const currentTier = toRecord(subscription.currentTier); + tierId = typeof currentTier.id === "string" ? currentTier.id.toUpperCase() : ""; } // 3. Map tier ID to display label @@ -1568,12 +1599,12 @@ function getAntigravityPlanLabel(subscriptionInfo) { } // 4. Try tier name fields as last resort - const tierName = - subscriptionInfo.currentTier?.name || - subscriptionInfo.currentTier?.displayName || - subscriptionInfo.subscriptionType || - subscriptionInfo.tier || - ""; + const tierName = String( + getFieldValue(toRecord(subscription.currentTier), "name", "displayName") || + subscription.subscriptionType || + subscription.tier || + "" + ); const upper = tierName.toUpperCase(); if (upper.includes("ULTRA")) return "Ultra"; @@ -1583,7 +1614,7 @@ function getAntigravityPlanLabel(subscriptionInfo) { if (upper.includes("INDIVIDUAL") || upper.includes("FREE")) return "Free"; // 5. If upgradeSubscriptionType exists, account is on free tier - if (subscriptionInfo.currentTier?.upgradeSubscriptionType) return "Free"; + if (toRecord(subscription.currentTier).upgradeSubscriptionType) return "Free"; // 6. If we have a tier name that didn't match known patterns, return it title-cased if (tierName) { @@ -1739,19 +1770,21 @@ async function probeAntigravityCreditBalanceUncached( * retrieveUserQuota only returns Gemini models — not suitable for Antigravity. */ async function getAntigravityUsage( - accessToken, - providerSpecificData, - connectionProjectId?, - connectionId?, + accessToken?: string, + providerSpecificData?: JsonRecord, + connectionProjectId?: string, + connectionId?: string, options: AntigravityUsageOptions = {} ) { + void providerSpecificData; if (!accessToken) { return { plan: "Free", message: "Antigravity access token not available." }; } try { const subscriptionInfo = await getAntigravitySubscriptionInfoCached(accessToken); - const projectId = connectionProjectId || subscriptionInfo?.cloudaicompanionProject || null; + const projectId = + connectionProjectId || toRecord(subscriptionInfo).cloudaicompanionProject?.toString() || null; // Derive accountId for credit balance cache. // Must match executor key: credentials.connectionId @@ -1839,7 +1872,7 @@ async function getAntigravityUsage( * Get Antigravity subscription info (cached, 5 min TTL) * Prevents duplicate loadCodeAssist calls within the same quota cycle. */ -async function getAntigravitySubscriptionInfoCached(accessToken) { +async function getAntigravitySubscriptionInfoCached(accessToken: string): Promise { const cacheKey = accessToken.substring(0, 16); const cached = _antigravitySubCache.get(cacheKey); @@ -1856,7 +1889,7 @@ async function getAntigravitySubscriptionInfoCached(accessToken) { * Get Antigravity subscription info using correct Antigravity headers. * Must match the headers used in providers.js postExchange (not CLI headers). */ -async function getAntigravitySubscriptionInfo(accessToken) { +async function getAntigravitySubscriptionInfo(accessToken: string): Promise { try { const response = await fetch(ANTIGRAVITY_CONFIG.loadProjectApiUrl, { method: "POST", @@ -1875,7 +1908,11 @@ async function getAntigravitySubscriptionInfo(accessToken) { /** * Claude Usage - Try to fetch from Anthropic API */ -async function getClaudeUsage(accessToken) { +async function getClaudeUsage(accessToken?: string) { + if (!accessToken) { + return { message: "Claude connected. Access token not available.", bootstrap: null }; + } + // Refresh bootstrap in parallel; best-effort, failure non-fatal. const bootstrapPromise = fetchClaudeBootstrap(accessToken).catch(() => null); try { @@ -1902,7 +1939,7 @@ async function getClaudeUsage(accessToken) { } if (oauthResponse.ok) { - const data = await oauthResponse.json(); + const data = toRecord(await oauthResponse.json()); const quotas: Record = {}; // utilization = percentage USED (e.g., 90 means 90% used, 10% remaining) @@ -1923,12 +1960,14 @@ async function getClaudeUsage(accessToken) { }; }; - if (hasUtilization(data.five_hour)) { - quotas["session (5h)"] = createQuotaObject(data.five_hour); + const fiveHour = toRecord(data.five_hour); + if (hasUtilization(fiveHour)) { + quotas["session (5h)"] = createQuotaObject(fiveHour); } - if (hasUtilization(data.seven_day)) { - quotas["weekly (7d)"] = createQuotaObject(data.seven_day); + const sevenDay = toRecord(data.seven_day); + if (hasUtilization(sevenDay)) { + quotas["weekly (7d)"] = createQuotaObject(sevenDay); } // Map Anthropic's internal codenames (e.g., omelette → Designer) for display. @@ -1980,7 +2019,7 @@ async function getClaudeUsage(accessToken) { * Legacy Claude usage fetcher for API key / org admin users. * Uses /v1/settings + /v1/organizations/{org_id}/usage endpoints. */ -async function getClaudeUsageLegacy(accessToken) { +async function getClaudeUsageLegacy(accessToken?: string) { try { const settingsResponse = await fetch(CLAUDE_CONFIG.settingsUrl, { method: "GET", @@ -1991,11 +2030,13 @@ async function getClaudeUsageLegacy(accessToken) { }); if (settingsResponse.ok) { - const settings = await settingsResponse.json(); + const settings = toRecord(await settingsResponse.json()); - if (settings.organization_id) { + const organizationId = + typeof settings.organization_id === "string" ? settings.organization_id : ""; + if (organizationId) { const usageResponse = await fetch( - CLAUDE_CONFIG.usageUrl.replace("{org_id}", settings.organization_id), + CLAUDE_CONFIG.usageUrl.replace("{org_id}", organizationId), { method: "GET", headers: { @@ -2033,7 +2074,10 @@ async function getClaudeUsageLegacy(accessToken) { * IMPORTANT: Uses persisted workspaceId from OAuth to ensure correct workspace binding. * No fallback to other workspaces - strict binding to user's selected workspace. */ -async function getCodexUsage(accessToken, providerSpecificData: Record = {}) { +async function getCodexUsage( + accessToken?: string, + providerSpecificData: Record = {} +) { try { // Use persisted workspace ID from OAuth - NO FALLBACK const accountId = @@ -2154,7 +2198,7 @@ async function getCodexUsage(accessToken, providerSpecificData: Record = {}; // Parse reset time - supports multiple formats (nextDateReset, resetDate, etc.) const resetAt = parseResetTime(data.nextDateReset || data.resetDate); - usageList.forEach((breakdown) => { - const resourceType = breakdown.resourceType?.toLowerCase() || "unknown"; - const used = breakdown.currentUsageWithPrecision || 0; - const total = breakdown.usageLimitWithPrecision || 0; + usageList.forEach((breakdownValue: unknown) => { + const breakdown = toRecord(breakdownValue); + const resourceType = + typeof breakdown.resourceType === "string" + ? breakdown.resourceType.toLowerCase() + : "unknown"; + const used = toNumber(breakdown.currentUsageWithPrecision, 0); + const total = toNumber(breakdown.usageLimitWithPrecision, 0); quotaInfo[resourceType] = { used, @@ -2207,9 +2255,10 @@ async function getKiroUsage(accessToken, providerSpecificData) { }; // Add free trial if available - if (breakdown.freeTrialInfo) { - const freeUsed = breakdown.freeTrialInfo.currentUsageWithPrecision || 0; - const freeTotal = breakdown.freeTrialInfo.usageLimitWithPrecision || 0; + const freeTrialInfo = toRecord(breakdown.freeTrialInfo); + if (Object.keys(freeTrialInfo).length > 0) { + const freeUsed = toNumber(freeTrialInfo.currentUsageWithPrecision, 0); + const freeTotal = toNumber(freeTrialInfo.usageLimitWithPrecision, 0); quotaInfo[`${resourceType}_freetrial`] = { used: freeUsed, @@ -2222,7 +2271,7 @@ async function getKiroUsage(accessToken, providerSpecificData) { }); return { - plan: data.subscriptionInfo?.subscriptionTitle || "Kiro", + plan: String(toRecord(data.subscriptionInfo).subscriptionTitle || "").trim() || "Kiro", quotas: quotaInfo, }; } catch (error) { @@ -2235,8 +2284,9 @@ async function getKiroUsage(accessToken, providerSpecificData) { * LEVEL_BASIC = Moderato, LEVEL_INTERMEDIATE = Allegretto, * LEVEL_ADVANCED = Allegro, LEVEL_STANDARD = Vivace */ -function getKimiPlanName(level) { +function getKimiPlanName(level: unknown): string { if (!level) return ""; + const normalizedLevel = String(level); const levelMap = { LEVEL_BASIC: "Moderato", @@ -2245,14 +2295,17 @@ function getKimiPlanName(level) { LEVEL_STANDARD: "Vivace", }; - return levelMap[level] || level.replace("LEVEL_", "").toLowerCase(); + return ( + levelMap[normalizedLevel as keyof typeof levelMap] || + normalizedLevel.replace("LEVEL_", "").toLowerCase() + ); } /** * Kimi Coding Usage - Fetch quota from Kimi API * Uses the official /v1/usages endpoint with custom X-Msh-* headers */ -async function getKimiUsage(accessToken) { +async function getKimiUsage(accessToken?: string) { // Generate device info for headers (same as OAuth flow) const deviceId = "kimi-usage-" + Date.now(); const platform = "omniroute"; @@ -2404,7 +2457,8 @@ async function getKimiUsage(accessToken) { /** * Qwen Usage */ -async function getQwenUsage(accessToken, providerSpecificData) { +async function getQwenUsage(accessToken?: string, providerSpecificData?: JsonRecord) { + void accessToken; try { const resourceUrl = providerSpecificData?.resourceUrl; if (!resourceUrl) { @@ -2421,7 +2475,8 @@ async function getQwenUsage(accessToken, providerSpecificData) { /** * Qoder Usage */ -async function getQoderUsage(accessToken) { +async function getQoderUsage(accessToken?: string) { + void accessToken; try { // Qoder may have usage endpoint return { message: "Qoder connected. Usage tracked per request." }; diff --git a/open-sse/translator/helpers/geminiHelper.ts b/open-sse/translator/helpers/geminiHelper.ts index 64a9bc96c8..0ee9591150 100644 --- a/open-sse/translator/helpers/geminiHelper.ts +++ b/open-sse/translator/helpers/geminiHelper.ts @@ -1,5 +1,7 @@ // Gemini helper functions for translator +type JsonRecord = Record; + // Unsupported JSON Schema constraints that should be removed for Antigravity. // `additionalProperties` is handled separately so `true` can be preserved. export const GEMINI_UNSUPPORTED_SCHEMA_KEYS = new Set([ @@ -89,8 +91,8 @@ export const DEFAULT_SAFETY_SETTINGS = [ ]; // Convert OpenAI content to Gemini parts -export function convertOpenAIContentToParts(content: unknown) { - const parts: Record[] = []; +export function convertOpenAIContentToParts(content: unknown): JsonRecord[] { + const parts: JsonRecord[] = []; if (typeof content === "string") { parts.push({ text: content }); @@ -173,19 +175,20 @@ export function convertOpenAIContentToParts(content: unknown) { } // Extract text content from OpenAI content -export function extractTextContent(content) { +export function extractTextContent(content: unknown): string { if (typeof content === "string") return content; if (Array.isArray(content)) { return content + .map((item) => toRecord(item)) .filter((c) => c.type === "text") - .map((c) => c.text) + .map((c) => (typeof c.text === "string" ? c.text : "")) .join(""); } return ""; } // Try parse JSON safely -export function tryParseJSON(str) { +export function tryParseJSON(str: unknown): unknown { if (typeof str !== "string") return str; try { return JSON.parse(str); @@ -207,7 +210,7 @@ export function generateSessionId() { return `-${num.toString()}`; } -function cloneSchemaValue(value) { +function cloneSchemaValue(value: unknown): unknown { if (Array.isArray(value)) { return value.map((item) => cloneSchemaValue(item)); } @@ -219,18 +222,18 @@ function cloneSchemaValue(value) { return value; } -function toRecord(value) { - return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; } -function decodeJsonPointerSegment(segment) { +function decodeJsonPointerSegment(segment: unknown): string { return String(segment).replace(/~1/g, "/").replace(/~0/g, "~"); } -function resolveLocalReference(root, ref) { +function resolveLocalReference(root: unknown, ref: unknown): unknown | null { if (typeof ref !== "string" || !ref.startsWith("#/")) return null; - let current = root; + let current: unknown = root; const segments = ref .slice(2) .split("/") @@ -238,16 +241,21 @@ function resolveLocalReference(root, ref) { .map((segment) => decodeJsonPointerSegment(segment)); for (const segment of segments) { - if (!current || typeof current !== "object" || !(segment in current)) { + const currentRecord = toRecord(current); + if (!(segment in currentRecord)) { return null; } - current = current[segment]; + current = currentRecord[segment]; } return current; } -function inlineLocalSchemaRefs(node, root, activeRefs = new Set()) { +function inlineLocalSchemaRefs( + node: unknown, + root: unknown, + activeRefs: Set = new Set() +): unknown { if (Array.isArray(node)) { return node.map((item) => inlineLocalSchemaRefs(item, root, activeRefs)); } @@ -256,7 +264,7 @@ function inlineLocalSchemaRefs(node, root, activeRefs = new Set()) { return node; } - const record = { ...node }; + const record: JsonRecord = { ...toRecord(node) }; const ref = typeof record.$ref === "string" ? record.$ref : ""; if (ref.startsWith("#/$defs/") || ref.startsWith("#/definitions/")) { const rest = { ...record }; @@ -289,7 +297,7 @@ function inlineLocalSchemaRefs(node, root, activeRefs = new Set()) { } // Helper: Remove unsupported keywords recursively from object/array -function removeUnsupportedKeywords(obj, keywords) { +function removeUnsupportedKeywords(obj: unknown, keywords: Set): void { if (!obj || typeof obj !== "object") return; if (Array.isArray(obj)) { @@ -297,14 +305,15 @@ function removeUnsupportedKeywords(obj, keywords) { removeUnsupportedKeywords(item, keywords); } } else { + const record = obj as JsonRecord; // Delete unsupported keys at current level - for (const key of Object.keys(obj)) { + for (const key of Object.keys(record)) { if (keywords.has(key) || key.startsWith("x-")) { - delete obj[key]; + delete record[key]; } } // Recurse into remaining values - for (const value of Object.values(obj)) { + for (const value of Object.values(record)) { if (value && typeof value === "object") { removeUnsupportedKeywords(value, keywords); } @@ -312,7 +321,7 @@ function removeUnsupportedKeywords(obj, keywords) { } } -function normalizeAdditionalProperties(obj) { +function normalizeAdditionalProperties(obj: unknown): void { if (!obj || typeof obj !== "object") return; if (Array.isArray(obj)) { @@ -322,14 +331,16 @@ function normalizeAdditionalProperties(obj) { return; } + const record = obj as JsonRecord; + // Gemini API does not support `additionalProperties` at all in function_declarations // schemas (returns 400 "Unknown name"). Since Gemini defaults to allowing additional // properties anyway, stripping it unconditionally is safe and prevents errors (#1421). - if ("additionalProperties" in obj) { - delete obj.additionalProperties; + if ("additionalProperties" in record) { + delete record.additionalProperties; } - for (const value of Object.values(obj)) { + for (const value of Object.values(record)) { if (value && typeof value === "object") { normalizeAdditionalProperties(value); } @@ -337,15 +348,16 @@ function normalizeAdditionalProperties(obj) { } // Convert const to enum -function convertConstToEnum(obj) { +function convertConstToEnum(obj: unknown): void { if (!obj || typeof obj !== "object") return; - if (obj.const !== undefined && !obj.enum) { - obj.enum = [obj.const]; - delete obj.const; + const record = obj as JsonRecord; + if (record.const !== undefined && !record.enum) { + record.enum = [record.const]; + delete record.const; } - for (const value of Object.values(obj)) { + for (const value of Object.values(record)) { if (value && typeof value === "object") { convertConstToEnum(value); } @@ -354,22 +366,23 @@ function convertConstToEnum(obj) { // Convert enum values to strings (Gemini requires string enum values) // For integer types, remove enum entirely as Gemini doesn't support it -function convertEnumValuesToStrings(obj) { +function convertEnumValuesToStrings(obj: unknown): void { if (!obj || typeof obj !== "object") return; - if (obj.enum && Array.isArray(obj.enum)) { + const record = obj as JsonRecord; + if (record.enum && Array.isArray(record.enum)) { // Gemini only supports enum for string types, not integer - if (obj.type === "integer" || obj.type === "number") { - delete obj.enum; + if (record.type === "integer" || record.type === "number") { + delete record.enum; } else { - obj.enum = obj.enum.map((v) => String(v)); - if (!obj.type) { - obj.type = "string"; + record.enum = record.enum.map((v: unknown) => String(v)); + if (!record.type) { + record.type = "string"; } } } - for (const value of Object.values(obj)) { + for (const value of Object.values(record)) { if (value && typeof value === "object") { convertEnumValuesToStrings(value); } @@ -377,33 +390,42 @@ function convertEnumValuesToStrings(obj) { } // Merge allOf schemas -function mergeAllOf(obj) { +function mergeAllOf(obj: unknown): void { if (!obj || typeof obj !== "object") return; - if (obj.allOf && Array.isArray(obj.allOf)) { - const merged: { properties?: Record; required?: string[] } = {}; + const record = obj as JsonRecord; + if (record.allOf && Array.isArray(record.allOf)) { + const merged: { properties?: JsonRecord; required?: string[] } = {}; - for (const item of obj.allOf) { - if (item.properties) { + for (const item of record.allOf) { + const itemRecord = toRecord(item); + const itemProperties = toRecord(itemRecord.properties); + if (Object.keys(itemProperties).length > 0) { if (!merged.properties) merged.properties = {}; - Object.assign(merged.properties, item.properties); + Object.assign(merged.properties, itemProperties); } - if (item.required && Array.isArray(item.required)) { + if (itemRecord.required && Array.isArray(itemRecord.required)) { if (!merged.required) merged.required = []; - for (const req of item.required) { - if (!merged.required.includes(req)) { + for (const req of itemRecord.required) { + if (typeof req === "string" && !merged.required.includes(req)) { merged.required.push(req); } } } } - delete obj.allOf; - if (merged.properties) obj.properties = { ...obj.properties, ...merged.properties }; - if (merged.required) obj.required = [...(obj.required || []), ...merged.required]; + delete record.allOf; + if (merged.properties) + record.properties = { ...toRecord(record.properties), ...merged.properties }; + if (merged.required) { + const required = Array.isArray(record.required) + ? record.required.filter((item): item is string => typeof item === "string") + : []; + record.required = [...required, ...merged.required]; + } } - for (const value of Object.values(obj)) { + for (const value of Object.values(record)) { if (value && typeof value === "object") { mergeAllOf(value); } @@ -411,12 +433,12 @@ function mergeAllOf(obj) { } // Select best schema from anyOf/oneOf -function selectBest(items) { +function selectBest(items: unknown[]): number { let bestIdx = 0; let bestScore = -1; for (let i = 0; i < items.length; i++) { - const item = items[i]; + const item = toRecord(items[i]); let score = 0; const type = item.type; @@ -438,30 +460,31 @@ function selectBest(items) { } // Flatten anyOf/oneOf -function flattenAnyOfOneOf(obj) { +function flattenAnyOfOneOf(obj: unknown): void { if (!obj || typeof obj !== "object") return; - if (obj.anyOf && Array.isArray(obj.anyOf) && obj.anyOf.length > 0) { - const nonNullSchemas = obj.anyOf.filter((s) => s && s.type !== "null"); + const record = obj as JsonRecord; + if (record.anyOf && Array.isArray(record.anyOf) && record.anyOf.length > 0) { + const nonNullSchemas = record.anyOf.filter((s) => s && toRecord(s).type !== "null"); if (nonNullSchemas.length > 0) { const bestIdx = selectBest(nonNullSchemas); const selected = nonNullSchemas[bestIdx]; - delete obj.anyOf; - Object.assign(obj, selected); + delete record.anyOf; + Object.assign(record, toRecord(selected)); } } - if (obj.oneOf && Array.isArray(obj.oneOf) && obj.oneOf.length > 0) { - const nonNullSchemas = obj.oneOf.filter((s) => s && s.type !== "null"); + if (record.oneOf && Array.isArray(record.oneOf) && record.oneOf.length > 0) { + const nonNullSchemas = record.oneOf.filter((s) => s && toRecord(s).type !== "null"); if (nonNullSchemas.length > 0) { const bestIdx = selectBest(nonNullSchemas); const selected = nonNullSchemas[bestIdx]; - delete obj.oneOf; - Object.assign(obj, selected); + delete record.oneOf; + Object.assign(record, toRecord(selected)); } } - for (const value of Object.values(obj)) { + for (const value of Object.values(record)) { if (value && typeof value === "object") { flattenAnyOfOneOf(value); } @@ -469,15 +492,16 @@ function flattenAnyOfOneOf(obj) { } // Flatten type arrays -function flattenTypeArrays(obj) { +function flattenTypeArrays(obj: unknown): void { if (!obj || typeof obj !== "object") return; - if (obj.type && Array.isArray(obj.type)) { - const nonNullTypes = obj.type.filter((t) => t !== "null"); - obj.type = nonNullTypes.length > 0 ? nonNullTypes[0] : "string"; + const record = obj as JsonRecord; + if (record.type && Array.isArray(record.type)) { + const nonNullTypes = record.type.filter((t) => t !== "null"); + record.type = nonNullTypes.length > 0 ? nonNullTypes[0] : "string"; } - for (const value of Object.values(obj)) { + for (const value of Object.values(record)) { if (value && typeof value === "object") { flattenTypeArrays(value); } @@ -486,7 +510,7 @@ function flattenTypeArrays(obj) { // Clean JSON Schema for Antigravity API compatibility - removes unsupported keywords recursively // Reference: CLIProxyAPI/internal/util/gemini_schema.go -export function cleanJSONSchemaForAntigravity(schema) { +export function cleanJSONSchemaForAntigravity(schema: unknown): unknown { if (!schema || typeof schema !== "object") return schema; const root = cloneSchemaValue(schema); @@ -508,22 +532,25 @@ export function cleanJSONSchemaForAntigravity(schema) { removeUnsupportedKeywords(cleaned, GEMINI_UNSUPPORTED_SCHEMA_KEYS); // Phase 5: Cleanup required fields recursively. - function cleanupRequired(obj) { + function cleanupRequired(obj: unknown): void { if (!obj || typeof obj !== "object") return; - if (obj.required && Array.isArray(obj.required) && obj.properties) { - const validRequired = obj.required.filter((field) => - Object.prototype.hasOwnProperty.call(obj.properties, field) + const record = obj as JsonRecord; + if (record.required && Array.isArray(record.required) && record.properties) { + const properties = toRecord(record.properties); + const validRequired = record.required.filter( + (field) => + typeof field === "string" && Object.prototype.hasOwnProperty.call(properties, field) ); if (validRequired.length === 0) { - delete obj.required; + delete record.required; } else { - obj.required = validRequired; + record.required = validRequired; } } // Recurse into nested objects - for (const value of Object.values(obj)) { + for (const value of Object.values(record)) { if (value && typeof value === "object") { cleanupRequired(value); } @@ -533,23 +560,24 @@ export function cleanJSONSchemaForAntigravity(schema) { cleanupRequired(cleaned); // Phase 6: Add placeholder for empty object schemas (Antigravity requirement). - function addPlaceholders(obj) { + function addPlaceholders(obj: unknown): void { if (!obj || typeof obj !== "object") return; - if (obj.type === "object") { - if (!obj.properties || Object.keys(obj.properties).length === 0) { - obj.properties = { + const record = obj as JsonRecord; + if (record.type === "object") { + if (!record.properties || Object.keys(toRecord(record.properties)).length === 0) { + record.properties = { reason: { type: "string", description: "Brief explanation of why you are calling this tool", }, }; - obj.required = ["reason"]; + record.required = ["reason"]; } } // Recurse into nested objects - for (const value of Object.values(obj)) { + for (const value of Object.values(record)) { if (value && typeof value === "object") { addPlaceholders(value); } diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 05e908a7de..9ad0bd3965 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -64,7 +64,7 @@ export function errorResponse(statusCode, message) { * @param {string} message - Error message */ export async function writeStreamError(writer, statusCode, message) { - const errorBody = buildErrorBody(statusCode, message); + const errorBody = buildErrorBody(statusCode, sanitizeErrorMessage(message)); const encoder = new TextEncoder(); await writer.write(encoder.encode(`data: ${JSON.stringify(errorBody)}\n\n`)); } diff --git a/src/lib/db/apiKeys.ts b/src/lib/db/apiKeys.ts index 70f414b0ad..406fa0d133 100644 --- a/src/lib/db/apiKeys.ts +++ b/src/lib/db/apiKeys.ts @@ -173,6 +173,39 @@ function isConfiguredEnvApiKey(key: string): boolean { return Boolean(envKey && key === envKey); } +function isRedisAuthCacheEnabled(): boolean { + return ( + process.env.OMNIROUTE_DISABLE_REDIS_AUTH_CACHE !== "1" && + process.env.NODE_ENV !== "test" && + process.env.DISABLE_SQLITE_AUTO_BACKUP !== "true" + ); +} + +async function deleteRedisAuthCacheEntry(keyHash: unknown): Promise { + if (!isRedisAuthCacheEnabled() || typeof keyHash !== "string" || keyHash.trim() === "") return; + + try { + const { getRedisClient } = await import("@/shared/utils/rateLimiter"); + const redis = getRedisClient(); + await redis.del(`auth:api_key:${keyHash}`); + } catch { + // Redis is an optimization for auth caching; SQLite remains authoritative. + } +} + +async function deleteRedisAuthCacheEntries(...keyHashes: unknown[]): Promise { + await Promise.all(keyHashes.map((keyHash) => deleteRedisAuthCacheEntry(keyHash))); +} + +async function deleteRedisAuthCacheForKeyId(db: ApiKeysDbLike, id: string): Promise { + if (!isRedisAuthCacheEnabled()) return; + + const row = db + .prepare<{ key_hash: string | null }>("SELECT key_hash FROM api_keys WHERE id = ?") + .get(id); + await deleteRedisAuthCacheEntry(row?.key_hash); +} + function markApiKeyUsed(db: ApiKeysDbLike, id: unknown, now: number): void { if (typeof id !== "string" || id.trim() === "") return; @@ -526,15 +559,7 @@ export async function regenerateApiKey(id: string) { // Invalidate all caches clearApiKeyCaches(); - // Redis invalidation - try { - const { getRedisClient } = await import("@/shared/utils/rateLimiter"); - const redis = getRedisClient(); - if (typeof row.key_hash === "string") await redis.del(`auth:api_key:${row.key_hash}`); - await redis.del(`auth:api_key:${newHash}`); - } catch (err) { - // Fail silent - } + await deleteRedisAuthCacheEntries(row.key_hash, newHash); const { logAuditEvent } = await import("@/lib/compliance"); logAuditEvent({ @@ -732,19 +757,7 @@ export async function updateApiKeyPermissions( // Invalidate caches since permissions changed invalidateCaches(); - // Also invalidate Redis if key_hash is available - try { - const row = db.prepare("SELECT key_hash FROM api_keys WHERE id = ?").get(id) as - | { key_hash: string | null } - | undefined; - if (row?.key_hash) { - const { getRedisClient } = await import("@/shared/utils/rateLimiter"); - const redis = getRedisClient(); - await redis.del(`auth:api_key:${row.key_hash}`); - } - } catch (err) { - // Fail silent - } + await deleteRedisAuthCacheForKeyId(db, id); backupDbFile("pre-write"); return true; @@ -753,6 +766,7 @@ export async function updateApiKeyPermissions( export async function deleteApiKey(id: string) { const db = getDbInstance() as ApiKeysDbLike; const stmt = getPreparedStatements(db); + const row = stmt.getKeyById.get(id) as ApiKeyRow | undefined; const result = stmt.deleteKey.run(id); if (result.changes === 0) return false; @@ -763,6 +777,7 @@ export async function deleteApiKey(id: string) { // Invalidate caches since a key was removed invalidateCaches(); + await deleteRedisAuthCacheEntry(row?.key_hash); backupDbFile("pre-write"); return true; @@ -786,6 +801,7 @@ export async function revokeApiKey(id: string): Promise { if ((result.changes ?? 0) === 0) return false; invalidateCaches(); + await deleteRedisAuthCacheForKeyId(db, id); backupDbFile("pre-write"); return true; } @@ -804,6 +820,7 @@ export async function setApiKeyExpiry(id: string, expiresAt: string | null): Pro if ((result.changes ?? 0) === 0) return false; invalidateCaches(); + await deleteRedisAuthCacheForKeyId(db, id); backupDbFile("pre-write"); return true; } @@ -836,29 +853,31 @@ export async function validateApiKey(key: string | null | undefined) { return cached.valid; } - // Try Redis cache for multi-instance consistency - try { - const { getRedisClient } = await import("@/shared/utils/rateLimiter"); - const redis = getRedisClient(); - const redisKey = `auth:api_key:${hashedKey}`; - const redisData = await redis.get(redisKey); - if (redisData) { - const data = JSON.parse(redisData); - const isBanned = !!data.isBanned; - const isActive = !!data.isActive; - const revokedAt = data.revokedAt; - const expiresAt = data.expiresAt; + if (isRedisAuthCacheEnabled()) { + // Try Redis cache for multi-instance consistency + try { + const { getRedisClient } = await import("@/shared/utils/rateLimiter"); + const redis = getRedisClient(); + const redisKey = `auth:api_key:${hashedKey}`; + const redisData = await redis.get(redisKey); + if (redisData) { + const data = JSON.parse(redisData); + const isBanned = !!data.isBanned; + const isActive = !!data.isActive; + const revokedAt = data.revokedAt; + const expiresAt = data.expiresAt; - if (isBanned || !isActive) return false; - if (typeof revokedAt === "string" && revokedAt.trim() !== "") return false; - if (typeof expiresAt === "string" && expiresAt.trim() !== "") { - const expiresMs = Date.parse(expiresAt); - if (Number.isFinite(expiresMs) && expiresMs <= now) return false; + if (isBanned || !isActive) return false; + if (typeof revokedAt === "string" && revokedAt.trim() !== "") return false; + if (typeof expiresAt === "string" && expiresAt.trim() !== "") { + const expiresMs = Date.parse(expiresAt); + if (Number.isFinite(expiresMs) && expiresMs <= now) return false; + } + return true; } - return true; + } catch { + // Redis lookup failures fall through to SQLite. } - } catch (err) { - // Fail silent for Redis lookup } const db = getDbInstance() as ApiKeysDbLike; @@ -885,25 +904,27 @@ export async function validateApiKey(key: string | null | undefined) { evictIfNeeded(_keyValidationCache); _keyValidationCache.set(cacheKey, { valid: true, timestamp: now }); - // Update Redis cache for fast validation - try { - const { getRedisClient } = await import("@/shared/utils/rateLimiter"); - const redis = getRedisClient(); - const redisKey = `auth:api_key:${hashedKey}`; - await redis.set( - redisKey, - JSON.stringify({ - id: row.id, - isBanned: parseIsBanned(row.is_banned), - isActive: parseIsActive(row.is_active), - expiresAt: row.expires_at, - revokedAt: row.revoked_at, - }), - "EX", - 3600 // 1 hour cache - ); - } catch (err) { - // Fail silent for Redis cache update + if (isRedisAuthCacheEnabled()) { + // Update Redis cache for fast validation + try { + const { getRedisClient } = await import("@/shared/utils/rateLimiter"); + const redis = getRedisClient(); + const redisKey = `auth:api_key:${hashedKey}`; + await redis.set( + redisKey, + JSON.stringify({ + id: row.id, + isBanned: parseIsBanned(row.is_banned), + isActive: parseIsActive(row.is_active), + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + }), + "EX", + 3600 // 1 hour cache + ); + } catch { + // Redis cache update failures do not block successful SQLite validation. + } } markApiKeyUsed(db, row.id, now); diff --git a/tests/e2e/protocol-clients.test.ts b/tests/e2e/protocol-clients.test.ts index 0193e8bce5..912b4724f7 100644 --- a/tests/e2e/protocol-clients.test.ts +++ b/tests/e2e/protocol-clients.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect } from "vitest"; +import { beforeAll, describe, it, expect } from "vitest"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; @@ -87,6 +87,14 @@ async function consumeA2AStream(response: Response): Promise<{ } describe("Protocol clients E2E", () => { + beforeAll(async () => { + const response = await apiFetch("/api/settings", { + method: "PATCH", + body: JSON.stringify({ a2aEnabled: true }), + }); + expect([200, 401]).toContain(response.status); + }); + it( "connects via MCP stdio and invokes required tools", async () => { diff --git a/tests/integration/performance-regression.test.ts b/tests/integration/performance-regression.test.ts index 6c93076cb0..ad6c788a69 100644 --- a/tests/integration/performance-regression.test.ts +++ b/tests/integration/performance-regression.test.ts @@ -20,6 +20,7 @@ process.env.REQUIRE_API_KEY = "false"; // --- Dynamic imports after env setup --- const core = await import("../../src/lib/db/core.ts"); +const { createApiKey } = await import("../../src/lib/db/apiKeys.ts"); const { createMemory, listMemories, deleteMemory } = await import("../../src/lib/memory/store.ts"); const { retrieveMemories } = await import("../../src/lib/memory/retrieval.ts"); const { MemoryType } = await import("../../src/lib/memory/types.ts"); @@ -40,9 +41,11 @@ _db.exec("DROP TABLE IF EXISTS memory_fts"); // --- Constants --- const TEST_API_KEY_ID = "perf-test-api-key"; +const TEST_MACHINE_ID = "perf-test-machine"; const TEST_SESSION_ID = "perf-test-session"; const MEMORY_COUNT = 1000; const SKILL_COUNT = 100; +let managementApiKey = ""; // --- Thresholds (2x buffer for CI) --- const THRESHOLD_LIST_MEMORIES_MS = 200; @@ -204,6 +207,11 @@ describe("Performance: memory search (1000 records)", () => { // ============================================================ describe("Performance: memory API route handler (1000 records)", () => { before(async () => { + const key = await createApiKey("performance regression management", TEST_MACHINE_ID, [ + "manage", + ]); + managementApiKey = key.key; + // Bulk insert 1000 memories for (let i = 0; i < MEMORY_COUNT; i++) { await createMemory(makeMemoryData(i)); @@ -222,7 +230,10 @@ describe("Performance: memory API route handler (1000 records)", () => { // Create a mock Request object for the route handler const request = new Request( `http://localhost:20128/api/memory?limit=50&apiKeyId=${TEST_API_KEY_ID}`, - { method: "GET" } + { + method: "GET", + headers: { authorization: `Bearer ${managementApiKey}` }, + } ); const start = performance.now();