From bbecbccb0af4d13f2dda2342619489bf69899b7d Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Mon, 11 May 2026 09:13:49 -0300 Subject: [PATCH] fix(cli): harden setup, doctor, and backup workflows Hide admin password entry during setup, make doctor degrade to warnings when source-only runtime checks are unavailable, and improve stop behavior by attempting graceful shutdown before force killing ports. Also use SQLite's backup API for safer snapshots under WAL, align CLI key writes with the current provider_connections schema, and include follow-on compatibility fixes for GLM provider detection, stream error sanitization, and auth-aware test coverage. --- bin/cli-commands.mjs | 36 ++- bin/cli/commands/doctor.mjs | 45 ++-- bin/cli/commands/setup.mjs | 4 +- bin/cli/io.mjs | 22 +- docker-compose.yml | 2 +- open-sse/config/glmProvider.ts | 9 +- open-sse/config/providerRegistry.ts | 2 +- open-sse/executors/antigravity.ts | 127 +++++++--- open-sse/services/accountFallback.ts | 138 +++++++---- open-sse/services/antigravityIdentity.ts | 5 +- open-sse/services/usage.ts | 219 +++++++++++------- open-sse/translator/helpers/geminiHelper.ts | 188 ++++++++------- open-sse/utils/error.ts | 2 +- src/lib/db/apiKeys.ts | 143 +++++++----- tests/e2e/protocol-clients.test.ts | 10 +- .../performance-regression.test.ts | 13 +- 16 files changed, 633 insertions(+), 332 deletions(-) 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();