diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 61b565a5..fc79958e 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -36,8 +36,10 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; * v12: Removed denylisted Antigravity Claude 4.5 aliases * v13: Removed aggressive Gemini alias expansion to reduce model list noise in Control Panel * v14: Added Gemini 3.1 Flash Antigravity aliases for upcoming rollout compatibility + * v15: Prune stale generated Antigravity Gemini preview aliases during regeneration + * v16: Narrow stale Gemini alias cleanup to broad multi-version guessed ranges */ -export const CLIPROXY_CONFIG_VERSION = 14; +export const CLIPROXY_CONFIG_VERSION = 16; interface OAuthModelAliasEntry { name: string; @@ -45,6 +47,11 @@ interface OAuthModelAliasEntry { fork?: boolean; } +interface PreservedAntigravityAliasesResult { + yaml: string; + prunedLegacyAliasCount: number; +} + const DEPRECATED_ANTIGRAVITY_ALIAS_PREFIX = 'gemini-claude-'; const UPSTREAM_CLAUDE_ALIAS_PREFIX = 'claude-'; const DEPRECATED_ANTIGRAVITY_SONNET_46_THINKING_REGEX = /^claude-sonnet-4(?:[.-])6-thinking$/i; @@ -69,6 +76,17 @@ const DEFAULT_ANTIGRAVITY_ALIASES: OAuthModelAliasEntry[] = [ { name: 'claude-opus-4-6-thinking', alias: 'claude-opus-4-6-thinking', fork: true }, ]; +const BUILT_IN_GEMINI_ALIAS_NAMES = new Set( + DEFAULT_ANTIGRAVITY_ALIASES.filter((entry) => entry.alias.startsWith('gemini-3')).map( + (entry) => entry.name + ) +); +const MIN_STALE_GUESSED_GEMINI_MINOR_VERSIONS = 2; +const MIN_STALE_HIGH_ONLY_GEMINI_MINOR_VERSIONS = 3; +const MIN_STALE_GUESSED_GEMINI_AVERAGE_VARIANTS_PER_MINOR = 2; +const LEGACY_GEMINI_STALE_ALIAS_MIGRATION_VERSION = 16; +const MAX_LEGACY_MANUAL_GEMINI_MINOR_VERSION = 2; + /** * Get provider configuration * Model mappings are loaded from config/base-{provider}.settings.json @@ -148,6 +166,10 @@ function addAliasEntry( entries.push(normalized); } +function buildAntigravityAliasKey(entry: Pick): string { + return `${sanitizeYamlScalar(entry.name)}\u0000${normalizeAntigravityAlias(entry.alias)}`; +} + function parseExistingAntigravityAliases(existingAliases: string): OAuthModelAliasEntry[] { const entries: OAuthModelAliasEntry[] = []; const lines = existingAliases.replace(/\r\n/g, '\n').split('\n'); @@ -204,6 +226,16 @@ function parseExistingAntigravityAliases(existingAliases: string): OAuthModelAli return entries; } +function getConfigVersionFromContent(content: string): number | null { + const versionMatch = content.match(/CCS v(\d+)/); + if (!versionMatch) { + return null; + } + + const parsedVersion = Number.parseInt(versionMatch[1], 10); + return Number.isNaN(parsedVersion) ? null : parsedVersion; +} + function toDottedGeminiVersionAlias(alias: string): string | null { const match = alias.match(/^(gemini-\d+)-(\d+)(-.+)$/); if (!match) return null; @@ -267,6 +299,184 @@ function getCompatibilityAliases(entries: OAuthModelAliasEntry[]): OAuthModelAli return compatibilityAliases; } +function buildGeneratedAntigravityAliases(): OAuthModelAliasEntry[] { + const generatedAliases: OAuthModelAliasEntry[] = []; + const generatedAliasIndex = new Map(); + + for (const alias of DEFAULT_ANTIGRAVITY_ALIASES) { + addAliasEntry(generatedAliases, generatedAliasIndex, alias); + } + + for (const alias of getCompatibilityAliases(generatedAliases)) { + addAliasEntry(generatedAliases, generatedAliasIndex, alias); + } + + return generatedAliases; +} + +const GENERATED_ANTIGRAVITY_ALIASES = buildGeneratedAntigravityAliases(); +const GENERATED_ANTIGRAVITY_ALIAS_MAP = new Map( + GENERATED_ANTIGRAVITY_ALIASES.map((entry) => [buildAntigravityAliasKey(entry), entry] as const) +); + +function serializeAntigravityAliases(entries: OAuthModelAliasEntry[]): string { + if (entries.length === 0) { + return ''; + } + + const lines = [' antigravity:']; + for (const entry of entries) { + lines.push(` - name: ${entry.name}`); + lines.push(` alias: ${entry.alias}`); + if (entry.fork) { + lines.push(' fork: true'); + } + } + + return lines.join('\n'); +} + +function getLegacyGeneratedGeminiPreviewInfo( + entry: OAuthModelAliasEntry +): { nameKey: string; minorVersion: string } | null { + if (!BUILT_IN_GEMINI_ALIAS_NAMES.has(entry.name)) { + return null; + } + + const normalizedAlias = normalizeAntigravityAlias(entry.alias).toLowerCase(); + if (!normalizedAlias.startsWith('gemini-3') || !normalizedAlias.includes('-preview')) { + return null; + } + + const aliasWithoutCustomtools = normalizedAlias.endsWith('-customtools') + ? normalizedAlias.slice(0, -'-customtools'.length) + : normalizedAlias; + const canonicalAlias = + toDottedGeminiVersionAlias(aliasWithoutCustomtools) ?? aliasWithoutCustomtools; + const minorVersionMatch = canonicalAlias.match(/^gemini-3\.(\d+)(-.+-preview)$/); + if (!minorVersionMatch) { + return null; + } + + return { + nameKey: sanitizeYamlScalar(entry.name), + minorVersion: minorVersionMatch[1], + }; +} + +function buildLegacyGeneratedGeminiAliasPruneSet( + parsedEntries: OAuthModelAliasEntry[] +): Set { + const aliasKeysByNameAndMinor = new Map>(); + + for (const entry of parsedEntries) { + if (entry.fork) { + continue; + } + + const aliasKey = buildAntigravityAliasKey(entry); + if (GENERATED_ANTIGRAVITY_ALIAS_MAP.has(aliasKey)) { + continue; + } + + const previewInfo = getLegacyGeneratedGeminiPreviewInfo(entry); + if (!previewInfo) { + continue; + } + + const aliasKeysByMinor = aliasKeysByNameAndMinor.get(previewInfo.nameKey) ?? new Map(); + const aliasKeys = aliasKeysByMinor.get(previewInfo.minorVersion) ?? []; + aliasKeys.push(aliasKey); + aliasKeysByMinor.set(previewInfo.minorVersion, aliasKeys); + aliasKeysByNameAndMinor.set(previewInfo.nameKey, aliasKeysByMinor); + } + + const staleAliasKeys = new Set(); + + for (const aliasKeysByMinor of aliasKeysByNameAndMinor.values()) { + const sortedMinorVersions = [...aliasKeysByMinor.keys()].sort((left, right) => { + return Number(left) - Number(right); + }); + const totalAliasCount = sortedMinorVersions.reduce((total, minorVersion) => { + return total + (aliasKeysByMinor.get(minorVersion)?.length ?? 0); + }, 0); + const preservedMinorVersion = + Number(sortedMinorVersions[0]) <= MAX_LEGACY_MANUAL_GEMINI_MINOR_VERSION + ? sortedMinorVersions[0] + : null; + const minimumMinorVersionsToPrune = preservedMinorVersion + ? MIN_STALE_GUESSED_GEMINI_MINOR_VERSIONS + : MIN_STALE_HIGH_ONLY_GEMINI_MINOR_VERSIONS; + if (sortedMinorVersions.length < minimumMinorVersionsToPrune) { + continue; + } + if ( + totalAliasCount < + sortedMinorVersions.length * MIN_STALE_GUESSED_GEMINI_AVERAGE_VARIANTS_PER_MINOR + ) { + continue; + } + + for (const minorVersion of sortedMinorVersions) { + if (minorVersion === preservedMinorVersion) { + continue; + } + + const aliasKeys = aliasKeysByMinor.get(minorVersion) ?? []; + for (const aliasKey of aliasKeys) { + staleAliasKeys.add(aliasKey); + } + } + } + + return staleAliasKeys; +} + +function extractPreservedAntigravityAliases( + existingAliases: string, + options?: { enableLegacyGeminiStaleCleanup?: boolean } +): PreservedAntigravityAliasesResult { + if (!existingAliases.trim()) { + return { yaml: '', prunedLegacyAliasCount: 0 }; + } + + const parsedEntries = parseExistingAntigravityAliases(existingAliases); + const staleAliasKeys = options?.enableLegacyGeminiStaleCleanup + ? buildLegacyGeneratedGeminiAliasPruneSet(parsedEntries) + : new Set(); + + const preservedEntries = parsedEntries.filter((entry) => { + const aliasKey = buildAntigravityAliasKey(entry); + const generatedEntry = GENERATED_ANTIGRAVITY_ALIAS_MAP.get(aliasKey); + + if (generatedEntry) { + return Boolean(entry.fork) && !generatedEntry.fork; + } + + if (entry.fork) { + return true; + } + + return !staleAliasKeys.has(aliasKey); + }); + + return { + yaml: serializeAntigravityAliases(preservedEntries), + prunedLegacyAliasCount: parsedEntries.filter((entry) => + staleAliasKeys.has(buildAntigravityAliasKey(entry)) + ).length, + }; +} + +function writeLegacyGeminiAliasCleanupBackup(configPath: string, existingContent: string): void { + const backupPath = `${configPath}.pre-v16-gemini-alias-cleanup.bak`; + if (fs.existsSync(backupPath)) { + return; + } + + fs.writeFileSync(backupPath, existingContent, { mode: 0o600 }); +} + /** * Generate oauth-model-alias YAML section. * Merges default Antigravity aliases with any user-added custom aliases. @@ -543,8 +753,20 @@ export function regenerateConfig(port: number = CLIPROXY_DEFAULT_PORT): string { // Preserve claude-api-key section (managed via dashboard/API) claudeApiKeySection = extractYamlSection(content, 'claude-api-key'); - // Preserve existing oauth-model-alias user customizations - existingAliases = extractYamlSection(content, 'oauth-model-alias'); + // Preserve user customizations while pruning legacy generated Gemini preview noise. + const existingConfigVersion = getConfigVersionFromContent(content); + const preservedAliases = extractPreservedAntigravityAliases( + extractYamlSection(content, 'oauth-model-alias'), + { + enableLegacyGeminiStaleCleanup: + existingConfigVersion === null || + existingConfigVersion < LEGACY_GEMINI_STALE_ALIAS_MIGRATION_VERSION, + } + ); + existingAliases = preservedAliases.yaml; + if (preservedAliases.prunedLegacyAliasCount > 0) { + writeLegacyGeminiAliasCleanupBackup(configPath, content); + } } catch { // Use defaults if reading fails } @@ -583,12 +805,10 @@ export function configNeedsRegeneration(): boolean { const content = fs.readFileSync(configPath, 'utf-8'); // Check for version marker - const versionMatch = content.match(/CCS v(\d+)/); - if (!versionMatch) { + const configVersion = getConfigVersionFromContent(content); + if (configVersion === null) { return true; // No version marker = old config } - - const configVersion = parseInt(versionMatch[1], 10); return configVersion < CLIPROXY_CONFIG_VERSION; } catch { return true; // Error reading = regenerate diff --git a/tests/unit/cliproxy/config-generator.test.js b/tests/unit/cliproxy/config-generator.test.js index 4445c807..0e55d717 100644 --- a/tests/unit/cliproxy/config-generator.test.js +++ b/tests/unit/cliproxy/config-generator.test.js @@ -753,6 +753,433 @@ oauth-model-alias: ); }); + it('drops stale multi-version Gemini preview ranges while preserving true custom aliases', () => { + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + const guessedMinors = ['3', '4', '5', '6']; + const buildGuessedRange = (name, family) => + guessedMinors + .flatMap((minor) => [ + ` - name: ${name}`, + ` alias: gemini-3.${minor}-${family}-preview`, + ` - name: ${name}`, + ` alias: gemini-3.${minor}-${family}-preview-customtools`, + ` - name: ${name}`, + ` alias: gemini-3-${minor}-${family}-preview`, + ` - name: ${name}`, + ` alias: gemini-3-${minor}-${family}-preview-customtools`, + ]) + .join('\n'); + + const initialConfig = `# CLIProxyAPI config generated by CCS v12 +port: 8317 +api-keys: + - "ccs-internal-managed" +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +oauth-model-alias: + antigravity: +${buildGuessedRange('gemini-3-pro-high', 'pro')} +${buildGuessedRange('gemini-3-flash', 'flash')} + - name: custom-model + alias: keep-me +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + regenerateConfig(); + + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + for (const minor of guessedMinors) { + assert( + !newConfig.includes(`alias: gemini-3.${minor}-pro-preview`), + `Should remove stale generated Pro aliases for 3.${minor}` + ); + assert( + !newConfig.includes(`alias: gemini-3-${minor}-flash-preview-customtools`), + `Should remove stale generated Flash aliases for 3.${minor}` + ); + } + assert(newConfig.includes('alias: keep-me'), 'Should preserve true custom aliases'); + assert( + newConfig.includes('alias: gemini-3-pro-preview'), + 'Should still keep current baseline Pro preview alias' + ); + assert( + newConfig.includes('alias: gemini-3.1-flash-preview-customtools'), + 'Should still keep current baseline Flash compatibility alias' + ); + }); + + it('preserves sparse manual minor-version overrides when a stale guessed range is pruned', () => { + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + const guessedMinors = ['3', '4', '5', '6']; + const staleRange = guessedMinors + .flatMap((minor) => [ + ` - name: gemini-3-pro-high`, + ` alias: gemini-3.${minor}-pro-preview`, + ` - name: gemini-3-pro-high`, + ` alias: gemini-3.${minor}-pro-preview-customtools`, + ` - name: gemini-3-pro-high`, + ` alias: gemini-3-${minor}-pro-preview`, + ` - name: gemini-3-pro-high`, + ` alias: gemini-3-${minor}-pro-preview-customtools`, + ]) + .join('\n'); + + const initialConfig = `# CLIProxyAPI config generated by CCS v12 +port: 8317 +api-keys: + - "ccs-internal-managed" +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +oauth-model-alias: + antigravity: + - name: gemini-3-pro-high + alias: gemini-3.2-pro-preview +${staleRange} +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + regenerateConfig(); + + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert( + newConfig.includes('alias: gemini-3.2-pro-preview'), + 'Should preserve sparse manual minor-version override' + ); + assert( + newConfig.includes('alias: gemini-3.2-pro-preview-customtools'), + 'Should still expand compatibility aliases for preserved sparse manual override' + ); + for (const minor of guessedMinors) { + assert( + !newConfig.includes(`alias: gemini-3.${minor}-pro-preview`), + `Should prune stale guessed aliases for 3.${minor}` + ); + } + }); + + it('preserves a manual 3.2 compatibility cluster while pruning higher stale guessed minors', () => { + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + const guessedMinors = ['3', '4', '5', '6']; + const staleRange = guessedMinors + .flatMap((minor) => [ + ` - name: gemini-3-pro-high`, + ` alias: gemini-3.${minor}-pro-preview`, + ` - name: gemini-3-pro-high`, + ` alias: gemini-3.${minor}-pro-preview-customtools`, + ` - name: gemini-3-pro-high`, + ` alias: gemini-3-${minor}-pro-preview`, + ` - name: gemini-3-pro-high`, + ` alias: gemini-3-${minor}-pro-preview-customtools`, + ]) + .join('\n'); + + const initialConfig = `# CLIProxyAPI config generated by CCS v12 +port: 8317 +api-keys: + - "ccs-internal-managed" +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +oauth-model-alias: + antigravity: + - name: gemini-3-pro-high + alias: gemini-3.2-pro-preview + - name: gemini-3-pro-high + alias: gemini-3.2-pro-preview-customtools + - name: gemini-3-pro-high + alias: gemini-3-2-pro-preview + - name: gemini-3-pro-high + alias: gemini-3-2-pro-preview-customtools +${staleRange} +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + regenerateConfig(); + + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert( + newConfig.includes('alias: gemini-3.2-pro-preview'), + 'Should preserve the manual 3.2 compatibility cluster during legacy cleanup' + ); + assert( + newConfig.includes('alias: gemini-3-2-pro-preview-customtools'), + 'Should preserve hyphenated manual compatibility aliases for 3.2' + ); + for (const minor of guessedMinors) { + assert( + !newConfig.includes(`alias: gemini-3.${minor}-pro-preview`), + `Should prune higher stale guessed aliases for 3.${minor}` + ); + } + }); + + it('prunes partially retained broad guessed ranges during legacy cleanup', () => { + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + const guessedMinors = ['3', '4', '5']; + const partialRange = guessedMinors + .flatMap((minor) => [ + ` - name: gemini-3-pro-high`, + ` alias: gemini-3.${minor}-pro-preview`, + ` - name: gemini-3-pro-high`, + ` alias: gemini-3.${minor}-pro-preview-customtools`, + ]) + .join('\n'); + + const initialConfig = `# CLIProxyAPI config generated by CCS v12 +port: 8317 +api-keys: + - "ccs-internal-managed" +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +oauth-model-alias: + antigravity: +${partialRange} +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + regenerateConfig(); + + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + for (const minor of guessedMinors) { + assert( + !newConfig.includes(`alias: gemini-3.${minor}-pro-preview`), + `Should prune partially retained guessed aliases for 3.${minor}` + ); + } + }); + + it('preserves manual high-only multi-version clusters that are not broad guessed ranges', () => { + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v12 +port: 8317 +api-keys: + - "ccs-internal-managed" +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +oauth-model-alias: + antigravity: + - name: gemini-3-pro-high + alias: gemini-3.3-pro-preview + - name: gemini-3-pro-high + alias: gemini-3.3-pro-preview-customtools + - name: gemini-3-pro-high + alias: gemini-3.4-pro-preview + - name: gemini-3-pro-high + alias: gemini-3.4-pro-preview-customtools +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + regenerateConfig(); + + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert( + newConfig.includes('alias: gemini-3.3-pro-preview'), + 'Should preserve manual 3.3 aliases when the cluster is not broad enough to infer stale fan-out' + ); + assert( + newConfig.includes('alias: gemini-3.4-pro-preview-customtools'), + 'Should preserve manual 3.4 compatibility aliases when provenance is ambiguous' + ); + }); + + it('preserves single-minor high-version aliases when they are not part of a guessed range', () => { + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v12 +port: 8317 +api-keys: + - "ccs-internal-managed" +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +oauth-model-alias: + antigravity: + - name: gemini-3-pro-high + alias: gemini-3.11-pro-preview + - name: gemini-3-pro-high + alias: gemini-3.11-pro-preview-customtools +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + regenerateConfig(); + + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert( + newConfig.includes('alias: gemini-3.11-pro-preview'), + 'Should preserve lone manual high-version aliases during legacy cleanup' + ); + assert( + newConfig.includes('alias: gemini-3.11-pro-preview-customtools'), + 'Should still expand compatibility aliases for preserved high-version aliases' + ); + }); + + it('writes a one-time backup before pruning legacy Gemini aliases during migration', () => { + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + const configPath = path.join(cliproxyDir, 'config.yaml'); + const guessedMinors = ['3', '4', '5']; + const staleRange = guessedMinors + .flatMap((minor) => [ + ` - name: gemini-3-pro-high`, + ` alias: gemini-3.${minor}-pro-preview`, + ` - name: gemini-3-pro-high`, + ` alias: gemini-3.${minor}-pro-preview-customtools`, + ]) + .join('\n'); + + const initialConfig = `# CLIProxyAPI config generated by CCS v12 +port: 8317 +api-keys: + - "ccs-internal-managed" +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +oauth-model-alias: + antigravity: +${staleRange} +`; + fs.writeFileSync(configPath, initialConfig); + + regenerateConfig(); + + const backupPath = `${configPath}.pre-v16-gemini-alias-cleanup.bak`; + assert(fs.existsSync(backupPath), 'Should keep a backup of the legacy config before pruning'); + assert( + fs.readFileSync(backupPath, 'utf-8').includes('gemini-3.5-pro-preview'), + 'Should preserve the pruned legacy alias in the migration backup' + ); + }); + + it('preserves manual Gemini preview overrides on built-in alias names', () => { + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v12 +port: 8317 +api-keys: + - "ccs-internal-managed" +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +oauth-model-alias: + antigravity: + - name: gemini-3-pro-high + alias: gemini-3.2-pro-preview + - name: gemini-3-flash + alias: gemini-3.2-flash-preview + fork: true +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + regenerateConfig(); + + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert( + newConfig.includes('alias: gemini-3.2-pro-preview'), + 'Should preserve manual Pro preview overrides on built-in alias names' + ); + assert( + newConfig.includes('alias: gemini-3.2-flash-preview'), + 'Should preserve manual Flash preview overrides on built-in alias names' + ); + assert( + newConfig.includes('alias: gemini-3.2-pro-preview-customtools'), + 'Should still expand compatibility aliases for preserved manual Pro override' + ); + assert( + newConfig.includes('alias: gemini-3.2-flash-preview-customtools'), + 'Should still expand compatibility aliases for preserved manual Flash override' + ); + + const lines = newConfig.split('\n'); + for (let i = 0; i < lines.length; i++) { + if (lines[i].includes('alias: gemini-3.2-flash-preview')) { + assert( + lines[i + 1] && lines[i + 1].trim() === 'fork: true', + 'Should preserve fork: true on manual Flash preview override' + ); + break; + } + } + }); + + it('preserves manual Gemini preview compatibility clusters on built-in alias names', () => { + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v12 +port: 8317 +api-keys: + - "ccs-internal-managed" +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +oauth-model-alias: + antigravity: + - name: gemini-3-pro-high + alias: gemini-3.2-pro-preview + - name: gemini-3-pro-high + alias: gemini-3.2-pro-preview-customtools + - name: gemini-3-pro-high + alias: gemini-3-2-pro-preview + - name: gemini-3-pro-high + alias: gemini-3-2-pro-preview-customtools +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + regenerateConfig(); + + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert( + newConfig.includes('alias: gemini-3.2-pro-preview'), + 'Should preserve manual dotted Pro preview override' + ); + assert( + newConfig.includes('alias: gemini-3.2-pro-preview-customtools'), + 'Should preserve manual dotted Pro customtools override' + ); + assert( + newConfig.includes('alias: gemini-3-2-pro-preview'), + 'Should preserve manual hyphenated Pro preview override' + ); + assert( + newConfig.includes('alias: gemini-3-2-pro-preview-customtools'), + 'Should preserve manual hyphenated Pro customtools override' + ); + }); + + it('does not re-prune multi-version manual clusters once the config is already migrated', () => { + const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + + const initialConfig = `# CLIProxyAPI config generated by CCS v16 +port: 8317 +api-keys: + - "ccs-internal-managed" +auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth" +oauth-model-alias: + antigravity: + - name: gemini-3-pro-high + alias: gemini-3.2-pro-preview + - name: gemini-3-pro-high + alias: gemini-3.3-pro-preview + - name: gemini-3-pro-high + alias: gemini-3.4-pro-preview +`; + fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig); + + regenerateConfig(); + + const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8'); + assert( + newConfig.includes('alias: gemini-3.2-pro-preview'), + 'Should preserve manual aliases after legacy migration has already run' + ); + assert( + newConfig.includes('alias: gemini-3.3-pro-preview'), + 'Should not re-prune later manual version clusters on v16+ configs' + ); + assert( + newConfig.includes('alias: gemini-3.4-pro-preview'), + 'Should leave already-migrated multi-version manual aliases untouched' + ); + }); + it('preserves user-added aliases with fork during regeneration', () => { const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy'); fs.mkdirSync(cliproxyDir, { recursive: true });