chore: merge origin/dev into pr-1061 fix branch

This commit is contained in:
Tam Nhu Tran committed 2026-04-22 22:38:20 -04:00
commit 5ff0370c1c
96 files changed
+3128 -624

No files matched your search

@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import getPort from 'get-port';
import * as fs from 'fs';
import * as http from 'http';
import * as os from 'os';
@@ -16,6 +15,29 @@ let tempDir: string;
let originalTimeoutEnv: string | undefined;
let originalCcsHome: string | undefined;
function resolveListeningPort(server: http.Server): number {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to resolve server port');
}
return address.port;
}
async function waitForServerListening(server: http.Server): Promise<number> {
if (server.listening) {
return resolveListeningPort(server);
}
return new Promise<number>((resolve, reject) => {
const onError = (error: Error) => reject(error);
server.once('error', onError);
server.once('listening', () => {
server.off('error', onError);
resolve(resolveListeningPort(server));
});
});
}
async function startUpstream(
handler: (req: http.IncomingMessage, res: http.ServerResponse) => Promise<void> | void
): Promise<void> {
@@ -28,9 +50,8 @@ async function startUpstream(
upstreamSockets.delete(socket);
});
});
await new Promise<void>((resolve) =>
upstreamServer.listen(upstreamPort, '127.0.0.1', () => resolve())
);
upstreamServer.listen(0, '127.0.0.1');
upstreamPort = await waitForServerListening(upstreamServer);
}
async function requestProxy(payload: unknown, signal?: AbortSignal): Promise<Response> {
@@ -46,8 +67,6 @@ async function requestProxy(payload: unknown, signal?: AbortSignal): Promise<Res
}
beforeEach(async () => {
upstreamPort = await getPort();
proxyPort = await getPort();
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-edge-'));
originalTimeoutEnv = process.env.CCS_OPENAI_PROXY_REQUEST_TIMEOUT_MS;
originalCcsHome = process.env.CCS_HOME;
@@ -94,9 +113,10 @@ describe('openai proxy message edge cases', () => {
};
proxyServer = startOpenAICompatProxyServer({
profile,
port: proxyPort,
port: 0,
authToken: 'test-proxy-token',
});
proxyPort = await waitForServerListening(proxyServer);
}
it('preserves rate-limit errors from the upstream provider', async () => {
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import getPort from 'get-port';
import * as http from 'http';
import { startOpenAICompatProxyServer } from '../../../src/proxy/server/proxy-server';
import type { OpenAICompatProfileConfig } from '../../../src/proxy/profile-router';
@@ -10,52 +9,64 @@ let upstreamBody: unknown;
let upstreamPort: number;
let proxyPort: number;
function startMockUpstream(): Promise<void> {
return new Promise((resolve) => {
upstreamServer = http.createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/v1/chat/completions') {
res.writeHead(404).end();
return;
}
function resolveListeningPort(server: http.Server): number {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to resolve server port');
}
return address.port;
}
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
upstreamBody = JSON.parse(body);
const parsed = upstreamBody as {
stream?: boolean;
messages?: Array<{ role?: string; content?: string | Array<{ type?: string; text?: string }> }>;
};
async function waitForServerListening(server: http.Server): Promise<number> {
if (server.listening) {
return resolveListeningPort(server);
}
if (parsed.stream) {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
return new Promise<number>((resolve, reject) => {
const onError = (error: Error) => reject(error);
server.once('error', onError);
server.once('listening', () => {
server.off('error', onError);
resolve(resolveListeningPort(server));
});
});
}
if (parsed.messages?.[0]?.content === 'interleaved tool fragments') {
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_2","type":"function","function":{"name":"open"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\":\\"a.ts\\"}"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"path\\":\\"b.ts\\"}"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n'
);
res.end('data: [DONE]\n\n');
return;
}
async function startMockUpstream(): Promise<void> {
upstreamServer = http.createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== '/v1/chat/completions') {
res.writeHead(404).end();
return;
}
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
upstreamBody = JSON.parse(body);
const parsed = upstreamBody as {
stream?: boolean;
messages?: Array<{
role?: string;
content?: string | Array<{ type?: string; text?: string }>;
}>;
};
if (parsed.stream) {
res.writeHead(200, { 'Content-Type': 'text/event-stream' });
if (parsed.messages?.[0]?.content === 'interleaved tool fragments') {
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\n\n'
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"docs\\"}"}}]}}]}\n\n'
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"id":"call_2","type":"function","function":{"name":"open"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"function":{"arguments":"{\\"path\\":\\"a.ts\\"}"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":1,"function":{"arguments":"{\\"path\\":\\"b.ts\\"}"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n'
@@ -64,25 +75,38 @@ function startMockUpstream(): Promise<void> {
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
id: 'chatcmpl_1',
model: 'hf-model',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Plain answer' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 2, completion_tokens: 3 },
})
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"role":"assistant","content":"Hello"}}]}\n\n'
);
});
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{"tool_calls":[{"index":0,"id":"call_1","type":"function","function":{"name":"search","arguments":"{\\"q\\":\\"docs\\"}"}}]}}]}\n\n'
);
res.write(
'data: {"id":"chatcmpl_1","model":"hf-model","choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":9,"completion_tokens":4}}\n\n'
);
res.end('data: [DONE]\n\n');
return;
}
upstreamServer.listen(upstreamPort, '127.0.0.1', () => resolve());
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
id: 'chatcmpl_1',
model: 'hf-model',
choices: [
{
index: 0,
message: { role: 'assistant', content: 'Plain answer' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 2, completion_tokens: 3 },
})
);
});
upstreamServer.listen(0, '127.0.0.1');
upstreamPort = await waitForServerListening(upstreamServer);
}
async function requestProxy(payload: unknown): Promise<Response> {
@@ -98,8 +122,6 @@ async function requestProxy(payload: unknown): Promise<Response> {
}
beforeEach(async () => {
upstreamPort = await getPort();
proxyPort = await getPort();
upstreamBody = undefined;
await startMockUpstream();
const profile: OpenAICompatProfileConfig = {
@@ -112,9 +134,10 @@ beforeEach(async () => {
};
proxyServer = startOpenAICompatProxyServer({
profile,
port: proxyPort,
port: 0,
authToken: 'test-proxy-token',
});
proxyPort = await waitForServerListening(proxyServer);
});
afterEach(async () => {
+63 -46
View File
@@ -1,5 +1,4 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import getPort from 'get-port';
import * as fs from 'fs';
import * as http from 'http';
import * as os from 'os';
@@ -13,40 +12,61 @@ let proxyServer: http.Server;
let upstreamServers: http.Server[] = [];
let proxyPort: number;
function startMockUpstream(
port: number,
function resolveListeningPort(server: http.Server): number {
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Failed to resolve server port');
}
return address.port;
}
async function waitForServerListening(server: http.Server): Promise<number> {
if (server.listening) {
return resolveListeningPort(server);
}
return new Promise<number>((resolve, reject) => {
const onError = (error: Error) => reject(error);
server.once('error', onError);
server.once('listening', () => {
server.off('error', onError);
resolve(resolveListeningPort(server));
});
});
}
async function startMockUpstream(
hitLabel: string,
hits: string[],
bodies: Array<{ label: string; body: unknown }>
): Promise<void> {
return new Promise((resolve) => {
const server = http.createServer(async (req, res) => {
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
hits.push(hitLabel);
bodies.push({ label: hitLabel, body: JSON.parse(body) });
): Promise<number> {
const server = http.createServer(async (req, res) => {
let body = '';
for await (const chunk of req) {
body += chunk.toString();
}
hits.push(hitLabel);
bodies.push({ label: hitLabel, body: JSON.parse(body) });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
id: `chatcmpl_${hitLabel}`,
model: hitLabel,
choices: [
{
index: 0,
message: { role: 'assistant', content: `Reply from ${hitLabel}` },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 2, completion_tokens: 3 },
})
);
});
upstreamServers.push(server);
server.listen(port, '127.0.0.1', () => resolve());
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
id: `chatcmpl_${hitLabel}`,
model: hitLabel,
choices: [
{
index: 0,
message: { role: 'assistant', content: `Reply from ${hitLabel}` },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 2, completion_tokens: 3 },
})
);
});
upstreamServers.push(server);
server.listen(0, '127.0.0.1');
return waitForServerListening(server);
}
function writeSettings(profileName: string, env: Record<string, string>): string {
@@ -71,7 +91,7 @@ beforeEach(async () => {
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-routing-'));
fs.mkdirSync(path.join(tempDir, '.ccs'), { recursive: true });
process.env.CCS_HOME = tempDir;
proxyPort = await getPort();
proxyPort = 0;
});
afterEach(async () => {
@@ -97,12 +117,10 @@ afterEach(async () => {
describe('openai proxy request routing', () => {
it('routes explicit profile:model selectors to the matching upstream profile', async () => {
const primaryPort = await getPort();
const secondaryPort = await getPort();
const hits: string[] = [];
const bodies: Array<{ label: string; body: unknown }> = [];
await startMockUpstream(primaryPort, 'primary', hits, bodies);
await startMockUpstream(secondaryPort, 'secondary', hits, bodies);
const primaryPort = await startMockUpstream('primary', hits, bodies);
const secondaryPort = await startMockUpstream('secondary', hits, bodies);
const primarySettings = writeSettings('hf', {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`,
@@ -133,9 +151,10 @@ describe('openai proxy request routing', () => {
};
proxyServer = startOpenAICompatProxyServer({
profile,
port: proxyPort,
port: 0,
authToken: 'test-proxy-token',
});
proxyPort = await waitForServerListening(proxyServer);
const response = await requestProxy({
model: 'deepseek:deepseek-reasoner',
@@ -150,12 +169,10 @@ describe('openai proxy request routing', () => {
});
it('routes thinking requests through the configured think scenario', async () => {
const primaryPort = await getPort();
const thinkPort = await getPort();
const hits: string[] = [];
const bodies: Array<{ label: string; body: unknown }> = [];
await startMockUpstream(primaryPort, 'primary', hits, bodies);
await startMockUpstream(thinkPort, 'thinker', hits, bodies);
const primaryPort = await startMockUpstream('primary', hits, bodies);
const thinkPort = await startMockUpstream('thinker', hits, bodies);
const primarySettings = writeSettings('hf', {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`,
@@ -197,9 +214,10 @@ describe('openai proxy request routing', () => {
};
proxyServer = startOpenAICompatProxyServer({
profile,
port: proxyPort,
port: 0,
authToken: 'test-proxy-token',
});
proxyPort = await waitForServerListening(proxyServer);
const response = await requestProxy({
model: 'hf-default',
@@ -216,12 +234,10 @@ describe('openai proxy request routing', () => {
});
it('routes adaptive thinking requests through the configured think scenario', async () => {
const primaryPort = await getPort();
const thinkPort = await getPort();
const hits: string[] = [];
const bodies: Array<{ label: string; body: unknown }> = [];
await startMockUpstream(primaryPort, 'primary', hits, bodies);
await startMockUpstream(thinkPort, 'thinker', hits, bodies);
const primaryPort = await startMockUpstream('primary', hits, bodies);
const thinkPort = await startMockUpstream('thinker', hits, bodies);
const primarySettings = writeSettings('hf', {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${primaryPort}`,
@@ -263,9 +279,10 @@ describe('openai proxy request routing', () => {
};
proxyServer = startOpenAICompatProxyServer({
profile,
port: proxyPort,
port: 0,
authToken: 'test-proxy-token',
});
proxyPort = await waitForServerListening(proxyServer);
const response = await requestProxy({
model: 'hf-default',
@@ -17,6 +17,8 @@ import {
handleQuotaExhaustion,
writeQuotaWarning,
maskEmail,
pauseAccountForQuotaCooldown,
restoreExpiredQuotaPauses,
} from '../../../src/cliproxy/account-safety';
import { sanitizeEmail } from '../../../src/cliproxy/auth-utils';
@@ -84,6 +86,12 @@ function writeClaudeAuth(accountId: string, accessToken: string): void {
);
}
function writeAuthToken(tokenFile: string, payload: Record<string, unknown>): void {
const authDir = path.join(tmpDir, '.ccs', 'cliproxy', 'auth');
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(payload, null, 2));
}
describe('Quota Exhaustion Handlers', () => {
describe('writeQuotaWarning', () => {
it('should write to stderr with box format', async () => {
@@ -247,10 +255,13 @@ describe('Quota Exhaustion Handlers', () => {
});
const result = await handleQuotaExhaustion('agy', 'only@gmail.com', 10);
const { getAccount } = await import('../../../src/cliproxy/account-manager');
// Should return gracefully with null switched
expect(result.switchedTo).toBeNull();
expect(result.reason).toContain('no alternatives');
expect(getAccount('agy', 'only@gmail.com')?.paused).not.toBe(true);
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false);
});
it('should switch Claude accounts when fallback quota endpoint returns 404', async () => {
@@ -305,6 +316,18 @@ describe('Quota Exhaustion Handlers', () => {
expect(result.switchedTo).toBe('fallback@example.com');
expect(getDefaultAccount('claude')?.id).toBe('fallback@example.com');
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(true);
expect(
fs.existsSync(
path.join(
tmpDir,
'.ccs',
'cliproxy',
'auth-paused',
`claude-${sanitizeEmail('exhausted@example.com')}.json`
)
)
).toBe(true);
});
it('should write warning to stderr', async () => {
@@ -389,5 +412,121 @@ describe('Quota Exhaustion Handlers', () => {
expect(result).toBeDefined();
expect(result.switchedTo).toBeNull();
});
it('auto-resumes quota-paused accounts after cooldown expiry', async () => {
writeRegistry({
agy: {
default: 'cooldown@gmail.com',
accounts: {
'cooldown@gmail.com': {
email: 'cooldown@gmail.com',
tokenFile: 'agy-cooldown.json',
},
},
},
});
writeAuthToken('agy-cooldown.json', {
type: 'agy',
email: 'cooldown@gmail.com',
access_token: 'token',
});
const now = Date.now();
expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true);
const { getAccount } = await import('../../../src/cliproxy/account-manager');
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true);
const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000);
expect(resumed).toBe(1);
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).not.toBe(true);
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false);
});
it('does not auto-resume quota-paused accounts when pausedAt metadata is missing', async () => {
writeRegistry({
agy: {
default: 'cooldown@gmail.com',
accounts: {
'cooldown@gmail.com': {
email: 'cooldown@gmail.com',
tokenFile: 'agy-cooldown.json',
},
},
},
});
writeAuthToken('agy-cooldown.json', {
type: 'agy',
email: 'cooldown@gmail.com',
access_token: 'token',
});
const now = Date.now();
expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true);
const registryPath = path.join(tmpDir, '.ccs', 'cliproxy', 'accounts.json');
const registry = JSON.parse(fs.readFileSync(registryPath, 'utf8')) as {
providers: {
agy: {
default: string;
accounts: Record<string, { paused?: boolean; pausedAt?: string }>;
};
};
};
delete registry.providers.agy.accounts['cooldown@gmail.com']?.pausedAt;
fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2));
const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000);
const { getAccount } = await import('../../../src/cliproxy/account-manager');
expect(resumed).toBe(0);
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true);
expect(
fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'agy-cooldown.json'))
).toBe(true);
expect(fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json'))).toBe(false);
});
it('keeps quota-paused entries when auto-resume fails and retries later', async () => {
writeRegistry({
agy: {
default: 'cooldown@gmail.com',
accounts: {
'cooldown@gmail.com': {
email: 'cooldown@gmail.com',
tokenFile: 'agy-cooldown.json',
},
},
},
});
writeAuthToken('agy-cooldown.json', {
type: 'agy',
email: 'cooldown@gmail.com',
access_token: 'token',
});
const now = Date.now();
expect(pauseAccountForQuotaCooldown('agy', 'cooldown@gmail.com', 5, now)).toBe(true);
fs.rmSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth'), {
recursive: true,
force: true,
});
const resumed = restoreExpiredQuotaPauses(now + 6 * 60 * 1000);
const { getAccount } = await import('../../../src/cliproxy/account-manager');
const quotaPausedPath = path.join(tmpDir, '.ccs', 'cliproxy', 'quota-paused.json');
const quotaPaused = JSON.parse(fs.readFileSync(quotaPausedPath, 'utf8')) as {
entries?: Array<{ accountId?: string }>;
};
expect(resumed).toBe(0);
expect(getAccount('agy', 'cooldown@gmail.com')?.paused).toBe(true);
expect(
fs.existsSync(path.join(tmpDir, '.ccs', 'cliproxy', 'auth-paused', 'agy-cooldown.json'))
).toBe(true);
expect(quotaPaused.entries?.map((entry) => entry.accountId)).toContain('cooldown@gmail.com');
});
});
});
+11 -8
View File
@@ -28,8 +28,8 @@ describe('Backend Selection', () => {
});
describe('DEFAULT_BACKEND', () => {
it('defaults to plus backend for backward compatibility', () => {
assert.strictEqual(platformDetector.DEFAULT_BACKEND, 'plus');
it('defaults to original backend (Plus upstream deleted, issue #1062)', () => {
assert.strictEqual(platformDetector.DEFAULT_BACKEND, 'original');
});
});
@@ -45,9 +45,10 @@ describe('Backend Selection', () => {
assert(info.binaryName.startsWith('CLIProxyAPIPlus_6.6.51-0_'));
});
it('uses plus backend by default', () => {
it('uses original backend by default (Plus upstream deleted, issue #1062)', () => {
const info = platformDetector.detectPlatform();
assert(info.binaryName.includes('CLIProxyAPIPlus'));
assert(info.binaryName.startsWith('CLIProxyAPI_'));
assert(!info.binaryName.includes('CLIProxyAPIPlus'));
});
it('uses fallback version when version not specified', () => {
@@ -76,9 +77,10 @@ describe('Backend Selection', () => {
assert.strictEqual(name, expected);
});
it('defaults to plus backend', () => {
it('defaults to original backend (Plus upstream deleted, issue #1062)', () => {
const name = platformDetector.getExecutableName();
assert(name.includes('cli-proxy-api-plus'));
const expected = isWindows ? 'cli-proxy-api.exe' : 'cli-proxy-api';
assert.strictEqual(name, expected);
});
});
@@ -94,9 +96,10 @@ describe('Backend Selection', () => {
assert(url.includes('router-for-me/CLIProxyAPIPlus/releases'));
});
it('defaults to plus backend', () => {
it('defaults to original backend (Plus upstream deleted, issue #1062)', () => {
const url = platformDetector.getDownloadUrl();
assert(url.includes('CLIProxyAPIPlus'));
assert(url.includes('router-for-me/CLIProxyAPI/releases'));
assert(!url.includes('CLIProxyAPIPlus'));
});
});
@@ -19,9 +19,12 @@ import {
} from '../../../ui/src/lib/default-ports';
import {
CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS,
CORE_CLIPROXY_PROVIDERS as UI_CORE_CLIPROXY_PROVIDERS,
DEVICE_CODE_PROVIDERS as UI_DEVICE_CODE_PROVIDERS,
PLUS_EXTRA_CLIPROXY_PROVIDERS as UI_PLUS_EXTRA_CLIPROXY_PROVIDERS,
PROVIDER_METADATA as UI_PROVIDER_METADATA,
} from '../../../ui/src/lib/provider-config';
import { PLUS_ONLY_PROVIDERS as BACKEND_PLUS_ONLY_PROVIDERS } from '../../../src/cliproxy/types';
function sorted(values: readonly string[]): string[] {
return [...values].sort((a, b) => a.localeCompare(b));
@@ -44,6 +47,17 @@ describe('Default Port Sync', () => {
expect(sorted(UI_DEVICE_CODE_PROVIDERS)).toEqual(sorted(getProvidersByOAuthFlow('device_code')));
});
test('plus-extra providers are synced between backend and UI', () => {
expect(sorted(UI_PLUS_EXTRA_CLIPROXY_PROVIDERS)).toEqual(sorted(BACKEND_PLUS_ONLY_PROVIDERS));
expect(sorted(UI_CORE_CLIPROXY_PROVIDERS)).toEqual(
sorted(
BACKEND_CLIPROXY_PROVIDER_IDS.filter(
(provider) => !BACKEND_PLUS_ONLY_PROVIDERS.includes(provider)
)
)
);
});
test('Provider display names are synced between backend and UI', () => {
for (const provider of BACKEND_CLIPROXY_PROVIDER_IDS) {
expect(UI_PROVIDER_METADATA[provider].displayName).toBe(getBackendProviderDisplayName(provider));
@@ -25,6 +25,80 @@ afterEach(() => {
});
describe('installCliproxyVersion', () => {
it('degrades explicit plus backend requests to original before install flows run', async () => {
let seenBackend: string | undefined;
const binaryManager = await import(
`../../../src/cliproxy/binary-manager?binary-manager-explicit-plus=${Date.now()}`
);
await binaryManager.installCliproxyVersion('6.7.1', false, 'plus', {
createManager: (_config: unknown, backend: string) => {
seenBackend = backend;
return {
isBinaryInstalled: () => false,
deleteBinary: () => undefined,
ensureBinary: async () => '/tmp/ccs-bin/original/cliproxy',
};
},
stopProxyFn: async () => ({ stopped: false, error: 'No active CLIProxy session found' }),
waitForPortFreeFn: async () => true,
formatInfo: (message: string) => message,
formatWarn: (message: string) => message,
getInstalledVersion: () => '6.6.80',
});
expect(seenBackend).toBe('original');
});
it('returns original and emits a real warning when plus backend is resolved locally', async () => {
const binaryManager = await import(
`../../../src/cliproxy/binary-manager?binary-manager-warning=${Date.now()}`
);
const writes: string[] = [];
const originalWrite = process.stderr.write.bind(process.stderr);
process.stderr.write = ((chunk: string | Uint8Array) => {
writes.push(typeof chunk === 'string' ? chunk : Buffer.from(chunk).toString('utf8'));
return true;
}) as typeof process.stderr.write;
try {
expect(binaryManager.resolveLocalBackend('plus', { warnOnFallback: true })).toBe('original');
} finally {
process.stderr.write = originalWrite;
}
expect(writes.join('')).toContain('CLIProxyAPIPlus upstream repo is currently unavailable');
expect(writes.join('')).toContain('backend: original');
});
it('reuses plus binary and pin state when local runtime falls back to original', async () => {
const { createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types');
const { saveUnifiedConfig } = await import('../../../src/config/unified-config-loader');
const { savePinnedVersion } = await import('../../../src/cliproxy/binary/version-cache');
const { getExecutableName } = await import('../../../src/cliproxy/platform-detector');
const binaryService = await import(
`../../../src/cliproxy/services/binary-service?binary-service-plus-migration=${Date.now()}`
);
const config = createEmptyUnifiedConfig();
config.cliproxy = { ...config.cliproxy, backend: 'plus' };
saveUnifiedConfig(config);
const plusBinDir = path.join(tempHome, '.ccs', 'cliproxy', 'bin', 'plus');
fs.mkdirSync(plusBinDir, { recursive: true });
fs.writeFileSync(path.join(plusBinDir, getExecutableName('plus')), 'fake-binary');
fs.writeFileSync(path.join(plusBinDir, '.version'), '6.6.80-0');
savePinnedVersion('6.6.80-0', 'plus');
const status = binaryService.getBinaryStatus();
expect(status.installed).toBe(true);
expect(status.pinnedVersion).toBe('6.6.80-0');
expect(status.binaryPath).toContain('/original/');
});
it('attempts to stop the proxy even when there is no tracked running session', async () => {
const calls = {
stopProxy: 0,
@@ -78,7 +152,7 @@ describe('installCliproxyVersion', () => {
skipAutoUpdate: true,
})
).rejects.toThrow(
'CLIProxy Plus binary is not installed locally. Run "ccs cliproxy install" when you have network access.'
'CLIProxy binary is not installed locally. Run "ccs cliproxy install" when you have network access.'
);
});
});
@@ -538,6 +538,20 @@ describe('applyThinkingConfig - composite variant integration', () => {
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking(32768)');
});
it('rewrites legacy budget suffixes for claude level-based models', () => {
const envVars: NodeJS.ProcessEnv = {
ANTHROPIC_MODEL: 'claude-opus-4-7(32768)',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-7(32768)',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
};
const result = applyThinkingConfig(envVars, 'claude' as CLIProxyProvider, 'max');
expect(result.ANTHROPIC_MODEL).toBe('claude-opus-4-7(max)');
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-7(max)');
});
it('should use codex effort suffix style when provider is codex', () => {
const envVars: NodeJS.ProcessEnv = {
ANTHROPIC_MODEL: 'gpt-5.3-codex',
@@ -40,7 +40,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
fs.rmSync(tempHome, { recursive: true, force: true });
});
it('rewrites local root URL to provider endpoint', () => {
it('rewrites local root URL to provider endpoint without stripping codex effort suffixes', () => {
writeSettings(settingsPath, {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
@@ -52,18 +52,18 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex');
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
env: Record<string, string>;
};
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
});
it('rewrites wrong local provider path to the requested provider', () => {
@@ -162,7 +162,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
expect(persisted.presets?.[0]?.haiku).toBe('claude-sonnet-4-6');
});
it('migrates codex preset model mappings to canonical IDs', () => {
it('preserves codex preset effort suffixes while loading provider env vars', () => {
writeSettings(
settingsPath,
{
@@ -191,10 +191,10 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf-8')) as {
presets: Array<Record<string, string>>;
};
expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex');
expect(persisted.presets[0]?.opus).toBe('gpt-5.3-codex');
expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex');
expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini');
expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex-xhigh');
expect(persisted.presets[0]?.opus).toBe('gpt-5.3-codex-xhigh');
expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex-high');
expect(persisted.presets[0]?.haiku).toBe('gpt-5-mini-medium');
});
it('migrates iflow placeholder model IDs to a supported default', () => {
@@ -424,7 +424,7 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
expect(repaired.presets?.[0]?.haiku).toBe('claude-sonnet-4-6');
});
it('migrates codex effort-suffixed preset IDs during ensureProviderSettings', () => {
it('preserves codex effort-suffixed IDs during ensureProviderSettings', () => {
process.env.CCS_HOME = tempHome;
const codexSettingsPath = path.join(tempHome, '.ccs', 'codex.settings.json');
fs.mkdirSync(path.dirname(codexSettingsPath), { recursive: true });
@@ -461,14 +461,14 @@ describe('getEffectiveEnvVars local provider URL normalization', () => {
env?: Record<string, string>;
presets?: Array<Record<string, string>>;
};
expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex');
expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex');
expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex');
expect(repaired.presets?.[0]?.haiku).toBe('gpt-5.4-mini');
expect(repaired.env?.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(repaired.env?.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(repaired.env?.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
expect(repaired.env?.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
expect(repaired.presets?.[0]?.default).toBe('gpt-5.3-codex-xhigh');
expect(repaired.presets?.[0]?.opus).toBe('gpt-5.3-codex-xhigh');
expect(repaired.presets?.[0]?.sonnet).toBe('gpt-5.3-codex-high');
expect(repaired.presets?.[0]?.haiku).toBe('gpt-5.4-mini-medium');
});
it('recovers malformed provider settings files by writing defaults and backup copy', () => {
@@ -58,4 +58,24 @@ describe('model-catalog compatibility lookups', () => {
expect(catalog?.models.map((model) => model.id)).toEqual(['gemini-2.5-pro']);
});
it('preserves static maxLevel when live thinking metadata omits it', () => {
const catalog = mergeCatalog('claude', [
{
id: 'claude-opus-4-7',
display_name: 'Claude Opus 4.7',
thinking: {
levels: ['low', 'medium', 'high', 'xhigh', 'max'],
dynamic_allowed: true,
},
},
]);
expect(catalog?.models[0]?.thinking).toMatchObject({
type: 'levels',
levels: ['low', 'medium', 'high', 'xhigh', 'max'],
maxLevel: 'max',
dynamicAllowed: true,
});
});
});
+5 -3
View File
@@ -147,9 +147,11 @@ describe('Model Catalog', () => {
const opus47 = MODEL_CATALOG.claude.models.find((m) => m.id === 'claude-opus-4-7');
assert(opus47, 'Should include Claude Opus 4.7');
assert.strictEqual(opus47.name, 'Claude Opus 4.7');
// Claude provider differs from AGY: zero thinking budget is disallowed,
// and extended (1M) context is available on the Anthropic API.
assert.strictEqual(opus47.thinking.zeroAllowed, false);
// Opus 4.7 requires adaptive thinking (type: 'levels'); manual budget_tokens
// is rejected by the Anthropic API with 400. Extended (1M) context is available.
assert.strictEqual(opus47.thinking.type, 'levels');
assert.deepStrictEqual(opus47.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']);
assert.strictEqual(opus47.thinking.maxLevel, 'max');
assert.strictEqual(opus47.extendedContext, true);
});
@@ -83,7 +83,9 @@ describe('model-id-normalizer', () => {
});
it('applies provider canonicalization for codex and antigravity', () => {
expect(canonicalizeModelIdForProvider('gpt-5.3-codex-xhigh', 'codex')).toBe('gpt-5.3-codex');
expect(canonicalizeModelIdForProvider('gpt-5.3-codex-xhigh', 'codex')).toBe(
'gpt-5.3-codex-xhigh'
);
expect(canonicalizeModelIdForProvider('claude-sonnet-4.6-thinking', 'agy')).toBe(
'claude-sonnet-4-6'
);
@@ -94,7 +96,7 @@ describe('model-id-normalizer', () => {
it('trims and canonicalizes provider model IDs with surrounding whitespace', () => {
expect(canonicalizeModelIdForProvider(' gpt-5.3-codex-high ', 'codex')).toBe(
'gpt-5.3-codex'
'gpt-5.3-codex-high'
);
expect(canonicalizeModelIdForProvider(' claude-sonnet-4.6-thinking ', 'agy')).toBe(
'claude-sonnet-4-6'
@@ -120,9 +122,11 @@ describe('model-id-normalizer', () => {
it('normalizes legacy codex aliases to the current supported model IDs', () => {
expect(normalizeCodexLegacyModelAliases('gpt-5-codex')).toBe('gpt-5.4');
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-mini[1m]')).toBe('gpt-5.4-mini[1m]');
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high')).toBe('gpt-5.4-high');
expect(normalizeCodexLegacyModelAliases('gpt-5-codex-high[1m]')).toBe('gpt-5.4-high[1m]');
expect(normalizeModelIdForProvider('gpt-5.2-codex', 'codex')).toBe('gpt-5.2');
expect(normalizeModelIdForProvider('gpt-5.1-codex-mini', 'codex')).toBe('gpt-5.4-mini');
expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4');
expect(canonicalizeModelIdForProvider('gpt-5-codex-high', 'codex')).toBe('gpt-5.4-high');
});
});
@@ -29,6 +29,7 @@ describe('Thinking Validator', () => {
expect(VALID_THINKING_LEVELS).toContain('medium');
expect(VALID_THINKING_LEVELS).toContain('high');
expect(VALID_THINKING_LEVELS).toContain('xhigh');
expect(VALID_THINKING_LEVELS).toContain('max');
expect(VALID_THINKING_LEVELS).toContain('auto');
});
@@ -38,6 +39,24 @@ describe('Thinking Validator', () => {
expect(THINKING_LEVEL_BUDGETS.medium).toBe(8192);
expect(THINKING_LEVEL_BUDGETS.high).toBe(24576);
expect(THINKING_LEVEL_BUDGETS.xhigh).toBe(32768);
// `max` sits above xhigh — unconstrained thinking on Opus 4.7 / Mythos
expect(THINKING_LEVEL_BUDGETS.max).toBeGreaterThan(THINKING_LEVEL_BUDGETS.xhigh);
});
it('should treat max as a distinct top tier on models that list it (Opus 4.7)', () => {
// Opus 4.7 exposes both xhigh and max; max must not collapse into xhigh.
const result = validateThinking('claude', 'claude-opus-4-7', 'max');
expect(result.valid).toBe(true);
expect(result.value).toBe('max');
expect(result.warning).toBeUndefined();
});
it('should still alias max -> xhigh for models without a max level (backcompat)', () => {
// Codex catalog uses ['low','medium','high','xhigh'] with maxLevel 'xhigh'.
// User input "max" should map down to xhigh rather than be rejected.
const result = validateThinking('codex', 'gpt-5.4', 'max');
expect(result.valid).toBe(true);
expect(result.value).toBe('xhigh');
});
it('should export off values', () => {
@@ -6,7 +6,10 @@ import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { updateVariant } from '../../../src/cliproxy/services/variant-service';
import {
updateVariant,
validateProviderBackend,
} from '../../../src/cliproxy/services/variant-service';
import { loadOrCreateUnifiedConfig } from '../../../src/config/unified-config-loader';
describe('updateVariant - provider/model consistency', () => {
@@ -50,6 +53,7 @@ preferences:
telemetry: false
auto_update: true
cliproxy:
backend: plus
oauth_accounts: {}
providers:
- gemini
@@ -92,6 +96,25 @@ cliproxy:
expect(result.error).toContain('denylist');
});
it('reports plus-only providers as temporarily unavailable on local CLIProxy', () => {
const error = validateProviderBackend('ghcp');
expect(error).toContain('currently supports only `backend: original`');
expect(error).toContain('issues/1062');
});
it('leaves the settings file unchanged when a plus-only provider update is rejected', () => {
const settingsPath = path.join(tmpDir, 'gemini-demo.settings.json');
const before = fs.readFileSync(settingsPath, 'utf-8');
const result = updateVariant('demo', {
provider: 'ghcp',
model: 'gpt-5.4-mini',
});
expect(result.success).toBe(false);
expect(fs.readFileSync(settingsPath, 'utf-8')).toBe(before);
});
it('updates provider and regenerates provider-specific core env in same settings file', () => {
const result = updateVariant('demo', {
provider: 'codex',
@@ -35,6 +35,7 @@ describe('config thinking override normalization', () => {
it('accepts valid levels', () => {
expect(parseThinkingOverrideInput('High')).toEqual({ value: 'high' });
expect(parseThinkingOverrideInput('Max')).toEqual({ value: 'max' });
});
it('validates numeric bounds', () => {
@@ -90,6 +90,16 @@ describe('translateAnthropicRequest', () => {
]);
});
it('maps adaptive anthropic thinking into Cursor reasoning effort', () => {
const translated = translateAnthropicRequest({
thinking: { type: 'adaptive' },
output_config: { effort: 'xhigh' },
messages: [{ role: 'user', content: 'hello' }],
});
expect(translated.reasoning_effort).toBe('high');
});
it('preserves mixed user text around tool_result blocks in order', () => {
const translated = translateAnthropicRequest({
messages: [
@@ -30,6 +30,17 @@ describe('ProxyRequestTransformer regressions', () => {
expect(result.reasoning).toEqual({ enabled: true, effort: 'high' });
});
it('explicitly normalizes anthropic xhigh adaptive effort for OpenAI-compatible upstreams', () => {
const result = new ProxyRequestTransformer().transform({
messages: [{ role: 'user', content: 'hello' }],
thinking: { type: 'adaptive' },
output_config: { effort: 'xhigh' },
});
expect(result.reasoning_effort).toBe('high');
expect(result.reasoning).toEqual({ enabled: true, effort: 'high' });
});
it('rejects unsupported thinking types instead of silently dropping them', () => {
expect(() =>
new ProxyRequestTransformer().transform({
@@ -0,0 +1,36 @@
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
function resolvePath(relativePath: string) {
return path.resolve(import.meta.dir, relativePath);
}
describe('pr ci workflow', () => {
test('keeps full coverage on pull requests', () => {
const workflowPath = resolvePath('../../../../.github/workflows/ci.yml');
expect(fs.existsSync(workflowPath)).toBe(true);
const workflow = fs.readFileSync(workflowPath, 'utf8');
expect(workflow).toContain('name: CI');
expect(workflow).toContain('pull_request:');
expect(workflow).toContain('branches: [main, dev]');
expect(workflow).toContain('group: ci-${{ github.ref }}');
expect(workflow).toContain('cancel-in-progress: true');
expect(workflow).toContain('fail-fast: false');
expect(workflow).toContain('runs-on: [self-hosted, linux, x64]');
expect(workflow).toContain("cmd: 'bun run typecheck'");
expect(workflow).toContain("cmd: 'bun run lint'");
expect(workflow).toContain("cmd: 'bun run format:check'");
expect(workflow).toContain("key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}");
expect(workflow).not.toContain('restore-keys:');
expect(workflow).toContain('name: dist');
expect(workflow).toContain('path: dist/');
expect(workflow).toContain('needs: [build]');
expect(workflow).toContain('run: bun run test:all');
expect(workflow).toContain("CCS_E2E_SKIP_BUILD: '1'");
expect(workflow).toContain('run: bun run test:e2e');
});
});
@@ -0,0 +1,34 @@
import { describe, expect, test } from 'bun:test';
import * as fs from 'node:fs';
import * as path from 'node:path';
function resolvePath(relativePath: string) {
return path.resolve(import.meta.dir, relativePath);
}
describe('push ci workflow', () => {
test('keeps dev push quality checks separate from release automation', () => {
const workflowPath = resolvePath('../../../../.github/workflows/push-ci.yml');
expect(fs.existsSync(workflowPath)).toBe(true);
const workflow = fs.readFileSync(workflowPath, 'utf8');
expect(workflow).toContain('name: Push CI');
expect(workflow).toContain('push:');
expect(workflow).toContain('branches: [dev]');
expect(workflow).toContain('group: push-ci-${{ github.ref }}');
expect(workflow).toContain('cancel-in-progress: true');
expect(workflow).toContain('runs-on: [self-hosted, linux, x64]');
expect(workflow).toContain("key: ${{ runner.os }}-bun-cache-v2-${{ hashFiles('bun.lock', 'ui/bun.lock') }}");
expect(workflow).not.toContain('restore-keys:');
expect(workflow).toContain("name: ${{ matrix.check.name }}");
expect(workflow).toContain("cmd: 'bun run typecheck'");
expect(workflow).toContain("cmd: 'bun run lint'");
expect(workflow).toContain("cmd: 'bun run format:check'");
expect(workflow).toContain('run: bun run build:all');
expect(workflow).toContain('run: bun run test:all');
expect(workflow).toContain("CCS_E2E_SKIP_BUILD: '1'");
expect(workflow).toContain('run: bun run test:e2e');
});
});
@@ -0,0 +1,28 @@
const { describe, expect, test } = require('bun:test');
const path = require('node:path');
const bucket = require('../../../scripts/run-test-bucket.js');
describe('run-test-bucket', () => {
test('all declared slow tests still exist on disk', () => {
for (const relativePath of bucket.slowTests) {
const absolutePath = path.resolve(__dirname, '../../../', relativePath);
expect(Bun.file(absolutePath).exists()).resolves.toBe(true);
}
});
test('forces npm tests into the slow bucket', () => {
expect(bucket.shouldForceSlow('tests/npm/cli.test.js')).toBe(true);
});
test('keeps dist-independent javascript tests in the fast bucket', () => {
expect(bucket.shouldForceSlow('tests/unit/flag-parsing-simple.test.js')).toBe(false);
});
test('keeps non-allowlisted javascript tests in the slow bucket', () => {
expect(bucket.shouldForceSlow('tests/unit/commands/persist-command.test.js')).toBe(true);
});
test('still forces dist-dependent tests into the slow bucket', () => {
expect(bucket.shouldForceSlow('tests/unit/config-dir-override.test.js')).toBe(true);
});
});
@@ -143,6 +143,23 @@ describe('droid-config-manager', () => {
expect(settings.customModels[0].extraArgs?.thinking?.budget_tokens).toBe(40960);
});
it('writes adaptive anthropic thinking for claude-opus-4-7 overrides', async () => {
await upsertCcsModel('claude', {
model: 'claude-opus-4-7',
displayName: 'CCS claude',
baseUrl: 'https://api.anthropic.com',
apiKey: 'anthropic-key',
provider: 'anthropic',
reasoningOverride: 'max',
});
const settingsPath = path.join(tmpDir, '.factory', 'settings.json');
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
expect(settings.customModels[0].extraArgs?.thinking?.type).toBe('adaptive');
expect(settings.customModels[0].extraArgs?.thinking?.budget_tokens).toBeUndefined();
expect(settings.customModels[0].extraArgs?.output_config?.effort).toBe('max');
});
it('should clear prior reasoning config when override disables thinking', async () => {
await upsertCcsModel('glm', {
model: 'glm-4.7',
@@ -136,6 +136,13 @@ printf "%s\n" "$@" > "${claudeArgsLogPath}"
printf "port=%s\n" "$CCS_BROWSER_DEVTOOLS_PORT"
printf "httpUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_HTTP_URL"
printf "wsUrl=%s\n" "$CCS_BROWSER_DEVTOOLS_WS_URL"
printf "stripAnthropic=%s\n" "$CCS_STRIP_INHERITED_ANTHROPIC_ENV"
printf "anthropicBaseUrl=%s\n" "$ANTHROPIC_BASE_URL"
printf "anthropicAuthToken=%s\n" "$ANTHROPIC_AUTH_TOKEN"
printf "anthropicApiKey=%s\n" "$ANTHROPIC_API_KEY"
printf "anthropicModel=%s\n" "$ANTHROPIC_MODEL"
printf "anthropicSonnet=%s\n" "$ANTHROPIC_DEFAULT_SONNET_MODEL"
printf "maxOutputTokens=%s\n" "$CLAUDE_CODE_MAX_OUTPUT_TOKENS"
} > "${claudeEnvLogPath}"
exit 0
`,
@@ -187,6 +194,69 @@ exit 0
expect(fs.existsSync(claudeArgsLogPath)).toBe(true);
});
it('passes selective Anthropic env stripping to settings-profile Claude launches while preserving model defaults', () => {
if (process.platform === 'win32') return;
fs.writeFileSync(
settingsPath,
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.z.ai/api/anthropic',
ANTHROPIC_AUTH_TOKEN: 'profile-token',
ANTHROPIC_MODEL: 'gpt-5.4',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345',
},
},
null,
2
) + '\n'
);
const originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpHome;
try {
mutateUnifiedConfig((config) => {
config.global_env = {
enabled: true,
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:9999/api/provider/global',
ANTHROPIC_AUTH_TOKEN: 'global-routing-token',
ANTHROPIC_API_KEY: 'global-api-key',
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '54321',
},
};
});
const result = runCcs(['glm', 'smoke'], {
...baseEnv,
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
ANTHROPIC_AUTH_TOKEN: 'parent-routing-token',
ANTHROPIC_API_KEY: 'parent-api-key',
ANTHROPIC_MODEL: 'gpt-5.4',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
});
expect(result.status).toBe(0);
const launchedEnv = fs.readFileSync(claudeEnvLogPath, 'utf8');
expect(launchedEnv).toContain('stripAnthropic=1');
expect(launchedEnv).toContain('anthropicBaseUrl=');
expect(launchedEnv).toContain('anthropicAuthToken=');
expect(launchedEnv).toContain('anthropicApiKey=');
expect(launchedEnv).toContain('anthropicModel=gpt-5.4');
expect(launchedEnv).toContain('anthropicSonnet=gpt-5.4');
expect(launchedEnv).toContain('maxOutputTokens=12345');
} finally {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
}
});
it('does not auto-enable browser reuse for settings-profile launches from env overrides alone', async () => {
if (process.platform === 'win32') return;
@@ -21,8 +21,16 @@ type SpawnCall = {
options: Record<string, unknown> | undefined;
};
const STEERING_PROMPT_SNIPPET = 'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches';
type SpawnSyncCall = {
command: string;
args: string[];
options: Record<string, unknown> | undefined;
};
const STEERING_PROMPT_SNIPPET =
'prefer the CCS MCP tool WebSearch instead of Bash/curl/http fetches';
const spawnCalls: SpawnCall[] = [];
const spawnSyncCalls: SpawnSyncCall[] = [];
const originalPlatform = process.platform;
let baselineSigintListeners: Array<(...args: unknown[]) => void> = [];
let baselineSigtermListeners: Array<(...args: unknown[]) => void> = [];
@@ -31,6 +39,7 @@ let originalCcsHome: string | undefined;
let originalCcsClaudePath: string | undefined;
let originalDisableAutoUpdater: string | undefined;
let originalClaudeConfigDir: string | undefined;
let originalTmux: string | undefined;
const realSpawn = childProcess.spawn.bind(childProcess);
const realSpawnSync = childProcess.spawnSync.bind(childProcess);
const realExecSync = childProcess.execSync.bind(childProcess);
@@ -103,6 +112,18 @@ function registerChildProcessMock(): void {
| Record<string, unknown>
| undefined;
if (command === 'tmux') {
spawnSyncCalls.push({ command, args, options });
return {
pid: process.pid,
output: ['', '', ''],
stdout: '',
stderr: '',
status: 0,
signal: null,
};
}
return realSpawnSync(command, args, options as Parameters<typeof childProcess.spawnSync>[2]);
},
execSync: (...execArgs: unknown[]) =>
@@ -140,6 +161,7 @@ ${yamlBody}
}
let execClaude: typeof import('../../../src/utils/shell-executor').execClaude;
let stripAnthropicRoutingEnv: typeof import('../../../src/utils/shell-executor').stripAnthropicRoutingEnv;
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor;
let SharedManager: typeof import('../../../src/management/shared-manager').default;
@@ -149,6 +171,7 @@ beforeAll(async () => {
const shellExecutor = await import('../../../src/utils/shell-executor');
execClaude = shellExecutor.execClaude;
stripAnthropicRoutingEnv = shellExecutor.stripAnthropicRoutingEnv;
stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv;
const sharedManagerModule = await import('../../../src/management/shared-manager');
@@ -165,6 +188,7 @@ afterAll(() => {
describe('CLAUDECODE environment stripping', () => {
beforeEach(() => {
spawnCalls.length = 0;
spawnSyncCalls.length = 0;
process.env.CCS_QUIET = '1';
// Save original env values for restoration in afterEach
@@ -172,10 +196,12 @@ describe('CLAUDECODE environment stripping', () => {
originalCcsClaudePath = process.env.CCS_CLAUDE_PATH;
originalDisableAutoUpdater = process.env.DISABLE_AUTOUPDATER;
originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR;
originalTmux = process.env.TMUX;
// Clear CCS-managed env vars that leak from host sessions
delete process.env.DISABLE_AUTOUPDATER;
delete process.env.CLAUDE_CONFIG_DIR;
delete process.env.TMUX;
baselineSigintListeners = process.listeners('SIGINT');
baselineSigtermListeners = process.listeners('SIGTERM');
@@ -197,8 +223,20 @@ describe('CLAUDECODE environment stripping', () => {
} else {
delete process.env.DISABLE_AUTOUPDATER;
}
if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
if (originalClaudeConfigDir !== undefined)
process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir;
else delete process.env.CLAUDE_CONFIG_DIR;
if (originalTmux !== undefined) process.env.TMUX = originalTmux;
else delete process.env.TMUX;
delete process.env.ANTHROPIC_BASE_URL;
delete process.env.ANTHROPIC_AUTH_TOKEN;
delete process.env.ANTHROPIC_API_KEY;
delete process.env.ANTHROPIC_MODEL;
delete process.env.ANTHROPIC_DEFAULT_OPUS_MODEL;
delete process.env.ANTHROPIC_DEFAULT_SONNET_MODEL;
delete process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL;
delete process.env.ANTHROPIC_SMALL_FAST_MODEL;
delete process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS;
for (const listener of process.listeners('SIGINT')) {
if (!baselineSigintListeners.includes(listener)) {
@@ -230,6 +268,25 @@ describe('CLAUDECODE environment stripping', () => {
expect(result.PATH).toBe('/usr/bin');
});
it('stripAnthropicRoutingEnv removes routing/auth env case-insensitively while preserving model vars', () => {
const input: NodeJS.ProcessEnv = {
anthropic_base_url: 'http://127.0.0.1:8317/api/provider/codex',
Anthropic_Auth_Token: 'parent-routing-token',
ANTHROPIC_API_KEY: 'parent-api-key',
ANTHROPIC_MODEL: 'gpt-5.4',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
PATH: '/usr/bin',
};
const result = stripAnthropicRoutingEnv(input);
expect(result.anthropic_base_url).toBeUndefined();
expect(result.Anthropic_Auth_Token).toBeUndefined();
expect(result.ANTHROPIC_API_KEY).toBeUndefined();
expect(result.ANTHROPIC_MODEL).toBe('gpt-5.4');
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4');
expect(result.PATH).toBe('/usr/bin');
});
it('execClaude strips CLAUDECODE from merged env (including overrides)', () => {
process.env.CLAUDECODE = 'from-parent';
process.env.claudecode = 'from-parent-lower';
@@ -336,6 +393,97 @@ describe('CLAUDECODE environment stripping', () => {
expect(normalizeSpy).toHaveBeenCalledWith(instancePath);
});
it('execClaude strips inherited ANTHROPIC routing env but keeps model intent for settings-profile Claude launches', () => {
process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex';
process.env.ANTHROPIC_AUTH_TOKEN = 'ccs-internal-managed';
process.env.ANTHROPIC_API_KEY = 'stale-api-key';
process.env.ANTHROPIC_MODEL = 'gpt-5.4';
process.env.ANTHROPIC_DEFAULT_OPUS_MODEL = 'gpt-5.4';
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gpt-5.4';
process.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = 'gpt-5.4-mini';
process.env.ANTHROPIC_SMALL_FAST_MODEL = 'gpt-5-codex-mini';
execClaude('claude', ['--help'], {
CCS_PROFILE_TYPE: 'settings',
CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1',
CLAUDE_CONFIG_DIR: path.join(os.tmpdir(), 'ccs-settings-profile-instance'),
CCS_WEBSEARCH_SKIP: '1',
});
expect(spawnCalls.length).toBeGreaterThan(0);
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(env.CCS_PROFILE_TYPE).toBe('settings');
expect(env.CLAUDE_CONFIG_DIR).toContain('ccs-settings-profile-instance');
expect(env.ANTHROPIC_BASE_URL).toBeUndefined();
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4');
expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.4');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4');
expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
expect(env.ANTHROPIC_SMALL_FAST_MODEL).toBe('gpt-5-codex-mini');
});
it('execClaude strips routing env reintroduced by explicit settings-profile overrides', () => {
execClaude('claude', ['--help'], {
CCS_PROFILE_TYPE: 'settings',
CCS_STRIP_INHERITED_ANTHROPIC_ENV: '1',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
ANTHROPIC_AUTH_TOKEN: 'reintroduced-routing-token',
ANTHROPIC_API_KEY: 'reintroduced-api-key',
ANTHROPIC_MODEL: 'gpt-5.4',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
});
expect(spawnCalls.length).toBeGreaterThan(0);
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(env.ANTHROPIC_BASE_URL).toBeUndefined();
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4');
});
it('execClaude sanitizes tmux teammate env for bridge-backed settings launches while keeping the launched child on the runtime proxy', () => {
process.env.TMUX = 'session-1';
process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex';
process.env.ANTHROPIC_AUTH_TOKEN = 'parent-routing-token';
process.env.ANTHROPIC_API_KEY = 'parent-api-key';
process.env.ANTHROPIC_MODEL = 'gpt-5.4';
process.env.ANTHROPIC_DEFAULT_SONNET_MODEL = 'gpt-5.4';
execClaude('claude', ['--help'], {
CCS_PROFILE_TYPE: 'settings',
CLAUDE_CONFIG_DIR: path.join(os.tmpdir(), 'ccs-settings-profile-instance'),
ANTHROPIC_BASE_URL: 'http://127.0.0.1:3456',
ANTHROPIC_AUTH_TOKEN: 'fresh-runtime-token',
ANTHROPIC_MODEL: 'gpt-5.4',
});
expect(spawnCalls.length).toBeGreaterThan(0);
const childEnv = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(childEnv.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:3456');
expect(childEnv.ANTHROPIC_AUTH_TOKEN).toBe('fresh-runtime-token');
const unsetBaseUrlCall = spawnSyncCalls.find(
(call) => call.command === 'tmux' && call.args.join(' ') === 'setenv -u ANTHROPIC_BASE_URL'
);
const unsetAuthTokenCall = spawnSyncCalls.find(
(call) => call.command === 'tmux' && call.args.join(' ') === 'setenv -u ANTHROPIC_AUTH_TOKEN'
);
const modelCall = spawnSyncCalls.find(
(call) =>
call.command === 'tmux' &&
call.args[0] === 'setenv' &&
call.args[1] === 'ANTHROPIC_MODEL' &&
call.args[2] === 'gpt-5.4'
);
expect(unsetBaseUrlCall).toBeDefined();
expect(unsetAuthTokenCall).toBeDefined();
expect(modelCall).toBeDefined();
});
it('headless executor spawn path strips CLAUDECODE before spawn', async () => {
writeConfigWithAutoUpdatePreference(false);
process.env.CLAUDECODE = 'nested';
@@ -432,6 +580,92 @@ describe('CLAUDECODE environment stripping', () => {
});
});
it('headless executor strips inherited routing env for settings-profile delegation while preserving model intent', async () => {
writeConfigWithAutoUpdatePreference(false);
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
fs.writeFileSync(
path.join(ccsDir, 'glm.settings.json'),
JSON.stringify(
{
env: {
ANTHROPIC_MODEL: 'gpt-5.4',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.4',
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345',
},
},
null,
2
) + '\n',
'utf8'
);
const projectDir = path.join(ccsDir, 'project-headless-settings');
fs.mkdirSync(projectDir, { recursive: true });
process.env.CCS_CLAUDE_PATH = 'claude';
process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex';
process.env.ANTHROPIC_AUTH_TOKEN = 'parent-routing-token';
process.env.ANTHROPIC_API_KEY = 'parent-api-key';
process.env.ANTHROPIC_MODEL = 'gpt-5.4';
const result = await HeadlessExecutor.execute('glm', 'latest AI chip news', {
cwd: projectDir,
permissionMode: 'default',
timeout: 1000,
});
expect(result.success).toBe(true);
expect(spawnCalls.length).toBeGreaterThan(0);
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(env.ANTHROPIC_BASE_URL).toBeUndefined();
expect(env.ANTHROPIC_AUTH_TOKEN).toBeUndefined();
expect(env.ANTHROPIC_API_KEY).toBeUndefined();
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4');
expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.4');
expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345');
});
it('headless executor rebuilds OpenAI-compatible bridge env from settings instead of inheriting stale parent routing', async () => {
writeConfigWithAutoUpdatePreference(false);
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
fs.writeFileSync(
path.join(ccsDir, 'bridge.settings.json'),
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.openai.com/v1',
ANTHROPIC_AUTH_TOKEN: 'settings-bridge-token',
ANTHROPIC_MODEL: 'gpt-5.4',
CLAUDE_CODE_MAX_OUTPUT_TOKENS: '12345',
},
},
null,
2
) + '\n',
'utf8'
);
const projectDir = path.join(ccsDir, 'project-headless-bridge');
fs.mkdirSync(projectDir, { recursive: true });
process.env.CCS_CLAUDE_PATH = 'claude';
process.env.ANTHROPIC_BASE_URL = 'http://127.0.0.1:8317/api/provider/codex';
process.env.ANTHROPIC_AUTH_TOKEN = 'parent-routing-token';
process.env.ANTHROPIC_API_KEY = 'parent-api-key';
const result = await HeadlessExecutor.execute('bridge', 'latest AI chip news', {
cwd: projectDir,
permissionMode: 'default',
timeout: 1000,
});
expect(result.success).toBe(true);
expect(spawnCalls.length).toBeGreaterThan(0);
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(env.ANTHROPIC_BASE_URL).toMatch(/^http:\/\/127\.0\.0\.1:\d+$/);
expect(env.ANTHROPIC_BASE_URL).not.toBe('http://127.0.0.1:8317/api/provider/codex');
expect(env.ANTHROPIC_AUTH_TOKEN).toBeDefined();
expect(env.ANTHROPIC_AUTH_TOKEN).not.toBe('parent-routing-token');
expect(env.ANTHROPIC_MODEL).toBe('gpt-5.4');
expect(env.CLAUDE_CODE_MAX_OUTPUT_TOKENS).toBe('12345');
});
it('headless executor prepares image-analysis MCP and suppresses the legacy hook on healthy launches', async () => {
writeConfigWithAutoUpdatePreference(false);
const ccsDir = path.join(process.env.CCS_HOME as string, '.ccs');
@@ -468,9 +702,7 @@ describe('CLAUDECODE environment stripping', () => {
args: [path.join(ccsDir, 'mcp', 'ccs-image-analysis-server.cjs')],
env: {},
});
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(
false
);
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analyzer-transformer.cjs'))).toBe(false);
expect(fs.existsSync(path.join(ccsDir, 'hooks', 'image-analysis-runtime.cjs'))).toBe(false);
});
@@ -56,7 +56,7 @@ describe('installDashboardCliproxyVersion', () => {
success: true,
restarted: true,
port: 8317,
message: 'Successfully installed CLIProxy Plus v6.7.1 and restarted it on port 8317',
message: 'Successfully installed CLIProxy v6.7.1 and restarted it on port 8317',
});
expect(calls.isCliproxyRunning).toBe(0);
expect(calls.installCliproxyVersion).toBe(1);
@@ -71,7 +71,7 @@ describe('installDashboardCliproxyVersion', () => {
expect(result).toEqual<DashboardCliproxyInstallResult>({
success: true,
restarted: false,
message: 'Successfully installed CLIProxy Plus v6.7.1',
message: 'Successfully installed CLIProxy v6.7.1',
});
expect(calls.isCliproxyRunning).toBe(1);
expect(calls.installCliproxyVersion).toBe(1);
@@ -118,8 +118,8 @@ describe('installDashboardCliproxyVersion', () => {
expect(result).toEqual<DashboardCliproxyInstallResult>({
success: false,
restarted: false,
error: 'Installed CLIProxy Plus v6.7.1, but restart failed',
message: 'Installed CLIProxy Plus v6.7.1, but failed to restart it',
error: 'Installed CLIProxy v6.7.1, but restart failed',
message: 'Installed CLIProxy v6.7.1, but failed to restart it',
});
});
});
@@ -10,6 +10,7 @@ let createEmptyUnifiedConfig: typeof import('../../../src/config/unified-config-
let saveUnifiedConfig: typeof import('../../../src/config/unified-config-loader').saveUnifiedConfig;
let setGlobalConfigDir: typeof import('../../../src/utils/config-manager').setGlobalConfigDir;
let writeInstalledVersion: typeof import('../../../src/cliproxy/binary/version-cache').writeInstalledVersion;
let writeVersionCache: typeof import('../../../src/cliproxy/binary/version-cache').writeVersionCache;
let writeVersionListCache: typeof import('../../../src/cliproxy/binary/version-cache').writeVersionListCache;
let server: Server;
@@ -25,7 +26,7 @@ beforeAll(async () => {
({ setGlobalConfigDir } = await import('../../../src/utils/config-manager'));
({ createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types'));
({ saveUnifiedConfig } = await import('../../../src/config/unified-config-loader'));
({ writeInstalledVersion, writeVersionListCache } = await import(
({ writeInstalledVersion, writeVersionCache, writeVersionListCache } = await import(
'../../../src/cliproxy/binary/version-cache'
));
@@ -39,6 +40,7 @@ beforeAll(async () => {
saveUnifiedConfig(config);
writeInstalledVersion(plusBinDir, '6.6.80');
writeVersionCache('6.6.89', 'plus');
writeVersionListCache(
{
versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'],
@@ -94,6 +96,23 @@ afterAll(async () => {
});
describe('cliproxy-stats-routes install contract', () => {
it('routes saved plus configs through original backend for update checks', async () => {
const response = await fetch(`${baseUrl}/api/cliproxy/update-check`);
expect(response.status).toBe(200);
const body = (await response.json()) as {
backend: string;
backendLabel: string;
currentVersion: string;
latestVersion: string;
};
expect(body.backend).toBe('original');
expect(body.backendLabel).toBe('CLIProxy');
expect(body.currentVersion).toBe('6.6.80');
expect(body.latestVersion).toBe('6.6.89');
});
it('returns faultyRange in the versions response', async () => {
const response = await fetch(`${baseUrl}/api/cliproxy/versions`);
expect(response.status).toBe(200);
@@ -129,7 +129,7 @@ describe('cliproxy-stats-routes model update canonicalization', () => {
expect(persisted.env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-6');
});
it('canonicalizes Codex effort suffix and syncs linked core model env vars', async () => {
it('preserves Codex effort suffixes while syncing linked core model env vars', async () => {
const settingsPath = path.join(tempHome, '.ccs', 'cliproxy', 'codex.settings.json');
writeSettings(settingsPath, {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
@@ -148,14 +148,14 @@ describe('cliproxy-stats-routes model update canonicalization', () => {
expect(response.status).toBe(200);
const body = (await response.json()) as { model: string };
expect(body.model).toBe('gpt-5.3-codex');
expect(body.model).toBe('gpt-5.3-codex-xhigh');
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
env: Record<string, string>;
};
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex');
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex');
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex');
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini');
});
@@ -154,6 +154,83 @@ describe('settings-routes model canonicalization', () => {
expect(persisted.presets[0]?.haiku).toBe('qwen3-coder-plus');
});
it('preserves codex effort suffixes on PUT /:profile while still normalizing legacy aliases', async () => {
const settingsPath = path.join(tempHome, '.ccs', 'codex.settings.json');
writeSettings(settingsPath, { env: {} });
const response = await fetch(`${baseUrl}/api/settings/codex`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
settings: {
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5-codex-high',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium',
},
presets: [
{
name: 'legacy-codex',
default: 'gpt-5.3-codex-xhigh',
opus: 'gpt-5-codex-high',
sonnet: 'gpt-5.3-codex-high',
haiku: 'gpt-5-mini-medium',
},
],
},
}),
});
expect(response.status).toBe(200);
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
env: Record<string, string>;
presets: Array<Record<string, string>>;
};
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.4-high');
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
expect(persisted.presets[0]?.default).toBe('gpt-5.3-codex-xhigh');
expect(persisted.presets[0]?.opus).toBe('gpt-5.4-high');
expect(persisted.presets[0]?.sonnet).toBe('gpt-5.3-codex-high');
expect(persisted.presets[0]?.haiku).toBe('gpt-5.4-mini-medium');
});
it('preserves codex effort suffixes on GET /:profile/raw canonicalization', async () => {
const settingsPath = path.join(tempHome, '.ccs', 'codex.settings.json');
writeSettings(settingsPath, {
env: {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317/api/provider/codex',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'gpt-5-codex-high',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium',
},
});
const response = await fetch(`${baseUrl}/api/settings/codex/raw`);
expect(response.status).toBe(200);
const body = (await response.json()) as { settings: { env: Record<string, string> } };
expect(body.settings.env.ANTHROPIC_MODEL).toBe('gpt-5.4-high');
expect(body.settings.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(body.settings.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
expect(body.settings.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
const persisted = JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as {
env: Record<string, string>;
};
expect(persisted.env.ANTHROPIC_MODEL).toBe('gpt-5.4-high');
expect(persisted.env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('gpt-5.3-codex-xhigh');
expect(persisted.env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('gpt-5.3-codex-high');
expect(persisted.env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.4-mini-medium');
});
it('canonicalizes AGY preset values on POST /:profile/presets', async () => {
const settingsPath = path.join(tempHome, '.ccs', 'agy.settings.json');
writeSettings(settingsPath, {