feat(cliproxy): persist scoped routing rules

This commit is contained in:
Tam Nhu Tran committed 2026-07-29 14:02:40 -04:00
1 parent a77ee1b91a
commit 9fb2016f90
7 files changed
+425 -17

No files matched your search

+91 -16
View File
@@ -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<string, number>,
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<OAuthModelAliasEntry, 'name' | 'alias'>): 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<string, number>();
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) {
@@ -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<string, unknown> {
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');
}
+141
View File
@@ -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<string, unknown> {
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<string, number>();
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();
}
+6
View File
@@ -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;
+48
View File
@@ -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<string, CLIProxyOAuthModelAliasEntry[]>;
/** 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<string, unknown>;
/** 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;
}
+7
View File
@@ -29,6 +29,13 @@ export type {
CLIProxyLoggingConfig,
CLIProxySafetyConfig,
CLIProxyRoutingConfig,
CLIProxyRetryConfig,
CLIProxyPoolRoutingConfig,
CLIProxyOAuthModelAliasEntry,
CLIProxyOAuthModelAliasConfig,
CLIProxyPayloadModelSelector,
CLIProxyPayloadOverrideRule,
CLIProxyPayloadConfig,
CLIProxyConfig,
} from './cliproxy';
+2 -1
View File
@@ -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;