mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 18:15:48 +00:00
Merge pull request #1678 from kaitranntt/kai/feat/1662-codex-fast-alias
feat(cliproxy): preserve scoped routing rules
This commit is contained in:
13 files changed
+827
-32
No files matched your search
@@ -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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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<string, Array<{ name: string; alias: string; fork?: boolean }>>;
|
||||
payload?: {
|
||||
override?: Array<{
|
||||
models: Array<{ name: string; protocol?: string }>;
|
||||
params: Record<string, unknown>;
|
||||
}>;
|
||||
[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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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 {
|
||||
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;
|
||||
|
||||
@@ -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<UnifiedConfig>);
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -29,6 +29,13 @@ export type {
|
||||
CLIProxyLoggingConfig,
|
||||
CLIProxySafetyConfig,
|
||||
CLIProxyRoutingConfig,
|
||||
CLIProxyRetryConfig,
|
||||
CLIProxyPoolRoutingConfig,
|
||||
CLIProxyOAuthModelAliasEntry,
|
||||
CLIProxyOAuthModelAliasConfig,
|
||||
CLIProxyPayloadModelSelector,
|
||||
CLIProxyPayloadOverrideRule,
|
||||
CLIProxyPayloadConfig,
|
||||
CLIProxyConfig,
|
||||
} from './cliproxy';
|
||||
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user