Merge pull request #594 from kaitranntt/dev

feat: v7.47.0 release — Kimi provider, multi-target droid, GHCP quota, thinking UX
This commit is contained in:
Kai (Tam Nhu) Tran
2026-02-21 00:00:09 +07:00
committed by GitHub
111 changed files with 5712 additions and 990 deletions
+7 -1
View File
@@ -99,7 +99,8 @@ The dashboard provides visual management for all account types:
| **Ollama** | Local | `ccs ollama` | Local open-source models, privacy |
| **Ollama Cloud** | API Key | `ccs ollama-cloud` | Cloud-hosted open-source models |
| **GLM** | API Key | `ccs glm` | Cost-optimized execution |
| **Kimi** | API Key | `ccs kimi` | Long-context, thinking mode |
| **KM (Kimi API)** | API Key | `ccs km` | Long-context, thinking mode |
| **Kimi (OAuth)** | OAuth | `ccs kimi` | Device-code OAuth via CLIProxy |
| **Azure Foundry** | API Key | `ccs foundry` | Claude via Microsoft Azure |
| **Minimax** | API Key | `ccs mm` | M2 series, 1M context |
| **DeepSeek** | API Key | `ccs deepseek` | V3.2 and R1 reasoning |
@@ -139,6 +140,7 @@ ccs ghcp # GitHub Copilot (OAuth device flow)
ccs agy # Antigravity (OAuth)
ccs ollama # Local Ollama (no API key needed)
ccs glm # GLM (API key)
ccs km # Kimi API profile (API key)
```
### Droid Alias (`argv[0]` pattern)
@@ -191,6 +193,8 @@ Detailed guide: [`docs/cursor-integration.md`](./docs/cursor-integration.md)
Run multiple terminals with different providers:
> Delegation compatibility: when CCS spawns child Claude sessions, it strips the `CLAUDECODE` guard variable to avoid nested-session blocking in Claude Code v2.1.39+.
```bash
# Terminal 1: Planning (Claude Pro)
ccs work "design the authentication system"
@@ -281,6 +285,8 @@ export CCS_CLAUDE_PATH="/path/to/claude" # Unix
$env:CCS_CLAUDE_PATH = "D:\Tools\Claude\claude.exe" # Windows
```
CCS sanitizes child Claude spawn environments by stripping `CLAUDECODE` (case-insensitive) to prevent nested-session guard failures during delegation. `CCS_CLAUDE_PATH` is still respected after this sanitization step.
</details>
<details>
+9
View File
@@ -429,6 +429,15 @@ if (needsShell) {
This pattern is used in both `ClaudeAdapter` and `DroidAdapter` to ensure cross-platform consistency.
For all Claude child-process launches (delegation, adapters, proxies, helper spawners), sanitize env before spawn:
```typescript
const cleanEnv = stripClaudeCodeEnv(mergedEnv); // case-insensitive remove of CLAUDECODE
spawn(binaryPath, args, { env: cleanEnv, stdio: 'inherit' });
```
This prevents Claude Code nested-session guard failures when CCS runs inside parent Claude sessions.
---
## React Component Standards (UI)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.46.0",
"version": "7.46.0-dev.9",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
+3
View File
@@ -36,9 +36,12 @@ export { pickOpenRouterModel, type OpenRouterSelection } from './openrouter-pick
// Provider presets for CLI
export {
PROVIDER_PRESETS,
PRESET_ALIASES,
OPENROUTER_BASE_URL,
getPresetById,
getPresetAliases,
getPresetIds,
isValidPresetId,
type ProviderPreset,
type PresetCategory,
} from './provider-presets';
+23 -158
View File
@@ -2,174 +2,34 @@
* Provider Presets for CLI
*
* Pre-configured templates for common API providers.
* Mirrors the UI presets in ui/src/lib/provider-presets.ts
* Uses shared source-of-truth catalog in src/shared/provider-preset-catalog.ts.
*/
export type PresetCategory = 'recommended' | 'alternative';
import {
OPENROUTER_BASE_URL,
PROVIDER_PRESET_ALIASES,
createProviderPresetDefinitions,
normalizeProviderPresetId,
type PresetCategory,
type ProviderPresetDefinition,
} from '../../shared/provider-preset-catalog';
export interface ProviderPreset {
id: string;
name: string;
description: string;
baseUrl: string;
defaultProfileName: string;
defaultModel: string;
apiKeyPlaceholder: string;
apiKeyHint: string;
category: PresetCategory;
/** Whether API key is required (default: true, set false for local providers) */
requiresApiKey: boolean;
/** Additional env vars for thinking mode, etc. */
extraEnv?: Record<string, string>;
/** Enable always thinking mode */
alwaysThinkingEnabled?: boolean;
}
export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api';
export { OPENROUTER_BASE_URL };
export type { PresetCategory };
export type ProviderPreset = ProviderPresetDefinition;
/**
* Provider presets available via CLI and UI
*
* NOTE: Keep in sync with ui/src/lib/provider-presets.ts
*/
export const PROVIDER_PRESETS: ProviderPreset[] = [
// Recommended
{
id: 'openrouter',
name: 'OpenRouter',
description: '349+ models from OpenAI, Anthropic, Google, Meta',
baseUrl: OPENROUTER_BASE_URL,
defaultProfileName: 'openrouter',
defaultModel: 'anthropic/claude-opus-4.5',
apiKeyPlaceholder: 'sk-or-...',
apiKeyHint: 'Get your API key at openrouter.ai/keys',
category: 'recommended',
requiresApiKey: true,
},
{
id: 'ollama',
name: 'Ollama (Local)',
description: 'Local open-source models via Ollama (32K+ context)',
baseUrl: 'http://localhost:11434',
defaultProfileName: 'ollama',
defaultModel: 'qwen3-coder',
apiKeyPlaceholder: 'ollama',
apiKeyHint: 'Install Ollama from ollama.com - no API key needed for local',
category: 'recommended',
requiresApiKey: false,
},
// Alternative providers
{
id: 'glm',
name: 'GLM',
description: 'Claude via Z.AI',
baseUrl: 'https://api.z.ai/api/anthropic',
defaultProfileName: 'glm',
defaultModel: 'glm-5',
apiKeyPlaceholder: 'ghp_...',
apiKeyHint: 'Get your API key from Z.AI',
category: 'alternative',
requiresApiKey: true,
},
{
id: 'glmt',
name: 'GLMT',
description: 'GLM with Thinking mode support',
baseUrl: 'https://api.z.ai/api/coding/paas/v4/chat/completions',
defaultProfileName: 'glmt',
defaultModel: 'glm-5',
apiKeyPlaceholder: 'ghp_...',
apiKeyHint: 'Same API key as GLM',
category: 'alternative',
requiresApiKey: true,
extraEnv: {
ANTHROPIC_TEMPERATURE: '0.2',
ANTHROPIC_MAX_TOKENS: '65536',
MAX_THINKING_TOKENS: '32768',
ENABLE_STREAMING: 'true',
ANTHROPIC_SAFE_MODE: 'false',
API_TIMEOUT_MS: '3000000',
},
alwaysThinkingEnabled: true,
},
{
id: 'km',
name: 'Kimi',
description: 'Moonshot AI - Fast reasoning model',
baseUrl: 'https://api.kimi.com/coding/',
defaultProfileName: 'km',
defaultModel: 'kimi-k2-thinking-turbo',
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key from Moonshot AI',
category: 'alternative',
requiresApiKey: true,
alwaysThinkingEnabled: true,
},
{
id: 'foundry',
name: 'Azure Foundry',
description: 'Claude via Microsoft Azure AI Foundry',
baseUrl: 'https://<your-resource>.services.ai.azure.com/api/anthropic',
defaultProfileName: 'foundry',
defaultModel: 'claude-sonnet-4-5',
apiKeyPlaceholder: 'YOUR_AZURE_API_KEY',
apiKeyHint: 'Create resource at ai.azure.com, get API key from Keys tab',
category: 'alternative',
requiresApiKey: true,
},
{
id: 'mm',
name: 'Minimax',
description: 'M2.1/M2.1-lightning/M2 - multilang coding (1M context)',
baseUrl: 'https://api.minimax.io/anthropic',
defaultProfileName: 'mm',
defaultModel: 'MiniMax-M2.1',
apiKeyPlaceholder: 'YOUR_MINIMAX_API_KEY_HERE',
apiKeyHint: 'Get your API key at platform.minimax.io',
category: 'alternative',
requiresApiKey: true,
},
{
id: 'deepseek',
name: 'DeepSeek',
description: 'V3.2 and R1 reasoning model (128K context)',
baseUrl: 'https://api.deepseek.com/anthropic',
defaultProfileName: 'deepseek',
defaultModel: 'deepseek-chat',
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key at platform.deepseek.com',
category: 'alternative',
requiresApiKey: true,
},
{
id: 'qwen',
name: 'Qwen',
description: 'Alibaba Cloud - Qwen3 models (256K-1M context, thinking support)',
baseUrl: 'https://dashscope-intl.aliyuncs.com/apps/anthropic',
defaultProfileName: 'qwen',
defaultModel: 'qwen3-coder-plus',
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key from Alibaba Cloud Model Studio',
category: 'alternative',
requiresApiKey: true,
},
{
id: 'ollama-cloud',
name: 'Ollama Cloud',
description: 'Ollama cloud models via direct API (glm-5:cloud, minimax-m2.1:cloud)',
baseUrl: 'https://ollama.com',
defaultProfileName: 'ollama-cloud',
defaultModel: 'glm-5:cloud',
apiKeyPlaceholder: 'YOUR_OLLAMA_CLOUD_API_KEY',
apiKeyHint: 'Get your API key at ollama.com',
category: 'alternative',
requiresApiKey: true,
},
];
export const PROVIDER_PRESETS: readonly ProviderPreset[] = Object.freeze(
createProviderPresetDefinitions()
);
export const PRESET_ALIASES: Readonly<Record<string, string>> = PROVIDER_PRESET_ALIASES;
/** Get preset by ID */
export function getPresetById(id: string): ProviderPreset | undefined {
return PROVIDER_PRESETS.find((p) => p.id === id.toLowerCase());
const canonical = normalizeProviderPresetId(id);
return PROVIDER_PRESETS.find((p) => p.id === canonical);
}
/** Get all preset IDs */
@@ -177,6 +37,11 @@ export function getPresetIds(): string[] {
return PROVIDER_PRESETS.map((p) => p.id);
}
/** Get alias map (alias -> canonical preset ID). */
export function getPresetAliases(): Readonly<Record<string, string>> {
return PRESET_ALIASES;
}
/** Check if preset ID is valid */
export function isValidPresetId(id: string): boolean {
return getPresetById(id) !== undefined;
+4 -3
View File
@@ -7,7 +7,7 @@
import { spawn, ChildProcess } from 'child_process';
import { initUI, header, color, fail, warn, info, infoBox, warnBox } from '../../utils/ui';
import { getClaudeCliInfo } from '../../utils/claude-detector';
import { escapeShellArg } from '../../utils/shell-executor';
import { escapeShellArg, stripClaudeCodeEnv } from '../../utils/shell-executor';
import { isUnifiedMode } from '../../config/unified-config-loader';
import { exitWithError } from '../../errors';
import { ExitCode } from '../../errors/exit-codes';
@@ -82,6 +82,7 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
}
const { path: claudeCli, needsShell } = claudeInfo;
const childEnv = stripClaudeCodeEnv({ ...process.env, CLAUDE_CONFIG_DIR: instancePath });
// Execute Claude in isolated instance (will auto-prompt for login if no credentials)
// On Windows, .cmd/.bat/.ps1 files need shell: true to execute properly
@@ -92,13 +93,13 @@ export async function handleCreate(ctx: CommandContext, args: string[]): Promise
stdio: 'inherit',
windowsHide: true,
shell: true,
env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath },
env: childEnv,
});
} else {
child = spawn(claudeCli, [], {
stdio: 'inherit',
windowsHide: true,
env: { ...process.env, CLAUDE_CONFIG_DIR: instancePath },
env: childEnv,
});
}
+34 -13
View File
@@ -2,7 +2,7 @@
* Profile Detector
*
* Determines profile type (settings-based vs account-based) for routing.
* Priority: settings-based profiles (glm/kimi) checked FIRST for backward compatibility.
* Priority: settings-based profiles (glm/km) checked FIRST for backward compatibility.
*
* Supports dual-mode configuration:
* - Unified YAML format (config.yaml) when CCS_UNIFIED_CONFIG=1 or config.yaml exists
@@ -22,6 +22,7 @@ import {
} from '../config/unified-config-types';
import { loadUnifiedConfig, isUnifiedMode } from '../config/unified-config-loader';
import { getCcsDir } from '../utils/config-manager';
import { getProfileLookupCandidates, isLegacyProfileAlias } from '../utils/profile-compat';
import type { CLIProxyProvider } from '../cliproxy/types';
import { CLIPROXY_PROVIDER_IDS, isCLIProxyProvider } from '../cliproxy/provider-capabilities';
import type { TargetType } from '../targets/target-adapter';
@@ -166,16 +167,26 @@ class ProfileDetector {
};
}
// Check API profiles
if (config.profiles?.[profileName]) {
const profile = config.profiles[profileName];
// Load env from settings file
const settingsEnv = loadSettingsFromFile(profile.settings);
// Check API profiles (supports compatibility aliases, e.g. km -> kimi)
for (const candidate of getProfileLookupCandidates(profileName)) {
if (!config.profiles?.[candidate]) {
continue;
}
const profile = config.profiles[candidate];
const settingsPath = profile.settings;
const settingsEnv = loadSettingsFromFile(settingsPath);
const viaLegacyAlias = isLegacyProfileAlias(profileName, candidate);
return {
type: 'settings',
name: profileName,
target: profile.target,
settingsPath,
env: settingsEnv,
message: viaLegacyAlias
? `Using legacy API profile "${candidate}" for "${profileName}".`
: undefined,
};
}
@@ -308,13 +319,23 @@ class ProfileDetector {
};
}
// Priority 3: Check settings-based profiles (glm) - LEGACY FALLBACK
if (config.profiles && config.profiles[profileName]) {
return {
type: 'settings',
name: profileName,
settingsPath: config.profiles[profileName],
};
// Priority 3: Check settings-based profiles (glm, km) - LEGACY FALLBACK
if (config.profiles) {
for (const candidate of getProfileLookupCandidates(profileName)) {
if (!config.profiles[candidate]) {
continue;
}
const viaLegacyAlias = isLegacyProfileAlias(profileName, candidate);
return {
type: 'settings',
name: profileName,
settingsPath: config.profiles[candidate],
message: viaLegacyAlias
? `Using legacy API profile "${candidate}" for "${profileName}".`
: undefined,
};
}
}
// Priority 4: Check account-based profiles (work, personal) - LEGACY FALLBACK
+10 -6
View File
@@ -9,6 +9,7 @@ import {
setGlobalConfigDir,
detectCloudSyncPath,
} from './utils/config-manager';
import { expandPath } from './utils/helpers';
import { validateGlmKey, validateMiniMaxKey } from './utils/api-key-validator';
import { ErrorManager } from './utils/error-manager';
import { execClaudeWithCLIProxy, CLIProxyProvider } from './cliproxy';
@@ -36,7 +37,7 @@ import { handleShellCompletionCommand } from './commands/shell-completion-comman
import { handleUpdateCommand } from './commands/update-command';
// Import extracted utility functions
import { execClaude, escapeShellArg } from './utils/shell-executor';
import { execClaude, escapeShellArg, stripClaudeCodeEnv } from './utils/shell-executor';
import { wireChildProcessSignals } from './utils/signal-forwarder';
// Import target adapter system
@@ -196,13 +197,13 @@ async function execClaudeWithProxy(
const needsShell = isWindows && /\.(cmd|bat)$/i.test(claudeCli);
const webSearchEnv = getWebSearchHookEnv();
const imageAnalysisEnv = getImageAnalysisHookEnv(profileName);
const env = {
const env = stripClaudeCodeEnv({
...process.env,
...envVars,
...webSearchEnv,
...imageAnalysisEnv,
CCS_PROFILE_TYPE: 'settings', // Signal to WebSearch hook this is a third-party provider
};
});
let claude: ChildProcess;
if (isPowerShellScript) {
@@ -596,6 +597,7 @@ async function main(): Promise<void> {
'auth',
'status',
'models',
'usage',
'start',
'stop',
'enable',
@@ -689,7 +691,7 @@ async function main(): Promise<void> {
if (profileInfo.type === 'settings' && profileInfo.name === 'glmt') {
console.error(fail(`${targetAdapter.displayName} does not support GLMT proxy profiles`));
console.error(
info('Use --target claude for glmt, or switch to a direct API profile (glm/kimi)')
info('Use --target claude for glmt, or switch to a direct API profile (glm/km)')
);
process.exit(1);
}
@@ -856,7 +858,7 @@ async function main(): Promise<void> {
fail(`${targetAdapter?.displayName || 'Target'} does not support GLMT proxy profiles`)
);
console.error(
info('Use --target claude for glmt, or switch to a direct API profile (glm/kimi)')
info('Use --target claude for glmt, or switch to a direct API profile (glm/km)')
);
process.exit(1);
}
@@ -865,7 +867,9 @@ async function main(): Promise<void> {
} else {
// EXISTING FLOW: Settings-based profile (glm)
// Use --settings flag (backward compatible)
const expandedSettingsPath = getSettingsPath(profileInfo.name);
const expandedSettingsPath = profileInfo.settingsPath
? expandPath(profileInfo.settingsPath)
: getSettingsPath(profileInfo.name);
const webSearchEnv = getWebSearchHookEnv();
const imageAnalysisEnv = getImageAnalysisHookEnv(profileInfo.name);
// Get global env vars (DISABLE_TELEMETRY, etc.) for third-party profiles
+66
View File
@@ -16,8 +16,13 @@ import { CLIProxyProvider } from './types';
import { loadAccountsRegistry, pauseAccount, resumeAccount } from './accounts/registry';
import { getCcsDir } from '../utils/config-manager';
const ISSUE_509_URL = 'https://github.com/kaitranntt/ccs/issues/509';
/** Providers that use Google OAuth (ban risk when overlapping) */
const GOOGLE_OAUTH_PROVIDERS: CLIProxyProvider[] = ['gemini', 'agy', 'codex'];
/** Providers that should display direct CLI warnings for #509 */
const BAN_WARNING_PROVIDERS: CLIProxyProvider[] = ['gemini', 'agy'];
const shownBanWarnings = new Set<CLIProxyProvider>();
// --- Auto-pause persistence (crash recovery) ---
@@ -161,6 +166,10 @@ export function warnCrossProviderDuplicates(provider: CLIProxyProvider): boolean
console.error('');
console.error(warn('Account safety: cross-provider duplicate detected'));
console.error(' Same Google account across providers risks account bans (ref: #509).');
console.error(
' If provider requests start returning 403/Forbidden, treat it as a possible ban.'
);
console.error(` Details: ${ISSUE_509_URL}`);
console.error('');
for (const [email, providers] of duplicates) {
@@ -188,10 +197,67 @@ export function warnNewAccountConflict(
` ${maskEmail(email)} is also registered under: ${conflictingProviders.join(', ')}`
);
console.error(' Concurrent usage may cause Google to ban your account.');
console.error(' 403/Forbidden responses can be an early sign of account disablement.');
console.error(' Consider pausing the duplicate or using a different account.');
console.error(` Details: ${ISSUE_509_URL}`);
console.error('');
}
function isBanWarningProvider(provider: CLIProxyProvider): boolean {
return BAN_WARNING_PROVIDERS.includes(provider);
}
/**
* Show one-time warning for known OAuth ban risk providers.
*/
export function warnOAuthBanRisk(provider: CLIProxyProvider): void {
if (!isBanWarningProvider(provider) || shownBanWarnings.has(provider)) return;
shownBanWarnings.add(provider);
console.error('');
console.error(warn('Account safety warning (#509)'));
console.error(
' Using the same Google account in both "ccs gemini" and "ccs agy" can trigger suspension.'
);
console.error(
' If you see 403/Forbidden during provider calls, treat it as likely account disable/ban.'
);
console.error(
' Use separate Google accounts per provider and stop retrying blocked accounts.'
);
console.error(` Details: ${ISSUE_509_URL}`);
console.error('');
}
/**
* Detect whether an error message contains a likely 403/Forbidden ban signal.
*/
export function isPossible403BanSignal(errorMessage: string): boolean {
const lower = errorMessage.toLowerCase();
return lower.includes('403') || lower.includes('forbidden');
}
/**
* Show targeted warning when OAuth provider errors include 403/Forbidden.
* Returns true when warning was emitted.
*/
export function warnPossible403Ban(provider: CLIProxyProvider, errorMessage: string): boolean {
if (!isBanWarningProvider(provider) || !isPossible403BanSignal(errorMessage)) {
return false;
}
console.error('');
console.error(warn(`Account safety: ${provider} returned 403/Forbidden`));
console.error(
' For gemini/agy flows this often means the Google account was blocked/disabled.'
);
console.error(' Stop retries for this account and switch to a different account/provider.');
console.error(` Details: ${ISSUE_509_URL}`);
console.error(` Error: "${truncate(errorMessage, 160)}"`);
console.error('');
return true;
}
// --- Enforcement: auto-pause/restore ---
/**
+33 -56
View File
@@ -5,7 +5,16 @@
*/
import { CLIProxyProvider } from '../types';
import { AccountInfo } from '../account-manager';
import type { AccountInfo } from '../account-manager';
import {
buildProviderMap,
CLIPROXY_PROVIDER_IDS,
getOAuthCallbackPort,
getCLIProxyCallbackProviderName,
getCLIProxyAuthUrlProviderName,
getProviderAuthFilePrefixes,
getProviderTokenTypeValues,
} from '../provider-capabilities';
/**
* Kiro authentication methods supported by CLIProxyAPIPlus.
@@ -90,17 +99,17 @@ export function toKiroManagementMethod(method: KiroAuthMethod): 'aws' | 'google'
* - GHCP: Device Code Flow (polling-based, NO callback port needed)
* - Kimi: Device Code Flow (polling-based, NO callback port needed)
*/
export const OAUTH_CALLBACK_PORTS: Partial<Record<CLIProxyProvider, number>> = {
gemini: 8085,
codex: 1455,
agy: 51121,
iflow: 11451,
claude: 54545,
// kiro: Device Code Flow - no callback port
// qwen: Device Code Flow - no callback port
// ghcp: Device Code Flow - no callback port
// kimi: Device Code Flow - no callback port
};
export const OAUTH_CALLBACK_PORTS: Partial<Record<CLIProxyProvider, number>> =
CLIPROXY_PROVIDER_IDS.reduce(
(acc, provider) => {
const callbackPort = getOAuthCallbackPort(provider);
if (callbackPort !== null) {
acc[provider] = callbackPort;
}
return acc;
},
{} as Partial<Record<CLIProxyProvider, number>>
);
/**
* Auth status for a provider
@@ -215,66 +224,34 @@ export const OAUTH_CONFIGS: Record<CLIProxyProvider, ProviderOAuthConfig> = {
* CLIProxyAPI names auth files with provider prefix (e.g., "antigravity-user@email.json")
* Note: Gemini tokens may NOT have prefix - CLIProxyAPI uses {email}-{projectID}.json format
*/
export const PROVIDER_AUTH_PREFIXES: Record<CLIProxyProvider, string[]> = {
gemini: ['gemini-', 'google-'],
codex: ['codex-', 'openai-'],
agy: ['antigravity-', 'agy-'],
qwen: ['qwen-'],
iflow: ['iflow-'],
kiro: ['kiro-', 'aws-', 'codewhisperer-'],
ghcp: ['github-copilot-', 'copilot-', 'gh-'],
claude: ['claude-', 'anthropic-'],
kimi: ['kimi-'],
};
export const PROVIDER_AUTH_PREFIXES: Record<CLIProxyProvider, string[]> = buildProviderMap(
(provider) => [...getProviderAuthFilePrefixes(provider)]
);
/**
* Provider type values inside token JSON files
* CLIProxyAPI sets "type" field in token JSON (e.g., {"type": "gemini"})
*/
export const PROVIDER_TYPE_VALUES: Record<CLIProxyProvider, string[]> = {
gemini: ['gemini'],
codex: ['codex'],
agy: ['antigravity'],
qwen: ['qwen'],
iflow: ['iflow'],
kiro: ['kiro', 'codewhisperer'],
ghcp: ['github-copilot', 'copilot'],
claude: ['claude', 'anthropic'],
kimi: ['kimi'],
};
export const PROVIDER_TYPE_VALUES: Record<CLIProxyProvider, string[]> = buildProviderMap(
(provider) => [...getProviderTokenTypeValues(provider)]
);
/**
* Maps CCS provider names to CLIProxyAPI callback provider names
* Used when submitting OAuth callbacks to CLIProxyAPI management endpoint
*/
export const CLIPROXY_CALLBACK_PROVIDER_MAP: Record<CLIProxyProvider, string> = {
gemini: 'gemini',
codex: 'codex',
agy: 'antigravity',
kiro: 'kiro',
ghcp: 'copilot',
claude: 'anthropic',
qwen: 'qwen',
iflow: 'iflow',
kimi: 'kimi',
};
export const CLIPROXY_CALLBACK_PROVIDER_MAP: Record<CLIProxyProvider, string> = buildProviderMap(
(provider) => getCLIProxyCallbackProviderName(provider)
);
/**
* Maps CCS provider names to CLIProxyAPI auth-url endpoint prefixes.
* Used for GET /v0/management/${prefix}-auth-url endpoints.
* These differ from callback names for some providers (e.g., gemini-cli vs gemini).
*/
export const CLIPROXY_AUTH_URL_PROVIDER_MAP: Record<CLIProxyProvider, string> = {
gemini: 'gemini-cli',
codex: 'codex',
agy: 'antigravity',
kiro: 'kiro',
ghcp: 'github',
claude: 'anthropic',
qwen: 'qwen',
iflow: 'iflow',
kimi: 'kimi',
};
export const CLIPROXY_AUTH_URL_PROVIDER_MAP: Record<CLIProxyProvider, string> = buildProviderMap(
(provider) => getCLIProxyAuthUrlProviderName(provider)
);
/**
* Get OAuth config for provider
+55 -33
View File
@@ -108,42 +108,53 @@ function isValidCliproxyToken(data: unknown): data is CliproxyGeminiToken {
* Read Gemini token from CLIProxy auth directory
* Returns credentials with source path, or null if no valid token found
*/
function readCliproxyGeminiCreds(): GeminiCredsWithSource | null {
function readCliproxyGeminiCreds(accountId?: string): GeminiCredsWithSource | null {
const authDir = getProviderAuthDir('gemini');
if (!fs.existsSync(authDir)) return null;
// Try to find default account's token file
const defaultAccount = getDefaultAccount('gemini');
let tokenPath: string | null = null;
const normalizedAccountId = accountId?.trim();
const accounts = getProviderAccounts('gemini');
if (defaultAccount) {
tokenPath = path.join(authDir, defaultAccount.tokenFile);
if (!fs.existsSync(tokenPath)) tokenPath = null;
// Account-specific refresh path (used by background worker)
if (normalizedAccountId) {
const targetAccount = accounts.find((account) => account.id === normalizedAccountId);
if (!targetAccount) {
return null;
}
tokenPath = path.join(authDir, targetAccount.tokenFile);
}
// Fallback: find any gemini token file by prefix or type
if (!tokenPath) {
const accounts = getProviderAccounts('gemini');
if (accounts.length > 0) {
if (!normalizedAccountId) {
// Try to find default account's token file
const defaultAccount = getDefaultAccount('gemini');
if (defaultAccount) {
tokenPath = path.join(authDir, defaultAccount.tokenFile);
if (!fs.existsSync(tokenPath)) tokenPath = null;
}
// Fallback: find any gemini account token file
if (!tokenPath && accounts.length > 0) {
tokenPath = path.join(authDir, accounts[0].tokenFile);
if (!fs.existsSync(tokenPath)) tokenPath = null;
}
}
// Last fallback: scan directory for gemini token files
if (!tokenPath) {
try {
const files = fs.readdirSync(authDir).filter((f) => f.endsWith('.json'));
for (const file of files) {
const filePath = path.join(authDir, file);
if (file.startsWith('gemini-') || isTokenFileForProvider(filePath, 'gemini')) {
tokenPath = filePath;
break;
// Last fallback: scan directory for gemini token files
if (!tokenPath) {
try {
const files = fs.readdirSync(authDir).filter((f) => f.endsWith('.json'));
for (const file of files) {
const filePath = path.join(authDir, file);
if (file.startsWith('gemini-') || isTokenFileForProvider(filePath, 'gemini')) {
tokenPath = filePath;
break;
}
}
} catch {
// Directory read failed - continue to return null
return null;
}
} catch {
// Directory read failed - continue to return null
return null;
}
}
@@ -172,13 +183,19 @@ function readCliproxyGeminiCreds(): GeminiCredsWithSource | null {
* Priority: CLIProxy auth dir first, then ~/.gemini/oauth_creds.json
* Returns credentials with source path for correct write-back
*/
function readGeminiCreds(): GeminiCredsWithSource | null {
function readGeminiCreds(accountId?: string): GeminiCredsWithSource | null {
// 1. Try CLIProxy auth directory first (CCS-managed tokens)
const cliproxyResult = readCliproxyGeminiCreds();
const cliproxyResult = readCliproxyGeminiCreds(accountId);
if (cliproxyResult) {
return cliproxyResult;
}
// Account-scoped refresh is only supported for CLIProxy account files.
// Do not fall back to ~/.gemini for a specific accountId.
if (accountId?.trim()) {
return null;
}
// 2. Fall back to standard Gemini CLI location
const oauthPath = getGeminiOAuthPath();
if (!fs.existsSync(oauthPath)) {
@@ -249,8 +266,8 @@ function writeGeminiCreds(creds: GeminiOAuthCreds, sourcePath: string): string |
/**
* Check if Gemini token is expired or expiring soon
*/
export function isGeminiTokenExpiringSoon(): boolean {
const result = readGeminiCreds();
export function isGeminiTokenExpiringSoon(accountId?: string): boolean {
const result = readGeminiCreds(accountId);
if (!result || !result.creds.access_token) {
return true; // No token = needs auth
}
@@ -263,14 +280,15 @@ export function isGeminiTokenExpiringSoon(): boolean {
/**
* Refresh Gemini access token using refresh_token
* @param accountId Optional account ID for account-scoped refresh
* @returns Result with success status, optional error, and expiry time
*/
export async function refreshGeminiToken(): Promise<{
export async function refreshGeminiToken(accountId?: string): Promise<{
success: boolean;
error?: string;
expiresAt?: number;
}> {
const result = readGeminiCreds();
const result = readGeminiCreds(accountId);
if (!result || !result.creds.refresh_token) {
return { success: false, error: 'No refresh token available' };
}
@@ -334,19 +352,23 @@ export async function refreshGeminiToken(): Promise<{
/**
* Ensure Gemini token is valid, refreshing if needed
* @param verbose Log progress if true
* @param accountId Optional account ID for account-scoped refresh
* @returns true if token is valid (or was refreshed), false if refresh failed
*/
export async function ensureGeminiTokenValid(verbose = false): Promise<{
export async function ensureGeminiTokenValid(
verbose = false,
accountId?: string
): Promise<{
valid: boolean;
refreshed: boolean;
error?: string;
}> {
const result = readGeminiCreds();
const result = readGeminiCreds(accountId);
if (!result || !result.creds.access_token) {
return { valid: false, refreshed: false, error: 'No Gemini credentials found' };
}
if (!isGeminiTokenExpiringSoon()) {
if (!isGeminiTokenExpiringSoon(accountId)) {
return { valid: true, refreshed: false };
}
@@ -355,7 +377,7 @@ export async function ensureGeminiTokenValid(verbose = false): Promise<{
console.log('[i] Gemini token expired or expiring soon, refreshing...');
}
const refreshResult = await refreshGeminiToken();
const refreshResult = await refreshGeminiToken(accountId);
if (refreshResult.success) {
if (verbose) {
console.log('[OK] Gemini token refreshed successfully');
+13 -2
View File
@@ -44,7 +44,12 @@ import { getProviderTokenDir, isAuthenticated, registerAccountFromToken } from '
import { executeOAuthProcess } from './oauth-process';
import { importKiroToken } from './kiro-import';
import { getProxyTarget, buildProxyUrl, buildManagementHeaders } from '../proxy-target-resolver';
import { checkNewAccountConflict, warnNewAccountConflict } from '../account-safety';
import {
checkNewAccountConflict,
warnNewAccountConflict,
warnOAuthBanRisk,
warnPossible403Ban,
} from '../account-safety';
/**
* Prompt user to add another account
@@ -278,7 +283,9 @@ async function handlePasteCallbackMode(
});
if (!startResponse.ok) {
const startError = `OAuth start failed with status ${startResponse.status}`;
console.log(fail('Failed to start OAuth flow'));
warnPossible403Ban(provider, startError);
return null;
}
@@ -380,7 +387,10 @@ async function handlePasteCallbackMode(
};
if (!callbackResponse.ok || callbackData.status === 'error') {
console.log(fail(callbackData.error || 'OAuth callback failed'));
const callbackError =
callbackData.error || `OAuth callback failed with status ${callbackResponse.status}`;
console.log(fail(callbackError));
warnPossible403Ban(provider, callbackError);
return null;
}
@@ -417,6 +427,7 @@ export async function triggerOAuth(
options: OAuthOptions = {}
): Promise<AccountInfo | null> {
const oauthConfig = getOAuthConfig(provider);
warnOAuthBanRisk(provider);
const { verbose = false, add = false, fromUI = false, noIncognito = true } = options;
let { nickname } = options;
const resolvedKiroMethod =
+41 -36
View File
@@ -11,6 +11,11 @@
*/
import { CLIProxyProvider } from '../../types';
import { getProviderAccounts } from '../../account-manager';
import {
getTokenRefreshOwnership,
isRefreshDelegatedToCLIProxy,
} from '../../provider-capabilities';
import { refreshGeminiToken } from '../gemini-token-refresh';
/** Token refresh result */
@@ -22,64 +27,64 @@ export interface ProviderRefreshResult {
delegated?: boolean;
}
/**
* Providers where CLIProxyAPIPlus owns token refresh.
* CLIProxyAPIPlus runs background refresh automatically (e.g. kiro: every 1 min).
* CCS should not attempt to refresh these — just trust CLIProxy.
*/
const CLIPROXY_DELEGATED_REFRESH: CLIProxyProvider[] = [
'codex',
'agy',
'kiro',
'ghcp',
'qwen',
'iflow',
'kimi',
];
function assertNever(value: never): never {
throw new Error(`Unhandled token refresh ownership: ${String(value)}`);
}
/**
* Check if a provider's token refresh is delegated to CLIProxy
*/
export function isRefreshDelegated(provider: CLIProxyProvider): boolean {
return CLIPROXY_DELEGATED_REFRESH.includes(provider);
return isRefreshDelegatedToCLIProxy(provider);
}
/**
* Refresh token for a specific provider and account
* @param provider Provider to refresh
* @param _accountId Account ID (currently unused, multi-account not yet implemented)
* @param accountId Account ID used to refresh the correct provider token
* @returns Refresh result with success status and optional error
*/
export async function refreshToken(
provider: CLIProxyProvider,
_accountId: string
accountId: string
): Promise<ProviderRefreshResult> {
switch (provider) {
case 'gemini':
return await refreshGeminiTokenWrapper();
const normalizedAccountId = accountId.trim();
if (!normalizedAccountId) {
return {
success: false,
error: 'Account ID is required for token refresh',
};
}
case 'codex':
case 'agy':
case 'qwen':
case 'iflow':
case 'kiro':
case 'ghcp':
case 'kimi':
const hasAccount = getProviderAccounts(provider).some(
(account) => account.id === normalizedAccountId
);
if (!hasAccount) {
return {
success: false,
error: `Account not found for ${provider}: ${normalizedAccountId}`,
};
}
if (provider === 'gemini') {
return await refreshGeminiTokenWrapper(normalizedAccountId);
}
const ownership = getTokenRefreshOwnership(provider);
switch (ownership) {
case 'cliproxy':
// CLIProxyAPIPlus handles refresh for these providers automatically.
// No action needed from CCS — report success with delegated flag.
return { success: true, delegated: true };
case 'claude':
case 'unsupported':
case 'ccs':
// Non-gemini CCS-owned refresh paths are not implemented yet.
return {
success: false,
error: `Token refresh not yet implemented for ${provider}`,
};
default:
return {
success: false,
error: `Unknown provider: ${provider}`,
};
return assertNever(ownership);
}
}
@@ -87,8 +92,8 @@ export async function refreshToken(
* Wrapper for Gemini token refresh
* Converts gemini-token-refresh.ts format to provider-refreshers format
*/
async function refreshGeminiTokenWrapper(): Promise<ProviderRefreshResult> {
const result = await refreshGeminiToken();
async function refreshGeminiTokenWrapper(accountId: string): Promise<ProviderRefreshResult> {
const result = await refreshGeminiToken(accountId);
if (!result.success) {
return {
+2 -1
View File
@@ -23,6 +23,7 @@ const CHANNEL_TO_PROVIDER: Record<string, CLIProxyProvider> = {
codex: 'codex',
qwen: 'qwen',
iflow: 'iflow',
kimi: 'kimi',
};
/** CCS provider → channel name mapping (reverse) */
@@ -31,7 +32,7 @@ export const PROVIDER_TO_CHANNEL: Record<string, string> = Object.fromEntries(
);
/** Providers to sync from CLIProxyAPI */
export const SYNCABLE_PROVIDERS: CLIProxyProvider[] = ['agy', 'gemini', 'codex', 'claude'];
export const SYNCABLE_PROVIDERS: CLIProxyProvider[] = ['agy', 'gemini', 'codex', 'claude', 'kimi'];
function getCacheFilePath(): string {
return path.join(getCcsDir(), CACHE_FILE_NAME);
+70 -10
View File
@@ -25,6 +25,14 @@ export interface CodexReasoningProxyConfig {
* Example: '/api/provider/codex' will transform '/api/provider/codex/v1/messages' to '/v1/messages'
*/
stripPathPrefix?: string;
/** When true, skip reasoning effort injection entirely (thinking mode: off) */
disableEffort?: boolean;
}
const EXTENDED_CONTEXT_SUFFIX_REGEX = /\[1m\]$/i;
function stripExtendedContextSuffix(model: string): string {
return model.replace(EXTENDED_CONTEXT_SUFFIX_REGEX, '').trim();
}
function isNonEmptyString(value: unknown): value is string {
@@ -38,14 +46,26 @@ function isRecord(value: unknown): value is Record<string, unknown> {
function parseModelEffortSuffix(
model: string
): { upstreamModel: string; effort: CodexReasoningEffort } | null {
const match = model.match(/^(.*)-(xhigh|high|medium)$/);
const normalizedModel = stripExtendedContextSuffix(model);
const match = normalizedModel.match(/^(.*)-(xhigh|high|medium)$/i);
if (!match) return null;
const upstreamModel = match[1]?.trim();
const effort = match[2] as CodexReasoningEffort;
const effort = match[2]?.toLowerCase() as CodexReasoningEffort;
if (!upstreamModel) return null;
return { upstreamModel, effort };
}
function isKnownCodexModelId(
model: string,
modelEffort: Map<string, CodexReasoningEffort>
): boolean {
if (modelEffort.has(model)) return true;
if (EFFORT_BY_RANK.some((effort) => modelEffort.has(`${model}-${effort}`))) {
return true;
}
return getModelMaxLevel('codex', model) !== undefined;
}
const EFFORT_RANK: Record<CodexReasoningEffort, number> = {
medium: 1,
high: 2,
@@ -86,8 +106,10 @@ export function buildCodexModelEffortMap(
const upsertMin = (model: string | undefined, effort: CodexReasoningEffort) => {
if (!isNonEmptyString(model)) return;
const existing = map.get(model);
map.set(model, existing ? minEffort(existing, effort) : effort);
const normalizedModel = stripExtendedContextSuffix(model);
if (!normalizedModel) return;
const existing = map.get(normalizedModel);
map.set(normalizedModel, existing ? minEffort(existing, effort) : effort);
};
upsertMin(models.defaultModel, 'xhigh');
@@ -108,9 +130,10 @@ export function getEffortForModel(
defaultEffort: CodexReasoningEffort
): CodexReasoningEffort {
if (!model) return defaultEffort;
const effort = modelEffort.get(model) ?? defaultEffort;
const normalizedModel = stripExtendedContextSuffix(model);
const effort = modelEffort.get(normalizedModel) ?? defaultEffort;
// Apply model-specific cap from catalog
return capEffortAtModelMax(model, effort);
return capEffortAtModelMax(normalizedModel, effort);
}
export function injectReasoningEffortIntoBody(
@@ -137,7 +160,12 @@ export class CodexReasoningProxy {
private readonly config: Required<
Pick<
CodexReasoningProxyConfig,
'upstreamBaseUrl' | 'verbose' | 'timeoutMs' | 'defaultEffort' | 'traceFilePath'
| 'upstreamBaseUrl'
| 'verbose'
| 'timeoutMs'
| 'defaultEffort'
| 'traceFilePath'
| 'disableEffort'
>
> &
Pick<CodexReasoningProxyConfig, 'modelMap' | 'stripPathPrefix'>;
@@ -160,10 +188,27 @@ export class CodexReasoningProxy {
defaultEffort: config.defaultEffort ?? 'medium',
traceFilePath: config.traceFilePath ?? '',
stripPathPrefix: config.stripPathPrefix,
disableEffort: config.disableEffort ?? false,
};
this.modelEffort = buildCodexModelEffortMap(this.config.modelMap, this.config.defaultEffort);
}
/**
* Treat trailing "-high/-medium/-xhigh" as an effort alias only for known codex models.
* Prevents stripping legitimate upstream model IDs that happen to end with those tokens.
*/
private parseEffortAlias(
model: string | null
): { upstreamModel: string; effort: CodexReasoningEffort } | null {
if (!model) return null;
const parsed = parseModelEffortSuffix(model);
if (!parsed) return null;
if (!isKnownCodexModelId(parsed.upstreamModel, this.modelEffort)) {
return null;
}
return parsed;
}
private log(message: string): void {
if (this.config.verbose) {
console.error(`[codex-reasoning-proxy] ${message}`);
@@ -316,17 +361,32 @@ export class CodexReasoningProxy {
const originalModel =
isRecord(parsed) && typeof parsed.model === 'string' ? parsed.model : null;
const normalizedRequestModel = originalModel
? stripExtendedContextSuffix(originalModel)
: null;
// When effort is disabled (thinking mode: off), strip model suffix but don't inject reasoning
if (this.config.disableEffort) {
const suffixParsed = this.parseEffortAlias(normalizedRequestModel);
const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
const forwarded =
upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed;
this.log(`[disabled] model=${originalModel ?? 'null'} -> passthrough (no reasoning)`);
await this.forwardJson(req, res, fullUpstreamUrl, forwarded);
return;
}
// Support "model aliases" like `gpt-5.2-codex-xhigh` by translating to:
// - upstream model: `gpt-5.2-codex`
// - reasoning.effort: `xhigh`
//
// This allows tier→effort mapping without inventing upstream model IDs.
const suffixParsed = originalModel ? parseModelEffortSuffix(originalModel) : null;
const upstreamModel = suffixParsed?.upstreamModel ?? originalModel;
const suffixParsed = this.parseEffortAlias(normalizedRequestModel);
const upstreamModel = suffixParsed?.upstreamModel ?? normalizedRequestModel;
const effort =
suffixParsed?.effort ??
getEffortForModel(originalModel, this.modelEffort, this.config.defaultEffort);
getEffortForModel(normalizedRequestModel, this.modelEffort, this.config.defaultEffort);
const withUpstreamModel =
upstreamModel && isRecord(parsed) ? { ...parsed, model: upstreamModel } : parsed;
+46
View File
@@ -164,9 +164,55 @@ function ensureRequiredEnvVars(
result.ANTHROPIC_AUTH_TOKEN = defaults.ANTHROPIC_AUTH_TOKEN;
}
// Normalize local CLIProxy root/wrong-provider URLs to provider-pinned endpoint.
// This prevents model-routed "unknown provider" failures for codex effort aliases.
if (result.ANTHROPIC_BASE_URL?.trim()) {
result.ANTHROPIC_BASE_URL = normalizeLocalProviderBaseUrl(
result.ANTHROPIC_BASE_URL,
provider,
validPort
);
}
return result;
}
/** Localhost hostnames used for local CLIProxy endpoints */
const LOCALHOST_NAMES = new Set(['127.0.0.1', 'localhost', '0.0.0.0']);
/**
* Normalize local CLIProxy endpoint to the expected provider route.
* Only rewrites localhost URLs that target the active local port.
*/
function normalizeLocalProviderBaseUrl(
baseUrl: string,
provider: CLIProxyProvider,
port: number
): string {
try {
const parsed = new URL(baseUrl);
if (!['http:', 'https:'].includes(parsed.protocol)) return baseUrl;
if (!LOCALHOST_NAMES.has(parsed.hostname.toLowerCase())) return baseUrl;
const effectivePort = parsed.port
? Number.parseInt(parsed.port, 10)
: parsed.protocol === 'https:'
? 443
: 80;
if (!Number.isFinite(effectivePort) || effectivePort !== port) return baseUrl;
const expectedPath = `/api/provider/${provider}`;
if (parsed.pathname === expectedPath && !parsed.search && !parsed.hash) return baseUrl;
parsed.pathname = expectedPath;
parsed.search = '';
parsed.hash = '';
return parsed.toString();
} catch {
return baseUrl;
}
}
/**
* Rewrite localhost URLs to remote server URLs.
* Handles various localhost patterns: 127.0.0.1, localhost, 0.0.0.0
+6 -6
View File
@@ -7,7 +7,7 @@ import { CLIProxyProvider } from '../types';
import { ThinkingConfig, DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types';
import { getThinkingConfig } from '../../config/unified-config-loader';
import { supportsThinking } from '../model-catalog';
import { validateThinking } from '../thinking-validator';
import { isThinkingOffValue, validateThinking } from '../thinking-validator';
import { warn } from '../../utils/ui';
/** Model tier types for thinking budget defaults */
@@ -169,10 +169,10 @@ export function applyThinkingConfig(
// Explicit "off" (CLI override or manual config override) must disable ALL tier thinking.
const explicitOffOverride =
thinkingOverride === 'off' ||
isThinkingOffValue(thinkingOverride) ||
(thinkingOverride === undefined &&
thinkingConfig.mode === 'manual' &&
thinkingConfig.override === 'off');
isThinkingOffValue(thinkingConfig.override));
if (explicitOffOverride) {
return result;
}
@@ -227,10 +227,10 @@ export function applyThinkingConfig(
// If auto-detection resolves default tier to "off", skip the main model but still allow
// explicit per-tier thinking values for other tiers.
if (thinkingValue === 'off') {
if (isThinkingOffValue(thinkingValue)) {
const hasPerTierThinking =
compositeTierThinking &&
Object.values(compositeTierThinking).some((v) => v !== undefined && v !== 'off');
Object.values(compositeTierThinking).some((v) => v !== undefined && !isThinkingOffValue(v));
if (!hasPerTierThinking) {
return result; // No thinking to apply anywhere
}
@@ -288,7 +288,7 @@ export function applyThinkingConfig(
}
// If per-tier thinking is 'off', skip this tier
if (tierThinkingValue === 'off') {
if (isThinkingOffValue(tierThinkingValue)) {
continue;
}
+6 -1
View File
@@ -20,6 +20,7 @@ import { CLIProxyProvider } from '../types';
import { CompositeTierConfig } from '../../config/unified-config-types';
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
import { getImageAnalysisHookEnv } from '../../utils/hooks/get-image-analysis-hook-env';
import { stripClaudeCodeEnv } from '../../utils/shell-executor';
import { CodexReasoningProxy } from '../codex-reasoning-proxy';
import { ToolSanitizationProxy } from '../tool-sanitization-proxy';
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
@@ -219,13 +220,17 @@ export function buildClaudeEnvironment(config: ProxyChainConfig): Record<string,
Object.entries(effectiveEnvVars).filter(([, v]) => v !== undefined)
) as Record<string, string>;
return {
const mergedEnv = {
...baseEnv,
...effectiveEnvVarsFiltered,
...webSearchEnv,
...imageAnalysisEnv,
CCS_PROFILE_TYPE: 'cliproxy', // Signal to WebSearch hook this is a third-party provider
};
return Object.fromEntries(
Object.entries(stripClaudeCodeEnv(mergedEnv)).filter(([, v]) => v !== undefined)
) as Record<string, string>;
}
/**
+28 -2
View File
@@ -49,7 +49,7 @@ import {
installWebSearchHook,
displayWebSearchStatus,
} from '../../utils/websearch-manager';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { loadOrCreateUnifiedConfig, getThinkingConfig } from '../../config/unified-config-loader';
import { installImageAnalyzerHook } from '../../utils/hooks';
import { HttpsTunnelProxy } from '../https-tunnel-proxy';
import { isKiroAuthMethod, KiroAuthMethod, normalizeKiroAuthMethod } from '../auth/auth-types';
@@ -67,11 +67,17 @@ import { checkOrJoinProxy, registerProxySession, setupCleanupHandlers } from './
import { parseThinkingOverride } from './thinking-arg-parser';
import {
warnCrossProviderDuplicates,
warnOAuthBanRisk,
cleanupStaleAutoPauses,
enforceProviderIsolation,
restoreAutoPausedAccounts,
} from '../account-safety';
import { getWebSearchHookEnv } from '../../utils/websearch-manager';
import {
buildThinkingStartupStatus,
resolveRuntimeThinkingOverride,
shouldDisableCodexReasoning,
} from './thinking-override-resolver';
/** Default executor configuration */
const DEFAULT_CONFIG: ExecutorConfig = {
@@ -187,6 +193,7 @@ export async function execClaudeWithCLIProxy(
const providerConfig = getProviderConfig(provider);
log(`Provider: ${providerConfig.displayName}`);
warnOAuthBanRisk(provider);
// Check remote proxy if configured
let useRemoteProxy = false;
@@ -352,7 +359,12 @@ export async function execClaudeWithCLIProxy(
process.exit(1);
}
const thinkingOverride = thinkingParse.value;
const { thinkingOverride, thinkingSource } = resolveRuntimeThinkingOverride(
thinkingParse.value,
process.env.CCS_THINKING
);
const thinkingCfg = getThinkingConfig();
if (thinkingParse.duplicateDisplays.length > 0) {
console.warn(
`[!] Multiple reasoning flags detected. Using first occurrence: ${thinkingParse.sourceDisplay}`
@@ -802,10 +814,12 @@ export async function execClaudeWithCLIProxy(
process.env.CCS_CODEX_REASONING_TRACE === '1' ||
process.env.CCS_CODEX_REASONING_TRACE === 'true';
const stripPathPrefix = useRemoteProxy ? '/api/provider/codex' : undefined;
const codexThinkingOff = shouldDisableCodexReasoning(thinkingCfg, thinkingOverride);
codexReasoningProxy = new CodexReasoningProxy({
upstreamBaseUrl: postSanitizationBaseUrl,
verbose,
defaultEffort: 'medium',
disableEffort: codexThinkingOff,
traceFilePath: traceEnabled ? path.join(getCcsDir(), 'codex-reasoning-proxy.log') : '',
modelMap: {
defaultModel: initialEnvVars.ANTHROPIC_MODEL,
@@ -873,6 +887,18 @@ export async function execClaudeWithCLIProxy(
const webSearchEnv = getWebSearchHookEnv();
logEnvironment(env, webSearchEnv, verbose);
// 11b. Print thinking status feedback (TTY only, non-piped sessions)
if (process.stderr.isTTY) {
const { thinkingLabel, sourceLabel } = buildThinkingStartupStatus(
thinkingCfg,
thinkingOverride,
thinkingSource,
thinkingParse.sourceDisplay
);
console.error(`[i] Thinking: ${thinkingLabel} (${sourceLabel})`);
}
// 12. Filter CCS-specific flags before passing to Claude CLI
const ccsFlags = [
'--auth',
+2 -1
View File
@@ -10,7 +10,7 @@
import { fail, warn, info } from '../../utils/ui';
import { CLIProxyProvider } from '../types';
import { handleBanDetection } from '../account-safety';
import { handleBanDetection, warnPossible403Ban } from '../account-safety';
import { CompositeTierConfig } from '../../config/unified-config-types';
/**
@@ -59,6 +59,7 @@ export async function handleTokenExpiration(
if (account) {
handleBanDetection(provider, account.id, tokenResult.error);
}
warnPossible403Ban(provider, tokenResult.error);
}
// Token expired and refresh failed - trigger re-auth
@@ -0,0 +1,129 @@
import type { ThinkingConfig } from '../../config/unified-config-types';
import {
isThinkingOffValue,
THINKING_BUDGET_MAX,
THINKING_BUDGET_MIN,
VALID_THINKING_LEVELS,
} from '../thinking-validator';
export type RuntimeThinkingSource = 'flag' | 'env' | 'config' | undefined;
/**
* Parse CCS_THINKING env value using same rules as CLI parsing:
* integer string => number, known level/off aliases => normalized string.
* Unknown/invalid values are ignored to preserve config fallback behavior.
*/
export function parseEnvThinkingOverride(raw: string | undefined): string | number | undefined {
if (raw === undefined) return undefined;
const trimmed = raw.trim();
if (!trimmed) return undefined;
if (/^\d+$/.test(trimmed)) {
const parsed = Number.parseInt(trimmed, 10);
if (parsed < THINKING_BUDGET_MIN || parsed > THINKING_BUDGET_MAX) {
return undefined;
}
return parsed;
}
const normalized = trimmed.toLowerCase();
if (isThinkingOffValue(normalized)) {
return 'off';
}
if ((VALID_THINKING_LEVELS as readonly string[]).includes(normalized)) {
return normalized;
}
return undefined;
}
/**
* Runtime precedence: CLI flag > CCS_THINKING env var.
* Config is handled later during model/env resolution.
*/
export function resolveRuntimeThinkingOverride(
flagOverride: string | number | undefined,
envValue: string | undefined
): { thinkingOverride: string | number | undefined; thinkingSource: RuntimeThinkingSource } {
if (flagOverride !== undefined) {
return { thinkingOverride: flagOverride, thinkingSource: 'flag' };
}
const envOverride = parseEnvThinkingOverride(envValue);
if (envOverride !== undefined) {
return { thinkingOverride: envOverride, thinkingSource: 'env' };
}
return { thinkingOverride: undefined, thinkingSource: undefined };
}
/**
* Effective off logic for codex reasoning proxy wiring.
*/
export function shouldDisableCodexReasoning(
thinkingConfig: ThinkingConfig,
thinkingOverride: string | number | undefined
): boolean {
return (
(thinkingConfig.mode === 'off' && thinkingOverride === undefined) ||
isThinkingOffValue(thinkingOverride) ||
(thinkingOverride === undefined &&
thinkingConfig.mode === 'manual' &&
isThinkingOffValue(thinkingConfig.override))
);
}
/**
* Build user-facing startup feedback label/source based on effective precedence.
*/
export function buildThinkingStartupStatus(
thinkingConfig: ThinkingConfig,
thinkingOverride: string | number | undefined,
thinkingSource: RuntimeThinkingSource,
sourceDisplay?: string
): { thinkingLabel: string; sourceLabel: string } {
const overrideDisablesThinking = isThinkingOffValue(thinkingOverride);
const configDisablesThinking =
thinkingOverride === undefined &&
(thinkingConfig.mode === 'off' ||
(thinkingConfig.mode === 'manual' && isThinkingOffValue(thinkingConfig.override)));
if (overrideDisablesThinking || configDisablesThinking) {
if (thinkingSource === 'flag') {
return {
thinkingLabel: 'off',
sourceLabel: `flag: ${sourceDisplay ?? '--thinking off'}`,
};
}
if (thinkingSource === 'env') {
return {
thinkingLabel: 'off',
sourceLabel: 'env: CCS_THINKING',
};
}
return {
thinkingLabel: 'off',
sourceLabel: thinkingConfig.mode === 'manual' ? 'config: manual' : 'config: off',
};
}
if (thinkingSource === 'flag') {
return {
thinkingLabel: String(thinkingOverride),
sourceLabel: `flag: ${sourceDisplay ?? '--thinking'}`,
};
}
if (thinkingSource === 'env') {
return {
thinkingLabel: String(thinkingOverride),
sourceLabel: 'env: CCS_THINKING',
};
}
if (thinkingConfig.mode === 'manual' && thinkingConfig.override !== undefined) {
return {
thinkingLabel: String(thinkingConfig.override),
sourceLabel: 'config: manual',
};
}
return {
thinkingLabel: thinkingConfig.mode === 'auto' ? 'auto' : 'default',
sourceLabel: 'config: auto',
};
}
+22 -5
View File
@@ -16,24 +16,41 @@ import type {
RemoteModelInfo,
GetModelDefinitionsResponse,
} from './management-api-types';
import { CLIPROXY_DEFAULT_PORT } from './config/port-manager';
/** Default timeout for management operations (longer than health check) */
const DEFAULT_TIMEOUT_MS = 5000;
/** Default port for HTTP protocol */
const DEFAULT_HTTP_PORT = 8317;
/** Default port for HTTPS protocol */
const DEFAULT_HTTPS_PORT = 443;
/** Avoid duplicate warnings for repeated invalid port inputs */
const WARNED_INVALID_PORTS = new Set<string>();
function isValidPort(port: number | undefined): port is number {
return port !== undefined && Number.isInteger(port) && port > 0 && port <= 65535;
}
/**
* Get effective port based on config and protocol.
*/
function getEffectivePort(port: number | undefined, protocol: 'http' | 'https'): number {
if (port !== undefined && Number.isInteger(port) && port > 0 && port <= 65535) {
if (isValidPort(port)) {
return port;
}
return protocol === 'https' ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT;
const fallbackPort = protocol === 'https' ? DEFAULT_HTTPS_PORT : CLIPROXY_DEFAULT_PORT;
if (port !== undefined) {
const warningKey = `${protocol}:${String(port)}`;
if (!WARNED_INVALID_PORTS.has(warningKey)) {
WARNED_INVALID_PORTS.add(warningKey);
console.warn(
`[management-api-client] Invalid port "${String(port)}", using default ${fallbackPort}`
);
}
}
return fallbackPort;
}
/**
+1 -1
View File
@@ -205,7 +205,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
{
id: 'kimi-k2.5',
name: 'Kimi K2.5',
description: 'Latest Moonshot coding model',
description: 'Latest multimodal model (262K context)',
thinking: {
type: 'budget',
min: 1024,
+141 -12
View File
@@ -1,11 +1,23 @@
import type { CLIProxyProvider } from './types';
export type OAuthFlowType = 'authorization_code' | 'device_code';
export type TokenRefreshOwnership = 'ccs' | 'cliproxy' | 'unsupported';
export interface ProviderCapabilities {
displayName: string;
description: string;
oauthFlow: OAuthFlowType;
callbackPort: number | null;
/** Provider name expected by CLIProxyAPI callback endpoint payload. */
callbackProviderName: string;
/** Provider name prefix used by CLIProxyAPI auth URL endpoint. */
authUrlProviderName: string;
/** Who owns token refresh logic for this provider. */
refreshOwnership: TokenRefreshOwnership;
/** Filename prefixes used to identify auth tokens for this provider. */
authFilePrefixes: readonly string[];
/** Token JSON "type" values accepted for this provider. */
tokenTypeValues: readonly string[];
/**
* Alternative provider names used by CLIProxyAPI or stats endpoints.
* These aliases normalize external names to canonical CCS provider IDs.
@@ -16,56 +28,110 @@ export interface ProviderCapabilities {
export const PROVIDER_CAPABILITIES: Record<CLIProxyProvider, ProviderCapabilities> = {
gemini: {
displayName: 'Google Gemini',
description: 'Gemini Pro/Flash models',
oauthFlow: 'authorization_code',
callbackPort: 8085,
callbackProviderName: 'gemini',
authUrlProviderName: 'gemini-cli',
refreshOwnership: 'ccs',
authFilePrefixes: ['gemini-', 'google-'],
tokenTypeValues: ['gemini'],
aliases: ['gemini-cli'],
},
codex: {
displayName: 'Codex',
displayName: 'OpenAI Codex',
description: 'GPT-4 and codex models',
oauthFlow: 'authorization_code',
callbackPort: 1455,
callbackProviderName: 'codex',
authUrlProviderName: 'codex',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['codex-', 'openai-'],
tokenTypeValues: ['codex'],
aliases: [],
},
agy: {
displayName: 'AntiGravity',
displayName: 'Antigravity',
description: 'Antigravity AI models',
oauthFlow: 'authorization_code',
callbackPort: 51121,
callbackProviderName: 'antigravity',
authUrlProviderName: 'antigravity',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['antigravity-', 'agy-'],
tokenTypeValues: ['antigravity'],
aliases: ['antigravity'],
},
qwen: {
displayName: 'Qwen',
displayName: 'Alibaba Qwen',
description: 'Qwen Code models',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'qwen',
authUrlProviderName: 'qwen',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['qwen-'],
tokenTypeValues: ['qwen'],
aliases: [],
},
iflow: {
displayName: 'iFlow',
description: 'iFlow AI models',
oauthFlow: 'authorization_code',
callbackPort: 11451,
callbackProviderName: 'iflow',
authUrlProviderName: 'iflow',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['iflow-'],
tokenTypeValues: ['iflow'],
aliases: [],
},
kiro: {
displayName: 'Kiro (AWS)',
description: 'AWS CodeWhisperer models',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'kiro',
authUrlProviderName: 'kiro',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['kiro-', 'aws-', 'codewhisperer-'],
tokenTypeValues: ['kiro', 'codewhisperer'],
aliases: ['codewhisperer'],
},
ghcp: {
displayName: 'GitHub Copilot (OAuth)',
description: 'GitHub Copilot via OAuth',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'copilot',
authUrlProviderName: 'github',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['github-copilot-', 'copilot-', 'gh-'],
tokenTypeValues: ['github-copilot', 'copilot'],
aliases: ['github-copilot', 'copilot'],
},
claude: {
displayName: 'Claude',
displayName: 'Claude (Anthropic)',
description: 'Claude Opus/Sonnet models',
oauthFlow: 'authorization_code',
callbackPort: 54545,
callbackProviderName: 'anthropic',
authUrlProviderName: 'anthropic',
refreshOwnership: 'unsupported',
authFilePrefixes: ['claude-', 'anthropic-'],
tokenTypeValues: ['claude', 'anthropic'],
aliases: ['anthropic'],
},
kimi: {
displayName: 'Kimi (Moonshot)',
description: 'Moonshot AI K2/K2.5 models',
oauthFlow: 'device_code',
callbackPort: null,
callbackProviderName: 'kimi',
authUrlProviderName: 'kimi',
refreshOwnership: 'cliproxy',
authFilePrefixes: ['kimi-'],
tokenTypeValues: ['kimi'],
aliases: ['moonshot'],
},
};
@@ -74,18 +140,53 @@ export const CLIPROXY_PROVIDER_IDS = Object.freeze(
Object.keys(PROVIDER_CAPABILITIES) as CLIProxyProvider[]
);
export function buildProviderMap<T>(
valueFor: (provider: CLIProxyProvider) => T
): Record<CLIProxyProvider, T> {
return CLIPROXY_PROVIDER_IDS.reduce(
(acc, provider) => {
acc[provider] = valueFor(provider);
return acc;
},
{} as Record<CLIProxyProvider, T>
);
}
const PROVIDER_ID_SET = new Set(CLIPROXY_PROVIDER_IDS);
const PROVIDER_ALIAS_MAP: ReadonlyMap<string, CLIProxyProvider> = (() => {
const entries: Array<[string, CLIProxyProvider]> = [];
for (const provider of CLIPROXY_PROVIDER_IDS) {
entries.push([provider, provider]);
for (const alias of PROVIDER_CAPABILITIES[provider].aliases) {
entries.push([alias.toLowerCase(), provider]);
export function buildProviderAliasMap(
capabilities: Record<CLIProxyProvider, ProviderCapabilities> = PROVIDER_CAPABILITIES
): ReadonlyMap<string, CLIProxyProvider> {
const aliasMap = new Map<string, CLIProxyProvider>();
const providers = Object.keys(capabilities) as CLIProxyProvider[];
const registerAlias = (alias: string, provider: CLIProxyProvider): void => {
const normalized = alias.trim().toLowerCase();
if (!normalized) {
return;
}
const existingProvider = aliasMap.get(normalized);
if (existingProvider && existingProvider !== provider) {
throw new Error(
`Provider alias collision for "${normalized}": ${existingProvider} and ${provider}`
);
}
aliasMap.set(normalized, provider);
};
for (const provider of providers) {
registerAlias(provider, provider);
for (const alias of capabilities[provider].aliases) {
registerAlias(alias, provider);
}
}
return new Map(entries);
})();
return aliasMap;
}
const PROVIDER_ALIAS_MAP: ReadonlyMap<string, CLIProxyProvider> = buildProviderAliasMap();
export function isCLIProxyProvider(provider: string): provider is CLIProxyProvider {
return PROVIDER_ID_SET.has(provider as CLIProxyProvider);
@@ -99,6 +200,10 @@ export function getProviderDisplayName(provider: CLIProxyProvider): string {
return PROVIDER_CAPABILITIES[provider].displayName;
}
export function getProviderDescription(provider: CLIProxyProvider): string {
return PROVIDER_CAPABILITIES[provider].description;
}
export function getProvidersByOAuthFlow(flowType: OAuthFlowType): CLIProxyProvider[] {
return CLIPROXY_PROVIDER_IDS.filter(
(provider) => PROVIDER_CAPABILITIES[provider].oauthFlow === flowType
@@ -113,6 +218,30 @@ export function getOAuthCallbackPort(provider: CLIProxyProvider): number | null
return PROVIDER_CAPABILITIES[provider].callbackPort;
}
export function getCLIProxyCallbackProviderName(provider: CLIProxyProvider): string {
return PROVIDER_CAPABILITIES[provider].callbackProviderName;
}
export function getCLIProxyAuthUrlProviderName(provider: CLIProxyProvider): string {
return PROVIDER_CAPABILITIES[provider].authUrlProviderName;
}
export function getTokenRefreshOwnership(provider: CLIProxyProvider): TokenRefreshOwnership {
return PROVIDER_CAPABILITIES[provider].refreshOwnership;
}
export function isRefreshDelegatedToCLIProxy(provider: CLIProxyProvider): boolean {
return PROVIDER_CAPABILITIES[provider].refreshOwnership === 'cliproxy';
}
export function getProviderAuthFilePrefixes(provider: CLIProxyProvider): readonly string[] {
return PROVIDER_CAPABILITIES[provider].authFilePrefixes;
}
export function getProviderTokenTypeValues(provider: CLIProxyProvider): readonly string[] {
return PROVIDER_CAPABILITIES[provider].tokenTypeValues;
}
export function mapExternalProviderName(providerName: string): CLIProxyProvider | null {
const normalized = providerName.toLowerCase();
return PROVIDER_ALIAS_MAP.get(normalized) ?? null;
+237
View File
@@ -0,0 +1,237 @@
/**
* Quota Fetcher for GitHub Copilot OAuth (ghcp) Accounts
*
* Fetches quota information from GitHub `/copilot_internal/user` endpoint
* using the account token managed by CLIProxy auth flow.
*/
import * as fs from 'node:fs';
import { getAccountTokenPath, getProviderAccounts } from './account-manager';
import type { GhcpQuotaResult, GhcpQuotaSnapshot } from './quota-types';
import { clampPercent } from '../utils/percentage';
const GHCP_USAGE_URL = 'https://api.github.com/copilot_internal/user';
const GHCP_USAGE_TIMEOUT_MS = 10000;
/**
* Mirrors headers currently accepted by GitHub Copilot internal usage endpoint.
* Keep aligned with upstream Copilot client/API changes when quota calls break.
*/
const GHCP_USER_AGENT = 'GitHubCopilotChat/0.26.7';
const GHCP_API_VERSION = '2025-04-01';
interface RawGhcpQuotaSnapshot {
entitlement?: number;
overage_count?: number;
overage_permitted?: boolean;
percent_remaining?: number;
quota_id?: string;
quota_remaining?: number;
remaining?: number;
unlimited?: boolean;
}
interface RawGhcpUsageResponse {
copilot_plan?: string;
quota_reset_date?: string;
quota_snapshots?: {
premium_interactions?: RawGhcpQuotaSnapshot;
chat?: RawGhcpQuotaSnapshot;
completions?: RawGhcpQuotaSnapshot;
};
}
interface TokenData {
access_token?: string;
token?: {
access_token?: string;
};
}
function normalizeSnapshot(raw?: RawGhcpQuotaSnapshot): GhcpQuotaSnapshot {
const entitlement = Number(raw?.entitlement ?? 0);
const remainingRaw = raw?.remaining ?? raw?.quota_remaining ?? 0;
const remaining = Number(remainingRaw);
const safeEntitlement = Number.isFinite(entitlement) ? Math.max(0, entitlement) : 0;
const safeRemaining = Number.isFinite(remaining) ? Math.max(0, remaining) : 0;
const used = Math.max(0, safeEntitlement - safeRemaining);
const percentRemainingRaw =
typeof raw?.percent_remaining === 'number' ? raw.percent_remaining : null;
const percentRemaining =
percentRemainingRaw !== null
? clampPercent(percentRemainingRaw)
: safeEntitlement > 0
? clampPercent((safeRemaining / safeEntitlement) * 100)
: 0;
return {
entitlement: safeEntitlement,
remaining: safeRemaining,
used,
percentRemaining,
percentUsed: clampPercent(100 - percentRemaining),
unlimited: Boolean(raw?.unlimited),
overageCount:
typeof raw?.overage_count === 'number' && Number.isFinite(raw.overage_count)
? Math.max(0, raw.overage_count)
: 0,
overagePermitted: Boolean(raw?.overage_permitted),
quotaId: raw?.quota_id || null,
};
}
function extractAccessToken(tokenData: TokenData): string | null {
if (typeof tokenData.access_token === 'string' && tokenData.access_token.trim()) {
return tokenData.access_token.trim();
}
if (
tokenData.token &&
typeof tokenData.token === 'object' &&
typeof tokenData.token.access_token === 'string' &&
tokenData.token.access_token.trim()
) {
return tokenData.token.access_token.trim();
}
return null;
}
function readGhcpAccessToken(accountId: string): { accessToken: string | null; error?: string } {
const account = getProviderAccounts('ghcp').find((item) => item.id === accountId);
if (!account) {
return { accessToken: null, error: `Account not found: ${accountId}` };
}
const tokenPath = getAccountTokenPath(account);
if (!tokenPath || !fs.existsSync(tokenPath)) {
return { accessToken: null, error: 'Auth token file not found' };
}
try {
const raw = fs.readFileSync(tokenPath, 'utf-8');
const data = JSON.parse(raw) as TokenData;
const accessToken = extractAccessToken(data);
if (!accessToken) {
return { accessToken: null, error: 'No access token in auth file' };
}
return { accessToken };
} catch (error) {
return {
accessToken: null,
error: error instanceof Error ? error.message : 'Failed to parse auth token file',
};
}
}
function buildEmptyQuotaResult(error: string, accountId?: string): GhcpQuotaResult {
return {
success: false,
planType: null,
quotaResetDate: null,
snapshots: {
premiumInteractions: normalizeSnapshot(),
chat: normalizeSnapshot(),
completions: normalizeSnapshot(),
},
lastUpdated: Date.now(),
error,
accountId,
};
}
function normalizeUsageResponse(raw: RawGhcpUsageResponse): GhcpQuotaResult {
const snapshots = raw.quota_snapshots || {};
return {
success: true,
planType: raw.copilot_plan ?? null,
quotaResetDate: raw.quota_reset_date ?? null,
snapshots: {
premiumInteractions: normalizeSnapshot(snapshots.premium_interactions),
chat: normalizeSnapshot(snapshots.chat),
completions: normalizeSnapshot(snapshots.completions),
},
lastUpdated: Date.now(),
};
}
/**
* Fetch quota for one ghcp account.
*/
export async function fetchGhcpQuota(accountId: string, verbose = false): Promise<GhcpQuotaResult> {
const { accessToken, error } = readGhcpAccessToken(accountId);
if (!accessToken) {
// Safe diagnostic: accountId + generic error only (never log token values/file contents).
if (verbose) console.error(`[!] ghcp quota token error (${accountId}): ${error}`);
return buildEmptyQuotaResult(error || 'Failed to load auth token', accountId);
}
if (verbose) console.error(`[i] Fetching ghcp quota for ${accountId}...`);
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), GHCP_USAGE_TIMEOUT_MS);
try {
const response = await fetch(GHCP_USAGE_URL, {
method: 'GET',
signal: controller.signal,
headers: {
Accept: 'application/json',
Authorization: `token ${accessToken}`,
'User-Agent': GHCP_USER_AGENT,
'x-github-api-version': GHCP_API_VERSION,
},
});
clearTimeout(timeoutId);
if (response.status === 401 || response.status === 403) {
return {
...buildEmptyQuotaResult('Authentication expired or invalid', accountId),
needsReauth: true,
};
}
if (response.status === 429) {
return buildEmptyQuotaResult('Rate limited - try again later', accountId);
}
if (!response.ok) {
return buildEmptyQuotaResult(`GitHub API error: ${response.status}`, accountId);
}
const data = (await response.json()) as RawGhcpUsageResponse;
return {
...normalizeUsageResponse(data),
accountId,
};
} catch (error) {
clearTimeout(timeoutId);
const message =
error instanceof Error && error.name === 'AbortError'
? 'Request timeout'
: error instanceof Error
? error.message
: 'Unknown error';
return buildEmptyQuotaResult(message, accountId);
}
}
/**
* Fetch quota for all ghcp accounts.
*/
export async function fetchAllGhcpQuotas(
verbose = false
): Promise<{ account: string; quota: GhcpQuotaResult }[]> {
const accounts = getProviderAccounts('ghcp');
const results = await Promise.all(
accounts.map(async (account) => ({
account: account.id,
quota: await fetchGhcpQuota(account.id, verbose),
}))
);
return results;
}
// Export for testing
export { normalizeSnapshot as normalizeGhcpSnapshot, extractAccessToken as extractGhcpAccessToken };
+51 -2
View File
@@ -2,11 +2,11 @@
* Shared Quota Type Definitions
*
* Unified types for multi-provider quota system.
* Supports Antigravity, Codex, and Gemini CLI providers.
* Supports Antigravity, Codex, Gemini CLI, and GitHub Copilot OAuth providers.
*/
/** Supported quota providers */
export type QuotaProvider = 'agy' | 'codex' | 'gemini';
export type QuotaProvider = 'agy' | 'codex' | 'gemini' | 'ghcp';
// Re-export Antigravity types for unified access
export type { QuotaResult as AntigravityQuotaResult } from './quota-fetcher';
@@ -110,3 +110,52 @@ export interface GeminiCliQuotaResult {
/** True if token is expired and needs re-authentication */
needsReauth?: boolean;
}
/**
* GitHub Copilot quota snapshot.
*/
export interface GhcpQuotaSnapshot {
/** Total quota allocation for this category */
entitlement: number;
/** Remaining quota count */
remaining: number;
/** Used quota count */
used: number;
/** Remaining quota percentage (0-100) */
percentRemaining: number;
/** Used quota percentage (0-100) */
percentUsed: number;
/** Whether this quota category is unlimited */
unlimited: boolean;
/** Overage usage count */
overageCount: number;
/** Whether overage is permitted */
overagePermitted: boolean;
/** Upstream quota identifier if available */
quotaId: string | null;
}
/**
* GitHub Copilot quota fetch result.
*/
export interface GhcpQuotaResult {
/** Whether fetch succeeded */
success: boolean;
/** Copilot plan type (individual/business/enterprise/free) */
planType: string | null;
/** Quota reset date/time (ISO string) */
quotaResetDate: string | null;
snapshots: {
premiumInteractions: GhcpQuotaSnapshot;
chat: GhcpQuotaSnapshot;
completions: GhcpQuotaSnapshot;
};
/** Timestamp of fetch */
lastUpdated: number;
/** Error message if fetch failed */
error?: string;
/** Account ID this quota belongs to */
accountId?: string;
/** True if token is expired/invalid and user needs re-authentication */
needsReauth?: boolean;
}
+17 -5
View File
@@ -86,6 +86,21 @@ export function capLevelAtMax(
export const THINKING_OFF_VALUES = ['off', 'none', 'disabled', '0'] as const;
export const THINKING_AUTO_VALUE = 'auto';
/**
* Check whether a value should disable thinking.
* Accepts common string aliases and numeric 0.
*/
export function isThinkingOffValue(value: unknown): boolean {
if (typeof value === 'number') {
return value === 0;
}
if (typeof value === 'string') {
const normalized = value.toLowerCase().trim();
return THINKING_OFF_VALUES.includes(normalized as (typeof THINKING_OFF_VALUES)[number]);
}
return false;
}
/**
* Find closest valid level using simple string matching
* Returns undefined if no close match found
@@ -158,11 +173,8 @@ export function validateThinking(
}
// Handle off/none/disabled values
if (typeof value === 'string') {
const normalizedValue = value.toLowerCase().trim();
if (THINKING_OFF_VALUES.includes(normalizedValue as (typeof THINKING_OFF_VALUES)[number])) {
return { valid: true, value: 'off' };
}
if (isThinkingOffValue(value)) {
return { valid: true, value: 'off' };
}
// If model has no thinking support info, pass through
+34 -20
View File
@@ -35,9 +35,12 @@ import {
isUsingUnifiedConfig,
isOpenRouterUrl,
pickOpenRouterModel,
PROVIDER_PRESETS,
getPresetById,
getPresetAliases,
getPresetIds,
type ModelMapping,
type ProviderPreset,
} from '../api/services';
import { syncToLocalConfig } from '../cliproxy/sync/local-config-sync';
@@ -51,6 +54,22 @@ interface ApiCommandArgs {
yes?: boolean;
}
function sanitizeHelpText(value: string): string {
return value
.replace(/[\r\n\t]+/g, ' ')
.replace(/[\x00-\x1f\x7f]/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string {
const presetId = sanitizeHelpText(preset.id) || 'unknown';
const paddedId = presetId.padEnd(idWidth);
const presetName = sanitizeHelpText(preset.name) || 'Unknown preset';
const presetDescription = sanitizeHelpText(preset.description) || 'No description';
return ` ${color(paddedId, 'command')} ${presetName} - ${presetDescription}`;
}
/** Parse command line arguments for api commands */
function parseArgs(args: string[]): ApiCommandArgs {
const result: ApiCommandArgs = {};
@@ -92,7 +111,7 @@ async function handleCreate(args: string[]): Promise<void> {
console.log(fail(`Unknown preset: ${parsedArgs.preset}`));
console.log('');
console.log('Available presets:');
getPresetIds().forEach((id) => console.log(` - ${id}`));
getPresetIds().forEach((id) => console.log(` - ${sanitizeHelpText(id)}`));
process.exit(1);
}
@@ -434,6 +453,11 @@ async function handleRemove(args: string[]): Promise<void> {
/** Show help for api commands */
async function showHelp(): Promise<void> {
await initUI();
const presetIds = getPresetIds()
.map((id) => sanitizeHelpText(id))
.filter(Boolean);
const presetAliases = getPresetAliases();
const presetIdWidth = Math.max(0, ...presetIds.map((id) => id.length)) + 2;
console.log(header('CCS API Management'));
console.log('');
@@ -447,7 +471,7 @@ async function showHelp(): Promise<void> {
console.log('');
console.log(subheader('Options'));
console.log(
` ${color('--preset <id>', 'command')} Use provider preset (openrouter, ollama, ollama-cloud, glm, glmt, kimi, foundry, mm, deepseek, qwen)`
` ${color('--preset <id>', 'command')} Use provider preset (${presetIds.join(', ')})`
);
console.log(` ${color('--base-url <url>', 'command')} API base URL (create)`);
console.log(` ${color('--api-key <key>', 'command')} API key (create)`);
@@ -456,24 +480,14 @@ async function showHelp(): Promise<void> {
console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`);
console.log('');
console.log(subheader('Provider Presets'));
console.log(
` ${color('openrouter', 'command')} OpenRouter - 349+ models (Claude, GPT, Gemini, Llama)`
);
console.log(
` ${color('ollama', 'command')} Ollama - Local open-source models (no API key)`
);
console.log(
` ${color('ollama-cloud', 'command')} Ollama Cloud - glm-5:cloud, qwen3-coder:480b`
);
console.log(` ${color('glm', 'command')} GLM - Claude via Z.AI`);
console.log(` ${color('glmt', 'command')} GLMT - GLM with Thinking mode`);
console.log(` ${color('kimi', 'command')} Kimi - Moonshot AI reasoning model`);
console.log(` ${color('foundry', 'command')} Azure Foundry - Claude via Microsoft Azure`);
console.log(` ${color('mm', 'command')} Minimax - M2 series with 1M context`);
console.log(` ${color('deepseek', 'command')} DeepSeek - V3.2 and R1 reasoning (128K)`);
console.log(
` ${color('qwen', 'command')} Qwen - Alibaba Cloud qwen3-coder-plus (256K)`
);
PROVIDER_PRESETS.forEach((preset) => console.log(renderPresetHelpLine(preset, presetIdWidth)));
Object.entries(presetAliases).forEach(([alias, canonical]) => {
const safeAlias = sanitizeHelpText(alias);
const safeCanonical = sanitizeHelpText(canonical);
console.log(
` ${dim(`Legacy alias: --preset ${safeAlias} (auto-mapped to ${safeCanonical})`)}`
);
});
console.log('');
console.log(subheader('Examples'));
console.log(` ${dim('# Interactive wizard')}`);
+1 -1
View File
@@ -55,7 +55,7 @@ export async function showHelp(): Promise<void> {
['pause <account>', 'Pause account (skip in rotation)'],
['resume <account>', 'Resume paused account'],
['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'],
['quota --provider <name>', 'Filter by provider (agy|codex|gemini)'],
['quota --provider <name>', 'Filter by provider (agy|codex|gemini|ghcp)'],
],
],
[
+20 -10
View File
@@ -80,10 +80,10 @@ function getEffectiveBackend(cliBackend?: CLIProxyBackend): CLIProxyBackend {
/**
* Parse --provider flag from args for quota command
* Returns the provider filter value and remaining args
* Accepts: agy, codex, gemini, gemini-cli, all
* Accepts: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all
*/
function parseProviderArg(args: string[]): {
provider: 'agy' | 'codex' | 'gemini' | 'all';
provider: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all';
remainingArgs: string[];
} {
const providerIdx = args.indexOf('--provider');
@@ -97,24 +97,29 @@ function parseProviderArg(args: string[]): {
// Handle empty value
if (!value) {
console.error(
'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, all'
'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all'
);
return { provider: 'all', remainingArgs };
}
// Normalize gemini-cli to gemini
const normalized = value === 'gemini-cli' ? 'gemini' : value;
const normalized =
value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value;
if (
normalized !== 'agy' &&
normalized !== 'codex' &&
normalized !== 'gemini' &&
normalized !== 'ghcp' &&
normalized !== 'all'
) {
console.error(
`Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, all`
`Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all`
);
return { provider: 'all', remainingArgs };
}
return { provider: normalized as 'agy' | 'codex' | 'gemini' | 'all', remainingArgs };
return {
provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all',
remainingArgs,
};
}
return { provider: 'all', remainingArgs: args };
}
@@ -122,26 +127,31 @@ function parseProviderArg(args: string[]): {
// Warn if no value or value looks like another flag
if (!rawValue || rawValue.startsWith('-')) {
console.error(
'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, all'
'Warning: --provider requires a value. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all'
);
}
const value = rawValue?.toLowerCase() || 'all';
const remainingArgs = [...args];
remainingArgs.splice(providerIdx, 2);
// Normalize gemini-cli to gemini
const normalized = value === 'gemini-cli' ? 'gemini' : value;
const normalized =
value === 'gemini-cli' ? 'gemini' : value === 'github-copilot' ? 'ghcp' : value;
if (
normalized !== 'agy' &&
normalized !== 'codex' &&
normalized !== 'gemini' &&
normalized !== 'ghcp' &&
normalized !== 'all'
) {
console.error(
`Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, all`
`Invalid provider '${value}'. Valid options: agy, codex, gemini, gemini-cli, ghcp, github-copilot, all`
);
return { provider: 'all', remainingArgs };
}
return { provider: normalized as 'agy' | 'codex' | 'gemini' | 'all', remainingArgs };
return {
provider: normalized as 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all',
remainingArgs,
};
}
/**
+78 -3
View File
@@ -19,7 +19,12 @@ import {
import { fetchAllProviderQuotas } from '../../cliproxy/quota-fetcher';
import { fetchAllCodexQuotas } from '../../cliproxy/quota-fetcher-codex';
import { fetchAllGeminiCliQuotas } from '../../cliproxy/quota-fetcher-gemini-cli';
import type { CodexQuotaResult, GeminiCliQuotaResult } from '../../cliproxy/quota-types';
import { fetchAllGhcpQuotas } from '../../cliproxy/quota-fetcher-ghcp';
import type {
CodexQuotaResult,
GeminiCliQuotaResult,
GhcpQuotaResult,
} from '../../cliproxy/quota-types';
import { isOnCooldown } from '../../cliproxy/quota-manager';
import { CLIProxyProvider } from '../../cliproxy/types';
import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../../utils/ui';
@@ -417,9 +422,68 @@ function displayGeminiCliQuotaSection(
}
}
function formatSnapshotLabel(
snapshot: GhcpQuotaResult['snapshots'][keyof GhcpQuotaResult['snapshots']]
): string {
if (snapshot.unlimited) {
return `${snapshot.percentUsed.toFixed(0)}% used (unlimited)`;
}
return `${snapshot.used}/${snapshot.entitlement} used`;
}
function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaResult }[]): void {
console.log(
subheader(`GitHub Copilot (${results.length} account${results.length !== 1 ? 's' : ''})`)
);
console.log('');
for (const { account, quota } of results) {
const accountInfo = findAccountByQuery('ghcp', account);
const defaultMark = accountInfo?.isDefault ? color(' (default)', 'info') : '';
if (!quota.success) {
console.log(` ${fail(account)}${defaultMark}`);
console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`);
console.log('');
continue;
}
const rows = [
quota.snapshots.premiumInteractions.percentRemaining,
quota.snapshots.chat.percentRemaining,
quota.snapshots.completions.percentRemaining,
];
const minQuota = rows.length > 0 ? Math.min(...rows) : 0;
const statusIcon = minQuota > 50 ? ok('') : minQuota > 10 ? warn('') : fail('');
const planBadge = quota.planType ? color(` [${quota.planType}]`, 'info') : '';
console.log(` ${statusIcon}${account}${defaultMark}${planBadge}`);
if (quota.quotaResetDate) {
console.log(` ${dim(`Resets ${formatResetTimeISO(quota.quotaResetDate)}`)}`);
}
const items: Array<[string, GhcpQuotaResult['snapshots'][keyof GhcpQuotaResult['snapshots']]]> =
[
['Premium interactions', quota.snapshots.premiumInteractions],
['Chat', quota.snapshots.chat],
['Completions', quota.snapshots.completions],
];
for (const [label, snapshot] of items) {
const bar = formatQuotaBar(snapshot.percentRemaining);
const usageLabel = dim(` ${formatSnapshotLabel(snapshot)}`);
console.log(
` ${label.padEnd(24)} ${bar} ${snapshot.percentRemaining.toFixed(0)}%${usageLabel}`
);
}
console.log('');
}
}
export async function handleQuotaStatus(
verbose = false,
providerFilter: 'agy' | 'codex' | 'gemini' | 'all' = 'all'
providerFilter: 'agy' | 'codex' | 'gemini' | 'ghcp' | 'all' = 'all'
): Promise<void> {
await initUI();
console.log(header('Quota Status'));
@@ -429,14 +493,16 @@ export async function handleQuotaStatus(
agy: providerFilter === 'all' || providerFilter === 'agy',
codex: providerFilter === 'all' || providerFilter === 'codex',
gemini: providerFilter === 'all' || providerFilter === 'gemini',
ghcp: providerFilter === 'all' || providerFilter === 'ghcp',
};
console.log(dim('Fetching quotas...'));
const [agyResults, codexResults, geminiResults] = await Promise.all([
const [agyResults, codexResults, geminiResults, ghcpResults] = await Promise.all([
shouldFetch.agy ? fetchAllProviderQuotas('agy', verbose) : null,
shouldFetch.codex ? fetchAllCodexQuotas(verbose) : null,
shouldFetch.gemini ? fetchAllGeminiCliQuotas(verbose) : null,
shouldFetch.ghcp ? fetchAllGhcpQuotas(verbose) : null,
]);
console.log('');
@@ -467,6 +533,15 @@ export async function handleQuotaStatus(
console.log(` Run: ${color('ccs gemini --auth', 'command')} to authenticate`);
console.log('');
}
if (ghcpResults && ghcpResults.length > 0) {
displayGhcpQuotaSection(ghcpResults);
} else if (shouldFetch.ghcp) {
console.log(subheader('GitHub Copilot (0 accounts)'));
console.log(info('No GitHub Copilot accounts configured'));
console.log(` Run: ${color('ccs ghcp --auth', 'command')} to authenticate`);
console.log('');
}
}
export async function handleDoctor(verbose = false): Promise<void> {
+17
View File
@@ -68,6 +68,14 @@ function showHelp(): void {
console.log(' --timeout <s> Set analysis timeout (seconds)');
console.log(' --set-model <p> <m> Set model for provider');
console.log('');
console.log(' thinking Manage thinking/reasoning settings');
console.log(' --mode <mode> Set mode (auto, off, manual)');
console.log(' --override <l> Set persistent override level');
console.log(' --clear-override Remove persistent override');
console.log(' --tier <t> <l> Set tier default level');
console.log(' --provider-override <p> <t> <l> Set provider tier override');
console.log(' --clear-provider-override <p> [t] Remove provider override');
console.log('');
console.log('Options:');
console.log(' --port, -p PORT Specify server port (default: auto-detect)');
console.log(' --dev Development mode with Vite HMR');
@@ -80,6 +88,8 @@ function showHelp(): void {
console.log(' ccs config auth setup Configure dashboard login');
console.log(' ccs config image-analysis Show image settings');
console.log(' ccs config image-analysis --enable Enable feature');
console.log(' ccs config thinking Show thinking settings');
console.log(' ccs config thinking --mode auto Set auto mode');
console.log('');
}
@@ -101,6 +111,13 @@ export async function handleConfigCommand(args: string[]): Promise<void> {
return;
}
// Route thinking subcommand
if (args[0] === 'thinking') {
const { handleConfigThinkingCommand } = await import('./config-thinking-command');
await handleConfigThinkingCommand(args.slice(1));
return;
}
await initUI();
const options = parseArgs(args);
+303
View File
@@ -0,0 +1,303 @@
/**
* Config Thinking Command Handler
*
* Manages thinking section of config.yaml via CLI.
* Usage: ccs config thinking [options]
*/
import { initUI, header, ok, info, warn, fail, subheader, color, dim } from '../utils/ui';
import {
getThinkingConfig,
updateUnifiedConfig,
loadOrCreateUnifiedConfig,
} from '../config/unified-config-loader';
import { DEFAULT_THINKING_TIER_DEFAULTS } from '../config/unified-config-types';
import { VALID_THINKING_LEVELS } from '../cliproxy/thinking-validator';
import {
clearProviderOverride,
parseThinkingCommandArgs,
parseThinkingOverrideInput,
} from './config-thinking-parser';
const VALID_THINKING_MODES = ['auto', 'off', 'manual'] as const;
const VALID_TIERS = ['opus', 'sonnet', 'haiku'] as const;
type ThinkingTier = (typeof VALID_TIERS)[number];
export { parseThinkingCommandArgs, parseThinkingOverrideInput } from './config-thinking-parser';
function showHelp(): void {
console.log('');
console.log(header('ccs config thinking'));
console.log('');
console.log(' Configure extended thinking/reasoning for CLIProxy providers.');
console.log('');
console.log(subheader('Usage:'));
console.log(` ${color('ccs config thinking', 'command')} [options]`);
console.log('');
console.log(subheader('Options:'));
console.log(
` ${color('--mode <mode>', 'command')} Set mode (auto, off, manual)`
);
console.log(
` ${color('--override <level>', 'command')} Set persistent override (manual mode)`
);
console.log(
` ${color('--clear-override', 'command')} Remove persistent override`
);
console.log(
` ${color('--tier <tier> <level>', 'command')} Set tier default (opus/sonnet/haiku)`
);
console.log(
` ${color('--provider-override <p> <t> <l>', 'command')} Set provider-specific tier override`
);
console.log(
` ${color('--clear-provider-override <p> [t]', 'command')} Remove provider override (provider or tier)`
);
console.log(` ${color('--help, -h', 'command')} Show this help`);
console.log('');
console.log(subheader('Levels:'));
console.log(
` ${dim('minimal (512), low (1K), medium (8K), high (24K), xhigh (32K), auto, off')}`
);
console.log('');
console.log(subheader('Examples:'));
console.log(
` $ ${color('ccs config thinking', 'command')} ${dim('# Show status')}`
);
console.log(
` $ ${color('ccs config thinking --mode auto', 'command')} ${dim('# Auto mode')}`
);
console.log(
` $ ${color('ccs config thinking --mode manual --override high', 'command')} ${dim('# Persistent high')}`
);
console.log(
` $ ${color('ccs config thinking --tier opus xhigh', 'command')} ${dim('# Opus -> xhigh')}`
);
console.log(
` $ ${color('ccs config thinking --provider-override codex opus xhigh', 'command')}`
);
console.log(
` $ ${color('ccs config thinking --clear-provider-override codex opus', 'command')}`
);
console.log('');
console.log(subheader('Environment:'));
console.log(
` ${color('CCS_THINKING', 'command')} Override per-session via env var (priority: flag > env > config)`
);
console.log(` ${dim('Example: CCS_THINKING=high ccs codex "debug this"')}`);
console.log('');
}
function showStatus(): void {
const config = getThinkingConfig();
console.log('');
console.log(header('Thinking Configuration'));
console.log('');
// Mode
const modeText =
config.mode === 'auto' ? ok('Auto') : config.mode === 'off' ? warn('Off') : info('Manual');
console.log(` Mode: ${modeText}`);
// Override
if (config.override !== undefined) {
console.log(` Override: ${color(String(config.override), 'command')}`);
}
// Warnings
console.log(` Warnings: ${config.show_warnings !== false ? 'on' : 'off'}`);
console.log('');
// Tier defaults
console.log(subheader('Tier Defaults:'));
for (const tier of VALID_TIERS) {
const level = config.tier_defaults?.[tier] ?? DEFAULT_THINKING_TIER_DEFAULTS[tier];
const isDefault = level === DEFAULT_THINKING_TIER_DEFAULTS[tier];
const suffix = isDefault ? dim(' (default)') : '';
console.log(` ${color(tier.padEnd(10), 'command')} ${level}${suffix}`);
}
console.log('');
// Provider overrides
const overrides = config.provider_overrides;
if (overrides && Object.keys(overrides).length > 0) {
console.log(subheader('Provider Overrides:'));
for (const [provider, tierOverrides] of Object.entries(overrides)) {
const parts = Object.entries(tierOverrides)
.map(([t, l]) => `${t}=${l}`)
.join(', ');
console.log(` ${color(provider.padEnd(10), 'command')} ${parts}`);
}
console.log('');
}
// Config location
console.log(subheader('Configuration:'));
console.log(` File: ${color('~/.ccs/config.yaml', 'path')}`);
console.log(` Section: ${dim('thinking')}`);
console.log('');
// Env var hint
if (process.env.CCS_THINKING) {
console.log(info(`CCS_THINKING env var active: ${process.env.CCS_THINKING}`));
console.log('');
}
}
export async function handleConfigThinkingCommand(args: string[]): Promise<void> {
await initUI();
const { options, error } = parseThinkingCommandArgs(args);
if (error) {
console.error(fail(error));
process.exitCode = 1;
return;
}
if (options.help) {
showHelp();
return;
}
let hasChanges = false;
const config = loadOrCreateUnifiedConfig();
const thinkingConfig = config.thinking ?? {
mode: 'auto' as const,
tier_defaults: { ...DEFAULT_THINKING_TIER_DEFAULTS },
show_warnings: true,
};
// Validate and apply --mode
if (options.mode !== undefined) {
const normalizedMode = options.mode.trim().toLowerCase();
if (!(VALID_THINKING_MODES as readonly string[]).includes(normalizedMode)) {
console.error(fail(`Invalid mode: ${options.mode}`));
console.error(info(`Valid modes: ${VALID_THINKING_MODES.join(', ')}`));
process.exitCode = 1;
return;
}
thinkingConfig.mode = normalizedMode as 'auto' | 'off' | 'manual';
hasChanges = true;
}
// Validate and apply --override
if (options.override !== undefined) {
const parsedOverride = parseThinkingOverrideInput(options.override);
if (parsedOverride.error) {
console.error(fail(parsedOverride.error));
console.error(info(`Valid levels: ${VALID_THINKING_LEVELS.join(', ')}, or a number`));
process.exitCode = 1;
return;
}
thinkingConfig.override = parsedOverride.value;
hasChanges = true;
}
// Apply --clear-override
if (options.clearOverride) {
thinkingConfig.override = undefined;
hasChanges = true;
}
// Validate and apply --tier
if (options.tier) {
const tier = options.tier.tier.toLowerCase().trim();
const level = options.tier.level.toLowerCase().trim();
if (!(VALID_TIERS as readonly string[]).includes(tier)) {
console.error(fail(`Invalid tier: ${options.tier.tier}`));
console.error(info(`Valid tiers: ${VALID_TIERS.join(', ')}`));
process.exitCode = 1;
return;
}
if (!(VALID_THINKING_LEVELS as readonly string[]).includes(level)) {
console.error(fail(`Invalid level for ${tier}: ${options.tier.level}`));
console.error(info(`Valid levels: ${VALID_THINKING_LEVELS.join(', ')}`));
process.exitCode = 1;
return;
}
thinkingConfig.tier_defaults = {
...DEFAULT_THINKING_TIER_DEFAULTS,
...thinkingConfig.tier_defaults,
[tier]: level,
};
hasChanges = true;
}
// Validate and apply --provider-override
if (options.providerOverride) {
const provider = options.providerOverride.provider.trim().toLowerCase();
const tier = options.providerOverride.tier.trim().toLowerCase();
const level = options.providerOverride.level.trim().toLowerCase();
if (!provider) {
console.error(fail('Provider name cannot be empty'));
process.exitCode = 1;
return;
}
if (!(VALID_TIERS as readonly string[]).includes(tier)) {
console.error(fail(`Invalid tier: ${options.providerOverride.tier}`));
process.exitCode = 1;
return;
}
if (!(VALID_THINKING_LEVELS as readonly string[]).includes(level)) {
console.error(fail(`Invalid level: ${options.providerOverride.level}`));
process.exitCode = 1;
return;
}
const normalizedTier = tier as ThinkingTier;
thinkingConfig.provider_overrides = {
...thinkingConfig.provider_overrides,
[provider]: {
...thinkingConfig.provider_overrides?.[provider],
[normalizedTier]: level,
},
};
hasChanges = true;
}
// Validate and apply --clear-provider-override
if (options.clearProviderOverride) {
const provider = options.clearProviderOverride.provider.trim().toLowerCase();
const tier = options.clearProviderOverride.tier?.trim().toLowerCase();
if (!provider) {
console.error(fail('Provider name cannot be empty'));
process.exitCode = 1;
return;
}
if (tier && !(VALID_TIERS as readonly string[]).includes(tier)) {
console.error(fail(`Invalid tier: ${options.clearProviderOverride.tier}`));
console.error(info(`Valid tiers: ${VALID_TIERS.join(', ')}`));
process.exitCode = 1;
return;
}
const normalizedTier = tier as ThinkingTier | undefined;
const clearResult = clearProviderOverride(
thinkingConfig.provider_overrides,
provider,
normalizedTier
);
thinkingConfig.provider_overrides = clearResult.nextOverrides;
if (clearResult.changed) {
hasChanges = true;
} else {
console.log(
info(`No provider override found for '${provider}'${tier ? ` tier '${tier}'` : ''}`)
);
console.log('');
}
}
if (hasChanges) {
updateUnifiedConfig({ thinking: thinkingConfig });
console.log(ok('Configuration updated'));
console.log('');
}
// Always show current status
showStatus();
}
+158
View File
@@ -0,0 +1,158 @@
import {
isThinkingOffValue,
THINKING_BUDGET_MAX,
THINKING_BUDGET_MIN,
VALID_THINKING_LEVELS,
} from '../cliproxy/thinking-validator';
interface ThinkingCommandOptions {
mode?: string;
override?: string;
clearOverride?: boolean;
tier?: { tier: string; level: string };
providerOverride?: { provider: string; tier: string; level: string };
clearProviderOverride?: { provider: string; tier?: string };
help?: boolean;
}
type ThinkingTier = 'opus' | 'sonnet' | 'haiku';
export type ThinkingTierOverrideMap = Partial<Record<ThinkingTier, string>>;
export type ThinkingProviderOverrides = Record<string, ThinkingTierOverrideMap>;
export interface ParseResult {
options: ThinkingCommandOptions;
error?: string;
}
export function parseThinkingCommandArgs(args: string[]): ParseResult {
const options: ThinkingCommandOptions = {};
const requireValue = (index: number): string | undefined => {
const value = args[index];
if (!value || value.startsWith('-')) {
return undefined;
}
return value;
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === '--mode') {
const value = requireValue(i + 1);
if (!value) return { options, error: `${arg} requires a value` };
options.mode = value;
i += 1;
} else if (arg === '--override') {
const value = requireValue(i + 1);
if (!value) return { options, error: `${arg} requires a value` };
options.override = value;
i += 1;
} else if (arg === '--clear-override') {
options.clearOverride = true;
} else if (arg === '--tier') {
const tier = requireValue(i + 1);
const level = requireValue(i + 2);
if (!tier || !level) return { options, error: `${arg} requires 2 values: <tier> <level>` };
options.tier = { tier, level };
i += 2;
} else if (arg === '--provider-override') {
const provider = requireValue(i + 1);
const tier = requireValue(i + 2);
const level = requireValue(i + 3);
if (!provider || !tier || !level) {
return { options, error: `${arg} requires 3 values: <provider> <tier> <level>` };
}
options.providerOverride = {
provider,
tier,
level,
};
i += 3;
} else if (arg === '--clear-provider-override') {
const provider = requireValue(i + 1);
if (!provider)
return { options, error: `${arg} requires at least 1 value: <provider> [tier]` };
const tier = requireValue(i + 2);
options.clearProviderOverride = { provider, tier };
i += tier ? 2 : 1;
} else if (arg === '--help' || arg === '-h') {
options.help = true;
} else if (arg.startsWith('-')) {
return { options, error: `Unknown option: ${arg}` };
} else {
return { options, error: `Unexpected argument: ${arg}` };
}
}
return { options };
}
export function parseThinkingOverrideInput(rawOverride: string): {
value?: string | number;
error?: string;
} {
const normalized = rawOverride.toLowerCase().trim();
if (isThinkingOffValue(normalized)) {
return { value: 'off' };
}
if ((VALID_THINKING_LEVELS as readonly string[]).includes(normalized)) {
return { value: normalized };
}
if (/^\d+$/.test(normalized)) {
const budget = Number.parseInt(normalized, 10);
if (budget < THINKING_BUDGET_MIN || budget > THINKING_BUDGET_MAX) {
return {
error: `Invalid override: numeric budget must be between ${THINKING_BUDGET_MIN} and ${THINKING_BUDGET_MAX}`,
};
}
return { value: budget };
}
return {
error: `Invalid override: ${rawOverride}`,
};
}
export function clearProviderOverride(
currentOverrides: ThinkingProviderOverrides | undefined,
provider: string,
tier?: ThinkingTier
): { nextOverrides: ThinkingProviderOverrides | undefined; changed: boolean } {
const current = currentOverrides ?? {};
const nextOverrides: ThinkingProviderOverrides = { ...current };
const providerEntry = nextOverrides[provider];
if (!providerEntry) {
return {
nextOverrides: Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined,
changed: false,
};
}
if (!tier) {
delete nextOverrides[provider];
return {
nextOverrides: Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined,
changed: true,
};
}
if (providerEntry[tier] === undefined) {
return {
nextOverrides: Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined,
changed: false,
};
}
const nextProviderEntry = { ...providerEntry };
delete nextProviderEntry[tier];
if (Object.keys(nextProviderEntry).length === 0) {
delete nextOverrides[provider];
} else {
nextOverrides[provider] = nextProviderEntry;
}
return {
nextOverrides: Object.keys(nextOverrides).length > 0 ? nextOverrides : undefined,
changed: true,
};
}
+66
View File
@@ -7,6 +7,7 @@
import {
startAuthFlow,
getCopilotStatus,
getCopilotUsage,
startDaemon,
stopDaemon,
getAvailableModels,
@@ -29,6 +30,8 @@ export async function handleCopilotCommand(args: string[]): Promise<number> {
return handleStatus();
case 'models':
return handleModels();
case 'usage':
return handleUsage();
case 'start':
return handleStart();
case 'stop':
@@ -61,6 +64,7 @@ function handleHelp(): number {
console.log(' auth Start GitHub OAuth authentication');
console.log(' status Show authentication and daemon status');
console.log(' models List available models');
console.log(' usage Show Copilot quota usage');
console.log(' start Start copilot-api daemon');
console.log(' stop Stop copilot-api daemon');
console.log(' enable Enable copilot integration');
@@ -71,6 +75,7 @@ function handleHelp(): number {
console.log(' 1. ccs copilot auth # Authenticate with GitHub');
console.log(' 2. ccs copilot enable # Enable integration');
console.log(' 3. ccs copilot start # Start daemon');
console.log(' 4. ccs copilot usage # Check quota usage');
console.log('');
console.log('Or use the web UI: ccs config → Copilot tab');
console.log('');
@@ -190,6 +195,67 @@ async function handleModels(): Promise<number> {
return 0;
}
function formatQuotaLine(
label: string,
snapshot: {
entitlement: number;
used: number;
percentUsed: number;
percentRemaining: number;
unlimited: boolean;
}
): string {
const quotaText = snapshot.unlimited
? 'Unlimited'
: `${snapshot.used}/${snapshot.entitlement} used`;
return `${label.padEnd(20)} ${quotaText} (${snapshot.percentUsed.toFixed(1)}% used, ${snapshot.percentRemaining.toFixed(1)}% remaining)`;
}
function formatResetDate(resetDate: string | null): string {
if (!resetDate) return 'unknown';
const date = new Date(resetDate);
if (Number.isNaN(date.getTime())) return resetDate;
return date.toLocaleString();
}
/**
* Handle usage subcommand.
*/
async function handleUsage(): Promise<number> {
const config = loadOrCreateUnifiedConfig();
const copilotConfig = config.copilot ?? DEFAULT_COPILOT_CONFIG;
const status = await getCopilotStatus(copilotConfig);
if (!status.daemon.running) {
console.error(fail('copilot-api daemon is not running.'));
console.error('');
console.error('Start daemon first: ccs copilot start');
return 1;
}
const usage = await getCopilotUsage(copilotConfig.port);
if (!usage) {
console.error(fail('Failed to fetch Copilot usage.'));
console.error('');
console.error('Try restarting daemon: ccs copilot stop && ccs copilot start');
return 1;
}
console.log('GitHub Copilot Usage');
console.log('────────────────────');
console.log('');
console.log(`Plan: ${usage.plan || 'unknown'}`);
console.log(`Quota Reset: ${formatResetDate(usage.quotaResetDate)}`);
console.log('');
console.log('Quotas:');
console.log(` ${formatQuotaLine('Premium Interactions', usage.quotas.premiumInteractions)}`);
console.log(` ${formatQuotaLine('Chat', usage.quotas.chat)}`);
console.log(` ${formatQuotaLine('Completions', usage.quotas.completions)}`);
console.log('');
return 0;
}
/**
* Handle start subcommand.
*/
+12 -4
View File
@@ -14,6 +14,7 @@ import { isUnifiedMode, loadUnifiedConfig } from '../config/unified-config-loade
import { expandPath } from '../utils/helpers';
import { getCcsDir } from '../utils/config-manager';
import { ProfileRegistry } from '../auth/profile-registry';
import { getProfileLookupCandidates } from '../utils/profile-compat';
type ShellType = 'bash' | 'fish' | 'powershell';
type OutputFormat = 'openai' | 'anthropic' | 'raw';
@@ -101,15 +102,22 @@ function isCLIProxyProfile(name: string): boolean {
return (CLIPROXY_PROFILES as readonly string[]).includes(name);
}
/** Resolve env vars for settings-based profiles (glm, kimi, custom API profiles) */
/** Resolve env vars for settings-based profiles (glm, km, custom API profiles) */
function resolveSettingsProfile(profileName: string): Record<string, string> | null {
if (!isUnifiedMode()) return null;
const config = loadUnifiedConfig();
if (!config) return null;
// Check unified config profiles section
const profileConfig = config.profiles?.[profileName];
// Check unified config profiles section (supports compatibility aliases, e.g. km -> kimi)
let profileConfig: { type?: string; settings?: string } | undefined;
for (const candidate of getProfileLookupCandidates(profileName)) {
const candidateConfig = config.profiles?.[candidate];
if (candidateConfig) {
profileConfig = candidateConfig;
break;
}
}
if (!profileConfig) return null;
if (profileConfig.type !== 'api') {
@@ -225,7 +233,7 @@ export async function handleEnvCommand(args: string[]): Promise<void> {
if (v !== undefined) envVars[k] = v;
}
} else {
// Settings-based profile (glm, kimi, custom API)
// Settings-based profile (glm, km, custom API)
const resolved = resolveSettingsProfile(profile);
if (!resolved) {
// Check if it's an account-based profile
+8 -2
View File
@@ -3,6 +3,7 @@ import * as path from 'path';
import { initUI, box, color, dim, sectionHeader, subheader } from '../utils/ui';
import { isUnifiedMode } from '../config/unified-config-loader';
import { getCcsDir, getCcsDirSource } from '../utils/config-manager';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
// Get version from package.json (same as version-command.ts)
const VERSION = JSON.parse(
@@ -219,6 +220,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs copilot auth', 'Authenticate with GitHub'],
['ccs copilot status', 'Show integration status'],
['ccs copilot models', 'List available models'],
['ccs copilot usage', 'Show Copilot quota usage'],
['ccs copilot start', 'Start copilot-api daemon'],
['ccs copilot stop', 'Stop copilot-api daemon'],
['ccs copilot enable', 'Enable integration'],
@@ -281,6 +283,9 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['ccs config auth show', 'Show dashboard auth status'],
['ccs config image-analysis', 'Show image analysis settings'],
['ccs config image-analysis --enable', 'Enable image analysis'],
['ccs config thinking', 'Show thinking/reasoning settings'],
['ccs config thinking --mode auto', 'Set thinking mode'],
['ccs config thinking --clear-provider-override codex', 'Clear provider overrides'],
['ccs config --port 3000', 'Use specific port'],
['ccs persist <profile>', 'Write profile env to ~/.claude/settings.json'],
['ccs persist --list-backups', 'List available settings.json backups'],
@@ -345,7 +350,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
// CLI Proxy configuration flags (new)
printSubSection('CLI Proxy Configuration', [
['--proxy-host <host>', 'Remote proxy hostname/IP'],
['--proxy-port <port>', 'Proxy port (default: 8317)'],
['--proxy-port <port>', `Proxy port (default: ${CLIPROXY_DEFAULT_PORT})`],
['--proxy-protocol <proto>', 'Protocol: http or https (default: http)'],
['--proxy-auth-token <token>', 'Auth token for remote proxy'],
['--proxy-timeout <ms>', 'Connection timeout in ms (default: 2000)'],
@@ -403,6 +408,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
['CCS_DIR', 'Override CCS config directory (default: ~/.ccs)'],
['CCS_HOME', 'Override home directory (legacy, appends .ccs)'],
['CCS_DEBUG', 'Enable debug logging'],
['CCS_THINKING', 'Override thinking level (flag > env > config)'],
]);
// CLI Proxy env vars
@@ -421,7 +427,7 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
console.log(` Binary: ${color(`${dirDisplay}/cliproxy/bin/cli-proxy-api-plus`, 'path')}`);
console.log(` Config: ${color(`${dirDisplay}/cliproxy/config.yaml`, 'path')}`);
console.log(` Auth: ${color(`${dirDisplay}/cliproxy/auth/`, 'path')}`);
console.log(` ${dim('Port: 8317 (default)')}`);
console.log(` ${dim(`Port: ${CLIPROXY_DEFAULT_PORT} (default)`)}`);
console.log('');
// Shared Data
+1 -1
View File
@@ -390,7 +390,7 @@ async function showHelp(): Promise<void> {
);
console.log('');
console.log(subheader('Supported Profile Types'));
console.log(` ${color('API profiles', 'command')} glm, glmt, kimi, custom API profiles`);
console.log(` ${color('API profiles', 'command')} glm, glmt, km, custom API profiles`);
console.log(` ${color('CLIProxy', 'command')} gemini, codex, agy, qwen, kiro, ghcp`);
console.log(` ${color('Copilot', 'command')} copilot (requires copilot-api daemon)`);
console.log(` ${dim('Account-based')} Not supported (uses CLAUDE_CONFIG_DIR)`);
+5 -4
View File
@@ -24,6 +24,7 @@ import {
} from '../config/unified-config-loader';
import { DEFAULT_CLIPROXY_SERVER_CONFIG } from '../config/unified-config-types';
import { getCcsDir } from '../utils/config-manager';
import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager';
/** Custom error for user cancellation (Ctrl+C) */
class UserCancelledError extends Error {
@@ -226,7 +227,7 @@ async function configureRemoteProxy(rl: readline.Interface): Promise<{
])) as 'http' | 'https';
// Port (optional) - with validation
const defaultPort = protocol === 'https' ? '443' : '80';
const defaultPort = protocol === 'https' ? '443' : String(CLIPROXY_DEFAULT_PORT);
const portStr = await prompt(rl, `Port (leave empty for default ${defaultPort})`);
let port: number | undefined;
if (portStr) {
@@ -318,7 +319,7 @@ async function runSetupWizard(force: boolean = false): Promise<void> {
auto_start: false,
},
local: {
port: 8317,
port: CLIPROXY_DEFAULT_PORT,
auto_start: false, // Disable local auto-start when using remote
},
};
@@ -341,7 +342,7 @@ async function runSetupWizard(force: boolean = false): Promise<void> {
auth_token: '',
},
local: {
port: 8317,
port: CLIPROXY_DEFAULT_PORT,
auto_start: true,
},
};
@@ -370,7 +371,7 @@ async function runSetupWizard(force: boolean = false): Promise<void> {
console.log(' Use the following commands to create profiles:');
console.log('');
console.log(' ccs api create glm --preset glm');
console.log(' ccs api create kimi --preset kimi');
console.log(' ccs api create km --preset km');
console.log(' ccs api create custom --prompt');
console.log('');
console.log(' After creating, edit the settings file to add your API key.');
+17 -11
View File
@@ -9,6 +9,7 @@ import * as fs from 'fs';
import { initUI, header, subheader, color, warn } from '../utils/ui';
import { getActiveConfigPath, getCcsDir } from '../utils/config-manager';
import { getVersion } from '../utils/version';
import { getProfileLookupCandidates } from '../utils/profile-compat';
/**
* Handle version command
@@ -38,18 +39,23 @@ export async function handleVersionCommand(): Promise<void> {
const readyProfiles: string[] = [];
// Check for profiles with valid API keys
for (const profile of ['glm', 'kimi']) {
const settingsPath = path.join(ccsDir, `${profile}.settings.json`);
if (fs.existsSync(settingsPath)) {
try {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const apiKey = settings.env?.ANTHROPIC_AUTH_TOKEN;
if (apiKey && !apiKey.match(/YOUR_.*_API_KEY_HERE/) && !apiKey.match(/sk-test.*/)) {
readyProfiles.push(profile);
}
} catch (_error) {
// Invalid JSON, skip
for (const profile of ['glm', 'km']) {
const settingsPath = getProfileLookupCandidates(profile)
.map((candidate) => path.join(ccsDir, `${candidate}.settings.json`))
.find((candidatePath) => fs.existsSync(candidatePath));
if (!settingsPath) {
continue;
}
try {
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
const apiKey = settings.env?.ANTHROPIC_AUTH_TOKEN;
if (apiKey && !apiKey.match(/YOUR_.*_API_KEY_HERE/) && !apiKey.match(/sk-test.*/)) {
readyProfiles.push(profile);
}
} catch (_error) {
// Invalid JSON, skip
}
}
+70 -10
View File
@@ -16,6 +16,7 @@ import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../utils/config-manager';
import { expandPath } from '../utils/helpers';
import { resolveAliasToCanonical } from '../utils/profile-compat';
import type { ProfileConfig, AccountConfig, CLIProxyVariantConfig } from './unified-config-types';
import { createEmptyUnifiedConfig } from './unified-config-types';
import { CLIPROXY_PROVIDER_IDS } from '../cliproxy/provider-capabilities';
@@ -73,7 +74,8 @@ export function loadMigrationCheckData(): MigrationCheckData {
if (legacyConfig?.profiles && typeof legacyConfig.profiles === 'object' && unifiedConfig) {
const legacyProfiles = legacyConfig.profiles as Record<string, unknown>;
for (const profileName of Object.keys(legacyProfiles)) {
if (!unifiedConfig.profiles[profileName]) {
const targetProfileName = resolveAliasToCanonical(profileName);
if (!unifiedConfig.profiles[targetProfileName]) {
needsMigration = true;
break;
}
@@ -182,12 +184,40 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
// config.yaml only stores reference to the settings file
if (oldConfig?.profiles) {
for (const [name, settingsPath] of Object.entries(oldConfig.profiles)) {
const sourceName = name.trim();
const targetName = resolveAliasToCanonical(sourceName);
const pathStr = settingsPath as string;
const expandedPath = expandPath(pathStr);
const canonicalEntryValue = (oldConfig.profiles as Record<string, unknown>)[targetName];
const canonicalPathFromLegacyConfig =
sourceName !== targetName && typeof canonicalEntryValue === 'string'
? canonicalEntryValue
: undefined;
// Deterministic priority: explicit canonical profile wins over legacy alias rename.
if (canonicalPathFromLegacyConfig !== undefined) {
if (canonicalPathFromLegacyConfig !== pathStr) {
warnings.push(
`Skipped ${sourceName}: canonical profile "${targetName}" exists in config.json with different settings (${canonicalPathFromLegacyConfig})`
);
}
continue;
}
// Verify settings file exists
if (!fs.existsSync(expandedPath)) {
warnings.push(`Skipped ${name}: settings file not found at ${pathStr}`);
warnings.push(`Skipped ${sourceName}: settings file not found at ${pathStr}`);
continue;
}
if (unifiedConfig.profiles[targetName]) {
const existing = unifiedConfig.profiles[targetName].settings;
if (existing !== pathStr) {
warnings.push(
`Skipped ${sourceName}: target profile "${targetName}" already exists with different settings (${existing})`
);
}
continue;
}
@@ -196,8 +226,15 @@ export async function migrate(dryRun = false): Promise<MigrationResult> {
type: 'api',
settings: pathStr,
};
unifiedConfig.profiles[name] = profile;
migratedFiles.push(`config.json.profiles.${name} → config.yaml (settings: ${pathStr})`);
unifiedConfig.profiles[targetName] = profile;
migratedFiles.push(
`config.json.profiles.${sourceName} → config.yaml.profiles.${targetName} (settings: ${pathStr})`
);
if (targetName !== sourceName) {
warnings.push(
`Renamed legacy API profile "${sourceName}" to "${targetName}" (ccs kimi API profile is now ccs km)`
);
}
}
}
@@ -456,15 +493,33 @@ async function migrateProfilesToUnified(
// Migrate API profiles from config.json
for (const [name, settingsPath] of Object.entries(oldConfig.profiles)) {
const sourceName = name.trim();
const targetName = resolveAliasToCanonical(sourceName);
const pathStr = settingsPath as string;
const canonicalEntryValue = (oldConfig.profiles as Record<string, unknown>)[targetName];
const canonicalPathFromLegacyConfig =
sourceName !== targetName && typeof canonicalEntryValue === 'string'
? canonicalEntryValue
: undefined;
// Deterministic priority: explicit canonical profile wins over legacy alias rename.
if (canonicalPathFromLegacyConfig !== undefined) {
if (canonicalPathFromLegacyConfig !== pathStr) {
warnings.push(
`Skipped ${sourceName}: canonical profile "${targetName}" exists in config.json with different settings (${canonicalPathFromLegacyConfig})`
);
}
continue;
}
// H7: Detect collision - profile exists in both configs
if (unifiedConfig.profiles[name]) {
if (unifiedConfig.profiles[targetName]) {
// Check if settings differ (potential data loss)
const existingSettings = unifiedConfig.profiles[name].settings;
const existingSettings = unifiedConfig.profiles[targetName].settings;
if (existingSettings && existingSettings !== pathStr) {
warnings.push(
`Profile "${name}" exists in both configs with different settings - keeping existing (${existingSettings}), skipping legacy (${pathStr})`
`Profile "${targetName}" exists in both configs with different settings - keeping existing (${existingSettings}), skipping legacy ${sourceName} (${pathStr})`
);
}
continue;
@@ -474,16 +529,21 @@ async function migrateProfilesToUnified(
// Verify settings file exists
if (!fs.existsSync(expandedPath)) {
warnings.push(`Skipped ${name}: settings file not found at ${pathStr}`);
warnings.push(`Skipped ${sourceName}: settings file not found at ${pathStr}`);
continue;
}
// Store reference to settings file
unifiedConfig.profiles[name] = {
unifiedConfig.profiles[targetName] = {
type: 'api',
settings: pathStr,
};
migratedFiles.push(name);
migratedFiles.push(targetName);
if (targetName !== sourceName) {
warnings.push(
`Renamed legacy API profile "${sourceName}" to "${targetName}" (ccs kimi API profile is now ccs km)`
);
}
modified = true;
}
+1 -1
View File
@@ -328,7 +328,7 @@ export interface ProxyRemoteConfig {
* Remote proxy port.
* Optional - defaults based on protocol:
* - HTTPS: 443
* - HTTP: 80
* - HTTP: 8317
* When empty/undefined, uses protocol default.
*/
port?: number;
+3 -2
View File
@@ -15,6 +15,7 @@ import { CopilotStatus } from './types';
import { fail, info, ok } from '../utils/ui';
import { getWebSearchHookEnv } from '../utils/websearch-manager';
import { getImageAnalysisHookEnv } from '../utils/hooks';
import { stripClaudeCodeEnv } from '../utils/shell-executor';
/**
* Get full copilot status (auth + daemon).
@@ -138,14 +139,14 @@ export async function executeCopilotProfile(
// Merge with current environment (global env first, copilot overrides, then hook env vars)
const webSearchEnv = getWebSearchHookEnv();
const imageAnalysisEnv = getImageAnalysisHookEnv('copilot');
const env = {
const env = stripClaudeCodeEnv({
...process.env,
...globalEnv,
...copilotEnv,
...webSearchEnv,
...imageAnalysisEnv,
CCS_PROFILE_TYPE: 'copilot',
};
});
console.log(info(`Using GitHub Copilot proxy (model: ${config.model})`));
console.log('');
+123
View File
@@ -0,0 +1,123 @@
/**
* Copilot Usage Fetcher
*
* Fetches usage/quota data from copilot-api `/usage` endpoint and normalizes it
* for CLI and dashboard consumers.
*/
import * as http from 'http';
import type { CopilotQuotaSnapshot, CopilotUsage } from './types';
import { clampPercent } from '../utils/percentage';
interface RawCopilotQuotaSnapshot {
entitlement?: number;
remaining?: number;
percent_remaining?: number;
unlimited?: boolean;
}
interface RawCopilotUsage {
copilot_plan?: string;
quota_reset_date?: string;
quota_snapshots?: {
premium_interactions?: RawCopilotQuotaSnapshot;
chat?: RawCopilotQuotaSnapshot;
completions?: RawCopilotQuotaSnapshot;
};
}
function normalizeSnapshot(raw?: RawCopilotQuotaSnapshot): CopilotQuotaSnapshot {
const entitlement = Number(raw?.entitlement ?? 0);
const remaining = Number(raw?.remaining ?? 0);
const safeEntitlement = Number.isFinite(entitlement) && entitlement > 0 ? entitlement : 0;
const safeRemaining = Number.isFinite(remaining) ? Math.max(0, remaining) : 0;
const used = Math.max(0, safeEntitlement - safeRemaining);
const percentRemainingFromApi =
raw && typeof raw.percent_remaining === 'number' ? raw.percent_remaining : null;
const percentRemaining =
percentRemainingFromApi !== null
? clampPercent(percentRemainingFromApi)
: safeEntitlement > 0
? clampPercent((safeRemaining / safeEntitlement) * 100)
: 0;
return {
entitlement: safeEntitlement,
remaining: safeRemaining,
used,
percentRemaining,
percentUsed: clampPercent(100 - percentRemaining),
unlimited: Boolean(raw?.unlimited),
};
}
export function normalizeCopilotUsage(raw: unknown): CopilotUsage {
const usage = (raw || {}) as RawCopilotUsage;
const snapshots = usage.quota_snapshots || {};
return {
plan: usage.copilot_plan ?? null,
quotaResetDate: usage.quota_reset_date ?? null,
quotas: {
premiumInteractions: normalizeSnapshot(snapshots.premium_interactions),
chat: normalizeSnapshot(snapshots.chat),
completions: normalizeSnapshot(snapshots.completions),
},
};
}
/**
* Fetch Copilot usage from running copilot-api daemon.
*
* @returns normalized usage on success, null on daemon/network/parsing failure
*/
export async function fetchCopilotUsageFromDaemon(port: number): Promise<CopilotUsage | null> {
return new Promise((resolve) => {
const req = http.request(
{
hostname: '127.0.0.1',
port,
path: '/usage',
method: 'GET',
timeout: 5000,
},
(res) => {
let data = '';
res.on('data', (chunk) => {
data += chunk;
});
res.on('end', () => {
if (res.statusCode !== 200 || !data) {
resolve(null);
return;
}
try {
const parsed = JSON.parse(data) as unknown;
resolve(normalizeCopilotUsage(parsed));
} catch {
resolve(null);
}
});
}
);
req.on('error', () => {
resolve(null);
});
req.on('timeout', () => {
req.destroy();
resolve(null);
});
req.end();
});
}
export async function getCopilotUsage(port: number): Promise<CopilotUsage | null> {
return fetchCopilotUsageFromDaemon(port);
}
+7
View File
@@ -44,5 +44,12 @@ export {
getDefaultModel,
} from './copilot-models';
// Usage
export {
normalizeCopilotUsage,
fetchCopilotUsageFromDaemon,
getCopilotUsage,
} from './copilot-usage';
// Executor
export { getCopilotStatus, generateCopilotEnv, executeCopilotProfile } from './copilot-executor';
+33
View File
@@ -68,3 +68,36 @@ export interface CopilotDebugInfo {
authenticated?: boolean;
tokenPath?: string;
}
/**
* Quota snapshot from Copilot usage endpoint.
*/
export interface CopilotQuotaSnapshot {
/** Total quota allocation for this bucket */
entitlement: number;
/** Remaining quota count */
remaining: number;
/** Used quota count */
used: number;
/** Remaining quota percentage (0-100) */
percentRemaining: number;
/** Used quota percentage (0-100) */
percentUsed: number;
/** Whether quota is unlimited */
unlimited: boolean;
}
/**
* Normalized Copilot usage response used by CLI and dashboard.
*/
export interface CopilotUsage {
/** Copilot plan name (free/pro/business/enterprise) */
plan: string | null;
/** ISO date string when quota resets */
quotaResetDate: string | null;
quotas: {
premiumInteractions: CopilotQuotaSnapshot;
chat: CopilotQuotaSnapshot;
completions: CopilotQuotaSnapshot;
};
}
+1 -1
View File
@@ -320,7 +320,7 @@ export class DelegationHandler {
if (!profile) {
console.error(fail('No profile specified'));
console.error(' Usage: ccs <profile> -p "task"');
console.error(' Examples: ccs glm -p "task", ccs kimi -p "task"');
console.error(' Examples: ccs glm -p "task", ccs km -p "task"');
process.exit(1);
}
+17 -5
View File
@@ -16,6 +16,8 @@ import { type ExecutionOptions, type ExecutionResult, type StreamMessage } from
import { StreamBuffer, formatToolVerbose } from './executor/stream-parser';
import { buildExecutionResult } from './executor/result-aggregator';
import { getCcsDir, getModelDisplayName } from '../utils/config-manager';
import { getProfileLookupCandidates } from '../utils/profile-compat';
import { stripClaudeCodeEnv } from '../utils/shell-executor';
// Re-export types for consumers
export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executor/types';
@@ -26,7 +28,7 @@ export type { ExecutionOptions, ExecutionResult, StreamMessage } from './executo
export class HeadlessExecutor {
/**
* Execute task via headless Claude CLI
* @param profile - Profile name (glm, kimi, custom)
* @param profile - Profile name (glm, km, custom)
* @param enhancedPrompt - Enhanced prompt with context
* @param options - Execution options
* @returns execution result
@@ -63,13 +65,18 @@ export class HeadlessExecutor {
);
}
// Get settings path for profile
const settingsPath = path.join(getCcsDir(), `${profile}.settings.json`);
// Get settings path for profile (supports compatibility aliases like km -> kimi)
const ccsDir = getCcsDir();
const settingsCandidates = getProfileLookupCandidates(profile).map((candidate) =>
path.join(ccsDir, `${candidate}.settings.json`)
);
const settingsPath = settingsCandidates.find((candidatePath) => fs.existsSync(candidatePath));
const primarySettingsPath = path.join(ccsDir, `${profile}.settings.json`);
// Validate settings file exists
if (!fs.existsSync(settingsPath)) {
if (!settingsPath) {
throw new Error(
`Settings file not found: ${settingsPath}\nProfile "${profile}" may not be configured.`
`Settings file not found: ${primarySettingsPath}\nProfile "${profile}" may not be configured.`
);
}
@@ -201,10 +208,15 @@ export class HeadlessExecutor {
console.error(ui.info(`Delegating to ${modelName}...`));
}
// Strip Claude Code nested session guard env var to allow CCS delegation
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
const cleanEnv = stripClaudeCodeEnv(process.env);
const proc = spawn(claudeCli, args, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
timeout,
env: cleanEnv,
});
let stdout = '';
+3 -1
View File
@@ -5,7 +5,7 @@
import * as fs from 'fs';
import { spawn } from 'child_process';
import { getClaudeCliInfo } from '../../utils/claude-detector';
import { escapeShellArg } from '../../utils/shell-executor';
import { escapeShellArg, stripClaudeCodeEnv } from '../../utils/shell-executor';
import { ok, fail } from '../../utils/ui';
import { HealthCheck, IHealthChecker, createSpinner } from './types';
import { getCcsDir } from '../../utils/config-manager';
@@ -48,10 +48,12 @@ export class ClaudeCliChecker implements IHealthChecker {
stdio: 'pipe',
timeout: 5000,
shell: true,
env: stripClaudeCodeEnv(process.env),
})
: spawn(claudeCli, ['--version'], {
stdio: 'pipe',
timeout: 5000,
env: stripClaudeCodeEnv(process.env),
});
let output = '';
+280
View File
@@ -0,0 +1,280 @@
/**
* Shared provider preset catalog for CLI + Dashboard.
*
* Keep this file runtime-agnostic (no Node/browser APIs) so both
* backend and UI can import the same source of truth.
*/
export type PresetCategory = 'recommended' | 'alternative';
export const PROVIDER_PRESET_IDS = [
'openrouter',
'ollama',
'glm',
'glmt',
'km',
'foundry',
'mm',
'deepseek',
'qwen',
'ollama-cloud',
] as const;
export type ProviderPresetId = (typeof PROVIDER_PRESET_IDS)[number];
export interface ProviderPresetDefinition {
id: ProviderPresetId;
name: string;
description: string;
baseUrl: string;
defaultProfileName: string;
defaultModel: string;
apiKeyPlaceholder: string;
apiKeyHint: string;
category: PresetCategory;
requiresApiKey: boolean;
/** Additional env vars for thinking mode, etc. */
extraEnv?: Record<string, string>;
/** Enable always thinking mode. */
alwaysThinkingEnabled?: boolean;
/** UI metadata */
badge?: string;
featured?: boolean;
icon?: string;
}
export const OPENROUTER_BASE_URL = 'https://openrouter.ai/api';
/**
* Legacy aliases mapped to canonical preset IDs.
* Keep this minimal and explicit to avoid hidden implicit behavior.
*/
export const PROVIDER_PRESET_ALIASES: Readonly<Record<string, ProviderPresetId>> = Object.freeze({
kimi: 'km',
});
const RAW_PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = [
{
id: 'openrouter',
name: 'OpenRouter',
description: '349+ models from OpenAI, Anthropic, Google, Meta',
baseUrl: OPENROUTER_BASE_URL,
defaultProfileName: 'openrouter',
defaultModel: 'anthropic/claude-opus-4.5',
apiKeyPlaceholder: 'sk-or-...',
apiKeyHint: 'Get your API key at openrouter.ai/keys',
category: 'recommended',
requiresApiKey: true,
badge: '349+ models',
featured: true,
icon: '/icons/openrouter.svg',
},
{
id: 'ollama',
name: 'Ollama (Local)',
description: 'Local open-source models via Ollama (32K+ context)',
baseUrl: 'http://localhost:11434',
defaultProfileName: 'ollama',
defaultModel: 'qwen3-coder',
apiKeyPlaceholder: 'ollama',
apiKeyHint: 'Install Ollama from ollama.com - no API key needed for local',
category: 'recommended',
requiresApiKey: false,
badge: 'Local',
featured: true,
icon: '/icons/ollama.svg',
},
{
id: 'glm',
name: 'GLM',
description: 'Claude via Z.AI',
baseUrl: 'https://api.z.ai/api/anthropic',
defaultProfileName: 'glm',
defaultModel: 'glm-5',
apiKeyPlaceholder: 'ghp_...',
apiKeyHint: 'Get your API key from Z.AI',
category: 'alternative',
requiresApiKey: true,
badge: 'Z.AI',
icon: '/icons/zai.svg',
},
{
id: 'glmt',
name: 'GLMT',
description: 'GLM with Thinking mode support',
baseUrl: 'https://api.z.ai/api/coding/paas/v4/chat/completions',
defaultProfileName: 'glmt',
defaultModel: 'glm-5',
apiKeyPlaceholder: 'ghp_...',
apiKeyHint: 'Same API key as GLM',
category: 'alternative',
requiresApiKey: true,
extraEnv: {
ANTHROPIC_TEMPERATURE: '0.2',
ANTHROPIC_MAX_TOKENS: '65536',
MAX_THINKING_TOKENS: '32768',
ENABLE_STREAMING: 'true',
ANTHROPIC_SAFE_MODE: 'false',
API_TIMEOUT_MS: '3000000',
},
alwaysThinkingEnabled: true,
badge: 'Thinking',
icon: '/icons/zai.svg',
},
{
id: 'km',
name: 'Kimi',
description: 'Moonshot AI - Fast reasoning model',
baseUrl: 'https://api.kimi.com/coding/',
defaultProfileName: 'km',
defaultModel: 'kimi-k2-thinking-turbo',
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key from Moonshot AI',
category: 'alternative',
requiresApiKey: true,
alwaysThinkingEnabled: true,
badge: 'Reasoning',
icon: '/icons/kimi.svg',
},
{
id: 'foundry',
name: 'Azure Foundry',
description: 'Claude via Microsoft Azure AI Foundry',
baseUrl: 'https://<your-resource>.services.ai.azure.com/api/anthropic',
defaultProfileName: 'foundry',
defaultModel: 'claude-sonnet-4-5',
apiKeyPlaceholder: 'YOUR_AZURE_API_KEY',
apiKeyHint: 'Create resource at ai.azure.com, get API key from Keys tab',
category: 'alternative',
requiresApiKey: true,
badge: 'Azure',
icon: '/icons/azure.svg',
},
{
id: 'mm',
name: 'Minimax',
description: 'M2.1/M2.1-lightning/M2 - multilang coding (1M context)',
baseUrl: 'https://api.minimax.io/anthropic',
defaultProfileName: 'mm',
defaultModel: 'MiniMax-M2.1',
apiKeyPlaceholder: 'YOUR_MINIMAX_API_KEY_HERE',
apiKeyHint: 'Get your API key at platform.minimax.io',
category: 'alternative',
requiresApiKey: true,
badge: '1M context',
icon: '/icons/minimax.svg',
},
{
id: 'deepseek',
name: 'DeepSeek',
description: 'V3.2 and R1 reasoning model (128K context)',
baseUrl: 'https://api.deepseek.com/anthropic',
defaultProfileName: 'deepseek',
defaultModel: 'deepseek-chat',
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key at platform.deepseek.com',
category: 'alternative',
requiresApiKey: true,
badge: 'Reasoning',
icon: '/icons/deepseek.svg',
},
{
id: 'qwen',
name: 'Qwen',
description: 'Alibaba Cloud - Qwen3 models (256K-1M context, thinking support)',
baseUrl: 'https://dashscope-intl.aliyuncs.com/apps/anthropic',
defaultProfileName: 'qwen',
defaultModel: 'qwen3-coder-plus',
apiKeyPlaceholder: 'sk-...',
apiKeyHint: 'Get your API key from Alibaba Cloud Model Studio',
category: 'alternative',
requiresApiKey: true,
badge: 'Alibaba',
icon: '/assets/providers/qwen-color.svg',
},
{
id: 'ollama-cloud',
name: 'Ollama Cloud',
description: 'Ollama cloud models via direct API (glm-5:cloud, minimax-m2.1:cloud)',
baseUrl: 'https://ollama.com',
defaultProfileName: 'ollama-cloud',
defaultModel: 'glm-5:cloud',
apiKeyPlaceholder: 'YOUR_OLLAMA_CLOUD_API_KEY',
apiKeyHint: 'Get your API key at ollama.com',
category: 'alternative',
requiresApiKey: true,
badge: 'Cloud',
icon: '/icons/ollama.svg',
},
];
function clonePresetDefinition(preset: ProviderPresetDefinition): ProviderPresetDefinition {
return {
...preset,
extraEnv: preset.extraEnv ? { ...preset.extraEnv } : undefined,
};
}
function freezePresetDefinition(preset: ProviderPresetDefinition): ProviderPresetDefinition {
const cloned = clonePresetDefinition(preset);
if (cloned.extraEnv) {
Object.freeze(cloned.extraEnv);
}
return Object.freeze(cloned);
}
function assertProviderPresetCatalogIntegrity(
definitions: readonly ProviderPresetDefinition[],
aliases: Readonly<Record<string, ProviderPresetId>>
): void {
const presetIdSet = new Set<string>();
for (const definition of definitions) {
const normalizedId = definition.id.trim().toLowerCase();
if (definition.id !== normalizedId) {
throw new Error(`Preset ID must be normalized: "${definition.id}"`);
}
if (presetIdSet.has(definition.id)) {
throw new Error(`Duplicate preset ID detected: "${definition.id}"`);
}
presetIdSet.add(definition.id);
}
const normalizedAliasSet = new Set<string>();
for (const [alias, target] of Object.entries(aliases)) {
const normalizedAlias = alias.trim().toLowerCase();
if (!normalizedAlias) {
throw new Error('Preset alias keys cannot be empty');
}
if (alias !== normalizedAlias) {
throw new Error(`Preset alias must be normalized: "${alias}"`);
}
if (normalizedAliasSet.has(normalizedAlias)) {
throw new Error(`Duplicate normalized preset alias detected: "${alias}"`);
}
normalizedAliasSet.add(normalizedAlias);
if (!presetIdSet.has(target)) {
throw new Error(`Preset alias "${alias}" points to unknown target "${target}"`);
}
if (presetIdSet.has(normalizedAlias)) {
throw new Error(
`Preset alias "${alias}" collides with canonical preset ID "${normalizedAlias}"`
);
}
}
}
assertProviderPresetCatalogIntegrity(RAW_PROVIDER_PRESET_DEFINITIONS, PROVIDER_PRESET_ALIASES);
export const PROVIDER_PRESET_DEFINITIONS: readonly ProviderPresetDefinition[] = Object.freeze(
RAW_PROVIDER_PRESET_DEFINITIONS.map(freezePresetDefinition)
);
export function createProviderPresetDefinitions(): ProviderPresetDefinition[] {
return PROVIDER_PRESET_DEFINITIONS.map(clonePresetDefinition);
}
export function normalizeProviderPresetId(id: string): string {
const normalized = id.trim().toLowerCase();
return PROVIDER_PRESET_ALIASES[normalized] || normalized;
}
+2 -2
View File
@@ -9,7 +9,7 @@ import { spawn, ChildProcess } from 'child_process';
import { TargetAdapter, TargetBinaryInfo, TargetCredentials, TargetType } from './target-adapter';
import { detectClaudeCli, getClaudeCliInfo } from '../utils/claude-detector';
import type { ProfileType } from '../types/profile';
import { escapeShellArg, stripAnthropicEnv } from '../utils/shell-executor';
import { escapeShellArg, stripAnthropicEnv, stripClaudeCodeEnv } from '../utils/shell-executor';
import { ErrorManager } from '../utils/error-manager';
import { getWebSearchHookEnv } from '../utils/websearch-manager';
import { wireChildProcessSignals } from '../utils/signal-forwarder';
@@ -56,7 +56,7 @@ export class ClaudeAdapter implements TargetAdapter {
if (creds.apiKey) env['ANTHROPIC_AUTH_TOKEN'] = creds.apiKey;
if (creds.model) env['ANTHROPIC_MODEL'] = creds.model;
return env;
return stripClaudeCodeEnv(env);
}
exec(
+3 -2
View File
@@ -6,7 +6,7 @@
*/
import { spawn, ChildProcess, SpawnOptions } from 'child_process';
import { escapeShellArg } from './shell-executor';
import { escapeShellArg, stripClaudeCodeEnv } from './shell-executor';
import { getClaudeCliInfo } from './claude-detector';
import { ErrorManager } from './error-manager';
@@ -46,7 +46,8 @@ export function spawnClaude(options: SpawnClaudeOptions = {}): SpawnClaudeResult
const { args = [], env, cwd, stdio = 'inherit' } = options;
// Merge environment
const mergedEnv = env ? { ...process.env, ...env } : process.env;
const mergedEnvBase = env ? { ...process.env, ...env } : process.env;
const mergedEnv = stripClaudeCodeEnv(mergedEnvBase);
let child: ChildProcess;
if (needsShell) {
+10 -4
View File
@@ -5,6 +5,7 @@ import * as path from 'path';
import { Settings } from '../types';
import { ValidationResult } from '../types/utils';
import { getCcsDir } from './config-manager';
import { getProfileLookupCandidates } from './profile-compat';
/**
* Extended validation result for delegation profiles
@@ -24,19 +25,24 @@ interface DelegationValidationResult extends ValidationResult {
export class DelegationValidator {
/**
* Validate a delegation profile
* @param profileName - Name of profile to validate (e.g., 'glm', 'kimi')
* @param profileName - Name of profile to validate (e.g., 'glm', 'km')
* @returns Validation result { valid: boolean, error?: string, settingsPath?: string }
*/
static validate(profileName: string): DelegationValidationResult {
const settingsPath = path.join(getCcsDir(), `${profileName}.settings.json`);
const ccsDir = getCcsDir();
const candidateSettingsPath = getProfileLookupCandidates(profileName)
.map((candidate) => path.join(ccsDir, `${candidate}.settings.json`))
.find((candidatePath) => fs.existsSync(candidatePath));
const primarySettingsPath = path.join(ccsDir, `${profileName}.settings.json`);
const settingsPath = candidateSettingsPath || primarySettingsPath;
// Check if profile directory exists
if (!fs.existsSync(settingsPath)) {
if (!candidateSettingsPath) {
return {
valid: false,
error: `Profile not found: ${profileName}`,
suggestion:
`Profile settings missing at: ${settingsPath}\n\n` +
`Profile settings missing at: ${primarySettingsPath}\n\n` +
`To set up ${profileName} profile:\n` +
` 1. Copy base settings: cp config/base-${profileName}.settings.json ~/.ccs/${profileName}.settings.json\n` +
` 2. Edit settings: Edit ~/.ccs/${profileName}.settings.json\n` +
+7
View File
@@ -0,0 +1,7 @@
/**
* Clamp percentage-like values to a safe 0-100 range.
*/
export function clampPercent(value: number): number {
if (!Number.isFinite(value)) return 0;
return Math.max(0, Math.min(100, value));
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Profile compatibility helpers for renamed commands/profiles.
*
* Current compatibility mappings:
* - `km` is the canonical Kimi API profile command
* - `kimi` remains as legacy API profile name in existing user configs
*/
const PROFILE_COMPAT_ALIASES: Readonly<Record<string, readonly string[]>> = Object.freeze({
km: ['kimi'],
});
/**
* Resolve a legacy alias to its canonical profile name.
* Returns trimmed input when no alias mapping exists.
*/
export function resolveAliasToCanonical(profileName: string): string {
const raw = profileName.trim();
const normalized = raw.toLowerCase();
for (const [canonical, aliases] of Object.entries(PROFILE_COMPAT_ALIASES)) {
if (aliases.includes(normalized)) {
return canonical;
}
}
return raw;
}
/**
* Build lookup candidates for a profile.
* Order: exact input -> lowercase form (if different) -> legacy aliases.
*/
export function getProfileLookupCandidates(profileName: string): string[] {
const raw = profileName.trim();
const normalized = raw.toLowerCase();
const aliases = PROFILE_COMPAT_ALIASES[normalized] || [];
const ordered = [raw, normalized, ...aliases];
return [...new Set(ordered.filter(Boolean))];
}
/**
* Check whether a resolved profile name came from a legacy alias.
*/
export function isLegacyProfileAlias(requestedName: string, resolvedName: string): boolean {
const requestedNormalized = requestedName.trim().toLowerCase();
const resolvedNormalized = resolvedName.trim().toLowerCase();
if (requestedNormalized === resolvedNormalized) {
return false;
}
const aliases = PROFILE_COMPAT_ALIASES[requestedNormalized] || [];
return aliases.includes(resolvedNormalized);
}
+21 -1
View File
@@ -24,6 +24,22 @@ export function stripAnthropicEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
return result;
}
/**
* Strip Claude Code nested-session guard env var from a process environment.
*
* Note: Windows env keys are case-insensitive, so remove case-insensitively
* to avoid missing variants like `claudecode`.
*/
export function stripClaudeCodeEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
const result: NodeJS.ProcessEnv = {};
for (const key of Object.keys(env)) {
if (key.toUpperCase() !== 'CLAUDECODE') {
result[key] = env[key];
}
}
return result;
}
/**
* Escape arguments for shell execution (cross-platform)
*
@@ -80,10 +96,14 @@ export function execClaude(
: process.env;
// Prepare environment (merge with base env if envVars provided)
const env = envVars
const mergedEnv = envVars
? { ...baseEnv, ...envVars, ...webSearchEnv }
: { ...baseEnv, ...webSearchEnv };
// Strip Claude Code nested session guard env var to allow CCS delegation
// (Claude Code v2.1.39+ sets CLAUDECODE to detect nested sessions)
const env = stripClaudeCodeEnv(mergedEnv);
// propagate key env vars to tmux session so agent team teammates
// (spawned via tmux split-window) inherit the correct config dir
if (process.env.TMUX && envVars) {
+47 -44
View File
@@ -87,24 +87,23 @@ export function checkConfigFile(): HealthCheck {
}
/**
* Check settings files (glm, kimi)
* Check settings files (glm, km with legacy kimi fallback)
*/
export function checkSettingsFiles(ccsDir: string): HealthCheck[] {
const checks: HealthCheck[] = [];
const files = [
{ name: 'glm.settings.json', profile: 'glm' },
{ name: 'kimi.settings.json', profile: 'kimi' },
];
const profiles = ['glm', 'km'];
const { DelegationValidator } = require('../../utils/delegation-validator');
for (const file of files) {
const filePath = path.join(ccsDir, file.name);
for (const profile of profiles) {
const fileName = `${profile}.settings.json`;
const filePath = path.join(ccsDir, fileName);
const validation = DelegationValidator.validate(profile);
if (!fs.existsSync(filePath)) {
if (!validation.valid && validation.error?.includes('Profile not found')) {
checks.push({
id: `settings-${file.profile}`,
name: file.name,
id: `settings-${profile}`,
name: fileName,
status: 'info',
message: 'Not configured',
details: filePath,
@@ -112,46 +111,50 @@ export function checkSettingsFiles(ccsDir: string): HealthCheck[] {
continue;
}
try {
const content = fs.readFileSync(filePath, 'utf8');
JSON.parse(content);
const resolvedPath = validation.settingsPath || filePath;
const resolvedName = path.basename(resolvedPath);
const validation = DelegationValidator.validate(file.profile);
if (validation.valid) {
checks.push({
id: `settings-${file.profile}`,
name: file.name,
status: 'ok',
message: 'Key configured',
details: filePath,
});
} else if (validation.error && validation.error.includes('placeholder')) {
checks.push({
id: `settings-${file.profile}`,
name: file.name,
status: 'warning',
message: 'Placeholder key',
details: filePath,
});
} else {
checks.push({
id: `settings-${file.profile}`,
name: file.name,
status: 'ok',
message: 'Valid JSON',
details: filePath,
});
}
} catch {
if (validation.valid) {
checks.push({
id: `settings-${file.profile}`,
name: file.name,
id: `settings-${profile}`,
name: resolvedName,
status: 'ok',
message: 'Key configured',
details: resolvedPath,
});
continue;
}
if (validation.error?.includes('placeholder')) {
checks.push({
id: `settings-${profile}`,
name: resolvedName,
status: 'warning',
message: 'Placeholder key',
details: resolvedPath,
});
continue;
}
if (validation.error?.includes('Failed to parse settings.json')) {
checks.push({
id: `settings-${profile}`,
name: resolvedName,
status: 'error',
message: 'Invalid JSON',
details: filePath,
details: resolvedPath,
});
continue;
}
// Keep prior behavior for non-placeholder validation issues (e.g., missing key).
checks.push({
id: `settings-${profile}`,
name: resolvedName,
status: 'ok',
message: 'Valid JSON',
details: resolvedPath,
});
}
return checks;
+30 -17
View File
@@ -450,37 +450,50 @@ const PRICING_REGISTRY: Record<string, ModelPricing> = {
},
// ---------------------------------------------------------------------------
// Kimi Models (Moonshot AI) - Source: better-ccusage
// Kimi Models (Moonshot AI) - Source: Official Kimi Platform pricing
// inputPerMillion = cache miss price, cacheReadPerMillion = cache hit price
// ---------------------------------------------------------------------------
'kimi-for-coding': {
inputPerMillion: 0.15,
outputPerMillion: 0.6,
'kimi-k2.5': {
inputPerMillion: 0.6,
outputPerMillion: 3.0,
cacheCreationPerMillion: 0.0,
cacheReadPerMillion: 0.0,
cacheReadPerMillion: 0.1,
},
'kimi-for-coding': {
inputPerMillion: 0.6,
outputPerMillion: 2.5,
cacheCreationPerMillion: 0.0,
cacheReadPerMillion: 0.15,
},
'kimi-k2-0905-preview': {
inputPerMillion: 0.15,
outputPerMillion: 0.6,
inputPerMillion: 0.6,
outputPerMillion: 2.5,
cacheCreationPerMillion: 0.0,
cacheReadPerMillion: 0.0,
cacheReadPerMillion: 0.15,
},
'kimi-k2-turbo-preview': {
inputPerMillion: 0.15,
outputPerMillion: 1.15,
inputPerMillion: 1.15,
outputPerMillion: 8.0,
cacheCreationPerMillion: 0.0,
cacheReadPerMillion: 0.0,
cacheReadPerMillion: 0.15,
},
'kimi-k2-thinking': {
inputPerMillion: 0.15,
outputPerMillion: 0.6,
inputPerMillion: 0.6,
outputPerMillion: 2.5,
cacheCreationPerMillion: 0.0,
cacheReadPerMillion: 0.0,
cacheReadPerMillion: 0.15,
},
'kimi-k2-thinking-turbo': {
inputPerMillion: 0.15,
outputPerMillion: 1.15,
inputPerMillion: 1.15,
outputPerMillion: 8.0,
cacheCreationPerMillion: 0.0,
cacheReadPerMillion: 0.0,
cacheReadPerMillion: 0.15,
},
'kimi-k2': {
inputPerMillion: 0.6,
outputPerMillion: 2.5,
cacheCreationPerMillion: 0.0,
cacheReadPerMillion: 0.15,
},
'kimi-k2-instruct': {
inputPerMillion: 1.0,
+65 -4
View File
@@ -15,8 +15,13 @@ import {
import { fetchAccountQuota } from '../../cliproxy/quota-fetcher';
import { fetchCodexQuota } from '../../cliproxy/quota-fetcher-codex';
import { fetchGeminiCliQuota } from '../../cliproxy/quota-fetcher-gemini-cli';
import { fetchGhcpQuota } from '../../cliproxy/quota-fetcher-ghcp';
import { getCachedQuota, setCachedQuota } from '../../cliproxy/quota-response-cache';
import type { CodexQuotaResult, GeminiCliQuotaResult } from '../../cliproxy/quota-types';
import type {
CodexQuotaResult,
GeminiCliQuotaResult,
GhcpQuotaResult,
} from '../../cliproxy/quota-types';
import type { QuotaResult } from '../../cliproxy/quota-fetcher';
import type { CLIProxyProvider } from '../../cliproxy/types';
import { CLIPROXY_PROFILES } from '../../auth/profile-detector';
@@ -43,6 +48,7 @@ import {
DEFAULT_BACKEND,
} from '../../cliproxy/platform-detector';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { CLIPROXY_DEFAULT_PORT } from '../../cliproxy/config/port-manager';
const router = Router();
@@ -77,6 +83,20 @@ function shouldCacheGeminiQuotaResult(result: GeminiCliQuotaResult): boolean {
return false;
}
function shouldCacheGhcpQuotaResult(result: GhcpQuotaResult): boolean {
if (result.success) return true;
if (result.needsReauth) return true;
const msg = (result.error || '').toLowerCase();
if (!msg) return false;
if (msg.includes('timeout')) return false;
if (msg.includes('rate limited')) return false;
if (msg.includes('api error: 5')) return false;
if (msg.includes('fetch failed')) return false;
return false;
}
/** Get configured backend from config */
function getConfiguredBackend() {
try {
@@ -139,7 +159,7 @@ const handleStatsRequest = async (_req: Request, res: Response): Promise<void> =
if (!running) {
res.status(503).json({
error: 'CLIProxy Plus not running',
message: 'Start a CLIProxy session (gemini, codex, agy) to collect stats',
message: 'Start a CLIProxy session (gemini, codex, agy, ghcp) to collect stats',
});
return;
}
@@ -208,7 +228,7 @@ router.get('/proxy-status', async (_req: Request, res: Response): Promise<void>
// Proxy running but no session lock - legacy/untracked instance
res.json({
running: true,
port: 8317, // Default port
port: CLIPROXY_DEFAULT_PORT,
sessionCount: 0, // Unknown sessions
// No pid/startedAt since we don't have session lock
});
@@ -631,10 +651,51 @@ router.get('/quota/gemini/:accountId', async (req: Request, res: Response): Prom
}
});
/**
* GET /api/cliproxy/quota/ghcp/:accountId - Get GitHub Copilot (ghcp) quota for a specific account
* Returns: GhcpQuotaResult with premium/chat/completions quota snapshots
* Caching: 2 minute TTL to reduce GitHub API calls
*/
router.get('/quota/ghcp/:accountId', async (req: Request, res: Response): Promise<void> => {
const { accountId } = req.params;
// Validate accountId - prevent path traversal
if (
!accountId ||
accountId.includes('..') ||
accountId.includes('/') ||
accountId.includes('\\')
) {
res.status(400).json({ error: 'Invalid account ID' });
return;
}
try {
// Check cache first
const cached = getCachedQuota<GhcpQuotaResult>('ghcp', accountId);
if (cached) {
res.json({ ...cached, cached: true });
return;
}
// Fetch from GitHub API
const result = await fetchGhcpQuota(accountId);
// Cache successful and stable failure states; skip transient network failures.
if (shouldCacheGhcpQuotaResult(result)) {
setCachedQuota('ghcp', accountId, result);
}
res.json(result);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/cliproxy/quota/:provider/:accountId - Get quota for a specific account (generic)
* Returns: QuotaResult with model quotas and reset times
* NOTE: This generic route MUST come after specific routes (codex, gemini) to avoid matching them
* NOTE: This generic route MUST come after specific routes (codex, gemini, ghcp)
* Caching: 2 minute TTL to reduce external API calls
*/
router.get('/quota/:provider/:accountId', async (req: Request, res: Response): Promise<void> => {
+34
View File
@@ -7,6 +7,8 @@ import {
checkAuthStatus as checkCopilotAuth,
startAuthFlow as startCopilotAuth,
getCopilotStatus,
getCopilotUsage,
isDaemonRunning,
startDaemon as startCopilotDaemon,
stopDaemon as stopCopilotDaemon,
getAvailableModels as getCopilotModels,
@@ -152,6 +154,38 @@ router.get('/models', async (_req: Request, res: Response): Promise<void> => {
}
});
/**
* GET /api/copilot/usage - Get Copilot quota usage from copilot-api /usage endpoint
*/
router.get('/usage', async (_req: Request, res: Response): Promise<void> => {
try {
const config = loadOrCreateUnifiedConfig();
const port = config.copilot?.port ?? DEFAULT_COPILOT_CONFIG.port;
const daemonRunning = await isDaemonRunning(port);
if (!daemonRunning) {
res.status(503).json({
error: 'copilot-api daemon is not running',
message: 'Start daemon first: ccs copilot start',
});
return;
}
const usage = await getCopilotUsage(port);
if (!usage) {
res.status(503).json({
error: 'Failed to fetch Copilot usage',
message: 'copilot-api /usage endpoint is unavailable',
});
return;
}
res.json(usage);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/copilot/daemon/start - Start copilot-api daemon
*/
+60 -7
View File
@@ -26,6 +26,20 @@ import { validateFilePath } from './route-helpers';
const router = Router();
export function resolveThinkingProviderOverridesForSave(
currentProviderOverrides: ThinkingConfig['provider_overrides'] | undefined,
updatesProviderOverrides: Record<string, Partial<ThinkingConfig['tier_defaults']>> | undefined,
shouldClearProviderOverrides: boolean
): ThinkingConfig['provider_overrides'] | undefined {
if (shouldClearProviderOverrides) {
return undefined;
}
if (updatesProviderOverrides !== undefined) {
return updatesProviderOverrides;
}
return currentProviderOverrides;
}
// ==================== Generic File API ====================
/**
@@ -261,8 +275,17 @@ router.get('/thinking', (_req: Request, res: Response): void => {
*/
router.put('/thinking', (req: Request, res: Response): void => {
try {
const { lastModified, ...updates } = req.body as Partial<ThinkingConfig> & {
const {
lastModified,
clear_override: clearOverrideFlag,
clear_provider_overrides: clearProviderOverridesFlag,
...updates
} = req.body as Omit<Partial<ThinkingConfig>, 'override' | 'provider_overrides'> & {
lastModified?: number;
override?: string | number | null;
provider_overrides?: Record<string, Partial<ThinkingConfig['tier_defaults']>> | null;
clear_override?: boolean;
clear_provider_overrides?: boolean;
};
// W4: Optimistic locking - check if file was modified since last read
@@ -282,18 +305,30 @@ router.put('/thinking', (req: Request, res: Response): void => {
}
const config = loadOrCreateUnifiedConfig();
const shouldClearOverride = clearOverrideFlag === true || updates.override === null;
const shouldClearProviderOverrides =
clearProviderOverridesFlag === true || updates.provider_overrides === null;
let normalizedOverride: string | number | undefined = config.thinking?.override as
| string
| number
| undefined;
let normalizedProviderOverrides:
| Record<string, Partial<ThinkingConfig['tier_defaults']>>
| undefined;
// Validate mode if provided
if (updates.mode !== undefined) {
const validModes = ['auto', 'off', 'manual'];
if (!validModes.includes(updates.mode)) {
const normalizedMode = updates.mode.toLowerCase().trim();
if (!validModes.includes(normalizedMode)) {
res.status(400).json({ error: `Invalid mode: must be one of ${validModes.join(', ')}` });
return;
}
updates.mode = normalizedMode as ThinkingConfig['mode'];
}
// Validate override if provided (budget or level)
if (updates.override !== undefined) {
if (updates.override !== undefined && updates.override !== null) {
// C3: Reject objects/arrays - only number or string allowed
if (typeof updates.override !== 'number' && typeof updates.override !== 'string') {
res.status(400).json({
@@ -313,6 +348,7 @@ router.put('/thinking', (req: Request, res: Response): void => {
});
return;
}
normalizedOverride = updates.override;
} else if (typeof updates.override === 'string') {
const normalizedValue = updates.override.toLowerCase().trim();
const validValues = [...VALID_THINKING_LEVELS, ...THINKING_OFF_VALUES] as readonly string[];
@@ -322,6 +358,7 @@ router.put('/thinking', (req: Request, res: Response): void => {
});
return;
}
normalizedOverride = normalizedValue === '0' ? 'off' : normalizedValue;
}
}
@@ -351,7 +388,7 @@ router.put('/thinking', (req: Request, res: Response): void => {
}
// C4: Validate provider_overrides if provided (nested structure: Record<string, Partial<ThinkingTierDefaults>>)
if (updates.provider_overrides !== undefined) {
if (updates.provider_overrides !== undefined && updates.provider_overrides !== null) {
if (
typeof updates.provider_overrides !== 'object' ||
updates.provider_overrides === null ||
@@ -362,6 +399,7 @@ router.put('/thinking', (req: Request, res: Response): void => {
}
const validLevels = [...VALID_THINKING_LEVELS] as string[];
const validTiers = [...VALID_THINKING_TIERS] as string[];
const sanitizedOverrides: Record<string, Partial<ThinkingConfig['tier_defaults']>> = {};
for (const [provider, tierOverrides] of Object.entries(updates.provider_overrides)) {
if (typeof provider !== 'string' || provider.trim() === '') {
res
@@ -383,26 +421,41 @@ router.put('/thinking', (req: Request, res: Response): void => {
});
return;
}
if (typeof level !== 'string' || !validLevels.includes(level)) {
if (typeof level !== 'string' || !validLevels.includes(level.toLowerCase().trim())) {
res.status(400).json({
error: `Invalid level for provider_overrides.${provider}.${tier}: must be one of ${validLevels.join(', ')}`,
});
return;
}
const normalizedProvider = provider.trim().toLowerCase();
const normalizedTier = tier.trim().toLowerCase() as keyof ThinkingConfig['tier_defaults'];
const normalizedLevel = level.toLowerCase().trim();
sanitizedOverrides[normalizedProvider] = sanitizedOverrides[normalizedProvider] ?? {};
sanitizedOverrides[normalizedProvider][normalizedTier] = normalizedLevel;
}
}
normalizedProviderOverrides =
Object.keys(sanitizedOverrides).length > 0 ? sanitizedOverrides : undefined;
}
// Update thinking section
config.thinking = {
mode: updates.mode ?? config.thinking?.mode ?? 'auto',
override: updates.override ?? config.thinking?.override,
override: shouldClearOverride
? undefined
: updates.override !== undefined
? normalizedOverride
: config.thinking?.override,
tier_defaults: {
opus: updates.tier_defaults?.opus ?? config.thinking?.tier_defaults?.opus ?? 'high',
sonnet: updates.tier_defaults?.sonnet ?? config.thinking?.tier_defaults?.sonnet ?? 'medium',
haiku: updates.tier_defaults?.haiku ?? config.thinking?.tier_defaults?.haiku ?? 'low',
},
provider_overrides: updates.provider_overrides ?? config.thinking?.provider_overrides,
provider_overrides: resolveThinkingProviderOverridesForSave(
config.thinking?.provider_overrides,
updates.provider_overrides !== undefined ? normalizedProviderOverrides : undefined,
shouldClearProviderOverrides
),
show_warnings: updates.show_warnings ?? config.thinking?.show_warnings ?? true,
};
+28
View File
@@ -0,0 +1,28 @@
import { describe, expect, it } from 'bun:test';
import { getPresetById, isValidPresetId } from '../../../src/api/services/provider-presets';
describe('provider-presets', () => {
it('resolves canonical km preset id', () => {
const preset = getPresetById('km');
expect(preset?.id).toBe('km');
});
it('resolves legacy kimi preset alias to km', () => {
const preset = getPresetById('kimi');
expect(preset?.id).toBe('km');
});
it('resolves preset id with extra whitespace', () => {
const preset = getPresetById(' km ');
expect(preset?.id).toBe('km');
});
it('resolves uppercase legacy alias', () => {
const preset = getPresetById('KIMI');
expect(preset?.id).toBe('km');
});
it('treats legacy kimi alias as a valid preset id', () => {
expect(isValidPresetId('kimi')).toBe(true);
});
});
+37
View File
@@ -120,6 +120,43 @@ describe('ProfileDetector', () => {
}
});
it('should resolve km to legacy kimi API profile from unified config', () => {
const settingsPath = path.join(tempDir, 'kimi.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({ env: { ANTHROPIC_MODEL: 'kimi-k2-thinking-turbo' } })
);
const mockUnifiedConfig = {
version: 2,
profiles: {
kimi: { settings: settingsPath, type: 'api' },
},
};
const isUnifiedModeSpy = spyOn(unifiedConfigLoader, 'isUnifiedMode').mockReturnValue(true);
const loadUnifiedConfigSpy = spyOn(unifiedConfigLoader, 'loadUnifiedConfig').mockReturnValue(
mockUnifiedConfig as any
);
try {
const result = detector.detectProfileType('km');
expect(result.type).toBe('settings');
expect(result.name).toBe('km');
expect(result.settingsPath).toBe(settingsPath);
expect(result.env).toEqual({ ANTHROPIC_MODEL: 'kimi-k2-thinking-turbo' });
} finally {
isUnifiedModeSpy.mockRestore();
loadUnifiedConfigSpy.mockRestore();
}
});
it('should keep ccs kimi mapped to CLIProxy provider', () => {
const result = detector.detectProfileType('kimi');
expect(result.type).toBe('cliproxy');
expect(result.provider).toBe('kimi');
});
it('should detect account-based profile from unified config', () => {
const mockUnifiedConfig = {
version: 2,
@@ -0,0 +1,58 @@
/**
* Default Port Sync Test
*
* Keeps backend and UI default ports in sync while allowing independent modules.
*/
import { describe, expect, test } from 'bun:test';
import { CLIPROXY_DEFAULT_PORT as BACKEND_CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager';
import { DEFAULT_CURSOR_PORT as BACKEND_CURSOR_DEFAULT_PORT } from '../../../src/cursor/cursor-models';
import {
CLIPROXY_PROVIDER_IDS as BACKEND_CLIPROXY_PROVIDER_IDS,
getProviderDescription as getBackendProviderDescription,
getProviderDisplayName as getBackendProviderDisplayName,
getProvidersByOAuthFlow,
} from '../../../src/cliproxy/provider-capabilities';
import {
CLIPROXY_DEFAULT_PORT as UI_CLIPROXY_DEFAULT_PORT,
DEFAULT_CURSOR_PORT as UI_CURSOR_DEFAULT_PORT,
} from '../../../ui/src/lib/default-ports';
import {
CLIPROXY_PROVIDERS as UI_CLIPROXY_PROVIDERS,
DEVICE_CODE_PROVIDERS as UI_DEVICE_CODE_PROVIDERS,
PROVIDER_METADATA as UI_PROVIDER_METADATA,
} from '../../../ui/src/lib/provider-config';
function sorted(values: readonly string[]): string[] {
return [...values].sort((a, b) => a.localeCompare(b));
}
describe('Default Port Sync', () => {
test('CLIProxy default port is synced between backend and UI', () => {
expect(UI_CLIPROXY_DEFAULT_PORT).toBe(BACKEND_CLIPROXY_DEFAULT_PORT);
});
test('Cursor default port is synced between backend and UI', () => {
expect(UI_CURSOR_DEFAULT_PORT).toBe(BACKEND_CURSOR_DEFAULT_PORT);
});
test('CLIProxy provider IDs are synced between backend and UI', () => {
expect(sorted(UI_CLIPROXY_PROVIDERS)).toEqual(sorted(BACKEND_CLIPROXY_PROVIDER_IDS));
});
test('Device code providers are synced between backend and UI', () => {
expect(sorted(UI_DEVICE_CODE_PROVIDERS)).toEqual(sorted(getProvidersByOAuthFlow('device_code')));
});
test('Provider display names are synced between backend and UI', () => {
for (const provider of BACKEND_CLIPROXY_PROVIDER_IDS) {
expect(UI_PROVIDER_METADATA[provider].displayName).toBe(getBackendProviderDisplayName(provider));
}
});
test('Provider descriptions are synced between backend and UI', () => {
for (const provider of BACKEND_CLIPROXY_PROVIDER_IDS) {
expect(UI_PROVIDER_METADATA[provider].description).toBe(getBackendProviderDescription(provider));
}
});
});
@@ -0,0 +1,350 @@
import * as http from 'http';
import { afterEach, describe, expect, it } from 'bun:test';
import {
buildCodexModelEffortMap,
CodexReasoningProxy,
getEffortForModel,
} from '../../../src/cliproxy/codex-reasoning-proxy';
import {
parseEnvThinkingOverride,
resolveRuntimeThinkingOverride,
shouldDisableCodexReasoning,
} from '../../../src/cliproxy/executor/thinking-override-resolver';
type JsonRecord = Record<string, unknown>;
function closeServer(server: http.Server): Promise<void> {
return new Promise((resolve) => {
server.close(() => resolve());
});
}
function listenOnRandomPort(server: http.Server): Promise<number> {
return new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
if (typeof address !== 'object' || !address) {
reject(new Error('Failed to resolve server address'));
return;
}
resolve(address.port);
});
});
}
function postJson(
url: string,
body: JsonRecord
): Promise<{ statusCode: number; body: JsonRecord }> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const payload = JSON.stringify(body);
const req = http.request(
{
hostname: parsed.hostname,
port: parsed.port,
path: parsed.pathname + parsed.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(payload),
},
},
(res) => {
let responseBody = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
responseBody += chunk;
});
res.on('end', () => {
let parsedResponse: JsonRecord = {};
try {
parsedResponse = responseBody ? (JSON.parse(responseBody) as JsonRecord) : {};
} catch {
parsedResponse = {};
}
resolve({ statusCode: res.statusCode ?? 0, body: parsedResponse });
});
}
);
req.on('error', reject);
req.write(payload);
req.end();
});
}
describe('CodexReasoningProxy extended-context compatibility', () => {
const cleanupServers: http.Server[] = [];
afterEach(async () => {
while (cleanupServers.length > 0) {
const server = cleanupServers.pop();
if (server) {
await closeServer(server);
}
}
});
it('normalizes [1m] suffixes in effort map lookups', () => {
const map = buildCodexModelEffortMap({
defaultModel: 'gpt-5.3-codex-xhigh[1m]',
sonnetModel: 'gpt-5.3-codex-high[1m]',
haikuModel: 'gpt-5-mini-medium[1m]',
});
expect(getEffortForModel('gpt-5.3-codex-high', map, 'medium')).toBe('high');
expect(getEffortForModel('gpt-5-mini-medium', map, 'high')).toBe('medium');
});
it('strips [1m] and codex effort suffixes before forwarding upstream', async () => {
let capturedBody: JsonRecord | null = null;
let capturedPath = '';
const upstream = http.createServer((req, res) => {
let rawBody = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
rawBody += chunk;
});
req.on('end', () => {
capturedPath = req.url || '';
capturedBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
});
cleanupServers.push(upstream);
const upstreamPort = await listenOnRandomPort(upstream);
const proxy = new CodexReasoningProxy({
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
modelMap: {
defaultModel: 'gpt-5.3-codex-xhigh[1m]',
opusModel: 'gpt-5.3-codex-xhigh[1m]',
sonnetModel: 'gpt-5.3-codex-high[1m]',
haikuModel: 'gpt-5-mini-medium[1m]',
},
defaultEffort: 'medium',
});
const proxyPort = await proxy.start();
const response = await postJson(
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
{
model: 'gpt-5.3-codex-high[1m]',
messages: [],
}
);
proxy.stop();
expect(response.statusCode).toBe(200);
expect(capturedPath).toBe('/api/provider/codex/v1/messages');
expect(capturedBody?.model).toBe('gpt-5.3-codex');
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBe('high');
});
it('skips reasoning injection when disableEffort is enabled', async () => {
let capturedBody: JsonRecord | null = null;
const upstream = http.createServer((req, res) => {
let rawBody = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
rawBody += chunk;
});
req.on('end', () => {
capturedBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
});
cleanupServers.push(upstream);
const upstreamPort = await listenOnRandomPort(upstream);
const proxy = new CodexReasoningProxy({
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
modelMap: {
sonnetModel: 'gpt-5.3-codex-high',
},
disableEffort: true,
});
const proxyPort = await proxy.start();
const response = await postJson(
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
{
model: 'gpt-5.3-codex-high',
messages: [],
}
);
proxy.stop();
expect(response.statusCode).toBe(200);
expect(capturedBody?.model).toBe('gpt-5.3-codex');
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBeUndefined();
});
it('does not strip unknown model ids that merely end with "-high"', async () => {
let capturedBody: JsonRecord | null = null;
const upstream = http.createServer((req, res) => {
let rawBody = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
rawBody += chunk;
});
req.on('end', () => {
capturedBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
});
cleanupServers.push(upstream);
const upstreamPort = await listenOnRandomPort(upstream);
const proxy = new CodexReasoningProxy({
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
modelMap: {
defaultModel: 'gpt-5.1-codex-mini',
},
defaultEffort: 'medium',
});
const proxyPort = await proxy.start();
const response = await postJson(
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
{
model: 'enterprise-internal-high',
messages: [],
}
);
proxy.stop();
expect(response.statusCode).toBe(200);
expect(capturedBody?.model).toBe('enterprise-internal-high');
});
it('keeps reasoning enabled when CCS_THINKING=high overrides config off', async () => {
let capturedBody: JsonRecord | null = null;
expect(parseEnvThinkingOverride('high')).toBe('high');
const { thinkingOverride } = resolveRuntimeThinkingOverride(undefined, 'high');
const disableEffort = shouldDisableCodexReasoning(
{
mode: 'off',
tier_defaults: {
opus: 'high',
sonnet: 'medium',
haiku: 'low',
},
show_warnings: true,
},
thinkingOverride
);
const upstream = http.createServer((req, res) => {
let rawBody = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
rawBody += chunk;
});
req.on('end', () => {
capturedBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
});
cleanupServers.push(upstream);
const upstreamPort = await listenOnRandomPort(upstream);
const proxy = new CodexReasoningProxy({
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
disableEffort,
defaultEffort: 'medium',
modelMap: {
defaultModel: 'gpt-5.3-codex',
},
});
const proxyPort = await proxy.start();
const response = await postJson(
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
{
model: 'gpt-5.3-codex-high',
messages: [],
}
);
proxy.stop();
expect(response.statusCode).toBe(200);
expect(disableEffort).toBe(false);
expect(capturedBody?.model).toBe('gpt-5.3-codex');
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBe('high');
});
it('disables reasoning when CCS_THINKING=off is provided', async () => {
let capturedBody: JsonRecord | null = null;
expect(parseEnvThinkingOverride('off')).toBe('off');
const { thinkingOverride } = resolveRuntimeThinkingOverride(undefined, 'off');
const disableEffort = shouldDisableCodexReasoning(
{
mode: 'auto',
tier_defaults: {
opus: 'high',
sonnet: 'medium',
haiku: 'low',
},
show_warnings: true,
},
thinkingOverride
);
const upstream = http.createServer((req, res) => {
let rawBody = '';
req.setEncoding('utf8');
req.on('data', (chunk) => {
rawBody += chunk;
});
req.on('end', () => {
capturedBody = rawBody ? (JSON.parse(rawBody) as JsonRecord) : {};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
});
});
cleanupServers.push(upstream);
const upstreamPort = await listenOnRandomPort(upstream);
const proxy = new CodexReasoningProxy({
upstreamBaseUrl: `http://127.0.0.1:${upstreamPort}`,
disableEffort,
defaultEffort: 'medium',
modelMap: {
defaultModel: 'gpt-5.3-codex',
},
});
const proxyPort = await proxy.start();
const response = await postJson(
`http://127.0.0.1:${proxyPort}/api/provider/codex/v1/messages`,
{
model: 'gpt-5.3-codex-high',
messages: [],
}
);
proxy.stop();
expect(response.statusCode).toBe(200);
expect(disableEffort).toBe(true);
expect(capturedBody?.model).toBe('gpt-5.3-codex');
expect((capturedBody?.reasoning as JsonRecord | undefined)?.effort).toBeUndefined();
});
});
@@ -311,6 +311,24 @@ describe('applyThinkingConfig - composite variant integration', () => {
expect(result.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001');
});
it('treats off override aliases case-insensitively', () => {
const envVars: NodeJS.ProcessEnv = {
ANTHROPIC_MODEL: 'claude-sonnet-4-5-thinking',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6-thinking',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-thinking',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001',
};
const result = applyThinkingConfig(envVars, 'agy' as CLIProxyProvider, 'OFF', {
opus: 'xhigh',
sonnet: 'high',
});
expect(result.ANTHROPIC_MODEL).toBe('claude-sonnet-4-5-thinking');
expect(result.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking');
expect(result.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-thinking');
});
it('uses per-tier provider capability checks for mixed-provider composites', () => {
const envVars: NodeJS.ProcessEnv = {
ANTHROPIC_MODEL: 'gemini-2.5-pro',
@@ -0,0 +1,74 @@
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import { getEffectiveEnvVars } from '../../../src/cliproxy/config/env-builder';
interface EnvSettings {
ANTHROPIC_BASE_URL: string;
ANTHROPIC_AUTH_TOKEN: string;
ANTHROPIC_MODEL: string;
ANTHROPIC_DEFAULT_OPUS_MODEL: string;
ANTHROPIC_DEFAULT_SONNET_MODEL: string;
ANTHROPIC_DEFAULT_HAIKU_MODEL: string;
}
function writeCodexSettings(settingsPath: string, env: EnvSettings): void {
fs.writeFileSync(settingsPath, JSON.stringify({ env }, null, 2));
}
describe('getEffectiveEnvVars local provider URL normalization', () => {
let tempHome: string;
let settingsPath: string;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-env-url-'));
settingsPath = path.join(tempHome, 'codex.settings.json');
});
afterEach(() => {
fs.rmSync(tempHome, { recursive: true, force: true });
});
it('rewrites local root URL to provider endpoint', () => {
writeCodexSettings(settingsPath, {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:8317',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium',
});
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:8317/api/provider/codex');
});
it('rewrites wrong local provider path to the requested provider', () => {
writeCodexSettings(settingsPath, {
ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/my-codex-variant?debug=1',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium',
});
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
expect(env.ANTHROPIC_BASE_URL).toBe('http://localhost:8317/api/provider/codex');
});
it('does not rewrite localhost URLs targeting non-cliproxy ports', () => {
writeCodexSettings(settingsPath, {
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
ANTHROPIC_AUTH_TOKEN: 'ccs-internal-managed',
ANTHROPIC_MODEL: 'gpt-5.3-codex-xhigh',
ANTHROPIC_DEFAULT_OPUS_MODEL: 'gpt-5.3-codex-xhigh',
ANTHROPIC_DEFAULT_SONNET_MODEL: 'gpt-5.3-codex-high',
ANTHROPIC_DEFAULT_HAIKU_MODEL: 'gpt-5-mini-medium',
});
const env = getEffectiveEnvVars('codex', 8317, settingsPath);
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:11434');
});
});
@@ -1,7 +1,7 @@
/**
* Unit tests for management-api-client module
*/
import { describe, it, expect, beforeEach, mock } from 'bun:test';
import { describe, it, expect, beforeEach, mock, spyOn } from 'bun:test';
import { ManagementApiClient } from '../../../src/cliproxy/management-api-client';
import type {
ManagementClientConfig,
@@ -76,6 +76,18 @@ describe('management-api-client', () => {
const client = new ManagementApiClient(configNoPort);
expect(client.getBaseUrl()).toBe('https://localhost');
});
it('should warn and fall back when configured port is invalid', () => {
const warnSpy = spyOn(console, 'warn').mockImplementation(() => {});
const client = new ManagementApiClient({ ...config, port: 99999 });
expect(client.getBaseUrl()).toBe('http://localhost:8317');
expect(warnSpy).toHaveBeenCalledWith(
'[management-api-client] Invalid port "99999", using default 8317'
);
warnSpy.mockRestore();
});
});
describe('error code mapping', () => {
+40
View File
@@ -36,6 +36,33 @@ describe('Model Catalog', () => {
});
});
describe('Kimi models', () => {
it('contains Kimi provider catalog', () => {
const { MODEL_CATALOG } = modelCatalog;
assert(MODEL_CATALOG.kimi, 'Should have kimi provider');
assert.strictEqual(MODEL_CATALOG.kimi.provider, 'kimi');
assert.strictEqual(MODEL_CATALOG.kimi.displayName, 'Kimi (Moonshot)');
});
it('has correct default model', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.kimi.defaultModel, 'kimi-k2.5');
});
it('includes K2.5, K2 Thinking, K2', () => {
const { MODEL_CATALOG } = modelCatalog;
const ids = MODEL_CATALOG.kimi.models.map((m) => m.id);
assert(ids.includes('kimi-k2.5'), 'Should include kimi-k2.5');
assert(ids.includes('kimi-k2-thinking'), 'Should include kimi-k2-thinking');
assert(ids.includes('kimi-k2'), 'Should include kimi-k2');
});
it('has 3 models total', () => {
const { MODEL_CATALOG } = modelCatalog;
assert.strictEqual(MODEL_CATALOG.kimi.models.length, 3);
});
});
describe('AGY models', () => {
it('has correct default model', () => {
const { MODEL_CATALOG } = modelCatalog;
@@ -126,6 +153,11 @@ describe('Model Catalog', () => {
assert.strictEqual(supportsModelConfig('codex'), true);
});
it('returns true for kimi', () => {
const { supportsModelConfig } = modelCatalog;
assert.strictEqual(supportsModelConfig('kimi'), true);
});
it('returns false for qwen', () => {
const { supportsModelConfig } = modelCatalog;
assert.strictEqual(supportsModelConfig('qwen'), false);
@@ -155,6 +187,14 @@ describe('Model Catalog', () => {
assert.strictEqual(catalog.provider, 'codex');
assert(Array.isArray(catalog.models));
});
it('returns catalog for kimi', () => {
const { getProviderCatalog } = modelCatalog;
const catalog = getProviderCatalog('kimi');
assert(catalog, 'Should return catalog');
assert.strictEqual(catalog.provider, 'kimi');
assert(Array.isArray(catalog.models));
});
});
describe('findModel', () => {
@@ -1,8 +1,10 @@
import { describe, expect, it } from 'bun:test';
import {
buildProviderAliasMap,
CLIPROXY_PROVIDER_IDS,
getOAuthCallbackPort,
getOAuthFlowType,
PROVIDER_CAPABILITIES,
getProviderDisplayName,
getProvidersByOAuthFlow,
isCLIProxyProvider,
@@ -68,7 +70,25 @@ describe('provider-capabilities', () => {
expect(getOAuthCallbackPort('qwen')).toBeNull();
expect(getOAuthCallbackPort('kiro')).toBeNull();
expect(getOAuthCallbackPort('gemini')).toBe(8085);
expect(getProviderDisplayName('agy')).toBe('AntiGravity');
expect(getProviderDisplayName('agy')).toBe('Antigravity');
});
it('throws when provider aliases collide across providers', () => {
const capabilitiesWithCollision = {
...PROVIDER_CAPABILITIES,
gemini: {
...PROVIDER_CAPABILITIES.gemini,
aliases: ['shared-alias'],
},
codex: {
...PROVIDER_CAPABILITIES.codex,
aliases: ['shared-alias'],
},
};
expect(() =>
buildProviderAliasMap(capabilitiesWithCollision as typeof PROVIDER_CAPABILITIES)
).toThrow(/shared-alias/i);
});
it('keeps diagnostics flow metadata in sync with provider capabilities', () => {
@@ -0,0 +1,232 @@
/**
* GitHub Copilot (GHCP) Quota Fetcher Unit Tests
*
* Covers normalization and token extraction edge cases.
*/
import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import {
normalizeGhcpSnapshot,
extractGhcpAccessToken,
fetchGhcpQuota,
} from '../../../src/cliproxy/quota-fetcher-ghcp';
let tmpDir: string;
let originalCcsHome: string | undefined;
let originalFetch: typeof fetch;
function createGhcpAccount(
accountId: string,
tokenPayload: Record<string, unknown>,
tokenFile = `${accountId}.json`
): void {
const cliproxyDir = path.join(tmpDir, '.ccs', 'cliproxy');
const authDir = path.join(cliproxyDir, 'auth');
fs.mkdirSync(authDir, { recursive: true });
fs.writeFileSync(path.join(authDir, tokenFile), JSON.stringify(tokenPayload));
fs.writeFileSync(
path.join(cliproxyDir, 'accounts.json'),
JSON.stringify(
{
version: 1,
providers: {
ghcp: {
default: accountId,
accounts: {
[accountId]: {
nickname: accountId,
tokenFile,
createdAt: '2026-02-20T00:00:00.000Z',
lastUsedAt: '2026-02-20T00:00:00.000Z',
},
},
},
},
},
null,
2
)
);
}
beforeEach(() => {
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-ghcp-quota-test-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tmpDir;
originalFetch = global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('GHCP Quota Fetcher', () => {
describe('normalizeGhcpSnapshot', () => {
it('handles missing/undefined raw data', () => {
const snapshot = normalizeGhcpSnapshot();
expect(snapshot).toEqual({
entitlement: 0,
remaining: 0,
used: 0,
percentRemaining: 0,
percentUsed: 100,
unlimited: false,
overageCount: 0,
overagePermitted: false,
quotaId: null,
});
});
it('clamps percent_remaining to 0-100 range', () => {
const above = normalizeGhcpSnapshot({
entitlement: 100,
remaining: 80,
percent_remaining: 140,
});
const below = normalizeGhcpSnapshot({
entitlement: 100,
remaining: 80,
percent_remaining: -15,
});
expect(above.percentRemaining).toBe(100);
expect(above.percentUsed).toBe(0);
expect(below.percentRemaining).toBe(0);
expect(below.percentUsed).toBe(100);
});
it('calculates percentRemaining when API does not provide it', () => {
const snapshot = normalizeGhcpSnapshot({
entitlement: 80,
remaining: 20,
});
expect(snapshot.entitlement).toBe(80);
expect(snapshot.remaining).toBe(20);
expect(snapshot.used).toBe(60);
expect(snapshot.percentRemaining).toBe(25);
expect(snapshot.percentUsed).toBe(75);
});
it('handles non-finite entitlement values safely', () => {
const snapshot = normalizeGhcpSnapshot({
entitlement: Number.POSITIVE_INFINITY,
remaining: 25,
});
expect(snapshot.entitlement).toBe(0);
expect(snapshot.remaining).toBe(25);
expect(snapshot.used).toBe(0);
expect(snapshot.percentRemaining).toBe(0);
expect(snapshot.percentUsed).toBe(100);
});
});
describe('extractGhcpAccessToken', () => {
it('extracts from top-level access_token', () => {
const token = extractGhcpAccessToken({
access_token: ' top-level-token ',
});
expect(token).toBe('top-level-token');
});
it('extracts from nested token.access_token', () => {
const token = extractGhcpAccessToken({
token: {
access_token: 'nested-token',
},
});
expect(token).toBe('nested-token');
});
it('returns null for empty/whitespace tokens', () => {
const emptyTopLevel = extractGhcpAccessToken({ access_token: ' ' });
const emptyNested = extractGhcpAccessToken({
token: { access_token: ' ' },
});
expect(emptyTopLevel).toBeNull();
expect(emptyNested).toBeNull();
});
});
describe('fetchGhcpQuota', () => {
it('fetches and normalizes quota for a valid account token', async () => {
createGhcpAccount('ghcp-main', { access_token: 'top-level-token' });
global.fetch = mock((url: string, options?: RequestInit) => {
expect(url).toBe('https://api.github.com/copilot_internal/user');
expect(options?.method).toBe('GET');
expect(options?.headers).toEqual({
Accept: 'application/json',
Authorization: 'token top-level-token',
'User-Agent': 'GitHubCopilotChat/0.26.7',
'x-github-api-version': '2025-04-01',
});
return Promise.resolve(
new Response(
JSON.stringify({
copilot_plan: 'business',
quota_reset_date: '2026-02-28T00:00:00Z',
quota_snapshots: {
premium_interactions: { entitlement: 1000, remaining: 900 },
chat: { entitlement: 500, remaining: 100, percent_remaining: 20 },
completions: { entitlement: 250, remaining: 125 },
},
}),
{
status: 200,
headers: { 'Content-Type': 'application/json' },
}
)
);
}) as typeof fetch;
const result = await fetchGhcpQuota('ghcp-main');
expect(result.success).toBe(true);
expect(result.accountId).toBe('ghcp-main');
expect(result.planType).toBe('business');
expect(result.quotaResetDate).toBe('2026-02-28T00:00:00Z');
expect(result.snapshots.premiumInteractions.percentRemaining).toBe(90);
expect(result.snapshots.chat.percentRemaining).toBe(20);
expect(result.snapshots.completions.percentRemaining).toBe(50);
});
it('returns needsReauth on 401/403 responses', async () => {
createGhcpAccount('ghcp-auth', { access_token: 'token-auth' });
global.fetch = mock(() => Promise.resolve(new Response('', { status: 401 }))) as typeof fetch;
const result = await fetchGhcpQuota('ghcp-auth');
expect(result.success).toBe(false);
expect(result.needsReauth).toBe(true);
expect(result.error).toBe('Authentication expired or invalid');
});
it('fails fast when token file has no valid access token', async () => {
createGhcpAccount('ghcp-missing-token', { access_token: ' ' });
const fetchMock = mock(() => Promise.resolve(new Response('', { status: 200 })));
global.fetch = fetchMock as typeof fetch;
const result = await fetchGhcpQuota('ghcp-missing-token');
expect(result.success).toBe(false);
expect(result.error).toBe('No access token in auth file');
expect(fetchMock).toHaveBeenCalledTimes(0);
});
});
});
@@ -0,0 +1,87 @@
import { describe, expect, it } from 'bun:test';
import type { ThinkingConfig } from '../../../src/config/unified-config-types';
import {
buildThinkingStartupStatus,
parseEnvThinkingOverride,
resolveRuntimeThinkingOverride,
shouldDisableCodexReasoning,
} from '../../../src/cliproxy/executor/thinking-override-resolver';
const baseConfig: ThinkingConfig = {
mode: 'auto',
tier_defaults: {
opus: 'high',
sonnet: 'medium',
haiku: 'low',
},
show_warnings: true,
};
describe('thinking-override-resolver', () => {
it('parses env thinking values with CLI-compatible integer handling', () => {
expect(parseEnvThinkingOverride(undefined)).toBeUndefined();
expect(parseEnvThinkingOverride(' ')).toBeUndefined();
expect(parseEnvThinkingOverride('8192')).toBe(8192);
expect(parseEnvThinkingOverride(' OFF ')).toBe('off');
expect(parseEnvThinkingOverride('bogus')).toBeUndefined();
expect(parseEnvThinkingOverride('-1')).toBeUndefined();
expect(parseEnvThinkingOverride('100001')).toBeUndefined();
});
it('resolves runtime priority as flag > env', () => {
expect(resolveRuntimeThinkingOverride('high', 'low')).toEqual({
thinkingOverride: 'high',
thinkingSource: 'flag',
});
expect(resolveRuntimeThinkingOverride(undefined, 'xhigh')).toEqual({
thinkingOverride: 'xhigh',
thinkingSource: 'env',
});
expect(resolveRuntimeThinkingOverride(undefined, 'invalid')).toEqual({
thinkingOverride: undefined,
thinkingSource: undefined,
});
});
it('disables codex reasoning for off aliases regardless of case', () => {
expect(shouldDisableCodexReasoning(baseConfig, 'OFF')).toBe(true);
expect(
shouldDisableCodexReasoning(
{
...baseConfig,
mode: 'off',
},
'high'
)
).toBe(false);
expect(
shouldDisableCodexReasoning(
{
...baseConfig,
mode: 'manual',
override: 'off',
},
undefined
)
).toBe(true);
});
it('builds startup status from effective precedence instead of raw config mode', () => {
const offConfig: ThinkingConfig = { ...baseConfig, mode: 'off' };
expect(buildThinkingStartupStatus(offConfig, 'high', 'env')).toEqual({
thinkingLabel: 'high',
sourceLabel: 'env: CCS_THINKING',
});
expect(buildThinkingStartupStatus(offConfig, undefined, undefined)).toEqual({
thinkingLabel: 'off',
sourceLabel: 'config: off',
});
expect(buildThinkingStartupStatus(baseConfig, 'off', 'flag', '--effort off')).toEqual({
thinkingLabel: 'off',
sourceLabel: 'flag: --effort off',
});
});
});
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'bun:test';
import {
parseThinkingCommandArgs,
parseThinkingOverrideInput,
} from '../../../src/commands/config-thinking-command';
import { clearProviderOverride } from '../../../src/commands/config-thinking-parser';
describe('config thinking command parser', () => {
it('rejects missing required option values', () => {
const result = parseThinkingCommandArgs(['--mode']);
expect(result.error).toBe('--mode requires a value');
});
it('rejects unknown options', () => {
const result = parseThinkingCommandArgs(['--unknown-flag']);
expect(result.error).toBe('Unknown option: --unknown-flag');
});
it('parses clear-provider-override with optional tier', () => {
const withTier = parseThinkingCommandArgs(['--clear-provider-override', 'codex', 'opus']);
expect(withTier.error).toBeUndefined();
expect(withTier.options.clearProviderOverride).toEqual({ provider: 'codex', tier: 'opus' });
const withoutTier = parseThinkingCommandArgs(['--clear-provider-override', 'codex']);
expect(withoutTier.error).toBeUndefined();
expect(withoutTier.options.clearProviderOverride).toEqual({ provider: 'codex', tier: undefined });
});
});
describe('config thinking override normalization', () => {
it('normalizes off aliases and case', () => {
expect(parseThinkingOverrideInput('OFF')).toEqual({ value: 'off' });
expect(parseThinkingOverrideInput('0')).toEqual({ value: 'off' });
});
it('accepts valid levels', () => {
expect(parseThinkingOverrideInput('High')).toEqual({ value: 'high' });
});
it('validates numeric bounds', () => {
expect(parseThinkingOverrideInput('100001').error).toContain('between 0 and 100000');
expect(parseThinkingOverrideInput('8192')).toEqual({ value: 8192 });
});
});
describe('config thinking provider override clearing', () => {
it('is a no-op when provider override does not exist', () => {
const result = clearProviderOverride(
{
codex: { opus: 'high' },
},
'gemini'
);
expect(result.changed).toBe(false);
expect(result.nextOverrides).toEqual({
codex: { opus: 'high' },
});
});
it('is a no-op when provider exists but tier override does not', () => {
const result = clearProviderOverride(
{
codex: { opus: 'high' },
},
'codex',
'haiku'
);
expect(result.changed).toBe(false);
expect(result.nextOverrides).toEqual({
codex: { opus: 'high' },
});
});
it('removes provider entry when last tier is cleared', () => {
const result = clearProviderOverride(
{
codex: { opus: 'high' },
},
'codex',
'opus'
);
expect(result.changed).toBe(true);
expect(result.nextOverrides).toBeUndefined();
});
});
+135
View File
@@ -0,0 +1,135 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { loadMigrationCheckData, migrate } from '../../../src/config/migration-manager';
import { saveUnifiedConfig } from '../../../src/config/unified-config-loader';
import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types';
describe('migration-manager legacy kimi compatibility', () => {
let tempHome: string;
let ccsDir: string;
let originalCcsHome: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-migration-manager-test-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('prefers explicit canonical km profile over legacy kimi when both exist', async () => {
const kmSettingsPath = path.join(ccsDir, 'km.settings.json');
const kimiSettingsPath = path.join(ccsDir, 'kimi.settings.json');
fs.writeFileSync(kmSettingsPath, JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'sk-km' } }));
fs.writeFileSync(
kimiSettingsPath,
JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'sk-kimi' } })
);
// Intentionally place legacy alias first to verify deterministic behavior.
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify(
{
profiles: {
kimi: kimiSettingsPath,
km: kmSettingsPath,
},
},
null,
2
)
);
const result = await migrate(true);
expect(result.success).toBe(true);
expect(
result.migratedFiles.some((entry) =>
entry.includes(`config.json.profiles.km → config.yaml.profiles.km (settings: ${kmSettingsPath})`)
)
).toBe(true);
expect(
result.migratedFiles.some((entry) =>
entry.includes(`(settings: ${kimiSettingsPath})`)
)
).toBe(false);
expect(
result.warnings.some((warning) =>
warning.includes(
'Skipped kimi: canonical profile "km" exists in config.json with different settings'
)
)
).toBe(true);
});
it('renames case-variant legacy Kimi profile key to km', async () => {
const kimiSettingsPath = path.join(ccsDir, 'kimi.settings.json');
fs.writeFileSync(
kimiSettingsPath,
JSON.stringify({ env: { ANTHROPIC_AUTH_TOKEN: 'sk-kimi-case-variant' } })
);
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify(
{
profiles: {
Kimi: kimiSettingsPath,
},
},
null,
2
)
);
const result = await migrate(true);
expect(result.success).toBe(true);
expect(
result.migratedFiles.some((entry) =>
entry.includes('config.json.profiles.Kimi → config.yaml.profiles.km')
)
).toBe(true);
});
it('treats legacy kimi profile as migrated when unified config already has km', () => {
const unifiedConfig = createEmptyUnifiedConfig();
unifiedConfig.profiles.km = {
type: 'api',
settings: '~/.ccs/km.settings.json',
};
saveUnifiedConfig(unifiedConfig);
fs.writeFileSync(
path.join(ccsDir, 'config.json'),
JSON.stringify(
{
profiles: {
kimi: '~/.ccs/kimi.settings.json',
},
},
null,
2
)
);
const checkData = loadMigrationCheckData();
expect(checkData.needsMigration).toBe(false);
});
});
+32
View File
@@ -0,0 +1,32 @@
import { describe, expect, it } from 'bun:test';
import {
API_BASE_URL,
API_CONFLICT_ERROR_CODE,
ApiConflictError,
isApiConflictError,
withApiBase,
} from '../../ui/src/lib/api-client';
describe('ui api-client helpers', () => {
it('normalizes relative paths with API base prefix', () => {
expect(withApiBase('/cliproxy/status')).toBe('/api/cliproxy/status');
expect(withApiBase('cliproxy/status')).toBe('/api/cliproxy/status');
});
it('preserves paths that already include API base', () => {
expect(withApiBase('/api/cliproxy/status')).toBe('/api/cliproxy/status');
expect(withApiBase('/api')).toBe('/api');
});
it('handles empty and absolute URLs safely', () => {
expect(withApiBase('')).toBe(API_BASE_URL);
expect(withApiBase('https://example.com/api')).toBe('https://example.com/api');
});
it('identifies typed API conflict errors', () => {
const conflict = new ApiConflictError('conflict');
expect(conflict.code).toBe(API_CONFLICT_ERROR_CODE);
expect(isApiConflictError(conflict)).toBe(true);
expect(isApiConflictError(new Error('plain'))).toBe(false);
});
});
@@ -0,0 +1,241 @@
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test';
import { EventEmitter } from 'events';
import * as childProcess from 'child_process';
type SpawnCall = {
command: string;
args: string[];
options: Record<string, unknown> | undefined;
};
const spawnCalls: SpawnCall[] = [];
const originalPlatform = process.platform;
let baselineSigintListeners: Array<(...args: unknown[]) => void> = [];
let baselineSigtermListeners: Array<(...args: unknown[]) => void> = [];
let baselineSighupListeners: Array<(...args: unknown[]) => void> = [];
const realSpawn = childProcess.spawn.bind(childProcess);
const realSpawnSync = childProcess.spawnSync.bind(childProcess);
const realExecSync = childProcess.execSync.bind(childProcess);
function createMockChild(): EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
exitCode: number | null;
killed: boolean;
pid: number;
unref: () => EventEmitter;
kill: () => boolean;
} {
const child = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
exitCode: number | null;
killed: boolean;
pid: number;
unref: () => EventEmitter;
kill: () => boolean;
};
child.stdout = new EventEmitter();
child.stderr = new EventEmitter();
child.exitCode = null;
child.killed = false;
child.pid = process.pid;
child.unref = () => child;
child.kill = () => {
child.killed = true;
child.exitCode = 1;
return true;
};
return child;
}
function shouldMockCommand(command: string): boolean {
const normalized = command.toLowerCase();
return normalized.includes('claude');
}
function registerChildProcessMock(): void {
mock.module('child_process', () => ({
...childProcess,
spawn: (...spawnArgs: unknown[]) => {
const command = String(spawnArgs[0] ?? '');
const maybeArgs = spawnArgs[1];
const args = Array.isArray(maybeArgs) ? (maybeArgs as string[]) : [];
const options = (Array.isArray(maybeArgs) ? spawnArgs[2] : spawnArgs[1]) as
| Record<string, unknown>
| undefined;
if (!shouldMockCommand(command)) {
return realSpawn(command, args, options as Parameters<typeof childProcess.spawn>[2]);
}
spawnCalls.push({ command, args, options });
const child = createMockChild();
setTimeout(() => child.emit('close', 0), 0);
return child;
},
spawnSync: (...spawnArgs: unknown[]) => {
const command = String(spawnArgs[0] ?? '');
const maybeArgs = spawnArgs[1];
const args = Array.isArray(maybeArgs) ? (maybeArgs as string[]) : [];
const options = (Array.isArray(maybeArgs) ? spawnArgs[2] : spawnArgs[1]) as
| Record<string, unknown>
| undefined;
return realSpawnSync(command, args, options as Parameters<typeof childProcess.spawnSync>[2]);
},
execSync: (...execArgs: unknown[]) =>
realExecSync(
execArgs[0] as Parameters<typeof childProcess.execSync>[0],
execArgs[1] as Parameters<typeof childProcess.execSync>[1]
),
}));
}
let execClaude: typeof import('../../../src/utils/shell-executor').execClaude;
let stripClaudeCodeEnv: typeof import('../../../src/utils/shell-executor').stripClaudeCodeEnv;
let HeadlessExecutor: typeof import('../../../src/delegation/headless-executor').HeadlessExecutor;
beforeAll(async () => {
registerChildProcessMock();
const shellExecutor = await import('../../../src/utils/shell-executor');
execClaude = shellExecutor.execClaude;
stripClaudeCodeEnv = shellExecutor.stripClaudeCodeEnv;
const headless = await import('../../../src/delegation/headless-executor');
HeadlessExecutor = headless.HeadlessExecutor;
});
afterAll(() => {
mock.restore();
});
describe('CLAUDECODE environment stripping', () => {
beforeEach(() => {
spawnCalls.length = 0;
process.env.CCS_QUIET = '1';
baselineSigintListeners = process.listeners('SIGINT');
baselineSigtermListeners = process.listeners('SIGTERM');
baselineSighupListeners = process.listeners('SIGHUP');
});
afterEach(() => {
Object.defineProperty(process, 'platform', { value: originalPlatform });
delete process.env.CLAUDECODE;
delete process.env.claudecode;
delete process.env.CCS_QUIET;
for (const listener of process.listeners('SIGINT')) {
if (!baselineSigintListeners.includes(listener)) {
process.removeListener('SIGINT', listener as (...args: unknown[]) => void);
}
}
for (const listener of process.listeners('SIGTERM')) {
if (!baselineSigtermListeners.includes(listener)) {
process.removeListener('SIGTERM', listener as (...args: unknown[]) => void);
}
}
for (const listener of process.listeners('SIGHUP')) {
if (!baselineSighupListeners.includes(listener)) {
process.removeListener('SIGHUP', listener as (...args: unknown[]) => void);
}
}
});
it('stripClaudeCodeEnv removes CLAUDECODE case-insensitively', () => {
const input: NodeJS.ProcessEnv = {
CLAUDECODE: 'upper',
claudecode: 'lower',
ClAuDeCoDe: 'mixed',
PATH: '/usr/bin',
};
const result = stripClaudeCodeEnv(input);
expect(Object.keys(result).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
expect(result.PATH).toBe('/usr/bin');
});
it('execClaude strips CLAUDECODE from merged env (including overrides)', () => {
process.env.CLAUDECODE = 'from-parent';
process.env.claudecode = 'from-parent-lower';
execClaude('claude', ['--version'], {
CCS_PROFILE_TYPE: 'default',
CLAUDECODE: 'from-override',
CCS_WEBSEARCH_SKIP: '1',
});
expect(spawnCalls.length).toBeGreaterThan(0);
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(env).toBeDefined();
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
expect(env.CCS_WEBSEARCH_SKIP).toBe('1');
});
it('execClaude keeps behavior when CLAUDECODE is absent', () => {
execClaude('claude', ['--help'], { CCS_PROFILE_TYPE: 'default' });
expect(spawnCalls.length).toBeGreaterThan(0);
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(env).toBeDefined();
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
expect(env.CCS_PROFILE_TYPE).toBe('default');
});
it('execClaude strips CLAUDECODE on Windows shell launch path', () => {
Object.defineProperty(process, 'platform', { value: 'win32' });
process.env.CLAUDECODE = 'set';
execClaude('claude.cmd', ['--version'], { CCS_PROFILE_TYPE: 'default' });
expect(spawnCalls.length).toBeGreaterThan(0);
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
});
it('headless executor spawn path strips CLAUDECODE before spawn', async () => {
process.env.CLAUDECODE = 'nested';
process.env.claudecode = 'nested-lower';
const result = await (
HeadlessExecutor as unknown as {
_spawnAndExecute: (
claudeCli: string,
args: string[],
ctx: {
cwd: string;
profile: string;
timeout: number;
resumeSession: boolean;
sessionId: string | null;
sessionMgr: {
updateSession: (...args: unknown[]) => void;
storeSession: (...args: unknown[]) => void;
cleanupExpired: () => void;
};
}
) => Promise<unknown>;
}
)._spawnAndExecute('claude', ['-p', 'test'], {
cwd: process.cwd(),
profile: 'glm',
timeout: 1000,
resumeSession: false,
sessionId: null,
sessionMgr: {
updateSession: () => {},
storeSession: () => {},
cleanupExpired: () => {},
},
});
expect(result).toBeDefined();
expect(spawnCalls.length).toBeGreaterThan(0);
const env = spawnCalls[0].options?.env as NodeJS.ProcessEnv;
expect(Object.keys(env).map((k) => k.toUpperCase())).not.toContain('CLAUDECODE');
});
});
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'bun:test';
import {
getProfileLookupCandidates,
isLegacyProfileAlias,
resolveAliasToCanonical,
} from '../../../src/utils/profile-compat';
describe('profile-compat', () => {
describe('getProfileLookupCandidates', () => {
it('returns km candidates with legacy kimi fallback', () => {
expect(getProfileLookupCandidates('km')).toEqual(['km', 'kimi']);
});
it('keeps non-aliased profiles unchanged', () => {
expect(getProfileLookupCandidates('glm')).toEqual(['glm']);
});
it('normalizes uppercase input and still resolves aliases', () => {
expect(getProfileLookupCandidates('KM')).toEqual(['KM', 'km', 'kimi']);
});
it('handles surrounding whitespace', () => {
expect(getProfileLookupCandidates(' km ')).toEqual(['km', 'kimi']);
});
it('returns empty candidates for empty input', () => {
expect(getProfileLookupCandidates('')).toEqual([]);
expect(getProfileLookupCandidates(' ')).toEqual([]);
});
});
describe('isLegacyProfileAlias', () => {
it('returns true for km -> kimi', () => {
expect(isLegacyProfileAlias('km', 'kimi')).toBe(true);
});
it('returns false for canonical names', () => {
expect(isLegacyProfileAlias('km', 'km')).toBe(false);
expect(isLegacyProfileAlias('glm', 'glm')).toBe(false);
});
it('returns false for unrelated names', () => {
expect(isLegacyProfileAlias('glm', 'kimi')).toBe(false);
});
});
describe('resolveAliasToCanonical', () => {
it('maps legacy kimi alias to canonical km', () => {
expect(resolveAliasToCanonical('kimi')).toBe('km');
expect(resolveAliasToCanonical('KIMI')).toBe('km');
});
it('keeps canonical and non-aliased names', () => {
expect(resolveAliasToCanonical('km')).toBe('km');
expect(resolveAliasToCanonical('glm')).toBe('glm');
});
});
});
@@ -0,0 +1,49 @@
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { checkSettingsFiles } from '../../../src/web-server/health/config-checks';
describe('web-server config-checks settings compatibility', () => {
let tempHome: string;
let ccsDir: string;
let originalCcsHome: string | undefined;
beforeEach(() => {
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-config-checks-test-'));
originalCcsHome = process.env.CCS_HOME;
process.env.CCS_HOME = tempHome;
ccsDir = path.join(tempHome, '.ccs');
fs.mkdirSync(ccsDir, { recursive: true });
});
afterEach(() => {
if (originalCcsHome !== undefined) {
process.env.CCS_HOME = originalCcsHome;
} else {
delete process.env.CCS_HOME;
}
if (fs.existsSync(tempHome)) {
fs.rmSync(tempHome, { recursive: true, force: true });
}
});
it('reports km as configured when only legacy kimi.settings.json exists', () => {
fs.writeFileSync(
path.join(ccsDir, 'kimi.settings.json'),
JSON.stringify({
env: {
ANTHROPIC_AUTH_TOKEN: 'sk-live-kimi-compat',
},
})
);
const checks = checkSettingsFiles(ccsDir);
const kmCheck = checks.find((check) => check.id === 'settings-km');
expect(kmCheck).toBeDefined();
expect(kmCheck?.status).toBe('ok');
expect(kmCheck?.name).toBe('kimi.settings.json');
});
});
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'bun:test';
import { resolveThinkingProviderOverridesForSave } from '../../../src/web-server/routes/misc-routes';
describe('thinking routes logic', () => {
it('clears provider overrides when clear flag is set', () => {
const result = resolveThinkingProviderOverridesForSave(
{
codex: { opus: 'high' },
},
{
gemini: { sonnet: 'medium' },
},
true
);
expect(result).toBeUndefined();
});
it('applies normalized updates when provided and clear flag is false', () => {
const updates = {
gemini: { sonnet: 'medium' },
};
const result = resolveThinkingProviderOverridesForSave(
{
codex: { opus: 'high' },
},
updates,
false
);
expect(result).toEqual(updates);
});
it('preserves current overrides when no updates are provided', () => {
const current = {
codex: { opus: 'high' },
};
const result = resolveThinkingProviderOverridesForSave(current, undefined, false);
expect(result).toEqual(current);
});
});
@@ -4,6 +4,7 @@
import {
cn,
formatQuotaPercent,
getCodexQuotaBreakdown,
getProviderMinQuota,
getProviderResetTime,
@@ -110,6 +111,7 @@ export function AccountCard({
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
{ label: 'Wk', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
].filter((row): row is { label: string; value: number } => row.value !== null);
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
// Tier badge (AGY only) - show P for Pro, U for Ultra
const showTierBadge =
@@ -240,7 +242,7 @@ export function AccountCard({
: 'text-red-500'
)}
>
{minQuota}%
{minQuotaLabel}%
</span>
</div>
{account.provider === 'codex' && codexQuotaRows.length > 0 && (
+2 -11
View File
@@ -27,21 +27,12 @@ import { MoreHorizontal, Trash2, User, Pencil } from 'lucide-react';
import { useDeleteVariant } from '@/hooks/use-cliproxy';
import { CliproxyEditDialog } from './cliproxy-edit-dialog';
import type { Variant } from '@/lib/api-client';
import { getProviderDisplayName } from '@/lib/provider-config';
interface CliproxyTableProps {
data: Variant[];
}
const providerLabels: Record<string, string> = {
gemini: 'Google Gemini',
codex: 'OpenAI Codex',
agy: 'Antigravity',
qwen: 'Alibaba Qwen',
iflow: 'iFlow',
kiro: 'Kiro (AWS)',
ghcp: 'GitHub Copilot (OAuth)',
};
export function CliproxyTable({ data }: CliproxyTableProps) {
const deleteMutation = useDeleteVariant();
const [editingVariant, setEditingVariant] = useState<Variant | null>(null);
@@ -59,7 +50,7 @@ export function CliproxyTable({ data }: CliproxyTableProps) {
if (row.original.type === 'composite') {
return <Badge variant="secondary">composite</Badge>;
}
return providerLabels[row.original.provider] || row.original.provider;
return getProviderDisplayName(row.original.provider);
},
},
{
@@ -9,11 +9,9 @@
import { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { RefreshCw, AlertCircle, Key, X, Gauge, Globe, Settings } from 'lucide-react';
import { useQuery } from '@tanstack/react-query';
import { api } from '@/lib/api-client';
import { api, withApiBase } from '@/lib/api-client';
import type { CliproxyServerConfig } from '@/lib/api-client';
/** CLIProxyAPI default port */
const CLIPROXY_DEFAULT_PORT = 8317;
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
interface AuthTokensResponse {
apiKey: { value: string; isCustom: boolean };
@@ -26,7 +24,8 @@ interface ControlPanelEmbedProps {
export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanelEmbedProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [isLoading, setIsLoading] = useState(true);
const [loadedUrl, setLoadedUrl] = useState<string | null>(null);
const [iframeRevision, setIframeRevision] = useState(0);
const [error, setError] = useState<string | null>(null);
const [isConnected, setIsConnected] = useState(false);
const [showLoginHint, setShowLoginHint] = useState(true);
@@ -42,7 +41,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
const { data: authTokens } = useQuery<AuthTokensResponse>({
queryKey: ['auth-tokens-raw'],
queryFn: async () => {
const response = await fetch('/api/settings/auth/tokens/raw');
const response = await fetch(withApiBase('/settings/auth/tokens/raw'));
if (!response.ok) throw new Error('Failed to fetch auth tokens');
return response.json();
},
@@ -62,8 +61,8 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
if (remote?.enabled && remote?.host) {
const protocol = remote.protocol || 'http';
// Use port from config, or default based on protocol (443 for https, 80 for http)
const remotePort = remote.port || (protocol === 'https' ? 443 : 80);
// Use port from config, or default based on protocol (443 for https, 8317 for http)
const remotePort = remote.port || (protocol === 'https' ? 443 : CLIPROXY_DEFAULT_PORT);
// Only include port in URL if it's non-standard
const portSuffix =
(protocol === 'https' && remotePort === 443) || (protocol === 'http' && remotePort === 80)
@@ -91,6 +90,9 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
};
}, [cliproxyConfig, authTokens, port]);
const iframeLoaded = loadedUrl === managementUrl;
const isLoading = !iframeLoaded;
// Check if CLIProxy is running
useEffect(() => {
const controller = new AbortController();
@@ -132,48 +134,53 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
return () => controller.abort();
}, [checkUrl, isRemote, displayHost]);
// Handle iframe load - attempt to auto-login via postMessage
const handleIframeLoad = useCallback(() => {
setIsLoading(false);
// Try to inject credentials via postMessage
// The management.html needs to listen for this message
// If it doesn't support it, user will see the login page
if (iframeRef.current?.contentWindow && authToken) {
try {
// Derive apiBase from checkUrl (remove trailing slash)
const apiBase = checkUrl.replace(/\/$/, '');
// Security: Validate iframe src matches target origin before sending credentials
const iframeSrc = iframeRef.current.src;
if (!iframeSrc.startsWith(apiBase)) {
console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage');
return;
}
// Send credentials to iframe
iframeRef.current.contentWindow.postMessage(
{
type: 'ccs-auto-login',
apiBase,
managementKey: authToken,
},
apiBase
);
} catch (e) {
// Cross-origin restriction - expected if not same origin
console.debug('[ControlPanelEmbed] postMessage failed - cross-origin:', e);
}
const postAutoLoginCredentials = useCallback(() => {
// Auto-login can only run when iframe has loaded and authToken is available.
if (!iframeLoaded || !iframeRef.current?.contentWindow || !authToken) {
return;
}
}, [checkUrl, authToken]);
try {
// Derive apiBase from checkUrl (remove trailing slash)
const apiBase = checkUrl.replace(/\/$/, '');
// Security: Validate iframe src matches target origin before sending credentials
const iframeSrc = iframeRef.current.src;
if (!iframeSrc.startsWith(apiBase)) {
console.warn('[ControlPanelEmbed] Iframe origin mismatch, skipping postMessage');
return;
}
// Send credentials to iframe
iframeRef.current.contentWindow.postMessage(
{
type: 'ccs-auto-login',
apiBase,
managementKey: authToken,
},
apiBase
);
} catch (e) {
// Cross-origin restriction - expected if not same origin
console.debug('[ControlPanelEmbed] postMessage failed - cross-origin:', e);
}
}, [authToken, checkUrl, iframeLoaded]);
// Retry auto-login when token/checkUrl arrive after iframe onLoad.
useEffect(() => {
postAutoLoginCredentials();
}, [postAutoLoginCredentials]);
// Handle iframe load - mark ready then let effect post credentials.
const handleIframeLoad = useCallback(() => {
setLoadedUrl(managementUrl);
}, [managementUrl]);
const handleRefresh = () => {
setIsLoading(true);
setLoadedUrl(null);
setIframeRevision((value) => value + 1);
setError(null);
setIsConnected(false);
if (iframeRef.current) {
iframeRef.current.src = managementUrl;
}
};
// Show error state if CLIProxy is not running
@@ -266,6 +273,7 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
{/* Iframe */}
<iframe
key={`${managementUrl}:${iframeRevision}`}
ref={iframeRef}
src={managementUrl}
className="flex-1 w-full border-0"
@@ -32,6 +32,7 @@ import {
} from 'lucide-react';
import {
cn,
formatQuotaPercent,
getCodexQuotaBreakdown,
getProviderMinQuota,
getProviderResetTime,
@@ -129,6 +130,7 @@ export function AccountItem({
{ label: '5h', value: codexBreakdown?.fiveHourWindow?.remainingPercent ?? null },
{ label: 'Weekly', value: codexBreakdown?.weeklyWindow?.remainingPercent ?? null },
].filter((row): row is { label: string; value: number } => row.value !== null);
const minQuotaLabel = minQuota !== null ? formatQuotaPercent(minQuota) : null;
return (
<div
@@ -376,7 +378,9 @@ export function AccountItem({
className="h-2 flex-1"
indicatorClassName={getQuotaColor(minQuota)}
/>
<span className="text-xs font-medium w-10 text-right">{minQuota}%</span>
<span className="text-xs font-medium w-10 text-right">
{minQuotaLabel}%
</span>
</div>
)}
</TooltipTrigger>
@@ -16,7 +16,7 @@ import {
useCreatePreset,
useDeletePreset,
} from '@/hooks/use-cliproxy';
import { CLIPROXY_PORT } from '@/lib/preset-utils';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import { usePrivacy } from '@/contexts/privacy-context';
import { useProviderEditor } from './use-provider-editor';
import { CustomPresetDialog } from './custom-preset-dialog';
@@ -70,6 +70,7 @@ export function ProviderEditor({
iflow: ['iflow'],
kiro: ['kiro', 'aws'],
ghcp: ['github', 'copilot'],
kimi: ['kimi', 'moonshot'],
};
const owners = ownerMap[modelFilterProvider.toLowerCase()] || [
modelFilterProvider.toLowerCase(),
@@ -79,6 +80,8 @@ export function ProviderEditor({
);
}, [modelsData, modelFilterProvider]);
const providerRoute = (baseProvider || provider).toLowerCase();
const {
data,
isLoading,
@@ -117,9 +120,9 @@ export function ProviderEditor({
const effectiveApiKey = authTokens?.apiKey?.value ?? 'ccs-internal-managed';
const handleApplyPreset = (updates: Record<string, string>) => {
const effectivePort = port ?? CLIPROXY_PORT;
const effectivePort = port ?? CLIPROXY_DEFAULT_PORT;
updateEnvValues({
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`,
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${providerRoute}`,
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
...updates,
});
@@ -127,9 +130,9 @@ export function ProviderEditor({
};
const handleCustomPresetApply = (values: ModelMappingValues, presetName?: string) => {
const effectivePort = port ?? CLIPROXY_PORT;
const effectivePort = port ?? CLIPROXY_DEFAULT_PORT;
updateEnvValues({
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`,
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${providerRoute}`,
ANTHROPIC_AUTH_TOKEN: effectiveApiKey,
ANTHROPIC_MODEL: values.default,
ANTHROPIC_DEFAULT_OPUS_MODEL: values.opus,
+12 -37
View File
@@ -4,6 +4,11 @@
*/
import { cn } from '@/lib/utils';
import {
getProviderFallbackVisual,
getProviderLogoAsset,
providerNeedsDarkLogoBackground,
} from '@/lib/provider-config';
interface ProviderLogoProps {
provider: string;
@@ -11,35 +16,6 @@ interface ProviderLogoProps {
size?: 'sm' | 'md' | 'lg';
}
/** Provider image assets mapping */
const PROVIDER_IMAGES: Record<string, string> = {
gemini: '/assets/providers/gemini-color.svg',
codex: '/assets/providers/openai.svg',
agy: '/assets/providers/agy.png',
qwen: '/assets/providers/qwen-color.svg',
iflow: '/assets/providers/iflow.png',
kiro: '/assets/providers/kiro.png',
ghcp: '/assets/providers/copilot.svg',
claude: '/assets/providers/claude.svg',
kimi: '/assets/providers/kimi.svg',
};
/** Provider color configuration (for fallback only - no background for image logos) */
const PROVIDER_CONFIG: Record<string, { text: string; letter: string }> = {
gemini: { text: 'text-blue-600', letter: 'G' },
claude: { text: 'text-orange-600', letter: 'C' },
codex: { text: 'text-emerald-600', letter: 'X' },
agy: { text: 'text-violet-600', letter: 'A' },
qwen: { text: 'text-cyan-600', letter: 'Q' },
iflow: { text: 'text-indigo-600', letter: 'i' },
kiro: { text: 'text-teal-600', letter: 'K' },
ghcp: { text: 'text-green-600', letter: 'C' },
kimi: { text: 'text-orange-500', letter: 'K' },
};
/** Providers whose logos require a dark background */
const DARK_BG_PROVIDERS = new Set(['kimi']);
/** Size configuration */
const SIZE_CONFIG = {
sm: { container: 'w-6 h-6', icon: 'w-4 h-4', text: 'text-xs' },
@@ -48,19 +24,16 @@ const SIZE_CONFIG = {
};
export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoProps) {
const providerKey = provider.toLowerCase();
const config = PROVIDER_CONFIG[providerKey] || {
text: 'text-gray-600',
letter: provider[0]?.toUpperCase() || '?',
};
const fallback = getProviderFallbackVisual(provider);
const sizeConfig = SIZE_CONFIG[size];
const imageSrc = PROVIDER_IMAGES[providerKey];
const imageSrc = getProviderLogoAsset(provider);
return (
<div
className={cn(
'flex items-center justify-center rounded-md',
imageSrc && (DARK_BG_PROVIDERS.has(providerKey) ? 'bg-gray-900 p-1' : 'bg-white p-1'),
imageSrc &&
(providerNeedsDarkLogoBackground(provider) ? 'bg-gray-900 p-1' : 'bg-white p-1'),
sizeConfig.container,
className
)}
@@ -72,7 +45,9 @@ export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoP
className={cn(sizeConfig.icon, 'object-contain')}
/>
) : (
<span className={cn('font-semibold', config.text, sizeConfig.text)}>{config.letter}</span>
<span className={cn('font-semibold', fallback.textClass, sizeConfig.text)}>
{fallback.letter}
</span>
)}
</div>
);
@@ -5,6 +5,7 @@
import { useState, useMemo, useCallback } from 'react';
import { useCopilot } from '@/hooks/use-copilot';
import { isApiConflictError } from '@/lib/api-client';
import { toast } from 'sonner';
import type { ModelPreset } from './types';
@@ -171,7 +172,7 @@ export function useCopilotConfigForm() {
setLocalOverrides({});
setRawJsonEdits(null);
} catch (error) {
if ((error as Error).message === 'CONFLICT') {
if (isApiConflictError(error)) {
setConflictDialog(true);
} else {
toast.error('Failed to save settings');
@@ -4,7 +4,7 @@
*/
/* eslint-disable react-hooks/set-state-in-effect */
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useCallback } from 'react';
import { useForm, useWatch } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
@@ -30,6 +30,7 @@ import { cn } from '@/lib/utils';
import {
PROVIDER_PRESETS,
getPresetsByCategory,
getPresetById,
type ProviderPreset,
} from '@/lib/provider-presets';
import {
@@ -55,6 +56,7 @@ const schema = z.object({
});
type FormData = z.infer<typeof schema>;
type PresetSelection = ProviderPreset['id'] | 'custom';
interface ProfileCreateDialogProps {
open: boolean;
@@ -65,6 +67,26 @@ interface ProfileCreateDialogProps {
// Common URL mistakes to warn about
const PROBLEMATIC_PATHS = ['/chat/completions', '/v1/messages', '/messages', '/completions'];
const CUSTOM_PRESET_ID = 'custom';
const DEFAULT_PRESET_ID: ProviderPreset['id'] = 'openrouter';
const EMPTY_FORM_VALUES: FormData = {
name: '',
baseUrl: '',
apiKey: '',
model: '',
opusModel: '',
sonnetModel: '',
haikuModel: '',
};
const RECOMMENDED_PRESETS = getPresetsByCategory('recommended');
const QUICK_TEMPLATE_PRESETS = PROVIDER_PRESETS.filter(
(preset) => preset.category !== 'recommended'
);
const QUICK_TEMPLATE_PRESET_IDS = new Set<string>(
QUICK_TEMPLATE_PRESETS.map((preset) => preset.id)
);
export function ProfileCreateDialog({
open,
@@ -76,7 +98,7 @@ export function ProfileCreateDialog({
const [activeTab, setActiveTab] = useState('basic');
const [urlWarning, setUrlWarning] = useState<string | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
const [selectedPreset, setSelectedPreset] = useState<string | null>('openrouter');
const [selectedPreset, setSelectedPreset] = useState<PresetSelection>(DEFAULT_PRESET_ID);
const [modelSearch, setModelSearch] = useState('');
// OpenRouter models for model picker
@@ -91,23 +113,34 @@ export function ProfileCreateDialog({
setValue,
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
name: '',
baseUrl: '',
apiKey: '',
model: '',
opusModel: '',
sonnetModel: '',
haikuModel: '',
},
defaultValues: EMPTY_FORM_VALUES,
});
const baseUrlValue = useWatch({ control, name: 'baseUrl' });
const applyPresetToForm = useCallback(
(preset: ProviderPreset | null) => {
if (!preset) {
reset(EMPTY_FORM_VALUES);
return;
}
reset({
...EMPTY_FORM_VALUES,
name: preset.defaultProfileName,
baseUrl: preset.baseUrl,
model: preset.defaultModel,
opusModel: preset.defaultModel,
sonnetModel: preset.defaultModel,
haikuModel: preset.defaultModel,
});
},
[reset]
);
// Get current preset config
const currentPreset = useMemo(() => {
if (!selectedPreset || selectedPreset === 'custom') return null;
return PROVIDER_PRESETS.find((p) => p.id === selectedPreset);
if (selectedPreset === CUSTOM_PRESET_ID) return null;
return getPresetById(selectedPreset) ?? null;
}, [selectedPreset]);
// Filter models for OpenRouter search (newest first)
@@ -124,7 +157,6 @@ export function ProfileCreateDialog({
// Reset form when dialog opens
useEffect(() => {
if (open) {
reset();
setActiveTab('basic');
setUrlWarning(null);
setShowApiKey(false);
@@ -132,45 +164,35 @@ export function ProfileCreateDialog({
// Set initial preset based on initialMode
if (initialMode === 'normal') {
// Custom mode - clear form
setSelectedPreset('custom');
setTimeout(() => {
setValue('name', '');
setValue('baseUrl', '');
}, 0);
setSelectedPreset(CUSTOM_PRESET_ID);
applyPresetToForm(null);
} else {
// OpenRouter mode (default)
setSelectedPreset('openrouter');
const openrouterPreset = PROVIDER_PRESETS.find((p) => p.id === 'openrouter');
if (openrouterPreset) {
setTimeout(() => {
setValue('name', openrouterPreset.defaultProfileName);
setValue('baseUrl', openrouterPreset.baseUrl);
}, 0);
const defaultPreset = getPresetById(DEFAULT_PRESET_ID);
if (defaultPreset) {
setSelectedPreset(defaultPreset.id);
applyPresetToForm(defaultPreset);
return;
}
// Safe fallback if default preset is missing.
setSelectedPreset(CUSTOM_PRESET_ID);
applyPresetToForm(null);
}
}
}, [open, reset, setValue, initialMode]);
}, [open, initialMode, applyPresetToForm]);
// Handle preset selection
const handlePresetSelect = (presetId: string) => {
setSelectedPreset(presetId);
const preset = PROVIDER_PRESETS.find((p) => p.id === presetId);
const preset = getPresetById(presetId);
if (preset) {
setValue('name', preset.defaultProfileName);
setValue('baseUrl', preset.baseUrl);
if (preset.defaultModel) {
setValue('model', preset.defaultModel);
setValue('opusModel', preset.defaultModel);
setValue('sonnetModel', preset.defaultModel);
setValue('haikuModel', preset.defaultModel);
}
} else {
// Custom
setValue('name', '');
setValue('baseUrl', '');
setValue('model', '');
setSelectedPreset(preset.id);
applyPresetToForm(preset);
return;
}
setSelectedPreset(CUSTOM_PRESET_ID);
applyPresetToForm(null);
};
// Handle model selection from picker - applies to all 4 model tiers
@@ -190,7 +212,7 @@ export function ProfileCreateDialog({
// Presets (OpenRouter, GLM, GLMT, Kimi) have vetted URLs that may require full paths
useEffect(() => {
// Only warn for custom URLs, not preset-selected ones
const isCustomUrl = selectedPreset === 'custom';
const isCustomUrl = selectedPreset === CUSTOM_PRESET_ID;
if (baseUrlValue && isCustomUrl) {
const lowerUrl = baseUrlValue.toLowerCase();
for (const path of PROBLEMATIC_PATHS) {
@@ -232,11 +254,13 @@ export function ProfileCreateDialog({
const hasModelErrors =
!!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel;
const isOpenRouter = selectedPreset === 'openrouter';
const isQuickTemplateSelected =
selectedPreset !== CUSTOM_PRESET_ID && QUICK_TEMPLATE_PRESET_IDS.has(selectedPreset);
const isOpenRouter = currentPreset?.id === DEFAULT_PRESET_ID;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] p-0 gap-0 overflow-hidden max-h-[90vh]">
<DialogContent className="sm:max-w-[700px] p-0 gap-0 overflow-hidden max-h-[90vh] flex flex-col">
<DialogHeader className="p-6 pb-4 border-b">
<DialogTitle className="flex items-center gap-2">
<Plus className="w-5 h-5 text-primary" />
@@ -247,14 +271,17 @@ export function ProfileCreateDialog({
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="flex flex-col overflow-hidden">
<form
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col flex-1 min-h-0 overflow-hidden"
>
{/* Provider Preset Cards - Compact horizontal layout */}
<div className="px-6 py-3 border-b bg-muted/30 space-y-2">
{/* Main Options: OpenRouter + Custom */}
<div>
<Label className="text-xs text-muted-foreground mb-1.5 block">Provider</Label>
<div className="flex gap-2">
{getPresetsByCategory('recommended').map((preset) => (
{RECOMMENDED_PRESETS.map((preset) => (
<CompactPresetCard
key={preset.id}
preset={preset}
@@ -265,11 +292,10 @@ export function ProfileCreateDialog({
{/* Custom option */}
<button
type="button"
onClick={() => handlePresetSelect('custom')}
onClick={() => handlePresetSelect(CUSTOM_PRESET_ID)}
className={cn(
'flex items-center gap-2 px-4 py-2 rounded-md border-2 transition-all text-sm font-medium',
selectedPreset === 'custom' ||
getPresetsByCategory('alternative').some((p) => p.id === selectedPreset)
selectedPreset === CUSTOM_PRESET_ID || isQuickTemplateSelected
? 'border-primary bg-primary/10 text-primary dark:bg-primary/20'
: 'border-dashed border-muted-foreground/40 hover:border-primary/50 hover:bg-muted/50 text-muted-foreground hover:text-foreground'
)}
@@ -280,15 +306,14 @@ export function ProfileCreateDialog({
</div>
</div>
{/* Show alternative presets when Custom is selected or an alternative is selected */}
{(selectedPreset === 'custom' ||
getPresetsByCategory('alternative').some((p) => p.id === selectedPreset)) && (
{/* Show quick templates when custom mode or non-recommended preset is selected */}
{(selectedPreset === CUSTOM_PRESET_ID || isQuickTemplateSelected) && (
<div className="pt-3 mt-2 border-t border-dashed border-muted-foreground/30">
<Label className="text-xs font-medium text-foreground/70 mb-2 block">
Quick Templates
</Label>
<div className="flex gap-2 flex-wrap">
{getPresetsByCategory('alternative').map((preset) => (
{QUICK_TEMPLATE_PRESETS.map((preset) => (
<CompactPresetCard
key={preset.id}
preset={preset}
@@ -304,7 +329,7 @@ export function ProfileCreateDialog({
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-col flex-1 overflow-hidden"
className="flex flex-col flex-1 min-h-0 overflow-hidden"
>
<div className="px-6 pt-4">
<TabsList className="grid w-full grid-cols-2">
@@ -323,7 +348,7 @@ export function ProfileCreateDialog({
</TabsList>
</div>
<ScrollArea className="flex-1">
<ScrollArea className="flex-1 min-h-0">
<TabsContent value="basic" className="p-6 space-y-4 mt-0">
{/* Profile Name */}
<div className="space-y-1.5">
+7 -16
View File
@@ -5,20 +5,11 @@
*/
import type { ProviderOption } from './types';
import type { CLIProxyProvider } from '@/lib/provider-config';
/** Provider display info for wizard - ordered by recommendation */
const PROVIDER_INFO: Record<CLIProxyProvider, { name: string; description: string }> = {
agy: { name: 'Antigravity', description: 'Antigravity AI models' },
claude: { name: 'Claude (Anthropic)', description: 'Claude Opus/Sonnet models' },
gemini: { name: 'Google Gemini', description: 'Gemini Pro/Flash models' },
codex: { name: 'OpenAI Codex', description: 'GPT-4 and codex models' },
qwen: { name: 'Alibaba Qwen', description: 'Qwen Code models' },
iflow: { name: 'iFlow', description: 'iFlow AI models' },
kiro: { name: 'Kiro (AWS)', description: 'AWS CodeWhisperer models' },
ghcp: { name: 'GitHub Copilot (OAuth)', description: 'GitHub Copilot via OAuth' },
kimi: { name: 'Kimi (Moonshot)', description: 'Moonshot AI K2/K2.5 models' },
};
import {
type CLIProxyProvider,
getProviderDescription,
getProviderDisplayName,
} from '@/lib/provider-config';
/** Wizard display order - most recommended first */
const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [
@@ -35,8 +26,8 @@ const WIZARD_PROVIDER_ORDER: CLIProxyProvider[] = [
export const PROVIDERS: ProviderOption[] = WIZARD_PROVIDER_ORDER.map((id) => ({
id,
name: PROVIDER_INFO[id].name,
description: PROVIDER_INFO[id].description,
name: getProviderDisplayName(id),
description: getProviderDescription(id),
}));
export const ALL_STEPS = ['provider', 'auth', 'variant', 'success'];
+11 -16
View File
@@ -17,6 +17,10 @@ import {
import { Button } from '@/components/ui/button';
import { ExternalLink, Copy, Check, Loader2, KeyRound } from 'lucide-react';
import { toast } from 'sonner';
import {
getDeviceCodeProviderDisplayName,
getDeviceCodeProviderInstruction,
} from '@/lib/provider-config';
interface DeviceCodeDialogProps {
open: boolean;
@@ -28,18 +32,6 @@ interface DeviceCodeDialogProps {
expiresAt: number;
}
/** Provider display names */
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
ghcp: 'GitHub Copilot',
qwen: 'Qwen Code',
};
/** Provider specific instructions */
const PROVIDER_INSTRUCTIONS: Record<string, string> = {
ghcp: 'Sign in with your GitHub account that has Copilot access.',
qwen: 'Sign in with your Qwen account to authorize access.',
};
export function DeviceCodeDialog({
open,
onClose,
@@ -91,9 +83,12 @@ export function DeviceCodeDialog({
window.open(verificationUrl, '_blank', 'noopener,noreferrer');
}, [verificationUrl]);
const providerDisplay = PROVIDER_DISPLAY_NAMES[provider] || provider;
const instructions =
PROVIDER_INSTRUCTIONS[provider] || 'Complete the authorization in your browser.';
const providerDisplay = getDeviceCodeProviderDisplayName(provider);
const instructions = getDeviceCodeProviderInstruction(provider);
const openActionLabel =
providerDisplay === 'Unknown provider'
? 'Open verification page'
: `Open ${providerDisplay.split(' ')[0]}`;
// Format remaining time
const formatTime = (seconds: number): string => {
@@ -155,7 +150,7 @@ export function DeviceCodeDialog({
<div className="flex flex-col gap-3">
<Button onClick={handleOpenUrl} className="w-full">
<ExternalLink className="w-4 h-4 mr-2" />
Open {providerDisplay.split(' ')[0]}
{openActionLabel}
</Button>
<Button variant="outline" onClick={handleCopyCode} className="w-full">
{hasCopied ? (
+5 -4
View File
@@ -5,7 +5,7 @@
*/
import { cn } from '@/lib/utils';
import { PROVIDER_ASSETS, PROVIDER_COLORS } from '@/lib/provider-config';
import { getProviderLogoAsset, PROVIDER_COLORS } from '@/lib/provider-config';
interface ProviderIconProps {
provider: string;
@@ -22,7 +22,8 @@ export function ProviderIcon({
withBackground = false,
}: ProviderIconProps) {
const normalized = provider.toLowerCase();
const assetPath = PROVIDER_ASSETS[normalized];
const assetPath = getProviderLogoAsset(provider);
const providerColor = PROVIDER_COLORS[normalized as keyof typeof PROVIDER_COLORS] || '#6b7280';
// Icon size is smaller when inside background circle
const iconSize = withBackground ? Math.floor(size * 0.65) : size;
@@ -40,7 +41,7 @@ export function ProviderIcon({
<span
className="font-bold"
style={{
color: PROVIDER_COLORS[normalized] || '#6b7280',
color: providerColor,
fontSize: iconSize * 0.6,
}}
>
@@ -75,7 +76,7 @@ export function ProviderIcon({
);
}
const bgColor = PROVIDER_COLORS[normalized] || '#6b7280';
const bgColor = providerColor;
return (
<div
className={cn(
@@ -6,6 +6,7 @@
import { Clock } from 'lucide-react';
import {
cn,
formatQuotaPercent,
formatResetTime,
getCodexQuotaBreakdown,
getCodexWindowDisplayLabel,
@@ -14,6 +15,7 @@ import {
isAgyQuotaResult,
isCodexQuotaResult,
isGeminiQuotaResult,
isGhcpQuotaResult,
type ModelTier,
type UnifiedQuotaResult,
} from '@/lib/utils';
@@ -23,6 +25,16 @@ interface QuotaTooltipContentProps {
resetTime: string | null;
}
function formatPlanLabel(planType: string | null | undefined): string | null {
if (!planType) return null;
const normalized = planType
.split(/[\s_-]+/g)
.map((part) => part.trim())
.filter((part) => part.length > 0)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1));
return normalized.length > 0 ? normalized.join(' ') : planType;
}
/**
* Renders provider-specific quota tooltip content
* Uses type guards for proper TypeScript narrowing
@@ -122,6 +134,45 @@ export function QuotaTooltipContent({ quota, resetTime }: QuotaTooltipContentPro
);
}
// GitHub Copilot (ghcp) provider tooltip
if (isGhcpQuotaResult(quota)) {
const snapshotRows = [
{ label: 'Premium Interactions', snapshot: quota.snapshots.premiumInteractions },
{ label: 'Chat', snapshot: quota.snapshots.chat },
{ label: 'Completions', snapshot: quota.snapshots.completions },
];
const effectiveResetTime = quota.quotaResetDate ?? resetTime;
const planLabel = formatPlanLabel(quota.planType);
return (
<div className="text-xs space-y-1">
<p className="font-medium">Quota Snapshots:</p>
{planLabel && <p className="text-muted-foreground">Plan: {planLabel}</p>}
{snapshotRows.map(({ label, snapshot }) => {
const isLow = snapshot.percentRemaining < 20;
return (
<div key={label} className="space-y-0.5">
<div className="flex justify-between gap-4">
<span className={cn(isLow && 'text-red-500')}>{label}</span>
<span className={cn('font-mono', isLow && 'text-red-500')}>
{snapshot.unlimited
? 'Unlimited'
: `${formatQuotaPercent(snapshot.percentRemaining)}%`}
</span>
</div>
{!snapshot.unlimited && (
<div className="text-[11px] text-muted-foreground">
{snapshot.remaining}/{snapshot.entitlement} remaining
</div>
)}
</div>
);
})}
<ResetTimeIndicator resetTime={effectiveResetTime} />
</div>
);
}
return null;
}
+24 -3
View File
@@ -8,6 +8,7 @@ import type {
QuotaResult,
CodexQuotaResult,
GeminiCliQuotaResult,
GhcpQuotaResult,
} from '@/lib/api-client';
import type { UnifiedQuotaResult } from '@/lib/utils';
@@ -202,10 +203,10 @@ export function useCliproxyErrorLogContent(name: string | null) {
}
// Re-export for consumers
export type { ModelQuota, QuotaResult, CodexQuotaResult, GeminiCliQuotaResult };
export type { ModelQuota, QuotaResult, CodexQuotaResult, GeminiCliQuotaResult, GhcpQuotaResult };
/** Providers with quota API support */
export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'gemini'] as const;
export const QUOTA_SUPPORTED_PROVIDERS = ['agy', 'codex', 'gemini', 'ghcp'] as const;
export type QuotaSupportedProvider = (typeof QUOTA_SUPPORTED_PROVIDERS)[number];
/**
@@ -262,6 +263,24 @@ async function fetchGeminiQuotaApi(accountId: string): Promise<GeminiCliQuotaRes
return response.json();
}
/**
* Fetch GitHub Copilot (ghcp) quota from API
*/
async function fetchGhcpQuotaApi(accountId: string): Promise<GhcpQuotaResult> {
const response = await fetch(`/api/cliproxy/quota/ghcp/${encodeURIComponent(accountId)}`);
if (!response.ok) {
let message = 'Failed to fetch GitHub Copilot quota';
try {
const error = await response.json();
message = error.message || message;
} catch {
// Use default message if response isn't JSON
}
throw new Error(message);
}
return response.json();
}
// Re-export unified type from utils for consumers
export type { UnifiedQuotaResult } from '@/lib/utils';
@@ -277,6 +296,8 @@ async function fetchQuotaByProvider(
return fetchCodexQuotaApi(accountId);
case 'gemini':
return fetchGeminiQuotaApi(accountId);
case 'ghcp':
return fetchGhcpQuotaApi(accountId);
default:
return fetchAccountQuota(provider, accountId);
}
@@ -284,7 +305,7 @@ async function fetchQuotaByProvider(
/**
* Hook to get account quota
* Supports agy, codex, and gemini providers
* Supports agy, codex, gemini, and ghcp providers
*/
export function useAccountQuota(provider: string, accountId: string, enabled = true) {
return useQuery({
+13 -14
View File
@@ -6,8 +6,7 @@
import { useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
const API_BASE = '/api';
import { ApiConflictError, withApiBase } from '@/lib/api-client';
// Types
export interface CopilotStatus {
@@ -80,31 +79,31 @@ export interface CopilotRawSettings {
// API functions
async function fetchCopilotStatus(): Promise<CopilotStatus> {
const res = await fetch(`${API_BASE}/copilot/status`);
const res = await fetch(withApiBase('/copilot/status'));
if (!res.ok) throw new Error('Failed to fetch copilot status');
return res.json();
}
async function fetchCopilotConfig(): Promise<CopilotConfig> {
const res = await fetch(`${API_BASE}/copilot/config`);
const res = await fetch(withApiBase('/copilot/config'));
if (!res.ok) throw new Error('Failed to fetch copilot config');
return res.json();
}
async function fetchCopilotModels(): Promise<{ models: CopilotModel[]; current: string }> {
const res = await fetch(`${API_BASE}/copilot/models`);
const res = await fetch(withApiBase('/copilot/models'));
if (!res.ok) throw new Error('Failed to fetch copilot models');
return res.json();
}
async function fetchCopilotRawSettings(): Promise<CopilotRawSettings> {
const res = await fetch(`${API_BASE}/copilot/settings/raw`);
const res = await fetch(withApiBase('/copilot/settings/raw'));
if (!res.ok) throw new Error('Failed to fetch copilot raw settings');
return res.json();
}
async function updateCopilotConfig(config: Partial<CopilotConfig>): Promise<{ success: boolean }> {
const res = await fetch(`${API_BASE}/copilot/config`, {
const res = await fetch(withApiBase('/copilot/config'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
@@ -117,12 +116,12 @@ async function saveCopilotRawSettings(data: {
settings: CopilotRawSettings['settings'];
expectedMtime?: number;
}): Promise<{ success: boolean; mtime: number }> {
const res = await fetch(`${API_BASE}/copilot/settings/raw`, {
const res = await fetch(withApiBase('/copilot/settings/raw'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (res.status === 409) throw new Error('CONFLICT');
if (res.status === 409) throw new ApiConflictError('Copilot raw settings changed externally');
if (!res.ok) throw new Error('Failed to save copilot raw settings');
return res.json();
}
@@ -135,31 +134,31 @@ export interface CopilotAuthResult {
}
async function startCopilotAuth(): Promise<CopilotAuthResult> {
const res = await fetch(`${API_BASE}/copilot/auth/start`, { method: 'POST' });
const res = await fetch(withApiBase('/copilot/auth/start'), { method: 'POST' });
if (!res.ok) throw new Error('Failed to start auth');
return res.json();
}
async function startCopilotDaemon(): Promise<{ success: boolean; pid?: number; error?: string }> {
const res = await fetch(`${API_BASE}/copilot/daemon/start`, { method: 'POST' });
const res = await fetch(withApiBase('/copilot/daemon/start'), { method: 'POST' });
if (!res.ok) throw new Error('Failed to start daemon');
return res.json();
}
async function stopCopilotDaemon(): Promise<{ success: boolean; error?: string }> {
const res = await fetch(`${API_BASE}/copilot/daemon/stop`, { method: 'POST' });
const res = await fetch(withApiBase('/copilot/daemon/stop'), { method: 'POST' });
if (!res.ok) throw new Error('Failed to stop daemon');
return res.json();
}
async function fetchCopilotInfo(): Promise<CopilotInfo> {
const res = await fetch(`${API_BASE}/copilot/info`);
const res = await fetch(withApiBase('/copilot/info'));
if (!res.ok) throw new Error('Failed to fetch copilot info');
return res.json();
}
async function installCopilotApi(version?: string): Promise<CopilotInstallResult> {
const res = await fetch(`${API_BASE}/copilot/install`, {
const res = await fetch(withApiBase('/copilot/install'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(version ? { version } : {}),
+12 -13
View File
@@ -6,8 +6,7 @@
import { useMemo } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
const API_BASE = '/api';
import { ApiConflictError, withApiBase } from '@/lib/api-client';
export interface CursorStatus {
enabled: boolean;
@@ -59,25 +58,25 @@ interface CursorAuthResult {
}
async function fetchCursorStatus(): Promise<CursorStatus> {
const res = await fetch(`${API_BASE}/cursor/status`);
const res = await fetch(withApiBase('/cursor/status'));
if (!res.ok) throw new Error('Failed to fetch cursor status');
return res.json();
}
async function fetchCursorConfig(): Promise<CursorConfig> {
const res = await fetch(`${API_BASE}/cursor/settings`);
const res = await fetch(withApiBase('/cursor/settings'));
if (!res.ok) throw new Error('Failed to fetch cursor config');
return res.json();
}
async function fetchCursorModels(): Promise<CursorModelsResponse> {
const res = await fetch(`${API_BASE}/cursor/models`);
const res = await fetch(withApiBase('/cursor/models'));
if (!res.ok) throw new Error('Failed to fetch cursor models');
return res.json();
}
async function fetchCursorRawSettings(): Promise<CursorRawSettings> {
const res = await fetch(`${API_BASE}/cursor/settings/raw`);
const res = await fetch(withApiBase('/cursor/settings/raw'));
if (!res.ok) throw new Error('Failed to fetch cursor raw settings');
return res.json();
}
@@ -85,7 +84,7 @@ async function fetchCursorRawSettings(): Promise<CursorRawSettings> {
async function updateCursorConfig(
updates: Partial<CursorConfig>
): Promise<{ success: boolean; cursor: CursorConfig }> {
const res = await fetch(`${API_BASE}/cursor/settings`, {
const res = await fetch(withApiBase('/cursor/settings'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
@@ -98,18 +97,18 @@ async function saveCursorRawSettings(data: {
settings: CursorRawSettings['settings'];
expectedMtime?: number;
}): Promise<{ success: boolean; mtime: number }> {
const res = await fetch(`${API_BASE}/cursor/settings/raw`, {
const res = await fetch(withApiBase('/cursor/settings/raw'), {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (res.status === 409) throw new Error('CONFLICT');
if (res.status === 409) throw new ApiConflictError('Cursor raw settings changed externally');
if (!res.ok) throw new Error('Failed to save cursor raw settings');
return res.json();
}
async function autoDetectCursorAuth(): Promise<CursorAuthResult> {
const res = await fetch(`${API_BASE}/cursor/auth/auto-detect`, { method: 'POST' });
const res = await fetch(withApiBase('/cursor/auth/auto-detect'), { method: 'POST' });
if (!res.ok) {
const error = await res.json().catch(() => ({ error: 'Auto-detect failed' }));
throw new Error(error.error || 'Auto-detect failed');
@@ -121,7 +120,7 @@ async function importCursorAuthManual(data: {
accessToken: string;
machineId: string;
}): Promise<CursorAuthResult> {
const res = await fetch(`${API_BASE}/cursor/auth/import`, {
const res = await fetch(withApiBase('/cursor/auth/import'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
@@ -134,13 +133,13 @@ async function importCursorAuthManual(data: {
}
async function startCursorDaemon(): Promise<{ success: boolean; pid?: number; error?: string }> {
const res = await fetch(`${API_BASE}/cursor/daemon/start`, { method: 'POST' });
const res = await fetch(withApiBase('/cursor/daemon/start'), { method: 'POST' });
if (!res.ok) throw new Error('Failed to start cursor daemon');
return res.json();
}
async function stopCursorDaemon(): Promise<{ success: boolean; error?: string }> {
const res = await fetch(`${API_BASE}/cursor/daemon/stop`, { method: 'POST' });
const res = await fetch(withApiBase('/cursor/daemon/stop'), { method: 'POST' });
if (!res.ok) throw new Error('Failed to stop cursor daemon');
return res.json();
}
+13 -12
View File
@@ -8,6 +8,7 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { toast } from 'sonner';
import { getDeviceCodeProviderDisplayName } from '@/lib/provider-config';
export interface DeviceCodePrompt {
sessionId: string;
@@ -23,12 +24,13 @@ interface DeviceCodeState {
error: string | null;
}
/** Provider display names for user-friendly messages */
const PROVIDER_DISPLAY_NAMES: Record<string, string> = {
ghcp: 'GitHub Copilot',
kiro: 'Kiro (AWS)',
qwen: 'Qwen Code',
};
function coerceProvider(value: unknown): string {
if (typeof value !== 'string') {
return 'unknown';
}
const normalized = value.trim().toLowerCase();
return normalized || 'unknown';
}
export function useDeviceCode() {
const [state, setState] = useState<DeviceCodeState>({
@@ -44,14 +46,15 @@ export function useDeviceCode() {
if (data.type === 'deviceCodeReceived') {
console.log('[DeviceCode] Received prompt:', data.sessionId);
const displayName = PROVIDER_DISPLAY_NAMES[data.provider as string] || data.provider;
const provider = coerceProvider(data.provider);
const displayName = getDeviceCodeProviderDisplayName(provider);
toast.info(`${displayName} authorization required`);
setState({
isOpen: true,
prompt: {
sessionId: data.sessionId as string,
provider: data.provider as string,
provider,
userCode: data.userCode as string,
verificationUrl: data.verificationUrl as string,
expiresAt: data.expiresAt as number,
@@ -62,8 +65,7 @@ export function useDeviceCode() {
console.log('[DeviceCode] Auth completed:', data.sessionId);
setState((prev) => {
if (prev.prompt && prev.prompt.sessionId === data.sessionId) {
const displayName =
PROVIDER_DISPLAY_NAMES[prev.prompt.provider] || prev.prompt.provider;
const displayName = getDeviceCodeProviderDisplayName(prev.prompt.provider);
toast.success(`${displayName} authentication successful!`);
return { isOpen: false, prompt: null, error: null };
}
@@ -73,8 +75,7 @@ export function useDeviceCode() {
console.log('[DeviceCode] Auth failed:', data.sessionId, data.error);
setState((prev) => {
if (prev.prompt && prev.prompt.sessionId === data.sessionId) {
const displayName =
PROVIDER_DISPLAY_NAMES[prev.prompt.provider] || prev.prompt.provider;
const displayName = getDeviceCodeProviderDisplayName(prev.prompt.provider);
toast.error(`${displayName} authentication failed`);
return { isOpen: false, prompt: null, error: data.error as string };
}
+128 -9
View File
@@ -5,20 +5,92 @@
import type { CLIProxyProvider } from './provider-config';
const BASE_URL = '/api';
export const API_BASE_URL = '/api';
export const API_CONFLICT_ERROR_CODE = 'CONFLICT';
export class ApiConflictError extends Error {
readonly code = API_CONFLICT_ERROR_CODE;
constructor(message = 'Resource modified externally') {
super(message);
this.name = 'ApiConflictError';
}
}
export function isApiConflictError(error: unknown): error is Error & { code: string } {
return (
error instanceof Error &&
'code' in error &&
(error as { code?: unknown }).code === API_CONFLICT_ERROR_CODE
);
}
export function withApiBase(path: string): string {
if (!path) {
return API_BASE_URL;
}
if (/^https?:\/\//i.test(path)) {
return path;
}
if (path === API_BASE_URL || path.startsWith(`${API_BASE_URL}/`)) {
return path;
}
return `${API_BASE_URL}${path.startsWith('/') ? path : `/${path}`}`;
}
async function parseErrorMessage(response: Response): Promise<string> {
const fallbackMessage = `Request failed (${response.status}${response.statusText ? ` ${response.statusText}` : ''})`;
const bodyText = await response.text();
if (!bodyText) {
return fallbackMessage;
}
try {
const parsed = JSON.parse(bodyText) as { error?: string; message?: string };
if (parsed.error?.trim()) {
return parsed.error;
}
if (parsed.message?.trim()) {
return parsed.message;
}
return fallbackMessage;
} catch {
return bodyText.trim() || fallbackMessage;
}
}
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(`${BASE_URL}${url}`, {
const res = await fetch(withApiBase(url), {
headers: { 'Content-Type': 'application/json' },
...options,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ error: 'Unknown error' }));
throw new Error(error.error || res.statusText);
throw new Error(await parseErrorMessage(res));
}
return res.json();
if (res.status === 204) {
return undefined as T;
}
const contentType = res.headers.get('content-type')?.toLowerCase() ?? '';
if (contentType.includes('application/json')) {
return (await res.json()) as T;
}
const bodyText = await res.text();
if (!bodyText) {
return undefined as T;
}
try {
return JSON.parse(bodyText) as T;
} catch {
return bodyText as T;
}
}
// Types
@@ -265,6 +337,53 @@ export interface GeminiCliQuotaResult {
cached?: boolean;
}
/** GitHub Copilot quota snapshot */
export interface GhcpQuotaSnapshot {
/** Total quota allocation for this category */
entitlement: number;
/** Remaining quota count */
remaining: number;
/** Used quota count */
used: number;
/** Remaining quota percentage (0-100) */
percentRemaining: number;
/** Used quota percentage (0-100) */
percentUsed: number;
/** Whether this quota category is unlimited */
unlimited: boolean;
/** Overage usage count */
overageCount: number;
/** Whether overage is permitted */
overagePermitted: boolean;
/** Upstream quota identifier if available */
quotaId: string | null;
}
/** GitHub Copilot (ghcp) quota result */
export interface GhcpQuotaResult {
/** Whether fetch succeeded */
success: boolean;
/** Copilot plan type */
planType: string | null;
/** Quota reset date/time */
quotaResetDate: string | null;
snapshots: {
premiumInteractions: GhcpQuotaSnapshot;
chat: GhcpQuotaSnapshot;
completions: GhcpQuotaSnapshot;
};
/** Timestamp of fetch */
lastUpdated: number;
/** Error message if fetch failed */
error?: string;
/** Account ID this quota belongs to */
accountId?: string;
/** True if token is expired and needs re-authentication */
needsReauth?: boolean;
/** True if result was served from cache */
cached?: boolean;
}
/** Provider accounts summary */
export type ProviderAccountsMap = Record<string, OAuthAccount[]>;
@@ -486,12 +605,12 @@ export const api = {
// Config YAML for Config tab
getConfigYaml: async (): Promise<string> => {
const res = await fetch(`${BASE_URL}/cliproxy/config.yaml`);
const res = await fetch(withApiBase('/cliproxy/config.yaml'));
if (!res.ok) throw new Error('Failed to load config');
return res.text();
},
saveConfigYaml: async (content: string): Promise<void> => {
const res = await fetch(`${BASE_URL}/cliproxy/config.yaml`, {
const res = await fetch(withApiBase('/cliproxy/config.yaml'), {
method: 'PUT',
headers: { 'Content-Type': 'application/yaml' },
body: content,
@@ -506,7 +625,7 @@ export const api = {
getAuthFiles: () => request<{ files: AuthFile[] }>('/cliproxy/auth-files'),
getAuthFile: async (name: string): Promise<string> => {
const res = await fetch(
`${BASE_URL}/cliproxy/auth-files/download?name=${encodeURIComponent(name)}`
withApiBase(`/cliproxy/auth-files/download?name=${encodeURIComponent(name)}`)
);
if (!res.ok) throw new Error('Failed to load auth file');
return res.text();
@@ -588,7 +707,7 @@ export const api = {
list: () => request<{ files: CliproxyErrorLog[] }>('/cliproxy/error-logs'),
/** Get content of a specific error log */
getContent: async (name: string): Promise<string> => {
const res = await fetch(`${BASE_URL}/cliproxy/error-logs/${encodeURIComponent(name)}`);
const res = await fetch(withApiBase(`/cliproxy/error-logs/${encodeURIComponent(name)}`));
if (!res.ok) throw new Error('Failed to load error log');
return res.text();
},
+9
View File
@@ -0,0 +1,9 @@
/**
* UI-side default ports.
*
* Keep UI defaults explicit to preserve frontend decoupling from backend build internals.
* Sync is enforced by backend/UI parity tests in `tests/unit/cliproxy`.
*/
export const CLIPROXY_DEFAULT_PORT = 8317;
export const DEFAULT_CURSOR_PORT = 20129;
+34
View File
@@ -275,6 +275,40 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
},
],
},
kimi: {
provider: 'kimi',
displayName: 'Kimi (Moonshot)',
defaultModel: 'kimi-k2.5',
models: [
{
id: 'kimi-k2.5',
name: 'Kimi K2.5',
description: 'Latest multimodal model (262K context)',
presetMapping: {
default: 'kimi-k2.5',
opus: 'kimi-k2.5',
sonnet: 'kimi-k2-thinking',
haiku: 'kimi-k2',
},
},
{
id: 'kimi-k2-thinking',
name: 'Kimi K2 Thinking',
description: 'Extended reasoning model',
presetMapping: {
default: 'kimi-k2-thinking',
opus: 'kimi-k2.5',
sonnet: 'kimi-k2-thinking',
haiku: 'kimi-k2',
},
},
{
id: 'kimi-k2',
name: 'Kimi K2',
description: 'Flagship coding model',
},
],
},
kiro: {
provider: 'kiro',
displayName: 'Kiro (AWS)',
+4 -5
View File
@@ -4,9 +4,8 @@
*/
import { MODEL_CATALOGS } from './model-catalogs';
/** CLIProxy port - should match the backend configuration */
export const CLIPROXY_PORT = 8317;
import { CLIPROXY_DEFAULT_PORT } from './default-ports';
export { CLIPROXY_DEFAULT_PORT } from './default-ports';
/** Default fallback API key if fetch fails */
const DEFAULT_API_KEY = 'ccs-internal-managed';
@@ -31,7 +30,7 @@ async function fetchEffectiveApiKey(): Promise<string> {
* Uses the first model's presetMapping or falls back to using defaultModel for all tiers
*
* @param provider - The provider ID (e.g., 'gemini', 'codex', 'agy')
* @param port - Optional custom port (defaults to CLIPROXY_PORT)
* @param port - Optional custom port (defaults to CLIPROXY_DEFAULT_PORT)
* @returns Object with success status and applied preset name
*/
export async function applyDefaultPreset(
@@ -53,7 +52,7 @@ export async function applyDefaultPreset(
// Fetch effective API key (respects user customization)
const effectiveApiKey = await fetchEffectiveApiKey();
const effectivePort = port ?? CLIPROXY_PORT;
const effectivePort = port ?? CLIPROXY_DEFAULT_PORT;
const settings = {
env: {
ANTHROPIC_BASE_URL: `http://127.0.0.1:${effectivePort}/api/provider/${provider}`,
+159 -16
View File
@@ -30,8 +30,56 @@ export function isValidProvider(provider: string): provider is CLIProxyProvider
return CLIPROXY_PROVIDERS.includes(provider as CLIProxyProvider);
}
function normalizeProviderInput(provider: unknown): string {
return typeof provider === 'string' ? provider.trim().toLowerCase() : '';
}
interface ProviderMetadata {
displayName: string;
description: string;
}
export const PROVIDER_METADATA: Record<CLIProxyProvider, ProviderMetadata> = {
agy: {
displayName: 'Antigravity',
description: 'Antigravity AI models',
},
claude: {
displayName: 'Claude (Anthropic)',
description: 'Claude Opus/Sonnet models',
},
gemini: {
displayName: 'Google Gemini',
description: 'Gemini Pro/Flash models',
},
codex: {
displayName: 'OpenAI Codex',
description: 'GPT-4 and codex models',
},
qwen: {
displayName: 'Alibaba Qwen',
description: 'Qwen Code models',
},
iflow: {
displayName: 'iFlow',
description: 'iFlow AI models',
},
kiro: {
displayName: 'Kiro (AWS)',
description: 'AWS CodeWhisperer models',
},
ghcp: {
displayName: 'GitHub Copilot (OAuth)',
description: 'GitHub Copilot via OAuth',
},
kimi: {
displayName: 'Kimi (Moonshot)',
description: 'Moonshot AI K2/K2.5 models',
},
};
// Map provider names to asset filenames (only providers with actual logos)
export const PROVIDER_ASSETS: Record<string, string> = {
export const PROVIDER_ASSETS: Record<CLIProxyProvider, string> = {
gemini: '/assets/providers/gemini-color.svg',
agy: '/assets/providers/agy.png',
codex: '/assets/providers/openai.svg',
@@ -43,6 +91,56 @@ export const PROVIDER_ASSETS: Record<string, string> = {
kimi: '/assets/providers/kimi.svg',
};
interface ProviderFallbackVisual {
textClass: string;
letter: string;
}
const DEFAULT_PROVIDER_FALLBACK_VISUAL: ProviderFallbackVisual = {
textClass: 'text-gray-600',
letter: '?',
};
/** Fallback visual style when a provider logo asset is unavailable. */
export const PROVIDER_FALLBACK_VISUALS: Record<CLIProxyProvider, ProviderFallbackVisual> = {
gemini: { textClass: 'text-blue-600', letter: 'G' },
claude: { textClass: 'text-orange-600', letter: 'C' },
codex: { textClass: 'text-emerald-600', letter: 'X' },
agy: { textClass: 'text-violet-600', letter: 'A' },
qwen: { textClass: 'text-cyan-600', letter: 'Q' },
iflow: { textClass: 'text-indigo-600', letter: 'i' },
kiro: { textClass: 'text-teal-600', letter: 'K' },
ghcp: { textClass: 'text-green-600', letter: 'C' },
kimi: { textClass: 'text-orange-500', letter: 'K' },
};
/** Providers whose logo looks better on dark background. */
export const PROVIDERS_WITH_DARK_LOGO_BG: ReadonlySet<CLIProxyProvider> = new Set(['kimi']);
export function getProviderLogoAsset(provider: unknown): string | undefined {
const normalized = normalizeProviderInput(provider);
if (!isValidProvider(normalized)) {
return undefined;
}
return PROVIDER_ASSETS[normalized];
}
export function getProviderFallbackVisual(provider: unknown): ProviderFallbackVisual {
const normalized = normalizeProviderInput(provider);
if (isValidProvider(normalized)) {
return PROVIDER_FALLBACK_VISUALS[normalized];
}
return {
...DEFAULT_PROVIDER_FALLBACK_VISUAL,
letter: normalized[0]?.toUpperCase() || DEFAULT_PROVIDER_FALLBACK_VISUAL.letter,
};
}
export function providerNeedsDarkLogoBackground(provider: unknown): boolean {
const normalized = normalizeProviderInput(provider);
return isValidProvider(normalized) && PROVIDERS_WITH_DARK_LOGO_BG.has(normalized);
}
// Provider brand colors
export const PROVIDER_COLORS: Record<string, string> = {
gemini: '#4285F4',
@@ -59,21 +157,26 @@ export const PROVIDER_COLORS: Record<string, string> = {
// Provider display names
const PROVIDER_NAMES: Record<string, string> = {
gemini: 'Gemini',
agy: 'Antigravity',
codex: 'Codex',
...Object.fromEntries(
CLIPROXY_PROVIDERS.map((provider) => [provider, PROVIDER_METADATA[provider].displayName])
),
vertex: 'Vertex AI',
iflow: 'iFlow',
qwen: 'Qwen',
kiro: 'Kiro (AWS)',
ghcp: 'GitHub Copilot (OAuth)',
claude: 'Claude (Anthropic)',
kimi: 'Kimi (Moonshot)',
};
// Map provider to display name
export function getProviderDisplayName(provider: string): string {
return PROVIDER_NAMES[provider.toLowerCase()] || provider;
export function getProviderDisplayName(provider: unknown): string {
const normalized = normalizeProviderInput(provider);
if (!normalized) {
return 'Unknown provider';
}
return PROVIDER_NAMES[normalized] || String(provider);
}
/** Map provider to user-facing short description */
export function getProviderDescription(provider: unknown): string {
const normalized = normalizeProviderInput(provider);
if (!isValidProvider(normalized)) return '';
return PROVIDER_METADATA[normalized].description;
}
/**
@@ -82,17 +185,57 @@ export function getProviderDisplayName(provider: string): string {
*/
export const DEVICE_CODE_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro', 'qwen', 'kimi'];
const DEVICE_CODE_PROVIDER_DISPLAY_NAMES: Readonly<Partial<Record<CLIProxyProvider, string>>> =
Object.freeze({
ghcp: 'GitHub Copilot',
kiro: 'Kiro (AWS)',
qwen: 'Qwen Code',
});
const DEVICE_CODE_PROVIDER_INSTRUCTIONS: Readonly<Partial<Record<CLIProxyProvider, string>>> =
Object.freeze({
ghcp: 'Sign in with your GitHub account that has Copilot access.',
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.',
});
/** Check if provider uses Device Code flow */
export function isDeviceCodeProvider(provider: string): boolean {
return DEVICE_CODE_PROVIDERS.includes(provider as CLIProxyProvider);
export function isDeviceCodeProvider(provider: unknown): boolean {
const normalized = normalizeProviderInput(provider);
return isValidProvider(normalized) && DEVICE_CODE_PROVIDERS.includes(normalized);
}
/** Provider display name tuned for device-code UX copy. */
export function getDeviceCodeProviderDisplayName(provider: unknown): string {
const normalized = normalizeProviderInput(provider);
if (!normalized) {
return 'Unknown provider';
}
if (isValidProvider(normalized)) {
return DEVICE_CODE_PROVIDER_DISPLAY_NAMES[normalized] || getProviderDisplayName(normalized);
}
return String(provider);
}
/** Provider-specific helper text for device-code dialog. */
export function getDeviceCodeProviderInstruction(provider: unknown): string {
const normalized = normalizeProviderInput(provider);
if (isValidProvider(normalized)) {
return (
DEVICE_CODE_PROVIDER_INSTRUCTIONS[normalized] || 'Complete the authorization in your browser.'
);
}
return 'Complete the authorization in your browser.';
}
/** Providers that require nickname because token payload may not include email. */
export const NICKNAME_REQUIRED_PROVIDERS: CLIProxyProvider[] = ['ghcp', 'kiro'];
/** Check if provider requires user-supplied nickname in auth flow */
export function isNicknameRequiredProvider(provider: string): boolean {
return NICKNAME_REQUIRED_PROVIDERS.includes(provider as CLIProxyProvider);
export function isNicknameRequiredProvider(provider: unknown): boolean {
const normalized = normalizeProviderInput(provider);
return isValidProvider(normalized) && NICKNAME_REQUIRED_PROVIDERS.includes(normalized);
}
/** Kiro auth methods exposed in CCS UI (aligned with CLIProxyAPIPlus support). */

Some files were not shown because too many files have changed in this diff Show More