From 9fb2016f9012466afc92049b0b068dbafc487b57 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 29 Jul 2026 13:44:17 -0400 Subject: [PATCH 1/3] feat(cliproxy): persist scoped routing rules --- src/cliproxy/config/generator.ts | 107 +++++++++++-- .../config/oauth-model-alias-config.ts | 130 ++++++++++++++++ src/cliproxy/config/payload-rule-config.ts | 141 ++++++++++++++++++ src/cliproxy/types/config-types.ts | 6 + src/config/schemas/cliproxy.ts | 48 ++++++ src/config/schemas/index.ts | 7 + src/config/schemas/version.ts | 3 +- 7 files changed, 425 insertions(+), 17 deletions(-) create mode 100644 src/cliproxy/config/oauth-model-alias-config.ts create mode 100644 src/cliproxy/config/payload-rule-config.ts 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; From 2ce3bfca26df80481f87ad2b29c4a8d2835e6e7a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 29 Jul 2026 13:44:17 -0400 Subject: [PATCH 2/3] test(cliproxy): cover routing rule preservation --- .../codex-user-routing-generator.test.ts | 164 ++++++++++++++++++ .../__tests__/user-routing-config.test.ts | 151 ++++++++++++++++ .../__tests__/cliproxy-user-routing.test.ts | 37 ++++ .../__tests__/schemas-reexport.test.ts | 41 ++++- 4 files changed, 390 insertions(+), 3 deletions(-) create mode 100644 src/cliproxy/config/__tests__/codex-user-routing-generator.test.ts create mode 100644 src/cliproxy/config/__tests__/user-routing-config.test.ts create mode 100644 src/config/schemas/__tests__/cliproxy-user-routing.test.ts diff --git a/src/cliproxy/config/__tests__/codex-user-routing-generator.test.ts b/src/cliproxy/config/__tests__/codex-user-routing-generator.test.ts new file mode 100644 index 00000000..c4c2d853 --- /dev/null +++ b/src/cliproxy/config/__tests__/codex-user-routing-generator.test.ts @@ -0,0 +1,164 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import * as yaml from 'js-yaml'; +import { configNeedsRegeneration, regenerateConfig } from '../generator'; + +interface GeneratedCliProxyConfig { + 'oauth-model-alias'?: Record>; + payload?: { + override?: Array<{ + models: Array<{ name: string; protocol?: string }>; + params: Record; + }>; + [section: string]: unknown; + }; + [key: string]: unknown; +} + +describe('Codex user routing config generation', () => { + let testHome: string; + let originalCcsHome: string | undefined; + let generatedConfigPath: string; + let generatedAuthDir: string; + + beforeEach(() => { + testHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-codex-routing-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = testHome; + const ccsDir = path.join(testHome, '.ccs'); + const cliproxyDir = path.join(ccsDir, 'cliproxy'); + fs.mkdirSync(cliproxyDir, { recursive: true }); + generatedConfigPath = path.join(cliproxyDir, 'config.yaml'); + generatedAuthDir = path.join(cliproxyDir, 'auth'); + + fs.writeFileSync( + path.join(ccsDir, 'config.yaml'), + `version: 14 +cliproxy: + oauth_model_alias: + codex: + - name: gpt-5.6-sol + alias: gpt-5.6-sol-fast + fork: true + payload: + override: + - models: + - name: gpt-5.6-sol-fast + protocol: codex + params: + service_tier: priority +`, + 'utf8' + ); + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + fs.rmSync(testHome, { recursive: true, force: true }); + }); + + function readGeneratedConfig(): GeneratedCliProxyConfig { + return yaml.load(fs.readFileSync(generatedConfigPath, 'utf8')) as GeneratedCliProxyConfig; + } + + it('generates external client alias semantics with a Codex-only priority override', () => { + regenerateConfig(8317, { + configPath: generatedConfigPath, + authDir: generatedAuthDir, + }); + + const generated = readGeneratedConfig(); + expect(generated['oauth-model-alias']?.codex).toEqual([ + { name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast', fork: true }, + ]); + expect(generated.payload?.override).toEqual([ + { + models: [{ name: 'gpt-5.6-sol-fast', protocol: 'codex' }], + params: { service_tier: 'priority' }, + }, + ]); + expect( + generated.payload?.override?.some((rule) => + rule.models.some((model) => model.name === 'gpt-5.6-sol') + ) + ).toBe(false); + }); + + it('preserves manual provider/payload sections and stays stable across regeneration', () => { + fs.writeFileSync( + generatedConfigPath, + `# CLIProxyAPI config generated by CCS v20 +port: 8317 +api-keys: + - "ccs-internal-managed" +auth-dir: "${generatedAuthDir}" +oauth-model-alias: + codex: + - name: existing-upstream + alias: existing-client + - name: gpt-5.6-sol + alias: gpt-5.6-sol-fast + future-provider: + - name: future-upstream + alias: future-client +payload: + default: + - models: + - name: existing-client + params: + temperature: 0.2 + override: + - models: + - name: gpt-5.6-sol-fast + protocol: codex + params: + service_tier: standard + - models: + - name: manual-client + protocol: codex + params: + custom_flag: preserved +`, + 'utf8' + ); + + expect(configNeedsRegeneration()).toBe(true); + regenerateConfig(8317, { + configPath: generatedConfigPath, + authDir: generatedAuthDir, + }); + const first = readGeneratedConfig(); + + expect(first['oauth-model-alias']?.codex).toEqual([ + { name: 'existing-upstream', alias: 'existing-client' }, + { name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast', fork: true }, + ]); + expect(first['oauth-model-alias']?.['future-provider']).toEqual([ + { name: 'future-upstream', alias: 'future-client' }, + ]); + expect(first.payload?.default).toBeDefined(); + expect(first.payload?.override).toEqual([ + { + models: [{ name: 'gpt-5.6-sol-fast', protocol: 'codex' }], + params: { service_tier: 'priority' }, + }, + { + models: [{ name: 'manual-client', protocol: 'codex' }], + params: { custom_flag: 'preserved' }, + }, + ]); + expect(configNeedsRegeneration()).toBe(false); + + regenerateConfig(8317, { + configPath: generatedConfigPath, + authDir: generatedAuthDir, + }); + expect(readGeneratedConfig()).toEqual(first); + }); +}); diff --git a/src/cliproxy/config/__tests__/user-routing-config.test.ts b/src/cliproxy/config/__tests__/user-routing-config.test.ts new file mode 100644 index 00000000..5fb86c85 --- /dev/null +++ b/src/cliproxy/config/__tests__/user-routing-config.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from 'bun:test'; +import { + mergeOAuthModelAliases, + parseOAuthModelAliasSection, + serializeOAuthModelAliasBody, +} from '../oauth-model-alias-config'; +import { + mergePayloadConfig, + parsePayloadSection, + serializePayloadSection, +} from '../payload-rule-config'; + +describe('CLIProxy user routing config', () => { + it('parses and round-trips provider aliases without dropping provider sections', () => { + const body = ` codex: + - name: gpt-5.6-sol + alias: gpt-5.6-sol-fast + fork: true + future-provider: + - name: upstream-model + alias: client-model +`; + + const parsed = parseOAuthModelAliasSection(body); + expect(parsed.codex).toEqual([{ name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast', fork: true }]); + expect(parsed['future-provider']).toEqual([{ name: 'upstream-model', alias: 'client-model' }]); + expect(parseOAuthModelAliasSection(serializeOAuthModelAliasBody(parsed))).toEqual(parsed); + }); + + it('preserves insertion order and deduplicates exact aliases while promoting fork', () => { + const merged = mergeOAuthModelAliases( + { + codex: [ + { name: 'first-upstream', alias: 'first-client' }, + { name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast' }, + ], + }, + { + codex: [ + { name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast', fork: true }, + { name: 'last-upstream', alias: 'last-client' }, + ], + } + ); + + expect(merged.codex).toEqual([ + { name: 'first-upstream', alias: 'first-client' }, + { name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast', fork: true }, + { name: 'last-upstream', alias: 'last-client' }, + ]); + }); + + it('lets configured aliases replace an existing provider client alias in place', () => { + const merged = mergeOAuthModelAliases( + { + codex: [ + { name: 'first-upstream', alias: 'first-client' }, + { name: 'old-upstream', alias: 'gpt-5.6-sol-fast', fork: true }, + { name: 'last-upstream', alias: 'last-client' }, + ], + }, + { + codex: [{ name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast' }], + } + ); + + expect(merged.codex).toEqual([ + { name: 'first-upstream', alias: 'first-client' }, + { name: 'gpt-5.6-sol', alias: 'gpt-5.6-sol-fast' }, + { name: 'last-upstream', alias: 'last-client' }, + ]); + }); + + it('round-trips unknown payload subsections and replaces matching scoped rules', () => { + const existing = parsePayloadSection(` default: + - models: + - name: keep-default + params: + temperature: 0.2 + override: + - models: + - name: gpt-5.6-sol-fast + protocol: codex + params: + service_tier: standard +`); + const configured = { + override: [ + { + models: [{ name: 'gpt-5.6-sol-fast', protocol: 'codex' }], + params: { service_tier: 'priority' }, + }, + ], + }; + + const merged = mergePayloadConfig(existing, configured); + expect(merged?.default).toBeDefined(); + expect(merged?.override).toEqual(configured.override); + expect(parsePayloadSection(serializePayloadSection(merged).replace(/^payload:\n/, ''))).toEqual( + merged + ); + }); + + it('preserves distinct full predicates that select the same model', () => { + const existing = { + override: [ + { + models: [ + { + name: 'gpt-5.6-sol-fast', + protocol: 'codex', + 'from-protocol': 'openai', + }, + ], + headers: { 'x-tenant': 'alpha' }, + match: { project: '^priority-' }, + params: { service_tier: 'standard' }, + }, + { + models: [{ name: 'gpt-5.6-sol-fast', protocol: 'codex' }], + headers: { 'x-tenant': 'beta' }, + 'not-match': { project: '^disabled-' }, + exist: ['metadata.project'], + params: { service_tier: 'standard' }, + }, + ], + }; + const configured = { + override: [ + { + models: [ + { + protocol: 'codex', + 'from-protocol': 'openai', + name: 'gpt-5.6-sol-fast', + }, + ], + match: { project: '^priority-' }, + headers: { 'x-tenant': 'alpha' }, + params: { service_tier: 'priority' }, + }, + ], + }; + + const merged = mergePayloadConfig(existing, configured); + expect(merged?.override).toEqual([configured.override[0], existing.override[1]]); + expect(parsePayloadSection(serializePayloadSection(merged).replace(/^payload:\n/, ''))).toEqual( + merged + ); + }); +}); diff --git a/src/config/schemas/__tests__/cliproxy-user-routing.test.ts b/src/config/schemas/__tests__/cliproxy-user-routing.test.ts new file mode 100644 index 00000000..935ec5d7 --- /dev/null +++ b/src/config/schemas/__tests__/cliproxy-user-routing.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'bun:test'; +import * as yaml from 'js-yaml'; +import { mergeWithDefaults } from '../../loader/defaults-merger'; +import { generateYamlWithComments } from '../../loader/yaml-serializer'; +import type { UnifiedConfig } from '../unified-config'; + +describe('CLIProxy user routing schema', () => { + it('survives default merging and YAML serialization', () => { + const configuredRouting = { + oauth_model_alias: { + codex: [ + { + name: 'gpt-5.6-sol', + alias: 'gpt-5.6-sol-fast', + fork: true, + }, + ], + }, + payload: { + override: [ + { + models: [{ name: 'gpt-5.6-sol-fast', protocol: 'codex' }], + params: { service_tier: 'priority' }, + }, + ], + }, + }; + const merged = mergeWithDefaults({ + version: 14, + cliproxy: configuredRouting, + } as Partial); + const serialized = yaml.load(generateYamlWithComments(merged)) as UnifiedConfig; + + expect(serialized.cliproxy.oauth_model_alias).toEqual(configuredRouting.oauth_model_alias); + expect(serialized.cliproxy.payload).toEqual(configuredRouting.payload); + }); +}); diff --git a/src/config/schemas/__tests__/schemas-reexport.test.ts b/src/config/schemas/__tests__/schemas-reexport.test.ts index 5bdda5d9..41f07a3d 100644 --- a/src/config/schemas/__tests__/schemas-reexport.test.ts +++ b/src/config/schemas/__tests__/schemas-reexport.test.ts @@ -33,6 +33,13 @@ import type { CLIProxyLoggingConfig, CLIProxySafetyConfig, CLIProxyRoutingConfig, + CLIProxyRetryConfig, + CLIProxyPoolRoutingConfig, + CLIProxyOAuthModelAliasEntry, + CLIProxyOAuthModelAliasConfig, + CLIProxyPayloadModelSelector, + CLIProxyPayloadOverrideRule, + CLIProxyPayloadConfig, CLIProxyConfig, AutoQuotaConfig, RuntimeMonitorConfig, @@ -77,12 +84,40 @@ import type { } from '../../unified-config-types'; describe('config schemas backward compatibility', () => { + it('re-exports structured CLIProxy user routing types', () => { + const alias: CLIProxyOAuthModelAliasEntry = { + name: 'gpt-5.6-sol', + alias: 'gpt-5.6-sol-fast', + }; + const aliases: CLIProxyOAuthModelAliasConfig = { codex: [alias] }; + const selector: CLIProxyPayloadModelSelector = { + name: alias.alias, + protocol: 'codex', + 'from-protocol': 'openai', + }; + const rule: CLIProxyPayloadOverrideRule = { + models: [selector], + params: { service_tier: 'priority' }, + headers: { 'x-tenant': 'alpha' }, + }; + const payload: CLIProxyPayloadConfig = { override: [rule] }; + const retry: CLIProxyRetryConfig = { request_retry: 0 }; + const pool: CLIProxyPoolRoutingConfig = { enabled: false }; + + expect({ aliases, payload, retry, pool }).toEqual({ + aliases: { codex: [alias] }, + payload: { override: [rule] }, + retry: { request_retry: 0 }, + pool: { enabled: false }, + }); + }); + // ------------------------------------------------------------------------- // Constants // ------------------------------------------------------------------------- it('re-exports UNIFIED_CONFIG_VERSION', () => { - expect(barrel.UNIFIED_CONFIG_VERSION).toBe(13); - expect(schemas.UNIFIED_CONFIG_VERSION).toBe(13); + expect(barrel.UNIFIED_CONFIG_VERSION).toBe(14); + expect(schemas.UNIFIED_CONFIG_VERSION).toBe(14); }); it('re-exports CLIPROXY_SUPPORTED_PROVIDERS', () => { @@ -129,7 +164,7 @@ describe('config schemas backward compatibility', () => { expect(typeof schemas.createEmptyUnifiedConfig).toBe('function'); const config = barrel.createEmptyUnifiedConfig(); - expect(config.version).toBe(13); + expect(config.version).toBe(14); expect(config.accounts).toEqual({}); expect(config.profiles).toEqual({}); expect(config.cliproxy).toBeDefined(); From 32c8c7b767ab9b2c1fe03dd41b335047393ed806 Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 29 Jul 2026 14:02:52 -0400 Subject: [PATCH 3/3] chore(hardening): refresh routing inventory --- docs/reports/hardening-inventory.json | 16 ++++++++-------- docs/reports/hardening-inventory.md | 8 ++++---- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/reports/hardening-inventory.json b/docs/reports/hardening-inventory.json index d36b40a8..41590117 100644 --- a/docs/reports/hardening-inventory.json +++ b/docs/reports/hardening-inventory.json @@ -1,8 +1,8 @@ { "scope": "src/**/*.{ts,tsx,js,jsx,mjs,cjs}", "syncFs": { - "totalOccurrences": 2382, - "filesAffected": 256, + "totalOccurrences": 2388, + "filesAffected": 257, "hotpathOccurrences": 1103, "hotpathFilesAffected": 151, "topHotpathFiles": [ @@ -573,8 +573,8 @@ }, "loggerCoverage": { "filesWithCreateLogger": 65, - "totalSourceFiles": 754, - "coverageRatio": 0.0862, + "totalSourceFiles": 756, + "coverageRatio": 0.086, "subdomainsWithZeroCreateLogger": [ "api", "bin", @@ -737,6 +737,10 @@ "file": "src/web-server/model-pricing.ts", "loc": 1127 }, + { + "file": "src/cliproxy/config/generator.ts", + "loc": 1109 + }, { "file": "src/cliproxy/auth/oauth-process.ts", "loc": 1048 @@ -749,10 +753,6 @@ "file": "src/web-server/routes/settings-routes.ts", "loc": 1042 }, - { - "file": "src/cliproxy/config/generator.ts", - "loc": 1034 - }, { "file": "src/cliproxy/proxy/tool-sanitization-proxy.ts", "loc": 1020 diff --git a/docs/reports/hardening-inventory.md b/docs/reports/hardening-inventory.md index ef785347..9839ced0 100644 --- a/docs/reports/hardening-inventory.md +++ b/docs/reports/hardening-inventory.md @@ -6,8 +6,8 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | Metric | Value | |---|---:| -| Sync fs occurrences (all) | 2382 | -| Sync fs files affected (all) | 256 | +| Sync fs occurrences (all) | 2388 | +| Sync fs files affected (all) | 257 | | Sync fs occurrences (runtime hotpaths) | 1103 | | Sync fs files affected (runtime hotpaths) | 151 | | Legacy shim markers | 456 | @@ -60,7 +60,7 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | typed-error adoption (P4 locked subdomains) | 91.7% (22/24), target 40% | | hotpath console.error/warn occurrences | 266 (571 total, 305 CLI-UX exempt) | | hotpath console.error/warn files | 82 | -| files with createLogger | 65/754 | +| files with createLogger | 65/756 | | subdomains with zero createLogger | 15 (api, bin, channels, cliproxy, cliproxy/accounts, cliproxy/ai-providers, cliproxy/binary, cliproxy/config, cliproxy/management, cliproxy/sync, cliproxy/types, config, dispatcher, shared, types) | | files > 400 LOC | 91 | | files > 600 LOC | 42 | @@ -94,10 +94,10 @@ Scope: `src/**/*.{ts,tsx,js,jsx,mjs,cjs}` | `src/cliproxy/auth/oauth-handler.ts` | 1467 | | `src/cursor/cursor-executor.ts` | 1234 | | `src/web-server/model-pricing.ts` | 1127 | +| `src/cliproxy/config/generator.ts` | 1109 | | `src/cliproxy/auth/oauth-process.ts` | 1048 | | `src/cliproxy/config/env-builder.ts` | 1045 | | `src/web-server/routes/settings-routes.ts` | 1042 | -| `src/cliproxy/config/generator.ts` | 1034 | | `src/cliproxy/proxy/tool-sanitization-proxy.ts` | 1020 | | `src/commands/cliproxy/variant-subcommand.ts` | 997 | | `src/cliproxy/quota/quota-manager.ts` | 954 |