From 54a2b4759b7bf831ba6801cb71f97b6aef24481c Mon Sep 17 00:00:00 2001 From: minhbi245 <8097269+minhbi245@users.noreply.github.com> Date: Fri, 17 Jul 2026 00:02:12 +0700 Subject: [PATCH] feat(cliproxy): add xAI Grok provider --- README.md | 3 +- config/base-xai.settings.json | 10 +++ docs/project-overview-pdr.md | 6 +- docs/system-architecture/provider-flows.md | 52 +++++++++----- src/auth/profile-detector.ts | 12 ++-- .../__tests__/model-catalog-compat.test.ts | 19 +++++ src/cliproxy/__tests__/model-catalog.test.js | 26 +++++++ .../__tests__/provider-capabilities.test.ts | 25 +++++++ .../account-manager-discover.test.js | 30 ++++++++ src/cliproxy/accounts/types.ts | 2 +- .../auth-types-management-path.test.ts | 1 + .../__tests__/oauth-handler-xai-args.test.ts | 20 ++++++ .../oauth-process-error-parser.test.ts | 17 +++++ src/cliproxy/auth/auth-types.ts | 8 +++ src/cliproxy/auth/oauth-handler.ts | 4 +- src/cliproxy/auth/oauth-process.ts | 29 +++++--- .../auth/provider-refreshers/index.ts | 4 +- .../__tests__/base-config-loader.test.ts | 1 + src/cliproxy/config/generator.ts | 2 +- src/cliproxy/model-catalog.ts | 69 +++++++++++++++++++ src/cliproxy/provider-capabilities.ts | 46 +++++++++++++ src/cliproxy/services/catalog-cache.ts | 1 + src/cliproxy/types/provider-types.ts | 1 + src/commands/command-catalog.ts | 16 ++++- src/commands/completion-backend.ts | 9 ++- src/config/loader/yaml-serializer.ts | 2 +- src/dispatcher/pre-dispatch.ts | 13 ++-- tests/unit/auth/profile-detector.test.ts | 8 +++ .../unit/commands/completion-backend.test.ts | 7 ++ .../unit/commands/help-command-parity.test.ts | 2 + ui/public/assets/providers/xai.svg | 4 ++ .../components/account/add-account-dialog.tsx | 2 +- ui/src/components/setup/wizard/constants.ts | 1 + ui/src/lib/model-catalogs.ts | 62 +++++++++++++++++ ui/src/lib/provider-config.ts | 4 ++ .../unit/ui/lib/model-catalogs-xai.test.ts | 30 ++++++++ ui/tests/unit/ui/lib/provider-config.test.ts | 16 +++++ 37 files changed, 510 insertions(+), 54 deletions(-) create mode 100644 config/base-xai.settings.json create mode 100644 src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts create mode 100644 ui/public/assets/providers/xai.svg create mode 100644 ui/tests/unit/ui/lib/model-catalogs-xai.test.ts diff --git a/README.md b/README.md index 9eea7e95..bb57a1b4 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,7 @@ Then launch whatever runtime fits the task: ```bash ccs ccs codex +ccs xai # Alias: ccs grok ccs --target droid glm ccs glm 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 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 - API and local-model profiles like GLM, Kimi, OpenRouter, Ollama, llama.cpp, Novita, Fireworks AI, and Alibaba Coding Plan diff --git a/config/base-xai.settings.json b/config/base-xai.settings.json new file mode 100644 index 00000000..0a908b8e --- /dev/null +++ b/config/base-xai.settings.json @@ -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" + } +} diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index d4e6449c..de9d4f86 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -31,7 +31,7 @@ Developers using Claude Code face these challenges: CCS provides: 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 4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API 5. **Visual Dashboard**: React SPA for configuration management @@ -68,8 +68,8 @@ CCS provides: - Share commands, skills, agents across accounts ### FR-003: OAuth Provider Integration -- Support Gemini, Codex, Antigravity, Kiro, and deprecated Copilot compatibility OAuth flows -- Browser-based authentication (Authorization Code flow for most, Device Code for ghcp compatibility) +- Support Gemini, Codex, xAI/Grok, Antigravity, Kiro, and deprecated Copilot compatibility OAuth flows +- Browser-based authentication with provider-specific Authorization Code, Device Code, or polling flows - Token caching and refresh ### FR-004: API Profile Management diff --git a/docs/system-architecture/provider-flows.md b/docs/system-architecture/provider-flows.md index 98c2e49d..39b516c3 100644 --- a/docs/system-architecture/provider-flows.md +++ b/docs/system-architecture/provider-flows.md @@ -1,6 +1,6 @@ # 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. @@ -35,13 +35,13 @@ Generated local CLIProxy configs also keep the management dashboard aligned with +---> OAuth Authentication | | | +---> Authorization Code Flow (port-based) - | | - Gemini, Codex, Antigravity, Kiro (port 9876) + | | - Gemini, Codex, Antigravity, iFlow, Claude, GitLab | | - Opens browser for user auth | | - Callback to localhost:PORT | | | +---> Device Code Flow (no port needed) - | - GitHub Copilot (ghcp, deprecated compatibility) - | - User enters code at github.com/login/device + | - xAI/Grok, Kimi, Kiro, Copilot, CodeBuddy, Kilo, and Qoder + | - User enters a code on the provider verification page | - Polls for token completion | | | +---> Browser URL Polling (no callback port) @@ -70,23 +70,37 @@ Generated local CLIProxy configs also keep the management dashboard aligned with +---> Provider APIs | +---> Google (Gemini) - +---> GitHub (Codex) + +---> OpenAI (Codex) + +---> xAI (Grok) +---> Antigravity (AGY) +---> AWS Kiro (Claude-powered) +---> GitHub Copilot (ghcp, deprecated compatibility) +---> 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 | -| Codex | `codex` | Authorization Code | 9876 | CLIProxyAPI | -| Antigravity | `agy` | Authorization Code | 9876 | CLIProxyAPI | -| Kiro (AWS) | `kiro` | Method-aware (default: Device Code) | 9876 | CLIProxyAPIPlus fork | -| GitHub Copilot (deprecated) | `ghcp` | Device Code | none | CLIProxyAPIPlus fork | -| Cursor | `cursor` | Browser URL polling | none | CLIProxyAPIPlus fork | +| Gemini | `gemini` | Authorization Code | 8085 | Original | +| Codex | `codex` | Authorization Code | 1455 | Original | +| xAI (Grok) | `xai` (`grok` CLI alias) | Device Code | none | Original | +| Antigravity | `agy` | Authorization Code | 51121 | Original | +| Qwen | `qwen` | Account linking unavailable in bundled runtime | none | Original | +| 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 @@ -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. -### 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 -// Profile name matching -const hardcodedProviders = ['gemini', 'codex', 'agy', 'kiro', 'ghcp']; +const provider = resolveCLIProxyProviderShortcut(profileName); -if (hardcodedProviders.includes(profileName)) { - return execClaudeWithCLIProxy(claudeCli, profileName, args); +if (provider) { + return execClaudeWithCLIProxy(claudeCli, provider, args); } ``` diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index 48948a8e..dbac96b3 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -24,7 +24,10 @@ import { import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat'; 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 { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer'; import type { TargetType } from '../targets/target-adapter'; @@ -332,11 +335,12 @@ class ProfileDetector { } // Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...) - if (isCLIProxyProvider(profileName)) { + const builtinProvider = resolveCLIProxyProviderShortcut(profileName); + if (builtinProvider) { return { type: 'cliproxy', - name: profileName, - provider: profileName, + name: builtinProvider, + provider: builtinProvider, }; } diff --git a/src/cliproxy/__tests__/model-catalog-compat.test.ts b/src/cliproxy/__tests__/model-catalog-compat.test.ts index 9fe98a30..d7e4dfda 100644 --- a/src/cliproxy/__tests__/model-catalog-compat.test.ts +++ b/src/cliproxy/__tests__/model-catalog-compat.test.ts @@ -37,11 +37,30 @@ describe('model-catalog compatibility lookups', () => { }); it('maps all dashboard providers to upstream catalog channels', () => { + expect(SYNCABLE_PROVIDERS).toContain('xai'); expect(SYNCABLE_PROVIDERS).toContain('qwen'); expect(SYNCABLE_PROVIDERS).toContain('iflow'); expect(SYNCABLE_PROVIDERS).toContain('kiro'); expect(SYNCABLE_PROVIDERS).toContain('ghcp'); 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', () => { diff --git a/src/cliproxy/__tests__/model-catalog.test.js b/src/cliproxy/__tests__/model-catalog.test.js index 34d0a133..29ac5d5d 100644 --- a/src/cliproxy/__tests__/model-catalog.test.js +++ b/src/cliproxy/__tests__/model-catalog.test.js @@ -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', () => { it('has correct default model', () => { const { MODEL_CATALOG } = modelCatalog; diff --git a/src/cliproxy/__tests__/provider-capabilities.test.ts b/src/cliproxy/__tests__/provider-capabilities.test.ts index b9665bd2..1b480026 100644 --- a/src/cliproxy/__tests__/provider-capabilities.test.ts +++ b/src/cliproxy/__tests__/provider-capabilities.test.ts @@ -11,6 +11,7 @@ import { getProvidersByOAuthFlow, isCLIProxyProvider, mapExternalProviderName, + resolveCLIProxyProviderShortcut, QUOTA_SUPPORTED_PROVIDER_IDS, isQuotaSupportedProvider, QUOTA_PROVIDER_OPTION_VALUES, @@ -25,6 +26,7 @@ import { getKiroCallbackPort, getKiroCLIAuthArgs, getKiroCLIAuthFlag, + getOAuthConfig, normalizeKiroIDCFlow, normalizeKiroAuthMethod, OAUTH_CALLBACK_PORTS as AUTH_CALLBACK_PORTS, @@ -36,6 +38,7 @@ describe('provider-capabilities', () => { expect(CLIPROXY_PROVIDER_IDS).toEqual([ 'gemini', 'codex', + 'xai', 'agy', 'qwen', 'iflow', @@ -61,6 +64,7 @@ describe('provider-capabilities', () => { it('returns providers by OAuth flow capability', () => { expect(getProvidersByOAuthFlow('device_code')).toEqual([ + 'xai', 'qwen', 'kiro', 'ghcp', @@ -82,6 +86,7 @@ describe('provider-capabilities', () => { it('separates browser URL auth providers from verification-code device flows', () => { expect(getDeviceCodeVerificationProviders()).toEqual([ + 'xai', 'qwen', 'kiro', 'ghcp', @@ -101,11 +106,20 @@ describe('provider-capabilities', () => { expect(mapExternalProviderName('anthropic')).toBe('claude'); expect(mapExternalProviderName('gitlab-duo')).toBe('gitlab'); expect(mapExternalProviderName('tencent')).toBe('codebuddy'); + expect(mapExternalProviderName('grok')).toBe('xai'); + expect(mapExternalProviderName('x.ai')).toBe('xai'); expect(mapExternalProviderName(' COPILOT ')).toBe('ghcp'); expect(mapExternalProviderName('')).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', () => { expect(QUOTA_SUPPORTED_PROVIDER_IDS).toEqual(['agy', 'codex', 'claude', 'gemini', 'ghcp']); expect(QUOTA_PROVIDER_OPTION_VALUES).toEqual([ @@ -130,6 +144,7 @@ describe('provider-capabilities', () => { it('exposes callback port and display name capabilities', () => { expect(getOAuthCallbackPort('qwen')).toBeNull(); + expect(getOAuthCallbackPort('xai')).toBeNull(); expect(getOAuthCallbackPort('kiro')).toBeNull(); expect(getOAuthCallbackPort('cursor')).toBeNull(); expect(getOAuthCallbackPort('gitlab')).toBe(17171); @@ -138,6 +153,7 @@ describe('provider-capabilities', () => { expect(PROVIDER_CAPABILITIES.qwen.refreshOwnership).toBe('unsupported'); expect(getProviderDisplayName('agy')).toBe('Antigravity'); expect(getProviderDisplayName('kilo')).toBe('Kilo AI'); + expect(getProviderDisplayName('xai')).toBe('xAI (Grok)'); }); it('exposes auth start support separately from OAuth flow type', () => { @@ -147,6 +163,15 @@ describe('provider-capabilities', () => { ); expect(getUnsupportedAuthStartReason('kiro')).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', () => { diff --git a/src/cliproxy/accounts/__tests__/account-manager-discover.test.js b/src/cliproxy/accounts/__tests__/account-manager-discover.test.js index 31cc52a1..9899ad7a 100644 --- a/src/cliproxy/accounts/__tests__/account-manager-discover.test.js +++ b/src/cliproxy/accounts/__tests__/account-manager-discover.test.js @@ -128,6 +128,36 @@ describe('Account Manager - discoverExistingAccounts', () => { 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', () => { createAuthFile('kiro-test.json', { type: 'KIRO', diff --git a/src/cliproxy/accounts/types.ts b/src/cliproxy/accounts/types.ts index f64b2cd6..839510ee 100644 --- a/src/cliproxy/accounts/types.ts +++ b/src/cliproxy/accounts/types.ts @@ -11,7 +11,7 @@ export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown'; * Providers that typically have empty email in OAuth token files. * 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 */ export interface AccountInfo { diff --git a/src/cliproxy/auth/__tests__/auth-types-management-path.test.ts b/src/cliproxy/auth/__tests__/auth-types-management-path.test.ts index 221f0f57..d81b97a3 100644 --- a/src/cliproxy/auth/__tests__/auth-types-management-path.test.ts +++ b/src/cliproxy/auth/__tests__/auth-types-management-path.test.ts @@ -11,6 +11,7 @@ describe('auth-types paste-callback start path', () => { '/v0/management/gemini-cli-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( '/v0/management/antigravity-auth-url?is_webui=true' ); diff --git a/src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts b/src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts new file mode 100644 index 00000000..6a91c889 --- /dev/null +++ b/src/cliproxy/auth/__tests__/oauth-handler-xai-args.test.ts @@ -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' + ); + }); +}); diff --git a/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts b/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts index 87756f16..9ae13c83 100644 --- a/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts +++ b/src/cliproxy/auth/__tests__/oauth-process-error-parser.test.ts @@ -4,11 +4,28 @@ import { extractLikelyAuthFailureFromLogs, extractLikelyAuthFailureFromStderr, extractLikelyOAuthAuthorizationUrl, + extractDeviceCodePrompt, getExpectedLocalCallback, getKiroBuilderIdSelectionInput, validateManualCallbackUrl, } 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', () => { it('does not match provider-specific patterns for other providers', () => { const stderr = diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index ee44afa1..3e321a8b 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -149,6 +149,7 @@ export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google' * OAuth flow types per provider: * - Gemini: Authorization Code Flow with local callback server on port 8085 * - 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 * - iFlow: Authorization Code Flow with local callback server on port 11451 * - Claude: Authorization Code Flow with local callback server on port 54545 (Anthropic OAuth) @@ -228,6 +229,13 @@ export const OAUTH_CONFIGS: Record = { scopes: ['openid', 'profile'], 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: { provider: 'agy', displayName: 'Antigravity', diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index 2cde9b35..14264460 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -1,7 +1,7 @@ /** * 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: * 1. Check if auth exists (token files in CCS auth directory) * 2. Trigger OAuth flow by spawning binary with auth flag @@ -605,7 +605,7 @@ async function prepareBinary( } } -function buildOAuthArgs( +export function buildOAuthArgs( provider: CLIProxyProvider, configPath: string, headless: boolean, diff --git a/src/cliproxy/auth/oauth-process.ts b/src/cliproxy/auth/oauth-process.ts index 10c9a806..f94b77f7 100644 --- a/src/cliproxy/auth/oauth-process.ts +++ b/src/cliproxy/auth/oauth-process.ts @@ -127,6 +127,22 @@ function resolveAuthFlowType(options: OAuthProcessOptions): 'device_code' | 'aut 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 { const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase(); return ( @@ -392,19 +408,14 @@ async function handleStdout( // Handle Device Code Flow: parse and display user code if (isDeviceCodeFlow && !state.deviceCodeDisplayed) { - // Parse device/user code from various formats: - // "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); + const devicePrompt = extractDeviceCodePrompt(state.accumulatedOutput); - if (codeMatch) { - state.userCode = codeMatch[1].toUpperCase(); + if (devicePrompt) { + state.userCode = devicePrompt.userCode; state.deviceCodeDisplayed = true; 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 const deviceCodePrompt: DeviceCodePrompt = { diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts index aa9925f5..3615f806 100644 --- a/src/cliproxy/auth/provider-refreshers/index.ts +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -4,8 +4,8 @@ * Exports refresh functions for each OAuth provider. * * Refresh responsibility: - * - CLIProxy-delegated: gemini, codex, agy, kiro, ghcp, iflow, kimi - * (CLIProxyAPIPlus handles refresh automatically in background) + * - CLIProxy-delegated: gemini, codex, xai, agy, kiro, ghcp, iflow, kimi + * (the selected CLIProxy backend handles refresh automatically in background) * - Unsupported account linking: qwen * - Not implemented: claude */ diff --git a/src/cliproxy/config/__tests__/base-config-loader.test.ts b/src/cliproxy/config/__tests__/base-config-loader.test.ts index 4a5bd42a..f1a7dde3 100644 --- a/src/cliproxy/config/__tests__/base-config-loader.test.ts +++ b/src/cliproxy/config/__tests__/base-config-loader.test.ts @@ -9,6 +9,7 @@ describe('base-config-loader new providers', () => { ['codebuddy', '/api/provider/codebuddy', 'auto'], ['kilo', '/api/provider/kilo', 'kilo/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) => { const config = loadBaseConfig(provider); diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index a12906fb..4bddff8a 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -698,7 +698,7 @@ routing: // Unified config with enhanced CLIProxyAPI features 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()} # # This config is auto-managed by CCS. Manual edits may be overwritten. diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index 94cf22b6..f698b590 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -301,6 +301,75 @@ export const MODEL_CATALOG: Partial> = }, ], }, + 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: { provider: 'qoder', displayName: 'Qoder', diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index b72bf07f..b9436271 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -24,6 +24,8 @@ export interface ProviderCapabilities { authFilePrefixes: readonly string[]; /** Token JSON "type" values accepted for this provider. */ tokenTypeValues: readonly string[]; + /** Alternative names accepted as first-class CCS CLI shortcuts. */ + cliAliases?: readonly string[]; /** * Alternative provider names used by CLIProxyAPI or stats endpoints. * These aliases normalize external names to canonical CCS provider IDs. @@ -58,6 +60,20 @@ export const PROVIDER_CAPABILITIES: Record = buildProviderAliasMap(); +const PROVIDER_CLI_SHORTCUT_MAP: ReadonlyMap = (() => { + const shortcutMap = new Map(); + 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 { 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 { return PROVIDER_CAPABILITIES[provider]; } diff --git a/src/cliproxy/services/catalog-cache.ts b/src/cliproxy/services/catalog-cache.ts index 75fe40d6..429a41e0 100644 --- a/src/cliproxy/services/catalog-cache.ts +++ b/src/cliproxy/services/catalog-cache.ts @@ -33,6 +33,7 @@ const CHANNEL_TO_PROVIDER: Record = { claude: 'claude', gemini: 'gemini', codex: 'codex', + xai: 'xai', qwen: 'qwen', iflow: 'iflow', kimi: 'kimi', diff --git a/src/cliproxy/types/provider-types.ts b/src/cliproxy/types/provider-types.ts index e96fade7..e241d259 100644 --- a/src/cliproxy/types/provider-types.ts +++ b/src/cliproxy/types/provider-types.ts @@ -6,6 +6,7 @@ export type CLIProxyProvider = | 'gemini' | 'codex' + | 'xai' | 'agy' | 'qwen' | 'iflow' diff --git a/src/commands/command-catalog.ts b/src/commands/command-catalog.ts index e5e269b9..1623b9b2 100644 --- a/src/commands/command-catalog.ts +++ b/src/commands/command-catalog.ts @@ -1,5 +1,9 @@ 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 = | 'profiles' @@ -200,6 +204,7 @@ export const BUILTIN_PROVIDER_SHORTCUTS: readonly ShortcutEntry[] = CLIPROXY_PRO { gemini: 'Google Gemini via CLIProxy OAuth', codex: 'OpenAI Codex via CLIProxy OAuth', + xai: 'xAI Grok via CLIProxy OAuth (alias: grok)', agy: 'Antigravity via CLIProxy OAuth', qwen: 'Qwen Code via CLIProxy; account linking unsupported', 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[] = [ { name: 'ccs auth create work', summary: 'Create a concurrent Claude account profile' }, { diff --git a/src/commands/completion-backend.ts b/src/commands/completion-backend.ts index 1838db9c..6d6816cd 100644 --- a/src/commands/completion-backend.ts +++ b/src/commands/completion-backend.ts @@ -2,6 +2,7 @@ import ProfileDetector from '../auth/profile-detector'; import { API_SUBCOMMANDS, AUTH_SUBCOMMANDS, + BUILTIN_PROVIDER_ALIAS_SHORTCUTS, BUILTIN_PROVIDER_SHORTCUTS, CLEANUP_FLAGS, COMMAND_FLAG_SUGGESTIONS, @@ -65,6 +66,7 @@ function getTopLevelSuggestions(): CompletionSuggestion[] { ...getPublicRootCommandTokens().map((value) => suggestion(value)), ...ROOT_COMMAND_FLAGS.map((value) => suggestion(value)), ...BUILTIN_PROVIDER_SHORTCUTS.map((entry) => suggestion(entry.name, entry.summary)), + ...BUILTIN_PROVIDER_ALIAS_SHORTCUTS.map((entry) => suggestion(entry.name, entry.summary)), ...getDynamicProfileSuggestions(), ]; } @@ -262,6 +264,7 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg ...getProfileNames('settings'), ...getProfileNames('accounts'), ...BUILTIN_PROVIDER_SHORTCUTS.map((entry) => entry.name), + ...BUILTIN_PROVIDER_ALIAS_SHORTCUTS.map((entry) => entry.name), ]), COMMAND_FLAG_SUGGESTIONS.env ); @@ -280,7 +283,11 @@ function getSuggestionsForCommand(tokensBeforeCurrent: string[]): CompletionSugg ['--bash', '--zsh', '--fish', '--powershell', '--force', '-f'] ); 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') return completeSubcommands([], [...PROVIDER_FLAGS, '--accept-agr-risk']); if (command === 'kiro') { diff --git a/src/config/loader/yaml-serializer.ts b/src/config/loader/yaml-serializer.ts index bd511b53..d93ca244 100644 --- a/src/config/loader/yaml-serializer.ts +++ b/src/config/loader/yaml-serializer.ts @@ -63,7 +63,7 @@ export function generateYamlWithComments(config: UnifiedConfig): string { // CLIProxy section 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('# Edit the settings file directly to customize model or other settings.'); lines.push( diff --git a/src/dispatcher/pre-dispatch.ts b/src/dispatcher/pre-dispatch.ts index 32b4c897..a47bed12 100644 --- a/src/dispatcher/pre-dispatch.ts +++ b/src/dispatcher/pre-dispatch.ts @@ -9,7 +9,7 @@ */ import { info } from '../utils/ui'; -import { isCLIProxyProvider } from '../cliproxy/provider-capabilities'; +import { resolveCLIProxyProviderShortcut } from '../cliproxy/provider-capabilities'; import { isCopilotSubcommandToken } from '../copilot/constants'; import { isCursorSubcommandToken, LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants'; import { isCacheStale } from '../utils/update-checker'; @@ -111,14 +111,11 @@ export async function runPreDispatchHandlers(ctx: PreDispatchContext): Promise 1 && - (args.includes('--help') || args.includes('-h')) - ) { + const shortcutProvider = + typeof firstArg === 'string' ? resolveCLIProxyProviderShortcut(firstArg) : null; + if (shortcutProvider && args.length > 1 && (args.includes('--help') || args.includes('-h'))) { const { showProviderShortcutHelp } = await import('../commands/help-command'); - await showProviderShortcutHelp(firstArg); + await showProviderShortcutHelp(shortcutProvider); return true; } diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index 8769615b..c028453a 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -94,12 +94,20 @@ describe('ProfileDetector', () => { }); it('should detect newly added cliproxy providers', () => { + expect(detector.detectProfileType('xai').provider).toBe('xai'); expect(detector.detectProfileType('gitlab').provider).toBe('gitlab'); expect(detector.detectProfileType('codebuddy').provider).toBe('codebuddy'); expect(detector.detectProfileType('kilo').provider).toBe('kilo'); 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', () => { const settingsPath = path.join(tempDir, 'glm.settings.json'); fs.writeFileSync(settingsPath, JSON.stringify({ env: { ANTHROPIC_MODEL: 'glm-4' } })); diff --git a/tests/unit/commands/completion-backend.test.ts b/tests/unit/commands/completion-backend.test.ts index b98ae57a..a5ff4eff 100644 --- a/tests/unit/commands/completion-backend.test.ts +++ b/tests/unit/commands/completion-backend.test.ts @@ -82,6 +82,8 @@ describe('completion backend', () => { expect(values).toContain('cursor'); expect(values).toContain('copilot'); expect(values).toContain('gemini'); + expect(values).toContain('xai'); + expect(values).toContain('grok'); expect(values).toContain('gitlab'); expect(values).toContain('codebuddy'); expect(values).toContain('kilo'); @@ -94,6 +96,11 @@ describe('completion backend', () => { 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', () => { const values = suggestionValues(['help']); expect(values).toContain('profiles'); diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index 739f4488..ba8ee389 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -57,6 +57,8 @@ describe('help command parity', () => { expect(rendered.includes('ccs help kiro')).toBe(true); expect(rendered.includes('gemini')).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('gitlab')).toBe(true); expect(rendered.includes('codebuddy')).toBe(true); diff --git a/ui/public/assets/providers/xai.svg b/ui/public/assets/providers/xai.svg new file mode 100644 index 00000000..f5a97161 --- /dev/null +++ b/ui/public/assets/providers/xai.svg @@ -0,0 +1,4 @@ + + xAI Grok + + diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 77e8a2fb..837e239a 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -1,7 +1,7 @@ /** * Add Account Dialog Component * 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. * Shows auth URL + callback paste field. Polling auto-closes on success. * For Kiro: Also shows "Import from IDE" option. diff --git a/ui/src/components/setup/wizard/constants.ts b/ui/src/components/setup/wizard/constants.ts index 1fca25f4..8c2141d4 100644 --- a/ui/src/components/setup/wizard/constants.ts +++ b/ui/src/components/setup/wizard/constants.ts @@ -17,6 +17,7 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [ 'claude', 'gemini', 'codex', + 'xai', 'cursor', 'gitlab', 'codebuddy', diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 2a6475ee..97df35d4 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -382,6 +382,68 @@ export const MODEL_CATALOGS: Record = { }, ], }, + 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: { provider: 'qwen', displayName: 'Qwen', diff --git a/ui/src/lib/provider-config.ts b/ui/src/lib/provider-config.ts index cda84a9b..d2adf7c6 100644 --- a/ui/src/lib/provider-config.ts +++ b/ui/src/lib/provider-config.ts @@ -98,6 +98,7 @@ export const PROVIDER_ASSETS: Partial> = { gemini: '/assets/providers/gemini-color.svg', agy: '/assets/providers/agy.png', codex: '/assets/providers/codex-color.svg', + xai: '/assets/providers/xai.svg', qwen: '/assets/providers/qwen-color.svg', iflow: '/assets/providers/iflow.png', kiro: '/assets/providers/kiro.png', @@ -128,6 +129,7 @@ export const PROVIDER_FALLBACK_VISUALS: Record = { gemini: '#4285F4', agy: '#f3722c', codex: '#10a37f', + xai: '#111827', openai: '#111827', vertex: '#4285F4', iflow: '#f94144', @@ -370,6 +373,7 @@ const DEVICE_CODE_PROVIDER_INSTRUCTIONS: Readonly { + 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', + }); + }); +}); diff --git a/ui/tests/unit/ui/lib/provider-config.test.ts b/ui/tests/unit/ui/lib/provider-config.test.ts index 8d11c520..8bcec84a 100644 --- a/ui/tests/unit/ui/lib/provider-config.test.ts +++ b/ui/tests/unit/ui/lib/provider-config.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { CLIPROXY_PROVIDER_SECTIONS, + CLIPROXY_PROVIDERS, CORE_CLIPROXY_PROVIDERS, formatRequestedUpstreamModelRules, getProviderDescription, @@ -19,6 +20,7 @@ import { PROVIDER_COLORS, variantUsesPlusExtraProvider, } from '@/lib/provider-config'; +import { PROVIDERS as WIZARD_PROVIDERS } from '@/components/setup/wizard/constants'; describe('provider model mapping helpers', () => { 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(CORE_CLIPROXY_PROVIDERS).toContain('gemini'); expect(CORE_CLIPROXY_PROVIDERS).toContain('kimi'); + expect(CORE_CLIPROXY_PROVIDERS).toContain('xai'); expect(PLUS_EXTRA_CLIPROXY_PROVIDERS).toEqual([ 'kiro', 'ghcp', @@ -76,6 +79,12 @@ describe('provider presentation metadata', () => { 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', () => { const grouped = groupProvidersBySection( [ @@ -96,6 +105,7 @@ describe('provider presentation metadata', () => { expect(isDeviceCodeProvider('cursor')).toBe(false); expect(isDeviceCodeProvider('ghcp')).toBe(true); expect(isDeviceCodeProvider('qwen')).toBe(true); + expect(isDeviceCodeProvider('xai')).toBe(true); }); it('detects plus-extra providers inside composite variants', () => { @@ -120,6 +130,7 @@ describe('provider presentation metadata', () => { }); it.each([ + ['xai', 'xAI (Grok)', 'Grok coding and reasoning models', '/assets/providers/xai.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'], [ @@ -153,10 +164,15 @@ describe('provider presentation metadata', () => { textClass: 'text-rose-600', letter: 'K', }); + expect(getProviderFallbackVisual('xai')).toEqual({ + textClass: 'text-slate-900', + letter: 'X', + }); expect(PROVIDER_COLORS.cursor).toBe('#111827'); expect(PROVIDER_COLORS.gitlab).toBe('#FC6D26'); expect(PROVIDER_COLORS.codebuddy).toBe('#2563EB'); expect(PROVIDER_COLORS.kilo).toBe('#E11D48'); + expect(PROVIDER_COLORS.xai).toBe('#111827'); }); });