From 40bd60959e33a2ae4d053b3d826c0cdcf6b97eb9 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 10 May 2026 20:48:03 -0300 Subject: [PATCH] refactor(core): strengthen typing and normalize auth and model flows Tighten executor, usage, model-resolution, and state-management code with explicit types and safer record handling to reduce runtime edge cases across providers. Also normalize management-token failures to 403 responses, require API keys consistently on cloud agent task routes with CORS-safe errors, refresh stale Gemini CLI project IDs, prioritize Gemini search tools correctly, add new provider/model registry entries, and serialize integration tests for more reliable CI. --- .github/workflows/ci.yml | 2 +- open-sse/config/providerRegistry.ts | 2 + open-sse/config/runway.ts | 1 + open-sse/executors/antigravity.ts | 163 +++++++++----- open-sse/executors/codex.ts | 59 +++-- open-sse/executors/gemini-cli.ts | 18 +- open-sse/services/accountFallback.ts | 150 +++++++++---- open-sse/services/autoCombo/virtualFactory.ts | 4 +- open-sse/services/model.ts | 102 ++++++--- open-sse/services/thinkingBudget.ts | 129 +++++++---- open-sse/services/usage.ts | 204 ++++++++++------- open-sse/translator/helpers/geminiHelper.ts | 211 ++++++++++-------- .../helpers/geminiToolsSanitizer.ts | 8 +- open-sse/utils/error.ts | 14 +- package.json | 2 +- .../settings/components/MemorySkillsTab.tsx | 2 +- src/app/api/v1/agents/tasks/[id]/route.ts | 88 ++++---- src/app/api/v1/agents/tasks/route.ts | 54 +++-- src/app/docs/lib/docs-auto-generated.ts | 2 +- src/lib/api/requireManagementAuth.ts | 4 +- src/lib/db/apiKeys.ts | 156 ++++++++----- src/lib/db/domainState.ts | 52 +++-- src/lib/db/settings.ts | 11 +- src/shared/constants/providers.ts | 33 +++ src/shared/utils/circuitBreaker.ts | 4 +- src/sse/services/auth.ts | 13 +- .../integration/batch-e2e-rate-limit.test.ts | 43 ++-- tests/integration/proxy-pipeline.test.ts | 2 +- tests/unit/api-auth.test.ts | 12 +- tests/unit/executor-gemini-cli.test.ts | 6 +- tests/unit/model-alias-route.test.ts | 4 +- tests/unit/model-test-route.test.ts | 4 +- tests/unit/payload-rules-route.test.ts | 4 +- tests/unit/plan3-p0.test.ts | 4 +- tests/unit/proxy-management-v1-route.test.ts | 4 +- tests/unit/route-edge-coverage.test.ts | 8 +- tests/unit/sse-auth.test.ts | 8 +- tests/unit/usage-service-hardening.test.ts | 6 +- 38 files changed, 1034 insertions(+), 559 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 647c7a0d43..96036f2971 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -436,7 +436,7 @@ jobs: cache: npm - run: npm ci - run: npm run check:node-runtime - - run: node --import tsx/esm --test --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts + - run: node --import tsx/esm --test --test-force-exit --test-concurrency=1 --test-shard=${{ matrix.shard }}/2 tests/integration/*.test.ts test-security: name: Security Tests diff --git a/open-sse/config/providerRegistry.ts b/open-sse/config/providerRegistry.ts index df4ae34c3d..f365a290e4 100644 --- a/open-sse/config/providerRegistry.ts +++ b/open-sse/config/providerRegistry.ts @@ -591,6 +591,7 @@ export const REGISTRY: Record = { }, models: [ { id: "auto-kiro", name: "Auto (Kiro picks best model)" }, + { id: "claude-opus-4.7", name: "Claude Opus 4.7" }, { id: "claude-opus-4.6", name: "Claude Opus 4.6" }, { id: "claude-opus-4.5", name: "Claude Opus 4.5" }, { id: "claude-sonnet-4.6", name: "Claude Sonnet 4.6" }, @@ -599,6 +600,7 @@ export const REGISTRY: Record = { { id: "claude-haiku-4.5", name: "Claude Haiku 4.5" }, { id: "claude-3.7-sonnet", name: "Claude 3.7 Sonnet" }, // Dash aliases — Claude Code sends dashes, Kiro API uses dots + { id: "claude-opus-4-7", name: "Claude Opus 4.7" }, { id: "claude-opus-4-6", name: "Claude Opus 4.6" }, { id: "claude-opus-4-5", name: "Claude Opus 4.5" }, { id: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, diff --git a/open-sse/config/runway.ts b/open-sse/config/runway.ts index 165a9af4c2..7ea0c6bea0 100644 --- a/open-sse/config/runway.ts +++ b/open-sse/config/runway.ts @@ -4,6 +4,7 @@ export const RUNWAYML_API_VERSION = "2024-11-06"; export const RUNWAYML_SUPPORTED_VIDEO_MODELS = [ { id: "gen4.5", name: "Gen-4.5" }, { id: "gen4_turbo", name: "Gen-4 Turbo" }, + { id: "gen3a_turbo", name: "Gen-3 Alpha Turbo" }, { id: "veo3.1", name: "Veo 3.1" }, { id: "veo3.1_fast", name: "Veo 3.1 Fast" }, ]; diff --git a/open-sse/executors/antigravity.ts b/open-sse/executors/antigravity.ts index 4edc387408..52a1d9bbe7 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"; @@ -42,11 +48,14 @@ const CREDITS_EXHAUSTED_TTL_MS = 5 * 60 * 60 * 1000; // 5 hours const BARE_PRO_IDS = new Set(["gemini-3.1-pro"]); -function getChunkedOrFixedBody(bodyStr: string, stream: boolean) { +function getChunkedOrFixedBody(bodyStr: string, stream: boolean): BodyInit { if (stream) { - return (async function* () { - yield new TextEncoder().encode(bodyStr); - })(); + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(bodyStr)); + controller.close(); + }, + }); } return bodyStr; } @@ -92,6 +101,20 @@ type AntigravityRequestEnvelope = Record & { request: Record; enabledCreditTypes?: string[]; }; +type AntigravityCredentials = ProviderCredentials & { + projectId?: string | null; +}; +type AntigravityPart = Record & { + text?: unknown; + thought?: unknown; + thoughtSignature?: unknown; + functionResponse?: unknown; + functionCall?: { name?: unknown } | null; +}; +type AntigravityContent = Record & { + role?: unknown; + parts?: AntigravityPart[]; +}; /** * Per-account GOOGLE_ONE_AI credits-exhausted tracker. @@ -160,7 +183,7 @@ function markCreditsExhausted(accountId: string): void { function processAntigravitySSEPayload( payload: string, collected: AntigravityCollectedStream, - log?: { debug?: (scope: string, message: string) => void } + log?: { debug?: (scope: string, message: string) => void } | null ) { if (!payload || payload === "[DONE]") return; try { @@ -199,7 +222,7 @@ function processAntigravitySSEText( text: string, partialLine: { value: string }, collected: AntigravityCollectedStream, - log?: { debug?: (scope: string, message: string) => void } + log?: { debug?: (scope: string, message: string) => void } | null ) { partialLine.value += text; const lines = partialLine.value.split("\n"); @@ -215,7 +238,7 @@ function processAntigravitySSEText( function flushAntigravitySSEText( partialLine: { value: string }, collected: AntigravityCollectedStream, - log?: { debug?: (scope: string, message: string) => void } + log?: { debug?: (scope: string, message: string) => void } | null ) { const trimmed = partialLine.value.trim(); partialLine.value = ""; @@ -366,7 +389,9 @@ export class AntigravityExecutor extends BaseExecutor { super("antigravity", PROVIDERS.antigravity); } - buildUrl(model, stream, urlIndex = 0) { + buildUrl(model: string, stream: boolean, urlIndex = 0) { + void model; + void stream; const baseUrls = this.getBaseUrls(); const baseUrl = baseUrls[urlIndex] || baseUrls[0]; // Always use streaming endpoint — the non-streaming `generateContent` causes @@ -377,7 +402,8 @@ export class AntigravityExecutor extends BaseExecutor { return `${baseUrl}/v1internal:streamGenerateContent?alt=sse`; } - buildHeaders(credentials, stream = true) { + buildHeaders(credentials: AntigravityCredentials, stream = true) { + void stream; const raw = { "Content-Type": "application/json", Authorization: `Bearer ${credentials.accessToken}`, @@ -389,11 +415,18 @@ 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 { + void stream; // 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 = bodyRecord.project; const credentialsProjectId = credentials?.projectId; const allowBodyProjectOverride = process.env.OMNIROUTE_ALLOW_BODY_PROJECT_OVERRIDE === "1"; @@ -424,36 +457,43 @@ export class AntigravityExecutor extends BaseExecutor { // Returning a Response object signals the executor to stop and forward it return resp as unknown as never; } + const resolvedProjectId = typeof projectId === "string" ? projectId : String(projectId); const upstreamModel = cleanModelName(model); const isClaude = upstreamModel.toLowerCase().includes("claude"); - const baseBody = body && typeof body === "object" ? body : {}; - const normalizedBody = shouldStripCloudCodeThinking(this.provider, upstreamModel) - ? stripCloudCodeThinkingConfig(baseBody) - : baseBody; + const baseBody = bodyRecord; + const normalizedBody = ( + shouldStripCloudCodeThinking(this.provider, upstreamModel) + ? stripCloudCodeThinkingConfig(baseBody) + : baseBody + ) as Record; + const normalizedRequest = asRecord(normalizedBody.request) ?? {}; // 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; - if (c.parts?.some((p) => p.functionResponse)) { - role = "user"; - } + const normalizedContents = ( + Array.isArray(normalizedRequest.contents) + ? (normalizedRequest.contents as AntigravityContent[]) + : [] + ).map((c) => { + let role = c.role; + if (c.parts?.some((p: AntigravityPart) => p.functionResponse)) { + role = "user"; + } - const hasFunctionCall = c.parts?.some((p) => p.functionCall) || false; + const hasFunctionCall = c.parts?.some((p: AntigravityPart) => p.functionCall) || false; - const parts = - c.parts?.filter((p) => { - if (typeof p.text === "string" && p.text === "") return false; - if (p.functionCall && !p.functionCall.name) return false; + const parts = + c.parts?.filter((p: AntigravityPart) => { + if (typeof p.text === "string" && p.text === "") return false; + if (p.functionCall && !p.functionCall.name) return false; - return !p.thought && (hasFunctionCall || !p.thoughtSignature); - }) || []; - return { ...c, role, parts }; - }) || []; + return !p.thought && (hasFunctionCall || !p.thoughtSignature); + }) || []; + return { ...c, role, parts }; + }); - const contents: any[] = []; + const contents: Array & { parts: unknown[] }> = []; for (const c of normalizedContents) { if (!Array.isArray(c.parts) || c.parts.length === 0) continue; if (contents.length > 0 && contents[contents.length - 1].role === c.role) { @@ -464,14 +504,14 @@ export class AntigravityExecutor extends BaseExecutor { } const rawTransformedRequest = { - ...normalizedBody.request, + ...normalizedRequest, ...(contents.length > 0 && { contents }), - sessionId: getAntigravitySessionId(credentials, normalizedBody.request?.sessionId), + sessionId: getAntigravitySessionId(credentials, normalizedRequest.sessionId), 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 @@ -506,7 +546,7 @@ export class AntigravityExecutor extends BaseExecutor { const requestType = _requestType === "image_gen" ? "image_gen" : "agent"; const envelope: AntigravityRequestEnvelope = { - project: projectId, + project: resolvedProjectId, requestId: generateAntigravityRequestId(), request: transformedRequest, model: upstreamModel, @@ -522,7 +562,7 @@ export class AntigravityExecutor extends BaseExecutor { return envelope; } - async refreshCredentials(credentials, log) { + async refreshCredentials(credentials: AntigravityCredentials, log?: ExecutorLog | null) { if (!credentials.refreshToken) return null; try { @@ -543,17 +583,23 @@ 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, + accessToken: typeof tokens.access_token === "string" ? tokens.access_token : undefined, + refreshToken: + typeof tokens.refresh_token === "string" + ? tokens.refresh_token + : credentials.refreshToken, expiresIn: tokens.expires_in, projectId: credentials.projectId, }; } catch (error) { - log?.error?.("TOKEN", `Antigravity refresh error: ${error.message}`); + log?.error?.( + "TOKEN", + `Antigravity refresh error: ${error instanceof Error ? error.message : String(error)}` + ); return null; } } @@ -562,7 +608,7 @@ export class AntigravityExecutor extends BaseExecutor { 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"); @@ -595,7 +641,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); @@ -619,7 +665,18 @@ 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(); @@ -709,7 +766,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 @@ -802,7 +859,7 @@ export class AntigravityExecutor extends BaseExecutor { let response = await fetch(url, { method: "POST", headers: finalHeaders, - body: getChunkedOrFixedBody(serializedRequest.bodyString, stream) as any, + body: getChunkedOrFixedBody(serializedRequest.bodyString, stream), ...(stream ? { duplex: "half" } : {}), signal, }); @@ -814,7 +871,7 @@ export class AntigravityExecutor extends BaseExecutor { response = await fetch(url, { method: "POST", headers: retryHeaders, - body: getChunkedOrFixedBody(serializedRequest.bodyString, stream) as any, + body: getChunkedOrFixedBody(serializedRequest.bodyString, stream), ...(stream ? { duplex: "half" } : {}), signal, }); @@ -884,7 +941,7 @@ export class AntigravityExecutor extends BaseExecutor { const creditsResp = await fetch(url, { method: "POST", headers: finalCreditsHeaders, - body: getChunkedOrFixedBody(serializedCreditsRequest.bodyString, stream) as any, + body: getChunkedOrFixedBody(serializedCreditsRequest.bodyString, stream), ...(stream ? { duplex: "half" } : {}), signal, }); @@ -905,7 +962,9 @@ export class AntigravityExecutor extends BaseExecutor { const syntheticJson = await collected.response.clone().json(); const rc = syntheticJson?._remainingCredits; if (Array.isArray(rc)) { - const googleCredit = rc.find((c) => c.creditType === "GOOGLE_ONE_AI"); + const googleCredit = rc.find( + (c: { creditType?: string }) => c.creditType === "GOOGLE_ONE_AI" + ); if (googleCredit) { const balance = parseInt(googleCredit.creditAmount, 10); if (!isNaN(balance)) @@ -1009,7 +1068,7 @@ export class AntigravityExecutor extends BaseExecutor { ) { try { const respBody = await response.clone().text(); - let obj; + let obj: Record; try { obj = JSON.parse(respBody); } catch { @@ -1125,7 +1184,7 @@ export class AntigravityExecutor extends BaseExecutor { const parsed = JSON.parse(payload); if (Array.isArray(parsed?.remainingCredits)) { const googleCredit = parsed.remainingCredits.find( - (c) => c?.creditType === "GOOGLE_ONE_AI" + (c: { creditType?: string }) => c?.creditType === "GOOGLE_ONE_AI" ); if (googleCredit) { const balance = parseInt(googleCredit.creditAmount, 10); diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 7db15a834c..b68ed08c6b 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -3,7 +3,9 @@ import { BaseExecutor, mergeUpstreamExtraHeaders, setUserAgentHeader, + type ExecutorLog, type ExecuteInput, + type ProviderCredentials, } from "./base.ts"; import { CODEX_CHAT_DEFAULT_INSTRUCTIONS, @@ -19,6 +21,7 @@ import { applyCodexClientIdentityHeaders, applyCodexClientMetadata, createCodexClientIdentity, + type CodexClientIdentity, } from "../config/codexIdentity.ts"; import { getAccessToken } from "../services/tokenRefresh.ts"; import { @@ -1088,7 +1091,12 @@ export class CodexExecutor extends BaseExecutor { }; } - buildUrl(model: string, stream: boolean, urlIndex = 0, credentials: any = null) { + buildUrl( + model: string, + stream: boolean, + urlIndex = 0, + credentials: ProviderCredentials | null = null + ) { void model; void stream; void urlIndex; @@ -1110,7 +1118,7 @@ export class CodexExecutor extends BaseExecutor { * Always request event-stream from upstream, even when client requested stream=false. * Includes chatgpt-account-id header for strict workspace binding. */ - buildHeaders(credentials, stream = true) { + buildHeaders(credentials: ProviderCredentials, stream = true) { const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath); const headers = super.buildHeaders(credentials, isCompactRequest ? false : true); headers.Version = getCodexClientVersion(); @@ -1118,10 +1126,13 @@ export class CodexExecutor extends BaseExecutor { // Add workspace binding header if workspaceId is persisted const workspaceId = credentials?.providerSpecificData?.workspaceId; - if (workspaceId) { + if (typeof workspaceId === "string" && workspaceId) { headers["chatgpt-account-id"] = workspaceId; } - const clientIdentity = credentials?.providerSpecificData?.codexClientIdentity; + const clientIdentity = credentials?.providerSpecificData?.codexClientIdentity as + | CodexClientIdentity + | null + | undefined; // Originator header — identifies the client type to the Codex backend. // Ref: openai/codex login/src/auth/default_client.rs DEFAULT_ORIGINATOR = "codex_cli_rs" @@ -1148,7 +1159,7 @@ export class CodexExecutor extends BaseExecutor { * Ref: openai/codex core/src/client.rs line 853 */ private getPromptCacheSessionId( - credentials, + credentials: ProviderCredentials | null | undefined, body: Record | null ): string | null { const promptCacheKey = normalizeCodexSessionId(body?.prompt_cache_key); @@ -1173,7 +1184,7 @@ export class CodexExecutor extends BaseExecutor { * have expired or become invalid. chatCore.ts calls this on 401; previously the * base class returned null causing the request to fail instead of refreshing. */ - async refreshCredentials(credentials, log) { + async refreshCredentials(credentials: ProviderCredentials, log?: ExecutorLog | null) { if (!credentials?.refreshToken) { log?.warn?.("TOKEN_REFRESH", "Codex: no refresh token available, re-authentication required"); return null; @@ -1192,11 +1203,19 @@ export class CodexExecutor extends BaseExecutor { /** * Transform request before sending - inject default instructions if missing */ - transformRequest(model, body, stream, credentials) { + transformRequest( + model: string, + bodyInput: unknown, + stream: boolean, + credentials: ProviderCredentials + ) { + void stream; // Do not mutate the caller's payload in place. Combo quality checks and // other post-execute paths still inspect the original request body. - body = - body && typeof body === "object" ? structuredClone(body) : ({} as Record); + const body: Record = + bodyInput && typeof bodyInput === "object" + ? structuredClone(bodyInput as Record) + : {}; const nativeCodexPassthrough = body?._nativeCodexPassthrough === true; const isCompactRequest = isCompactResponsesEndpoint(credentials?.requestEndpointPath); @@ -1259,7 +1278,7 @@ export class CodexExecutor extends BaseExecutor { }, ]; } else if (!body.input && Array.isArray(body.prompt)) { - body.input = body.prompt.map((p: any) => ({ + body.input = body.prompt.map((p: unknown) => ({ type: "message", role: "user", content: [{ type: "input_text", text: typeof p === "string" ? p : JSON.stringify(p) }], @@ -1350,10 +1369,14 @@ export class CodexExecutor extends BaseExecutor { if (splitModel.effort) { modelEffort = splitModel.effort; body.model = splitModel.baseModel; - cleanModel = body.model; + cleanModel = splitModel.baseModel; } - const explicitReasoning = normalizeEffortValue(body?.reasoning?.effort); + const reasoningRecord = + body.reasoning && typeof body.reasoning === "object" && !Array.isArray(body.reasoning) + ? (body.reasoning as Record) + : null; + const explicitReasoning = normalizeEffortValue(reasoningRecord?.effort); const requestReasoningEffort = normalizeEffortValue(body.reasoning_effort); const fallbackReasoningEffort = allowConnectionReasoningDefaults ? requestDefaults.reasoningEffort || "medium" @@ -1363,12 +1386,12 @@ export class CodexExecutor extends BaseExecutor { if (explicitReasoning) { body.reasoning = { - ...(body.reasoning && typeof body.reasoning === "object" ? body.reasoning : {}), + ...(reasoningRecord || {}), effort: clampEffort(cleanModel, explicitReasoning), }; } else if (rawEffort) { body.reasoning = { - ...(body.reasoning && typeof body.reasoning === "object" ? body.reasoning : {}), + ...(reasoningRecord || {}), effort: clampEffort(cleanModel, rawEffort), }; } @@ -1401,7 +1424,13 @@ export class CodexExecutor extends BaseExecutor { } } if (!isCompactRequest) { - applyCodexClientMetadata(body, credentials?.providerSpecificData?.codexClientIdentity); + applyCodexClientMetadata( + body, + credentials?.providerSpecificData?.codexClientIdentity as + | CodexClientIdentity + | null + | undefined + ); } // Delete session_id and conversation_id from the body. diff --git a/open-sse/executors/gemini-cli.ts b/open-sse/executors/gemini-cli.ts index d97fcc504e..1b77fc4b5b 100644 --- a/open-sse/executors/gemini-cli.ts +++ b/open-sse/executors/gemini-cli.ts @@ -310,13 +310,15 @@ export class GeminiCLIExecutor extends BaseExecutor { ? cloneGeminiCliRecord(bodyRecord.request as Record) : {}; + const storedProject = + bodyRecord.project || + credentials.projectId || + (credentials.providerSpecificData as Record)?.projectId || + ""; + const envelope: Record = { model: currentModel, - project: - bodyRecord.project || - credentials.projectId || - (credentials.providerSpecificData as Record)?.projectId || - "", + project: storedProject, user_prompt_id: bodyRecord.user_prompt_id || generateGeminiCliRequestId(), request: { ...requestRecord, @@ -330,9 +332,9 @@ export class GeminiCLIExecutor extends BaseExecutor { } } - // Refresh the project ID via loadCodeAssist (cached for 30s) only when project not provided - // and credentials have an access token - if (!envelope.project && credentials.accessToken) { + // Native Gemini CLI refreshes the Cloud Code project periodically because + // stored project IDs can go stale. Keep the stored value as a fallback. + if (credentials.accessToken) { const freshProject = await this.refreshProject(credentials.accessToken, currentModel); if (freshProject) { envelope.project = freshProject; diff --git a/open-sse/services/accountFallback.ts b/open-sse/services/accountFallback.ts index 7ec998d196..c59ba4c112 100644 --- a/open-sse/services/accountFallback.ts +++ b/open-sse/services/accountFallback.ts @@ -57,6 +57,24 @@ type ModelFailureState = { lastFailureAt: number; resetAfterMs: number; }; +type ModelLockoutInfo = { + provider: string; + connectionId: string; + model: string; + reason: string; + remainingMs: number; + failureCount: number; + lockedAt: string; +}; +type RetryHintHeaders = Headers | Record; +type AccountCooldownState = { + id?: string; + rateLimitedUntil?: string | null; + backoffLevel?: number; + lastError?: string | null; + lastErrorAt?: string | null; + status?: string; +}; // Provider-level failure tracking for circuit breaker behavior // Error codes that count toward provider-level failure threshold @@ -210,7 +228,11 @@ function buildProviderProfile( * Get the resilience profile for a provider (oauth or apikey). * @param {string} provider - Provider ID or alias */ -export function getProviderProfile(provider) { +function asJsonRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +export function getProviderProfile(provider: string) { const category = getProviderCategory(provider); return buildProviderProfile(category); } @@ -227,7 +249,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,11 +332,11 @@ 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 = {} ) { if (!model) return; // No model → skip model-level locking @@ -400,7 +422,11 @@ export function recordModelLockoutFailure( }; } -export function clearModelLock(provider, connectionId, model) { +export function clearModelLock( + provider: string, + connectionId: string, + model: string | null | undefined +) { if (!model) return false; const key = getModelLockKey(provider, connectionId, model); const hadLock = modelLockouts.delete(key); @@ -470,7 +496,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 +) { if (!model) return false; const key = getModelLockKey(provider, connectionId, model); cleanupModelLockKey(key); @@ -481,7 +511,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); @@ -500,7 +534,7 @@ export function getModelLockoutInfo(provider, connectionId, model) { */ export function getAllModelLockouts() { const now = Date.now(); - const active: any[] = []; + const active: ModelLockoutInfo[] = []; for (const key of modelLockouts.keys()) { cleanupModelLockKey(key, now); } @@ -513,6 +547,7 @@ export function getAllModelLockouts() { reason: entry.reason, remainingMs: entry.until - now, failureCount: entry.failureCount, + lockedAt: new Date(entry.lockedAt).toISOString(), }); } return active; @@ -668,10 +703,13 @@ 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: string | JsonRecord | null | undefined) { + let body: JsonRecord; try { - body = typeof responseBody === "string" ? JSON.parse(responseBody) : responseBody; + body = + typeof responseBody === "string" + ? asJsonRecord(JSON.parse(responseBody)) + : asJsonRecord(responseBody); } catch { return { retryAfterMs: null, reason: RateLimitReason.UNKNOWN }; } @@ -679,18 +717,20 @@ export function parseRetryAfterFromBody(responseBody) { if (!body) return { retryAfterMs: null, reason: RateLimitReason.UNKNOWN }; // Gemini: { error: { details: [{ retryDelay: "33s" }] } } - const details = body.error?.details || body.details || []; + const error = asJsonRecord(body.error); + const details = error.details || body.details || []; for (const detail of Array.isArray(details) ? details : []) { - if (detail.retryDelay) { + const detailRecord = asJsonRecord(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 { @@ -700,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 }; } @@ -713,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); @@ -737,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: string | null | undefined): number | null { if (!errorText || typeof errorText !== "string") return null; const match = errorText.match(/reset after (\d+h)?(\d+m)?(\d+s)?/i); @@ -754,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 @@ -767,7 +807,7 @@ function computeDurationMs(match) { /** * Classify error text into RateLimitReason */ -export function classifyErrorText(errorText) { +export function classifyErrorText(errorText: string | null | undefined) { if (!errorText) return RateLimitReason.UNKNOWN; const lower = String(errorText).toLowerCase(); @@ -810,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: string | null | undefined) { // Text classification takes priority (more specific) const textReason = classifyErrorText(errorText); if (textReason !== RateLimitReason.UNKNOWN) return textReason; @@ -876,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) { const idx = Math.min(failureCount, BACKOFF_STEPS_MS.length - 1); return BACKOFF_STEPS_MS[idx]; } @@ -908,7 +948,7 @@ export function checkFallbackError( backoffLevel: number = 0, _model: string | null = null, provider: string | null = null, - headers: any = null, + headers: RetryHintHeaders | null = null, profileOverride: ProviderProfile | null = null ): { shouldFallback: boolean; @@ -933,32 +973,35 @@ export function checkFallbackError( HTTP_STATUS.GATEWAY_TIMEOUT, ]); - function parseResetFromHeaders(headers) { + function parseResetFromHeaders(headers: RetryHintHeaders | null) { if (!headers) return null; // 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") + : (headers as Record)["retry-after"] || + (headers as Record)["Retry-After"]; if (retryAfter) { - const seconds = parseInt(retryAfter, 10); - if (!isNaN(seconds) && String(seconds) === String(retryAfter).trim()) { + const retryAfterText = String(retryAfter); + const seconds = parseInt(retryAfterText, 10); + if (!isNaN(seconds) && String(seconds) === retryAfterText.trim()) { return Date.now() + seconds * 1000; } - const date = new Date(retryAfter); + const date = new Date(retryAfterText); if (!isNaN(date.getTime())) return date.getTime(); } // 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") + : (headers as Record)["x-ratelimit-reset"] || + (headers as Record)["X-RateLimit-Reset"]; if (rlReset) { - const ts = parseInt(rlReset, 10); + const ts = parseInt(String(rlReset), 10); if (!isNaN(ts)) { return ts > 10000000000 ? ts : ts * 1000; } @@ -982,7 +1025,7 @@ export function checkFallbackError( return null; } - function getScaledBaseCooldown(reason, level = backoffLevel) { + function getScaledBaseCooldown(reason: string, level = backoffLevel) { const baseCooldownMs = typeof profile?.baseCooldownMs === "number" && profile.baseCooldownMs >= 0 ? profile.baseCooldownMs @@ -994,7 +1037,7 @@ export function checkFallbackError( }; } - function buildRetryableFallback(reason) { + function buildRetryableFallback(reason: string) { const upstreamRetryHintMs = getUpstreamRetryHintMs(); if (typeof upstreamRetryHintMs === "number" && upstreamRetryHintMs > 0) { return { @@ -1122,7 +1165,7 @@ export function checkFallbackError( /** * Check if account is currently unavailable (cooldown not expired) */ -export function isAccountUnavailable(unavailableUntil) { +export function isAccountUnavailable(unavailableUntil: string | null | undefined) { if (!unavailableUntil) return false; return new Date(unavailableUntil).getTime() > Date.now(); } @@ -1130,14 +1173,14 @@ export function isAccountUnavailable(unavailableUntil) { /** * Calculate unavailable until timestamp */ -export function getUnavailableUntil(cooldownMs) { +export function getUnavailableUntil(cooldownMs: number) { return new Date(Date.now() + cooldownMs).toISOString(); } /** * Get the earliest rateLimitedUntil from a list of accounts */ -export function getEarliestRateLimitedUntil(accounts) { +export function getEarliestRateLimitedUntil(accounts: AccountCooldownState[]) { let earliest: number | null = null; const now = Date.now(); for (const acc of accounts) { @@ -1153,7 +1196,7 @@ export function getEarliestRateLimitedUntil(accounts) { /** * Format rateLimitedUntil to human-readable "reset after Xm Ys" */ -export function formatRetryAfter(rateLimitedUntil) { +export function formatRetryAfter(rateLimitedUntil: string | null | undefined) { if (!rateLimitedUntil) return ""; const diffMs = new Date(rateLimitedUntil).getTime() - Date.now(); if (diffMs <= 0) return "reset after 0s"; @@ -1171,7 +1214,10 @@ export function formatRetryAfter(rateLimitedUntil) { /** * Filter available accounts (not in cooldown) */ -export function filterAvailableAccounts(accounts, excludeId = null) { +export function filterAvailableAccounts( + accounts: AccountCooldownState[], + excludeId: string | null = null +) { const now = Date.now(); return accounts.filter((acc) => { if (excludeId && acc.id === excludeId) return false; @@ -1186,7 +1232,9 @@ export function filterAvailableAccounts(accounts, excludeId = null) { /** * Reset account state when request succeeds */ -export function resetAccountState(account) { +export function resetAccountState( + account: TAccount +) { if (!account) return account; return { ...account, @@ -1200,7 +1248,12 @@ export function resetAccountState(account) { /** * Apply error state to account */ -export function applyErrorState(account, status, errorText, provider = null) { +export function applyErrorState( + account: TAccount, + status: number, + errorText: string | null, + provider: string | null = null +) { if (!account) return account; const backoffLevel = account.backoffLevel || 0; @@ -1223,7 +1276,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: AccountCooldownState | null | undefined, + model?: unknown +) { if (!account) return 0; let score = 100; score -= (account.backoffLevel || 0) * 10; diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index a38c45421a..589450186b 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -5,7 +5,9 @@ import { AutoVariant } from "./autoPrefix"; import { getProviderConnections } from "@/lib/db/providers"; import { getProviderRegistry } from "./providerRegistryAccessor"; import type { ConnectionFields } from "@/lib/db/encryption"; -import { log } from "@omniroute/open-sse/utils/logger"; +import { createLogger } from "@omniroute/open-sse/utils/logger"; + +const log = createLogger(); /** Minimal connection shape needed for virtual auto-combo factory */ interface VirtualFactoryConn extends ConnectionFields { diff --git a/open-sse/services/model.ts b/open-sse/services/model.ts index 58ffe2cf0b..d66ae5dcc6 100644 --- a/open-sse/services/model.ts +++ b/open-sse/services/model.ts @@ -2,9 +2,24 @@ import { PROVIDER_ID_TO_ALIAS, PROVIDER_MODELS } from "../config/providerModels. import { ANTIGRAVITY_MODEL_ALIASES } from "../config/antigravityModelAliases.ts"; import { resolveWildcardAlias } from "./wildcardRouter.ts"; +type ProviderModelAliasMap = Record>; +type ModelAliasValue = string | { provider?: string; model?: string }; +type ModelAliasMap = Record; +type ParsedModel = { + provider: string | null; + model: string | null; + isAlias: boolean; + providerAlias: string | null; + extendedContext: boolean; +}; +type ResolvedModelTarget = { + provider?: string | null; + model: string | null; +}; + // Derive alias→provider mapping from the single source of truth (PROVIDER_ID_TO_ALIAS) // This prevents the two maps from drifting out of sync -const ALIAS_TO_PROVIDER_ID = {}; +const ALIAS_TO_PROVIDER_ID: Record = {}; for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) { if (ALIAS_TO_PROVIDER_ID[alias]) { console.log( @@ -16,7 +31,7 @@ for (const [id, alias] of Object.entries(PROVIDER_ID_TO_ALIAS)) { // Provider-scoped legacy model aliases. Used to normalize provider/model inputs // and keep backward compatibility when upstream IDs change. -const PROVIDER_MODEL_ALIASES = { +const PROVIDER_MODEL_ALIASES: ProviderModelAliasMap = { github: { "claude-4.5-opus": "claude-opus-4-5-20251101", "claude-opus-4.5": "claude-opus-4-5-20251101", @@ -39,10 +54,10 @@ const PROVIDER_MODEL_ALIASES = { "gpt-oss-20b": "openai/gpt-oss-20b", "nvidia/gpt-oss-20b": "openai/gpt-oss-20b", }, - antigravity: ANTIGRAVITY_MODEL_ALIASES, + antigravity: { ...ANTIGRAVITY_MODEL_ALIASES }, }; -const CROSS_PROXY_MODEL_ALIASES = { +const CROSS_PROXY_MODEL_ALIASES: Record = { "gpt-oss:120b": "gpt-oss-120b", "deepseek-v3.2-chat": "deepseek-v3.2", "deepseek-v3-2": "deepseek-v3.2", @@ -59,7 +74,7 @@ const CROSS_PROXY_MODEL_ALIASES_LOWER = Object.fromEntries( ); // Reverse index: modelId -> providerIds that expose this model -const MODEL_TO_PROVIDERS = new Map(); +const MODEL_TO_PROVIDERS = new Map(); for (const [aliasOrId, models] of Object.entries(PROVIDER_MODELS)) { const providerId = ALIAS_TO_PROVIDER_ID[aliasOrId] || aliasOrId; for (const modelEntry of models || []) { @@ -86,7 +101,8 @@ interface ProviderConnectionLike { /** * Resolve provider alias to provider ID */ -export function resolveProviderAlias(aliasOrId) { +export function resolveProviderAlias(aliasOrId: string | null | undefined): string | null { + if (typeof aliasOrId !== "string") return null; return ALIAS_TO_PROVIDER_ID[aliasOrId] || aliasOrId; } @@ -95,9 +111,17 @@ function isCrossProxyModelCompatEnabled() { return raw !== "false" && raw !== "0"; } -export function normalizeCrossProxyModelId(modelId) { +export function normalizeCrossProxyModelId(modelId: unknown): { + modelId: string | null; + applied: boolean; + original: string | null; +} { if (!modelId || typeof modelId !== "string" || !isCrossProxyModelCompatEnabled()) { - return { modelId, applied: false, original: null }; + return { + modelId: typeof modelId === "string" ? modelId : null, + applied: false, + original: null, + }; } const normalized = @@ -114,17 +138,22 @@ export function normalizeCrossProxyModelId(modelId) { /** * Resolve provider-specific legacy model alias to canonical model ID. */ -function resolveProviderModelAlias(providerOrAlias, modelId) { +function resolveProviderModelAlias( + providerOrAlias: string | null | undefined, + modelId: string | null | undefined +) { if (!modelId || typeof modelId !== "string") return modelId; const providerId = resolveProviderAlias(providerOrAlias); + if (typeof providerId !== "string") return modelId; const aliases = PROVIDER_MODEL_ALIASES[providerId]; return aliases?.[modelId] || modelId; } -function hasKnownProviderModel(providerOrAlias, modelId) { +function hasKnownProviderModel(providerOrAlias: string | null | undefined, modelId: string | null) { if (!providerOrAlias || !modelId) return false; const providerId = resolveProviderAlias(providerOrAlias); + if (typeof providerId !== "string") return false; const providerAlias = PROVIDER_ID_TO_ALIAS[providerId] || providerId; const models = PROVIDER_MODELS[providerAlias] || PROVIDER_MODELS[providerId] || []; @@ -134,7 +163,7 @@ function hasKnownProviderModel(providerOrAlias, modelId) { return canonicalModel !== modelId && models.some((entry) => entry?.id === canonicalModel); } -function hasCodexPreferredUnprefixedModel(modelId) { +function hasCodexPreferredUnprefixedModel(modelId: string) { const canonicalModel = CODEX_PREFERRED_UNPREFIXED_MODEL_ALIASES.get(modelId); if (!canonicalModel) return false; @@ -143,7 +172,7 @@ function hasCodexPreferredUnprefixedModel(modelId) { return models.some((entry) => entry?.id === canonicalModel); } -function resolveInferredProviderModel(provider, modelId) { +function resolveInferredProviderModel(provider: string, modelId: string) { const codexPreferredModel = CODEX_PREFERRED_UNPREFIXED_MODEL_ALIASES.get(modelId); if (provider === "codex" && codexPreferredModel) { return codexPreferredModel; @@ -151,7 +180,7 @@ function resolveInferredProviderModel(provider, modelId) { return resolveProviderModelAlias(provider, modelId); } -function getInferredProvidersForModel(modelId) { +function getInferredProvidersForModel(modelId: string) { const providers = [...(MODEL_TO_PROVIDERS.get(modelId) || [])]; if ( @@ -196,7 +225,7 @@ async function getActiveProviderSet() { } } -function shouldTreatAsExactModelId(modelStr) { +function shouldTreatAsExactModelId(modelStr: string | null) { if (!modelStr || typeof modelStr !== "string" || !modelStr.includes("/")) return false; if (!KNOWN_MODEL_IDS.has(modelStr)) return false; @@ -210,7 +239,10 @@ function shouldTreatAsExactModelId(modelStr) { * Resolve a provider/model pair into canonical provider ID + provider-scoped model ID. * Keeps provider-specific legacy aliases out of downstream capability and budget lookups. */ -export function resolveCanonicalProviderModel(providerOrAlias, modelId) { +export function resolveCanonicalProviderModel( + providerOrAlias: string | null | undefined, + modelId: string | null | undefined +) { if (!modelId || typeof modelId !== "string") { return { provider: resolveProviderAlias(providerOrAlias), @@ -229,7 +261,7 @@ export function resolveCanonicalProviderModel(providerOrAlias, modelId) { * Parse model string: "alias/model" or "provider/model" or just alias * Supports [1m] suffix for extended 1M context window (e.g. "claude-sonnet-4-6[1m]") */ -export function parseModel(modelStr) { +export function parseModel(modelStr: string | null | undefined): ParsedModel { if (!modelStr) { return { provider: null, @@ -264,7 +296,7 @@ export function parseModel(modelStr) { // Normalize known cross-proxy provider/model dialects before deciding whether // the slash belongs to a provider prefix or to the model ID itself. if (cleanStr.includes("/")) { - cleanStr = normalizeCrossProxyModelId(cleanStr).modelId; + cleanStr = normalizeCrossProxyModelId(cleanStr).modelId || cleanStr; } if (shouldTreatAsExactModelId(cleanStr)) { @@ -289,7 +321,7 @@ export function parseModel(modelStr) { * Resolve model alias from aliases object * Format: { "alias": "provider/model" } */ -export function resolveModelAliasFromMap(alias, aliases) { +export function resolveModelAliasFromMap(alias: string | null, aliases: ModelAliasMap | null) { const resolved = resolveModelAliasTarget(alias, aliases); if (!resolved?.provider) return null; return { @@ -298,8 +330,11 @@ export function resolveModelAliasFromMap(alias, aliases) { }; } -function resolveModelAliasTarget(alias, aliases) { - if (!aliases) return null; +function resolveModelAliasTarget( + alias: string | null, + aliases: ModelAliasMap | null +): ResolvedModelTarget | null { + if (!alias || !aliases) return null; const resolved = aliases[alias]; if (!resolved) return null; @@ -308,24 +343,29 @@ function resolveModelAliasTarget(alias, aliases) { return parseAliasTarget(resolved); } - if (typeof resolved === "object" && resolved.provider && resolved.model) { + if ( + resolved && + typeof resolved === "object" && + typeof resolved.provider === "string" && + typeof resolved.model === "string" + ) { const normalizedPair = normalizeCrossProxyModelId( `${resolved.provider}/${resolved.model}` ).modelId; - if (normalizedPair !== `${resolved.provider}/${resolved.model}`) { + if (normalizedPair && normalizedPair !== `${resolved.provider}/${resolved.model}`) { return parseAliasTarget(normalizedPair); } return { provider: resolveProviderAlias(resolved.provider), - model: normalizeCrossProxyModelId(resolved.model).modelId, + model: normalizeCrossProxyModelId(resolved.model).modelId || resolved.model, }; } return null; } -function parseAliasTarget(target) { +function parseAliasTarget(target: string): ResolvedModelTarget | null { const normalizedTarget = normalizeCrossProxyModelId(target).modelId; if (!normalizedTarget || typeof normalizedTarget !== "string") return null; @@ -344,7 +384,7 @@ function parseAliasTarget(target) { return { model: normalizedTarget }; } -async function resolveModelByProviderInference(modelId, extendedContext) { +async function resolveModelByProviderInference(modelId: string, extendedContext: boolean) { const providers = getInferredProvidersForModel(modelId); const nonOpenAIProviders = providers.filter((p) => p !== "openai"); @@ -429,7 +469,10 @@ async function resolveModelByProviderInference(modelId, extendedContext) { * @param {string} modelStr - Model string * @param {object|function} aliasesOrGetter - Aliases object or async function to get aliases */ -export async function getModelInfoCore(modelStr, aliasesOrGetter) { +export async function getModelInfoCore( + modelStr: string, + aliasesOrGetter: ModelAliasMap | (() => Promise) | null +) { const parsed = parseModel(modelStr); const { extendedContext } = parsed; @@ -464,9 +507,9 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) { if (aliases && typeof aliases === "object") { const aliasEntries = Object.entries(aliases).map(([pattern, target]) => ({ pattern, - target: target as string, + target: typeof target === "string" ? target : "", })); - const wildcardMatch = resolveWildcardAlias(parsed.model, aliasEntries); + const wildcardMatch = parsed.model ? resolveWildcardAlias(parsed.model, aliasEntries) : null; if (wildcardMatch) { const target = wildcardMatch.target as string; if (target.includes("/")) { @@ -486,5 +529,8 @@ export async function getModelInfoCore(modelStr, aliasesOrGetter) { } const normalizedModelId = normalizeCrossProxyModelId(parsed.model).modelId; + if (!normalizedModelId) { + return { provider: null, model: null, extendedContext }; + } return await resolveModelByProviderInference(normalizedModelId, extendedContext); } diff --git a/open-sse/services/thinkingBudget.ts b/open-sse/services/thinkingBudget.ts index 7061bb95e0..6f792fd3bd 100644 --- a/open-sse/services/thinkingBudget.ts +++ b/open-sse/services/thinkingBudget.ts @@ -12,6 +12,14 @@ export const ThinkingMode = { CUSTOM: "custom", // Set fixed budget ADAPTIVE: "adaptive", // Scale based on request complexity }; +export type ThinkingModeValue = (typeof ThinkingMode)[keyof typeof ThinkingMode]; + +type JsonRecord = Record; +type ThinkingBudgetConfig = { + mode: ThinkingModeValue; + customBudget: number; + effortLevel: string; +}; import { capThinkingBudget, @@ -21,7 +29,7 @@ import { } from "@/lib/modelCapabilities"; // Effort → budget token mapping -export const EFFORT_BUDGETS = { +export const EFFORT_BUDGETS: Record = { none: 0, low: 1024, medium: 10240, @@ -32,7 +40,7 @@ export const EFFORT_BUDGETS = { // thinkingLevel string → budget token mapping // Used when clients send string-based thinking levels (e.g., VS Code Copilot) -export const THINKING_LEVEL_MAP = { +export const THINKING_LEVEL_MAP: Record = { none: 0, low: 4096, medium: 8192, @@ -46,15 +54,24 @@ export const DEFAULT_THINKING_CONFIG = { mode: ThinkingMode.PASSTHROUGH, customBudget: 10240, effortLevel: "medium", -}; +} satisfies ThinkingBudgetConfig; // In-memory config (loaded from DB on startup, or default) -let _config = { ...DEFAULT_THINKING_CONFIG }; +let _config: ThinkingBudgetConfig = { ...DEFAULT_THINKING_CONFIG }; + +function toRecord(value: unknown): JsonRecord { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : {}; +} + +function getStringField(record: JsonRecord, key: string): string { + const value = record[key]; + return typeof value === "string" ? value : ""; +} /** * Set the thinking budget config (called from settings API or startup) */ -export function setThinkingBudgetConfig(config) { +export function setThinkingBudgetConfig(config: Partial) { _config = { ...DEFAULT_THINKING_CONFIG, ...config }; } @@ -73,15 +90,15 @@ export function getThinkingBudgetConfig() { * @param {object} body - Request body * @returns {object} Body with string thinkingLevel converted to numeric budget */ -export function normalizeThinkingLevel(body) { +export function normalizeThinkingLevel(body: unknown) { if (!body || typeof body !== "object") return body; - const result = { ...body }; + const result: JsonRecord = { ...(body as JsonRecord) }; // Handle top-level thinkingLevel or thinking_level string fields const levelStr = result.thinkingLevel || result.thinking_level; if (typeof levelStr === "string" && THINKING_LEVEL_MAP[levelStr.toLowerCase()] !== undefined) { const rawBudget = THINKING_LEVEL_MAP[levelStr.toLowerCase()]; - const budget = capThinkingBudget(result.model || "", rawBudget); + const budget = capThinkingBudget(getStringField(result, "model"), rawBudget); // Convert to Claude thinking format as canonical representation result.thinking = { type: budget > 0 ? "enabled" : "disabled", @@ -92,25 +109,29 @@ export function normalizeThinkingLevel(body) { } // Handle Gemini's generationConfig.thinkingConfig.thinkingLevel - const geminiLevel = - result.generationConfig?.thinkingConfig?.thinkingLevel || - result.generationConfig?.thinking_config?.thinkingLevel; + const generationConfig = toRecord(result.generationConfig); + const thinkingConfig = toRecord(generationConfig.thinkingConfig); + const thinkingConfigSnake = toRecord(generationConfig.thinking_config); + const geminiLevel = thinkingConfig.thinkingLevel || thinkingConfigSnake.thinkingLevel; if ( typeof geminiLevel === "string" && THINKING_LEVEL_MAP[geminiLevel.toLowerCase()] !== undefined ) { const rawBudget = THINKING_LEVEL_MAP[geminiLevel.toLowerCase()]; - const budget = capThinkingBudget(result.model || "", rawBudget); + const budget = capThinkingBudget(getStringField(result, "model"), rawBudget); result.generationConfig = { - ...result.generationConfig, - thinkingConfig: { ...result.generationConfig.thinkingConfig, thinkingBudget: budget }, + ...generationConfig, + thinkingConfig: { ...thinkingConfig, thinkingBudget: budget }, }; // Clean up string variants - if (result.generationConfig.thinkingConfig) { - delete result.generationConfig.thinkingConfig.thinkingLevel; + const nextGenerationConfig = result.generationConfig as JsonRecord; + const nextThinkingConfig = toRecord(nextGenerationConfig.thinkingConfig); + if (Object.keys(nextThinkingConfig).length > 0) { + delete nextThinkingConfig.thinkingLevel; + nextGenerationConfig.thinkingConfig = nextThinkingConfig; } - if (result.generationConfig.thinking_config) { - delete result.generationConfig.thinking_config; + if ("thinking_config" in nextGenerationConfig) { + delete nextGenerationConfig.thinking_config; } } @@ -124,17 +145,18 @@ export function normalizeThinkingLevel(body) { * @param {object} body - Request body * @returns {object} Body with thinking config auto-injected if needed */ -export function ensureThinkingConfig(body) { +export function ensureThinkingConfig(body: unknown) { if (!body || typeof body !== "object") return body; - const model = body.model || ""; + const bodyRecord = body as JsonRecord; + const model = getStringField(bodyRecord, "model"); // Only auto-inject for models with -thinking suffix if (!model.endsWith("-thinking")) return body; // If thinking config already present, don't override - if (body.thinking) return body; + if (bodyRecord.thinking) return body; - const result = { ...body }; + const result: JsonRecord = { ...bodyRecord }; result.thinking = { type: "enabled", budget_tokens: getDefaultThinkingBudget(model) || EFFORT_BUDGETS.medium, @@ -152,13 +174,17 @@ export function ensureThinkingConfig(body) { * @param {object} [config] - Override config (defaults to stored config) * @returns {object} Modified body */ -export function applyThinkingBudget(body, config = null) { +export function applyThinkingBudget( + body: unknown, + config: Partial | null = null +) { const cfg = config || _config; if (!body || typeof body !== "object") return body; // Early exit: strip ALL reasoning/thinking params for models that don't support them. // Provider-specific Cloud Code restrictions should be handled at the executor boundary. - const modelStr = typeof body.model === "string" ? body.model : ""; + const bodyRecord = body as JsonRecord; + const modelStr = typeof bodyRecord.model === "string" ? bodyRecord.model : ""; if (modelStr && !supportsReasoning(modelStr)) { return stripThinkingConfig(body); } @@ -177,7 +203,7 @@ export function applyThinkingBudget(body, config = null) { return processed; case ThinkingMode.CUSTOM: - return setCustomBudget(processed, cfg.customBudget); + return setCustomBudget(processed, cfg.customBudget ?? DEFAULT_THINKING_CONFIG.customBudget); case ThinkingMode.ADAPTIVE: return applyAdaptiveBudget(processed, cfg); @@ -190,8 +216,8 @@ export function applyThinkingBudget(body, config = null) { /** * AUTO mode: strip all thinking configuration, let provider decide */ -function stripThinkingConfig(body) { - const result = { ...body }; +function stripThinkingConfig(body: unknown) { + const result: JsonRecord = { ...toRecord(body) }; // Claude format delete result.thinking; @@ -202,9 +228,10 @@ function stripThinkingConfig(body) { // Gemini format if (result.generationConfig) { - result.generationConfig = { ...result.generationConfig }; - delete result.generationConfig.thinking_config; - delete result.generationConfig.thinkingConfig; + const generationConfig = { ...toRecord(result.generationConfig) }; + delete generationConfig.thinking_config; + delete generationConfig.thinkingConfig; + result.generationConfig = generationConfig; } return result; @@ -213,8 +240,8 @@ function stripThinkingConfig(body) { /** * CUSTOM mode: set exact budget tokens */ -function setCustomBudget(body, budget) { - const result = { ...body }; +function setCustomBudget(body: unknown, budget: number) { + const result: JsonRecord = { ...toRecord(body) }; // If body already has thinking config in Claude format, update it if (result.thinking || hasThinkingCapableModel(result)) { @@ -242,9 +269,10 @@ function setCustomBudget(body, budget) { } // Gemini thinking_config - if (result.generationConfig?.thinking_config || result.generationConfig?.thinkingConfig) { + const generationConfig = toRecord(result.generationConfig); + if (generationConfig.thinking_config || generationConfig.thinkingConfig) { result.generationConfig = { - ...result.generationConfig, + ...generationConfig, thinking_config: { thinking_budget: budget }, }; } @@ -255,21 +283,27 @@ function setCustomBudget(body, budget) { /** * ADAPTIVE mode: scale budget based on request complexity */ -function applyAdaptiveBudget(body, cfg) { - const messages = body.messages || body.input || []; +function applyAdaptiveBudget(body: unknown, cfg: Partial) { + const bodyRecord = toRecord(body); + const messages = Array.isArray(bodyRecord.messages) + ? bodyRecord.messages + : Array.isArray(bodyRecord.input) + ? bodyRecord.input + : []; const messageCount = messages.length; - const tools = body.tools || []; + const tools = Array.isArray(bodyRecord.tools) ? bodyRecord.tools : []; const toolCount = tools.length; // Get last user message length let lastMsgLength = 0; for (let i = messages.length - 1; i >= 0; i--) { const msg = messages[i]; - if (msg.role === "user") { + const msgRecord = toRecord(msg); + if (msgRecord.role === "user") { lastMsgLength = - typeof msg.content === "string" - ? msg.content.length - : JSON.stringify(msg.content || "").length; + typeof msgRecord.content === "string" + ? msgRecord.content.length + : JSON.stringify(msgRecord.content || "").length; break; } } @@ -281,10 +315,13 @@ function applyAdaptiveBudget(body, cfg) { if (lastMsgLength > 2000) multiplier += 0.3; const baseBudget = - EFFORT_BUDGETS[cfg.effortLevel] || - getDefaultThinkingBudget(body.model || "") || + EFFORT_BUDGETS[typeof cfg.effortLevel === "string" ? cfg.effortLevel : "medium"] || + getDefaultThinkingBudget(getStringField(bodyRecord, "model")) || EFFORT_BUDGETS.medium; - const budget = capThinkingBudget(body.model || "", Math.ceil(baseBudget * multiplier)); + const budget = capThinkingBudget( + getStringField(bodyRecord, "model"), + Math.ceil(baseBudget * multiplier) + ); return setCustomBudget(body, budget); } @@ -292,8 +329,8 @@ function applyAdaptiveBudget(body, cfg) { /** * Check if model name suggests thinking capability */ -export function hasThinkingCapableModel(body) { - const model = body.model || ""; +export function hasThinkingCapableModel(body: unknown) { + const model = getStringField(toRecord(body), "model"); const resolved = getResolvedModelCapabilities(model); if (resolved.supportsThinking === true) return true; if (resolved.supportsThinking === false) return false; diff --git a/open-sse/services/usage.ts b/open-sse/services/usage.ts index 86187a01a0..87d3768f01 100644 --- a/open-sse/services/usage.ts +++ b/open-sse/services/usage.ts @@ -100,6 +100,15 @@ const MINIMAX_USAGE_CONFIG = { } as const; type JsonRecord = Record; +type ProviderUsageConnection = { + id?: string; + provider?: string; + accessToken?: string | null; + apiKey?: string | null; + providerSpecificData?: JsonRecord | null; + projectId?: string | null; + email?: string | null; +}; type UsageQuota = { used: number; total: number; @@ -644,10 +653,13 @@ async function getGlmUsage(apiKey: string, providerSpecificData?: Record ({ - name: String(m.model || ""), - used: toNumber(m.percentage, 0), - })) + ? src.models.map((model) => { + const modelRecord = toRecord(model); + return { + name: String(modelRecord.model || ""), + used: toNumber(modelRecord.percentage, 0), + }; + }) : [], unlimited: false, }; @@ -1002,7 +1014,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: ProviderUsageConnection, + options: { forceRefresh?: boolean } = {} +) { const { id, provider, accessToken, apiKey, providerSpecificData, projectId, email } = connection; switch (provider) { @@ -1015,9 +1030,9 @@ export async function getUsageForProvider(connection, options: { forceRefresh?: case "claude": return await getClaudeUsage(accessToken); case "codex": - return await getCodexUsage(accessToken, providerSpecificData); + 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); @@ -1031,21 +1046,25 @@ 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 || undefined + ); 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}` }; } @@ -1055,7 +1074,7 @@ 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 { @@ -1083,7 +1102,10 @@ 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 | null, + providerSpecificData: JsonRecord | null = null +) { try { if (!accessToken) { throw new Error("No GitHub access token available. Please re-authorize the connection."); @@ -1179,7 +1201,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; @@ -1275,7 +1300,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 /** @@ -1283,17 +1308,22 @@ 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 | null, + providerSpecificData: JsonRecord | null = null, + connectionProjectId?: string | null +) { if (!accessToken) { return { plan: "Free", message: "Gemini CLI access token not available." }; } try { const subscriptionInfo = await getGeminiCliSubscriptionInfoCached(accessToken); + const subscriptionRecord = toRecord(subscriptionInfo); const projectId = connectionProjectId || providerSpecificData?.projectId || - subscriptionInfo?.cloudaicompanionProject || + subscriptionRecord.cloudaicompanionProject || null; const plan = getGeminiCliPlanLabel(subscriptionInfo); @@ -1354,7 +1384,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) { const cacheKey = accessToken; const cached = _geminiCliSubCache.get(cacheKey); @@ -1370,7 +1400,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", @@ -1398,13 +1428,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) { + 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; } @@ -1412,7 +1444,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) { @@ -1425,12 +1458,13 @@ function getGeminiCliPlanLabel(subscriptionInfo) { } const tierName = - subscriptionInfo.currentTier?.name || - subscriptionInfo.currentTier?.displayName || - subscriptionInfo.subscriptionType || - subscriptionInfo.tier || + toRecord(subscription.currentTier).name || + toRecord(subscription.currentTier).displayName || + subscription.subscriptionType || + subscription.tier || ""; - const upper = tierName.toUpperCase(); + const tierNameString = typeof tierName === "string" ? tierName : ""; + const upper = tierNameString.toUpperCase(); if (upper.includes("ULTRA")) return "Ultra"; if (upper.includes("PRO")) return "Pro"; @@ -1438,9 +1472,9 @@ 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 (tierName) { - return tierName.charAt(0).toUpperCase() + tierName.slice(1).toLowerCase(); + if (toRecord(subscription.currentTier).upgradeSubscriptionType) return "Free"; + if (tierNameString) { + return tierNameString.charAt(0).toUpperCase() + tierNameString.slice(1).toLowerCase(); } return "Free"; @@ -1449,7 +1483,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; @@ -1535,14 +1569,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) { + 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; } @@ -1551,7 +1587,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 @@ -1566,12 +1603,13 @@ function getAntigravityPlanLabel(subscriptionInfo) { // 4. Try tier name fields as last resort const tierName = - subscriptionInfo.currentTier?.name || - subscriptionInfo.currentTier?.displayName || - subscriptionInfo.subscriptionType || - subscriptionInfo.tier || + toRecord(subscription.currentTier).name || + toRecord(subscription.currentTier).displayName || + subscription.subscriptionType || + subscription.tier || ""; - const upper = tierName.toUpperCase(); + const tierNameString = typeof tierName === "string" ? tierName : ""; + const upper = tierNameString.toUpperCase(); if (upper.includes("ULTRA")) return "Ultra"; if (upper.includes("PRO")) return "Pro"; @@ -1580,11 +1618,11 @@ 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) { - return tierName.charAt(0).toUpperCase() + tierName.slice(1).toLowerCase(); + if (tierNameString) { + return tierNameString.charAt(0).toUpperCase() + tierNameString.slice(1).toLowerCase(); } return "Free"; @@ -1736,10 +1774,10 @@ async function probeAntigravityCreditBalanceUncached( * retrieveUserQuota only returns Gemini models — not suitable for Antigravity. */ async function getAntigravityUsage( - accessToken, - providerSpecificData, - connectionProjectId?, - connectionId?, + accessToken?: string | null, + providerSpecificData: JsonRecord | null = null, + connectionProjectId?: string | null, + connectionId?: string | null, options: AntigravityUsageOptions = {} ) { if (!accessToken) { @@ -1748,7 +1786,10 @@ async function getAntigravityUsage( try { const subscriptionInfo = await getAntigravitySubscriptionInfoCached(accessToken); - const projectId = connectionProjectId || subscriptionInfo?.cloudaicompanionProject || null; + const subscriptionProjectId = toRecord(subscriptionInfo).cloudaicompanionProject; + const projectId = + connectionProjectId || + (typeof subscriptionProjectId === "string" ? subscriptionProjectId : null); // Derive accountId for credit balance cache. // Must match executor key: credentials.connectionId @@ -1836,7 +1877,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) { const cacheKey = accessToken.substring(0, 16); const cached = _antigravitySubCache.get(cacheKey); @@ -1853,7 +1894,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", @@ -1872,9 +1913,9 @@ async function getAntigravitySubscriptionInfo(accessToken) { /** * Claude Usage - Try to fetch from Anthropic API */ -async function getClaudeUsage(accessToken) { +async function getClaudeUsage(accessToken?: string | null) { // Refresh bootstrap in parallel; best-effort, failure non-fatal. - const bootstrapPromise = fetchClaudeBootstrap(accessToken).catch(() => null); + const bootstrapPromise = fetchClaudeBootstrap(accessToken || "").catch(() => null); try { // Real CLI uses axios here, not Stainless — UA is `claude-code/` // (not `claude-cli/...`) and the shape is simpler than /v1/messages. @@ -1977,7 +2018,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 | null) { try { const settingsResponse = await fetch(CLAUDE_CONFIG.settingsUrl, { method: "GET", @@ -2030,7 +2071,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 | null, + providerSpecificData: Record = {} +) { try { // Use persisted workspace ID from OAuth - NO FALLBACK const accountId = @@ -2151,7 +2195,10 @@ 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((breakdown: JsonRecord) => { + 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, @@ -2205,8 +2255,9 @@ 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); + const freeUsed = toNumber(freeTrialInfo.currentUsageWithPrecision, 0); + const freeTotal = toNumber(freeTrialInfo.usageLimitWithPrecision, 0); quotaInfo[`${resourceType}_freetrial`] = { used: freeUsed, @@ -2232,10 +2283,10 @@ async function getKiroUsage(accessToken, providerSpecificData) { * LEVEL_BASIC = Moderato, LEVEL_INTERMEDIATE = Allegretto, * LEVEL_ADVANCED = Allegro, LEVEL_STANDARD = Vivace */ -function getKimiPlanName(level) { - if (!level) return ""; +function getKimiPlanName(level: unknown) { + if (typeof level !== "string" || !level) return ""; - const levelMap = { + const levelMap: Record = { LEVEL_BASIC: "Moderato", LEVEL_INTERMEDIATE: "Allegretto", LEVEL_ADVANCED: "Allegro", @@ -2249,7 +2300,7 @@ function getKimiPlanName(level) { * 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 | null) { // Generate device info for headers (same as OAuth flow) const deviceId = "kimi-usage-" + Date.now(); const platform = "omniroute"; @@ -2401,7 +2452,10 @@ async function getKimiUsage(accessToken) { /** * Qwen Usage */ -async function getQwenUsage(accessToken, providerSpecificData) { +async function getQwenUsage( + accessToken?: string | null, + providerSpecificData: JsonRecord | null = null +) { try { const resourceUrl = providerSpecificData?.resourceUrl; if (!resourceUrl) { @@ -2418,7 +2472,7 @@ async function getQwenUsage(accessToken, providerSpecificData) { /** * Qoder Usage */ -async function getQoderUsage(accessToken) { +async function getQoderUsage(accessToken?: string | null) { 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 5a2c1f825f..414d70d5f3 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,19 +91,20 @@ export const DEFAULT_SAFETY_SETTINGS = [ ]; // Convert OpenAI content to Gemini parts -export function convertOpenAIContentToParts(content: any) { - const parts: any[] = []; +export function convertOpenAIContentToParts(content: unknown) { + const parts: Array> = []; if (typeof content === "string") { parts.push({ text: content }); } else if (Array.isArray(content)) { - for (const item of content) { + for (const rawItem of content) { + const item = toRecord(rawItem); if (item.type === "text") { parts.push({ text: item.text }); } else { // 1. Handle Gemini native inline_data injected into OpenAI arrays (e.g. Cherry Studio) - const geminiInline = item.inline_data || item.inlineData; - if (geminiInline?.data) { + const geminiInline = toRecord(item.inline_data || item.inlineData); + if (typeof geminiInline.data === "string") { parts.push({ inlineData: { mimeType: geminiInline.mime_type || geminiInline.mimeType || "application/pdf", @@ -112,23 +115,26 @@ export function convertOpenAIContentToParts(content: any) { } // 2. Handle Claude-style source blocks commonly used by AI clients - if (item.source?.type === "base64" && item.source?.data) { + const source = toRecord(item.source); + if (source.type === "base64" && typeof source.data === "string") { parts.push({ inlineData: { - mimeType: item.source.media_type || "application/pdf", - data: item.source.data.replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""), + mimeType: source.media_type || "application/pdf", + data: source.data.replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""), }, }); continue; } // 3. Handle raw data strings (e.g. {"type": "file", "data": "JVBER...", "mime_type": "..."}) - const rawDataStr = item.data || item.file?.data || item.document?.data; + const file = toRecord(item.file); + const document = toRecord(item.document); + const rawDataStr = item.data || file.data || document.data; const mimeTypeFallback = item.mime_type || item.media_type || - item.file?.mime_type || - item.document?.mime_type || + file.mime_type || + document.mime_type || "application/octet-stream"; if (typeof rawDataStr === "string" && !rawDataStr.startsWith("http")) { const rawData = rawDataStr.replace(/^data:[a-zA-Z0-9/+-]+;base64,/, ""); @@ -142,8 +148,9 @@ export function convertOpenAIContentToParts(content: any) { } // 4. Standard OpenAI Data URIs - const fileData = - item.image_url?.url || item.file_url?.url || item.file?.url || item.document?.url; + const imageUrl = toRecord(item.image_url); + const fileUrl = toRecord(item.file_url); + const fileData = imageUrl.url || fileUrl.url || file.url || document.url; if (typeof fileData === "string" && fileData.startsWith("data:")) { const commaIndex = fileData.indexOf(","); if (commaIndex !== -1) { @@ -164,19 +171,20 @@ export function convertOpenAIContentToParts(content: any) { } // 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((c) => toRecord(c)) .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); @@ -198,9 +206,9 @@ export function generateSessionId() { return `-${num.toString()}`; } -function cloneSchemaValue(value) { +function cloneSchemaValue(value: unknown): unknown { if (Array.isArray(value)) { - return value.map((item) => cloneSchemaValue(item)); + return value.map((item: unknown) => cloneSchemaValue(item)); } if (value && typeof value === "object") { return Object.fromEntries( @@ -210,18 +218,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: string): string { return String(segment).replace(/~1/g, "/").replace(/~0/g, "~"); } -function resolveLocalReference(root, ref) { +function resolveLocalReference(root: unknown, ref: unknown): unknown { if (typeof ref !== "string" || !ref.startsWith("#/")) return null; - let current = root; + let current: unknown = root; const segments = ref .slice(2) .split("/") @@ -229,25 +237,30 @@ 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 (Object.keys(currentRecord).length === 0 || !(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)); + return node.map((item: unknown) => inlineLocalSchemaRefs(item, root, activeRefs)); } if (!node || typeof node !== "object") { return node; } - const record = { ...node }; + const record = { ...toRecord(node) }; const ref = typeof record.$ref === "string" ? record.$ref : ""; if (ref.startsWith("#/$defs/") || ref.startsWith("#/definitions/")) { const rest = { ...record }; @@ -280,7 +293,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) { if (!obj || typeof obj !== "object") return; if (Array.isArray(obj)) { @@ -288,14 +301,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); } @@ -303,7 +317,7 @@ function removeUnsupportedKeywords(obj, keywords) { } } -function normalizeAdditionalProperties(obj) { +function normalizeAdditionalProperties(obj: unknown) { if (!obj || typeof obj !== "object") return; if (Array.isArray(obj)) { @@ -316,11 +330,12 @@ function normalizeAdditionalProperties(obj) { // 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; + const record = obj as JsonRecord; + 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); } @@ -328,15 +343,16 @@ function normalizeAdditionalProperties(obj) { } // Convert const to enum -function convertConstToEnum(obj) { +function convertConstToEnum(obj: unknown) { 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); } @@ -345,22 +361,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) { 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); } @@ -368,20 +385,24 @@ function convertEnumValuesToStrings(obj) { } // Merge allOf schemas -function mergeAllOf(obj) { +function mergeAllOf(obj: unknown) { if (!obj || typeof obj !== "object") return; - if (obj.allOf && Array.isArray(obj.allOf)) { + const record = obj as JsonRecord; + if (record.allOf && Array.isArray(record.allOf)) { const merged: { properties?: Record; required?: string[] } = {}; - for (const item of obj.allOf) { + for (const itemValue of record.allOf) { + const item = toRecord(itemValue); if (item.properties) { if (!merged.properties) merged.properties = {}; - Object.assign(merged.properties, item.properties); + Object.assign(merged.properties, toRecord(item.properties)); } if (item.required && Array.isArray(item.required)) { if (!merged.required) merged.required = []; - for (const req of item.required) { + for (const req of item.required.filter( + (value: unknown): value is string => typeof value === "string" + )) { if (!merged.required.includes(req)) { merged.required.push(req); } @@ -389,12 +410,18 @@ function mergeAllOf(obj) { } } - 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 existingRequired = Array.isArray(record.required) + ? record.required.filter((value: unknown): value is string => typeof value === "string") + : []; + record.required = [...existingRequired, ...merged.required]; + } } - for (const value of Object.values(obj)) { + for (const value of Object.values(record)) { if (value && typeof value === "object") { mergeAllOf(value); } @@ -402,12 +429,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; @@ -429,30 +456,31 @@ function selectBest(items) { } // Flatten anyOf/oneOf -function flattenAnyOfOneOf(obj) { +function flattenAnyOfOneOf(obj: unknown) { 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: unknown) => 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: unknown) => 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); } @@ -460,15 +488,16 @@ function flattenAnyOfOneOf(obj) { } // Flatten type arrays -function flattenTypeArrays(obj) { +function flattenTypeArrays(obj: unknown) { 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: unknown) => 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); } @@ -477,7 +506,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); @@ -499,22 +528,25 @@ export function cleanJSONSchemaForAntigravity(schema) { removeUnsupportedKeywords(cleaned, GEMINI_UNSUPPORTED_SCHEMA_KEYS); // Phase 5: Cleanup required fields recursively. - function cleanupRequired(obj) { + function cleanupRequired(obj: unknown) { 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: unknown): field is string => + 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); } @@ -524,23 +556,24 @@ export function cleanJSONSchemaForAntigravity(schema) { cleanupRequired(cleaned); // Phase 6: Add placeholder for empty object schemas (Antigravity requirement). - function addPlaceholders(obj) { + function addPlaceholders(obj: unknown) { 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/translator/helpers/geminiToolsSanitizer.ts b/open-sse/translator/helpers/geminiToolsSanitizer.ts index 3d36dc3468..3ed55b880a 100644 --- a/open-sse/translator/helpers/geminiToolsSanitizer.ts +++ b/open-sse/translator/helpers/geminiToolsSanitizer.ts @@ -211,13 +211,13 @@ export function buildGeminiTools( const result: GeminiTool[] = []; + if (googleSearchTool) { + return [googleSearchTool]; + } + if (functionDeclarations.length > 0) { result.push({ functionDeclarations }); } - if (googleSearchTool) { - result.push(googleSearchTool); - } - return result.length > 0 ? result : undefined; } diff --git a/open-sse/utils/error.ts b/open-sse/utils/error.ts index 8a84469d4a..a281d9d694 100644 --- a/open-sse/utils/error.ts +++ b/open-sse/utils/error.ts @@ -3,6 +3,14 @@ import { getDefaultErrorMessage, getErrorInfo } from "../config/errorConfig.ts"; import { normalizePayloadForLog } from "@/lib/logPayloads"; import type { ModelCooldownErrorPayload } from "@/types"; +type ErrorResponseBody = { + error: { + message: string; + type: string; + code: string; + }; +}; + /** * Sanitize an error message to prevent stack trace exposure in API responses. * Strips stack traces and internal file paths from error messages before they @@ -22,7 +30,7 @@ function sanitizeErrorMessage(message: unknown): string { * @param {string} message - Error message * @returns {object} Error response object */ -export function buildErrorBody(statusCode, message) { +export function buildErrorBody(statusCode, message): ErrorResponseBody { const errorInfo = getErrorInfo(statusCode); return { @@ -220,10 +228,10 @@ export function createErrorResult( ) { const body = buildErrorBody(statusCode, message); if (errorCode) { - (body.error as any).code = errorCode; + body.error.code = errorCode; } if (errorType) { - (body.error as any).type = errorType; + body.error.type = errorType; } const result: { diff --git a/package.json b/package.json index f389317ebe..96ddbfcc20 100644 --- a/package.json +++ b/package.json @@ -90,7 +90,7 @@ "typecheck:noimplicit:core": "tsc --pretty false -p tsconfig.typecheck-noimplicit-core.json", "backfill-aggregation": "node --import tsx/esm src/scripts/backfillAggregation.ts", "env:sync": "node scripts/sync-env.mjs", - "test:integration": "node --import tsx/esm --test tests/integration/*.test.ts", + "test:integration": "cross-env DISABLE_SQLITE_AUTO_BACKUP=true node --import tsx/esm --test --test-force-exit --test-concurrency=1 tests/integration/*.test.ts", "test:e2e": "node scripts/run-playwright-tests.mjs test tests/e2e/*.spec.ts", "test:protocols:e2e": "node scripts/run-protocol-clients-tests.mjs", "test:vitest": "vitest run --config vitest.mcp.config.ts", diff --git a/src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx b/src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx index a2fc51e900..e1c476be64 100644 --- a/src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx +++ b/src/app/(dashboard)/dashboard/settings/components/MemorySkillsTab.tsx @@ -710,7 +710,7 @@ export default function MemorySkillsTab() { - {/* Skills Settings (placeholder) */} + {/* Skills Settings */}
diff --git a/src/app/api/v1/agents/tasks/[id]/route.ts b/src/app/api/v1/agents/tasks/[id]/route.ts index 031bd51a29..78ef7dedf0 100644 --- a/src/app/api/v1/agents/tasks/[id]/route.ts +++ b/src/app/api/v1/agents/tasks/[id]/route.ts @@ -1,7 +1,11 @@ import { NextRequest, NextResponse } from "next/server"; import { extractApiKey } from "@/sse/services/auth"; import { getAgent } from "@/lib/cloudAgent/registry"; -import { getCloudAgentTaskById, updateCloudAgentTask } from "@/lib/cloudAgent/db"; +import { + createCloudAgentTaskTable, + getCloudAgentTaskById, + updateCloudAgentTask, +} from "@/lib/cloudAgent/db"; import { z } from "zod"; import pino from "pino"; @@ -32,8 +36,34 @@ const CancelSchema = z.object({ action: z.literal("cancel"), }); +const TaskActionSchema = z.discriminatedUnion("action", [ + ApproveSchema, + MessageSchema, + CancelSchema, +]); + +function apiKeyRequiredResponse() { + return NextResponse.json( + { error: "API key required" }, + { status: 401, headers: getCorsHeaders() } + ); +} + +function getRequiredApiKey(request: NextRequest) { + const apiKey = extractApiKey(request); + if (!apiKey) { + return { apiKey: null, response: apiKeyRequiredResponse() }; + } + return { apiKey, response: null }; +} + export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { + const auth = getRequiredApiKey(request); + if (auth.response) return auth.response; + + createCloudAgentTaskTable(); + const { id } = await params; const task = getCloudAgentTaskById(id); @@ -44,18 +74,10 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ ); } - const apiKey = extractApiKey(request); - if (!apiKey) { - return NextResponse.json( - { error: "API key required" }, - { status: 401, headers: getCorsHeaders() } - ); - } - const agent = getAgent(task.provider_id); if (agent && task.external_id) { try { - const statusResult = await agent.getStatus(task.external_id, { apiKey }); + const statusResult = await agent.getStatus(task.external_id, { apiKey: auth.apiKey }); updateCloudAgentTask(id, { status: statusResult.status, @@ -104,8 +126,20 @@ export async function GET(request: NextRequest, { params }: { params: Promise<{ export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) { try { + const auth = getRequiredApiKey(request); + if (auth.response) return auth.response; + const { id } = await params; const body = await request.json(); + const validation = TaskActionSchema.safeParse(body); + if (!validation.success) { + return NextResponse.json( + { error: "Validation failed", details: validation.error.issues }, + { status: 400, headers: getCorsHeaders() } + ); + } + + createCloudAgentTaskTable(); const task = getCloudAgentTaskById(id); if (!task) { @@ -115,27 +149,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ ); } - const apiKey = extractApiKey(request); - if (!apiKey) { - return NextResponse.json( - { error: "API key required" }, - { status: 401, headers: getCorsHeaders() } - ); - } - - let validated; - if (body.action === "approve") { - validated = ApproveSchema.parse(body); - } else if (body.action === "message") { - validated = MessageSchema.parse(body); - } else if (body.action === "cancel") { - validated = CancelSchema.parse(body); - } else { - return NextResponse.json( - { error: "Invalid action" }, - { status: 400, headers: getCorsHeaders() } - ); - } + const validated = validation.data; const agent = getAgent(task.provider_id); if (!agent) { @@ -152,7 +166,7 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ { status: 400, headers: getCorsHeaders() } ); } - await agent.approvePlan(task.external_id, { apiKey }); + await agent.approvePlan(task.external_id, { apiKey: auth.apiKey }); updateCloudAgentTask(id, { status: "running" }); } else if (validated.action === "message") { if (!task.external_id) { @@ -161,7 +175,9 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ { status: 400, headers: getCorsHeaders() } ); } - const activity = await agent.sendMessage(task.external_id, validated.message, { apiKey }); + const activity = await agent.sendMessage(task.external_id, validated.message, { + apiKey: auth.apiKey, + }); const activities = JSON.parse(task.activities); activities.push(activity); updateCloudAgentTask(id, { activities: JSON.stringify(activities) }); @@ -171,12 +187,6 @@ export async function POST(request: NextRequest, { params }: { params: Promise<{ return NextResponse.json({ success: true }, { headers: getCorsHeaders() }); } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: "Validation failed", details: error.errors }, - { status: 400, headers: getCorsHeaders() } - ); - } logger.error({ err: error }, "Failed to process task action"); return NextResponse.json( { error: error instanceof Error ? error.message : "Unknown error" }, diff --git a/src/app/api/v1/agents/tasks/route.ts b/src/app/api/v1/agents/tasks/route.ts index 600817d107..260edc8a97 100644 --- a/src/app/api/v1/agents/tasks/route.ts +++ b/src/app/api/v1/agents/tasks/route.ts @@ -2,17 +2,14 @@ import { NextRequest, NextResponse } from "next/server"; import { extractApiKey } from "@/sse/services/auth"; import { getAgent } from "@/lib/cloudAgent/registry"; import { + createCloudAgentTaskTable, insertCloudAgentTask, - getCloudAgentTaskById, getAllCloudAgentTasks, getCloudAgentTasksByProvider, getCloudAgentTasksByStatus, - updateCloudAgentTask, deleteCloudAgentTask, } from "@/lib/cloudAgent/db"; import { CreateCloudAgentTaskSchema } from "@/lib/cloudAgent/types"; -import { CLOUD_AGENT_PROVIDERS } from "@/shared/constants/providers"; -import { z } from "zod"; import pino from "pino"; const logger = pino({ name: "cloud-agents-api" }); @@ -25,12 +22,32 @@ function getCorsHeaders() { }; } +function apiKeyRequiredResponse() { + return NextResponse.json( + { error: "API key required" }, + { status: 401, headers: getCorsHeaders() } + ); +} + +function getRequiredApiKey(request: NextRequest) { + const apiKey = extractApiKey(request); + if (!apiKey) { + return { apiKey: null, response: apiKeyRequiredResponse() }; + } + return { apiKey, response: null }; +} + export async function OPTIONS() { return new NextResponse(null, { headers: getCorsHeaders() }); } export async function GET(request: NextRequest) { try { + const auth = getRequiredApiKey(request); + if (auth.response) return auth.response; + + createCloudAgentTaskTable(); + const { searchParams } = new URL(request.url); const providerId = searchParams.get("provider"); const status = searchParams.get("status"); @@ -75,17 +92,20 @@ export async function GET(request: NextRequest) { export async function POST(request: NextRequest) { try { - const body = await request.json(); - const validated = CreateCloudAgentTaskSchema.parse(body); + const auth = getRequiredApiKey(request); + if (auth.response) return auth.response; - const apiKey = extractApiKey(request); - if (!apiKey) { + const body = await request.json(); + const validation = CreateCloudAgentTaskSchema.safeParse(body); + if (!validation.success) { return NextResponse.json( - { error: "API key required" }, - { status: 401, headers: getCorsHeaders() } + { error: "Validation failed", details: validation.error.issues }, + { status: 400, headers: getCorsHeaders() } ); } + const validated = validation.data; + const agent = getAgent(validated.providerId); if (!agent) { return NextResponse.json( @@ -100,9 +120,10 @@ export async function POST(request: NextRequest) { source: validated.source, options: validated.options || {}, }, - { apiKey } + { apiKey: auth.apiKey } ); + createCloudAgentTaskTable(); insertCloudAgentTask({ id: task.id, provider_id: task.providerId, @@ -135,12 +156,6 @@ export async function POST(request: NextRequest) { { status: 201, headers: getCorsHeaders() } ); } catch (error) { - if (error instanceof z.ZodError) { - return NextResponse.json( - { error: "Validation failed", details: error.errors }, - { status: 400, headers: getCorsHeaders() } - ); - } logger.error({ err: error }, "Failed to create cloud agent task"); return NextResponse.json( { error: error instanceof Error ? error.message : "Unknown error" }, @@ -151,6 +166,11 @@ export async function POST(request: NextRequest) { export async function DELETE(request: NextRequest) { try { + const auth = getRequiredApiKey(request); + if (auth.response) return auth.response; + + createCloudAgentTaskTable(); + const { searchParams } = new URL(request.url); const taskId = searchParams.get("id"); diff --git a/src/app/docs/lib/docs-auto-generated.ts b/src/app/docs/lib/docs-auto-generated.ts index 47b3790d63..c9a2f7988c 100644 --- a/src/app/docs/lib/docs-auto-generated.ts +++ b/src/app/docs/lib/docs-auto-generated.ts @@ -296,7 +296,7 @@ export const autoSearchIndex: AutoGenSearchItem[] = [ fileName: "AUTO-COMBO.md", section: "Features", content: - "Self-managing model chains with adaptive scoring + zero-config auto-routing NEW: No combo creation required. Use auto/ prefix directly in any client. Model ID Variant Behavior ------------------ --------- ------------------------------------------------------------------------ auto default All conne", + "Self-managing model chains with adaptive scoring + zero-config auto-routing NEW: No combo creation required. Use auto/ prefix directly in any client. Model ID Variant Behavior -------------- ------- ------------------------------------------------------------------------ auto default All connected p", headings: [ "Zero-Config Auto-Routing (auto/ prefix)", "Quick Examples", diff --git a/src/lib/api/requireManagementAuth.ts b/src/lib/api/requireManagementAuth.ts index 9f119e5239..9c085e4f5e 100644 --- a/src/lib/api/requireManagementAuth.ts +++ b/src/lib/api/requireManagementAuth.ts @@ -24,8 +24,8 @@ export async function requireManagementAuth(request: Request): Promise(); +interface RedisAuthCacheClient { + get(key: string): Promise; + set(key: string, value: string, mode: "EX", ttlSeconds: number): Promise; + del(...keys: string[]): Promise; +} + const API_KEY_COLUMN_FALLBACKS = [ { name: "allowed_models", definition: "allowed_models TEXT" }, { name: "no_log", definition: "no_log INTEGER NOT NULL DEFAULT 0" }, @@ -164,6 +170,65 @@ function invalidateCaches() { _lastUsedUpdateCache.clear(); } +function shouldUseRedisAuthCache(): boolean { + if (process.env.REDIS_URL && process.env.REDIS_URL.trim() !== "") return true; + return process.env.NODE_ENV !== "test" && process.env.DISABLE_SQLITE_AUTO_BACKUP !== "true"; +} + +async function getRedisAuthCacheClient(): Promise { + if (!shouldUseRedisAuthCache()) return null; + + try { + const { getRedisClient } = await import("@/shared/utils/rateLimiter"); + return getRedisClient() as RedisAuthCacheClient; + } catch { + return null; + } +} + +async function getRedisAuthCache(hashedKey: string): Promise { + const redis = await getRedisAuthCacheClient(); + if (!redis) return null; + + try { + return await redis.get(`auth:api_key:${hashedKey}`); + } catch { + return null; + } +} + +async function setRedisAuthCache( + hashedKey: string, + value: JsonRecord, + ttlSeconds: number +): Promise { + const redis = await getRedisAuthCacheClient(); + if (!redis) return; + + try { + await redis.set(`auth:api_key:${hashedKey}`, JSON.stringify(value), "EX", ttlSeconds); + } catch { + // Redis is an optimization; SQLite remains authoritative. + } +} + +async function deleteRedisAuthCacheKeys(...hashes: unknown[]): Promise { + const redisKeys = hashes + .filter((hash): hash is string => typeof hash === "string" && hash.trim() !== "") + .map((hash) => `auth:api_key:${hash}`); + + if (redisKeys.length === 0) return; + + const redis = await getRedisAuthCacheClient(); + if (!redis) return; + + try { + await redis.del(...redisKeys); + } catch { + // Redis is an optimization; SQLite remains authoritative. + } +} + function toRecord(value: unknown): JsonRecord { return value && typeof value === "object" ? (value as JsonRecord) : {}; } @@ -402,13 +467,11 @@ function parseRateLimits(value: unknown): RateLimitRule[] | null { try { const parsed = JSON.parse(value); if (!Array.isArray(parsed)) return null; - return parsed.filter( - (rule: any) => - typeof rule === "object" && - rule !== null && - typeof rule.limit === "number" && - typeof rule.window === "number" - ) as RateLimitRule[]; + return parsed.filter((rule): rule is RateLimitRule => { + if (!rule || typeof rule !== "object") return false; + const candidate = rule as Record; + return typeof candidate.limit === "number" && typeof candidate.window === "number"; + }); } catch { return null; } @@ -526,15 +589,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 deleteRedisAuthCacheKeys(row.key_hash, newHash); const { logAuditEvent } = await import("@/lib/compliance"); logAuditEvent({ @@ -737,12 +792,8 @@ export async function updateApiKeyPermissions( 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) { + await deleteRedisAuthCacheKeys(row?.key_hash); + } catch { // Fail silent } @@ -753,6 +804,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 +815,7 @@ export async function deleteApiKey(id: string) { // Invalidate caches since a key was removed invalidateCaches(); + await deleteRedisAuthCacheKeys(row?.key_hash ?? row?.keyHash); backupDbFile("pre-write"); return true; @@ -786,6 +839,10 @@ export async function revokeApiKey(id: string): Promise { if ((result.changes ?? 0) === 0) return false; invalidateCaches(); + const row = db.prepare("SELECT key_hash FROM api_keys WHERE id = ?").get(id) as + | { key_hash: string | null } + | undefined; + await deleteRedisAuthCacheKeys(row?.key_hash); backupDbFile("pre-write"); return true; } @@ -804,6 +861,10 @@ export async function setApiKeyExpiry(id: string, expiresAt: string | null): Pro if ((result.changes ?? 0) === 0) return false; invalidateCaches(); + const row = db.prepare("SELECT key_hash FROM api_keys WHERE id = ?").get(id) as + | { key_hash: string | null } + | undefined; + await deleteRedisAuthCacheKeys(row?.key_hash); backupDbFile("pre-write"); return true; } @@ -817,10 +878,8 @@ export async function setApiKeyExpiry(id: string, expiresAt: string | null): Pro * - revoked_at IS NULL, * - expires_at IS NULL OR expires_at > now. * - * Cache TTL is short (CACHE_TTL) and the metadata cache is also invalidated - * by revokeApiKey/updateApiKeyPermissions/deleteApiKey, so a revoke takes - * effect within at most CACHE_TTL even without an explicit clear in the - * caller. + * Cache TTL is short (CACHE_TTL), and every key lifecycle path invalidates + * both the in-process caches and the Redis auth fast path. */ export async function validateApiKey(key: string | null | undefined) { if (!key || typeof key !== "string") return false; @@ -836,13 +895,9 @@ 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 redisData = await getRedisAuthCache(hashedKey); + if (redisData) { + try { const data = JSON.parse(redisData); const isBanned = !!data.isBanned; const isActive = !!data.isActive; @@ -856,9 +911,9 @@ export async function validateApiKey(key: string | null | undefined) { if (Number.isFinite(expiresMs) && expiresMs <= now) return false; } return true; + } catch { + await deleteRedisAuthCacheKeys(hashedKey); } - } catch (err) { - // Fail silent for Redis lookup } const db = getDbInstance() as ApiKeysDbLike; @@ -885,26 +940,17 @@ 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 - } + await setRedisAuthCache( + hashedKey, + { + id: row.id, + isBanned: parseIsBanned(row.is_banned), + isActive: parseIsActive(row.is_active), + expiresAt: row.expires_at, + revokedAt: row.revoked_at, + }, + 3600 + ); markApiKeyUsed(db, row.id, now); diff --git a/src/lib/db/domainState.ts b/src/lib/db/domainState.ts index 4f0e9edb32..093355a28e 100644 --- a/src/lib/db/domainState.ts +++ b/src/lib/db/domainState.ts @@ -38,6 +38,24 @@ interface BudgetResetLogRecord { periodEnd: number; } +interface FallbackChainEntry { + provider: string; + priority: number; + enabled: boolean; +} + +interface LockoutStateRecord { + attempts: number[]; + lockedUntil: number | null; +} + +interface CircuitBreakerStateRecord { + state: string; + failureCount: number; + lastFailureTime: number | null; + options?: JsonRecord | null; +} + let _budgetSchemaChecked = false; function asRecord(value: unknown): JsonRecord { @@ -114,7 +132,7 @@ function ensureBudgetSchema() { * @param {string} model * @param {Array<{provider: string, priority: number, enabled: boolean}>} chain */ -export function saveFallbackChain(model, chain) { +export function saveFallbackChain(model: string, chain: FallbackChainEntry[]) { const db = getDbInstance(); db.prepare("INSERT OR REPLACE INTO domain_fallback_chains (model, chain) VALUES (?, ?)").run( model, @@ -127,7 +145,7 @@ export function saveFallbackChain(model, chain) { * @param {string} model * @returns {Array<{provider: string, priority: number, enabled: boolean}> | null} */ -export function loadFallbackChain(model) { +export function loadFallbackChain(model: string): FallbackChainEntry[] | null { const db = getDbInstance(); const row = db.prepare("SELECT chain FROM domain_fallback_chains WHERE model = ?").get(model); const chain = asRecord(row).chain; @@ -157,7 +175,7 @@ export function loadAllFallbackChains() { * @param {string} model * @returns {boolean} */ -export function deleteFallbackChain(model) { +export function deleteFallbackChain(model: string) { const db = getDbInstance(); const info = db.prepare("DELETE FROM domain_fallback_chains WHERE model = ?").run(model); return info.changes > 0; @@ -178,7 +196,7 @@ export function deleteAllFallbackChains() { * @param {string} apiKeyId * @param {{ dailyLimitUsd: number, monthlyLimitUsd?: number, warningThreshold?: number }} config */ -export function saveBudget(apiKeyId, config) { +export function saveBudget(apiKeyId: string, config: Partial) { ensureBudgetSchema(); const db = getDbInstance(); db.prepare( @@ -216,7 +234,7 @@ export function saveBudget(apiKeyId, config) { * @param {string} apiKeyId * @returns {{ dailyLimitUsd: number, monthlyLimitUsd: number, warningThreshold: number } | null} */ -export function loadBudget(apiKeyId) { +export function loadBudget(apiKeyId: string): BudgetConfigRecord | null { ensureBudgetSchema(); const db = getDbInstance(); const row = db.prepare("SELECT * FROM domain_budgets WHERE api_key_id = ?").get(apiKeyId); @@ -228,7 +246,9 @@ export function loadBudget(apiKeyId) { monthlyLimitUsd: toNumber(record.monthly_limit_usd), warningThreshold: toNumber(record.warning_threshold, 0.8), resetInterval: - typeof record.reset_interval === "string" ? record.reset_interval : ("daily" as const), + typeof record.reset_interval === "string" + ? (record.reset_interval as BudgetResetInterval) + : "daily", resetTime: typeof record.reset_time === "string" ? record.reset_time : "00:00", budgetResetAt: toNumber(record.budget_reset_at, 0) || null, lastBudgetResetAt: toNumber(record.last_budget_reset_at, 0) || null, @@ -335,7 +355,7 @@ export function loadBudgetResetLogs(apiKeyId: string, limit = 10) { * Delete a budget config. * @param {string} apiKeyId */ -export function deleteBudget(apiKeyId) { +export function deleteBudget(apiKeyId: string) { ensureBudgetSchema(); const db = getDbInstance(); db.prepare("DELETE FROM domain_budgets WHERE api_key_id = ?").run(apiKeyId); @@ -350,7 +370,7 @@ export function deleteBudget(apiKeyId) { * @param {number} cost * @param {number} [timestamp] */ -export function saveCostEntry(apiKeyId, cost, timestamp = Date.now()) { +export function saveCostEntry(apiKeyId: string, cost: number, timestamp = Date.now()) { ensureBudgetSchema(); const db = getDbInstance(); db.prepare("INSERT INTO domain_cost_history (api_key_id, cost, timestamp) VALUES (?, ?, ?)").run( @@ -437,7 +457,7 @@ export function loadCostEntriesInRange( * @param {number} olderThanTimestamp * @returns {number} deleted count */ -export function cleanOldCostEntries(olderThanTimestamp) { +export function cleanOldCostEntries(olderThanTimestamp: number) { ensureBudgetSchema(); const db = getDbInstance(); const info = db @@ -450,7 +470,7 @@ export function cleanOldCostEntries(olderThanTimestamp) { * Delete all cost data for an API key. * @param {string} apiKeyId */ -export function deleteCostEntries(apiKeyId) { +export function deleteCostEntries(apiKeyId: string) { ensureBudgetSchema(); const db = getDbInstance(); db.prepare("DELETE FROM domain_cost_history WHERE api_key_id = ?").run(apiKeyId); @@ -474,7 +494,7 @@ export function deleteAllCostData() { * @param {string} identifier * @param {{ attempts: number[], lockedUntil: number|null }} state */ -export function saveLockoutState(identifier, state) { +export function saveLockoutState(identifier: string, state: LockoutStateRecord) { const db = getDbInstance(); db.prepare( `INSERT OR REPLACE INTO domain_lockout_state (identifier, attempts, locked_until) @@ -487,7 +507,7 @@ export function saveLockoutState(identifier, state) { * @param {string} identifier * @returns {{ attempts: number[], lockedUntil: number|null } | null} */ -export function loadLockoutState(identifier) { +export function loadLockoutState(identifier: string): LockoutStateRecord | null { const db = getDbInstance(); const row = db.prepare("SELECT * FROM domain_lockout_state WHERE identifier = ?").get(identifier); if (!row) return null; @@ -504,7 +524,7 @@ export function loadLockoutState(identifier) { * Delete lockout state for an identifier. * @param {string} identifier */ -export function deleteLockoutState(identifier) { +export function deleteLockoutState(identifier: string) { const db = getDbInstance(); db.prepare("DELETE FROM domain_lockout_state WHERE identifier = ?").run(identifier); } @@ -538,7 +558,7 @@ export function loadAllLockedIdentifiers() { * @param {string} name * @param {{ state: string, failureCount: number, lastFailureTime: number|null, options?: object }} cbState */ -export function saveCircuitBreakerState(name, cbState) { +export function saveCircuitBreakerState(name: string, cbState: CircuitBreakerStateRecord) { const db = getDbInstance(); db.prepare( `INSERT OR REPLACE INTO domain_circuit_breakers (name, state, failure_count, last_failure_time, options) @@ -557,7 +577,7 @@ export function saveCircuitBreakerState(name, cbState) { * @param {string} name * @returns {{ state: string, failureCount: number, lastFailureTime: number|null, options?: object } | null} */ -export function loadCircuitBreakerState(name) { +export function loadCircuitBreakerState(name: string): CircuitBreakerStateRecord | null { const db = getDbInstance(); const row = db.prepare("SELECT * FROM domain_circuit_breakers WHERE name = ?").get(name); if (!row) return null; @@ -596,7 +616,7 @@ export function loadAllCircuitBreakerStates() { * Delete a circuit breaker state. * @param {string} name */ -export function deleteCircuitBreakerState(name) { +export function deleteCircuitBreakerState(name: string) { const db = getDbInstance(); db.prepare("DELETE FROM domain_circuit_breakers WHERE name = ?").run(name); } diff --git a/src/lib/db/settings.ts b/src/lib/db/settings.ts index c76fd50dd9..7ac957d04a 100644 --- a/src/lib/db/settings.ts +++ b/src/lib/db/settings.ts @@ -272,7 +272,10 @@ export async function getPricingWithSources(): Promise<{ export async function getPricingForModel(provider: string, model: string) { const pricing = await getPricing(); - const findKeyInsensitive = (obj: Record | undefined | null, key: string) => { + const findKeyInsensitive = ( + obj: Record | undefined | null, + key: string + ): T | undefined => { if (!obj || !key) return undefined; const lowerKey = key.toLowerCase(); for (const [k, v] of Object.entries(obj)) { @@ -282,10 +285,10 @@ export async function getPricingForModel(provider: string, model: string) { }; const pLower = (provider || "").toLowerCase(); - let providerPricing = findKeyInsensitive(pricing, pLower); + let providerPricing = findKeyInsensitive(pricing, pLower); if (!providerPricing) { - const alias = findKeyInsensitive(PROVIDER_ID_TO_ALIAS, pLower); + const alias = findKeyInsensitive(PROVIDER_ID_TO_ALIAS, pLower); if (alias) providerPricing = findKeyInsensitive(pricing, alias); } @@ -308,7 +311,7 @@ export async function getPricingForModel(provider: string, model: string) { if (!providerPricing) return null; const mLower = (model || "").toLowerCase(); - let modelPricing = findKeyInsensitive(providerPricing, mLower); + let modelPricing = findKeyInsensitive(providerPricing, mLower); if (!modelPricing) { const hyphenModel = mLower.replace(/\./g, "-"); diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 3c01070b24..0a8b74a79b 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -1854,6 +1854,39 @@ export const UPSTREAM_PROXY_PROVIDERS = { }, }; +export const CLOUD_AGENT_PROVIDERS = { + jules: { + id: "jules", + alias: "jules", + name: "Google Jules", + icon: "engineering", + color: "#4285F4", + textIcon: "JL", + website: "https://jules.google", + authHint: "Jules API key for creating and managing cloud coding tasks.", + }, + devin: { + id: "devin", + alias: "devin", + name: "Devin", + icon: "smart_toy", + color: "#111827", + textIcon: "DV", + website: "https://devin.ai", + authHint: "Devin API key for cloud agent sessions.", + }, + "codex-cloud": { + id: "codex-cloud", + alias: "codex-cloud", + name: "Codex Cloud", + icon: "cloud", + color: "#10A37F", + textIcon: "CC", + website: "https://openai.com/codex", + authHint: "OpenAI API key with Codex Cloud task access.", + }, +}; + export function isClaudeCodeCompatibleProvider(providerId: unknown): providerId is string { return typeof providerId === "string" && providerId.startsWith(CLAUDE_CODE_COMPATIBLE_PREFIX); } diff --git a/src/shared/utils/circuitBreaker.ts b/src/shared/utils/circuitBreaker.ts index e6ba3a17f2..cb7b47af5a 100644 --- a/src/shared/utils/circuitBreaker.ts +++ b/src/shared/utils/circuitBreaker.ts @@ -148,7 +148,7 @@ export class CircuitBreaker { * @returns {Promise} * @throws {Error} If circuit is OPEN */ - async execute(fn) { + async execute(fn: () => Promise): Promise { this._refreshOpenState(); if (this.state === STATE.OPEN) { @@ -319,7 +319,7 @@ export class CircuitBreaker { } } - _transition(newState) { + _transition(newState: CircuitState) { const oldState = this.state; this.state = newState; if (newState === STATE.HALF_OPEN) { diff --git a/src/sse/services/auth.ts b/src/sse/services/auth.ts index 5af85cc791..210fb66a4e 100644 --- a/src/sse/services/auth.ts +++ b/src/sse/services/auth.ts @@ -1326,21 +1326,26 @@ export async function getProviderCredentialsWithQuotaPreflight( return credentials; } - const preflight = await preflightQuota(provider, credentials.connectionId, credentials); + const connectionId = credentials.connectionId; + if (!connectionId) { + return credentials; + } + + const preflight = await preflightQuota(provider, connectionId, credentials); if (preflight.proceed) { return credentials; } blockedByPreflight.push({ - id: credentials.connectionId, + id: connectionId, quotaPercent: preflight.quotaPercent, resetAt: preflight.resetAt ?? null, }); - excludedConnectionIds.add(credentials.connectionId); + excludedConnectionIds.add(connectionId); log.info( "AUTH", - `${provider} | preflight blocked ${credentials.connectionId.slice(0, 8)}${ + `${provider} | preflight blocked ${connectionId.slice(0, 8)}${ Number.isFinite(preflight.quotaPercent) ? ` at ${Math.round((preflight.quotaPercent as number) * 100)}%` : "" diff --git a/tests/integration/batch-e2e-rate-limit.test.ts b/tests/integration/batch-e2e-rate-limit.test.ts index 35c49d0f6f..a56e361d8a 100644 --- a/tests/integration/batch-e2e-rate-limit.test.ts +++ b/tests/integration/batch-e2e-rate-limit.test.ts @@ -111,26 +111,29 @@ function createServerProcess() { const stderrLines: string[] = []; let exitInfo: { code: number | null; signal: NodeJS.Signals | null } | null = null; - const child = spawn( - process.execPath, - ["node_modules/next/dist/bin/next", "dev", "--port", String(SERVER_PORT)], - { - cwd: REPO_ROOT, - env: { - DATA_DIR: TEST_DATA_DIR, - PORT: String(SERVER_PORT), - HOST: "127.0.0.1", - REQUIRE_API_KEY: "false", - API_KEY_SECRET: "batch-e2e-rl-secret", - DISABLE_SQLITE_AUTO_BACKUP: "true", - INITIAL_PASSWORD: "", - NEXT_TELEMETRY_DISABLED: "1", - OMNIROUTE_E2E_BOOTSTRAP_MODE: "open", - PATH: process.env.PATH, - }, - stdio: ["ignore", "pipe", "pipe"], - } - ); + const child = spawn(process.execPath, ["scripts/run-next-playwright.mjs", "dev"], { + cwd: REPO_ROOT, + env: { + ...process.env, + DATA_DIR: TEST_DATA_DIR, + PORT: String(SERVER_PORT), + DASHBOARD_PORT: String(SERVER_PORT), + API_PORT: String(SERVER_PORT), + HOST: "127.0.0.1", + REQUIRE_API_KEY: "false", + API_KEY_SECRET: "batch-e2e-rl-secret", + DISABLE_SQLITE_AUTO_BACKUP: "true", + INITIAL_PASSWORD: "", + NEXT_TELEMETRY_DISABLED: "1", + OMNIROUTE_E2E_BOOTSTRAP_MODE: "open", + OMNIROUTE_DISABLE_BACKGROUND_SERVICES: "false", + OMNIROUTE_DISABLE_TOKEN_HEALTHCHECK: "true", + OMNIROUTE_DISABLE_LOCAL_HEALTHCHECK: "true", + OMNIROUTE_HIDE_HEALTHCHECK_LOGS: "true", + PATH: process.env.PATH, + }, + stdio: ["ignore", "pipe", "pipe"], + }); child.once("exit", (code, signal) => { exitInfo = { code, signal }; diff --git a/tests/integration/proxy-pipeline.test.ts b/tests/integration/proxy-pipeline.test.ts index b54d369aea..f591fcb58c 100644 --- a/tests/integration/proxy-pipeline.test.ts +++ b/tests/integration/proxy-pipeline.test.ts @@ -59,7 +59,7 @@ describe("Chat Pipeline — handleSingleModelChat decomposition", () => { it("handleSingleModelChat should use resolveModelOrError", () => { // Extract handleSingleModelChat body - assert.match(src, /resolveModelOrError\(modelStr/); + assert.match(src, /resolveModelOrError\(\s*modelStr/); }); it("handleSingleModelChat should use checkPipelineGates", () => { diff --git a/tests/unit/api-auth.test.ts b/tests/unit/api-auth.test.ts index 4ac3ca42bb..5ced7ec13e 100644 --- a/tests/unit/api-auth.test.ts +++ b/tests/unit/api-auth.test.ts @@ -278,11 +278,13 @@ test("requireManagementAuth returns 401 with no credentials", async () => { assert.equal(res.status, 401); }); -test("requireManagementAuth returns 401 for an invalid API key", async () => { +test("requireManagementAuth returns 403 for an invalid management token", async () => { await setupAuth(); const res = await requireManagementAuth(managementRequest("sk-not-a-real-key")); assert.ok(res); - assert.equal(res.status, 401); + assert.equal(res.status, 403); + const body = await res.json(); + assert.equal(body.error?.message, "Invalid management token"); }); test("requireManagementAuth returns 403 for valid key without manage scope", async () => { @@ -314,13 +316,15 @@ test("requireManagementAuth returns null for OMNIROUTE_API_KEY env passthrough", } }); -test("requireManagementAuth returns 401 for revoked key with manage scope", async () => { +test("requireManagementAuth returns 403 for revoked key with manage scope", async () => { await setupAuth(); const key = await apiKeysDb.createApiKey("revoked-admin", "machine-test", ["manage"]); await apiKeysDb.revokeApiKey(key.id); const res = await requireManagementAuth(managementRequest(key.key)); assert.ok(res); - assert.equal(res.status, 401); + assert.equal(res.status, 403); + const body = await res.json(); + assert.equal(body.error?.message, "Invalid management token"); }); test("requireManagementAuth returns null for valid JWT cookie", async () => { diff --git a/tests/unit/executor-gemini-cli.test.ts b/tests/unit/executor-gemini-cli.test.ts index 4179c4ab8d..34ca694920 100644 --- a/tests/unit/executor-gemini-cli.test.ts +++ b/tests/unit/executor-gemini-cli.test.ts @@ -82,7 +82,7 @@ test("GeminiCLIExecutor.buildHeaders derives the User-Agent from the request mod assert.notEqual(flashHeaders["User-Agent"], proHeaders["User-Agent"]); }); -test("GeminiCLIExecutor.refreshProject caches loadCodeAssist lookups and transformRequest preserves existing body.project", async () => { +test("GeminiCLIExecutor.refreshProject caches loadCodeAssist lookups and transformRequest refreshes stale body.project", async () => { const executor = new GeminiCLIExecutor(); const originalFetch = globalThis.fetch; let calls = 0; @@ -107,7 +107,7 @@ test("GeminiCLIExecutor.refreshProject caches loadCodeAssist lookups and transfo assert.equal(first, "fresh-project-id"); assert.equal(second, "fresh-project-id"); assert.equal(calls, 1); - assert.equal(transformed.project, "stale-project"); + assert.equal(transformed.project, "fresh-project-id"); } finally { globalThis.fetch = originalFetch; } @@ -368,7 +368,7 @@ test("GeminiCLIExecutor.execute applies CLI fingerprint to the final Cloud Code "Authorization", ]); assert.equal(finalBody.model, "gemini-3.1-pro-preview"); - assert.equal(finalBody.project, "old-project"); + assert.equal(finalBody.project, "project-live"); assert.match(finalBody.user_prompt_id, /^agent-/); assert.match(finalBody.request.session_id, /^-\d+$/); assert.match(finalCall.headers["User-Agent"], /^GeminiCLI\/0\.41\.2\/gemini-3\.1-pro-preview /); diff --git a/tests/unit/model-alias-route.test.ts b/tests/unit/model-alias-route.test.ts index ccc90c024d..4682bdd25f 100644 --- a/tests/unit/model-alias-route.test.ts +++ b/tests/unit/model-alias-route.test.ts @@ -80,8 +80,8 @@ test("model alias route requires a dashboard session when management auth is ena assert.equal(unauthenticated.status, 401); assert.equal(unauthenticatedBody.error.message, "Authentication required"); - assert.equal(invalidToken.status, 401); - assert.equal(invalidTokenBody.error.message, "Invalid API key"); + assert.equal(invalidToken.status, 403); + assert.equal(invalidTokenBody.error.message, "Invalid management token"); assert.match(unauthenticated.headers.get("X-Model-Catalog-Version") || "", /^model-metadata-v1:/); }); diff --git a/tests/unit/model-test-route.test.ts b/tests/unit/model-test-route.test.ts index cfa92d793a..54445f54d3 100644 --- a/tests/unit/model-test-route.test.ts +++ b/tests/unit/model-test-route.test.ts @@ -94,8 +94,8 @@ test("model test route requires management auth when login protection is enabled assert.equal(unauthenticated.status, 401); assert.equal(unauthenticatedBody.error.message, "Authentication required"); - assert.equal(invalidToken.status, 401); - assert.equal(invalidTokenBody.error.message, "Invalid API key"); + assert.equal(invalidToken.status, 403); + assert.equal(invalidTokenBody.error.message, "Invalid management token"); }); test("model test route ignores forwarded hosts and works in strict API-key mode", async () => { diff --git a/tests/unit/payload-rules-route.test.ts b/tests/unit/payload-rules-route.test.ts index 2d6b17302e..9db6cf0117 100644 --- a/tests/unit/payload-rules-route.test.ts +++ b/tests/unit/payload-rules-route.test.ts @@ -91,8 +91,8 @@ test("payload rules route requires a dashboard session when management auth is e assert.equal(unauthenticated.status, 401); assert.equal(unauthenticatedBody.error.message, "Authentication required"); - assert.equal(invalidToken.status, 401); - assert.equal(invalidTokenBody.error.message, "Invalid API key"); + assert.equal(invalidToken.status, 403); + assert.equal(invalidTokenBody.error.message, "Invalid management token"); assert.equal(authenticated.status, 200); assert.deepEqual(authenticatedBody, { default: [], diff --git a/tests/unit/plan3-p0.test.ts b/tests/unit/plan3-p0.test.ts index 16e0a683da..7c4a786dcf 100644 --- a/tests/unit/plan3-p0.test.ts +++ b/tests/unit/plan3-p0.test.ts @@ -40,8 +40,8 @@ test("getModelInfoCore keeps unprefixed gpt-5.5 on the OpenAI fallback", async ( assert.equal(info.model, "gpt-5.5"); }); -test("getModelInfoCore keeps explicit gpt-5.5-medium separate from gpt-5.5", async () => { - const info = await getModelInfoCore("gpt-5.5-medium", {}); +test("getModelInfoCore keeps explicit cx/gpt-5.5-medium separate from gpt-5.5", async () => { + const info = await getModelInfoCore("cx/gpt-5.5-medium", {}); assert.equal(info.provider, "codex"); assert.equal(info.model, "gpt-5.5-medium"); }); diff --git a/tests/unit/proxy-management-v1-route.test.ts b/tests/unit/proxy-management-v1-route.test.ts index 66d8e2809f..8fa6b8e760 100644 --- a/tests/unit/proxy-management-v1-route.test.ts +++ b/tests/unit/proxy-management-v1-route.test.ts @@ -133,7 +133,7 @@ test("v1 management proxies main route covers auth, lookup variants, update and }), }) ); - assert.equal(postAuthRes.status, 401); + assert.equal(postAuthRes.status, 403); const patchAuthRes = await proxyV1Route.PATCH( new Request("http://localhost/api/v1/management/proxies", { @@ -142,7 +142,7 @@ test("v1 management proxies main route covers auth, lookup variants, update and body: JSON.stringify({ id: "proxy-1", notes: "denied" }), }) ); - assert.equal(patchAuthRes.status, 401); + assert.equal(patchAuthRes.status, 403); const deleteAuthRes = await proxyV1Route.DELETE( new Request("http://localhost/api/v1/management/proxies?id=proxy-1", { diff --git a/tests/unit/route-edge-coverage.test.ts b/tests/unit/route-edge-coverage.test.ts index 5b95e12125..87380832a7 100644 --- a/tests/unit/route-edge-coverage.test.ts +++ b/tests/unit/route-edge-coverage.test.ts @@ -174,8 +174,8 @@ test("api keys route covers auth, create, masking, pagination fallback and cloud assert.equal(unauthenticated.status, 401); assert.equal(unauthenticatedBody.error.message, "Authentication required"); - assert.equal(invalidToken.status, 401); - assert.equal(invalidTokenBody.error.message, "Invalid API key"); + assert.equal(invalidToken.status, 403); + assert.equal(invalidTokenBody.error.message, "Invalid management token"); assert.equal(created.status, 201); assert.equal(createdBody.name, "Key / Prod #1"); @@ -595,8 +595,8 @@ test("management proxies route covers auth, pagination, lookup, where-used, patc assert.equal(unauthenticated.status, 401); assert.equal(unauthenticatedBody.error.message, "Authentication required"); - assert.equal(invalidToken.status, 401); - assert.equal(invalidTokenBody.error.message, "Invalid API key"); + assert.equal(invalidToken.status, 403); + assert.equal(invalidTokenBody.error.message, "Invalid management token"); assert.equal(createdResponse.status, 201); assert.equal(pagedList.status, 200); assert.equal(pagedListBody.page.limit, 200); diff --git a/tests/unit/sse-auth.test.ts b/tests/unit/sse-auth.test.ts index 23f3f5419d..2e13bea7ee 100644 --- a/tests/unit/sse-auth.test.ts +++ b/tests/unit/sse-auth.test.ts @@ -286,7 +286,7 @@ test("resolveQuotaLimitPolicy normalizes Codex windows, thresholds, and defaults }); assert.deepEqual(defaults, { enabled: true, - thresholdPercent: 90, + thresholdPercent: 99, windows: ["session", "weekly"], }); assert.deepEqual(generic, { @@ -1063,7 +1063,7 @@ test("markAccountUnavailable auto-disables permanently banned accounts when the assert.equal(result.shouldFallback, true); assert.equal(updated.isActive, false); - assert.equal(updated.testStatus, "unavailable"); + assert.equal(updated.testStatus, "banned"); }); test("markAccountUnavailable leaves permanently banned accounts active when auto-disable is disabled", async () => { @@ -1083,7 +1083,7 @@ test("markAccountUnavailable leaves permanently banned accounts active when auto assert.equal(result.shouldFallback, true); assert.equal(updated.isActive, true); - assert.equal(updated.testStatus, "unavailable"); + assert.equal(updated.testStatus, "banned"); }); test("markAccountUnavailable swallows auto-disable persistence errors", async () => { @@ -1127,7 +1127,7 @@ test("markAccountUnavailable swallows auto-disable persistence errors", async () assert.equal(result.shouldFallback, true); assert.equal(updated.isActive, true); - assert.equal(updated.testStatus, "unavailable"); + assert.equal(updated.testStatus, "banned"); } finally { db.prepare = originalPrepare; } diff --git a/tests/unit/usage-service-hardening.test.ts b/tests/unit/usage-service-hardening.test.ts index 3e2d19e404..fb5b5550a8 100644 --- a/tests/unit/usage-service-hardening.test.ts +++ b/tests/unit/usage-service-hardening.test.ts @@ -961,8 +961,10 @@ test("usage service covers Qwen, Qoder, GLM, Z.AI and GLMT branches", async () = providerSpecificData: { apiRegion: "international" }, }); assert.equal(glmt.plan, "Pro"); - assert.equal(glmt.quotas["5 Hours Quota"].used, 15); - assert.equal(glmt.quotas["Weekly Quota"].remaining, 36); + assert.equal(glmt.quotas.session.used, 64); + assert.equal(glmt.quotas.session.displayName, "5 Hours Quota"); + assert.equal(glmt.quotas.weekly.remaining, 75); + assert.equal(glmt.quotas.weekly.displayName, "Weekly Quota"); let glmCnUrl = ""; globalThis.fetch = async (url) => {