diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index 87523ddb..8e9ded86 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -17,6 +17,17 @@ import { CLIPROXY_DEFAULT_PORT } from './port-manager'; import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade'; import { getActiveDockerLegacyApiKeys } from '../../docker/docker-key-rotation'; import { isValidCliproxyRetryValue } from '../../config/schemas/cliproxy'; +import type { CLIProxyOAuthModelAliasConfig } from '../../config/schemas/cliproxy'; +import { + mergeOAuthModelAliases, + parseOAuthModelAliasSection, + serializeOAuthModelAliasBody, +} from './oauth-model-alias-config'; +import { + mergePayloadConfig, + parsePayloadSection, + serializePayloadSection, +} from './payload-rule-config'; /** Internal API key for CCS-managed requests */ export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed'; @@ -46,8 +57,9 @@ export const CCS_CONTROL_PANEL_SECRET = 'ccs'; * v18: Persist routing.session-affinity and routing.session-affinity-ttl from CCS unified config * v19: Persist backend-aware management panel repository from CCS unified config * v20: Pool-gated cooling/routing/retry-cap block; disable-cooling flips to false for pool users + * v21: Persist user-defined OAuth aliases and scoped payload override rules */ -export const CLIPROXY_CONFIG_VERSION = 20; +export const CLIPROXY_CONFIG_VERSION = 21; export const ORIGINAL_MANAGEMENT_PANEL_REPOSITORY = 'https://github.com/router-for-me/Cli-Proxy-API-Management-Center'; @@ -296,7 +308,7 @@ function addAliasEntry( if (getDeniedModelIdReasonForProvider(normalized.name, 'agy')) return; if (getDeniedModelIdReasonForProvider(normalized.alias, 'agy')) return; - const key = `${normalized.name}\u0000${normalized.alias}`; + const key = normalized.alias; const existingIndex = indexByKey.get(key); if (existingIndex !== undefined) { if (normalized.fork) entries[existingIndex].fork = true; @@ -307,6 +319,33 @@ function addAliasEntry( entries.push(normalized); } +function upsertConfiguredAliasEntry( + entries: OAuthModelAliasEntry[], + indexByKey: Map, + entry: OAuthModelAliasEntry +): void { + const normalized: OAuthModelAliasEntry = { + name: sanitizeYamlScalar(entry.name), + alias: normalizeAntigravityAlias(entry.alias), + fork: entry.fork || undefined, + }; + if (!normalized.name || !normalized.alias) return; + if (getDeniedModelIdReasonForProvider(normalized.name, 'agy')) return; + if (getDeniedModelIdReasonForProvider(normalized.alias, 'agy')) return; + + const existingIndex = entries.findIndex((candidate) => candidate.alias === normalized.alias); + if (existingIndex === -1) { + addAliasEntry(entries, indexByKey, normalized); + return; + } + + entries[existingIndex] = normalized; + indexByKey.clear(); + entries.forEach((candidate, index) => { + indexByKey.set(candidate.alias, index); + }); +} + function buildAntigravityAliasKey(entry: Pick): string { return `${sanitizeYamlScalar(entry.name)}\u0000${normalizeAntigravityAlias(entry.alias)}`; } @@ -622,9 +661,13 @@ function writeLegacyGeminiAliasCleanupBackup(configPath: string, existingContent * Generate oauth-model-alias YAML section. * Merges default Antigravity aliases with any user-added custom aliases. */ -function generateOAuthModelAliasSection(existingAliases?: string): string { +function generateOAuthModelAliasSection( + existingAliases?: string, + configuredAliases?: CLIProxyOAuthModelAliasConfig +): string { const aliasEntries: OAuthModelAliasEntry[] = []; const aliasIndexByKey = new Map(); + const normalizedConfiguredAliases = mergeOAuthModelAliases({}, configuredAliases); // Start with default aliases. for (const alias of DEFAULT_ANTIGRAVITY_ALIASES) { @@ -639,6 +682,11 @@ function generateOAuthModelAliasSection(existingAliases?: string): string { } } + // CCS-owned structured aliases replace the same client alias in place. + for (const alias of normalizedConfiguredAliases.antigravity ?? []) { + upsertConfiguredAliasEntry(aliasEntries, aliasIndexByKey, alias); + } + // Expand lightweight compatibility aliases (dot/hyphen + customtools toggle). for (const alias of getCompatibilityAliases(aliasEntries)) { addAliasEntry(aliasEntries, aliasIndexByKey, alias); @@ -652,7 +700,15 @@ function generateOAuthModelAliasSection(existingAliases?: string): string { }) .join('\n'); - return `oauth-model-alias:\n antigravity:\n${entries}`; + const existingAliasConfig = parseOAuthModelAliasSection(existingAliases ?? ''); + const mergedAliasConfig = mergeOAuthModelAliases( + existingAliasConfig, + normalizedConfiguredAliases + ); + delete mergedAliasConfig.antigravity; + const otherProviders = serializeOAuthModelAliasBody(mergedAliasConfig); + + return `oauth-model-alias:\n antigravity:\n${entries}${otherProviders ? `\n${otherProviders}` : ''}`; } /** @@ -663,11 +719,13 @@ function generateOAuthModelAliasSection(existingAliases?: string): string { * @param port - Server port (default: 8317) * @param userApiKeys - User-added API keys to preserve (default: []) * @param existingAliases - Existing oauth-model-alias content to merge with defaults + * @param existingPayload - Existing payload content to merge with structured CCS rules */ function generateUnifiedConfigContent( port: number = CLIPROXY_DEFAULT_PORT, userApiKeys: string[] = [], - existingAliases?: string + existingAliases?: string, + existingPayload?: string ): string { const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories // Convert Windows backslashes to forward slashes for YAML compatibility @@ -682,6 +740,10 @@ function generateUnifiedConfigContent( const requestRetry = getRequestRetry(); const maxRetryInterval = getMaxRetryInterval(); const managementPanelRepository = getManagementPanelRepository(); + const userRoutingConfig = loadOrCreateUnifiedConfig().cliproxy; + const payloadSection = serializePayloadSection( + mergePayloadConfig(parsePayloadSection(existingPayload ?? ''), userRoutingConfig.payload) + ); // Get effective auth tokens (respects user customization) const effectiveApiKey = getEffectiveApiKey(); @@ -795,7 +857,8 @@ ${apiKeysYaml} # OAuth tokens directory (auto-discovered by CLIProxyAPI) auth-dir: "${authDirNormalized}" -${generateOAuthModelAliasSection(existingAliases)} +${generateOAuthModelAliasSection(existingAliases, userRoutingConfig.oauth_model_alias)} +${payloadSection ? `${payloadSection}\n` : ''} `; return config; @@ -917,6 +980,7 @@ export function regenerateConfig( let effectivePort = port; let userApiKeys: string[] = []; let existingAliases = ''; + let existingPayload = ''; const preservedSections: PreservedYamlSection[] = []; if (fs.existsSync(configPath)) { @@ -945,15 +1009,21 @@ export function regenerateConfig( // 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; + const existingAliasBody = extractYamlSection(content, 'oauth-model-alias'); + const preservedAliases = extractPreservedAntigravityAliases(existingAliasBody, { + enableLegacyGeminiStaleCleanup: + existingConfigVersion === null || + existingConfigVersion < LEGACY_GEMINI_STALE_ALIAS_MIGRATION_VERSION, + }); + const preservedAliasConfig = parseOAuthModelAliasSection(existingAliasBody); + const preservedAntigravityConfig = parseOAuthModelAliasSection(preservedAliases.yaml); + if (preservedAntigravityConfig.antigravity) { + preservedAliasConfig.antigravity = preservedAntigravityConfig.antigravity; + } else { + delete preservedAliasConfig.antigravity; + } + existingAliases = serializeOAuthModelAliasBody(preservedAliasConfig); + existingPayload = extractYamlSection(content, 'payload'); if (preservedAliases.prunedLegacyAliasCount > 0) { writeLegacyGeminiAliasCleanupBackup(configPath, content); } @@ -969,7 +1039,12 @@ export function regenerateConfig( fs.mkdirSync(authDir, { recursive: true, mode: 0o700 }); // Generate fresh config with preserved user API keys and aliases - let configContent = generateUnifiedConfigContent(effectivePort, userApiKeys, existingAliases); + let configContent = generateUnifiedConfigContent( + effectivePort, + userApiKeys, + existingAliases, + existingPayload + ); // Re-append managed top-level sections that are not part of the generated defaults. for (const section of preservedSections) { diff --git a/src/cliproxy/config/oauth-model-alias-config.ts b/src/cliproxy/config/oauth-model-alias-config.ts new file mode 100644 index 00000000..3a1c31d7 --- /dev/null +++ b/src/cliproxy/config/oauth-model-alias-config.ts @@ -0,0 +1,130 @@ +/** Parse, merge, and serialize user-owned CLIProxy OAuth model aliases. */ + +import * as yaml from 'js-yaml'; +import type { + CLIProxyOAuthModelAliasConfig, + CLIProxyOAuthModelAliasEntry, +} from '../../config/schemas/cliproxy'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function normalizeAliasEntry(value: unknown): CLIProxyOAuthModelAliasEntry | null { + if (!isRecord(value) || typeof value.name !== 'string' || typeof value.alias !== 'string') { + return null; + } + + const name = value.name.trim(); + const alias = value.alias.trim(); + if (!name || !alias) { + return null; + } + + return { + name, + alias, + ...(value.fork === true ? { fork: true } : {}), + }; +} + +function aliasKey(entry: CLIProxyOAuthModelAliasEntry): string { + return entry.alias; +} + +export function parseOAuthModelAliasSection(body: string): CLIProxyOAuthModelAliasConfig { + if (!body.trim()) { + return {}; + } + + try { + const parsed = yaml.load(`oauth-model-alias:\n${body}`); + if (!isRecord(parsed) || !isRecord(parsed['oauth-model-alias'])) { + return {}; + } + + const result = Object.create(null) as CLIProxyOAuthModelAliasConfig; + for (const [provider, entries] of Object.entries(parsed['oauth-model-alias'])) { + if (!provider.trim() || !Array.isArray(entries)) { + continue; + } + + const normalized = entries + .map(normalizeAliasEntry) + .filter((entry): entry is CLIProxyOAuthModelAliasEntry => entry !== null); + if (normalized.length > 0) { + result[provider] = normalized; + } + } + return result; + } catch { + return {}; + } +} + +export function mergeOAuthModelAliases( + existing: CLIProxyOAuthModelAliasConfig, + configured?: CLIProxyOAuthModelAliasConfig +): CLIProxyOAuthModelAliasConfig { + const merged = Object.create(null) as CLIProxyOAuthModelAliasConfig; + + for (const [sourceIndex, source] of [existing, configured].entries()) { + if (!isRecord(source)) { + continue; + } + const replaceMatching = sourceIndex === 1; + for (const [provider, rawEntries] of Object.entries(source)) { + if (!provider.trim() || !Array.isArray(rawEntries)) { + continue; + } + + const entries = merged[provider] ?? []; + const indexByKey = new Map(entries.map((entry, index) => [aliasKey(entry), index])); + for (const rawEntry of rawEntries) { + const entry = normalizeAliasEntry(rawEntry); + if (!entry) { + continue; + } + + const key = aliasKey(entry); + const existingIndex = indexByKey.get(key); + if (existingIndex !== undefined) { + if (replaceMatching) { + entries[existingIndex] = entry; + } else if (entry.fork) { + entries[existingIndex] = { ...entries[existingIndex], fork: true }; + } + continue; + } + + indexByKey.set(key, entries.length); + entries.push(entry); + } + + if (entries.length > 0) { + merged[provider] = entries; + } + } + } + + return merged; +} + +export function serializeOAuthModelAliasBody(config: CLIProxyOAuthModelAliasConfig): string { + if (Object.keys(config).length === 0) { + return ''; + } + + return yaml + .dump(config, { + indent: 2, + lineWidth: -1, + noRefs: true, + noCompatMode: true, + quotingType: '"', + }) + .trimEnd() + .split('\n') + .map((line) => ` ${line}`) + .join('\n'); +} diff --git a/src/cliproxy/config/payload-rule-config.ts b/src/cliproxy/config/payload-rule-config.ts new file mode 100644 index 00000000..b46a8a14 --- /dev/null +++ b/src/cliproxy/config/payload-rule-config.ts @@ -0,0 +1,141 @@ +/** Parse, merge, and serialize user-owned CLIProxy payload rules. */ + +import * as yaml from 'js-yaml'; +import type { + CLIProxyPayloadConfig, + CLIProxyPayloadModelSelector, + CLIProxyPayloadOverrideRule, +} from '../../config/schemas/cliproxy'; + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function isValidModelSelector(value: unknown): value is CLIProxyPayloadModelSelector { + if (!isRecord(value) || typeof value.name !== 'string') { + return false; + } + + return value.name.trim().length > 0; +} + +function cloneOverrideRule(value: unknown): CLIProxyPayloadOverrideRule | null { + if (!isRecord(value) || !Array.isArray(value.models) || !isRecord(value.params)) { + return null; + } + + if (value.models.length === 0 || !value.models.every(isValidModelSelector)) { + return null; + } + + return structuredClone(value) as CLIProxyPayloadOverrideRule; +} + +function canonicalizeForKey(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(canonicalizeForKey); + } + if (!isRecord(value)) { + return value; + } + + return Object.fromEntries( + Object.keys(value) + .sort() + .map((key) => [key, canonicalizeForKey(value[key])]) + ); +} + +function predicateKey(rule: CLIProxyPayloadOverrideRule): string { + const predicate = Object.fromEntries(Object.entries(rule).filter(([key]) => key !== 'params')); + return JSON.stringify(canonicalizeForKey(predicate)); +} + +export function parsePayloadSection(body: string): CLIProxyPayloadConfig | undefined { + if (!body.trim()) { + return undefined; + } + + try { + const parsed = yaml.load(`payload:\n${body}`); + if (!isRecord(parsed) || !isRecord(parsed.payload)) { + return undefined; + } + return parsed.payload as CLIProxyPayloadConfig; + } catch { + return undefined; + } +} + +export function mergePayloadConfig( + existing?: CLIProxyPayloadConfig, + configured?: CLIProxyPayloadConfig +): CLIProxyPayloadConfig | undefined { + const existingConfig = isRecord(existing) ? (existing as CLIProxyPayloadConfig) : undefined; + const configuredConfig = isRecord(configured) ? (configured as CLIProxyPayloadConfig) : undefined; + if (!existingConfig && !configuredConfig) { + return undefined; + } + + const merged: CLIProxyPayloadConfig = { + ...(existingConfig ?? {}), + ...(configuredConfig ?? {}), + }; + const rules: CLIProxyPayloadOverrideRule[] = []; + const indexByPredicate = new Map(); + + const addRules = (rawRules: unknown, replaceMatching: boolean) => { + if (!Array.isArray(rawRules)) { + return; + } + + for (const rawRule of rawRules) { + const rule = cloneOverrideRule(rawRule); + if (!rule) { + continue; + } + + const key = predicateKey(rule); + const existingIndex = indexByPredicate.get(key); + if (existingIndex !== undefined) { + if (replaceMatching) { + rules[existingIndex] = rule; + } + continue; + } + + indexByPredicate.set(key, rules.length); + rules.push(rule); + } + }; + + addRules(existingConfig?.override, false); + addRules(configuredConfig?.override, true); + + if (rules.length > 0) { + merged.override = rules; + } else { + delete merged.override; + } + + return Object.keys(merged).length > 0 ? merged : undefined; +} + +export function serializePayloadSection(config?: CLIProxyPayloadConfig): string { + if (!config || Object.keys(config).length === 0) { + return ''; + } + + return yaml + .dump( + { payload: config }, + { + indent: 2, + lineWidth: -1, + noRefs: true, + noCompatMode: true, + quotingType: '"', + } + ) + .trimEnd(); +} diff --git a/src/cliproxy/types/config-types.ts b/src/cliproxy/types/config-types.ts index bf873439..b67e02d2 100644 --- a/src/cliproxy/types/config-types.ts +++ b/src/cliproxy/types/config-types.ts @@ -3,6 +3,10 @@ */ import type { CompositeTierConfig } from '../../config/unified-config-types'; +import type { + CLIProxyOAuthModelAliasConfig, + CLIProxyPayloadConfig, +} from '../../config/schemas/cliproxy'; import type { CliproxyRoutingStrategy } from './provider-types'; /** CLIProxy config.yaml structure (minimal) */ @@ -11,6 +15,8 @@ export interface CLIProxyConfig { 'api-keys': string[]; 'auth-dir': string; debug: boolean; + 'oauth-model-alias'?: CLIProxyOAuthModelAliasConfig; + payload?: CLIProxyPayloadConfig; routing?: { strategy?: CliproxyRoutingStrategy; 'session-affinity'?: boolean; diff --git a/src/config/schemas/cliproxy.ts b/src/config/schemas/cliproxy.ts index 92da2cb9..d3d1f6c7 100644 --- a/src/config/schemas/cliproxy.ts +++ b/src/config/schemas/cliproxy.ts @@ -135,6 +135,50 @@ export interface CLIProxyRetryConfig { max_retry_interval?: number; } +/** One OAuth model alias exposed by CLIProxy to compatible clients. */ +export interface CLIProxyOAuthModelAliasEntry { + /** Upstream model ID used after alias resolution. */ + name: string; + /** Client-visible model ID accepted by CLIProxy. */ + alias: string; + /** Keep both the upstream name and alias visible when supported upstream. */ + fork?: boolean; +} + +/** Provider/protocol keyed OAuth model aliases (for example `codex`). */ +export type CLIProxyOAuthModelAliasConfig = Record; + +/** Model selector for a CLIProxy payload rule. */ +export interface CLIProxyPayloadModelSelector { + /** Client-visible model ID to match before OAuth alias resolution. */ + name: string; + /** Optional protocol boundary, such as `codex`. */ + protocol?: string; + /** Additional CLIProxy selector constraints retained across regeneration. */ + [constraint: string]: unknown; +} + +/** A scoped CLIProxy payload override rule. */ +export interface CLIProxyPayloadOverrideRule { + /** All client model/protocol selectors covered by this rule. */ + models: CLIProxyPayloadModelSelector[]; + /** Upstream request parameters added when a selector matches. */ + params: Record; + /** Additional CLIProxy match constraints retained across regeneration. */ + [constraint: string]: unknown; +} + +/** + * User-owned CLIProxy payload configuration. + * + * Unknown supported payload subsections are retained so CCS regeneration does + * not erase options introduced by newer CLIProxy releases. + */ +export interface CLIProxyPayloadConfig { + override?: CLIProxyPayloadOverrideRule[]; + [section: string]: unknown; +} + /** Lower bound (inclusive) accepted for CLIProxy retry config fields. */ export const CLIPROXY_RETRY_MIN_VALUE = 0; @@ -217,4 +261,8 @@ export interface CLIProxyConfig { pool_routing?: CLIProxyPoolRoutingConfig; /** Request-retry behavior for transient upstream errors (opt-in, default: disabled) */ retry?: CLIProxyRetryConfig; + /** User-defined OAuth model aliases emitted into managed CLIProxy config. */ + oauth_model_alias?: CLIProxyOAuthModelAliasConfig; + /** Scoped payload rules emitted into managed CLIProxy config. */ + payload?: CLIProxyPayloadConfig; } diff --git a/src/config/schemas/index.ts b/src/config/schemas/index.ts index 43a437b5..6bbcede2 100644 --- a/src/config/schemas/index.ts +++ b/src/config/schemas/index.ts @@ -29,6 +29,13 @@ export type { CLIProxyLoggingConfig, CLIProxySafetyConfig, CLIProxyRoutingConfig, + CLIProxyRetryConfig, + CLIProxyPoolRoutingConfig, + CLIProxyOAuthModelAliasEntry, + CLIProxyOAuthModelAliasConfig, + CLIProxyPayloadModelSelector, + CLIProxyPayloadOverrideRule, + CLIProxyPayloadConfig, CLIProxyConfig, } from './cliproxy'; diff --git a/src/config/schemas/version.ts b/src/config/schemas/version.ts index 98979314..9f060cd9 100644 --- a/src/config/schemas/version.ts +++ b/src/config/schemas/version.ts @@ -19,5 +19,6 @@ * Version 11 = Discord Channels runtime auto-enable preferences * Version 12 = Official Channels multi-provider support (Telegram, Discord, iMessage) * Version 13 = Browser automation defaults to safe manual/off exposure + * Version 14 = User-defined CLIProxy OAuth aliases and scoped payload rules */ -export const UNIFIED_CONFIG_VERSION = 13; +export const UNIFIED_CONFIG_VERSION = 14;