From 2ce3bfca26df80481f87ad2b29c4a8d2835e6e7a Mon Sep 17 00:00:00 2001 From: Tam Nhu Tran Date: Wed, 29 Jul 2026 13:44:17 -0400 Subject: [PATCH] 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();