From 8d2ec861551a68d30442a2e548e08578d511cb49 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 21 Feb 2026 01:31:16 +0700 Subject: [PATCH 01/27] refactor(core): centralize claude paths and command parsing --- src/ccs.ts | 184 +++++++----------- .../config/extended-context-config.ts | 36 ++-- src/commands/api-command.ts | 41 ++-- src/commands/arg-extractor.ts | 66 +++++++ src/commands/cliproxy/index.ts | 178 +++++------------ src/commands/config-command.ts | 34 ++-- src/commands/config-image-analysis-command.ts | 49 ++--- src/commands/persist-command.ts | 54 ++--- src/management/checks/config-check.ts | 4 +- src/shared/extended-context-utils.ts | 29 +++ src/utils/claude-config-path.ts | 26 +++ src/utils/websearch/hook-config.ts | 17 +- src/web-server/jsonl-parser.ts | 5 +- src/web-server/routes/persist-routes.ts | 7 +- src/web-server/routes/route-helpers.ts | 3 +- src/web-server/shared-routes.ts | 4 +- ui/src/lib/extended-context-utils.ts | 51 +---- ui/src/lib/provider-config.ts | 93 +++------ 18 files changed, 395 insertions(+), 486 deletions(-) create mode 100644 src/commands/arg-extractor.ts create mode 100644 src/shared/extended-context-utils.ts create mode 100644 src/utils/claude-config-path.ts diff --git a/src/ccs.ts b/src/ccs.ts index b1d89e27..5676fd96 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -433,56 +433,6 @@ async function main(): Promise { console.warn('[!] Recovery failed:', (err as Error).message); } - // Special case: version command (check BEFORE profile detection) - if (firstArg === 'version' || firstArg === '--version' || firstArg === '-v') { - await handleVersionCommand(); - return; - } - - // Special case: help command - if (firstArg === '--help' || firstArg === '-h' || firstArg === 'help') { - await handleHelpCommand(); - return; - } - - // Special case: install command - if (firstArg === '--install') { - handleInstallCommand(); - return; - } - - // Special case: uninstall command - if (firstArg === '--uninstall') { - handleUninstallCommand(); - return; - } - - // Special case: shell completion installer - if (firstArg === '--shell-completion' || firstArg === '-sc') { - await handleShellCompletionCommand(args.slice(1)); - return; - } - - // Special case: doctor command - if (firstArg === 'doctor' || firstArg === '--doctor') { - const restArgs = args.slice(args.indexOf(firstArg) + 1); - await handleDoctorCommand(restArgs); - return; - } - - // Special case: sync command - if (firstArg === 'sync' || firstArg === '--sync') { - await handleSyncCommand(); - return; - } - - // Special case: cleanup command - if (firstArg === 'cleanup' || firstArg === '--cleanup') { - const { handleCleanupCommand } = await import('./commands/cleanup-command'); - await handleCleanupCommand(args.slice(1)); - return; - } - // Special case: migrate command if (firstArg === 'migrate' || firstArg === '--migrate') { const { handleMigrateCommand, printMigrateHelp } = await import('./commands/migrate-command'); @@ -525,72 +475,80 @@ async function main(): Promise { return; } - // Special case: auth command - if (firstArg === 'auth') { - const AuthCommandsModule = await import('./auth/auth-commands'); - const AuthCommands = AuthCommandsModule.default; - const authCommands = new AuthCommands(); - await authCommands.route(args.slice(1)); + const commandAliases: Record = { + '--version': 'version', + '-v': 'version', + '--help': 'help', + '-h': 'help', + '--doctor': 'doctor', + '--sync': 'sync', + '--cleanup': 'cleanup', + '--setup': 'setup', + }; + + const normalizedFirstArg = commandAliases[firstArg] || firstArg; + + const earlyCommandHandlers: Record Promise> = { + version: async () => handleVersionCommand(), + help: async () => handleHelpCommand(), + '--install': async () => handleInstallCommand(), + '--uninstall': async () => handleUninstallCommand(), + '--shell-completion': async () => handleShellCompletionCommand(args.slice(1)), + '-sc': async () => handleShellCompletionCommand(args.slice(1)), + doctor: async () => handleDoctorCommand(args.slice(1)), + sync: async () => handleSyncCommand(), + cleanup: async () => { + const { handleCleanupCommand } = await import('./commands/cleanup-command'); + await handleCleanupCommand(args.slice(1)); + }, + auth: async () => { + const AuthCommandsModule = await import('./auth/auth-commands'); + const AuthCommands = AuthCommandsModule.default; + const authCommands = new AuthCommands(); + await authCommands.route(args.slice(1)); + }, + api: async () => { + const { handleApiCommand } = await import('./commands/api-command'); + await handleApiCommand(args.slice(1)); + }, + cliproxy: async () => { + const { handleCliproxyCommand } = await import('./commands/cliproxy-command'); + await handleCliproxyCommand(args.slice(1)); + }, + config: async () => { + const { handleConfigCommand } = await import('./commands/config-command'); + await handleConfigCommand(args.slice(1)); + }, + tokens: async () => { + const { handleTokensCommand } = await import('./commands/tokens-command'); + const exitCode = await handleTokensCommand(args.slice(1)); + process.exit(exitCode); + }, + persist: async () => { + const { handlePersistCommand } = await import('./commands/persist-command'); + await handlePersistCommand(args.slice(1)); + }, + env: async () => { + const { handleEnvCommand } = await import('./commands/env-command'); + await handleEnvCommand(args.slice(1)); + }, + setup: async () => { + const { handleSetupCommand } = await import('./commands/setup-command'); + await handleSetupCommand(args.slice(1)); + }, + cursor: async () => { + const { handleCursorCommand } = await import('./commands/cursor-command'); + const exitCode = await handleCursorCommand(args.slice(1)); + process.exit(exitCode); + }, + }; + + const earlyCommandHandler = earlyCommandHandlers[normalizedFirstArg]; + if (earlyCommandHandler) { + await earlyCommandHandler(); return; } - // Special case: api command (manages API profiles) - if (firstArg === 'api') { - const { handleApiCommand } = await import('./commands/api-command'); - await handleApiCommand(args.slice(1)); - return; - } - - // Special case: cliproxy command (manages CLIProxyAPI binary) - if (firstArg === 'cliproxy') { - const { handleCliproxyCommand } = await import('./commands/cliproxy-command'); - await handleCliproxyCommand(args.slice(1)); - return; - } - - // Special case: config command (web dashboard) - if (firstArg === 'config') { - const { handleConfigCommand } = await import('./commands/config-command'); - await handleConfigCommand(args.slice(1)); - return; - } - - // Special case: tokens command (auth token management) - if (firstArg === 'tokens') { - const { handleTokensCommand } = await import('./commands/tokens-command'); - const exitCode = await handleTokensCommand(args.slice(1)); - process.exit(exitCode); - } - - // Special case: persist command (write profile env to ~/.claude/settings.json) - if (firstArg === 'persist') { - const { handlePersistCommand } = await import('./commands/persist-command'); - await handlePersistCommand(args.slice(1)); - return; - } - - // Special case: env command (export env vars for third-party tools) - if (firstArg === 'env') { - const { handleEnvCommand } = await import('./commands/env-command'); - await handleEnvCommand(args.slice(1)); - return; - } - - // Special case: setup command (first-time wizard) - if (firstArg === 'setup' || firstArg === '--setup') { - const { handleSetupCommand } = await import('./commands/setup-command'); - await handleSetupCommand(args.slice(1)); - return; - } - - // Special case: cursor command (Cursor IDE integration) - // All `ccs cursor *` routes to cursor command handler — cursor has no profile-switching mode - if (firstArg === 'cursor') { - const { handleCursorCommand } = await import('./commands/cursor-command'); - const exitCode = await handleCursorCommand(args.slice(1)); - process.exit(exitCode); - } - // Special case: copilot command (GitHub Copilot integration) // Only route to command handler for known subcommands, otherwise treat as profile const COPILOT_SUBCOMMANDS = [ diff --git a/src/cliproxy/config/extended-context-config.ts b/src/cliproxy/config/extended-context-config.ts index 6090bf84..60616e09 100644 --- a/src/cliproxy/config/extended-context-config.ts +++ b/src/cliproxy/config/extended-context-config.ts @@ -10,26 +10,17 @@ */ import { CLIProxyProvider } from '../types'; -import { supportsExtendedContext, isNativeGeminiModel } from '../model-catalog'; +import { supportsExtendedContext } from '../model-catalog'; import { warn } from '../../utils/ui'; +import { + applyExtendedContextSuffix as applyExtendedContextSuffixShared, + isNativeGeminiModel, + stripExtendedContextSuffix, +} from '../../shared/extended-context-utils'; -/** Extended context suffix recognized by Claude Code */ -const EXTENDED_CONTEXT_SUFFIX = '[1m]'; - -/** - * Apply extended context suffix to model name. - * Appends [1m] suffix if not already present. - * - * @param model - Model name (may include thinking suffix like "model(high)") - * @returns Model name with [1m] suffix, e.g., "gemini-2.5-pro[1m]" or "gemini-2.5-pro(high)[1m]" - */ -export function applyExtendedContextSuffix(model: string): string { - if (!model) return model; - // Case-insensitive check to avoid double suffix (handles [1M], [1m], etc.) - if (model.toLowerCase().endsWith(EXTENDED_CONTEXT_SUFFIX.toLowerCase())) { - return model; - } - return `${model}${EXTENDED_CONTEXT_SUFFIX}`; +// Backward-compatible export retained for tests/importers that reference this module. +export function applyExtendedContextSuffix(modelId: string): string { + return applyExtendedContextSuffixShared(modelId); } /** @@ -109,7 +100,7 @@ export function applyExtendedContextConfig( // Apply suffix to main model if (envVars.ANTHROPIC_MODEL) { - envVars.ANTHROPIC_MODEL = applyExtendedContextSuffix(envVars.ANTHROPIC_MODEL); + envVars.ANTHROPIC_MODEL = applyExtendedContextSuffixShared(envVars.ANTHROPIC_MODEL); } // Apply to tier models if they support extended context @@ -119,7 +110,7 @@ export function applyExtendedContextConfig( if (model) { const tierCleanId = stripModelSuffixes(model); if (shouldApplyExtendedContext(provider, tierCleanId, extendedContextOverride)) { - envVars[tierVar] = applyExtendedContextSuffix(model); + envVars[tierVar] = applyExtendedContextSuffixShared(model); } } } @@ -133,8 +124,5 @@ export function applyExtendedContextConfig( * "gemini-2.5-pro" -> "gemini-2.5-pro" */ function stripModelSuffixes(modelId: string): string { - return modelId - .trim() - .replace(/\[1m\]$/i, '') // Remove [1m] suffix - .replace(/\([^)]+\)$/, ''); // Remove thinking suffix like (high) or (8192) + return stripExtendedContextSuffix(modelId.trim()).replace(/\([^)]+\)$/, ''); } diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index cdd8c817..fd15cdbd 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -43,6 +43,7 @@ import { type ProviderPreset, } from '../api/services'; import { syncToLocalConfig } from '../cliproxy/sync/local-config-sync'; +import { extractOption, hasAnyFlag } from './arg-extractor'; interface ApiCommandArgs { name?: string; @@ -72,28 +73,30 @@ function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string { /** Parse command line arguments for api commands */ function parseArgs(args: string[]): ApiCommandArgs { - const result: ApiCommandArgs = {}; + const result: ApiCommandArgs = { + force: hasAnyFlag(args, ['--force']), + yes: hasAnyFlag(args, ['--yes', '-y']), + }; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; + let remaining = [...args]; - if (arg === '--base-url' && args[i + 1]) { - result.baseUrl = args[++i]; - } else if (arg === '--api-key' && args[i + 1]) { - result.apiKey = args[++i]; - } else if (arg === '--model' && args[i + 1]) { - result.model = args[++i]; - } else if (arg === '--preset' && args[i + 1]) { - result.preset = args[++i]; - } else if (arg === '--force') { - result.force = true; - } else if (arg === '--yes' || arg === '-y') { - result.yes = true; - } else if (!arg.startsWith('-') && !result.name) { - result.name = arg; - } - } + const baseUrl = extractOption(remaining, ['--base-url']); + if (baseUrl.value) result.baseUrl = baseUrl.value; + remaining = baseUrl.remainingArgs; + const apiKey = extractOption(remaining, ['--api-key']); + if (apiKey.value) result.apiKey = apiKey.value; + remaining = apiKey.remainingArgs; + + const model = extractOption(remaining, ['--model']); + if (model.value) result.model = model.value; + remaining = model.remainingArgs; + + const preset = extractOption(remaining, ['--preset']); + if (preset.value) result.preset = preset.value; + remaining = preset.remainingArgs; + + result.name = remaining.find((arg) => !arg.startsWith('-')); return result; } diff --git a/src/commands/arg-extractor.ts b/src/commands/arg-extractor.ts new file mode 100644 index 00000000..a4a45dcd --- /dev/null +++ b/src/commands/arg-extractor.ts @@ -0,0 +1,66 @@ +/** + * Small helpers for consistent CLI option extraction. + */ + +export interface ExtractedOption { + found: boolean; + value?: string; + missingValue: boolean; + remainingArgs: string[]; +} + +function findInlineOption(arg: string, flag: string): string | undefined { + const prefix = `${flag}=`; + return arg.startsWith(prefix) ? arg.slice(prefix.length) : undefined; +} + +/** + * Extract a single-value option and remove it from args. + * Supports `--flag value` and `--flag=value` forms. + */ +export function extractOption(args: string[], flags: readonly string[]): ExtractedOption { + const remaining = [...args]; + + for (let i = 0; i < remaining.length; i++) { + const token = remaining[i]; + + for (const flag of flags) { + if (token === flag) { + const next = remaining[i + 1]; + if (!next || next.startsWith('-')) { + remaining.splice(i, 1); + return { found: true, missingValue: true, remainingArgs: remaining }; + } + + remaining.splice(i, 2); + return { + found: true, + value: next, + missingValue: false, + remainingArgs: remaining, + }; + } + + const inlineValue = findInlineOption(token, flag); + if (inlineValue !== undefined) { + remaining.splice(i, 1); + if (!inlineValue.trim()) { + return { found: true, missingValue: true, remainingArgs: remaining }; + } + return { + found: true, + value: inlineValue, + missingValue: false, + remainingArgs: remaining, + }; + } + } + } + + return { found: false, missingValue: false, remainingArgs: remaining }; +} + +/** Returns true if any of the provided boolean flags are present. */ +export function hasAnyFlag(args: string[], flags: readonly string[]): boolean { + return args.some((arg) => flags.includes(arg)); +} diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 60a24aec..f592dc3f 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -9,6 +9,7 @@ import { CLIProxyBackend } from '../../cliproxy/types'; import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { handleSync } from '../cliproxy-sync-handler'; +import { extractOption, hasAnyFlag } from '../arg-extractor'; // Import subcommand handlers import { handleList } from './auth-subcommand'; @@ -42,30 +43,23 @@ function parseBackendArg(args: string[]): { backend: CLIProxyBackend | undefined; remainingArgs: string[]; } { - const backendIdx = args.indexOf('--backend'); - if (backendIdx === -1) { - // Also check for --backend=value format - const backendEqualsIdx = args.findIndex((a) => a.startsWith('--backend=')); - if (backendEqualsIdx !== -1) { - const value = args[backendEqualsIdx].split('=')[1] as CLIProxyBackend; - if (value !== 'original' && value !== 'plus') { - console.warn(`Invalid backend '${value}'. Valid options: original, plus`); - return { backend: undefined, remainingArgs: args }; - } - const remainingArgs = [...args]; - remainingArgs.splice(backendEqualsIdx, 1); - return { backend: value, remainingArgs }; - } + const extracted = extractOption(args, ['--backend']); + if (!extracted.found) { return { backend: undefined, remainingArgs: args }; } - const value = args[backendIdx + 1]; + + if (extracted.missingValue || !extracted.value) { + console.warn(`Invalid backend ''. Valid options: original, plus`); + return { backend: undefined, remainingArgs: extracted.remainingArgs }; + } + + const value = extracted.value as CLIProxyBackend; if (value !== 'original' && value !== 'plus') { console.warn(`Invalid backend '${value}'. Valid options: original, plus`); - return { backend: undefined, remainingArgs: args }; + return { backend: undefined, remainingArgs: extracted.remainingArgs }; } - const remainingArgs = [...args]; - remainingArgs.splice(backendIdx, 2); - return { backend: value, remainingArgs }; + + return { backend: value, remainingArgs: extracted.remainingArgs }; } /** @@ -86,54 +80,19 @@ function parseProviderArg(args: string[]): { provider: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all'; remainingArgs: string[]; } { - const providerIdx = args.indexOf('--provider'); - if (providerIdx === -1) { - // Also check for --provider=value format - const providerEqualsIdx = args.findIndex((a) => a.startsWith('--provider=')); - if (providerEqualsIdx !== -1) { - const value = args[providerEqualsIdx].split('=')[1]?.toLowerCase() || ''; - const remainingArgs = [...args]; - remainingArgs.splice(providerEqualsIdx, 1); - // Handle empty value - if (!value) { - console.error( - 'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all' - ); - return { provider: 'all', remainingArgs }; - } - // Normalize gemini-cli to gemini - const normalized = - value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value; - if ( - normalized !== 'agy' && - normalized !== 'codex' && - normalized !== 'gemini' && - normalized !== 'ghcp' && - normalized !== 'all' - ) { - console.error( - `Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all` - ); - return { provider: 'all', remainingArgs }; - } - return { - provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all', - remainingArgs, - }; - } + const extracted = extractOption(args, ['--provider']); + if (!extracted.found) { return { provider: 'all', remainingArgs: args }; } - const rawValue = args[providerIdx + 1]; - // Warn if no value or value looks like another flag - if (!rawValue || rawValue.startsWith('-')) { + + if (extracted.missingValue || !extracted.value) { console.error( 'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all' ); + return { provider: 'all', remainingArgs: extracted.remainingArgs }; } - const value = rawValue?.toLowerCase() || 'all'; - const remainingArgs = [...args]; - remainingArgs.splice(providerIdx, 2); - // Normalize gemini-cli to gemini + + const value = extracted.value.toLowerCase(); const normalized = value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value; if ( @@ -146,11 +105,11 @@ function parseProviderArg(args: string[]): { console.error( `Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all` ); - return { provider: 'all', remainingArgs }; + return { provider: 'all', remainingArgs: extracted.remainingArgs }; } return { provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all', - remainingArgs, + remainingArgs: extracted.remainingArgs, }; } @@ -162,35 +121,14 @@ export async function handleCliproxyCommand(args: string[]): Promise { const { backend: cliBackend, remainingArgs } = parseBackendArg(args); const effectiveBackend = getEffectiveBackend(cliBackend); - const verbose = remainingArgs.includes('--verbose') || remainingArgs.includes('-v'); + const verbose = hasAnyFlag(remainingArgs, ['--verbose', '-v']); const command = remainingArgs[0]; - if (remainingArgs.includes('--help') || remainingArgs.includes('-h')) { + if (hasAnyFlag(remainingArgs, ['--help', '-h'])) { await showHelp(); return; } - // Profile commands - if (command === 'create') { - await handleCreate(remainingArgs.slice(1), effectiveBackend); - return; - } - - if (command === 'edit') { - await handleEdit(remainingArgs.slice(1), effectiveBackend); - return; - } - - if (command === 'list' || command === 'ls') { - await handleList(); - return; - } - - if (command === 'remove' || command === 'delete' || command === 'rm') { - await handleRemove(remainingArgs.slice(1)); - return; - } - // Catalog commands if (command === 'catalog') { const subcommand = remainingArgs[1]; @@ -212,55 +150,37 @@ export async function handleCliproxyCommand(args: string[]): Promise { return; } - // Proxy lifecycle commands - if (command === 'start') { - await handleStart(verbose); - return; - } - - if (command === 'stop') { - await handleStop(); - return; - } - - if (command === 'restart') { - await handleRestart(verbose); - return; - } - - if (command === 'status') { - await handleProxyStatus(); - return; - } - - // Diagnostics - if (command === 'doctor' || command === 'diag') { - await handleDoctor(verbose); - return; - } - - // Quota management commands - if (command === 'default') { - await handleSetDefault(remainingArgs.slice(1)); - return; - } - - if (command === 'pause') { - await handlePauseAccount(remainingArgs.slice(1)); - return; - } - - if (command === 'resume') { - await handleResumeAccount(remainingArgs.slice(1)); - return; - } - if (command === 'quota') { const { provider: providerFilter } = parseProviderArg(remainingArgs.slice(1)); await handleQuotaStatus(verbose, providerFilter); return; } + const commandHandlers: Record Promise> = { + create: async () => handleCreate(remainingArgs.slice(1), effectiveBackend), + edit: async () => handleEdit(remainingArgs.slice(1), effectiveBackend), + list: async () => handleList(), + ls: async () => handleList(), + remove: async () => handleRemove(remainingArgs.slice(1)), + delete: async () => handleRemove(remainingArgs.slice(1)), + rm: async () => handleRemove(remainingArgs.slice(1)), + start: async () => handleStart(verbose), + stop: async () => handleStop(), + restart: async () => handleRestart(verbose), + status: async () => handleProxyStatus(), + doctor: async () => handleDoctor(verbose), + diag: async () => handleDoctor(verbose), + default: async () => handleSetDefault(remainingArgs.slice(1)), + pause: async () => handlePauseAccount(remainingArgs.slice(1)), + resume: async () => handleResumeAccount(remainingArgs.slice(1)), + }; + + const commandHandler = command ? commandHandlers[command] : undefined; + if (commandHandler) { + await commandHandler(); + return; + } + // Binary installation commands const installIdx = remainingArgs.indexOf('--install'); if (installIdx !== -1) { diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index 906fac3d..28bb0dfa 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -13,6 +13,7 @@ import { setupGracefulShutdown } from '../web-server/shutdown'; import { ensureCliproxyService } from '../cliproxy/service-manager'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator'; import { initUI, header, ok, info, warn, fail } from '../utils/ui'; +import { extractOption, hasAnyFlag } from './arg-extractor'; interface ConfigOptions { port?: number; @@ -25,25 +26,28 @@ interface ConfigOptions { function parseArgs(args: string[]): ConfigOptions { const result: ConfigOptions = {}; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; + if (hasAnyFlag(args, ['--help', '-h'])) { + showHelp(); + process.exit(0); + } - if ((arg === '--port' || arg === '-p') && args[i + 1]) { - const port = parseInt(args[++i], 10); - if (!isNaN(port) && port > 0 && port < 65536) { - result.port = port; - } else { - console.error(fail('Invalid port number')); - process.exit(1); - } - } else if (arg === '--dev') { - result.dev = true; - } else if (arg === '--help' || arg === '-h') { - showHelp(); - process.exit(0); + const portOption = extractOption(args, ['--port', '-p']); + if (portOption.found) { + if (portOption.missingValue || !portOption.value) { + console.error(fail('Invalid port number')); + process.exit(1); + } + + const port = parseInt(portOption.value, 10); + if (!isNaN(port) && port > 0 && port < 65536) { + result.port = port; + } else { + console.error(fail('Invalid port number')); + process.exit(1); } } + result.dev = hasAnyFlag(args, ['--dev']); return result; } diff --git a/src/commands/config-image-analysis-command.ts b/src/commands/config-image-analysis-command.ts index bc13f8c1..93c44602 100644 --- a/src/commands/config-image-analysis-command.ts +++ b/src/commands/config-image-analysis-command.ts @@ -12,6 +12,8 @@ import { loadOrCreateUnifiedConfig, } from '../config/unified-config-loader'; import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../config/unified-config-types'; +import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; +import { extractOption, hasAnyFlag } from './arg-extractor'; interface ImageAnalysisCommandOptions { enable?: boolean; @@ -22,29 +24,28 @@ interface ImageAnalysisCommandOptions { } function parseArgs(args: string[]): ImageAnalysisCommandOptions { - const options: ImageAnalysisCommandOptions = {}; + const options: ImageAnalysisCommandOptions = { + enable: hasAnyFlag(args, ['--enable']), + disable: hasAnyFlag(args, ['--disable']), + help: hasAnyFlag(args, ['--help', '-h']), + }; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; + const timeoutOption = extractOption(args, ['--timeout']); + if (timeoutOption.found) { + const timeout = parseInt(timeoutOption.value || '', 10); + if (isNaN(timeout) || timeout < 10 || timeout > 600) { + console.error(fail('Timeout must be between 10 and 600 seconds')); + process.exit(1); + } + options.timeout = timeout; + } - if (arg === '--enable') { - options.enable = true; - } else if (arg === '--disable') { - options.disable = true; - } else if (arg === '--timeout' && args[i + 1]) { - const timeout = parseInt(args[++i], 10); - if (isNaN(timeout) || timeout < 10 || timeout > 600) { - console.error(fail('Timeout must be between 10 and 600 seconds')); - process.exit(1); - } - options.timeout = timeout; - } else if (arg === '--set-model' && args[i + 1] && args[i + 2]) { - options.setModel = { - provider: args[++i], - model: args[++i], - }; - } else if (arg === '--help' || arg === '-h') { - options.help = true; + const setModelIdx = args.indexOf('--set-model'); + if (setModelIdx !== -1) { + const provider = args[setModelIdx + 1]; + const model = args[setModelIdx + 2]; + if (provider && model && !provider.startsWith('-') && !model.startsWith('-')) { + options.setModel = { provider, model }; } } @@ -186,8 +187,10 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< } if (options.setModel) { - const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow']; - if (!validProviders.includes(options.setModel.provider)) { + const validProviders = [...CLIPROXY_PROVIDER_IDS]; + if ( + !validProviders.includes(options.setModel.provider as (typeof CLIPROXY_PROVIDER_IDS)[number]) + ) { console.error(fail(`Invalid provider: ${options.setModel.provider}`)); console.error(info(`Valid providers: ${validProviders.join(', ')}`)); process.exit(1); diff --git a/src/commands/persist-command.ts b/src/commands/persist-command.ts index 514afa8f..eba20cc5 100644 --- a/src/commands/persist-command.ts +++ b/src/commands/persist-command.ts @@ -10,7 +10,6 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../utils/ui'; import { InteractivePrompt } from '../utils/prompt'; import ProfileDetector, { @@ -21,6 +20,8 @@ import ProfileDetector, { import { getEffectiveEnvVars, CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator'; import { generateCopilotEnv } from '../copilot/copilot-executor'; import { expandPath } from '../utils/helpers'; +import { getClaudeConfigDir, getClaudeSettingsPath } from '../utils/claude-config-path'; +import { extractOption, hasAnyFlag } from './arg-extractor'; interface PersistCommandArgs { profile?: string; @@ -37,34 +38,37 @@ interface ResolvedEnv { /** Parse command line arguments */ function parseArgs(args: string[]): PersistCommandArgs { - const result: PersistCommandArgs = {}; - for (let i = 0; i < args.length; i++) { - const arg = args[i]; - if (arg === '--yes' || arg === '-y') { - result.yes = true; - } else if (arg === '--help' || arg === '-h') { - // Will be handled in main function - } else if (arg === '--list-backups') { - result.listBackups = true; - } else if (arg === '--restore') { - // Check if next arg is a timestamp (not a flag) - const nextArg = args[i + 1]; - if (nextArg && !nextArg.startsWith('-')) { - result.restore = nextArg; - i++; // Skip next arg - } else { - result.restore = true; // Use latest - } - } else if (!arg.startsWith('-') && !result.profile) { + const result: PersistCommandArgs = { + yes: hasAnyFlag(args, ['--yes', '-y']), + listBackups: hasAnyFlag(args, ['--list-backups']), + }; + + const restoreOption = extractOption(args, ['--restore']); + if (restoreOption.found) { + result.restore = restoreOption.missingValue ? true : restoreOption.value || true; + } + + for (const arg of restoreOption.remainingArgs) { + if (!arg.startsWith('-')) { result.profile = arg; + break; } } return result; } -/** Get Claude settings.json path */ -function getClaudeSettingsPath(): string { - return path.join(os.homedir(), '.claude', 'settings.json'); +function formatDisplayPath(filePath: string): string { + const claudeDir = getClaudeConfigDir(); + if (filePath === claudeDir) { + return '~/.claude'; + } + + const claudePrefix = `${claudeDir}${path.sep}`; + if (filePath.startsWith(claudePrefix)) { + return filePath.replace(claudePrefix, '~/.claude/'); + } + + return filePath; } /** Read existing Claude settings.json with validation */ @@ -517,7 +521,7 @@ export async function handlePersistCommand(args: string[]): Promise { if (createBackupFlag) { try { createdBackupPath = createBackup(); - console.log(ok(`Backup created: ${createdBackupPath.replace(os.homedir(), '~')}`)); + console.log(ok(`Backup created: ${formatDisplayPath(createdBackupPath)}`)); console.log(''); } catch (error) { console.log(fail(`Failed to create backup: ${(error as Error).message}`)); @@ -560,7 +564,7 @@ export async function handlePersistCommand(args: string[]): Promise { if (createdBackupPath) { console.log(''); console.log(info(`A backup was created before this error:`)); - console.log(` ${createdBackupPath.replace(os.homedir(), '~')}`); + console.log(` ${formatDisplayPath(createdBackupPath)}`); console.log(dim(' To restore: ccs persist --restore')); } process.exit(1); diff --git a/src/management/checks/config-check.ts b/src/management/checks/config-check.ts index c05d8457..8c1af017 100644 --- a/src/management/checks/config-check.ts +++ b/src/management/checks/config-check.ts @@ -4,10 +4,10 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { ok, fail, warn, info } from '../../utils/ui'; import { HealthCheck, IHealthChecker, createSpinner } from './types'; import { getCcsDir } from '../../utils/config-manager'; +import { getClaudeConfigDir } from '../../utils/claude-config-path'; const ora = createSpinner(); @@ -195,7 +195,7 @@ export class ClaudeSettingsChecker implements IHealthChecker { private readonly claudeDir: string; constructor() { - this.claudeDir = path.join(os.homedir(), '.claude'); + this.claudeDir = getClaudeConfigDir(); } run(results: HealthCheck): void { diff --git a/src/shared/extended-context-utils.ts b/src/shared/extended-context-utils.ts new file mode 100644 index 00000000..88b0486a --- /dev/null +++ b/src/shared/extended-context-utils.ts @@ -0,0 +1,29 @@ +/** + * Shared extended context helpers used by CLI + UI. + */ + +/** Extended context suffix recognized by Claude Code. */ +export const EXTENDED_CONTEXT_SUFFIX = '[1m]'; + +/** Check if model is a native Gemini model (auto-enabled behavior). */ +export function isNativeGeminiModel(modelId: string): boolean { + return modelId.toLowerCase().startsWith('gemini-'); +} + +/** Check if model already has [1m] suffix. */ +export function hasExtendedContextSuffix(model: string): boolean { + return model.toLowerCase().endsWith(EXTENDED_CONTEXT_SUFFIX.toLowerCase()); +} + +/** Apply [1m] suffix to model if not already present. */ +export function applyExtendedContextSuffix(model: string): string { + if (!model) return model; + if (hasExtendedContextSuffix(model)) return model; + return `${model}${EXTENDED_CONTEXT_SUFFIX}`; +} + +/** Strip [1m] suffix from model string. */ +export function stripExtendedContextSuffix(model: string): string { + if (!model) return model; + return hasExtendedContextSuffix(model) ? model.slice(0, -EXTENDED_CONTEXT_SUFFIX.length) : model; +} diff --git a/src/utils/claude-config-path.ts b/src/utils/claude-config-path.ts new file mode 100644 index 00000000..0ea40972 --- /dev/null +++ b/src/utils/claude-config-path.ts @@ -0,0 +1,26 @@ +import * as os from 'os'; +import * as path from 'path'; + +/** + * Resolve Claude config directory with test/dev overrides. + * Precedence: + * 1. CLAUDE_CONFIG_DIR (explicit override) + * 2. CCS_HOME compatibility path (/.claude) + * 3. ~/.claude (default) + */ +export function getClaudeConfigDir(): string { + if (process.env.CLAUDE_CONFIG_DIR) { + return path.resolve(process.env.CLAUDE_CONFIG_DIR); + } + + if (process.env.CCS_HOME) { + return path.join(path.dirname(path.resolve(process.env.CCS_HOME)), '.claude'); + } + + return path.join(os.homedir(), '.claude'); +} + +/** Resolve Claude settings.json path. */ +export function getClaudeSettingsPath(): string { + return path.join(getClaudeConfigDir(), 'settings.json'); +} diff --git a/src/utils/websearch/hook-config.ts b/src/utils/websearch/hook-config.ts index cc2754ce..b7d6c092 100644 --- a/src/utils/websearch/hook-config.ts +++ b/src/utils/websearch/hook-config.ts @@ -8,30 +8,15 @@ import * as fs from 'fs'; import * as path from 'path'; -import * as os from 'os'; import { info, warn } from '../ui'; import { getWebSearchConfig } from '../../config/unified-config-loader'; import { getCcsHooksDir } from '../config-manager'; +import { getClaudeSettingsPath } from '../claude-config-path'; import { isCcsWebSearchHook, deduplicateCcsHooks } from './hook-utils'; // Hook file name const WEBSEARCH_HOOK = 'websearch-transformer.cjs'; -/** - * Get Claude settings path (respects CCS_HOME for test isolation) - * In tests, returns path under CCS_HOME; in production, uses real ~/.claude/ - */ -function getClaudeSettingsPath(): string { - const ccsHome = process.env.CCS_HOME; - if (ccsHome) { - // Test mode: use CCS_HOME parent for .claude directory - // This prevents tests from modifying user's real settings - return path.join(path.dirname(ccsHome), '.claude', 'settings.json'); - } - // Production: use real home directory - return path.join(os.homedir(), '.claude', 'settings.json'); -} - // Buffer time added to max provider timeout for hook timeout (seconds) const HOOK_TIMEOUT_BUFFER = 30; diff --git a/src/web-server/jsonl-parser.ts b/src/web-server/jsonl-parser.ts index 41432e7b..f0f4a39f 100644 --- a/src/web-server/jsonl-parser.ts +++ b/src/web-server/jsonl-parser.ts @@ -14,7 +14,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as readline from 'readline'; -import * as os from 'os'; +import { getClaudeConfigDir } from '../utils/claude-config-path'; // ============================================================================ // TYPE DEFINITIONS @@ -176,8 +176,7 @@ export async function parseProjectDirectory(projectDir: string): Promise = { - agy: { - displayName: 'Antigravity', - description: 'Antigravity AI models', - }, - claude: { - displayName: 'Claude (Anthropic)', - description: 'Claude Opus/Sonnet models', - }, - gemini: { - displayName: 'Google Gemini', - description: 'Gemini Pro/Flash models', - }, - codex: { - displayName: 'OpenAI Codex', - description: 'GPT-4 and codex models', - }, - qwen: { - displayName: 'Alibaba Qwen', - description: 'Qwen Code models', - }, - iflow: { - displayName: 'iFlow', - description: 'iFlow AI models', - }, - kiro: { - displayName: 'Kiro (AWS)', - description: 'AWS CodeWhisperer models', - }, - ghcp: { - displayName: 'GitHub Copilot (OAuth)', - description: 'GitHub Copilot via OAuth', - }, - kimi: { - displayName: 'Kimi (Moonshot)', - description: 'Moonshot AI K2/K2.5 models', - }, -}; +export const PROVIDER_METADATA: Record = Object.freeze( + Object.fromEntries( + CLIPROXY_PROVIDERS.map((provider) => [ + provider, + { + displayName: PROVIDER_CAPABILITIES[provider].displayName, + description: PROVIDER_CAPABILITIES[provider].description, + }, + ]) + ) as Record +); // Map provider names to asset filenames (only providers with actual logos) export const PROVIDER_ASSETS: Record = { @@ -149,13 +113,12 @@ export const PROVIDER_COLORS: Record = { vertex: '#4285F4', iflow: '#f94144', qwen: '#6236FF', - kiro: '#4d908e', // Dark Cyan (AWS-inspired) - ghcp: '#43aa8b', // Seaweed (GitHub-inspired) - claude: '#D97757', // Anthropic brand color (matches SVG) - kimi: '#FF6B35', // Moonshot AI brand orange + kiro: '#4d908e', + ghcp: '#43aa8b', + claude: '#D97757', + kimi: '#FF6B35', }; -// Provider display names const PROVIDER_NAMES: Record = { ...Object.fromEntries( CLIPROXY_PROVIDERS.map((provider) => [provider, PROVIDER_METADATA[provider].displayName]) @@ -163,7 +126,6 @@ const PROVIDER_NAMES: Record = { vertex: 'Vertex AI', }; -// Map provider to display name export function getProviderDisplayName(provider: unknown): string { const normalized = normalizeProviderInput(provider); if (!normalized) { @@ -181,9 +143,10 @@ export function getProviderDescription(provider: unknown): string { /** * Providers that use Device Code OAuth flow instead of Authorization Code flow. - * Device Code flow requires displaying a user code for manual entry at provider's website. */ -export const DEVICE_CODE_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro', 'qwen', 'kimi']; +export const DEVICE_CODE_PROVIDERS: CLIProxyProvider[] = [ + ...getProvidersByOAuthFlow('device_code'), +]; const DEVICE_CODE_PROVIDER_DISPLAY_NAMES: Readonly>> = Object.freeze({ From af6aa2d7b2b40f144b7e53b3f69a63e92cbb10c6 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 21 Feb 2026 01:35:58 +0700 Subject: [PATCH 02/27] fix(types): use type-only import for composite tier config --- src/cliproxy/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index eaa9a016..925ee534 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -3,7 +3,7 @@ * Types for CLIProxyAPI binary management and execution */ -import { CompositeTierConfig } from '../config/unified-config-types'; +import type { CompositeTierConfig } from '../config/unified-config-types'; /** * Supported operating systems From 6074fcb0b628c801b3a579c2673f79e829270dc1 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 21 Feb 2026 01:44:44 +0700 Subject: [PATCH 03/27] fix(refactor): address PR review follow-up findings --- src/commands/cliproxy/index.ts | 56 ++++++++++----- tests/unit/commands/arg-extractor.test.ts | 83 +++++++++++++++++++++++ ui/src/lib/provider-config.ts | 3 + 3 files changed, 125 insertions(+), 17 deletions(-) create mode 100644 tests/unit/commands/arg-extractor.test.ts diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index f592dc3f..0174e40c 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -7,6 +7,7 @@ import { CLIProxyBackend } from '../../cliproxy/types'; import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector'; +import { CLIPROXY_PROVIDER_IDS } from '../../cliproxy/provider-capabilities'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { handleSync } from '../cliproxy-sync-handler'; import { extractOption, hasAnyFlag } from '../arg-extractor'; @@ -76,8 +77,40 @@ function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend { * Returns the provider filter value and remaining args * Accepts: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all */ +type QuotaProvider = 'agy' | 'codex' | 'gemini' | 'ghcp'; +type QuotaProviderFilter = QuotaProvider | 'all'; + +const PROVIDER_ARG_HELP_TEXT = 'agy, codex, gemini, gemini-cli, ghcp, github-copilot, all'; + +const QUOTA_PROVIDER_ALIAS_MAP: Readonly> = { + 'gemini-cli': 'gemini', + 'github-copilot': 'ghcp', +}; + +const QUOTA_PROVIDER_IDS = Object.freeze( + CLIPROXY_PROVIDER_IDS.filter( + (provider): provider is QuotaProvider => + provider === 'agy' || provider === 'codex' || provider === 'gemini' || provider === 'ghcp' + ) +); + +const QUOTA_PROVIDER_SET = new Set(QUOTA_PROVIDER_IDS); + +function normalizeQuotaProvider(value: string): QuotaProviderFilter | null { + if (value === 'all') { + return 'all'; + } + + const normalized = QUOTA_PROVIDER_ALIAS_MAP[value] ?? value; + if (!QUOTA_PROVIDER_SET.has(normalized as QuotaProvider)) { + return null; + } + + return normalized as QuotaProvider; +} + function parseProviderArg(args: string[]): { - provider: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all'; + provider: QuotaProviderFilter; remainingArgs: string[]; } { const extracted = extractOption(args, ['--provider']); @@ -86,29 +119,18 @@ function parseProviderArg(args: string[]): { } if (extracted.missingValue || !extracted.value) { - console.error( - 'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all' - ); + console.error(`Warning: --provider requires a value. Valid options: ${PROVIDER_ARG_HELP_TEXT}`); return { provider: 'all', remainingArgs: extracted.remainingArgs }; } const value = extracted.value.toLowerCase(); - const normalized = - value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value; - if ( - normalized !== 'agy' && - normalized !== 'codex' && - normalized !== 'gemini' && - normalized !== 'ghcp' && - normalized !== 'all' - ) { - console.error( - `Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all` - ); + const normalized = normalizeQuotaProvider(value); + if (!normalized) { + console.error(`Invalid provider '${value}'. Valid options: ${PROVIDER_ARG_HELP_TEXT}`); return { provider: 'all', remainingArgs: extracted.remainingArgs }; } return { - provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all', + provider: normalized, remainingArgs: extracted.remainingArgs, }; } diff --git a/tests/unit/commands/arg-extractor.test.ts b/tests/unit/commands/arg-extractor.test.ts new file mode 100644 index 00000000..eeaa508d --- /dev/null +++ b/tests/unit/commands/arg-extractor.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'bun:test'; + +import { extractOption, hasAnyFlag } from '../../../src/commands/arg-extractor'; + +describe('arg-extractor', () => { + describe('extractOption', () => { + it('extracts --flag value and removes both tokens from remaining args', () => { + const result = extractOption(['--profile', 'gemini', '--yes'], ['--profile']); + + expect(result).toEqual({ + found: true, + value: 'gemini', + missingValue: false, + remainingArgs: ['--yes'], + }); + }); + + it('extracts --flag=value and removes inline token from remaining args', () => { + const result = extractOption(['--yes', '--profile=codex', 'prompt'], ['--profile']); + + expect(result).toEqual({ + found: true, + value: 'codex', + missingValue: false, + remainingArgs: ['--yes', 'prompt'], + }); + }); + + it('marks missing value when flag is last token', () => { + const result = extractOption(['prompt', '--profile'], ['--profile']); + + expect(result).toEqual({ + found: true, + missingValue: true, + remainingArgs: ['prompt'], + }); + }); + + it('marks missing value for empty inline value', () => { + const result = extractOption(['--profile=', '--yes'], ['--profile']); + + expect(result).toEqual({ + found: true, + missingValue: true, + remainingArgs: ['--yes'], + }); + }); + + it('marks missing value when next token is another flag and keeps that flag', () => { + const result = extractOption(['--profile', '--yes', 'prompt'], ['--profile']); + + expect(result).toEqual({ + found: true, + missingValue: true, + remainingArgs: ['--yes', 'prompt'], + }); + }); + + it('returns non-match state without altering args content', () => { + const args = ['--yes', 'prompt']; + const result = extractOption(args, ['--profile', '-p']); + + expect(result).toEqual({ + found: false, + missingValue: false, + remainingArgs: ['--yes', 'prompt'], + }); + expect(args).toEqual(['--yes', 'prompt']); + }); + }); + + describe('hasAnyFlag', () => { + it('returns true when any exact flag is present', () => { + expect(hasAnyFlag(['prompt', '--yes'], ['--yes', '-y'])).toBe(true); + expect(hasAnyFlag(['prompt', '-y'], ['--yes', '-y'])).toBe(true); + }); + + it('returns false when only non-matching or inline tokens exist', () => { + expect(hasAnyFlag(['prompt', '--yes=true'], ['--yes', '-y'])).toBe(false); + expect(hasAnyFlag(['prompt', '--profile=gemini'], ['--yes', '-y'])).toBe(false); + }); + }); +}); diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index 59e98cde..26268eed 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -10,6 +10,9 @@ import { getProvidersByOAuthFlow, } from '../../../src/cliproxy/provider-capabilities'; +// Monorepo contract: UI consumes provider capability constants directly from backend +// to enforce one source of truth and prevent provider drift across surfaces. + /** Canonical list of CLIProxy provider IDs (shared with backend). */ export const CLIPROXY_PROVIDERS = CLIPROXY_PROVIDER_IDS; From 6429781e8fc7ec5d72aedb7b2409f26f0c739e00 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 21 Feb 2026 02:14:09 +0700 Subject: [PATCH 04/27] fix(cliproxy): normalize provider-aware Claude model IDs --- src/cliproxy/config/env-builder.ts | 40 ++++--- src/cliproxy/config/thinking-config.ts | 10 +- src/cliproxy/executor/env-resolver.ts | 6 +- src/cliproxy/index.ts | 10 ++ src/cliproxy/model-catalog.ts | 8 +- src/cliproxy/model-id-normalizer.ts | 104 ++++++++++++++++++ src/cliproxy/tool-sanitization-proxy.ts | 24 +++- .../cliproxy/composite-env-routing.test.ts | 21 ++++ .../unit/cliproxy/composite-thinking.test.ts | 19 ++++ .../cliproxy/env-builder-provider-url.test.ts | 25 ++++- .../cliproxy/model-catalog-compat.test.ts | 17 +++ .../unit/cliproxy/model-id-normalizer.test.ts | 85 ++++++++++++++ ...ool-sanitization-proxy-integration.test.ts | 71 ++++++++++++ 13 files changed, 408 insertions(+), 32 deletions(-) create mode 100644 src/cliproxy/model-id-normalizer.ts create mode 100644 tests/unit/cliproxy/model-catalog-compat.test.ts create mode 100644 tests/unit/cliproxy/model-id-normalizer.test.ts diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index d4353572..277f4d41 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -19,6 +19,11 @@ import { CLIPROXY_DEFAULT_PORT, } from './port-manager'; import { getProviderSettingsPath } from './path-resolver'; +import { + MODEL_ENV_VAR_KEYS, + normalizeModelEnvVarsForProvider, + normalizeModelIdForProvider, +} from '../model-id-normalizer'; /** Settings file structure for user overrides */ interface ProviderSettings { @@ -30,14 +35,6 @@ const DEPRECATED_MODEL_PREFIX = 'gemini-claude-'; /** Replacement prefix matching actual upstream model names */ const UPSTREAM_MODEL_PREFIX = 'claude-'; -/** Env vars that contain model names and may need migration */ -const MODEL_ENV_KEYS = [ - 'ANTHROPIC_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', -]; - /** * Migrate deprecated gemini-claude-* model names to upstream claude-* names in a settings file. * CLIProxyAPI registry no longer recognizes the gemini-claude-* prefix convention. @@ -49,7 +46,7 @@ function migrateDeprecatedModelNames(settingsPath: string, settings: ProviderSet if (!settings.env || typeof settings.env !== 'object') return false; let migrated = false; - for (const key of MODEL_ENV_KEYS) { + for (const key of MODEL_ENV_VAR_KEYS) { const value = settings.env[key]; if (typeof value !== 'string') continue; @@ -124,10 +121,12 @@ export function getClaudeEnvVars( } = baseEnvVars; // Merge core env vars with additional env vars from base config - return { + const mergedEnv = { ...coreEnvVars, ...additionalEnvVars, // Includes ANTHROPIC_MAX_TOKENS, etc. }; + + return normalizeModelEnvVarsForProvider(mergedEnv, provider); } /** @@ -174,7 +173,7 @@ function ensureRequiredEnvVars( ); } - return result; + return normalizeModelEnvVarsForProvider(result, provider); } /** Localhost hostnames used for local CLIProxy endpoints */ @@ -458,7 +457,7 @@ export function getRemoteEnvVars( ANTHROPIC_AUTH_TOKEN: remoteConfig.authToken || getEffectiveApiKey(), }; - return env; + return normalizeModelEnvVarsForProvider(env, provider) as Record; } /** Remote config for composite variant (passed from env-resolver) */ @@ -519,10 +518,19 @@ export function getCompositeEnvVars( const validPort = validatePort(port); // Defensive: handle missing tiers gracefully - const opusModel = tiers.opus?.model; - const sonnetModel = tiers.sonnet?.model; - const haikuModel = tiers.haiku?.model; - const defaultModel = tiers[defaultTier]?.model; + const opusModel = tiers.opus?.model + ? normalizeModelIdForProvider(tiers.opus.model, tiers.opus.provider) + : undefined; + const sonnetModel = tiers.sonnet?.model + ? normalizeModelIdForProvider(tiers.sonnet.model, tiers.sonnet.provider) + : undefined; + const haikuModel = tiers.haiku?.model + ? normalizeModelIdForProvider(tiers.haiku.model, tiers.haiku.provider) + : undefined; + const defaultTierModel = tiers[defaultTier]; + const defaultModel = defaultTierModel?.model + ? normalizeModelIdForProvider(defaultTierModel.model, defaultTierModel.provider) + : undefined; // If default tier is missing, we cannot proceed meaningfully if (!defaultModel) { diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index 840b87e3..76e67752 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -8,6 +8,7 @@ import { ThinkingConfig, DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/uni import { getThinkingConfig } from '../../config/unified-config-loader'; import { supportsThinking } from '../model-catalog'; import { isThinkingOffValue, validateThinking } from '../thinking-validator'; +import { normalizeModelIdForProvider } from '../model-id-normalizer'; import { warn } from '../../utils/ui'; /** Model tier types for thinking budget defaults */ @@ -19,22 +20,23 @@ export type ModelTier = 'opus' | 'sonnet' | 'haiku'; */ function normalizeModelForThinkingLookup(model: string, provider: CLIProxyProvider): string { const withoutExtendedContext = model.replace(/\[1m\]$/i, '').trim(); + const providerNormalized = normalizeModelIdForProvider(withoutExtendedContext, provider); - if (provider !== 'codex') return withoutExtendedContext; + if (provider !== 'codex') return providerNormalized; // New codex suffix form: gpt-5.3-codex-high -> gpt-5.3-codex - const codexSuffixMatch = withoutExtendedContext.match(/^(.*)-(xhigh|high|medium)$/i); + const codexSuffixMatch = providerNormalized.match(/^(.*)-(xhigh|high|medium)$/i); if (codexSuffixMatch?.[1]) { return codexSuffixMatch[1].trim(); } // Legacy codex suffix form: gpt-5.3-codex(high) -> gpt-5.3-codex - const codexLegacyMatch = withoutExtendedContext.match(/^(.*)\((xhigh|high|medium)\)$/i); + const codexLegacyMatch = providerNormalized.match(/^(.*)\((xhigh|high|medium)\)$/i); if (codexLegacyMatch?.[1]) { return codexLegacyMatch[1].trim(); } - return withoutExtendedContext; + return providerNormalized; } /** diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index ea9dbcdb..ace7a18d 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -24,6 +24,7 @@ import { stripClaudeCodeEnv } from '../../utils/shell-executor'; import { CodexReasoningProxy } from '../codex-reasoning-proxy'; import { ToolSanitizationProxy } from '../tool-sanitization-proxy'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; +import { normalizeModelIdForProvider } from '../model-id-normalizer'; export interface RemoteProxyConfig { host: string; @@ -271,10 +272,11 @@ export function applyFallback( } as const; const result = { ...env }; const originalModel = result[tierEnvMap[failedTier]]; - result[tierEnvMap[failedTier]] = fallback.model; + const normalizedFallbackModel = normalizeModelIdForProvider(fallback.model, fallback.provider); + result[tierEnvMap[failedTier]] = normalizedFallbackModel; // If failed tier is default tier, also update ANTHROPIC_MODEL if (result.ANTHROPIC_MODEL === originalModel) { - result.ANTHROPIC_MODEL = fallback.model; + result.ANTHROPIC_MODEL = normalizedFallbackModel; } return result; } diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index 0975e3cb..a250bb2d 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -84,6 +84,16 @@ export { // Model catalog and configuration export type { ModelEntry, ProviderCatalog } from './model-catalog'; export { MODEL_CATALOG, supportsModelConfig, getProviderCatalog, findModel } from './model-catalog'; +export { + MODEL_ENV_VAR_KEYS, + extractProviderFromPathname, + isAntigravityProvider, + normalizeClaudeDottedMajorMinor, + normalizeClaudeDottedThinkingMajorMinor, + normalizeModelIdForProvider, + normalizeModelIdForRouting, + normalizeModelEnvVarsForProvider, +} from './model-id-normalizer'; export { hasUserSettings, getCurrentModel, diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index e03d6b6a..13b6c45c 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -6,6 +6,7 @@ */ import { CLIProxyProvider } from './types'; +import { normalizeModelIdForProvider } from './model-id-normalizer'; /** * Thinking support configuration for a model. @@ -322,7 +323,12 @@ export function findModel(provider: CLIProxyProvider, modelId: string): ModelEnt const catalog = MODEL_CATALOG[provider]; if (!catalog || !modelId) return undefined; const normalizedId = modelId.trim().toLowerCase(); - return catalog.models.find((m) => m.id.toLowerCase() === normalizedId); + const providerNormalizedId = normalizeModelIdForProvider(normalizedId, provider) + .trim() + .toLowerCase(); + const lookupCandidates = new Set([normalizedId, providerNormalizedId]); + + return catalog.models.find((m) => lookupCandidates.has(m.id.toLowerCase())); } /** diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts new file mode 100644 index 00000000..7d8ee86b --- /dev/null +++ b/src/cliproxy/model-id-normalizer.ts @@ -0,0 +1,104 @@ +/** + * Model ID normalization helpers. + * + * Handles provider-aware compatibility between dotted and hyphenated Claude + * model version formats (e.g., 4.6 vs 4-6). + */ + +import { CLIProxyProvider } from './types'; + +/** Env vars that carry model identifiers. */ +export const MODEL_ENV_VAR_KEYS = [ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', +] as const; + +type ProviderLike = CLIProxyProvider | string | null | undefined; + +const CLAUDE_DOTTED_VERSION_REGEX = /claude-(sonnet|opus|haiku)-(\d+)\.(\d+)(?=(?:$|-|\[|\(|\/))/gi; +const CLAUDE_DOTTED_THINKING_REGEX = + /claude-(sonnet|opus|haiku)-(\d+)\.(\d+)-thinking(?=(?:$|-|\[|\(|\/))/gi; + +/** Extract provider segment from /api/provider/{provider} paths. */ +export function extractProviderFromPathname(pathname: string): string | null { + const match = pathname.match(/\/api\/provider\/([^/]+)/i); + if (!match?.[1]) return null; + return match[1].toLowerCase(); +} + +/** Whether the provider uses Antigravity model routing conventions. */ +export function isAntigravityProvider(provider: ProviderLike): boolean { + if (typeof provider !== 'string') return false; + const normalized = provider.trim().toLowerCase(); + return normalized === 'agy' || normalized === 'antigravity'; +} + +/** Normalize Claude dotted major.minor IDs to hyphenated format. */ +export function normalizeClaudeDottedMajorMinor(model: string): string { + return model.replace( + CLAUDE_DOTTED_VERSION_REGEX, + (_match: string, family: string, major: string, minor: string) => + `claude-${family.toLowerCase()}-${major}-${minor}` + ); +} + +/** + * Normalize only dotted Claude thinking IDs to hyphenated format. + * Keeps non-thinking dotted IDs unchanged. + */ +export function normalizeClaudeDottedThinkingMajorMinor(model: string): string { + return model.replace( + CLAUDE_DOTTED_THINKING_REGEX, + (_match: string, family: string, major: string, minor: string) => + `claude-${family.toLowerCase()}-${major}-${minor}-thinking` + ); +} + +/** + * Normalize model ID for a specific provider. + * Antigravity requires hyphenated Claude major.minor model IDs. + */ +export function normalizeModelIdForProvider(model: string, provider: ProviderLike): string { + if (!isAntigravityProvider(provider)) return model; + return normalizeClaudeDottedMajorMinor(model); +} + +/** + * Normalize model ID for request routing. + * - Antigravity routes: normalize all dotted Claude major.minor forms. + * - Root/composite routes: normalize only thinking forms to avoid mutating + * valid non-thinking dotted IDs used by other providers. + */ +export function normalizeModelIdForRouting(model: string, provider: ProviderLike): string { + if (isAntigravityProvider(provider)) { + return normalizeClaudeDottedMajorMinor(model); + } + return normalizeClaudeDottedThinkingMajorMinor(model); +} + +/** + * Normalize model-related env vars for a provider. + * Returns original object when no changes are required. + */ +export function normalizeModelEnvVarsForProvider( + envVars: NodeJS.ProcessEnv, + provider: ProviderLike, + keys: readonly string[] = MODEL_ENV_VAR_KEYS +): NodeJS.ProcessEnv { + let nextEnv: NodeJS.ProcessEnv | null = null; + + for (const key of keys) { + const value = envVars[key]; + if (typeof value !== 'string' || value.trim().length === 0) continue; + + const normalizedValue = normalizeModelIdForProvider(value, provider); + if (normalizedValue === value) continue; + + if (!nextEnv) nextEnv = { ...envVars }; + nextEnv[key] = normalizedValue; + } + + return nextEnv ?? envVars; +} diff --git a/src/cliproxy/tool-sanitization-proxy.ts b/src/cliproxy/tool-sanitization-proxy.ts index 36c7c40f..3dc0b379 100644 --- a/src/cliproxy/tool-sanitization-proxy.ts +++ b/src/cliproxy/tool-sanitization-proxy.ts @@ -18,6 +18,7 @@ import * as os from 'os'; import { URL } from 'url'; import { ToolNameMapper, type Tool, type ContentBlock } from './tool-name-mapper'; import { sanitizeToolSchemas } from './schema-sanitizer'; +import { extractProviderFromPathname, normalizeModelIdForRouting } from './model-id-normalizer'; import { getCcsDir } from '../utils/config-manager'; export interface ToolSanitizationProxyConfig { @@ -206,12 +207,25 @@ export class ToolSanitizationProxy { // Create mapper for this request const mapper = new ToolNameMapper(); - // Sanitize tools if present + // Normalize dotted Claude model IDs for provider-compatible routing. let modifiedBody = parsed; - if (isRecord(parsed) && Array.isArray(parsed.tools)) { + if (isRecord(modifiedBody) && typeof modifiedBody.model === 'string') { + const providerFromPath = extractProviderFromPathname(fullUpstreamUrl.pathname); + const normalizedModel = normalizeModelIdForRouting(modifiedBody.model, providerFromPath); + if (normalizedModel !== modifiedBody.model) { + this.writeLog( + 'warn', + `[tool-sanitization-proxy] Model normalized for provider routing (${providerFromPath ?? 'root'}): "${modifiedBody.model}" → "${normalizedModel}"` + ); + modifiedBody = { ...modifiedBody, model: normalizedModel }; + } + } + + // Sanitize tools if present + if (isRecord(modifiedBody) && Array.isArray(modifiedBody.tools)) { // Step 1: Sanitize input_schema properties (remove non-standard JSON Schema properties) const schemaResult = sanitizeToolSchemas( - parsed.tools as Array<{ name: string; input_schema?: Record }> + modifiedBody.tools as Array<{ name: string; input_schema?: Record }> ); if (schemaResult.totalRemoved > 0) { @@ -228,7 +242,7 @@ export class ToolSanitizationProxy { // Step 2: Sanitize tool names (truncate to 64 chars for Gemini) const sanitizedTools = mapper.registerTools(schemaResult.tools as Tool[]); - modifiedBody = { ...parsed, tools: sanitizedTools }; + modifiedBody = { ...modifiedBody, tools: sanitizedTools }; // Log sanitization warnings if (mapper.hasChanges()) { @@ -252,7 +266,7 @@ export class ToolSanitizationProxy { } // Check if streaming is requested - const isStreaming = isRecord(parsed) && parsed.stream === true; + const isStreaming = isRecord(modifiedBody) && modifiedBody.stream === true; if (isStreaming) { await this.forwardJsonStreaming(req, res, fullUpstreamUrl, modifiedBody, mapper); diff --git a/tests/unit/cliproxy/composite-env-routing.test.ts b/tests/unit/cliproxy/composite-env-routing.test.ts index 18978f21..281aae1b 100644 --- a/tests/unit/cliproxy/composite-env-routing.test.ts +++ b/tests/unit/cliproxy/composite-env-routing.test.ts @@ -71,4 +71,25 @@ describe('buildClaudeEnvironment - composite remote routing', () => { // Gemini thinking overrides may be normalized to numeric budgets, and [1m] may be auto-appended. expect(env.ANTHROPIC_MODEL).toMatch(/\([^)]+\)(\[1m\])?$/); }); + + it('normalizes dotted Claude model IDs for antigravity tiers in composite mode', () => { + const env = buildClaudeEnvironment({ + provider: 'agy', + useRemoteProxy: false, + localPort: 8318, + verbose: false, + isComposite: true, + compositeTiers: { + opus: { provider: 'agy', model: 'claude-opus-4.6-thinking' }, + sonnet: { provider: 'gemini', model: 'gemini-2.5-pro' }, + haiku: { provider: 'codex', model: 'gpt-5.1-codex-mini' }, + }, + compositeDefaultTier: 'opus', + }); + + expect(env.ANTHROPIC_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toMatch(/^gemini-2.5-pro(\([^)]+\))?$/); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini'); + }); }); diff --git a/tests/unit/cliproxy/composite-thinking.test.ts b/tests/unit/cliproxy/composite-thinking.test.ts index 4f66dad4..fe1ad59c 100644 --- a/tests/unit/cliproxy/composite-thinking.test.ts +++ b/tests/unit/cliproxy/composite-thinking.test.ts @@ -378,6 +378,25 @@ describe('applyThinkingConfig - composite variant integration', () => { expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking(high)'); }); + it('handles dotted agy Claude IDs for thinking capability lookup', () => { + const envVars: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-opus-4.5-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.5-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.5-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5', + }; + + const result = applyThinkingConfig(envVars, 'agy' as CLIProxyProvider, 'high'); + + // Capability lookup should succeed for dotted agy IDs after normalization. + // Main model value is validated for budget models; tier values keep raw override. + expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4.5-thinking(24576)'); + expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4.5-thinking(high)'); + expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4.5-thinking(high)'); + // Haiku does not support thinking in agy catalog. + expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4.5'); + }); + it('should handle numeric budgets in per-tier thinking', () => { const envVars: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking', diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts index 0705638c..bae0639d 100644 --- a/tests/unit/cliproxy/env-builder-provider-url.test.ts +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -13,7 +13,7 @@ interface EnvSettings { ANTHROPIC_DEFAULT_HAIKU_MODEL: string; } -function writeCodexSettings(settingsPath: string, env: EnvSettings): void { +function writeSettings(settingsPath: string, env: EnvSettings): void { fs.writeFileSync(settingsPath, JSON.stringify({ env }, null, 2)); } @@ -31,7 +31,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); it('rewrites local root URL to provider endpoint', () => { - writeCodexSettings(settingsPath, { + writeSettings(settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', @@ -45,7 +45,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); it('rewrites wrong local provider path to the requested provider', () => { - writeCodexSettings(settingsPath, { + writeSettings(settingsPath, { ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/my-codex-variant?debug=1', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', @@ -59,7 +59,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { }); it('does not rewrite localhost URLs targeting non-cliproxy ports', () => { - writeCodexSettings(settingsPath, { + writeSettings(settingsPath, { ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434', ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh', @@ -71,4 +71,21 @@ describe('getEffectiveEnvVars local provider URL normalization', () => { const env = getEffectiveEnvVars('codex', 8317, settingsPath); expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:11434'); }); + + it('normalizes dotted Claude major.minor IDs for agy provider settings', () => { + writeSettings(settingsPath, { + ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/agy', + ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed', + ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6-thinking', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5', + }); + + const env = getEffectiveEnvVars('agy', 8317, settingsPath); + expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6-thinking'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6-thinking'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); + }); }); diff --git a/tests/unit/cliproxy/model-catalog-compat.test.ts b/tests/unit/cliproxy/model-catalog-compat.test.ts new file mode 100644 index 00000000..90fad46f --- /dev/null +++ b/tests/unit/cliproxy/model-catalog-compat.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'bun:test'; +import { findModel, supportsThinking } from '../../../src/cliproxy/model-catalog'; + +describe('model-catalog compatibility lookups', () => { + it('finds agy Claude models using dotted major.minor IDs', () => { + const dottedThinking = findModel('agy', 'claude-sonnet-4.5-thinking'); + const dottedNonThinking = findModel('agy', 'claude-sonnet-4.5'); + + expect(dottedThinking?.id).toBe('claude-sonnet-4-5-thinking'); + expect(dottedNonThinking?.id).toBe('claude-sonnet-4-5'); + }); + + it('supports thinking checks for dotted agy model IDs', () => { + expect(supportsThinking('agy', 'claude-opus-4.6-thinking')).toBe(true); + expect(supportsThinking('agy', 'claude-sonnet-4.5')).toBe(false); + }); +}); diff --git a/tests/unit/cliproxy/model-id-normalizer.test.ts b/tests/unit/cliproxy/model-id-normalizer.test.ts new file mode 100644 index 00000000..d13bca6b --- /dev/null +++ b/tests/unit/cliproxy/model-id-normalizer.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'bun:test'; +import { + extractProviderFromPathname, + isAntigravityProvider, + normalizeClaudeDottedMajorMinor, + normalizeClaudeDottedThinkingMajorMinor, + normalizeModelIdForProvider, + normalizeModelIdForRouting, + normalizeModelEnvVarsForProvider, +} from '../../../src/cliproxy/model-id-normalizer'; + +describe('model-id-normalizer', () => { + describe('provider parsing', () => { + it('extracts provider from provider route path', () => { + expect(extractProviderFromPathname('/api/provider/agy/v1/messages')).toBe('agy'); + expect(extractProviderFromPathname('/api/provider/antigravity')).toBe('antigravity'); + expect(extractProviderFromPathname('/v1/messages')).toBeNull(); + }); + + it('detects antigravity provider aliases', () => { + expect(isAntigravityProvider('agy')).toBe(true); + expect(isAntigravityProvider('antigravity')).toBe(true); + expect(isAntigravityProvider('gemini')).toBe(false); + expect(isAntigravityProvider(undefined)).toBe(false); + }); + }); + + describe('model normalization', () => { + it('normalizes dotted Claude major.minor to hyphen format', () => { + expect(normalizeClaudeDottedMajorMinor('claude-sonnet-4.6-thinking')).toBe( + 'claude-sonnet-4-6-thinking' + ); + expect(normalizeClaudeDottedMajorMinor('claude-opus-4.6')).toBe('claude-opus-4-6'); + }); + + it('normalizes only dotted thinking variants for root/composite routing', () => { + expect(normalizeClaudeDottedThinkingMajorMinor('claude-sonnet-4.6-thinking')).toBe( + 'claude-sonnet-4-6-thinking' + ); + expect(normalizeClaudeDottedThinkingMajorMinor('claude-sonnet-4.6')).toBe( + 'claude-sonnet-4.6' + ); + }); + + it('applies provider-aware routing normalization', () => { + expect(normalizeModelIdForRouting('claude-sonnet-4.6-thinking', null)).toBe( + 'claude-sonnet-4-6-thinking' + ); + expect(normalizeModelIdForRouting('claude-sonnet-4.6', null)).toBe('claude-sonnet-4.6'); + expect(normalizeModelIdForRouting('claude-sonnet-4.6', 'agy')).toBe('claude-sonnet-4-6'); + }); + + it('applies provider-only normalization for antigravity', () => { + expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'agy')).toBe( + 'claude-opus-4-6-thinking' + ); + expect(normalizeModelIdForProvider('claude-opus-4.6-thinking', 'gemini')).toBe( + 'claude-opus-4.6-thinking' + ); + }); + }); + + describe('env normalization', () => { + it('normalizes model env vars for antigravity only', () => { + const input: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4.6-thinking', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4.6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4.5', + UNRELATED: 'keep-me', + }; + + const normalized = normalizeModelEnvVarsForProvider(input, 'agy'); + expect(normalized.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6-thinking'); + expect(normalized.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); + expect(normalized.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-6'); + expect(normalized.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5'); + expect(normalized.UNRELATED).toBe('keep-me'); + + const unchanged = normalizeModelEnvVarsForProvider(input, 'gemini'); + expect(unchanged.ANTHROPIC_MODEL).toBe('claude-sonnet-4.6-thinking'); + expect(unchanged.UNRELATED).toBe('keep-me'); + }); + }); +}); diff --git a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts index 03a9bc5f..dd7dd2ac 100644 --- a/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts +++ b/tests/unit/cliproxy/tool-sanitization-proxy-integration.test.ts @@ -184,6 +184,77 @@ describe('ToolSanitizationProxy Integration', () => { } }); + it('normalizes dotted Claude thinking model IDs for root/composite routes', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'claude-sonnet-4.6-thinking', + messages: [{ role: 'user', content: 'test' }], + }), + }); + + expect(lastRequest).not.toBeNull(); + expect((lastRequest!.body as Record).model).toBe( + 'claude-sonnet-4-6-thinking' + ); + } finally { + proxy.stop(); + } + }); + + it('keeps dotted non-thinking model IDs unchanged', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'claude-sonnet-4.5', + messages: [{ role: 'user', content: 'test' }], + }), + }); + + expect(lastRequest).not.toBeNull(); + expect((lastRequest!.body as Record).model).toBe('claude-sonnet-4.5'); + } finally { + proxy.stop(); + } + }); + + it('normalizes dotted Claude major.minor IDs on antigravity provider route', async () => { + const proxy = new ToolSanitizationProxy({ + upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, + }); + const port = await proxy.start(); + + try { + await fetch(`http://127.0.0.1:${port}/api/provider/agy/v1/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + model: 'claude-opus-4.6', + messages: [{ role: 'user', content: 'test' }], + }), + }); + + expect(lastRequest).not.toBeNull(); + expect((lastRequest!.body as Record).model).toBe('claude-opus-4-6'); + } finally { + proxy.stop(); + } + }); + it('preserves other tool properties during sanitization', async () => { const proxy = new ToolSanitizationProxy({ upstreamBaseUrl: `http://127.0.0.1:${mockUpstreamPort}`, From 187c6f7f130d6f425dfcdd00c1a760b63c83afb4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 21 Feb 2026 02:21:32 +0700 Subject: [PATCH 05/27] docs(cliproxy): add normalizer API usage examples --- src/cliproxy/model-id-normalizer.ts | 31 ++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts index 7d8ee86b..9e771780 100644 --- a/src/cliproxy/model-id-normalizer.ts +++ b/src/cliproxy/model-id-normalizer.ts @@ -21,7 +21,13 @@ const CLAUDE_DOTTED_VERSION_REGEX = /claude-(sonnet|opus|haiku)-(\d+)\.(\d+)(?=( const CLAUDE_DOTTED_THINKING_REGEX = /claude-(sonnet|opus|haiku)-(\d+)\.(\d+)-thinking(?=(?:$|-|\[|\(|\/))/gi; -/** Extract provider segment from /api/provider/{provider} paths. */ +/** + * Extract provider segment from `/api/provider/{provider}` request paths. + * + * @example + * extractProviderFromPathname('/api/provider/agy/v1/messages') + * // => 'agy' + */ export function extractProviderFromPathname(pathname: string): string | null { const match = pathname.match(/\/api\/provider\/([^/]+)/i); if (!match?.[1]) return null; @@ -59,6 +65,14 @@ export function normalizeClaudeDottedThinkingMajorMinor(model: string): string { /** * Normalize model ID for a specific provider. * Antigravity requires hyphenated Claude major.minor model IDs. + * + * @example + * normalizeModelIdForProvider('claude-opus-4.6-thinking', 'agy') + * // => 'claude-opus-4-6-thinking' + * + * @example + * normalizeModelIdForProvider('claude-opus-4.6-thinking', 'gemini') + * // => 'claude-opus-4.6-thinking' */ export function normalizeModelIdForProvider(model: string, provider: ProviderLike): string { if (!isAntigravityProvider(provider)) return model; @@ -70,6 +84,14 @@ export function normalizeModelIdForProvider(model: string, provider: ProviderLik * - Antigravity routes: normalize all dotted Claude major.minor forms. * - Root/composite routes: normalize only thinking forms to avoid mutating * valid non-thinking dotted IDs used by other providers. + * + * @example + * normalizeModelIdForRouting('claude-sonnet-4.6-thinking', null) + * // => 'claude-sonnet-4-6-thinking' + * + * @example + * normalizeModelIdForRouting('claude-sonnet-4.6', null) + * // => 'claude-sonnet-4.6' */ export function normalizeModelIdForRouting(model: string, provider: ProviderLike): string { if (isAntigravityProvider(provider)) { @@ -81,6 +103,13 @@ export function normalizeModelIdForRouting(model: string, provider: ProviderLike /** * Normalize model-related env vars for a provider. * Returns original object when no changes are required. + * + * @example + * normalizeModelEnvVarsForProvider( + * { ANTHROPIC_MODEL: 'claude-sonnet-4.6-thinking' }, + * 'agy' + * ) + * // => { ANTHROPIC_MODEL: 'claude-sonnet-4-6-thinking' } */ export function normalizeModelEnvVarsForProvider( envVars: NodeJS.ProcessEnv, From 343ec959fc8ea26c3e2b75d88613627cdad3b223 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sat, 21 Feb 2026 10:32:19 +0700 Subject: [PATCH 06/27] fix(core): resolve edge cases and hardcoded drift --- src/cliproxy/provider-capabilities.ts | 30 +++- src/commands/api-command.ts | 135 +++++++++++++++--- src/commands/arg-extractor.ts | 54 ++++++- src/commands/cliproxy/help-subcommand.ts | 3 +- src/commands/cliproxy/index.ts | 40 ++---- src/commands/cliproxy/quota-subcommand.ts | 126 ++++++++++------ src/commands/config-image-analysis-command.ts | 33 ++++- src/commands/persist-command.ts | 61 ++++++-- src/web-server/jsonl-parser.ts | 82 +++++++---- src/web-server/routes/persist-routes.ts | 83 ++++++----- src/web-server/routes/route-helpers.ts | 63 +++++++- .../cliproxy/extended-context-config.test.ts | 12 ++ .../cliproxy/provider-capabilities.test.ts | 26 ++++ tests/unit/commands/api-command-args.test.ts | 38 +++++ tests/unit/commands/arg-extractor.test.ts | 57 +++++++- .../config-image-analysis-command.test.ts | 29 +++- tests/unit/jsonl-parser.test.ts | 103 +++++++++++++ tests/unit/web-server/persist-routes.test.js | 53 ++----- tests/unit/web-server/route-helpers.test.ts | 94 ++++++++++++ 19 files changed, 894 insertions(+), 228 deletions(-) create mode 100644 tests/unit/commands/api-command-args.test.ts create mode 100644 tests/unit/web-server/route-helpers.test.ts diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index c582cfb7..02c5f3ea 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -140,6 +140,25 @@ export const CLIPROXY_PROVIDER_IDS = Object.freeze( Object.keys(PROVIDER_CAPABILITIES) as CLIProxyProvider[] ); +/** Providers currently supported by quota status fetchers. */ +export const QUOTA_SUPPORTED_PROVIDER_IDS = Object.freeze([ + 'agy', + 'codex', + 'gemini', + 'ghcp', +] as const); +export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDER_IDS)[number]; +const QUOTA_SUPPORTED_PROVIDER_SET = new Set(QUOTA_SUPPORTED_PROVIDER_IDS); + +export const QUOTA_PROVIDER_OPTION_VALUES = Object.freeze( + [ + ...QUOTA_SUPPORTED_PROVIDER_IDS, + ...QUOTA_SUPPORTED_PROVIDER_IDS.flatMap((provider) => PROVIDER_CAPABILITIES[provider].aliases), + 'all', + ].filter((value, index, values) => values.indexOf(value) === index) +); +export const QUOTA_PROVIDER_HELP_TEXT = QUOTA_PROVIDER_OPTION_VALUES.join(', '); + export function buildProviderMap( valueFor: (provider: CLIProxyProvider) => T ): Record { @@ -243,6 +262,15 @@ export function getProviderTokenTypeValues(provider: CLIProxyProvider): readonly } export function mapExternalProviderName(providerName: string): CLIProxyProvider | null { - const normalized = providerName.toLowerCase(); + const normalized = providerName.trim().toLowerCase(); + if (!normalized) { + return null; + } return PROVIDER_ALIAS_MAP.get(normalized) ?? null; } + +export function isQuotaSupportedProvider( + provider: CLIProxyProvider +): provider is QuotaSupportedProvider { + return QUOTA_SUPPORTED_PROVIDER_SET.has(provider as QuotaSupportedProvider); +} diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index fd15cdbd..495617d0 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -53,8 +53,14 @@ interface ApiCommandArgs { preset?: string; force?: boolean; yes?: boolean; + errors: string[]; } +const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y'] as const; +const API_VALUE_FLAGS = ['--base-url', '--api-key', '--model', '--preset'] as const; +const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS]; +const API_VALUE_FLAG_SET = new Set(API_VALUE_FLAGS); + function sanitizeHelpText(value: string): string { return value .replace(/[\r\n\t]+/g, ' ') @@ -71,39 +77,129 @@ function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string { return ` ${color(paddedId, 'command')} ${presetName} - ${presetDescription}`; } +function applyRepeatedOption( + args: string[], + flags: readonly string[], + onValue: (value: string) => void, + onMissing: () => void +): string[] { + let remaining = [...args]; + + while (true) { + const extracted = extractOption(remaining, flags, { + allowDashValue: true, + knownFlags: API_KNOWN_FLAGS, + }); + if (!extracted.found) { + return remaining; + } + + if (extracted.missingValue || !extracted.value) { + onMissing(); + } else { + onValue(extracted.value); + } + + remaining = extracted.remainingArgs; + } +} + +function extractPositionalArgs(args: string[]): string[] { + const positionals: string[] = []; + + for (let i = 0; i < args.length; i++) { + const token = args[i]; + if (token === '--') { + positionals.push(...args.slice(i + 1)); + break; + } + + if (token.startsWith('-')) { + if (!token.includes('=') && API_VALUE_FLAG_SET.has(token)) { + const next = args[i + 1]; + if (next && !next.startsWith('-')) { + i++; + } + } + continue; + } + + positionals.push(token); + } + + return positionals; +} + /** Parse command line arguments for api commands */ -function parseArgs(args: string[]): ApiCommandArgs { +export function parseApiCommandArgs(args: string[]): ApiCommandArgs { const result: ApiCommandArgs = { force: hasAnyFlag(args, ['--force']), yes: hasAnyFlag(args, ['--yes', '-y']), + errors: [], }; let remaining = [...args]; - const baseUrl = extractOption(remaining, ['--base-url']); - if (baseUrl.value) result.baseUrl = baseUrl.value; - remaining = baseUrl.remainingArgs; + remaining = applyRepeatedOption( + remaining, + ['--base-url'], + (value) => { + result.baseUrl = value; + }, + () => { + result.errors.push('Missing value for --base-url'); + } + ); - const apiKey = extractOption(remaining, ['--api-key']); - if (apiKey.value) result.apiKey = apiKey.value; - remaining = apiKey.remainingArgs; + remaining = applyRepeatedOption( + remaining, + ['--api-key'], + (value) => { + result.apiKey = value; + }, + () => { + result.errors.push('Missing value for --api-key'); + } + ); - const model = extractOption(remaining, ['--model']); - if (model.value) result.model = model.value; - remaining = model.remainingArgs; + remaining = applyRepeatedOption( + remaining, + ['--model'], + (value) => { + result.model = value; + }, + () => { + result.errors.push('Missing value for --model'); + } + ); - const preset = extractOption(remaining, ['--preset']); - if (preset.value) result.preset = preset.value; - remaining = preset.remainingArgs; + remaining = applyRepeatedOption( + remaining, + ['--preset'], + (value) => { + result.preset = value; + }, + () => { + result.errors.push('Missing value for --preset'); + } + ); - result.name = remaining.find((arg) => !arg.startsWith('-')); + const positionalArgs = extractPositionalArgs(remaining); + result.name = positionalArgs[0]; return result; } /** Handle 'ccs api create' command */ async function handleCreate(args: string[]): Promise { await initUI(); - const parsedArgs = parseArgs(args); + const parsedArgs = parseApiCommandArgs(args); + + if (parsedArgs.errors.length > 0) { + parsedArgs.errors.forEach((errorMessage) => { + console.log(fail(errorMessage)); + }); + process.exit(1); + } console.log(header('Create API Profile')); console.log(''); @@ -389,7 +485,14 @@ async function handleList(): Promise { /** Handle 'ccs api remove' command */ async function handleRemove(args: string[]): Promise { await initUI(); - const parsedArgs = parseArgs(args); + const parsedArgs = parseApiCommandArgs(args); + + if (parsedArgs.errors.length > 0) { + parsedArgs.errors.forEach((errorMessage) => { + console.log(fail(errorMessage)); + }); + process.exit(1); + } const apis = getApiProfileNames(); diff --git a/src/commands/arg-extractor.ts b/src/commands/arg-extractor.ts index a4a45dcd..6c2709c3 100644 --- a/src/commands/arg-extractor.ts +++ b/src/commands/arg-extractor.ts @@ -9,17 +9,43 @@ export interface ExtractedOption { remainingArgs: string[]; } +export interface ExtractOptionOptions { + /** + * Allow values that start with "-" when they are not recognized flags. + * Useful for model IDs or other arbitrary strings. + */ + allowDashValue?: boolean; + /** + * Known flags for the current command. Used with allowDashValue to avoid + * treating a real flag token as a value. + */ + knownFlags?: readonly string[]; +} + function findInlineOption(arg: string, flag: string): string | undefined { const prefix = `${flag}=`; return arg.startsWith(prefix) ? arg.slice(prefix.length) : undefined; } +function isKnownFlagToken(token: string, knownFlags: readonly string[] | undefined): boolean { + if (!knownFlags || knownFlags.length === 0) { + return false; + } + + return knownFlags.some((flag) => token === flag || token.startsWith(`${flag}=`)); +} + /** * Extract a single-value option and remove it from args. * Supports `--flag value` and `--flag=value` forms. */ -export function extractOption(args: string[], flags: readonly string[]): ExtractedOption { +export function extractOption( + args: string[], + flags: readonly string[], + options: ExtractOptionOptions = {} +): ExtractedOption { const remaining = [...args]; + const allowDashValue = options.allowDashValue ?? false; for (let i = 0; i < remaining.length; i++) { const token = remaining[i]; @@ -27,7 +53,14 @@ export function extractOption(args: string[], flags: readonly string[]): Extract for (const flag of flags) { if (token === flag) { const next = remaining[i + 1]; - if (!next || next.startsWith('-')) { + if (!next) { + remaining.splice(i, 1); + return { found: true, missingValue: true, remainingArgs: remaining }; + } + + const nextLooksLikeFlag = next.startsWith('-'); + const nextIsKnownFlag = isKnownFlagToken(next, options.knownFlags); + if (nextLooksLikeFlag && (!allowDashValue || nextIsKnownFlag)) { remaining.splice(i, 1); return { found: true, missingValue: true, remainingArgs: remaining }; } @@ -62,5 +95,20 @@ export function extractOption(args: string[], flags: readonly string[]): Extract /** Returns true if any of the provided boolean flags are present. */ export function hasAnyFlag(args: string[], flags: readonly string[]): boolean { - return args.some((arg) => flags.includes(arg)); + const truthyValues = new Set(['1', 'true', 'yes', 'on']); + return args.some((arg) => + flags.some((flag) => { + if (arg === flag) { + return true; + } + + const prefix = `${flag}=`; + if (!arg.startsWith(prefix)) { + return false; + } + + const value = arg.slice(prefix.length).trim().toLowerCase(); + return truthyValues.has(value); + }) + ); } diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 7f3219d9..9d4e41dd 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -11,6 +11,7 @@ import { getFallbackVersion, BACKEND_CONFIG, } from '../../cliproxy/platform-detector'; +import { QUOTA_PROVIDER_HELP_TEXT } from '../../cliproxy/provider-capabilities'; export async function showHelp(): Promise { await initUI(); @@ -55,7 +56,7 @@ export async function showHelp(): Promise { ['pause ', 'Pause account (skip in rotation)'], ['resume ', 'Resume paused account'], ['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'], - ['quota --provider ', 'Filter by provider (agy|codex|gemini|ghcp)'], + ['quota --provider ', `Filter by provider (${QUOTA_PROVIDER_HELP_TEXT})`], ], ], [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 0174e40c..b1735bc5 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -7,7 +7,12 @@ import { CLIProxyBackend } from '../../cliproxy/types'; import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector'; -import { CLIPROXY_PROVIDER_IDS } from '../../cliproxy/provider-capabilities'; +import { + type QuotaSupportedProvider, + QUOTA_PROVIDER_HELP_TEXT, + mapExternalProviderName, + isQuotaSupportedProvider, +} from '../../cliproxy/provider-capabilities'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { handleSync } from '../cliproxy-sync-handler'; import { extractOption, hasAnyFlag } from '../arg-extractor'; @@ -75,38 +80,21 @@ function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend { /** * Parse --provider flag from args for quota command * Returns the provider filter value and remaining args - * Accepts: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all + * Accepts canonical + aliases from quota-supported providers, and `all` */ -type QuotaProvider = 'agy' | 'codex' | 'gemini' | 'ghcp'; -type QuotaProviderFilter = QuotaProvider | 'all'; - -const PROVIDER_ARG_HELP_TEXT = 'agy, codex, gemini, gemini-cli, ghcp, github-copilot, all'; - -const QUOTA_PROVIDER_ALIAS_MAP: Readonly> = { - 'gemini-cli': 'gemini', - 'github-copilot': 'ghcp', -}; - -const QUOTA_PROVIDER_IDS = Object.freeze( - CLIPROXY_PROVIDER_IDS.filter( - (provider): provider is QuotaProvider => - provider === 'agy' || provider === 'codex' || provider === 'gemini' || provider === 'ghcp' - ) -); - -const QUOTA_PROVIDER_SET = new Set(QUOTA_PROVIDER_IDS); +type QuotaProviderFilter = QuotaSupportedProvider | 'all'; function normalizeQuotaProvider(value: string): QuotaProviderFilter | null { if (value === 'all') { return 'all'; } - const normalized = QUOTA_PROVIDER_ALIAS_MAP[value] ?? value; - if (!QUOTA_PROVIDER_SET.has(normalized as QuotaProvider)) { + const canonicalProvider = mapExternalProviderName(value); + if (!canonicalProvider || !isQuotaSupportedProvider(canonicalProvider)) { return null; } - return normalized as QuotaProvider; + return canonicalProvider; } function parseProviderArg(args: string[]): { @@ -119,14 +107,16 @@ function parseProviderArg(args: string[]): { } if (extracted.missingValue || !extracted.value) { - console.error(`Warning: --provider requires a value. Valid options: ${PROVIDER_ARG_HELP_TEXT}`); + console.error( + `Warning: --provider requires a value. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}` + ); return { provider: 'all', remainingArgs: extracted.remainingArgs }; } const value = extracted.value.toLowerCase(); const normalized = normalizeQuotaProvider(value); if (!normalized) { - console.error(`Invalid provider '${value}'. Valid options: ${PROVIDER_ARG_HELP_TEXT}`); + console.error(`Invalid provider '${value}'. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`); return { provider: 'all', remainingArgs: extracted.remainingArgs }; } return { diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index d3445377..a6ef643a 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -27,6 +27,10 @@ import type { } from '../../cliproxy/quota-types'; import { isOnCooldown } from '../../cliproxy/quota-manager'; import { CLIProxyProvider } from '../../cliproxy/types'; +import { + QUOTA_SUPPORTED_PROVIDER_IDS, + type QuotaSupportedProvider, +} from '../../cliproxy/provider-capabilities'; import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui'; interface CliproxyProfileArgs { @@ -481,65 +485,99 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes } } +interface QuotaProviderRuntime { + fetch: (verbose: boolean) => Promise; + hasData: (result: unknown) => boolean; + render: (result: unknown) => void; + emptyTitle: string; + emptyMessage: string; + authCommand: string; +} + +const QUOTA_PROVIDER_RUNTIME: Record = { + agy: { + fetch: (verbose) => fetchAllProviderQuotas('agy', verbose), + hasData: (result) => + (result as Awaited>).accounts.length > 0, + render: (result) => + displayAntigravityQuotaSection(result as Awaited>), + emptyTitle: 'Antigravity (0 accounts)', + emptyMessage: 'No Antigravity accounts configured', + authCommand: 'ccs agy --auth', + }, + codex: { + fetch: (verbose) => fetchAllCodexQuotas(verbose), + hasData: (result) => (result as { account: string; quota: CodexQuotaResult }[]).length > 0, + render: (result) => + displayCodexQuotaSection(result as { account: string; quota: CodexQuotaResult }[]), + emptyTitle: 'Codex (0 accounts)', + emptyMessage: 'No Codex accounts configured', + authCommand: 'ccs codex --auth', + }, + gemini: { + fetch: (verbose) => fetchAllGeminiCliQuotas(verbose), + hasData: (result) => (result as { account: string; quota: GeminiCliQuotaResult }[]).length > 0, + render: (result) => + displayGeminiCliQuotaSection(result as { account: string; quota: GeminiCliQuotaResult }[]), + emptyTitle: 'Gemini CLI (0 accounts)', + emptyMessage: 'No Gemini CLI accounts configured', + authCommand: 'ccs gemini --auth', + }, + ghcp: { + fetch: (verbose) => fetchAllGhcpQuotas(verbose), + hasData: (result) => (result as { account: string; quota: GhcpQuotaResult }[]).length > 0, + render: (result) => + displayGhcpQuotaSection(result as { account: string; quota: GhcpQuotaResult }[]), + emptyTitle: 'GitHub Copilot (0 accounts)', + emptyMessage: 'No GitHub Copilot accounts configured', + authCommand: 'ccs ghcp --auth', + }, +}; + export async function handleQuotaStatus( verbose = false, - providerFilter: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all' = 'all' + providerFilter: QuotaSupportedProvider | 'all' = 'all' ): Promise { await initUI(); console.log(header('Quota Status')); console.log(''); - const shouldFetch = { - agy: providerFilter === 'all' || providerFilter === 'agy', - codex: providerFilter === 'all' || providerFilter === 'codex', - gemini: providerFilter === 'all' || providerFilter === 'gemini', - ghcp: providerFilter === 'all' || providerFilter === 'ghcp', - }; + const requestedProviders = new Set( + providerFilter === 'all' ? QUOTA_SUPPORTED_PROVIDER_IDS : [providerFilter] + ); + const shouldFetch = (provider: QuotaSupportedProvider): boolean => + requestedProviders.has(provider); console.log(dim('Fetching quotas...')); - const [agyResults, codexResults, geminiResults, ghcpResults] = await Promise.all([ - shouldFetch.agy ? fetchAllProviderQuotas('agy', verbose) : null, - shouldFetch.codex ? fetchAllCodexQuotas(verbose) : null, - shouldFetch.gemini ? fetchAllGeminiCliQuotas(verbose) : null, - shouldFetch.ghcp ? fetchAllGhcpQuotas(verbose) : null, - ]); + const providerResults = new Map( + await Promise.all( + QUOTA_SUPPORTED_PROVIDER_IDS.map(async (provider) => { + if (!shouldFetch(provider)) { + return [provider, null] as const; + } + return [provider, await QUOTA_PROVIDER_RUNTIME[provider].fetch(verbose)] as const; + }) + ) + ); console.log(''); - if (agyResults && agyResults.accounts.length > 0) { - displayAntigravityQuotaSection(agyResults); - } else if (shouldFetch.agy) { - console.log(subheader('Antigravity (0 accounts)')); - console.log(info('No Antigravity accounts configured')); - console.log(` Run: ${color('ccs agy --auth', 'command')} to authenticate`); - console.log(''); - } + for (const provider of QUOTA_SUPPORTED_PROVIDER_IDS) { + if (!shouldFetch(provider)) { + continue; + } - if (codexResults && codexResults.length > 0) { - displayCodexQuotaSection(codexResults); - } else if (shouldFetch.codex) { - console.log(subheader('Codex (0 accounts)')); - console.log(info('No Codex accounts configured')); - console.log(` Run: ${color('ccs codex --auth', 'command')} to authenticate`); - console.log(''); - } + const runtime = QUOTA_PROVIDER_RUNTIME[provider]; + const result = providerResults.get(provider) ?? null; + if (result !== null && runtime.hasData(result)) { + runtime.render(result); + continue; + } - if (geminiResults && geminiResults.length > 0) { - displayGeminiCliQuotaSection(geminiResults); - } else if (shouldFetch.gemini) { - console.log(subheader('Gemini CLI (0 accounts)')); - console.log(info('No Gemini CLI accounts configured')); - console.log(` Run: ${color('ccs gemini --auth', 'command')} to authenticate`); - console.log(''); - } - - if (ghcpResults && ghcpResults.length > 0) { - displayGhcpQuotaSection(ghcpResults); - } else if (shouldFetch.ghcp) { - console.log(subheader('GitHub Copilot (0 accounts)')); - console.log(info('No GitHub Copilot accounts configured')); - console.log(` Run: ${color('ccs ghcp --auth', 'command')} to authenticate`); + console.log(subheader(runtime.emptyTitle)); + console.log(info(runtime.emptyMessage)); + console.log(` Run: ${color(runtime.authCommand, 'command')} to authenticate`); console.log(''); } } diff --git a/src/commands/config-image-analysis-command.ts b/src/commands/config-image-analysis-command.ts index 93c44602..41ec5df5 100644 --- a/src/commands/config-image-analysis-command.ts +++ b/src/commands/config-image-analysis-command.ts @@ -12,7 +12,11 @@ import { loadOrCreateUnifiedConfig, } from '../config/unified-config-loader'; import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../config/unified-config-types'; -import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; +import { + CLIPROXY_PROVIDER_IDS, + PROVIDER_CAPABILITIES, + mapExternalProviderName, +} from '../cliproxy/provider-capabilities'; import { extractOption, hasAnyFlag } from './arg-extractor'; interface ImageAnalysisCommandOptions { @@ -20,9 +24,16 @@ interface ImageAnalysisCommandOptions { disable?: boolean; timeout?: number; setModel?: { provider: string; model: string }; + setModelError?: string; help?: boolean; } +const IMAGE_ANALYSIS_PROVIDER_ALIASES = Object.freeze( + CLIPROXY_PROVIDER_IDS.flatMap((provider) => PROVIDER_CAPABILITIES[provider].aliases).filter( + (alias, index, aliases) => aliases.indexOf(alias) === index + ) +); + function parseArgs(args: string[]): ImageAnalysisCommandOptions { const options: ImageAnalysisCommandOptions = { enable: hasAnyFlag(args, ['--enable']), @@ -46,6 +57,8 @@ function parseArgs(args: string[]): ImageAnalysisCommandOptions { const model = args[setModelIdx + 2]; if (provider && model && !provider.startsWith('-') && !model.startsWith('-')) { options.setModel = { provider, model }; + } else { + options.setModelError = '--set-model requires '; } } @@ -73,7 +86,10 @@ function showHelp(): void { console.log(''); console.log(subheader('Provider Models:')); - console.log(` ${dim('Providers with vision support: agy, gemini, codex, kiro, ghcp, claude')}`); + console.log(` ${dim(`Valid providers: ${CLIPROXY_PROVIDER_IDS.join(', ')}`)}`); + if (IMAGE_ANALYSIS_PROVIDER_ALIASES.length > 0) { + console.log(` ${dim(`Aliases accepted: ${IMAGE_ANALYSIS_PROVIDER_ALIASES.join(', ')}`)}`); + } console.log(` ${dim('Default model: gemini-2.5-flash (most providers)')}`); console.log(''); @@ -160,6 +176,11 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< return; } + if (options.setModelError) { + console.error(fail(options.setModelError)); + process.exit(1); + } + // Validate conflicting flags (Edge case #2: --enable + --disable conflict) if (options.enable && options.disable) { console.error(fail('Cannot use --enable and --disable together')); @@ -188,9 +209,9 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< if (options.setModel) { const validProviders = [...CLIPROXY_PROVIDER_IDS]; - if ( - !validProviders.includes(options.setModel.provider as (typeof CLIPROXY_PROVIDER_IDS)[number]) - ) { + const normalizedProviderInput = options.setModel.provider.trim().toLowerCase(); + const canonicalProvider = mapExternalProviderName(normalizedProviderInput); + if (!canonicalProvider) { console.error(fail(`Invalid provider: ${options.setModel.provider}`)); console.error(info(`Valid providers: ${validProviders.join(', ')}`)); process.exit(1); @@ -203,7 +224,7 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise< } imageConfig.provider_models = { ...imageConfig.provider_models, - [options.setModel.provider]: model, + [canonicalProvider]: model, }; hasChanges = true; } diff --git a/src/commands/persist-command.ts b/src/commands/persist-command.ts index eba20cc5..b02e41ed 100644 --- a/src/commands/persist-command.ts +++ b/src/commands/persist-command.ts @@ -10,6 +10,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import * as os from 'os'; import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../utils/ui'; import { InteractivePrompt } from '../utils/prompt'; import ProfileDetector, { @@ -58,7 +59,14 @@ function parseArgs(args: string[]): PersistCommandArgs { } function formatDisplayPath(filePath: string): string { + const defaultClaudeDir = path.join(os.homedir(), '.claude'); const claudeDir = getClaudeConfigDir(); + + // Keep real path when user overrides Claude directory. + if (path.resolve(claudeDir) !== path.resolve(defaultClaudeDir)) { + return filePath; + } + if (filePath === claudeDir) { return '~/.claude'; } @@ -71,6 +79,10 @@ function formatDisplayPath(filePath: string): string { return filePath; } +function getClaudeSettingsDisplayPath(): string { + return formatDisplayPath(getClaudeSettingsPath()); +} + /** Read existing Claude settings.json with validation */ function readClaudeSettings(): Record { const settingsPath = getClaudeSettingsPath(); @@ -175,6 +187,25 @@ interface BackupFile { date: Date; } +function parseBackupTimestamp(timestamp: string): Date | null { + const year = parseInt(timestamp.slice(0, 4), 10); + const month = parseInt(timestamp.slice(4, 6), 10); + const day = parseInt(timestamp.slice(6, 8), 10); + const hour = parseInt(timestamp.slice(9, 11), 10); + const minute = parseInt(timestamp.slice(11, 13), 10); + const second = parseInt(timestamp.slice(13, 15), 10); + const date = new Date(year, month - 1, day, hour, minute, second); + + if (date.getFullYear() !== year) return null; + if (date.getMonth() !== month - 1) return null; + if (date.getDate() !== day) return null; + if (date.getHours() !== hour) return null; + if (date.getMinutes() !== minute) return null; + if (date.getSeconds() !== second) return null; + + return date; +} + /** Get all backup files sorted by date (newest first) */ function getBackupFiles(): BackupFile[] { const settingsPath = getClaudeSettingsPath(); @@ -190,17 +221,12 @@ function getBackupFiles(): BackupFile[] { const match = f.match(backupPattern); if (!match) return null; const timestamp = match[1]; - // Parse YYYYMMDD_HHMMSS - const year = parseInt(timestamp.slice(0, 4)); - const month = parseInt(timestamp.slice(4, 6)) - 1; - const day = parseInt(timestamp.slice(6, 8)); - const hour = parseInt(timestamp.slice(9, 11)); - const min = parseInt(timestamp.slice(11, 13)); - const sec = parseInt(timestamp.slice(13, 15)); + const date = parseBackupTimestamp(timestamp); + if (!date) return null; return { path: path.join(dir, f), timestamp, - date: new Date(year, month, day, hour, min, sec), + date, }; }) .filter((f): f is BackupFile => f !== null) @@ -328,7 +354,7 @@ async function handleRestore(timestamp: string | boolean, yes: boolean): Promise console.log(`Backup: ${color(backup.timestamp, 'command')}`); console.log(`Date: ${backup.date.toLocaleString()}`); console.log(''); - console.log(warn('This will replace ~/.claude/settings.json')); + console.log(warn(`This will replace ${getClaudeSettingsDisplayPath()}`)); console.log(''); if (!yes) { const proceed = await InteractivePrompt.confirm('Proceed with restore?', { default: false }); @@ -346,6 +372,11 @@ async function handleRestore(timestamp: string | boolean, yes: boolean): Promise process.exit(1); } } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + console.log(fail('Backup was deleted during restore')); + process.exit(1); + } console.log(fail(`Backup file is corrupted: ${(error as Error).message}`)); process.exit(1); } @@ -377,7 +408,7 @@ async function showHelp(): Promise { console.log(''); console.log(subheader('Description')); console.log(" Writes a profile's environment variables directly to"); - console.log(' ~/.claude/settings.json for native Claude Code usage.'); + console.log(` ${getClaudeSettingsDisplayPath()} for native Claude Code usage.`); console.log(''); console.log(' This allows Claude Code to use the profile without CCS,'); console.log(' enabling compatibility with IDEs and extensions.'); @@ -418,7 +449,9 @@ async function showHelp(): Promise { console.log(subheader('Notes')); console.log(' [i] CLIProxy profiles require the proxy to be running.'); console.log(' [i] Copilot profiles require copilot-api daemon.'); - console.log(' [i] Backups are saved as ~/.claude/settings.json.backup.YYYYMMDD_HHMMSS'); + console.log( + ` [i] Backups are saved as ${getClaudeSettingsDisplayPath()}.backup.YYYYMMDD_HHMMSS` + ); console.log(''); } @@ -478,7 +511,7 @@ export async function handlePersistCommand(args: string[]): Promise { console.log(''); console.log(`Profile type: ${color(resolved.profileType, 'command')}`); console.log(''); - console.log('The following env vars will be written to ~/.claude/settings.json:'); + console.log(`The following env vars will be written to ${getClaudeSettingsDisplayPath()}:`); console.log(''); // Display env vars (mask sensitive values) const envKeys = Object.keys(resolved.env); @@ -502,7 +535,7 @@ export async function handlePersistCommand(args: string[]): Promise { console.log(''); } // Warning about modification - console.log(warn('This will modify ~/.claude/settings.json')); + console.log(warn(`This will modify ${getClaudeSettingsDisplayPath()}`)); console.log(dim(' Existing hooks and other settings will be preserved.')); console.log(''); // Check if settings.json exists for backup @@ -570,7 +603,7 @@ export async function handlePersistCommand(args: string[]): Promise { process.exit(1); } console.log(''); - console.log(ok(`Profile '${parsedArgs.profile}' written to ~/.claude/settings.json`)); + console.log(ok(`Profile '${parsedArgs.profile}' written to ${getClaudeSettingsDisplayPath()}`)); console.log(''); console.log(info('Claude Code will now use this profile by default.')); console.log(dim(' To revert, restore the backup or edit settings.json manually.')); diff --git a/src/web-server/jsonl-parser.ts b/src/web-server/jsonl-parser.ts index f0f4a39f..b6d2f74b 100644 --- a/src/web-server/jsonl-parser.ts +++ b/src/web-server/jsonl-parser.ts @@ -63,6 +63,9 @@ export interface ParserOptions { projectsDir?: string; } +const DEFAULT_SCAN_CONCURRENCY = 10; +const MAX_SCAN_CONCURRENCY = 64; + // ============================================================================ // CORE PARSING FUNCTIONS // ============================================================================ @@ -71,6 +74,14 @@ export interface ParserOptions { * Parse a single JSONL line into RawUsageEntry if valid * Returns null for non-assistant entries or entries without usage data */ +function toNonNegativeNumber(value: unknown): number { + const numeric = typeof value === 'number' ? value : Number(value); + if (!Number.isFinite(numeric) || numeric < 0) { + return 0; + } + return numeric; +} + export function parseUsageEntry(line: string, projectPath: string): RawUsageEntry | null { // Strip UTF-8 BOM if present (can occur on first line of some files) const cleanLine = line.replace(/^\uFEFF/, '').trim(); @@ -88,10 +99,10 @@ export function parseUsageEntry(line: string, projectPath: string): RawUsageEntr const assistant = entry as JsonlAssistantEntry; return { - inputTokens: usage.input_tokens || 0, - outputTokens: usage.output_tokens || 0, - cacheCreationTokens: usage.cache_creation_input_tokens || 0, - cacheReadTokens: usage.cache_read_input_tokens || 0, + inputTokens: toNonNegativeNumber(usage.input_tokens), + outputTokens: toNonNegativeNumber(usage.output_tokens), + cacheCreationTokens: toNonNegativeNumber(usage.cache_creation_input_tokens), + cacheReadTokens: toNonNegativeNumber(usage.cache_read_input_tokens), model: assistant.message.model, sessionId: assistant.sessionId || '', timestamp: assistant.timestamp || new Date().toISOString(), @@ -118,38 +129,48 @@ export async function parseJsonlFile( ): Promise { const entries: RawUsageEntry[] = []; - if (!fs.existsSync(filePath)) { - return entries; - } + let fileStream: fs.ReadStream | null = null; + let rl: readline.Interface | null = null; + try { + fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); + rl = readline.createInterface({ + input: fileStream, + crlfDelay: Infinity, + }); - const fileStream = fs.createReadStream(filePath, { encoding: 'utf8' }); - const rl = readline.createInterface({ - input: fileStream, - crlfDelay: Infinity, - }); - - for await (const line of rl) { - const entry = parseUsageEntry(line, projectPath); - if (entry) { - entries.push(entry); + for await (const line of rl) { + const entry = parseUsageEntry(line, projectPath); + if (entry) { + entries.push(entry); + } } + } catch { + // File read/stream error - return whatever was parsed so far + } finally { + rl?.close(); + fileStream?.destroy(); } return entries; } +function decodeProjectPath(projectDir: string): string { + const raw = path.basename(projectDir).replace(/-/g, '/'); + const safeSegments = raw + .split('/') + .filter((segment) => segment && segment !== '.' && segment !== '..'); + + return `/${safeSegments.join('/')}`; +} + /** * Parse all JSONL files in a single project directory */ export async function parseProjectDirectory(projectDir: string): Promise { const entries: RawUsageEntry[] = []; - if (!fs.existsSync(projectDir)) { - return entries; - } - // Get project path from directory name (e.g., "-home-kai-project" -> "/home/kai/project") - const projectPath = path.basename(projectDir).replace(/-/g, '/'); + const projectPath = decodeProjectPath(projectDir); try { const files = await fs.promises.readdir(projectDir); @@ -185,10 +206,6 @@ export function getDefaultProjectsDir(): string { export function findProjectDirectories(projectsDir?: string): string[] { const dir = projectsDir || getDefaultProjectsDir(); - if (!fs.existsSync(dir)) { - return []; - } - try { const entries = fs.readdirSync(dir, { withFileTypes: true }); return entries @@ -207,7 +224,14 @@ export function findProjectDirectories(projectsDir?: string): string[] { * @returns All parsed usage entries from all projects */ export async function scanProjectsDirectory(options: ParserOptions = {}): Promise { - const { concurrency = 10, projectsDir } = options; + const requestedConcurrency = options.concurrency; + const concurrency = + typeof requestedConcurrency === 'number' && + Number.isInteger(requestedConcurrency) && + requestedConcurrency > 0 + ? Math.min(requestedConcurrency, MAX_SCAN_CONCURRENCY) + : DEFAULT_SCAN_CONCURRENCY; + const { projectsDir } = options; const allEntries: RawUsageEntry[] = []; const projectDirs = findProjectDirectories(projectsDir); @@ -230,8 +254,8 @@ export async function scanProjectsDirectory(options: ParserOptions = {}): Promis if (options.minDate) { const minTime = options.minDate.getTime(); return allEntries.filter((entry) => { - const entryTime = new Date(entry.timestamp).getTime(); - return entryTime >= minTime; + const entryTime = Date.parse(entry.timestamp); + return Number.isFinite(entryTime) && entryTime >= minTime; }); } diff --git a/src/web-server/routes/persist-routes.ts b/src/web-server/routes/persist-routes.ts index 76f0cb60..ebc9ec3a 100644 --- a/src/web-server/routes/persist-routes.ts +++ b/src/web-server/routes/persist-routes.ts @@ -28,39 +28,28 @@ interface BackupFile { /** * Async mutex for restore operations - prevents race conditions * - * Design: Uses a Promise queue pattern for atomic lock acquisition. - * When the mutex is locked, subsequent callers are added to a queue - * and immediately receive `false` when released, signaling they should - * return a 409 Conflict rather than wait. This prevents request pileup - * while ensuring only one restore can execute at a time. + * Design: Fast-fail lock. + * If a restore is already running, callers immediately get `false` + * and the route returns HTTP 409. This avoids request pileup. */ class RestoreMutex { private locked = false; - private queue: Array<() => void> = []; /** * Attempt to acquire the mutex - * @returns true if acquired, false if already locked (queued request) + * @returns true if acquired, false if already locked */ async acquire(): Promise { if (this.locked) { - // Already locked - add to queue and wait - return new Promise((resolve) => { - this.queue.push(() => resolve(false)); // Return false = was queued, reject - }); + return false; } this.locked = true; return true; } - /** Release the mutex, signaling next queued request (if any) to fail */ + /** Release the mutex */ release(): void { - const next = this.queue.shift(); - if (next) { - next(); // Signal queued request to fail - } else { - this.locked = false; - } + this.locked = false; } } @@ -76,6 +65,25 @@ function isSymlink(filePath: string): boolean { } } +function parseBackupTimestamp(timestamp: string): Date | null { + const year = parseInt(timestamp.slice(0, 4), 10); + const month = parseInt(timestamp.slice(4, 6), 10); + const day = parseInt(timestamp.slice(6, 8), 10); + const hour = parseInt(timestamp.slice(9, 11), 10); + const minute = parseInt(timestamp.slice(11, 13), 10); + const second = parseInt(timestamp.slice(13, 15), 10); + const date = new Date(year, month - 1, day, hour, minute, second); + + if (date.getFullYear() !== year) return null; + if (date.getMonth() !== month - 1) return null; + if (date.getDate() !== day) return null; + if (date.getHours() !== hour) return null; + if (date.getMinutes() !== minute) return null; + if (date.getSeconds() !== second) return null; + + return date; +} + /** Get all backup files sorted by date (newest first) */ function getBackupFiles(): BackupFile[] { const settingsPath = getClaudeSettingsPath(); @@ -91,16 +99,12 @@ function getBackupFiles(): BackupFile[] { const match = f.match(backupPattern); if (!match) return null; const timestamp = match[1]; - const year = parseInt(timestamp.slice(0, 4)); - const month = parseInt(timestamp.slice(4, 6)) - 1; - const day = parseInt(timestamp.slice(6, 8)); - const hour = parseInt(timestamp.slice(9, 11)); - const min = parseInt(timestamp.slice(11, 13)); - const sec = parseInt(timestamp.slice(13, 15)); + const date = parseBackupTimestamp(timestamp); + if (!date) return null; return { path: path.join(dir, f), timestamp, - date: new Date(year, month, day, hour, min, sec), + date, }; }) .filter((f): f is BackupFile => f !== null) @@ -178,16 +182,18 @@ router.post('/restore', restoreRateLimiter, async (req: Request, res: Response): let backupContent: string; let fd: number | undefined; try { - // Verify not symlink immediately before open - const stats = fs.lstatSync(backup.path); - if (stats.isSymbolicLink()) { - res - .status(400) - .json({ error: 'Backup became symlink during read - refusing for security' }); + if (typeof fs.constants.O_NOFOLLOW !== 'number') { + res.status(500).json({ error: 'Secure restore unsupported on this platform' }); return; } // Open file descriptor for atomic read - fd = fs.openSync(backup.path, 'r'); + const openFlags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW; + fd = fs.openSync(backup.path, openFlags); + const stats = fs.fstatSync(fd); + if (!stats.isFile()) { + res.status(400).json({ error: 'Backup path is not a regular file' }); + return; + } const buffer = Buffer.alloc(stats.size); fs.readSync(fd, buffer, 0, stats.size, 0); backupContent = buffer.toString('utf8'); @@ -199,6 +205,10 @@ router.post('/restore', restoreRateLimiter, async (req: Request, res: Response): } } catch (err) { const error = err as NodeJS.ErrnoException; + if (error.code === 'ELOOP') { + res.status(400).json({ error: 'Backup file is a symlink - refusing for security' }); + return; + } if (error.code === 'ENOENT') { res.status(404).json({ error: 'Backup was deleted during restore' }); return; @@ -217,17 +227,18 @@ router.post('/restore', restoreRateLimiter, async (req: Request, res: Response): // Atomic restore with rollback capability const settingsDir = path.dirname(settingsPath); - const tempPath = path.join(settingsDir, 'settings.json.restore-tmp'); - const rollbackPath = path.join(settingsDir, 'settings.json.rollback-tmp'); + const restoreNonce = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const tempPath = path.join(settingsDir, `settings.json.restore-${restoreNonce}.tmp`); + const rollbackPath = path.join(settingsDir, `settings.json.rollback-${restoreNonce}.tmp`); try { // Step 1: Backup current settings for rollback if (fs.existsSync(settingsPath)) { - fs.copyFileSync(settingsPath, rollbackPath); + fs.copyFileSync(settingsPath, rollbackPath, fs.constants.COPYFILE_EXCL); } // Step 2: Write validated content to temp file - fs.writeFileSync(tempPath, backupContent, 'utf8'); + fs.writeFileSync(tempPath, backupContent, { encoding: 'utf8', flag: 'wx', mode: 0o600 }); // Step 3: Atomic rename (replaces existing file) fs.renameSync(tempPath, settingsPath); diff --git a/src/web-server/routes/route-helpers.ts b/src/web-server/routes/route-helpers.ts index 82f00788..076a12d7 100644 --- a/src/web-server/routes/route-helpers.ts +++ b/src/web-server/routes/route-helpers.ts @@ -161,21 +161,69 @@ export function updateSettingsFile( * - ~/.ccs/ directory: read/write allowed * - ~/.claude/settings.json: read-only */ +function normalizePathForComparison(filePath: string): string { + const normalized = path.resolve(path.normalize(filePath)); + return process.platform === 'win32' ? normalized.toLowerCase() : normalized; +} + +function isPathWithin(basePath: string, targetPath: string): boolean { + const relative = path.relative(basePath, targetPath); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function isSymlinkPath(filePath: string): boolean { + try { + return fs.lstatSync(filePath).isSymbolicLink(); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT' || nodeError.code === 'ENOTDIR') { + return false; + } + return true; + } +} + +function hasSymlinkSegment(basePath: string, targetPath: string): boolean { + const relative = path.relative(basePath, targetPath); + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { + return false; + } + + let currentPath = basePath; + const segments = relative.split(path.sep).filter(Boolean); + for (const segment of segments) { + currentPath = path.join(currentPath, segment); + if (isSymlinkPath(currentPath)) { + return true; + } + } + + return false; +} + export function validateFilePath(filePath: string): { valid: boolean; readonly: boolean; error?: string; } { const expandedPath = expandPath(filePath); - const normalizedPath = path.normalize(expandedPath); - const ccsDir = getCcsDir(); - const claudeSettingsPath = path.normalize(getClaudeSettingsPath()); + const resolvedPath = path.resolve(path.normalize(expandedPath)); + const resolvedCcsDir = path.resolve(path.normalize(getCcsDir())); + const resolvedClaudeSettingsPath = path.resolve(path.normalize(getClaudeSettingsPath())); + const normalizedPath = normalizePathForComparison(resolvedPath); + const ccsDir = normalizePathForComparison(resolvedCcsDir); + const claudeSettingsPath = normalizePathForComparison(resolvedClaudeSettingsPath); // Check if path is within ~/.ccs/ - if (normalizedPath.startsWith(ccsDir)) { + if (isPathWithin(ccsDir, normalizedPath)) { + if (hasSymlinkSegment(resolvedCcsDir, resolvedPath)) { + return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; + } + // Block access to sensitive subdirectories - const relativePath = normalizedPath.slice(ccsDir.length); - if (relativePath.includes('/.git/') || relativePath.includes('/node_modules/')) { + const relativePath = path.relative(ccsDir, normalizedPath); + const pathSegments = relativePath.split(path.sep).filter(Boolean); + if (pathSegments.includes('.git') || pathSegments.includes('node_modules')) { return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; } return { valid: true, readonly: false }; @@ -183,6 +231,9 @@ export function validateFilePath(filePath: string): { // Allow read-only access to ~/.claude/settings.json if (normalizedPath === claudeSettingsPath) { + if (isSymlinkPath(resolvedClaudeSettingsPath)) { + return { valid: false, readonly: false, error: 'Access to this path is not allowed' }; + } return { valid: true, readonly: true }; } diff --git a/tests/unit/cliproxy/extended-context-config.test.ts b/tests/unit/cliproxy/extended-context-config.test.ts index 697ba35e..17e93a10 100644 --- a/tests/unit/cliproxy/extended-context-config.test.ts +++ b/tests/unit/cliproxy/extended-context-config.test.ts @@ -144,6 +144,14 @@ describe('applyExtendedContextConfig', () => { expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro(high)[1m]'); }); + it('handles model IDs that already include both thinking and [1m] suffixes', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'gemini-2.5-pro(high)[1m]', + }; + applyExtendedContextConfig(env, 'gemini', undefined); + expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro(high)[1m]'); + }); + it('handles empty env vars gracefully', () => { const env: NodeJS.ProcessEnv = {}; applyExtendedContextConfig(env, 'gemini', undefined); @@ -164,8 +172,12 @@ describe('applyExtendedContextConfig', () => { it('strips [1m] suffix when --no-1m is explicit even if model has it', () => { const env: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'gemini-2.5-pro[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'gemini-3-pro-preview[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'gemini-2.5-pro[1m]', }; applyExtendedContextConfig(env, 'gemini', false); expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gemini-3-pro-preview'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gemini-2.5-pro'); }); }); diff --git a/tests/unit/cliproxy/provider-capabilities.test.ts b/tests/unit/cliproxy/provider-capabilities.test.ts index bbc8ae8b..3e9a606f 100644 --- a/tests/unit/cliproxy/provider-capabilities.test.ts +++ b/tests/unit/cliproxy/provider-capabilities.test.ts @@ -9,6 +9,10 @@ import { getProvidersByOAuthFlow, isCLIProxyProvider, mapExternalProviderName, + QUOTA_SUPPORTED_PROVIDER_IDS, + isQuotaSupportedProvider, + QUOTA_PROVIDER_OPTION_VALUES, + QUOTA_PROVIDER_HELP_TEXT, } from '../../../src/cliproxy/provider-capabilities'; import { OAUTH_CALLBACK_PORTS as DIAGNOSTIC_CALLBACK_PORTS, @@ -63,9 +67,31 @@ describe('provider-capabilities', () => { expect(mapExternalProviderName('github-copilot')).toBe('ghcp'); expect(mapExternalProviderName('copilot')).toBe('ghcp'); expect(mapExternalProviderName('anthropic')).toBe('claude'); + expect(mapExternalProviderName(' COPILOT ')).toBe('ghcp'); + expect(mapExternalProviderName('')).toBeNull(); expect(mapExternalProviderName('unknown-provider')).toBeNull(); }); + it('exposes quota-supported providers and guards correctly', () => { + expect(QUOTA_SUPPORTED_PROVIDER_IDS).toEqual(['agy', 'codex', 'gemini', 'ghcp']); + expect(QUOTA_PROVIDER_OPTION_VALUES).toEqual([ + 'agy', + 'codex', + 'gemini', + 'ghcp', + 'antigravity', + 'gemini-cli', + 'github-copilot', + 'copilot', + 'all', + ]); + expect(QUOTA_PROVIDER_HELP_TEXT).toBe( + 'agy, codex, gemini, ghcp, antigravity, gemini-cli, github-copilot, copilot, all' + ); + expect(isQuotaSupportedProvider('ghcp')).toBe(true); + expect(isQuotaSupportedProvider('kiro')).toBe(false); + }); + it('exposes callback port and display name capabilities', () => { expect(getOAuthCallbackPort('qwen')).toBeNull(); expect(getOAuthCallbackPort('kiro')).toBeNull(); diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts new file mode 100644 index 00000000..b8a0dc82 --- /dev/null +++ b/tests/unit/commands/api-command-args.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from 'bun:test'; + +import { parseApiCommandArgs } from '../../../src/commands/api-command'; + +describe('api-command arg parser', () => { + test('keeps positional API name when boolean flags precede it', () => { + const parsed = parseApiCommandArgs(['--yes', 'my-api']); + + expect(parsed.yes).toBe(true); + expect(parsed.name).toBe('my-api'); + }); + + test('uses last value when repeated value flags are provided', () => { + const parsed = parseApiCommandArgs([ + 'profile-a', + '--model', + 'claude-3-5-sonnet', + '--model=claude-3-7-sonnet', + ]); + + expect(parsed.name).toBe('profile-a'); + expect(parsed.model).toBe('claude-3-7-sonnet'); + expect(parsed.errors).toEqual([]); + }); + + test('collects missing-value errors for required option values', () => { + const parsed = parseApiCommandArgs(['profile-a', '--base-url', '--api-key']); + + expect(parsed.errors).toEqual(['Missing value for --base-url', 'Missing value for --api-key']); + }); + + test('supports option terminator for positional args that look like flags', () => { + const parsed = parseApiCommandArgs(['--yes', '--', '-my-api']); + + expect(parsed.yes).toBe(true); + expect(parsed.name).toBe('-my-api'); + }); +}); diff --git a/tests/unit/commands/arg-extractor.test.ts b/tests/unit/commands/arg-extractor.test.ts index eeaa508d..b65d6862 100644 --- a/tests/unit/commands/arg-extractor.test.ts +++ b/tests/unit/commands/arg-extractor.test.ts @@ -56,6 +56,53 @@ describe('arg-extractor', () => { }); }); + it('accepts dash-prefixed value when allowDashValue is enabled', () => { + const result = extractOption(['--model', '-preview', '--yes'], ['--model'], { + allowDashValue: true, + knownFlags: ['--model', '--yes'], + }); + + expect(result).toEqual({ + found: true, + value: '-preview', + missingValue: false, + remainingArgs: ['--yes'], + }); + }); + + it('still treats known flags as missing when allowDashValue is enabled', () => { + const result = extractOption(['--model', '--yes', 'prompt'], ['--model'], { + allowDashValue: true, + knownFlags: ['--model', '--yes'], + }); + + expect(result).toEqual({ + found: true, + missingValue: true, + remainingArgs: ['--yes', 'prompt'], + }); + }); + + it('supports repeated extraction loops with deterministic last-value wins behavior', () => { + let remaining = ['--model', 'gpt-4.1-mini', '--model', 'gpt-4.1']; + let selected: string | undefined; + + while (true) { + const extracted = extractOption(remaining, ['--model']); + if (!extracted.found) { + break; + } + + if (!extracted.missingValue && extracted.value) { + selected = extracted.value; + } + remaining = extracted.remainingArgs; + } + + expect(selected).toBe('gpt-4.1'); + expect(remaining).toEqual([]); + }); + it('returns non-match state without altering args content', () => { const args = ['--yes', 'prompt']; const result = extractOption(args, ['--profile', '-p']); @@ -75,8 +122,14 @@ describe('arg-extractor', () => { expect(hasAnyFlag(['prompt', '-y'], ['--yes', '-y'])).toBe(true); }); - it('returns false when only non-matching or inline tokens exist', () => { - expect(hasAnyFlag(['prompt', '--yes=true'], ['--yes', '-y'])).toBe(false); + it('supports inline truthy values for boolean flags', () => { + expect(hasAnyFlag(['prompt', '--yes=true'], ['--yes', '-y'])).toBe(true); + expect(hasAnyFlag(['prompt', '--yes=1'], ['--yes', '-y'])).toBe(true); + expect(hasAnyFlag(['prompt', '--yes=on'], ['--yes', '-y'])).toBe(true); + }); + + it('returns false for non-truthy or unrelated inline tokens', () => { + expect(hasAnyFlag(['prompt', '--yes=false'], ['--yes', '-y'])).toBe(false); expect(hasAnyFlag(['prompt', '--profile=gemini'], ['--yes', '-y'])).toBe(false); }); }); diff --git a/tests/unit/commands/config-image-analysis-command.test.ts b/tests/unit/commands/config-image-analysis-command.test.ts index b31af9f2..6a7f9134 100644 --- a/tests/unit/commands/config-image-analysis-command.test.ts +++ b/tests/unit/commands/config-image-analysis-command.test.ts @@ -117,7 +117,17 @@ image_analysis: describe('provider validation', () => { it('should accept valid providers', () => { - const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow']; + const validProviders = [ + 'agy', + 'gemini', + 'codex', + 'kiro', + 'ghcp', + 'claude', + 'qwen', + 'iflow', + 'kimi', + ]; for (const provider of validProviders) { expect(validProviders.includes(provider)).toBe(true); @@ -125,7 +135,17 @@ image_analysis: }); it('should reject invalid providers', () => { - const validProviders = ['agy', 'gemini', 'codex', 'kiro', 'ghcp', 'claude', 'qwen', 'iflow']; + const validProviders = [ + 'agy', + 'gemini', + 'codex', + 'kiro', + 'ghcp', + 'claude', + 'qwen', + 'iflow', + 'kimi', + ]; const invalidProviders = ['unknown', 'custom', 'my-provider', 'test']; for (const provider of invalidProviders) { @@ -147,12 +167,15 @@ image_analysis: kiro: 'kiro-claude-haiku-4-5', ghcp: 'claude-haiku-4.5', claude: 'claude-haiku-4-5-20251001', + qwen: 'vision-model', + iflow: 'qwen3-vl-plus', + kimi: 'vision-model', }, }; expect(defaultConfig.enabled).toBe(true); expect(defaultConfig.timeout).toBe(60); - expect(Object.keys(defaultConfig.provider_models).length).toBe(6); + expect(Object.keys(defaultConfig.provider_models).length).toBe(9); }); }); diff --git a/tests/unit/jsonl-parser.test.ts b/tests/unit/jsonl-parser.test.ts index b3c00e71..ce8b1e6f 100644 --- a/tests/unit/jsonl-parser.test.ts +++ b/tests/unit/jsonl-parser.test.ts @@ -167,6 +167,28 @@ describe('parseUsageEntry', () => { expect(result).not.toBeNull(); expect(result!.target).toBeUndefined(); }); + + test('coerces token fields to non-negative numbers', () => { + const withInvalidUsage = JSON.stringify({ + ...JSON.parse(VALID_ASSISTANT_ENTRY), + message: { + model: 'claude-sonnet-4-5', + usage: { + input_tokens: '1500', + output_tokens: -10, + cache_creation_input_tokens: 'bad', + cache_read_input_tokens: null, + }, + }, + }); + + const result = parseUsageEntry(withInvalidUsage, '/test'); + expect(result).not.toBeNull(); + expect(result!.inputTokens).toBe(1500); + expect(result!.outputTokens).toBe(0); + expect(result!.cacheCreationTokens).toBe(0); + expect(result!.cacheReadTokens).toBe(0); + }); }); // ============================================================================ @@ -217,6 +239,14 @@ describe('parseJsonlFile', () => { expect(entries.length).toBe(0); }); + test('returns empty array when stream cannot be opened', async () => { + const directoryPath = path.join(tempDir, 'not-a-file'); + fs.mkdirSync(directoryPath); + + const entries = await parseJsonlFile(directoryPath, '/test'); + expect(entries).toEqual([]); + }); + test('handles file with blank lines', async () => { const filePath = path.join(tempDir, 'blanks.jsonl'); const content = ['', VALID_ASSISTANT_ENTRY, '', ' ', ASSISTANT_ENTRY_NO_CACHE, ''].join('\n'); @@ -280,6 +310,17 @@ describe('parseProjectDirectory', () => { readdirSpy.mockRestore(); } }); + + test('sanitizes derived projectPath from dashed directory names', async () => { + const projectDir = path.join(tempDir, '-..-etc-passwd'); + fs.mkdirSync(projectDir); + fs.writeFileSync(path.join(projectDir, 'session.jsonl'), VALID_ASSISTANT_ENTRY); + + const entries = await parseProjectDirectory(projectDir); + + expect(entries.length).toBe(1); + expect(entries[0].projectPath).toBe('/etc/passwd'); + }); }); describe('findProjectDirectories', () => { @@ -395,6 +436,33 @@ describe('scanProjectsDirectory', () => { expect(entries[0].sessionId).toBe('new'); }); + test('skips entries with invalid timestamps when minDate filtering is enabled', async () => { + const project = path.join(tempDir, '-test-invalid-timestamp'); + fs.mkdirSync(project); + + const invalidTimestampEntry = JSON.stringify({ + type: 'assistant', + sessionId: 'invalid-time', + timestamp: 'not-a-date', + message: { model: 'claude-sonnet-4-5', usage: { input_tokens: 100, output_tokens: 50 } }, + }); + const validEntry = JSON.stringify({ + type: 'assistant', + sessionId: 'valid-time', + timestamp: '2025-12-09T00:00:00.000Z', + message: { model: 'claude-sonnet-4-5', usage: { input_tokens: 200, output_tokens: 100 } }, + }); + fs.writeFileSync(path.join(project, 'session.jsonl'), [invalidTimestampEntry, validEntry].join('\n')); + + const entries = await scanProjectsDirectory({ + projectsDir: tempDir, + minDate: new Date('2025-01-01'), + }); + + expect(entries.length).toBe(1); + expect(entries[0].sessionId).toBe('valid-time'); + }); + test('returns empty array for empty directory', async () => { const entries = await scanProjectsDirectory({ projectsDir: tempDir }); expect(entries.length).toBe(0); @@ -416,6 +484,41 @@ describe('scanProjectsDirectory', () => { expect(entries.length).toBe(5); }); + + test('falls back to default concurrency when invalid concurrency is provided', async () => { + for (let i = 0; i < 3; i++) { + const project = path.join(tempDir, `-invalid-concurrency-${i}`); + fs.mkdirSync(project); + fs.writeFileSync(path.join(project, 'session.jsonl'), VALID_ASSISTANT_ENTRY); + } + + const zeroEntries = await scanProjectsDirectory({ + projectsDir: tempDir, + concurrency: 0, + }); + expect(zeroEntries.length).toBe(3); + + const negativeEntries = await scanProjectsDirectory({ + projectsDir: tempDir, + concurrency: -5, + }); + expect(negativeEntries.length).toBe(3); + }); + + test('caps very high concurrency values to a safe maximum', async () => { + for (let i = 0; i < 4; i++) { + const project = path.join(tempDir, `-capped-concurrency-${i}`); + fs.mkdirSync(project); + fs.writeFileSync(path.join(project, 'session.jsonl'), VALID_ASSISTANT_ENTRY); + } + + const entries = await scanProjectsDirectory({ + projectsDir: tempDir, + concurrency: 9999, + }); + + expect(entries.length).toBe(4); + }); }); // ============================================================================ diff --git a/tests/unit/web-server/persist-routes.test.js b/tests/unit/web-server/persist-routes.test.js index 3542be53..7060de05 100644 --- a/tests/unit/web-server/persist-routes.test.js +++ b/tests/unit/web-server/persist-routes.test.js @@ -235,26 +235,18 @@ describe('Persist Routes', function () { class RestoreMutex { constructor() { this.locked = false; - this.queue = []; } async acquire() { if (this.locked) { - return new Promise((resolve) => { - this.queue.push(() => resolve(false)); - }); + return false; } this.locked = true; return true; } release() { - const next = this.queue.shift(); - if (next) { - next(); - } else { - this.locked = false; - } + this.locked = false; } } @@ -266,21 +258,16 @@ describe('Persist Routes', function () { assert.strictEqual(mutex.locked, true); }); - it('should queue and reject concurrent requests', async function () { + it('should reject concurrent requests while locked', async function () { const mutex = new RestoreMutex(); // First acquire succeeds const first = await mutex.acquire(); assert.strictEqual(first, true); - // Second acquire queues and gets false when released - const secondPromise = mutex.acquire(); - - // Release the mutex - mutex.release(); - - const second = await secondPromise; - assert.strictEqual(second, false); // Queued request returns false + // Second acquire fails immediately + const second = await mutex.acquire(); + assert.strictEqual(second, false); }); it('should unlock after release with no queue', async function () { @@ -293,30 +280,12 @@ describe('Persist Routes', function () { assert.strictEqual(mutex.locked, false); }); - it('should process multiple queued requests in order', async function () { + it('should allow new acquire after release', async function () { const mutex = new RestoreMutex(); - const results = []; - - // First acquire - const first = await mutex.acquire(); - results.push({ id: 1, acquired: first }); - - // Queue multiple requests - const p2 = mutex.acquire().then((r) => results.push({ id: 2, acquired: r })); - const p3 = mutex.acquire().then((r) => results.push({ id: 3, acquired: r })); - - // Release all - mutex.release(); // Signals #2 - mutex.release(); // Signals #3 - - await Promise.all([p2, p3]); - - assert.strictEqual(results[0].id, 1); - assert.strictEqual(results[0].acquired, true); - assert.strictEqual(results[1].id, 2); - assert.strictEqual(results[1].acquired, false); - assert.strictEqual(results[2].id, 3); - assert.strictEqual(results[2].acquired, false); + assert.strictEqual(await mutex.acquire(), true); + assert.strictEqual(await mutex.acquire(), false); + mutex.release(); + assert.strictEqual(await mutex.acquire(), true); }); }); diff --git a/tests/unit/web-server/route-helpers.test.ts b/tests/unit/web-server/route-helpers.test.ts new file mode 100644 index 00000000..4a494149 --- /dev/null +++ b/tests/unit/web-server/route-helpers.test.ts @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { validateFilePath } from '../../../src/web-server/routes/route-helpers'; + +describe('validateFilePath', () => { + let tempDir: string; + let originalCcsHome: string | undefined; + let originalClaudeConfigDir: string | undefined; + + beforeEach(() => { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'route-helpers-test-')); + originalCcsHome = process.env.CCS_HOME; + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + + process.env.CCS_HOME = tempDir; + process.env.CLAUDE_CONFIG_DIR = path.join(tempDir, '.claude-custom'); + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } + + fs.rmSync(tempDir, { recursive: true, force: true }); + }); + + test('allows files within ~/.ccs tree', () => { + const filePath = path.join(tempDir, '.ccs', 'config.yaml'); + const result = validateFilePath(filePath); + + expect(result.valid).toBe(true); + expect(result.readonly).toBe(false); + }); + + test('rejects sibling paths that only share ~/.ccs prefix', () => { + const bypassPath = path.join(tempDir, '.ccs-evil', 'config.yaml'); + const result = validateFilePath(bypassPath); + + expect(result.valid).toBe(false); + expect(result.readonly).toBe(false); + }); + + test('allows readonly access to resolved Claude settings path', () => { + const filePath = path.join(tempDir, '.claude-custom', 'settings.json'); + const result = validateFilePath(filePath); + + expect(result.valid).toBe(true); + expect(result.readonly).toBe(true); + }); + + test('rejects symlinked paths inside ~/.ccs tree', () => { + if (process.platform === 'win32') { + return; + } + + const ccsDir = path.join(tempDir, '.ccs'); + const outsideDir = path.join(tempDir, 'outside'); + const linkedDir = path.join(ccsDir, 'linked'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.mkdirSync(outsideDir, { recursive: true }); + fs.symlinkSync(outsideDir, linkedDir, 'dir'); + + const result = validateFilePath(path.join(linkedDir, 'config.yaml')); + expect(result.valid).toBe(false); + expect(result.readonly).toBe(false); + }); + + test('rejects symlinked Claude settings path', () => { + if (process.platform === 'win32') { + return; + } + + const claudeDir = path.join(tempDir, '.claude-custom'); + const targetFile = path.join(tempDir, 'target-settings.json'); + const settingsPath = path.join(claudeDir, 'settings.json'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(targetFile, '{}'); + fs.symlinkSync(targetFile, settingsPath, 'file'); + + const result = validateFilePath(settingsPath); + expect(result.valid).toBe(false); + expect(result.readonly).toBe(false); + }); +}); From b7166532dfb09c84caf7dd76600e309a7a707354 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Feb 2026 03:33:22 +0000 Subject: [PATCH 07/27] chore(release): 7.47.0-dev.1 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f9b06b58..278a21ab 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.47.0", + "version": "7.47.0-dev.1", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From d8028d15c968aab33553059d1d274e65a51b2a75 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Feb 2026 03:42:18 +0000 Subject: [PATCH 08/27] chore(release): 7.47.0-dev.2 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 278a21ab..6d1c2bbc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.47.0-dev.1", + "version": "7.47.0-dev.2", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 653f8092aea7e9b0d0e3f7ca7d6fd2a42f87e4a5 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 22 Feb 2026 00:14:15 +0700 Subject: [PATCH 09/27] fix(cliproxy): add gemini 3.1 preview alias compatibility --- src/cliproxy/config/generator.ts | 5 ++++- tests/unit/cliproxy/config-generator.test.js | 23 ++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index ca832297..c5de0df8 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -28,8 +28,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; * v5: Added disable-cooling: true for stability * v6: Added oauth-model-alias with Opus 4.6 support * v7: Added fork:true for Claude model aliases (keep both upstream and alias names) + * v8: Added Gemini 3.1 preview aliases for provider routing compatibility */ -export const CLIPROXY_CONFIG_VERSION = 7; +export const CLIPROXY_CONFIG_VERSION = 8; /** * Default Antigravity oauth-model-alias entries. @@ -40,6 +41,8 @@ const DEFAULT_ANTIGRAVITY_ALIASES: Array<{ name: string; alias: string; fork?: b { name: 'rev19-uic3-1p', alias: 'gemini-2.5-computer-use-preview-10-2025' }, { name: 'gemini-3-pro-image', alias: 'gemini-3-pro-image-preview' }, { name: 'gemini-3-pro-high', alias: 'gemini-3-pro-preview' }, + { name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview' }, + { name: 'gemini-3-pro-high', alias: 'gemini-3.1-pro-preview-customtools' }, { name: 'gemini-3-flash', alias: 'gemini-3-flash-preview' }, { name: 'claude-sonnet-4-5', alias: 'gemini-claude-sonnet-4-5', fork: true }, { name: 'claude-sonnet-4-5-thinking', alias: 'gemini-claude-sonnet-4-5-thinking', fork: true }, diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js index 22b28984..1a184bf9 100644 --- a/tests/unit/cliproxy/config-generator.test.js +++ b/tests/unit/cliproxy/config-generator.test.js @@ -621,6 +621,29 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" } }); + it('generates Gemini 3.1 compatibility aliases without fork', () => { + regenerateConfig(); + + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + const config = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + const lines = config.split('\n'); + + const gemini31AliasLines = [ + 'alias: gemini-3.1-pro-preview', + 'alias: gemini-3.1-pro-preview-customtools', + ]; + + for (const aliasLine of gemini31AliasLines) { + const lineIndex = lines.findIndex((line) => line.includes(aliasLine)); + assert(lineIndex >= 0, `Should include ${aliasLine}`); + const nextLine = lines[lineIndex + 1] || ''; + assert( + !nextLine.trim().startsWith('fork:'), + `Gemini alias should not have fork: ${aliasLine}` + ); + } + }); + it('preserves user-added aliases with fork during regeneration', () => { const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); fs.mkdirSync(cliproxyDir, { recursive: true }); From 63619cb9dc7b4eb35b4904322b5fe02d36278638 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 22 Feb 2026 00:35:20 +0700 Subject: [PATCH 10/27] fix(cliproxy): harden antigravity alias generation --- src/cliproxy/config/generator.ts | 285 ++++++++++++++++--- tests/unit/cliproxy/config-generator.test.js | 84 ++++++ 2 files changed, 331 insertions(+), 38 deletions(-) diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index c5de0df8..8ea310f7 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -10,6 +10,7 @@ import { getProviderDisplayName } from '../provider-capabilities'; import { getModelMappingFromConfig } from '../base-config-loader'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { getEffectiveApiKey, getEffectiveManagementSecret } from '../auth-token-manager'; +import { getCachedCatalog } from '../catalog-cache'; import { getAuthDir, getProviderAuthDir, getConfigPathForPort } from './path-resolver'; import { CLIPROXY_DEFAULT_PORT } from './port-manager'; @@ -29,15 +30,24 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; * v6: Added oauth-model-alias with Opus 4.6 support * v7: Added fork:true for Claude model aliases (keep both upstream and alias names) * v8: Added Gemini 3.1 preview aliases for provider routing compatibility + * v9: Added resilient alias compatibility expansion and cache-assisted alias enrichment */ -export const CLIPROXY_CONFIG_VERSION = 8; +export const CLIPROXY_CONFIG_VERSION = 9; + +interface OAuthModelAliasEntry { + name: string; + alias: string; + fork?: boolean; +} + +const GEMINI_MINOR_COMPAT_RANGE = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const; /** * Default Antigravity oauth-model-alias entries. * Maps user-facing model names to Antigravity internal model names. - * Must stay in sync with CLIProxyAPIPlus defaultAntigravityAliases(). + * Additional compatibility aliases are derived automatically at generation time. */ -const DEFAULT_ANTIGRAVITY_ALIASES: Array<{ name: string; alias: string; fork?: boolean }> = [ +const DEFAULT_ANTIGRAVITY_ALIASES: OAuthModelAliasEntry[] = [ { name: 'rev19-uic3-1p', alias: 'gemini-2.5-computer-use-preview-10-2025' }, { name: 'gemini-3-pro-image', alias: 'gemini-3-pro-image-preview' }, { name: 'gemini-3-pro-high', alias: 'gemini-3-pro-preview' }, @@ -78,51 +88,250 @@ function getLoggingSettings(): { loggingToFile: boolean; requestLog: boolean } { }; } +function sanitizeYamlScalar(rawValue: string): string { + const trimmed = rawValue.trim(); + if ( + (trimmed.startsWith('"') && trimmed.endsWith('"')) || + (trimmed.startsWith("'") && trimmed.endsWith("'")) + ) { + return trimmed.slice(1, -1).trim(); + } + return trimmed; +} + +function addAliasEntry( + entries: OAuthModelAliasEntry[], + indexByKey: Map, + entry: OAuthModelAliasEntry +): void { + const normalized: OAuthModelAliasEntry = { + name: sanitizeYamlScalar(entry.name), + alias: sanitizeYamlScalar(entry.alias), + fork: entry.fork || undefined, + }; + if (!normalized.name || !normalized.alias) return; + + const key = `${normalized.name}\u0000${normalized.alias}`; + const existingIndex = indexByKey.get(key); + if (existingIndex !== undefined) { + if (normalized.fork) entries[existingIndex].fork = true; + return; + } + + indexByKey.set(key, entries.length); + entries.push(normalized); +} + +function parseExistingAntigravityAliases(existingAliases: string): OAuthModelAliasEntry[] { + const entries: OAuthModelAliasEntry[] = []; + const lines = existingAliases.replace(/\r\n/g, '\n').split('\n'); + + let currentChannel = ''; + let currentName = ''; + let currentAlias = ''; + let currentFork = false; + + const flushCurrent = () => { + if (currentName && currentAlias && (!currentChannel || currentChannel === 'antigravity')) { + entries.push({ + name: sanitizeYamlScalar(currentName), + alias: sanitizeYamlScalar(currentAlias), + fork: currentFork || undefined, + }); + } + currentName = ''; + currentAlias = ''; + currentFork = false; + }; + + for (const line of lines) { + const channelMatch = line.match(/^\s{2}([a-zA-Z0-9_-]+):\s*$/); + if (channelMatch) { + flushCurrent(); + currentChannel = channelMatch[1].trim().toLowerCase(); + continue; + } + + if (currentChannel && currentChannel !== 'antigravity') continue; + + const nameMatch = line.match(/^\s+-\s*name:\s*(.+)/); + const aliasMatch = line.match(/^\s+alias:\s*(.+)/); + const forkMatch = line.match(/^\s+fork:\s*(.+)/); + + if (nameMatch) { + flushCurrent(); + currentName = nameMatch[1]; + continue; + } + + if (aliasMatch) { + currentAlias = aliasMatch[1]; + continue; + } + + if (forkMatch) { + currentFork = sanitizeYamlScalar(forkMatch[1]).toLowerCase() === 'true'; + } + } + + flushCurrent(); + return entries; +} + +function toDottedGeminiVersionAlias(alias: string): string | null { + const match = alias.match(/^(gemini-\d+)-(\d+)(-.+)$/); + if (!match) return null; + return `${match[1]}.${match[2]}${match[3]}`; +} + +function toHyphenatedGeminiVersionAlias(alias: string): string | null { + const match = alias.match(/^(gemini-\d+)\.(\d+)(-.+)$/); + if (!match) return null; + return `${match[1]}-${match[2]}${match[3]}`; +} + +function buildGeminiCompatibilityAliases(alias: string): string[] { + if (!alias.startsWith('gemini-') || !alias.includes('-preview')) return []; + + const variants = new Set(); + const queue: string[] = [alias]; + + const enqueue = (candidate: string) => { + if (!candidate || candidate === alias || variants.has(candidate)) return; + variants.add(candidate); + queue.push(candidate); + }; + + const basePreviewMatch = alias.match(/^gemini-(\d+)-(pro|flash)-preview(?:-customtools)?$/); + if (basePreviewMatch) { + const major = basePreviewMatch[1]; + const family = basePreviewMatch[2]; + for (const minor of GEMINI_MINOR_COMPAT_RANGE) { + enqueue(`gemini-${major}.${minor}-${family}-preview`); + enqueue(`gemini-${major}-${minor}-${family}-preview`); + } + } + + const visited = new Set(); + while (queue.length > 0) { + const current = queue.pop(); + if (!current || visited.has(current)) continue; + visited.add(current); + + if (current.startsWith('gemini-') && current.includes('-preview')) { + if (current.endsWith('-customtools')) { + enqueue(current.slice(0, -'-customtools'.length)); + } else { + enqueue(`${current}-customtools`); + } + } + + const dotted = toDottedGeminiVersionAlias(current); + if (dotted) enqueue(dotted); + + const hyphenated = toHyphenatedGeminiVersionAlias(current); + if (hyphenated) enqueue(hyphenated); + } + + return [...variants]; +} + +function getGeminiPreviewFamily(alias: string): string | null { + const withoutCustomTools = alias.replace(/-customtools$/, ''); + const normalized = toHyphenatedGeminiVersionAlias(withoutCustomTools) || withoutCustomTools; + + const majorMinorMatch = normalized.match(/^gemini-(\d+)-(\d+)-(.+-preview(?:-[0-9-]+)?)$/); + if (majorMinorMatch) { + return `gemini-${majorMinorMatch[1]}-${majorMinorMatch[3]}`; + } + + const majorOnlyMatch = normalized.match(/^gemini-(\d+)-(.+-preview(?:-[0-9-]+)?)$/); + if (majorOnlyMatch) { + return `gemini-${majorOnlyMatch[1]}-${majorOnlyMatch[2]}`; + } + + return null; +} + +function getCacheDerivedAntigravityAliases( + currentEntries: OAuthModelAliasEntry[] +): OAuthModelAliasEntry[] { + const cached = getCachedCatalog(); + const remoteAgyModels = cached?.providers?.agy; + if (!remoteAgyModels || remoteAgyModels.length === 0) return []; + + const familyToName = new Map(); + for (const entry of currentEntries) { + const family = getGeminiPreviewFamily(entry.alias); + if (family && !familyToName.has(family)) { + familyToName.set(family, entry.name); + } + } + + const derivedAliases: OAuthModelAliasEntry[] = []; + for (const remoteModel of remoteAgyModels) { + if (!remoteModel || typeof remoteModel.id !== 'string') continue; + const family = getGeminiPreviewFamily(remoteModel.id); + if (!family) continue; + + const mappedName = familyToName.get(family); + if (mappedName) { + derivedAliases.push({ + name: mappedName, + alias: remoteModel.id, + }); + } + } + + return derivedAliases; +} + +function getCompatibilityAliases(entries: OAuthModelAliasEntry[]): OAuthModelAliasEntry[] { + const compatibilityAliases: OAuthModelAliasEntry[] = []; + for (const entry of entries) { + const variants = buildGeminiCompatibilityAliases(entry.alias); + for (const variant of variants) { + compatibilityAliases.push({ + name: entry.name, + alias: variant, + fork: entry.fork, + }); + } + } + return compatibilityAliases; +} + /** * Generate oauth-model-alias YAML section. * Merges default Antigravity aliases with any user-added custom aliases. */ function generateOAuthModelAliasSection(existingAliases?: string): string { - // Start with default aliases - const aliasEntries = [...DEFAULT_ANTIGRAVITY_ALIASES]; + const aliasEntries: OAuthModelAliasEntry[] = []; + const aliasIndexByKey = new Map(); - // Parse and merge existing user aliases if provided + // Start with default aliases. + for (const alias of DEFAULT_ANTIGRAVITY_ALIASES) { + addAliasEntry(aliasEntries, aliasIndexByKey, alias); + } + + // Merge existing user aliases (dedupe by name+alias, not by name only). if (existingAliases) { - const existingNames = new Set(aliasEntries.map((a) => a.name)); - const lines = existingAliases.split('\n'); - let currentName = ''; - let currentAlias = ''; - let currentFork = false; - for (const line of lines) { - const nameMatch = line.match(/^\s+-\s*name:\s*(.+)/); - const aliasMatch = line.match(/^\s+alias:\s*(.+)/); - const forkMatch = line.match(/^\s+fork:\s*(.+)/); - if (nameMatch) { - // Flush previous entry if complete - if (currentName && currentAlias && !existingNames.has(currentName)) { - aliasEntries.push({ - name: currentName, - alias: currentAlias, - fork: currentFork || undefined, - }); - existingNames.add(currentName); - } - currentName = nameMatch[1].trim(); - currentAlias = ''; - currentFork = false; - } else if (aliasMatch) { - currentAlias = aliasMatch[1].trim(); - } else if (forkMatch) { - currentFork = forkMatch[1].trim().toLowerCase() === 'true'; - } - } - // Flush last entry - if (currentName && currentAlias && !existingNames.has(currentName)) { - aliasEntries.push({ name: currentName, alias: currentAlias, fork: currentFork || undefined }); - existingNames.add(currentName); + const parsed = parseExistingAntigravityAliases(existingAliases); + for (const alias of parsed) { + addAliasEntry(aliasEntries, aliasIndexByKey, alias); } } + // Pull latest known aliases from cached remote catalog when available. + for (const alias of getCacheDerivedAntigravityAliases(aliasEntries)) { + addAliasEntry(aliasEntries, aliasIndexByKey, alias); + } + + // Expand compatibility aliases to reduce breakage on upstream naming drift. + for (const alias of getCompatibilityAliases(aliasEntries)) { + addAliasEntry(aliasEntries, aliasIndexByKey, alias); + } + const entries = aliasEntries .map((a) => { let entry = ` - name: ${a.name}\n alias: ${a.alias}`; diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js index 1a184bf9..2b744bed 100644 --- a/tests/unit/cliproxy/config-generator.test.js +++ b/tests/unit/cliproxy/config-generator.test.js @@ -644,6 +644,90 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" } }); + it('generates dot and hyphen alias forms for preview compatibility', () => { + regenerateConfig(); + + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + const config = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + + assert(config.includes('alias: gemini-3.1-pro-preview'), 'Should include dotted version alias'); + assert(config.includes('alias: gemini-3-1-pro-preview'), 'Should include hyphen version alias'); + assert( + config.includes('alias: gemini-3-1-pro-preview-customtools'), + 'Should include hyphen customtools alias' + ); + }); + + it('preserves multiple aliases that share the same model name', () => { + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v8 +port: 8317 +api-keys: + - "ccs-internal-managed" +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +oauth-model-alias: + antigravity: + - name: custom-model + alias: custom-alias-a + - name: custom-model + alias: custom-alias-b +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + regenerateConfig(); + + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + + assert(newConfig.includes('alias: custom-alias-a'), 'Should preserve first alias'); + assert(newConfig.includes('alias: custom-alias-b'), 'Should preserve second alias'); + assert.strictEqual( + (newConfig.match(/alias: custom-alias-a/g) || []).length, + 1, + 'Should keep one entry for first alias' + ); + assert.strictEqual( + (newConfig.match(/alias: custom-alias-b/g) || []).length, + 1, + 'Should keep one entry for second alias' + ); + }); + + it('enriches aliases from cached catalog for unseen preview minor versions', () => { + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + const cachePath = path.join(testDir, '.ccs', 'model-catalog-cache.json'); + fs.mkdirSync(path.dirname(cachePath), { recursive: true }); + + regenerateConfig(); + + const beforeCacheConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert( + !beforeCacheConfig.includes('alias: gemini-3.11-pro-preview'), + 'Should not include unseen minor alias before cache enrichment' + ); + + const cachePayload = { + providers: { + agy: [{ id: 'gemini-3.11-pro-preview', display_name: 'Gemini 3.11 Pro Preview' }], + }, + fetchedAt: Date.now(), + }; + fs.writeFileSync(cachePath, JSON.stringify(cachePayload)); + + regenerateConfig(); + + const afterCacheConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert( + afterCacheConfig.includes('alias: gemini-3.11-pro-preview'), + 'Should include cache-derived unseen minor alias' + ); + assert( + afterCacheConfig.includes('alias: gemini-3.11-pro-preview-customtools'), + 'Should include compatibility alias for cache-derived entry' + ); + }); + it('preserves user-added aliases with fork during regeneration', () => { const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); fs.mkdirSync(cliproxyDir, { recursive: true }); From 2231b6c3a7297d1328bef0bdf4591261ac05bbee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Feb 2026 17:58:53 +0000 Subject: [PATCH 11/27] chore(release): 7.47.0-dev.3 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6d1c2bbc..d4ee9935 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.47.0-dev.2", + "version": "7.47.0-dev.3", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 2385d9028ae37d00200516c9728cb29b9c45fd21 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 22 Feb 2026 01:19:02 +0700 Subject: [PATCH 12/27] feat(cliproxy): add Claude quota windows and account failover --- README.md | 6 +- src/cliproxy/executor/index.ts | 11 +- src/cliproxy/executor/retry-handler.ts | 4 +- src/cliproxy/quota-fetcher-claude.ts | 546 ++++++++++++++++++ src/cliproxy/quota-manager.ts | 134 ++++- src/cliproxy/quota-types.ts | 80 ++- src/commands/cliproxy/help-subcommand.ts | 4 +- src/commands/cliproxy/index.ts | 38 +- src/commands/cliproxy/quota-subcommand.ts | 192 +++++- .../routes/cliproxy-stats-routes.ts | 63 +- .../cliproxy/quota-fetcher-claude.test.ts | 257 +++++++++ .../account/flow-viz/account-card.tsx | 43 +- .../cliproxy/provider-editor/account-item.tsx | 39 +- .../provider-editor/accounts-section.tsx | 2 +- .../cliproxy/provider-editor/types.ts | 2 +- .../shared/quota-tooltip-content.tsx | 90 +++ ui/src/hooks/use-cliproxy-stats.ts | 36 +- ui/src/lib/api-client.ts | 56 ++ ui/src/lib/utils.ts | 70 ++- 19 files changed, 1599 insertions(+), 74 deletions(-) create mode 100644 src/cliproxy/quota-fetcher-claude.ts create mode 100644 tests/unit/cliproxy/quota-fetcher-claude.test.ts diff --git a/README.md b/README.md index b234b704..47cf77ce 100644 --- a/README.md +++ b/README.md @@ -249,14 +249,14 @@ ccs sync Re-creates symlinks for shared commands, skills, and settings. -### Antigravity Quota Management +### Quota Management ```bash ccs cliproxy doctor # Check quota status for all agy accounts -ccs cliproxy quota # Show agy/codex/gemini quotas (Codex: 5h + weekly reset schedule) +ccs cliproxy quota # Show agy/claude/codex/gemini/ghcp quotas (Claude/Codex: 5h + weekly reset schedule) ``` -**Auto-Failover**: When an Antigravity account runs out of quota, CCS automatically switches to another account with remaining capacity. Shared GCP project accounts are excluded (pooled quota). +**Auto-Failover**: When a managed account runs out of quota, CCS automatically switches to another account with remaining capacity. Shared GCP project accounts are excluded (pooled quota). ### CLIProxy Lifecycle diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 2d76146c..220ccdd2 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -610,12 +610,15 @@ export async function execClaudeWithCLIProxy( } } - // 3b. Preflight quota check (Antigravity only) + // 3b. Preflight quota check (providers with quota-based rotation) if (!skipLocalAuth) { - // Multi-tier quota check for composite variants (check if ANY tier uses 'agy') + // Multi-tier quota check for composite variants (check if any tier uses a managed provider) if (compositeProviders.length > 0) { - if (compositeProviders.includes('agy')) { - await handleQuotaCheck('agy'); + const managedQuotaProviders = ['agy', 'claude'] as const; + for (const managedProvider of managedQuotaProviders) { + if (compositeProviders.includes(managedProvider)) { + await handleQuotaCheck(managedProvider); + } } } else { await handleQuotaCheck(provider); diff --git a/src/cliproxy/executor/retry-handler.ts b/src/cliproxy/executor/retry-handler.ts index 7149cdad..e7cb50f9 100644 --- a/src/cliproxy/executor/retry-handler.ts +++ b/src/cliproxy/executor/retry-handler.ts @@ -77,10 +77,10 @@ export async function handleTokenExpiration( } /** - * Handle quota check and auto-switching for Antigravity + * Handle quota check and auto-switching for providers with quota-based rotation. */ export async function handleQuotaCheck(provider: CLIProxyProvider): Promise { - if (provider !== 'agy') return; + if (provider !== 'agy' && provider !== 'claude') return; const { preflightCheck } = await import('../quota-manager'); const preflight = await preflightCheck(provider); diff --git a/src/cliproxy/quota-fetcher-claude.ts b/src/cliproxy/quota-fetcher-claude.ts new file mode 100644 index 00000000..2be1c248 --- /dev/null +++ b/src/cliproxy/quota-fetcher-claude.ts @@ -0,0 +1,546 @@ +/** + * Quota Fetcher for Claude (Anthropic) Accounts + * + * Fetches policy limits from Claude API and normalizes 5h + weekly windows. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { getAuthDir } from './config-generator'; +import { getPausedDir, getProviderAccounts } from './account-manager'; +import { sanitizeEmail, isTokenExpired } from './auth-utils'; +import type { ClaudeQuotaResult, ClaudeQuotaWindow, ClaudeCoreUsageSummary } from './quota-types'; +import { clampPercent } from '../utils/percentage'; + +const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_code/policy_limits'; +const CLAUDE_QUOTA_TIMEOUT_MS = 10000; +const CLAUDE_QUOTA_MAX_ATTEMPTS = 2; +const CLAUDE_USER_AGENT = 'ccs-cli/claude-quota'; + +interface ClaudeAuthData { + accessToken: string; + isExpired: boolean; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function asBoolean(value: unknown): boolean | undefined { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + if (value === 'true') return true; + if (value === 'false') return false; + } + return undefined; +} + +function asNumber(value: unknown): number | null { + if (typeof value === 'number' && isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value); + return isFinite(parsed) ? parsed : null; + } + return null; +} + +function normalizeTimestamp(value: unknown): string | null { + const asNum = asNumber(value); + if (asNum !== null) { + const millis = asNum > 1e12 ? asNum : asNum * 1000; + const date = new Date(millis); + return isNaN(date.getTime()) ? null : date.toISOString(); + } + + const str = asString(value); + if (!str) return null; + + // Numeric strings can be either epoch seconds or epoch milliseconds. + if (/^\d+$/.test(str)) { + const numeric = Number(str); + if (isFinite(numeric)) { + const millis = numeric > 1e12 ? numeric : numeric * 1000; + const date = new Date(millis); + return isNaN(date.getTime()) ? null : date.toISOString(); + } + } + + const date = new Date(str); + return isNaN(date.getTime()) ? null : date.toISOString(); +} + +function getClaudeWindowLabel(rateLimitType: string): string { + switch (rateLimitType) { + case 'five_hour': + return 'Session limit'; + case 'seven_day': + return 'Weekly limit'; + case 'seven_day_opus': + return 'Opus limit'; + case 'seven_day_sonnet': + return 'Sonnet limit'; + case 'overage': + return 'Extra usage'; + default: + return rateLimitType || 'Unknown limit'; + } +} + +function normalizeUtilization(raw: Record): { + utilization: number | null; + usedPercent: number; + remainingPercent: number; +} { + const utilizationRaw = asNumber(raw['utilization']); + const usedPercentRaw = asNumber(raw['usedPercent'] ?? raw['used_percent']); + const remainingPercentRaw = asNumber(raw['remainingPercent'] ?? raw['remaining_percent']); + + if (utilizationRaw !== null) { + const ratio = utilizationRaw <= 1 ? utilizationRaw : utilizationRaw / 100; + const usedPercent = clampPercent(ratio * 100); + return { + utilization: ratio, + usedPercent, + remainingPercent: clampPercent(100 - usedPercent), + }; + } + + if (usedPercentRaw !== null) { + const usedPercent = clampPercent(usedPercentRaw); + return { + utilization: usedPercent / 100, + usedPercent, + remainingPercent: clampPercent(100 - usedPercent), + }; + } + + if (remainingPercentRaw !== null) { + const remainingPercent = clampPercent(remainingPercentRaw); + const usedPercent = clampPercent(100 - remainingPercent); + return { + utilization: usedPercent / 100, + usedPercent, + remainingPercent, + }; + } + + return { + utilization: null, + usedPercent: 0, + remainingPercent: 100, + }; +} + +function normalizeRateLimitType(value: unknown, fallbackKey?: string): string { + const direct = asString(value); + if (direct) return direct; + if (fallbackKey) return fallbackKey; + return 'unknown'; +} + +function toObject(value: unknown): Record | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function normalizeRestriction( + raw: Record, + fallbackKey?: string +): ClaudeQuotaWindow | null { + const rateLimitType = normalizeRateLimitType( + raw['rateLimitType'] ?? raw['rate_limit_type'] ?? raw['claim'] ?? raw['claimAbbrev'], + fallbackKey + ); + if (!rateLimitType || rateLimitType === 'unknown') return null; + + const status = asString(raw['status']) || 'unknown'; + const resetAt = + normalizeTimestamp(raw['resetsAt'] ?? raw['resets_at'] ?? raw['resetAt'] ?? raw['reset_at']) || + null; + const overageResetsAt = + normalizeTimestamp( + raw['overageResetsAt'] ?? + raw['overage_resets_at'] ?? + raw['overageResetAt'] ?? + raw['overage_reset_at'] + ) || null; + + const { utilization, usedPercent, remainingPercent } = normalizeUtilization(raw); + + return { + rateLimitType, + label: getClaudeWindowLabel(rateLimitType), + status, + utilization, + usedPercent, + remainingPercent, + resetAt, + surpassedThreshold: asBoolean(raw['surpassedThreshold'] ?? raw['surpassed_threshold']), + severity: asString(raw['severity']) || undefined, + overageStatus: asString(raw['overageStatus'] ?? raw['overage_status']) || undefined, + overageResetsAt, + overageDisabledReason: + asString(raw['overageDisabledReason'] ?? raw['overage_disabled_reason']) || undefined, + isUsingOverage: asBoolean(raw['isUsingOverage'] ?? raw['is_using_overage']), + hasExtraUsageEnabled: asBoolean(raw['hasExtraUsageEnabled'] ?? raw['has_extra_usage_enabled']), + }; +} + +/** + * Parse raw policy limits response into normalized windows. + * Supports both array and object-map `restrictions` shapes. + */ +export function buildClaudeQuotaWindows(payload: Record): ClaudeQuotaWindow[] { + const rawRestrictions = payload['restrictions']; + const windows: ClaudeQuotaWindow[] = []; + + if (Array.isArray(rawRestrictions)) { + for (const item of rawRestrictions) { + const raw = toObject(item); + if (!raw) continue; + const window = normalizeRestriction(raw); + if (window) windows.push(window); + } + } else if (toObject(rawRestrictions)) { + for (const [key, value] of Object.entries(rawRestrictions as Record)) { + const raw = toObject(value); + if (!raw) continue; + const window = normalizeRestriction(raw, key); + if (window) windows.push(window); + } + } else if (toObject(payload)) { + // Some responses may contain a single restriction object directly. + const direct = normalizeRestriction(payload); + if (direct) windows.push(direct); + } + + const seen = new Set(); + const unique: ClaudeQuotaWindow[] = []; + for (const window of windows) { + const key = `${window.rateLimitType}:${window.resetAt ?? ''}:${window.status}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(window); + } + + return unique.sort((a, b) => a.rateLimitType.localeCompare(b.rateLimitType)); +} + +function toEpochMs(iso: string | null): number | null { + if (!iso) return null; + const value = new Date(iso).getTime(); + return isNaN(value) ? null : value; +} + +function pickMostRestrictiveWeekly(windows: ClaudeQuotaWindow[]): ClaudeQuotaWindow | null { + if (windows.length === 0) return null; + return [...windows].sort((a, b) => { + if (a.remainingPercent !== b.remainingPercent) { + return a.remainingPercent - b.remainingPercent; + } + const aReset = toEpochMs(a.resetAt); + const bReset = toEpochMs(b.resetAt); + if (aReset === null && bReset === null) return 0; + if (aReset === null) return 1; + if (bReset === null) return -1; + return aReset - bReset; + })[0]; +} + +function mapCoreWindow(window: ClaudeQuotaWindow | null): ClaudeCoreUsageSummary['fiveHour'] { + if (!window) return null; + return { + rateLimitType: window.rateLimitType, + label: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + status: window.status, + }; +} + +/** + * Build explicit 5h + weekly usage summary from Claude policy windows. + */ +export function buildClaudeCoreUsageSummary(windows: ClaudeQuotaWindow[]): ClaudeCoreUsageSummary { + if (!windows || windows.length === 0) { + return { fiveHour: null, weekly: null }; + } + + const fiveHourWindow = windows.find((window) => window.rateLimitType === 'five_hour') || null; + const weeklyCandidates = windows.filter((window) => + ['seven_day', 'seven_day_opus', 'seven_day_sonnet'].includes(window.rateLimitType) + ); + const weeklyWindow = pickMostRestrictiveWeekly(weeklyCandidates); + + // Fallback: infer shortest/longest reset windows from non-overage limits. + if (!fiveHourWindow || !weeklyWindow) { + const nonOverage = windows.filter((window) => window.rateLimitType !== 'overage'); + const withReset = nonOverage + .map((window) => ({ + window, + resetMs: toEpochMs(window.resetAt), + })) + .filter((entry) => entry.resetMs !== null) + .sort((a, b) => (a.resetMs as number) - (b.resetMs as number)); + + const inferredFiveHour = + fiveHourWindow || + (withReset.length > 0 + ? withReset[0].window + : nonOverage.length > 0 + ? pickMostRestrictiveWeekly(nonOverage) + : null); + const inferredWeekly = + weeklyWindow || + (withReset.length > 1 + ? withReset[withReset.length - 1].window + : nonOverage.find((window) => window !== inferredFiveHour) || null); + + return { + fiveHour: mapCoreWindow(inferredFiveHour), + weekly: mapCoreWindow(inferredWeekly), + }; + } + + return { + fiveHour: mapCoreWindow(fiveHourWindow), + weekly: mapCoreWindow(weeklyWindow), + }; +} + +function extractAccessToken(data: Record): string | null { + const direct = asString(data['access_token']); + if (direct) return direct; + + const nested = toObject(data['token']); + if (nested) { + const nestedToken = asString(nested['access_token']); + if (nestedToken) return nestedToken; + } + + return null; +} + +function extractExpiry(data: Record): string | null { + const direct = asString(data['expired']); + if (direct) return direct; + + const nested = toObject(data['token']); + if (nested) { + return asString(nested['expiry']); + } + + return null; +} + +function readClaudeAuthData(accountId: string): ClaudeAuthData | null { + const authDirs = [getAuthDir(), getPausedDir()]; + const sanitizedId = sanitizeEmail(accountId); + const expectedFiles = [`claude-${sanitizedId}.json`, `anthropic-${sanitizedId}.json`]; + + for (const authDir of authDirs) { + if (!fs.existsSync(authDir)) continue; + + for (const expectedFile of expectedFiles) { + const filePath = path.join(authDir, expectedFile); + if (!fs.existsSync(filePath)) continue; + + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record; + const accessToken = extractAccessToken(data); + if (!accessToken) continue; + + const expiry = extractExpiry(data); + return { + accessToken, + isExpired: isTokenExpired(expiry ?? undefined), + }; + } catch { + continue; + } + } + + const files = fs.readdirSync(authDir); + for (const file of files) { + if ( + !file.endsWith('.json') || + (!file.startsWith('claude-') && !file.startsWith('anthropic-')) + ) { + continue; + } + + const filePath = path.join(authDir, file); + try { + const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record; + const accessToken = extractAccessToken(data); + if (!accessToken) continue; + + const fileEmail = asString(data['email']); + const typeValue = asString(data['type']); + const isClaudeType = + typeValue === null || typeValue === 'claude' || typeValue === 'anthropic'; + const matchesEmail = fileEmail === accountId; + const matchesFile = file.includes(sanitizedId); + + if ((matchesEmail || matchesFile) && isClaudeType) { + const expiry = extractExpiry(data); + return { + accessToken, + isExpired: isTokenExpired(expiry ?? undefined), + }; + } + } catch { + continue; + } + } + } + + return null; +} + +function buildEmptyResult( + error: string, + accountId: string, + needsReauth = false +): ClaudeQuotaResult { + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: Date.now(), + error, + accountId, + needsReauth, + }; +} + +/** + * Fetch quota for a single Claude account. + */ +export async function fetchClaudeQuota( + accountId: string, + verbose = false +): Promise { + const authData = readClaudeAuthData(accountId); + if (!authData) { + return buildEmptyResult('Auth file not found for Claude account', accountId); + } + + if (authData.isExpired) { + return buildEmptyResult( + 'Token expired - re-authenticate with ccs cliproxy auth claude', + accountId, + true + ); + } + + let lastError = 'Unknown error'; + + for (let attempt = 1; attempt <= CLAUDE_QUOTA_MAX_ATTEMPTS; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), CLAUDE_QUOTA_TIMEOUT_MS); + + try { + const response = await fetch(CLAUDE_POLICY_LIMITS_URL, { + method: 'GET', + signal: controller.signal, + headers: { + Authorization: `Bearer ${authData.accessToken}`, + Accept: 'application/json', + 'User-Agent': CLAUDE_USER_AGENT, + }, + }); + + clearTimeout(timeoutId); + if (verbose) { + console.error(`[i] Claude policy limits status: ${response.status} (attempt ${attempt})`); + } + + if (response.status === 401) { + return buildEmptyResult('Authentication required for policy limits', accountId, true); + } + + if (response.status === 404) { + // Some accounts may not expose policy limits; treat as empty but successful. + return { + success: true, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: Date.now(), + accountId, + }; + } + + if (response.status === 403) { + return buildEmptyResult('Not authorized for policy limits', accountId); + } + + if (!response.ok) { + lastError = `Policy limits API error: ${response.status}`; + if ( + attempt < CLAUDE_QUOTA_MAX_ATTEMPTS && + (response.status === 429 || response.status >= 500) + ) { + continue; + } + return buildEmptyResult(lastError, accountId); + } + + let payload: unknown; + try { + payload = await response.json(); + } catch { + return buildEmptyResult('Invalid policy limits format', accountId); + } + + if (!toObject(payload)) { + return buildEmptyResult('Invalid policy limits format', accountId); + } + + const windows = buildClaudeQuotaWindows(payload as Record); + const coreUsage = buildClaudeCoreUsageSummary(windows); + + return { + success: true, + windows, + coreUsage, + lastUpdated: Date.now(), + accountId, + }; + } catch (error) { + clearTimeout(timeoutId); + lastError = + error instanceof Error && error.name === 'AbortError' + ? 'Policy limits request timeout' + : error instanceof Error + ? error.message + : 'Unknown error'; + + if (verbose) { + console.error(`[!] Claude policy limits failed (attempt ${attempt}): ${lastError}`); + } + + if (attempt >= CLAUDE_QUOTA_MAX_ATTEMPTS) { + return buildEmptyResult(lastError, accountId); + } + } + } + + return buildEmptyResult(lastError, accountId); +} + +/** + * Fetch quota for all Claude accounts. + */ +export async function fetchAllClaudeQuotas( + verbose = false +): Promise<{ account: string; quota: ClaudeQuotaResult }[]> { + const accounts = getProviderAccounts('claude'); + const results = await Promise.all( + accounts.map(async (account) => ({ + account: account.id, + quota: await fetchClaudeQuota(account.id, verbose), + })) + ); + return results; +} diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index f4091c8a..2aea73fc 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -14,6 +14,8 @@ import { CLIProxyProvider } from './types'; import { QuotaResult, fetchAccountQuota } from './quota-fetcher'; +import { fetchClaudeQuota } from './quota-fetcher-claude'; +import type { ClaudeQuotaResult } from './quota-types'; import { getDefaultAccount, getProviderAccounts, @@ -25,12 +27,21 @@ import { import { loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; import type { RuntimeMonitorConfig } from '../config/unified-config-types'; +type ManagedQuotaProvider = 'agy' | 'claude'; +type ManagedQuotaResult = QuotaResult | ClaudeQuotaResult; + +const MANAGED_QUOTA_PROVIDERS: readonly ManagedQuotaProvider[] = ['agy', 'claude']; + +function isManagedQuotaProvider(provider: CLIProxyProvider): provider is ManagedQuotaProvider { + return MANAGED_QUOTA_PROVIDERS.includes(provider as ManagedQuotaProvider); +} + // ============================================================================ // QUOTA CACHE (30-second TTL) // ============================================================================ interface CacheEntry { - result: QuotaResult; + result: ManagedQuotaResult; timestamp: number; } @@ -38,7 +49,7 @@ const CACHE_TTL_MS = 30_000; // 30 seconds const quotaCache = new Map(); // Request deduplication: track in-flight fetch promises to avoid parallel duplicate requests -const pendingFetches = new Map>(); +const pendingFetches = new Map>(); function getCacheKey(provider: CLIProxyProvider, accountId: string): string { return `${provider}:${accountId}`; @@ -47,7 +58,10 @@ function getCacheKey(provider: CLIProxyProvider, accountId: string): string { /** * Get cached quota result if still valid */ -export function getCachedQuota(provider: CLIProxyProvider, accountId: string): QuotaResult | null { +export function getCachedQuota( + provider: CLIProxyProvider, + accountId: string +): ManagedQuotaResult | null { const key = getCacheKey(provider, accountId); const entry = quotaCache.get(key); @@ -67,7 +81,7 @@ export function getCachedQuota(provider: CLIProxyProvider, accountId: string): Q export function setCachedQuota( provider: CLIProxyProvider, accountId: string, - result: QuotaResult + result: ManagedQuotaResult ): void { const key = getCacheKey(provider, accountId); quotaCache.set(key, { result, timestamp: Date.now() }); @@ -85,10 +99,10 @@ export function clearQuotaCache(): void { * If a fetch for this account is already in progress, return the existing promise */ async function fetchQuotaWithDedup( - provider: CLIProxyProvider, + provider: ManagedQuotaProvider, accountId: string, verbose = false -): Promise { +): Promise { const key = getCacheKey(provider, accountId); // Check if fetch already in progress @@ -98,12 +112,22 @@ async function fetchQuotaWithDedup( } // Start new fetch and track it - const fetchPromise = fetchAccountQuota(provider, accountId, verbose) + const fetchPromise = fetchManagedQuota(provider, accountId, verbose) .then((result) => { setCachedQuota(provider, accountId, result); return result; }) - .catch((): QuotaResult => { + .catch((): ManagedQuotaResult => { + if (provider === 'claude') { + return { + success: false, + windows: [], + coreUsage: { fiveHour: null, weekly: null }, + lastUpdated: Date.now(), + error: 'Failed to fetch Claude quota', + accountId, + }; + } return { success: false, models: [], lastUpdated: Date.now() }; }) .finally(() => { @@ -114,6 +138,17 @@ async function fetchQuotaWithDedup( return fetchPromise; } +async function fetchManagedQuota( + provider: ManagedQuotaProvider, + accountId: string, + verbose: boolean +): Promise { + if (provider === 'claude') { + return fetchClaudeQuota(accountId, verbose); + } + return fetchAccountQuota(provider, accountId, verbose); +} + // ============================================================================ // COOLDOWN TRACKING // ============================================================================ @@ -201,17 +236,40 @@ export interface PreflightResult { quotaPercent?: number | null; } -/** - * Calculate average quota percentage from models - */ -function calculateAverageQuota(quota: QuotaResult): number | null { - if (!quota.success || quota.models.length === 0) { - return null; // No data available - } - const total = quota.models.reduce((sum, m) => sum + m.percentage, 0); +function calculateAgyQuotaPercent(quota: QuotaResult): number | null { + if (!quota.success || quota.models.length === 0) return null; + const total = quota.models.reduce((sum, model) => sum + model.percentage, 0); return total / quota.models.length; } +function calculateClaudeQuotaPercent(quota: ClaudeQuotaResult): number | null { + if (!quota.success) return null; + + const coreWindows = [quota.coreUsage?.fiveHour, quota.coreUsage?.weekly].filter( + (window): window is NonNullable => !!window + ); + if (coreWindows.length > 0) { + return Math.min(...coreWindows.map((window) => window.remainingPercent)); + } + + const usageWindows = quota.windows.filter((window) => window.rateLimitType !== 'overage'); + if (usageWindows.length > 0) { + return Math.min(...usageWindows.map((window) => window.remainingPercent)); + } + + return null; +} + +/** + * Calculate normalized quota percentage for managed providers. + */ +function calculateQuotaPercent(quota: ManagedQuotaResult): number | null { + if ('models' in quota) { + return calculateAgyQuotaPercent(quota); + } + return calculateClaudeQuotaPercent(quota); +} + /** * Find healthy account with remaining quota * Respects tier priority and skips paused/cooldown accounts @@ -220,6 +278,10 @@ export async function findHealthyAccount( provider: CLIProxyProvider, exclude: string[] ): Promise<{ id: string; tier: string; lastQuota: number } | null> { + if (!isManagedQuotaProvider(provider)) { + return null; + } + const config = loadOrCreateUnifiedConfig(); const tierPriority = config.quota_management?.auto?.tier_priority ?? ['ultra', 'pro', 'free']; const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5; @@ -243,7 +305,7 @@ export async function findHealthyAccount( quota = await fetchQuotaWithDedup(provider, account.id); } - const avgQuota = calculateAverageQuota(quota) ?? 0; + const avgQuota = calculateQuotaPercent(quota) ?? 0; return { id: account.id, @@ -276,7 +338,7 @@ export async function findHealthyAccount( * Find and switch to a healthy account */ async function findAndSwitch( - provider: CLIProxyProvider, + provider: ManagedQuotaProvider, excludeAccountId: string, reason: string ): Promise { @@ -310,12 +372,12 @@ async function findAndSwitch( * Checks if default account has sufficient quota, auto-switches if needed. * Respects paused accounts, tier priority, and cooldown settings. * - * @param provider - CLIProxy provider (only 'agy' supports quota) + * @param provider - CLIProxy provider * @returns PreflightResult with account to use and any switch info */ export async function preflightCheck(provider: CLIProxyProvider): Promise { - // Only Antigravity supports quota checking - if (provider !== 'agy') { + // Only providers with quota-based account rotation need preflight checks. + if (!isManagedQuotaProvider(provider)) { const defaultAccount = getDefaultAccount(provider); return { proceed: true, accountId: defaultAccount?.id || '' }; } @@ -359,8 +421,18 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise { let quota = getCachedQuota(provider, account.id); - if (!quota && provider === 'agy') { + if (!quota && isManagedQuotaProvider(provider)) { quota = await fetchQuotaWithDedup(provider, account.id); } - const avgQuota = quota ? calculateAverageQuota(quota) : null; + const avgQuota = quota ? calculateQuotaPercent(quota) : null; return { account, @@ -436,7 +508,7 @@ let monitorStopped = false; * Uses setTimeout chain (not setInterval) for dynamic interval switching. */ function scheduleNextPoll( - provider: CLIProxyProvider, + provider: ManagedQuotaProvider, accountId: string, monitorConfig: RuntimeMonitorConfig, intervalMs: number @@ -448,7 +520,7 @@ function scheduleNextPoll( try { const quota = await fetchQuotaWithDedup(provider, accountId); if (monitorStopped) return; // Re-check after async fetch - const avgQuota = calculateAverageQuota(quota) ?? 100; + const avgQuota = calculateQuotaPercent(quota) ?? 100; if (avgQuota <= monitorConfig.exhaustion_threshold) { // EXHAUSTED: cooldown + switch default + stop monitoring. @@ -502,12 +574,12 @@ function scheduleNextPoll( * critical_interval (60s) when quota hits warn_threshold (20%). * Auto-stops on exhaustion or when stopQuotaMonitor() is called. * - * Only monitors 'agy' provider (only one with quota API). + * Only monitors providers with quota-based account rotation. * No-op for other providers, manual mode, or if disabled in config. */ export function startQuotaMonitor(provider: CLIProxyProvider, accountId: string): void { - // Only Antigravity supports quota - if (provider !== 'agy') return; + // Only managed providers support runtime quota monitoring. + if (!isManagedQuotaProvider(provider)) return; // Prevent duplicate monitors if (monitorTimer) return; diff --git a/src/cliproxy/quota-types.ts b/src/cliproxy/quota-types.ts index 5966212b..51fba8f6 100644 --- a/src/cliproxy/quota-types.ts +++ b/src/cliproxy/quota-types.ts @@ -2,11 +2,11 @@ * Shared Quota Type Definitions * * Unified types for multi-provider quota system. - * Supports Antigravity, Codex, Gemini CLI, and GitHub Copilot OAuth providers. + * Supports Antigravity, Codex, Claude, Gemini CLI, and GitHub Copilot OAuth providers. */ /** Supported quota providers */ -export type QuotaProvider = 'agy' | 'codex' | 'gemini' | 'ghcp'; +export type QuotaProvider = 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp'; // Re-export Antigravity types for unified access export type { QuotaResult as AntigravityQuotaResult } from './quota-fetcher'; @@ -71,6 +71,82 @@ export interface CodexQuotaResult { isForbidden?: boolean; } +/** + * Claude policy limit window (5h/weekly/overage) + */ +export interface ClaudeQuotaWindow { + /** Source identifier: five_hour, seven_day, seven_day_opus, seven_day_sonnet, overage, ... */ + rateLimitType: string; + /** Human-friendly label for UI/CLI display */ + label: string; + /** Upstream status: allowed, allowed_warning, rejected */ + status: string; + /** Utilization ratio (0-1) reported by API; null when unavailable */ + utilization: number | null; + /** Utilization as percentage (0-100) */ + usedPercent: number; + /** Remaining percentage (100 - usedPercent) */ + remainingPercent: number; + /** ISO timestamp when this window resets, null if unknown */ + resetAt: string | null; + /** Whether usage surpassed threshold for this window (if provided by API) */ + surpassedThreshold?: boolean; + /** Optional severity hint (warning/error) */ + severity?: string; + /** Overage status when provided by API */ + overageStatus?: string; + /** ISO timestamp when overage resets, if provided */ + overageResetsAt?: string | null; + /** Why overage is disabled, if provided */ + overageDisabledReason?: string | null; + /** Whether account is currently using overage */ + isUsingOverage?: boolean; + /** Whether extra usage is enabled */ + hasExtraUsageEnabled?: boolean; +} + +/** Core Claude usage window (5h/weekly) extracted from policy limits */ +export interface ClaudeCoreUsageWindow { + /** Source rate limit type */ + rateLimitType: string; + /** Display label */ + label: string; + /** Percentage remaining (0-100) */ + remainingPercent: number; + /** ISO timestamp when quota resets, null if unknown */ + resetAt: string | null; + /** Raw status string */ + status: string; +} + +/** Core Claude usage summary with explicit 5h + weekly windows */ +export interface ClaudeCoreUsageSummary { + /** Short-cycle usage limit window (5h/session) */ + fiveHour: ClaudeCoreUsageWindow | null; + /** Long-cycle usage limit window (weekly) */ + weekly: ClaudeCoreUsageWindow | null; +} + +/** + * Claude quota fetch result + */ +export interface ClaudeQuotaResult { + /** Whether fetch succeeded */ + success: boolean; + /** Policy limit windows */ + windows: ClaudeQuotaWindow[]; + /** Explicit core usage windows (5h + weekly) */ + coreUsage?: ClaudeCoreUsageSummary; + /** Timestamp of fetch */ + lastUpdated: number; + /** Error message if fetch failed */ + error?: string; + /** Account ID (email) this quota belongs to */ + accountId?: string; + /** True if token is expired/invalid and re-auth is required */ + needsReauth?: boolean; +} + /** * Gemini CLI quota bucket (grouped by model series and token type) */ diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index 7f3219d9..780fee69 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -54,8 +54,8 @@ export async function showHelp(): Promise { ['default ', 'Set default account for rotation'], ['pause ', 'Pause account (skip in rotation)'], ['resume ', 'Resume paused account'], - ['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'], - ['quota --provider ', 'Filter by provider (agy|codex|gemini|ghcp)'], + ['quota', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'], + ['quota --provider ', 'Filter by provider (agy|codex|claude|gemini|ghcp)'], ], ], [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 60a24aec..39327bcc 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -80,10 +80,10 @@ function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend { /** * Parse --provider flag from args for quota command * Returns the provider filter value and remaining args - * Accepts: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all + * Accepts: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all */ function parseProviderArg(args: string[]): { - provider: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all'; + provider: 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp' | 'all'; remainingArgs: string[]; } { const providerIdx = args.indexOf('--provider'); @@ -97,27 +97,34 @@ function parseProviderArg(args: string[]): { // Handle empty value if (!value) { console.error( - 'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all' + 'Warning: --provider requires a value. Valid options: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all' ); return { provider: 'all', remainingArgs }; } - // Normalize gemini-cli to gemini + // Normalize aliases const normalized = - value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value; + value === 'gemini-cli' + ? 'gemini' + : value === 'github-copilot' + ? 'ghcp' + : value === 'anthropic' + ? 'claude' + : value; if ( normalized !== 'agy' && normalized !== 'codex' && + normalized !== 'claude' && normalized !== 'gemini' && normalized !== 'ghcp' && normalized !== 'all' ) { console.error( - `Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all` + `Invalid provider '${value}'. Valid options: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all` ); return { provider: 'all', remainingArgs }; } return { - provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all', + provider: normalized as 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp' | 'all', remainingArgs, }; } @@ -127,29 +134,36 @@ function parseProviderArg(args: string[]): { // Warn if no value or value looks like another flag if (!rawValue || rawValue.startsWith('-')) { console.error( - 'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all' + 'Warning: --provider requires a value. Valid options: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all' ); } const value = rawValue?.toLowerCase() || 'all'; const remainingArgs = [...args]; remainingArgs.splice(providerIdx, 2); - // Normalize gemini-cli to gemini + // Normalize aliases const normalized = - value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value; + value === 'gemini-cli' + ? 'gemini' + : value === 'github-copilot' + ? 'ghcp' + : value === 'anthropic' + ? 'claude' + : value; if ( normalized !== 'agy' && normalized !== 'codex' && + normalized !== 'claude' && normalized !== 'gemini' && normalized !== 'ghcp' && normalized !== 'all' ) { console.error( - `Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all` + `Invalid provider '${value}'. Valid options: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all` ); return { provider: 'all', remainingArgs }; } return { - provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all', + provider: normalized as 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp' | 'all', remainingArgs, }; } diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index d3445377..c53a9ab1 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -18,10 +18,12 @@ import { } from '../../cliproxy/account-manager'; import { fetchAllProviderQuotas } from '../../cliproxy/quota-fetcher'; import { fetchAllCodexQuotas } from '../../cliproxy/quota-fetcher-codex'; +import { fetchAllClaudeQuotas } from '../../cliproxy/quota-fetcher-claude'; import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota-fetcher-gemini-cli'; import { fetchAllGhcpQuotas } from '../../cliproxy/quota-fetcher-ghcp'; import type { CodexQuotaResult, + ClaudeQuotaResult, GeminiCliQuotaResult, GhcpQuotaResult, } from '../../cliproxy/quota-types'; @@ -378,6 +380,181 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR } } +interface ClaudeDisplayWindow { + rateLimitType: string; + label: string; + remainingPercent: number; + resetAt: string | null; + status: string; +} + +function getClaudeWindowDisplayLabel( + window: Pick +): string { + switch (window.rateLimitType) { + case 'five_hour': + return '5h usage limit'; + case 'seven_day': + return 'Weekly usage limit'; + case 'seven_day_opus': + return 'Weekly usage (Opus)'; + case 'seven_day_sonnet': + return 'Weekly usage (Sonnet)'; + case 'seven_day_oauth_apps': + return 'Weekly usage (OAuth apps)'; + case 'seven_day_cowork': + return 'Weekly usage (Cowork)'; + case 'overage': + return 'Extra usage'; + default: + return window.label; + } +} + +function toClaudeDisplayWindow(window: ClaudeQuotaResult['windows'][number]): ClaudeDisplayWindow { + return { + rateLimitType: window.rateLimitType, + label: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + status: window.status, + }; +} + +function toClaudeCoreDisplayWindow( + window: NonNullable['fiveHour'] +): ClaudeDisplayWindow | null { + if (!window) return null; + return { + rateLimitType: window.rateLimitType, + label: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + status: window.status, + }; +} + +function pickClaudeWeeklyWindow( + windows: ClaudeQuotaResult['windows'] +): ClaudeQuotaResult['windows'][number] | null { + const weeklyCandidates = windows.filter((window) => + [ + 'seven_day', + 'seven_day_opus', + 'seven_day_sonnet', + 'seven_day_oauth_apps', + 'seven_day_cowork', + ].includes(window.rateLimitType) + ); + if (weeklyCandidates.length === 0) return null; + + return [...weeklyCandidates].sort((a, b) => { + if (a.remainingPercent !== b.remainingPercent) { + return a.remainingPercent - b.remainingPercent; + } + const aReset = a.resetAt ? new Date(a.resetAt).getTime() : Number.POSITIVE_INFINITY; + const bReset = b.resetAt ? new Date(b.resetAt).getTime() : Number.POSITIVE_INFINITY; + return aReset - bReset; + })[0]; +} + +function getClaudeCoreUsageWindows(quota: ClaudeQuotaResult): { + fiveHourWindow: ClaudeDisplayWindow | null; + weeklyWindow: ClaudeDisplayWindow | null; +} { + const coreUsage = quota.coreUsage; + const fiveHourFromCore = toClaudeCoreDisplayWindow(coreUsage?.fiveHour ?? null); + const weeklyFromCore = toClaudeCoreDisplayWindow(coreUsage?.weekly ?? null); + if (fiveHourFromCore || weeklyFromCore) { + return { + fiveHourWindow: fiveHourFromCore, + weeklyWindow: weeklyFromCore, + }; + } + + const fiveHourPolicy = + quota.windows.find((window) => window.rateLimitType === 'five_hour') ?? null; + const weeklyPolicy = pickClaudeWeeklyWindow(quota.windows); + + return { + fiveHourWindow: fiveHourPolicy ? toClaudeDisplayWindow(fiveHourPolicy) : null, + weeklyWindow: weeklyPolicy ? toClaudeDisplayWindow(weeklyPolicy) : null, + }; +} + +function displayClaudeQuotaSection(results: { account: string; quota: ClaudeQuotaResult }[]): void { + console.log(subheader(`Claude (${results.length} account${results.length !== 1 ? 's' : ''})`)); + console.log(''); + + for (const { account, quota } of results) { + const accountInfo = findAccountByQuery('claude', account); + const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : ''; + + if (!quota.success) { + console.log(` ${fail(account)}${defaultMark}`); + console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + console.log(''); + continue; + } + + const { fiveHourWindow, weeklyWindow } = getClaudeCoreUsageWindows(quota); + const coreWindows = [fiveHourWindow, weeklyWindow].filter( + (window, index, arr): window is ClaudeDisplayWindow => + !!window && arr.indexOf(window) === index + ); + const statusWindows = + coreWindows.length > 0 ? coreWindows : quota.windows.map(toClaudeDisplayWindow); + const minQuota = + statusWindows.length > 0 + ? Math.min(...statusWindows.map((window) => window.remainingPercent)) + : null; + const statusIcon = + minQuota === null ? info('') : minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail(''); + + console.log(` ${statusIcon}${account}${defaultMark}`); + + const resetParts: string[] = []; + if (fiveHourWindow?.resetAt) + resetParts.push(`5h ${formatResetTimeISO(fiveHourWindow.resetAt)}`); + if (weeklyWindow?.resetAt) + resetParts.push(`weekly ${formatResetTimeISO(weeklyWindow.resetAt)}`); + if (resetParts.length > 0) { + console.log(` ${dim(`Reset schedule: ${resetParts.join(' | ')}`)}`); + } + + const orderedWindows = [...coreWindows, ...quota.windows.map(toClaudeDisplayWindow)].filter( + (window, index, arr) => + arr.findIndex( + (candidate) => + candidate.rateLimitType === window.rateLimitType && + candidate.resetAt === window.resetAt && + candidate.status === window.status + ) === index + ); + + if (orderedWindows.length === 0) { + console.log(` ${dim('Policy limits unavailable for this account')}`); + console.log(''); + continue; + } + + for (const window of orderedWindows) { + const bar = formatQuotaBar(window.remainingPercent); + const resetLabel = window.resetAt ? dim(` Resets ${formatResetTimeISO(window.resetAt)}`) : ''; + const statusLabel = + window.status === 'rejected' + ? dim(' [blocked]') + : window.status === 'allowed_warning' + ? dim(' [warning]') + : ''; + console.log( + ` ${getClaudeWindowDisplayLabel(window).padEnd(24)} ${bar} ${window.remainingPercent.toFixed(0)}%${statusLabel}${resetLabel}` + ); + } + console.log(''); + } +} + function displayGeminiCliQuotaSection( results: { account: string; quota: GeminiCliQuotaResult }[] ): void { @@ -483,7 +660,7 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes export async function handleQuotaStatus( verbose = false, - providerFilter: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all' = 'all' + providerFilter: 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp' | 'all' = 'all' ): Promise { await initUI(); console.log(header('Quota Status')); @@ -492,15 +669,17 @@ export async function handleQuotaStatus( const shouldFetch = { agy: providerFilter === 'all' || providerFilter === 'agy', codex: providerFilter === 'all' || providerFilter === 'codex', + claude: providerFilter === 'all' || providerFilter === 'claude', gemini: providerFilter === 'all' || providerFilter === 'gemini', ghcp: providerFilter === 'all' || providerFilter === 'ghcp', }; console.log(dim('Fetching quotas...')); - const [agyResults, codexResults, geminiResults, ghcpResults] = await Promise.all([ + const [agyResults, codexResults, claudeResults, geminiResults, ghcpResults] = await Promise.all([ shouldFetch.agy ? fetchAllProviderQuotas('agy', verbose) : null, shouldFetch.codex ? fetchAllCodexQuotas(verbose) : null, + shouldFetch.claude ? fetchAllClaudeQuotas(verbose) : null, shouldFetch.gemini ? fetchAllGeminiCliQuotas(verbose) : null, shouldFetch.ghcp ? fetchAllGhcpQuotas(verbose) : null, ]); @@ -525,6 +704,15 @@ export async function handleQuotaStatus( console.log(''); } + if (claudeResults && claudeResults.length > 0) { + displayClaudeQuotaSection(claudeResults); + } else if (shouldFetch.claude) { + console.log(subheader('Claude (0 accounts)')); + console.log(info('No Claude accounts configured')); + console.log(` Run: ${color('ccs claude --auth', 'command')} to authenticate`); + console.log(''); + } + if (geminiResults && geminiResults.length > 0) { displayGeminiCliQuotaSection(geminiResults); } else if (shouldFetch.gemini) { diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 571fbc86..8fd2611e 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -14,11 +14,13 @@ import { } from '../../cliproxy/stats-fetcher'; import { fetchAccountQuota } from '../../cliproxy/quota-fetcher'; import { fetchCodexQuota } from '../../cliproxy/quota-fetcher-codex'; +import { fetchClaudeQuota } from '../../cliproxy/quota-fetcher-claude'; import { fetchGeminiCliQuota } from '../../cliproxy/quota-fetcher-gemini-cli'; import { fetchGhcpQuota } from '../../cliproxy/quota-fetcher-ghcp'; import { getCachedQuota, setCachedQuota } from '../../cliproxy/quota-response-cache'; import type { CodexQuotaResult, + ClaudeQuotaResult, GeminiCliQuotaResult, GhcpQuotaResult, } from '../../cliproxy/quota-types'; @@ -83,6 +85,20 @@ function shouldCacheGeminiQuotaResult(result: GeminiCliQuotaResult): boolean { return false; } +function shouldCacheClaudeQuotaResult(result: ClaudeQuotaResult): boolean { + if (result.success) return true; + if (result.needsReauth) return true; + + const msg = (result.error || '').toLowerCase(); + if (!msg) return false; + if (msg.includes('timeout')) return false; + if (msg.includes('rate limited')) return false; + if (msg.includes('api error: 5')) return false; + if (msg.includes('fetch failed')) return false; + + return false; +} + function shouldCacheGhcpQuotaResult(result: GhcpQuotaResult): boolean { if (result.success) return true; if (result.needsReauth) return true; @@ -159,7 +175,7 @@ const handleStatsRequest = async (_req: Request, res: Response): Promise = if (!running) { res.status(503).json({ error: 'CLIProxy Plus not running', - message: 'Start a CLIProxy session (gemini, codex, agy, ghcp) to collect stats', + message: 'Start a CLIProxy session (gemini, codex, claude, agy, ghcp) to collect stats', }); return; } @@ -292,7 +308,7 @@ router.get('/models', async (_req: Request, res: Response): Promise => { if (!running) { res.status(503).json({ error: 'CLIProxy Plus not running', - message: 'Start a CLIProxy session (gemini, codex, agy) to fetch available models', + message: 'Start a CLIProxy session (gemini, codex, claude, agy) to fetch available models', }); return; } @@ -610,6 +626,47 @@ router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promi } }); +/** + * GET /api/cliproxy/quota/claude/:accountId - Get Claude quota for a specific account + * Returns: ClaudeQuotaResult with policy windows (5h + weekly) + * Caching: 2 minute TTL to reduce Anthropic API calls + */ +router.get('/quota/claude/:accountId', async (req: Request, res: Response): Promise => { + const { accountId } = req.params; + + // Validate accountId - prevent path traversal + if ( + !accountId || + accountId.includes('..') || + accountId.includes('/') || + accountId.includes('\\') + ) { + res.status(400).json({ error: 'Invalid account ID' }); + return; + } + + try { + // Check cache first + const cached = getCachedQuota('claude', accountId); + if (cached) { + res.json({ ...cached, cached: true }); + return; + } + + // Fetch from external API + const result = await fetchClaudeQuota(accountId); + + // Cache successful and stable failure states; skip transient network failures. + if (shouldCacheClaudeQuotaResult(result)) { + setCachedQuota('claude', accountId, result); + } + + res.json(result); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + /** * GET /api/cliproxy/quota/gemini/:accountId - Get Gemini quota for a specific account * Returns: GeminiCliQuotaResult with quota buckets @@ -695,7 +752,7 @@ router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promis /** * GET /api/cliproxy/quota/:provider/:accountId - Get quota for a specific account (generic) * Returns: QuotaResult with model quotas and reset times - * NOTE: This generic route MUST come after specific routes (codex, gemini, ghcp) + * NOTE: This generic route MUST come after specific routes (codex, claude, gemini, ghcp) * Caching: 2 minute TTL to reduce external API calls */ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise => { diff --git a/tests/unit/cliproxy/quota-fetcher-claude.test.ts b/tests/unit/cliproxy/quota-fetcher-claude.test.ts new file mode 100644 index 00000000..1e591067 --- /dev/null +++ b/tests/unit/cliproxy/quota-fetcher-claude.test.ts @@ -0,0 +1,257 @@ +/** + * Claude Quota Fetcher Unit Tests + * + * Covers policy limits parsing and auth/token edge cases. + */ + +import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { + buildClaudeQuotaWindows, + buildClaudeCoreUsageSummary, + fetchClaudeQuota, + fetchAllClaudeQuotas, +} from '../../../src/cliproxy/quota-fetcher-claude'; +import { sanitizeEmail } from '../../../src/cliproxy/auth-utils'; + +let tmpDir: string; +let originalCcsHome: string | undefined; +let originalFetch: typeof fetch; + +function createClaudeAccount( + accountId: string, + tokenPayload: Record, + tokenPrefix: 'claude' | 'anthropic' = 'claude' +): void { + const cliproxyDir = path.join(tmpDir, '.ccs', 'cliproxy'); + const authDir = path.join(cliproxyDir, 'auth'); + const sanitized = sanitizeEmail(accountId); + const tokenFile = `${tokenPrefix}-${sanitized}.json`; + + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(tokenPayload, null, 2)); + fs.writeFileSync( + path.join(cliproxyDir, 'accounts.json'), + JSON.stringify( + { + version: 1, + providers: { + claude: { + default: accountId, + accounts: { + [accountId]: { + email: accountId, + tokenFile, + createdAt: '2026-02-20T00:00:00.000Z', + lastUsedAt: '2026-02-20T00:00:00.000Z', + }, + }, + }, + }, + }, + null, + 2 + ) + ); +} + +beforeEach(() => { + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-claude-quota-test-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tmpDir; + originalFetch = global.fetch; +}); + +afterEach(() => { + global.fetch = originalFetch; + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + fs.rmSync(tmpDir, { recursive: true, force: true }); +}); + +describe('Claude Quota Fetcher', () => { + describe('buildClaudeQuotaWindows', () => { + it('parses restrictions array payload', () => { + const windows = buildClaudeQuotaWindows({ + restrictions: [ + { + rateLimitType: 'five_hour', + utilization: 0.4, + resetsAt: '2026-02-28T10:00:00Z', + status: 'allowed', + }, + { + rateLimitType: 'seven_day', + utilization: 0.8, + resetsAt: '2026-03-06T10:00:00Z', + status: 'allowed_warning', + }, + ], + }); + + expect(windows).toHaveLength(2); + expect(windows[0].rateLimitType).toBe('five_hour'); + expect(windows[0].remainingPercent).toBe(60); + expect(windows[1].rateLimitType).toBe('seven_day'); + expect(windows[1].remainingPercent).toBe(20); + }); + + it('parses object-map restrictions payload', () => { + const windows = buildClaudeQuotaWindows({ + restrictions: { + five_hour: { + utilization: 0.25, + resetsAt: '2026-02-28T10:00:00Z', + status: 'allowed', + }, + seven_day_opus: { + utilization: 0.9, + resetsAt: '2026-03-06T10:00:00Z', + status: 'allowed_warning', + }, + }, + }); + + expect(windows.map((window) => window.rateLimitType)).toEqual( + expect.arrayContaining(['five_hour', 'seven_day_opus']) + ); + }); + }); + + describe('buildClaudeCoreUsageSummary', () => { + it('selects most restrictive weekly window', () => { + const summary = buildClaudeCoreUsageSummary([ + { + rateLimitType: 'five_hour', + label: 'Session limit', + status: 'allowed', + utilization: 0.35, + usedPercent: 35, + remainingPercent: 65, + resetAt: '2026-02-28T10:00:00Z', + }, + { + rateLimitType: 'seven_day', + label: 'Weekly limit', + status: 'allowed', + utilization: 0.45, + usedPercent: 45, + remainingPercent: 55, + resetAt: '2026-03-06T10:00:00Z', + }, + { + rateLimitType: 'seven_day_opus', + label: 'Opus limit', + status: 'allowed_warning', + utilization: 0.85, + usedPercent: 85, + remainingPercent: 15, + resetAt: '2026-03-06T12:00:00Z', + }, + ]); + + expect(summary.fiveHour?.rateLimitType).toBe('five_hour'); + expect(summary.weekly?.rateLimitType).toBe('seven_day_opus'); + expect(summary.weekly?.remainingPercent).toBe(15); + }); + }); + + describe('fetchClaudeQuota', () => { + it('fetches and normalizes policy limits response', async () => { + createClaudeAccount('claude-main@example.com', { + access_token: 'claude-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + global.fetch = mock((url: string, options?: RequestInit) => { + expect(url).toBe('https://api.anthropic.com/api/claude_code/policy_limits'); + expect(options?.method).toBe('GET'); + expect(options?.headers).toMatchObject({ + Authorization: 'Bearer claude-token', + Accept: 'application/json', + }); + + return Promise.resolve( + new Response( + JSON.stringify({ + restrictions: [ + { + rateLimitType: 'five_hour', + utilization: 0.5, + resetsAt: '2026-03-01T01:00:00Z', + status: 'allowed', + }, + { + rateLimitType: 'seven_day', + utilization: 0.75, + resetsAt: '2026-03-07T01:00:00Z', + status: 'allowed_warning', + }, + ], + }), + { + status: 200, + headers: { 'Content-Type': 'application/json' }, + } + ) + ); + }) as typeof fetch; + + const result = await fetchClaudeQuota('claude-main@example.com'); + + expect(result.success).toBe(true); + expect(result.accountId).toBe('claude-main@example.com'); + expect(result.windows).toHaveLength(2); + expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(50); + expect(result.coreUsage?.weekly?.remainingPercent).toBe(25); + + const all = await fetchAllClaudeQuotas(); + expect(all).toHaveLength(1); + expect(all[0].account).toBe('claude-main@example.com'); + expect(all[0].quota.success).toBe(true); + }); + + it('returns needsReauth on 401 responses', async () => { + createClaudeAccount( + 'claude-auth@example.com', + { + access_token: 'expired-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'anthropic', + }, + 'anthropic' + ); + + global.fetch = mock(() => Promise.resolve(new Response('', { status: 401 }))) as typeof fetch; + + const result = await fetchClaudeQuota('claude-auth@example.com'); + + expect(result.success).toBe(false); + expect(result.needsReauth).toBe(true); + expect(result.error).toContain('Authentication'); + }); + + it('fails fast when auth file has no token', async () => { + createClaudeAccount('claude-missing@example.com', { + access_token: ' ', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + const fetchMock = mock(() => Promise.resolve(new Response('', { status: 200 }))); + global.fetch = fetchMock as typeof fetch; + + const result = await fetchClaudeQuota('claude-missing@example.com'); + + expect(result.success).toBe(false); + expect(result.error).toContain('Auth file not found'); + expect(fetchMock).toHaveBeenCalledTimes(0); + }); + }); +}); diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 4fdf42a8..599c1c1a 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -8,6 +8,7 @@ import { getCodexQuotaBreakdown, getProviderMinQuota, getProviderResetTime, + isClaudeQuotaResult, isCodexQuotaResult, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; @@ -90,7 +91,7 @@ export function AccountCard({ const borderColor = getBorderColorStyle(zone, account.color); const connectorPosition = CONNECTOR_POSITION_MAP[zone]; - // Quota for CLIProxy accounts (agy, codex, gemini) + // Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp) const isCliproxyProvider = QUOTA_SUPPORTED_PROVIDERS.includes( account.provider as QuotaSupportedProvider ); @@ -111,6 +112,40 @@ export function AccountCard({ { label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null }, { label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null }, ].filter((row): row is { label: string; value: number } => row.value !== null); + const claudeQuotaRows = + account.provider === 'claude' && quota && isClaudeQuotaResult(quota) + ? [ + { + label: '5h', + value: + quota.coreUsage?.fiveHour?.remainingPercent ?? + quota.windows.find((window) => window.rateLimitType === 'five_hour') + ?.remainingPercent ?? + null, + }, + { + label: 'Wk', + value: + quota.coreUsage?.weekly?.remainingPercent ?? + quota.windows.find((window) => + [ + 'seven_day', + 'seven_day_opus', + 'seven_day_sonnet', + 'seven_day_oauth_apps', + 'seven_day_cowork', + ].includes(window.rateLimitType) + )?.remainingPercent ?? + null, + }, + ].filter((row): row is { label: string; value: number } => row.value !== null) + : []; + const compactQuotaRows = + account.provider === 'codex' + ? codexQuotaRows + : account.provider === 'claude' + ? claudeQuotaRows + : []; const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null; // Tier badge (AGY only) - show P for Pro, U for Ultra @@ -215,7 +250,7 @@ export function AccountCard({ failure={account.failureCount} showDetails={showDetails} /> - {/* Quota bar for CLIProxy accounts (agy, codex, gemini) */} + {/* Quota bar for CLIProxy accounts */} {isCliproxyProvider && (
{quotaLoading ? ( @@ -245,9 +280,9 @@ export function AccountCard({ {minQuotaLabel}%
- {account.provider === 'codex' && codexQuotaRows.length > 0 && ( + {compactQuotaRows.length > 0 && (
- {codexQuotaRows.map((row) => ( + {compactQuotaRows.map((row) => ( {row.label} {row.value}% diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 17dafc52..01aa19b3 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -36,6 +36,7 @@ import { getCodexQuotaBreakdown, getProviderMinQuota, getProviderResetTime, + isClaudeQuotaResult, isCodexQuotaResult, } from '@/lib/utils'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; @@ -130,6 +131,40 @@ export function AccountItem({ { label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null }, { label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null }, ].filter((row): row is { label: string; value: number } => row.value !== null); + const claudeQuotaRows = + account.provider === 'claude' && quota && isClaudeQuotaResult(quota) + ? [ + { + label: '5h', + value: + quota.coreUsage?.fiveHour?.remainingPercent ?? + quota.windows.find((window) => window.rateLimitType === 'five_hour') + ?.remainingPercent ?? + null, + }, + { + label: 'Weekly', + value: + quota.coreUsage?.weekly?.remainingPercent ?? + quota.windows.find((window) => + [ + 'seven_day', + 'seven_day_opus', + 'seven_day_sonnet', + 'seven_day_oauth_apps', + 'seven_day_cowork', + ].includes(window.rateLimitType) + )?.remainingPercent ?? + null, + }, + ].filter((row): row is { label: string; value: number } => row.value !== null) + : []; + const dualWindowQuotaRows = + account.provider === 'codex' + ? codexQuotaRows + : account.provider === 'claude' + ? claudeQuotaRows + : []; const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null; return ( @@ -353,9 +388,9 @@ export function AccountItem({ - {account.provider === 'codex' && codexQuotaRows.length > 0 ? ( + {dualWindowQuotaRows.length > 0 ? (
- {codexQuotaRows.map((row) => ( + {dualWindowQuotaRows.map((row) => (
{row.label} diff --git a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx index 64ed3ef5..cef6301e 100644 --- a/ui/src/components/cliproxy/provider-editor/accounts-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/accounts-section.tsx @@ -35,7 +35,7 @@ interface AccountsSectionProps { /** Bulk resume mutation in progress */ isBulkResuming?: boolean; privacyMode?: boolean; - /** Show quota bars for accounts (only applicable for 'agy' provider) */ + /** Show quota bars for accounts when provider supports quota API */ showQuota?: boolean; /** Kiro-specific: show "use normal browser" toggle */ isKiro?: boolean; diff --git a/ui/src/components/cliproxy/provider-editor/types.ts b/ui/src/components/cliproxy/provider-editor/types.ts index 51d980ec..0c154ca6 100644 --- a/ui/src/components/cliproxy/provider-editor/types.ts +++ b/ui/src/components/cliproxy/provider-editor/types.ts @@ -61,7 +61,7 @@ export interface AccountItemProps { /** Solo mode mutation in progress */ isSoloingAccount?: boolean; privacyMode?: boolean; - /** Show quota bar (only for 'agy' provider) */ + /** Show quota bar for providers with quota API support */ showQuota?: boolean; /** Enable checkbox for multi-select */ selectable?: boolean; diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 8030c16a..4dbac2d6 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -13,6 +13,7 @@ import { getModelsWithTiers, groupModelsByTier, isAgyQuotaResult, + isClaudeQuotaResult, isCodexQuotaResult, isGeminiQuotaResult, isGhcpQuotaResult, @@ -35,6 +36,27 @@ function formatPlanLabel(planType: string | null | undefined): string | null { return normalized.length > 0 ? normalized.join(' ') : planType; } +function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): string { + switch (rateLimitType) { + case 'five_hour': + return '5h usage limit'; + case 'seven_day': + return 'Weekly usage limit'; + case 'seven_day_opus': + return 'Weekly usage (Opus)'; + case 'seven_day_sonnet': + return 'Weekly usage (Sonnet)'; + case 'seven_day_oauth_apps': + return 'Weekly usage (OAuth apps)'; + case 'seven_day_cowork': + return 'Weekly usage (Cowork)'; + case 'overage': + return 'Extra usage'; + default: + return fallback; + } +} + /** * Renders provider-specific quota tooltip content * Uses type guards for proper TypeScript narrowing @@ -115,6 +137,74 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro ); } + // Claude provider tooltip + if (isClaudeQuotaResult(quota)) { + const coreWindows = [quota.coreUsage?.fiveHour, quota.coreUsage?.weekly] + .filter((window): window is NonNullable => !!window) + .map((window) => ({ + rateLimitType: window.rateLimitType, + label: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + status: window.status, + })); + const policyWindows = quota.windows.map((window) => ({ + rateLimitType: window.rateLimitType, + label: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + status: window.status, + })); + const orderedWindows = [...coreWindows, ...policyWindows].filter( + (window, index, arr) => + arr.findIndex( + (candidate) => + candidate.rateLimitType === window.rateLimitType && + candidate.resetAt === window.resetAt && + candidate.status === window.status + ) === index + ); + + const fiveHourResetAt = + quota.coreUsage?.fiveHour?.resetAt ?? + quota.windows.find((window) => window.rateLimitType === 'five_hour')?.resetAt ?? + null; + const weeklyResetAt = + quota.coreUsage?.weekly?.resetAt ?? + quota.windows.find((window) => + [ + 'seven_day', + 'seven_day_opus', + 'seven_day_sonnet', + 'seven_day_oauth_apps', + 'seven_day_cowork', + ].includes(window.rateLimitType) + )?.resetAt ?? + null; + + return ( +
+

Rate Limits:

+ {orderedWindows.map((window, index) => ( +
+ + {getClaudeWindowDisplayLabel(window.rateLimitType, window.label)} + + {window.remainingPercent}% +
+ ))} + +
+ ); + } + // Gemini provider tooltip if (isGeminiQuotaResult(quota)) { return ( diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 0120bddb..5cb09892 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -7,6 +7,7 @@ import type { ModelQuota, QuotaResult, CodexQuotaResult, + ClaudeQuotaResult, GeminiCliQuotaResult, GhcpQuotaResult, } from '@/lib/api-client'; @@ -203,14 +204,21 @@ export function useCliproxyErrorLogContent(name: string | null) { } // Re-export for consumers -export type { ModelQuota, QuotaResult, CodexQuotaResult, GeminiCliQuotaResult, GhcpQuotaResult }; +export type { + ModelQuota, + QuotaResult, + CodexQuotaResult, + ClaudeQuotaResult, + GeminiCliQuotaResult, + GhcpQuotaResult, +}; /** Providers with quota API support */ -export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'gemini', 'ghcp'] as const; +export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'claude', 'gemini', 'ghcp'] as const; export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number]; /** - * Fetch account quota from API (Antigravity only) + * Fetch account quota from generic API route */ async function fetchAccountQuota(provider: string, accountId: string): Promise { const response = await fetch(`/api/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`); @@ -245,6 +253,24 @@ async function fetchCodexQuotaApi(accountId: string): Promise return response.json(); } +/** + * Fetch Claude quota from API + */ +async function fetchClaudeQuotaApi(accountId: string): Promise { + const response = await fetch(`/api/cliproxy/quota/claude/${encodeURIComponent(accountId)}`); + if (!response.ok) { + let message = 'Failed to fetch Claude quota'; + try { + const error = await response.json(); + message = error.message || message; + } catch { + // Use default message if response isn't JSON + } + throw new Error(message); + } + return response.json(); +} + /** * Fetch Gemini quota from API */ @@ -294,6 +320,8 @@ async function fetchQuotaByProvider( switch (provider) { case 'codex': return fetchCodexQuotaApi(accountId); + case 'claude': + return fetchClaudeQuotaApi(accountId); case 'gemini': return fetchGeminiQuotaApi(accountId); case 'ghcp': @@ -305,7 +333,7 @@ async function fetchQuotaByProvider( /** * Hook to get account quota - * Supports agy, codex, gemini, and ghcp providers + * Supports agy, codex, claude, gemini, and ghcp providers */ export function useAccountQuota(provider: string, accountId: string, enabled = true) { return useQuery({ diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index a38c2ccb..a450f44f 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -299,6 +299,59 @@ export interface CodexQuotaResult { isForbidden?: boolean; } +/** Claude policy limit window */ +export interface ClaudeQuotaWindow { + /** Source identifier: five_hour, seven_day, seven_day_opus, seven_day_sonnet, overage, ... */ + rateLimitType: string; + /** Human-friendly label for UI display */ + label: string; + /** Upstream status: allowed, allowed_warning, rejected */ + status: string; + /** Utilization ratio (0-1) when available */ + utilization: number | null; + /** Utilization as percentage (0-100) */ + usedPercent: number; + /** Remaining percentage (100 - usedPercent) */ + remainingPercent: number; + /** Reset timestamp for this window, null if unknown */ + resetAt: string | null; + surpassedThreshold?: boolean; + severity?: string; + overageStatus?: string; + overageResetsAt?: string | null; + overageDisabledReason?: string | null; + isUsingOverage?: boolean; + hasExtraUsageEnabled?: boolean; +} + +/** Core Claude usage window (5h/weekly) */ +export interface ClaudeCoreUsageWindow { + rateLimitType: string; + label: string; + remainingPercent: number; + resetAt: string | null; + status: string; +} + +/** Core Claude usage summary (5h + weekly) */ +export interface ClaudeCoreUsageSummary { + fiveHour: ClaudeCoreUsageWindow | null; + weekly: ClaudeCoreUsageWindow | null; +} + +/** Claude quota result */ +export interface ClaudeQuotaResult { + success: boolean; + windows: ClaudeQuotaWindow[]; + coreUsage?: ClaudeCoreUsageSummary; + lastUpdated: number; + error?: string; + accountId?: string; + needsReauth?: boolean; + /** True if result was served from cache */ + cached?: boolean; +} + /** Gemini CLI bucket (grouped by model series) */ export interface GeminiCliBucket { /** Unique bucket identifier (e.g., "gemini-flash-series::input") */ @@ -793,6 +846,9 @@ export const api = { /** Fetch Codex quota for a specific account */ getCodex: (accountId: string) => request(`/cliproxy/quota/codex/${encodeURIComponent(accountId)}`), + /** Fetch Claude quota for a specific account */ + getClaude: (accountId: string) => + request(`/cliproxy/quota/claude/${encodeURIComponent(accountId)}`), /** Fetch Gemini CLI quota for a specific account */ getGemini: (accountId: string) => request(`/cliproxy/quota/gemini/${encodeURIComponent(accountId)}`), diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 8f04bc45..7296690e 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -3,6 +3,7 @@ import { twMerge } from 'tailwind-merge'; import type { CodexQuotaWindow, CodexQuotaResult, + ClaudeQuotaResult, GeminiCliBucket, GeminiCliQuotaResult, GhcpQuotaResult, @@ -42,6 +43,7 @@ const PROVIDER_COLORS: Record = { agy: '#f3722c', // Pumpkin gemini: '#277da1', // Cerulean codex: '#f8961e', // Carrot + claude: '#4d908e', // Dark Cyan vertex: '#577590', // Blue Slate iflow: '#f94144', // Strawberry qwen: '#f9c74f', // Tuscan @@ -522,6 +524,48 @@ export function getCodexResetTime(windows: CodexQuotaWindow[]): string | null { return resets.sort()[0]; } +/** + * Get minimum remaining percentage across Claude policy windows. + */ +export function getMinClaudePolicyQuota(quota: ClaudeQuotaResult): number | null { + if (!quota.success) return null; + + const coreWindows = [quota.coreUsage?.fiveHour, quota.coreUsage?.weekly].filter( + (window): window is NonNullable => !!window + ); + if (coreWindows.length > 0) { + return Math.min(...coreWindows.map((window) => window.remainingPercent)); + } + + const usageWindows = quota.windows.filter((window) => window.rateLimitType !== 'overage'); + if (usageWindows.length > 0) { + return Math.min(...usageWindows.map((window) => window.remainingPercent)); + } + + return null; +} + +/** + * Get earliest reset time from Claude policy windows. + */ +export function getClaudePolicyResetTime(quota: ClaudeQuotaResult): string | null { + if (!quota.success) return null; + + const coreResets = [quota.coreUsage?.fiveHour?.resetAt, quota.coreUsage?.weekly?.resetAt].filter( + (value): value is string => !!value + ); + if (coreResets.length > 0) { + return coreResets.sort()[0]; + } + + const resets = quota.windows + .filter((window) => window.rateLimitType !== 'overage') + .map((window) => window.resetAt) + .filter((value): value is string => value !== null); + if (resets.length === 0) return null; + return resets.sort()[0]; +} + /** * Get minimum remaining percentage across Gemini CLI buckets */ @@ -570,6 +614,7 @@ export function getGhcpResetTime(quotaResetDate: string | null): string | null { export type UnifiedQuotaResult = | QuotaResult | CodexQuotaResult + | ClaudeQuotaResult | GeminiCliQuotaResult | GhcpQuotaResult; @@ -580,7 +625,18 @@ export function isAgyQuotaResult(quota: UnifiedQuotaResult): quota is QuotaResul /** Type guard: Check if quota result is from Codex provider */ export function isCodexQuotaResult(quota: UnifiedQuotaResult): quota is CodexQuotaResult { - return 'windows' in quota && Array.isArray((quota as CodexQuotaResult).windows); + return ( + 'windows' in quota && 'planType' in quota && Array.isArray((quota as CodexQuotaResult).windows) + ); +} + +/** Type guard: Check if quota result is from Claude provider */ +export function isClaudeQuotaResult(quota: UnifiedQuotaResult): quota is ClaudeQuotaResult { + return ( + 'windows' in quota && + !('planType' in quota) && + Array.isArray((quota as ClaudeQuotaResult).windows) + ); } /** Type guard: Check if quota result is from Gemini CLI provider */ @@ -626,6 +682,12 @@ export function getProviderMinQuota( return getMinCodexQuota(quota.windows); } return null; + case 'claude': + case 'anthropic': + if (isClaudeQuotaResult(quota)) { + return getMinClaudePolicyQuota(quota); + } + return null; case 'gemini': if (isGeminiQuotaResult(quota)) { return getMinGeminiQuota(quota.buckets); @@ -663,6 +725,12 @@ export function getProviderResetTime( return getCodexResetTime(quota.windows); } return null; + case 'claude': + case 'anthropic': + if (isClaudeQuotaResult(quota)) { + return getClaudePolicyResetTime(quota); + } + return null; case 'gemini': if (isGeminiQuotaResult(quota)) { return getGeminiResetTime(quota.buckets); From 9371a72c1638d72f2a3e1fcb3fe9416c79f7cd1a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 22 Feb 2026 01:35:32 +0700 Subject: [PATCH 13/27] fix(cliproxy): split claude quota fetcher for maintainability gate --- .../quota-fetcher-claude-normalizer.ts | 294 ++++++++++++++ src/cliproxy/quota-fetcher-claude.ts | 379 +++--------------- 2 files changed, 355 insertions(+), 318 deletions(-) create mode 100644 src/cliproxy/quota-fetcher-claude-normalizer.ts diff --git a/src/cliproxy/quota-fetcher-claude-normalizer.ts b/src/cliproxy/quota-fetcher-claude-normalizer.ts new file mode 100644 index 00000000..a082fd18 --- /dev/null +++ b/src/cliproxy/quota-fetcher-claude-normalizer.ts @@ -0,0 +1,294 @@ +/** + * Claude Quota Response Normalization Helpers + * + * Parses Anthropic policy limits payload into normalized windows and core usage summary. + */ + +import type { ClaudeCoreUsageSummary, ClaudeQuotaWindow } from './quota-types'; +import { clampPercent } from '../utils/percentage'; + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; +} + +function asBoolean(value: unknown): boolean | undefined { + if (typeof value === 'boolean') return value; + if (typeof value === 'string') { + if (value === 'true') return true; + if (value === 'false') return false; + } + return undefined; +} + +function asNumber(value: unknown): number | null { + if (typeof value === 'number' && isFinite(value)) return value; + if (typeof value === 'string') { + const parsed = Number(value); + return isFinite(parsed) ? parsed : null; + } + return null; +} + +function normalizeTimestamp(value: unknown): string | null { + const asNum = asNumber(value); + if (asNum !== null) { + const millis = asNum > 1e12 ? asNum : asNum * 1000; + const date = new Date(millis); + return isNaN(date.getTime()) ? null : date.toISOString(); + } + + const str = asString(value); + if (!str) return null; + + // Numeric strings can be either epoch seconds or epoch milliseconds. + if (/^\d+$/.test(str)) { + const numeric = Number(str); + if (isFinite(numeric)) { + const millis = numeric > 1e12 ? numeric : numeric * 1000; + const date = new Date(millis); + return isNaN(date.getTime()) ? null : date.toISOString(); + } + } + + const date = new Date(str); + return isNaN(date.getTime()) ? null : date.toISOString(); +} + +function getClaudeWindowLabel(rateLimitType: string): string { + switch (rateLimitType) { + case 'five_hour': + return 'Session limit'; + case 'seven_day': + return 'Weekly limit'; + case 'seven_day_opus': + return 'Opus limit'; + case 'seven_day_sonnet': + return 'Sonnet limit'; + case 'overage': + return 'Extra usage'; + default: + return rateLimitType || 'Unknown limit'; + } +} + +function normalizeUtilization(raw: Record): { + utilization: number | null; + usedPercent: number; + remainingPercent: number; +} { + const utilizationRaw = asNumber(raw['utilization']); + const usedPercentRaw = asNumber(raw['usedPercent'] ?? raw['used_percent']); + const remainingPercentRaw = asNumber(raw['remainingPercent'] ?? raw['remaining_percent']); + + if (utilizationRaw !== null) { + const ratio = utilizationRaw <= 1 ? utilizationRaw : utilizationRaw / 100; + const usedPercent = clampPercent(ratio * 100); + return { + utilization: ratio, + usedPercent, + remainingPercent: clampPercent(100 - usedPercent), + }; + } + + if (usedPercentRaw !== null) { + const usedPercent = clampPercent(usedPercentRaw); + return { + utilization: usedPercent / 100, + usedPercent, + remainingPercent: clampPercent(100 - usedPercent), + }; + } + + if (remainingPercentRaw !== null) { + const remainingPercent = clampPercent(remainingPercentRaw); + const usedPercent = clampPercent(100 - remainingPercent); + return { + utilization: usedPercent / 100, + usedPercent, + remainingPercent, + }; + } + + return { + utilization: null, + usedPercent: 0, + remainingPercent: 100, + }; +} + +function normalizeRateLimitType(value: unknown, fallbackKey?: string): string { + const direct = asString(value); + if (direct) return direct; + if (fallbackKey) return fallbackKey; + return 'unknown'; +} + +function toObject(value: unknown): Record | null { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; + return value as Record; +} + +function normalizeRestriction( + raw: Record, + fallbackKey?: string +): ClaudeQuotaWindow | null { + const rateLimitType = normalizeRateLimitType( + raw['rateLimitType'] ?? raw['rate_limit_type'] ?? raw['claim'] ?? raw['claimAbbrev'], + fallbackKey + ); + if (!rateLimitType || rateLimitType === 'unknown') return null; + + const status = asString(raw['status']) || 'unknown'; + const resetAt = + normalizeTimestamp(raw['resetsAt'] ?? raw['resets_at'] ?? raw['resetAt'] ?? raw['reset_at']) || + null; + const overageResetsAt = + normalizeTimestamp( + raw['overageResetsAt'] ?? + raw['overage_resets_at'] ?? + raw['overageResetAt'] ?? + raw['overage_reset_at'] + ) || null; + + const { utilization, usedPercent, remainingPercent } = normalizeUtilization(raw); + + return { + rateLimitType, + label: getClaudeWindowLabel(rateLimitType), + status, + utilization, + usedPercent, + remainingPercent, + resetAt, + surpassedThreshold: asBoolean(raw['surpassedThreshold'] ?? raw['surpassed_threshold']), + severity: asString(raw['severity']) || undefined, + overageStatus: asString(raw['overageStatus'] ?? raw['overage_status']) || undefined, + overageResetsAt, + overageDisabledReason: + asString(raw['overageDisabledReason'] ?? raw['overage_disabled_reason']) || undefined, + isUsingOverage: asBoolean(raw['isUsingOverage'] ?? raw['is_using_overage']), + hasExtraUsageEnabled: asBoolean(raw['hasExtraUsageEnabled'] ?? raw['has_extra_usage_enabled']), + }; +} + +/** + * Parse raw policy limits response into normalized windows. + * Supports both array and object-map `restrictions` shapes. + */ +export function buildClaudeQuotaWindows(payload: Record): ClaudeQuotaWindow[] { + const rawRestrictions = payload['restrictions']; + const windows: ClaudeQuotaWindow[] = []; + + if (Array.isArray(rawRestrictions)) { + for (const item of rawRestrictions) { + const raw = toObject(item); + if (!raw) continue; + const window = normalizeRestriction(raw); + if (window) windows.push(window); + } + } else if (toObject(rawRestrictions)) { + for (const [key, value] of Object.entries(rawRestrictions as Record)) { + const raw = toObject(value); + if (!raw) continue; + const window = normalizeRestriction(raw, key); + if (window) windows.push(window); + } + } else if (toObject(payload)) { + // Some responses may contain a single restriction object directly. + const direct = normalizeRestriction(payload); + if (direct) windows.push(direct); + } + + const seen = new Set(); + const unique: ClaudeQuotaWindow[] = []; + for (const window of windows) { + const key = `${window.rateLimitType}:${window.resetAt ?? ''}:${window.status}`; + if (seen.has(key)) continue; + seen.add(key); + unique.push(window); + } + + return unique.sort((a, b) => a.rateLimitType.localeCompare(b.rateLimitType)); +} + +function toEpochMs(iso: string | null): number | null { + if (!iso) return null; + const value = new Date(iso).getTime(); + return isNaN(value) ? null : value; +} + +function pickMostRestrictiveWeekly(windows: ClaudeQuotaWindow[]): ClaudeQuotaWindow | null { + if (windows.length === 0) return null; + return [...windows].sort((a, b) => { + if (a.remainingPercent !== b.remainingPercent) { + return a.remainingPercent - b.remainingPercent; + } + const aReset = toEpochMs(a.resetAt); + const bReset = toEpochMs(b.resetAt); + if (aReset === null && bReset === null) return 0; + if (aReset === null) return 1; + if (bReset === null) return -1; + return aReset - bReset; + })[0]; +} + +function mapCoreWindow(window: ClaudeQuotaWindow | null): ClaudeCoreUsageSummary['fiveHour'] { + if (!window) return null; + return { + rateLimitType: window.rateLimitType, + label: window.label, + remainingPercent: window.remainingPercent, + resetAt: window.resetAt, + status: window.status, + }; +} + +/** + * Build explicit 5h + weekly usage summary from Claude policy windows. + */ +export function buildClaudeCoreUsageSummary(windows: ClaudeQuotaWindow[]): ClaudeCoreUsageSummary { + if (!windows || windows.length === 0) { + return { fiveHour: null, weekly: null }; + } + + const fiveHourWindow = windows.find((window) => window.rateLimitType === 'five_hour') || null; + const weeklyCandidates = windows.filter((window) => + ['seven_day', 'seven_day_opus', 'seven_day_sonnet'].includes(window.rateLimitType) + ); + const weeklyWindow = pickMostRestrictiveWeekly(weeklyCandidates); + + // Fallback: infer shortest/longest reset windows from non-overage limits. + if (!fiveHourWindow || !weeklyWindow) { + const nonOverage = windows.filter((window) => window.rateLimitType !== 'overage'); + const withReset = nonOverage + .map((window) => ({ + window, + resetMs: toEpochMs(window.resetAt), + })) + .filter((entry) => entry.resetMs !== null) + .sort((a, b) => (a.resetMs as number) - (b.resetMs as number)); + + const inferredFiveHour = + fiveHourWindow || + (withReset.length > 0 + ? withReset[0].window + : nonOverage.length > 0 + ? pickMostRestrictiveWeekly(nonOverage) + : null); + const inferredWeekly = + weeklyWindow || + (withReset.length > 1 + ? withReset[withReset.length - 1].window + : nonOverage.find((window) => window !== inferredFiveHour) || null); + + return { + fiveHour: mapCoreWindow(inferredFiveHour), + weekly: mapCoreWindow(inferredWeekly), + }; + } + + return { + fiveHour: mapCoreWindow(fiveHourWindow), + weekly: mapCoreWindow(weeklyWindow), + }; +} diff --git a/src/cliproxy/quota-fetcher-claude.ts b/src/cliproxy/quota-fetcher-claude.ts index 2be1c248..745a5b03 100644 --- a/src/cliproxy/quota-fetcher-claude.ts +++ b/src/cliproxy/quota-fetcher-claude.ts @@ -4,13 +4,18 @@ * Fetches policy limits from Claude API and normalizes 5h + weekly windows. */ -import * as fs from 'node:fs'; import * as path from 'node:path'; +import * as fsp from 'node:fs/promises'; import { getAuthDir } from './config-generator'; import { getPausedDir, getProviderAccounts } from './account-manager'; import { sanitizeEmail, isTokenExpired } from './auth-utils'; -import type { ClaudeQuotaResult, ClaudeQuotaWindow, ClaudeCoreUsageSummary } from './quota-types'; -import { clampPercent } from '../utils/percentage'; +import type { ClaudeQuotaResult } from './quota-types'; +import { + buildClaudeQuotaWindows, + buildClaudeCoreUsageSummary, +} from './quota-fetcher-claude-normalizer'; + +export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary }; const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_code/policy_limits'; const CLAUDE_QUOTA_TIMEOUT_MS = 10000; @@ -26,288 +31,11 @@ function asString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; } -function asBoolean(value: unknown): boolean | undefined { - if (typeof value === 'boolean') return value; - if (typeof value === 'string') { - if (value === 'true') return true; - if (value === 'false') return false; - } - return undefined; -} - -function asNumber(value: unknown): number | null { - if (typeof value === 'number' && isFinite(value)) return value; - if (typeof value === 'string') { - const parsed = Number(value); - return isFinite(parsed) ? parsed : null; - } - return null; -} - -function normalizeTimestamp(value: unknown): string | null { - const asNum = asNumber(value); - if (asNum !== null) { - const millis = asNum > 1e12 ? asNum : asNum * 1000; - const date = new Date(millis); - return isNaN(date.getTime()) ? null : date.toISOString(); - } - - const str = asString(value); - if (!str) return null; - - // Numeric strings can be either epoch seconds or epoch milliseconds. - if (/^\d+$/.test(str)) { - const numeric = Number(str); - if (isFinite(numeric)) { - const millis = numeric > 1e12 ? numeric : numeric * 1000; - const date = new Date(millis); - return isNaN(date.getTime()) ? null : date.toISOString(); - } - } - - const date = new Date(str); - return isNaN(date.getTime()) ? null : date.toISOString(); -} - -function getClaudeWindowLabel(rateLimitType: string): string { - switch (rateLimitType) { - case 'five_hour': - return 'Session limit'; - case 'seven_day': - return 'Weekly limit'; - case 'seven_day_opus': - return 'Opus limit'; - case 'seven_day_sonnet': - return 'Sonnet limit'; - case 'overage': - return 'Extra usage'; - default: - return rateLimitType || 'Unknown limit'; - } -} - -function normalizeUtilization(raw: Record): { - utilization: number | null; - usedPercent: number; - remainingPercent: number; -} { - const utilizationRaw = asNumber(raw['utilization']); - const usedPercentRaw = asNumber(raw['usedPercent'] ?? raw['used_percent']); - const remainingPercentRaw = asNumber(raw['remainingPercent'] ?? raw['remaining_percent']); - - if (utilizationRaw !== null) { - const ratio = utilizationRaw <= 1 ? utilizationRaw : utilizationRaw / 100; - const usedPercent = clampPercent(ratio * 100); - return { - utilization: ratio, - usedPercent, - remainingPercent: clampPercent(100 - usedPercent), - }; - } - - if (usedPercentRaw !== null) { - const usedPercent = clampPercent(usedPercentRaw); - return { - utilization: usedPercent / 100, - usedPercent, - remainingPercent: clampPercent(100 - usedPercent), - }; - } - - if (remainingPercentRaw !== null) { - const remainingPercent = clampPercent(remainingPercentRaw); - const usedPercent = clampPercent(100 - remainingPercent); - return { - utilization: usedPercent / 100, - usedPercent, - remainingPercent, - }; - } - - return { - utilization: null, - usedPercent: 0, - remainingPercent: 100, - }; -} - -function normalizeRateLimitType(value: unknown, fallbackKey?: string): string { - const direct = asString(value); - if (direct) return direct; - if (fallbackKey) return fallbackKey; - return 'unknown'; -} - function toObject(value: unknown): Record | null { if (typeof value !== 'object' || value === null || Array.isArray(value)) return null; return value as Record; } -function normalizeRestriction( - raw: Record, - fallbackKey?: string -): ClaudeQuotaWindow | null { - const rateLimitType = normalizeRateLimitType( - raw['rateLimitType'] ?? raw['rate_limit_type'] ?? raw['claim'] ?? raw['claimAbbrev'], - fallbackKey - ); - if (!rateLimitType || rateLimitType === 'unknown') return null; - - const status = asString(raw['status']) || 'unknown'; - const resetAt = - normalizeTimestamp(raw['resetsAt'] ?? raw['resets_at'] ?? raw['resetAt'] ?? raw['reset_at']) || - null; - const overageResetsAt = - normalizeTimestamp( - raw['overageResetsAt'] ?? - raw['overage_resets_at'] ?? - raw['overageResetAt'] ?? - raw['overage_reset_at'] - ) || null; - - const { utilization, usedPercent, remainingPercent } = normalizeUtilization(raw); - - return { - rateLimitType, - label: getClaudeWindowLabel(rateLimitType), - status, - utilization, - usedPercent, - remainingPercent, - resetAt, - surpassedThreshold: asBoolean(raw['surpassedThreshold'] ?? raw['surpassed_threshold']), - severity: asString(raw['severity']) || undefined, - overageStatus: asString(raw['overageStatus'] ?? raw['overage_status']) || undefined, - overageResetsAt, - overageDisabledReason: - asString(raw['overageDisabledReason'] ?? raw['overage_disabled_reason']) || undefined, - isUsingOverage: asBoolean(raw['isUsingOverage'] ?? raw['is_using_overage']), - hasExtraUsageEnabled: asBoolean(raw['hasExtraUsageEnabled'] ?? raw['has_extra_usage_enabled']), - }; -} - -/** - * Parse raw policy limits response into normalized windows. - * Supports both array and object-map `restrictions` shapes. - */ -export function buildClaudeQuotaWindows(payload: Record): ClaudeQuotaWindow[] { - const rawRestrictions = payload['restrictions']; - const windows: ClaudeQuotaWindow[] = []; - - if (Array.isArray(rawRestrictions)) { - for (const item of rawRestrictions) { - const raw = toObject(item); - if (!raw) continue; - const window = normalizeRestriction(raw); - if (window) windows.push(window); - } - } else if (toObject(rawRestrictions)) { - for (const [key, value] of Object.entries(rawRestrictions as Record)) { - const raw = toObject(value); - if (!raw) continue; - const window = normalizeRestriction(raw, key); - if (window) windows.push(window); - } - } else if (toObject(payload)) { - // Some responses may contain a single restriction object directly. - const direct = normalizeRestriction(payload); - if (direct) windows.push(direct); - } - - const seen = new Set(); - const unique: ClaudeQuotaWindow[] = []; - for (const window of windows) { - const key = `${window.rateLimitType}:${window.resetAt ?? ''}:${window.status}`; - if (seen.has(key)) continue; - seen.add(key); - unique.push(window); - } - - return unique.sort((a, b) => a.rateLimitType.localeCompare(b.rateLimitType)); -} - -function toEpochMs(iso: string | null): number | null { - if (!iso) return null; - const value = new Date(iso).getTime(); - return isNaN(value) ? null : value; -} - -function pickMostRestrictiveWeekly(windows: ClaudeQuotaWindow[]): ClaudeQuotaWindow | null { - if (windows.length === 0) return null; - return [...windows].sort((a, b) => { - if (a.remainingPercent !== b.remainingPercent) { - return a.remainingPercent - b.remainingPercent; - } - const aReset = toEpochMs(a.resetAt); - const bReset = toEpochMs(b.resetAt); - if (aReset === null && bReset === null) return 0; - if (aReset === null) return 1; - if (bReset === null) return -1; - return aReset - bReset; - })[0]; -} - -function mapCoreWindow(window: ClaudeQuotaWindow | null): ClaudeCoreUsageSummary['fiveHour'] { - if (!window) return null; - return { - rateLimitType: window.rateLimitType, - label: window.label, - remainingPercent: window.remainingPercent, - resetAt: window.resetAt, - status: window.status, - }; -} - -/** - * Build explicit 5h + weekly usage summary from Claude policy windows. - */ -export function buildClaudeCoreUsageSummary(windows: ClaudeQuotaWindow[]): ClaudeCoreUsageSummary { - if (!windows || windows.length === 0) { - return { fiveHour: null, weekly: null }; - } - - const fiveHourWindow = windows.find((window) => window.rateLimitType === 'five_hour') || null; - const weeklyCandidates = windows.filter((window) => - ['seven_day', 'seven_day_opus', 'seven_day_sonnet'].includes(window.rateLimitType) - ); - const weeklyWindow = pickMostRestrictiveWeekly(weeklyCandidates); - - // Fallback: infer shortest/longest reset windows from non-overage limits. - if (!fiveHourWindow || !weeklyWindow) { - const nonOverage = windows.filter((window) => window.rateLimitType !== 'overage'); - const withReset = nonOverage - .map((window) => ({ - window, - resetMs: toEpochMs(window.resetAt), - })) - .filter((entry) => entry.resetMs !== null) - .sort((a, b) => (a.resetMs as number) - (b.resetMs as number)); - - const inferredFiveHour = - fiveHourWindow || - (withReset.length > 0 - ? withReset[0].window - : nonOverage.length > 0 - ? pickMostRestrictiveWeekly(nonOverage) - : null); - const inferredWeekly = - weeklyWindow || - (withReset.length > 1 - ? withReset[withReset.length - 1].window - : nonOverage.find((window) => window !== inferredFiveHour) || null); - - return { - fiveHour: mapCoreWindow(inferredFiveHour), - weekly: mapCoreWindow(inferredWeekly), - }; - } - - return { - fiveHour: mapCoreWindow(fiveHourWindow), - weekly: mapCoreWindow(weeklyWindow), - }; -} - function extractAccessToken(data: Record): string | null { const direct = asString(data['access_token']); if (direct) return direct; @@ -333,34 +61,51 @@ function extractExpiry(data: Record): string | null { return null; } -function readClaudeAuthData(accountId: string): ClaudeAuthData | null { +async function readJsonFile(filePath: string): Promise | null> { + try { + const raw = await fsp.readFile(filePath, 'utf-8'); + const parsed = JSON.parse(raw) as unknown; + return toObject(parsed); + } catch { + return null; + } +} + +async function readAuthCandidate(filePath: string): Promise { + const data = await readJsonFile(filePath); + if (!data) return null; + + const accessToken = extractAccessToken(data); + if (!accessToken) return null; + + const expiry = extractExpiry(data); + return { + accessToken, + isExpired: isTokenExpired(expiry ?? undefined), + }; +} + +async function readClaudeAuthData(accountId: string): Promise { const authDirs = [getAuthDir(), getPausedDir()]; const sanitizedId = sanitizeEmail(accountId); const expectedFiles = [`claude-${sanitizedId}.json`, `anthropic-${sanitizedId}.json`]; for (const authDir of authDirs) { - if (!fs.existsSync(authDir)) continue; - for (const expectedFile of expectedFiles) { const filePath = path.join(authDir, expectedFile); - if (!fs.existsSync(filePath)) continue; - - try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record; - const accessToken = extractAccessToken(data); - if (!accessToken) continue; - - const expiry = extractExpiry(data); - return { - accessToken, - isExpired: isTokenExpired(expiry ?? undefined), - }; - } catch { - continue; + const authData = await readAuthCandidate(filePath); + if (authData) { + return authData; } } - const files = fs.readdirSync(authDir); + let files: string[]; + try { + files = await fsp.readdir(authDir); + } catch { + continue; + } + for (const file of files) { if ( !file.endsWith('.json') || @@ -370,27 +115,25 @@ function readClaudeAuthData(accountId: string): ClaudeAuthData | null { } const filePath = path.join(authDir, file); - try { - const data = JSON.parse(fs.readFileSync(filePath, 'utf-8')) as Record; - const accessToken = extractAccessToken(data); - if (!accessToken) continue; + const data = await readJsonFile(filePath); + if (!data) continue; - const fileEmail = asString(data['email']); - const typeValue = asString(data['type']); - const isClaudeType = - typeValue === null || typeValue === 'claude' || typeValue === 'anthropic'; - const matchesEmail = fileEmail === accountId; - const matchesFile = file.includes(sanitizedId); + const accessToken = extractAccessToken(data); + if (!accessToken) continue; - if ((matchesEmail || matchesFile) && isClaudeType) { - const expiry = extractExpiry(data); - return { - accessToken, - isExpired: isTokenExpired(expiry ?? undefined), - }; - } - } catch { - continue; + const fileEmail = asString(data['email']); + const typeValue = asString(data['type']); + const isClaudeType = + typeValue === null || typeValue === 'claude' || typeValue === 'anthropic'; + const matchesEmail = fileEmail === accountId; + const matchesFile = file.includes(sanitizedId); + + if ((matchesEmail || matchesFile) && isClaudeType) { + const expiry = extractExpiry(data); + return { + accessToken, + isExpired: isTokenExpired(expiry ?? undefined), + }; } } } @@ -421,7 +164,7 @@ export async function fetchClaudeQuota( accountId: string, verbose = false ): Promise { - const authData = readClaudeAuthData(accountId); + const authData = await readClaudeAuthData(accountId); if (!authData) { return buildEmptyResult('Auth file not found for Claude account', accountId); } From 8c790f41ffba9a09467899b8b641dcbe2b692ae3 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 22 Feb 2026 02:12:40 +0700 Subject: [PATCH 14/27] fix(cliproxy): close remaining quota edge-case gaps --- .../quota-fetcher-claude-normalizer.ts | 75 ++--- src/cliproxy/quota-manager.ts | 13 +- src/commands/cliproxy/index.ts | 18 +- .../cliproxy/quota-fetcher-claude.test.ts | 260 ++++++++++++++++++ .../commands/cliproxy-provider-arg.test.ts | 54 ++++ .../account/flow-viz/account-card.tsx | 48 ++-- .../cliproxy/provider-editor/account-item.tsx | 38 ++- .../provider-editor/model-config-tab.tsx | 12 +- ui/src/hooks/use-cliproxy-stats.ts | 38 ++- ui/src/lib/utils.ts | 6 +- 10 files changed, 482 insertions(+), 80 deletions(-) create mode 100644 tests/unit/commands/cliproxy-provider-arg.test.ts diff --git a/src/cliproxy/quota-fetcher-claude-normalizer.ts b/src/cliproxy/quota-fetcher-claude-normalizer.ts index a082fd18..11d8396e 100644 --- a/src/cliproxy/quota-fetcher-claude-normalizer.ts +++ b/src/cliproxy/quota-fetcher-claude-normalizer.ts @@ -64,6 +64,10 @@ function getClaudeWindowLabel(rateLimitType: string): string { return 'Opus limit'; case 'seven_day_sonnet': return 'Sonnet limit'; + case 'seven_day_oauth_apps': + return 'OAuth apps limit'; + case 'seven_day_cowork': + return 'Cowork limit'; case 'overage': return 'Extra usage'; default: @@ -71,6 +75,10 @@ function getClaudeWindowLabel(rateLimitType: string): string { } } +function clampUnit(value: number): number { + return Math.max(0, Math.min(1, value)); +} + function normalizeUtilization(raw: Record): { utilization: number | null; usedPercent: number; @@ -82,9 +90,10 @@ function normalizeUtilization(raw: Record): { if (utilizationRaw !== null) { const ratio = utilizationRaw <= 1 ? utilizationRaw : utilizationRaw / 100; - const usedPercent = clampPercent(ratio * 100); + const normalizedRatio = clampUnit(ratio); + const usedPercent = clampPercent(normalizedRatio * 100); return { - utilization: ratio, + utilization: normalizedRatio, usedPercent, remainingPercent: clampPercent(100 - usedPercent), }; @@ -243,6 +252,14 @@ function mapCoreWindow(window: ClaudeQuotaWindow | null): ClaudeCoreUsageSummary }; } +const WEEKLY_RATE_LIMIT_TYPES = new Set([ + 'seven_day', + 'seven_day_opus', + 'seven_day_sonnet', + 'seven_day_oauth_apps', + 'seven_day_cowork', +]); + /** * Build explicit 5h + weekly usage summary from Claude policy windows. */ @@ -253,42 +270,38 @@ export function buildClaudeCoreUsageSummary(windows: ClaudeQuotaWindow[]): Claud const fiveHourWindow = windows.find((window) => window.rateLimitType === 'five_hour') || null; const weeklyCandidates = windows.filter((window) => - ['seven_day', 'seven_day_opus', 'seven_day_sonnet'].includes(window.rateLimitType) + WEEKLY_RATE_LIMIT_TYPES.has(window.rateLimitType) ); const weeklyWindow = pickMostRestrictiveWeekly(weeklyCandidates); - // Fallback: infer shortest/longest reset windows from non-overage limits. - if (!fiveHourWindow || !weeklyWindow) { - const nonOverage = windows.filter((window) => window.rateLimitType !== 'overage'); - const withReset = nonOverage - .map((window) => ({ - window, - resetMs: toEpochMs(window.resetAt), - })) - .filter((entry) => entry.resetMs !== null) - .sort((a, b) => (a.resetMs as number) - (b.resetMs as number)); - - const inferredFiveHour = - fiveHourWindow || - (withReset.length > 0 - ? withReset[0].window - : nonOverage.length > 0 - ? pickMostRestrictiveWeekly(nonOverage) - : null); - const inferredWeekly = - weeklyWindow || - (withReset.length > 1 - ? withReset[withReset.length - 1].window - : nonOverage.find((window) => window !== inferredFiveHour) || null); - + if (fiveHourWindow && weeklyWindow) { return { - fiveHour: mapCoreWindow(inferredFiveHour), - weekly: mapCoreWindow(inferredWeekly), + fiveHour: mapCoreWindow(fiveHourWindow), + weekly: mapCoreWindow(weeklyWindow), }; } + // Fallback: infer shortest/longest reset windows from non-overage limits. + const nonOverage = windows.filter((window) => window.rateLimitType !== 'overage'); + const withReset = nonOverage + .map((window) => ({ + window, + resetMs: toEpochMs(window.resetAt), + })) + .filter((entry) => entry.resetMs !== null) + .sort((a, b) => (a.resetMs as number) - (b.resetMs as number)); + + const inferredWeekly = + weeklyWindow || + [...withReset].reverse().find((entry) => entry.window !== fiveHourWindow)?.window || + pickMostRestrictiveWeekly(nonOverage.filter((window) => window !== fiveHourWindow)); + const inferredFiveHour = + fiveHourWindow || + withReset.find((entry) => entry.window !== inferredWeekly)?.window || + pickMostRestrictiveWeekly(nonOverage.filter((window) => window !== inferredWeekly)); + return { - fiveHour: mapCoreWindow(fiveHourWindow), - weekly: mapCoreWindow(weeklyWindow), + fiveHour: mapCoreWindow(inferredFiveHour), + weekly: mapCoreWindow(inferredWeekly), }; } diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index 2aea73fc..d8a4fd78 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -520,7 +520,18 @@ function scheduleNextPoll( try { const quota = await fetchQuotaWithDedup(provider, accountId); if (monitorStopped) return; // Re-check after async fetch - const avgQuota = calculateQuotaPercent(quota) ?? 100; + const avgQuota = calculateQuotaPercent(quota); + + if (avgQuota === null) { + // Quota data unavailable: keep polling, but do not treat unknown as healthy/exhausted. + scheduleNextPoll( + provider, + accountId, + monitorConfig, + monitorConfig.normal_interval_seconds * 1000 + ); + return; + } if (avgQuota <= monitorConfig.exhaustion_threshold) { // EXHAUSTED: cooldown + switch default + stop monitoring. diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index b1735bc5..972c692e 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -97,31 +97,33 @@ function normalizeQuotaProvider(value: string): QuotaProviderFilter | null { return canonicalProvider; } -function parseProviderArg(args: string[]): { +export function parseProviderArg(args: string[]): { provider: QuotaProviderFilter; remainingArgs: string[]; + invalid: boolean; } { const extracted = extractOption(args, ['--provider']); if (!extracted.found) { - return { provider: 'all', remainingArgs: args }; + return { provider: 'all', remainingArgs: args, invalid: false }; } if (extracted.missingValue || !extracted.value) { console.error( - `Warning: --provider requires a value. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}` + `Invalid provider value. --provider requires a value. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}` ); - return { provider: 'all', remainingArgs: extracted.remainingArgs }; + return { provider: 'all', remainingArgs: extracted.remainingArgs, invalid: true }; } const value = extracted.value.toLowerCase(); const normalized = normalizeQuotaProvider(value); if (!normalized) { console.error(`Invalid provider '${value}'. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`); - return { provider: 'all', remainingArgs: extracted.remainingArgs }; + return { provider: 'all', remainingArgs: extracted.remainingArgs, invalid: true }; } return { provider: normalized, remainingArgs: extracted.remainingArgs, + invalid: false, }; } @@ -163,7 +165,11 @@ export async function handleCliproxyCommand(args: string[]): Promise { } if (command === 'quota') { - const { provider: providerFilter } = parseProviderArg(remainingArgs.slice(1)); + const { provider: providerFilter, invalid } = parseProviderArg(remainingArgs.slice(1)); + if (invalid) { + process.exitCode = 1; + return; + } await handleQuotaStatus(verbose, providerFilter); return; } diff --git a/tests/unit/cliproxy/quota-fetcher-claude.test.ts b/tests/unit/cliproxy/quota-fetcher-claude.test.ts index 1e591067..40cbcdef 100644 --- a/tests/unit/cliproxy/quota-fetcher-claude.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-claude.test.ts @@ -121,6 +121,42 @@ describe('Claude Quota Fetcher', () => { expect.arrayContaining(['five_hour', 'seven_day_opus']) ); }); + + it('clamps utilization ratio into 0..1', () => { + const windows = buildClaudeQuotaWindows({ + restrictions: [ + { + rateLimitType: 'five_hour', + utilization: 150, + status: 'allowed', + }, + { + rateLimitType: 'seven_day', + utilization: -25, + status: 'allowed', + }, + ], + }); + + expect(windows).toHaveLength(2); + expect(windows[0].utilization).toBe(1); + expect(windows[0].remainingPercent).toBe(0); + expect(windows[1].utilization).toBe(0); + expect(windows[1].remainingPercent).toBe(100); + }); + + it('parses direct single restriction payload shape', () => { + const windows = buildClaudeQuotaWindows({ + rateLimitType: 'five_hour', + utilization: 0.6, + status: 'allowed', + resetsAt: '2026-02-28T10:00:00Z', + }); + + expect(windows).toHaveLength(1); + expect(windows[0].rateLimitType).toBe('five_hour'); + expect(windows[0].remainingPercent).toBe(40); + }); }); describe('buildClaudeCoreUsageSummary', () => { @@ -159,6 +195,93 @@ describe('Claude Quota Fetcher', () => { expect(summary.weekly?.rateLimitType).toBe('seven_day_opus'); expect(summary.weekly?.remainingPercent).toBe(15); }); + + it('considers oauth/cowork weekly windows in core summary', () => { + const summary = buildClaudeCoreUsageSummary([ + { + rateLimitType: 'five_hour', + label: 'Session limit', + status: 'allowed', + utilization: 0.35, + usedPercent: 35, + remainingPercent: 65, + resetAt: '2026-02-28T10:00:00Z', + }, + { + rateLimitType: 'seven_day_oauth_apps', + label: 'OAuth apps limit', + status: 'allowed_warning', + utilization: 0.92, + usedPercent: 92, + remainingPercent: 8, + resetAt: '2026-03-06T10:00:00Z', + }, + { + rateLimitType: 'seven_day_cowork', + label: 'Cowork limit', + status: 'allowed', + utilization: 0.4, + usedPercent: 40, + remainingPercent: 60, + resetAt: '2026-03-06T12:00:00Z', + }, + ]); + + expect(summary.fiveHour?.rateLimitType).toBe('five_hour'); + expect(summary.weekly?.rateLimitType).toBe('seven_day_oauth_apps'); + expect(summary.weekly?.remainingPercent).toBe(8); + }); + + it('does not duplicate weekly window into fiveHour when only weekly exists', () => { + const summary = buildClaudeCoreUsageSummary([ + { + rateLimitType: 'seven_day', + label: 'Weekly limit', + status: 'allowed', + utilization: 0.45, + usedPercent: 45, + remainingPercent: 55, + resetAt: '2026-03-06T10:00:00Z', + }, + ]); + + expect(summary.fiveHour).toBeNull(); + expect(summary.weekly?.rateLimitType).toBe('seven_day'); + }); + + it('uses earliest reset as tie-breaker for equal weekly remaining quota', () => { + const summary = buildClaudeCoreUsageSummary([ + { + rateLimitType: 'five_hour', + label: 'Session limit', + status: 'allowed', + utilization: 0.2, + usedPercent: 20, + remainingPercent: 80, + resetAt: '2026-02-28T10:00:00Z', + }, + { + rateLimitType: 'seven_day_opus', + label: 'Opus limit', + status: 'allowed', + utilization: 0.6, + usedPercent: 60, + remainingPercent: 40, + resetAt: '2026-03-06T12:00:00Z', + }, + { + rateLimitType: 'seven_day_sonnet', + label: 'Sonnet limit', + status: 'allowed', + utilization: 0.6, + usedPercent: 60, + remainingPercent: 40, + resetAt: '2026-03-06T10:00:00Z', + }, + ]); + + expect(summary.weekly?.rateLimitType).toBe('seven_day_sonnet'); + }); }); describe('fetchClaudeQuota', () => { @@ -253,5 +376,142 @@ describe('Claude Quota Fetcher', () => { expect(result.error).toContain('Auth file not found'); expect(fetchMock).toHaveBeenCalledTimes(0); }); + + it('retries once on transient 500 then succeeds', async () => { + createClaudeAccount('claude-retry@example.com', { + access_token: 'retry-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + let attempt = 0; + global.fetch = mock(() => { + attempt += 1; + if (attempt === 1) { + return Promise.resolve(new Response('', { status: 500 })); + } + + return Promise.resolve( + new Response( + JSON.stringify({ + restrictions: [ + { + rateLimitType: 'five_hour', + utilization: 0.4, + resetsAt: '2026-03-01T01:00:00Z', + status: 'allowed', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + }) as typeof fetch; + + const result = await fetchClaudeQuota('claude-retry@example.com'); + + expect(result.success).toBe(true); + expect(attempt).toBe(2); + expect(result.coreUsage?.fiveHour?.remainingPercent).toBe(60); + }); + + it('retries once after AbortError and succeeds', async () => { + createClaudeAccount('claude-timeout@example.com', { + access_token: 'timeout-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'claude', + }); + + let attempt = 0; + global.fetch = mock(() => { + attempt += 1; + if (attempt === 1) { + const abortError = Object.assign(new Error('aborted'), { name: 'AbortError' }); + return Promise.reject(abortError); + } + + return Promise.resolve( + new Response( + JSON.stringify({ + restrictions: [ + { + rateLimitType: 'seven_day', + utilization: 0.3, + resetsAt: '2026-03-07T01:00:00Z', + status: 'allowed', + }, + ], + }), + { status: 200, headers: { 'Content-Type': 'application/json' } } + ) + ); + }) as typeof fetch; + + const result = await fetchClaudeQuota('claude-timeout@example.com'); + + expect(result.success).toBe(true); + expect(attempt).toBe(2); + expect(result.coreUsage?.weekly?.remainingPercent).toBe(70); + }); + + it('falls back to alternate auth file when preferred file is invalid JSON', async () => { + const accountId = 'claude-fallback@example.com'; + const cliproxyDir = path.join(tmpDir, '.ccs', 'cliproxy'); + const authDir = path.join(cliproxyDir, 'auth'); + const sanitized = sanitizeEmail(accountId); + + fs.mkdirSync(authDir, { recursive: true }); + fs.writeFileSync(path.join(authDir, `claude-${sanitized}.json`), '{invalid'); + fs.writeFileSync( + path.join(authDir, `anthropic-${sanitized}.json`), + JSON.stringify( + { + access_token: 'valid-anthropic-token', + expired: '2099-01-01T00:00:00.000Z', + type: 'anthropic', + }, + null, + 2 + ) + ); + fs.writeFileSync( + path.join(cliproxyDir, 'accounts.json'), + JSON.stringify( + { + version: 1, + providers: { + claude: { + default: accountId, + accounts: { + [accountId]: { + email: accountId, + tokenFile: `anthropic-${sanitized}.json`, + createdAt: '2026-02-20T00:00:00.000Z', + lastUsedAt: '2026-02-20T00:00:00.000Z', + }, + }, + }, + }, + }, + null, + 2 + ) + ); + + global.fetch = mock((_url: string, options?: RequestInit) => { + expect(options?.headers).toMatchObject({ + Authorization: 'Bearer valid-anthropic-token', + }); + return Promise.resolve( + new Response(JSON.stringify({ restrictions: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ); + }) as typeof fetch; + + const result = await fetchClaudeQuota(accountId); + expect(result.success).toBe(true); + }); }); }); diff --git a/tests/unit/commands/cliproxy-provider-arg.test.ts b/tests/unit/commands/cliproxy-provider-arg.test.ts new file mode 100644 index 00000000..2be22e06 --- /dev/null +++ b/tests/unit/commands/cliproxy-provider-arg.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, mock } from 'bun:test'; +import { parseProviderArg } from '../../../src/commands/cliproxy'; + +const originalConsoleError = console.error; + +afterEach(() => { + console.error = originalConsoleError; +}); + +describe('parseProviderArg', () => { + it('defaults to all when --provider is not specified', () => { + const result = parseProviderArg(['--verbose']); + + expect(result.provider).toBe('all'); + expect(result.invalid).toBe(false); + expect(result.remainingArgs).toEqual(['--verbose']); + }); + + it('accepts canonical providers', () => { + const result = parseProviderArg(['--provider', 'claude']); + + expect(result.provider).toBe('claude'); + expect(result.invalid).toBe(false); + }); + + it('accepts external aliases', () => { + const result = parseProviderArg(['--provider', 'anthropic']); + + expect(result.provider).toBe('claude'); + expect(result.invalid).toBe(false); + }); + + it('marks invalid when provider value is unsupported', () => { + const errorSpy = mock(() => {}); + console.error = errorSpy as typeof console.error; + + const result = parseProviderArg(['--provider', 'nope']); + + expect(result.provider).toBe('all'); + expect(result.invalid).toBe(true); + expect(errorSpy).toHaveBeenCalled(); + }); + + it('marks invalid when --provider value is missing', () => { + const errorSpy = mock(() => {}); + console.error = errorSpy as typeof console.error; + + const result = parseProviderArg(['--provider']); + + expect(result.provider).toBe('all'); + expect(result.invalid).toBe(true); + expect(errorSpy).toHaveBeenCalled(); + }); +}); diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 599c1c1a..4bf4abc5 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -24,6 +24,13 @@ import { cleanEmail } from './utils'; import { AccountCardStats } from './account-card-stats'; type Zone = 'left' | 'right' | 'top' | 'bottom'; +const QUOTA_PROVIDER_ALIASES = [ + 'antigravity', + 'anthropic', + 'gemini-cli', + 'copilot', + 'github-copilot', +]; interface AccountCardProps { account: AccountData; @@ -92,11 +99,14 @@ export function AccountCard({ const connectorPosition = CONNECTOR_POSITION_MAP[zone]; // Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp) - const isCliproxyProvider = QUOTA_SUPPORTED_PROVIDERS.includes( - account.provider as QuotaSupportedProvider - ); + const normalizedProvider = account.provider.toLowerCase(); + const isCliproxyProvider = + QUOTA_SUPPORTED_PROVIDERS.includes(normalizedProvider as QuotaSupportedProvider) || + QUOTA_PROVIDER_ALIASES.includes(normalizedProvider); + const isCodexProvider = normalizedProvider === 'codex'; + const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic'; const { data: quota, isLoading: quotaLoading } = useAccountQuota( - account.provider, + normalizedProvider, account.id, isCliproxyProvider ); @@ -105,7 +115,7 @@ export function AccountCard({ const minQuota = getProviderMinQuota(account.provider, quota); const resetTime = getProviderResetTime(account.provider, quota); const codexBreakdown = - account.provider === 'codex' && quota && isCodexQuotaResult(quota) + isCodexProvider && quota && isCodexQuotaResult(quota) ? getCodexQuotaBreakdown(quota.windows) : null; const codexQuotaRows = [ @@ -113,7 +123,7 @@ export function AccountCard({ { label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null }, ].filter((row): row is { label: string; value: number } => row.value !== null); const claudeQuotaRows = - account.provider === 'claude' && quota && isClaudeQuotaResult(quota) + isClaudeProvider && quota && isClaudeQuotaResult(quota) ? [ { label: '5h', @@ -140,13 +150,13 @@ export function AccountCard({ }, ].filter((row): row is { label: string; value: number } => row.value !== null) : []; - const compactQuotaRows = - account.provider === 'codex' - ? codexQuotaRows - : account.provider === 'claude' - ? claudeQuotaRows - : []; + const compactQuotaRows = isCodexProvider + ? codexQuotaRows + : isClaudeProvider + ? claudeQuotaRows + : []; const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null; + const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null; // Tier badge (AGY only) - show P for Pro, U for Ultra const showTierBadge = @@ -258,7 +268,7 @@ export function AccountCard({ Quota...
- ) : minQuota !== null ? ( + ) : minQuotaValue !== null ? ( @@ -270,9 +280,9 @@ export function AccountCard({ 50 + minQuotaValue > 50 ? 'text-emerald-600 dark:text-emerald-400' - : minQuota > 20 + : minQuotaValue > 20 ? 'text-amber-500' : 'text-red-500' )} @@ -293,13 +303,13 @@ export function AccountCard({
50 + minQuotaValue > 50 ? 'bg-emerald-500' - : minQuota > 20 + : minQuotaValue > 20 ? 'bg-amber-500' : 'bg-red-500' )} - style={{ width: `${minQuota}%` }} + style={{ width: `${minQuotaValue}%` }} />
@@ -309,6 +319,8 @@ export function AccountCard({
+ ) : quota?.success ? ( +
Quota limits unavailable
) : quota?.needsReauth ? ( diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 01aa19b3..fd95e5fe 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -106,12 +106,16 @@ export function AccountItem({ selected, onSelectChange, }: AccountItemProps) { + const normalizedProvider = account.provider.toLowerCase(); + const isCodexProvider = normalizedProvider === 'codex'; + const isClaudeProvider = normalizedProvider === 'claude' || normalizedProvider === 'anthropic'; + // Fetch runtime stats to get actual lastUsedAt (more accurate than file state) const { data: stats } = useCliproxyStats(showQuota); // Fetch quota for all provider accounts const { data: quota, isLoading: quotaLoading } = useAccountQuota( - account.provider, + normalizedProvider, account.id, showQuota ); @@ -124,7 +128,7 @@ export function AccountItem({ const minQuota = getProviderMinQuota(account.provider, quota); const nextReset = getProviderResetTime(account.provider, quota); const codexBreakdown = - account.provider === 'codex' && quota && isCodexQuotaResult(quota) + isCodexProvider && quota && isCodexQuotaResult(quota) ? getCodexQuotaBreakdown(quota.windows) : null; const codexQuotaRows = [ @@ -132,7 +136,7 @@ export function AccountItem({ { label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null }, ].filter((row): row is { label: string; value: number } => row.value !== null); const claudeQuotaRows = - account.provider === 'claude' && quota && isClaudeQuotaResult(quota) + isClaudeProvider && quota && isClaudeQuotaResult(quota) ? [ { label: '5h', @@ -159,13 +163,13 @@ export function AccountItem({ }, ].filter((row): row is { label: string; value: number } => row.value !== null) : []; - const dualWindowQuotaRows = - account.provider === 'codex' - ? codexQuotaRows - : account.provider === 'claude' - ? claudeQuotaRows - : []; + const dualWindowQuotaRows = isCodexProvider + ? codexQuotaRows + : isClaudeProvider + ? claudeQuotaRows + : []; const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null; + const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null; return (
Loading quota...
- ) : minQuota !== null ? ( + ) : minQuotaValue !== null ? (
{/* Status indicator based on runtime usage, not file state */}
@@ -409,9 +413,9 @@ export function AccountItem({ ) : (
{minQuotaLabel}% @@ -425,6 +429,16 @@ export function AccountItem({
+ ) : quota?.success ? ( +
+ + + No limits + +
) : quota?.needsReauth ? ( diff --git a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx index a42ed816..320a0495 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-tab.tsx @@ -94,6 +94,14 @@ export function ModelConfigTab({ privacyMode, isRemoteMode, }: ModelConfigTabProps) { + const normalizedProvider = provider.toLowerCase(); + const showQuota = + (QUOTA_SUPPORTED_PROVIDERS.includes(normalizedProvider as QuotaSupportedProvider) || + ['anthropic', 'antigravity', 'gemini-cli', 'copilot', 'github-copilot'].includes( + normalizedProvider + )) && + !isRemoteMode; + // Kiro-specific: no-incognito setting (defaults to true = normal browser) const isKiro = provider === 'kiro'; const [kiroNoIncognito, setKiroNoIncognito] = useState(true); @@ -177,9 +185,7 @@ export function ModelConfigTab({ isBulkPausing={isBulkPausing} isBulkResuming={isBulkResuming} privacyMode={privacyMode} - showQuota={ - QUOTA_SUPPORTED_PROVIDERS.includes(provider as QuotaSupportedProvider) && !isRemoteMode - } + showQuota={showQuota} isKiro={isKiro} kiroNoIncognito={kiroNoIncognito} onKiroNoIncognitoChange={saveKiroNoIncognito} diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 5cb09892..105049b6 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -216,6 +216,26 @@ export type { /** Providers with quota API support */ export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'claude', 'gemini', 'ghcp'] as const; export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number]; +const QUOTA_PROVIDER_ALIAS_MAP: Readonly> = { + antigravity: 'agy', + anthropic: 'claude', + 'gemini-cli': 'gemini', + copilot: 'ghcp', + 'github-copilot': 'ghcp', +}; + +function normalizeQuotaProvider(provider: string): QuotaSupportedProvider | null { + const normalized = provider.trim().toLowerCase(); + if (!normalized) { + return null; + } + + if ((QUOTA_SUPPORTED_PROVIDERS as readonly string[]).includes(normalized)) { + return normalized as QuotaSupportedProvider; + } + + return QUOTA_PROVIDER_ALIAS_MAP[normalized] ?? null; +} /** * Fetch account quota from generic API route @@ -317,7 +337,12 @@ async function fetchQuotaByProvider( provider: string, accountId: string ): Promise { - switch (provider) { + const canonicalProvider = normalizeQuotaProvider(provider); + if (!canonicalProvider) { + return fetchAccountQuota(provider, accountId); + } + + switch (canonicalProvider) { case 'codex': return fetchCodexQuotaApi(accountId); case 'claude': @@ -336,13 +361,12 @@ async function fetchQuotaByProvider( * Supports agy, codex, claude, gemini, and ghcp providers */ export function useAccountQuota(provider: string, accountId: string, enabled = true) { + const canonicalProvider = normalizeQuotaProvider(provider); + return useQuery({ - queryKey: ['account-quota', provider, accountId], - queryFn: () => fetchQuotaByProvider(provider, accountId), - enabled: - enabled && - QUOTA_SUPPORTED_PROVIDERS.includes(provider as QuotaSupportedProvider) && - !!accountId, + queryKey: ['account-quota', canonicalProvider ?? provider, accountId], + queryFn: () => fetchQuotaByProvider(canonicalProvider ?? provider, accountId), + enabled: enabled && !!canonicalProvider && !!accountId, staleTime: 60000, // Match refetchInterval to prevent early refetching refetchInterval: 60000, // Refresh every 1 minute refetchOnWindowFocus: false, // Don't refetch on tab switch diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 7296690e..56007a4b 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -670,8 +670,9 @@ export function getProviderMinQuota( quota: UnifiedQuotaResult | null | undefined ): number | null { if (!quota?.success) return null; + const normalizedProvider = provider.trim().toLowerCase(); - switch (provider) { + switch (normalizedProvider) { case 'agy': if (isAgyQuotaResult(quota)) { return getMinClaudeQuota(quota.models); @@ -713,8 +714,9 @@ export function getProviderResetTime( quota: UnifiedQuotaResult | null | undefined ): string | null { if (!quota?.success) return null; + const normalizedProvider = provider.trim().toLowerCase(); - switch (provider) { + switch (normalizedProvider) { case 'agy': if (isAgyQuotaResult(quota)) { return getClaudeResetTime(quota.models); From ca58cb5a088d0f4e714d0936d5b339de8f5ad053 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 22 Feb 2026 02:35:07 +0700 Subject: [PATCH 15/27] fix(cliproxy): harden quota guards and review follow-ups --- .../quota-fetcher-claude-normalizer.ts | 25 ++++-- src/cliproxy/quota-fetcher-claude.ts | 16 +++- src/cliproxy/quota-manager.ts | 6 +- src/commands/cliproxy/quota-subcommand.ts | 27 +----- .../routes/cliproxy-stats-routes.ts | 60 +++++++++++++ .../cliproxy/quota-fetcher-claude.test.ts | 21 +++++ .../account/flow-viz/account-card.tsx | 2 +- .../cliproxy/provider-editor/account-item.tsx | 2 +- .../shared/quota-tooltip-content.tsx | 10 ++- ui/src/lib/utils.ts | 88 +++++++++++++++---- ui/tests/unit/ui/lib/quota-utils.test.ts | 52 +++++++++++ 11 files changed, 251 insertions(+), 58 deletions(-) diff --git a/src/cliproxy/quota-fetcher-claude-normalizer.ts b/src/cliproxy/quota-fetcher-claude-normalizer.ts index 11d8396e..25104425 100644 --- a/src/cliproxy/quota-fetcher-claude-normalizer.ts +++ b/src/cliproxy/quota-fetcher-claude-normalizer.ts @@ -7,6 +7,9 @@ import type { ClaudeCoreUsageSummary, ClaudeQuotaWindow } from './quota-types'; import { clampPercent } from '../utils/percentage'; +// Distinguishes epoch milliseconds from seconds (1e12 ~= 2001-09-09T01:46:40Z). +const EPOCH_MS_THRESHOLD = 1e12; + function asString(value: unknown): string | null { return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null; } @@ -32,7 +35,7 @@ function asNumber(value: unknown): number | null { function normalizeTimestamp(value: unknown): string | null { const asNum = asNumber(value); if (asNum !== null) { - const millis = asNum > 1e12 ? asNum : asNum * 1000; + const millis = asNum > EPOCH_MS_THRESHOLD ? asNum : asNum * 1000; const date = new Date(millis); return isNaN(date.getTime()) ? null : date.toISOString(); } @@ -44,7 +47,7 @@ function normalizeTimestamp(value: unknown): string | null { if (/^\d+$/.test(str)) { const numeric = Number(str); if (isFinite(numeric)) { - const millis = numeric > 1e12 ? numeric : numeric * 1000; + const millis = numeric > EPOCH_MS_THRESHOLD ? numeric : numeric * 1000; const date = new Date(millis); return isNaN(date.getTime()) ? null : date.toISOString(); } @@ -260,6 +263,19 @@ const WEEKLY_RATE_LIMIT_TYPES = new Set([ 'seven_day_cowork', ]); +export function isClaudeWeeklyRateLimitType(rateLimitType: string): boolean { + return WEEKLY_RATE_LIMIT_TYPES.has(rateLimitType); +} + +export function pickMostRestrictiveClaudeWeeklyWindow( + windows: ClaudeQuotaWindow[] +): ClaudeQuotaWindow | null { + const weeklyCandidates = windows.filter((window) => + isClaudeWeeklyRateLimitType(window.rateLimitType) + ); + return pickMostRestrictiveWeekly(weeklyCandidates); +} + /** * Build explicit 5h + weekly usage summary from Claude policy windows. */ @@ -269,10 +285,7 @@ export function buildClaudeCoreUsageSummary(windows: ClaudeQuotaWindow[]): Claud } const fiveHourWindow = windows.find((window) => window.rateLimitType === 'five_hour') || null; - const weeklyCandidates = windows.filter((window) => - WEEKLY_RATE_LIMIT_TYPES.has(window.rateLimitType) - ); - const weeklyWindow = pickMostRestrictiveWeekly(weeklyCandidates); + const weeklyWindow = pickMostRestrictiveClaudeWeeklyWindow(windows); if (fiveHourWindow && weeklyWindow) { return { diff --git a/src/cliproxy/quota-fetcher-claude.ts b/src/cliproxy/quota-fetcher-claude.ts index 745a5b03..3f0c0aaf 100644 --- a/src/cliproxy/quota-fetcher-claude.ts +++ b/src/cliproxy/quota-fetcher-claude.ts @@ -17,7 +17,7 @@ import { export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary }; -const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_code/policy_limits'; +export const CLAUDE_POLICY_LIMITS_URL = 'https://api.anthropic.com/api/claude_code/policy_limits'; const CLAUDE_QUOTA_TIMEOUT_MS = 10000; const CLAUDE_QUOTA_MAX_ATTEMPTS = 2; const CLAUDE_USER_AGENT = 'ccs-cli/claude-quota'; @@ -61,6 +61,10 @@ function extractExpiry(data: Record): string | null { return null; } +function isAuthExpired(expiry: string | null): boolean { + return expiry ? isTokenExpired(expiry) : false; +} + async function readJsonFile(filePath: string): Promise | null> { try { const raw = await fsp.readFile(filePath, 'utf-8'); @@ -81,7 +85,7 @@ async function readAuthCandidate(filePath: string): Promise= CLAUDE_QUOTA_MAX_ATTEMPTS) { diff --git a/src/cliproxy/quota-manager.ts b/src/cliproxy/quota-manager.ts index d8a4fd78..afb431e8 100644 --- a/src/cliproxy/quota-manager.ts +++ b/src/cliproxy/quota-manager.ts @@ -209,10 +209,14 @@ export function clearCooldown(provider: CLIProxyProvider, accountId: string): vo async function batchedMap( items: T[], fn: (item: T) => Promise, - concurrency = 10 + concurrency = 10, + delayMs = 100 ): Promise { const results: R[] = []; for (let i = 0; i < items.length; i += concurrency) { + if (i > 0 && delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } const batch = items.slice(i, i + concurrency); const batchResults = await Promise.all(batch.map(fn)); results.push(...batchResults); diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index dfd58dcd..c19dec31 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -19,6 +19,7 @@ import { import { fetchAllProviderQuotas } from '../../cliproxy/quota-fetcher'; import { fetchAllCodexQuotas } from '../../cliproxy/quota-fetcher-codex'; import { fetchAllClaudeQuotas } from '../../cliproxy/quota-fetcher-claude'; +import { pickMostRestrictiveClaudeWeeklyWindow } from '../../cliproxy/quota-fetcher-claude-normalizer'; import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota-fetcher-gemini-cli'; import { fetchAllGhcpQuotas } from '../../cliproxy/quota-fetcher-ghcp'; import type { @@ -438,30 +439,6 @@ function toClaudeCoreDisplayWindow( }; } -function pickClaudeWeeklyWindow( - windows: ClaudeQuotaResult['windows'] -): ClaudeQuotaResult['windows'][number] | null { - const weeklyCandidates = windows.filter((window) => - [ - 'seven_day', - 'seven_day_opus', - 'seven_day_sonnet', - 'seven_day_oauth_apps', - 'seven_day_cowork', - ].includes(window.rateLimitType) - ); - if (weeklyCandidates.length === 0) return null; - - return [...weeklyCandidates].sort((a, b) => { - if (a.remainingPercent !== b.remainingPercent) { - return a.remainingPercent - b.remainingPercent; - } - const aReset = a.resetAt ? new Date(a.resetAt).getTime() : Number.POSITIVE_INFINITY; - const bReset = b.resetAt ? new Date(b.resetAt).getTime() : Number.POSITIVE_INFINITY; - return aReset - bReset; - })[0]; -} - function getClaudeCoreUsageWindows(quota: ClaudeQuotaResult): { fiveHourWindow: ClaudeDisplayWindow | null; weeklyWindow: ClaudeDisplayWindow | null; @@ -478,7 +455,7 @@ function getClaudeCoreUsageWindows(quota: ClaudeQuotaResult): { const fiveHourPolicy = quota.windows.find((window) => window.rateLimitType === 'five_hour') ?? null; - const weeklyPolicy = pickClaudeWeeklyWindow(quota.windows); + const weeklyPolicy = pickMostRestrictiveClaudeWeeklyWindow(quota.windows); return { fiveHourWindow: fiveHourPolicy ? toClaudeDisplayWindow(fiveHourPolicy) : null, diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 8fd2611e..4d446b90 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -54,6 +54,36 @@ import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager'; const router = Router(); +const QUOTA_RATE_LIMIT_WINDOW_MS = 60_000; +const QUOTA_RATE_LIMIT_MAX_REQUESTS = 120; + +interface QuotaRateLimitEntry { + windowStart: number; + count: number; +} + +const quotaRateLimits = new Map(); + +function buildQuotaRateLimitKey(req: Request, provider: string): string { + const clientIp = req.ip || req.socket.remoteAddress || 'unknown'; + return `${clientIp}:${provider}`; +} + +function isQuotaRouteRateLimited(req: Request, provider: string): boolean { + const key = buildQuotaRateLimitKey(req, provider); + const now = Date.now(); + const current = quotaRateLimits.get(key); + + if (!current || now - current.windowStart >= QUOTA_RATE_LIMIT_WINDOW_MS) { + quotaRateLimits.set(key, { windowStart: now, count: 1 }); + return false; + } + + current.count += 1; + quotaRateLimits.set(key, current); + return current.count > QUOTA_RATE_LIMIT_MAX_REQUESTS; +} + /** * Cache only stable failures; avoid pinning transient network failures (timeouts, 429s). */ @@ -592,6 +622,12 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise => { const { accountId } = req.params; + if (isQuotaRouteRateLimited(req, 'codex')) { + res + .status(429) + .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); + return; + } // Validate accountId - prevent path traversal if ( @@ -633,6 +669,12 @@ router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promi */ router.get('/quota/claude/:accountId', async (req: Request, res: Response): Promise => { const { accountId } = req.params; + if (isQuotaRouteRateLimited(req, 'claude')) { + res + .status(429) + .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); + return; + } // Validate accountId - prevent path traversal if ( @@ -674,6 +716,12 @@ router.get('/quota/claude/:accountId', async (req: Request, res: Response): Prom */ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Promise => { const { accountId } = req.params; + if (isQuotaRouteRateLimited(req, 'gemini')) { + res + .status(429) + .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); + return; + } // Validate accountId - prevent path traversal if ( @@ -715,6 +763,12 @@ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Prom */ router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promise => { const { accountId } = req.params; + if (isQuotaRouteRateLimited(req, 'ghcp')) { + res + .status(429) + .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); + return; + } // Validate accountId - prevent path traversal if ( @@ -757,6 +811,12 @@ router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promis */ router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise => { const { provider, accountId } = req.params; + if (isQuotaRouteRateLimited(req, provider)) { + res + .status(429) + .json({ error: 'Too many quota requests', message: 'Retry after a short delay' }); + return; + } // Validate provider - use canonical CLIPROXY_PROFILES const validProviders: CLIProxyProvider[] = [...CLIPROXY_PROFILES]; diff --git a/tests/unit/cliproxy/quota-fetcher-claude.test.ts b/tests/unit/cliproxy/quota-fetcher-claude.test.ts index 40cbcdef..dd197288 100644 --- a/tests/unit/cliproxy/quota-fetcher-claude.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-claude.test.ts @@ -377,6 +377,27 @@ describe('Claude Quota Fetcher', () => { expect(fetchMock).toHaveBeenCalledTimes(0); }); + it('treats missing expiry as not expired', async () => { + createClaudeAccount('claude-no-expiry@example.com', { + access_token: 'no-expiry-token', + type: 'claude', + }); + + global.fetch = mock(() => + Promise.resolve( + new Response(JSON.stringify({ restrictions: [] }), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + ) + ) as typeof fetch; + + const result = await fetchClaudeQuota('claude-no-expiry@example.com'); + + expect(result.success).toBe(true); + expect(result.windows).toHaveLength(0); + }); + it('retries once on transient 500 then succeeds', async () => { createClaudeAccount('claude-retry@example.com', { access_token: 'retry-token', diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 4bf4abc5..f3410a91 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -315,7 +315,7 @@ export function AccountCard({
- {quota && } + diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index fd95e5fe..501fc4c3 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -424,7 +424,7 @@ export function AccountItem({ )} - {quota && } + diff --git a/ui/src/components/shared/quota-tooltip-content.tsx b/ui/src/components/shared/quota-tooltip-content.tsx index 4dbac2d6..d95dab71 100644 --- a/ui/src/components/shared/quota-tooltip-content.tsx +++ b/ui/src/components/shared/quota-tooltip-content.tsx @@ -22,7 +22,7 @@ import { } from '@/lib/utils'; interface QuotaTooltipContentProps { - quota: UnifiedQuotaResult; + quota: UnifiedQuotaResult | null | undefined; resetTime: string | null; } @@ -62,7 +62,13 @@ function getClaudeWindowDisplayLabel(rateLimitType: string, fallback: string): s * Uses type guards for proper TypeScript narrowing */ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentProps) { - if (!quota?.success) return null; + if (!quota) { + return

Loading quota...

; + } + + if (!quota.success) { + return

{quota.error || 'Failed to load quota'}

; + } // Antigravity (agy) provider tooltip if (isAgyQuotaResult(quota)) { diff --git a/ui/src/lib/utils.ts b/ui/src/lib/utils.ts index 56007a4b..1fbbe124 100644 --- a/ui/src/lib/utils.ts +++ b/ui/src/lib/utils.ts @@ -618,45 +618,97 @@ export type UnifiedQuotaResult = | GeminiCliQuotaResult | GhcpQuotaResult; +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + /** Type guard: Check if quota result is from Antigravity (agy) provider */ export function isAgyQuotaResult(quota: UnifiedQuotaResult): quota is QuotaResult { - return 'models' in quota && Array.isArray((quota as QuotaResult).models); + if (!isRecord(quota)) return false; + const models = (quota as Partial).models; + return typeof quota.success === 'boolean' && Array.isArray(models); } /** Type guard: Check if quota result is from Codex provider */ export function isCodexQuotaResult(quota: UnifiedQuotaResult): quota is CodexQuotaResult { - return ( - 'windows' in quota && 'planType' in quota && Array.isArray((quota as CodexQuotaResult).windows) + if (!isRecord(quota)) return false; + + const candidate = quota as Partial; + if (typeof candidate.success !== 'boolean') return false; + if (!Array.isArray(candidate.windows)) return false; + if (!('planType' in candidate)) return false; + + return candidate.windows.every( + (window) => + isRecord(window) && + typeof window.label === 'string' && + isFiniteNumber(window.usedPercent) && + isFiniteNumber(window.remainingPercent) ); } /** Type guard: Check if quota result is from Claude provider */ export function isClaudeQuotaResult(quota: UnifiedQuotaResult): quota is ClaudeQuotaResult { - return ( - 'windows' in quota && - !('planType' in quota) && - Array.isArray((quota as ClaudeQuotaResult).windows) + if (!isRecord(quota)) return false; + + const candidate = quota as Partial; + if (typeof candidate.success !== 'boolean') return false; + if (!Array.isArray(candidate.windows)) return false; + if ('planType' in candidate) return false; + + return candidate.windows.every( + (window) => + isRecord(window) && + typeof window.rateLimitType === 'string' && + isFiniteNumber(window.remainingPercent) && + typeof window.status === 'string' ); } /** Type guard: Check if quota result is from Gemini CLI provider */ export function isGeminiQuotaResult(quota: UnifiedQuotaResult): quota is GeminiCliQuotaResult { - return 'buckets' in quota && Array.isArray((quota as GeminiCliQuotaResult).buckets); + if (!isRecord(quota)) return false; + + const candidate = quota as Partial; + if (typeof candidate.success !== 'boolean') return false; + if (!Array.isArray(candidate.buckets)) return false; + + return candidate.buckets.every( + (bucket) => + isRecord(bucket) && + typeof bucket.id === 'string' && + isFiniteNumber(bucket.remainingFraction) && + isFiniteNumber(bucket.remainingPercent) && + Array.isArray(bucket.modelIds) + ); } /** Type guard: Check if quota result is from GitHub Copilot (ghcp) provider */ export function isGhcpQuotaResult(quota: UnifiedQuotaResult): quota is GhcpQuotaResult { - const candidate = quota as GhcpQuotaResult; - const snapshots = candidate.snapshots as Record | null | undefined; + if (!isRecord(quota)) return false; - return ( - 'snapshots' in quota && - typeof snapshots === 'object' && - snapshots !== null && - 'premiumInteractions' in snapshots && - 'chat' in snapshots && - 'completions' in snapshots - ); + const candidate = quota as Partial; + const snapshots = candidate.snapshots as Record | null | undefined; + if (typeof candidate.success !== 'boolean') return false; + if (!isRecord(snapshots)) return false; + + const snapshotKeys: Array = [ + 'premiumInteractions', + 'chat', + 'completions', + ]; + return snapshotKeys.every((key) => { + const snapshot = snapshots[key] as Record | undefined; + return ( + isRecord(snapshot) && + isFiniteNumber(snapshot.percentRemaining) && + isFiniteNumber(snapshot.percentUsed) + ); + }); } // ==================== Unified Quota Helpers ==================== diff --git a/ui/tests/unit/ui/lib/quota-utils.test.ts b/ui/tests/unit/ui/lib/quota-utils.test.ts index d6c9dcd2..62865580 100644 --- a/ui/tests/unit/ui/lib/quota-utils.test.ts +++ b/ui/tests/unit/ui/lib/quota-utils.test.ts @@ -15,6 +15,7 @@ import { getProviderMinQuota, getProviderResetTime, isAgyQuotaResult, + isClaudeQuotaResult, isCodexQuotaResult, isGeminiQuotaResult, isGhcpQuotaResult, @@ -22,6 +23,7 @@ import { import type { CodexQuotaWindow, CodexQuotaResult, + ClaudeQuotaResult, GeminiCliBucket, GeminiCliQuotaResult, GhcpQuotaResult, @@ -1002,6 +1004,56 @@ describe('isCodexQuotaResult', () => { }); }); +describe('isClaudeQuotaResult', () => { + it('returns true for valid Claude quota result', () => { + const quota: ClaudeQuotaResult = { + success: true, + windows: [ + { + rateLimitType: 'five_hour', + label: 'Session limit', + status: 'allowed', + utilization: 0.5, + usedPercent: 50, + remainingPercent: 50, + resetAt: '2026-01-30T12:00:00Z', + }, + ], + coreUsage: { + fiveHour: { + rateLimitType: 'five_hour', + label: 'Session limit', + remainingPercent: 50, + resetAt: '2026-01-30T12:00:00Z', + status: 'allowed', + }, + weekly: null, + }, + lastUpdated: Date.now(), + }; + expect(isClaudeQuotaResult(quota)).toBe(true); + }); + + it('returns false for Codex quota result', () => { + const quota: CodexQuotaResult = { + success: true, + windows: [], + planType: 'free', + lastUpdated: Date.now(), + }; + expect(isClaudeQuotaResult(quota as unknown as ClaudeQuotaResult)).toBe(false); + }); + + it('returns false when Claude windows are malformed', () => { + const malformed = { + success: true, + windows: [{ rateLimitType: 'five_hour', label: 'Session limit' }], + lastUpdated: Date.now(), + }; + expect(isClaudeQuotaResult(malformed as unknown as ClaudeQuotaResult)).toBe(false); + }); +}); + describe('isGeminiQuotaResult', () => { it('returns true for valid Gemini quota result', () => { const quota: GeminiCliQuotaResult = { From dcdb2f6284dff04a7718ba98f07d7bab13043d4f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sat, 21 Feb 2026 19:46:08 +0000 Subject: [PATCH 16/27] chore(release): 7.47.0-dev.4 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d4ee9935..6a4eca3d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.47.0-dev.3", + "version": "7.47.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 34292ca7f8d1cb51b7aaa5a59383e8ff2a381bd4 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 22 Feb 2026 22:38:50 +0700 Subject: [PATCH 17/27] fix(cliproxy): prevent false remote timeout on reachable proxy --- src/cliproxy/remote-proxy-client.ts | 54 +++++++++++-------- .../unit/cliproxy/remote-proxy-client.test.ts | 18 +++---- 2 files changed, 41 insertions(+), 31 deletions(-) diff --git a/src/cliproxy/remote-proxy-client.ts b/src/cliproxy/remote-proxy-client.ts index a2197fe0..3e502c99 100644 --- a/src/cliproxy/remote-proxy-client.ts +++ b/src/cliproxy/remote-proxy-client.ts @@ -196,8 +196,8 @@ function createHttpsAgent(allowSelfSigned: boolean): https.Agent | undefined { /** * Check health of remote CLIProxyAPI instance * - * Uses /v1/models endpoint for health check since CLIProxyAPI doesn't expose /health. - * This endpoint is always available and returns 200 when the server is operational. + * Uses root endpoint (/) for health check since CLIProxyAPI doesn't expose /health. + * Root is cheap and avoids false negatives from slower model-list endpoints. * * @param config Remote proxy client configuration * @returns RemoteProxyStatus with reachability and latency @@ -217,14 +217,13 @@ export async function checkRemoteProxy( }; } - // Use /v1/models as health check - CLIProxyAPI doesn't have /health endpoint - const url = buildProxyUrl(host, port, protocol, '/v1/models'); + // Use root endpoint for liveness check - cheap and available across deployments + const url = buildProxyUrl(host, port, protocol, '/'); const startTime = Date.now(); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), timeout); - // Build request options const headers: Record = { Accept: 'application/json', @@ -245,8 +244,19 @@ export async function checkRemoteProxy( // Use native https module for self-signed cert support response = await new Promise((resolve, reject) => { const agent = createHttpsAgent(true); + let settled = false; + + const settle = (callback: () => void) => { + if (settled) return; + settled = true; + clearTimeout(reqTimeout); + callback(); + }; + const reqTimeout = setTimeout(() => { - reject(new Error('Request timeout')); + const timeoutError = new Error('Request timeout'); + req.destroy(timeoutError); + settle(() => reject(timeoutError)); }, timeout); const req = https.request( @@ -258,28 +268,28 @@ export async function checkRemoteProxy( timeout, }, (res) => { - clearTimeout(reqTimeout); - let data = ''; - res.on('data', (chunk) => (data += chunk)); - res.on('end', () => { + // Health check only needs response headers; don't wait for full body. + // This avoids timeout false negatives when servers stream slower payloads. + res.resume(); + settle(() => resolve( - new Response(data, { + new Response(null, { status: res.statusCode || 500, - statusText: res.statusMessage, + statusText: res.statusMessage ?? '', }) - ); - }); + ) + ); } ); req.on('error', (err) => { - clearTimeout(reqTimeout); - reject(err); + settle(() => reject(err)); }); req.on('timeout', () => { - req.destroy(); - reject(new Error('Request timeout')); + const timeoutError = new Error('Request timeout'); + req.destroy(timeoutError); + settle(() => reject(timeoutError)); }); req.end(); @@ -292,8 +302,6 @@ export async function checkRemoteProxy( }); } - clearTimeout(timeoutId); - const latencyMs = Date.now() - startTime; // Check for auth failure @@ -328,6 +336,8 @@ export async function checkRemoteProxy( error: getErrorMessage(errorCode, err.message), errorCode, }; + } finally { + clearTimeout(timeoutId); } } diff --git a/tests/unit/cliproxy/remote-proxy-client.test.ts b/tests/unit/cliproxy/remote-proxy-client.test.ts index e15b8d3a..3a4c8c5a 100644 --- a/tests/unit/cliproxy/remote-proxy-client.test.ts +++ b/tests/unit/cliproxy/remote-proxy-client.test.ts @@ -2,9 +2,9 @@ * Unit tests for remote-proxy-client module */ import { describe, it, expect } from 'bun:test'; -import type { - RemoteProxyClientConfig, - RemoteProxyStatus, +import { + type RemoteProxyClientConfig, + type RemoteProxyStatus, } from '../../../src/cliproxy/remote-proxy-client'; // We test the module's type exports and error handling logic @@ -119,15 +119,15 @@ describe('remote-proxy-client', () => { }); describe('health check URL construction', () => { - // CLIProxyAPI uses /v1/models for health checks (no /health endpoint) - it('should construct correct health check URL pattern using /v1/models', () => { + // CLIProxyAPI uses root endpoint for liveness checks (no /health endpoint) + it('should construct correct health check URL pattern using /', () => { const config: RemoteProxyClientConfig = { host: '192.168.1.100', port: 8317, protocol: 'http', }; - const expectedUrl = `${config.protocol}://${config.host}:${config.port}/v1/models`; - expect(expectedUrl).toBe('http://192.168.1.100:8317/v1/models'); + const expectedUrl = `${config.protocol}://${config.host}:${config.port}/`; + expect(expectedUrl).toBe('http://192.168.1.100:8317/'); }); it('should construct HTTPS URL when protocol is https', () => { @@ -136,8 +136,8 @@ describe('remote-proxy-client', () => { port: 443, protocol: 'https', }; - const expectedUrl = `${config.protocol}://${config.host}:${config.port}/v1/models`; - expect(expectedUrl).toBe('https://secure.example.com:443/v1/models'); + const expectedUrl = `${config.protocol}://${config.host}:${config.port}/`; + expect(expectedUrl).toBe('https://secure.example.com:443/'); }); }); From 88be99f8a071c8f5786800a958218948286cbba0 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 22 Feb 2026 22:44:26 +0700 Subject: [PATCH 18/27] fix(cliproxy): keep remote-proxy client under maintainability limit --- src/cliproxy/remote-proxy-client.ts | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/cliproxy/remote-proxy-client.ts b/src/cliproxy/remote-proxy-client.ts index 3e502c99..838a7bff 100644 --- a/src/cliproxy/remote-proxy-client.ts +++ b/src/cliproxy/remote-proxy-client.ts @@ -341,14 +341,7 @@ export async function checkRemoteProxy( } } -/** - * Test connection to remote CLIProxyAPI (alias for dashboard use) - * - * This is an alias for checkRemoteProxy() for semantic clarity in UI contexts. - * - * @param config Remote proxy client configuration - * @returns RemoteProxyStatus with reachability and latency - */ +/** Alias for dashboard connection tests. */ export async function testConnection(config: RemoteProxyClientConfig): Promise { return checkRemoteProxy(config); } From e3255e5615df121831f626300510c934518d2cca Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 22 Feb 2026 22:51:40 +0700 Subject: [PATCH 19/27] fix(persist): add auto-approve permission flags --- src/commands/persist-command.ts | 109 +++++++++++++++++++- tests/unit/commands/persist-command.test.js | 94 ++++++++++++++++- 2 files changed, 199 insertions(+), 4 deletions(-) diff --git a/src/commands/persist-command.ts b/src/commands/persist-command.ts index b02e41ed..aad2b6fb 100644 --- a/src/commands/persist-command.ts +++ b/src/commands/persist-command.ts @@ -29,6 +29,9 @@ interface PersistCommandArgs { yes?: boolean; listBackups?: boolean; restore?: string | boolean; + permissionMode?: PermissionMode; + dangerouslySkipPermissions?: boolean; + parseError?: string; } interface ResolvedEnv { @@ -37,6 +40,40 @@ interface ResolvedEnv { warning?: string; } +const PERSIST_KNOWN_FLAGS = [ + '--yes', + '-y', + '--list-backups', + '--restore', + '--permission-mode', + '--dangerously-skip-permissions', + '--auto-approve', + '--help', + '-h', +] as const; + +const VALID_PERMISSION_MODES = ['default', 'plan', 'acceptEdits', 'bypassPermissions'] as const; + +type PermissionMode = (typeof VALID_PERMISSION_MODES)[number]; + +function isPermissionMode(value: string): value is PermissionMode { + return VALID_PERMISSION_MODES.includes(value as PermissionMode); +} + +function resolvePermissionMode(parsedArgs: PersistCommandArgs): PermissionMode | undefined { + if (!parsedArgs.dangerouslySkipPermissions) { + return parsedArgs.permissionMode; + } + + if (parsedArgs.permissionMode && parsedArgs.permissionMode !== 'bypassPermissions') { + throw new Error( + '--dangerously-skip-permissions conflicts with --permission-mode. Use bypassPermissions or remove one flag.' + ); + } + + return 'bypassPermissions'; +} + /** Parse command line arguments */ function parseArgs(args: string[]): PersistCommandArgs { const result: PersistCommandArgs = { @@ -49,7 +86,27 @@ function parseArgs(args: string[]): PersistCommandArgs { result.restore = restoreOption.missingValue ? true : restoreOption.value || true; } - for (const arg of restoreOption.remainingArgs) { + const permissionModeOption = extractOption(restoreOption.remainingArgs, ['--permission-mode'], { + knownFlags: PERSIST_KNOWN_FLAGS, + }); + if (permissionModeOption.found) { + if (permissionModeOption.missingValue) { + result.parseError = 'Missing value for --permission-mode'; + } else if (permissionModeOption.value) { + if (!isPermissionMode(permissionModeOption.value)) { + result.parseError = `Invalid --permission-mode "${permissionModeOption.value}". Valid modes: ${VALID_PERMISSION_MODES.join(', ')}`; + } else { + result.permissionMode = permissionModeOption.value; + } + } + } + + result.dangerouslySkipPermissions = hasAnyFlag(permissionModeOption.remainingArgs, [ + '--dangerously-skip-permissions', + '--auto-approve', + ]); + + for (const arg of permissionModeOption.remainingArgs) { if (!arg.startsWith('-')) { result.profile = arg; break; @@ -415,6 +472,13 @@ async function showHelp(): Promise { console.log(''); console.log(subheader('Options')); console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts (auto-backup)`); + console.log( + ` ${color('--permission-mode ', 'command')} Set default permission mode in settings.json` + ); + console.log( + ` ${color('--dangerously-skip-permissions', 'command')} Persist auto-approve (bypassPermissions)` + ); + console.log(` ${color('--auto-approve', 'command')} Alias for --dangerously-skip-permissions`); console.log(` ${color('--help, -h', 'command')} Show this help message`); console.log(''); console.log(subheader('Backup Management')); @@ -437,6 +501,12 @@ async function showHelp(): Promise { console.log(` ${dim('# Persist with auto-confirmation')}`); console.log(` ${color('ccs persist gemini --yes', 'command')}`); console.log(''); + console.log(` ${dim('# Persist with default permission mode')}`); + console.log(` ${color('ccs persist glm --permission-mode acceptEdits', 'command')}`); + console.log(''); + console.log(` ${dim('# Persist with auto-approve enabled')}`); + console.log(` ${color('ccs persist codex --dangerously-skip-permissions', 'command')}`); + console.log(''); console.log(` ${dim('# List all backups')}`); console.log(` ${color('ccs persist --list-backups', 'command')}`); console.log(''); @@ -474,6 +544,17 @@ export async function handlePersistCommand(args: string[]): Promise { return; } await initUI(); + if (parsedArgs.parseError) { + console.log(fail(parsedArgs.parseError)); + process.exit(1); + } + let resolvedPermissionMode: PermissionMode | undefined; + try { + resolvedPermissionMode = resolvePermissionMode(parsedArgs); + } catch (error) { + console.log(fail((error as Error).message)); + process.exit(1); + } if (!parsedArgs.profile) { console.log(fail('Profile name is required')); console.log(''); @@ -529,6 +610,13 @@ export async function handlePersistCommand(args: string[]): Promise { console.log(` ${color(paddedKey, 'command')} = ${displayValue}`); } console.log(''); + if (resolvedPermissionMode) { + console.log(`Default permission mode: ${color(resolvedPermissionMode, 'command')}`); + if (resolvedPermissionMode === 'bypassPermissions') { + console.log(warn('Auto-approve enabled: Claude will skip permission prompts by default.')); + } + console.log(''); + } // Show warning if applicable if (resolved.warning) { console.log(warn(resolved.warning)); @@ -582,13 +670,30 @@ export async function handlePersistCommand(args: string[]): Promise { existingEnv = rawEnv as Record; } } - const mergedSettings = { + const mergedSettings: Record = { ...existingSettings, env: { ...existingEnv, ...resolved.env, }, }; + if (resolvedPermissionMode) { + const rawPermissions = existingSettings.permissions; + let existingPermissions: Record = {}; + if (rawPermissions !== undefined && rawPermissions !== null) { + if (typeof rawPermissions !== 'object' || Array.isArray(rawPermissions)) { + console.log( + warn('Existing permissions in settings.json is not an object - it will be replaced') + ); + } else { + existingPermissions = rawPermissions as Record; + } + } + mergedSettings.permissions = { + ...existingPermissions, + defaultMode: resolvedPermissionMode, + }; + } // Write merged settings try { writeClaudeSettings(mergedSettings); diff --git a/tests/unit/commands/persist-command.test.js b/tests/unit/commands/persist-command.test.js index 7282ac37..9674da5c 100644 --- a/tests/unit/commands/persist-command.test.js +++ b/tests/unit/commands/persist-command.test.js @@ -20,11 +20,15 @@ describe('Persist Command', () => { * Simulates the argument parsing logic from persist-command.ts */ function parseArgs(args) { + const validPermissionModes = ['default', 'plan', 'acceptEdits', 'bypassPermissions']; const result = { profile: undefined, yes: false, listBackups: false, restore: undefined, + permissionMode: undefined, + dangerouslySkipPermissions: false, + parseError: undefined, }; for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -43,6 +47,27 @@ describe('Persist Command', () => { } else { result.restore = true; // Use latest } + } else if (arg === '--permission-mode') { + const nextArg = args[i + 1]; + if (!nextArg || nextArg.startsWith('-')) { + result.parseError = 'Missing value for --permission-mode'; + } else if (!validPermissionModes.includes(nextArg)) { + result.parseError = `Invalid --permission-mode "${nextArg}". Valid modes: ${validPermissionModes.join(', ')}`; + } else { + result.permissionMode = nextArg; + i++; // Skip next arg + } + } else if (arg.startsWith('--permission-mode=')) { + const mode = arg.split('=').slice(1).join('='); + if (!mode.trim()) { + result.parseError = 'Missing value for --permission-mode'; + } else if (!validPermissionModes.includes(mode)) { + result.parseError = `Invalid --permission-mode "${mode}". Valid modes: ${validPermissionModes.join(', ')}`; + } else { + result.permissionMode = mode; + } + } else if (arg === '--dangerously-skip-permissions' || arg === '--auto-approve') { + result.dangerouslySkipPermissions = true; } else if (!arg.startsWith('-') && !result.profile) { result.profile = arg; } @@ -117,6 +142,34 @@ describe('Persist Command', () => { assert.strictEqual(result.restore, '20260110_205324'); assert.strictEqual(result.yes, true); }); + + it('parses --permission-mode with valid mode', () => { + const result = parseArgs(['glm', '--permission-mode', 'acceptEdits']); + assert.strictEqual(result.profile, 'glm'); + assert.strictEqual(result.permissionMode, 'acceptEdits'); + assert.strictEqual(result.parseError, undefined); + }); + + it('parses --permission-mode=value syntax', () => { + const result = parseArgs(['glm', '--permission-mode=bypassPermissions']); + assert.strictEqual(result.permissionMode, 'bypassPermissions'); + assert.strictEqual(result.parseError, undefined); + }); + + it('sets parseError for invalid --permission-mode', () => { + const result = parseArgs(['glm', '--permission-mode', 'invalid']); + assert.match(result.parseError, /Invalid --permission-mode/); + }); + + it('parses --dangerously-skip-permissions flag', () => { + const result = parseArgs(['glm', '--dangerously-skip-permissions']); + assert.strictEqual(result.dangerouslySkipPermissions, true); + }); + + it('parses --auto-approve as alias flag', () => { + const result = parseArgs(['glm', '--auto-approve']); + assert.strictEqual(result.dangerouslySkipPermissions, true); + }); }); // ========================================================================= @@ -170,15 +223,28 @@ describe('Persist Command', () => { /** * Simulates the merge logic from persist-command.ts */ - function mergeSettings(existing, newEnv) { + function mergeSettings(existing, newEnv, permissionMode) { const existingEnv = existing.env || {}; - return { + const merged = { ...existing, env: { ...existingEnv, ...newEnv, }, }; + + if (permissionMode) { + const existingPermissions = + existing.permissions && typeof existing.permissions === 'object' + ? existing.permissions + : {}; + merged.permissions = { + ...existingPermissions, + defaultMode: permissionMode, + }; + } + + return merged; } it('merges env vars into empty settings', () => { @@ -258,6 +324,30 @@ describe('Persist Command', () => { assert.strictEqual(result.env.ANTHROPIC_BASE_URL, 'http://test.com'); assert.strictEqual(result.env.ANTHROPIC_MODEL, 'test'); }); + + it('sets permissions.defaultMode when permission mode is provided', () => { + const existing = { + hooks: { PreToolUse: [] }, + }; + const result = mergeSettings(existing, { ANTHROPIC_MODEL: 'test' }, 'acceptEdits'); + + assert.strictEqual(result.permissions.defaultMode, 'acceptEdits'); + assert.deepStrictEqual(result.hooks, existing.hooks); + }); + + it('preserves existing permissions allow/deny when setting defaultMode', () => { + const existing = { + permissions: { + allow: ['Bash(ls:*)'], + deny: ['Bash(rm:*)'], + }, + }; + const result = mergeSettings(existing, { ANTHROPIC_MODEL: 'test' }, 'bypassPermissions'); + + assert.deepStrictEqual(result.permissions.allow, ['Bash(ls:*)']); + assert.deepStrictEqual(result.permissions.deny, ['Bash(rm:*)']); + assert.strictEqual(result.permissions.defaultMode, 'bypassPermissions'); + }); }); // ========================================================================= From 5003ae55db6c50937b9b7fbbfe36dd266f2ee609 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 22 Feb 2026 15:55:31 +0000 Subject: [PATCH 20/27] chore(release): 7.47.0-dev.5 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 6a4eca3d..d37b6268 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.47.0-dev.4", + "version": "7.47.0-dev.5", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 61bcd4df5e343a34c3089c939ee23ce9a815c029 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 22 Feb 2026 23:01:38 +0700 Subject: [PATCH 21/27] fix(persist): resolve CI gate and review test gaps --- src/commands/persist-command.ts | 11 +--- tests/unit/commands/persist-command.test.js | 59 ++++++++++++++++----- 2 files changed, 48 insertions(+), 22 deletions(-) diff --git a/src/commands/persist-command.ts b/src/commands/persist-command.ts index aad2b6fb..4e461e73 100644 --- a/src/commands/persist-command.ts +++ b/src/commands/persist-command.ts @@ -545,16 +545,9 @@ export async function handlePersistCommand(args: string[]): Promise { } await initUI(); if (parsedArgs.parseError) { - console.log(fail(parsedArgs.parseError)); - process.exit(1); - } - let resolvedPermissionMode: PermissionMode | undefined; - try { - resolvedPermissionMode = resolvePermissionMode(parsedArgs); - } catch (error) { - console.log(fail((error as Error).message)); - process.exit(1); + throw new Error(parsedArgs.parseError); } + const resolvedPermissionMode = resolvePermissionMode(parsedArgs); if (!parsedArgs.profile) { console.log(fail('Profile name is required')); console.log(''); diff --git a/tests/unit/commands/persist-command.test.js b/tests/unit/commands/persist-command.test.js index 9674da5c..11e15eb0 100644 --- a/tests/unit/commands/persist-command.test.js +++ b/tests/unit/commands/persist-command.test.js @@ -10,6 +10,8 @@ */ const assert = require('assert'); +const fs = require('fs'); +const path = require('path'); describe('Persist Command', () => { // ========================================================================= @@ -75,6 +77,20 @@ describe('Persist Command', () => { return result; } + function resolvePermissionMode(parsedArgs) { + if (!parsedArgs.dangerouslySkipPermissions) { + return parsedArgs.permissionMode; + } + + if (parsedArgs.permissionMode && parsedArgs.permissionMode !== 'bypassPermissions') { + throw new Error( + '--dangerously-skip-permissions conflicts with --permission-mode. Use bypassPermissions or remove one flag.' + ); + } + + return 'bypassPermissions'; + } + it('parses profile name as first positional argument', () => { const result = parseArgs(['glm']); assert.strictEqual(result.profile, 'glm'); @@ -170,6 +186,32 @@ describe('Persist Command', () => { const result = parseArgs(['glm', '--auto-approve']); assert.strictEqual(result.dangerouslySkipPermissions, true); }); + + it('handles both dangerous alias flags together', () => { + const result = parseArgs(['glm', '--dangerously-skip-permissions', '--auto-approve']); + assert.strictEqual(result.dangerouslySkipPermissions, true); + assert.strictEqual(resolvePermissionMode(result), 'bypassPermissions'); + }); + + it('throws on conflict between dangerous mode and non-bypass permission mode', () => { + const parsed = parseArgs(['glm', '--permission-mode', 'acceptEdits', '--auto-approve']); + + assert.throws( + () => resolvePermissionMode(parsed), + /--dangerously-skip-permissions conflicts with --permission-mode/ + ); + }); + + it('keeps test permission mode list aligned with source constant', () => { + const sourcePath = path.join(__dirname, '../../../src/commands/persist-command.ts'); + const sourceContent = fs.readFileSync(sourcePath, 'utf8'); + ['default', 'plan', 'acceptEdits', 'bypassPermissions'].forEach((mode) => { + assert( + sourceContent.includes(`'${mode}'`), + `Expected mode '${mode}' to exist in source constant` + ); + }); + }); }); // ========================================================================= @@ -479,7 +521,7 @@ describe('Persist Command', () => { describe('Error Messages', () => { it('account profile error message mentions CLAUDE_CONFIG_DIR', () => { const errorMessage = - "Account profiles use CLAUDE_CONFIG_DIR isolation, not env vars.\n" + + 'Account profiles use CLAUDE_CONFIG_DIR isolation, not env vars.\n' + "Use 'ccs profileName' to run with this profile instead."; assert(errorMessage.includes('CLAUDE_CONFIG_DIR')); assert(errorMessage.includes('ccs profileName')); @@ -567,30 +609,21 @@ describe('Persist Command', () => { // ========================================================================= describe('Backup Restore Logic', () => { it('selects first backup when restore=true (latest)', () => { - const backups = [ - { timestamp: '20260110_205324' }, - { timestamp: '20260110_100000' }, - ]; + const backups = [{ timestamp: '20260110_205324' }, { timestamp: '20260110_100000' }]; const restore = true; const selected = restore === true ? backups[0] : backups.find((b) => b.timestamp === restore); assert.strictEqual(selected.timestamp, '20260110_205324'); }); it('selects specific backup when restore is a timestamp', () => { - const backups = [ - { timestamp: '20260110_205324' }, - { timestamp: '20260110_100000' }, - ]; + const backups = [{ timestamp: '20260110_205324' }, { timestamp: '20260110_100000' }]; const restore = '20260110_100000'; const selected = restore === true ? backups[0] : backups.find((b) => b.timestamp === restore); assert.strictEqual(selected.timestamp, '20260110_100000'); }); it('returns undefined when timestamp not found', () => { - const backups = [ - { timestamp: '20260110_205324' }, - { timestamp: '20260110_100000' }, - ]; + const backups = [{ timestamp: '20260110_205324' }, { timestamp: '20260110_100000' }]; const restore = '20260101_000000'; const selected = restore === true ? backups[0] : backups.find((b) => b.timestamp === restore); assert.strictEqual(selected, undefined); From 742b5ed5803dc7937547fc5cf78c8b23c6b3d10f Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 22 Feb 2026 23:12:23 +0700 Subject: [PATCH 22/27] fix(cliproxy): normalize codex effort aliases without reasoning proxy --- src/cliproxy/executor/env-resolver.ts | 42 +++++++- .../env-resolver-codex-fallback.test.ts | 95 +++++++++++++++++++ 2 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 tests/unit/cliproxy/env-resolver-codex-fallback.test.ts diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index ace7a18d..dbe49ad6 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -24,7 +24,7 @@ import { stripClaudeCodeEnv } from '../../utils/shell-executor'; import { CodexReasoningProxy } from '../codex-reasoning-proxy'; import { ToolSanitizationProxy } from '../tool-sanitization-proxy'; import { HttpsTunnelProxy } from '../https-tunnel-proxy'; -import { normalizeModelIdForProvider } from '../model-id-normalizer'; +import { MODEL_ENV_VAR_KEYS, normalizeModelIdForProvider } from '../model-id-normalizer'; export interface RemoteProxyConfig { host: string; @@ -61,6 +61,38 @@ export interface ProxyChainConfig { compositeDefaultTier?: 'opus' | 'sonnet' | 'haiku'; } +const CODEX_EFFORT_SUFFIX_REGEX = /^(.*)-(xhigh|high|medium)$/i; +const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i; + +function normalizeCodexModelForDirectUpstream(model: string): string { + const withoutExtendedContext = model.trim().replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '').trim(); + if (!withoutExtendedContext) return withoutExtendedContext; + + const effortMatch = withoutExtendedContext.match(CODEX_EFFORT_SUFFIX_REGEX); + if (!effortMatch?.[1] || !effortMatch[2]) { + return withoutExtendedContext; + } + + return `${effortMatch[1].trim()}(${effortMatch[2].toLowerCase()})`; +} + +function normalizeCodexEnvForDirectUpstream(envVars: NodeJS.ProcessEnv): NodeJS.ProcessEnv { + let nextEnv: NodeJS.ProcessEnv | null = null; + + for (const key of MODEL_ENV_VAR_KEYS) { + const value = envVars[key]; + if (typeof value !== 'string' || value.trim().length === 0) continue; + + const normalizedValue = normalizeCodexModelForDirectUpstream(value); + if (normalizedValue === value) continue; + + if (!nextEnv) nextEnv = { ...envVars }; + nextEnv[key] = normalizedValue; + } + + return nextEnv ?? envVars; +} + /** * Build final environment variables for Claude CLI execution * Handles proxy chain ordering and integration with hooks @@ -190,6 +222,14 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record { + afterEach(() => { + while (tempDirs.length > 0) { + const tempDir = tempDirs.pop(); + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + } + }); + + it('normalizes codex effort aliases when reasoning proxy is unavailable', () => { + const settingsPath = createCodexSettingsFile({ + defaultModel: 'gpt-5.3-codex-high', + opusModel: 'gpt-5.3-codex-xhigh', + sonnetModel: 'gpt-5.3-codex-high', + haikuModel: 'gpt-5-mini-medium', + }); + + const env = buildClaudeEnvironment({ + provider: 'codex', + useRemoteProxy: false, + localPort: 8317, + customSettingsPath: settingsPath, + verbose: false, + }); + + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex(high)'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex(xhigh)'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex(high)'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini(medium)'); + }); + + it('keeps codex effort aliases when reasoning proxy is active', () => { + const settingsPath = createCodexSettingsFile({ + defaultModel: 'gpt-5.3-codex-high', + opusModel: 'gpt-5.3-codex-xhigh', + sonnetModel: 'gpt-5.3-codex-high', + haikuModel: 'gpt-5-mini-medium', + }); + + const env = buildClaudeEnvironment({ + provider: 'codex', + useRemoteProxy: false, + localPort: 8317, + customSettingsPath: settingsPath, + codexReasoningPort: 9444, + verbose: false, + }); + + expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-high'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-medium'); + expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:9444/api/provider/codex'); + }); +}); From b57a71ea7924973df7620bc50181bf2208cb3ca6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 22 Feb 2026 16:29:29 +0000 Subject: [PATCH 23/27] chore(release): 7.47.0-dev.6 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index d37b6268..7c4b2456 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.47.0-dev.5", + "version": "7.47.0-dev.6", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From a81176da79568189a686f748d3f76b2c3e1a4240 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Sun, 22 Feb 2026 23:40:45 +0700 Subject: [PATCH 24/27] fix(cliproxy): keep canonical codex model ids in settings --- config/base-codex.settings.json | 8 ++-- src/cliproxy/model-config.ts | 17 +++----- src/cliproxy/services/variant-settings.ts | 39 +++++++------------ .../cliproxy/variant-update-service.test.ts | 24 ++++++------ 4 files changed, 37 insertions(+), 51 deletions(-) diff --git a/config/base-codex.settings.json b/config/base-codex.settings.json index dcc10763..9c92f741 100644 --- a/config/base-codex.settings.json +++ b/config/base-codex.settings.json @@ -2,9 +2,9 @@ "env": { "ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codex", "ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed", - "ANTHROPIC_MODEL": "gpt-5.3-codex-xhigh", - "ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5.3-codex-xhigh", - "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.3-codex-high", - "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5-mini-medium" + "ANTHROPIC_MODEL": "gpt-5.3-codex", + "ANTHROPIC_DEFAULT_OPUS_MODEL": "gpt-5.3-codex", + "ANTHROPIC_DEFAULT_SONNET_MODEL": "gpt-5.3-codex", + "ANTHROPIC_DEFAULT_HAIKU_MODEL": "gpt-5-mini" } } diff --git a/src/cliproxy/model-config.ts b/src/cliproxy/model-config.ts index 1b03a8f4..a9ab8a84 100644 --- a/src/cliproxy/model-config.ts +++ b/src/cliproxy/model-config.ts @@ -21,13 +21,9 @@ function stripCodexEffortSuffix(model: string, provider: CLIProxyProvider): stri return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); } -function normalizeCodexTierModel( - provider: CLIProxyProvider, - model: string, - fallbackEffort: 'medium' | 'high' | 'xhigh' -): string { +function normalizeCodexTierModel(provider: CLIProxyProvider, model: string): string { if (provider !== 'codex') return model; - return CODEX_EFFORT_SUFFIX_REGEX.test(model) ? model : `${model}-${fallbackEffort}`; + return stripCodexEffortSuffix(model, provider); } /** @@ -160,13 +156,12 @@ export async function configureProviderModel( // Get base env vars for defaults const baseEnv = getClaudeEnvVars(provider); - const selectedDefaultModel = normalizeCodexTierModel(provider, selectedModel, 'xhigh'); - const selectedOpusModel = normalizeCodexTierModel(provider, selectedModel, 'xhigh'); - const selectedSonnetModel = normalizeCodexTierModel(provider, selectedModel, 'high'); + const selectedDefaultModel = normalizeCodexTierModel(provider, selectedModel); + const selectedOpusModel = normalizeCodexTierModel(provider, selectedModel); + const selectedSonnetModel = normalizeCodexTierModel(provider, selectedModel); const selectedHaikuModel = normalizeCodexTierModel( provider, - baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || selectedModel, - 'medium' + baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || selectedModel ); // Read existing settings to preserve user customizations diff --git a/src/cliproxy/services/variant-settings.ts b/src/cliproxy/services/variant-settings.ts index 551a2f89..36862948 100644 --- a/src/cliproxy/services/variant-settings.ts +++ b/src/cliproxy/services/variant-settings.ts @@ -37,17 +37,12 @@ interface SettingsFile { const CODEX_EFFORT_SUFFIX_REGEX = /-(xhigh|high|medium)$/i; -function hasCodexEffortSuffix(model: string): boolean { - return CODEX_EFFORT_SUFFIX_REGEX.test(model); -} - -function normalizeCodexTierModel( +function canonicalizeModelForProvider( provider: CLIProxyProfileName | undefined, - model: string, - fallbackEffort: 'medium' | 'high' | 'xhigh' + model: string ): string { if (provider !== 'codex') return model; - return hasCodexEffortSuffix(model) ? model : `${model}-${fallbackEffort}`; + return model.replace(CODEX_EFFORT_SUFFIX_REGEX, ''); } /** @@ -59,13 +54,13 @@ function buildSettingsEnv( port: number = CLIPROXY_DEFAULT_PORT ): SettingsEnv { const baseEnv = getClaudeEnvVars(provider as CLIProxyProvider, port); - const defaultModel = normalizeCodexTierModel(provider, model, 'xhigh'); - const opusModel = normalizeCodexTierModel(provider, model, 'xhigh'); - const sonnetModel = normalizeCodexTierModel(provider, model, 'high'); - const haikuModel = normalizeCodexTierModel( + const normalizedModel = canonicalizeModelForProvider(provider, model); + const defaultModel = normalizedModel; + const opusModel = normalizedModel; + const sonnetModel = normalizedModel; + const haikuModel = canonicalizeModelForProvider( provider, - baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model, - 'medium' + baseEnv.ANTHROPIC_DEFAULT_HAIKU_MODEL || model ); return { @@ -313,18 +308,14 @@ export function updateSettingsModel( if (model) { settings.env = settings.env || ({} as SettingsEnv); - settings.env.ANTHROPIC_MODEL = normalizeCodexTierModel(provider, model, 'xhigh'); - settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = normalizeCodexTierModel(provider, model, 'xhigh'); - settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = normalizeCodexTierModel( - provider, - model, - 'high' - ); + const normalizedModel = canonicalizeModelForProvider(provider, model); + settings.env.ANTHROPIC_MODEL = normalizedModel; + settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL = normalizedModel; + settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL = normalizedModel; if (provider === 'codex' && settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL) { - settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = normalizeCodexTierModel( + settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = canonicalizeModelForProvider( provider, - settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, - 'medium' + settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL ); } } else { diff --git a/tests/unit/cliproxy/variant-update-service.test.ts b/tests/unit/cliproxy/variant-update-service.test.ts index 38220280..ae3874a4 100644 --- a/tests/unit/cliproxy/variant-update-service.test.ts +++ b/tests/unit/cliproxy/variant-update-service.test.ts @@ -100,10 +100,10 @@ cliproxy: }; expect(settings.env.ANTHROPIC_BASE_URL).toContain('/api/provider/codex'); - expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.1-codex-mini-xhigh'); - expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.1-codex-mini-xhigh'); - expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.1-codex-mini-high'); - expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-medium'); + expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.1-codex-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.1-codex-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.1-codex-mini'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); expect(settings.env.CUSTOM_FLAG).toBe('keep-me'); expect(settings.hooks.PreToolUse.length).toBe(1); @@ -122,10 +122,10 @@ cliproxy: let settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { env: Record; }; - expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); - expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); - expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); - expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-medium'); + expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); + expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); const modelOnly = updateVariant('demo', { model: 'gpt-5.3-codex' }); expect(modelOnly.success).toBe(true); @@ -133,9 +133,9 @@ cliproxy: settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as { env: Record; }; - expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh'); - expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh'); - expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high'); - expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini-medium'); + expect(settings.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex'); + expect(settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex'); + expect(settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex'); + expect(settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5-mini'); }); }); From d32811add507fe156159411484cf182c03120d5b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 22 Feb 2026 16:47:36 +0000 Subject: [PATCH 25/27] chore(release): 7.47.0-dev.7 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 7c4b2456..9f6312c1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.47.0-dev.6", + "version": "7.47.0-dev.7", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli", From 29cceb3a881600d70511bcc6d11b63f5b809f04b Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Mon, 23 Feb 2026 00:05:47 +0700 Subject: [PATCH 26/27] fix(persist): harden persist restore safety and edge cases --- src/commands/persist-command.ts | 513 +++++++++++++----- .../commands/persist-command-handler.test.ts | 273 ++++++++++ tests/unit/commands/persist-command.test.js | 171 +++++- 3 files changed, 807 insertions(+), 150 deletions(-) create mode 100644 tests/unit/commands/persist-command-handler.test.ts diff --git a/src/commands/persist-command.ts b/src/commands/persist-command.ts index 4e461e73..1f157a80 100644 --- a/src/commands/persist-command.ts +++ b/src/commands/persist-command.ts @@ -11,6 +11,7 @@ import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; +import * as lockfile from 'proper-lockfile'; import { initUI, header, subheader, color, dim, ok, fail, warn, info } from '../utils/ui'; import { InteractivePrompt } from '../utils/prompt'; import ProfileDetector, { @@ -53,6 +54,10 @@ const PERSIST_KNOWN_FLAGS = [ ] as const; const VALID_PERMISSION_MODES = ['default', 'plan', 'acceptEdits', 'bypassPermissions'] as const; +const PERSIST_LOCK_STALE_MS = 10000; +const PERSIST_LOCK_RETRIES = 5; +const PERSIST_LOCK_RETRY_MIN_MS = 100; +const PERSIST_LOCK_RETRY_MAX_MS = 500; type PermissionMode = (typeof VALID_PERMISSION_MODES)[number]; @@ -60,6 +65,10 @@ function isPermissionMode(value: string): value is PermissionMode { return VALID_PERMISSION_MODES.includes(value as PermissionMode); } +function isKnownPersistFlagToken(token: string): boolean { + return PERSIST_KNOWN_FLAGS.some((flag) => token === flag || token.startsWith(`${flag}=`)); +} + function resolvePermissionMode(parsedArgs: PersistCommandArgs): PermissionMode | undefined { if (!parsedArgs.dangerouslySkipPermissions) { return parsedArgs.permissionMode; @@ -106,6 +115,27 @@ function parseArgs(args: string[]): PersistCommandArgs { '--auto-approve', ]); + const unknownFlags = permissionModeOption.remainingArgs.filter( + (arg) => arg.startsWith('-') && !isKnownPersistFlagToken(arg) + ); + if (!result.parseError && unknownFlags.length > 0) { + const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', '); + result.parseError = `Unknown option(s): ${unknownList}. Run 'ccs persist --help' for usage.`; + } + + if (!result.parseError && result.listBackups && result.restore) { + result.parseError = '--list-backups cannot be used with --restore'; + } + + if ( + !result.parseError && + (result.listBackups || result.restore) && + (result.permissionMode || result.dangerouslySkipPermissions) + ) { + result.parseError = + 'Permission flags are not valid with backup operations. Use them only with ccs persist .'; + } + for (const arg of permissionModeOption.remainingArgs) { if (!arg.startsWith('-')) { result.profile = arg; @@ -140,71 +170,193 @@ function getClaudeSettingsDisplayPath(): string { return formatDisplayPath(getClaudeSettingsPath()); } -/** Read existing Claude settings.json with validation */ -function readClaudeSettings(): Record { - const settingsPath = getClaudeSettingsPath(); +async function pathExists(filePath: string): Promise { try { - const content = fs.readFileSync(settingsPath, 'utf8'); - // Handle empty file (0 bytes) - if (!content.trim()) { - return {}; - } - const parsed: unknown = JSON.parse(content); - // Validate parsed value is a plain object (not array, null, or primitive) - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - throw new Error('settings.json must contain a JSON object, not an array or primitive'); - } - return parsed as Record; - } catch (error) { - const nodeError = error as NodeJS.ErrnoException; - if (nodeError.code === 'ENOENT') { - return {}; - } - throw new Error(`Failed to parse settings.json: ${(error as Error).message}`); + await fs.promises.access(filePath, fs.constants.F_OK); + return true; + } catch { + return false; } } -/** - * Write settings back to settings.json - * Note: mode 0o600 only applies when creating a new file. - * Existing file permissions are preserved (acceptable behavior). - */ -function writeClaudeSettings(settings: Record): void { - const settingsPath = getClaudeSettingsPath(); - // Security: Reject symlinks to prevent writing to unexpected locations - if (isSymlink(settingsPath)) { - throw new Error('settings.json is a symlink - refusing to write for security'); - } - const dir = path.dirname(settingsPath); - if (!fs.existsSync(dir)) { - fs.mkdirSync(dir, { recursive: true }); - } - fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', { mode: 0o600 }); -} - -/** Maximum number of backups to keep (oldest are deleted) */ -const MAX_BACKUPS = 10; - -/** Check if path is a symlink (security check) */ -function isSymlink(filePath: string): boolean { +async function isSymlinkAsync(filePath: string): Promise { try { - const stats = fs.lstatSync(filePath); + const stats = await fs.promises.lstat(filePath); return stats.isSymbolicLink(); } catch { return false; } } -/** Create backup of settings.json with proper permissions and rotation */ -function createBackup(): string { +function getNoFollowFlag(): number { + const candidate = (fs.constants as Record)['O_NOFOLLOW']; + if (process.platform !== 'win32' && typeof candidate === 'number') { + return candidate; + } + return 0; +} + +function createSymlinkReadError(filePath: string): NodeJS.ErrnoException { + const error = new Error( + `Refusing to read symlinked file for security: ${formatDisplayPath(filePath)}` + ) as NodeJS.ErrnoException; + error.code = 'ELOOP'; + return error; +} + +async function readFileUtf8NoFollow(filePath: string): Promise { + if (await isSymlinkAsync(filePath)) { + throw createSymlinkReadError(filePath); + } + + const noFollowFlag = getNoFollowFlag(); + const flags = fs.constants.O_RDONLY | noFollowFlag; + const handle = await fs.promises.open(filePath, flags); + try { + // Best-effort fallback for platforms without O_NOFOLLOW (notably Windows). + // Re-check symlink status after open to reduce check-then-use windows. + if (noFollowFlag === 0 && (await isSymlinkAsync(filePath))) { + throw createSymlinkReadError(filePath); + } + + const stats = await handle.stat(); + if (!stats.isFile()) { + throw new Error('Path is not a regular file'); + } + + if (noFollowFlag === 0) { + const latestStats = await fs.promises.stat(filePath); + if (latestStats.dev !== stats.dev || latestStats.ino !== stats.ino) { + throw new Error('Path changed during secure read'); + } + } + + return await handle.readFile({ encoding: 'utf8' }); + } finally { + await handle.close(); + } +} + +function parseSettingsObject(content: string, sourceLabel: string): Record { + if (!content.trim()) { + return {}; + } + const parsed: unknown = JSON.parse(content); + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error(`${sourceLabel} must contain a JSON object, not an array or primitive`); + } + return parsed as Record; +} + +async function withPersistSettingsLock(operation: () => Promise): Promise { const settingsPath = getClaudeSettingsPath(); - if (!fs.existsSync(settingsPath)) { + const settingsDir = path.dirname(settingsPath); + await fs.promises.mkdir(settingsDir, { recursive: true }); + + let release: (() => Promise) | undefined; + try { + release = await lockfile.lock(settingsDir, { + stale: PERSIST_LOCK_STALE_MS, + retries: { + retries: PERSIST_LOCK_RETRIES, + minTimeout: PERSIST_LOCK_RETRY_MIN_MS, + maxTimeout: PERSIST_LOCK_RETRY_MAX_MS, + }, + realpath: false, + }); + } catch (error) { + throw new Error( + `Failed to lock Claude settings directory (${formatDisplayPath(settingsDir)}): ${(error as Error).message}` + ); + } + + try { + return await operation(); + } finally { + if (release) { + try { + await release(); + } catch { + // Best-effort release. + } + } + } +} + +/** Read existing Claude settings.json with validation */ +async function readClaudeSettings(): Promise> { + const settingsPath = getClaudeSettingsPath(); + try { + const content = await readFileUtf8NoFollow(settingsPath); + return parseSettingsObject(content, 'settings.json'); + } catch (error) { + const nodeError = error as NodeJS.ErrnoException; + if (nodeError.code === 'ENOENT') { + return {}; + } + if (nodeError.code === 'ELOOP') { + throw new Error('settings.json is a symlink - refusing to read for security'); + } + throw new Error(`Failed to parse settings.json: ${(error as Error).message}`); + } +} + +/** Write settings back to settings.json with atomic replace semantics. */ +async function writeClaudeSettings(settings: Record): Promise { + const settingsPath = getClaudeSettingsPath(); + if (await isSymlinkAsync(settingsPath)) { + throw new Error('settings.json is a symlink - refusing to write for security'); + } + + const settingsDir = path.dirname(settingsPath); + await fs.promises.mkdir(settingsDir, { recursive: true }); + + const nonce = `${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + const tmpPath = path.join(settingsDir, `settings.json.tmp-${nonce}`); + const flags = + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | getNoFollowFlag(); + + let handle: fs.promises.FileHandle | undefined; + try { + handle = await fs.promises.open(tmpPath, flags, 0o600); + await handle.writeFile(JSON.stringify(settings, null, 2) + '\n', { encoding: 'utf8' }); + await handle.sync(); + } finally { + if (handle) { + await handle.close(); + } + } + + try { + await fs.promises.rename(tmpPath, settingsPath); + } catch (error) { + try { + await fs.promises.unlink(tmpPath); + } catch { + // Best-effort cleanup. + } + throw error; + } + + try { + await fs.promises.chmod(settingsPath, 0o600); + } catch { + // Best-effort permission hardening. + } +} + +/** Maximum number of backups to keep (oldest are deleted) */ +const MAX_BACKUPS = 10; + +/** Create backup of settings.json with proper permissions and rotation */ +async function createBackup(): Promise { + const settingsPath = getClaudeSettingsPath(); + if (!(await pathExists(settingsPath))) { throw new Error('No settings.json to backup'); } - // Security: Reject symlinks to prevent writing to unexpected locations - if (isSymlink(settingsPath)) { - throw new Error('settings.json is a symlink - refusing to backup for security'); - } + + const settingsContent = await readFileUtf8NoFollow(settingsPath); + const now = new Date(); const timestamp = now.getFullYear().toString() + @@ -215,9 +367,27 @@ function createBackup(): string { now.getMinutes().toString().padStart(2, '0') + now.getSeconds().toString().padStart(2, '0'); const backupPath = `${settingsPath}.backup.${timestamp}`; - fs.copyFileSync(settingsPath, backupPath); - // Security: Set restrictive permissions on backup (contains API keys) - fs.chmodSync(backupPath, 0o600); + + const flags = + fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | getNoFollowFlag(); + + let handle: fs.promises.FileHandle | undefined; + try { + handle = await fs.promises.open(backupPath, flags, 0o600); + await handle.writeFile(settingsContent, { encoding: 'utf8' }); + await handle.sync(); + } finally { + if (handle) { + await handle.close(); + } + } + + try { + await fs.promises.chmod(backupPath, 0o600); + } catch { + // Best-effort permission hardening. + } + // Cleanup: Rotate old backups (keep only MAX_BACKUPS) cleanupOldBackups(); return backupPath; @@ -231,8 +401,12 @@ function cleanupOldBackups(): void { for (const backup of toDelete) { try { fs.unlinkSync(backup.path); - } catch { - // Ignore deletion errors (file may be locked or already deleted) + } catch (error) { + console.log( + warn( + `Failed to delete old backup ${formatDisplayPath(backup.path)}: ${(error as Error).message}` + ) + ); } } } @@ -299,6 +473,46 @@ function maskApiKey(key: string): string { return `${key.slice(0, 4)}...${key.slice(-4)}`; } +const SENSITIVE_ENV_PARTS = new Set([ + 'TOKEN', + 'KEY', + 'SECRET', + 'PASSWORD', + 'PASS', + 'AUTH', + 'CREDENTIAL', + 'PRIVATE', + 'ACCESS', + 'REFRESH', + 'APIKEY', +]); + +function splitSensitiveKeyParts(key: string): string[] { + const withCamelCaseBoundaries = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2'); + return withCamelCaseBoundaries + .toUpperCase() + .split(/[^A-Z0-9]+/) + .filter(Boolean); +} + +function isSensitiveEnvKey(key: string): boolean { + const parts = splitSensitiveKeyParts(key); + if (parts.some((part) => SENSITIVE_ENV_PARTS.has(part))) { + return true; + } + + const compact = parts.join(''); + return ( + compact.includes('TOKEN') || + compact.includes('APIKEY') || + compact.includes('ACCESSKEY') || + compact.includes('AUTHKEY') || + compact.includes('SECRET') || + compact.includes('PASSWORD') || + compact.includes('CREDENTIAL') + ); +} + /** Resolve env vars for a profile */ async function resolveProfileEnvVars( profileName: string, @@ -420,36 +634,60 @@ async function handleRestore(timestamp: string | boolean, yes: boolean): Promise process.exit(0); } } - // Validate backup JSON integrity before restore + + let parsedBackupSettings: Record; try { - const backupContent = fs.readFileSync(backup.path, 'utf8'); - const parsed: unknown = JSON.parse(backupContent); - if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { - console.log(fail('Backup file is corrupted: not a valid JSON object')); - process.exit(1); - } + const backupContent = await readFileUtf8NoFollow(backup.path); + parsedBackupSettings = parseSettingsObject(backupContent, 'Backup file'); } catch (error) { const nodeError = error as NodeJS.ErrnoException; if (nodeError.code === 'ENOENT') { console.log(fail('Backup was deleted during restore')); process.exit(1); } + if (nodeError.code === 'ELOOP') { + console.log(fail('Backup file is a symlink - refusing to restore for security')); + process.exit(1); + } console.log(fail(`Backup file is corrupted: ${(error as Error).message}`)); process.exit(1); } - // Security: Reject symlink backup files - if (isSymlink(backup.path)) { - console.log(fail('Backup file is a symlink - refusing to restore for security')); + + try { + await withPersistSettingsLock(async () => { + const settingsPath = getClaudeSettingsPath(); + if (await isSymlinkAsync(settingsPath)) { + throw new Error('settings.json is a symlink - refusing to restore for security'); + } + + let rollbackBackupPath: string | null = null; + if (await pathExists(settingsPath)) { + rollbackBackupPath = await createBackup(); + } + + try { + await writeClaudeSettings(parsedBackupSettings); + } catch (error) { + const writeError = error as Error; + if (rollbackBackupPath) { + try { + const rollbackContent = await readFileUtf8NoFollow(rollbackBackupPath); + const rollbackSettings = parseSettingsObject(rollbackContent, 'Rollback backup'); + await writeClaudeSettings(rollbackSettings); + } catch (rollbackError) { + throw new Error( + `Restore failed: ${writeError.message}. Rollback also failed: ${(rollbackError as Error).message}. Manual recovery backup: ${formatDisplayPath(rollbackBackupPath)}` + ); + } + } + throw new Error(`Restore failed: ${writeError.message}`); + } + }); + } catch (error) { + console.log(fail((error as Error).message)); process.exit(1); } - // Copy backup over settings.json - const settingsPath = getClaudeSettingsPath(); - // Security: Reject symlink target - if (isSymlink(settingsPath)) { - console.log(fail('settings.json is a symlink - refusing to restore for security')); - process.exit(1); - } - fs.copyFileSync(backup.path, settingsPath); + console.log(ok(`Restored from backup: ${backup.timestamp}`)); } @@ -533,6 +771,9 @@ export async function handlePersistCommand(args: string[]): Promise { return; } const parsedArgs = parseArgs(args); + if (parsedArgs.parseError) { + throw new Error(parsedArgs.parseError); + } // Handle --list-backups if (parsedArgs.listBackups) { await handleListBackups(); @@ -544,9 +785,6 @@ export async function handlePersistCommand(args: string[]): Promise { return; } await initUI(); - if (parsedArgs.parseError) { - throw new Error(parsedArgs.parseError); - } const resolvedPermissionMode = resolvePermissionMode(parsedArgs); if (!parsedArgs.profile) { console.log(fail('Profile name is required')); @@ -596,10 +834,7 @@ export async function handlePersistCommand(args: string[]): Promise { const maxKeyLen = Math.max(...envKeys.map((k) => k.length)); for (const [key, value] of Object.entries(resolved.env)) { const paddedKey = key.padEnd(maxKeyLen + 2); - const displayValue = - key.includes('TOKEN') || key.includes('KEY') || key.includes('SECRET') - ? maskApiKey(value) - : value; + const displayValue = isSensitiveEnvKey(key) ? maskApiKey(value) : value; console.log(` ${color(paddedKey, 'command')} = ${displayValue}`); } console.log(''); @@ -622,26 +857,17 @@ export async function handlePersistCommand(args: string[]): Promise { // Check if settings.json exists for backup const settingsPath = getClaudeSettingsPath(); const settingsExist = fs.existsSync(settingsPath); + let createBackupFlag = false; // Track backup path for error recovery guidance let createdBackupPath: string | null = null; // Backup prompt (unless --yes) if (settingsExist) { - let createBackupFlag: boolean = parsedArgs.yes === true; // Auto-backup with --yes + createBackupFlag = parsedArgs.yes === true; // Auto-backup with --yes if (!parsedArgs.yes) { createBackupFlag = await InteractivePrompt.confirm('Create backup before modifying?', { default: true, }); } - if (createBackupFlag) { - try { - createdBackupPath = createBackup(); - console.log(ok(`Backup created: ${formatDisplayPath(createdBackupPath)}`)); - console.log(''); - } catch (error) { - console.log(fail(`Failed to create backup: ${(error as Error).message}`)); - process.exit(1); - } - } } // Proceed confirmation (unless --yes) if (!parsedArgs.yes) { @@ -651,47 +877,72 @@ export async function handlePersistCommand(args: string[]): Promise { process.exit(0); } } - // Read existing settings and merge - const existingSettings = readClaudeSettings(); - // Validate existing env is an object (not array/primitive) - const rawEnv = existingSettings.env; - let existingEnv: Record = {}; - if (rawEnv !== undefined && rawEnv !== null) { - if (typeof rawEnv !== 'object' || Array.isArray(rawEnv)) { - console.log(warn('Existing env in settings.json is not an object - it will be replaced')); - } else { - existingEnv = rawEnv as Record; - } - } - const mergedSettings: Record = { - ...existingSettings, - env: { - ...existingEnv, - ...resolved.env, - }, - }; - if (resolvedPermissionMode) { - const rawPermissions = existingSettings.permissions; - let existingPermissions: Record = {}; - if (rawPermissions !== undefined && rawPermissions !== null) { - if (typeof rawPermissions !== 'object' || Array.isArray(rawPermissions)) { - console.log( - warn('Existing permissions in settings.json is not an object - it will be replaced') - ); - } else { - existingPermissions = rawPermissions as Record; - } - } - mergedSettings.permissions = { - ...existingPermissions, - defaultMode: resolvedPermissionMode, - }; - } - // Write merged settings try { - writeClaudeSettings(mergedSettings); + await withPersistSettingsLock(async () => { + if (createBackupFlag && (await pathExists(settingsPath))) { + try { + createdBackupPath = await createBackup(); + console.log(ok(`Backup created: ${formatDisplayPath(createdBackupPath)}`)); + console.log(''); + } catch (error) { + throw new Error(`Failed to create backup: ${(error as Error).message}`); + } + } + + // Read existing settings and merge + const existingSettings = await readClaudeSettings(); + // Validate existing env is an object (not array/primitive) + const rawEnv = existingSettings.env; + let existingEnv: Record = {}; + if (rawEnv !== undefined) { + if (rawEnv === null) { + console.log(warn('Existing env in settings.json is null - it will be replaced')); + } else if (typeof rawEnv !== 'object' || Array.isArray(rawEnv)) { + console.log(warn('Existing env in settings.json is not an object - it will be replaced')); + } else { + existingEnv = rawEnv as Record; + } + } + + const mergedSettings: Record = { + ...existingSettings, + env: { + ...existingEnv, + ...resolved.env, + }, + }; + + if (resolvedPermissionMode) { + const rawPermissions = existingSettings.permissions; + let existingPermissions: Record = {}; + if (rawPermissions !== undefined) { + if (rawPermissions === null) { + console.log( + warn('Existing permissions in settings.json is null - it will be replaced') + ); + } else if (typeof rawPermissions !== 'object' || Array.isArray(rawPermissions)) { + console.log( + warn('Existing permissions in settings.json is not an object - it will be replaced') + ); + } else { + existingPermissions = rawPermissions as Record; + } + } + mergedSettings.permissions = { + ...existingPermissions, + defaultMode: resolvedPermissionMode, + }; + } + + await writeClaudeSettings(mergedSettings); + }); } catch (error) { - console.log(fail(`Failed to write settings: ${(error as Error).message}`)); + const message = (error as Error).message; + if (message.startsWith('Failed to create backup:')) { + console.log(fail(message)); + } else { + console.log(fail(`Failed to write settings: ${message}`)); + } if (createdBackupPath) { console.log(''); console.log(info(`A backup was created before this error:`)); diff --git a/tests/unit/commands/persist-command-handler.test.ts b/tests/unit/commands/persist-command-handler.test.ts new file mode 100644 index 00000000..d285a59b --- /dev/null +++ b/tests/unit/commands/persist-command-handler.test.ts @@ -0,0 +1,273 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as lockfile from 'proper-lockfile'; +import { handlePersistCommand } from '../../../src/commands/persist-command'; + +interface RestoreFixture { + claudeDir: string; + settingsPath: string; + backupPath: string; + timestamp: string; + originalSettings: Record; + backupSettings: Record; +} + +let tempRoot: string; +let originalClaudeConfigDir: string | undefined; +let originalProcessExit: typeof process.exit; +let originalFsOpen: typeof fs.promises.open; +let originalFsRename: typeof fs.promises.rename; + +async function pathExists(filePath: string): Promise { + try { + await fs.promises.access(filePath, fs.constants.F_OK); + return true; + } catch { + return false; + } +} + +async function createRestoreFixture( + options: { + timestamp?: string; + originalSettings?: Record; + backupSettings?: Record; + } = {} +): Promise { + const timestamp = options.timestamp ?? '20260110_205324'; + const claudeDir = path.join(tempRoot, '.claude'); + const settingsPath = path.join(claudeDir, 'settings.json'); + const backupPath = `${settingsPath}.backup.${timestamp}`; + + const originalSettings = options.originalSettings ?? { + env: { ORIGINAL_TOKEN: 'original-value' }, + permissions: { defaultMode: 'plan' }, + }; + const backupSettings = options.backupSettings ?? { + env: { NEW_TOKEN: 'new-value' }, + permissions: { defaultMode: 'acceptEdits' }, + }; + + await fs.promises.mkdir(claudeDir, { recursive: true }); + await fs.promises.writeFile(settingsPath, JSON.stringify(originalSettings, null, 2) + '\n', 'utf8'); + await fs.promises.writeFile(backupPath, JSON.stringify(backupSettings, null, 2) + '\n', 'utf8'); + + return { claudeDir, settingsPath, backupPath, timestamp, originalSettings, backupSettings }; +} + +function stubProcessExit(): void { + process.exit = ((code?: number) => { + throw new Error(`process.exit(${code ?? 0})`); + }) as typeof process.exit; +} + +beforeEach(async () => { + tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ccs-persist-handler-test-')); + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + originalProcessExit = process.exit; + originalFsOpen = fs.promises.open; + originalFsRename = fs.promises.rename; +}); + +afterEach(async () => { + process.exit = originalProcessExit; + fs.promises.open = originalFsOpen; + fs.promises.rename = originalFsRename; + + if (originalClaudeConfigDir === undefined) { + delete process.env.CLAUDE_CONFIG_DIR; + } else { + process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + } + + if (tempRoot) { + await fs.promises.rm(tempRoot, { recursive: true, force: true }); + } +}); + +describe('persist command real handler paths', () => { + it('throws parseError for missing --permission-mode before profile detection', async () => { + await expect(handlePersistCommand(['glm', '--permission-mode'])).rejects.toThrow( + 'Missing value for --permission-mode' + ); + }); + + it('throws parseError for empty inline --permission-mode before profile detection', async () => { + await expect(handlePersistCommand(['glm', '--permission-mode='])).rejects.toThrow( + 'Missing value for --permission-mode' + ); + }); + + it('throws parseError for invalid --permission-mode before profile detection', async () => { + await expect(handlePersistCommand(['glm', '--permission-mode', 'invalid-mode'])).rejects.toThrow( + /Invalid --permission-mode/ + ); + }); + + it('throws parseError for unknown flags on real handler path', async () => { + await expect(handlePersistCommand(['glm', '--unknown-flag'])).rejects.toThrow( + /Unknown option\(s\)/ + ); + }); + + it('throws parseError for list/restore conflict on real handler path', async () => { + await expect(handlePersistCommand(['--list-backups', '--restore'])).rejects.toThrow( + '--list-backups cannot be used with --restore' + ); + }); + + it('throws parseError for permission flags with --restore on real handler path', async () => { + await expect(handlePersistCommand(['--restore', '--auto-approve'])).rejects.toThrow( + /Permission flags are not valid with backup operations/ + ); + }); + + it('shows help when --help is present even with other invalid args', async () => { + await expect(handlePersistCommand(['--help', '--permission-mode'])).resolves.toBeUndefined(); + }); + + it('does not create CLAUDE_CONFIG_DIR on parseError path', async () => { + const isolatedClaudeDir = path.join(tempRoot, '.claude-parse-early'); + process.env.CLAUDE_CONFIG_DIR = isolatedClaudeDir; + + await expect(handlePersistCommand(['glm', '--permission-mode='])).rejects.toThrow( + 'Missing value for --permission-mode' + ); + expect(await pathExists(isolatedClaudeDir)).toBe(false); + }); +}); + +describe('persist command restore failure handling', () => { + it('exits when lock cannot be acquired (concurrency protection)', async () => { + const fixture = await createRestoreFixture(); + process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir; + + const release = await lockfile.lock(fixture.claudeDir, { + stale: 60000, + retries: { retries: 0 }, + realpath: false, + }); + + stubProcessExit(); + try { + await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( + 'process.exit(1)' + ); + } finally { + await release(); + } + }); + + it('exits when backup read fails with ENOENT after selection', async () => { + const fixture = await createRestoreFixture(); + process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir; + + fs.promises.open = (async (...args: Parameters) => { + const target = String(args[0]); + if (target === fixture.backupPath) { + const error = new Error('forced missing backup') as NodeJS.ErrnoException; + error.code = 'ENOENT'; + throw error; + } + return originalFsOpen(...args); + }) as typeof fs.promises.open; + + stubProcessExit(); + await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( + 'process.exit(1)' + ); + }); + + it('exits when backup read fails with ELOOP (symlink rejection)', async () => { + const fixture = await createRestoreFixture(); + process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir; + + fs.promises.open = (async (...args: Parameters) => { + const target = String(args[0]); + if (target === fixture.backupPath) { + const error = new Error('forced symlink rejection') as NodeJS.ErrnoException; + error.code = 'ELOOP'; + throw error; + } + return originalFsOpen(...args); + }) as typeof fs.promises.open; + + stubProcessExit(); + await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( + 'process.exit(1)' + ); + }); + + it('exits when backup path resolves to a non-regular file', async () => { + const fixture = await createRestoreFixture(); + process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir; + + fs.promises.open = (async (...args: Parameters) => { + const target = String(args[0]); + if (target === fixture.backupPath) { + const fakeHandle = { + stat: async () => ({ isFile: () => false }), + readFile: async () => '', + close: async () => undefined, + } as unknown as fs.promises.FileHandle; + return fakeHandle; + } + return originalFsOpen(...args); + }) as typeof fs.promises.open; + + stubProcessExit(); + await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( + 'process.exit(1)' + ); + }); + + it('rolls back settings when restore write fails mid-flight', async () => { + const fixture = await createRestoreFixture(); + process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir; + + let renameCalls = 0; + fs.promises.rename = (async (...args: Parameters) => { + renameCalls += 1; + if (renameCalls === 1) { + throw new Error('forced rename failure'); + } + return originalFsRename(...args); + }) as typeof fs.promises.rename; + + stubProcessExit(); + await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( + 'process.exit(1)' + ); + + const finalContent = await fs.promises.readFile(fixture.settingsPath, 'utf8'); + const finalSettings = JSON.parse(finalContent); + expect(finalSettings).toEqual(fixture.originalSettings); + }); + + it('includes dual failure context when restore write and rollback both fail', async () => { + const fixture = await createRestoreFixture(); + process.env.CLAUDE_CONFIG_DIR = fixture.claudeDir; + + fs.promises.rename = (async () => { + throw new Error('forced rename failure'); + }) as typeof fs.promises.rename; + + const originalConsoleLog = console.log; + const capturedLogs: string[] = []; + console.log = (...args: unknown[]) => { + capturedLogs.push(args.map((arg) => String(arg)).join(' ')); + }; + + stubProcessExit(); + try { + await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( + 'process.exit(1)' + ); + expect(capturedLogs.some((line) => line.includes('Rollback also failed'))).toBe(true); + } finally { + console.log = originalConsoleLog; + } + }); +}); diff --git a/tests/unit/commands/persist-command.test.js b/tests/unit/commands/persist-command.test.js index 11e15eb0..894a9157 100644 --- a/tests/unit/commands/persist-command.test.js +++ b/tests/unit/commands/persist-command.test.js @@ -23,6 +23,17 @@ describe('Persist Command', () => { */ function parseArgs(args) { const validPermissionModes = ['default', 'plan', 'acceptEdits', 'bypassPermissions']; + const knownFlags = [ + '--yes', + '-y', + '--list-backups', + '--restore', + '--permission-mode', + '--dangerously-skip-permissions', + '--auto-approve', + '--help', + '-h', + ]; const result = { profile: undefined, yes: false, @@ -32,6 +43,7 @@ describe('Persist Command', () => { dangerouslySkipPermissions: false, parseError: undefined, }; + const unknownFlags = []; for (let i = 0; i < args.length; i++) { const arg = args[i]; if (arg === '--yes' || arg === '-y') { @@ -72,8 +84,32 @@ describe('Persist Command', () => { result.dangerouslySkipPermissions = true; } else if (!arg.startsWith('-') && !result.profile) { result.profile = arg; + } else if (arg.startsWith('-')) { + const known = knownFlags.some((flag) => arg === flag || arg.startsWith(`${flag}=`)); + if (!known) { + unknownFlags.push(arg); + } } } + + if (!result.parseError && unknownFlags.length > 0) { + const unknownList = unknownFlags.map((flag) => `"${flag}"`).join(', '); + result.parseError = `Unknown option(s): ${unknownList}. Run 'ccs persist --help' for usage.`; + } + + if (!result.parseError && result.listBackups && result.restore) { + result.parseError = '--list-backups cannot be used with --restore'; + } + + if ( + !result.parseError && + (result.listBackups || result.restore) && + (result.permissionMode || result.dangerouslySkipPermissions) + ) { + result.parseError = + 'Permission flags are not valid with backup operations. Use them only with ccs persist .'; + } + return result; } @@ -120,10 +156,9 @@ describe('Persist Command', () => { assert.strictEqual(result.yes, false); }); - it('ignores unknown flags', () => { + it('rejects unknown flags with a clear parseError', () => { const result = parseArgs(['glm', '--unknown', '--yes']); - assert.strictEqual(result.profile, 'glm'); - assert.strictEqual(result.yes, true); + assert.match(result.parseError, /Unknown option\(s\)/); }); it('takes only first positional as profile', () => { @@ -177,6 +212,16 @@ describe('Persist Command', () => { assert.match(result.parseError, /Invalid --permission-mode/); }); + it('sets parseError for missing --permission-mode value', () => { + const result = parseArgs(['glm', '--permission-mode']); + assert.strictEqual(result.parseError, 'Missing value for --permission-mode'); + }); + + it('sets parseError for empty inline --permission-mode=', () => { + const result = parseArgs(['glm', '--permission-mode=']); + assert.strictEqual(result.parseError, 'Missing value for --permission-mode'); + }); + it('parses --dangerously-skip-permissions flag', () => { const result = parseArgs(['glm', '--dangerously-skip-permissions']); assert.strictEqual(result.dangerouslySkipPermissions, true); @@ -193,25 +238,68 @@ describe('Persist Command', () => { assert.strictEqual(resolvePermissionMode(result), 'bypassPermissions'); }); - it('throws on conflict between dangerous mode and non-bypass permission mode', () => { - const parsed = parseArgs(['glm', '--permission-mode', 'acceptEdits', '--auto-approve']); + ['acceptEdits', 'plan', 'default'].forEach((mode) => { + it(`throws on conflict between dangerous mode and --permission-mode ${mode}`, () => { + const parsed = parseArgs(['glm', '--permission-mode', mode, '--auto-approve']); - assert.throws( - () => resolvePermissionMode(parsed), - /--dangerously-skip-permissions conflicts with --permission-mode/ - ); - }); - - it('keeps test permission mode list aligned with source constant', () => { - const sourcePath = path.join(__dirname, '../../../src/commands/persist-command.ts'); - const sourceContent = fs.readFileSync(sourcePath, 'utf8'); - ['default', 'plan', 'acceptEdits', 'bypassPermissions'].forEach((mode) => { - assert( - sourceContent.includes(`'${mode}'`), - `Expected mode '${mode}' to exist in source constant` + assert.throws( + () => resolvePermissionMode(parsed), + /--dangerously-skip-permissions conflicts with --permission-mode/ ); }); }); + + it('allows dangerous mode with --permission-mode bypassPermissions', () => { + const parsed = parseArgs([ + 'glm', + '--permission-mode', + 'bypassPermissions', + '--dangerously-skip-permissions', + ]); + assert.strictEqual(resolvePermissionMode(parsed), 'bypassPermissions'); + }); + + it('sets parseError when --list-backups and --restore are both provided', () => { + const parsed = parseArgs(['--list-backups', '--restore']); + assert.strictEqual(parsed.parseError, '--list-backups cannot be used with --restore'); + }); + + it('sets parseError when permission flags are used with --restore', () => { + const parsed = parseArgs(['--restore', '--auto-approve']); + assert.match(parsed.parseError, /Permission flags are not valid with backup operations/); + }); + + it('sets parseError when permission flags are used with --list-backups', () => { + const parsed = parseArgs(['--list-backups', '--permission-mode', 'plan']); + assert.match(parsed.parseError, /Permission flags are not valid with backup operations/); + }); + + it('keeps test permission mode list exactly aligned with source constant', () => { + const sourcePath = path.join(__dirname, '../../../src/commands/persist-command.ts'); + const sourceContent = fs.readFileSync(sourcePath, 'utf8'); + const match = sourceContent.match(/const VALID_PERMISSION_MODES = \[(.*?)\] as const/s); + assert(match, 'Expected VALID_PERMISSION_MODES constant to exist'); + const sourceModes = [...match[1].matchAll(/'([^']+)'/g)].map((entry) => entry[1]).sort(); + const testModes = ['default', 'plan', 'acceptEdits', 'bypassPermissions'].sort(); + assert.deepStrictEqual(sourceModes, testModes); + }); + + it('accepts common option ordering permutations', () => { + const cases = [ + ['glm', '--yes', '--permission-mode', 'plan'], + ['--yes', 'glm', '--permission-mode=plan'], + ['--permission-mode', 'plan', 'glm', '--yes'], + ['--yes', '--permission-mode', 'plan', 'glm'], + ]; + + for (const input of cases) { + const parsed = parseArgs(input); + assert.strictEqual(parsed.parseError, undefined, `Expected no parseError for: ${input.join(' ')}`); + assert.strictEqual(parsed.profile, 'glm'); + assert.strictEqual(parsed.yes, true); + assert.strictEqual(parsed.permissionMode, 'plan'); + } + }); }); // ========================================================================= @@ -469,7 +557,34 @@ describe('Persist Command', () => { * Simulates the logic to detect sensitive keys for masking */ function isSensitiveKey(key) { - return key.includes('TOKEN') || key.includes('KEY') || key.includes('SECRET'); + const sensitiveParts = new Set([ + 'TOKEN', + 'KEY', + 'SECRET', + 'PASSWORD', + 'PASS', + 'AUTH', + 'CREDENTIAL', + 'PRIVATE', + 'ACCESS', + 'REFRESH', + 'APIKEY', + ]); + const withCamelCaseBoundaries = key.replace(/([a-z0-9])([A-Z])/g, '$1_$2'); + const parts = withCamelCaseBoundaries.toUpperCase().split(/[^A-Z0-9]+/).filter(Boolean); + if (parts.some((part) => sensitiveParts.has(part))) { + return true; + } + const compact = parts.join(''); + return ( + compact.includes('TOKEN') || + compact.includes('APIKEY') || + compact.includes('ACCESSKEY') || + compact.includes('AUTHKEY') || + compact.includes('SECRET') || + compact.includes('PASSWORD') || + compact.includes('CREDENTIAL') + ); } it('detects TOKEN in key name', () => { @@ -492,6 +607,24 @@ describe('Persist Command', () => { assert.strictEqual(isSensitiveKey('ANTHROPIC_MODEL'), false); assert.strictEqual(isSensitiveKey('DISABLE_TELEMETRY'), false); }); + + it('detects lowercase and mixed-case sensitive keys', () => { + assert.strictEqual(isSensitiveKey('anthropic_auth_token'), true); + assert.strictEqual(isSensitiveKey('Api_Key'), true); + assert.strictEqual(isSensitiveKey('clientSecret'), true); + }); + + it('detects additional secret families', () => { + assert.strictEqual(isSensitiveKey('DB_PASSWORD'), true); + assert.strictEqual(isSensitiveKey('refresh_token_value'), true); + assert.strictEqual(isSensitiveKey('private_credential_blob'), true); + }); + + it('detects camelCase sensitive key variants', () => { + assert.strictEqual(isSensitiveKey('accessKeyId'), true); + assert.strictEqual(isSensitiveKey('authTokenValue'), true); + assert.strictEqual(isSensitiveKey('apiKeyValue'), true); + }); }); // ========================================================================= From dd9c850153a6f6b991a8cbe009c4b9da853ed917 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 22 Feb 2026 17:11:23 +0000 Subject: [PATCH 27/27] chore(release): 7.47.0-dev.8 [skip ci] --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 9f6312c1..e36bf876 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.47.0-dev.7", + "version": "7.47.0-dev.8", "description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6", "keywords": [ "cli",