mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 18:15:23 +00:00
Merge remote-tracking branch 'origin/dev' into fix/548-claude-quota-balance
# Conflicts: # src/commands/cliproxy/help-subcommand.ts # src/commands/cliproxy/index.ts # src/commands/cliproxy/quota-subcommand.ts
This commit is contained in:
commit
d47620592d
46 files changed
+2171
-777
No files matched your search
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "7.47.0",
|
||||
"version": "7.47.0-dev.3",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
+71
-113
@@ -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 = [
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(/\([^)]+\)$/, '');
|
||||
}
|
||||
@@ -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}`;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -24,6 +24,7 @@ import { stripClaudeCodeEnv } from '../../utils/shell-executor';
|
||||
import { CodexReasoningProxy } from '../codex-reasoning-proxy';
|
||||
import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
|
||||
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
|
||||
import { normalizeModelIdForProvider } from '../model-id-normalizer';
|
||||
|
||||
export interface RemoteProxyConfig {
|
||||
host: string;
|
||||
@@ -271,10 +272,11 @@ export function applyFallback(
|
||||
} as const;
|
||||
const result = { ...env };
|
||||
const originalModel = result[tierEnvMap[failedTier]];
|
||||
result[tierEnvMap[failedTier]] = fallback.model;
|
||||
const normalizedFallbackModel = normalizeModelIdForProvider(fallback.model, fallback.provider);
|
||||
result[tierEnvMap[failedTier]] = normalizedFallbackModel;
|
||||
// If failed tier is default tier, also update ANTHROPIC_MODEL
|
||||
if (result.ANTHROPIC_MODEL === originalModel) {
|
||||
result.ANTHROPIC_MODEL = fallback.model;
|
||||
result.ANTHROPIC_MODEL = normalizedFallbackModel;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -55,7 +56,7 @@ export async function showHelp(): Promise<void> {
|
||||
['pause <account>', 'Pause account (skip in rotation)'],
|
||||
['resume <account>', 'Resume paused account'],
|
||||
['quota', 'Show quota status for all providers (Codex/Claude include 5h + weekly reset)'],
|
||||
['quota --provider <name>', 'Filter by provider (agy|codex|claude|gemini|ghcp)'],
|
||||
['quota --provider <name>', `Filter by provider (${QUOTA_PROVIDER_HELP_TEXT})`],
|
||||
],
|
||||
],
|
||||
[
|
||||
|
||||
+77
-159
@@ -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,91 +80,48 @@ function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend {
|
||||
/**
|
||||
* Parse --provider flag from args for quota command
|
||||
* Returns the provider filter value and remaining args
|
||||
* Accepts: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all
|
||||
* Accepts canonical + aliases from quota-supported providers, and `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;
|
||||
}
|
||||
|
||||
function parseProviderArg(args: string[]): {
|
||||
provider: 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp' | 'all';
|
||||
provider: QuotaProviderFilter;
|
||||
remainingArgs: string[];
|
||||
} {
|
||||
const providerIdx = args.indexOf('--provider');
|
||||
if (providerIdx === -1) {
|
||||
// Also check for --provider=value format
|
||||
const providerEqualsIdx = args.findIndex((a) => a.startsWith('--provider='));
|
||||
if (providerEqualsIdx !== -1) {
|
||||
const value = args[providerEqualsIdx].split('=')[1]?.toLowerCase() || '';
|
||||
const remainingArgs = [...args];
|
||||
remainingArgs.splice(providerEqualsIdx, 1);
|
||||
// Handle empty value
|
||||
if (!value) {
|
||||
console.error(
|
||||
'Warning: --provider requires a value. Valid options: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all'
|
||||
);
|
||||
return { provider: 'all', remainingArgs };
|
||||
}
|
||||
// Normalize aliases
|
||||
const normalized =
|
||||
value === 'gemini-cli'
|
||||
? 'gemini'
|
||||
: value === 'github-copilot'
|
||||
? 'ghcp'
|
||||
: value === 'anthropic'
|
||||
? 'claude'
|
||||
: value;
|
||||
if (
|
||||
normalized !== 'agy' &&
|
||||
normalized !== 'codex' &&
|
||||
normalized !== 'claude' &&
|
||||
normalized !== 'gemini' &&
|
||||
normalized !== 'ghcp' &&
|
||||
normalized !== 'all'
|
||||
) {
|
||||
console.error(
|
||||
`Invalid provider '${value}'. Valid options: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all`
|
||||
);
|
||||
return { provider: 'all', remainingArgs };
|
||||
}
|
||||
return {
|
||||
provider: normalized as 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp' | 'all',
|
||||
remainingArgs,
|
||||
};
|
||||
}
|
||||
const extracted = extractOption(args, ['--provider']);
|
||||
if (!extracted.found) {
|
||||
return { provider: 'all', remainingArgs: args };
|
||||
}
|
||||
const rawValue = args[providerIdx + 1];
|
||||
// Warn if no value or value looks like another flag
|
||||
if (!rawValue || rawValue.startsWith('-')) {
|
||||
|
||||
if (extracted.missingValue || !extracted.value) {
|
||||
console.error(
|
||||
'Warning: --provider requires a value. Valid options: agy, codex, claude, anthropic, gemini, gemini-cli, ghcp, github-copilot, all'
|
||||
`Warning: --provider requires a value. Valid options: ${QUOTA_PROVIDER_HELP_TEXT}`
|
||||
);
|
||||
return { provider: 'all', remainingArgs: extracted.remainingArgs };
|
||||
}
|
||||
const value = rawValue?.toLowerCase() || 'all';
|
||||
const remainingArgs = [...args];
|
||||
remainingArgs.splice(providerIdx, 2);
|
||||
// Normalize aliases
|
||||
const normalized =
|
||||
value === 'gemini-cli'
|
||||
? 'gemini'
|
||||
: value === 'github-copilot'
|
||||
? 'ghcp'
|
||||
: value === 'anthropic'
|
||||
? 'claude'
|
||||
: value;
|
||||
if (
|
||||
normalized !== 'agy' &&
|
||||
normalized !== 'codex' &&
|
||||
normalized !== 'claude' &&
|
||||
normalized !== 'gemini' &&
|
||||
normalized !== 'ghcp' &&
|
||||
normalized !== 'all'
|
||||
) {
|
||||
console.error(
|
||||
`Invalid provider '${value}'. Valid options: agy, codex, claude, anthropic, 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 };
|
||||
}
|
||||
return {
|
||||
provider: normalized as 'agy' | 'codex' | 'claude' | 'gemini' | 'ghcp' | 'all',
|
||||
remainingArgs,
|
||||
provider: normalized,
|
||||
remainingArgs: extracted.remainingArgs,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -176,35 +133,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];
|
||||
@@ -226,55 +162,37 @@ 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));
|
||||
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) {
|
||||
|
||||
@@ -29,6 +29,10 @@ import type {
|
||||
} from '../../cliproxy/quota-types';
|
||||
import { isOnCooldown } from '../../cliproxy/quota-manager';
|
||||
import { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import {
|
||||
QUOTA_SUPPORTED_PROVIDER_IDS,
|
||||
type QuotaSupportedProvider,
|
||||
} from '../../cliproxy/provider-capabilities';
|
||||
import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui';
|
||||
|
||||
interface CliproxyProfileArgs {
|
||||
@@ -658,76 +662,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' | 'claude' | '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',
|
||||
claude: providerFilter === 'all' || providerFilter === 'claude',
|
||||
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, claudeResults, geminiResults, ghcpResults] = await Promise.all([
|
||||
shouldFetch.agy ? fetchAllProviderQuotas('agy', verbose) : null,
|
||||
shouldFetch.codex ? fetchAllCodexQuotas(verbose) : null,
|
||||
shouldFetch.claude ? fetchAllClaudeQuotas(verbose) : null,
|
||||
shouldFetch.gemini ? fetchAllGeminiCliQuotas(verbose) : null,
|
||||
shouldFetch.ghcp ? fetchAllGhcpQuotas(verbose) : null,
|
||||
]);
|
||||
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 (claudeResults && claudeResults.length > 0) {
|
||||
displayClaudeQuotaSection(claudeResults);
|
||||
} else if (shouldFetch.claude) {
|
||||
console.log(subheader('Claude (0 accounts)'));
|
||||
console.log(info('No Claude accounts configured'));
|
||||
console.log(` Run: ${color('ccs claude --auth', 'command')} to authenticate`);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
if (geminiResults && geminiResults.length > 0) {
|
||||
displayGeminiCliQuotaSection(geminiResults);
|
||||
} else if (shouldFetch.gemini) {
|
||||
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('');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -21,6 +21,8 @@ import ProfileDetector, {
|
||||
import { getEffectiveEnvVars, CLIPROXY_DEFAULT_PORT } from '../cliproxy/config-generator';
|
||||
import { generateCopilotEnv } from '../copilot/copilot-executor';
|
||||
import { expandPath } from '../utils/helpers';
|
||||
import { getClaudeConfigDir, getClaudeSettingsPath } from '../utils/claude-config-path';
|
||||
import { extractOption, hasAnyFlag } from './arg-extractor';
|
||||
|
||||
interface PersistCommandArgs {
|
||||
profile?: string;
|
||||
@@ -37,34 +39,48 @@ interface ResolvedEnv {
|
||||
|
||||
/** Parse command line arguments */
|
||||
function parseArgs(args: string[]): PersistCommandArgs {
|
||||
const result: PersistCommandArgs = {};
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
if (arg === '--yes' || arg === '-y') {
|
||||
result.yes = true;
|
||||
} else if (arg === '--help' || arg === '-h') {
|
||||
// Will be handled in main function
|
||||
} else if (arg === '--list-backups') {
|
||||
result.listBackups = true;
|
||||
} else if (arg === '--restore') {
|
||||
// Check if next arg is a timestamp (not a flag)
|
||||
const nextArg = args[i + 1];
|
||||
if (nextArg && !nextArg.startsWith('-')) {
|
||||
result.restore = nextArg;
|
||||
i++; // Skip next arg
|
||||
} else {
|
||||
result.restore = true; // Use latest
|
||||
}
|
||||
} else if (!arg.startsWith('-') && !result.profile) {
|
||||
const result: PersistCommandArgs = {
|
||||
yes: hasAnyFlag(args, ['--yes', '-y']),
|
||||
listBackups: hasAnyFlag(args, ['--list-backups']),
|
||||
};
|
||||
|
||||
const restoreOption = extractOption(args, ['--restore']);
|
||||
if (restoreOption.found) {
|
||||
result.restore = restoreOption.missingValue ? true : restoreOption.value || true;
|
||||
}
|
||||
|
||||
for (const arg of restoreOption.remainingArgs) {
|
||||
if (!arg.startsWith('-')) {
|
||||
result.profile = arg;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Get Claude settings.json path */
|
||||
function getClaudeSettingsPath(): string {
|
||||
return path.join(os.homedir(), '.claude', 'settings.json');
|
||||
function formatDisplayPath(filePath: string): string {
|
||||
const 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;
|
||||
}
|
||||
|
||||
function getClaudeSettingsDisplayPath(): string {
|
||||
return formatDisplayPath(getClaudeSettingsPath());
|
||||
}
|
||||
|
||||
/** Read existing Claude settings.json with validation */
|
||||
@@ -171,6 +187,25 @@ interface BackupFile {
|
||||
date: Date;
|
||||
}
|
||||
|
||||
function parseBackupTimestamp(timestamp: string): Date | null {
|
||||
const year = parseInt(timestamp.slice(0, 4), 10);
|
||||
const month = parseInt(timestamp.slice(4, 6), 10);
|
||||
const day = parseInt(timestamp.slice(6, 8), 10);
|
||||
const hour = parseInt(timestamp.slice(9, 11), 10);
|
||||
const minute = parseInt(timestamp.slice(11, 13), 10);
|
||||
const second = parseInt(timestamp.slice(13, 15), 10);
|
||||
const date = new Date(year, month - 1, day, hour, minute, second);
|
||||
|
||||
if (date.getFullYear() !== year) return null;
|
||||
if (date.getMonth() !== month - 1) return null;
|
||||
if (date.getDate() !== day) return null;
|
||||
if (date.getHours() !== hour) return null;
|
||||
if (date.getMinutes() !== minute) return null;
|
||||
if (date.getSeconds() !== second) return null;
|
||||
|
||||
return date;
|
||||
}
|
||||
|
||||
/** Get all backup files sorted by date (newest first) */
|
||||
function getBackupFiles(): BackupFile[] {
|
||||
const settingsPath = getClaudeSettingsPath();
|
||||
@@ -186,17 +221,12 @@ function getBackupFiles(): BackupFile[] {
|
||||
const match = f.match(backupPattern);
|
||||
if (!match) return null;
|
||||
const timestamp = match[1];
|
||||
// Parse YYYYMMDD_HHMMSS
|
||||
const year = parseInt(timestamp.slice(0, 4));
|
||||
const month = parseInt(timestamp.slice(4, 6)) - 1;
|
||||
const day = parseInt(timestamp.slice(6, 8));
|
||||
const hour = parseInt(timestamp.slice(9, 11));
|
||||
const min = parseInt(timestamp.slice(11, 13));
|
||||
const sec = parseInt(timestamp.slice(13, 15));
|
||||
const date = parseBackupTimestamp(timestamp);
|
||||
if (!date) return null;
|
||||
return {
|
||||
path: path.join(dir, f),
|
||||
timestamp,
|
||||
date: new Date(year, month, day, hour, min, sec),
|
||||
date,
|
||||
};
|
||||
})
|
||||
.filter((f): f is BackupFile => f !== null)
|
||||
@@ -324,7 +354,7 @@ async function handleRestore(timestamp: string | boolean, yes: boolean): Promise
|
||||
console.log(`Backup: ${color(backup.timestamp, 'command')}`);
|
||||
console.log(`Date: ${backup.date.toLocaleString()}`);
|
||||
console.log('');
|
||||
console.log(warn('This will replace ~/.claude/settings.json'));
|
||||
console.log(warn(`This will replace ${getClaudeSettingsDisplayPath()}`));
|
||||
console.log('');
|
||||
if (!yes) {
|
||||
const proceed = await InteractivePrompt.confirm('Proceed with restore?', { default: false });
|
||||
@@ -342,6 +372,11 @@ async function handleRestore(timestamp: string | boolean, yes: boolean): Promise
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code === 'ENOENT') {
|
||||
console.log(fail('Backup was deleted during restore'));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(fail(`Backup file is corrupted: ${(error as Error).message}`));
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -373,7 +408,7 @@ 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.');
|
||||
@@ -414,7 +449,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('');
|
||||
}
|
||||
|
||||
@@ -474,7 +511,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);
|
||||
@@ -498,7 +535,7 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
|
||||
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
|
||||
@@ -517,7 +554,7 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
|
||||
if (createBackupFlag) {
|
||||
try {
|
||||
createdBackupPath = createBackup();
|
||||
console.log(ok(`Backup created: ${createdBackupPath.replace(os.homedir(), '~')}`));
|
||||
console.log(ok(`Backup created: ${formatDisplayPath(createdBackupPath)}`));
|
||||
console.log('');
|
||||
} catch (error) {
|
||||
console.log(fail(`Failed to create backup: ${(error as Error).message}`));
|
||||
@@ -560,13 +597,13 @@ export async function handlePersistCommand(args: string[]): Promise<void> {
|
||||
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.'));
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user