From 40bd60959e33a2ae4d053b3d826c0cdcf6b97eb9 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 10 May 2026 20:48:03 -0300 Subject: [PATCH 1/3] 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) => { From ee408653ddba661d1a29e833a40b8ff65aeacd58 Mon Sep 17 00:00:00 2001 From: diegosouzapw Date: Sun, 10 May 2026 21:16:52 -0300 Subject: [PATCH 2/3] fix(chatcore): stop leaking provider credentials in response headers Remove upstream provider headers from non-stream chatCore JSON responses to prevent authorization and API key values from being exposed to clients. Add coverage to verify sensitive provider request headers are omitted while OmniRoute metadata headers remain present. --- open-sse/handlers/chatCore.ts | 1 - tests/unit/chatcore-translation-paths.test.ts | 19 +++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/open-sse/handlers/chatCore.ts b/open-sse/handlers/chatCore.ts index 5e60a86260..ec187039c5 100644 --- a/open-sse/handlers/chatCore.ts +++ b/open-sse/handlers/chatCore.ts @@ -4200,7 +4200,6 @@ export async function handleChatCore({ success: true, response: new Response(JSON.stringify(translatedResponse), { headers: { - ...Object.fromEntries(providerHeaders.entries()), "Content-Type": "application/json", [OMNIROUTE_RESPONSE_HEADERS.cache]: "MISS", ...buildOmniRouteResponseMetaHeaders({ diff --git a/tests/unit/chatcore-translation-paths.test.ts b/tests/unit/chatcore-translation-paths.test.ts index 944ab8b6c3..d5779e5583 100644 --- a/tests/unit/chatcore-translation-paths.test.ts +++ b/tests/unit/chatcore-translation-paths.test.ts @@ -1354,6 +1354,25 @@ test("chatCore attaches OmniRoute response metadata headers to non-stream respon assert.match(String(result.response.headers.get("X-OmniRoute-Response-Cost")), /^\d+\.\d{10}$/); }); +test("chatCore does not expose provider request credentials in non-stream response headers", async () => { + const { result } = await invokeChatCore({ + provider: "openai", + model: "gpt-4o-mini", + body: { + model: "gpt-4o-mini", + stream: false, + messages: [{ role: "user", content: "hide provider credentials" }], + }, + responseFormat: "openai", + }); + + assert.equal(result.success, true); + assert.equal(result.response.headers.get("authorization"), null); + assert.equal(result.response.headers.get("x-api-key"), null); + assert.equal(result.response.headers.get("Content-Type"), "application/json"); + assert.equal(result.response.headers.get("X-OmniRoute-Cache"), "MISS"); +}); + test("chatCore normalizes tool finish reasons and estimates usage when upstream omits it", async () => { const { result } = await invokeChatCore({ provider: "openai", From 110fa35d04601dbf3328c92d137710eec94ba361 Mon Sep 17 00:00:00 2001 From: backryun Date: Mon, 11 May 2026 09:20:39 +0900 Subject: [PATCH 3/3] fix: restore cloud agent provider exports and logger import (#2138) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.0 — cloud agent provider exports and logger import fixes were already present in the release branch. Thank you for the quick response to the crash report! --- open-sse/services/autoCombo/virtualFactory.ts | 2 +- src/shared/constants/providers.ts | 31 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/open-sse/services/autoCombo/virtualFactory.ts b/open-sse/services/autoCombo/virtualFactory.ts index a38c45421a..123af3bda7 100644 --- a/open-sse/services/autoCombo/virtualFactory.ts +++ b/open-sse/services/autoCombo/virtualFactory.ts @@ -5,7 +5,7 @@ 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 { defaultLogger as log } from "@omniroute/open-sse/utils/logger"; /** Minimal connection shape needed for virtual auto-combo factory */ interface VirtualFactoryConn extends ConnectionFields { diff --git a/src/shared/constants/providers.ts b/src/shared/constants/providers.ts index 3c01070b24..335115835a 100644 --- a/src/shared/constants/providers.ts +++ b/src/shared/constants/providers.ts @@ -1880,6 +1880,37 @@ export function isSelfHostedChatProvider(providerId: unknown): boolean { return typeof providerId === "string" && SELF_HOSTED_CHAT_PROVIDER_IDS.has(providerId); } +// ── Cloud Agent Providers ─────────────────────────────────────────────────── +export const CLOUD_AGENT_PROVIDERS = { + jules: { + id: "jules", + alias: "jules", + name: "Jules", + icon: "engineering", + color: "#EAB308", + textIcon: "JU", + website: "https://jules.google.com", + }, + devin: { + id: "devin", + alias: "devin", + name: "Devin", + icon: "smart_toy", + color: "#2563EB", + textIcon: "DV", + website: "https://devin.ai", + }, + "codex-cloud": { + id: "codex-cloud", + alias: "codex-cloud", + name: "Codex Cloud", + icon: "code", + color: "#10B981", + textIcon: "CX", + website: "https://chatgpt.com/codex", + }, +}; + // ── System Providers (virtual, not user-connectable) ────────────────────────── export const SYSTEM_PROVIDERS = { auto: {