Merge pull request #610 from kaitranntt/dev

feat(release): v7.45→v7.47 — Kimi provider, Droid targets, quota systems, thinking UX
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-22 12:20:56 -05:00
committed by GitHub
75 changed files with 5628 additions and 1075 deletions
+3 -3
View File
@@ -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
+4 -4
View File
@@ -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"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.47.0",
"version": "7.47.0-dev.8",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
+71 -113
View File
@@ -433,56 +433,6 @@ async function main(): Promise<void> {
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<void> {
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<string, string> = {
'--version': 'version',
'-v': 'version',
'--help': 'help',
'-h': 'help',
'--doctor': 'doctor',
'--sync': 'sync',
'--cleanup': 'cleanup',
'--setup': 'setup',
};
const normalizedFirstArg = commandAliases[firstArg] || firstArg;
const earlyCommandHandlers: Record<string, () => Promise<void>> = {
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 = [
+24 -16
View File
@@ -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<string, string>;
}
/** 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) {
+12 -24
View File
@@ -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(/\([^)]+\)$/, '');
}
+250 -38
View File
@@ -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';
@@ -28,18 +29,30 @@ 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
* v9: Added resilient alias compatibility expansion and cache-assisted alias enrichment
*/
export const CLIPROXY_CONFIG_VERSION = 7;
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' },
{ 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 },
@@ -75,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<string, number>,
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<string>();
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<string>();
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<string, string>();
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<string, number>();
// 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}`;
+6 -4
View File
@@ -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;
}
/**
+44 -2
View File
@@ -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 { MODEL_ENV_VAR_KEYS, normalizeModelIdForProvider } from '../model-id-normalizer';
export interface RemoteProxyConfig {
host: string;
@@ -60,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
@@ -189,6 +222,14 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
// Auto-enabled for Gemini, opt-in for Claude (--1m flag)
applyExtendedContextConfig(envVars, provider, extendedContextOverride);
// Fallback compatibility for Codex:
// CLIProxyAPI provider lookup recognizes model(level) suffix form, while CCS stores codex
// effort aliases as model-level (e.g., gpt-5.3-codex-high). If codex reasoning proxy is
// unavailable, normalize to model(level) to avoid "unknown provider for model ..." failures.
if (provider === 'codex' && !isComposite && !codexReasoningPort) {
envVars = normalizeCodexEnvForDirectUpstream(envVars);
}
// Determine the final ANTHROPIC_BASE_URL based on active proxies
// Chain order: Claude CLI → [CodexReasoningProxy] → [ToolSanitizationProxy] → CLIProxy
let finalBaseUrl = envVars.ANTHROPIC_BASE_URL;
@@ -271,10 +312,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;
}
+7 -4
View File
@@ -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);
+2 -2
View File
@@ -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<void> {
if (provider !== 'agy') return;
if (provider !== 'agy' && provider !== 'claude') return;
const { preflightCheck } = await import('../quota-manager');
const preflight = await preflightCheck(provider);
+10
View File
@@ -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,
+7 -1
View File
@@ -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()));
}
/**
+6 -11
View File
@@ -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
+133
View File
@@ -0,0 +1,133 @@
/**
* 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}` 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;
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.
*
* @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;
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.
*
* @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)) {
return normalizeClaudeDottedMajorMinor(model);
}
return normalizeClaudeDottedThinkingMajorMinor(model);
}
/**
* 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,
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;
}
+30 -1
View File
@@ -140,6 +140,26 @@ 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',
'claude',
'gemini',
'ghcp',
] as const);
export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDER_IDS)[number];
const QUOTA_SUPPORTED_PROVIDER_SET = new Set<QuotaSupportedProvider>(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<T>(
valueFor: (provider: CLIProxyProvider) => T
): Record<CLIProxyProvider, T> {
@@ -243,6 +263,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);
}
@@ -0,0 +1,320 @@
/**
* 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';
// 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;
}
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 > EPOCH_MS_THRESHOLD ? 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 > EPOCH_MS_THRESHOLD ? 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 'seven_day_oauth_apps':
return 'OAuth apps limit';
case 'seven_day_cowork':
return 'Cowork limit';
case 'overage':
return 'Extra usage';
default:
return rateLimitType || 'Unknown limit';
}
}
function clampUnit(value: number): number {
return Math.max(0, Math.min(1, value));
}
function normalizeUtilization(raw: Record<string, unknown>): {
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 normalizedRatio = clampUnit(ratio);
const usedPercent = clampPercent(normalizedRatio * 100);
return {
utilization: normalizedRatio,
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<string, unknown> | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function normalizeRestriction(
raw: Record<string, unknown>,
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<string, unknown>): 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<string, unknown>)) {
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<string>();
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,
};
}
const WEEKLY_RATE_LIMIT_TYPES = new Set([
'seven_day',
'seven_day_opus',
'seven_day_sonnet',
'seven_day_oauth_apps',
'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.
*/
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 weeklyWindow = pickMostRestrictiveClaudeWeeklyWindow(windows);
if (fiveHourWindow && weeklyWindow) {
return {
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(inferredFiveHour),
weekly: mapCoreWindow(inferredWeekly),
};
}
+297
View File
@@ -0,0 +1,297 @@
/**
* Quota Fetcher for Claude (Anthropic) Accounts
*
* Fetches policy limits from Claude API and normalizes 5h + weekly windows.
*/
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 } from './quota-types';
import {
buildClaudeQuotaWindows,
buildClaudeCoreUsageSummary,
} from './quota-fetcher-claude-normalizer';
export { buildClaudeQuotaWindows, buildClaudeCoreUsageSummary };
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';
interface ClaudeAuthData {
accessToken: string;
isExpired: boolean;
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
}
function toObject(value: unknown): Record<string, unknown> | null {
if (typeof value !== 'object' || value === null || Array.isArray(value)) return null;
return value as Record<string, unknown>;
}
function extractAccessToken(data: Record<string, unknown>): 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, unknown>): 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 isAuthExpired(expiry: string | null): boolean {
return expiry ? isTokenExpired(expiry) : false;
}
async function readJsonFile(filePath: string): Promise<Record<string, unknown> | 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<ClaudeAuthData | null> {
const data = await readJsonFile(filePath);
if (!data) return null;
const accessToken = extractAccessToken(data);
if (!accessToken) return null;
const expiry = extractExpiry(data);
return {
accessToken,
isExpired: isAuthExpired(expiry),
};
}
async function readClaudeAuthData(accountId: string): Promise<ClaudeAuthData | null> {
const authDirs = [getAuthDir(), getPausedDir()];
const sanitizedId = sanitizeEmail(accountId);
const expectedFiles = [`claude-${sanitizedId}.json`, `anthropic-${sanitizedId}.json`];
for (const authDir of authDirs) {
for (const expectedFile of expectedFiles) {
const filePath = path.join(authDir, expectedFile);
const authData = await readAuthCandidate(filePath);
if (authData) {
return authData;
}
}
let files: string[];
try {
files = await fsp.readdir(authDir);
} catch {
continue;
}
for (const file of files) {
if (
!file.endsWith('.json') ||
(!file.startsWith('claude-') && !file.startsWith('anthropic-'))
) {
continue;
}
const filePath = path.join(authDir, file);
const data = await readJsonFile(filePath);
if (!data) continue;
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: isAuthExpired(expiry),
};
}
}
}
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<ClaudeQuotaResult> {
const authData = await 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<string, unknown>);
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) {
const errorDetails =
error instanceof Error ? (error.stack ?? error.message) : JSON.stringify(error);
console.error(
`[!] Claude policy limits failed (attempt ${attempt}): ${lastError}${errorDetails ? `\n${errorDetails}` : ''}`
);
}
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;
}
+119 -32
View File
@@ -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<string, CacheEntry>();
// Request deduplication: track in-flight fetch promises to avoid parallel duplicate requests
const pendingFetches = new Map<string, Promise<QuotaResult>>();
const pendingFetches = new Map<string, Promise<ManagedQuotaResult>>();
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<QuotaResult> {
): Promise<ManagedQuotaResult> {
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<ManagedQuotaResult> {
if (provider === 'claude') {
return fetchClaudeQuota(accountId, verbose);
}
return fetchAccountQuota(provider, accountId, verbose);
}
// ============================================================================
// COOLDOWN TRACKING
// ============================================================================
@@ -174,10 +209,14 @@ export function clearCooldown(provider: CLIProxyProvider, accountId: string): vo
async function batchedMap<T, R>(
items: T[],
fn: (item: T) => Promise<R>,
concurrency = 10
concurrency = 10,
delayMs = 100
): Promise<R[]> {
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);
@@ -201,17 +240,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<typeof window> => !!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 +282,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 +309,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 +342,7 @@ export async function findHealthyAccount(
* Find and switch to a healthy account
*/
async function findAndSwitch(
provider: CLIProxyProvider,
provider: ManagedQuotaProvider,
excludeAccountId: string,
reason: string
): Promise<PreflightResult> {
@@ -310,12 +376,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<PreflightResult> {
// 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 +425,18 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
quota = await fetchQuotaWithDedup(provider, defaultAccount.id);
}
// Calculate average quota
const avgQuota = calculateAverageQuota(quota) ?? 0;
// Calculate normalized quota percentage.
// Null means quota data is unavailable (e.g. provider does not expose limits for this account).
const quotaPercent = calculateQuotaPercent(quota);
if (quotaPercent === null) {
return {
proceed: true,
accountId: defaultAccount.id,
reason: 'Quota unavailable, using default account',
};
}
const avgQuota = quotaPercent;
const threshold = quotaConfig.auto?.exhaustion_threshold ?? 5;
if (avgQuota < threshold) {
@@ -376,7 +452,7 @@ export async function preflightCheck(provider: CLIProxyProvider): Promise<Prefli
return {
proceed: true,
accountId: defaultAccount.id,
quotaPercent: calculateAverageQuota(quota),
quotaPercent,
};
}
@@ -399,11 +475,11 @@ export async function getQuotaStatus(provider: CLIProxyProvider): Promise<{
const results = await Promise.all(
accounts.map(async (account) => {
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 +512,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 +524,18 @@ 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);
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.
@@ -502,12 +589,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;
+78 -2
View File
@@ -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)
*/
+33 -30
View File
@@ -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<string, string> = {
Accept: 'application/json',
@@ -245,8 +244,19 @@ export async function checkRemoteProxy(
// Use native https module for self-signed cert support
response = await new Promise<Response>((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,17 +336,12 @@ export async function checkRemoteProxy(
error: getErrorMessage(errorCode, err.message),
errorCode,
};
} finally {
clearTimeout(timeoutId);
}
}
/**
* 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<RemoteProxyStatus> {
return checkRemoteProxy(config);
}
+15 -24
View File
@@ -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 {
+19 -5
View File
@@ -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<string, unknown> }>
modifiedBody.tools as Array<{ name: string; input_schema?: Record<string, unknown> }>
);
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);
+1 -1
View File
@@ -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
+127 -21
View File
@@ -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;
@@ -52,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<string>(API_VALUE_FLAGS);
function sanitizeHelpText(value: string): string {
return value
.replace(/[\r\n\t]+/g, ' ')
@@ -70,37 +77,129 @@ function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string {
return ` ${color(paddedId, 'command')} ${presetName} - ${presetDescription}`;
}
/** Parse command line arguments for api commands */
function parseArgs(args: string[]): ApiCommandArgs {
const result: ApiCommandArgs = {};
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 arg = args[i];
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 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 */
export function parseApiCommandArgs(args: string[]): ApiCommandArgs {
const result: ApiCommandArgs = {
force: hasAnyFlag(args, ['--force']),
yes: hasAnyFlag(args, ['--yes', '-y']),
errors: [],
};
let remaining = [...args];
remaining = applyRepeatedOption(
remaining,
['--base-url'],
(value) => {
result.baseUrl = value;
},
() => {
result.errors.push('Missing value for --base-url');
}
);
remaining = applyRepeatedOption(
remaining,
['--api-key'],
(value) => {
result.apiKey = value;
},
() => {
result.errors.push('Missing value for --api-key');
}
);
remaining = applyRepeatedOption(
remaining,
['--model'],
(value) => {
result.model = value;
},
() => {
result.errors.push('Missing value for --model');
}
);
remaining = applyRepeatedOption(
remaining,
['--preset'],
(value) => {
result.preset = value;
},
() => {
result.errors.push('Missing value for --preset');
}
);
const positionalArgs = extractPositionalArgs(remaining);
result.name = positionalArgs[0];
return result;
}
/** Handle 'ccs api create' command */
async function handleCreate(args: string[]): Promise<void> {
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('');
@@ -386,7 +485,14 @@ async function handleList(): Promise<void> {
/** Handle 'ccs api remove' command */
async function handleRemove(args: string[]): Promise<void> {
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();
+114
View File
@@ -0,0 +1,114 @@
/**
* Small helpers for consistent CLI option extraction.
*/
export interface ExtractedOption {
found: boolean;
value?: string;
missingValue: boolean;
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[],
options: ExtractOptionOptions = {}
): ExtractedOption {
const remaining = [...args];
const allowDashValue = options.allowDashValue ?? false;
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) {
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 };
}
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 {
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);
})
);
}
+3 -2
View File
@@ -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<void> {
await initUI();
@@ -54,8 +55,8 @@ export async function showHelp(): Promise<void> {
['default <account>', 'Set default account for rotation'],
['pause <account>', 'Pause account (skip in rotation)'],
['resume <account>', 'Resume paused account'],
['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'],
['quota --provider <name>', 'Filter by provider (agy|codex|gemini|ghcp)'],
['quota', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'],
['quota --provider <name>', `Filter by provider (${QUOTA_PROVIDER_HELP_TEXT})`],
],
],
[
+86 -148
View File
@@ -7,8 +7,15 @@
import { CLIProxyBackend } from '../../cliproxy/types';
import { DEFAULT_BACKEND } from '../../cliproxy/platform-detector';
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';
// Import subcommand handlers
import { handleList } from './auth-subcommand';
@@ -42,30 +49,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 };
}
/**
@@ -80,77 +80,50 @@ 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`
*/
function parseProviderArg(args: string[]): {
provider: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all';
type QuotaProviderFilter = QuotaSupportedProvider | 'all';
function normalizeQuotaProvider(value: string): QuotaProviderFilter | null {
if (value === 'all') {
return 'all';
}
const canonicalProvider = mapExternalProviderName(value);
if (!canonicalProvider || !isQuotaSupportedProvider(canonicalProvider)) {
return null;
}
return canonicalProvider;
}
export function parseProviderArg(args: string[]): {
provider: QuotaProviderFilter;
remainingArgs: string[];
invalid: boolean;
} {
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,
};
}
return { provider: 'all', remainingArgs: args };
const extracted = extractOption(args, ['--provider']);
if (!extracted.found) {
return { provider: 'all', remainingArgs: args, invalid: false };
}
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'
`Invalid provider value. --provider requires a value. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`
);
return { provider: 'all', remainingArgs: extracted.remainingArgs, invalid: true };
}
const value = rawValue?.toLowerCase() || 'all';
const remainingArgs = [...args];
remainingArgs.splice(providerIdx, 2);
// 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 };
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, invalid: true };
}
return {
provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all',
remainingArgs,
provider: normalized,
remainingArgs: extracted.remainingArgs,
invalid: false,
};
}
@@ -162,35 +135,14 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
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 +164,41 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
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));
const { provider: providerFilter, invalid } = parseProviderArg(remainingArgs.slice(1));
if (invalid) {
process.exitCode = 1;
return;
}
await handleQuotaStatus(verbose, providerFilter);
return;
}
const commandHandlers: Record<string, () => Promise<void>> = {
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) {
+245 -44
View File
@@ -18,15 +18,22 @@ 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 { pickMostRestrictiveClaudeWeeklyWindow } from '../../cliproxy/quota-fetcher-claude-normalizer';
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';
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 {
@@ -378,6 +385,157 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR
}
}
interface ClaudeDisplayWindow {
rateLimitType: string;
label: string;
remainingPercent: number;
resetAt: string | null;
status: string;
}
function getClaudeWindowDisplayLabel(
window: Pick<ClaudeDisplayWindow, 'rateLimitType' | 'label'>
): 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<ClaudeQuotaResult['coreUsage']>['fiveHour']
): ClaudeDisplayWindow | null {
if (!window) return null;
return {
rateLimitType: window.rateLimitType,
label: window.label,
remainingPercent: window.remainingPercent,
resetAt: window.resetAt,
status: window.status,
};
}
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 = pickMostRestrictiveClaudeWeeklyWindow(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 {
@@ -481,65 +639,108 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes
}
}
interface QuotaProviderRuntime {
fetch: (verbose: boolean) => Promise<unknown>;
hasData: (result: unknown) => boolean;
render: (result: unknown) => void;
emptyTitle: string;
emptyMessage: string;
authCommand: string;
}
const QUOTA_PROVIDER_RUNTIME: Record<QuotaSupportedProvider, QuotaProviderRuntime> = {
agy: {
fetch: (verbose) => fetchAllProviderQuotas('agy', verbose),
hasData: (result) =>
(result as Awaited<ReturnType<typeof fetchAllProviderQuotas>>).accounts.length > 0,
render: (result) =>
displayAntigravityQuotaSection(result as Awaited<ReturnType<typeof fetchAllProviderQuotas>>),
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',
},
claude: {
fetch: (verbose) => fetchAllClaudeQuotas(verbose),
hasData: (result) => (result as { account: string; quota: ClaudeQuotaResult }[]).length > 0,
render: (result) =>
displayClaudeQuotaSection(result as { account: string; quota: ClaudeQuotaResult }[]),
emptyTitle: 'Claude (0 accounts)',
emptyMessage: 'No Claude accounts configured',
authCommand: 'ccs claude --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<void> {
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<QuotaSupportedProvider>(
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<QuotaSupportedProvider, unknown | null>(
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('');
}
}
+19 -15
View File
@@ -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;
}
+49 -25
View File
@@ -12,39 +12,53 @@ import {
loadOrCreateUnifiedConfig,
} from '../config/unified-config-loader';
import { DEFAULT_IMAGE_ANALYSIS_CONFIG } from '../config/unified-config-types';
import {
CLIPROXY_PROVIDER_IDS,
PROVIDER_CAPABILITIES,
mapExternalProviderName,
} from '../cliproxy/provider-capabilities';
import { extractOption, hasAnyFlag } from './arg-extractor';
interface ImageAnalysisCommandOptions {
enable?: boolean;
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 = {};
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 };
} else {
options.setModelError = '--set-model requires <provider> <model>';
}
}
@@ -72,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('');
@@ -159,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'));
@@ -186,8 +208,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];
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);
@@ -200,7 +224,7 @@ export async function handleConfigImageAnalysisCommand(args: string[]): Promise<
}
imageConfig.provider_models = {
...imageConfig.provider_models,
[options.setModel.provider]: model,
[canonicalProvider]: model,
};
hasChanges = true;
}
+531 -145
View File
@@ -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, {
@@ -21,12 +22,17 @@ 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;
yes?: boolean;
listBackups?: boolean;
restore?: string | boolean;
permissionMode?: PermissionMode;
dangerouslySkipPermissions?: boolean;
parseError?: string;
}
interface ResolvedEnv {
@@ -35,103 +41,322 @@ 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;
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];
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;
}
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 = {};
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
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;
}
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.restore = true; // Use latest
result.permissionMode = permissionModeOption.value;
}
} else if (!arg.startsWith('-') && !result.profile) {
}
}
result.dangerouslySkipPermissions = hasAnyFlag(permissionModeOption.remainingArgs, [
'--dangerously-skip-permissions',
'--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 <profile>.';
}
for (const arg of permissionModeOption.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 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';
}
const claudePrefix = `${claudeDir}${path.sep}`;
if (filePath.startsWith(claudePrefix)) {
return filePath.replace(claudePrefix, '~/.claude/');
}
return filePath;
}
/** Read existing Claude settings.json with validation */
function readClaudeSettings(): Record<string, unknown> {
const settingsPath = getClaudeSettingsPath();
function getClaudeSettingsDisplayPath(): string {
return formatDisplayPath(getClaudeSettingsPath());
}
async function pathExists(filePath: string): Promise<boolean> {
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<string, unknown>;
} 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<string, unknown>): 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<boolean> {
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<string, number>)['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<string> {
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<string, unknown> {
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<string, unknown>;
}
async function withPersistSettingsLock<T>(operation: () => Promise<T>): Promise<T> {
const settingsPath = getClaudeSettingsPath();
if (!fs.existsSync(settingsPath)) {
const settingsDir = path.dirname(settingsPath);
await fs.promises.mkdir(settingsDir, { recursive: true });
let release: (() => Promise<void>) | 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<Record<string, unknown>> {
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<string, unknown>): Promise<void> {
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<string> {
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() +
@@ -142,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;
@@ -158,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}`
)
);
}
}
}
@@ -171,6 +418,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();
@@ -186,17 +452,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)
@@ -212,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,
@@ -324,7 +625,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 });
@@ -333,31 +634,60 @@ async function handleRestore(timestamp: string | boolean, yes: boolean): Promise
process.exit(0);
}
}
// Validate backup JSON integrity before restore
let parsedBackupSettings: Record<string, unknown>;
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'));
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);
}
} catch (error) {
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}`));
}
@@ -373,13 +703,20 @@ async function showHelp(): Promise<void> {
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.');
console.log('');
console.log(subheader('Options'));
console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts (auto-backup)`);
console.log(
` ${color('--permission-mode <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'));
@@ -402,6 +739,12 @@ async function showHelp(): Promise<void> {
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('');
@@ -414,7 +757,9 @@ async function showHelp(): Promise<void> {
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('');
}
@@ -426,6 +771,9 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
return;
}
const parsedArgs = parseArgs(args);
if (parsedArgs.parseError) {
throw new Error(parsedArgs.parseError);
}
// Handle --list-backups
if (parsedArgs.listBackups) {
await handleListBackups();
@@ -437,6 +785,7 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
return;
}
await initUI();
const resolvedPermissionMode = resolvePermissionMode(parsedArgs);
if (!parsedArgs.profile) {
console.log(fail('Profile name is required'));
console.log('');
@@ -474,7 +823,7 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
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);
@@ -485,45 +834,40 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
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('');
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));
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
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: ${createdBackupPath.replace(os.homedir(), '~')}`));
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) {
@@ -533,40 +877,82 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
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<string, string> = {};
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<string, string>;
}
}
const mergedSettings = {
...existingSettings,
env: {
...existingEnv,
...resolved.env,
},
};
// 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<string, string> = {};
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<string, string>;
}
}
const mergedSettings: Record<string, unknown> = {
...existingSettings,
env: {
...existingEnv,
...resolved.env,
},
};
if (resolvedPermissionMode) {
const rawPermissions = existingSettings.permissions;
let existingPermissions: Record<string, unknown> = {};
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<string, unknown>;
}
}
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:`));
console.log(` ${createdBackupPath.replace(os.homedir(), '~')}`);
console.log(` ${formatDisplayPath(createdBackupPath)}`);
console.log(dim(' To restore: ccs persist --restore'));
}
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.'));
+2 -2
View File
@@ -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 {
+29
View File
@@ -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;
}
+26
View File
@@ -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 (<dirname(CCS_HOME)>/.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');
}
+1 -16
View File
@@ -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;
+55 -32
View File
@@ -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
@@ -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<RawUsageEntry[]> {
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<RawUsageEntry[]> {
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);
@@ -176,8 +197,7 @@ export async function parseProjectDirectory(projectDir: string): Promise<RawUsag
* Get default Claude projects directory
*/
export function getDefaultProjectsDir(): string {
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
return path.join(configDir, 'projects');
return path.join(getClaudeConfigDir(), 'projects');
}
/**
@@ -186,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
@@ -208,7 +224,14 @@ export function findProjectDirectories(projectsDir?: string): string[] {
* @returns All parsed usage entries from all projects
*/
export async function scanProjectsDirectory(options: ParserOptions = {}): Promise<RawUsageEntry[]> {
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);
@@ -231,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;
});
}
+120 -3
View File
@@ -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';
@@ -52,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<string, QuotaRateLimitEntry>();
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).
*/
@@ -83,6 +115,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 +205,7 @@ const handleStatsRequest = async (_req: Request, res: Response): Promise<void> =
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 +338,7 @@ router.get('/models', async (_req: Request, res: Response): Promise<void> => {
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;
}
@@ -576,6 +622,12 @@ router.put('/models/:provider', async (req: Request, res: Response): Promise<voi
*/
router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promise<void> => {
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 (
@@ -610,6 +662,53 @@ 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<void> => {
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 (
!accountId ||
accountId.includes('..') ||
accountId.includes('/') ||
accountId.includes('\\')
) {
res.status(400).json({ error: 'Invalid account ID' });
return;
}
try {
// Check cache first
const cached = getCachedQuota<ClaudeQuotaResult>('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
@@ -617,6 +716,12 @@ router.get('/quota/codex/:accountId', async (req: Request, res: Response): Promi
*/
router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Promise<void> => {
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 (
@@ -658,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<void> => {
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 (
@@ -695,11 +806,17 @@ 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<void> => {
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];
+48 -42
View File
@@ -6,7 +6,7 @@ import { Router, Request, Response } from 'express';
import rateLimit from 'express-rate-limit';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { getClaudeSettingsPath } from '../../utils/claude-config-path';
const router = Router();
@@ -28,49 +28,33 @@ 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<boolean> {
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;
}
}
const restoreMutex = new RestoreMutex();
/** Get Claude settings.json path */
function getClaudeSettingsPath(): string {
return path.join(os.homedir(), '.claude', 'settings.json');
}
/** Check if path is a symlink (security check) */
function isSymlink(filePath: string): boolean {
try {
@@ -81,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();
@@ -96,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)
@@ -183,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');
@@ -204,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;
@@ -222,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);
+58 -6
View File
@@ -6,6 +6,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir, getConfigPath, loadConfigSafe, loadSettings } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers';
import { getClaudeSettingsPath } from '../../utils/claude-config-path';
import type { Config, Settings } from '../../types/config';
/** Model mapping for API profiles */
@@ -160,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 = expandPath('~/.claude/settings.json');
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 };
@@ -182,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 };
}
+2 -2
View File
@@ -7,8 +7,8 @@
import { Router, Request, Response } from 'express';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { getCcsDir } from '../utils/config-manager';
import { getClaudeConfigDir } from '../utils/claude-config-path';
export const sharedRoutes = Router();
@@ -141,7 +141,7 @@ function checkSymlinkStatus(): { valid: boolean; message: string } {
if (stats.isSymbolicLink()) {
const target = fs.readlinkSync(linkPath);
// Check if it points to ~/.claude/{linkType}
const expectedTarget = path.join(os.homedir(), '.claude', linkType);
const expectedTarget = path.join(getClaudeConfigDir(), linkType);
if (path.resolve(path.dirname(linkPath), target) === path.resolve(expectedTarget)) {
validLinks++;
}
@@ -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');
});
});
@@ -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',
@@ -621,6 +621,113 @@ 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('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 });
@@ -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');
});
});
@@ -0,0 +1,95 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it } from 'bun:test';
import { buildClaudeEnvironment } from '../../../src/cliproxy/executor/env-resolver';
const tempDirs: string[] = [];
function createCodexSettingsFile(models: {
defaultModel: string;
opusModel: string;
sonnetModel: string;
haikuModel: string;
}): string {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-fallback-'));
tempDirs.push(tempDir);
const settingsPath = path.join(tempDir, 'codex-test.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
ANTHROPIC_AUTH_TOKEN: 'test-token',
ANTHROPIC_MODEL: models.defaultModel,
ANTHROPIC_DEFAULT_OPUS_MODEL: models.opusModel,
ANTHROPIC_DEFAULT_SONNET_MODEL: models.sonnetModel,
ANTHROPIC_DEFAULT_HAIKU_MODEL: models.haikuModel,
},
},
null,
2
) + '\n'
);
return settingsPath;
}
describe('buildClaudeEnvironment codex fallback normalization', () => {
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');
});
});
@@ -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');
});
});
@@ -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);
});
});
@@ -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');
});
});
});
@@ -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,33 @@ 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', 'claude', 'gemini', 'ghcp']);
expect(QUOTA_PROVIDER_OPTION_VALUES).toEqual([
'agy',
'codex',
'claude',
'gemini',
'ghcp',
'antigravity',
'anthropic',
'gemini-cli',
'github-copilot',
'copilot',
'all',
]);
expect(QUOTA_PROVIDER_HELP_TEXT).toBe(
'agy, codex, claude, gemini, ghcp, antigravity, anthropic, 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();
@@ -0,0 +1,538 @@
/**
* 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<string, unknown>,
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'])
);
});
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', () => {
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);
});
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', () => {
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);
});
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',
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);
});
});
});
@@ -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/');
});
});
@@ -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<string, unknown>).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<string, unknown>).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<string, unknown>).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}`,
@@ -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<string, string>;
};
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<string, string>;
};
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');
});
});
@@ -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');
});
});
+136
View File
@@ -0,0 +1,136 @@
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('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']);
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('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);
});
});
});
@@ -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();
});
});
@@ -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);
});
});
@@ -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<string, unknown>;
backupSettings: Record<string, unknown>;
}
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<boolean> {
try {
await fs.promises.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
async function createRestoreFixture(
options: {
timestamp?: string;
originalSettings?: Record<string, unknown>;
backupSettings?: Record<string, unknown>;
} = {}
): Promise<RestoreFixture> {
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<typeof fs.promises.open>) => {
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<typeof fs.promises.open>) => {
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<typeof fs.promises.open>) => {
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<typeof fs.promises.rename>) => {
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;
}
});
});
+275 -19
View File
@@ -10,6 +10,8 @@
*/
const assert = require('assert');
const fs = require('fs');
const path = require('path');
describe('Persist Command', () => {
// =========================================================================
@@ -20,12 +22,28 @@ describe('Persist Command', () => {
* Simulates the argument parsing logic from persist-command.ts
*/
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,
listBackups: false,
restore: undefined,
permissionMode: undefined,
dangerouslySkipPermissions: false,
parseError: undefined,
};
const unknownFlags = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--yes' || arg === '-y') {
@@ -43,13 +61,72 @@ 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;
} 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 <profile>.';
}
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');
@@ -79,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', () => {
@@ -117,6 +193,113 @@ 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('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);
});
it('parses --auto-approve as alias flag', () => {
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');
});
['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('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');
}
});
});
// =========================================================================
@@ -170,15 +353,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 +454,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');
});
});
// =========================================================================
@@ -337,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', () => {
@@ -360,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);
});
});
// =========================================================================
@@ -389,7 +654,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'));
@@ -477,30 +742,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);
+103
View File
@@ -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);
});
});
// ============================================================================
+11 -42
View File
@@ -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);
});
});
@@ -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);
});
});
@@ -8,6 +8,7 @@ import {
getCodexQuotaBreakdown,
getProviderMinQuota,
getProviderResetTime,
isClaudeQuotaResult,
isCodexQuotaResult,
} from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
@@ -23,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;
@@ -90,12 +98,15 @@ export function AccountCard({
const borderColor = getBorderColorStyle(zone, account.color);
const connectorPosition = CONNECTOR_POSITION_MAP[zone];
// Quota for CLIProxy accounts (agy, codex, gemini)
const isCliproxyProvider = QUOTA_SUPPORTED_PROVIDERS.includes(
account.provider as QuotaSupportedProvider
);
// Quota for CLIProxy accounts (agy, codex, claude, gemini, ghcp)
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
);
@@ -104,14 +115,48 @@ 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 = [
{ 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 =
isClaudeProvider && 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 = 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 =
@@ -215,7 +260,7 @@ export function AccountCard({
failure={account.failureCount}
showDetails={showDetails}
/>
{/* Quota bar for CLIProxy accounts (agy, codex, gemini) */}
{/* Quota bar for CLIProxy accounts */}
{isCliproxyProvider && (
<div className="mt-2 px-0.5">
{quotaLoading ? (
@@ -223,7 +268,7 @@ export function AccountCard({
<Loader2 className="w-2.5 h-2.5 animate-spin" />
<span>Quota...</span>
</div>
) : minQuota !== null ? (
) : minQuotaValue !== null ? (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
@@ -235,9 +280,9 @@ export function AccountCard({
<span
className={cn(
'text-[10px] font-mono font-bold',
minQuota > 50
minQuotaValue > 50
? 'text-emerald-600 dark:text-emerald-400'
: minQuota > 20
: minQuotaValue > 20
? 'text-amber-500'
: 'text-red-500'
)}
@@ -245,9 +290,9 @@ export function AccountCard({
{minQuotaLabel}%
</span>
</div>
{account.provider === 'codex' && codexQuotaRows.length > 0 && (
{compactQuotaRows.length > 0 && (
<div className="flex items-center justify-between text-[7px] text-muted-foreground/70">
{codexQuotaRows.map((row) => (
{compactQuotaRows.map((row) => (
<span key={row.label}>
{row.label} {row.value}%
</span>
@@ -258,22 +303,24 @@ export function AccountCard({
<div
className={cn(
'h-full rounded-full transition-all',
minQuota > 50
minQuotaValue > 50
? 'bg-emerald-500'
: minQuota > 20
: minQuotaValue > 20
? 'bg-amber-500'
: 'bg-red-500'
)}
style={{ width: `${minQuota}%` }}
style={{ width: `${minQuotaValue}%` }}
/>
</div>
</div>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{quota && <QuotaTooltipContent quota={quota} resetTime={resetTime} />}
<QuotaTooltipContent quota={quota} resetTime={resetTime} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
) : quota?.success ? (
<div className="text-[8px] text-muted-foreground/60">Quota limits unavailable</div>
) : quota?.needsReauth ? (
<TooltipProvider>
<Tooltip>
@@ -36,6 +36,7 @@ import {
getCodexQuotaBreakdown,
getProviderMinQuota,
getProviderResetTime,
isClaudeQuotaResult,
isCodexQuotaResult,
} from '@/lib/utils';
import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
@@ -105,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
);
@@ -123,14 +128,48 @@ 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 = [
{ 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 =
isClaudeProvider && 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 = isCodexProvider
? codexQuotaRows
: isClaudeProvider
? claudeQuotaRows
: [];
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
const minQuotaValue = minQuotaLabel !== null ? Number(minQuotaLabel) : null;
return (
<div
@@ -324,7 +363,7 @@ export function AccountItem({
<Loader2 className="w-3 h-3 animate-spin" />
<span>Loading quota...</span>
</div>
) : minQuota !== null ? (
) : minQuotaValue !== null ? (
<div className="space-y-1.5">
{/* Status indicator based on runtime usage, not file state */}
<div className="flex items-center gap-1.5 text-xs">
@@ -353,9 +392,9 @@ export function AccountItem({
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
{account.provider === 'codex' && codexQuotaRows.length > 0 ? (
{dualWindowQuotaRows.length > 0 ? (
<div className="space-y-1.5">
{codexQuotaRows.map((row) => (
{dualWindowQuotaRows.map((row) => (
<div key={row.label} className="flex items-center gap-2">
<span className="w-10 text-[10px] text-muted-foreground">
{row.label}
@@ -374,9 +413,9 @@ export function AccountItem({
) : (
<div className="flex items-center gap-2">
<Progress
value={Math.max(0, Math.min(100, minQuota))}
value={Math.max(0, Math.min(100, minQuotaValue))}
className="h-2 flex-1"
indicatorClassName={getQuotaColor(minQuota)}
indicatorClassName={getQuotaColor(minQuotaValue)}
/>
<span className="text-xs font-medium w-10 text-right">
{minQuotaLabel}%
@@ -385,11 +424,21 @@ export function AccountItem({
)}
</TooltipTrigger>
<TooltipContent side="bottom" className="max-w-xs">
{quota && <QuotaTooltipContent quota={quota} resetTime={nextReset} />}
<QuotaTooltipContent quota={quota} resetTime={nextReset} />
</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
) : quota?.success ? (
<div className="flex items-center gap-1.5">
<Badge
variant="outline"
className="text-[10px] h-5 px-2 gap-1 border-muted-foreground/50 text-muted-foreground"
>
<HelpCircle className="w-3 h-3" />
No limits
</Badge>
</div>
) : quota?.needsReauth ? (
<TooltipProvider>
<Tooltip>
@@ -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;
@@ -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}
@@ -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;
@@ -13,6 +13,7 @@ import {
getModelsWithTiers,
groupModelsByTier,
isAgyQuotaResult,
isClaudeQuotaResult,
isCodexQuotaResult,
isGeminiQuotaResult,
isGhcpQuotaResult,
@@ -21,7 +22,7 @@ import {
} from '@/lib/utils';
interface QuotaTooltipContentProps {
quota: UnifiedQuotaResult;
quota: UnifiedQuotaResult | null | undefined;
resetTime: string | null;
}
@@ -35,12 +36,39 @@ 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
*/
export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentProps) {
if (!quota?.success) return null;
if (!quota) {
return <p className="text-xs text-muted-foreground">Loading quota...</p>;
}
if (!quota.success) {
return <p className="text-xs text-destructive">{quota.error || 'Failed to load quota'}</p>;
}
// Antigravity (agy) provider tooltip
if (isAgyQuotaResult(quota)) {
@@ -115,6 +143,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<typeof window> => !!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 (
<div className="text-xs space-y-1">
<p className="font-medium">Rate Limits:</p>
{orderedWindows.map((window, index) => (
<div
key={`${window.rateLimitType}-${window.resetAt ?? 'no-reset'}-${window.status}-${index}`}
className="flex justify-between gap-4"
>
<span className={cn(window.remainingPercent < 20 && 'text-red-500')}>
{getClaudeWindowDisplayLabel(window.rateLimitType, window.label)}
</span>
<span className="font-mono">{window.remainingPercent}%</span>
</div>
))}
<CodexResetIndicators
fiveHourResetTime={fiveHourResetAt}
weeklyResetTime={weeklyResetAt}
fallbackResetTime={resetTime}
/>
</div>
);
}
// Gemini provider tooltip
if (isGeminiQuotaResult(quota)) {
return (
+63 -11
View File
@@ -7,6 +7,7 @@ import type {
ModelQuota,
QuotaResult,
CodexQuotaResult,
ClaudeQuotaResult,
GeminiCliQuotaResult,
GhcpQuotaResult,
} from '@/lib/api-client';
@@ -203,14 +204,41 @@ 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];
const QUOTA_PROVIDER_ALIAS_MAP: Readonly<Record<string, QuotaSupportedProvider>> = {
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 API (Antigravity only)
* Fetch account quota from generic API route
*/
async function fetchAccountQuota(provider: string, accountId: string): Promise<QuotaResult> {
const response = await fetch(`/api/cliproxy/quota/${provider}/${encodeURIComponent(accountId)}`);
@@ -245,6 +273,24 @@ async function fetchCodexQuotaApi(accountId: string): Promise<CodexQuotaResult>
return response.json();
}
/**
* Fetch Claude quota from API
*/
async function fetchClaudeQuotaApi(accountId: string): Promise<ClaudeQuotaResult> {
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
*/
@@ -291,9 +337,16 @@ async function fetchQuotaByProvider(
provider: string,
accountId: string
): Promise<UnifiedQuotaResult> {
switch (provider) {
const canonicalProvider = normalizeQuotaProvider(provider);
if (!canonicalProvider) {
return fetchAccountQuota(provider, accountId);
}
switch (canonicalProvider) {
case 'codex':
return fetchCodexQuotaApi(accountId);
case 'claude':
return fetchClaudeQuotaApi(accountId);
case 'gemini':
return fetchGeminiQuotaApi(accountId);
case 'ghcp':
@@ -305,16 +358,15 @@ 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) {
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
+56
View File
@@ -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<CodexQuotaResult>(`/cliproxy/quota/codex/${encodeURIComponent(accountId)}`),
/** Fetch Claude quota for a specific account */
getClaude: (accountId: string) =>
request<ClaudeQuotaResult>(`/cliproxy/quota/claude/${encodeURIComponent(accountId)}`),
/** Fetch Gemini CLI quota for a specific account */
getGemini: (accountId: string) =>
request<GeminiCliQuotaResult>(`/cliproxy/quota/gemini/${encodeURIComponent(accountId)}`),
+8 -43
View File
@@ -1,46 +1,11 @@
/**
* Extended Context Utilities
* Shared utilities for extended context (1M token window) feature.
* UI facade for shared extended-context helpers.
*/
/** The suffix that enables 1M token context in Claude Code */
export const EXTENDED_CONTEXT_SUFFIX = '[1m]';
/**
* Check if model is a native Gemini model (auto-enabled behavior).
* Native Gemini models have the gemini-* prefix.
*
* NOTE: This function is intentionally duplicated from src/cliproxy/model-catalog.ts
* to avoid bundling backend code in the UI. Keep both in sync.
*/
export function isNativeGeminiModel(modelId: string): boolean {
const lower = modelId.toLowerCase();
return lower.startsWith('gemini-');
}
/**
* Check if model string already has [1m] suffix
*/
export function hasExtendedContextSuffix(model: string): boolean {
return model.toLowerCase().endsWith(EXTENDED_CONTEXT_SUFFIX.toLowerCase());
}
/**
* Apply [1m] suffix to model string 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;
if (hasExtendedContextSuffix(model)) {
return model.slice(0, -EXTENDED_CONTEXT_SUFFIX.length);
}
return model;
}
export {
EXTENDED_CONTEXT_SUFFIX,
isNativeGeminiModel,
hasExtendedContextSuffix,
applyExtendedContextSuffix,
stripExtendedContextSuffix,
} from '../../../src/shared/extended-context-utils';
+31 -65
View File
@@ -1,26 +1,20 @@
/**
* Provider Configuration
* Shared constants for CLIProxy providers - SINGLE SOURCE OF TRUTH for UI
*
* When adding a new provider, update CLIPROXY_PROVIDERS array and related mappings.
* Backend provider capabilities are the source of truth.
* UI keeps only presentation-specific overrides (assets/colors/instructions).
*/
/**
* Canonical list of CLIProxy provider IDs
* This is the UI's single source of truth for valid providers.
* Must stay in sync with backend's CLIPROXY_PROFILES in src/auth/profile-detector.ts
*/
export const CLIPROXY_PROVIDERS = [
'gemini',
'codex',
'agy',
'qwen',
'iflow',
'kiro',
'ghcp',
'claude',
'kimi',
] as const;
import {
CLIPROXY_PROVIDER_IDS,
PROVIDER_CAPABILITIES,
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;
/** Union type for CLIProxy provider IDs */
export type CLIProxyProvider = (typeof CLIPROXY_PROVIDERS)[number];
@@ -39,44 +33,17 @@ interface ProviderMetadata {
description: string;
}
export const PROVIDER_METADATA: Record<CLIProxyProvider, ProviderMetadata> = {
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<CLIProxyProvider, ProviderMetadata> = Object.freeze(
Object.fromEntries(
CLIPROXY_PROVIDERS.map((provider) => [
provider,
{
displayName: PROVIDER_CAPABILITIES[provider].displayName,
description: PROVIDER_CAPABILITIES[provider].description,
},
])
) as Record<CLIProxyProvider, ProviderMetadata>
);
// Map provider names to asset filenames (only providers with actual logos)
export const PROVIDER_ASSETS: Record<CLIProxyProvider, string> = {
@@ -149,13 +116,12 @@ export const PROVIDER_COLORS: Record<string, string> = {
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<string, string> = {
...Object.fromEntries(
CLIPROXY_PROVIDERS.map((provider) => [provider, PROVIDER_METADATA[provider].displayName])
@@ -163,7 +129,6 @@ const PROVIDER_NAMES: Record<string, string> = {
vertex: 'Vertex AI',
};
// Map provider to display name
export function getProviderDisplayName(provider: unknown): string {
const normalized = normalizeProviderInput(provider);
if (!normalized) {
@@ -181,9 +146,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<Partial<Record<CLIProxyProvider, string>>> =
Object.freeze({
+137 -15
View File
@@ -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<string, string> = {
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<typeof window> => !!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,37 +614,101 @@ export function getGhcpResetTime(quotaResetDate: string | null): string | null {
export type UnifiedQuotaResult =
| QuotaResult
| CodexQuotaResult
| ClaudeQuotaResult
| GeminiCliQuotaResult
| GhcpQuotaResult;
function isRecord(value: unknown): value is Record<string, unknown> {
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<QuotaResult>).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 && Array.isArray((quota as CodexQuotaResult).windows);
if (!isRecord(quota)) return false;
const candidate = quota as Partial<CodexQuotaResult>;
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 {
if (!isRecord(quota)) return false;
const candidate = quota as Partial<ClaudeQuotaResult>;
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<GeminiCliQuotaResult>;
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<string, unknown> | 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<GhcpQuotaResult>;
const snapshots = candidate.snapshots as Record<string, unknown> | null | undefined;
if (typeof candidate.success !== 'boolean') return false;
if (!isRecord(snapshots)) return false;
const snapshotKeys: Array<keyof GhcpQuotaResult['snapshots']> = [
'premiumInteractions',
'chat',
'completions',
];
return snapshotKeys.every((key) => {
const snapshot = snapshots[key] as Record<string, unknown> | undefined;
return (
isRecord(snapshot) &&
isFiniteNumber(snapshot.percentRemaining) &&
isFiniteNumber(snapshot.percentUsed)
);
});
}
// ==================== Unified Quota Helpers ====================
@@ -614,8 +722,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);
@@ -626,6 +735,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);
@@ -651,8 +766,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);
@@ -663,6 +779,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);
+52
View File
@@ -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 = {