feat(cliproxy): add xAI Grok provider

This commit is contained in:
minhbi245 committed 2026-07-17 00:02:12 +07:00
1 parent 1b67c7840b
commit 54a2b4759b
37 files changed
+510 -54

No files matched your search

+2 -1
View File
@@ -50,6 +50,7 @@ Then launch whatever runtime fits the task:
```bash ```bash
ccs ccs
ccs codex ccs codex
ccs xai # Alias: ccs grok
ccs --target droid glm ccs --target droid glm
ccs glm ccs glm
ccs ollama ccs ollama
@@ -61,7 +62,7 @@ CCS gives you one stable command surface while letting you switch between:
- multiple runtimes such as Claude Code, Factory Droid, and Codex CLI - multiple runtimes such as Claude Code, Factory Droid, and Codex CLI
- multiple Claude subscriptions and isolated account contexts - multiple Claude subscriptions and isolated account contexts
- OAuth providers like Codex, Kiro, Claude, Kimi, and more, with legacy - OAuth providers like Codex, xAI/Grok, Kiro, Claude, Kimi, and more, with legacy
Copilot compatibility for existing setups Copilot compatibility for existing setups
- API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp, - API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp,
Novita, Fireworks AI, and Alibaba Coding Plan Novita, Fireworks AI, and Alibaba Coding Plan
+10
View File
@@ -0,0 +1,10 @@
{
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:8317/api/provider/xai",
"ANTHROPIC_AUTH_TOKEN": "ccs-internal-managed",
"ANTHROPIC_MODEL": "grok-build-0.1",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "grok-4.5",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "grok-build-0.1",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "grok-composer-2.5-fast"
}
}
+3 -3
View File
@@ -31,7 +31,7 @@ Developers using Claude Code face these challenges:
CCS provides: CCS provides:
1. **Multi-Account Claude**: Isolated instances via `CLAUDE_CONFIG_DIR` 1. **Multi-Account Claude**: Isolated instances via `CLAUDE_CONFIG_DIR`
2. **OAuth Providers**: Zero-config Gemini, Codex, Antigravity, Kiro, and other active OAuth integrations, with deprecated Copilot compatibility for existing setups 2. **OAuth Providers**: Zero-config Gemini, Codex, xAI/Grok, Antigravity, Kiro, and other active OAuth integrations, with deprecated Copilot compatibility for existing setups
3. **AI Providers**: Dedicated CLIProxy dashboard for Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key families 3. **AI Providers**: Dedicated CLIProxy dashboard for Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key families
4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API 4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API
5. **Visual Dashboard**: React SPA for configuration management 5. **Visual Dashboard**: React SPA for configuration management
@@ -68,8 +68,8 @@ CCS provides:
- Share commands, skills, agents across accounts - Share commands, skills, agents across accounts
### FR-003: OAuth Provider Integration ### FR-003: OAuth Provider Integration
- Support Gemini, Codex, Antigravity, Kiro, and deprecated Copilot compatibility OAuth flows - Support Gemini, Codex, xAI/Grok, Antigravity, Kiro, and deprecated Copilot compatibility OAuth flows
- Browser-based authentication (Authorization Code flow for most, Device Code for ghcp compatibility) - Browser-based authentication with provider-specific Authorization Code, Device Code, or polling flows
- Token caching and refresh - Token caching and refresh
### FR-004: API Profile Management ### FR-004: API Profile Management
+33 -19
View File
@@ -1,6 +1,6 @@
# Provider Integration Flows # Provider Integration Flows
Last Updated: 2026-05-07 Last Updated: 2026-07-16
Detailed provider integration flows including CLIProxyAPI, legacy GLMT compatibility transforms, remote CLIProxy, quota management, and authentication. Detailed provider integration flows including CLIProxyAPI, legacy GLMT compatibility transforms, remote CLIProxy, quota management, and authentication.
@@ -35,13 +35,13 @@ Generated local CLIProxy configs also keep the management dashboard aligned with
+---> OAuth Authentication +---> OAuth Authentication
| | | |
| +---> Authorization Code Flow (port-based) | +---> Authorization Code Flow (port-based)
| | - Gemini, Codex, Antigravity, Kiro (port 9876) | | - Gemini, Codex, Antigravity, iFlow, Claude, GitLab
| | - Opens browser for user auth | | - Opens browser for user auth
| | - Callback to localhost:PORT | | - Callback to localhost:PORT
| | | |
| +---> Device Code Flow (no port needed) | +---> Device Code Flow (no port needed)
| - GitHub Copilot (ghcp, deprecated compatibility) | - xAI/Grok, Kimi, Kiro, Copilot, CodeBuddy, Kilo, and Qoder
| - User enters code at github.com/login/device | - User enters a code on the provider verification page
| - Polls for token completion | - Polls for token completion
| | | |
| +---> Browser URL Polling (no callback port) | +---> Browser URL Polling (no callback port)
@@ -70,23 +70,37 @@ Generated local CLIProxy configs also keep the management dashboard aligned with
+---> Provider APIs +---> Provider APIs
| |
+---> Google (Gemini) +---> Google (Gemini)
+---> GitHub (Codex) +---> OpenAI (Codex)
+---> xAI (Grok)
+---> Antigravity (AGY) +---> Antigravity (AGY)
+---> AWS Kiro (Claude-powered) +---> AWS Kiro (Claude-powered)
+---> GitHub Copilot (ghcp, deprecated compatibility) +---> GitHub Copilot (ghcp, deprecated compatibility)
+---> OpenAI-compatible endpoints +---> OpenAI-compatible endpoints
``` ```
### Supported Hardcoded Providers ### Supported Built-In Providers
| Provider | ID | Auth Method | Port | Binary | | Provider | ID | Auth Method | Callback Port | Backend |
|----------|----|----|------|--------| |----------|----|----|------|--------|
| Gemini | `gemini` | Authorization Code | 9876 | CLIProxyAPI | | Gemini | `gemini` | Authorization Code | 8085 | Original |
| Codex | `codex` | Authorization Code | 9876 | CLIProxyAPI | | Codex | `codex` | Authorization Code | 1455 | Original |
| Antigravity | `agy` | Authorization Code | 9876 | CLIProxyAPI | | xAI (Grok) | `xai` (`grok` CLI alias) | Device Code | none | Original |
| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | 9876 | CLIProxyAPIPlus fork | | Antigravity | `agy` | Authorization Code | 51121 | Original |
| GitHub Copilot (deprecated) | `ghcp` | Device Code | none | CLIProxyAPIPlus fork | | Qwen | `qwen` | Account linking unavailable in bundled runtime | none | Original |
| Cursor | `cursor` | Browser URL polling | none | CLIProxyAPIPlus fork | | iFlow | `iflow` | Authorization Code | 11451 | Original |
| Claude | `claude` | Authorization Code | 54545 | Original |
| Kimi | `kimi` | Device Code | none | Original |
| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | none by default | Plus |
| GitHub Copilot (deprecated) | `ghcp` | Device Code | none | Plus |
| Cursor | `cursor` | Browser URL polling | none | Plus |
| GitLab Duo | `gitlab` | Authorization Code or PAT | 17171 | Plus |
| CodeBuddy | `codebuddy` | Device-style polling | none | Plus |
| Kilo AI | `kilo` | Device Code | none | Plus |
| Qoder | `qoder` | Device Code | none | Plus |
xAI uses CLIProxyAPI's `--xai-login` device flow. CCS stores and discovers the resulting
`xai-*.json` credentials under the canonical `xai` provider. `ccs grok` is a command alias only,
so it shares the same accounts, settings, live model catalog, and routing as `ccs xai`.
### Codex Duplicate-Email Account Identity ### Codex Duplicate-Email Account Identity
@@ -100,16 +114,16 @@ Codex can legitimately produce multiple auth files for the same email when the u
This preserves the user-visible distinction between business and personal Codex sessions while keeping other providers on their existing email-backed identity model. This preserves the user-visible distinction between business and personal Codex sessions while keeping other providers on their existing email-backed identity model.
### Hardcoded Provider Detection ### Built-In Provider Detection
CCS detects hardcoded providers via `profile-detector.ts` and routes through `execClaudeWithCLIProxy()`. CCS derives canonical providers from `provider-capabilities.ts`. `profile-detector.ts` resolves only
explicit CLI aliases before routing through the CLIProxy execution flow.
```typescript ```typescript
// Profile name matching const provider = resolveCLIProxyProviderShortcut(profileName);
const hardcodedProviders = ['gemini', 'codex', 'agy', 'kiro', 'ghcp'];
if (hardcodedProviders.includes(profileName)) { if (provider) {
return execClaudeWithCLIProxy(claudeCli, profileName, args); return execClaudeWithCLIProxy(claudeCli, provider, args);
} }
``` ```
+8 -4
View File
@@ -24,7 +24,10 @@ import {
import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat'; import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat';
import type { CLIProxyProvider } from '../cliproxy/types'; import type { CLIProxyProvider } from '../cliproxy/types';
import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities'; import {
CLIPROXY_PROVIDER_IDS,
resolveCLIProxyProviderShortcut,
} from '../cliproxy/provider-capabilities';
import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants';
import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer'; import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer';
import type { TargetType } from '../targets/target-adapter'; import type { TargetType } from '../targets/target-adapter';
@@ -332,11 +335,12 @@ class ProfileDetector {
} }
// Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...) // Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...)
if (isCLIProxyProvider(profileName)) { const builtinProvider = resolveCLIProxyProviderShortcut(profileName);
if (builtinProvider) {
return { return {
type: 'cliproxy', type: 'cliproxy',
name: profileName, name: builtinProvider,
provider: profileName, provider: builtinProvider,
}; };
} }
@@ -37,11 +37,30 @@ describe('model-catalog compatibility lookups', () => {
}); });
it('maps all dashboard providers to upstream catalog channels', () => { it('maps all dashboard providers to upstream catalog channels', () => {
expect(SYNCABLE_PROVIDERS).toContain('xai');
expect(SYNCABLE_PROVIDERS).toContain('qwen'); expect(SYNCABLE_PROVIDERS).toContain('qwen');
expect(SYNCABLE_PROVIDERS).toContain('iflow'); expect(SYNCABLE_PROVIDERS).toContain('iflow');
expect(SYNCABLE_PROVIDERS).toContain('kiro'); expect(SYNCABLE_PROVIDERS).toContain('kiro');
expect(SYNCABLE_PROVIDERS).toContain('ghcp'); expect(SYNCABLE_PROVIDERS).toContain('ghcp');
expect(PROVIDER_TO_CHANNEL.ghcp).toBe('github-copilot'); expect(PROVIDER_TO_CHANNEL.ghcp).toBe('github-copilot');
expect(PROVIDER_TO_CHANNEL.xai).toBe('xai');
});
it('merges live xAI model metadata while preserving the static coding default', () => {
const catalog = mergeCatalog('xai', [
{ id: 'grok-build-0.1', display_name: 'Grok Build 0.1' },
{
id: 'grok-4.5',
display_name: 'Grok 4.5',
thinking: { levels: ['low', 'medium', 'high'] },
},
]);
expect(catalog?.defaultModel).toBe('grok-build-0.1');
expect(catalog?.models[1]?.thinking).toMatchObject({
type: 'levels',
levels: ['low', 'medium', 'high'],
});
}); });
it('does not re-add stale static-only models when live catalog data is present', () => { it('does not re-add stale static-only models when live catalog data is present', () => {
@@ -63,6 +63,32 @@ describe('Model Catalog', () => {
}); });
}); });
describe('xAI models', () => {
it('contains the xAI provider catalog with the coding default', () => {
const { MODEL_CATALOG } = modelCatalog;
assert(MODEL_CATALOG.xai, 'Should have xai provider');
assert.strictEqual(MODEL_CATALOG.xai.provider, 'xai');
assert.strictEqual(MODEL_CATALOG.xai.displayName, 'xAI (Grok)');
assert.strictEqual(MODEL_CATALOG.xai.defaultModel, 'grok-build-0.1');
});
it('matches the CLIProxyAPI text-model fallback set', () => {
const { MODEL_CATALOG } = modelCatalog;
const ids = MODEL_CATALOG.xai.models.map((model) => model.id);
assert.deepStrictEqual(ids, [
'grok-build-0.1',
'grok-4.5',
'grok-4.3',
'grok-4.20-0309-reasoning',
'grok-4.20-0309-non-reasoning',
'grok-4.20-multi-agent-0309',
'grok-3-mini',
'grok-3-mini-fast',
'grok-composer-2.5-fast',
]);
});
});
describe('AGY models', () => { describe('AGY models', () => {
it('has correct default model', () => { it('has correct default model', () => {
const { MODEL_CATALOG } = modelCatalog; const { MODEL_CATALOG } = modelCatalog;
@@ -11,6 +11,7 @@ import {
getProvidersByOAuthFlow, getProvidersByOAuthFlow,
isCLIProxyProvider, isCLIProxyProvider,
mapExternalProviderName, mapExternalProviderName,
resolveCLIProxyProviderShortcut,
QUOTA_SUPPORTED_PROVIDER_IDS, QUOTA_SUPPORTED_PROVIDER_IDS,
isQuotaSupportedProvider, isQuotaSupportedProvider,
QUOTA_PROVIDER_OPTION_VALUES, QUOTA_PROVIDER_OPTION_VALUES,
@@ -25,6 +26,7 @@ import {
getKiroCallbackPort, getKiroCallbackPort,
getKiroCLIAuthArgs, getKiroCLIAuthArgs,
getKiroCLIAuthFlag, getKiroCLIAuthFlag,
getOAuthConfig,
normalizeKiroIDCFlow, normalizeKiroIDCFlow,
normalizeKiroAuthMethod, normalizeKiroAuthMethod,
OAUTH_CALLBACK_PORTS as AUTH_CALLBACK_PORTS, OAUTH_CALLBACK_PORTS as AUTH_CALLBACK_PORTS,
@@ -36,6 +38,7 @@ describe('provider-capabilities', () => {
expect(CLIPROXY_PROVIDER_IDS).toEqual([ expect(CLIPROXY_PROVIDER_IDS).toEqual([
'gemini', 'gemini',
'codex', 'codex',
'xai',
'agy', 'agy',
'qwen', 'qwen',
'iflow', 'iflow',
@@ -61,6 +64,7 @@ describe('provider-capabilities', () => {
it('returns providers by OAuth flow capability', () => { it('returns providers by OAuth flow capability', () => {
expect(getProvidersByOAuthFlow('device_code')).toEqual([ expect(getProvidersByOAuthFlow('device_code')).toEqual([
'xai',
'qwen', 'qwen',
'kiro', 'kiro',
'ghcp', 'ghcp',
@@ -82,6 +86,7 @@ describe('provider-capabilities', () => {
it('separates browser URL auth providers from verification-code device flows', () => { it('separates browser URL auth providers from verification-code device flows', () => {
expect(getDeviceCodeVerificationProviders()).toEqual([ expect(getDeviceCodeVerificationProviders()).toEqual([
'xai',
'qwen', 'qwen',
'kiro', 'kiro',
'ghcp', 'ghcp',
@@ -101,11 +106,20 @@ describe('provider-capabilities', () => {
expect(mapExternalProviderName('anthropic')).toBe('claude'); expect(mapExternalProviderName('anthropic')).toBe('claude');
expect(mapExternalProviderName('gitlab-duo')).toBe('gitlab'); expect(mapExternalProviderName('gitlab-duo')).toBe('gitlab');
expect(mapExternalProviderName('tencent')).toBe('codebuddy'); expect(mapExternalProviderName('tencent')).toBe('codebuddy');
expect(mapExternalProviderName('grok')).toBe('xai');
expect(mapExternalProviderName('x.ai')).toBe('xai');
expect(mapExternalProviderName(' COPILOT ')).toBe('ghcp'); expect(mapExternalProviderName(' COPILOT ')).toBe('ghcp');
expect(mapExternalProviderName('')).toBeNull(); expect(mapExternalProviderName('')).toBeNull();
expect(mapExternalProviderName('unknown-provider')).toBeNull(); expect(mapExternalProviderName('unknown-provider')).toBeNull();
}); });
it('keeps CLI aliases explicit and canonical', () => {
expect(resolveCLIProxyProviderShortcut('xai')).toBe('xai');
expect(resolveCLIProxyProviderShortcut('grok')).toBe('xai');
expect(resolveCLIProxyProviderShortcut(' GROK ')).toBe('xai');
expect(resolveCLIProxyProviderShortcut('anthropic')).toBeNull();
});
it('exposes quota-supported providers and guards correctly', () => { it('exposes quota-supported providers and guards correctly', () => {
expect(QUOTA_SUPPORTED_PROVIDER_IDS).toEqual(['agy', 'codex', 'claude', 'gemini', 'ghcp']); expect(QUOTA_SUPPORTED_PROVIDER_IDS).toEqual(['agy', 'codex', 'claude', 'gemini', 'ghcp']);
expect(QUOTA_PROVIDER_OPTION_VALUES).toEqual([ expect(QUOTA_PROVIDER_OPTION_VALUES).toEqual([
@@ -130,6 +144,7 @@ describe('provider-capabilities', () => {
it('exposes callback port and display name capabilities', () => { it('exposes callback port and display name capabilities', () => {
expect(getOAuthCallbackPort('qwen')).toBeNull(); expect(getOAuthCallbackPort('qwen')).toBeNull();
expect(getOAuthCallbackPort('xai')).toBeNull();
expect(getOAuthCallbackPort('kiro')).toBeNull(); expect(getOAuthCallbackPort('kiro')).toBeNull();
expect(getOAuthCallbackPort('cursor')).toBeNull(); expect(getOAuthCallbackPort('cursor')).toBeNull();
expect(getOAuthCallbackPort('gitlab')).toBe(17171); expect(getOAuthCallbackPort('gitlab')).toBe(17171);
@@ -138,6 +153,7 @@ describe('provider-capabilities', () => {
expect(PROVIDER_CAPABILITIES.qwen.refreshOwnership).toBe('unsupported'); expect(PROVIDER_CAPABILITIES.qwen.refreshOwnership).toBe('unsupported');
expect(getProviderDisplayName('agy')).toBe('Antigravity'); expect(getProviderDisplayName('agy')).toBe('Antigravity');
expect(getProviderDisplayName('kilo')).toBe('Kilo AI'); expect(getProviderDisplayName('kilo')).toBe('Kilo AI');
expect(getProviderDisplayName('xai')).toBe('xAI (Grok)');
}); });
it('exposes auth start support separately from OAuth flow type', () => { it('exposes auth start support separately from OAuth flow type', () => {
@@ -147,6 +163,15 @@ describe('provider-capabilities', () => {
); );
expect(getUnsupportedAuthStartReason('kiro')).toBeNull(); expect(getUnsupportedAuthStartReason('kiro')).toBeNull();
expect(getUnsupportedAuthStartReason('qoder')).toBeNull(); expect(getUnsupportedAuthStartReason('qoder')).toBeNull();
expect(getUnsupportedAuthStartReason('xai')).toBeNull();
});
it('uses the CLIProxyAPI xAI OAuth command contract', () => {
expect(getOAuthConfig('xai')).toMatchObject({
provider: 'xai',
authFlag: '--xai-login',
});
expect(AUTH_CALLBACK_PORTS.xai).toBeUndefined();
}); });
it('throws when provider aliases collide across providers', () => { it('throws when provider aliases collide across providers', () => {
@@ -128,6 +128,36 @@ describe('Account Manager - discoverExistingAccounts', () => {
assert(accounts.providers.ghcp, 'copilot type should map to ghcp provider'); assert(accounts.providers.ghcp, 'copilot type should map to ghcp provider');
}); });
it('discovers xAI accounts with type="xai"', () => {
createAuthFile('xai-user@example.com.json', {
type: 'xai',
email: 'user@example.com',
});
accountManager.discoverExistingAccounts();
const accounts = getAccountsFile();
assert(accounts.providers.xai, 'xai type should map to xai provider');
assert(accounts.providers.xai.accounts['user@example.com']);
});
it('keeps multiple xAI accounts distinct when token metadata has no email', () => {
createAuthFile('xai-subject-one.json', {
type: 'xai',
});
createAuthFile('xai-subject-two.json', {
type: 'xai',
});
accountManager.discoverExistingAccounts();
const accounts = getAccountsFile();
assert.deepStrictEqual(Object.keys(accounts.providers.xai.accounts).sort(), [
'subject-one',
'subject-two',
]);
});
it('handles case-insensitive type values', () => { it('handles case-insensitive type values', () => {
createAuthFile('kiro-test.json', { createAuthFile('kiro-test.json', {
type: 'KIRO', type: 'KIRO',
+1 -1
View File
@@ -11,7 +11,7 @@ export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
* Providers that typically have empty email in OAuth token files. * Providers that typically have empty email in OAuth token files.
* For these providers, nickname is used as accountId instead of email. * For these providers, nickname is used as accountId instead of email.
*/ */
export const PROVIDERS_WITHOUT_EMAIL: CLIProxyProvider[] = ['kiro', 'ghcp']; export const PROVIDERS_WITHOUT_EMAIL: CLIProxyProvider[] = ['kiro', 'ghcp', 'xai'];
/** Account information */ /** Account information */
export interface AccountInfo { export interface AccountInfo {
@@ -11,6 +11,7 @@ describe('auth-types paste-callback start path', () => {
'/v0/management/gemini-cli-auth-url?is_webui=true' '/v0/management/gemini-cli-auth-url?is_webui=true'
); );
expect(getPasteCallbackStartPath('codex')).toBe('/v0/management/codex-auth-url?is_webui=true'); expect(getPasteCallbackStartPath('codex')).toBe('/v0/management/codex-auth-url?is_webui=true');
expect(getPasteCallbackStartPath('xai')).toBe('/v0/management/xai-auth-url?is_webui=true');
expect(getPasteCallbackStartPath('agy')).toBe( expect(getPasteCallbackStartPath('agy')).toBe(
'/v0/management/antigravity-auth-url?is_webui=true' '/v0/management/antigravity-auth-url?is_webui=true'
); );
@@ -0,0 +1,20 @@
import { describe, expect, it } from 'bun:test';
import { OAUTH_CALLBACK_PORTS } from '../../../management/oauth-port-diagnostics';
import { buildOAuthArgs } from '../oauth-handler';
describe('xAI OAuth process arguments', () => {
it('starts CLIProxy device auth without a callback port argument', () => {
expect(OAUTH_CALLBACK_PORTS.xai).toBeNull();
expect(buildOAuthArgs('xai', '/tmp/cliproxy-config.yaml', false, false)).toEqual([
'--config',
'/tmp/cliproxy-config.yaml',
'--xai-login',
]);
});
it('uses no-browser mode in headless environments', () => {
expect(buildOAuthArgs('xai', '/tmp/cliproxy-config.yaml', true, false)).toContain(
'--no-browser'
);
});
});
@@ -4,11 +4,28 @@ import {
extractLikelyAuthFailureFromLogs, extractLikelyAuthFailureFromLogs,
extractLikelyAuthFailureFromStderr, extractLikelyAuthFailureFromStderr,
extractLikelyOAuthAuthorizationUrl, extractLikelyOAuthAuthorizationUrl,
extractDeviceCodePrompt,
getExpectedLocalCallback, getExpectedLocalCallback,
getKiroBuilderIdSelectionInput, getKiroBuilderIdSelectionInput,
validateManualCallbackUrl, validateManualCallbackUrl,
} from '../oauth-process'; } from '../oauth-process';
describe('oauth-process device code parsing', () => {
it('parses the xAI device authorization output contract', () => {
const output = `
To authenticate, please visit:
https://accounts.x.ai/oauth2/device?user_code=ABCD-1234
Then enter this code: ABCD-1234
`;
expect(extractDeviceCodePrompt(output)).toEqual({
userCode: 'ABCD-1234',
verificationUrl: 'https://accounts.x.ai/oauth2/device?user_code=ABCD-1234',
});
});
});
describe('oauth-process stderr parsing', () => { describe('oauth-process stderr parsing', () => {
it('does not match provider-specific patterns for other providers', () => { it('does not match provider-specific patterns for other providers', () => {
const stderr = const stderr =
+8
View File
@@ -149,6 +149,7 @@ export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google'
* OAuth flow types per provider: * OAuth flow types per provider:
* - Gemini: Authorization Code Flow with local callback server on port 8085 * - Gemini: Authorization Code Flow with local callback server on port 8085
* - Codex: Authorization Code Flow with local callback server on port 1455 * - Codex: Authorization Code Flow with local callback server on port 1455
* - xAI: Device Code Flow (polling-based, NO callback port needed)
* - Agy: Authorization Code Flow with local callback server on port 51121 * - Agy: Authorization Code Flow with local callback server on port 51121
* - iFlow: Authorization Code Flow with local callback server on port 11451 * - iFlow: Authorization Code Flow with local callback server on port 11451
* - Claude: Authorization Code Flow with local callback server on port 54545 (Anthropic OAuth) * - Claude: Authorization Code Flow with local callback server on port 54545 (Anthropic OAuth)
@@ -228,6 +229,13 @@ export const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
scopes: ['openid', 'profile'], scopes: ['openid', 'profile'],
authFlag: '--codex-login', authFlag: '--codex-login',
}, },
xai: {
provider: 'xai',
displayName: 'xAI (Grok)',
authUrl: 'https://accounts.x.ai/oauth2/device',
scopes: ['openid', 'profile', 'email', 'offline_access', 'grok-cli:access', 'api:access'],
authFlag: '--xai-login',
},
agy: { agy: {
provider: 'agy', provider: 'agy',
displayName: 'Antigravity', displayName: 'Antigravity',
+2 -2
View File
@@ -1,7 +1,7 @@
/** /**
* OAuth Handler for CLIProxyAPI * OAuth Handler for CLIProxyAPI
* *
* Manages OAuth authentication flow for CLIProxy providers (Gemini, Codex, Antigravity, Kiro, Copilot). * Manages OAuth authentication flow for CLIProxy providers such as Gemini, Codex, xAI, and Kiro.
* CLIProxyAPI handles OAuth internally - we just need to: * CLIProxyAPI handles OAuth internally - we just need to:
* 1. Check if auth exists (token files in CCS auth directory) * 1. Check if auth exists (token files in CCS auth directory)
* 2. Trigger OAuth flow by spawning binary with auth flag * 2. Trigger OAuth flow by spawning binary with auth flag
@@ -605,7 +605,7 @@ async function prepareBinary(
} }
} }
function buildOAuthArgs( export function buildOAuthArgs(
provider: CLIProxyProvider, provider: CLIProxyProvider,
configPath: string, configPath: string,
headless: boolean, headless: boolean,
+20 -9
View File
@@ -127,6 +127,22 @@ function resolveAuthFlowType(options: OAuthProcessOptions): 'device_code' | 'aut
return options.authFlowType || OAUTH_FLOW_TYPES[options.provider] || 'authorization_code'; return options.authFlowType || OAUTH_FLOW_TYPES[options.provider] || 'authorization_code';
} }
export function extractDeviceCodePrompt(output: string): {
userCode: string;
verificationUrl: string | null;
} | null {
const codeMatch = output.match(
/(?:enter\s+)?(?:this\s+)?(?:user\s+)?code[:\s]+["']?([A-Z0-9]{4,8}[-\s]?[A-Z0-9]{4,8})["']?/i
);
if (!codeMatch) return null;
const urlMatch = output.match(/(https?:\/\/[^\s]+device[^\s]*)/i);
return {
userCode: codeMatch[1].toUpperCase(),
verificationUrl: urlMatch?.[1] ?? null,
};
}
export function isLoopbackHost(hostname: string): boolean { export function isLoopbackHost(hostname: string): boolean {
const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase(); const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase();
return ( return (
@@ -392,19 +408,14 @@ async function handleStdout(
// Handle Device Code Flow: parse and display user code // Handle Device Code Flow: parse and display user code
if (isDeviceCodeFlow && !state.deviceCodeDisplayed) { if (isDeviceCodeFlow && !state.deviceCodeDisplayed) {
// Parse device/user code from various formats: const devicePrompt = extractDeviceCodePrompt(state.accumulatedOutput);
// "Enter code: XXXX-YYYY" or "code XXXX-YYYY" or "user code: XXXX-YYYY"
const codeMatch = state.accumulatedOutput.match(
/(?:enter\s+)?(?:user\s+)?code[:\s]+["']?([A-Z0-9]{4,8}[-\s]?[A-Z0-9]{4,8})["']?/i
);
const urlMatch = state.accumulatedOutput.match(/(https?:\/\/[^\s]+device[^\s]*)/i);
if (codeMatch) { if (devicePrompt) {
state.userCode = codeMatch[1].toUpperCase(); state.userCode = devicePrompt.userCode;
state.deviceCodeDisplayed = true; state.deviceCodeDisplayed = true;
log(`Parsed device code: ${state.userCode}`); log(`Parsed device code: ${state.userCode}`);
const verificationUrl = urlMatch?.[1] || 'https://github.com/login/device'; const verificationUrl = devicePrompt.verificationUrl || 'https://github.com/login/device';
// Emit device code event for WebSocket broadcast to UI // Emit device code event for WebSocket broadcast to UI
const deviceCodePrompt: DeviceCodePrompt = { const deviceCodePrompt: DeviceCodePrompt = {
@@ -4,8 +4,8 @@
* Exports refresh functions for each OAuth provider. * Exports refresh functions for each OAuth provider.
* *
* Refresh responsibility: * Refresh responsibility:
* - CLIProxy-delegated: gemini, codex, agy, kiro, ghcp, iflow, kimi * - CLIProxy-delegated: gemini, codex, xai, agy, kiro, ghcp, iflow, kimi
* (CLIProxyAPIPlus handles refresh automatically in background) * (the selected CLIProxy backend handles refresh automatically in background)
* - Unsupported account linking: qwen * - Unsupported account linking: qwen
* - Not implemented: claude * - Not implemented: claude
*/ */
@@ -9,6 +9,7 @@ describe('base-config-loader new providers', () => {
['codebuddy', '/api/provider/codebuddy', 'auto'], ['codebuddy', '/api/provider/codebuddy', 'auto'],
['kilo', '/api/provider/kilo', 'kilo/auto'], ['kilo', '/api/provider/kilo', 'kilo/auto'],
['qoder', '/api/provider/qoder', 'qoder/auto'], ['qoder', '/api/provider/qoder', 'qoder/auto'],
['xai', '/api/provider/xai', 'grok-build-0.1'],
] as const)('loads base settings for %s', (provider, baseUrlPath, defaultModel) => { ] as const)('loads base settings for %s', (provider, baseUrlPath, defaultModel) => {
const config = loadBaseConfig(provider); const config = loadBaseConfig(provider);
+1 -1
View File
@@ -698,7 +698,7 @@ routing:
// Unified config with enhanced CLIProxyAPI features // Unified config with enhanced CLIProxyAPI features
const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION} const config = `# CLIProxyAPI config generated by CCS v${CLIPROXY_CONFIG_VERSION}
# Supports: gemini, codex, agy, qwen, iflow (concurrent usage) # Supports: gemini, codex, xai, agy, qwen, iflow, and other registered providers (concurrent usage)
# Generated: ${new Date().toISOString()} # Generated: ${new Date().toISOString()}
# #
# This config is auto-managed by CCS. Manual edits may be overwritten. # This config is auto-managed by CCS. Manual edits may be overwritten.
+69
View File
@@ -301,6 +301,75 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
}, },
], ],
}, },
xai: {
provider: 'xai',
displayName: 'xAI (Grok)',
defaultModel: 'grok-build-0.1',
models: [
{
id: 'grok-build-0.1',
name: 'Grok Build 0.1',
description: 'Fast coding model for agentic software engineering workflows',
},
{
id: 'grok-4.5',
name: 'Grok 4.5',
description: 'Frontier model for coding, engineering, and agentic workflows',
thinking: {
type: 'levels',
levels: ['low', 'medium', 'high'],
zeroAllowed: false,
},
},
{
id: 'grok-4.3',
name: 'Grok 4.3',
description: 'General-purpose Grok model with a one-million-token context window',
extendedContext: true,
thinking: {
type: 'levels',
levels: ['none', 'low', 'medium', 'high'],
zeroAllowed: true,
},
},
{
id: 'grok-4.20-0309-reasoning',
name: 'Grok 4.20 0309 Reasoning',
description: 'Reasoning model with a two-million-token context window',
extendedContext: true,
},
{
id: 'grok-4.20-0309-non-reasoning',
name: 'Grok 4.20 0309 Non Reasoning',
description: 'Non-reasoning model with a two-million-token context window',
extendedContext: true,
},
{
id: 'grok-4.20-multi-agent-0309',
name: 'Grok 4.20 Multi Agent 0309',
description: 'Multi-agent model with a two-million-token context window',
extendedContext: true,
thinking: { type: 'levels', levels: ['low', 'medium', 'high'] },
},
{
id: 'grok-3-mini',
name: 'Grok 3 Mini',
description: 'Compact reasoning model',
thinking: { type: 'levels', levels: ['low', 'medium', 'high'] },
},
{
id: 'grok-3-mini-fast',
name: 'Grok 3 Mini Fast',
description: 'Faster compact reasoning model',
thinking: { type: 'levels', levels: ['low', 'medium', 'high'] },
},
{
id: 'grok-composer-2.5-fast',
name: 'Grok Composer 2.5 Fast',
description: 'Fast Composer model for lightweight coding tasks',
},
],
},
qoder: { qoder: {
provider: 'qoder', provider: 'qoder',
displayName: 'Qoder', displayName: 'Qoder',
+46
View File
@@ -24,6 +24,8 @@ export interface ProviderCapabilities {
authFilePrefixes: readonly string[]; authFilePrefixes: readonly string[];
/** Token JSON "type" values accepted for this provider. */ /** Token JSON "type" values accepted for this provider. */
tokenTypeValues: readonly string[]; tokenTypeValues: readonly string[];
/** Alternative names accepted as first-class CCS CLI shortcuts. */
cliAliases?: readonly string[];
/** /**
* Alternative provider names used by CLIProxyAPI or stats endpoints. * Alternative provider names used by CLIProxyAPI or stats endpoints.
* These aliases normalize external names to canonical CCS provider IDs. * These aliases normalize external names to canonical CCS provider IDs.
@@ -58,6 +60,20 @@ export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilitie
tokenTypeValues: ['codex'], tokenTypeValues: ['codex'],
aliases: [], aliases: [],
}, },
xai: {
displayName: 'xAI (Grok)',
description: 'Grok coding and reasoning models',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'xai',
authUrlProviderName: 'xai',
refreshOwnership: 'cliproxy',
authStartSupport: 'cliproxy-cli',
authFilePrefixes: ['xai-'],
tokenTypeValues: ['xai'],
cliAliases: ['grok'],
aliases: ['grok', 'x-ai', 'x.ai'],
},
agy: { agy: {
displayName: 'Antigravity', displayName: 'Antigravity',
description: 'Antigravity AI models', description: 'Antigravity AI models',
@@ -296,10 +312,40 @@ export function buildProviderAliasMap(
const PROVIDER_ALIAS_MAP: ReadonlyMap<string, CLIProxyProvider> = buildProviderAliasMap(); const PROVIDER_ALIAS_MAP: ReadonlyMap<string, CLIProxyProvider> = buildProviderAliasMap();
const PROVIDER_CLI_SHORTCUT_MAP: ReadonlyMap<string, CLIProxyProvider> = (() => {
const shortcutMap = new Map<string, CLIProxyProvider>();
for (const provider of CLIPROXY_PROVIDER_IDS) {
shortcutMap.set(provider, provider);
for (const alias of PROVIDER_CAPABILITIES[provider].cliAliases ?? []) {
const normalized = alias.trim().toLowerCase();
if (!normalized) continue;
const existingProvider = shortcutMap.get(normalized);
if (existingProvider && existingProvider !== provider) {
throw new ConfigError(
`Provider CLI alias collision for "${normalized}": ${existingProvider} and ${provider}`
);
}
shortcutMap.set(normalized, provider);
}
}
return shortcutMap;
})();
export function isCLIProxyProvider(provider: string): provider is CLIProxyProvider { export function isCLIProxyProvider(provider: string): provider is CLIProxyProvider {
return PROVIDER_ID_SET.has(provider as CLIProxyProvider); return PROVIDER_ID_SET.has(provider as CLIProxyProvider);
} }
/** Resolve canonical provider IDs and explicitly supported CLI shortcut aliases. */
export function resolveCLIProxyProviderShortcut(providerName: string): CLIProxyProvider | null {
const normalized = providerName.trim().toLowerCase();
if (!normalized) return null;
return PROVIDER_CLI_SHORTCUT_MAP.get(normalized) ?? null;
}
export function getProviderCLIAliases(provider: CLIProxyProvider): readonly string[] {
return PROVIDER_CAPABILITIES[provider].cliAliases ?? [];
}
export function getProviderCapabilities(provider: CLIProxyProvider): ProviderCapabilities { export function getProviderCapabilities(provider: CLIProxyProvider): ProviderCapabilities {
return PROVIDER_CAPABILITIES[provider]; return PROVIDER_CAPABILITIES[provider];
} }
+1
View File
@@ -33,6 +33,7 @@ const CHANNEL_TO_PROVIDER: Record<string, CLIProxyProvider> = {
claude: 'claude', claude: 'claude',
gemini: 'gemini', gemini: 'gemini',
codex: 'codex', codex: 'codex',
xai: 'xai',
qwen: 'qwen', qwen: 'qwen',
iflow: 'iflow', iflow: 'iflow',
kimi: 'kimi', kimi: 'kimi',
+1
View File
@@ -6,6 +6,7 @@
export type CLIProxyProvider = export type CLIProxyProvider =
| 'gemini' | 'gemini'
| 'codex' | 'codex'
| 'xai'
| 'agy' | 'agy'
| 'qwen' | 'qwen'
| 'iflow' | 'iflow'
+15 -1
View File
@@ -1,5 +1,9 @@
import { COPILOT_SUBCOMMANDS } from '../copilot/constants'; import { COPILOT_SUBCOMMANDS } from '../copilot/constants';
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities'; import {
CLIPROXY_PROVIDER_IDS,
getProviderCLIAliases,
getProviderDisplayName,
} from '../cliproxy/provider-capabilities';
export type HelpTopicName = export type HelpTopicName =
| 'profiles' | 'profiles'
@@ -200,6 +204,7 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO
{ {
gemini: 'Google Gemini via CLIProxy OAuth', gemini: 'Google Gemini via CLIProxy OAuth',
codex: 'OpenAI Codex via CLIProxy OAuth', codex: 'OpenAI Codex via CLIProxy OAuth',
xai: 'xAI Grok via CLIProxy OAuth (alias: grok)',
agy: 'Antigravity via CLIProxy OAuth', agy: 'Antigravity via CLIProxy OAuth',
qwen: 'Qwen Code via CLIProxy; account linking unsupported', qwen: 'Qwen Code via CLIProxy; account linking unsupported',
iflow: 'iFlow via CLIProxy OAuth', iflow: 'iFlow via CLIProxy OAuth',
@@ -216,6 +221,15 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO
}) })
); );
/** Additional top-level CLI aliases; canonical provider lists remain alias-free. */
export const BUILTIN_PROVIDER_ALIAS_SHORTCUTS: readonly ShortcutEntry[] =
CLIPROXY_PROVIDER_IDS.flatMap((provider) =>
getProviderCLIAliases(provider).map((name) => ({
name,
summary: `Alias for ${getProviderDisplayName(provider)} provider shortcut`,
}))
);
export const ROOT_PROFILE_EXAMPLES: readonly ShortcutEntry[] = [ export const ROOT_PROFILE_EXAMPLES: readonly ShortcutEntry[] = [
{ name: 'ccs auth create work', summary: 'Create a concurrent Claude account profile' }, { name: 'ccs auth create work', summary: 'Create a concurrent Claude account profile' },
{ {
+8 -1
View File
@@ -2,6 +2,7 @@ import ProfileDetector from '../auth/profile-detector';
import { import {
API_SUBCOMMANDS, API_SUBCOMMANDS,
AUTH_SUBCOMMANDS, AUTH_SUBCOMMANDS,
BUILTIN_PROVIDER_ALIAS_SHORTCUTS,
BUILTIN_PROVIDER_SHORTCUTS, BUILTIN_PROVIDER_SHORTCUTS,
CLEANUP_FLAGS, CLEANUP_FLAGS,
COMMAND_FLAG_SUGGESTIONS, COMMAND_FLAG_SUGGESTIONS,
@@ -65,6 +66,7 @@ function getTopLevelSuggestions(): CompletionSuggestion[] {
...getPublicRootCommandTokens().map((value) => suggestion(value)), ...getPublicRootCommandTokens().map((value) => suggestion(value)),
...ROOT_COMMAND_FLAGS.map((value) => suggestion(value)), ...ROOT_COMMAND_FLAGS.map((value) => suggestion(value)),
...BUILTIN_PROVIDER_SHORTCUTS.map((entry) => suggestion(entry.name, entry.summary)), ...BUILTIN_PROVIDER_SHORTCUTS.map((entry) => suggestion(entry.name, entry.summary)),
...BUILTIN_PROVIDER_ALIAS_SHORTCUTS.map((entry) => suggestion(entry.name, entry.summary)),
...getDynamicProfileSuggestions(), ...getDynamicProfileSuggestions(),
]; ];
} }
@@ -262,6 +264,7 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
...getProfileNames('settings'), ...getProfileNames('settings'),
...getProfileNames('accounts'), ...getProfileNames('accounts'),
...BUILTIN_PROVIDER_SHORTCUTS.map((entry) => entry.name), ...BUILTIN_PROVIDER_SHORTCUTS.map((entry) => entry.name),
...BUILTIN_PROVIDER_ALIAS_SHORTCUTS.map((entry) => entry.name),
]), ]),
COMMAND_FLAG_SUGGESTIONS.env COMMAND_FLAG_SUGGESTIONS.env
); );
@@ -280,7 +283,11 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg
['--bash', '--zsh', '--fish', '--powershell', '--force', '-f'] ['--bash', '--zsh', '--fish', '--powershell', '--force', '-f']
); );
default: default:
if (BUILTIN_PROVIDER_SHORTCUTS.some((entry) => entry.name === command)) { if (
[...BUILTIN_PROVIDER_SHORTCUTS, ...BUILTIN_PROVIDER_ALIAS_SHORTCUTS].some(
(entry) => entry.name === command
)
) {
if (command === 'agy') if (command === 'agy')
return completeSubcommands([], [...PROVIDER_FLAGS, '--accept-agr-risk']); return completeSubcommands([], [...PROVIDER_FLAGS, '--accept-agr-risk']);
if (command === 'kiro') { if (command === 'kiro') {
+1 -1
View File
@@ -63,7 +63,7 @@ export function generateYamlWithComments(config: UnifiedConfig): string {
// CLIProxy section // CLIProxy section
lines.push('# ----------------------------------------------------------------------------'); lines.push('# ----------------------------------------------------------------------------');
lines.push('# CLIProxy: OAuth-based providers (gemini, codex, agy, qwen, iflow)'); lines.push('# CLIProxy: OAuth-based providers (gemini, codex, xai/grok, agy, qwen, iflow)');
lines.push('# Each variant can reference a *.settings.json file for custom env vars.'); lines.push('# Each variant can reference a *.settings.json file for custom env vars.');
lines.push('# Edit the settings file directly to customize model or other settings.'); lines.push('# Edit the settings file directly to customize model or other settings.');
lines.push( lines.push(
+5 -8
View File
@@ -9,7 +9,7 @@
*/ */
import { info } from '../utils/ui'; import { info } from '../utils/ui';
import { isCLIProxyProvider } from '../cliproxy/provider-capabilities'; import { resolveCLIProxyProviderShortcut } from '../cliproxy/provider-capabilities';
import { isCopilotSubcommandToken } from '../copilot/constants'; import { isCopilotSubcommandToken } from '../copilot/constants';
import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants';
import { isCacheStale } from '../utils/update-checker'; import { isCacheStale } from '../utils/update-checker';
@@ -111,14 +111,11 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise<b
} }
// Provider help shortcut: `ccs gemini --help` → provider-specific help page // Provider help shortcut: `ccs gemini --help` → provider-specific help page
if ( const shortcutProvider =
typeof firstArg === 'string' && typeof firstArg === 'string' ? resolveCLIProxyProviderShortcut(firstArg) : null;
isCLIProxyProvider(firstArg) && if (shortcutProvider && args.length > 1 && (args.includes('--help') || args.includes('-h'))) {
args.length > 1 &&
(args.includes('--help') || args.includes('-h'))
) {
const { showProviderShortcutHelp } = await import('../commands/help-command'); const { showProviderShortcutHelp } = await import('../commands/help-command');
await showProviderShortcutHelp(firstArg); await showProviderShortcutHelp(shortcutProvider);
return true; return true;
} }
+8
View File
@@ -94,12 +94,20 @@ describe('ProfileDetector', () => {
}); });
it('should detect newly added cliproxy providers', () => { it('should detect newly added cliproxy providers', () => {
expect(detector.detectProfileType('xai').provider).toBe('xai');
expect(detector.detectProfileType('gitlab').provider).toBe('gitlab'); expect(detector.detectProfileType('gitlab').provider).toBe('gitlab');
expect(detector.detectProfileType('codebuddy').provider).toBe('codebuddy'); expect(detector.detectProfileType('codebuddy').provider).toBe('codebuddy');
expect(detector.detectProfileType('kilo').provider).toBe('kilo'); expect(detector.detectProfileType('kilo').provider).toBe('kilo');
expect(detector.detectProfileType('qoder').provider).toBe('qoder'); expect(detector.detectProfileType('qoder').provider).toBe('qoder');
}); });
it('should resolve the grok CLI alias to canonical xai routing', () => {
const result = detector.detectProfileType('grok');
expect(result.type).toBe('cliproxy');
expect(result.name).toBe('xai');
expect(result.provider).toBe('xai');
});
it('should detect settings-based profile from unified config', () => { it('should detect settings-based profile from unified config', () => {
const settingsPath = path.join(tempDir, 'glm.settings.json'); const settingsPath = path.join(tempDir, 'glm.settings.json');
fs.writeFileSync(settingsPath, JSON.stringify({ env: { ANTHROPIC_MODEL: 'glm-4' } })); fs.writeFileSync(settingsPath, JSON.stringify({ env: { ANTHROPIC_MODEL: 'glm-4' } }));
@@ -82,6 +82,8 @@ describe('completion backend', () => {
expect(values).toContain('cursor'); expect(values).toContain('cursor');
expect(values).toContain('copilot'); expect(values).toContain('copilot');
expect(values).toContain('gemini'); expect(values).toContain('gemini');
expect(values).toContain('xai');
expect(values).toContain('grok');
expect(values).toContain('gitlab'); expect(values).toContain('gitlab');
expect(values).toContain('codebuddy'); expect(values).toContain('codebuddy');
expect(values).toContain('kilo'); expect(values).toContain('kilo');
@@ -94,6 +96,11 @@ describe('completion backend', () => {
expect(values).not.toContain('--uninstall'); expect(values).not.toContain('--uninstall');
}); });
test('offers provider flags for both xai and grok shortcuts', () => {
expect(suggestionValues(['xai'])).toEqual(expect.arrayContaining(['--auth', '--accounts']));
expect(suggestionValues(['grok'])).toEqual(expect.arrayContaining(['--auth', '--accounts']));
});
test('suggests help topics from the help command', () => { test('suggests help topics from the help command', () => {
const values = suggestionValues(['help']); const values = suggestionValues(['help']);
expect(values).toContain('profiles'); expect(values).toContain('profiles');
@@ -57,6 +57,8 @@ describe('help command parity', () => {
expect(rendered.includes('ccs help kiro')).toBe(true); expect(rendered.includes('ccs help kiro')).toBe(true);
expect(rendered.includes('gemini')).toBe(true); expect(rendered.includes('gemini')).toBe(true);
expect(rendered.includes('codex')).toBe(true); expect(rendered.includes('codex')).toBe(true);
expect(rendered.includes('xai')).toBe(true);
expect(rendered.includes('alias: grok')).toBe(true);
expect(rendered.includes('ghcp')).toBe(true); expect(rendered.includes('ghcp')).toBe(true);
expect(rendered.includes('gitlab')).toBe(true); expect(rendered.includes('gitlab')).toBe(true);
expect(rendered.includes('codebuddy')).toBe(true); expect(rendered.includes('codebuddy')).toBe(true);
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor">
<title>xAI Grok</title>
<path d="M6.469 8.776 16.512 23h-4.464L2.005 8.776H6.47Zm-.004 7.9 2.233 3.164L6.467 23H2l4.465-6.324ZM22 2.582V23h-3.659V7.764L22 2.582ZM22 1l-9.952 14.095-2.233-3.163L17.533 1H22Z"/>
</svg>

After

Width:  |  Height:  |  Size: 301 B

@@ -1,7 +1,7 @@
/** /**
* Add Account Dialog Component * Add Account Dialog Component
* Uses /start-url to get OAuth URL + polls for completion via management API. * Uses /start-url to get OAuth URL + polls for completion via management API.
* For Device Code flows (ghcp, qwen, kiro): Uses /start endpoint which spawns CLIProxy * For verification-code flows: Uses /start endpoint which spawns CLIProxy
* binary and emits WebSocket events. DeviceCodeDialog handles user code display. * binary and emits WebSocket events. DeviceCodeDialog handles user code display.
* Shows auth URL + callback paste field. Polling auto-closes on success. * Shows auth URL + callback paste field. Polling auto-closes on success.
* For Kiro: Also shows "Import from IDE" option. * For Kiro: Also shows "Import from IDE" option.
@@ -17,6 +17,7 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [
'claude', 'claude',
'gemini', 'gemini',
'codex', 'codex',
'xai',
'cursor', 'cursor',
'gitlab', 'gitlab',
'codebuddy', 'codebuddy',
+62
View File
@@ -382,6 +382,68 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
}, },
], ],
}, },
xai: {
provider: 'xai',
displayName: 'xAI (Grok)',
defaultModel: 'grok-build-0.1',
models: [
{
id: 'grok-build-0.1',
name: 'Grok Build 0.1',
description: 'Fast coding model for agentic software engineering workflows',
presetMapping: {
default: 'grok-build-0.1',
opus: 'grok-4.5',
sonnet: 'grok-build-0.1',
haiku: 'grok-composer-2.5-fast',
},
},
{
id: 'grok-4.5',
name: 'Grok 4.5',
description: 'Frontier model for coding, engineering, and agentic workflows',
},
{
id: 'grok-4.3',
name: 'Grok 4.3',
description: 'General-purpose Grok model with a one-million-token context window',
extendedContext: true,
},
{
id: 'grok-4.20-0309-reasoning',
name: 'Grok 4.20 0309 Reasoning',
description: 'Reasoning model with a two-million-token context window',
extendedContext: true,
},
{
id: 'grok-4.20-0309-non-reasoning',
name: 'Grok 4.20 0309 Non Reasoning',
description: 'Non-reasoning model with a two-million-token context window',
extendedContext: true,
},
{
id: 'grok-4.20-multi-agent-0309',
name: 'Grok 4.20 Multi Agent 0309',
description: 'Multi-agent model with a two-million-token context window',
extendedContext: true,
},
{
id: 'grok-3-mini',
name: 'Grok 3 Mini',
description: 'Compact reasoning model',
},
{
id: 'grok-3-mini-fast',
name: 'Grok 3 Mini Fast',
description: 'Faster compact reasoning model',
},
{
id: 'grok-composer-2.5-fast',
name: 'Grok Composer 2.5 Fast',
description: 'Fast Composer model for lightweight coding tasks',
},
],
},
qwen: { qwen: {
provider: 'qwen', provider: 'qwen',
displayName: 'Qwen', displayName: 'Qwen',
+4
View File
@@ -98,6 +98,7 @@ export const PROVIDER_ASSETS: Partial<Record<ProviderVisualId, string>> = {
gemini: '/assets/providers/gemini-color.svg', gemini: '/assets/providers/gemini-color.svg',
agy: '/assets/providers/agy.png', agy: '/assets/providers/agy.png',
codex: '/assets/providers/codex-color.svg', codex: '/assets/providers/codex-color.svg',
xai: '/assets/providers/xai.svg',
qwen: '/assets/providers/qwen-color.svg', qwen: '/assets/providers/qwen-color.svg',
iflow: '/assets/providers/iflow.png', iflow: '/assets/providers/iflow.png',
kiro: '/assets/providers/kiro.png', kiro: '/assets/providers/kiro.png',
@@ -128,6 +129,7 @@ export const PROVIDER_FALLBACK_VISUALS: Record<ProviderVisualId, ProviderFallbac
gemini: { textClass: 'text-blue-600', letter: 'G' }, gemini: { textClass: 'text-blue-600', letter: 'G' },
claude: { textClass: 'text-orange-600', letter: 'C' }, claude: { textClass: 'text-orange-600', letter: 'C' },
codex: { textClass: 'text-emerald-600', letter: 'X' }, codex: { textClass: 'text-emerald-600', letter: 'X' },
xai: { textClass: 'text-slate-900', letter: 'X' },
agy: { textClass: 'text-violet-600', letter: 'A' }, agy: { textClass: 'text-violet-600', letter: 'A' },
qwen: { textClass: 'text-cyan-600', letter: 'Q' }, qwen: { textClass: 'text-cyan-600', letter: 'Q' },
iflow: { textClass: 'text-indigo-600', letter: 'i' }, iflow: { textClass: 'text-indigo-600', letter: 'i' },
@@ -265,6 +267,7 @@ export const PROVIDER_COLORS: Record<string, string> = {
gemini: '#4285F4', gemini: '#4285F4',
agy: '#f3722c', agy: '#f3722c',
codex: '#10a37f', codex: '#10a37f',
xai: '#111827',
openai: '#111827', openai: '#111827',
vertex: '#4285F4', vertex: '#4285F4',
iflow: '#f94144', iflow: '#f94144',
@@ -370,6 +373,7 @@ const DEVICE_CODE_PROVIDER_INSTRUCTIONS: Readonly<Partial<Record<CLIProxyProvide
qwen: 'Sign in with your Qwen account to authorize access.', qwen: 'Sign in with your Qwen account to authorize access.',
kiro: 'Sign in with your selected Kiro auth provider to continue.', kiro: 'Sign in with your selected Kiro auth provider to continue.',
kimi: 'Sign in with your Kimi account and finish the device authorization.', kimi: 'Sign in with your Kimi account and finish the device authorization.',
xai: 'Sign in with your xAI account and finish the Grok device authorization.',
}); });
/** Check if the add-account UI should use the verification-code auth dialog. */ /** Check if the add-account UI should use the verification-code auth dialog. */
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
describe('xAI model catalog defaults', () => {
it('mirrors the CLIProxyAPI text catalog and default tier routing', () => {
const catalog = MODEL_CATALOGS.xai;
const ids = catalog.models.map((model) => model.id);
const defaultModel = catalog.models.find((model) => model.id === catalog.defaultModel);
expect(catalog.displayName).toBe('xAI (Grok)');
expect(catalog.defaultModel).toBe('grok-build-0.1');
expect(ids).toEqual([
'grok-build-0.1',
'grok-4.5',
'grok-4.3',
'grok-4.20-0309-reasoning',
'grok-4.20-0309-non-reasoning',
'grok-4.20-multi-agent-0309',
'grok-3-mini',
'grok-3-mini-fast',
'grok-composer-2.5-fast',
]);
expect(defaultModel?.presetMapping).toEqual({
default: 'grok-build-0.1',
opus: 'grok-4.5',
sonnet: 'grok-build-0.1',
haiku: 'grok-composer-2.5-fast',
});
});
});
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest';
import { import {
CLIPROXY_PROVIDER_SECTIONS, CLIPROXY_PROVIDER_SECTIONS,
CLIPROXY_PROVIDERS,
CORE_CLIPROXY_PROVIDERS, CORE_CLIPROXY_PROVIDERS,
formatRequestedUpstreamModelRules, formatRequestedUpstreamModelRules,
getProviderDescription, getProviderDescription,
@@ -19,6 +20,7 @@ import {
PROVIDER_COLORS, PROVIDER_COLORS,
variantUsesPlusExtraProvider, variantUsesPlusExtraProvider,
} from '@/lib/provider-config'; } from '@/lib/provider-config';
import { PROVIDERS as WIZARD_PROVIDERS } from '@/components/setup/wizard/constants';
describe('provider model mapping helpers', () => { describe('provider model mapping helpers', () => {
it('parses requested=upstream rules into stored upstream+alias pairs', () => { it('parses requested=upstream rules into stored upstream+alias pairs', () => {
@@ -61,6 +63,7 @@ describe('provider presentation metadata', () => {
expect(CLIPROXY_PROVIDER_SECTIONS.map((section) => section.id)).toEqual(['core', 'plus-extra']); expect(CLIPROXY_PROVIDER_SECTIONS.map((section) => section.id)).toEqual(['core', 'plus-extra']);
expect(CORE_CLIPROXY_PROVIDERS).toContain('gemini'); expect(CORE_CLIPROXY_PROVIDERS).toContain('gemini');
expect(CORE_CLIPROXY_PROVIDERS).toContain('kimi'); expect(CORE_CLIPROXY_PROVIDERS).toContain('kimi');
expect(CORE_CLIPROXY_PROVIDERS).toContain('xai');
expect(PLUS_EXTRA_CLIPROXY_PROVIDERS).toEqual([ expect(PLUS_EXTRA_CLIPROXY_PROVIDERS).toEqual([
'kiro', 'kiro',
'ghcp', 'ghcp',
@@ -76,6 +79,12 @@ describe('provider presentation metadata', () => {
expect(isPlusExtraProvider('gemini')).toBe(false); expect(isPlusExtraProvider('gemini')).toBe(false);
}); });
it('keeps every non-deprecated canonical provider available in the setup wizard', () => {
expect(WIZARD_PROVIDERS.map((provider) => provider.id).sort()).toEqual(
CLIPROXY_PROVIDERS.filter((provider) => provider !== 'ghcp').sort()
);
});
it('groups provider-backed data by shared section metadata', () => { it('groups provider-backed data by shared section metadata', () => {
const grouped = groupProvidersBySection( const grouped = groupProvidersBySection(
[ [
@@ -96,6 +105,7 @@ describe('provider presentation metadata', () => {
expect(isDeviceCodeProvider('cursor')).toBe(false); expect(isDeviceCodeProvider('cursor')).toBe(false);
expect(isDeviceCodeProvider('ghcp')).toBe(true); expect(isDeviceCodeProvider('ghcp')).toBe(true);
expect(isDeviceCodeProvider('qwen')).toBe(true); expect(isDeviceCodeProvider('qwen')).toBe(true);
expect(isDeviceCodeProvider('xai')).toBe(true);
}); });
it('detects plus-extra providers inside composite variants', () => { it('detects plus-extra providers inside composite variants', () => {
@@ -120,6 +130,7 @@ describe('provider presentation metadata', () => {
}); });
it.each([ it.each([
['xai', 'xAI (Grok)', 'Grok coding and reasoning models', '/assets/providers/xai.svg'],
['cursor', 'Cursor', 'Cursor browser-authenticated provider', '/assets/sidebar/cursor.svg'], ['cursor', 'Cursor', 'Cursor browser-authenticated provider', '/assets/sidebar/cursor.svg'],
['gitlab', 'GitLab Duo', 'GitLab Duo with OAuth or PAT auth', '/assets/providers/gitlab.svg'], ['gitlab', 'GitLab Duo', 'GitLab Duo with OAuth or PAT auth', '/assets/providers/gitlab.svg'],
[ [
@@ -153,10 +164,15 @@ describe('provider presentation metadata', () => {
textClass: 'text-rose-600', textClass: 'text-rose-600',
letter: 'K', letter: 'K',
}); });
expect(getProviderFallbackVisual('xai')).toEqual({
textClass: 'text-slate-900',
letter: 'X',
});
expect(PROVIDER_COLORS.cursor).toBe('#111827'); expect(PROVIDER_COLORS.cursor).toBe('#111827');
expect(PROVIDER_COLORS.gitlab).toBe('#FC6D26'); expect(PROVIDER_COLORS.gitlab).toBe('#FC6D26');
expect(PROVIDER_COLORS.codebuddy).toBe('#2563EB'); expect(PROVIDER_COLORS.codebuddy).toBe('#2563EB');
expect(PROVIDER_COLORS.kilo).toBe('#E11D48'); expect(PROVIDER_COLORS.kilo).toBe('#E11D48');
expect(PROVIDER_COLORS.xai).toBe('#111827');
}); });
}); });