mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-13 06:15:03 +00:00
Merge pull request #1645 from kaitranntt/kai/release/dev-to-main-20260718
feat: promote dev to main
This commit is contained in:
97 files changed
+4976
-380
No files matched your search
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
+21
-10
@@ -10,8 +10,9 @@ refresh in one session overwrites the other's credentials.
|
||||
|
||||
`ccsx auth` solves this by giving each account its own profile directory under
|
||||
`~/.ccs/codex-instances/<name>/`. Each profile holds its own `auth.json` and
|
||||
`history.jsonl`. Shared `config.toml`, `agents/`, and `skills/` resources are linked
|
||||
via symlink so model/provider settings and relative agent role config files stay in sync.
|
||||
`history.jsonl`, plus its own session data. Shared `config.toml`, `agents/`, `skills/`,
|
||||
and plugin cache resources come from `~/.codex/` so configuration and installed plugin
|
||||
skills stay in sync across profiles.
|
||||
|
||||
## Quick start (4 commands)
|
||||
|
||||
@@ -149,17 +150,26 @@ No OAuth tokens are ever returned by the API endpoint or shown in the UI.
|
||||
├── sessions/ # Per-profile chat session dirs (optional)
|
||||
├── config.toml -> ~/.codex/config.toml (symlink — shared)
|
||||
├── agents/ -> ~/.codex/agents/ (symlink — shared)
|
||||
└── skills/ -> ~/.codex/skills/ (symlink — shared)
|
||||
├── skills/ -> ~/.codex/skills/ (symlink — shared)
|
||||
└── plugins/ # Profile-local parent; may hold local metadata
|
||||
└── cache/ -> ~/.codex/plugins/cache/ (symlink — shared)
|
||||
|
||||
~/.codex/
|
||||
├── config.toml # Single shared model/provider config
|
||||
├── agents/ # Shared Codex agent role config files
|
||||
└── skills/ # Shared Codex skills
|
||||
├── skills/ # Shared Codex skills
|
||||
└── plugins/
|
||||
└── cache/ # Shared installed plugin payloads
|
||||
```
|
||||
|
||||
`ccsx auth create <name>` and `ccsx <name>` both repair these links idempotently.
|
||||
This keeps relative Codex config entries such as `agents/foo.toml` valid inside
|
||||
each isolated `CODEX_HOME`.
|
||||
Only `plugins/cache/` is shared. The profile's parent `plugins/` directory remains a
|
||||
real local directory so Codex can keep profile-specific plugin metadata beside the
|
||||
shared cache.
|
||||
|
||||
`ccsx auth create <name>` and direct `ccsx <name>` launches repair these links
|
||||
idempotently before Codex starts. This keeps relative entries such as
|
||||
`agents/foo.toml` valid and prevents stale first-launch skill warnings after a plugin
|
||||
install or update changes the cache.
|
||||
|
||||
## Caveats
|
||||
|
||||
@@ -167,9 +177,10 @@ each isolated `CODEX_HOME`.
|
||||
|
||||
On Windows, creating symlinks requires Developer Mode or elevated privileges.
|
||||
If symlink creation fails, CCS falls back to copying `config.toml`, `agents/`,
|
||||
and `skills/`. In this case, changes to `~/.codex/` resources are **not**
|
||||
automatically reflected in the profile; re-run `ccsx auth create <name> --force`
|
||||
to refresh the copy.
|
||||
`skills/`, and the current `plugins/cache/` snapshot. Copies do not update live with
|
||||
`~/.codex/`; after a plugin update, another profile launch or
|
||||
`ccsx auth create <name> --force` repair copies newly missing cache entries. Existing
|
||||
profile-local cache files are preserved.
|
||||
|
||||
### Native Codex project-local config warnings
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -901,17 +901,9 @@ function tryAgySearch(query, timeoutSec = DEFAULT_TIMEOUT_SEC) {
|
||||
const timeoutMs = timeoutSec * 1000;
|
||||
const model = process.env.CCS_WEBSEARCH_AGY_MODEL || PROVIDER_CONFIG.agy.model;
|
||||
const prompt = buildPrompt('agy', query);
|
||||
// gemini flag mapping: `--yolo` -> `--dangerously-skip-permissions`, `-m` -> `--model`,
|
||||
// shell `timeout N` -> native `--print-timeout Ns`. The prompt is passed to `-p` (print mode).
|
||||
const args = [
|
||||
'--model',
|
||||
model,
|
||||
'--dangerously-skip-permissions',
|
||||
'--print-timeout',
|
||||
`${timeoutSec}s`,
|
||||
'-p',
|
||||
prompt,
|
||||
];
|
||||
// Use print mode without disabling Antigravity permission checks. WebSearch queries can
|
||||
// contain untrusted prompt text, so permission prompts must remain enforced.
|
||||
const args = ['--model', model, '--print-timeout', `${timeoutSec}s`, '-p', prompt];
|
||||
|
||||
debug(`Executing Antigravity (agy) fallback with model ${model}`);
|
||||
return runAgyCommand(args, timeoutMs);
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kaitranntt/ccs",
|
||||
"version": "8.7.0",
|
||||
"version": "8.7.0-dev.12",
|
||||
"description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more",
|
||||
"keywords": [
|
||||
"cli",
|
||||
|
||||
@@ -47,6 +47,7 @@ const slowTests = [
|
||||
const fastJsTests = new Set(['tests/unit/flag-parsing-simple.test.js']);
|
||||
|
||||
const isolatedTests = new Set([
|
||||
'tests/unit/commands/bar-command.test.ts',
|
||||
'tests/unit/targets/codex-adapter-exec.test.ts',
|
||||
'tests/unit/targets/codex-adapter.test.ts',
|
||||
'tests/unit/targets/droid-adapter.test.ts',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -57,7 +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);
|
||||
return ACCOUNT_PROFILE_NAME_PATTERN.test(value) && !isReservedName(value);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -132,7 +175,19 @@ describe('Model Catalog', () => {
|
||||
});
|
||||
it('has correct default model', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
assert.strictEqual(MODEL_CATALOG.claude.defaultModel, 'claude-sonnet-4-6');
|
||||
assert.strictEqual(MODEL_CATALOG.claude.defaultModel, 'claude-sonnet-5');
|
||||
});
|
||||
|
||||
it('includes Claude Sonnet 5 with adaptive levels and extended context', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const sonnet = MODEL_CATALOG.claude.models.find((m) => m.id === 'claude-sonnet-5');
|
||||
assert(sonnet, 'Should include Claude Sonnet 5');
|
||||
assert.strictEqual(sonnet.name, 'Claude Sonnet 5');
|
||||
assert.strictEqual(sonnet.thinking.type, 'levels');
|
||||
assert.deepStrictEqual(sonnet.thinking.levels, ['low', 'medium', 'high', 'xhigh', 'max']);
|
||||
assert.strictEqual(sonnet.thinking.maxLevel, 'max');
|
||||
assert.strictEqual(sonnet.nativeImageInput, true);
|
||||
assert.strictEqual(sonnet.extendedContext, true);
|
||||
});
|
||||
|
||||
it('includes Claude Sonnet 4.6', () => {
|
||||
@@ -235,6 +290,9 @@ describe('Model Catalog', () => {
|
||||
const { MODEL_CATALOG } = modelCatalog;
|
||||
const ids = MODEL_CATALOG.codex.models.map((m) => m.id);
|
||||
assert.deepStrictEqual(ids, [
|
||||
'gpt-5.6-sol',
|
||||
'gpt-5.6-terra',
|
||||
'gpt-5.6-luna',
|
||||
'gpt-5.5',
|
||||
'gpt-5.4',
|
||||
'gpt-5.4-mini',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -69,6 +69,13 @@ describe('Thinking Validator', () => {
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should treat max as a distinct top tier on Claude Sonnet 5', () => {
|
||||
const result = validateThinking('claude', 'claude-sonnet-5', 'max');
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.value).toBe('max');
|
||||
expect(result.warning).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should still alias max -> xhigh for models without a max level (backcompat)', () => {
|
||||
// Codex catalog uses ['low','medium','high','xhigh'] with maxLevel 'xhigh'.
|
||||
// User input "max" should map down to xhigh rather than be rejected.
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -29,6 +29,9 @@ describe('codex plan compatibility', () => {
|
||||
});
|
||||
|
||||
it('does not rewrite cross-plan or already-safe Codex models', () => {
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.6-sol')).toBeNull();
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.6-terra-high')).toBeNull();
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.6-luna-fast')).toBeNull();
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.4')).toBeNull();
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.4-mini')).toBeNull();
|
||||
expect(getFreePlanFallbackCodexModel('gpt-5.2')).toBeNull();
|
||||
@@ -94,6 +97,9 @@ describe('codex plan compatibility', () => {
|
||||
});
|
||||
|
||||
it('tracks Codex thinking caps for current safe defaults, paid models, and legacy aliases', () => {
|
||||
expect(getModelMaxLevel('codex', 'gpt-5.6-sol')).toBe('xhigh');
|
||||
expect(getModelMaxLevel('codex', 'gpt-5.6-terra')).toBe('xhigh');
|
||||
expect(getModelMaxLevel('codex', 'gpt-5.6-luna')).toBe('xhigh');
|
||||
expect(getModelMaxLevel('codex', 'gpt-5.5')).toBe('xhigh');
|
||||
expect(getModelMaxLevel('codex', 'gpt-5.4')).toBe('xhigh');
|
||||
expect(getModelMaxLevel('codex', 'gpt-5.4-mini')).toBe('high');
|
||||
|
||||
@@ -168,6 +168,26 @@ describe('applyExtendedContextConfig', () => {
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-pro(high)[1m]');
|
||||
});
|
||||
|
||||
it('preserves a saved [1m] preference for supported Claude models in auto mode', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-5-20250929[1m]',
|
||||
};
|
||||
|
||||
applyExtendedContextConfig(env, 'claude', undefined);
|
||||
|
||||
expect(env.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-20250929[1m]');
|
||||
});
|
||||
|
||||
it('strips a stale [1m] preference from unsupported native Gemini models', () => {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
ANTHROPIC_MODEL: 'gemini-2.5-flash[1m]',
|
||||
};
|
||||
|
||||
applyExtendedContextConfig(env, 'gemini', undefined);
|
||||
|
||||
expect(env.ANTHROPIC_MODEL).toBe('gemini-2.5-flash');
|
||||
});
|
||||
|
||||
it('handles empty env vars gracefully', () => {
|
||||
const env: NodeJS.ProcessEnv = {};
|
||||
applyExtendedContextConfig(env, 'gemini', undefined);
|
||||
|
||||
@@ -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}"`;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -13,9 +13,12 @@ import type { CLIProxyProvider } from '../types';
|
||||
import { supportsExtendedContext } from '../model-catalog';
|
||||
import { warn } from '../../utils/ui';
|
||||
import {
|
||||
ANTHROPIC_MODEL_ENV_KEYS,
|
||||
applyExtendedContextPreferenceToAnthropicModels,
|
||||
applyExtendedContextSuffix as applyExtendedContextSuffixShared,
|
||||
hasExtendedContextSuffix,
|
||||
isNativeGeminiModel,
|
||||
stripExtendedContextSuffix,
|
||||
stripModelConfigurationSuffixes,
|
||||
} from '../../shared/extended-context-utils';
|
||||
|
||||
@@ -73,20 +76,41 @@ export function applyExtendedContextConfig(
|
||||
provider: CLIProxyProvider,
|
||||
extendedContextOverride?: boolean
|
||||
): void {
|
||||
if (extendedContextOverride === false) {
|
||||
Object.assign(envVars, applyExtendedContextPreferenceToAnthropicModels(envVars, false));
|
||||
if (extendedContextOverride === true || extendedContextOverride === false) {
|
||||
Object.assign(
|
||||
envVars,
|
||||
applyExtendedContextPreferenceToAnthropicModels(envVars, extendedContextOverride, {
|
||||
supportsExtendedContext: (modelId) =>
|
||||
shouldApplyExtendedContext(
|
||||
provider,
|
||||
stripModelConfigurationSuffixes(modelId),
|
||||
extendedContextOverride
|
||||
),
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Object.assign(
|
||||
envVars,
|
||||
applyExtendedContextPreferenceToAnthropicModels(envVars, true, {
|
||||
supportsExtendedContext: (modelId) =>
|
||||
shouldApplyExtendedContext(
|
||||
provider,
|
||||
stripModelConfigurationSuffixes(modelId),
|
||||
extendedContextOverride
|
||||
),
|
||||
})
|
||||
);
|
||||
// Auto mode (no explicit --1m/--no-1m this launch): don't force-strip a
|
||||
// previously saved [1m] preference just because the model is Claude — only
|
||||
// strip when the model no longer supports extended context. Native Gemini
|
||||
// models still get auto-toggled based on catalog support.
|
||||
for (const key of ANTHROPIC_MODEL_ENV_KEYS) {
|
||||
const value = envVars[key];
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
const modelId = stripModelConfigurationSuffixes(value);
|
||||
|
||||
if (isNativeGeminiModel(modelId)) {
|
||||
envVars[key] = supportsExtendedContext(provider, modelId)
|
||||
? applyExtendedContextSuffixShared(value)
|
||||
: stripExtendedContextSuffix(value);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (hasExtendedContextSuffix(value) && !supportsExtendedContext(provider, modelId)) {
|
||||
envVars[key] = stripExtendedContextSuffix(value);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -190,6 +190,42 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
displayName: 'Copilot Codex',
|
||||
defaultModel: 'gpt-5.4',
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-5.6-sol',
|
||||
name: 'GPT-5.6 Sol',
|
||||
description: 'Latest frontier agentic coding model.',
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
maxLevel: 'xhigh',
|
||||
dynamicAllowed: false,
|
||||
},
|
||||
codexServiceTiers: ['fast'],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.6-terra',
|
||||
name: 'GPT-5.6 Terra',
|
||||
description: 'Balanced agentic coding model for everyday work.',
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
maxLevel: 'xhigh',
|
||||
dynamicAllowed: false,
|
||||
},
|
||||
codexServiceTiers: ['fast'],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.6-luna',
|
||||
name: 'GPT-5.6 Luna',
|
||||
description: 'Fast and affordable agentic coding model.',
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh'],
|
||||
maxLevel: 'xhigh',
|
||||
dynamicAllowed: false,
|
||||
},
|
||||
codexServiceTiers: ['fast'],
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.5',
|
||||
name: 'GPT-5.5',
|
||||
@@ -265,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',
|
||||
@@ -367,8 +468,22 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
|
||||
claude: {
|
||||
provider: 'claude',
|
||||
displayName: 'Claude (Anthropic)',
|
||||
defaultModel: 'claude-sonnet-4-6',
|
||||
defaultModel: 'claude-sonnet-5',
|
||||
models: [
|
||||
{
|
||||
id: 'claude-sonnet-5',
|
||||
name: 'Claude Sonnet 5',
|
||||
description: 'Latest Sonnet model',
|
||||
nativeImageInput: true,
|
||||
// Sonnet 5 uses adaptive thinking; manual budget_tokens is rejected with 400.
|
||||
thinking: {
|
||||
type: 'levels',
|
||||
levels: ['low', 'medium', 'high', 'xhigh', 'max'],
|
||||
maxLevel: 'max',
|
||||
dynamicAllowed: true,
|
||||
},
|
||||
extendedContext: true,
|
||||
},
|
||||
{
|
||||
id: 'claude-fable-5',
|
||||
name: 'Claude Fable 5',
|
||||
@@ -646,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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
export type CLIProxyProvider =
|
||||
| 'gemini'
|
||||
| 'codex'
|
||||
| 'xai'
|
||||
| 'agy'
|
||||
| 'qwen'
|
||||
| 'iflow'
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { ConfigError } from '../errors/error-types';
|
||||
import { resolveCanonicalPath, symlinkPointsTo } from '../management/shared-manager/fs-helpers';
|
||||
import { createLogger } from '../services/logging';
|
||||
|
||||
const logger = createLogger('codex-auth:resources');
|
||||
const FALLBACK_SAFE_SYMLINK_ERRORS = new Set(['EPERM', 'EACCES', 'ENOSYS']);
|
||||
|
||||
export function ensureSharedPluginCache(profileDir: string, sharedCodexHome: string): void {
|
||||
const targetPath = path.join(sharedCodexHome, 'plugins', 'cache');
|
||||
const pluginsPath = path.join(profileDir, 'plugins');
|
||||
const linkPath = path.join(pluginsPath, 'cache');
|
||||
|
||||
ensureProfileLocalPluginsDirectory(pluginsPath);
|
||||
fs.mkdirSync(targetPath, { recursive: true, mode: 0o700 });
|
||||
|
||||
if (resolveCanonicalPath(pluginsPath) === resolveCanonicalPath(path.dirname(targetPath))) {
|
||||
throw new ConfigError(
|
||||
'Refusing plugin cache repair: profile plugins directory resolves to the shared plugins directory.'
|
||||
);
|
||||
}
|
||||
|
||||
const existingStat = lstatIfExists(linkPath);
|
||||
if (isExpectedCacheLink(linkPath, targetPath, existingStat)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let backupPath = existingStat === null ? null : createPluginCacheBackupPath(pluginsPath);
|
||||
if (backupPath !== null) {
|
||||
try {
|
||||
fs.renameSync(linkPath, backupPath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
|
||||
throw err;
|
||||
}
|
||||
backupPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
fs.symlinkSync(targetPath, linkPath, 'dir');
|
||||
} catch (err) {
|
||||
if (isExpectedCacheLink(linkPath, targetPath)) {
|
||||
removePluginCacheBackup(backupPath);
|
||||
return;
|
||||
}
|
||||
|
||||
const pathAfterFailure = lstatIfExists(linkPath);
|
||||
if (backupPath !== null && pathAfterFailure === null) {
|
||||
if (restorePluginCacheWithoutOverwrite(linkPath, backupPath)) {
|
||||
backupPath = null;
|
||||
}
|
||||
}
|
||||
|
||||
const code = (err as NodeJS.ErrnoException | null)?.code;
|
||||
if (!code || !FALLBACK_SAFE_SYMLINK_ERRORS.has(code)) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (isExpectedCacheLink(linkPath, targetPath)) {
|
||||
removePluginCacheBackup(backupPath);
|
||||
return;
|
||||
}
|
||||
const fallbackPathStat = lstatIfExists(linkPath);
|
||||
if (backupPath !== null && fallbackPathStat?.isDirectory()) {
|
||||
mergeMissingResourceTree(backupPath, linkPath);
|
||||
removePluginCacheBackup(backupPath);
|
||||
}
|
||||
|
||||
ensureLocalPluginCacheDirectory(linkPath);
|
||||
mergeMissingResourceTree(targetPath, linkPath);
|
||||
process.stderr.write(
|
||||
`[!] codex-auth: symlink unavailable; using profile-local plugin cache at ${linkPath}. ` +
|
||||
`Copied missing shared entries; plugin cache updates won't propagate automatically.\n`
|
||||
);
|
||||
logger.warn(
|
||||
'codex-auth.plugin-cache-copy-fallback',
|
||||
'Copied shared plugin cache after symlink failure',
|
||||
{
|
||||
link: linkPath,
|
||||
target: targetPath,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
removePluginCacheBackup(backupPath);
|
||||
|
||||
logger.stage(
|
||||
'dispatch',
|
||||
'codex.plugin-cache.symlink.created',
|
||||
'Created shared plugin cache symlink',
|
||||
{
|
||||
link: linkPath,
|
||||
target: targetPath,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function ensureProfileLocalPluginsDirectory(pluginsPath: string): void {
|
||||
const pluginsStat = lstatIfExists(pluginsPath);
|
||||
if (pluginsStat === null) {
|
||||
fs.mkdirSync(pluginsPath, { recursive: true, mode: 0o700 });
|
||||
return;
|
||||
}
|
||||
if (!pluginsStat.isDirectory()) {
|
||||
throw new ConfigError(
|
||||
'Refusing plugin cache repair: profile plugins path is not a local directory.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isExpectedCacheLink(
|
||||
linkPath: string,
|
||||
targetPath: string,
|
||||
stat: fs.Stats | null = lstatIfExists(linkPath)
|
||||
): boolean {
|
||||
if (!stat?.isSymbolicLink()) {
|
||||
return false;
|
||||
}
|
||||
return symlinkPointsTo(linkPath, targetPath);
|
||||
}
|
||||
|
||||
function lstatIfExists(resourcePath: string): fs.Stats | null {
|
||||
try {
|
||||
return fs.lstatSync(resourcePath);
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
function createPluginCacheBackupPath(pluginsPath: string): string {
|
||||
return path.join(
|
||||
pluginsPath,
|
||||
`.cache.ccs-backup-${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||||
);
|
||||
}
|
||||
|
||||
function removePluginCacheBackup(backupPath: string | null): void {
|
||||
if (backupPath !== null) {
|
||||
fs.rmSync(backupPath, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function ensureLocalPluginCacheDirectory(linkPath: string): void {
|
||||
let existingStat = lstatIfExists(linkPath);
|
||||
if (existingStat?.isDirectory()) {
|
||||
return;
|
||||
}
|
||||
if (existingStat !== null) {
|
||||
throw new ConfigError(
|
||||
'Refusing plugin cache fallback: profile cache path is not a local directory.'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
fs.mkdirSync(linkPath, { recursive: false, mode: 0o700 });
|
||||
return;
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
existingStat = lstatIfExists(linkPath);
|
||||
if (!existingStat?.isDirectory()) {
|
||||
throw new ConfigError(
|
||||
'Refusing plugin cache fallback: profile cache path is not a local directory.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function restorePluginCacheWithoutOverwrite(linkPath: string, backupPath: string): boolean {
|
||||
const backupStat = lstatIfExists(backupPath);
|
||||
if (!backupStat?.isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let linkStat = lstatIfExists(linkPath);
|
||||
if (linkStat === null) {
|
||||
try {
|
||||
fs.mkdirSync(linkPath, { recursive: false, mode: 0o700 });
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
linkStat = lstatIfExists(linkPath);
|
||||
}
|
||||
|
||||
if (!linkStat?.isDirectory()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
mergeMissingResourceTree(backupPath, linkPath);
|
||||
removePluginCacheBackup(backupPath);
|
||||
return true;
|
||||
}
|
||||
|
||||
function mergeMissingResourceTree(sourceDir: string, targetDir: string): void {
|
||||
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
||||
const sourcePath = path.join(sourceDir, entry.name);
|
||||
const targetPath = path.join(targetDir, entry.name);
|
||||
const targetStat = lstatIfExists(targetPath);
|
||||
|
||||
if (targetStat === null) {
|
||||
fs.cpSync(sourcePath, targetPath, {
|
||||
recursive: true,
|
||||
force: false,
|
||||
errorOnExist: false,
|
||||
preserveTimestamps: true,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isDirectory() && targetStat.isDirectory()) {
|
||||
mergeMissingResourceTree(sourcePath, targetPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { createLogger } from '../services/logging';
|
||||
import { ensureSharedPluginCache } from './codex-profile-plugin-cache';
|
||||
|
||||
const logger = createLogger('codex-auth:resources');
|
||||
|
||||
@@ -23,6 +24,8 @@ export function ensureCodexProfileResources(
|
||||
for (const resourceName of SHARED_CODEX_RESOURCE_DIRS) {
|
||||
ensureSharedResourceDir(profileDir, sharedCodexHome, resourceName);
|
||||
}
|
||||
|
||||
ensureSharedPluginCache(profileDir, sharedCodexHome);
|
||||
}
|
||||
|
||||
function ensureSharedResourceDir(
|
||||
|
||||
@@ -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}`));
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
* instead of the package-manager entrypoint.
|
||||
*/
|
||||
|
||||
import * as crypto from 'crypto';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
@@ -17,6 +18,10 @@ import type { LaunchJson } from './bar-paths';
|
||||
|
||||
const SHIM_MODE = 0o700;
|
||||
|
||||
function sha256File(filePath: string): string {
|
||||
return crypto.createHash('sha256').update(fs.readFileSync(filePath)).digest('hex');
|
||||
}
|
||||
|
||||
export interface LaunchDescriptorOptions {
|
||||
entrypointPath?: string;
|
||||
runtime?: string;
|
||||
@@ -38,11 +43,29 @@ function resolveEntrypoint(entrypointPath?: string): string {
|
||||
|
||||
export function writeLaunchShim(home: string, entrypointPath?: string): string {
|
||||
const resolvedEntrypoint = resolveEntrypoint(entrypointPath);
|
||||
const expectedEntrypointHash = sha256File(resolvedEntrypoint);
|
||||
const shimPath = getLaunchShimPath(home);
|
||||
const shimDir = path.dirname(shimPath);
|
||||
const contents = [
|
||||
'#!/usr/bin/env node',
|
||||
`require(${JSON.stringify(resolvedEntrypoint)});`,
|
||||
"const crypto = require('crypto');",
|
||||
"const fs = require('fs');",
|
||||
"const Module = require('module');",
|
||||
"const path = require('path');",
|
||||
`const expectedEntrypoint = ${JSON.stringify(resolvedEntrypoint)};`,
|
||||
`const expectedHash = ${JSON.stringify(expectedEntrypointHash)};`,
|
||||
'const resolvedEntrypoint = fs.realpathSync(expectedEntrypoint);',
|
||||
"if (resolvedEntrypoint !== expectedEntrypoint) throw new Error('CCS Bar launch shim target changed. Run `ccs bar launch` to refresh launch.json.');",
|
||||
'const entrypointStat = fs.statSync(resolvedEntrypoint);',
|
||||
"if (!entrypointStat.isFile()) throw new Error('CCS Bar launch shim target is not a regular file.');",
|
||||
'const source = fs.readFileSync(resolvedEntrypoint);',
|
||||
"const actualHash = crypto.createHash('sha256').update(source).digest('hex');",
|
||||
"if (actualHash !== expectedHash) throw new Error('CCS Bar launch shim target changed. Run `ccs bar launch` to refresh launch.json.');",
|
||||
'const targetModule = new Module(resolvedEntrypoint, module);',
|
||||
'targetModule.filename = resolvedEntrypoint;',
|
||||
'targetModule.paths = Module._nodeModulePaths(path.dirname(resolvedEntrypoint));',
|
||||
'require.cache[resolvedEntrypoint] = targetModule;',
|
||||
"targetModule._compile(source.toString('utf8'), resolvedEntrypoint);",
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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' },
|
||||
{
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,75 @@ export function parseTomlObject(rawText: string): Record<string, unknown> {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export interface TomlNewlineRepairProposal {
|
||||
repairedText: string;
|
||||
insertionOffset: number;
|
||||
}
|
||||
|
||||
const CODEX_MODEL_MIGRATION_TABLE = /^[ \t]*\[notice\.model_migrations\][ \t]*(?:#.*)?$/;
|
||||
const QUOTED_MIGRATION_JOIN =
|
||||
/^[ \t]*(?:"(?:\\.|[^"\\])*"|'[^']*')[ \t]*=[ \t]*(?:"(?:\\.|[^"\\])*"|'[^']*')(?=\[)/;
|
||||
const TOML_TABLE_HEADER_SUFFIX = /^(?:\[\[[^\]\r\n]+\]\]|\[[^\]\r\n]+\])[ \t]*(?:#.*)?$/;
|
||||
|
||||
type TomlMultilineDelimiter = '"""' | "'''";
|
||||
|
||||
function isEscapedAt(text: string, offset: number): boolean {
|
||||
let backslashCount = 0;
|
||||
for (let index = offset - 1; index >= 0 && text[index] === '\\'; index -= 1) {
|
||||
backslashCount += 1;
|
||||
}
|
||||
return backslashCount % 2 === 1;
|
||||
}
|
||||
|
||||
function updateTomlMultilineDelimiter(
|
||||
line: string,
|
||||
initialDelimiter: TomlMultilineDelimiter | null
|
||||
): TomlMultilineDelimiter | null {
|
||||
let delimiter = initialDelimiter;
|
||||
let cursor = 0;
|
||||
|
||||
while (cursor < line.length) {
|
||||
if (delimiter) {
|
||||
const closingOffset = line.indexOf(delimiter, cursor);
|
||||
if (closingOffset === -1) return delimiter;
|
||||
if (delimiter === '"""' && isEscapedAt(line, closingOffset)) {
|
||||
cursor = closingOffset + 1;
|
||||
continue;
|
||||
}
|
||||
cursor = closingOffset + delimiter.length;
|
||||
delimiter = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
const char = line[cursor];
|
||||
if (char === '#') return null;
|
||||
if (line.startsWith('"""', cursor) || line.startsWith("'''", cursor)) {
|
||||
delimiter = line.slice(cursor, cursor + 3) as TomlMultilineDelimiter;
|
||||
cursor += 3;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') {
|
||||
cursor += 1;
|
||||
while (cursor < line.length) {
|
||||
if (line[cursor] === '"' && !isEscapedAt(line, cursor)) {
|
||||
cursor += 1;
|
||||
break;
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (char === "'") {
|
||||
const closingOffset = line.indexOf("'", cursor + 1);
|
||||
cursor = closingOffset === -1 ? line.length : closingOffset + 1;
|
||||
continue;
|
||||
}
|
||||
cursor += 1;
|
||||
}
|
||||
|
||||
return delimiter;
|
||||
}
|
||||
|
||||
export function safeParseTomlObject(rawText: string): SafeTomlObjectParseResult {
|
||||
try {
|
||||
return {
|
||||
@@ -39,3 +108,61 @@ export function safeParseTomlObject(rawText: string): SafeTomlObjectParseResult
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function proposeJoinedCodexModelMigrationRepair(
|
||||
rawText: string
|
||||
): TomlNewlineRepairProposal | null {
|
||||
if (!safeParseTomlObject(rawText).parseError) return null;
|
||||
|
||||
const candidates: Array<{ insertionOffset: number; lineEnding: string }> = [];
|
||||
let cursor = 0;
|
||||
let inModelMigrationTable = false;
|
||||
let previousLineEnding = '\n';
|
||||
let multilineDelimiter: TomlMultilineDelimiter | null = null;
|
||||
|
||||
while (cursor < rawText.length) {
|
||||
const newlineOffset = rawText.indexOf('\n', cursor);
|
||||
const rawLineEnd = newlineOffset === -1 ? rawText.length : newlineOffset;
|
||||
const usesCrLf = newlineOffset !== -1 && rawText[rawLineEnd - 1] === '\r';
|
||||
const contentEnd = usesCrLf ? rawLineEnd - 1 : rawLineEnd;
|
||||
const lineEnding = newlineOffset === -1 ? '' : usesCrLf ? '\r\n' : '\n';
|
||||
const rawLine = rawText.slice(cursor, contentEnd);
|
||||
const line = cursor === 0 && rawLine.charCodeAt(0) === 0xfeff ? rawLine.slice(1) : rawLine;
|
||||
|
||||
if (!multilineDelimiter) {
|
||||
if (CODEX_MODEL_MIGRATION_TABLE.test(line)) {
|
||||
inModelMigrationTable = true;
|
||||
} else if (/^[ \t]*\[\[?/.test(line)) {
|
||||
inModelMigrationTable = false;
|
||||
} else if (inModelMigrationTable) {
|
||||
const joinedMapping = line.match(QUOTED_MIGRATION_JOIN);
|
||||
if (joinedMapping) {
|
||||
const suffix = line.slice(joinedMapping[0].length);
|
||||
if (TOML_TABLE_HEADER_SUFFIX.test(suffix)) {
|
||||
candidates.push({
|
||||
insertionOffset: cursor + joinedMapping[0].length,
|
||||
lineEnding: lineEnding || previousLineEnding,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
multilineDelimiter = updateTomlMultilineDelimiter(line, multilineDelimiter);
|
||||
|
||||
if (lineEnding) previousLineEnding = lineEnding;
|
||||
if (newlineOffset === -1) break;
|
||||
cursor = newlineOffset + 1;
|
||||
}
|
||||
|
||||
if (candidates.length !== 1) return null;
|
||||
|
||||
const [{ insertionOffset, lineEnding }] = candidates;
|
||||
const repairedText = `${rawText.slice(0, insertionOffset)}${lineEnding}${rawText.slice(insertionOffset)}`;
|
||||
|
||||
try {
|
||||
parseTomlObject(repairedText);
|
||||
return { repairedText, insertionOffset };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -216,6 +216,21 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
|
||||
cacheCreationPerMillion: 3.75,
|
||||
cacheReadPerMillion: 0.3,
|
||||
},
|
||||
// Claude Sonnet 5 ($3/$15) — latest Sonnet; shares the standard Sonnet rates.
|
||||
// Registered explicitly so it is a known model rather than relying on the
|
||||
// unknown-model fallback that coincidentally matches these rates.
|
||||
'claude-sonnet-5': {
|
||||
inputPerMillion: 3.0,
|
||||
outputPerMillion: 15.0,
|
||||
cacheCreationPerMillion: 3.75,
|
||||
cacheReadPerMillion: 0.3,
|
||||
},
|
||||
'claude-sonnet-5-thinking': {
|
||||
inputPerMillion: 3.0,
|
||||
outputPerMillion: 15.0,
|
||||
cacheCreationPerMillion: 3.75,
|
||||
cacheReadPerMillion: 0.3,
|
||||
},
|
||||
// Claude 4 Opus ($15/$75)
|
||||
'claude-4-opus-20250514': {
|
||||
inputPerMillion: 15.0,
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* Bar-only adapter for CLIProxy pool-account quota.
|
||||
*
|
||||
* Bar's cache and row builder consume the legacy Antigravity QuotaResult shape.
|
||||
* Claude and Codex expose richer window-based results, so this adapter dispatches
|
||||
* to their single-account fetchers and collapses the binding core window into
|
||||
* the existing percentage/reset fields. Other providers retain the legacy
|
||||
* fetcher's behavior, including Antigravity and quota_not_supported results.
|
||||
*/
|
||||
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import type { QuotaResult } from '../../cliproxy/quota/quota-fetcher';
|
||||
import { fetchAccountQuota } from '../../cliproxy/quota/quota-fetcher';
|
||||
import {
|
||||
getCachedQuota,
|
||||
invalidateQuotaCache,
|
||||
setCachedQuota,
|
||||
} from '../../cliproxy/quota/quota-response-cache';
|
||||
import {
|
||||
buildClaudeCoreUsageSummary,
|
||||
fetchClaudeQuota,
|
||||
} from '../../cliproxy/quota/quota-fetcher-claude';
|
||||
import {
|
||||
buildCodexCoreUsageSummary,
|
||||
fetchCodexQuota,
|
||||
} from '../../cliproxy/quota/quota-fetcher-codex';
|
||||
import type {
|
||||
ClaudeQuotaResult,
|
||||
CodexQuotaResult,
|
||||
QuotaErrorMetadata,
|
||||
} from '../../cliproxy/quota/quota-types';
|
||||
import { shouldCacheQuotaResult } from './cliproxy-stats-routes/quota-helpers';
|
||||
|
||||
interface BindingWindow {
|
||||
name: string;
|
||||
displayName: string;
|
||||
remainingPercent: number;
|
||||
resetAt: string | null;
|
||||
}
|
||||
|
||||
interface ProviderQuotaResult extends QuotaErrorMetadata {
|
||||
success: boolean;
|
||||
lastUpdated: number;
|
||||
error?: string;
|
||||
accountId?: string;
|
||||
needsReauth?: boolean;
|
||||
}
|
||||
|
||||
type RawWindowQuotaResult = ClaudeQuotaResult | CodexQuotaResult;
|
||||
|
||||
const rawQuotaByNormalizedResult = new WeakMap<QuotaResult, RawWindowQuotaResult>();
|
||||
|
||||
export interface BarPoolAccountQuotaFetcherDeps {
|
||||
fetchLegacyAccountQuota: (provider: CLIProxyProvider, accountId: string) => Promise<QuotaResult>;
|
||||
fetchClaudeQuota: (accountId: string) => Promise<ClaudeQuotaResult>;
|
||||
fetchCodexQuota: (accountId: string) => Promise<CodexQuotaResult>;
|
||||
}
|
||||
|
||||
function normalizeRemainingPercent(value: number): number {
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function isPresent<T>(value: T | null | undefined): value is T {
|
||||
return value !== null && value !== undefined;
|
||||
}
|
||||
|
||||
function resetTimestamp(resetAt: string | null): number {
|
||||
if (!resetAt) return Number.POSITIVE_INFINITY;
|
||||
const timestamp = Date.parse(resetAt);
|
||||
return Number.isFinite(timestamp) ? timestamp : Number.POSITIVE_INFINITY;
|
||||
}
|
||||
|
||||
function pickBindingWindow(windows: BindingWindow[]): BindingWindow | null {
|
||||
return (
|
||||
windows
|
||||
.filter((window) => Number.isFinite(window.remainingPercent))
|
||||
.sort(
|
||||
(left, right) =>
|
||||
left.remainingPercent - right.remainingPercent ||
|
||||
resetTimestamp(left.resetAt) - resetTimestamp(right.resetAt)
|
||||
)[0] ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function pickEarliestReset(windows: BindingWindow[]): string | null {
|
||||
return (
|
||||
windows
|
||||
.map((window) => ({
|
||||
resetAt: window.resetAt,
|
||||
timestamp: resetTimestamp(window.resetAt),
|
||||
}))
|
||||
.filter((reset) => Number.isFinite(reset.timestamp))
|
||||
.sort((left, right) => left.timestamp - right.timestamp)[0]?.resetAt ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeResult(
|
||||
result: ProviderQuotaResult,
|
||||
bindingWindow: BindingWindow | null,
|
||||
earliestReset: string | null
|
||||
): QuotaResult {
|
||||
return {
|
||||
success: result.success,
|
||||
models:
|
||||
result.success && bindingWindow
|
||||
? [
|
||||
{
|
||||
name: bindingWindow.name,
|
||||
displayName: bindingWindow.displayName,
|
||||
percentage: normalizeRemainingPercent(bindingWindow.remainingPercent),
|
||||
resetTime: earliestReset,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
lastUpdated: result.lastUpdated,
|
||||
httpStatus: result.httpStatus,
|
||||
errorCode: result.errorCode,
|
||||
errorDetail: result.errorDetail,
|
||||
isForbidden: result.isForbidden,
|
||||
error: result.error,
|
||||
actionHint: result.actionHint,
|
||||
retryable: result.retryable,
|
||||
needsReauth: result.needsReauth,
|
||||
accountId: result.accountId,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeClaudeQuota(result: ClaudeQuotaResult): QuotaResult {
|
||||
const coreUsage =
|
||||
result.coreUsage?.fiveHour || result.coreUsage?.weekly
|
||||
? result.coreUsage
|
||||
: buildClaudeCoreUsageSummary(result.windows);
|
||||
const windows = [coreUsage?.fiveHour, coreUsage?.weekly].filter(isPresent).map((window) => ({
|
||||
name: window.rateLimitType,
|
||||
displayName: window.label,
|
||||
remainingPercent: window.remainingPercent,
|
||||
resetAt: window.resetAt,
|
||||
}));
|
||||
const bindingWindow = pickBindingWindow(windows);
|
||||
|
||||
const normalized = normalizeResult(result, bindingWindow, pickEarliestReset(windows));
|
||||
rawQuotaByNormalizedResult.set(normalized, result);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeCodexQuota(result: CodexQuotaResult): QuotaResult {
|
||||
const coreUsage =
|
||||
result.coreUsage?.fiveHour || result.coreUsage?.weekly
|
||||
? result.coreUsage
|
||||
: buildCodexCoreUsageSummary(result.windows);
|
||||
const windows = [coreUsage?.fiveHour, coreUsage?.weekly].filter(isPresent).map((window) => ({
|
||||
name: window.label,
|
||||
displayName: window.label,
|
||||
remainingPercent: window.remainingPercent,
|
||||
resetAt: window.resetAt,
|
||||
}));
|
||||
const bindingWindow = pickBindingWindow(windows);
|
||||
|
||||
const normalized = normalizeResult(result, bindingWindow, pickEarliestReset(windows));
|
||||
rawQuotaByNormalizedResult.set(normalized, result);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isWindowQuotaResult(value: unknown): value is RawWindowQuotaResult {
|
||||
return (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
'windows' in value &&
|
||||
Array.isArray((value as { windows?: unknown }).windows)
|
||||
);
|
||||
}
|
||||
|
||||
export function getCachedBarPoolAccountQuota<T>(
|
||||
provider: CLIProxyProvider | string,
|
||||
accountId: string
|
||||
): T | null {
|
||||
const cached = getCachedQuota<unknown>(provider, accountId);
|
||||
if (!cached) return null;
|
||||
if (provider === 'claude' && isWindowQuotaResult(cached)) {
|
||||
return normalizeClaudeQuota(cached as ClaudeQuotaResult) as T;
|
||||
}
|
||||
if (provider === 'codex' && isWindowQuotaResult(cached)) {
|
||||
return normalizeCodexQuota(cached as CodexQuotaResult) as T;
|
||||
}
|
||||
return cached as T;
|
||||
}
|
||||
|
||||
export function setCachedBarPoolAccountQuota<T>(
|
||||
provider: CLIProxyProvider | string,
|
||||
accountId: string,
|
||||
data: T
|
||||
): void {
|
||||
const raw =
|
||||
typeof data === 'object' && data !== null
|
||||
? rawQuotaByNormalizedResult.get(data as unknown as QuotaResult)
|
||||
: undefined;
|
||||
const cacheValue = raw ?? data;
|
||||
if (
|
||||
typeof cacheValue === 'object' &&
|
||||
cacheValue !== null &&
|
||||
'success' in cacheValue &&
|
||||
typeof cacheValue.success === 'boolean' &&
|
||||
!shouldCacheQuotaResult(cacheValue as ProviderQuotaResult)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
setCachedQuota(provider, accountId, cacheValue);
|
||||
}
|
||||
|
||||
export function invalidateCachedBarPoolAccountQuota(
|
||||
provider: CLIProxyProvider | string,
|
||||
accountId: string
|
||||
): void {
|
||||
invalidateQuotaCache(provider, accountId);
|
||||
}
|
||||
|
||||
export function createBarPoolAccountQuotaFetcher(
|
||||
deps: BarPoolAccountQuotaFetcherDeps
|
||||
): (provider: CLIProxyProvider, accountId: string) => Promise<QuotaResult> {
|
||||
return async (provider, accountId) => {
|
||||
if (provider === 'claude') {
|
||||
return normalizeClaudeQuota(await deps.fetchClaudeQuota(accountId));
|
||||
}
|
||||
if (provider === 'codex') {
|
||||
return normalizeCodexQuota(await deps.fetchCodexQuota(accountId));
|
||||
}
|
||||
return deps.fetchLegacyAccountQuota(provider, accountId);
|
||||
};
|
||||
}
|
||||
|
||||
export const fetchBarPoolAccountQuota = createBarPoolAccountQuotaFetcher({
|
||||
fetchLegacyAccountQuota: fetchAccountQuota,
|
||||
fetchClaudeQuota,
|
||||
fetchCodexQuota,
|
||||
});
|
||||
@@ -631,11 +631,11 @@ export function createBarRouter(deps: BarRouterDeps): Router {
|
||||
|
||||
import { getAllAccountsSummary } from '../../cliproxy/accounts/query';
|
||||
import {
|
||||
getCachedQuota,
|
||||
setCachedQuota,
|
||||
invalidateQuotaCache,
|
||||
} from '../../cliproxy/quota/quota-response-cache';
|
||||
import { fetchAccountQuota } from '../../cliproxy/quota/quota-fetcher';
|
||||
fetchBarPoolAccountQuota,
|
||||
getCachedBarPoolAccountQuota,
|
||||
invalidateCachedBarPoolAccountQuota,
|
||||
setCachedBarPoolAccountQuota,
|
||||
} from './bar-pool-account-quota-adapter';
|
||||
import { getTodayCostByAccount } from '../usage/data-aggregator';
|
||||
import { loadCliproxySnapshotDetails } from '../usage/cliproxy-snapshot-reader';
|
||||
import { getCachedDailyData, getCachedHourlyData } from '../usage/aggregator';
|
||||
@@ -644,10 +644,10 @@ import { getNativeAccountRows, getCachedNativeAccountRows } from '../usage/nativ
|
||||
/** Production bar router — wired to real dependencies */
|
||||
const barRouter: Router = createBarRouter({
|
||||
getAllAccountsSummary,
|
||||
getCachedQuota,
|
||||
setCachedQuota,
|
||||
invalidateQuotaCache,
|
||||
fetchAccountQuota,
|
||||
getCachedQuota: getCachedBarPoolAccountQuota,
|
||||
setCachedQuota: setCachedBarPoolAccountQuota,
|
||||
invalidateQuotaCache: invalidateCachedBarPoolAccountQuota,
|
||||
fetchAccountQuota: fetchBarPoolAccountQuota,
|
||||
getTodayCostByAccount,
|
||||
loadCliproxyDetails: loadCliproxySnapshotDetails,
|
||||
loadDailyUsage: () => getCachedDailyData(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -399,14 +399,50 @@ function isSymlinkPath(filePath: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function hasSymlinkSegment(basePath: string, targetPath: string): boolean {
|
||||
const relative = path.relative(basePath, targetPath);
|
||||
function getRequestedBasePath(basePath: string, targetPath: string): string {
|
||||
const comparisonBasePath = normalizePathForComparison(basePath);
|
||||
const comparisonTargetPath = normalizePathForComparison(targetPath);
|
||||
const comparisonBaseWithSeparator = comparisonBasePath.endsWith(path.sep)
|
||||
? comparisonBasePath
|
||||
: `${comparisonBasePath}${path.sep}`;
|
||||
|
||||
if (
|
||||
comparisonTargetPath === comparisonBasePath ||
|
||||
comparisonTargetPath.startsWith(comparisonBaseWithSeparator)
|
||||
) {
|
||||
return targetPath.slice(0, basePath.length);
|
||||
}
|
||||
|
||||
return basePath;
|
||||
}
|
||||
|
||||
function hasSymlinkSegment(
|
||||
basePath: string,
|
||||
targetPath: string,
|
||||
comparisonBasePath: string,
|
||||
comparisonTargetPath: string
|
||||
): boolean {
|
||||
const relative = path.relative(comparisonBasePath, comparisonTargetPath);
|
||||
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let currentPath = basePath;
|
||||
const segments = relative.split(path.sep).filter(Boolean);
|
||||
const requestedBasePath = getRequestedBasePath(basePath, targetPath);
|
||||
if (requestedBasePath !== basePath && isSymlinkPath(requestedBasePath)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const requestedRelative = path.relative(requestedBasePath, targetPath);
|
||||
if (
|
||||
requestedRelative === '' ||
|
||||
requestedRelative.startsWith('..') ||
|
||||
path.isAbsolute(requestedRelative)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
let currentPath = requestedBasePath;
|
||||
const segments = requestedRelative.split(path.sep).filter(Boolean);
|
||||
for (const segment of segments) {
|
||||
currentPath = path.join(currentPath, segment);
|
||||
if (isSymlinkPath(currentPath)) {
|
||||
@@ -432,7 +468,7 @@ export function validateFilePath(filePath: string): {
|
||||
|
||||
// Check if path is within ~/.ccs/
|
||||
if (isPathWithin(ccsDir, normalizedPath)) {
|
||||
if (hasSymlinkSegment(resolvedCcsDir, resolvedPath)) {
|
||||
if (hasSymlinkSegment(resolvedCcsDir, resolvedPath, ccsDir, normalizedPath)) {
|
||||
return { valid: false, readonly: false, error: 'Access to this path is not allowed' };
|
||||
}
|
||||
|
||||
|
||||
@@ -113,7 +113,8 @@ async function loadInstanceData(instancePath: string): Promise<{
|
||||
}
|
||||
|
||||
function getHourlyRequestCount(hour: HourlyUsage): number {
|
||||
return hour.requestCount ?? hour.modelBreakdowns.length;
|
||||
// modelBreakdowns can be absent at runtime (legacy snapshots, files read mid-write).
|
||||
return hour.requestCount ?? hour.modelBreakdowns?.length ?? 0;
|
||||
}
|
||||
|
||||
function finalizeDailyUsage(day: DailyUsage): DailyUsage {
|
||||
@@ -165,7 +166,7 @@ export function mergeDailyData(
|
||||
existing.cacheReadTokens += day.cacheReadTokens;
|
||||
existing.totalCost += day.totalCost;
|
||||
// Merge model breakdowns by aggregating same modelName
|
||||
for (const breakdown of day.modelBreakdowns) {
|
||||
for (const breakdown of day.modelBreakdowns ?? []) {
|
||||
const breakdownKey = getProviderModelKey(breakdown);
|
||||
const existingBreakdown = existing.modelBreakdowns.find(
|
||||
(b) => getProviderModelKey(b) === breakdownKey
|
||||
@@ -182,7 +183,7 @@ export function mergeDailyData(
|
||||
}
|
||||
} else {
|
||||
// Clone to avoid mutating original
|
||||
const modelBreakdowns = day.modelBreakdowns.map((b) => ({ ...b }));
|
||||
const modelBreakdowns = (day.modelBreakdowns ?? []).map((b) => ({ ...b }));
|
||||
dateMap.set(mergeKey, {
|
||||
...day,
|
||||
...(options.preserveProfile && day.profile ? { profile: day.profile } : {}),
|
||||
@@ -219,7 +220,7 @@ export function mergeMonthlyData(
|
||||
existing.cacheCreationTokens += month.cacheCreationTokens;
|
||||
existing.cacheReadTokens += month.cacheReadTokens;
|
||||
existing.totalCost += month.totalCost;
|
||||
for (const breakdown of month.modelBreakdowns) {
|
||||
for (const breakdown of month.modelBreakdowns ?? []) {
|
||||
const breakdownKey = getProviderModelKey(breakdown);
|
||||
const existingBreakdown = existing.modelBreakdowns.find(
|
||||
(item) => getProviderModelKey(item) === breakdownKey
|
||||
@@ -235,7 +236,9 @@ export function mergeMonthlyData(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const modelBreakdowns = month.modelBreakdowns.map((breakdown) => ({ ...breakdown }));
|
||||
const modelBreakdowns = (month.modelBreakdowns ?? []).map((breakdown) => ({
|
||||
...breakdown,
|
||||
}));
|
||||
monthMap.set(mergeKey, {
|
||||
...month,
|
||||
...(options.preserveProfile && month.profile ? { profile: month.profile } : {}),
|
||||
@@ -275,7 +278,7 @@ export function mergeHourlyData(
|
||||
existing.totalCost += hour.totalCost;
|
||||
existing.requestCount = getHourlyRequestCount(existing) + getHourlyRequestCount(hour);
|
||||
// Merge model breakdowns
|
||||
for (const breakdown of hour.modelBreakdowns) {
|
||||
for (const breakdown of hour.modelBreakdowns ?? []) {
|
||||
const breakdownKey = getProviderModelKey(breakdown);
|
||||
const existingBreakdown = existing.modelBreakdowns.find(
|
||||
(b) => getProviderModelKey(b) === breakdownKey
|
||||
@@ -291,7 +294,7 @@ export function mergeHourlyData(
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const modelBreakdowns = hour.modelBreakdowns.map((b) => ({ ...b }));
|
||||
const modelBreakdowns = (hour.modelBreakdowns ?? []).map((b) => ({ ...b }));
|
||||
hourMap.set(mergeKey, {
|
||||
...hour,
|
||||
...(options.preserveProfile && hour.profile ? { profile: hour.profile } : {}),
|
||||
|
||||
@@ -497,24 +497,31 @@ function serveCached(state: ProviderState): BarSummaryRow | null {
|
||||
* is absent or unparseable, returns null — the caller emits a parked row.
|
||||
* Never calls security/Keychain — zero new keychain access from this feature.
|
||||
*/
|
||||
function readClaudeCredentialsForProfileFromDisk(profile: string): ClaudeNativeCredentials | null {
|
||||
// The bare `ccs` default login uses the standard global credential lookup:
|
||||
// ~/.claude/.credentials.json, falling back to the single global
|
||||
// "Claude Code-credentials" Keychain item that Claude Code itself maintains.
|
||||
// This is the ONE pre-existing global read the shipped Bar already performs --
|
||||
// NOT a per-profile Keychain scan. Isolated `ccs auth` profiles below stay
|
||||
// file-only and never touch the Keychain.
|
||||
if (profile === DEFAULT_PROFILE) {
|
||||
return readClaudeCredentials();
|
||||
}
|
||||
function readClaudeCredentialsForProfileFromDisk(
|
||||
profile: string,
|
||||
readDefaultCredentials: () => ClaudeNativeCredentials | null = readClaudeCredentials
|
||||
): ClaudeNativeCredentials | null {
|
||||
try {
|
||||
const instanceDir = path.join(getCcsDir(), 'instances', profile);
|
||||
const credFile = path.join(instanceDir, '.credentials.json');
|
||||
if (!fs.existsSync(credFile)) return null;
|
||||
const raw = fs.readFileSync(credFile, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as ClaudeNativeCredentials;
|
||||
if (fs.existsSync(credFile)) {
|
||||
const raw = fs.readFileSync(credFile, 'utf8');
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as ClaudeNativeCredentials;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The bare `ccs` default login uses the standard global credential lookup:
|
||||
// ~/.claude/.credentials.json, falling back to the single global
|
||||
// "Claude Code-credentials" Keychain item that Claude Code itself maintains.
|
||||
// This is the ONE pre-existing global read the shipped Bar already performs --
|
||||
// NOT a per-profile Keychain scan. Isolated `ccs auth` profiles stay
|
||||
// file-only and never touch the Keychain; a real instance directory named
|
||||
// "default" is therefore parked when its file is absent.
|
||||
if (profile === DEFAULT_PROFILE && !fs.existsSync(instanceDir)) {
|
||||
return readDefaultCredentials();
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
@@ -710,12 +717,72 @@ function codexProfileMatchesPausedAccount(
|
||||
/**
|
||||
* Tag a row with whether it is the surface's default profile (drives the
|
||||
* "active" badge only). The row's own `paused` flag is authoritative for
|
||||
* dimming: it is already set by the collector to reflect LIVENESS — parked
|
||||
* (no on-disk creds / unsupported) rows arrive with paused:true; profiles with
|
||||
* a usable token arrive with paused:false regardless of default status. We must
|
||||
* NOT override `paused` from `isDefault`, or a valid non-default subscription
|
||||
* (e.g. an isolated `ccsx` profile) would render dimmed despite live quota.
|
||||
* dimming: live/default rows arrive with paused:false, while non-default
|
||||
* profiles are served cache-only and dimmed to avoid live-refresh fan-out.
|
||||
*/
|
||||
|
||||
function buildParkedClaudeProfileRow(profile: string, now: number): BarSummaryRow {
|
||||
return {
|
||||
account_id: `${SURFACE_CLAUDE}:${profile}`,
|
||||
provider: CLAUDE_NATIVE_PROVIDER,
|
||||
surface: SURFACE_CLAUDE,
|
||||
profile,
|
||||
is_subscription: true,
|
||||
displayName: profile,
|
||||
tier: null,
|
||||
paused: true,
|
||||
quota_percentage: null,
|
||||
quotaStatus: 'unsupported',
|
||||
next_reset: null,
|
||||
is_default: false,
|
||||
last_activity_at: null,
|
||||
today_cost: null,
|
||||
health: 'ok',
|
||||
cached: false,
|
||||
fetchedAt: new Date(now).toISOString(),
|
||||
needsReauth: true,
|
||||
};
|
||||
}
|
||||
|
||||
function buildParkedCodexProfileRow(profile: string, now: number): BarSummaryRow {
|
||||
return {
|
||||
account_id: `${SURFACE_CODEX}:${profile}`,
|
||||
provider: CODEX_NATIVE_PROVIDER,
|
||||
surface: SURFACE_CODEX,
|
||||
profile,
|
||||
is_subscription: true,
|
||||
displayName: profile,
|
||||
tier: null,
|
||||
paused: true,
|
||||
quota_percentage: null,
|
||||
quotaStatus: 'unsupported',
|
||||
next_reset: null,
|
||||
is_default: false,
|
||||
last_activity_at: null,
|
||||
today_cost: null,
|
||||
health: 'ok',
|
||||
cached: false,
|
||||
fetchedAt: new Date(now).toISOString(),
|
||||
needsReauth: true,
|
||||
};
|
||||
}
|
||||
|
||||
function collectCachedOrParkedProfileRow(
|
||||
map: Map<string, ProviderState>,
|
||||
profile: string,
|
||||
now: number,
|
||||
buildParkedRow: (profile: string, now: number) => BarSummaryRow
|
||||
): BarSummaryRow {
|
||||
const state = getState(map, profile);
|
||||
if (state.cachedRow) {
|
||||
return { ...state.cachedRow, cached: true, paused: true };
|
||||
}
|
||||
const parkedRow = buildParkedRow(profile, now);
|
||||
state.cachedRow = parkedRow;
|
||||
state.cachedAt = now;
|
||||
return parkedRow;
|
||||
}
|
||||
|
||||
function markDefault(row: BarSummaryRow, isDefault: boolean): BarSummaryRow {
|
||||
return { ...row, is_default: isDefault };
|
||||
}
|
||||
@@ -734,10 +801,11 @@ function markDefaultAndSyncCache(
|
||||
isDefault: boolean
|
||||
): BarSummaryRow {
|
||||
const state = map.get(profile);
|
||||
const marked = markDefault(row, isDefault);
|
||||
if (state?.cachedRow) {
|
||||
state.cachedRow = { ...state.cachedRow, is_default: isDefault };
|
||||
}
|
||||
return markDefault(row, isDefault);
|
||||
return marked;
|
||||
}
|
||||
|
||||
async function collectClaudeRowForProfile(
|
||||
@@ -768,9 +836,10 @@ async function collectClaudeRowForProfile(
|
||||
}
|
||||
|
||||
// For per-profile reads: use the injected seam (file-only, no keychain).
|
||||
const readDefaultCredentials = deps.readCredentials ?? readClaudeCredentials;
|
||||
const readCreds =
|
||||
deps.readClaudeCredentialsForProfile ??
|
||||
((p: string) => readClaudeCredentialsForProfileFromDisk(p));
|
||||
((p: string) => readClaudeCredentialsForProfileFromDisk(p, readDefaultCredentials));
|
||||
const fetchQuota = deps.fetchClaudeQuota ?? fetchClaudeQuotaWithToken;
|
||||
const sleep = deps.sleep ?? defaultSleep;
|
||||
|
||||
@@ -789,26 +858,7 @@ async function collectClaudeRowForProfile(
|
||||
// user has not logged in via 'ccs auth' for this machine or the credentials
|
||||
// are stored only in keychain (which we deliberately do not access here).
|
||||
if (!creds) {
|
||||
const parkedRow: BarSummaryRow = {
|
||||
account_id: `${SURFACE_CLAUDE}:${profile}`,
|
||||
provider: CLAUDE_NATIVE_PROVIDER,
|
||||
surface: SURFACE_CLAUDE,
|
||||
profile,
|
||||
is_subscription: true,
|
||||
displayName: profile,
|
||||
tier: null,
|
||||
paused: true,
|
||||
quota_percentage: null,
|
||||
quotaStatus: 'unsupported',
|
||||
next_reset: null,
|
||||
is_default: false,
|
||||
last_activity_at: null,
|
||||
today_cost: null,
|
||||
health: 'ok',
|
||||
cached: false,
|
||||
fetchedAt: new Date(now).toISOString(),
|
||||
needsReauth: true,
|
||||
};
|
||||
const parkedRow = buildParkedClaudeProfileRow(profile, now);
|
||||
// Cache the parked row so repeated calls don't re-stat the fs.
|
||||
state.cachedRow = parkedRow;
|
||||
state.cachedAt = now;
|
||||
@@ -1062,26 +1112,7 @@ async function collectCodexRowForProfile(
|
||||
// profile.
|
||||
const stale = serveCached(state);
|
||||
if (stale) return stale;
|
||||
const parkedRow: BarSummaryRow = {
|
||||
account_id: `${SURFACE_CODEX}:${profile}`,
|
||||
provider: CODEX_NATIVE_PROVIDER,
|
||||
surface: SURFACE_CODEX,
|
||||
profile,
|
||||
is_subscription: true,
|
||||
displayName: profile,
|
||||
tier: null,
|
||||
paused: true,
|
||||
quota_percentage: null,
|
||||
quotaStatus: 'unsupported',
|
||||
next_reset: null,
|
||||
is_default: false,
|
||||
last_activity_at: null,
|
||||
today_cost: null,
|
||||
health: 'ok',
|
||||
cached: false,
|
||||
fetchedAt: new Date(now).toISOString(),
|
||||
needsReauth: true,
|
||||
};
|
||||
const parkedRow = buildParkedCodexProfileRow(profile, now);
|
||||
state.cachedRow = parkedRow;
|
||||
state.cachedAt = now;
|
||||
return parkedRow;
|
||||
@@ -1545,30 +1576,62 @@ async function getNativeAccountRowsMultiProfile(
|
||||
})();
|
||||
|
||||
const tasks: Promise<BarSummaryRow | null>[] = [];
|
||||
const results: (BarSummaryRow | null)[] = [];
|
||||
const now = (deps.now ?? Date.now)();
|
||||
|
||||
// Forced refresh applies to every profile: parked profiles (no creds) short-
|
||||
// circuit to a parked row with zero network, so forcing them is free, while
|
||||
// every profile that has a usable token gets live quota — not just the
|
||||
// default. Per-profile TTL + breaker still protect each account.
|
||||
// Preserve the safety budget: only the active/default profile for each surface
|
||||
// may perform a live refresh. Non-default profiles are cache-only (or parked)
|
||||
// so one /summary request can trigger at most one Claude and one Codex live
|
||||
// upstream call regardless of configured profile count.
|
||||
for (const p of claudeProfiles) {
|
||||
const isDefault = p === claudeDefault;
|
||||
if (!isDefault) {
|
||||
results.push(
|
||||
markDefaultAndSyncCache(
|
||||
claudeProfileStates,
|
||||
p,
|
||||
collectCachedOrParkedProfileRow(claudeProfileStates, p, now, buildParkedClaudeProfileRow),
|
||||
false
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
tasks.push(
|
||||
collectClaudeRowForProfile(p, deps, force)
|
||||
.then((r) => (r ? markDefaultAndSyncCache(claudeProfileStates, p, r, isDefault) : null))
|
||||
collectClaudeRowForProfile(
|
||||
p,
|
||||
deps,
|
||||
force || claudeProfileStates.get(p)?.cachedRow?.quotaStatus === 'unsupported'
|
||||
)
|
||||
.then((r) => (r ? markDefaultAndSyncCache(claudeProfileStates, p, r, true) : null))
|
||||
.catch(() => null)
|
||||
);
|
||||
}
|
||||
|
||||
for (const p of codexProfiles) {
|
||||
const isDefault = p === codexDefault;
|
||||
if (!isDefault) {
|
||||
results.push(
|
||||
markDefaultAndSyncCache(
|
||||
codexProfileStates,
|
||||
p,
|
||||
collectCachedOrParkedProfileRow(codexProfileStates, p, now, buildParkedCodexProfileRow),
|
||||
false
|
||||
)
|
||||
);
|
||||
continue;
|
||||
}
|
||||
tasks.push(
|
||||
collectCodexRowForProfile(p, deps, force)
|
||||
.then((r) => (r ? markDefaultAndSyncCache(codexProfileStates, p, r, isDefault) : null))
|
||||
collectCodexRowForProfile(
|
||||
p,
|
||||
deps,
|
||||
force || codexProfileStates.get(p)?.cachedRow?.quotaStatus === 'unsupported'
|
||||
)
|
||||
.then((r) => (r ? markDefaultAndSyncCache(codexProfileStates, p, r, true) : null))
|
||||
.catch(() => null)
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(tasks);
|
||||
results.push(...(await Promise.all(tasks)));
|
||||
const rows = results.filter((r): r is BarSummaryRow => r !== null);
|
||||
|
||||
// Sort by (surface, profile) for stable ordering.
|
||||
|
||||
@@ -20,6 +20,12 @@ describe('account context helpers', () => {
|
||||
expect(isValidAccountProfileName('gemini:default')).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects reserved account profile names', () => {
|
||||
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', () => {
|
||||
const resolved = resolveAccountContextPolicy({
|
||||
context_mode: 'shared',
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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' } }));
|
||||
|
||||
@@ -176,9 +176,45 @@ describe('codex-runtime router — non-auth profile resolution', () => {
|
||||
expect(process.env.CCS_CODEX_PROFILE).toBe('ck');
|
||||
expect(process.env.CODEX_HOME).toBe(profileDir);
|
||||
expect(fs.existsSync(path.join(profileDir, 'agents', 'brainstormer.toml'))).toBe(true);
|
||||
expect(fs.lstatSync(path.join(profileDir, 'plugins', 'cache')).isSymbolicLink()).toBe(true);
|
||||
expect(argv).toEqual(['node', 'codex-runtime', 'default', 'fix failing tests']);
|
||||
});
|
||||
|
||||
it('repairs a positional ccsx profile before delegating to CCS', async () => {
|
||||
const profileDir = makeProfileDir('ck');
|
||||
writeRegistry({
|
||||
version: '1.0',
|
||||
default: null,
|
||||
profiles: { ck: { type: 'codex', created: '2026-01-01T00:00:00.000Z', last_used: null } },
|
||||
});
|
||||
const events: string[] = [];
|
||||
|
||||
flushRouterCache();
|
||||
require.cache[resourcePath] = {
|
||||
exports: {
|
||||
ensureCodexProfileResources: (dir: string) => {
|
||||
expect(dir).toBe(profileDir);
|
||||
events.push('repair');
|
||||
},
|
||||
},
|
||||
} as NodeJS.Module;
|
||||
require.cache[ccsPath] = Object.defineProperty({} as NodeJS.Module, 'exports', {
|
||||
configurable: true,
|
||||
get: () => {
|
||||
events.push('ccs');
|
||||
return {};
|
||||
},
|
||||
});
|
||||
|
||||
const argv = ['node', 'codex-runtime', 'ck'];
|
||||
const { main } = require(routerPath) as { main: (args: string[]) => Promise<number> };
|
||||
const code = await main(argv);
|
||||
|
||||
expect(code).toBe(-1);
|
||||
expect(events).toEqual(['repair', 'ccs']);
|
||||
expect(argv).toEqual(['node', 'codex-runtime', 'default']);
|
||||
});
|
||||
|
||||
it('self-heals missing Codex profile resources during launch', async () => {
|
||||
const profileDir = makeProfileDir('ck');
|
||||
writeRegistry({
|
||||
|
||||
@@ -6,6 +6,22 @@ import * as path from 'path';
|
||||
let tempDir: string;
|
||||
let profileDir: string;
|
||||
let sharedCodexHome: string;
|
||||
const cachedSkillRelativePath = path.join(
|
||||
'openai-bundled',
|
||||
'sites',
|
||||
'0.1.27',
|
||||
'skills',
|
||||
'site-builder',
|
||||
'SKILL.md'
|
||||
);
|
||||
const staleCachedSkillRelativePath = path.join(
|
||||
'openai-bundled',
|
||||
'sites',
|
||||
'0.1.26',
|
||||
'skills',
|
||||
'site-builder',
|
||||
'SKILL.md'
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codex-resources-test-'));
|
||||
@@ -18,6 +34,17 @@ beforeEach(() => {
|
||||
);
|
||||
fs.mkdirSync(path.join(sharedCodexHome, 'skills'), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(path.join(sharedCodexHome, 'skills', 'review.md'), '# Review\n');
|
||||
fs.mkdirSync(
|
||||
path.join(sharedCodexHome, 'plugins', 'cache', path.dirname(cachedSkillRelativePath)),
|
||||
{
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
}
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(sharedCodexHome, 'plugins', 'cache', cachedSkillRelativePath),
|
||||
'# Current shared skill\n'
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -26,7 +53,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe('ensureCodexProfileResources', () => {
|
||||
it('links shared agents and skills into a fresh Codex profile', async () => {
|
||||
it('exposes shared agents, skills, and plugin cache in a fresh Codex profile', async () => {
|
||||
const { ensureCodexProfileResources } = await import(
|
||||
'../../../src/codex-auth/codex-profile-resources'
|
||||
);
|
||||
@@ -38,6 +65,344 @@ describe('ensureCodexProfileResources', () => {
|
||||
expect(fs.lstatSync(resourcePath).isSymbolicLink()).toBe(true);
|
||||
expect(fs.readlinkSync(resourcePath)).toBe(path.join(sharedCodexHome, resourceName));
|
||||
}
|
||||
|
||||
const cachedSkillPath = path.join(profileDir, 'plugins', 'cache', cachedSkillRelativePath);
|
||||
expect(fs.readFileSync(cachedSkillPath, 'utf8')).toBe('# Current shared skill\n');
|
||||
});
|
||||
|
||||
it('replaces a stale profile-local plugin cache while preserving plugin siblings', async () => {
|
||||
const { ensureCodexProfileResources } = await import(
|
||||
'../../../src/codex-auth/codex-profile-resources'
|
||||
);
|
||||
const pluginsDir = path.join(profileDir, 'plugins');
|
||||
const cacheDir = path.join(pluginsDir, 'cache');
|
||||
const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath);
|
||||
const currentSkillPath = path.join(cacheDir, cachedSkillRelativePath);
|
||||
const siblingPath = path.join(pluginsDir, 'marketplaces.json');
|
||||
fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(staleSkillPath, '# Stale profile skill\n');
|
||||
fs.writeFileSync(siblingPath, '{"preserve":true}\n');
|
||||
|
||||
ensureCodexProfileResources(profileDir, { sharedCodexHome });
|
||||
|
||||
expect(fs.lstatSync(cacheDir).isSymbolicLink()).toBe(true);
|
||||
expect(fs.existsSync(staleSkillPath)).toBe(false);
|
||||
expect(fs.readFileSync(currentSkillPath, 'utf8')).toBe('# Current shared skill\n');
|
||||
expect(fs.readFileSync(siblingPath, 'utf8')).toBe('{"preserve":true}\n');
|
||||
});
|
||||
|
||||
it('refuses a symlinked plugins parent without changing its external target', async () => {
|
||||
const { ensureCodexProfileResources } = await import(
|
||||
'../../../src/codex-auth/codex-profile-resources'
|
||||
);
|
||||
const sharedPluginsDir = path.join(sharedCodexHome, 'plugins');
|
||||
const sharedSkillPath = path.join(sharedCodexHome, 'plugins', 'cache', cachedSkillRelativePath);
|
||||
const profilePluginsDir = path.join(profileDir, 'plugins');
|
||||
fs.mkdirSync(profileDir, { recursive: true, mode: 0o700 });
|
||||
fs.symlinkSync(sharedPluginsDir, profilePluginsDir, 'dir');
|
||||
|
||||
expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow(
|
||||
'profile plugins path is not a local directory'
|
||||
);
|
||||
|
||||
expect(fs.lstatSync(profilePluginsDir).isSymbolicLink()).toBe(true);
|
||||
expect(fs.lstatSync(path.join(sharedPluginsDir, 'cache')).isDirectory()).toBe(true);
|
||||
expect(fs.readFileSync(sharedSkillPath, 'utf8')).toBe('# Current shared skill\n');
|
||||
});
|
||||
|
||||
it('refuses a profile root that resolves to the shared Codex home without changing its cache', async () => {
|
||||
const { ensureCodexProfileResources } = await import(
|
||||
'../../../src/codex-auth/codex-profile-resources'
|
||||
);
|
||||
const sharedPluginsDir = path.join(sharedCodexHome, 'plugins');
|
||||
const sharedCacheDir = path.join(sharedPluginsDir, 'cache');
|
||||
const sharedMarkerPath = path.join(sharedCacheDir, 'shared-marker.txt');
|
||||
fs.writeFileSync(sharedMarkerPath, 'preserve shared cache\n');
|
||||
const cacheInodeBeforeRepair = fs.lstatSync(sharedCacheDir).ino;
|
||||
fs.symlinkSync(sharedCodexHome, profileDir, 'dir');
|
||||
|
||||
expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow(
|
||||
'profile plugins directory resolves to the shared plugins directory'
|
||||
);
|
||||
|
||||
expect(fs.lstatSync(profileDir).isSymbolicLink()).toBe(true);
|
||||
expect(fs.lstatSync(sharedCacheDir).isDirectory()).toBe(true);
|
||||
expect(fs.lstatSync(sharedCacheDir).isSymbolicLink()).toBe(false);
|
||||
expect(fs.lstatSync(sharedCacheDir).ino).toBe(cacheInodeBeforeRepair);
|
||||
expect(fs.readFileSync(sharedMarkerPath, 'utf8')).toBe('preserve shared cache\n');
|
||||
expect(
|
||||
fs.readdirSync(sharedPluginsDir).some((name) => name.startsWith('.cache.ccs-backup-'))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses a shared plugins directory that resolves to the profile plugins directory', async () => {
|
||||
const { ensureCodexProfileResources } = await import(
|
||||
'../../../src/codex-auth/codex-profile-resources'
|
||||
);
|
||||
const profilePluginsDir = path.join(profileDir, 'plugins');
|
||||
const profileCacheDir = path.join(profilePluginsDir, 'cache');
|
||||
const profileMarkerPath = path.join(profileCacheDir, 'profile-marker.txt');
|
||||
const sharedPluginsDir = path.join(sharedCodexHome, 'plugins');
|
||||
fs.mkdirSync(profileCacheDir, { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(profileMarkerPath, 'preserve profile cache\n');
|
||||
const cacheInodeBeforeRepair = fs.lstatSync(profileCacheDir).ino;
|
||||
fs.rmSync(sharedPluginsDir, { recursive: true, force: true });
|
||||
fs.symlinkSync(profilePluginsDir, sharedPluginsDir, 'dir');
|
||||
|
||||
expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow(
|
||||
'profile plugins directory resolves to the shared plugins directory'
|
||||
);
|
||||
|
||||
expect(fs.lstatSync(sharedPluginsDir).isSymbolicLink()).toBe(true);
|
||||
expect(fs.lstatSync(profileCacheDir).isDirectory()).toBe(true);
|
||||
expect(fs.lstatSync(profileCacheDir).isSymbolicLink()).toBe(false);
|
||||
expect(fs.lstatSync(profileCacheDir).ino).toBe(cacheInodeBeforeRepair);
|
||||
expect(fs.readFileSync(profileMarkerPath, 'utf8')).toBe('preserve profile cache\n');
|
||||
expect(
|
||||
fs.readdirSync(profilePluginsDir).some((name) => name.startsWith('.cache.ccs-backup-'))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps the plugin cache projection stable across repeated repairs', async () => {
|
||||
const { ensureCodexProfileResources } = await import(
|
||||
'../../../src/codex-auth/codex-profile-resources'
|
||||
);
|
||||
|
||||
ensureCodexProfileResources(profileDir, { sharedCodexHome });
|
||||
const cacheDir = path.join(profileDir, 'plugins', 'cache');
|
||||
const firstTarget = fs.readlinkSync(cacheDir);
|
||||
const firstInode = fs.lstatSync(cacheDir).ino;
|
||||
|
||||
ensureCodexProfileResources(profileDir, { sharedCodexHome });
|
||||
|
||||
expect(fs.readlinkSync(cacheDir)).toBe(firstTarget);
|
||||
expect(fs.lstatSync(cacheDir).ino).toBe(firstInode);
|
||||
expect(fs.readFileSync(path.join(cacheDir, cachedSkillRelativePath), 'utf8')).toBe(
|
||||
'# Current shared skill\n'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps a concurrently-created correct cache link instead of restoring stale state', async () => {
|
||||
const { ensureCodexProfileResources } = await import(
|
||||
'../../../src/codex-auth/codex-profile-resources'
|
||||
);
|
||||
const cacheDir = path.join(profileDir, 'plugins', 'cache');
|
||||
const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath);
|
||||
fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(staleSkillPath, '# Stale profile skill\n');
|
||||
|
||||
const realSymlinkSync = fs.symlinkSync.bind(fs);
|
||||
let injectedConcurrentRepair = false;
|
||||
const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation(
|
||||
(...args: Parameters<typeof fs.symlinkSync>) => {
|
||||
const [, pathToCreate] = args;
|
||||
if (
|
||||
!injectedConcurrentRepair &&
|
||||
path.resolve(String(pathToCreate)) === path.resolve(cacheDir)
|
||||
) {
|
||||
injectedConcurrentRepair = true;
|
||||
ensureCodexProfileResources(profileDir, { sharedCodexHome });
|
||||
}
|
||||
return realSymlinkSync(...args);
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
ensureCodexProfileResources(profileDir, { sharedCodexHome });
|
||||
} finally {
|
||||
symlinkSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(fs.lstatSync(cacheDir).isSymbolicLink()).toBe(true);
|
||||
expect(fs.existsSync(staleSkillPath)).toBe(false);
|
||||
expect(fs.readFileSync(path.join(cacheDir, cachedSkillRelativePath), 'utf8')).toBe(
|
||||
'# Current shared skill\n'
|
||||
);
|
||||
expect(
|
||||
fs.readdirSync(path.dirname(cacheDir)).some((name) => name.startsWith('.cache.ccs-backup-'))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('recovers when another repair moves the stale cache before this repair can rename it', async () => {
|
||||
const { ensureCodexProfileResources } = await import(
|
||||
'../../../src/codex-auth/codex-profile-resources'
|
||||
);
|
||||
const cacheDir = path.join(profileDir, 'plugins', 'cache');
|
||||
const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath);
|
||||
fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(staleSkillPath, '# Stale profile skill\n');
|
||||
|
||||
const realRenameSync = fs.renameSync.bind(fs);
|
||||
let injectedRenameRace = false;
|
||||
const renameSpy = spyOn(fs, 'renameSync').mockImplementation(
|
||||
(...args: Parameters<typeof fs.renameSync>) => {
|
||||
const [oldPath] = args;
|
||||
if (!injectedRenameRace && path.resolve(String(oldPath)) === path.resolve(cacheDir)) {
|
||||
injectedRenameRace = true;
|
||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
throw Object.assign(new Error('simulated concurrent move'), { code: 'ENOENT' });
|
||||
}
|
||||
return realRenameSync(...args);
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
ensureCodexProfileResources(profileDir, { sharedCodexHome });
|
||||
} finally {
|
||||
renameSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(fs.lstatSync(cacheDir).isSymbolicLink()).toBe(true);
|
||||
expect(fs.existsSync(staleSkillPath)).toBe(false);
|
||||
expect(fs.readFileSync(path.join(cacheDir, cachedSkillRelativePath), 'utf8')).toBe(
|
||||
'# Current shared skill\n'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not delete a concurrent cache path when symlink creation loses an EEXIST race', async () => {
|
||||
const { ensureCodexProfileResources } = await import(
|
||||
'../../../src/codex-auth/codex-profile-resources'
|
||||
);
|
||||
const pluginsDir = path.join(profileDir, 'plugins');
|
||||
const cacheDir = path.join(pluginsDir, 'cache');
|
||||
const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath);
|
||||
const concurrentMarkerPath = path.join(cacheDir, 'concurrent-marker.txt');
|
||||
fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(staleSkillPath, '# Stale profile skill\n');
|
||||
|
||||
const realSymlinkSync = fs.symlinkSync.bind(fs);
|
||||
const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation(
|
||||
(...args: Parameters<typeof fs.symlinkSync>) => {
|
||||
const [, pathToCreate] = args;
|
||||
if (path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) {
|
||||
fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(concurrentMarkerPath, 'preserve concurrent cache\n');
|
||||
throw Object.assign(new Error('simulated concurrent EEXIST'), { code: 'EEXIST' });
|
||||
}
|
||||
return realSymlinkSync(...args);
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow(
|
||||
'simulated concurrent EEXIST'
|
||||
);
|
||||
} finally {
|
||||
symlinkSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(fs.readFileSync(concurrentMarkerPath, 'utf8')).toBe('preserve concurrent cache\n');
|
||||
const backupNames = fs
|
||||
.readdirSync(pluginsDir)
|
||||
.filter((name) => name.startsWith('.cache.ccs-backup-'));
|
||||
expect(backupNames).toHaveLength(1);
|
||||
expect(
|
||||
fs.readFileSync(path.join(pluginsDir, backupNames[0], staleCachedSkillRelativePath), 'utf8')
|
||||
).toBe('# Stale profile skill\n');
|
||||
});
|
||||
|
||||
it('merges rollback data when a concurrent cache appears during restoration', async () => {
|
||||
const { ensureCodexProfileResources } = await import(
|
||||
'../../../src/codex-auth/codex-profile-resources'
|
||||
);
|
||||
const cacheDir = path.join(profileDir, 'plugins', 'cache');
|
||||
const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath);
|
||||
const concurrentMarkerPath = path.join(cacheDir, 'concurrent-marker.txt');
|
||||
fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(staleSkillPath, '# Stale profile skill\n');
|
||||
|
||||
const realSymlinkSync = fs.symlinkSync.bind(fs);
|
||||
const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation(
|
||||
(...args: Parameters<typeof fs.symlinkSync>) => {
|
||||
const [, pathToCreate] = args;
|
||||
if (path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) {
|
||||
throw Object.assign(new Error('simulated filesystem corruption'), { code: 'EIO' });
|
||||
}
|
||||
return realSymlinkSync(...args);
|
||||
}
|
||||
);
|
||||
const realMkdirSync = fs.mkdirSync.bind(fs);
|
||||
let injectedRestoreRace = false;
|
||||
const mkdirSpy = spyOn(fs, 'mkdirSync').mockImplementation(
|
||||
(...args: Parameters<typeof fs.mkdirSync>) => {
|
||||
const [pathToCreate] = args;
|
||||
if (!injectedRestoreRace && path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) {
|
||||
injectedRestoreRace = true;
|
||||
realMkdirSync(cacheDir, { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(concurrentMarkerPath, 'preserve concurrent cache\n');
|
||||
throw Object.assign(new Error('simulated concurrent create'), { code: 'EEXIST' });
|
||||
}
|
||||
return realMkdirSync(...args);
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow(
|
||||
'simulated filesystem corruption'
|
||||
);
|
||||
} finally {
|
||||
mkdirSpy.mockRestore();
|
||||
symlinkSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(fs.readFileSync(concurrentMarkerPath, 'utf8')).toBe('preserve concurrent cache\n');
|
||||
expect(fs.readFileSync(staleSkillPath, 'utf8')).toBe('# Stale profile skill\n');
|
||||
expect(
|
||||
fs.readdirSync(path.dirname(cacheDir)).some((name) => name.startsWith('.cache.ccs-backup-'))
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('preserves a concurrent non-directory cache path during fallback restoration', async () => {
|
||||
const { ensureCodexProfileResources } = await import(
|
||||
'../../../src/codex-auth/codex-profile-resources'
|
||||
);
|
||||
const pluginsDir = path.join(profileDir, 'plugins');
|
||||
const cacheDir = path.join(pluginsDir, 'cache');
|
||||
const staleSkillPath = path.join(cacheDir, staleCachedSkillRelativePath);
|
||||
fs.mkdirSync(path.dirname(staleSkillPath), { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(staleSkillPath, '# Stale profile skill\n');
|
||||
|
||||
const realSymlinkSync = fs.symlinkSync.bind(fs);
|
||||
const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation(
|
||||
(...args: Parameters<typeof fs.symlinkSync>) => {
|
||||
const [, pathToCreate] = args;
|
||||
if (path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) {
|
||||
throw Object.assign(new Error('simulated Windows symlink denial'), { code: 'EPERM' });
|
||||
}
|
||||
return realSymlinkSync(...args);
|
||||
}
|
||||
);
|
||||
const realMkdirSync = fs.mkdirSync.bind(fs);
|
||||
let injectedRestoreRace = false;
|
||||
const mkdirSpy = spyOn(fs, 'mkdirSync').mockImplementation(
|
||||
(...args: Parameters<typeof fs.mkdirSync>) => {
|
||||
const [pathToCreate] = args;
|
||||
if (!injectedRestoreRace && path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) {
|
||||
injectedRestoreRace = true;
|
||||
fs.writeFileSync(cacheDir, 'preserve concurrent file\n');
|
||||
throw Object.assign(new Error('simulated concurrent create'), { code: 'EEXIST' });
|
||||
}
|
||||
return realMkdirSync(...args);
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow(
|
||||
'profile cache path is not a local directory'
|
||||
);
|
||||
} finally {
|
||||
mkdirSpy.mockRestore();
|
||||
symlinkSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(fs.readFileSync(cacheDir, 'utf8')).toBe('preserve concurrent file\n');
|
||||
const backupNames = fs
|
||||
.readdirSync(pluginsDir)
|
||||
.filter((name) => name.startsWith('.cache.ccs-backup-'));
|
||||
expect(backupNames).toHaveLength(1);
|
||||
expect(
|
||||
fs.readFileSync(path.join(pluginsDir, backupNames[0], staleCachedSkillRelativePath), 'utf8')
|
||||
).toBe('# Stale profile skill\n');
|
||||
});
|
||||
|
||||
it('repairs a missing resource link without changing existing shared files', async () => {
|
||||
@@ -107,4 +472,88 @@ describe('ensureCodexProfileResources', () => {
|
||||
expect(fs.lstatSync(agentsPath).isDirectory()).toBe(true);
|
||||
expect(fs.existsSync(path.join(agentsPath, 'brainstormer.toml'))).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves a local plugin cache and copies missing shared entries on EPERM', async () => {
|
||||
const { ensureCodexProfileResources } = await import(
|
||||
'../../../src/codex-auth/codex-profile-resources'
|
||||
);
|
||||
ensureCodexProfileResources(profileDir, { sharedCodexHome });
|
||||
|
||||
const cacheDir = path.join(profileDir, 'plugins', 'cache');
|
||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(path.join(cacheDir, path.dirname(staleCachedSkillRelativePath)), {
|
||||
recursive: true,
|
||||
mode: 0o700,
|
||||
});
|
||||
fs.writeFileSync(path.join(cacheDir, 'local-only.txt'), 'keep me\n');
|
||||
fs.writeFileSync(path.join(cacheDir, staleCachedSkillRelativePath), '# Existing stale skill\n');
|
||||
|
||||
const realSymlinkSync = fs.symlinkSync.bind(fs);
|
||||
const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation(
|
||||
(...args: Parameters<typeof fs.symlinkSync>) => {
|
||||
const [, pathToCreate] = args;
|
||||
if (path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) {
|
||||
throw Object.assign(new Error('simulated Windows symlink denial'), { code: 'EPERM' });
|
||||
}
|
||||
return realSymlinkSync(...args);
|
||||
}
|
||||
);
|
||||
const origWrite = process.stderr.write.bind(process.stderr);
|
||||
process.stderr.write = () => true;
|
||||
|
||||
try {
|
||||
ensureCodexProfileResources(profileDir, { sharedCodexHome });
|
||||
} finally {
|
||||
process.stderr.write = origWrite;
|
||||
symlinkSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(fs.lstatSync(cacheDir).isDirectory()).toBe(true);
|
||||
expect(fs.readFileSync(path.join(cacheDir, 'local-only.txt'), 'utf8')).toBe('keep me\n');
|
||||
expect(fs.readFileSync(path.join(cacheDir, staleCachedSkillRelativePath), 'utf8')).toBe(
|
||||
'# Existing stale skill\n'
|
||||
);
|
||||
expect(fs.readFileSync(path.join(cacheDir, cachedSkillRelativePath), 'utf8')).toBe(
|
||||
'# Current shared skill\n'
|
||||
);
|
||||
});
|
||||
|
||||
it('restores a stale plugin cache and rethrows an unexpected symlink error', async () => {
|
||||
const { ensureCodexProfileResources } = await import(
|
||||
'../../../src/codex-auth/codex-profile-resources'
|
||||
);
|
||||
ensureCodexProfileResources(profileDir, { sharedCodexHome });
|
||||
|
||||
const pluginsDir = path.join(profileDir, 'plugins');
|
||||
const cacheDir = path.join(pluginsDir, 'cache');
|
||||
const siblingPath = path.join(pluginsDir, 'marketplaces.json');
|
||||
fs.rmSync(cacheDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(cacheDir, { recursive: true, mode: 0o700 });
|
||||
fs.writeFileSync(path.join(cacheDir, 'local-only.txt'), 'restore me\n');
|
||||
fs.writeFileSync(siblingPath, '{"preserve":true}\n');
|
||||
|
||||
const realSymlinkSync = fs.symlinkSync.bind(fs);
|
||||
const symlinkSpy = spyOn(fs, 'symlinkSync').mockImplementation(
|
||||
(...args: Parameters<typeof fs.symlinkSync>) => {
|
||||
const [, pathToCreate] = args;
|
||||
if (path.resolve(String(pathToCreate)) === path.resolve(cacheDir)) {
|
||||
throw Object.assign(new Error('simulated filesystem corruption'), { code: 'EIO' });
|
||||
}
|
||||
return realSymlinkSync(...args);
|
||||
}
|
||||
);
|
||||
|
||||
try {
|
||||
expect(() => ensureCodexProfileResources(profileDir, { sharedCodexHome })).toThrow(
|
||||
'simulated filesystem corruption'
|
||||
);
|
||||
} finally {
|
||||
symlinkSpy.mockRestore();
|
||||
}
|
||||
|
||||
expect(fs.lstatSync(cacheDir).isDirectory()).toBe(true);
|
||||
expect(fs.readFileSync(path.join(cacheDir, 'local-only.txt'), 'utf8')).toBe('restore me\n');
|
||||
expect(fs.existsSync(path.join(cacheDir, cachedSkillRelativePath))).toBe(false);
|
||||
expect(fs.readFileSync(siblingPath, 'utf8')).toBe('{"preserve":true}\n');
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,24 @@ beforeEach(() => {
|
||||
fs.writeFileSync(path.join(homeDir, '.codex', 'agents', 'brainstormer.toml'), 'name = "b"\n');
|
||||
fs.mkdirSync(path.join(homeDir, '.codex', 'skills'), { recursive: true });
|
||||
fs.writeFileSync(path.join(homeDir, '.codex', 'skills', 'review.md'), '# Review\n');
|
||||
fs.mkdirSync(
|
||||
path.join(homeDir, '.codex', 'plugins', 'cache', 'claudekit', '1.0.0', 'skills', 'test'),
|
||||
{ recursive: true }
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(
|
||||
homeDir,
|
||||
'.codex',
|
||||
'plugins',
|
||||
'cache',
|
||||
'claudekit',
|
||||
'1.0.0',
|
||||
'skills',
|
||||
'test',
|
||||
'SKILL.md'
|
||||
),
|
||||
'# Test skill\n'
|
||||
);
|
||||
fs.mkdirSync(path.join(ccsHome, '.ccs'), { recursive: true });
|
||||
process.env.CCS_HOME = ccsHome;
|
||||
spyOn(os, 'homedir').mockReturnValue(homeDir);
|
||||
@@ -96,6 +114,21 @@ describe('handleCreateCodex — happy path', () => {
|
||||
expect(fs.existsSync(instancesDir)).toBe(true);
|
||||
expect(fs.lstatSync(path.join(instancesDir, 'agents')).isSymbolicLink()).toBe(true);
|
||||
expect(fs.lstatSync(path.join(instancesDir, 'skills')).isSymbolicLink()).toBe(true);
|
||||
expect(
|
||||
fs.readFileSync(
|
||||
path.join(
|
||||
instancesDir,
|
||||
'plugins',
|
||||
'cache',
|
||||
'claudekit',
|
||||
'1.0.0',
|
||||
'skills',
|
||||
'test',
|
||||
'SKILL.md'
|
||||
),
|
||||
'utf8'
|
||||
)
|
||||
).toBe('# Test skill\n');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -805,9 +805,61 @@ describe('launch descriptor shim', () => {
|
||||
const mode = fs.statSync(descriptor.args[0]).mode & 0o777;
|
||||
expect((mode & 0o022) === 0).toBe(true);
|
||||
const resolvedEntrypoint = fs.realpathSync(realEntrypoint);
|
||||
expect(fs.readFileSync(descriptor.args[0], 'utf8')).toContain(
|
||||
`require(${JSON.stringify(resolvedEntrypoint)});`
|
||||
const shimContents = fs.readFileSync(descriptor.args[0], 'utf8');
|
||||
expect(shimContents).toContain(
|
||||
`const expectedEntrypoint = ${JSON.stringify(resolvedEntrypoint)};`
|
||||
);
|
||||
expect(shimContents).toContain('const expectedHash = ');
|
||||
expect(shimContents).toContain('const source = fs.readFileSync(resolvedEntrypoint);');
|
||||
expect(shimContents).toContain('if (actualHash !== expectedHash)');
|
||||
expect(shimContents).toContain('require.cache[resolvedEntrypoint] = targetModule;');
|
||||
expect(shimContents).toContain(
|
||||
"targetModule._compile(source.toString('utf8'), resolvedEntrypoint);"
|
||||
);
|
||||
expect(shimContents).not.toContain('require(resolvedEntrypoint);');
|
||||
});
|
||||
|
||||
it('rejects a modified original entrypoint before executing it', async () => {
|
||||
const packageDist = path.join(
|
||||
tempHome,
|
||||
'.bun',
|
||||
'install',
|
||||
'global',
|
||||
'node_modules',
|
||||
'@kaitranntt',
|
||||
'ccs',
|
||||
'dist'
|
||||
);
|
||||
const binDir = path.join(tempHome, '.bun', 'bin');
|
||||
const markerPath = path.join(tempHome, 'attacker-marker');
|
||||
const realEntrypoint = path.join(packageDist, 'ccs.js');
|
||||
const symlinkedEntrypoint = path.join(binDir, 'ccs');
|
||||
|
||||
fs.mkdirSync(packageDist, { recursive: true });
|
||||
fs.mkdirSync(binDir, { recursive: true });
|
||||
fs.writeFileSync(realEntrypoint, 'console.log("original");\n', { mode: 0o777 });
|
||||
fs.symlinkSync(realEntrypoint, symlinkedEntrypoint);
|
||||
|
||||
const { createBarLaunchDescriptor } = await loadLaunchDescriptor();
|
||||
const descriptor = createBarLaunchDescriptor({
|
||||
entrypointPath: symlinkedEntrypoint,
|
||||
runtime: process.execPath,
|
||||
home: tempHome,
|
||||
});
|
||||
|
||||
fs.writeFileSync(
|
||||
realEntrypoint,
|
||||
`require('fs').writeFileSync(${JSON.stringify(markerPath)}, 'executed');\n`
|
||||
);
|
||||
|
||||
const proc = Bun.spawnSync([descriptor.runtime, ...descriptor.args], {
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
});
|
||||
|
||||
expect(proc.exitCode).not.toBe(0);
|
||||
expect(Buffer.from(proc.stderr).toString()).toContain('CCS Bar launch shim target changed');
|
||||
expect(fs.existsSync(markerPath)).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);
|
||||
|
||||
@@ -133,6 +133,16 @@ describe('websearch-transformer legacy CLI safety', () => {
|
||||
expect(source).not.toContain('shell: isWindows');
|
||||
expect(source.match(/shell: false/g) || []).toHaveLength(4);
|
||||
});
|
||||
|
||||
it('does not disable Antigravity permission checks for query-derived prompts', () => {
|
||||
const source = readFileSync(hookPath, 'utf8');
|
||||
|
||||
expect(source).not.toContain("'--dangerously-skip-permissions'");
|
||||
expect(source).not.toContain('"--dangerously-skip-permissions"');
|
||||
expect(source).toContain(
|
||||
"const args = ['--model', model, '--print-timeout', `${timeoutSec}s`, '-p', prompt]"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('websearch-transformer hook helpers', () => {
|
||||
|
||||
@@ -277,6 +277,14 @@ describe('model-pricing', () => {
|
||||
expect(fable5.cacheReadPerMillion).toBe(1.0);
|
||||
});
|
||||
|
||||
it('should return correct pricing for Claude Sonnet 5', () => {
|
||||
const sonnet5 = getModelPricing('claude-sonnet-5');
|
||||
expect(sonnet5.inputPerMillion).toBe(3.0);
|
||||
expect(sonnet5.outputPerMillion).toBe(15.0);
|
||||
expect(sonnet5.cacheCreationPerMillion).toBe(3.75);
|
||||
expect(sonnet5.cacheReadPerMillion).toBe(0.3);
|
||||
});
|
||||
|
||||
it('should not map unknown future model families onto known family pricing', () => {
|
||||
const fallback = getModelPricing('unknown-model-xyz');
|
||||
|
||||
@@ -398,6 +406,17 @@ describe('model-pricing', () => {
|
||||
expect(cost).toBe(73.5); // 10 + 50 + 12.5 + 1.0
|
||||
});
|
||||
|
||||
it('should calculate Claude Sonnet 5 cost including cache token rates', () => {
|
||||
const usage: TokenUsage = {
|
||||
inputTokens: 1_000_000,
|
||||
outputTokens: 1_000_000,
|
||||
cacheCreationTokens: 1_000_000,
|
||||
cacheReadTokens: 1_000_000,
|
||||
};
|
||||
const cost = calculateCost(usage, 'claude-sonnet-5');
|
||||
expect(cost).toBe(22.05); // 3 + 15 + 3.75 + 0.3
|
||||
});
|
||||
|
||||
it('should calculate fast-tier Claude Opus 4.8 cost (2x premium)', () => {
|
||||
const usage: TokenUsage = {
|
||||
inputTokens: 1_000_000,
|
||||
@@ -426,6 +445,13 @@ describe('model-pricing', () => {
|
||||
const models = getKnownModels();
|
||||
expect(models.some((m) => m.startsWith('glm-'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should include Claude Sonnet 5 as an explicitly registered model', () => {
|
||||
// Sonnet 5 must be a first-class known model rather than relying on the
|
||||
// unknown-model fallback that happens to share Sonnet's $3/$15 rates.
|
||||
const models = getKnownModels();
|
||||
expect(models).toContain('claude-sonnet-5');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasCustomPricing', () => {
|
||||
@@ -434,6 +460,14 @@ describe('model-pricing', () => {
|
||||
expect(hasCustomPricing('glm-4.6')).toBe(true);
|
||||
});
|
||||
|
||||
it('should treat Claude Sonnet 5 as a known model with explicit pricing', () => {
|
||||
// Without an explicit registry entry Sonnet 5 falls through to
|
||||
// UNKNOWN_MODEL_PRICING (which coincidentally matches Sonnet rates);
|
||||
// register it so cost accounting is intentional, not accidental.
|
||||
expect(hasCustomPricing('claude-sonnet-5')).toBe(true);
|
||||
expect(hasCustomPricing('claude-sonnet-5-thinking')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for deterministic qwen3-coder alias', () => {
|
||||
expect(hasCustomPricing('qwen3-coder')).toBe(true);
|
||||
});
|
||||
|
||||
@@ -87,11 +87,13 @@ describe('run-test-bucket', () => {
|
||||
test('isolates known sticky mock suites outside src', () => {
|
||||
const runs = bucket.getBunRuns('fast', [
|
||||
'tests/unit/scripts/run-test-bucket.test.js',
|
||||
'tests/unit/commands/bar-command.test.ts',
|
||||
'tests/unit/targets/target-registry.test.ts',
|
||||
]);
|
||||
|
||||
expect(runs.map((run) => run.label)).toEqual([
|
||||
'shared',
|
||||
'tests/unit/commands/bar-command.test.ts',
|
||||
'tests/unit/targets/target-registry.test.ts',
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
|
||||
import {
|
||||
parseTomlObject,
|
||||
proposeJoinedCodexModelMigrationRepair,
|
||||
} from '../../../src/shared/toml-object';
|
||||
|
||||
describe('joined Codex model migration TOML recovery', () => {
|
||||
it('proposes the exact one-newline repair for a joined following table header', () => {
|
||||
const rawText = `model = "gpt-5.4"
|
||||
|
||||
[notice.model_migrations]
|
||||
"gpt-5.3-codex" = "gpt-5.4"[agents.code_simplifier]
|
||||
description = "Keep this exact text"
|
||||
`;
|
||||
|
||||
const proposal = proposeJoinedCodexModelMigrationRepair(rawText);
|
||||
const insertionOffset = rawText.indexOf('[agents.code_simplifier]');
|
||||
|
||||
expect(proposal).toEqual({
|
||||
repairedText: `${rawText.slice(0, insertionOffset)}\n${rawText.slice(insertionOffset)}`,
|
||||
insertionOffset,
|
||||
});
|
||||
expect(() => parseTomlObject(proposal?.repairedText ?? '')).not.toThrow();
|
||||
});
|
||||
|
||||
it('preserves a UTF-8 BOM, CRLF endings, comments, spacing, and quoting', () => {
|
||||
const rawText =
|
||||
"\uFEFF# exact comment\r\n[notice.model_migrations]\r\n 'gpt-5.3-codex' = 'gpt-5.4'[[agents]]\r\nname = \"one\"\r\n";
|
||||
|
||||
const proposal = proposeJoinedCodexModelMigrationRepair(rawText);
|
||||
const insertionOffset = rawText.indexOf('[[agents]]');
|
||||
|
||||
expect(proposal?.repairedText).toBe(
|
||||
`${rawText.slice(0, insertionOffset)}\r\n${rawText.slice(insertionOffset)}`
|
||||
);
|
||||
expect(proposal?.insertionOffset).toBe(insertionOffset);
|
||||
});
|
||||
|
||||
it('does not propose a repair for valid TOML', () => {
|
||||
const rawText = `[notice.model_migrations]
|
||||
"gpt-5.3-codex" = "gpt-5.4"
|
||||
|
||||
[agents.code_simplifier]
|
||||
description = "valid"
|
||||
`;
|
||||
|
||||
expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not generically repair a joined boundary outside the migration table', () => {
|
||||
const rawText = `model = "gpt-5.4"[agents.code_simplifier]
|
||||
description = "invalid"
|
||||
`;
|
||||
|
||||
expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull();
|
||||
});
|
||||
|
||||
it('ignores a migration table lookalike inside a multiline TOML string', () => {
|
||||
const rawText = `description = """
|
||||
[notice.model_migrations]
|
||||
"""
|
||||
"old" = "new"[agents.outside]
|
||||
`;
|
||||
|
||||
expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not propose a repair for unrelated invalid TOML', () => {
|
||||
expect(proposeJoinedCodexModelMigrationRepair('model = "gpt-5.4"\n[features\n')).toBeNull();
|
||||
});
|
||||
|
||||
it('requires a quoted string-to-string migration mapping', () => {
|
||||
const rawText = `[notice.model_migrations]
|
||||
gpt-5 = 54[agents.code_simplifier]
|
||||
description = "invalid"
|
||||
`;
|
||||
|
||||
expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not propose a repair when the following text is not a valid table header', () => {
|
||||
const rawText = `[notice.model_migrations]
|
||||
"gpt-5.3-codex" = "gpt-5.4"[agents.code_simplifier
|
||||
`;
|
||||
|
||||
expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not propose a repair when another syntax error would remain', () => {
|
||||
const rawText = `[notice.model_migrations]
|
||||
"gpt-5.3-codex" = "gpt-5.4"[agents.code_simplifier]
|
||||
description = "valid"
|
||||
[features
|
||||
`;
|
||||
|
||||
expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not propose an ambiguous repair when multiple joined boundaries match', () => {
|
||||
const rawText = `[notice.model_migrations]
|
||||
"gpt-5.2" = "gpt-5.3"[agents.first]
|
||||
name = "first"
|
||||
|
||||
[notice.model_migrations]
|
||||
"gpt-5.3" = "gpt-5.4"[agents.second]
|
||||
name = "second"
|
||||
`;
|
||||
|
||||
expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull();
|
||||
});
|
||||
|
||||
it('does not treat a joined-looking string inside a multiline value as a repair candidate', () => {
|
||||
const rawText = `[notice.model_migrations]
|
||||
note = """
|
||||
"gpt-5.3-codex" = "gpt-5.4"[agents.code_simplifier]
|
||||
"""
|
||||
[features
|
||||
`;
|
||||
|
||||
expect(proposeJoinedCodexModelMigrationRepair(rawText)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import type { QuotaResult } from '../../../src/cliproxy/quota/quota-fetcher';
|
||||
import type {
|
||||
ClaudeQuotaResult,
|
||||
CodexQuotaResult,
|
||||
} from '../../../src/cliproxy/quota/quota-types';
|
||||
import {
|
||||
createBarPoolAccountQuotaFetcher,
|
||||
type BarPoolAccountQuotaFetcherDeps,
|
||||
} from '../../../src/web-server/routes/bar-pool-account-quota-adapter';
|
||||
|
||||
function agyResult(overrides: Partial<QuotaResult> = {}): QuotaResult {
|
||||
return {
|
||||
success: true,
|
||||
models: [{ name: 'gemini-pro', percentage: 81, resetTime: null }],
|
||||
lastUpdated: 1,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function claudeResult(overrides: Partial<ClaudeQuotaResult> = {}): ClaudeQuotaResult {
|
||||
return {
|
||||
success: true,
|
||||
windows: [],
|
||||
coreUsage: {
|
||||
fiveHour: {
|
||||
rateLimitType: 'five_hour',
|
||||
label: '5h usage limit',
|
||||
remainingPercent: 72,
|
||||
resetAt: '2026-07-18T16:00:00.000Z',
|
||||
status: 'allowed',
|
||||
},
|
||||
weekly: {
|
||||
rateLimitType: 'seven_day',
|
||||
label: 'Weekly usage limit',
|
||||
remainingPercent: 45,
|
||||
resetAt: '2026-07-21T12:00:00.000Z',
|
||||
status: 'allowed_warning',
|
||||
},
|
||||
},
|
||||
lastUpdated: 2,
|
||||
accountId: 'claude@example.com',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function codexResult(overrides: Partial<CodexQuotaResult> = {}): CodexQuotaResult {
|
||||
return {
|
||||
success: true,
|
||||
windows: [],
|
||||
coreUsage: {
|
||||
fiveHour: {
|
||||
label: 'Primary',
|
||||
remainingPercent: 36,
|
||||
resetAfterSeconds: 3600,
|
||||
resetAt: '2026-07-18T14:00:00.000Z',
|
||||
},
|
||||
weekly: {
|
||||
label: 'Secondary',
|
||||
remainingPercent: 24,
|
||||
resetAfterSeconds: 259200,
|
||||
resetAt: '2026-07-21T13:00:00.000Z',
|
||||
},
|
||||
},
|
||||
planType: 'pro',
|
||||
lastUpdated: 3,
|
||||
accountId: 'codex@example.com',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createDeps(
|
||||
overrides: Partial<BarPoolAccountQuotaFetcherDeps> = {}
|
||||
): BarPoolAccountQuotaFetcherDeps {
|
||||
return {
|
||||
fetchLegacyAccountQuota: async () => agyResult(),
|
||||
fetchClaudeQuota: async () => claudeResult(),
|
||||
fetchCodexQuota: async () => codexResult(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('Bar pool account quota adapter', () => {
|
||||
it('maps Claude percentage from the most restrictive window and reset from the earliest', async () => {
|
||||
const fetchQuota = createBarPoolAccountQuotaFetcher(createDeps());
|
||||
|
||||
const result = await fetchQuota('claude', 'claude@example.com');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.models).toEqual([
|
||||
{
|
||||
name: 'seven_day',
|
||||
displayName: 'Weekly usage limit',
|
||||
percentage: 45,
|
||||
resetTime: '2026-07-18T16:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
expect(result.accountId).toBe('claude@example.com');
|
||||
});
|
||||
|
||||
it('maps Codex percentage from the most restrictive window and reset from the earliest', async () => {
|
||||
const fetchQuota = createBarPoolAccountQuotaFetcher(createDeps());
|
||||
|
||||
const result = await fetchQuota('codex', 'codex@example.com');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.models).toEqual([
|
||||
{
|
||||
name: 'Secondary',
|
||||
displayName: 'Secondary',
|
||||
percentage: 24,
|
||||
resetTime: '2026-07-18T14:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
expect(result.accountId).toBe('codex@example.com');
|
||||
});
|
||||
|
||||
it('preserves Claude transient failure metadata', async () => {
|
||||
const fetchQuota = createBarPoolAccountQuotaFetcher(
|
||||
createDeps({
|
||||
fetchClaudeQuota: async () =>
|
||||
claudeResult({
|
||||
success: false,
|
||||
windows: [],
|
||||
coreUsage: { fiveHour: null, weekly: null },
|
||||
error: 'Claude OAuth usage request timeout',
|
||||
errorCode: 'network_timeout',
|
||||
retryable: true,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const result = await fetchQuota('claude', 'claude@example.com');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
models: [],
|
||||
error: 'Claude OAuth usage request timeout',
|
||||
errorCode: 'network_timeout',
|
||||
retryable: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves Codex reauthentication state', async () => {
|
||||
const fetchQuota = createBarPoolAccountQuotaFetcher(
|
||||
createDeps({
|
||||
fetchCodexQuota: async () =>
|
||||
codexResult({
|
||||
success: false,
|
||||
windows: [],
|
||||
coreUsage: { fiveHour: null, weekly: null },
|
||||
error: 'Token expired or invalid',
|
||||
errorCode: 'reauth_required',
|
||||
httpStatus: 401,
|
||||
needsReauth: true,
|
||||
retryable: false,
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const result = await fetchQuota('codex', 'codex@example.com');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
models: [],
|
||||
errorCode: 'reauth_required',
|
||||
httpStatus: 401,
|
||||
needsReauth: true,
|
||||
retryable: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('delegates Antigravity unchanged to the legacy fetcher', async () => {
|
||||
const expected = agyResult();
|
||||
const calls: Array<[string, string]> = [];
|
||||
const fetchQuota = createBarPoolAccountQuotaFetcher(
|
||||
createDeps({
|
||||
fetchLegacyAccountQuota: async (provider, accountId) => {
|
||||
calls.push([provider, accountId]);
|
||||
return expected;
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const result = await fetchQuota('agy', 'agy@example.com');
|
||||
|
||||
expect(result).toBe(expected);
|
||||
expect(calls).toEqual([['agy', 'agy@example.com']]);
|
||||
});
|
||||
|
||||
it('preserves legacy unsupported-provider semantics', async () => {
|
||||
const fetchQuota = createBarPoolAccountQuotaFetcher(
|
||||
createDeps({
|
||||
fetchLegacyAccountQuota: async (provider) =>
|
||||
agyResult({
|
||||
success: false,
|
||||
models: [],
|
||||
error: `Quota not supported for provider: ${provider}`,
|
||||
errorCode: 'quota_not_supported',
|
||||
}),
|
||||
})
|
||||
);
|
||||
|
||||
const result = await fetchQuota('kiro', 'kiro-account');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
success: false,
|
||||
models: [],
|
||||
errorCode: 'quota_not_supported',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
import { afterEach, describe, expect, it } from 'bun:test';
|
||||
import type { QuotaResult } from '../../../src/cliproxy/quota/quota-fetcher';
|
||||
import type {
|
||||
ClaudeQuotaResult,
|
||||
CodexQuotaResult,
|
||||
} from '../../../src/cliproxy/quota/quota-types';
|
||||
import {
|
||||
clearQuotaCache,
|
||||
getCachedQuota,
|
||||
} from '../../../src/cliproxy/quota/quota-response-cache';
|
||||
import {
|
||||
createBarPoolAccountQuotaFetcher,
|
||||
getCachedBarPoolAccountQuota,
|
||||
setCachedBarPoolAccountQuota,
|
||||
} from '../../../src/web-server/routes/bar-pool-account-quota-adapter';
|
||||
|
||||
function createFetcher(
|
||||
claudeResult: ClaudeQuotaResult,
|
||||
codexResult: CodexQuotaResult
|
||||
): (provider: 'claude' | 'codex', accountId: string) => Promise<QuotaResult> {
|
||||
return createBarPoolAccountQuotaFetcher({
|
||||
fetchLegacyAccountQuota: async () => {
|
||||
throw new Error('Legacy fetcher should not be called');
|
||||
},
|
||||
fetchClaudeQuota: async () => claudeResult,
|
||||
fetchCodexQuota: async () => codexResult,
|
||||
});
|
||||
}
|
||||
|
||||
function claudeFailure(overrides: Partial<ClaudeQuotaResult> = {}): ClaudeQuotaResult {
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
coreUsage: { fiveHour: null, weekly: null },
|
||||
lastUpdated: 1,
|
||||
accountId: 'claude@example.com',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function codexFailure(overrides: Partial<CodexQuotaResult> = {}): CodexQuotaResult {
|
||||
return {
|
||||
success: false,
|
||||
windows: [],
|
||||
coreUsage: { fiveHour: null, weekly: null },
|
||||
planType: 'pro',
|
||||
lastUpdated: 2,
|
||||
accountId: 'codex@example.com',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
clearQuotaCache();
|
||||
});
|
||||
|
||||
describe('Bar pool account quota cache policy', () => {
|
||||
it('does not cache a retryable Claude timeout', async () => {
|
||||
const raw = claudeFailure({
|
||||
error: 'Claude OAuth usage request timeout',
|
||||
errorCode: 'network_timeout',
|
||||
retryable: true,
|
||||
});
|
||||
const fetchQuota = createFetcher(raw, codexFailure());
|
||||
const result = await fetchQuota('claude', 'claude@example.com');
|
||||
|
||||
setCachedBarPoolAccountQuota('claude', 'claude@example.com', result);
|
||||
|
||||
expect(getCachedQuota('claude', 'claude@example.com')).toBeNull();
|
||||
expect(getCachedBarPoolAccountQuota('claude', 'claude@example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('does not cache a Codex 429 failure', async () => {
|
||||
const raw = codexFailure({
|
||||
error: 'Codex usage API rate limited',
|
||||
errorCode: 'rate_limited',
|
||||
httpStatus: 429,
|
||||
});
|
||||
const fetchQuota = createFetcher(claudeFailure(), raw);
|
||||
const result = await fetchQuota('codex', 'codex@example.com');
|
||||
|
||||
setCachedBarPoolAccountQuota('codex', 'codex@example.com', result);
|
||||
|
||||
expect(getCachedQuota('codex', 'codex@example.com')).toBeNull();
|
||||
expect(getCachedBarPoolAccountQuota('codex', 'codex@example.com')).toBeNull();
|
||||
});
|
||||
|
||||
it('caches a stable Codex reauthentication failure in raw provider shape', async () => {
|
||||
const raw = codexFailure({
|
||||
error: 'Token expired or invalid',
|
||||
errorCode: 'reauth_required',
|
||||
httpStatus: 401,
|
||||
needsReauth: true,
|
||||
retryable: false,
|
||||
});
|
||||
const fetchQuota = createFetcher(claudeFailure(), raw);
|
||||
const result = await fetchQuota('codex', 'codex@example.com');
|
||||
|
||||
setCachedBarPoolAccountQuota('codex', 'codex@example.com', result);
|
||||
|
||||
const shared = getCachedQuota<CodexQuotaResult>('codex', 'codex@example.com');
|
||||
expect(shared).toEqual(raw);
|
||||
expect((shared as CodexQuotaResult & Partial<QuotaResult>)?.models).toBeUndefined();
|
||||
expect(getCachedBarPoolAccountQuota<QuotaResult>('codex', 'codex@example.com')).toMatchObject({
|
||||
success: false,
|
||||
models: [],
|
||||
needsReauth: true,
|
||||
httpStatus: 401,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
import { expect, it } from 'bun:test';
|
||||
import express from 'express';
|
||||
import type { Server } from 'node:http';
|
||||
import type { QuotaResult } from '../../../src/cliproxy/quota/quota-fetcher';
|
||||
import type { ClaudeQuotaResult } from '../../../src/cliproxy/quota/quota-types';
|
||||
import {
|
||||
clearQuotaCache,
|
||||
getCachedQuota,
|
||||
} from '../../../src/cliproxy/quota/quota-response-cache';
|
||||
import {
|
||||
createBarPoolAccountQuotaFetcher,
|
||||
getCachedBarPoolAccountQuota,
|
||||
invalidateCachedBarPoolAccountQuota,
|
||||
setCachedBarPoolAccountQuota,
|
||||
} from '../../../src/web-server/routes/bar-pool-account-quota-adapter';
|
||||
import {
|
||||
createBarRouter,
|
||||
resetForceFreshDebounce,
|
||||
} from '../../../src/web-server/routes/bar-routes';
|
||||
|
||||
it('stores normalized Claude quota in the existing Bar cache', async () => {
|
||||
clearQuotaCache();
|
||||
let fetchCount = 0;
|
||||
const fetchQuota = createBarPoolAccountQuotaFetcher({
|
||||
fetchLegacyAccountQuota: async () => {
|
||||
throw new Error('Legacy fetcher should not be called');
|
||||
},
|
||||
fetchClaudeQuota: async () => {
|
||||
fetchCount += 1;
|
||||
return {
|
||||
success: true,
|
||||
windows: [],
|
||||
coreUsage: {
|
||||
fiveHour: {
|
||||
rateLimitType: 'five_hour',
|
||||
label: '5h usage limit',
|
||||
remainingPercent: 72,
|
||||
resetAt: '2026-07-18T16:00:00.000Z',
|
||||
status: 'allowed',
|
||||
},
|
||||
weekly: {
|
||||
rateLimitType: 'seven_day',
|
||||
label: 'Weekly usage limit',
|
||||
remainingPercent: 45,
|
||||
resetAt: '2026-07-21T12:00:00.000Z',
|
||||
status: 'allowed_warning',
|
||||
},
|
||||
},
|
||||
lastUpdated: 2,
|
||||
accountId: 'claude@example.com',
|
||||
};
|
||||
},
|
||||
fetchCodexQuota: async () => {
|
||||
throw new Error('Codex fetcher should not be called');
|
||||
},
|
||||
});
|
||||
const app = express();
|
||||
app.use(
|
||||
'/api/bar',
|
||||
createBarRouter({
|
||||
getAllAccountsSummary: () =>
|
||||
({
|
||||
claude: [
|
||||
{
|
||||
id: 'claude@example.com',
|
||||
provider: 'claude',
|
||||
nickname: 'Claude pool',
|
||||
paused: false,
|
||||
isDefault: true,
|
||||
tokenFile: 'claude-claude_example_com.json',
|
||||
createdAt: '2026-07-18T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
}) as never,
|
||||
getCachedQuota: getCachedBarPoolAccountQuota,
|
||||
setCachedQuota: setCachedBarPoolAccountQuota,
|
||||
invalidateQuotaCache: invalidateCachedBarPoolAccountQuota,
|
||||
fetchAccountQuota: fetchQuota,
|
||||
getTodayCostByAccount: () => ({}),
|
||||
loadCliproxyDetails: async () => [],
|
||||
loadDailyUsage: async () => [],
|
||||
loadHourlyUsage: async () => [],
|
||||
})
|
||||
);
|
||||
|
||||
resetForceFreshDebounce();
|
||||
const server = await new Promise<Server>((resolve, reject) => {
|
||||
const instance = app.listen(0, '127.0.0.1');
|
||||
instance.once('error', reject);
|
||||
instance.once('listening', () => resolve(instance));
|
||||
});
|
||||
|
||||
try {
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('No server address');
|
||||
const url = `http://127.0.0.1:${address.port}/api/bar/summary`;
|
||||
const first = (await (await fetch(url)).json()) as Array<Record<string, unknown>>;
|
||||
const second = (await (await fetch(url)).json()) as Array<Record<string, unknown>>;
|
||||
|
||||
expect(fetchCount).toBe(1);
|
||||
expect(first[0]).toMatchObject({
|
||||
quota_percentage: 45,
|
||||
next_reset: '2026-07-18T16:00:00.000Z',
|
||||
quotaStatus: 'ok',
|
||||
cached: false,
|
||||
});
|
||||
expect(second[0]).toMatchObject({
|
||||
quota_percentage: 45,
|
||||
next_reset: '2026-07-18T16:00:00.000Z',
|
||||
quotaStatus: 'ok',
|
||||
cached: true,
|
||||
});
|
||||
const sharedCache = getCachedQuota<ClaudeQuotaResult>('claude', 'claude@example.com');
|
||||
expect(sharedCache?.windows).toEqual([]);
|
||||
expect((sharedCache as ClaudeQuotaResult & Partial<QuotaResult>)?.models).toBeUndefined();
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
clearQuotaCache();
|
||||
}
|
||||
});
|
||||
@@ -989,7 +989,7 @@ describe('getCachedNativeAccountRows (instant, no-fetch fallback)', () => {
|
||||
*
|
||||
* - claudeProfiles: profile names for the Claude surface (ccs)
|
||||
* - codexProfiles: profile names for the Codex surface (ccsx)
|
||||
* - claudeDefault / codexDefault: the active profile per surface (paused:false)
|
||||
* - claudeDefault / codexDefault: the only live-polled profile per surface (paused:false)
|
||||
* - credsForProfile: map from profile name to credentials (null = parked)
|
||||
* - claudeFetch: network fetcher for Claude (all profiles share one implementation)
|
||||
* - codexNativeAuth: map from profile name to {accessToken, accountId}
|
||||
@@ -1113,13 +1113,13 @@ describe('multi-profile: account_id and wire fields', () => {
|
||||
expect(ck?.is_subscription).toBe(true);
|
||||
});
|
||||
|
||||
it('paused reflects liveness (creds present), NOT default-ness; is_default marks the default independently', async () => {
|
||||
it('only default profiles are live-polled; is_default marks the default independently', async () => {
|
||||
const clock = { now: 1_000_000 };
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
// Claude: work = default + creds (live); ck = non-default + NO creds (parked).
|
||||
claudeProfiles: ['work', 'ck'],
|
||||
// Codex: personal = default + creds (live); ck = NON-default + creds (live).
|
||||
// Codex: personal = default + creds (live); ck = NON-default + creds (cache-only).
|
||||
codexProfiles: ['personal', 'ck'],
|
||||
claudeDefault: 'work',
|
||||
codexDefault: 'personal',
|
||||
@@ -1146,11 +1146,11 @@ describe('multi-profile: account_id and wire fields', () => {
|
||||
expect(codexPersonal?.paused).toBe(false);
|
||||
expect(codexPersonal?.is_default).toBe(true);
|
||||
|
||||
// Codex ck: NON-default but HAS creds -> LIVE, NOT dimmed. This is the key
|
||||
// correctness guarantee: a valid isolated subscription is never dimmed just
|
||||
// because it is not the surface default.
|
||||
// Codex ck: NON-default with creds is cache-only/parked. Only the default
|
||||
// profile is live-polled so a dashboard request cannot fan out to every
|
||||
// configured subscription account.
|
||||
const codexCk = rows.find((r) => r.surface === 'ccsx' && r.profile === 'ck');
|
||||
expect(codexCk?.paused).toBe(false);
|
||||
expect(codexCk?.paused).toBe(true);
|
||||
expect(codexCk?.is_default).toBe(false);
|
||||
});
|
||||
|
||||
@@ -1170,6 +1170,8 @@ describe('multi-profile: account_id and wire fields', () => {
|
||||
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
expect(rows.length).toBe(claudeProfiles.length + codexProfiles.length);
|
||||
expect(deps.claudeFetchCount()).toBe(1);
|
||||
expect(deps.codexNetworkCount()).toBe(1);
|
||||
});
|
||||
|
||||
it('rows are sorted by (surface, profile)', async () => {
|
||||
@@ -1249,6 +1251,96 @@ describe('multi-profile: Claude file-only reader', () => {
|
||||
expect(row?.quotaStatus).toBe('unsupported');
|
||||
expect(row?.is_subscription).toBe(true);
|
||||
});
|
||||
|
||||
it('existing default profile directory without creds does not read global credentials', async () => {
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
const tempCcsHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-default-profile-'));
|
||||
const clock = { now: 1_000_000 };
|
||||
let defaultReadCount = 0;
|
||||
let fetchCount = 0;
|
||||
|
||||
try {
|
||||
process.env.CCS_HOME = tempCcsHome;
|
||||
fs.mkdirSync(path.join(tempCcsHome, '.ccs', 'instances', 'default'), { recursive: true });
|
||||
|
||||
const rows = await getNativeAccountRows({
|
||||
readCredentials: () => {
|
||||
defaultReadCount += 1;
|
||||
return { claudeAiOauth: { accessToken: 'global-token', subscriptionType: 'max' } };
|
||||
},
|
||||
listClaudeProfiles: () => ['default'],
|
||||
listCodexProfiles: () => [],
|
||||
defaultClaudeProfile: () => 'default',
|
||||
defaultCodexProfile: () => null,
|
||||
fetchClaudeQuota: async () => {
|
||||
fetchCount += 1;
|
||||
return successQuota();
|
||||
},
|
||||
getCodexQuota: async () => null,
|
||||
now: () => clock.now,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
const row = rows.find((r) => r.surface === 'ccs' && r.profile === 'default');
|
||||
expect(defaultReadCount).toBe(0);
|
||||
expect(fetchCount).toBe(0);
|
||||
expect(row?.needsReauth).toBe(true);
|
||||
expect(row?.quotaStatus).toBe('unsupported');
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempCcsHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('existing default profile credentials win over global credentials', async () => {
|
||||
const originalCcsHome = process.env.CCS_HOME;
|
||||
const tempCcsHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-bar-default-profile-'));
|
||||
const clock = { now: 1_000_000 };
|
||||
let fetchedToken: string | null = null;
|
||||
|
||||
try {
|
||||
process.env.CCS_HOME = tempCcsHome;
|
||||
const defaultInstanceDir = path.join(tempCcsHome, '.ccs', 'instances', 'default');
|
||||
fs.mkdirSync(defaultInstanceDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(defaultInstanceDir, '.credentials.json'),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: 'profile-token', subscriptionType: 'max' } })
|
||||
);
|
||||
|
||||
const rows = await getNativeAccountRows({
|
||||
readCredentials: () => ({
|
||||
claudeAiOauth: { accessToken: 'global-token', subscriptionType: 'max' },
|
||||
}),
|
||||
listClaudeProfiles: () => ['default'],
|
||||
listCodexProfiles: () => [],
|
||||
defaultClaudeProfile: () => 'default',
|
||||
defaultCodexProfile: () => null,
|
||||
fetchClaudeQuota: async (token) => {
|
||||
fetchedToken = token;
|
||||
return successQuota();
|
||||
},
|
||||
getCodexQuota: async () => null,
|
||||
now: () => clock.now,
|
||||
sleep: async () => {},
|
||||
});
|
||||
|
||||
const row = rows.find((r) => r.surface === 'ccs' && r.profile === 'default');
|
||||
expect(fetchedToken).toBe('profile-token');
|
||||
expect(row?.needsReauth).toBe(false);
|
||||
expect(row?.quotaStatus).toBe('ok');
|
||||
} finally {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
fs.rmSync(tempCcsHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('multi-profile: per-profile circuit breaker isolation', () => {
|
||||
@@ -1264,7 +1356,7 @@ describe('multi-profile: per-profile circuit breaker isolation', () => {
|
||||
claudeDefault: 'work',
|
||||
credsForProfile: () => maxCreds(),
|
||||
claudeFetch: async (_token, accountId) => {
|
||||
// 'work' (ccs:work) always 429s; 'ck' always succeeds
|
||||
// 'work' (ccs:work) always 429s; 'ck' remains cache-only
|
||||
if (accountId?.includes('work') && workFails) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -1294,8 +1386,8 @@ describe('multi-profile: per-profile circuit breaker isolation', () => {
|
||||
});
|
||||
}
|
||||
|
||||
// After the three 429s on 'work', check that 'ck' still succeeds.
|
||||
// We reset state to have a clean run where 'ck' has no prior breaker history.
|
||||
// After the three 429s on 'work', check that 'ck' remains cache-only.
|
||||
// We reset state to have a clean run with no prior breaker history.
|
||||
resetNativeQuotaState();
|
||||
clock.now += MAX_COOLDOWN_JUMP_MP;
|
||||
workFails = false;
|
||||
@@ -1304,8 +1396,8 @@ describe('multi-profile: per-profile circuit breaker isolation', () => {
|
||||
const ckRow = rows.find((r) => r.profile === 'ck');
|
||||
const workRow = rows.find((r) => r.profile === 'work');
|
||||
|
||||
// 'ck' should succeed — its breaker was never tripped.
|
||||
expect(ckRow?.quotaStatus).toBe('ok');
|
||||
// 'ck' stays cache-only, independent of work's breaker.
|
||||
expect(ckRow?.quotaStatus).toBe('unsupported');
|
||||
// 'work' is also fine after reset (no breaker state).
|
||||
expect(workRow?.quotaStatus).toBe('ok');
|
||||
});
|
||||
@@ -1339,10 +1431,10 @@ describe('multi-profile: per-profile circuit breaker isolation', () => {
|
||||
},
|
||||
});
|
||||
|
||||
// First call: 'work' gets a 429, 'ck' succeeds.
|
||||
// First call: 'work' gets a 429, 'ck' remains cache-only.
|
||||
const rows1 = await getNativeAccountRows(deps);
|
||||
const ck1 = rows1.find((r) => r.profile === 'ck');
|
||||
expect(ck1?.quotaStatus).toBe('ok');
|
||||
expect(ck1?.quotaStatus).toBe('unsupported');
|
||||
expect(workCall429Count).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// Skip past cooldown for 'work' only; 'ck' is within TTL.
|
||||
@@ -1351,8 +1443,8 @@ describe('multi-profile: per-profile circuit breaker isolation', () => {
|
||||
// Second call past 'work' cooldown: work tries again (429 again); ck cached.
|
||||
const rows2 = await getNativeAccountRows(deps);
|
||||
const ck2 = rows2.find((r) => r.profile === 'ck');
|
||||
// 'ck' still has a good cached row.
|
||||
expect(ck2?.quotaStatus).toBe('ok');
|
||||
// 'ck' remains cache-only and is not affected by work's breaker.
|
||||
expect(ck2?.quotaStatus).toBe('unsupported');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1449,11 +1541,11 @@ describe('review focus areas: reauth caching + codex local fallback', () => {
|
||||
const r1 = first.find((r) => r.profile === 'ck');
|
||||
expect(r1?.needsReauth).toBe(true);
|
||||
expect(r1?.paused).toBe(true);
|
||||
expect(deps.codexNetworkCount()).toBe(1);
|
||||
expect(deps.codexNetworkCount()).toBe(0);
|
||||
|
||||
const second = await getNativeAccountRows(deps, { force: true });
|
||||
expect(second.find((r) => r.profile === 'ck')?.cached).toBe(true);
|
||||
expect(deps.codexNetworkCount()).toBe(1);
|
||||
expect(deps.codexNetworkCount()).toBe(0);
|
||||
});
|
||||
|
||||
it('Codex named profile without on-disk auth is parked, never filled from global local data', async () => {
|
||||
@@ -1563,7 +1655,7 @@ describe('review focus areas: reauth caching + codex local fallback', () => {
|
||||
expect(deps.claudeFetchCount()).toBe(1); // re-checked -> fetched
|
||||
});
|
||||
|
||||
it('Codex named profile with valid auth but sparse payload stays active, not parked', async () => {
|
||||
it('Codex named profile with valid auth but sparse payload stays parked when not default', async () => {
|
||||
resetNativeQuotaState();
|
||||
const clock = { now: 7_000_000 };
|
||||
const deps = makeMultiProfileDeps({
|
||||
@@ -1579,9 +1671,42 @@ describe('review focus areas: reauth caching + codex local fallback', () => {
|
||||
const rows = await getNativeAccountRows(deps);
|
||||
const ck = rows.find((r) => r.profile === 'ck');
|
||||
expect(ck).toBeDefined();
|
||||
expect(ck?.paused).toBe(false); // active subscription, not parked
|
||||
expect(ck?.needsReauth).toBe(false);
|
||||
expect(ck?.quotaStatus).toBe('ok');
|
||||
expect(ck?.quota_percentage).toBeNull(); // no windows, but still active
|
||||
expect(ck?.paused).toBe(true); // cache-only, parked until selected as default
|
||||
expect(ck?.needsReauth).toBe(true);
|
||||
expect(ck?.quotaStatus).toBe('unsupported');
|
||||
expect(ck?.quota_percentage).toBeNull(); // no windows while parked
|
||||
});
|
||||
|
||||
it('non-default cache-only rows do not overwrite the canonical live cache', async () => {
|
||||
resetNativeQuotaState();
|
||||
const clock = { now: 8_000_000 };
|
||||
let codexDefault = 'ck';
|
||||
const deps = makeMultiProfileDeps({
|
||||
clock,
|
||||
claudeProfiles: [],
|
||||
codexProfiles: ['default', 'ck'],
|
||||
codexDefault,
|
||||
codexNativeAuth: (p) => ({ accessToken: `t-${p}`, accountId: `id-${p}` }),
|
||||
codexNetworkFetch: async () => codexSuccessQuota(),
|
||||
});
|
||||
deps.defaultCodexProfile = () => codexDefault;
|
||||
|
||||
const first = await getNativeAccountRows(deps);
|
||||
expect(first.find((r) => r.profile === 'ck')?.paused).toBe(false);
|
||||
expect(deps.codexNetworkCount()).toBe(1);
|
||||
|
||||
codexDefault = 'default';
|
||||
const second = await getNativeAccountRows(deps);
|
||||
const ckAsNonDefault = second.find((r) => r.profile === 'ck');
|
||||
expect(ckAsNonDefault?.cached).toBe(true);
|
||||
expect(ckAsNonDefault?.paused).toBe(true);
|
||||
expect(deps.codexNetworkCount()).toBe(2);
|
||||
|
||||
codexDefault = 'ck';
|
||||
const third = await getNativeAccountRows(deps);
|
||||
const ckDefaultAgain = third.find((r) => r.profile === 'ck');
|
||||
expect(ckDefaultAgain?.cached).toBe(true);
|
||||
expect(ckDefaultAgain?.paused).toBe(false);
|
||||
expect(deps.codexNetworkCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -99,6 +99,82 @@ describe('validateFilePath', () => {
|
||||
expect(result.readonly).toBe(false);
|
||||
});
|
||||
|
||||
test('rejects macOS case-variant paths through symlinked segments', () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
|
||||
try {
|
||||
const ccsDir = path.join(tempDir, '.ccs');
|
||||
const caseVariantCcsDir = path.join(tempDir, '.CCS');
|
||||
const sharedDir = path.join(ccsDir, 'shared');
|
||||
const outsideCommandsDir = path.join(tempDir, '.claude', 'commands');
|
||||
const linkedCommandsDir = path.join(sharedDir, 'commands');
|
||||
|
||||
fs.mkdirSync(sharedDir, { recursive: true });
|
||||
fs.mkdirSync(outsideCommandsDir, { recursive: true });
|
||||
try {
|
||||
fs.symlinkSync(ccsDir, caseVariantCcsDir, 'dir');
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code !== 'EEXIST') {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
fs.symlinkSync(outsideCommandsDir, linkedCommandsDir, 'dir');
|
||||
|
||||
const result = validateFilePath(
|
||||
path.join(caseVariantCcsDir, 'shared', 'commands', 'pwned.md')
|
||||
);
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.readonly).toBe(false);
|
||||
} finally {
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', platformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects macOS case-variant CCS directory symlinks', () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
}
|
||||
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform');
|
||||
Object.defineProperty(process, 'platform', { value: 'darwin' });
|
||||
|
||||
try {
|
||||
const ccsDir = path.join(tempDir, '.ccs');
|
||||
const caseVariantCcsDir = path.join(tempDir, '.CCS');
|
||||
const outsideDir = path.join(tempDir, 'outside');
|
||||
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.mkdirSync(outsideDir, { recursive: true });
|
||||
try {
|
||||
fs.symlinkSync(outsideDir, caseVariantCcsDir, 'dir');
|
||||
} catch (error) {
|
||||
const nodeError = error as NodeJS.ErrnoException;
|
||||
if (nodeError.code === 'EEXIST') {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
||||
const result = validateFilePath(path.join(caseVariantCcsDir, 'pwned.md'));
|
||||
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.readonly).toBe(false);
|
||||
} finally {
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', platformDescriptor);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects symlinked Claude settings path', () => {
|
||||
if (process.platform === 'win32') {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { describe, expect, it } from 'bun:test';
|
||||
import {
|
||||
mergeDailyData,
|
||||
mergeHourlyData,
|
||||
mergeMonthlyData,
|
||||
} from '../../../src/web-server/usage/aggregator';
|
||||
import type {
|
||||
DailyUsage,
|
||||
HourlyUsage,
|
||||
ModelBreakdown,
|
||||
MonthlyUsage,
|
||||
} from '../../../src/web-server/usage/types';
|
||||
|
||||
const breakdown: ModelBreakdown = {
|
||||
modelName: 'claude-sonnet-4-5',
|
||||
inputTokens: 100,
|
||||
outputTokens: 40,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cost: 0.1,
|
||||
};
|
||||
|
||||
function validHour(): HourlyUsage {
|
||||
return {
|
||||
hour: '2026-03-02 10:00',
|
||||
source: 'test',
|
||||
inputTokens: 100,
|
||||
outputTokens: 40,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cost: 0.1,
|
||||
totalCost: 0.1,
|
||||
modelsUsed: ['claude-sonnet-4-5'],
|
||||
modelBreakdowns: [{ ...breakdown }],
|
||||
requestCount: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function validDay(): DailyUsage {
|
||||
return {
|
||||
date: '2026-03-02',
|
||||
source: 'test',
|
||||
inputTokens: 100,
|
||||
outputTokens: 40,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
cost: 0.1,
|
||||
totalCost: 0.1,
|
||||
modelsUsed: ['claude-sonnet-4-5'],
|
||||
modelBreakdowns: [{ ...breakdown }],
|
||||
};
|
||||
}
|
||||
|
||||
function validMonth(): MonthlyUsage {
|
||||
return {
|
||||
month: '2026-03',
|
||||
source: 'test',
|
||||
inputTokens: 100,
|
||||
outputTokens: 40,
|
||||
cacheCreationTokens: 0,
|
||||
cacheReadTokens: 0,
|
||||
totalCost: 0.1,
|
||||
modelsUsed: ['claude-sonnet-4-5'],
|
||||
modelBreakdowns: [{ ...breakdown }],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Simulates a partially-written or legacy persisted usage record that is
|
||||
* missing `modelBreakdowns` (and `requestCount`) despite the type declaring
|
||||
* them, e.g. a Codex rollout file scanned mid-write.
|
||||
*/
|
||||
function stripBreakdowns<T extends { modelBreakdowns: ModelBreakdown[] }>(record: T): T {
|
||||
const partial = { ...record } as Partial<T>;
|
||||
delete partial.modelBreakdowns;
|
||||
if ('requestCount' in partial) {
|
||||
delete (partial as { requestCount?: number }).requestCount;
|
||||
}
|
||||
return partial as T;
|
||||
}
|
||||
|
||||
describe('usage aggregator merge with missing modelBreakdowns', () => {
|
||||
it('does not throw when a same-hour record is missing modelBreakdowns (merge-into-existing path)', () => {
|
||||
const merged = mergeHourlyData([[validHour()], [stripBreakdowns(validHour())]]);
|
||||
expect(merged).toHaveLength(1);
|
||||
// requestCount stays numeric instead of throwing on `.length` of undefined.
|
||||
expect(typeof merged[0]?.requestCount).toBe('number');
|
||||
// Tokens from both records still aggregate.
|
||||
expect(merged[0]?.inputTokens).toBe(200);
|
||||
});
|
||||
|
||||
it('does not throw when the only record is missing modelBreakdowns (new-bucket path)', () => {
|
||||
const merged = mergeHourlyData([[stripBreakdowns(validHour())]]);
|
||||
expect(merged).toHaveLength(1);
|
||||
expect(merged[0]?.modelBreakdowns).toEqual([]);
|
||||
expect(merged[0]?.requestCount).toBe(0);
|
||||
});
|
||||
|
||||
it('daily merge tolerates a record missing modelBreakdowns', () => {
|
||||
expect(() => mergeDailyData([[validDay()], [stripBreakdowns(validDay())]])).not.toThrow();
|
||||
});
|
||||
|
||||
it('monthly merge tolerates a record missing modelBreakdowns', () => {
|
||||
expect(() => mergeMonthlyData([[validMonth()], [stripBreakdowns(validMonth())]])).not.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface ModelEntry {
|
||||
};
|
||||
/** Highest codex reasoning-effort suffix this model supports in the dashboard UI. */
|
||||
codexMaxEffort?: CodexEffort;
|
||||
/** Exact Codex reasoning-effort suffixes supported by this model in the dashboard UI. */
|
||||
codexEfforts?: CodexEffort[];
|
||||
/** Additional Codex service-tier suffixes supported by this model in the dashboard UI. */
|
||||
codexServiceTiers?: CodexServiceTier[];
|
||||
}
|
||||
@@ -72,6 +74,17 @@ interface ProviderModelSelectorProps {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CUSTOM_MODEL_ID_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:/-]*$/;
|
||||
const CUSTOM_MODEL_ID_SEPARATOR_PATTERN = /[._:/-]/;
|
||||
|
||||
function isCustomModelId(value: string): boolean {
|
||||
const trimmedValue = value.trim();
|
||||
return (
|
||||
CUSTOM_MODEL_ID_PATTERN.test(trimmedValue) &&
|
||||
CUSTOM_MODEL_ID_SEPARATOR_PATTERN.test(trimmedValue)
|
||||
);
|
||||
}
|
||||
|
||||
function PaidBadge({ label }: { label: string }) {
|
||||
return (
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
@@ -336,12 +349,13 @@ function getPreferredOptionValue(
|
||||
|
||||
function getModelOptionValues(
|
||||
codexMaxEffort: CodexEffort | undefined,
|
||||
codexEfforts: readonly CodexEffort[] | undefined,
|
||||
codexServiceTiers: readonly CodexServiceTier[] | undefined,
|
||||
optionValue: string,
|
||||
isCodexProvider: boolean
|
||||
): string[] {
|
||||
return isCodexProvider
|
||||
? getCodexEffortVariants(optionValue, codexMaxEffort, codexServiceTiers)
|
||||
? getCodexEffortVariants(optionValue, codexMaxEffort, codexServiceTiers, codexEfforts)
|
||||
: [optionValue];
|
||||
}
|
||||
|
||||
@@ -384,6 +398,7 @@ export function FlexibleModelSelector({
|
||||
resolvedCatalogModels.flatMap((model) =>
|
||||
getModelOptionValues(
|
||||
model.codexMaxEffort,
|
||||
model.codexEfforts,
|
||||
model.codexServiceTiers,
|
||||
getPreferredOptionValue(model.id, routingHints.get(model.id.toLowerCase())),
|
||||
isCodexProvider
|
||||
@@ -396,11 +411,31 @@ export function FlexibleModelSelector({
|
||||
() => routingHints.get(normalizeModelValue(value, routing).toLowerCase()),
|
||||
[routing, routingHints, value]
|
||||
);
|
||||
const createCustomModelOption = (query: string) => {
|
||||
const customModelId = query.trim();
|
||||
if (!isCustomModelId(customModelId)) return null;
|
||||
|
||||
return {
|
||||
value: customModelId,
|
||||
groupKey: 'custom',
|
||||
searchText: customModelId,
|
||||
triggerContent: <span className="truncate font-mono text-xs">{customModelId}</span>,
|
||||
itemContent: (
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<span className="truncate font-mono text-xs">{customModelId}</span>
|
||||
<Badge variant="outline" className="text-[9px] h-4 px-1">
|
||||
{t('providerModelSelector.customModel')}
|
||||
</Badge>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
const recommendedOptions = resolvedCatalogModels.flatMap((model) => {
|
||||
const routingHint = routingHints.get(model.id.toLowerCase());
|
||||
const optionValues = getModelOptionValues(
|
||||
model.codexMaxEffort,
|
||||
model.codexEfforts,
|
||||
model.codexServiceTiers,
|
||||
getPreferredOptionValue(model.id, routingHint),
|
||||
isCodexProvider
|
||||
@@ -455,6 +490,7 @@ export function FlexibleModelSelector({
|
||||
.flatMap((model) => {
|
||||
const routingHint = routingHints.get(model.id.toLowerCase());
|
||||
const optionValues = getModelOptionValues(
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
getPreferredOptionValue(model.id, routingHint),
|
||||
@@ -544,6 +580,7 @@ export function FlexibleModelSelector({
|
||||
: t('providerModelSelector.noModelsAvailable')
|
||||
}
|
||||
triggerClassName="h-9"
|
||||
createOption={createCustomModelOption}
|
||||
groups={[
|
||||
...(selectedValueMissing && legacySelectedOption
|
||||
? [
|
||||
@@ -597,6 +634,14 @@ export function FlexibleModelSelector({
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: 'custom',
|
||||
label: (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t('providerModelSelector.customModel')}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
]}
|
||||
options={[
|
||||
...(selectedValueMissing && legacySelectedOption ? [legacySelectedOption] : []),
|
||||
|
||||
@@ -17,6 +17,7 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [
|
||||
'claude',
|
||||
'gemini',
|
||||
'codex',
|
||||
'xai',
|
||||
'cursor',
|
||||
'gitlab',
|
||||
'codebuddy',
|
||||
|
||||
@@ -25,6 +25,7 @@ interface SearchableSelectProps {
|
||||
value?: string;
|
||||
onChange: (value: string) => void;
|
||||
options: SearchableSelectOption[];
|
||||
createOption?: (query: string) => SearchableSelectOption | null | undefined;
|
||||
groups?: SearchableSelectGroup[];
|
||||
placeholder: string;
|
||||
searchPlaceholder: string;
|
||||
@@ -47,6 +48,7 @@ export function SearchableSelect({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
createOption,
|
||||
groups,
|
||||
placeholder,
|
||||
searchPlaceholder,
|
||||
@@ -64,20 +66,32 @@ export function SearchableSelect({
|
||||
const optionRefs = React.useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
|
||||
const selectedOption = React.useMemo(
|
||||
() => options.find((option) => option.value === value),
|
||||
[options, value]
|
||||
() =>
|
||||
options.find((option) => option.value === value) ??
|
||||
(value ? (createOption?.(value) ?? undefined) : undefined),
|
||||
[createOption, options, value]
|
||||
);
|
||||
|
||||
const filteredOptions = React.useMemo(() => {
|
||||
const normalizedQuery = normalizeSearch(query);
|
||||
if (!normalizedQuery) return options;
|
||||
|
||||
return options.filter((option) =>
|
||||
const matchedOptions = options.filter((option) =>
|
||||
[option.searchText, ...(option.keywords ?? [])].some((candidate) =>
|
||||
normalizeSearch(candidate).includes(normalizedQuery)
|
||||
)
|
||||
);
|
||||
}, [options, query]);
|
||||
const createdOption = createOption?.(query.trim()) ?? null;
|
||||
if (!createdOption) return matchedOptions;
|
||||
|
||||
const normalizedCreatedValue = normalizeSearch(createdOption.value);
|
||||
const optionAlreadyExists = options.some(
|
||||
(option) => normalizeSearch(option.value) === normalizedCreatedValue
|
||||
);
|
||||
if (optionAlreadyExists) return matchedOptions;
|
||||
|
||||
return [...matchedOptions, createdOption];
|
||||
}, [createOption, options, query]);
|
||||
|
||||
const groupedOptions = React.useMemo(() => {
|
||||
const knownGroups = new Map((groups ?? []).map((group) => [group.key, group]));
|
||||
|
||||
@@ -69,7 +69,8 @@ export function applyCodexEffortSuffix(
|
||||
export function getCodexEffortVariants(
|
||||
modelId: string,
|
||||
maxEffort: CodexEffort | undefined,
|
||||
serviceTiers: readonly CodexServiceTier[] = []
|
||||
serviceTiers: readonly CodexServiceTier[] = [],
|
||||
efforts?: readonly CodexEffort[]
|
||||
): string[] {
|
||||
if (!maxEffort) {
|
||||
const explicitEffort = parseCodexEffort(modelId);
|
||||
@@ -91,9 +92,9 @@ export function getCodexEffortVariants(
|
||||
|
||||
appendVariantsForEffort(undefined);
|
||||
|
||||
for (const effort of CODEX_EFFORTS_IN_ORDER) {
|
||||
for (const effort of efforts ?? CODEX_EFFORTS_IN_ORDER) {
|
||||
appendVariantsForEffort(effort);
|
||||
if (effort === maxEffort) {
|
||||
if (!efforts && effort === maxEffort) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,6 +221,7 @@ const resources = {
|
||||
prefixOnly: 'Prefix only',
|
||||
current: 'Current',
|
||||
currentValue: 'Current value',
|
||||
customModel: 'Custom model',
|
||||
preferredPinnedModel: 'Preferred pinned model:',
|
||||
pinnedRouteStatus: 'Pinned route status:',
|
||||
pinnedModelNotAdvertised:
|
||||
@@ -2947,6 +2948,7 @@ const resources = {
|
||||
prefixOnly: '仅前缀',
|
||||
current: '当前',
|
||||
currentValue: '当前值',
|
||||
customModel: '自定义模型',
|
||||
preferredPinnedModel: '偏好的固定模型:',
|
||||
pinnedRouteStatus: '固定路由状态:',
|
||||
pinnedModelNotAdvertised: '固定模型当前未被代理广播:{{model}}',
|
||||
@@ -5555,6 +5557,7 @@ const resources = {
|
||||
prefixOnly: 'Chỉ tiền tố',
|
||||
current: 'Hiện tại',
|
||||
currentValue: 'Giá trị hiện tại',
|
||||
customModel: 'Mô hình tùy chỉnh',
|
||||
preferredPinnedModel: 'Mô hình ghim ưu tiên:',
|
||||
pinnedRouteStatus: 'Trạng thái tuyến ghim:',
|
||||
pinnedModelNotAdvertised: 'Mô hình ghim hiện không được proxy quảng bá: {{model}}',
|
||||
@@ -8279,6 +8282,7 @@ const resources = {
|
||||
prefixOnly: 'プレフィックスのみ',
|
||||
current: '現在',
|
||||
currentValue: '現在の値',
|
||||
customModel: 'カスタムモデル',
|
||||
preferredPinnedModel: '優先ピンモデル:',
|
||||
pinnedRouteStatus: 'ピンルートの状態:',
|
||||
pinnedModelNotAdvertised: 'ピンモデルは現在プロキシから通知されていません: {{model}}',
|
||||
@@ -11026,6 +11030,7 @@ const resources = {
|
||||
prefixOnly: '접두사 전용',
|
||||
current: '현재',
|
||||
currentValue: '현재 값',
|
||||
customModel: '사용자 지정 모델',
|
||||
preferredPinnedModel: '선호하는 고정 모델:',
|
||||
pinnedRouteStatus: '고정된 라우트 상태:',
|
||||
pinnedModelNotAdvertised: '고정된 모델이 현재 프록시에서 알리지 않고 있습니다: {{model}}',
|
||||
|
||||
+249
-28
@@ -259,6 +259,48 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
displayName: 'Codex',
|
||||
defaultModel: 'gpt-5.4',
|
||||
models: [
|
||||
{
|
||||
id: 'gpt-5.6-sol',
|
||||
name: 'GPT-5.6 Sol',
|
||||
description: 'Latest frontier agentic coding model.',
|
||||
codexMaxEffort: 'xhigh',
|
||||
codexEfforts: ['low', 'medium', 'high', 'xhigh'],
|
||||
codexServiceTiers: ['fast'],
|
||||
presetMapping: {
|
||||
default: 'gpt-5.6-sol',
|
||||
opus: 'gpt-5.6-sol',
|
||||
sonnet: 'gpt-5.6-sol',
|
||||
haiku: 'gpt-5.4-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.6-terra',
|
||||
name: 'GPT-5.6 Terra',
|
||||
description: 'Balanced agentic coding model for everyday work.',
|
||||
codexMaxEffort: 'xhigh',
|
||||
codexEfforts: ['low', 'medium', 'high', 'xhigh'],
|
||||
codexServiceTiers: ['fast'],
|
||||
presetMapping: {
|
||||
default: 'gpt-5.6-terra',
|
||||
opus: 'gpt-5.6-terra',
|
||||
sonnet: 'gpt-5.6-terra',
|
||||
haiku: 'gpt-5.4-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.6-luna',
|
||||
name: 'GPT-5.6 Luna',
|
||||
description: 'Fast and affordable agentic coding model.',
|
||||
codexMaxEffort: 'xhigh',
|
||||
codexEfforts: ['low', 'medium', 'high', 'xhigh'],
|
||||
codexServiceTiers: ['fast'],
|
||||
presetMapping: {
|
||||
default: 'gpt-5.6-luna',
|
||||
opus: 'gpt-5.6-luna',
|
||||
sonnet: 'gpt-5.6-luna',
|
||||
haiku: 'gpt-5.4-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.5',
|
||||
name: 'GPT-5.5',
|
||||
@@ -340,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',
|
||||
@@ -680,8 +780,50 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
ghcp: {
|
||||
provider: 'ghcp',
|
||||
displayName: 'GitHub Copilot (OAuth)',
|
||||
defaultModel: 'claude-sonnet-4.5',
|
||||
defaultModel: 'claude-sonnet-5',
|
||||
models: [
|
||||
{
|
||||
id: 'claude-sonnet-5',
|
||||
name: 'Claude Sonnet 5',
|
||||
description: 'Anthropic Claude Sonnet 5 via GitHub Copilot',
|
||||
extendedContext: true,
|
||||
presetMapping: {
|
||||
default: 'claude-sonnet-5',
|
||||
opus: 'claude-opus-4.8',
|
||||
sonnet: 'claude-sonnet-5',
|
||||
haiku: 'claude-haiku-4.5',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4.8',
|
||||
name: 'Claude Opus 4.8',
|
||||
description: 'Anthropic Claude Opus 4.8 via GitHub Copilot',
|
||||
extendedContext: true,
|
||||
presetMapping: {
|
||||
default: 'claude-opus-4.8',
|
||||
opus: 'claude-opus-4.8',
|
||||
sonnet: 'claude-sonnet-5',
|
||||
haiku: 'claude-haiku-4.5',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4.8-fast-mode',
|
||||
name: 'Claude Opus 4.8 Fast Mode',
|
||||
description: 'Anthropic Claude Opus 4.8 preview fast mode via GitHub Copilot',
|
||||
extendedContext: true,
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4.7',
|
||||
name: 'Claude Opus 4.7',
|
||||
description: 'Anthropic Claude Opus 4.7 via GitHub Copilot',
|
||||
extendedContext: true,
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4.6',
|
||||
name: 'Claude Opus 4.6',
|
||||
description: 'Anthropic Claude Opus 4.6 via GitHub Copilot',
|
||||
extendedContext: true,
|
||||
},
|
||||
{
|
||||
id: 'claude-opus-4.5',
|
||||
name: 'Claude Opus 4.5',
|
||||
@@ -693,6 +835,18 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
haiku: 'claude-haiku-4.5',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4.6',
|
||||
name: 'Claude Sonnet 4.6',
|
||||
description: 'Anthropic Claude Sonnet 4.6 via GitHub Copilot',
|
||||
extendedContext: true,
|
||||
presetMapping: {
|
||||
default: 'claude-sonnet-4.6',
|
||||
opus: 'claude-opus-4.7',
|
||||
sonnet: 'claude-sonnet-4.6',
|
||||
haiku: 'claude-haiku-4.5',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4.5',
|
||||
name: 'Claude Sonnet 4.5',
|
||||
@@ -704,36 +858,56 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
haiku: 'claude-haiku-4.5',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'claude-sonnet-4',
|
||||
name: 'Claude Sonnet 4',
|
||||
description: 'Anthropic Claude Sonnet 4 via GitHub Copilot',
|
||||
},
|
||||
{
|
||||
id: 'claude-haiku-4.5',
|
||||
name: 'Claude Haiku 4.5',
|
||||
description: 'Anthropic Claude Haiku 4.5 via GitHub Copilot',
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.2',
|
||||
name: 'GPT-5.2',
|
||||
description: 'OpenAI GPT-5.2 via GitHub Copilot',
|
||||
id: 'claude-fable-5',
|
||||
name: 'Claude Fable 5',
|
||||
description: 'Anthropic Claude Fable 5 via GitHub Copilot',
|
||||
extendedContext: true,
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.5',
|
||||
name: 'GPT-5.5',
|
||||
description: 'OpenAI GPT-5.5 via GitHub Copilot',
|
||||
extendedContext: true,
|
||||
presetMapping: {
|
||||
default: 'gpt-5.2',
|
||||
opus: 'gpt-5.2',
|
||||
sonnet: 'gpt-5.1',
|
||||
haiku: 'gpt-5-mini',
|
||||
default: 'gpt-5.5',
|
||||
opus: 'gpt-5.5',
|
||||
sonnet: 'gpt-5.4',
|
||||
haiku: 'gpt-5.4-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.1',
|
||||
name: 'GPT-5.1',
|
||||
description: 'OpenAI GPT-5.1 via GitHub Copilot',
|
||||
id: 'gpt-5.4',
|
||||
name: 'GPT-5.4',
|
||||
description: 'OpenAI GPT-5.4 via GitHub Copilot',
|
||||
extendedContext: true,
|
||||
},
|
||||
{
|
||||
id: 'gpt-5',
|
||||
name: 'GPT-5',
|
||||
description: 'OpenAI GPT-5 via GitHub Copilot',
|
||||
id: 'gpt-5.4-mini',
|
||||
name: 'GPT-5.4 Mini',
|
||||
description: 'OpenAI GPT-5.4 Mini via GitHub Copilot',
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.4-nano',
|
||||
name: 'GPT-5.4 Nano',
|
||||
description: 'OpenAI GPT-5.4 Nano via GitHub Copilot',
|
||||
},
|
||||
{
|
||||
id: 'gpt-5.3-codex',
|
||||
name: 'GPT-5.3 Codex',
|
||||
description: 'OpenAI GPT-5.3 Codex via GitHub Copilot',
|
||||
extendedContext: true,
|
||||
presetMapping: {
|
||||
default: 'gpt-5.3-codex',
|
||||
opus: 'gpt-5.3-codex',
|
||||
sonnet: 'gpt-5.3-codex',
|
||||
haiku: 'gpt-5-mini',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'gpt-5-mini',
|
||||
@@ -741,17 +915,59 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
description: 'OpenAI GPT-5 Mini via GitHub Copilot',
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-pro',
|
||||
name: 'Gemini 3 Pro',
|
||||
description: 'Google Gemini 3 Pro via GitHub Copilot',
|
||||
id: 'gemini-2.5-pro',
|
||||
name: 'Gemini 2.5 Pro',
|
||||
description: 'Google Gemini 2.5 Pro via GitHub Copilot',
|
||||
},
|
||||
{
|
||||
id: 'gemini-3-flash',
|
||||
name: 'Gemini 3 Flash',
|
||||
description: 'Google Gemini 3 Flash via GitHub Copilot',
|
||||
},
|
||||
{
|
||||
id: 'gemini-3.1-pro',
|
||||
name: 'Gemini 3.1 Pro',
|
||||
description: 'Google Gemini 3.1 Pro via GitHub Copilot',
|
||||
},
|
||||
{
|
||||
id: 'gemini-3.5-flash',
|
||||
name: 'Gemini 3.5 Flash',
|
||||
description: 'Google Gemini 3.5 Flash via GitHub Copilot',
|
||||
},
|
||||
{
|
||||
id: 'kimi-k2.7-code',
|
||||
name: 'Kimi K2.7 Code',
|
||||
description: 'Moonshot Kimi K2.7 Code via GitHub Copilot',
|
||||
},
|
||||
{
|
||||
id: 'mai-code-1-flash',
|
||||
name: 'MAI-Code-1-Flash',
|
||||
description: 'Microsoft MAI-Code-1-Flash via GitHub Copilot',
|
||||
},
|
||||
{
|
||||
id: 'raptor-mini',
|
||||
name: 'Raptor Mini',
|
||||
description: 'Microsoft Raptor Mini via GitHub Copilot',
|
||||
},
|
||||
],
|
||||
},
|
||||
claude: {
|
||||
provider: 'claude',
|
||||
displayName: 'Claude (Anthropic)',
|
||||
defaultModel: 'claude-sonnet-4-6',
|
||||
defaultModel: 'claude-sonnet-5',
|
||||
models: [
|
||||
{
|
||||
id: 'claude-sonnet-5',
|
||||
name: 'Claude Sonnet 5',
|
||||
description: 'Latest Sonnet model',
|
||||
extendedContext: true,
|
||||
presetMapping: {
|
||||
default: 'claude-sonnet-5',
|
||||
opus: 'claude-opus-4-8',
|
||||
sonnet: 'claude-sonnet-5',
|
||||
haiku: 'claude-haiku-4-5-20251001',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'claude-fable-5',
|
||||
name: 'Claude Fable 5',
|
||||
@@ -760,7 +976,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
presetMapping: {
|
||||
default: 'claude-fable-5',
|
||||
opus: 'claude-fable-5',
|
||||
sonnet: 'claude-sonnet-4-6',
|
||||
sonnet: 'claude-sonnet-5',
|
||||
haiku: 'claude-haiku-4-5-20251001',
|
||||
},
|
||||
},
|
||||
@@ -772,7 +988,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
presetMapping: {
|
||||
default: 'claude-opus-4-8',
|
||||
opus: 'claude-opus-4-8',
|
||||
sonnet: 'claude-sonnet-4-6',
|
||||
sonnet: 'claude-sonnet-5',
|
||||
haiku: 'claude-haiku-4-5-20251001',
|
||||
},
|
||||
},
|
||||
@@ -784,7 +1000,7 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
|
||||
presetMapping: {
|
||||
default: 'claude-opus-4-7',
|
||||
opus: 'claude-opus-4-7',
|
||||
sonnet: 'claude-sonnet-4-6',
|
||||
sonnet: 'claude-sonnet-5',
|
||||
haiku: 'claude-haiku-4-5-20251001',
|
||||
},
|
||||
},
|
||||
@@ -904,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;
|
||||
}
|
||||
@@ -925,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,
|
||||
};
|
||||
});
|
||||
@@ -1124,5 +1344,6 @@ export function supportsExtendedContext(
|
||||
modelId: string,
|
||||
catalogOverride?: ProviderCatalog
|
||||
): boolean {
|
||||
if (provider.toLowerCase() === 'xai') return false;
|
||||
return findCatalogModel(provider, modelId, catalogOverride)?.extendedContext === true;
|
||||
}
|
||||
@@ -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. */
|
||||
|
||||
+70
-26
@@ -10,6 +10,7 @@ import { useCodex } from '@/hooks/use-codex';
|
||||
import { isApiConflictError } from '@/lib/api-client';
|
||||
import { CodexOverviewTab } from '@/components/compatible-cli/codex-overview-tab';
|
||||
import { RawConfigEditorPanel } from '@/components/compatible-cli/raw-json-settings-editor-panel';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
getKnownCodexFeatures,
|
||||
@@ -20,7 +21,13 @@ import {
|
||||
readCodexProjectTrust,
|
||||
readCodexTopLevelSettings,
|
||||
} from '@/lib/codex-config';
|
||||
import { safeParseTomlObject } from '@shared/toml-object';
|
||||
import { proposeJoinedCodexModelMigrationRepair, safeParseTomlObject } from '@shared/toml-object';
|
||||
|
||||
interface RawConfigDraft {
|
||||
text: string;
|
||||
baseText: string;
|
||||
expectedMtime?: number;
|
||||
}
|
||||
|
||||
export function CodexPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -40,10 +47,14 @@ export function CodexPage() {
|
||||
isPatchingConfig,
|
||||
} = useCodex();
|
||||
|
||||
const [rawDraftText, setRawDraftText] = useState<string | null>(null);
|
||||
const [rawDraft, setRawDraft] = useState<RawConfigDraft | null>(null);
|
||||
const rawBaseText = rawConfig?.rawText ?? '';
|
||||
const rawEditorText = rawDraftText ?? rawBaseText;
|
||||
const rawConfigDirty = rawDraftText !== null && rawDraftText !== rawBaseText;
|
||||
const rawEditorText = rawDraft?.text ?? rawBaseText;
|
||||
const rawConfigDirty = rawDraft !== null;
|
||||
const rawBaseRepairProposal = useMemo(() => {
|
||||
if (rawConfigDirty || !rawConfig?.parseError || rawConfig.readError) return null;
|
||||
return proposeJoinedCodexModelMigrationRepair(rawBaseText);
|
||||
}, [rawBaseText, rawConfig?.parseError, rawConfig?.readError, rawConfigDirty]);
|
||||
const rawEditorParsed = safeParseTomlObject(rawEditorText);
|
||||
const rawEditorValidation = rawEditorParsed.parseError
|
||||
? { valid: false as const, error: rawEditorParsed.parseError }
|
||||
@@ -84,11 +95,20 @@ export function CodexPage() {
|
||||
const featureState = useMemo(() => readCodexFeatureState(controlsConfig), [controlsConfig]);
|
||||
|
||||
const setRawEditorDraftText = (nextText: string) => {
|
||||
if (nextText === rawBaseText) {
|
||||
setRawDraftText(null);
|
||||
return;
|
||||
}
|
||||
setRawDraftText(nextText);
|
||||
setRawDraft((currentDraft) => {
|
||||
const baseText = currentDraft?.baseText ?? rawBaseText;
|
||||
if (nextText === baseText) return null;
|
||||
|
||||
return {
|
||||
text: nextText,
|
||||
baseText,
|
||||
expectedMtime: currentDraft
|
||||
? currentDraft.expectedMtime
|
||||
: rawConfig?.exists
|
||||
? rawConfig.mtime
|
||||
: undefined,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const refreshAll = async () => {
|
||||
@@ -103,24 +123,24 @@ export function CodexPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
setRawDraftText(null);
|
||||
setRawDraft(null);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || t('toasts.codexRefreshError'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveRawConfig = async () => {
|
||||
if (!rawEditorValidation.valid) {
|
||||
if (!rawDraft || !rawEditorValidation.valid) {
|
||||
toast.error(t('toasts.codexFixToml'));
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await saveRawConfigAsync({
|
||||
rawText: rawEditorText,
|
||||
expectedMtime: rawConfig?.exists ? rawConfig.mtime : undefined,
|
||||
rawText: rawDraft.text,
|
||||
expectedMtime: rawDraft.expectedMtime,
|
||||
});
|
||||
setRawDraftText(null);
|
||||
setRawDraft(null);
|
||||
toast.success(t('toasts.codexSaved'));
|
||||
await refetchDiagnostics();
|
||||
} catch (error) {
|
||||
@@ -141,7 +161,7 @@ export function CodexPage() {
|
||||
...patch,
|
||||
expectedMtime: rawConfig?.exists ? rawConfig.mtime : undefined,
|
||||
});
|
||||
setRawDraftText(null);
|
||||
setRawDraft(null);
|
||||
toast.success(successMessage);
|
||||
} catch (error) {
|
||||
if (isApiConflictError(error)) {
|
||||
@@ -259,7 +279,7 @@ export function CodexPage() {
|
||||
}}
|
||||
onSave={handleSaveRawConfig}
|
||||
onRefresh={refreshAll}
|
||||
onDiscard={() => setRawDraftText(null)}
|
||||
onDiscard={() => setRawDraft(null)}
|
||||
language="toml"
|
||||
exactText
|
||||
/* TODO i18n: missing key for "Loading config.toml..." */
|
||||
@@ -267,16 +287,40 @@ export function CodexPage() {
|
||||
/* TODO i18n: missing key for "TOML warning" */
|
||||
parseWarningLabel="TOML warning"
|
||||
ownershipNotice={
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/60 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/20 dark:text-amber-300">
|
||||
{/* TODO i18n: missing keys for ownership notice paragraphs */}
|
||||
<p className="font-medium">This file is upstream-owned by Codex CLI.</p>
|
||||
<p>
|
||||
CCS does not keep <code>~/.codex/config.toml</code> in sync for you.
|
||||
</p>
|
||||
<p>
|
||||
CCS-backed Codex launches may apply transient <code>-c</code> overrides and
|
||||
<code> CCS_CODEX_API_KEY</code>; those effective values may not appear here.
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{rawBaseRepairProposal ? (
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/20 dark:text-amber-300"
|
||||
>
|
||||
<p className="font-medium">Safe repair available</p>
|
||||
<p className="mt-1">
|
||||
Codex joined a model migration to the next table header. Previewing inserts
|
||||
exactly one newline in this unsaved editor draft. Review it, then select Save
|
||||
to write <code>config.toml</code>.
|
||||
</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-2"
|
||||
onClick={() => setRawEditorDraftText(rawBaseRepairProposal.repairedText)}
|
||||
>
|
||||
Preview one-newline repair
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="rounded-md border border-amber-200 bg-amber-50/60 px-3 py-2 text-sm text-amber-900 dark:bg-amber-950/20 dark:text-amber-300">
|
||||
{/* TODO i18n: missing keys for ownership notice paragraphs */}
|
||||
<p className="font-medium">This file is upstream-owned by Codex CLI.</p>
|
||||
<p>
|
||||
CCS does not keep <code>~/.codex/config.toml</code> in sync for you.
|
||||
</p>
|
||||
<p>
|
||||
CCS-backed Codex launches may apply transient <code>-c</code> overrides and
|
||||
<code> CCS_CODEX_API_KEY</code>; those effective values may not appear here.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -70,7 +70,7 @@ describe('FlexibleModelSelector', () => {
|
||||
expect(screen.queryByText(/All Models \(/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces Claude Opus 4.7 in the curated Claude picker', async () => {
|
||||
it('surfaces Claude Sonnet 5 in the curated Claude picker', async () => {
|
||||
render(
|
||||
<FlexibleModelSelector
|
||||
label="Primary model"
|
||||
@@ -83,11 +83,74 @@ describe('FlexibleModelSelector', () => {
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /select model/i }));
|
||||
|
||||
expect(screen.getByRole('option', { name: /claude-opus-4-7/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /claude-opus-4-6/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /claude-sonnet-5/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /claude-opus-4-8/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /claude-sonnet-4-6/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('surfaces Claude Sonnet 5 in the GitHub Copilot CLIProxy picker', async () => {
|
||||
render(
|
||||
<FlexibleModelSelector
|
||||
label="Sonnet model"
|
||||
value={undefined}
|
||||
onChange={vi.fn()}
|
||||
catalog={MODEL_CATALOGS.ghcp}
|
||||
allModels={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /select model/i }));
|
||||
await userEvent.type(await screen.findByPlaceholderText('Search models...'), 'sonnet');
|
||||
|
||||
expect(screen.getByRole('option', { name: /claude-sonnet-5/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('option', { name: /claude-sonnet-4\.6/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText('Custom model')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('lets users enter a future GitHub Copilot model ID from the CLIProxy picker', async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<FlexibleModelSelector
|
||||
label="Sonnet model"
|
||||
value={undefined}
|
||||
onChange={onChange}
|
||||
catalog={MODEL_CATALOGS.ghcp}
|
||||
allModels={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /select model/i }));
|
||||
await userEvent.type(await screen.findByPlaceholderText('Search models...'), 'claude-sonnet-6');
|
||||
|
||||
expect(screen.getAllByText('Custom model').length).toBeGreaterThan(0);
|
||||
await userEvent.click(screen.getByRole('option', { name: /claude-sonnet-6/i }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('claude-sonnet-6');
|
||||
});
|
||||
|
||||
it('lets users enter a future direct Claude model ID from the CLIProxy picker', async () => {
|
||||
const onChange = vi.fn();
|
||||
|
||||
render(
|
||||
<FlexibleModelSelector
|
||||
label="Primary model"
|
||||
value={undefined}
|
||||
onChange={onChange}
|
||||
catalog={MODEL_CATALOGS.claude}
|
||||
allModels={[]}
|
||||
/>
|
||||
);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /select model/i }));
|
||||
await userEvent.type(await screen.findByPlaceholderText('Search models...'), 'claude-sonnet-6');
|
||||
|
||||
expect(screen.getAllByText('Custom model').length).toBeGreaterThan(0);
|
||||
await userEvent.click(screen.getByRole('option', { name: /claude-sonnet-6/i }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('claude-sonnet-6');
|
||||
});
|
||||
|
||||
it('preserves a filtered legacy value under the current-value fallback group', async () => {
|
||||
render(
|
||||
<FlexibleModelSelector
|
||||
@@ -128,6 +191,10 @@ describe('FlexibleModelSelector', () => {
|
||||
expect(screen.getByText('gpt-5.3-codex-high')).toBeInTheDocument();
|
||||
expect(screen.getByText('gpt-5.3-codex-xhigh')).toBeInTheDocument();
|
||||
expect(screen.getByText('gpt-5.4-high-fast')).toBeInTheDocument();
|
||||
expect(screen.getByText('gpt-5.6-sol-low')).toBeInTheDocument();
|
||||
expect(screen.getByText('gpt-5.6-terra-xhigh')).toBeInTheDocument();
|
||||
expect(screen.getByText('gpt-5.6-luna-fast')).toBeInTheDocument();
|
||||
expect(screen.queryByText('gpt-5.6-sol-minimal')).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByText('gpt-5.3-codex-high'));
|
||||
expect(onChange).toHaveBeenCalledWith('gpt-5.3-codex-high');
|
||||
|
||||
@@ -42,6 +42,42 @@ function SearchableSelectHarness() {
|
||||
);
|
||||
}
|
||||
|
||||
function SearchableSelectWithCreateHarness() {
|
||||
const [value, setValue] = useState<string | undefined>();
|
||||
|
||||
return (
|
||||
<SearchableSelect
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
placeholder="Select model"
|
||||
searchPlaceholder="Search models..."
|
||||
emptyText="No results found."
|
||||
groups={[
|
||||
{ key: 'known', label: 'Known Models' },
|
||||
{ key: 'custom', label: 'Custom Model' },
|
||||
]}
|
||||
createOption={(query) =>
|
||||
/^[a-z0-9][a-z0-9.-]*$/i.test(query)
|
||||
? {
|
||||
value: query,
|
||||
groupKey: 'custom',
|
||||
searchText: query,
|
||||
itemContent: <span>Use custom model {query}</span>,
|
||||
}
|
||||
: null
|
||||
}
|
||||
options={[
|
||||
{
|
||||
value: 'claude-sonnet-4',
|
||||
groupKey: 'known',
|
||||
searchText: 'Claude Sonnet 4 claude-sonnet-4',
|
||||
itemContent: <span>Claude Sonnet 4</span>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('SearchableSelect', () => {
|
||||
beforeEach(() => {
|
||||
Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', {
|
||||
@@ -94,6 +130,22 @@ describe('SearchableSelect', () => {
|
||||
expect(screen.getByText('No results found.')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('can append and select a custom option from the search query', async () => {
|
||||
render(<SearchableSelectWithCreateHarness />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Select model' }));
|
||||
await userEvent.type(await screen.findByPlaceholderText('Search models...'), 'claude-sonnet-5');
|
||||
|
||||
expect(screen.getByText('Custom Model')).toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('option', { name: 'Use custom model claude-sonnet-5' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Use custom model claude-sonnet-5' })
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('supports keyboard navigation and selection from the search input', async () => {
|
||||
render(<SearchableSelectHarness />);
|
||||
|
||||
|
||||
@@ -4,6 +4,9 @@ import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
||||
describe('codex model catalog defaults', () => {
|
||||
it('mirrors the current Codex runtime catalog and free-safe defaults', () => {
|
||||
const codexCatalog = MODEL_CATALOGS.codex;
|
||||
const codex56Sol = codexCatalog.models.find((model) => model.id === 'gpt-5.6-sol');
|
||||
const codex56Terra = codexCatalog.models.find((model) => model.id === 'gpt-5.6-terra');
|
||||
const codex56Luna = codexCatalog.models.find((model) => model.id === 'gpt-5.6-luna');
|
||||
const codex55 = codexCatalog.models.find((model) => model.id === 'gpt-5.5');
|
||||
const codex53 = codexCatalog.models.find((model) => model.id === 'gpt-5.3-codex');
|
||||
const codex52 = codexCatalog.models.find((model) => model.id === 'gpt-5.2');
|
||||
@@ -11,6 +14,12 @@ describe('codex model catalog defaults', () => {
|
||||
const codexMini = codexCatalog.models.find((model) => model.id === 'gpt-5.4-mini');
|
||||
|
||||
expect(codexCatalog.defaultModel).toBe('gpt-5.4');
|
||||
for (const model of [codex56Sol, codex56Terra, codex56Luna]) {
|
||||
expect(model?.tier).toBeUndefined();
|
||||
expect(model?.codexEfforts).toEqual(['low', 'medium', 'high', 'xhigh']);
|
||||
expect(model?.codexServiceTiers).toEqual(['fast']);
|
||||
expect(model?.presetMapping?.haiku).toBe('gpt-5.4-mini');
|
||||
}
|
||||
expect(codex55?.tier).toBe('paid');
|
||||
expect(codex55?.presetMapping?.haiku).toBe('gpt-5.4-mini');
|
||||
expect(codex55?.codexMaxEffort).toBe('xhigh');
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
|
||||
|
||||
describe('GitHub Copilot model catalog defaults', () => {
|
||||
it('surfaces Claude Sonnet 5 as the default Copilot preset model', () => {
|
||||
const ghcpCatalog = MODEL_CATALOGS.ghcp;
|
||||
const sonnet5 = ghcpCatalog.models.find((model) => model.id === 'claude-sonnet-5');
|
||||
|
||||
expect(ghcpCatalog.defaultModel).toBe('claude-sonnet-5');
|
||||
expect(sonnet5?.name).toBe('Claude Sonnet 5');
|
||||
expect(sonnet5?.extendedContext).toBe(true);
|
||||
expect(sonnet5?.presetMapping).toEqual({
|
||||
default: 'claude-sonnet-5',
|
||||
opus: 'claude-opus-4.8',
|
||||
sonnet: 'claude-sonnet-5',
|
||||
haiku: 'claude-haiku-4.5',
|
||||
});
|
||||
});
|
||||
|
||||
it('advertises current Copilot models without retired recommendations', () => {
|
||||
const ids = MODEL_CATALOGS.ghcp.models.map((model) => model.id);
|
||||
|
||||
expect(ids).toEqual(
|
||||
expect.arrayContaining([
|
||||
'claude-sonnet-5',
|
||||
'claude-opus-4.8',
|
||||
'claude-opus-4.8-fast-mode',
|
||||
'claude-fable-5',
|
||||
'gpt-5.5',
|
||||
'gpt-5.4',
|
||||
'gpt-5.3-codex',
|
||||
'gemini-3.5-flash',
|
||||
'kimi-k2.7-code',
|
||||
'mai-code-1-flash',
|
||||
'raptor-mini',
|
||||
])
|
||||
);
|
||||
expect(ids).not.toEqual(
|
||||
expect.arrayContaining(['claude-sonnet-4', 'gpt-5.2', 'gpt-5.1', 'gpt-5', 'gemini-3-pro'])
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -16,11 +16,12 @@ describe('claude preset utils', () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('keeps the claude catalog default on Sonnet 4.6 while exposing Opus 4.7/4.8 and Fable 5', () => {
|
||||
it('keeps the claude catalog default on Sonnet 5 while exposing recent Claude models', () => {
|
||||
const claudeCatalog = MODEL_CATALOGS.claude;
|
||||
const ids = claudeCatalog.models.map((model) => model.id);
|
||||
|
||||
expect(claudeCatalog.defaultModel).toBe('claude-sonnet-4-6');
|
||||
expect(claudeCatalog.defaultModel).toBe('claude-sonnet-5');
|
||||
expect(ids).toContain('claude-sonnet-5');
|
||||
expect(ids).toContain('claude-fable-5');
|
||||
expect(ids).toContain('claude-opus-4-8');
|
||||
expect(ids).toContain('claude-opus-4-7');
|
||||
@@ -50,15 +51,15 @@ describe('claude preset utils', () => {
|
||||
|
||||
const result = await applyDefaultPreset('claude');
|
||||
|
||||
expect(result).toEqual({ success: true, presetName: 'Claude Sonnet 4.6' });
|
||||
expect(result).toEqual({ success: true, presetName: 'Claude Sonnet 5' });
|
||||
|
||||
const [, requestInit] = fetchMock.mock.calls[2] ?? [];
|
||||
const body = JSON.parse(String(requestInit?.body));
|
||||
|
||||
expect(body.settings.env).toMatchObject({
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6',
|
||||
ANTHROPIC_MODEL: 'claude-sonnet-5',
|
||||
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-8',
|
||||
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-5',
|
||||
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
|
||||
});
|
||||
});
|
||||
@@ -76,7 +77,7 @@ describe('claude preset utils', () => {
|
||||
|
||||
const result = await applyDefaultPreset('claude', undefined, MODEL_CATALOGS.claude);
|
||||
|
||||
expect(result).toEqual({ success: true, presetName: 'Claude Sonnet 4.6' });
|
||||
expect(result).toEqual({ success: true, presetName: 'Claude Sonnet 5' });
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(1, '/api/settings/auth/tokens/raw');
|
||||
expect(fetchMock).toHaveBeenNthCalledWith(
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -40,7 +40,11 @@ vi.mock('@/components/shared/code-editor', () => ({
|
||||
}));
|
||||
|
||||
vi.mock('@/components/compatible-cli/codex-control-center-tab', () => ({
|
||||
CodexControlCenterTab: () => <div>Control Center</div>,
|
||||
CodexControlCenterTab: ({ disabled }: { disabled?: boolean }) => (
|
||||
<div data-testid="codex-control-center" data-disabled={String(Boolean(disabled))}>
|
||||
Control Center
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@/components/compatible-cli/codex-docs-tab', () => ({
|
||||
@@ -53,6 +57,15 @@ vi.mock('@/components/compatible-cli/codex-overview-tab', () => ({
|
||||
|
||||
import { CodexPage } from '@/pages/codex';
|
||||
|
||||
const joinedMigrationRawText = `[notice.model_migrations]
|
||||
"gpt-5.3-codex" = "gpt-5.4"[agents.code_simplifier]
|
||||
description = "Keep exact text"
|
||||
`;
|
||||
const repairedMigrationRawText = joinedMigrationRawText.replace(
|
||||
'"gpt-5.4"[agents.code_simplifier]',
|
||||
'"gpt-5.4"\n[agents.code_simplifier]'
|
||||
);
|
||||
|
||||
const diagnostics = {
|
||||
binary: {
|
||||
installed: true,
|
||||
@@ -228,5 +241,184 @@ describe('CodexPage', () => {
|
||||
|
||||
expect(screen.getByText(/Read-only: Refusing symlink file for safety\./)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText('codex raw editor')).toHaveAttribute('readonly');
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Preview one-newline repair' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('previews the one-newline repair as an unsaved draft before explicitly saving it', async () => {
|
||||
mocks.saveRawConfigAsync.mockResolvedValue({ success: true, mtime: 101 });
|
||||
mocks.useCodex.mockReturnValue(
|
||||
buildUseCodexResult({
|
||||
rawConfig: {
|
||||
path: '$CODEX_HOME/config.toml',
|
||||
resolvedPath: '/tmp/.codex/config.toml',
|
||||
exists: true,
|
||||
mtime: 100,
|
||||
rawText: joinedMigrationRawText,
|
||||
config: null,
|
||||
parseError: 'Invalid TOML',
|
||||
readError: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
render(<CodexPage />);
|
||||
|
||||
await userEvent.click(screen.getByRole('tab', { name: 'Control Center' }));
|
||||
expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'true');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Preview one-newline repair' }));
|
||||
|
||||
expect(screen.getByLabelText('codex raw editor')).toHaveValue(repairedMigrationRawText);
|
||||
expect(screen.getByText('Unsaved')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: 'Save' })).toBeEnabled();
|
||||
expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'true');
|
||||
expect(mocks.saveRawConfigAsync).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.saveRawConfigAsync).toHaveBeenCalledWith({
|
||||
rawText: repairedMigrationRawText,
|
||||
expectedMtime: 100,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the repair draft bound to its original mtime across a fetched snapshot update', async () => {
|
||||
mocks.saveRawConfigAsync.mockResolvedValue({ success: true, mtime: 201 });
|
||||
let rawConfig = {
|
||||
path: '$CODEX_HOME/config.toml',
|
||||
resolvedPath: '/tmp/.codex/config.toml',
|
||||
exists: true,
|
||||
mtime: 100,
|
||||
rawText: joinedMigrationRawText,
|
||||
config: null,
|
||||
parseError: 'Invalid TOML',
|
||||
readError: null,
|
||||
};
|
||||
mocks.useCodex.mockImplementation(() => buildUseCodexResult({ rawConfig }));
|
||||
|
||||
const view = render(<CodexPage />);
|
||||
await userEvent.click(screen.getByRole('tab', { name: 'Control Center' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Preview one-newline repair' }));
|
||||
|
||||
rawConfig = {
|
||||
...rawConfig,
|
||||
mtime: 200,
|
||||
rawText: 'model = "gpt-5.4-mini"\n',
|
||||
config: { model: 'gpt-5.4-mini' },
|
||||
parseError: null,
|
||||
};
|
||||
view.rerender(<CodexPage />);
|
||||
|
||||
expect(screen.getByLabelText('codex raw editor')).toHaveValue(repairedMigrationRawText);
|
||||
expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'true');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.saveRawConfigAsync).toHaveBeenCalledWith({
|
||||
rawText: repairedMigrationRawText,
|
||||
expectedMtime: 100,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps manual raw edits bound to the snapshot mtime captured on first edit', async () => {
|
||||
mocks.saveRawConfigAsync.mockResolvedValue({ success: true, mtime: 201 });
|
||||
let rawConfig = {
|
||||
path: '$CODEX_HOME/config.toml',
|
||||
resolvedPath: '/tmp/.codex/config.toml',
|
||||
exists: true,
|
||||
mtime: 100,
|
||||
rawText: 'model = "gpt-5.4"\n',
|
||||
config: { model: 'gpt-5.4' },
|
||||
parseError: null,
|
||||
readError: null,
|
||||
};
|
||||
mocks.useCodex.mockImplementation(() => buildUseCodexResult({ rawConfig }));
|
||||
|
||||
const view = render(<CodexPage />);
|
||||
const editor = screen.getByLabelText('codex raw editor');
|
||||
await userEvent.clear(editor);
|
||||
await userEvent.type(editor, 'model = "gpt-5.4-mini"');
|
||||
|
||||
rawConfig = {
|
||||
...rawConfig,
|
||||
mtime: 200,
|
||||
rawText: 'model = "gpt-5.4-high"\n',
|
||||
config: { model: 'gpt-5.4-high' },
|
||||
};
|
||||
view.rerender(<CodexPage />);
|
||||
|
||||
expect(screen.getByLabelText('codex raw editor')).toHaveValue('model = "gpt-5.4-mini"');
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(mocks.saveRawConfigAsync).toHaveBeenCalledWith({
|
||||
rawText: 'model = "gpt-5.4-mini"',
|
||||
expectedMtime: 100,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not offer the targeted repair for generic invalid TOML', () => {
|
||||
mocks.useCodex.mockReturnValue(
|
||||
buildUseCodexResult({
|
||||
rawConfig: {
|
||||
path: '$CODEX_HOME/config.toml',
|
||||
resolvedPath: '/tmp/.codex/config.toml',
|
||||
exists: true,
|
||||
mtime: 100,
|
||||
rawText: 'model = "gpt-5.4"\n[features\n',
|
||||
config: null,
|
||||
parseError: 'Invalid TOML',
|
||||
readError: null,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
render(<CodexPage />);
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Preview one-newline repair' })
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('re-enables structured controls only after the saved valid snapshot is loaded', async () => {
|
||||
mocks.saveRawConfigAsync.mockResolvedValue({ success: true, mtime: 101 });
|
||||
let rawConfig = {
|
||||
path: '$CODEX_HOME/config.toml',
|
||||
resolvedPath: '/tmp/.codex/config.toml',
|
||||
exists: true,
|
||||
mtime: 100,
|
||||
rawText: joinedMigrationRawText,
|
||||
config: null,
|
||||
parseError: 'Invalid TOML',
|
||||
readError: null,
|
||||
};
|
||||
mocks.useCodex.mockImplementation(() => buildUseCodexResult({ rawConfig }));
|
||||
|
||||
const view = render(<CodexPage />);
|
||||
await userEvent.click(screen.getByRole('tab', { name: 'Control Center' }));
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Preview one-newline repair' }));
|
||||
expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'true');
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Save' }));
|
||||
await waitFor(() => expect(mocks.saveRawConfigAsync).toHaveBeenCalledTimes(1));
|
||||
|
||||
rawConfig = {
|
||||
...rawConfig,
|
||||
mtime: 101,
|
||||
rawText: repairedMigrationRawText,
|
||||
config: {
|
||||
notice: { model_migrations: { 'gpt-5.3-codex': 'gpt-5.4' } },
|
||||
agents: { code_simplifier: { description: 'Keep exact text' } },
|
||||
},
|
||||
parseError: null,
|
||||
};
|
||||
view.rerender(<CodexPage />);
|
||||
|
||||
expect(screen.getByTestId('codex-control-center')).toHaveAttribute('data-disabled', 'false');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user