fix(cliproxy): harden antigravity alias generation

This commit is contained in:
Tam Nhu Tran
2026-02-22 00:35:20 +07:00
parent 653f8092ae
commit 63619cb9dc
2 changed files with 331 additions and 38 deletions
+247 -38
View File
@@ -10,6 +10,7 @@ import { getProviderDisplayName } from '../provider-capabilities';
import { getModelMappingFromConfig } from '../base-config-loader';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { getEffectiveApiKey, getEffectiveManagementSecret } from '../auth-token-manager';
import { getCachedCatalog } from '../catalog-cache';
import { getAuthDir, getProviderAuthDir, getConfigPathForPort } from './path-resolver';
import { CLIPROXY_DEFAULT_PORT } from './port-manager';
@@ -29,15 +30,24 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs';
* v6: Added oauth-model-alias with Opus 4.6 support
* v7: Added fork:true for Claude model aliases (keep both upstream and alias names)
* v8: Added Gemini 3.1 preview aliases for provider routing compatibility
* v9: Added resilient alias compatibility expansion and cache-assisted alias enrichment
*/
export const CLIPROXY_CONFIG_VERSION = 8;
export const CLIPROXY_CONFIG_VERSION = 9;
interface OAuthModelAliasEntry {
name: string;
alias: string;
fork?: boolean;
}
const GEMINI_MINOR_COMPAT_RANGE = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const;
/**
* Default Antigravity oauth-model-alias entries.
* Maps user-facing model names to Antigravity internal model names.
* Must stay in sync with CLIProxyAPIPlus defaultAntigravityAliases().
* Additional compatibility aliases are derived automatically at generation time.
*/
const DEFAULT_ANTIGRAVITY_ALIASES: Array<{ name: string; alias: string; fork?: boolean }> = [
const DEFAULT_ANTIGRAVITY_ALIASES: OAuthModelAliasEntry[] = [
{ name: 'rev19-uic3-1p', alias: 'gemini-2.5-computer-use-preview-10-2025' },
{ name: 'gemini-3-pro-image', alias: 'gemini-3-pro-image-preview' },
{ name: 'gemini-3-pro-high', alias: 'gemini-3-pro-preview' },
@@ -78,51 +88,250 @@ function getLoggingSettings(): { loggingToFile: boolean; requestLog: boolean } {
};
}
function sanitizeYamlScalar(rawValue: string): string {
const trimmed = rawValue.trim();
if (
(trimmed.startsWith('"') && trimmed.endsWith('"')) ||
(trimmed.startsWith("'") && trimmed.endsWith("'"))
) {
return trimmed.slice(1, -1).trim();
}
return trimmed;
}
function addAliasEntry(
entries: OAuthModelAliasEntry[],
indexByKey: Map<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}`;
@@ -644,6 +644,90 @@ auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
}
});
it('generates dot and hyphen alias forms for preview compatibility', () => {
regenerateConfig();
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
const config = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
assert(config.includes('alias: gemini-3.1-pro-preview'), 'Should include dotted version alias');
assert(config.includes('alias: gemini-3-1-pro-preview'), 'Should include hyphen version alias');
assert(
config.includes('alias: gemini-3-1-pro-preview-customtools'),
'Should include hyphen customtools alias'
);
});
it('preserves multiple aliases that share the same model name', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });
const initialConfig = `# CLIProxyAPI config generated by CCS v8
port: 8317
api-keys:
- "ccs-internal-managed"
auth-dir: "${cliproxyDir.replace(/\\/g, '/')}/auth"
oauth-model-alias:
antigravity:
- name: custom-model
alias: custom-alias-a
- name: custom-model
alias: custom-alias-b
`;
fs.writeFileSync(path.join(cliproxyDir, 'config.yaml'), initialConfig);
regenerateConfig();
const newConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
assert(newConfig.includes('alias: custom-alias-a'), 'Should preserve first alias');
assert(newConfig.includes('alias: custom-alias-b'), 'Should preserve second alias');
assert.strictEqual(
(newConfig.match(/alias: custom-alias-a/g) || []).length,
1,
'Should keep one entry for first alias'
);
assert.strictEqual(
(newConfig.match(/alias: custom-alias-b/g) || []).length,
1,
'Should keep one entry for second alias'
);
});
it('enriches aliases from cached catalog for unseen preview minor versions', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
const cachePath = path.join(testDir, '.ccs', 'model-catalog-cache.json');
fs.mkdirSync(path.dirname(cachePath), { recursive: true });
regenerateConfig();
const beforeCacheConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
assert(
!beforeCacheConfig.includes('alias: gemini-3.11-pro-preview'),
'Should not include unseen minor alias before cache enrichment'
);
const cachePayload = {
providers: {
agy: [{ id: 'gemini-3.11-pro-preview', display_name: 'Gemini 3.11 Pro Preview' }],
},
fetchedAt: Date.now(),
};
fs.writeFileSync(cachePath, JSON.stringify(cachePayload));
regenerateConfig();
const afterCacheConfig = fs.readFileSync(path.join(cliproxyDir, 'config.yaml'), 'utf-8');
assert(
afterCacheConfig.includes('alias: gemini-3.11-pro-preview'),
'Should include cache-derived unseen minor alias'
);
assert(
afterCacheConfig.includes('alias: gemini-3.11-pro-preview-customtools'),
'Should include compatibility alias for cache-derived entry'
);
});
it('preserves user-added aliases with fork during regeneration', () => {
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
fs.mkdirSync(cliproxyDir, { recursive: true });