Merge pull request #1641 from minhbi245/feat/xai-grok-provider

feat(cliproxy): add xAI Grok provider
This commit is contained in:
Kai (Tam Nhu) Tran authored and GitHub committed 2026-07-18 19:07:33 -04:00
commit a8bf7d1a9a
54 files changed
+1770 -159

No files matched your search

+2 -1
View File
@@ -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
+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:
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
+33 -19
View File
@@ -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);
}
```
+7 -1
View File
@@ -5,7 +5,13 @@
*/
// Validation services
export { validateApiName, validateUrl, getUrlWarning, sanitizeBaseUrl } from './validation-service';
export {
validateApiName,
validateApiNameSyntax,
validateUrl,
getUrlWarning,
sanitizeBaseUrl,
} from './validation-service';
// Profile types
export {
+34 -11
View File
@@ -9,14 +9,19 @@ import * as path from 'path';
import type { Config, Settings } from '../../types';
import type { TargetType } from '../../targets/target-adapter';
import { getPersistedTargetChoices, isPersistedTargetType } from '../../targets/target-metadata';
import { ConfigError, ProfileError } from '../../errors/error-types';
import { getConfigPath } from '../../utils/config-manager';
import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
import { isSensitiveKey } from '../../utils/sensitive-keys';
import { isReservedName } from '../../config/reserved-names';
import {
canOverwriteGrandfatheredReservedProfileName,
isGrandfatheredReservedProfileName,
isReservedName,
} from '../../config/reserved-names';
import { validateApiName } from './validation-service';
import { listApiProfiles } from './profile-reader';
import { validateApiName, validateApiNameSyntax } from './validation-service';
import { apiProfileExists, listApiProfiles } from './profile-reader';
import { validateApiProfileSettingsPayload } from './profile-lifecycle-validation';
import type {
ApiProfileExportBundle,
@@ -44,7 +49,7 @@ function parseTargetValue(value: unknown): TargetType | null {
}
function validateProfileNameForPath(name: string, label: string): string | null {
const validationError = validateApiName(name);
const validationError = validateApiNameSyntax(name);
if (validationError) {
return `Invalid ${label} profile name "${name}": ${validationError}`;
}
@@ -70,7 +75,7 @@ function registerApiProfileInConfig(name: string, target: TargetType, force = fa
if (isUnifiedMode()) {
mutateConfig((config) => {
if (config.profiles[name] && !force) {
throw new Error(`API profile already exists: ${name}`);
throw new ProfileError(`API profile already exists: ${name}`, name);
}
config.profiles[name] = {
@@ -85,7 +90,7 @@ function registerApiProfileInConfig(name: string, target: TargetType, force = fa
const configPath = getConfigPath();
const config = loadConfigSafe() as Config;
if (config.profiles[name] && !force) {
throw new Error(`API profile already exists: ${name}`);
throw new ProfileError(`API profile already exists: ${name}`, name);
}
config.profiles[name] = `~/.ccs/${name}${SETTINGS_FILE_SUFFIX}`;
@@ -124,7 +129,7 @@ function readJsonObject(filePath: string): Record<string, unknown> {
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = JSON.parse(raw);
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
throw new Error('Settings file must contain a JSON object.');
throw new ConfigError('Settings file must contain a JSON object.', filePath);
}
return parsed as Record<string, unknown>;
}
@@ -158,7 +163,10 @@ export function discoverApiProfileOrphans(): DiscoverApiProfileOrphansResult {
.filter((file) => !registeredSettings.has(file))
.filter((file) => !file.startsWith('base-'))
.filter((file) => !ignoredNames.has(file))
.filter((file) => !isReservedName(file.slice(0, -SETTINGS_FILE_SUFFIX.length)))
.filter((file) => {
const name = file.slice(0, -SETTINGS_FILE_SUFFIX.length);
return !isReservedName(name) || isGrandfatheredReservedProfileName(name);
})
.map((file) => {
const name = file.slice(0, -SETTINGS_FILE_SUFFIX.length);
const settingsPath = path.join(ccsDir, file);
@@ -206,7 +214,7 @@ export function registerApiProfileOrphans(options?: {
const result: RegisterApiProfileOrphansResult = { registered: [], skipped: [] };
for (const orphan of selected) {
const nameError = validateApiName(orphan.name);
const nameError = validateApiNameSyntax(orphan.name);
if (nameError) {
result.skipped.push({
name: orphan.name,
@@ -247,7 +255,16 @@ export function copyApiProfile(
if (sourceError) return { success: false, error: sourceError };
const destinationError = validateApiName(destination);
if (destinationError) return { success: false, error: destinationError };
const canOverwriteGrandfatheredDestination = canOverwriteGrandfatheredReservedProfileName(
destination,
{
force: options?.force === true,
exists: apiProfileExists(destination),
}
);
if (destinationError && !canOverwriteGrandfatheredDestination) {
return { success: false, error: destinationError };
}
const sourceSettingsPath = getProfileSettingsPath(source);
if (!fs.existsSync(sourceSettingsPath)) {
@@ -363,7 +380,13 @@ export function importApiProfileBundle(
const name = options?.name || input.profile.name;
const nameError = validateApiName(name);
if (nameError) return { success: false, error: nameError };
const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(name, {
force: options?.force === true,
exists: apiProfileExists(name),
});
if (nameError && !canOverwriteGrandfatheredProfile) {
return { success: false, error: nameError };
}
const bundleTarget = parseTargetValue(input.profile.target);
if (input.profile.target !== undefined && bundleTarget === null) {
+27 -5
View File
@@ -12,7 +12,10 @@ import { ensureWebSearchMcpOrThrow } from '../../utils/websearch-manager';
import { ensureImageAnalysisMcpOrThrow } from '../../utils/image-analysis';
import type { TargetType } from '../../targets/target-adapter';
import { resolveDroidProvider } from '../../targets/droid-provider';
import { isReservedName } from '../../config/reserved-names';
import {
canOverwriteGrandfatheredReservedProfileName,
isReservedName,
} from '../../config/reserved-names';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
import {
extractProviderFromPathname,
@@ -266,8 +269,18 @@ export function createApiProfile(
models: ModelMapping,
target: TargetType = 'claude',
provider?: string,
extraModels?: string[]
extraModels?: string[],
options?: { force?: boolean }
): CreateApiProfileResult {
const nameError = validateApiName(name);
const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(name, {
force: options?.force === true,
exists: apiProfileExists(name),
});
if (nameError && !canOverwriteGrandfatheredProfile) {
return { success: false, settingsFile: '', error: nameError };
}
try {
const deniedReason = getDeniedModelReason(baseUrl, models);
if (deniedReason) {
@@ -304,10 +317,17 @@ export function createCliproxyBridgeProfile(
const providedName = options.name?.trim();
if (providedName) {
const nameError = validateApiName(providedName);
if (nameError) {
const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(
providedName,
{
force: options.force === true,
exists: apiProfileExists(providedName),
}
);
if (nameError && !canOverwriteGrandfatheredProfile) {
return { success: false, settingsFile: '', error: nameError };
}
if (isReservedName(providedName)) {
if (isReservedName(providedName) && !canOverwriteGrandfatheredProfile) {
return {
success: false,
settingsFile: '',
@@ -332,7 +352,9 @@ export function createCliproxyBridgeProfile(
resolved.apiKey,
resolved.models,
resolved.target,
provider
provider,
undefined,
{ force: options.force }
);
const detectedBridge = resolveCliproxyBridgeMetadata({
env: {
+20 -5
View File
@@ -8,10 +8,13 @@
import { isReservedName, isWindowsReservedName } from '../../config/reserved-names';
/**
* Validate API profile name
* Validate API profile name syntax for an existing profile reference.
*
* Existing profiles may use a name that became reserved after they were
* created, so this validator intentionally excludes built-in-name policy.
* @returns Error message if invalid, null if valid
*/
export function validateApiName(name: string): string | null {
export function validateApiNameSyntax(name: string): string | null {
if (!name) {
return 'API name is required';
}
@@ -21,15 +24,27 @@ export function validateApiName(name: string): string | null {
if (name.length > 32) {
return 'API name must be 32 characters or less';
}
if (isReservedName(name)) {
return `'${name}' is a reserved name`;
}
if (isWindowsReservedName(name)) {
return `'${name}' is a Windows reserved device name and cannot be used`;
}
return null;
}
/**
* Validate a name for creation of a new API profile.
* @returns Error message if invalid or reserved, null if valid
*/
export function validateApiName(name: string): string | null {
const syntaxError = validateApiNameSyntax(name);
if (syntaxError) {
return syntaxError;
}
if (isReservedName(name)) {
return `'${name}' is a reserved name`;
}
return null;
}
/**
* Validate URL format
* @returns Error message if invalid, null if valid
+3 -5
View File
@@ -5,6 +5,8 @@
* project workspace context with other accounts in the same context group.
*/
import { isReservedName } from '../config/reserved-names';
export type AccountContextMode = 'isolated' | 'shared';
export type AccountContinuityMode = 'standard' | 'deeper';
@@ -36,7 +38,6 @@ export const DEFAULT_ACCOUNT_CONTEXT_GROUP = 'default';
export const DEFAULT_ACCOUNT_CONTINUITY_MODE: AccountContinuityMode = 'standard';
export const MAX_CONTEXT_GROUP_LENGTH = 64;
export const ACCOUNT_PROFILE_NAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
const RESERVED_ACCOUNT_PROFILE_NAMES = new Set(['default']);
const CONTEXT_GROUP_PATTERN = /^[a-zA-Z][a-zA-Z0-9_-]*$/;
@@ -58,10 +59,7 @@ export function isValidContextGroupName(value: string): boolean {
* Validate account profile naming constraints.
*/
export function isValidAccountProfileName(value: string): boolean {
return (
ACCOUNT_PROFILE_NAME_PATTERN.test(value) &&
!RESERVED_ACCOUNT_PROFILE_NAMES.has(value.toLowerCase())
);
return ACCOUNT_PROFILE_NAME_PATTERN.test(value) && !isReservedName(value);
}
/**
+14 -3
View File
@@ -34,6 +34,7 @@ import {
maybeShowPoolOnboardingHint,
countNativeClaudeProfiles,
} from '../../cliproxy/routing/pool-onboarding-hint';
import { canOverwriteGrandfatheredReservedProfileName } from '../../config/reserved-names';
function sanitizeProfileNameForInstance(name: string): string {
return name.replace(/[^a-zA-Z0-9_-]/g, '-').toLowerCase();
@@ -63,7 +64,19 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
exitWithError('Profile name is required', ExitCode.PROFILE_ERROR);
}
if (!isValidAccountProfileName(profileName)) {
// Check exact account ownership before applying the narrow grandfathered
// repair exception. API profiles and case variants do not qualify.
const existsLegacy = ctx.registry.hasProfile(profileName);
const existsUnified = ctx.registry.hasAccountUnified(profileName);
const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(
profileName,
{
force: force === true,
exists: existsLegacy || existsUnified,
}
);
if (!isValidAccountProfileName(profileName) && !canOverwriteGrandfatheredProfile) {
exitWithError(
'Invalid profile name. Use letters/numbers/dash/underscore and start with a letter.',
ExitCode.PROFILE_ERROR
@@ -71,8 +84,6 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
}
// Check if profile already exists (check both legacy and unified)
const existsLegacy = ctx.registry.hasProfile(profileName);
const existsUnified = ctx.registry.hasAccountUnified(profileName);
if (!force && (existsLegacy || existsUnified)) {
// Keep the --force hint in the exitWithError message so it appears in the single output line
exitWithError(
+184 -49
View File
@@ -24,7 +24,13 @@ 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,
isCLIProxyProvider,
resolveCLIProxyProviderShortcut,
} from '../cliproxy/provider-capabilities';
import { isGrandfatheredReservedProfileName } from '../config/reserved-names';
import { ConfigError } from '../errors/error-types';
import { LEGACY_CURSOR_PROFILE_NAME } from '../cursor/constants';
import { normalizeCopilotModelId } from '../copilot/copilot-model-normalizer';
import type { TargetType } from '../targets/target-adapter';
@@ -82,6 +88,50 @@ export interface ProfileNotFoundError extends Error {
availableProfiles: string;
}
function hasOwnEntry(value: unknown, key: string): boolean {
return (
typeof value === 'object' && value !== null && Object.prototype.hasOwnProperty.call(value, key)
);
}
function isObjectRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function isResolvableCompositeVariant(value: unknown): value is CompositeVariantConfig {
if (!isObjectRecord(value) || value.type !== 'composite') {
return false;
}
if (
value.default_tier !== 'opus' &&
value.default_tier !== 'sonnet' &&
value.default_tier !== 'haiku'
) {
return false;
}
if (!isObjectRecord(value.tiers)) {
return false;
}
const tiers = value.tiers;
return (['opus', 'sonnet', 'haiku'] as const).every((tierName) => {
const tier = tiers[tierName];
return (
isObjectRecord(tier) &&
typeof tier.provider === 'string' &&
isCLIProxyProvider(tier.provider) &&
typeof tier.model === 'string' &&
tier.model.trim().length > 0
);
});
}
function isResolvableAccountProfile(value: unknown): value is ProfileMetadata {
return (
isObjectRecord(value) && typeof value.created === 'string' && value.created.trim().length > 0
);
}
/**
* Profile Detector Class
*/
@@ -130,28 +180,22 @@ class ProfileDetector {
config: UnifiedConfig
): ProfileDetectionResult | null {
// Check CLIProxy variants first
if (config.cliproxy?.variants?.[profileName]) {
const variant = config.cliproxy.variants[profileName];
if (hasOwnEntry(config.cliproxy?.variants, profileName)) {
const variant = config.cliproxy?.variants?.[profileName];
if (!isObjectRecord(variant)) {
return null;
}
// Handle composite variants
if ('type' in variant && variant.type === 'composite') {
const composite = variant as CompositeVariantConfig;
// Defensive: check for missing tiers or default_tier
if (!composite.tiers || !composite.default_tier) {
if (!isResolvableCompositeVariant(variant)) {
console.warn(
`[!] Warning: Composite variant '${profileName}' has missing tiers or default_tier`
`[!] Warning: Composite variant '${profileName}' has invalid or incomplete tiers/default_tier`
);
return null;
}
const composite = variant;
const defaultTierConfig = composite.tiers[composite.default_tier];
if (!defaultTierConfig) {
console.warn(
`[!] Warning: Composite variant '${profileName}' missing config for default tier '${composite.default_tier}'`
);
return null;
}
return {
type: 'cliproxy',
@@ -167,6 +211,9 @@ class ProfileDetector {
}
const singleVariant = variant as CLIProxyVariantConfig;
if (!isCLIProxyProvider(singleVariant.provider)) {
return null;
}
return {
type: 'cliproxy',
name: profileName,
@@ -179,11 +226,18 @@ class ProfileDetector {
// Check API profiles (supports compatibility aliases, e.g. km -> kimi)
for (const candidate of getProfileLookupCandidates(profileName)) {
if (!config.profiles?.[candidate]) {
if (!hasOwnEntry(config.profiles, candidate)) {
continue;
}
const profile = config.profiles[candidate];
if (
!isObjectRecord(profile) ||
typeof profile.settings !== 'string' ||
profile.settings.trim().length === 0
) {
return null;
}
const settingsPath = profile.settings;
const settingsEnv = loadSettingsFromFile(settingsPath);
const viaLegacyAlias = isLegacyProfileAlias(profileName, candidate);
@@ -201,8 +255,11 @@ class ProfileDetector {
}
// Check accounts
if (config.accounts?.[profileName]) {
if (hasOwnEntry(config.accounts, profileName)) {
const account = config.accounts[profileName];
if (!isResolvableAccountProfile(account)) {
return null;
}
return {
type: 'account',
name: profileName,
@@ -222,6 +279,20 @@ class ProfileDetector {
return null;
}
private hasUnifiedConfiguredProfile(profileName: string, config: UnifiedConfig): boolean {
return (
hasOwnEntry(config.cliproxy?.variants, profileName) ||
hasOwnEntry(config.profiles, profileName) ||
hasOwnEntry(config.accounts, profileName)
);
}
private createConfiguredCollisionError(profileName: string, source: string): ConfigError {
return new ConfigError(
`Configured profile "${profileName}" in ${source} is invalid and cannot be resolved as a grandfathered profile.`
);
}
/**
* Read settings-based config (config.json)
*/
@@ -261,11 +332,12 @@ class ProfileDetector {
*
* Priority order:
* 0. Hardcoded special runtime profiles (copilot, cursor)
* 0.5. Hardcoded CLIProxy profiles (gemini, codex, agy, qwen, ...)
* 0.5. Hardcoded CLIProxy profiles (except grandfathered xai/grok collisions)
* 1. Unified config profiles (if config.yaml exists or CCS_UNIFIED_CONFIG=1)
* 2. User-defined CLIProxy variants (config.cliproxy section) [legacy]
* 3. Settings-based profiles (config.profiles section) [legacy]
* 4. Account-based profiles (profiles.json) [legacy]
* 5. Built-in xai/grok shortcut fallback when no configured profile exists
*/
detectProfileType(profileName: string | null | undefined): ProfileDetectionResult {
// Special case: 'default' means use default profile
@@ -332,11 +404,13 @@ class ProfileDetector {
}
// Priority 0.5: Check CLIProxy profiles (gemini, codex, agy, qwen, ...)
if (isCLIProxyProvider(profileName)) {
const builtinProvider = resolveCLIProxyProviderShortcut(profileName);
const shouldGrandfatherConfiguredProfile = builtinProvider === 'xai';
if (builtinProvider && !shouldGrandfatherConfiguredProfile) {
return {
type: 'cliproxy',
name: profileName,
provider: profileName,
name: builtinProvider,
provider: builtinProvider,
};
}
@@ -345,6 +419,12 @@ class ProfileDetector {
if (unifiedConfig) {
const result = this.resolveFromUnifiedConfig(profileName, unifiedConfig);
if (result) return result;
if (
isGrandfatheredReservedProfileName(profileName) &&
this.hasUnifiedConfiguredProfile(profileName, unifiedConfig)
) {
throw this.createConfiguredCollisionError(profileName, 'config.yaml');
}
// Fall through to legacy if not found in unified config
}
@@ -353,22 +433,36 @@ class ProfileDetector {
const legacyTargetMap = (config as { profile_targets?: Record<string, TargetType> })
.profile_targets;
if (config.cliproxy && config.cliproxy[profileName]) {
const variant = config.cliproxy[profileName];
return {
type: 'cliproxy',
name: profileName,
target: variant.target,
provider: variant.provider as CLIProxyProfileName,
settingsPath: variant.settings,
port: variant.port,
};
if (hasOwnEntry(config.cliproxy, profileName)) {
const variant = config.cliproxy?.[profileName];
if (!isObjectRecord(variant) || !isCLIProxyProvider(variant.provider as string)) {
if (isGrandfatheredReservedProfileName(profileName)) {
throw this.createConfiguredCollisionError(profileName, 'config.json');
}
} else {
return {
type: 'cliproxy',
name: profileName,
target: variant.target,
provider: variant.provider,
settingsPath: variant.settings,
port: variant.port,
};
}
}
// Priority 3: Check settings-based profiles (glm, km) - LEGACY FALLBACK
if (config.profiles) {
for (const candidate of getProfileLookupCandidates(profileName)) {
if (!config.profiles[candidate]) {
if (!hasOwnEntry(config.profiles, candidate)) {
continue;
}
const settingsPath = config.profiles[candidate];
if (typeof settingsPath !== 'string' || settingsPath.trim().length === 0) {
if (candidate === profileName && isGrandfatheredReservedProfileName(profileName)) {
throw this.createConfiguredCollisionError(profileName, 'config.json');
}
continue;
}
@@ -376,7 +470,7 @@ class ProfileDetector {
return {
type: 'settings',
name: profileName,
settingsPath: config.profiles[candidate],
settingsPath,
target: legacyTargetMap?.[candidate],
message: viaLegacyAlias
? `Using legacy API profile "${candidate}" for "${profileName}".`
@@ -388,11 +482,28 @@ class ProfileDetector {
// Priority 4: Check account-based profiles (work, personal) - LEGACY FALLBACK
const profiles = this.readProfiles();
if (profiles.profiles && profiles.profiles[profileName]) {
if (hasOwnEntry(profiles.profiles, profileName)) {
const profile = profiles.profiles[profileName];
if (!isResolvableAccountProfile(profile)) {
if (isGrandfatheredReservedProfileName(profileName)) {
throw this.createConfiguredCollisionError(profileName, 'profiles.json');
}
} else {
return {
type: 'account',
name: profileName,
profile,
};
}
}
// xai and grok became reserved when the built-in xAI provider shipped. Existing
// configured profiles keep working, while new installs still get the shortcuts.
if (builtinProvider) {
return {
type: 'account',
name: profileName,
profile: profiles.profiles[profileName],
type: 'cliproxy',
name: builtinProvider,
provider: builtinProvider,
};
}
@@ -422,25 +533,49 @@ class ProfileDetector {
if (unifiedConfig?.default) {
const result = this.resolveFromUnifiedConfig(unifiedConfig.default, unifiedConfig);
if (result) return result;
if (
isGrandfatheredReservedProfileName(unifiedConfig.default) &&
this.hasUnifiedConfiguredProfile(unifiedConfig.default, unifiedConfig)
) {
throw this.createConfiguredCollisionError(unifiedConfig.default, 'config.yaml');
}
}
// Check if account-based default exists (legacy)
const profiles = this.readProfiles();
if (unifiedConfig?.default && profiles.profiles[unifiedConfig.default]) {
return {
type: 'account',
name: unifiedConfig.default,
profile: profiles.profiles[unifiedConfig.default],
};
if (unifiedConfig?.default && hasOwnEntry(profiles.profiles, unifiedConfig.default)) {
const profile = profiles.profiles[unifiedConfig.default];
if (
isGrandfatheredReservedProfileName(unifiedConfig.default) &&
!isResolvableAccountProfile(profile)
) {
throw this.createConfiguredCollisionError(unifiedConfig.default, 'profiles.json');
}
if (profile) {
return {
type: 'account',
name: unifiedConfig.default,
profile,
};
}
}
if (profiles.default && profiles.profiles[profiles.default]) {
return {
type: 'account',
name: profiles.default,
profile: profiles.profiles[profiles.default],
};
if (profiles.default && hasOwnEntry(profiles.profiles, profiles.default)) {
const profile = profiles.profiles[profiles.default];
if (
isGrandfatheredReservedProfileName(profiles.default) &&
!isResolvableAccountProfile(profile)
) {
throw this.createConfiguredCollisionError(profiles.default, 'profiles.json');
}
if (profile) {
return {
type: 'account',
name: profiles.default,
profile,
};
}
}
// Check if settings-based default exists
@@ -37,11 +37,42 @@ 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 export extended-context capability from live xAI context length', () => {
const catalog = mergeCatalog('xai', [
{
id: 'grok-4.3',
display_name: 'Grok 4.3',
context_length: 2_000_000,
},
]);
expect(catalog?.models[0]?.extendedContext).toBeUndefined();
});
it('does not re-add stale static-only models when live catalog data is present', () => {
@@ -63,6 +63,49 @@ 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',
]);
});
it('does not expose Claude [1m] suffix support for xAI model IDs', () => {
const { MODEL_CATALOG, supportsExtendedContext } = modelCatalog;
for (const model of MODEL_CATALOG.xai.models) {
assert.notStrictEqual(
model.extendedContext,
true,
`${model.id} must not advertise extended-context metadata`
);
assert.strictEqual(
supportsExtendedContext('xai', model.id),
false,
`${model.id} must not advertise [1m] suffix support`
);
}
});
});
describe('AGY models', () => {
it('has correct default model', () => {
const { MODEL_CATALOG } = modelCatalog;
@@ -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', () => {
@@ -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',
+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.
* 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 {
@@ -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'
);
@@ -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,41 @@ import {
extractLikelyAuthFailureFromLogs,
extractLikelyAuthFailureFromStderr,
extractLikelyOAuthAuthorizationUrl,
extractDeviceCodePrompt,
getExpectedLocalCallback,
getKiroBuilderIdSelectionInput,
resolveDeviceCodeVerificationUrl,
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',
});
});
it('preserves a parsed xAI verification URL exactly', () => {
const parsedUrl = 'https://accounts.x.ai/oauth2/device?user_code=ABCD-1234&source=cliproxy';
expect(resolveDeviceCodeVerificationUrl('xai', 'ABCD-1234', parsedUrl)).toBe(parsedUrl);
});
it('uses an xAI-specific device URL when upstream output omits the URL', () => {
expect(resolveDeviceCodeVerificationUrl('xai', 'ABCD-1234', null)).toBe(
'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 =
@@ -32,6 +62,13 @@ describe('oauth-process stderr parsing', () => {
expect(extractLikelyAuthFailureFromStderr('ghcp', stderr)).toBe('state mismatch');
});
it('extracts the current upstream xAI authentication failure format', () => {
const stderr =
'time="2026-07-18T00:00:00Z" level=error msg="xAI authentication failed: xai device code expired"';
expect(extractLikelyAuthFailureFromStderr('xai', stderr)).toBe('xai device code expired');
});
it('caps extracted message length to prevent noisy broadcasts', () => {
const longSuffix = 'x'.repeat(400);
const stderr = `level=error msg="Authentication failed: ${longSuffix}"`;
+8
View File
@@ -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<CLIProxyProvider, ProviderOAuthConfig> = {
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',
+2 -2
View File
@@ -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,
+39 -9
View File
@@ -127,6 +127,36 @@ 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 resolveDeviceCodeVerificationUrl(
provider: CLIProxyProvider,
userCode: string,
parsedUrl: string | null
): string {
if (parsedUrl) {
return parsedUrl;
}
if (provider === 'xai') {
return `https://accounts.x.ai/oauth2/device?user_code=${encodeURIComponent(userCode)}`;
}
return 'https://github.com/login/device';
}
export function isLoopbackHost(hostname: string): boolean {
const normalized = hostname.replace(/^\[|\]$/g, '').toLowerCase();
return (
@@ -392,19 +422,18 @@ 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 = resolveDeviceCodeVerificationUrl(
options.provider,
state.userCode,
devicePrompt.verificationUrl
);
// Emit device code event for WebSocket broadcast to UI
const deviceCodePrompt: DeviceCodePrompt = {
@@ -514,6 +543,7 @@ export function extractLikelyAuthFailureFromLogs(
});
const providerPatterns: Partial<Record<CLIProxyProvider, RegExp[]>> = {
xai: [/xai authentication failed:\s*(.+)/i],
ghcp: [
/github copilot authentication failed:\s*(.+)/i,
/failed to verify copilot access[^:]*:\s*(.+)/i,
@@ -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
*/
@@ -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);
+1 -1
View File
@@ -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.
+66
View File
@@ -301,6 +301,71 @@ 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',
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',
},
{
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',
},
{
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',
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',
@@ -696,6 +761,7 @@ export function supportsThinking(provider: CLIProxyProvider, modelId: string): b
* Returns true if model has extendedContext: true in catalog.
*/
export function supportsExtendedContext(provider: CLIProxyProvider, modelId: string): boolean {
if (provider === 'xai') return false;
const model = findModel(provider, modelId);
return model?.extendedContext === true;
}
+46
View File
@@ -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<CLIProxyProvider, ProviderCapabilitie
tokenTypeValues: ['codex'],
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: {
displayName: 'Antigravity',
description: 'Antigravity AI models',
@@ -296,10 +312,40 @@ export function 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 {
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];
}
+6 -3
View File
@@ -33,6 +33,7 @@ const CHANNEL_TO_PROVIDER: Record<string, CLIProxyProvider> = {
claude: 'claude',
gemini: 'gemini',
codex: 'codex',
xai: 'xai',
qwen: 'qwen',
iflow: 'iflow',
kimi: 'kimi',
@@ -214,13 +215,15 @@ function mapThinking(remote?: RemoteThinkingSupport): ThinkingSupport | undefine
}
/** Map RemoteModelInfo to ModelEntry */
function mapRemoteToModelEntry(remote: RemoteModelInfo): ModelEntry {
function mapRemoteToModelEntry(provider: CLIProxyProvider, remote: RemoteModelInfo): ModelEntry {
const entry: ModelEntry = {
id: remote.id,
name: remote.display_name || remote.id,
};
if (remote.description) entry.description = remote.description;
if (remote.context_length && remote.context_length >= 1_000_000) {
// xAI context length is inherent to the model ID; its API does not accept
// Claude's [1m] model suffix.
if (provider !== 'xai' && remote.context_length && remote.context_length >= 1_000_000) {
entry.extendedContext = true;
}
const thinking = mapThinking(remote.thinking);
@@ -263,7 +266,7 @@ export function mergeCatalog(
const mergedModels: ModelEntry[] = [];
for (const remote of filteredRemoteModels) {
const remoteEntry = mapRemoteToModelEntry(remote);
const remoteEntry = mapRemoteToModelEntry(provider, remote);
const staticEntry = staticMap.get(remote.id.toLowerCase());
if (staticEntry) {
const mergedThinking = remoteEntry.thinking
+1
View File
@@ -6,6 +6,7 @@
export type CLIProxyProvider =
| 'gemini'
| 'codex'
| 'xai'
| 'agy'
| 'qwen'
| 'iflow'
+23 -7
View File
@@ -22,6 +22,7 @@ import {
isCLIProxyProvider,
} from '../../cliproxy/provider-capabilities';
import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync';
import { canOverwriteGrandfatheredReservedProfileName } from '../../config/reserved-names';
import type { TargetType } from '../../targets/target-adapter';
import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui';
import { InteractivePrompt } from '../../utils/prompt';
@@ -63,7 +64,8 @@ function showPresetDeprecationNotice(presetId?: string): void {
async function resolveProfileName(
providedName: string | undefined,
preset: ProviderPreset | null
preset: ProviderPreset | null,
force: boolean
): Promise<string> {
const name = providedName || preset?.defaultProfileName;
if (!name) {
@@ -73,7 +75,11 @@ async function resolveProfileName(
}
const error = validateApiName(name);
if (error) {
const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(name, {
force,
exists: apiProfileExists(name),
});
if (error && !canOverwriteGrandfatheredProfile) {
console.log(fail(error));
process.exit(1);
}
@@ -83,11 +89,19 @@ async function resolveProfileName(
async function resolveCliproxyProfileName(
provider: string,
providedName: string | undefined,
yes: boolean | undefined
yes: boolean | undefined,
force: boolean
): Promise<string | undefined> {
if (providedName) {
const error = validateApiName(providedName);
if (error) {
const canOverwriteGrandfatheredProfile = canOverwriteGrandfatheredReservedProfileName(
providedName,
{
force,
exists: apiProfileExists(providedName),
}
);
if (error && !canOverwriteGrandfatheredProfile) {
console.log(fail(error));
process.exit(1);
}
@@ -368,7 +382,8 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
const name = await resolveCliproxyProfileName(
cliproxyProvider,
parsedArgs.name,
parsedArgs.yes
parsedArgs.yes,
parsedArgs.force === true
);
const target = await resolveDefaultTarget(null, parsedArgs.target, parsedArgs.yes);
@@ -456,7 +471,7 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
showPresetDeprecationNotice(parsedArgs.preset);
const preset = resolvePresetOrExit(parsedArgs.preset);
const name = await resolveProfileName(parsedArgs.name, preset);
const name = await resolveProfileName(parsedArgs.name, preset, parsedArgs.force === true);
if (apiProfileExists(name) && !parsedArgs.force) {
console.log(fail(`API '${name}' already exists`));
@@ -503,7 +518,8 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
finalModels,
target,
undefined,
parsedArgs.extraModels
parsedArgs.extraModels,
{ force: parsedArgs.force === true }
);
if (!result.success) {
console.log(fail(`Failed to create API profile: ${result.error}`));
+20 -1
View File
@@ -31,6 +31,7 @@ import {
} from '../../cliproxy/services';
import { DEFAULT_BACKEND } from '../../cliproxy/binary/platform-detector';
import { CompositeTierConfig } from '../../config/unified-config-types';
import { canOverwriteGrandfatheredReservedProfileName } from '../../config/reserved-names';
import { formatAccountDisplayName } from '../../cliproxy/accounts/email-account-identity';
import { isUnifiedMode } from '../../config/config-loader-facade';
@@ -120,6 +121,24 @@ export function parseProfileArgs(args: string[]): CliproxyProfileArgs {
return result;
}
export function validateVariantCreateName(
name: string,
force: boolean,
exists: boolean = variantExists(name)
): string | null {
const error = validateProfileName(name);
if (
error &&
!canOverwriteGrandfatheredReservedProfileName(name, {
force,
exists,
})
) {
return error;
}
return null;
}
function formatModelOption(model: ModelEntry): string {
const tierBadge =
model.tier === 'ultra'
@@ -306,7 +325,7 @@ export async function handleCreate(
validate: validateProfileName,
});
} else {
const error = validateProfileName(name);
const error = validateVariantCreateName(name, parsedArgs.force === true);
if (error) {
console.log(fail(error));
process.exit(1);
+15 -1
View File
@@ -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' },
{
+8 -1
View File
@@ -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') {
+1 -1
View File
@@ -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(
+34 -1
View File
@@ -1,3 +1,5 @@
import { ConfigError } from '../errors/error-types';
/**
* Reserved profile names that cannot be used for user-defined profiles.
* These names are reserved for CLIProxy providers and CLI commands.
@@ -6,6 +8,8 @@ export const RESERVED_PROFILE_NAMES = [
// CLIProxy providers (built-in OAuth)
'gemini',
'codex',
'xai',
'grok',
'agy',
'qwen',
'iflow',
@@ -32,6 +36,14 @@ export const RESERVED_PROFILE_NAMES = [
export type ReservedProfileName = (typeof RESERVED_PROFILE_NAMES)[number];
/**
* Reserved names that may still identify profiles created before the
* corresponding built-in provider shortcuts shipped.
*/
export const GRANDFATHERED_RESERVED_PROFILE_NAMES = ['xai', 'grok'] as const;
export type GrandfatheredReservedProfileName =
(typeof GRANDFATHERED_RESERVED_PROFILE_NAMES)[number];
/**
* Windows reserved device names - cannot be used as filenames on Windows.
* Case-insensitive on Windows filesystem.
@@ -70,6 +82,27 @@ export function isReservedName(name: string): boolean {
return RESERVED_PROFILE_NAMES.includes(name.toLowerCase() as ReservedProfileName);
}
/**
* Check whether a reserved name may identify an existing grandfathered profile.
* This does not permit creating a new profile with the name.
*/
export function isGrandfatheredReservedProfileName(name: string): boolean {
return GRANDFATHERED_RESERVED_PROFILE_NAMES.includes(
name.toLowerCase() as GrandfatheredReservedProfileName
);
}
/**
* Allow a reserved grandfathered name only for an explicit overwrite of the
* exact profile that already owns it.
*/
export function canOverwriteGrandfatheredReservedProfileName(
name: string,
options: { force: boolean; exists: boolean }
): boolean {
return options.force && options.exists && isGrandfatheredReservedProfileName(name);
}
/**
* Check if a name is a Windows reserved device name.
* These cause filesystem errors on Windows systems.
@@ -89,7 +122,7 @@ export function isWindowsReservedName(name: string): boolean {
*/
export function validateProfileName(name: string): void {
if (isReservedName(name)) {
throw new Error(
throw new ConfigError(
`Profile name '${name}' is reserved. Reserved names: ${RESERVED_PROFILE_NAMES.join(', ')}`
);
}
+5 -8
View File
@@ -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<b
}
// Provider help shortcut: `ccs gemini --help` → provider-specific help page
if (
typeof firstArg === 'string' &&
isCLIProxyProvider(firstArg) &&
args.length > 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;
}
+4 -4
View File
@@ -22,7 +22,7 @@ import {
apiProfileExists,
listCliproxyBridgeProviders,
listApiProfiles,
validateApiName,
validateApiNameSyntax,
} from '../../api/services';
import { normalizeDroidProvider } from '../../targets/droid-provider';
import { getPersistedTargetChoices } from '../../targets/target-metadata';
@@ -284,7 +284,7 @@ router.post('/orphans/register', (req: Request, res: Response): void => {
}
names = payload.names.map((value) => value.trim());
const invalidName = names.find((name) => validateApiName(name) !== null);
const invalidName = names.find((name) => validateApiNameSyntax(name) !== null);
if (invalidName) {
res.status(400).json({ error: `Invalid profile name in names: ${invalidName}` });
return;
@@ -321,7 +321,7 @@ router.post('/:name/copy', (req: Request, res: Response): void => {
}
const { name } = req.params;
const sourceNameError = validateApiName(name);
const sourceNameError = validateApiNameSyntax(name);
if (sourceNameError) {
res.status(400).json({ error: sourceNameError });
return;
@@ -359,7 +359,7 @@ router.post('/:name/export', (req: Request, res: Response): void => {
}
const { name } = req.params;
const profileNameError = validateApiName(name);
const profileNameError = validateApiNameSyntax(name);
if (profileNameError) {
res.status(400).json({ error: profileNameError });
return;
+3 -2
View File
@@ -21,8 +21,9 @@ describe('account context helpers', () => {
});
it('rejects reserved account profile names', () => {
expect(isValidAccountProfileName('default')).toBe(false);
expect(isValidAccountProfileName('Default')).toBe(false);
for (const name of ['default', 'Default', 'xai', 'XAI', 'grok', 'GROK']) {
expect(isValidAccountProfileName(name)).toBe(false);
}
});
it('falls back to default shared group for invalid persisted metadata', () => {
@@ -9,6 +9,7 @@ import {
importApiProfileBundle,
registerApiProfileOrphans,
} from '../../../src/api/services/profile-lifecycle-service';
import { createApiProfile } from '../../../src/api/services/profile-writer';
import {
loadConfigSafe,
runWithScopedConfigDir,
@@ -68,7 +69,7 @@ describe('profile lifecycle service', () => {
}
});
it('discovers only API profile orphans (skips registered and reserved names)', async () => {
it('discovers grandfathered xai/grok orphans while skipping other reserved names', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
@@ -101,9 +102,59 @@ describe('profile lifecycle service', () => {
2
) + '\n'
);
for (const profileName of ['xai', 'grok']) {
fs.writeFileSync(
path.join(ccsDir, `${profileName}.settings.json`),
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://api.example.com',
ANTHROPIC_AUTH_TOKEN: 'token',
},
},
null,
2
) + '\n'
);
}
const result = await runInScopedCcsDir(() => discoverApiProfileOrphans());
expect(result.orphans.map((orphan) => orphan.name)).toEqual(['extra']);
expect(result.orphans.map((orphan) => orphan.name).sort()).toEqual(['extra', 'grok', 'xai']);
});
it('registers a grandfathered xai orphan without opening general reserved-name creation', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'xai.settings.json'),
JSON.stringify(
{ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } },
null,
2
) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'gemini.settings.json'),
JSON.stringify(
{ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } },
null,
2
) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: {} }, null, 2) + '\n'
);
const result = await runInScopedCcsDir(() =>
registerApiProfileOrphans({ names: ['xai', 'gemini'] })
);
const config = await runInScopedCcsDir(() => loadConfigSafe());
expect(result.registered).toEqual(['xai']);
expect(result.skipped).toEqual([]);
expect(config.profiles.xai).toBe('~/.ccs/xai.settings.json');
expect(config.profiles.gemini).toBeUndefined();
});
it('treats explicit empty names list as no-op during orphan registration', async () => {
@@ -140,7 +191,10 @@ describe('profile lifecycle service', () => {
2
) + '\n'
);
fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n');
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: {} }, null, 2) + '\n'
);
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation(() => {
throw new Error('copy failed');
@@ -168,8 +222,15 @@ describe('profile lifecycle service', () => {
2
) + '\n'
);
fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n');
fs.writeFileSync(path.join(ccsDir, 'config.yaml'), 'version: 12\nwebsearch:\n enabled: false\n', 'utf8');
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: {} }, null, 2) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
'version: 12\nwebsearch:\n enabled: false\n',
'utf8'
);
const originalCopyFileSync = fs.copyFileSync.bind(fs);
const copyFileSpy = spyOn(fs, 'copyFileSync').mockImplementation((source, destination) => {
@@ -195,7 +256,10 @@ describe('profile lifecycle service', () => {
const malformedPath = path.join(ccsDir, 'bad.settings.json');
fs.writeFileSync(malformedPath, '{ invalid json', 'utf8');
fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n');
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: {} }, null, 2) + '\n'
);
const result = await runInScopedCcsDir(() =>
registerApiProfileOrphans({ names: ['bad'], force: true })
@@ -247,6 +311,173 @@ describe('profile lifecycle service', () => {
expect(result.error).toContain('Invalid source profile name');
});
it('copies and exports grandfathered sources but rejects reserved destinations', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'xai.settings.json'),
JSON.stringify(
{ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } },
null,
2
) + '\n'
);
const exported = await runInScopedCcsDir(() => exportApiProfile('xai'));
const copied = await runInScopedCcsDir(() => copyApiProfile('xai', 'xai-backup'));
const rejectedDestination = await runInScopedCcsDir(() => copyApiProfile('xai', 'GROK'));
expect(exported.success).toBe(true);
expect(exported.bundle?.profile.name).toBe('xai');
expect(copied.success).toBe(true);
expect(fs.existsSync(path.join(ccsDir, 'xai-backup.settings.json'))).toBe(true);
expect(rejectedDestination.success).toBe(false);
expect(rejectedDestination.error).toContain('reserved name');
expect(fs.existsSync(path.join(ccsDir, 'GROK.settings.json'))).toBe(false);
});
it('allows force copy only onto an exact existing grandfathered destination', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify(
{
profiles: {
source: '~/.ccs/source.settings.json',
xai: '~/.ccs/xai.settings.json',
},
},
null,
2
) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'source.settings.json'),
JSON.stringify(
{
env: {
ANTHROPIC_BASE_URL: 'https://source.example.com',
ANTHROPIC_AUTH_TOKEN: 'source-token',
},
},
null,
2
) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'xai.settings.json'),
JSON.stringify(
{ env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } },
null,
2
) + '\n'
);
const nonForced = await runInScopedCcsDir(() => copyApiProfile('source', 'xai'));
const forced = await runInScopedCcsDir(() => copyApiProfile('source', 'xai', { force: true }));
const absentForced = await runInScopedCcsDir(() =>
copyApiProfile('source', 'grok', { force: true })
);
expect(nonForced.success).toBe(false);
expect(nonForced.error).toContain('reserved name');
expect(forced.success).toBe(true);
expect(absentForced.success).toBe(false);
expect(absentForced.error).toContain('reserved name');
expect(fs.existsSync(path.join(ccsDir, 'grok.settings.json'))).toBe(false);
const copiedSettings = JSON.parse(
fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8')
) as {
env: Record<string, string>;
};
expect(copiedSettings.env.ANTHROPIC_BASE_URL).toBe('https://source.example.com');
expect(copiedSettings.env.ANTHROPIC_AUTH_TOKEN).toBe('source-token');
});
it('rejects an absent reserved name even when profile creation is forced', async () => {
const result = await runInScopedCcsDir(() =>
createApiProfile(
'XAI',
'https://api.example.com',
'token',
{
default: 'model',
opus: 'model',
sonnet: 'model',
haiku: 'model',
},
'claude',
undefined,
undefined,
{ force: true }
)
);
expect(result.success).toBe(false);
expect(result.error).toContain('reserved name');
expect(fs.existsSync(path.join(getScopedCcsDir(), 'XAI.settings.json'))).toBe(false);
});
it('allows force to repair an exact existing xai API profile', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'xai.settings.json'),
JSON.stringify(
{ env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } },
null,
2
) + '\n'
);
const nonForced = await runInScopedCcsDir(() =>
createApiProfile('xai', 'https://new.example.com', 'new-token', {
default: 'model',
opus: 'model',
sonnet: 'model',
haiku: 'model',
})
);
const forced = await runInScopedCcsDir(() =>
createApiProfile(
'xai',
'https://new.example.com',
'new-token',
{
default: 'model',
opus: 'model',
sonnet: 'model',
haiku: 'model',
},
'claude',
undefined,
undefined,
{ force: true }
)
);
expect(nonForced.success).toBe(false);
expect(nonForced.error).toContain('reserved name');
expect(forced.success).toBe(true);
const settings = JSON.parse(
fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8')
) as {
env: Record<string, string>;
};
expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://new.example.com');
expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('new-token');
});
it('rolls back copied settings when local WebSearch tool setup fails', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
@@ -294,6 +525,62 @@ describe('profile lifecycle service', () => {
expect(result.error).toContain('Invalid bundle profile target');
});
it('allows force import only for an exact existing grandfathered profile', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'xai.settings.json'),
JSON.stringify(
{ env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } },
null,
2
) + '\n'
);
const bundle = {
schemaVersion: 1,
exportedAt: new Date().toISOString(),
profile: { name: 'xai', target: 'claude' },
settings: {
env: {
ANTHROPIC_BASE_URL: 'https://repaired.example.com',
ANTHROPIC_AUTH_TOKEN: 'repaired-token',
},
},
};
const nonForced = await runInScopedCcsDir(() => importApiProfileBundle(bundle));
const forced = await runInScopedCcsDir(() => importApiProfileBundle(bundle, { force: true }));
const absentForced = await runInScopedCcsDir(() =>
importApiProfileBundle(
{
...bundle,
profile: { ...bundle.profile, name: 'grok' },
},
{ force: true }
)
);
expect(nonForced.success).toBe(false);
expect(nonForced.error).toContain('reserved name');
expect(forced.success).toBe(true);
expect(absentForced.success).toBe(false);
expect(absentForced.error).toContain('reserved name');
expect(fs.existsSync(path.join(ccsDir, 'grok.settings.json'))).toBe(false);
const settings = JSON.parse(
fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8')
) as {
env: Record<string, string>;
};
expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://repaired.example.com');
expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('repaired-token');
});
it('rolls back imported settings when local WebSearch tool setup fails', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
+98
View File
@@ -2,6 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import * as childProcess from 'child_process';
import ProfileRegistry from '../../src/auth/profile-registry';
import InstanceManager from '../../src/management/instance-manager';
import { handleResources } from '../../src/auth/commands/resources-command';
@@ -227,6 +228,103 @@ describe('auth resources command', () => {
expect(output).toContain('Unknown option(s): "--mode"');
});
it('rejects reserved xai and grok account profile names on auth create', async () => {
for (const profileName of ['xai', 'GROK']) {
const registry = new ProfileRegistry();
const instanceMgr = new InstanceManager();
const output = await expectProfileExit(() =>
handleCreate(
{
registry,
instanceMgr,
version: 'test',
},
[profileName]
)
);
expect(output).toContain('Invalid profile name');
expect(registry.hasAccountUnified(profileName)).toBe(false);
}
});
it('rejects --force for an absent reserved account profile', async () => {
const registry = new ProfileRegistry();
const instanceMgr = new InstanceManager();
const output = await expectProfileExit(() =>
handleCreate(
{
registry,
instanceMgr,
version: 'test',
},
['xai', '--force']
)
);
expect(output).toContain('Invalid profile name');
expect(registry.hasAccountUnified('xai')).toBe(false);
});
it('allows --force to repair an exact existing xai account profile', async () => {
const ccsDir = path.join(tempRoot, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.yaml'),
[
'version: 12',
'accounts:',
' xai:',
' created: "2026-02-01T00:00:00.000Z"',
' last_used: null',
'profiles: {}',
'cliproxy:',
' oauth_accounts: {}',
' providers: {}',
' variants: {}',
].join('\n'),
'utf8'
);
const claudeDetector = await import('../../src/utils/claude-detector');
const cliSpy = spyOn(claudeDetector, 'getClaudeCliInfo').mockReturnValue({
path: '/usr/bin/claude',
isWindows: false,
needsShell: false,
});
const child = {
on: () => child,
} as unknown as ReturnType<typeof childProcess.spawn>;
const spawnSpy = spyOn(childProcess, 'spawn').mockReturnValue(child);
const ensureSpy = spyOn(InstanceManager.prototype, 'ensureInstance').mockResolvedValue(
path.join(ccsDir, 'instances', 'xai')
);
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
const registry = new ProfileRegistry();
const instanceMgr = new InstanceManager();
await handleCreate(
{
registry,
instanceMgr,
version: 'test',
},
['xai', '--force']
);
expect(spawnSpy).toHaveBeenCalled();
expect(registry.hasAccountUnified('xai')).toBe(true);
} finally {
logSpy.mockRestore();
ensureSpy.mockRestore();
spawnSpy.mockRestore();
cliSpy.mockRestore();
}
});
it('rejects --mode on non-resources auth commands instead of silently ignoring it', async () => {
const registry = new ProfileRegistry();
const instanceMgr = new InstanceManager();
+184
View File
@@ -7,6 +7,7 @@ import * as path from 'path';
import * as os from 'os';
import ProfileDetector, { loadSettingsFromFile } from '../../../src/auth/profile-detector';
import * as unifiedConfigLoader from '../../../src/config/unified-config-loader';
import { ConfigError } from '../../../src/errors/error-types';
describe('ProfileDetector', () => {
const tempDir = path.join(os.tmpdir(), `ccs-test-profile-detector-${process.pid}`);
@@ -94,12 +95,195 @@ 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 xai and grok to built-in xai routing when no configured profile exists', () => {
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false);
const existsSyncSpy = spyOn(fs, 'existsSync').mockReturnValue(false);
try {
for (const shortcut of ['xai', 'grok']) {
const result = detector.detectProfileType(shortcut);
expect(result.type).toBe('cliproxy');
expect(result.name).toBe('xai');
expect(result.provider).toBe('xai');
}
} finally {
isUnifiedModeSpy.mockRestore();
existsSyncSpy.mockRestore();
}
});
it('should preserve configured xai and grok profiles from unified config', () => {
const xaiSettingsPath = path.join(tempDir, 'xai.settings.json');
const grokSettingsPath = path.join(tempDir, 'grok.settings.json');
fs.writeFileSync(
xaiSettingsPath,
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://legacy-xai.example' } })
);
fs.writeFileSync(
grokSettingsPath,
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://legacy-grok.example' } })
);
const mockUnifiedConfig = {
version: 2,
profiles: {
xai: { settings: xaiSettingsPath, type: 'api' },
grok: { settings: grokSettingsPath, type: 'api' },
},
};
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true);
const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(
mockUnifiedConfig as any
);
try {
for (const profileName of ['xai', 'grok']) {
const result = detector.detectProfileType(profileName);
expect(result.type).toBe('settings');
expect(result.name).toBe(profileName);
expect(result.settingsPath).toBe(
profileName === 'xai' ? xaiSettingsPath : grokSettingsPath
);
}
} finally {
isUnifiedModeSpy.mockRestore();
loadUnifiedConfigSpy.mockRestore();
}
});
it('should reject a malformed unified xai composite instead of using the built-in shortcut', () => {
const mockUnifiedConfig = {
version: 12,
profiles: {},
accounts: {},
cliproxy: {
variants: {
xai: {
type: 'composite',
default_tier: 'opus',
tiers: {},
},
},
},
};
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true);
const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(
mockUnifiedConfig as any
);
const warningSpy = spyOn(console, 'warn').mockImplementation(() => {});
try {
expect(() => detector.detectProfileType('xai')).toThrow(ConfigError);
expect(() => detector.detectProfileType('xai')).toThrow(
/Configured profile "xai" in config.yaml is invalid/
);
} finally {
warningSpy.mockRestore();
isUnifiedModeSpy.mockRestore();
loadUnifiedConfigSpy.mockRestore();
}
});
it('should reject a malformed legacy grok variant instead of using the built-in shortcut', () => {
const originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempDir;
const ccsDir = path.join(tempDir, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: {}, cliproxy: { grok: {} } }, null, 2)
);
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false);
try {
const localDetector = new ProfileDetector();
expect(() => localDetector.detectProfileType('grok')).toThrow(ConfigError);
expect(() => localDetector.detectProfileType('grok')).toThrow(
/Configured profile "grok" in config.json is invalid/
);
} finally {
isUnifiedModeSpy.mockRestore();
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
}
});
it('should reject a malformed unified xai default instead of using the native default', () => {
const mockUnifiedConfig = {
version: 12,
default: 'xai',
profiles: {},
accounts: {},
cliproxy: {
variants: {
xai: {
type: 'composite',
default_tier: 'opus',
tiers: {},
},
},
},
};
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true);
const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(
mockUnifiedConfig as any
);
const warningSpy = spyOn(console, 'warn').mockImplementation(() => {});
try {
expect(() => detector.resolveDefaultProfileResult()).toThrow(ConfigError);
expect(() => detector.resolveDefaultProfileResult()).toThrow(
/Configured profile "xai" in config.yaml is invalid/
);
} finally {
warningSpy.mockRestore();
isUnifiedModeSpy.mockRestore();
loadUnifiedConfigSpy.mockRestore();
}
});
it('should reject a malformed legacy xai account default', () => {
const originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempDir;
const ccsDir = path.join(tempDir, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'profiles.json'),
JSON.stringify({ default: 'xai', profiles: { xai: {} } }, null, 2)
);
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(false);
try {
const localDetector = new ProfileDetector();
expect(() => localDetector.resolveDefaultProfileResult()).toThrow(ConfigError);
expect(() => localDetector.resolveDefaultProfileResult()).toThrow(
/Configured profile "xai" in profiles.json is invalid/
);
} finally {
isUnifiedModeSpy.mockRestore();
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
}
});
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' } }));
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { handleApiCreateCommand } from '../../../src/commands/api-command/create-command';
describe('api create grandfathered force policy', () => {
let tempHome = '';
let originalCcsHome: string | undefined;
let originalUnifiedMode: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-api-create-grandfathered-'));
originalCcsHome = process.env.CCS_HOME;
originalUnifiedMode = process.env.CCS_UNIFIED_CONFIG;
process.env.CCS_HOME = tempHome;
delete process.env.CCS_UNIFIED_CONFIG;
});
afterEach(() => {
if (originalCcsHome === undefined) {
delete process.env.CCS_HOME;
} else {
process.env.CCS_HOME = originalCcsHome;
}
if (originalUnifiedMode === undefined) {
delete process.env.CCS_UNIFIED_CONFIG;
} else {
process.env.CCS_UNIFIED_CONFIG = originalUnifiedMode;
}
if (tempHome && fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('allows ccs api create --force to repair an exact existing xai profile', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'xai.settings.json'),
JSON.stringify(
{ env: { ANTHROPIC_BASE_URL: 'https://old.example.com', ANTHROPIC_AUTH_TOKEN: 'old' } },
null,
2
) + '\n'
);
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
try {
await handleApiCreateCommand([
'xai',
'--force',
'--yes',
'--base-url',
'https://new.example.com',
'--api-key',
'new-token',
'--model',
'new-model',
]);
} finally {
logSpy.mockRestore();
}
const settings = JSON.parse(
fs.readFileSync(path.join(ccsDir, 'xai.settings.json'), 'utf8')
) as {
env: Record<string, string>;
};
expect(settings.env.ANTHROPIC_BASE_URL).toBe('https://new.example.com');
expect(settings.env.ANTHROPIC_AUTH_TOKEN).toBe('new-token');
});
it('rejects ccs api create --force when the reserved profile is absent', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: {} }, null, 2) + '\n'
);
const originalExit = process.exit;
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
process.exit = ((code?: number) => {
throw new Error(`process.exit(${code ?? 0})`);
}) as typeof process.exit;
try {
await expect(
handleApiCreateCommand([
'grok',
'--force',
'--yes',
'--base-url',
'https://new.example.com',
'--api-key',
'new-token',
'--model',
'new-model',
])
).rejects.toThrow('process.exit(1)');
} finally {
process.exit = originalExit;
logSpy.mockRestore();
}
expect(fs.existsSync(path.join(ccsDir, 'grok.settings.json'))).toBe(false);
});
});
@@ -1,6 +1,9 @@
import { describe, expect, test } from 'bun:test';
import { parseProfileArgs } from '../../../src/commands/cliproxy/variant-subcommand';
import {
parseProfileArgs,
validateVariantCreateName,
} from '../../../src/commands/cliproxy/variant-subcommand';
describe('cliproxy variant arg parser', () => {
test('parses --target value form', () => {
@@ -56,3 +59,16 @@ describe('cliproxy variant arg parser', () => {
expect(parsed.errors).toEqual([]);
});
});
describe('cliproxy variant create name policy', () => {
test('allows force repair of an exact existing grandfathered variant', () => {
expect(validateVariantCreateName('xai', true, true)).toBeNull();
expect(validateVariantCreateName('GROK', true, true)).toBeNull();
});
test('keeps absent, non-force, and other reserved variant names blocked', () => {
expect(validateVariantCreateName('xai', true, false)).toContain('reserved name');
expect(validateVariantCreateName('xai', false, true)).toContain('reserved name');
expect(validateVariantCreateName('gemini', true, true)).toContain('reserved name');
});
});
@@ -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');
@@ -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);
+8
View File
@@ -44,6 +44,8 @@ describe('reserved-names', () => {
expect(RESERVED_PROFILE_NAMES).toContain('agy');
expect(RESERVED_PROFILE_NAMES).toContain('qwen');
expect(RESERVED_PROFILE_NAMES).toContain('iflow');
expect(RESERVED_PROFILE_NAMES).toContain('xai');
expect(RESERVED_PROFILE_NAMES).toContain('grok');
});
it('should include CLI commands', () => {
@@ -57,12 +59,16 @@ describe('reserved-names', () => {
it('should return true for reserved names', () => {
expect(isReservedName('gemini')).toBe(true);
expect(isReservedName('codex')).toBe(true);
expect(isReservedName('xai')).toBe(true);
expect(isReservedName('grok')).toBe(true);
expect(isReservedName('default')).toBe(true);
});
it('should be case-insensitive', () => {
expect(isReservedName('GEMINI')).toBe(true);
expect(isReservedName('Codex')).toBe(true);
expect(isReservedName('XAI')).toBe(true);
expect(isReservedName('GROK')).toBe(true);
expect(isReservedName('DEFAULT')).toBe(true);
});
@@ -76,6 +82,8 @@ describe('reserved-names', () => {
describe('validateProfileName', () => {
it('should throw for reserved names', () => {
expect(() => validateProfileName('gemini')).toThrow(/reserved/i);
expect(() => validateProfileName('xai')).toThrow(/reserved/i);
expect(() => validateProfileName('grok')).toThrow(/reserved/i);
expect(() => validateProfileName('default')).toThrow(/reserved/i);
});
@@ -76,11 +76,17 @@ describe('profile-routes lifecycle endpoints', () => {
it('does not register all orphans when names=[] is explicitly passed', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(path.join(ccsDir, 'config.json'), JSON.stringify({ profiles: {} }, null, 2) + '\n');
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: {} }, null, 2) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'lonely.settings.json'),
JSON.stringify({ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } }, null, 2) +
'\n'
JSON.stringify(
{ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } },
null,
2
) + '\n'
);
const response = await fetch(`${baseUrl}/api/profiles/orphans/register`, {
@@ -142,4 +148,85 @@ describe('profile-routes lifecycle endpoints', () => {
const body = (await response.json()) as { error: string };
expect(body.error).toContain('API name must start with letter');
});
it('keeps grandfathered profiles exportable, copyable, and removable', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: { xai: '~/.ccs/xai.settings.json' } }, null, 2) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'xai.settings.json'),
JSON.stringify(
{ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } },
null,
2
) + '\n'
);
const exportResponse = await fetch(`${baseUrl}/api/profiles/xai/export`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
});
expect(exportResponse.status).toBe(200);
const copyResponse = await fetch(`${baseUrl}/api/profiles/xai/copy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destination: 'xai-backup' }),
});
expect(copyResponse.status).toBe(201);
const reservedDestinationResponse = await fetch(`${baseUrl}/api/profiles/xai/copy`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destination: 'GROK' }),
});
expect(reservedDestinationResponse.status).toBe(400);
const reservedDestinationBody = (await reservedDestinationResponse.json()) as {
error: string;
};
expect(reservedDestinationBody.error).toContain('reserved name');
const deleteResponse = await fetch(`${baseUrl}/api/profiles/xai`, {
method: 'DELETE',
});
expect(deleteResponse.status).toBe(200);
expect(fs.existsSync(path.join(ccsDir, 'xai.settings.json'))).toBe(false);
});
it('recovers a grandfathered xai orphan through the route', async () => {
const ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify({ profiles: {} }, null, 2) + '\n'
);
fs.writeFileSync(
path.join(ccsDir, 'xai.settings.json'),
JSON.stringify(
{ env: { ANTHROPIC_BASE_URL: 'https://api.example.com', ANTHROPIC_AUTH_TOKEN: 'token' } },
null,
2
) + '\n'
);
const response = await fetch(`${baseUrl}/api/profiles/orphans/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ names: ['xai'] }),
});
expect(response.status).toBe(200);
const body = (await response.json()) as { registered: string[]; skipped: unknown[] };
expect(body.registered).toEqual(['xai']);
expect(body.skipped).toEqual([]);
const config = JSON.parse(fs.readFileSync(path.join(ccsDir, 'config.json'), 'utf8')) as {
profiles: Record<string, string>;
};
expect(config.profiles.xai).toBe('~/.ccs/xai.settings.json');
});
});
+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
* 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.
@@ -17,6 +17,7 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [
'claude',
'gemini',
'codex',
'xai',
'cursor',
'gitlab',
'codebuddy',
+65 -2
View File
@@ -382,6 +382,64 @@ 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',
},
{
id: 'grok-4.20-0309-reasoning',
name: 'Grok 4.20 0309 Reasoning',
description: 'Reasoning model with a two-million-token context window',
},
{
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',
},
{
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',
},
{
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',
@@ -1062,7 +1120,8 @@ export function buildUiCatalog(
provider: string,
liveCatalog: ProviderCatalog | undefined
): ProviderCatalog | undefined {
const staticCatalog = MODEL_CATALOGS[provider.toLowerCase()];
const normalizedProvider = provider.toLowerCase();
const staticCatalog = MODEL_CATALOGS[normalizedProvider];
if (!liveCatalog || liveCatalog.models.length === 0) {
return staticCatalog;
}
@@ -1083,7 +1142,10 @@ export function buildUiCatalog(
issueUrl: staticModel?.issueUrl,
deprecated: staticModel?.deprecated,
deprecationReason: staticModel?.deprecationReason,
extendedContext: model.extendedContext ?? staticModel?.extendedContext,
extendedContext:
normalizedProvider === 'xai'
? undefined
: (model.extendedContext ?? staticModel?.extendedContext),
presetMapping: staticModel?.presetMapping,
};
});
@@ -1282,5 +1344,6 @@ export function supportsExtendedContext(
modelId: string,
catalogOverride?: ProviderCatalog
): boolean {
if (provider.toLowerCase() === 'xai') return false;
return findCatalogModel(provider, modelId, catalogOverride)?.extendedContext === true;
}
+4
View File
@@ -98,6 +98,7 @@ export const PROVIDER_ASSETS: Partial<Record<ProviderVisualId, string>> = {
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<ProviderVisualId, ProviderFallbac
gemini: { textClass: 'text-blue-600', letter: 'G' },
claude: { textClass: 'text-orange-600', letter: 'C' },
codex: { textClass: 'text-emerald-600', letter: 'X' },
xai: { textClass: 'text-slate-900', letter: 'X' },
agy: { textClass: 'text-violet-600', letter: 'A' },
qwen: { textClass: 'text-cyan-600', letter: 'Q' },
iflow: { textClass: 'text-indigo-600', letter: 'i' },
@@ -265,6 +267,7 @@ export const PROVIDER_COLORS: Record<string, string> = {
gemini: '#4285F4',
agy: '#f3722c',
codex: '#10a37f',
xai: '#111827',
openai: '#111827',
vertex: '#4285F4',
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.',
kiro: 'Sign in with your selected Kiro auth provider to continue.',
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. */
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { buildUiCatalog, MODEL_CATALOGS, supportsExtendedContext } 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',
});
});
it('does not expose Claude [1m] suffix support for xAI model IDs', () => {
const catalog = MODEL_CATALOGS.xai;
for (const model of catalog.models) {
expect(model.extendedContext).not.toBe(true);
expect(supportsExtendedContext('xai', model.id)).toBe(false);
}
});
it('strips generic extended-context metadata from live xAI catalogs', () => {
const catalog = buildUiCatalog('xai', {
provider: 'xai',
displayName: 'xAI (Grok)',
defaultModel: 'grok-4.3',
models: [
{
id: 'grok-4.3',
name: 'Grok 4.3',
extendedContext: true,
},
],
});
expect(catalog?.models[0]?.extendedContext).toBeUndefined();
expect(supportsExtendedContext('xai', 'grok-4.3', catalog)).toBe(false);
});
});
@@ -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');
});
});