mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-15 22:14:40 +00:00
chore: merge origin/dev into pr-1061 fix branch
This commit is contained in:
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 () => {
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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, {
|
||||
|
||||
Reference in new issue
Block a user