mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-11 03:13:12 +00:00
feat(cliproxy): persist scoped routing rules
This commit is contained in:
1 parent
a77ee1b91a
commit
9fb2016f90
7 files changed
+425
-17
No files matched your search
@@ -17,6 +17,17 @@ import { CLIPROXY_DEFAULT_PORT } from './port-manager';
|
|||||||
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
|
import { loadOrCreateUnifiedConfig } from '../../config/config-loader-facade';
|
||||||
import { getActiveDockerLegacyApiKeys } from '../../docker/docker-key-rotation';
|
import { getActiveDockerLegacyApiKeys } from '../../docker/docker-key-rotation';
|
||||||
import { isValidCliproxyRetryValue } from '../../config/schemas/cliproxy';
|
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 */
|
/** Internal API key for CCS-managed requests */
|
||||||
export const CCS_INTERNAL_API_KEY = 'ccs-internal-managed';
|
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
|
* 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
|
* 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
|
* 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 =
|
export const ORIGINAL_MANAGEMENT_PANEL_REPOSITORY =
|
||||||
'https://github.com/router-for-me/Cli-Proxy-API-Management-Center';
|
'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.name, 'agy')) return;
|
||||||
if (getDeniedModelIdReasonForProvider(normalized.alias, 'agy')) return;
|
if (getDeniedModelIdReasonForProvider(normalized.alias, 'agy')) return;
|
||||||
|
|
||||||
const key = `${normalized.name}\u0000${normalized.alias}`;
|
const key = normalized.alias;
|
||||||
const existingIndex = indexByKey.get(key);
|
const existingIndex = indexByKey.get(key);
|
||||||
if (existingIndex !== undefined) {
|
if (existingIndex !== undefined) {
|
||||||
if (normalized.fork) entries[existingIndex].fork = true;
|
if (normalized.fork) entries[existingIndex].fork = true;
|
||||||
@@ -307,6 +319,33 @@ function addAliasEntry(
|
|||||||
entries.push(normalized);
|
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 {
|
function buildAntigravityAliasKey(entry: Pick<OAuthModelAliasEntry, 'name' | 'alias'>): string {
|
||||||
return `${sanitizeYamlScalar(entry.name)}\u0000${normalizeAntigravityAlias(entry.alias)}`;
|
return `${sanitizeYamlScalar(entry.name)}\u0000${normalizeAntigravityAlias(entry.alias)}`;
|
||||||
}
|
}
|
||||||
@@ -622,9 +661,13 @@ function writeLegacyGeminiAliasCleanupBackup(configPath: string, existingContent
|
|||||||
* 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,
|
||||||
|
configuredAliases?: CLIProxyOAuthModelAliasConfig
|
||||||
|
): string {
|
||||||
const aliasEntries: OAuthModelAliasEntry[] = [];
|
const aliasEntries: OAuthModelAliasEntry[] = [];
|
||||||
const aliasIndexByKey = new Map<string, number>();
|
const aliasIndexByKey = new Map<string, number>();
|
||||||
|
const normalizedConfiguredAliases = mergeOAuthModelAliases({}, configuredAliases);
|
||||||
|
|
||||||
// Start with default aliases.
|
// Start with default aliases.
|
||||||
for (const alias of DEFAULT_ANTIGRAVITY_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).
|
// Expand lightweight compatibility aliases (dot/hyphen + customtools toggle).
|
||||||
for (const alias of getCompatibilityAliases(aliasEntries)) {
|
for (const alias of getCompatibilityAliases(aliasEntries)) {
|
||||||
addAliasEntry(aliasEntries, aliasIndexByKey, alias);
|
addAliasEntry(aliasEntries, aliasIndexByKey, alias);
|
||||||
@@ -652,7 +700,15 @@ function generateOAuthModelAliasSection(existingAliases?: string): string {
|
|||||||
})
|
})
|
||||||
.join('\n');
|
.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 port - Server port (default: 8317)
|
||||||
* @param userApiKeys - User-added API keys to preserve (default: [])
|
* @param userApiKeys - User-added API keys to preserve (default: [])
|
||||||
* @param existingAliases - Existing oauth-model-alias content to merge with defaults
|
* @param existingAliases - Existing oauth-model-alias content to merge with defaults
|
||||||
|
* @param existingPayload - Existing payload content to merge with structured CCS rules
|
||||||
*/
|
*/
|
||||||
function generateUnifiedConfigContent(
|
function generateUnifiedConfigContent(
|
||||||
port: number = CLIPROXY_DEFAULT_PORT,
|
port: number = CLIPROXY_DEFAULT_PORT,
|
||||||
userApiKeys: string[] = [],
|
userApiKeys: string[] = [],
|
||||||
existingAliases?: string
|
existingAliases?: string,
|
||||||
|
existingPayload?: string
|
||||||
): string {
|
): string {
|
||||||
const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories
|
const authDir = getAuthDir(); // Base auth dir - CLIProxyAPI scans subdirectories
|
||||||
// Convert Windows backslashes to forward slashes for YAML compatibility
|
// Convert Windows backslashes to forward slashes for YAML compatibility
|
||||||
@@ -682,6 +740,10 @@ function generateUnifiedConfigContent(
|
|||||||
const requestRetry = getRequestRetry();
|
const requestRetry = getRequestRetry();
|
||||||
const maxRetryInterval = getMaxRetryInterval();
|
const maxRetryInterval = getMaxRetryInterval();
|
||||||
const managementPanelRepository = getManagementPanelRepository();
|
const managementPanelRepository = getManagementPanelRepository();
|
||||||
|
const userRoutingConfig = loadOrCreateUnifiedConfig().cliproxy;
|
||||||
|
const payloadSection = serializePayloadSection(
|
||||||
|
mergePayloadConfig(parsePayloadSection(existingPayload ?? ''), userRoutingConfig.payload)
|
||||||
|
);
|
||||||
|
|
||||||
// Get effective auth tokens (respects user customization)
|
// Get effective auth tokens (respects user customization)
|
||||||
const effectiveApiKey = getEffectiveApiKey();
|
const effectiveApiKey = getEffectiveApiKey();
|
||||||
@@ -795,7 +857,8 @@ ${apiKeysYaml}
|
|||||||
|
|
||||||
# OAuth tokens directory (auto-discovered by CLIProxyAPI)
|
# OAuth tokens directory (auto-discovered by CLIProxyAPI)
|
||||||
auth-dir: "${authDirNormalized}"
|
auth-dir: "${authDirNormalized}"
|
||||||
${generateOAuthModelAliasSection(existingAliases)}
|
${generateOAuthModelAliasSection(existingAliases, userRoutingConfig.oauth_model_alias)}
|
||||||
|
${payloadSection ? `${payloadSection}\n` : ''}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
@@ -917,6 +980,7 @@ export function regenerateConfig(
|
|||||||
let effectivePort = port;
|
let effectivePort = port;
|
||||||
let userApiKeys: string[] = [];
|
let userApiKeys: string[] = [];
|
||||||
let existingAliases = '';
|
let existingAliases = '';
|
||||||
|
let existingPayload = '';
|
||||||
const preservedSections: PreservedYamlSection[] = [];
|
const preservedSections: PreservedYamlSection[] = [];
|
||||||
|
|
||||||
if (fs.existsSync(configPath)) {
|
if (fs.existsSync(configPath)) {
|
||||||
@@ -945,15 +1009,21 @@ export function regenerateConfig(
|
|||||||
|
|
||||||
// Preserve user customizations while pruning legacy generated Gemini preview noise.
|
// Preserve user customizations while pruning legacy generated Gemini preview noise.
|
||||||
const existingConfigVersion = getConfigVersionFromContent(content);
|
const existingConfigVersion = getConfigVersionFromContent(content);
|
||||||
const preservedAliases = extractPreservedAntigravityAliases(
|
const existingAliasBody = extractYamlSection(content, 'oauth-model-alias');
|
||||||
extractYamlSection(content, 'oauth-model-alias'),
|
const preservedAliases = extractPreservedAntigravityAliases(existingAliasBody, {
|
||||||
{
|
enableLegacyGeminiStaleCleanup:
|
||||||
enableLegacyGeminiStaleCleanup:
|
existingConfigVersion === null ||
|
||||||
existingConfigVersion === null ||
|
existingConfigVersion < LEGACY_GEMINI_STALE_ALIAS_MIGRATION_VERSION,
|
||||||
existingConfigVersion < LEGACY_GEMINI_STALE_ALIAS_MIGRATION_VERSION,
|
});
|
||||||
}
|
const preservedAliasConfig = parseOAuthModelAliasSection(existingAliasBody);
|
||||||
);
|
const preservedAntigravityConfig = parseOAuthModelAliasSection(preservedAliases.yaml);
|
||||||
existingAliases = preservedAliases.yaml;
|
if (preservedAntigravityConfig.antigravity) {
|
||||||
|
preservedAliasConfig.antigravity = preservedAntigravityConfig.antigravity;
|
||||||
|
} else {
|
||||||
|
delete preservedAliasConfig.antigravity;
|
||||||
|
}
|
||||||
|
existingAliases = serializeOAuthModelAliasBody(preservedAliasConfig);
|
||||||
|
existingPayload = extractYamlSection(content, 'payload');
|
||||||
if (preservedAliases.prunedLegacyAliasCount > 0) {
|
if (preservedAliases.prunedLegacyAliasCount > 0) {
|
||||||
writeLegacyGeminiAliasCleanupBackup(configPath, content);
|
writeLegacyGeminiAliasCleanupBackup(configPath, content);
|
||||||
}
|
}
|
||||||
@@ -969,7 +1039,12 @@ export function regenerateConfig(
|
|||||||
fs.mkdirSync(authDir, { recursive: true, mode: 0o700 });
|
fs.mkdirSync(authDir, { recursive: true, mode: 0o700 });
|
||||||
|
|
||||||
// Generate fresh config with preserved user API keys and aliases
|
// 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.
|
// Re-append managed top-level sections that are not part of the generated defaults.
|
||||||
for (const section of preservedSections) {
|
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');
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -3,6 +3,10 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import type { CompositeTierConfig } from '../../config/unified-config-types';
|
import type { CompositeTierConfig } from '../../config/unified-config-types';
|
||||||
|
import type {
|
||||||
|
CLIProxyOAuthModelAliasConfig,
|
||||||
|
CLIProxyPayloadConfig,
|
||||||
|
} from '../../config/schemas/cliproxy';
|
||||||
import type { CliproxyRoutingStrategy } from './provider-types';
|
import type { CliproxyRoutingStrategy } from './provider-types';
|
||||||
|
|
||||||
/** CLIProxy config.yaml structure (minimal) */
|
/** CLIProxy config.yaml structure (minimal) */
|
||||||
@@ -11,6 +15,8 @@ export interface CLIProxyConfig {
|
|||||||
'api-keys': string[];
|
'api-keys': string[];
|
||||||
'auth-dir': string;
|
'auth-dir': string;
|
||||||
debug: boolean;
|
debug: boolean;
|
||||||
|
'oauth-model-alias'?: CLIProxyOAuthModelAliasConfig;
|
||||||
|
payload?: CLIProxyPayloadConfig;
|
||||||
routing?: {
|
routing?: {
|
||||||
strategy?: CliproxyRoutingStrategy;
|
strategy?: CliproxyRoutingStrategy;
|
||||||
'session-affinity'?: boolean;
|
'session-affinity'?: boolean;
|
||||||
|
|||||||
@@ -135,6 +135,50 @@ export interface CLIProxyRetryConfig {
|
|||||||
max_retry_interval?: number;
|
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. */
|
/** Lower bound (inclusive) accepted for CLIProxy retry config fields. */
|
||||||
export const CLIPROXY_RETRY_MIN_VALUE = 0;
|
export const CLIPROXY_RETRY_MIN_VALUE = 0;
|
||||||
|
|
||||||
@@ -217,4 +261,8 @@ export interface CLIProxyConfig {
|
|||||||
pool_routing?: CLIProxyPoolRoutingConfig;
|
pool_routing?: CLIProxyPoolRoutingConfig;
|
||||||
/** Request-retry behavior for transient upstream errors (opt-in, default: disabled) */
|
/** Request-retry behavior for transient upstream errors (opt-in, default: disabled) */
|
||||||
retry?: CLIProxyRetryConfig;
|
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;
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,13 @@ export type {
|
|||||||
CLIProxyLoggingConfig,
|
CLIProxyLoggingConfig,
|
||||||
CLIProxySafetyConfig,
|
CLIProxySafetyConfig,
|
||||||
CLIProxyRoutingConfig,
|
CLIProxyRoutingConfig,
|
||||||
|
CLIProxyRetryConfig,
|
||||||
|
CLIProxyPoolRoutingConfig,
|
||||||
|
CLIProxyOAuthModelAliasEntry,
|
||||||
|
CLIProxyOAuthModelAliasConfig,
|
||||||
|
CLIProxyPayloadModelSelector,
|
||||||
|
CLIProxyPayloadOverrideRule,
|
||||||
|
CLIProxyPayloadConfig,
|
||||||
CLIProxyConfig,
|
CLIProxyConfig,
|
||||||
} from './cliproxy';
|
} from './cliproxy';
|
||||||
|
|
||||||
|
|||||||
@@ -19,5 +19,6 @@
|
|||||||
* Version 11 = Discord Channels runtime auto-enable preferences
|
* Version 11 = Discord Channels runtime auto-enable preferences
|
||||||
* Version 12 = Official Channels multi-provider support (Telegram, Discord, iMessage)
|
* Version 12 = Official Channels multi-provider support (Telegram, Discord, iMessage)
|
||||||
* Version 13 = Browser automation defaults to safe manual/off exposure
|
* 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;
|
||||||
Reference in new issue
Block a user