feat(cliproxy): integrate missing provider support

This commit is contained in:
Tam Nhu Tran
2026-04-15 00:26:12 -04:00
parent 2589c02937
commit 2d9f8c9695
34 changed files with 863 additions and 34 deletions
+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/codebuddy",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "auto",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "glm-5.1",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2.5",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "deepseek-v3-2-volc"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/cursor",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "composer-2",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "claude-4-sonnet",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "claude-4-sonnet",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "cursor-small"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/gitlab",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "gitlab-duo",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "duo-chat-opus-4-6",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "duo-chat-sonnet-4-6",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "duo-chat-haiku-4-5"
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/kilo",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "kilo/auto",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "kilo/auto",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "kilo/auto",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "kilo/auto"
}
}
+16 -14
View File
@@ -253,9 +253,8 @@ class ProfileDetector {
* Detect profile type and return routing information
*
* Priority order:
* 0. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen)
* 0.5. Copilot profile (if enabled in config)
* 0.75. Cursor profile (if enabled in config)
* 0. Hardcoded special runtime profiles (copilot, cursor)
* 0.5. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen, ...)
* 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1)
* 2. User-defined CLIProxy variants (config.cliproxy section) [legacy]
* 3. Settings-based profiles (config.profiles section) [legacy]
@@ -267,16 +266,7 @@ class ProfileDetector {
return this.resolveDefaultProfile();
}
// Priority 0: Check CLIProxy profiles (gemini, codex, agy, qwen) - OAuth-based, zero config
if (isCLIProxyProvider(profileName)) {
return {
type: 'cliproxy',
name: profileName,
provider: profileName,
};
}
// Priority 0.5: Check Copilot profile - GitHub Copilot subscription via copilot-api
// Priority 0: Check Copilot profile - GitHub Copilot subscription via copilot-api
if (profileName === 'copilot') {
const unifiedConfig = this.readUnifiedConfig();
const copilotConfig = unifiedConfig?.copilot;
@@ -306,7 +296,10 @@ class ProfileDetector {
};
}
// Priority 0.75: Check Cursor profile - local Cursor daemon runtime
// Priority 0.25: Check Cursor profile - local Cursor daemon runtime.
// This keeps bare `ccs cursor` and `ccs cursor <subcommand>` bound to the
// existing runtime/admin surface even though CLIProxy also exposes a
// distinct provider named "cursor".
if (profileName === 'cursor') {
const cursorConfig = getCursorConfig();
@@ -334,6 +327,15 @@ class ProfileDetector {
};
}
// Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...)
if (isCLIProxyProvider(profileName)) {
return {
type: 'cliproxy',
name: profileName,
provider: profileName,
};
}
// Priority 1: Try unified config if available
const unifiedConfig = this.readUnifiedConfig();
if (unifiedConfig) {
+20 -2
View File
@@ -77,6 +77,7 @@ import { isDeprecatedGlmtProfileName, normalizeDeprecatedGlmtEnv } from './utils
import { maybeWarnAboutResumeLaneMismatch } from './auth/resume-lane-warning';
import { createLogger } from './services/logging';
import { buildCodexBrowserMcpOverrides } from './utils/browser-codex-overrides';
import type { ProfileDetectionResult } from './auth/profile-detector';
// Import target adapter system
import {
@@ -122,6 +123,7 @@ interface RuntimeReasoningResolution {
sourceDisplay: string | undefined;
}
const CURSOR_CLIPROXY_SHORTCUT_FLAGS = new Set(['--auth', '--logout', '--config', '--accounts']);
const CODEX_RUNTIME_REASONING_LEVELS = new Set(['minimal', 'low', 'medium', 'high', 'xhigh']);
const CODEX_NATIVE_PASSTHROUGH_FLAGS = new Set(['--help', '-h', '--version', '-v']);
@@ -147,6 +149,13 @@ function detectProfile(args: string[]): DetectedProfile {
}
}
function shouldUseCursorCliproxyShortcut(args: string[]): boolean {
return (
args[0] === 'cursor' &&
args.some((arg, index) => index > 0 && CURSOR_CLIPROXY_SHORTCUT_FLAGS.has(arg))
);
}
function resolveRuntimeReasoningFlags(
args: string[],
envThinkingValue: string | undefined
@@ -537,8 +546,17 @@ async function main(): Promise<void> {
try {
// Detect profile (strip --target flags before profile detection)
const cleanArgs = stripTargetFlag(args);
const { profile, remainingArgs } = detectProfile(cleanArgs);
const profileInfo = detector.detectProfileType(profile);
const useCursorCliproxyShortcut = shouldUseCursorCliproxyShortcut(cleanArgs);
const { profile, remainingArgs } = useCursorCliproxyShortcut
? { profile: 'cursor', remainingArgs: cleanArgs.slice(1) }
: detectProfile(cleanArgs);
const profileInfo: ProfileDetectionResult = useCursorCliproxyShortcut
? {
type: 'cliproxy',
name: 'cursor',
provider: 'cursor',
}
: detector.detectProfileType(profile);
let resolvedTarget: ReturnType<typeof resolveTargetType>;
try {
resolvedTarget = resolveTargetType(
+38
View File
@@ -155,6 +155,10 @@ export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google'
* - Qwen: Device Code Flow (polling-based, NO callback port needed)
* - GHCP: Device Code Flow (polling-based, NO callback port needed)
* - Kimi: Device Code Flow (polling-based, NO callback port needed)
* - Cursor: Device-style browser polling (NO callback port needed)
* - GitLab: Authorization Code Flow with callback server on port 17171
* - CodeBuddy: Device-style browser polling (NO callback port needed)
* - Kilo: Device Code Flow (polling-based, NO callback port needed)
*/
export const OAUTH_CALLBACK_PORTS: Partial<Record<CLIProxyProvider, number>> =
CLIPROXY_PROVIDER_IDS.reduce(
@@ -274,6 +278,34 @@ export const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
scopes: ['api'],
authFlag: '--kimi-login',
},
cursor: {
provider: 'cursor',
displayName: 'Cursor',
authUrl: 'https://cursor.com/loginDeepControl',
scopes: [],
authFlag: '--cursor-login',
},
gitlab: {
provider: 'gitlab',
displayName: 'GitLab Duo',
authUrl: 'https://gitlab.com/oauth/authorize',
scopes: ['api', 'read_user'],
authFlag: '--gitlab-login',
},
codebuddy: {
provider: 'codebuddy',
displayName: 'CodeBuddy (Tencent)',
authUrl: 'https://copilot.tencent.com/v2/plugin/auth/state',
scopes: [],
authFlag: '--codebuddy-login',
},
kilo: {
provider: 'kilo',
displayName: 'Kilo AI',
authUrl: 'https://api.kilo.ai/api/device-auth/codes',
scopes: [],
authFlag: '--kilo-login',
},
};
/**
@@ -373,6 +405,12 @@ export interface OAuthOptions {
noIncognito?: boolean;
/** If true, skip OAuth and import token from Kiro IDE directly (Kiro only) */
import?: boolean;
/** GitLab auth mode override. */
gitlabAuthMode?: 'oauth' | 'pat';
/** GitLab self-hosted base URL override. */
gitlabBaseUrl?: string;
/** GitLab personal access token for PAT login. */
gitlabPersonalAccessToken?: string;
/** Enable paste-callback mode: show auth URL and prompt for callback paste */
pasteCallback?: boolean;
/** If true, use port-forwarding mode (skip interactive prompt in headless) */
+156 -4
View File
@@ -83,9 +83,12 @@ const POLLED_AUTH_LOCAL_TOKEN_GRACE_MS = 15 * 1000;
export async function requestPasteCallbackStart(
provider: CLIProxyProvider,
target: ProxyTarget,
options?: { kiroMethod?: OAuthOptions['kiroMethod'] }
options?: {
kiroMethod?: OAuthOptions['kiroMethod'];
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
}
): Promise<PasteCallbackStartData> {
const startPath = getPasteCallbackStartPath(provider, {
let startPath = getPasteCallbackStartPath(provider, {
kiroMethod: options?.kiroMethod,
});
if (!startPath) {
@@ -93,6 +96,9 @@ export async function requestPasteCallbackStart(
`Paste-callback start is not available for ${provider} with the selected method`
);
}
if (provider === 'gitlab' && options?.gitlabBaseUrl?.trim()) {
startPath += `&base_url=${encodeURIComponent(options.gitlabBaseUrl.trim())}`;
}
const response = await fetch(buildProxyUrl(target, startPath), {
headers: buildManagementHeaders(target),
});
@@ -142,6 +148,27 @@ function parseAuthUrlState(url: string | null | undefined): string | null {
}
}
function normalizeGitLabBaseUrl(baseUrl: string | undefined): string | undefined {
const normalized = baseUrl?.trim();
return normalized ? normalized : undefined;
}
async function promptGitLabPersonalAccessToken(): Promise<string | null> {
const readline = await import('readline');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
return new Promise<string | null>((resolve) => {
rl.question('GitLab Personal Access Token: ', (answer) => {
rl.close();
const token = answer.trim();
resolve(token.length > 0 ? token : null);
});
});
}
export function findNewTokenSnapshotForManualAuth(
provider: CLIProxyProvider,
tokenDir: string,
@@ -458,7 +485,10 @@ async function handlePasteCallbackMode(
tokenDir: string,
nickname?: string,
expectedAccountId?: string,
options?: { kiroMethod?: OAuthOptions['kiroMethod'] }
options?: {
kiroMethod?: OAuthOptions['kiroMethod'];
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
}
): Promise<AccountInfo | null> {
// Resolve CLIProxyAPI target (local or remote based on config)
const target = getProxyTarget();
@@ -647,6 +677,97 @@ async function handlePasteCallbackMode(
}
}
async function handleGitLabPatLogin(
provider: CLIProxyProvider,
oauthConfig: ProviderOAuthConfig,
verbose: boolean,
tokenDir: string,
nickname?: string,
expectedAccountId?: string,
options?: {
gitlabBaseUrl?: OAuthOptions['gitlabBaseUrl'];
gitlabPersonalAccessToken?: OAuthOptions['gitlabPersonalAccessToken'];
}
): Promise<AccountInfo | null> {
const target = getProxyTarget();
const baseUrl = normalizeGitLabBaseUrl(options?.gitlabBaseUrl);
const knownTokenFiles = listProviderTokenSnapshots(provider, tokenDir);
const suppliedToken = options?.gitlabPersonalAccessToken?.trim();
const personalAccessToken =
suppliedToken || process.env['GITLAB_PERSONAL_ACCESS_TOKEN']?.trim() || undefined;
let token = personalAccessToken;
if (!token) {
console.log('');
console.log(info(`Starting ${oauthConfig.displayName} PAT login...`));
console.log('Paste a Personal Access Token with api and read_user scopes.');
token = (await promptGitLabPersonalAccessToken()) || undefined;
}
if (!token) {
console.log(info('Cancelled'));
return null;
}
const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildManagementHeaders(target),
},
body: JSON.stringify({
...(baseUrl ? { base_url: baseUrl } : {}),
personal_access_token: token,
}),
});
const responseBody = (await response.text()).trim();
let payload: Record<string, unknown> = {};
if (responseBody) {
try {
payload = JSON.parse(responseBody) as Record<string, unknown>;
} catch {
payload = { error: responseBody };
}
}
if (!response.ok || payload.status !== 'ok') {
const errorMessage =
(typeof payload.error === 'string' && payload.error) ||
responseBody ||
`GitLab PAT login failed with status ${response.status}`;
console.log(fail(errorMessage));
return null;
}
const tokenSnapshot = findNewTokenSnapshotForAuthAttempt(
provider,
tokenDir,
knownTokenFiles,
expectedAccountId
);
if (!tokenSnapshot) {
console.log(fail('GitLab PAT login completed, but CCS could not find the saved token file.'));
return null;
}
const account = registerAccountFromToken(
provider,
tokenDir,
nickname,
verbose,
expectedAccountId || tokenSnapshot.file
);
if (!account) {
console.log(fail('Authenticated GitLab token could not be registered as a CCS account.'));
return null;
}
console.log(ok('Authentication successful!'));
return account;
}
/**
* Trigger OAuth flow for provider
* Auto-detects headless environment and uses --no-browser flag accordingly
@@ -666,6 +787,10 @@ export async function triggerOAuth(
provider === 'kiro' ? normalizeKiroAuthMethod(options.kiroMethod) : DEFAULT_KIRO_AUTH_METHOD;
const resolvedKiroIDCFlow =
provider === 'kiro' ? normalizeKiroIDCFlow(options.kiroIDCFlow) : DEFAULT_KIRO_IDC_FLOW;
const resolvedGitLabAuthMode =
provider === 'gitlab' && options.gitlabAuthMode === 'pat' ? 'pat' : 'oauth';
const resolvedGitLabBaseUrl =
provider === 'gitlab' ? normalizeGitLabBaseUrl(options.gitlabBaseUrl) : undefined;
if (provider === 'agy') {
if (fromUI && !acceptAgyRisk) {
@@ -744,6 +869,14 @@ export async function triggerOAuth(
}
}
if (provider === 'gitlab' && resolvedGitLabBaseUrl && !selectedPasteCallback) {
selectedPasteCallback = true;
console.log('');
console.log(
info('GitLab custom base URL selected. Switching to paste-callback mode for OAuth.')
);
}
const useSelectedKiroLocalPasteCallback =
selectedPasteCallback &&
provider === 'kiro' &&
@@ -765,6 +898,22 @@ export async function triggerOAuth(
}
}
if (provider === 'gitlab' && resolvedGitLabAuthMode === 'pat') {
const tokenDir = getProviderTokenDir(provider);
return handleGitLabPatLogin(
provider,
oauthConfig,
verbose,
tokenDir,
nickname,
existingNameMatch?.id,
{
gitlabBaseUrl: resolvedGitLabBaseUrl,
gitlabPersonalAccessToken: options.gitlabPersonalAccessToken,
}
);
}
if (selectedPasteCallback && !useSelectedKiroDirectCliFlow) {
const tokenDir = getProviderTokenDir(provider);
return handlePasteCallbackMode(
@@ -774,7 +923,10 @@ export async function triggerOAuth(
tokenDir,
nickname,
existingNameMatch?.id,
{ kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined }
{
kiroMethod: provider === 'kiro' ? resolvedKiroMethod : undefined,
gitlabBaseUrl: provider === 'gitlab' ? resolvedGitLabBaseUrl : undefined,
}
);
}
+22
View File
@@ -353,6 +353,7 @@ export async function execClaudeWithCLIProxy(
const addAccount = argsWithoutProxy.includes('--add');
const showAccounts = argsWithoutProxy.includes('--accounts');
const forceImport = argsWithoutProxy.includes('--import');
const gitlabTokenLogin = argsWithoutProxy.includes('--token-login');
const acceptAgyRisk = hasAntigravityRiskAcceptanceFlag(argsWithoutProxy);
const incognitoFlag = argsWithoutProxy.includes('--incognito');
@@ -444,6 +445,16 @@ export async function execClaudeWithCLIProxy(
kiroIDCFlow = normalizeKiroIDCFlow(normalized);
}
let gitlabBaseUrl: string | undefined;
const gitlabBaseUrlValue = readOptionValue(argsWithoutProxy, '--gitlab-url');
if (gitlabBaseUrlValue.present && gitlabBaseUrlValue.value) {
gitlabBaseUrl = gitlabBaseUrlValue.value.trim();
} else if (gitlabBaseUrlValue.present) {
console.error(fail('--gitlab-url requires a value'));
process.exitCode = 1;
return;
}
if (kiroAuthMethod && provider !== 'kiro' && !compositeProviders.includes('kiro')) {
console.error(fail('--kiro-auth-method is only valid for ccs kiro'));
process.exitCode = 1;
@@ -491,6 +502,13 @@ export async function execClaudeWithCLIProxy(
return;
}
if ((gitlabTokenLogin || gitlabBaseUrl) && provider !== 'gitlab') {
const flagName = gitlabTokenLogin ? '--token-login' : '--gitlab-url';
console.error(fail(`${flagName} is only valid for ccs gitlab`));
process.exitCode = 1;
return;
}
// Parse --thinking / --effort flags (aliases; first occurrence wins)
const thinkingParse = parseThinkingOverride(argsWithoutProxy);
if (thinkingParse.error) {
@@ -730,6 +748,8 @@ export async function execClaudeWithCLIProxy(
...(kiroIDCStartUrl && p === 'kiro' ? { kiroIDCStartUrl } : {}),
...(kiroIDCRegion && p === 'kiro' ? { kiroIDCRegion } : {}),
...(kiroIDCFlow && p === 'kiro' ? { kiroIDCFlow } : {}),
...(gitlabTokenLogin && p === 'gitlab' ? { gitlabAuthMode: 'pat' as const } : {}),
...(gitlabBaseUrl && p === 'gitlab' ? { gitlabBaseUrl } : {}),
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
...(noIncognito ? { noIncognito: true } : {}),
@@ -775,6 +795,8 @@ export async function execClaudeWithCLIProxy(
...(kiroIDCStartUrl ? { kiroIDCStartUrl } : {}),
...(kiroIDCRegion ? { kiroIDCRegion } : {}),
...(kiroIDCFlow ? { kiroIDCFlow } : {}),
...(gitlabTokenLogin ? { gitlabAuthMode: 'pat' as const } : {}),
...(gitlabBaseUrl ? { gitlabBaseUrl } : {}),
...(forceHeadless ? { headless: true } : {}),
...(setNickname ? { nickname: setNickname } : {}),
...(noIncognito ? { noIncognito: true } : {}),
+48
View File
@@ -134,6 +134,54 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
tokenTypeValues: ['kimi'],
aliases: ['moonshot'],
},
cursor: {
displayName: 'Cursor',
description: 'Cursor browser-authenticated provider',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'cursor',
authUrlProviderName: 'cursor',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['cursor.', 'cursor-'],
tokenTypeValues: ['cursor'],
aliases: [],
},
gitlab: {
displayName: 'GitLab Duo',
description: 'GitLab Duo with OAuth or PAT auth',
oauthFlow: 'authorization_code',
callbackPort: 17171,
callbackProviderName: 'gitlab',
authUrlProviderName: 'gitlab',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['gitlab-'],
tokenTypeValues: ['gitlab'],
aliases: ['gitlab-duo'],
},
codebuddy: {
displayName: 'CodeBuddy (Tencent)',
description: 'Tencent CodeBuddy AI assistant',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'codebuddy',
authUrlProviderName: 'codebuddy',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['codebuddy-'],
tokenTypeValues: ['codebuddy'],
aliases: ['tencent'],
},
kilo: {
displayName: 'Kilo AI',
description: 'Kilo AI coding assistant',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'kilo',
authUrlProviderName: 'kilo',
refreshOwnership: 'unsupported',
authFilePrefixes: ['kilo-'],
tokenTypeValues: ['kilo'],
aliases: [],
},
};
export const CLIPROXY_PROVIDER_IDS = Object.freeze(
+19 -4
View File
@@ -122,6 +122,10 @@ export interface DownloadResult {
* - ghcp: GitHub Copilot via Device Code (OAuth through CLIProxyAPIPlus)
* - claude: Claude (Anthropic) via OAuth
* - kimi: Kimi (Moonshot AI) via Device Code OAuth
* - cursor: Cursor via PKCE browser polling
* - gitlab: GitLab Duo via OAuth or PAT
* - codebuddy: Tencent CodeBuddy via browser polling
* - kilo: Kilo AI via device flow
*/
export type CLIProxyProvider =
| 'gemini'
@@ -132,12 +136,16 @@ export type CLIProxyProvider =
| 'kiro'
| 'ghcp'
| 'claude'
| 'kimi';
| 'kimi'
| 'cursor'
| 'gitlab'
| 'codebuddy'
| 'kilo';
/**
* CLIProxy backend selection
* - original: CLIProxyAPI (no Kiro/ghcp support)
* - plus: CLIProxyAPIPlus (Kiro/ghcp support, default)
* - original: CLIProxyAPI (legacy provider subset)
* - plus: CLIProxyAPIPlus (expanded provider support, default)
*/
export type CLIProxyBackend = 'original' | 'plus';
@@ -149,7 +157,14 @@ export type CliproxyRoutingStrategy = 'round-robin' | 'fill-first';
/**
* Providers that require CLIProxyAPIPlus backend
*/
export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = ['kiro', 'ghcp'];
export const PLUS_ONLY_PROVIDERS: CLIProxyProvider[] = [
'kiro',
'ghcp',
'cursor',
'gitlab',
'codebuddy',
'kilo',
];
/**
* CLIProxy config.yaml structure (minimal)
+4
View File
@@ -183,6 +183,10 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO
ghcp: 'GitHub Copilot via CLIProxy OAuth',
claude: 'Claude via CLIProxy OAuth',
kimi: 'Kimi via CLIProxy OAuth',
cursor: 'Cursor via CLIProxy OAuth',
gitlab: 'GitLab Duo via CLIProxy OAuth',
codebuddy: 'CodeBuddy via CLIProxy OAuth',
kilo: 'Kilo AI via CLIProxy OAuth',
}[name] || 'CLIProxy OAuth provider',
})
);
+7
View File
@@ -9,6 +9,13 @@ export const RESERVED_PROFILE_NAMES = [
'agy',
'qwen',
'iflow',
'kiro',
'ghcp',
'claude',
'kimi',
'gitlab',
'codebuddy',
'kilo',
// Copilot API (GitHub Copilot proxy)
'copilot',
// Cursor IDE (Cursor proxy daemon)
+1 -1
View File
@@ -121,7 +121,7 @@ export function listClaudeExtensionProfiles(): ClaudeExtensionProfileOption[] {
'default',
...all.accounts,
...all.settings,
...all.cliproxy,
...all.cliproxy.filter((profileName) => profileName !== 'cursor'),
...all.cliproxyVariants,
];
const deduped = [...new Set(orderedNames)];
+145 -1
View File
@@ -229,6 +229,20 @@ function parseKiroIDCFlow(raw: unknown): { flow: KiroIDCFlow; invalid: boolean }
return { flow: normalizeKiroIDCFlow(normalized), invalid: false };
}
function parseGitLabAuthMode(raw: unknown): { mode: 'oauth' | 'pat'; invalid: boolean } {
if (raw === undefined || raw === null || raw === '') {
return { mode: 'oauth', invalid: false };
}
if (typeof raw !== 'string') {
return { mode: 'oauth', invalid: true };
}
const normalized = raw.trim().toLowerCase();
if (normalized === 'oauth' || normalized === 'pat') {
return { mode: normalized, invalid: false };
}
return { mode: 'oauth', invalid: true };
}
export function getKiroStartIDCValidationError(options: {
kiroMethod: KiroAuthMethod;
kiroIDCStartUrl?: string;
@@ -603,6 +617,13 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const kiroIDCRegion =
typeof requestBody.kiroIDCRegion === 'string' ? requestBody.kiroIDCRegion.trim() : undefined;
const kiroIDCFlowRaw = requestBody.kiroIDCFlow;
const gitlabAuthModeRaw = requestBody.gitlabAuthMode;
const gitlabBaseUrl =
typeof requestBody.gitlabBaseUrl === 'string' ? requestBody.gitlabBaseUrl.trim() : undefined;
const gitlabPersonalAccessToken =
typeof requestBody.gitlabPersonalAccessToken === 'string'
? requestBody.gitlabPersonalAccessToken.trim()
: undefined;
const riskAcknowledgement = requestBody.riskAcknowledgement;
const target = getProxyTarget();
if (target.isRemote) {
@@ -613,6 +634,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
const nickname = nicknameRaw?.trim();
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
const { flow: kiroIDCFlow, invalid: invalidKiroIDCFlow } = parseKiroIDCFlow(kiroIDCFlowRaw);
const { mode: gitlabAuthMode, invalid: invalidGitLabAuthMode } =
parseGitLabAuthMode(gitlabAuthModeRaw);
// Validate provider
if (!validProviders.includes(provider as CLIProxyProvider)) {
@@ -628,6 +651,14 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
return;
}
if (provider === 'gitlab' && invalidGitLabAuthMode) {
res.status(400).json({
error: 'Invalid gitlabAuthMode. Supported: oauth, pat',
code: 'INVALID_GITLAB_AUTH_MODE',
});
return;
}
if (provider === 'kiro') {
const kiroIDCValidationError = getKiroStartIDCValidationError({
kiroMethod,
@@ -651,6 +682,89 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
}
}
if (provider === 'gitlab' && gitlabAuthMode === 'pat') {
if (!gitlabPersonalAccessToken) {
res.status(400).json({
error: 'gitlabPersonalAccessToken is required when gitlabAuthMode=pat',
code: 'MISSING_GITLAB_PAT',
});
return;
}
try {
const localProvider = provider as CLIProxyProvider;
const knownTokenFiles = listProviderTokenSnapshots(localProvider);
const response = await fetch(buildProxyUrl(target, '/v0/management/gitlab-auth-url'), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...buildManagementHeaders(target),
},
body: JSON.stringify({
...(gitlabBaseUrl ? { base_url: gitlabBaseUrl } : {}),
personal_access_token: gitlabPersonalAccessToken,
}),
});
const data = (await response.json().catch(() => ({}))) as {
status?: string;
error?: string;
};
if (!response.ok || data.status !== 'ok') {
res.status(response.ok ? 400 : response.status).json({
error: data.error || 'GitLab PAT authentication failed',
});
return;
}
const tokenSnapshot = findNewTokenSnapshot(
listProviderTokenSnapshots(localProvider),
knownTokenFiles
);
if (!tokenSnapshot) {
res.status(409).json({
error: 'GitLab PAT authentication completed, but CCS could not find the saved token.',
});
return;
}
const account = registerAccountFromToken(
localProvider,
getProviderTokenDir(localProvider),
nickname,
false,
tokenSnapshot.file
);
if (!account) {
res.status(409).json({
error: 'GitLab PAT authentication succeeded, but account registration failed.',
});
return;
}
try {
await ensureManagedModelPrefixes([account.provider]);
} catch {
// Keep auth success path non-fatal when prefix repair cannot run.
}
res.json({
success: true,
account: {
id: account.id,
email: account.email,
nickname: account.nickname,
provider: account.provider,
isDefault: account.isDefault,
},
});
return;
} catch (error) {
respondInternalError(res, error, 'Failed to start GitLab PAT flow.');
return;
}
}
const existingAccounts = getProviderAccounts(provider as CLIProxyProvider);
const nicknameError = getStartAuthNicknameError(
provider as CLIProxyProvider,
@@ -681,6 +795,8 @@ router.post('/:provider/start', async (req: Request, res: Response): Promise<voi
kiroIDCStartUrl: provider === 'kiro' ? kiroIDCStartUrl : undefined,
kiroIDCRegion: provider === 'kiro' ? kiroIDCRegion : undefined,
kiroIDCFlow: provider === 'kiro' && kiroMethod === 'idc' ? kiroIDCFlow : undefined,
gitlabAuthMode: provider === 'gitlab' ? gitlabAuthMode : undefined,
gitlabBaseUrl: provider === 'gitlab' ? gitlabBaseUrl : undefined,
fromUI: true, // Enable project selection prompt in UI
noIncognito, // Kiro: use normal browser if enabled
});
@@ -837,9 +953,14 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
req.body && typeof req.body === 'object' ? (req.body as Record<string, unknown>) : {};
const nicknameRaw = typeof requestBody.nickname === 'string' ? requestBody.nickname : undefined;
const kiroMethodRaw = requestBody.kiroMethod;
const gitlabAuthModeRaw = requestBody.gitlabAuthMode;
const gitlabBaseUrl =
typeof requestBody.gitlabBaseUrl === 'string' ? requestBody.gitlabBaseUrl.trim() : undefined;
const riskAcknowledgement = requestBody.riskAcknowledgement;
const nickname = nicknameRaw?.trim();
const { method: kiroMethod, invalid: invalidKiroMethod } = parseKiroMethod(kiroMethodRaw);
const { mode: gitlabAuthMode, invalid: invalidGitLabAuthMode } =
parseGitLabAuthMode(gitlabAuthModeRaw);
// Check remote mode
const target = getProxyTarget();
@@ -862,6 +983,22 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
return;
}
if (provider === 'gitlab' && invalidGitLabAuthMode) {
res.status(400).json({
error: 'Invalid gitlabAuthMode. Supported: oauth, pat',
code: 'INVALID_GITLAB_AUTH_MODE',
});
return;
}
if (provider === 'gitlab' && gitlabAuthMode === 'pat') {
res.status(400).json({
error: 'GitLab PAT login must use /api/cliproxy/auth/gitlab/start',
code: 'GITLAB_PAT_REQUIRES_START',
});
return;
}
if (provider === 'agy' && !isAntigravityResponsibilityBypassEnabled()) {
const validation = validateAntigravityRiskAcknowledgement(riskAcknowledgement);
if (!validation.valid) {
@@ -900,11 +1037,18 @@ router.post('/:provider/start-url', async (req: Request, res: Response): Promise
provider === 'kiro' && kiroManagementMethod
? `&method=${encodeURIComponent(kiroManagementMethod)}`
: '';
const gitlabQuery =
provider === 'gitlab' && gitlabBaseUrl
? `&base_url=${encodeURIComponent(gitlabBaseUrl)}`
: '';
// Call CLIProxyAPI to start OAuth and get auth URL
// CLIProxyAPI management routes are under /v0/management prefix
const response = await fetch(
buildProxyUrl(target, `/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}`),
buildProxyUrl(
target,
`/v0/management/${authUrlProvider}-auth-url?is_webui=true${kiroQuery}${gitlabQuery}`
),
{ headers: buildManagementHeaders(target) }
);
+6
View File
@@ -93,6 +93,12 @@ describe('ProfileDetector', () => {
expect(result.provider).toBe('gemini');
});
it('should detect newly added cliproxy providers', () => {
expect(detector.detectProfileType('gitlab').provider).toBe('gitlab');
expect(detector.detectProfileType('codebuddy').provider).toBe('codebuddy');
expect(detector.detectProfileType('kilo').provider).toBe('kilo');
});
it('should detect settings-based profile from unified config', () => {
const settingsPath = path.join(tempDir, 'glm.settings.json');
fs.writeFileSync(settingsPath, JSON.stringify({ env: { ANTHROPIC_MODEL: 'glm-4' } }));
@@ -121,6 +121,13 @@ describe('Backend Selection', () => {
assert(types.PLUS_ONLY_PROVIDERS.includes('ghcp'));
});
it('includes the newer CLIProxyAPIPlus providers as plus-only', () => {
assert(types.PLUS_ONLY_PROVIDERS.includes('cursor'));
assert(types.PLUS_ONLY_PROVIDERS.includes('gitlab'));
assert(types.PLUS_ONLY_PROVIDERS.includes('codebuddy'));
assert(types.PLUS_ONLY_PROVIDERS.includes('kilo'));
});
it('does not include gemini as plus-only provider', () => {
assert(!types.PLUS_ONLY_PROVIDERS.includes('gemini'));
});
@@ -0,0 +1,21 @@
import { describe, expect, it } from 'bun:test';
import { loadBaseConfig } from '../../../src/cliproxy/base-config-loader';
describe('base-config-loader new providers', () => {
it.each([
['cursor', '/api/provider/cursor', 'composer-2'],
['gitlab', '/api/provider/gitlab', 'gitlab-duo'],
['codebuddy', '/api/provider/codebuddy', 'auto'],
['kilo', '/api/provider/kilo', 'kilo/auto'],
] as const)('loads base settings for %s', (provider, baseUrlPath, defaultModel) => {
const config = loadBaseConfig(provider);
expect(config.env.ANTHROPIC_BASE_URL).toContain(baseUrlPath);
expect(config.env.ANTHROPIC_AUTH_TOKEN).toBe('ccs-internal-managed');
expect(config.env.ANTHROPIC_MODEL).toBe(defaultModel);
expect(config.env.ANTHROPIC_DEFAULT_OPUS_MODEL.length).toBeGreaterThan(0);
expect(config.env.ANTHROPIC_DEFAULT_SONNET_MODEL.length).toBeGreaterThan(0);
expect(config.env.ANTHROPIC_DEFAULT_HAIKU_MODEL.length).toBeGreaterThan(0);
});
});
@@ -41,24 +41,38 @@ describe('provider-capabilities', () => {
'ghcp',
'claude',
'kimi',
'cursor',
'gitlab',
'codebuddy',
'kilo',
]);
});
it('validates provider IDs', () => {
expect(isCLIProxyProvider('gemini')).toBe(true);
expect(isCLIProxyProvider('ghcp')).toBe(true);
expect(isCLIProxyProvider('gitlab')).toBe(true);
expect(isCLIProxyProvider('not-a-provider')).toBe(false);
expect(isCLIProxyProvider('Gemini')).toBe(false);
});
it('returns providers by OAuth flow capability', () => {
expect(getProvidersByOAuthFlow('device_code')).toEqual(['qwen', 'kiro', 'ghcp', 'kimi']);
expect(getProvidersByOAuthFlow('device_code')).toEqual([
'qwen',
'kiro',
'ghcp',
'kimi',
'cursor',
'codebuddy',
'kilo',
]);
expect(getProvidersByOAuthFlow('authorization_code')).toEqual([
'gemini',
'codex',
'agy',
'iflow',
'claude',
'gitlab',
]);
});
@@ -69,6 +83,8 @@ describe('provider-capabilities', () => {
expect(mapExternalProviderName('github-copilot')).toBe('ghcp');
expect(mapExternalProviderName('copilot')).toBe('ghcp');
expect(mapExternalProviderName('anthropic')).toBe('claude');
expect(mapExternalProviderName('gitlab-duo')).toBe('gitlab');
expect(mapExternalProviderName('tencent')).toBe('codebuddy');
expect(mapExternalProviderName(' COPILOT ')).toBe('ghcp');
expect(mapExternalProviderName('')).toBeNull();
expect(mapExternalProviderName('unknown-provider')).toBeNull();
@@ -99,8 +115,11 @@ describe('provider-capabilities', () => {
it('exposes callback port and display name capabilities', () => {
expect(getOAuthCallbackPort('qwen')).toBeNull();
expect(getOAuthCallbackPort('kiro')).toBeNull();
expect(getOAuthCallbackPort('cursor')).toBeNull();
expect(getOAuthCallbackPort('gitlab')).toBe(17171);
expect(getOAuthCallbackPort('gemini')).toBe(8085);
expect(getProviderDisplayName('agy')).toBe('Antigravity');
expect(getProviderDisplayName('kilo')).toBe('Kilo AI');
});
it('throws when provider aliases collide across providers', () => {
@@ -82,6 +82,9 @@ describe('completion backend', () => {
expect(values).toContain('cursor');
expect(values).toContain('copilot');
expect(values).toContain('gemini');
expect(values).toContain('gitlab');
expect(values).toContain('codebuddy');
expect(values).toContain('kilo');
expect(values).toContain('localglm');
expect(values).toContain('work');
expect(values).toContain('my-codex');
@@ -50,6 +50,9 @@ describe('help command parity', () => {
expect(rendered.includes('gemini')).toBe(true);
expect(rendered.includes('codex')).toBe(true);
expect(rendered.includes('ghcp')).toBe(true);
expect(rendered.includes('gitlab')).toBe(true);
expect(rendered.includes('codebuddy')).toBe(true);
expect(rendered.includes('kilo')).toBe(true);
});
test('kiro topic documents IDC and callback flags', async () => {
@@ -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');
});
});
+7
View File
@@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="16" fill="#EFF6FF"/>
<rect x="14" y="16" width="36" height="32" rx="10" fill="#2563EB"/>
<path d="M24 28h16M24 36h10" stroke="#DBEAFE" stroke-width="4" stroke-linecap="round"/>
<circle cx="46" cy="42" r="8" fill="#0F172A"/>
<path d="M43 42h6M46 39v6" stroke="#F8FAFC" stroke-width="3" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 433 B

+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="16" fill="#111827"/>
<path d="M44 21.5a17.4 17.4 0 0 0-11.7-4.5c-9.6 0-17.3 7.6-17.3 17s7.7 17 17.3 17c4.6 0 8.9-1.7 12.1-4.9" stroke="#F9FAFB" stroke-width="6" stroke-linecap="round"/>
<path d="M37.5 25.5 46 32l-8.5 6.5" stroke="#22C55E" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 424 B

+6
View File
@@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="16" fill="#FFF7ED"/>
<path d="M18 44 32 17l14 27-14 10-14-10Z" fill="#FC6D26"/>
<path d="m18 44 5.4-16.2 8.6 26.2L18 44Zm28 0-5.4-16.2L32 54l14-10Z" fill="#E24329"/>
<path d="M23.4 27.8 32 17l8.6 10.8H23.4Z" fill="#FCA326"/>
</svg>

After

Width:  |  Height:  |  Size: 346 B

+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none">
<rect width="64" height="64" rx="16" fill="#FFF1F2"/>
<path d="M20 16h8v14l12-14h10L36.5 31 51 48H40.3L28 33.6V48h-8V16Z" fill="#E11D48"/>
</svg>

After

Width:  |  Height:  |  Size: 223 B

@@ -91,6 +91,9 @@ export function AddAccountDialog({
const [kiroIDCStartUrl, setKiroIDCStartUrl] = useState('');
const [kiroIDCRegion, setKiroIDCRegion] = useState('');
const [kiroIDCFlow, setKiroIDCFlow] = useState<KiroIDCFlow>(DEFAULT_KIRO_IDC_FLOW);
const [gitlabAuthMode, setGitlabAuthMode] = useState<'oauth' | 'pat'>('oauth');
const [gitlabBaseUrl, setGitlabBaseUrl] = useState('');
const [gitlabPersonalAccessToken, setGitlabPersonalAccessToken] = useState('');
const { t } = useTranslation();
const wasAuthenticatingRef = useRef(false);
const powerUserModeRequestIdRef = useRef(0);
@@ -99,6 +102,7 @@ export function AddAccountDialog({
const kiroImportMutation = useKiroImport();
const isKiro = provider === 'kiro';
const isGitLab = provider === 'gitlab';
const supportsPowerUserMode = provider === 'agy' || provider === 'gemini';
const requiresGeminiSafetyAcknowledgement = provider === 'gemini' && !powerUserModeEnabled;
const requiresAgyResponsibilityFlow = provider === 'agy' && !powerUserModeEnabled;
@@ -120,6 +124,8 @@ export function AddAccountDialog({
const nicknameTrimmed = nickname.trim();
const kiroIDCStartUrlTrimmed = kiroIDCStartUrl.trim();
const kiroIDCRegionTrimmed = kiroIDCRegion.trim();
const gitlabBaseUrlTrimmed = gitlabBaseUrl.trim();
const gitlabPersonalAccessTokenTrimmed = gitlabPersonalAccessToken.trim();
const errorMessage = localError || authFlow.error;
const fetchPowerUserModeState = useCallback(async (): Promise<boolean> => {
@@ -191,6 +197,9 @@ export function AddAccountDialog({
setKiroIDCStartUrl('');
setKiroIDCRegion('');
setKiroIDCFlow(DEFAULT_KIRO_IDC_FLOW);
setGitlabAuthMode('oauth');
setGitlabBaseUrl('');
setGitlabPersonalAccessToken('');
powerUserModeRequestIdRef.current += 1;
powerUserModeLoadErrorShownRef.current = false;
wasAuthenticatingRef.current = false;
@@ -310,6 +319,10 @@ export function AddAccountDialog({
setLocalError('IDC Start URL is required for Kiro IAM Identity Center login.');
return;
}
if (isGitLab && gitlabAuthMode === 'pat' && !gitlabPersonalAccessTokenTrimmed) {
setLocalError('GitLab Personal Access Token is required for PAT login.');
return;
}
wasAuthenticatingRef.current = true;
authFlow.startAuth(provider, {
nickname: nicknameTrimmed || undefined,
@@ -317,8 +330,15 @@ export function AddAccountDialog({
kiroIDCStartUrl: isKiroIdc ? kiroIDCStartUrlTrimmed : undefined,
kiroIDCRegion: isKiroIdc && kiroIDCRegionTrimmed ? kiroIDCRegionTrimmed : undefined,
kiroIDCFlow: isKiroIdc ? kiroIDCFlow : undefined,
gitlabAuthMode: isGitLab ? gitlabAuthMode : undefined,
gitlabBaseUrl: isGitLab && gitlabBaseUrlTrimmed ? gitlabBaseUrlTrimmed : undefined,
gitlabPersonalAccessToken:
isGitLab && gitlabAuthMode === 'pat' && gitlabPersonalAccessTokenTrimmed
? gitlabPersonalAccessTokenTrimmed
: undefined,
flowType: isKiro ? selectedKiroFlowType : undefined,
startEndpoint: isKiro ? selectedKiroStartEndpoint : undefined,
startEndpoint:
isKiro ? selectedKiroStartEndpoint : isGitLab && gitlabAuthMode === 'pat' ? 'start' : undefined,
riskAcknowledgement: requiresAgyResponsibilityFlow
? {
version: ANTIGRAVITY_ACK_VERSION,
@@ -512,6 +532,70 @@ export function AddAccountDialog({
</div>
)}
{isGitLab && !showAuthUI && (
<div className="space-y-4 rounded-lg border border-border/60 bg-muted/20 p-4">
<div className="space-y-2">
<Label htmlFor="gitlab-auth-mode">GitLab auth method</Label>
<Select
value={gitlabAuthMode}
onValueChange={(value) => {
setGitlabAuthMode(value as 'oauth' | 'pat');
setLocalError(null);
}}
>
<SelectTrigger id="gitlab-auth-mode">
<SelectValue placeholder="Select GitLab auth method" />
</SelectTrigger>
<SelectContent>
<SelectItem value="oauth">Browser OAuth</SelectItem>
<SelectItem value="pat">Personal Access Token</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Use Browser OAuth for gitlab.com, or PAT for self-hosted and admin-managed setups.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="gitlab-base-url">GitLab URL</Label>
<Input
id="gitlab-base-url"
value={gitlabBaseUrl}
onChange={(e) => {
setGitlabBaseUrl(e.target.value);
setLocalError(null);
}}
placeholder="https://gitlab.com"
disabled={isPending}
/>
<p className="text-xs text-muted-foreground">
Optional. Leave blank for gitlab.com, or set your self-hosted GitLab base URL.
</p>
</div>
{gitlabAuthMode === 'pat' && (
<div className="space-y-2">
<Label htmlFor="gitlab-pat">Personal Access Token</Label>
<Input
id="gitlab-pat"
type="password"
value={gitlabPersonalAccessToken}
onChange={(e) => {
setGitlabPersonalAccessToken(e.target.value);
setLocalError(null);
}}
placeholder="glpat-..."
disabled={isPending}
/>
<p className="text-xs text-muted-foreground">
Token must include at least <span className="font-mono">api</span> and{' '}
<span className="font-mono">read_user</span> scopes.
</p>
</div>
)}
</div>
)}
{/* Nickname input - only show before auth starts */}
{!showAuthUI && (
<div className="space-y-2">
@@ -86,8 +86,12 @@ export function ProviderEditor({
gemini: ['google'],
agy: ['antigravity'],
codex: ['openai'],
cursor: ['cursor'],
gitlab: ['gitlab', 'duo'],
codebuddy: ['codebuddy'],
qwen: ['alibaba', 'qwen'],
iflow: ['iflow'],
kilo: ['kilo'],
kiro: ['kiro', 'aws'],
ghcp: ['github', 'copilot'],
kimi: ['kimi', 'moonshot'],
+5 -1
View File
@@ -1,7 +1,7 @@
/**
* Constants for Quick Setup Wizard
* Provider display info with custom ordering for wizard UI.
* Provider IDs must match CLIPROXY_PROVIDERS from provider-config.ts
* IDs come from the provider-config presentation layer.
*/
import type { ProviderOption } from './types';
@@ -17,9 +17,13 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [
'claude',
'gemini',
'codex',
'cursor',
'gitlab',
'codebuddy',
'qwen',
'kimi',
'iflow',
'kilo',
'kiro',
'ghcp',
];
+6
View File
@@ -30,6 +30,9 @@ interface StartAuthOptions {
kiroIDCStartUrl?: string;
kiroIDCRegion?: string;
kiroIDCFlow?: 'authcode' | 'device';
gitlabAuthMode?: 'oauth' | 'pat';
gitlabBaseUrl?: string;
gitlabPersonalAccessToken?: string;
flowType?: 'authorization_code' | 'device_code';
startEndpoint?: 'start' | 'start-url';
riskAcknowledgement?: {
@@ -272,6 +275,9 @@ export function useCliproxyAuthFlow() {
kiroIDCStartUrl: options?.kiroIDCStartUrl,
kiroIDCRegion: options?.kiroIDCRegion,
kiroIDCFlow: options?.kiroIDCFlow,
gitlabAuthMode: options?.gitlabAuthMode,
gitlabBaseUrl: options?.gitlabBaseUrl,
gitlabPersonalAccessToken: options?.gitlabPersonalAccessToken,
riskAcknowledgement: options?.riskAcknowledgement,
};
+19 -5
View File
@@ -16,12 +16,10 @@ import type { AiProviderFamilyId, AiProviderModelAlias } from '../../../src/clip
/** Canonical list of CLIProxy provider IDs (shared with backend). */
export const CLIPROXY_PROVIDERS = CLIPROXY_PROVIDER_IDS;
/** Union type for CLIProxy provider IDs */
export type CLIProxyProvider = (typeof CLIPROXY_PROVIDERS)[number];
export type ProviderVisualId = CLIProxyProvider | 'openai' | 'vertex';
/** Check if a string is a valid CLIProxy provider */
/** Check if a string is a backend-supported CLIProxy provider. */
export function isValidProvider(provider: string): provider is CLIProxyProvider {
return CLIPROXY_PROVIDERS.includes(provider as CLIProxyProvider);
}
@@ -37,9 +35,13 @@ interface ProviderMetadata {
const SPECIAL_PROVIDER_VISUAL_IDS = ['openai', 'vertex'] as const;
function isPresentationProvider(provider: string): provider is CLIProxyProvider {
return isValidProvider(provider);
}
function isProviderVisualId(provider: string): provider is ProviderVisualId {
return (
isValidProvider(provider) ||
isPresentationProvider(provider) ||
SPECIAL_PROVIDER_VISUAL_IDS.includes(provider as (typeof SPECIAL_PROVIDER_VISUAL_IDS)[number])
);
}
@@ -64,6 +66,10 @@ export const PROVIDER_ASSETS: Partial<Record<ProviderVisualId, string>> = {
qwen: '/assets/providers/qwen-color.svg',
iflow: '/assets/providers/iflow.png',
kiro: '/assets/providers/kiro.png',
cursor: '/assets/providers/cursor.svg',
gitlab: '/assets/providers/gitlab.svg',
codebuddy: '/assets/providers/codebuddy.svg',
kilo: '/assets/providers/kilo.svg',
ghcp: '/assets/providers/copilot.svg',
claude: '/assets/providers/claude.svg',
kimi: '/assets/providers/kimi.svg',
@@ -90,6 +96,10 @@ export const PROVIDER_FALLBACK_VISUALS: Record<ProviderVisualId, ProviderFallbac
qwen: { textClass: 'text-cyan-600', letter: 'Q' },
iflow: { textClass: 'text-indigo-600', letter: 'i' },
kiro: { textClass: 'text-teal-600', letter: 'K' },
cursor: { textClass: 'text-slate-900', letter: 'C' },
gitlab: { textClass: 'text-orange-600', letter: 'G' },
codebuddy: { textClass: 'text-blue-600', letter: 'B' },
kilo: { textClass: 'text-rose-600', letter: 'K' },
ghcp: { textClass: 'text-green-600', letter: 'C' },
kimi: { textClass: 'text-orange-500', letter: 'K' },
openai: { textClass: 'text-slate-900', letter: 'O' },
@@ -223,6 +233,10 @@ export const PROVIDER_COLORS: Record<string, string> = {
iflow: '#f94144',
qwen: '#6236FF',
kiro: '#4d908e',
cursor: '#111827',
gitlab: '#FC6D26',
codebuddy: '#2563EB',
kilo: '#E11D48',
ghcp: '#43aa8b',
claude: '#D97757',
kimi: '#FF6B35',
@@ -247,7 +261,7 @@ export function getProviderDisplayName(provider: unknown): string {
/** Map provider to user-facing short description */
export function getProviderDescription(provider: unknown): string {
const normalized = normalizeProviderInput(provider);
if (!isValidProvider(normalized)) return '';
if (!isPresentationProvider(normalized)) return '';
return PROVIDER_METADATA[normalized].description;
}
@@ -59,6 +59,22 @@ describe('AddAccountDialog power user mode', () => {
vi.clearAllMocks();
fetchMock.mockReset();
vi.stubGlobal('fetch', fetchMock);
Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', {
configurable: true,
value: () => false,
});
Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', {
configurable: true,
value: () => {},
});
Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', {
configurable: true,
value: () => {},
});
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value: () => {},
});
});
afterEach(() => {
@@ -168,4 +184,26 @@ describe('AddAccountDialog power user mode', () => {
expect(screen.queryByText('Power user mode enabled')).not.toBeInTheDocument();
expect(authMocks.startAuth).not.toHaveBeenCalled();
});
it('submits GitLab PAT mode with base URL and token through the shared auth hook', async () => {
render(<AddAccountDialog open onClose={vi.fn()} provider="gitlab" displayName="GitLab Duo" />);
await userEvent.click(screen.getByRole('combobox', { name: 'GitLab auth method' }));
await userEvent.click(screen.getByRole('option', { name: 'Personal Access Token' }));
await userEvent.type(screen.getByLabelText('GitLab URL'), 'https://gitlab.example.com');
await userEvent.type(screen.getByLabelText('Personal Access Token'), 'glpat-test-token');
await userEvent.click(screen.getByRole('button', { name: 'Authenticate' }));
expect(authMocks.startAuth).toHaveBeenCalledWith(
'gitlab',
expect.objectContaining({
gitlabAuthMode: 'pat',
gitlabBaseUrl: 'https://gitlab.example.com',
gitlabPersonalAccessToken: 'glpat-test-token',
startEndpoint: 'start',
})
);
});
});
@@ -307,6 +307,56 @@ describe('useCliproxyAuthFlow', () => {
kiroIDCStartUrl: 'https://d-123.awsapps.com/start',
kiroIDCRegion: 'ca-central-1',
kiroIDCFlow: 'authcode',
gitlabAuthMode: undefined,
gitlabBaseUrl: undefined,
gitlabPersonalAccessToken: undefined,
riskAcknowledgement: undefined,
});
});
it('forwards GitLab PAT options to the backend start endpoint payload', async () => {
let requestBody: Record<string, unknown> | null = null;
vi.stubGlobal(
'fetch',
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input);
if (url.includes('/start')) {
requestBody = JSON.parse(String(init?.body)) as Record<string, unknown>;
return createJsonResponse({
success: true,
account: {
id: 'gitlab-account',
provider: 'gitlab',
},
});
}
return Promise.reject(new Error(`Unexpected fetch: ${url}`));
})
);
const { result } = renderHook(() => useCliproxyAuthFlow(), { wrapper });
await act(async () => {
await result.current.startAuth('gitlab', {
startEndpoint: 'start',
gitlabAuthMode: 'pat',
gitlabBaseUrl: 'https://gitlab.example.com',
gitlabPersonalAccessToken: 'glpat-test-token',
});
});
expect(requestBody).toEqual({
nickname: undefined,
kiroMethod: undefined,
kiroIDCStartUrl: undefined,
kiroIDCRegion: undefined,
kiroIDCFlow: undefined,
gitlabAuthMode: 'pat',
gitlabBaseUrl: 'https://gitlab.example.com',
gitlabPersonalAccessToken: 'glpat-test-token',
riskAcknowledgement: undefined,
});
});
@@ -2,9 +2,14 @@ import { describe, expect, it } from 'vitest';
import {
formatRequestedUpstreamModelRules,
getProviderDescription,
getProviderDisplayName,
getProviderFallbackVisual,
getProviderLogoAsset,
getRequestedUpstreamModelRuleErrors,
getRequestedModelId,
parseRequestedUpstreamModelRules,
PROVIDER_COLORS,
} from '@/lib/provider-config';
describe('provider model mapping helpers', () => {
@@ -42,3 +47,45 @@ describe('provider model mapping helpers', () => {
]);
});
});
describe('provider presentation metadata', () => {
it.each([
['cursor', 'Cursor', 'Cursor browser-authenticated provider', '/assets/providers/cursor.svg'],
['gitlab', 'GitLab Duo', 'GitLab Duo with OAuth or PAT auth', '/assets/providers/gitlab.svg'],
[
'codebuddy',
'CodeBuddy (Tencent)',
'Tencent CodeBuddy AI assistant',
'/assets/providers/codebuddy.svg',
],
['kilo', 'Kilo AI', 'Kilo AI coding assistant', '/assets/providers/kilo.svg'],
])('recognizes %s across dashboard display helpers', (provider, name, description, asset) => {
expect(getProviderDisplayName(provider)).toBe(name);
expect(getProviderDescription(provider)).toBe(description);
expect(getProviderLogoAsset(provider)).toBe(asset);
});
it('provides fallback visuals and brand colors for new providers', () => {
expect(getProviderFallbackVisual('cursor')).toEqual({
textClass: 'text-slate-900',
letter: 'C',
});
expect(getProviderFallbackVisual('gitlab')).toEqual({
textClass: 'text-orange-600',
letter: 'G',
});
expect(getProviderFallbackVisual('codebuddy')).toEqual({
textClass: 'text-blue-600',
letter: 'B',
});
expect(getProviderFallbackVisual('kilo')).toEqual({
textClass: 'text-rose-600',
letter: 'K',
});
expect(PROVIDER_COLORS.cursor).toBe('#111827');
expect(PROVIDER_COLORS.gitlab).toBe('#FC6D26');
expect(PROVIDER_COLORS.codebuddy).toBe('#2563EB');
expect(PROVIDER_COLORS.kilo).toBe('#E11D48');
});
});