diff --git a/.agents/skills/lodash-es-runtime-guards/SKILL.md b/.agents/skills/lodash-es-runtime-guards/SKILL.md new file mode 100644 index 0000000..0c3eae7 --- /dev/null +++ b/.agents/skills/lodash-es-runtime-guards/SKILL.md @@ -0,0 +1,74 @@ +# lodash-es-runtime-guards + +Replace runtime `typeof` checks and ad-hoc empty-string checks with `lodash-es` predicates. + +## When to Use + +- You see runtime checks like `typeof value === 'string'`. +- You see object guards like `value && typeof value === 'object'`. +- You see emptiness checks like `value.trim() !== ''` or `value.trim() === ''`. +- You want consistent guard style across app, server, IPC, and tests. + +## Do Not Replace + +- Type-level `typeof` (TypeScript only), for example: + - `ReturnType` + - `keyof typeof CONST_MAP` + - `z.infer` + +These are compile-time types and must remain as-is. + +## Guard Mapping + +- `typeof x === 'string'` -> `isString(x)` +- `typeof x !== 'string'` -> `!isString(x)` +- `typeof x === 'number'` -> `isNumber(x)` +- `typeof x === 'boolean'` -> `isBoolean(x)` +- `typeof x === 'function'` -> `isFunction(x)` +- `typeof x === 'undefined'` -> `isUndefined(x)` +- `x && typeof x === 'object'` -> `isObjectLike(x)` +- `typeof x === 'object' && x !== null` -> `isObjectLike(x)` +- `typeof x === 'object' && !Array.isArray(x)` -> `isObjectLike(x) && !isArray(x)` + +## String Emptiness Pattern + +- Prefer: + - `isEmpty(value.trim())` +- Instead of: + - `value.trim() === ''` + - `value.trim() !== ''` + - `value.trim().length === 0` + +## Import Style + +- Use named imports from `lodash-es`: + +```ts +import { isString, isNumber, isObjectLike, isEmpty } from 'lodash-es'; +``` + +- Do not use full-package imports. +- Reuse existing imports in file; avoid duplicate imports. + +## Refactor Checklist + +1. Replace runtime checks first, preserving behavior. +2. Keep array/object branching semantics unchanged. +3. Re-scan: + +```bash +rg -n "typeof\s+[^\n]+(?:===|!==)\s*'" src +``` + +4. Confirm only type-level `typeof` remains: + +```bash +rg -n "\btypeof\b" src +``` + +5. Run verification: + +```bash +npm run type-check +``` + diff --git a/src/components/CloudAccountList.tsx b/src/components/CloudAccountList.tsx index 68edc7d..ae46216 100644 --- a/src/components/CloudAccountList.tsx +++ b/src/components/CloudAccountList.tsx @@ -71,7 +71,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; -import { filter, flatMap, isEmpty, size, sumBy } from 'lodash-es'; +import { filter, flatMap, isEmpty, isNumber, size, sumBy } from 'lodash-es'; import { clampQuotaPercentage, getQuotaStatus, @@ -313,7 +313,7 @@ export function CloudAccountList() { { onSuccess: (updatedAccount) => { const credits = updatedAccount.quota?.ai_credits?.credits; - if (typeof credits === 'number') { + if (isNumber(credits)) { toast({ title: t('cloud.toast.quotaRefreshed'), description: `AI credits: $${credits.toFixed(2)}`, diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 22bacc7..9fc1bad 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; +import { isNumber, isObjectLike } from 'lodash-es'; import { Check, ChevronRight, Circle } from 'lucide-react'; import { cn } from '@/lib/utils'; @@ -52,11 +53,11 @@ const DropdownMenuContent = React.forwardRef< collisionPadding?: number | Partial>; } >(({ className, sideOffset = 4, collisionPadding = 8, ...props }, ref) => { - if (typeof collisionPadding === 'number') { + if (isNumber(collisionPadding)) { if (collisionPadding < 0) { throw new Error('collisionPadding must be a non-negative number'); } - } else if (typeof collisionPadding === 'object' && collisionPadding !== null) { + } else if (isObjectLike(collisionPadding)) { for (const key of Object.keys(collisionPadding)) { if ((collisionPadding as any)[key] < 0) { throw new Error(`collisionPadding.${key} must be a non-negative number`); diff --git a/src/ipc/cloud/handler.ts b/src/ipc/cloud/handler.ts index b2cb54d..03cffbe 100644 --- a/src/ipc/cloud/handler.ts +++ b/src/ipc/cloud/handler.ts @@ -6,6 +6,7 @@ import { logger } from '../../utils/logger'; import { shell } from 'electron'; import fs from 'fs'; +import { isEmpty, isString } from 'lodash-es'; import { updateTrayMenu } from '../../ipc/tray/handler'; import { ensureGlobalOriginalFromCurrentStorage, @@ -46,7 +47,7 @@ function isEnterpriseClient(clientKey?: string): boolean { } function normalizeProjectId(projectId?: string): string | null { - if (typeof projectId !== 'string') { + if (!isString(projectId)) { return null; } const normalized = projectId.trim(); @@ -148,7 +149,7 @@ async function clearAccountStatus(account: CloudAccount): Promise { function hydrateActiveOAuthClientFromSettings(): void { const preferredClientKey = CloudAccountRepo.getSetting(ACTIVE_OAUTH_CLIENT_KEY_SETTING, ''); - if (preferredClientKey && preferredClientKey.trim() !== '') { + if (isString(preferredClientKey) && !isEmpty(preferredClientKey.trim())) { try { GoogleAPIService.setActiveOAuthClientKey(preferredClientKey); } catch (error) { @@ -195,7 +196,7 @@ async function backfillMissingOAuthClientKeyForLegacyAccounts( } const projectMissing = - typeof account.token.project_id !== 'string' || account.token.project_id.trim() === ''; + !isString(account.token.project_id) || isEmpty(account.token.project_id.trim()); if (activeClientKey === ENTERPRISE_OAUTH_CLIENT_KEY && projectMissing) { skippedEnterpriseGuardCount += 1; continue; @@ -232,7 +233,7 @@ export async function addGoogleAccount( oauthClientKey?: string, ): Promise { try { - if (oauthClientKey && oauthClientKey.trim() !== '') { + if (isString(oauthClientKey) && !isEmpty(oauthClientKey.trim())) { setActiveOAuthClient(oauthClientKey); } else { hydrateActiveOAuthClientFromSettings(); @@ -684,7 +685,7 @@ export async function forcePollCloudMonitor(): Promise { } export async function startAuthFlow(oauthClientKey?: string): Promise { - if (oauthClientKey && oauthClientKey.trim() !== '') { + if (isString(oauthClientKey) && !isEmpty(oauthClientKey.trim())) { setActiveOAuthClient(oauthClientKey); } else { hydrateActiveOAuthClientFromSettings(); diff --git a/src/ipc/database/cloudHandler.ts b/src/ipc/database/cloudHandler.ts index a64e934..a8063e2 100644 --- a/src/ipc/database/cloudHandler.ts +++ b/src/ipc/database/cloudHandler.ts @@ -4,6 +4,7 @@ import fs from 'fs'; import { v4 as uuidv4 } from 'uuid'; import { desc, eq } from 'drizzle-orm'; import type { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { isNumber, isObjectLike, isPlainObject, isString } from 'lodash-es'; import { getCloudAccountsDbPath, getAntigravityDbPaths } from '../../utils/paths'; import { logger } from '../../utils/logger'; import { @@ -35,14 +36,14 @@ type DrizzleExecutor = Pick< >; function isSqliteBusyError(error: unknown): boolean { - if (!error || typeof error !== 'object') { + if (!isObjectLike(error)) { return false; } const err = error as { code?: string; message?: string }; if (err.code && SQLITE_BUSY_CODES.has(err.code)) { return true; } - if (typeof err.message === 'string') { + if (isString(err.message)) { return err.message.includes('SQLITE_BUSY') || err.message.includes('SQLITE_LOCKED'); } return false; @@ -168,17 +169,13 @@ interface MigrationStats { failedFields: number; } -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === 'object' && !Array.isArray(value); -} - function readStringCandidate( source: Record, ...keys: string[] ): string | undefined { for (const key of keys) { const candidate = source[key]; - if (typeof candidate === 'string' && candidate.length > 0) { + if (isString(candidate) && candidate.length > 0) { return candidate; } } @@ -186,14 +183,15 @@ function readStringCandidate( } function normalizeDeviceProfile(value: unknown): DeviceProfile | undefined { - if (!isRecord(value)) { + if (!isPlainObject(value)) { return undefined; } + const valueRecord = value as Record; - const machineId = readStringCandidate(value, 'machineId', 'machine_id'); - const macMachineId = readStringCandidate(value, 'macMachineId', 'mac_machine_id'); - const devDeviceId = readStringCandidate(value, 'devDeviceId', 'dev_device_id'); - const sqmId = readStringCandidate(value, 'sqmId', 'sqm_id'); + const machineId = readStringCandidate(valueRecord, 'machineId', 'machine_id'); + const macMachineId = readStringCandidate(valueRecord, 'macMachineId', 'mac_machine_id'); + const devDeviceId = readStringCandidate(valueRecord, 'devDeviceId', 'dev_device_id'); + const sqmId = readStringCandidate(valueRecord, 'sqmId', 'sqm_id'); if (!machineId || !macMachineId || !devDeviceId || !sqmId) { return undefined; @@ -217,45 +215,47 @@ function areDeviceProfilesEqual(left: DeviceProfile, right: DeviceProfile): bool } function readVersionedProfilePayload(value: unknown): unknown { - if (!isRecord(value)) { + if (!isPlainObject(value)) { return value; } - if (!('schemaVersion' in value)) { + const valueRecord = value as Record; + if (!('schemaVersion' in valueRecord)) { return value; } - const schemaVersion = value.schemaVersion; - if (typeof schemaVersion !== 'number' || !Number.isFinite(schemaVersion)) { + const schemaVersion = valueRecord.schemaVersion; + if (!isNumber(schemaVersion) || !Number.isFinite(schemaVersion)) { throw new Error('invalid_device_profile_schema_version'); } if (schemaVersion !== DEVICE_PAYLOAD_SCHEMA_VERSION) { throw new Error(`unsupported_device_profile_schema_version:${schemaVersion}`); } - if (!('profile' in value)) { + if (!('profile' in valueRecord)) { throw new Error('invalid_device_profile_payload'); } - return value.profile; + return valueRecord.profile; } function readVersionedHistoryPayload(value: unknown): unknown { - if (!isRecord(value)) { + if (!isPlainObject(value)) { return value; } - if (!('schemaVersion' in value)) { + const valueRecord = value as Record; + if (!('schemaVersion' in valueRecord)) { return value; } - const schemaVersion = value.schemaVersion; - if (typeof schemaVersion !== 'number' || !Number.isFinite(schemaVersion)) { + const schemaVersion = valueRecord.schemaVersion; + if (!isNumber(schemaVersion) || !Number.isFinite(schemaVersion)) { throw new Error('invalid_device_history_schema_version'); } if (schemaVersion !== DEVICE_PAYLOAD_SCHEMA_VERSION) { throw new Error(`unsupported_device_history_schema_version:${schemaVersion}`); } - if (!('history' in value)) { + if (!('history' in valueRecord)) { throw new Error('invalid_device_history_payload'); } - return value.history; + return valueRecord.history; } function serializeDeviceProfile(profile: DeviceProfile | undefined): string | null { @@ -285,23 +285,25 @@ function normalizeDeviceHistory(value: unknown): DeviceProfileVersion[] | undefi const normalized: DeviceProfileVersion[] = []; for (const item of value) { - if (!isRecord(item)) { + if (!isPlainObject(item)) { continue; } + const itemRecord = item as Record; - const profile = normalizeDeviceProfile(item.profile); + const profile = normalizeDeviceProfile(itemRecord.profile); if (!profile) { continue; } - const id = typeof item.id === 'string' && item.id.length > 0 ? item.id : uuidv4(); - const createdAtCandidate = item.createdAt; + const id = isString(itemRecord.id) && itemRecord.id.length > 0 ? itemRecord.id : uuidv4(); + const createdAtCandidate = itemRecord.createdAt; const createdAt = - typeof createdAtCandidate === 'number' && Number.isFinite(createdAtCandidate) + isNumber(createdAtCandidate) && Number.isFinite(createdAtCandidate) ? Math.floor(createdAtCandidate) : Math.floor(Date.now() / 1000); - const label = typeof item.label === 'string' && item.label.length > 0 ? item.label : 'legacy'; - const isCurrent = item.isCurrent === true; + const label = + isString(itemRecord.label) && itemRecord.label.length > 0 ? itemRecord.label : 'legacy'; + const isCurrent = itemRecord.isCurrent === true; normalized.push({ id, diff --git a/src/ipc/database/handler.ts b/src/ipc/database/handler.ts index fa0d369..fa44ac2 100644 --- a/src/ipc/database/handler.ts +++ b/src/ipc/database/handler.ts @@ -2,6 +2,7 @@ import Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; import { eq } from 'drizzle-orm'; +import { isString } from 'lodash-es'; import { AccountBackupData, AccountInfo } from '../../types/account'; import { ItemTableValueRowSchema, type ItemTableKey } from '../../types/db'; import { logger } from '../../utils/logger'; @@ -192,8 +193,8 @@ export function getCurrentAccountInfo(): AccountInfo { // Helper to find email in object const findEmail = (obj: { email?: string; user?: { email?: string } }): string => { if (!obj) return ''; - if (typeof obj.email === 'string') return obj.email; - if (obj.user && typeof obj.user.email === 'string') return obj.user.email; + if (isString(obj.email)) return obj.email; + if (obj.user && isString(obj.user.email)) return obj.user.email; return ''; }; @@ -323,7 +324,7 @@ function _restoreSingleDb(dbPath: string, backup: AccountBackupData): boolean { for (const key of KEYS_TO_BACKUP) { if (key in backup.data) { const value = backup.data[key]; - const stringValue = typeof value === 'string' ? value : JSON.stringify(value); + const stringValue = isString(value) ? value : JSON.stringify(value); tx.insert(itemTable) .values({ key, value: stringValue }) .onConflictDoUpdate({ diff --git a/src/ipc/device/handler.ts b/src/ipc/device/handler.ts index 09a99fd..bac4241 100644 --- a/src/ipc/device/handler.ts +++ b/src/ipc/device/handler.ts @@ -2,6 +2,7 @@ import Database from 'better-sqlite3'; import fs from 'fs'; import path from 'path'; import { createHash, randomBytes } from 'crypto'; +import { isBoolean, isNumber, isObjectLike, isString } from 'lodash-es'; import { type DeviceProfile } from '../../types/account'; import { logger } from '../../utils/logger'; import { getAgentDir, getAntigravityDbPaths, getAntigravityStoragePaths } from '../../utils/paths'; @@ -76,14 +77,14 @@ const deviceHardeningState: DeviceHardeningState = { }; function isSqliteBusyError(error: unknown): boolean { - if (!error || typeof error !== 'object') { + if (!isObjectLike(error)) { return false; } const candidate = error as { code?: string; message?: string }; if (candidate.code === 'SQLITE_BUSY' || candidate.code === 'SQLITE_LOCKED') { return true; } - if (typeof candidate.message === 'string') { + if (isString(candidate.message)) { return candidate.message.includes('SQLITE_BUSY') || candidate.message.includes('SQLITE_LOCKED'); } return false; @@ -139,8 +140,8 @@ function readLastKnownGoodMarker(): LastKnownGoodMarker | null { if ( parsed && parsed.version === 1 && - typeof parsed.savedAt === 'number' && - typeof parsed.hasStateDb === 'boolean' + isNumber(parsed.savedAt) && + isBoolean(parsed.hasStateDb) ) { return { version: 1, @@ -157,7 +158,7 @@ function readLastKnownGoodMarker(): LastKnownGoodMarker | null { function parseStorageJson(storagePath: string): Record { const content = fs.readFileSync(storagePath, 'utf-8'); const parsed = JSON.parse(content); - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + if (!isObjectLike(parsed) || Array.isArray(parsed)) { throw new Error('storage.json top-level value must be an object'); } return parsed as Record; @@ -165,15 +166,15 @@ function parseStorageJson(storagePath: string): Record { function getTelemetryField(storage: Record, key: string): string | null { const telemetry = storage.telemetry; - if (telemetry && typeof telemetry === 'object' && !Array.isArray(telemetry)) { + if (isObjectLike(telemetry) && !Array.isArray(telemetry)) { const nestedValue = (telemetry as Record)[key]; - if (typeof nestedValue === 'string' && nestedValue.length > 0) { + if (isString(nestedValue) && nestedValue.length > 0) { return nestedValue; } } const flatValue = storage[`telemetry.${key}`]; - if (typeof flatValue === 'string' && flatValue.length > 0) { + if (isString(flatValue) && flatValue.length > 0) { return flatValue; } @@ -183,7 +184,7 @@ function getTelemetryField(storage: Record, key: string): strin function ensureTelemetryObject(storage: Record): Record { if ( !storage.telemetry || - typeof storage.telemetry !== 'object' || + !isObjectLike(storage.telemetry) || Array.isArray(storage.telemetry) ) { storage.telemetry = {}; @@ -223,7 +224,7 @@ function readStateServiceMachineIdValue(dbPath: string): string | null { const row = db .prepare("SELECT value FROM ItemTable WHERE key = 'storage.serviceMachineId' LIMIT 1") .get() as { value?: unknown } | undefined; - if (!row || typeof row.value !== 'string' || row.value.length === 0) { + if (!row || !isString(row.value) || row.value.length === 0) { return null; } return row.value; diff --git a/src/ipc/manager.ts b/src/ipc/manager.ts index fbc403d..16d1e49 100644 --- a/src/ipc/manager.ts +++ b/src/ipc/manager.ts @@ -1,4 +1,5 @@ import { IPC_CHANNELS } from '@/constants'; +import { isString } from 'lodash-es'; // Custom IPC Client that correctly builds method paths function createIPCClient(port: MessagePort) { @@ -10,7 +11,7 @@ function createIPCClient(port: MessagePort) { port.onmessage = (event) => { try { - const data = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; + const data = isString(event.data) ? JSON.parse(event.data) : event.data; const id = data.i || data.id; const pending = pendingRequests.get(id); diff --git a/src/ipc/process/handler.ts b/src/ipc/process/handler.ts index 7702534..2296fe4 100644 --- a/src/ipc/process/handler.ts +++ b/src/ipc/process/handler.ts @@ -1,6 +1,7 @@ import { exec, execSync, spawn } from 'child_process'; import { promisify } from 'util'; import findProcess, { ProcessInfo } from 'find-process'; +import { isNumber } from 'lodash-es'; import { getAntigravityExecutablePath, isWsl } from '../../utils/paths'; import { logger } from '../../utils/logger'; @@ -98,7 +99,7 @@ export async function isProcessRunning(): Promise { const processMap = new Map(); for (const proc of allMatches) { - if (typeof proc.pid === 'number') { + if (isNumber(proc.pid)) { processMap.set(proc.pid, proc); } } diff --git a/src/lib/antigravity/ClaudeRequestMapper.ts b/src/lib/antigravity/ClaudeRequestMapper.ts index 8a1ad63..11921f6 100644 --- a/src/lib/antigravity/ClaudeRequestMapper.ts +++ b/src/lib/antigravity/ClaudeRequestMapper.ts @@ -1,5 +1,5 @@ import { v4 as uuidv4 } from 'uuid'; -import { isPlainObject, isString } from 'lodash-es'; +import { isEmpty, isPlainObject, isString } from 'lodash-es'; import { mapClaudeModelToGemini, normalizeGeminiModelAlias } from './ModelMapping'; import { getMaxOutputTokens, getThinkingBudget } from './ModelSpecs'; import { cleanJsonSchema, normalizeObjectJsonSchema } from './JsonSchemaUtils'; @@ -518,7 +518,7 @@ function buildContents( for (const block of contentBlocks) { if (block.type === 'text') { - if (block.text && block.text !== '(no content)' && block.text.trim() !== '') + if (block.text && block.text !== '(no content)' && !isEmpty(block.text.trim())) parts.push({ text: block.text.trim() }); } else if (block.type === 'thinking') { const part: any = { text: block.thinking, thought: true }; @@ -553,7 +553,7 @@ function buildContents( .filter((b: any) => b.type === 'text') .map((b: any) => b.text) .join('\n'); - if (mergedContent.trim().length === 0) + if (isEmpty(mergedContent.trim())) mergedContent = block.is_error ? 'Tool execution failed with no output.' : 'Command executed successfully.'; diff --git a/src/lib/antigravity/JsonSchemaUtils.ts b/src/lib/antigravity/JsonSchemaUtils.ts index f7c7f0f..ed49a47 100644 --- a/src/lib/antigravity/JsonSchemaUtils.ts +++ b/src/lib/antigravity/JsonSchemaUtils.ts @@ -1,3 +1,5 @@ +import { isArray, isBoolean, isNumber, isObjectLike, isString } from 'lodash-es'; + /** * Recursively cleans JSON Schema to meet Gemini interface requirements * @@ -9,7 +11,7 @@ */ export function cleanJsonSchema(value: any) { // 0. Preprocessing: Expand $ref (Schema Flattening) - if (value && typeof value === 'object' && !Array.isArray(value)) { + if (isObjectLike(value) && !isArray(value)) { const defs: Record = {}; // Extract $defs or definitions @@ -34,21 +36,21 @@ export function cleanJsonSchema(value: any) { export function normalizeObjectJsonSchema(schema: unknown): Record { const fallbackSchema: Record = { type: 'object', properties: {} }; - if (!schema || typeof schema !== 'object' || Array.isArray(schema)) { + if (!isObjectLike(schema) || isArray(schema)) { return fallbackSchema; } const normalizedSchema = JSON.parse(JSON.stringify(schema)) as Record; cleanJsonSchema(normalizedSchema); - if (typeof normalizedSchema.type !== 'string') { + if (!isString(normalizedSchema.type)) { normalizedSchema.type = 'object'; } if ( normalizedSchema.type === 'object' && (!normalizedSchema.properties || - typeof normalizedSchema.properties !== 'object' || - Array.isArray(normalizedSchema.properties)) + !isObjectLike(normalizedSchema.properties) || + isArray(normalizedSchema.properties)) ) { normalizedSchema.properties = {}; } @@ -60,10 +62,10 @@ export function normalizeObjectJsonSchema(schema: unknown): Record) { - if (!map || typeof map !== 'object') return; + if (!isObjectLike(map)) return; // Check and replace $ref - if (typeof map['$ref'] === 'string') { + if (isString(map['$ref'])) { const refPath = map['$ref']; // Parse reference name (e.g. #/$defs/MyType -> MyType) const parts = refPath.split('/'); @@ -74,7 +76,7 @@ function flattenRefs(map: any, defs: Record) { // $ref nodes should not have other properties, remove $ref directly delete map['$ref']; - if (defSchema && typeof defSchema === 'object') { + if (isObjectLike(defSchema)) { for (const [k, v] of Object.entries(defSchema)) { // Only insert if the key does not exist in current map (avoid overwrite) if (map[k] === undefined) { @@ -93,7 +95,7 @@ function flattenRefs(map: any, defs: Record) { for (const k in map) { if (Object.prototype.hasOwnProperty.call(map, k)) { const v = map[k]; - if (typeof v === 'object' && v !== null) { + if (isObjectLike(v)) { flattenRefs(v, defs); } } @@ -101,11 +103,11 @@ function flattenRefs(map: any, defs: Record) { } function cleanJsonSchemaRecursive(value: any) { - if (!value || typeof value !== 'object') { + if (!isObjectLike(value)) { return; } - if (Array.isArray(value)) { + if (isArray(value)) { // Array: Recursively process each element for (const v of value) { cleanJsonSchemaRecursive(v); @@ -142,7 +144,7 @@ function cleanJsonSchemaRecursive(value: any) { if (map[field] !== undefined) { const val = map[field]; // Only migrate if value is primitive type - if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') { + if (isString(val) || isNumber(val) || isBoolean(val)) { constraints.push(`${label}: ${val}`); delete map[field]; } else { @@ -188,13 +190,13 @@ function cleanJsonSchemaRecursive(value: any) { // 5. Handle type field (Gemini requires single lowercase string) if (map['type']) { const typeVal = map['type']; - if (typeof typeVal === 'string') { + if (isString(typeVal)) { map['type'] = typeVal.toLowerCase(); - } else if (Array.isArray(typeVal)) { + } else if (isArray(typeVal)) { // Union type downgrade: take the first non-null type let selectedType = 'string'; for (const item of typeVal) { - if (typeof item === 'string' && item !== 'null') { + if (isString(item) && item !== 'null') { selectedType = item.toLowerCase(); break; } diff --git a/src/lib/antigravity/ModelMapping.ts b/src/lib/antigravity/ModelMapping.ts index 4628b42..61431cb 100644 --- a/src/lib/antigravity/ModelMapping.ts +++ b/src/lib/antigravity/ModelMapping.ts @@ -1,3 +1,4 @@ +import { isEmpty, isString } from 'lodash-es'; import { logger } from '../../utils/logger'; const PUBLIC_SUPPORTED_MODELS = [ @@ -94,7 +95,7 @@ export function getSupportedModels(): string[] { } export function updateDynamicForwardingRules(oldModel: string, newModel: string): void { - if (typeof oldModel !== 'string' || typeof newModel !== 'string') { + if (!isString(oldModel) || !isString(newModel)) { return; } const normalizedOld = oldModel.trim(); @@ -140,7 +141,7 @@ export function getAllDynamicModels( if (dynamicModelIds) { for (const dynamicModelId of dynamicModelIds) { - if (typeof dynamicModelId === 'string' && dynamicModelId.trim() !== '') { + if (isString(dynamicModelId) && !isEmpty(dynamicModelId.trim())) { modelIds.add(dynamicModelId.trim()); } } @@ -150,7 +151,7 @@ export function getAllDynamicModels( } export function mapClaudeModelToGemini(input: string): string { - if (!input || typeof input !== 'string') { + if (!isString(input) || isEmpty(input)) { return ''; } const mappedModel = CLAUDE_TO_GEMINI[input]; diff --git a/src/lib/antigravity/ModelSpecs.ts b/src/lib/antigravity/ModelSpecs.ts index 5d9523b..d71c3de 100644 --- a/src/lib/antigravity/ModelSpecs.ts +++ b/src/lib/antigravity/ModelSpecs.ts @@ -1,4 +1,5 @@ import modelSpecsJson from './model-specs'; +import { isNumber } from 'lodash-es'; type ModelSpec = { max_output_tokens?: number; @@ -24,7 +25,7 @@ export function resolveModelAlias(modelId: string): string { export function getMaxOutputTokens(modelId: string): number { const resolved = resolveModelAlias(modelId); const fromSpec = SPECS.models[resolved]?.max_output_tokens; - if (typeof fromSpec === 'number' && Number.isFinite(fromSpec) && fromSpec > 0) { + if (isNumber(fromSpec) && Number.isFinite(fromSpec) && fromSpec > 0) { return Math.floor(fromSpec); } return DEFAULT_MAX_OUTPUT_TOKENS; @@ -33,7 +34,7 @@ export function getMaxOutputTokens(modelId: string): number { export function getThinkingBudget(modelId: string): number { const resolved = resolveModelAlias(modelId); const fromSpec = SPECS.models[resolved]?.thinking_budget; - if (typeof fromSpec === 'number' && Number.isFinite(fromSpec) && fromSpec >= 0) { + if (isNumber(fromSpec) && Number.isFinite(fromSpec) && fromSpec >= 0) { return Math.floor(fromSpec); } return DEFAULT_THINKING_BUDGET; diff --git a/src/lib/antigravity/QuotaService.ts b/src/lib/antigravity/QuotaService.ts index ea42e21..4d74d4a 100644 --- a/src/lib/antigravity/QuotaService.ts +++ b/src/lib/antigravity/QuotaService.ts @@ -1,4 +1,5 @@ import axios, { AxiosInstance } from 'axios'; +import { isNumber } from 'lodash-es'; import { QuotaData, LoadProjectResponse, QuotaApiResponse } from './types'; import { logger } from '../../utils/logger'; @@ -162,7 +163,7 @@ export class QuotaService { }; } - if (hasNextEndpoint && (status === 429 || (typeof status === 'number' && status >= 500))) { + if (hasNextEndpoint && (status === 429 || (isNumber(status) && status >= 500))) { logger.warn( `Quota API ${endpoint} returned ${status}, falling back to next endpoint`, ); @@ -173,7 +174,7 @@ export class QuotaService { logger.warn(`API Error: ${status} - ${text}`); lastError = new Error(`HTTP ${status} - ${text}`); - shouldFallback = typeof status !== 'number'; + shouldFallback = !isNumber(status); } else { logger.warn(`Request Failed at ${endpoint}: ${error.message}`); lastError = error instanceof Error ? error : new Error(String(error)); diff --git a/src/server/guards/api-key-auth.util.ts b/src/server/guards/api-key-auth.util.ts index f0147fa..60f2dca 100644 --- a/src/server/guards/api-key-auth.util.ts +++ b/src/server/guards/api-key-auth.util.ts @@ -1,9 +1,11 @@ +import { isEmpty, isString } from 'lodash-es'; + export type RequestHeaderValue = string | string[] | undefined; export type RequestHeaders = Record; export function hasConfiguredApiKey(apiKey: string | undefined): apiKey is string { - return typeof apiKey === 'string' && apiKey.trim() !== ''; + return isString(apiKey) && !isEmpty(apiKey.trim()); } export function extractApiKeyToken(headers: RequestHeaders): string | null { @@ -29,16 +31,16 @@ export function extractApiKeyToken(headers: RequestHeaders): string | null { } function readHeaderValue(headerValue: RequestHeaderValue): string | null { - if (typeof headerValue === 'string') { + if (isString(headerValue)) { const trimmedValue = headerValue.trim(); - return trimmedValue !== '' ? trimmedValue : null; + return !isEmpty(trimmedValue) ? trimmedValue : null; } if (Array.isArray(headerValue)) { for (const value of headerValue) { - if (typeof value === 'string') { + if (isString(value)) { const trimmedValue = value.trim(); - if (trimmedValue !== '') { + if (!isEmpty(trimmedValue)) { return trimmedValue; } } diff --git a/src/server/modules/proxy/clients/gemini.client.ts b/src/server/modules/proxy/clients/gemini.client.ts index 9a1a233..91c21fc 100644 --- a/src/server/modules/proxy/clients/gemini.client.ts +++ b/src/server/modules/proxy/clients/gemini.client.ts @@ -1,6 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import axios, { AxiosProxyConfig, AxiosRequestConfig, AxiosResponse } from 'axios'; -import { isNil } from 'lodash-es'; +import { isEmpty, isFunction, isNil, isObjectLike, isString } from 'lodash-es'; import { GeminiRequest, GeminiResponse } from '../interfaces/request-interfaces'; import { GeminiInternalRequest } from '../../../../lib/antigravity/types'; import { getServerConfig } from '../../../server-config'; @@ -131,10 +131,10 @@ export class GeminiClient { extraHeaders, ); const payload = response.data; - if (payload && typeof payload === 'object' && 'response' in payload) { - return payload.response; + if (isObjectLike(payload) && 'response' in payload) { + return (payload as { response: GeminiResponse }).response; } - return payload; + return payload as GeminiResponse; } private getInternalBaseUrls(): string[] { @@ -255,7 +255,7 @@ export class GeminiClient { return fromObject; } - if (typeof responseData === 'string') { + if (isString(responseData)) { return this.extractAxiosErrorMessageFromText(responseData); } @@ -272,20 +272,20 @@ export class GeminiClient { } private extractAxiosErrorMessageFromObject(responseData: unknown): string | null { - if (!responseData || typeof responseData !== 'object' || this.isReadableStream(responseData)) { + if (!isObjectLike(responseData) || this.isReadableStream(responseData)) { return null; } const errorRecord = (responseData as { error?: unknown }).error; - if (errorRecord && typeof errorRecord === 'object') { + if (isObjectLike(errorRecord)) { const message = (errorRecord as { message?: unknown }).message; - if (typeof message === 'string' && message.trim() !== '') { + if (isString(message) && !isEmpty(message.trim())) { return message.trim(); } } const message = (responseData as { message?: unknown }).message; - if (typeof message === 'string' && message.trim() !== '') { + if (isString(message) && !isEmpty(message.trim())) { return message.trim(); } @@ -333,11 +333,7 @@ export class GeminiClient { } private isReadableStream(value: unknown): value is NodeJS.ReadableStream { - return ( - Boolean(value) && - typeof value === 'object' && - typeof (value as { pipe?: unknown }).pipe === 'function' - ); + return isObjectLike(value) && isFunction((value as { pipe?: unknown }).pipe); } private async readStreamAsText(stream: NodeJS.ReadableStream): Promise { @@ -407,17 +403,17 @@ export class GeminiClient { } private extractRetryAfterHeader(headers: unknown): string | undefined { - if (!headers || typeof headers !== 'object') { + if (!isObjectLike(headers)) { return undefined; } const retryAfter = (headers as Record)['retry-after']; - if (typeof retryAfter === 'string' && retryAfter.trim() !== '') { + if (isString(retryAfter) && !isEmpty(retryAfter.trim())) { return retryAfter.trim(); } if (Array.isArray(retryAfter) && retryAfter.length > 0) { const first = retryAfter[0]; - if (typeof first === 'string' && first.trim() !== '') { + if (isString(first) && !isEmpty(first.trim())) { return first.trim(); } } @@ -439,7 +435,7 @@ export class GeminiClient { try { const seen = new WeakSet(); return JSON.stringify(obj, (key, value) => { - if (typeof value === 'object' && value !== null) { + if (isObjectLike(value)) { if (seen.has(value)) return '[Circular]'; seen.add(value); } diff --git a/src/server/modules/proxy/clients/upstream-error.ts b/src/server/modules/proxy/clients/upstream-error.ts index f515f3a..12d72c0 100644 --- a/src/server/modules/proxy/clients/upstream-error.ts +++ b/src/server/modules/proxy/clients/upstream-error.ts @@ -1,3 +1,5 @@ +import { isNumber, isObjectLike, isString } from 'lodash-es'; + export interface UpstreamErrorHeaders { retryAfter?: string; } @@ -16,10 +18,10 @@ export class UpstreamRequestError extends Error { super(params.message); this.name = 'UpstreamRequestError'; - if (params.status && typeof params.status !== 'number') { + if (params.status && !isNumber(params.status)) { throw new TypeError('status must be a number'); } - if (params.headers && typeof params.headers !== 'object') { + if (params.headers && !isObjectLike(params.headers)) { throw new TypeError('headers must be an object'); } @@ -27,7 +29,7 @@ export class UpstreamRequestError extends Error { this.headers = params.headers; // Sanitize and limit body size - if (typeof params.body === 'string') { + if (isString(params.body)) { const sanitized = params.body.replace(/<[^>]*>?/gm, ''); this.body = sanitized.substring(0, 1000); } else { diff --git a/src/server/modules/proxy/gemini.controller.ts b/src/server/modules/proxy/gemini.controller.ts index a6c289f..dacb439 100644 --- a/src/server/modules/proxy/gemini.controller.ts +++ b/src/server/modules/proxy/gemini.controller.ts @@ -11,7 +11,7 @@ import { Optional, } from '@nestjs/common'; import { FastifyReply } from 'fastify'; -import { isEmpty } from 'lodash-es'; +import { isEmpty, isFunction, isNumber, isString } from 'lodash-es'; import { Observable } from 'rxjs'; import { ProxyGuard } from './proxy.guard'; @@ -198,7 +198,7 @@ export class GeminiController { const candidates = (response.candidates ?? []).map((candidate, index) => ({ content: candidate.content, finishReason: candidate.finishReason, - index: typeof candidate.index === 'number' ? candidate.index : index, + index: isNumber(candidate.index) ? candidate.index : index, })); const normalized: GeminiResponse = { @@ -246,8 +246,8 @@ export class GeminiController { private writeObservableSseResponse(res: FastifyReply, stream: Observable): void { if ( !res.raw || - typeof res.raw.writeHead !== 'function' || - typeof res.raw.write !== 'function' + !isFunction(res.raw.writeHead) || + !isFunction(res.raw.write) ) { res.header('Content-Type', 'text/event-stream'); res.header('Cache-Control', 'no-cache'); @@ -271,7 +271,7 @@ export class GeminiController { if (res.raw.writableEnded) { return; } - const payload = typeof chunk === 'string' ? chunk : String(chunk ?? ''); + const payload = isString(chunk) ? chunk : String(chunk ?? ''); res.raw.write(payload); }, error: (error) => { @@ -302,6 +302,6 @@ export class GeminiController { } private supportsReplyHijack(reply: FastifyReply): reply is FastifyReply & { hijack: () => void } { - return typeof (reply as { hijack?: unknown }).hijack === 'function'; + return isFunction((reply as { hijack?: unknown }).hijack); } } diff --git a/src/server/modules/proxy/proxy.controller.ts b/src/server/modules/proxy/proxy.controller.ts index 6aec030..e839a44 100644 --- a/src/server/modules/proxy/proxy.controller.ts +++ b/src/server/modules/proxy/proxy.controller.ts @@ -12,7 +12,7 @@ import { Optional, } from '@nestjs/common'; import { FastifyReply, FastifyRequest } from 'fastify'; -import { isNil, isPlainObject, isString } from 'lodash-es'; +import { isEmpty, isFunction, isNil, isObjectLike, isPlainObject, isString } from 'lodash-es'; import { ProxyService } from './proxy.service'; import { Observable } from 'rxjs'; import { @@ -339,7 +339,7 @@ export class ProxyController { private toLegacyTextCompletionsResponse(response: OpenAIChatResponse): Record { const choice = response.choices?.[0]; const content = choice?.message?.content; - const text = typeof content === 'string' ? content : ''; + const text = isString(content) ? content : ''; return { id: response.id, @@ -397,7 +397,7 @@ export class ProxyController { stream?: boolean; }): OpenAIChatRequest { const messages: OpenAIChatRequest['messages'] = []; - if (body.instructions && body.instructions.trim() !== '') { + if (isString(body.instructions) && !isEmpty(body.instructions.trim())) { messages.push({ role: 'system', content: body.instructions, @@ -484,7 +484,7 @@ export class ProxyController { continue; } } - } else if (typeof body.input === 'string') { + } else if (isString(body.input)) { messages.push({ role: 'user', content: body.input, @@ -716,18 +716,14 @@ export class ProxyController { } private isObservableLike(value: unknown): value is Observable { - return ( - Boolean(value) && - typeof value === 'object' && - typeof (value as { subscribe?: unknown }).subscribe === 'function' - ); + return isObjectLike(value) && isFunction((value as { subscribe?: unknown }).subscribe); } private writeSseResponse(res: FastifyReply, stream: Observable): void { if ( !res.raw || - typeof res.raw.writeHead !== 'function' || - typeof res.raw.write !== 'function' + !isFunction(res.raw.writeHead) || + !isFunction(res.raw.write) ) { res.header('Content-Type', 'text/event-stream'); res.header('Cache-Control', 'no-cache'); @@ -736,7 +732,7 @@ export class ProxyController { return; } - if (typeof (res as { hijack?: () => void }).hijack === 'function') { + if (isFunction((res as { hijack?: () => void }).hijack)) { (res as { hijack: () => void }).hijack(); } @@ -751,7 +747,7 @@ export class ProxyController { if (res.raw.writableEnded) { return; } - const payload = typeof chunk === 'string' ? chunk : String(chunk ?? ''); + const payload = isString(chunk) ? chunk : String(chunk ?? ''); res.raw.write(payload); }, error: (error) => { @@ -804,7 +800,7 @@ export class ProxyController { } const content = result.choices?.[0]?.message?.content; - const image = this.extractInlineBase64Image(typeof content === 'string' ? content : ''); + const image = this.extractInlineBase64Image(isString(content) ? content : ''); if (!image) { this.logProxyEndpointError( '/v1/images/generations', @@ -908,7 +904,7 @@ export class ProxyController { if (!userMessage) { parts.push({ text: fallbackPrompt || 'Please generate an image based on this request.' }); - } else if (typeof userMessage.content === 'string') { + } else if (isString(userMessage.content)) { parts.push({ text: userMessage.content || @@ -917,7 +913,7 @@ export class ProxyController { }); } else { for (const block of userMessage.content) { - if (block.type === 'text' && typeof block.text === 'string' && block.text.trim() !== '') { + if (block.type === 'text' && isString(block.text) && !isEmpty(block.text.trim())) { textParts.push(block.text); } if (block.type === 'image_url') { @@ -964,7 +960,7 @@ export class ProxyController { private hasMultipartBoundary(req: FastifyRequest): boolean { const contentType = req.headers['content-type']; - if (typeof contentType !== 'string') { + if (!isString(contentType)) { return false; } diff --git a/src/server/modules/proxy/proxy.service.ts b/src/server/modules/proxy/proxy.service.ts index 2d50fab..007359e 100644 --- a/src/server/modules/proxy/proxy.service.ts +++ b/src/server/modules/proxy/proxy.service.ts @@ -1,5 +1,5 @@ import { Injectable, Logger, Inject } from '@nestjs/common'; -import { isEmpty, isNil, isPlainObject, isString } from 'lodash-es'; +import { isEmpty, isNil, isNumber, isPlainObject, isString } from 'lodash-es'; import { TokenManagerService } from './token-manager.service'; import { GeminiClient } from './clients/gemini.client'; import { v4 as uuidv4 } from 'uuid'; @@ -511,7 +511,7 @@ export class ProxyService { private getModelOutputCap(accountId: string, model: string): number { const normalizedModel = this.normalizeModelIdentifier(model); const dynamicCap = this.tokenManager.getModelOutputLimitForAccount(accountId, normalizedModel); - if (typeof dynamicCap === 'number' && Number.isFinite(dynamicCap) && dynamicCap > 0) { + if (isNumber(dynamicCap) && Number.isFinite(dynamicCap) && dynamicCap > 0) { return Math.floor(dynamicCap); } return getMaxOutputTokens(normalizedModel); @@ -523,7 +523,7 @@ export class ProxyService { accountId, normalizedModel, ); - if (typeof dynamicBudget === 'number' && Number.isFinite(dynamicBudget) && dynamicBudget >= 0) { + if (isNumber(dynamicBudget) && Number.isFinite(dynamicBudget) && dynamicBudget >= 0) { return Math.floor(dynamicBudget); } return getThinkingBudget(normalizedModel); @@ -548,12 +548,12 @@ export class ProxyService { | undefined; const adaptiveSentinel = thinkingConfig && - (typeof thinkingConfig.thinkingLevel === 'string' || + (isString(thinkingConfig.thinkingLevel) || thinkingConfig.thinkingBudget === -1 || thinkingConfig.thinkingBudget === 32768); if (thinkingConfig) { - if (!isClaudeModel && typeof thinkingConfig.thinkingLevel === 'string') { + if (!isClaudeModel && isString(thinkingConfig.thinkingLevel)) { const converted = this.resolveThinkingLevelBudget(thinkingConfig.thinkingLevel); if (converted !== undefined) { thinkingConfig.thinkingBudget = converted; @@ -561,12 +561,12 @@ export class ProxyService { delete thinkingConfig.thinkingLevel; } - if (typeof thinkingConfig.thinkingBudget === 'number' && thinkingConfig.thinkingBudget < 0) { + if (isNumber(thinkingConfig.thinkingBudget) && thinkingConfig.thinkingBudget < 0) { thinkingConfig.thinkingBudget = Math.min(thinkingBudgetCap, 24576); } if ( - typeof thinkingConfig.thinkingBudget === 'number' && + isNumber(thinkingConfig.thinkingBudget) && Number.isFinite(thinkingConfig.thinkingBudget) ) { thinkingConfig.thinkingBudget = Math.min( @@ -595,7 +595,7 @@ export class ProxyService { } if ( - typeof generationConfig.maxOutputTokens === 'number' && + isNumber(generationConfig.maxOutputTokens) && Number.isFinite(generationConfig.maxOutputTokens) ) { generationConfig.maxOutputTokens = Math.min( @@ -634,7 +634,7 @@ export class ProxyService { ? response.candidates.map((candidate, index) => ({ content: candidate?.content, finishReason: candidate?.finishReason, - index: typeof candidate?.index === 'number' ? candidate.index : index, + index: isNumber(candidate?.index) ? candidate.index : index, })) : []; @@ -1115,7 +1115,7 @@ export class ProxyService { const choice = response.choices?.[0]; const finishReason = choice?.finish_reason ?? 'stop'; const content = - choice?.message && typeof choice.message.content === 'string' ? choice.message.content : ''; + choice?.message && isString(choice.message.content) ? choice.message.content : ''; const chunkSize = 80; if (content.length === 0) { @@ -1216,7 +1216,7 @@ export class ProxyService { systemInstruction: request.systemInstruction ? { parts: request.systemInstruction.parts - .filter((part): part is { text: string } => typeof part.text === 'string') + .filter((part): part is { text: string } => isString(part.text)) .map((part) => ({ text: part.text })), } : undefined, @@ -1294,7 +1294,7 @@ export class ProxyService { private convertOpenAIPartsToAnthropicContent( content: OpenAIChatRequest['messages'][number]['content'], ): AnthropicContent[] { - if (typeof content === 'string') { + if (isString(content)) { return content.trim() ? [{ type: 'text', text: content }] : []; } @@ -1328,7 +1328,7 @@ export class ProxyService { private extractOpenAITextContent( content: OpenAIChatRequest['messages'][number]['content'], ): string { - if (typeof content === 'string') { + if (isString(content)) { return content; } @@ -1339,14 +1339,14 @@ export class ProxyService { } private parseOpenAIFunctionArguments(argumentsString: string): Record { - if (!argumentsString || argumentsString.trim() === '') { + if (isEmpty(argumentsString.trim())) { return {}; } try { const parsed = JSON.parse(argumentsString); - if (this.isRecord(parsed)) { - return parsed; + if (isPlainObject(parsed)) { + return parsed as Record; } return { value: parsed }; } catch { @@ -1446,7 +1446,7 @@ export class ProxyService { } private normalizeToolCallArguments(input: unknown): string { - if (typeof input === 'string') { + if (isString(input)) { return input; } if (isNil(input)) { @@ -1730,7 +1730,7 @@ export class ProxyService { const metadata = request.metadata; const sessionCandidate = metadata?.session_id ?? metadata?.sessionId ?? metadata?.user_id ?? metadata?.userId; - if (!isString(sessionCandidate) || sessionCandidate.trim() === '') { + if (!isString(sessionCandidate) || isEmpty(sessionCandidate.trim())) { return undefined; } return `anthropic:${sessionCandidate.trim()}`; @@ -1740,17 +1740,13 @@ export class ProxyService { const extra = request.extra; const sessionCandidate = extra?.session_id ?? extra?.sessionId ?? extra?.user_id ?? extra?.userId; - if (!isString(sessionCandidate) || sessionCandidate.trim() === '') { + if (!isString(sessionCandidate) || isEmpty(sessionCandidate.trim())) { return undefined; } return `openai:${sessionCandidate.trim()}`; } - private isRecord(value: unknown): value is Record { + private isGeminiPart(value: unknown): value is InternalGeminiPart { return isPlainObject(value); } - - private isGeminiPart(value: unknown): value is InternalGeminiPart { - return this.isRecord(value); - } } diff --git a/src/server/modules/proxy/rate-limit-tracker.ts b/src/server/modules/proxy/rate-limit-tracker.ts index 9e3a753..78189c7 100644 --- a/src/server/modules/proxy/rate-limit-tracker.ts +++ b/src/server/modules/proxy/rate-limit-tracker.ts @@ -1,3 +1,5 @@ +import { isEmpty, isNumber, isObjectLike, isString } from 'lodash-es'; + export enum RateLimitReason { QuotaExhausted = 'quota_exhausted', RateLimitExceeded = 'rate_limit_exceeded', @@ -68,7 +70,7 @@ function tryParseGoogleErrorBody(body: string | undefined): ParsedGoogleErrorBod try { const parsed = JSON.parse(trimmed) as ParsedGoogleErrorBody; - if (!parsed || typeof parsed !== 'object') { + if (!isObjectLike(parsed)) { return null; } return parsed; @@ -96,7 +98,7 @@ export class RateLimitTracker { private readonly failureCounts = new Map(); private buildLockoutKey(accountId: string, model?: string): string { - if (model && model.trim() !== '') { + if (!isEmpty(model?.trim() ?? '')) { return `${accountId}:${model}`; } return accountId; @@ -150,7 +152,7 @@ export class RateLimitTracker { model?: string, ): void { const retryAfterSec = Math.max(2, Math.ceil((resetTimeMs - Date.now()) / 1000)); - const key = model && model.trim() !== '' ? this.buildLockoutKey(accountId, model) : accountId; + const key = !isEmpty(model?.trim() ?? '') ? this.buildLockoutKey(accountId, model) : accountId; this.lockoutByKey.set(key, { resetTimeMs, retryAfterSec, @@ -240,7 +242,7 @@ export class RateLimitTracker { const parsedBody = tryParseGoogleErrorBody(body); const details = Array.isArray(parsedBody?.error?.details) ? parsedBody?.error?.details : []; for (const detail of details) { - if (typeof detail.reason !== 'string' || detail.reason.trim() === '') { + if (!isString(detail.reason) || isEmpty(detail.reason.trim())) { continue; } const mappedReason = mapGoogleReasonToTrackerReason(detail.reason); @@ -354,7 +356,7 @@ export class RateLimitTracker { const parsedBody = tryParseGoogleErrorBody(body); const details = Array.isArray(parsedBody?.error?.details) ? parsedBody.error.details : []; for (const detail of details) { - if (typeof detail.retryDelay === 'string' && detail.retryDelay.trim() !== '') { + if (isString(detail.retryDelay) && !isEmpty(detail.retryDelay.trim())) { const parsedDelay = parseDurationToSeconds(detail.retryDelay); if (parsedDelay !== null) { return parsedDelay; @@ -362,8 +364,8 @@ export class RateLimitTracker { } if ( - typeof detail.metadata?.retryDelay === 'string' && - detail.metadata.retryDelay.trim() !== '' + isString(detail.metadata?.retryDelay) && + !isEmpty(detail.metadata.retryDelay.trim()) ) { const parsedDelay = parseDurationToSeconds(detail.metadata.retryDelay); if (parsedDelay !== null) { @@ -372,8 +374,8 @@ export class RateLimitTracker { } if ( - typeof detail.metadata?.quotaResetDelay === 'string' && - detail.metadata.quotaResetDelay.trim() !== '' + isString(detail.metadata?.quotaResetDelay) && + !isEmpty(detail.metadata.quotaResetDelay.trim()) ) { const parsedDelay = parseDurationToSeconds(detail.metadata.quotaResetDelay); if (parsedDelay !== null) { @@ -383,7 +385,7 @@ export class RateLimitTracker { } const retryAfter = parsedBody?.error?.retry_after; - if (typeof retryAfter === 'number' && retryAfter > 0) { + if (isNumber(retryAfter) && retryAfter > 0) { return Math.ceil(retryAfter); } diff --git a/src/server/modules/proxy/request-user-agent.ts b/src/server/modules/proxy/request-user-agent.ts index f8b77ba..8974b62 100644 --- a/src/server/modules/proxy/request-user-agent.ts +++ b/src/server/modules/proxy/request-user-agent.ts @@ -1,4 +1,5 @@ import axios from 'axios'; +import { isString } from 'lodash-es'; import { getAntigravityVersion } from '../../../utils/antigravityVersion'; import { logger } from '../../../utils/logger'; @@ -20,7 +21,7 @@ let cachedUserAgentResolution: UserAgentResolution | null = null; let pendingUserAgentResolution: Promise | null = null; function normalizeNonEmptyString(value: string | null | undefined): string | null { - if (typeof value !== 'string') { + if (!isString(value)) { return null; } @@ -125,7 +126,7 @@ async function fetchTextPayload(url: string): Promise { }, }); - if (typeof response.data === 'string') { + if (isString(response.data)) { return response.data; } diff --git a/src/server/modules/proxy/token-manager.service.ts b/src/server/modules/proxy/token-manager.service.ts index 6a35ebe..a289f66 100644 --- a/src/server/modules/proxy/token-manager.service.ts +++ b/src/server/modules/proxy/token-manager.service.ts @@ -1,4 +1,5 @@ import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; +import { isEmpty, isNumber, isString } from 'lodash-es'; import { CloudAccountRepo } from '../../../ipc/database/cloudHandler'; import { CloudAccount, CloudQuotaData } from '../../../types/cloudAccount'; import { GoogleAPIService } from '../../../services/GoogleAPIService'; @@ -36,7 +37,7 @@ interface GetNextTokenOptions { type TokenEntry = [string, TokenData]; function normalizeProjectId(projectId: string | null | undefined): string | undefined { - if (typeof projectId !== 'string') { + if (!isString(projectId)) { return undefined; } @@ -53,7 +54,7 @@ function normalizeProjectId(projectId: string | null | undefined): string | unde } function normalizeModelId(modelId: string | null | undefined): string | undefined { - if (typeof modelId !== 'string') { + if (!isString(modelId)) { return undefined; } const normalized = modelId.replace(/^models\//i, '').trim(); @@ -61,7 +62,7 @@ function normalizeModelId(modelId: string | null | undefined): string | undefine } function normalizeClientKey(clientKey: string | undefined): string | undefined { - if (typeof clientKey !== 'string') { + if (!isString(clientKey)) { return undefined; } const normalized = clientKey.trim().toLowerCase(); @@ -190,7 +191,7 @@ export class TokenManagerService implements OnModuleInit { const accountId = this.resolveAccountId(params.accountIdOrEmail) ?? params.accountIdOrEmail; const normalizedModel = normalizeModelId(params.model); const hasExplicitRetryWindow = - Boolean(params.retryAfter && params.retryAfter.trim() !== '') || + Boolean(isString(params.retryAfter) && !isEmpty(params.retryAfter.trim())) || Boolean(params.body && params.body.includes('quotaResetDelay')); if (!hasExplicitRetryWindow && (params.status ?? 0) === 429) { @@ -215,7 +216,7 @@ export class TokenManagerService implements OnModuleInit { if ( parsed.reason !== RateLimitReason.QuotaExhausted || !parsed.model || - parsed.model.trim() === '' + isEmpty(parsed.model.trim()) ) { this.accountCooldowns.set(accountId, Date.now() + parsed.retryAfterSec * 1000); } @@ -258,7 +259,7 @@ export class TokenManagerService implements OnModuleInit { if ( parsed.reason !== RateLimitReason.QuotaExhausted || !parsed.model || - parsed.model.trim() === '' + isEmpty(parsed.model.trim()) ) { this.accountCooldowns.set(accountId, Date.now() + parsed.retryAfterSec * 1000); } @@ -661,14 +662,14 @@ export class TokenManagerService implements OnModuleInit { const limitCandidate = modelInfo.max_output_tokens ?? modelInfo.max_tokens; if ( - typeof limitCandidate === 'number' && + isNumber(limitCandidate) && Number.isFinite(limitCandidate) && limitCandidate > 0 ) { modelLimits[normalizedModel] = Math.floor(limitCandidate); } - if (typeof modelInfo.resetTime === 'string' && modelInfo.resetTime.trim() !== '') { + if (isString(modelInfo.resetTime) && !isEmpty(modelInfo.resetTime.trim())) { modelResetTimes[normalizedModel] = modelInfo.resetTime; } } @@ -692,7 +693,7 @@ export class TokenManagerService implements OnModuleInit { } private findEarliestQuotaResetTime(modelResetTimes: Record): string | null { - const validTimes = Object.values(modelResetTimes).filter((value) => value.trim() !== ''); + const validTimes = Object.values(modelResetTimes).filter((value) => !isEmpty(value.trim())); if (validTimes.length === 0) { return null; } @@ -1084,7 +1085,7 @@ export class TokenManagerService implements OnModuleInit { continue; } const budget = modelInfo?.thinking_budget; - if (typeof budget === 'number' && Number.isFinite(budget) && budget >= 0) { + if (isNumber(budget) && Number.isFinite(budget) && budget >= 0) { return Math.floor(budget); } } diff --git a/src/services/GoogleAPIService.ts b/src/services/GoogleAPIService.ts index eb71884..a0e34fa 100644 --- a/src/services/GoogleAPIService.ts +++ b/src/services/GoogleAPIService.ts @@ -8,7 +8,7 @@ import { FALLBACK_VERSION, resolveLocalInstalledVersion, } from '@/server/modules/proxy/request-user-agent'; -import { isNumber, isString } from 'lodash-es'; +import { isEmpty, isNumber, isString, isUndefined } from 'lodash-es'; // --- Constants & Config --- @@ -81,7 +81,7 @@ function buildOAuthClientRegistry(): OAuthClientRegistry { ]; const rawExtraClients = process.env[OAUTH_CLIENTS_ENV]; - if (isString(rawExtraClients) && rawExtraClients.trim() !== '') { + if (isString(rawExtraClients) && !isEmpty(rawExtraClients.trim())) { for (const entry of rawExtraClients.split(';')) { const trimmed = entry.trim(); if (trimmed === '') { @@ -180,7 +180,7 @@ function selectAuthClient(clientKey?: string): OAuthClientConfig { throw new Error('No OAuth clients configured'); } - if (clientKey && clientKey.trim() !== '') { + if (isString(clientKey) && !isEmpty(clientKey.trim())) { const selected = getClientByKey(registry.clients, clientKey); if (!selected) { throw new Error(`Unknown OAuth client key: ${clientKey}`); @@ -345,20 +345,20 @@ function buildInternalApiHeaders(accessToken: string): Record { function resolveSubscriptionTier(payload: LoadProjectResponse): string | undefined { const paidTier = payload.paidTier; - if (paidTier?.name && paidTier.name.trim() !== '') { + if (isString(paidTier?.name) && !isEmpty(paidTier.name.trim())) { return paidTier.name; } - if (paidTier?.id && paidTier.id.trim() !== '') { + if (isString(paidTier?.id) && !isEmpty(paidTier.id.trim())) { return paidTier.id; } const ineligible = Array.isArray(payload.ineligibleTiers) && payload.ineligibleTiers.length > 0; if (!ineligible) { const currentTier = payload.currentTier; - if (currentTier?.name && currentTier.name.trim() !== '') { + if (isString(currentTier?.name) && !isEmpty(currentTier.name.trim())) { return currentTier.name; } - if (currentTier?.id && currentTier.id.trim() !== '') { + if (isString(currentTier?.id) && !isEmpty(currentTier.id.trim())) { return currentTier.id; } } @@ -366,10 +366,10 @@ function resolveSubscriptionTier(payload: LoadProjectResponse): string | undefin if (Array.isArray(payload.allowedTiers)) { const preferredAllowedTier = payload.allowedTiers.find((tier) => tier.is_default === true) ?? payload.allowedTiers[0]; - if (preferredAllowedTier?.name && preferredAllowedTier.name.trim() !== '') { + if (isString(preferredAllowedTier?.name) && !isEmpty(preferredAllowedTier.name.trim())) { return ineligible ? `${preferredAllowedTier.name} (Restricted)` : preferredAllowedTier.name; } - if (preferredAllowedTier?.id && preferredAllowedTier.id.trim() !== '') { + if (isString(preferredAllowedTier?.id) && !isEmpty(preferredAllowedTier.id.trim())) { return ineligible ? `${preferredAllowedTier.id} (Restricted)` : preferredAllowedTier.id; } } @@ -410,7 +410,7 @@ function toModelForwardingRules( const forwardingRules: Record = {}; for (const [oldModelId, deprecatedInfo] of Object.entries(deprecatedModelIds)) { - if (typeof deprecatedInfo.newModelId === 'string' && deprecatedInfo.newModelId !== '') { + if (isString(deprecatedInfo.newModelId) && deprecatedInfo.newModelId !== '') { forwardingRules[oldModelId] = deprecatedInfo.newModelId; } } @@ -423,7 +423,7 @@ function parseCreditAmount(value: string | number | undefined): number { return value; } - if (typeof value === 'string') { + if (isString(value)) { const parsed = Number.parseInt(value, 10); if (Number.isFinite(parsed)) { return parsed; @@ -448,7 +448,7 @@ function toAiCredits( ? payload.remainingCredits : undefined; - if (typeof creditsValue === 'undefined') { + if (isUndefined(creditsValue)) { return null; } @@ -517,10 +517,10 @@ export class GoogleAPIService { ): string | undefined { const resolved = refreshedClientKey ?? currentToken.oauth_client_key; const projectMissing = - typeof currentToken.project_id !== 'string' || currentToken.project_id.trim() === ''; + !isString(currentToken.project_id) || isEmpty(currentToken.project_id.trim()); if ( - typeof currentToken.oauth_client_key !== 'string' && + !isString(currentToken.oauth_client_key) && projectMissing && resolved && normalizeClientKey(resolved) === DEFAULT_OAUTH_CLIENT_KEY @@ -812,7 +812,7 @@ export class GoogleAPIService { if (response.ok) { const data = (await response.json()) as LoadProjectResponse; - if (typeof data.cloudaicompanionProject === 'string') { + if (isString(data.cloudaicompanionProject)) { projectId = data.cloudaicompanionProject; } subscriptionTier = resolveSubscriptionTier(data); diff --git a/src/tests/e2e/app.spec.ts b/src/tests/e2e/app.spec.ts index 874fd6a..56b31e0 100644 --- a/src/tests/e2e/app.spec.ts +++ b/src/tests/e2e/app.spec.ts @@ -6,17 +6,18 @@ const injectCloudAccountsFailureScript = ` (() => { const START_ORPC_SERVER = 'start-orpc-server'; const originalPostMessage = window.postMessage.bind(window); + const isStringValue = (value) => Object.prototype.toString.call(value) === '[object String]'; window.postMessage = function (message, targetOrigin, transfer) { if (message === START_ORPC_SERVER && Array.isArray(transfer) && transfer[0]) { const serverPort = transfer[0]; - if (typeof serverPort.start === 'function') { + if (serverPort.start instanceof Function) { serverPort.start(); } serverPort.onmessage = (event) => { try { - const request = typeof event.data === 'string' ? JSON.parse(event.data) : event.data; + const request = isStringValue(event.data) ? JSON.parse(event.data) : event.data; const requestId = request && (request.i || request.id); const requestUrl = (request && request.p && request.p.u) || ''; diff --git a/src/tests/scripts/project-id-request-validation.ts b/src/tests/scripts/project-id-request-validation.ts index 73ce324..e32b7b3 100644 --- a/src/tests/scripts/project-id-request-validation.ts +++ b/src/tests/scripts/project-id-request-validation.ts @@ -1,4 +1,5 @@ import assert from 'node:assert/strict'; +import { isEmpty, isString } from 'lodash-es'; import { transformClaudeRequestIn } from '../../lib/antigravity/ClaudeRequestMapper'; import { ProxyService } from '../../server/modules/proxy/proxy.service'; @@ -34,7 +35,7 @@ class TestableProxyService extends ProxyService { function getEnv(name: string, fallback?: string): string { const value = process.env[name]; - if (value && value.trim() !== '') { + if (isString(value) && !isEmpty(value.trim())) { return value; } if (fallback !== undefined) { @@ -46,7 +47,7 @@ function getEnv(name: string, fallback?: string): string { function getEnvFromList(names: string[], fallback?: string): string { for (const name of names) { const value = process.env[name]; - if (value && value.trim() !== '') { + if (isString(value) && !isEmpty(value.trim())) { return value; } } @@ -60,7 +61,7 @@ function getLiveHeaders(apiKey?: string): Record { const headers: Record = { 'Content-Type': 'application/json', }; - if (apiKey && apiKey.trim() !== '') { + if (isString(apiKey) && !isEmpty(apiKey.trim())) { headers.Authorization = apiKey.startsWith('Bearer ') ? apiKey : `Bearer ${apiKey}`; } return headers; @@ -271,7 +272,7 @@ async function validateRuntimeAnthropicRequestFromRealTokenManager(): Promise; const tokenProjectId = selectedToken?.token?.project_id; const normalizedTokenProjectId = - typeof tokenProjectId === 'string' && tokenProjectId.trim() !== '' + isString(tokenProjectId) && !isEmpty(tokenProjectId.trim()) ? tokenProjectId.trim() : undefined; diff --git a/src/tests/unit/google-api-timeout.test.ts b/src/tests/unit/google-api-timeout.test.ts index 379ddd9..6bae1c7 100644 --- a/src/tests/unit/google-api-timeout.test.ts +++ b/src/tests/unit/google-api-timeout.test.ts @@ -1,4 +1,5 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { isString } from 'lodash-es'; // Mock module to test timeout behavior describe('GoogleAPIService Timeout', () => { @@ -92,13 +93,13 @@ describe('GoogleAPIService OAuth clients', () => { }); afterEach(() => { - if (typeof originalOauthClientsEnv === 'string') { + if (isString(originalOauthClientsEnv)) { process.env.ANTIGRAVITY_OAUTH_CLIENTS = originalOauthClientsEnv; } else { delete process.env.ANTIGRAVITY_OAUTH_CLIENTS; } - if (typeof originalActiveOauthClientEnv === 'string') { + if (isString(originalActiveOauthClientEnv)) { process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY = originalActiveOauthClientEnv; } else { delete process.env.ANTIGRAVITY_OAUTH_CLIENT_KEY; diff --git a/src/tests/unit/setup.ts b/src/tests/unit/setup.ts index 566edc0..c3864a8 100644 --- a/src/tests/unit/setup.ts +++ b/src/tests/unit/setup.ts @@ -1,11 +1,12 @@ import '@testing-library/jest-dom'; import * as matchers from '@testing-library/jest-dom/matchers'; +import { isUndefined } from 'lodash-es'; import { expect, vi } from 'vitest'; expect.extend(matchers); // Mock window and localStorage for Node environment -if (typeof window === 'undefined') { +if (isUndefined((globalThis as { window?: unknown }).window)) { global.window = { matchMedia: vi.fn().mockImplementation((query) => ({ matches: false, diff --git a/src/utils/account-status.ts b/src/utils/account-status.ts index 92c1b32..73b6d85 100644 --- a/src/utils/account-status.ts +++ b/src/utils/account-status.ts @@ -1,3 +1,5 @@ +import { isString } from 'lodash-es'; + export const RATE_LIMIT_HINTS = [ 'resource_exhausted', 'too many requests', @@ -40,7 +42,7 @@ export function extractErrorMessage(error: unknown): string { if (error instanceof Error) { return error.message; } - if (typeof error === 'string') { + if (isString(error)) { return error; } return String(error); diff --git a/src/utils/errorMessages.ts b/src/utils/errorMessages.ts index af22484..3872afc 100644 --- a/src/utils/errorMessages.ts +++ b/src/utils/errorMessages.ts @@ -1,4 +1,5 @@ import type { TFunction } from 'i18next'; +import { isObjectLike } from 'lodash-es'; const KEYCHAIN_ERROR_CODE = 'ERR_KEYCHAIN_UNAVAILABLE'; const KEYCHAIN_HINT_TRANSLOCATION = 'HINT_APP_TRANSLOCATION'; @@ -60,7 +61,7 @@ export function getLocalizedErrorMessage(error: unknown, t: TFunction): string { return rawMessage; } - if (error && typeof error === 'object' && 'message' in error) { + if (isObjectLike(error)) { const rawMessage = String((error as { message?: unknown }).message ?? ''); const [code, hint] = rawMessage.split('|'); if (code === KEYCHAIN_ERROR_CODE) { diff --git a/src/utils/googleAuthSubmission.ts b/src/utils/googleAuthSubmission.ts index 526e01d..d096d44 100644 --- a/src/utils/googleAuthSubmission.ts +++ b/src/utils/googleAuthSubmission.ts @@ -1,3 +1,5 @@ +import { isEmpty } from 'lodash-es'; + export function shouldAutoSubmitGoogleAuthCode(params: { authCode: string; isAddDialogOpen: boolean; @@ -5,7 +7,7 @@ export function shouldAutoSubmitGoogleAuthCode(params: { lastSubmittedAuthCode: string | null; }): boolean { return ( - params.authCode.trim() !== '' && + !isEmpty(params.authCode.trim()) && params.isAddDialogOpen && !params.isPending && params.authCode !== params.lastSubmittedAuthCode diff --git a/src/utils/logger.ts b/src/utils/logger.ts index bfff4bf..4ff6924 100644 --- a/src/utils/logger.ts +++ b/src/utils/logger.ts @@ -1,5 +1,6 @@ import fs from 'fs'; import path from 'path'; +import { isObjectLike } from 'lodash-es'; import winston from 'winston'; import DailyRotateFile from 'winston-daily-rotate-file'; import { getAgentDir } from './paths'; @@ -42,7 +43,7 @@ function safeStringify(obj: unknown): string { }; } // Handle circular references - if (typeof value === 'object' && value !== null) { + if (isObjectLike(value)) { if (seen.has(value)) { return '[Circular]'; } @@ -143,7 +144,7 @@ class Logger { private formatArgs(args: unknown[]): string { return args - .map((arg) => (typeof arg === 'object' ? safeStringify(arg) : String(arg))) + .map((arg) => (isObjectLike(arg) ? safeStringify(arg) : String(arg))) .join(' '); } diff --git a/src/utils/sensitiveDataMasking.ts b/src/utils/sensitiveDataMasking.ts index 5bc11df..1714470 100644 --- a/src/utils/sensitiveDataMasking.ts +++ b/src/utils/sensitiveDataMasking.ts @@ -1,3 +1,5 @@ +import { isObjectLike, isString } from 'lodash-es'; + /** * Keys to mask in logs to avoid exposing sensitive data. */ @@ -39,10 +41,10 @@ function sanitizeWithSeen(obj: unknown, seen: WeakSet): unknown { return obj; } - if (typeof obj === 'string') { + if (isString(obj)) { try { const parsed = JSON.parse(obj); - if (typeof parsed === 'object' && parsed !== null) { + if (isObjectLike(parsed)) { return JSON.stringify(sanitizeWithSeen(parsed, seen)); } } catch { @@ -59,7 +61,7 @@ function sanitizeWithSeen(obj: unknown, seen: WeakSet): unknown { return obj.map((item) => sanitizeWithSeen(item, seen)); } - if (typeof obj === 'object') { + if (isObjectLike(obj)) { if (seen.has(obj)) { return CIRCULAR_PLACEHOLDER; } @@ -94,7 +96,7 @@ export function safeStringifyPacket(obj: unknown): string { const sanitized = sanitizeObject(obj); const seen = new WeakSet(); return JSON.stringify(sanitized, (key, value) => { - if (typeof value === 'object' && value !== null) { + if (isObjectLike(value)) { if (seen.has(value)) { return '[Circular]'; }