mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-13 08:22:04 +00:00
fix(cliproxy): harden antigravity alias generation
This commit is contained in:
@@ -10,6 +10,7 @@ import { getProviderDisplayName } from '../provider-capabilities';
|
|||||||
import { getModelMappingFromConfig } from '../base-config-loader';
|
import { getModelMappingFromConfig } from '../base-config-loader';
|
||||||
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
|
||||||
import { getEffectiveApiKey, getEffectiveManagementSecret } from '../auth-token-manager';
|
import { getEffectiveApiKey, getEffectiveManagementSecret } from '../auth-token-manager';
|
||||||
|
import { getCachedCatalog } from '../catalog-cache';
|
||||||
import { getAuthDir, getProviderAuthDir, getConfigPathForPort } from './path-resolver';
|
import { getAuthDir, getProviderAuthDir, getConfigPathForPort } from './path-resolver';
|
||||||
import { CLIPROXY_DEFAULT_PORT } from './port-manager';
|
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
|
* v6: Added oauth-model-alias with Opus 4.6 support
|
||||||
* v7: Added fork:true for Claude model aliases (keep both upstream and alias names)
|
* 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
|
* 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.
|
* Default Antigravity oauth-model-alias entries.
|
||||||
* Maps user-facing model names to Antigravity internal model names.
|
* 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: '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-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-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.
|
* Generate oauth-model-alias YAML section.
|
||||||
* Merges default Antigravity aliases with any user-added custom aliases.
|
* Merges default Antigravity aliases with any user-added custom aliases.
|
||||||
*/
|
*/
|
||||||
function generateOAuthModelAliasSection(existingAliases?: string): string {
|
function generateOAuthModelAliasSection(existingAliases?: string): string {
|
||||||
// Start with default aliases
|
const aliasEntries: OAuthModelAliasEntry[] = [];
|
||||||
const aliasEntries = [...DEFAULT_ANTIGRAVITY_ALIASES];
|
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) {
|
if (existingAliases) {
|
||||||
const existingNames = new Set(aliasEntries.map((a) => a.name));
|
const parsed = parseExistingAntigravityAliases(existingAliases);
|
||||||
const lines = existingAliases.split('\n');
|
for (const alias of parsed) {
|
||||||
let currentName = '';
|
addAliasEntry(aliasEntries, aliasIndexByKey, alias);
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
const entries = aliasEntries
|
||||||
.map((a) => {
|
.map((a) => {
|
||||||
let entry = ` - name: ${a.name}\n alias: ${a.alias}`;
|
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', () => {
|
it('preserves user-added aliases with fork during regeneration', () => {
|
||||||
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
|
const cliproxyDir = path.join(testDir, '.ccs', 'cliproxy');
|
||||||
fs.mkdirSync(cliproxyDir, { recursive: true });
|
fs.mkdirSync(cliproxyDir, { recursive: true });
|
||||||
|
|||||||
Reference in New Issue
Block a user