mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-20 02:17:05 +00:00
chore: merge dev into openai provider routing
This commit is contained in:
commit
1923d87a84
118 files changed
+4162
-3620
No files matched your search
@@ -149,68 +149,72 @@ describe('api-routes remote write guard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('allows remote writes again when dashboard auth is enabled', async () => {
|
||||
const password = 'testpassword123';
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true';
|
||||
process.env.CCS_DASHBOARD_USERNAME = 'admin';
|
||||
process.env.CCS_DASHBOARD_PASSWORD_HASH = await bcrypt.hash(password, 10);
|
||||
it(
|
||||
'allows remote writes again when dashboard auth is enabled',
|
||||
async () => {
|
||||
const password = 'testpassword123';
|
||||
process.env.CCS_DASHBOARD_AUTH_ENABLED = 'true';
|
||||
process.env.CCS_DASHBOARD_USERNAME = 'admin';
|
||||
process.env.CCS_DASHBOARD_PASSWORD_HASH = await bcrypt.hash(password, 4);
|
||||
|
||||
const authApp = express();
|
||||
authApp.use(express.json());
|
||||
authApp.use((req, _res, next) => {
|
||||
Object.defineProperty(req.socket, 'remoteAddress', {
|
||||
value: forcedRemoteAddress,
|
||||
configurable: true,
|
||||
const authApp = express();
|
||||
authApp.use(express.json());
|
||||
authApp.use((req, _res, next) => {
|
||||
Object.defineProperty(req.socket, 'remoteAddress', {
|
||||
value: forcedRemoteAddress,
|
||||
configurable: true,
|
||||
});
|
||||
next();
|
||||
});
|
||||
next();
|
||||
});
|
||||
authApp.use(createSessionMiddleware());
|
||||
authApp.use(authMiddleware);
|
||||
authApp.use('/api', apiRoutes);
|
||||
authApp.use(createSessionMiddleware());
|
||||
authApp.use(authMiddleware);
|
||||
authApp.use('/api', apiRoutes);
|
||||
|
||||
const authServer = await new Promise<Server>((resolve, reject) => {
|
||||
const instance = authApp.listen(0, '127.0.0.1');
|
||||
instance.once('error', reject);
|
||||
instance.once('listening', () => resolve(instance));
|
||||
});
|
||||
const authServer = await new Promise<Server>((resolve, reject) => {
|
||||
const instance = authApp.listen(0, '127.0.0.1');
|
||||
instance.once('error', reject);
|
||||
instance.once('listening', () => resolve(instance));
|
||||
});
|
||||
|
||||
const address = authServer.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve auth-enabled test server port');
|
||||
}
|
||||
const authBaseUrl = `http://127.0.0.1:${address.port}`;
|
||||
const address = authServer.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve auth-enabled test server port');
|
||||
}
|
||||
const authBaseUrl = `http://127.0.0.1:${address.port}`;
|
||||
|
||||
const loginResponse = await fetch(`${authBaseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: 'admin',
|
||||
password,
|
||||
}),
|
||||
});
|
||||
const cookie = loginResponse.headers.get('set-cookie');
|
||||
const loginResponse = await fetch(`${authBaseUrl}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
username: 'admin',
|
||||
password,
|
||||
}),
|
||||
});
|
||||
const cookie = loginResponse.headers.get('set-cookie');
|
||||
|
||||
expect(loginResponse.status).toBe(200);
|
||||
expect(cookie).toBeTruthy();
|
||||
expect(loginResponse.status).toBe(200);
|
||||
expect(cookie).toBeTruthy();
|
||||
|
||||
const response = await fetch(`${authBaseUrl}/api/profiles`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: cookie as string,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: 'demo',
|
||||
baseUrl: 'https://api.example.com',
|
||||
apiKey: 'token',
|
||||
}),
|
||||
});
|
||||
const response = await fetch(`${authBaseUrl}/api/profiles`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Cookie: cookie as string,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: 'demo',
|
||||
baseUrl: 'https://api.example.com',
|
||||
apiKey: 'token',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
expect(response.status).toBe(201);
|
||||
|
||||
await new Promise<void>((resolve) => authServer.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => authServer.close(() => resolve()));
|
||||
|
||||
delete process.env.CCS_DASHBOARD_USERNAME;
|
||||
delete process.env.CCS_DASHBOARD_PASSWORD_HASH;
|
||||
});
|
||||
delete process.env.CCS_DASHBOARD_USERNAME;
|
||||
delete process.env.CCS_DASHBOARD_PASSWORD_HASH;
|
||||
},
|
||||
15000
|
||||
);
|
||||
});
|
||||
@@ -6,6 +6,11 @@ import * as path from 'path';
|
||||
import * as http from 'http';
|
||||
import type { Server } from 'http';
|
||||
import cliproxyAuthRoutes from '../../../src/web-server/routes/cliproxy-auth-routes';
|
||||
import {
|
||||
clearQuotaCache,
|
||||
getCachedQuota,
|
||||
setCachedQuota,
|
||||
} from '../../../src/cliproxy/quota-response-cache';
|
||||
import { restoreFetch, mockFetch } from '../../mocks';
|
||||
|
||||
describe('cliproxy-auth-routes manual callback nickname persistence', () => {
|
||||
@@ -49,6 +54,7 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
|
||||
|
||||
afterEach(() => {
|
||||
restoreFetch();
|
||||
clearQuotaCache();
|
||||
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
@@ -564,4 +570,62 @@ describe('cliproxy-auth-routes manual callback nickname persistence', () => {
|
||||
|
||||
expect(registry.providers.codex.accounts['new@example.com']?.email).toBe('new@example.com');
|
||||
});
|
||||
|
||||
it('clears stale Claude quota cache after polling auth success', async () => {
|
||||
mockFetch([
|
||||
{
|
||||
url: /\/v0\/management\/anthropic-auth-url\?is_webui=true$/,
|
||||
response: {
|
||||
auth_url: 'https://auth.example.com/authorize?state=state-claude-cache-clear',
|
||||
state: 'state-claude-cache-clear',
|
||||
},
|
||||
},
|
||||
{
|
||||
url: /\/v0\/management\/get-auth-status\?state=state-claude-cache-clear$/,
|
||||
response: { status: 'ok' },
|
||||
},
|
||||
]);
|
||||
|
||||
const startResponse = await postJson('/api/cliproxy/auth/claude/start-url', {});
|
||||
expect(startResponse.status).toBe(200);
|
||||
|
||||
const accountId = 'claude-team@example.com';
|
||||
setCachedQuota('claude', accountId, {
|
||||
success: false,
|
||||
error: 'Authentication required for policy limits',
|
||||
needsReauth: true,
|
||||
});
|
||||
expect(getCachedQuota('claude', accountId)).not.toBeNull();
|
||||
|
||||
const tokenDir = path.join(tempHome, '.ccs', 'cliproxy', 'auth');
|
||||
fs.mkdirSync(tokenDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tokenDir, 'claude-claude-team@example.com.json'),
|
||||
JSON.stringify({
|
||||
type: 'claude',
|
||||
email: accountId,
|
||||
access_token: 'fresh-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expired: '2099-01-01T00:00:00.000Z',
|
||||
}),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const statusResponse = await getJson(
|
||||
'/api/cliproxy/auth/claude/status?state=state-claude-cache-clear'
|
||||
);
|
||||
|
||||
expect(statusResponse.status).toBe(200);
|
||||
expect(statusResponse.body).toEqual({
|
||||
status: 'ok',
|
||||
account: {
|
||||
id: accountId,
|
||||
email: accountId,
|
||||
nickname: 'claude-team',
|
||||
provider: 'claude',
|
||||
isDefault: true,
|
||||
},
|
||||
});
|
||||
expect(getCachedQuota('claude', accountId)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -13,6 +13,15 @@ describe('cliproxy-auth-routes start-url guard', () => {
|
||||
);
|
||||
expect(getStartUrlUnsupportedReason('ghcp')).toContain("Provider 'ghcp' uses Device Code flow");
|
||||
expect(getStartUrlUnsupportedReason('qwen')).toContain("Provider 'qwen' uses Device Code flow");
|
||||
expect(getStartUrlUnsupportedReason('cursor')).toContain(
|
||||
"Provider 'cursor' uses Device Code flow"
|
||||
);
|
||||
expect(getStartUrlUnsupportedReason('codebuddy')).toContain(
|
||||
"Provider 'codebuddy' uses Device Code flow"
|
||||
);
|
||||
expect(getStartUrlUnsupportedReason('kilo')).toContain(
|
||||
"Provider 'kilo' uses Device Code flow"
|
||||
);
|
||||
});
|
||||
|
||||
it('allows Kiro social methods on start-url', () => {
|
||||
@@ -36,6 +45,7 @@ describe('cliproxy-auth-routes start-url guard', () => {
|
||||
expect(getStartUrlUnsupportedReason('gemini')).toBeNull();
|
||||
expect(getStartUrlUnsupportedReason('codex')).toBeNull();
|
||||
expect(getStartUrlUnsupportedReason('claude')).toBeNull();
|
||||
expect(getStartUrlUnsupportedReason('gitlab')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,6 +97,7 @@ describe('cliproxy-auth-routes start failure messaging', () => {
|
||||
it('keeps generic failure text for other providers', () => {
|
||||
expect(getStartAuthFailureMessage('gemini')).toBe('Authentication failed or was cancelled');
|
||||
expect(getStartAuthFailureMessage('kiro')).toBe('Authentication failed or was cancelled');
|
||||
expect(getStartAuthFailureMessage('gitlab')).toBe('Authentication failed or was cancelled');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -54,65 +54,73 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
describe('codex routes', () => {
|
||||
it('returns the current raw config snapshot from PATCH /config/patch', async () => {
|
||||
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
kind: 'top-level',
|
||||
values: {
|
||||
model: 'gpt-5.4',
|
||||
sandboxMode: 'workspace-write',
|
||||
},
|
||||
}),
|
||||
});
|
||||
it(
|
||||
'returns the current raw config snapshot from PATCH /config/patch',
|
||||
async () => {
|
||||
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
kind: 'top-level',
|
||||
values: {
|
||||
model: 'gpt-5.4',
|
||||
sandboxMode: 'workspace-write',
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const json = (await res.json()) as {
|
||||
success: boolean;
|
||||
exists: boolean;
|
||||
mtime: number;
|
||||
rawText: string;
|
||||
config: Record<string, unknown> | null;
|
||||
parseError: string | null;
|
||||
readError: string | null;
|
||||
};
|
||||
const json = (await res.json()) as {
|
||||
success: boolean;
|
||||
exists: boolean;
|
||||
mtime: number;
|
||||
rawText: string;
|
||||
config: Record<string, unknown> | null;
|
||||
parseError: string | null;
|
||||
readError: string | null;
|
||||
};
|
||||
|
||||
expect(json.success).toBe(true);
|
||||
expect(json.exists).toBe(true);
|
||||
expect(json.mtime).toBeGreaterThan(0);
|
||||
expect(json.parseError).toBeNull();
|
||||
expect(json.readError).toBeNull();
|
||||
expect(json.rawText).toContain('model = "gpt-5.4"');
|
||||
expect(json.rawText).toContain('sandbox_mode = "workspace-write"');
|
||||
expect(json.config?.model).toBe('gpt-5.4');
|
||||
expect(json.success).toBe(true);
|
||||
expect(json.exists).toBe(true);
|
||||
expect(json.mtime).toBeGreaterThan(0);
|
||||
expect(json.parseError).toBeNull();
|
||||
expect(json.readError).toBeNull();
|
||||
expect(json.rawText).toContain('model = "gpt-5.4"');
|
||||
expect(json.rawText).toContain('sandbox_mode = "workspace-write"');
|
||||
expect(json.config?.model).toBe('gpt-5.4');
|
||||
|
||||
const written = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||||
expect(written).toBe(json.rawText);
|
||||
});
|
||||
const written = fs.readFileSync(path.join(codexHome, 'config.toml'), 'utf8');
|
||||
expect(written).toBe(json.rawText);
|
||||
},
|
||||
10000
|
||||
);
|
||||
|
||||
it('returns 409 when PATCH /config/patch receives a stale expectedMtime', async () => {
|
||||
const configPath = path.join(codexHome, 'config.toml');
|
||||
fs.writeFileSync(configPath, 'model = "gpt-5.3-codex"\n');
|
||||
it(
|
||||
'returns 409 when PATCH /config/patch receives a stale expectedMtime',
|
||||
async () => {
|
||||
const configPath = path.join(codexHome, 'config.toml');
|
||||
fs.writeFileSync(configPath, 'model = "gpt-5.3-codex"\n');
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
kind: 'feature',
|
||||
feature: 'multi_agent',
|
||||
enabled: true,
|
||||
expectedMtime: 1,
|
||||
}),
|
||||
});
|
||||
const res = await fetch(`${baseUrl}/api/codex/config/patch`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
kind: 'feature',
|
||||
feature: 'multi_agent',
|
||||
enabled: true,
|
||||
expectedMtime: 1,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.status).toBe(409);
|
||||
|
||||
const json = (await res.json()) as { error: string; mtime: number };
|
||||
expect(json.error).toContain('File modified externally.');
|
||||
expect(json.mtime).toBeGreaterThan(0);
|
||||
});
|
||||
const json = (await res.json()) as { error: string; mtime: number };
|
||||
expect(json.error).toContain('File modified externally.');
|
||||
expect(json.mtime).toBeGreaterThan(0);
|
||||
},
|
||||
10000
|
||||
);
|
||||
|
||||
it('allows PATCH /config/patch on an existing config.toml without expectedMtime', async () => {
|
||||
fs.writeFileSync(path.join(codexHome, 'config.toml'), 'model = "gpt-5.4"\n');
|
||||
|
||||
@@ -131,18 +131,22 @@ describe('droid-dashboard-service', () => {
|
||||
expect(raw.rawText).toContain('invalid-json');
|
||||
});
|
||||
|
||||
it('includes structured docs links for fact-checking providers', async () => {
|
||||
const diagnostics = await getDroidDashboardDiagnostics();
|
||||
it(
|
||||
'includes structured docs links for fact-checking providers',
|
||||
async () => {
|
||||
const diagnostics = await getDroidDashboardDiagnostics();
|
||||
|
||||
expect(diagnostics.docsReference.links.length).toBeGreaterThan(0);
|
||||
expect(diagnostics.docsReference.providerDocs.length).toBeGreaterThan(0);
|
||||
expect(diagnostics.docsReference.links.every((link) => link.url.startsWith('https://'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
diagnostics.docsReference.providerDocs.some((doc) => doc.provider === 'anthropic')
|
||||
).toBe(true);
|
||||
});
|
||||
expect(diagnostics.docsReference.links.length).toBeGreaterThan(0);
|
||||
expect(diagnostics.docsReference.providerDocs.length).toBeGreaterThan(0);
|
||||
expect(diagnostics.docsReference.links.every((link) => link.url.startsWith('https://'))).toBe(
|
||||
true
|
||||
);
|
||||
expect(
|
||||
diagnostics.docsReference.providerDocs.some((doc) => doc.provider === 'anthropic')
|
||||
).toBe(true);
|
||||
},
|
||||
10000
|
||||
);
|
||||
|
||||
it('falls back to legacy config custom_models when settings customModels is absent', async () => {
|
||||
const settingsDir = path.join(testRoot, '.factory');
|
||||
|
||||
Reference in new issue
Block a user