diff --git a/README.md b/README.md index 32dde7df..b234b704 100644 --- a/README.md +++ b/README.md @@ -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. +
diff --git a/docs/code-standards.md b/docs/code-standards.md index 94ef0f8d..8d6182ba 100644 --- a/docs/code-standards.md +++ b/docs/code-standards.md @@ -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) diff --git a/package.json b/package.json index d8053190..751826af 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/api/services/index.ts b/src/api/services/index.ts index c6ee337e..bdb07b20 100644 --- a/src/api/services/index.ts +++ b/src/api/services/index.ts @@ -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'; diff --git a/src/api/services/provider-presets.ts b/src/api/services/provider-presets.ts index 7daedaa3..d1404020 100644 --- a/src/api/services/provider-presets.ts +++ b/src/api/services/provider-presets.ts @@ -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; - /** 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://.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> = 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> { + return PRESET_ALIASES; +} + /** Check if preset ID is valid */ export function isValidPresetId(id: string): boolean { return getPresetById(id) !== undefined; diff --git a/src/auth/commands/create-command.ts b/src/auth/commands/create-command.ts index caeb41ed..3dd55c84 100644 --- a/src/auth/commands/create-command.ts +++ b/src/auth/commands/create-command.ts @@ -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, }); } diff --git a/src/auth/profile-detector.ts b/src/auth/profile-detector.ts index f700b030..c86668ca 100644 --- a/src/auth/profile-detector.ts +++ b/src/auth/profile-detector.ts @@ -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 diff --git a/src/ccs.ts b/src/ccs.ts index 32e0302c..b1d89e27 100644 --- a/src/ccs.ts +++ b/src/ccs.ts @@ -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 { 'auth', 'status', 'models', + 'usage', 'start', 'stop', 'enable', @@ -689,7 +691,7 @@ async function main(): Promise { 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 { 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 { } 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 diff --git a/src/cliproxy/account-safety.ts b/src/cliproxy/account-safety.ts index 8c9c2136..f7a89cde 100644 --- a/src/cliproxy/account-safety.ts +++ b/src/cliproxy/account-safety.ts @@ -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(); // --- 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 --- /** diff --git a/src/cliproxy/auth/auth-types.ts b/src/cliproxy/auth/auth-types.ts index acb10a43..92080547 100644 --- a/src/cliproxy/auth/auth-types.ts +++ b/src/cliproxy/auth/auth-types.ts @@ -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> = { - 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> = + CLIPROXY_PROVIDER_IDS.reduce( + (acc, provider) => { + const callbackPort = getOAuthCallbackPort(provider); + if (callbackPort !== null) { + acc[provider] = callbackPort; + } + return acc; + }, + {} as Partial> + ); /** * Auth status for a provider @@ -215,66 +224,34 @@ export const OAUTH_CONFIGS: Record = { * 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 = { - 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 = 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 = { - 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 = 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 = { - 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 = 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 = { - 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 = buildProviderMap( + (provider) => getCLIProxyAuthUrlProviderName(provider) +); /** * Get OAuth config for provider diff --git a/src/cliproxy/auth/gemini-token-refresh.ts b/src/cliproxy/auth/gemini-token-refresh.ts index 2117a3be..c08799b8 100644 --- a/src/cliproxy/auth/gemini-token-refresh.ts +++ b/src/cliproxy/auth/gemini-token-refresh.ts @@ -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'); diff --git a/src/cliproxy/auth/oauth-handler.ts b/src/cliproxy/auth/oauth-handler.ts index dc651216..f05316d2 100644 --- a/src/cliproxy/auth/oauth-handler.ts +++ b/src/cliproxy/auth/oauth-handler.ts @@ -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 { const oauthConfig = getOAuthConfig(provider); + warnOAuthBanRisk(provider); const { verbose = false, add = false, fromUI = false, noIncognito = true } = options; let { nickname } = options; const resolvedKiroMethod = diff --git a/src/cliproxy/auth/provider-refreshers/index.ts b/src/cliproxy/auth/provider-refreshers/index.ts index abcf3ff8..207ac0fa 100644 --- a/src/cliproxy/auth/provider-refreshers/index.ts +++ b/src/cliproxy/auth/provider-refreshers/index.ts @@ -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 { - 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 { - const result = await refreshGeminiToken(); +async function refreshGeminiTokenWrapper(accountId: string): Promise { + const result = await refreshGeminiToken(accountId); if (!result.success) { return { diff --git a/src/cliproxy/catalog-cache.ts b/src/cliproxy/catalog-cache.ts index 3045bc6a..948f0b75 100644 --- a/src/cliproxy/catalog-cache.ts +++ b/src/cliproxy/catalog-cache.ts @@ -23,6 +23,7 @@ const CHANNEL_TO_PROVIDER: Record = { codex: 'codex', qwen: 'qwen', iflow: 'iflow', + kimi: 'kimi', }; /** CCS provider → channel name mapping (reverse) */ @@ -31,7 +32,7 @@ export const PROVIDER_TO_CHANNEL: Record = 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); diff --git a/src/cliproxy/codex-reasoning-proxy.ts b/src/cliproxy/codex-reasoning-proxy.ts index d846cb14..57eb7e39 100644 --- a/src/cliproxy/codex-reasoning-proxy.ts +++ b/src/cliproxy/codex-reasoning-proxy.ts @@ -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 { 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 +): 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 = { 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; @@ -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; diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 11a74b2a..d4353572 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -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 diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index 940c157e..840b87e3 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -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; } diff --git a/src/cliproxy/executor/env-resolver.ts b/src/cliproxy/executor/env-resolver.ts index 4390ee9c..ea9dbcdb 100644 --- a/src/cliproxy/executor/env-resolver.ts +++ b/src/cliproxy/executor/env-resolver.ts @@ -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 v !== undefined) ) as Record; - 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; } /** diff --git a/src/cliproxy/executor/index.ts b/src/cliproxy/executor/index.ts index 3ea5b1c7..2d76146c 100644 --- a/src/cliproxy/executor/index.ts +++ b/src/cliproxy/executor/index.ts @@ -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', diff --git a/src/cliproxy/executor/retry-handler.ts b/src/cliproxy/executor/retry-handler.ts index 514af33f..7149cdad 100644 --- a/src/cliproxy/executor/retry-handler.ts +++ b/src/cliproxy/executor/retry-handler.ts @@ -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 diff --git a/src/cliproxy/executor/thinking-override-resolver.ts b/src/cliproxy/executor/thinking-override-resolver.ts new file mode 100644 index 00000000..593187e1 --- /dev/null +++ b/src/cliproxy/executor/thinking-override-resolver.ts @@ -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', + }; +} diff --git a/src/cliproxy/management-api-client.ts b/src/cliproxy/management-api-client.ts index 26f9a6ae..f33edd21 100644 --- a/src/cliproxy/management-api-client.ts +++ b/src/cliproxy/management-api-client.ts @@ -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(); + +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; } /** diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index c73e61bd..e03d6b6a 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -205,7 +205,7 @@ export const MODEL_CATALOG: Partial> = { id: 'kimi-k2.5', name: 'Kimi K2.5', - description: 'Latest Moonshot coding model', + description: 'Latest multimodal model (262K context)', thinking: { type: 'budget', min: 1024, diff --git a/src/cliproxy/provider-capabilities.ts b/src/cliproxy/provider-capabilities.ts index 4ddd4f27..c582cfb7 100644 --- a/src/cliproxy/provider-capabilities.ts +++ b/src/cliproxy/provider-capabilities.ts @@ -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 = { 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( + valueFor: (provider: CLIProxyProvider) => T +): Record { + return CLIPROXY_PROVIDER_IDS.reduce( + (acc, provider) => { + acc[provider] = valueFor(provider); + return acc; + }, + {} as Record + ); +} + const PROVIDER_ID_SET = new Set(CLIPROXY_PROVIDER_IDS); -const PROVIDER_ALIAS_MAP: ReadonlyMap = (() => { - 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 = PROVIDER_CAPABILITIES +): ReadonlyMap { + const aliasMap = new Map(); + 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 = 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; diff --git a/src/cliproxy/quota-fetcher-ghcp.ts b/src/cliproxy/quota-fetcher-ghcp.ts new file mode 100644 index 00000000..e2c4f8f2 --- /dev/null +++ b/src/cliproxy/quota-fetcher-ghcp.ts @@ -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 { + 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 }; diff --git a/src/cliproxy/quota-types.ts b/src/cliproxy/quota-types.ts index 17f32f72..5966212b 100644 --- a/src/cliproxy/quota-types.ts +++ b/src/cliproxy/quota-types.ts @@ -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; +} diff --git a/src/cliproxy/thinking-validator.ts b/src/cliproxy/thinking-validator.ts index 29a2a7d3..8b14ed95 100644 --- a/src/cliproxy/thinking-validator.ts +++ b/src/cliproxy/thinking-validator.ts @@ -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 diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts index 58108e38..cdd8c817 100644 --- a/src/commands/api-command.ts +++ b/src/commands/api-command.ts @@ -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 { 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 { /** Show help for api commands */ async function showHelp(): Promise { 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 { console.log(''); console.log(subheader('Options')); console.log( - ` ${color('--preset ', 'command')} Use provider preset (openrouter, ollama, ollama-cloud, glm, glmt, kimi, foundry, mm, deepseek, qwen)` + ` ${color('--preset ', 'command')} Use provider preset (${presetIds.join(', ')})` ); console.log(` ${color('--base-url ', 'command')} API base URL (create)`); console.log(` ${color('--api-key ', 'command')} API key (create)`); @@ -456,24 +480,14 @@ async function showHelp(): Promise { 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')}`); diff --git a/src/commands/cliproxy/help-subcommand.ts b/src/commands/cliproxy/help-subcommand.ts index a5c2ed4d..7f3219d9 100644 --- a/src/commands/cliproxy/help-subcommand.ts +++ b/src/commands/cliproxy/help-subcommand.ts @@ -55,7 +55,7 @@ export async function showHelp(): Promise { ['pause ', 'Pause account (skip in rotation)'], ['resume ', 'Resume paused account'], ['quota', 'Show quota status for all providers (Codex includes 5h + weekly reset)'], - ['quota --provider ', 'Filter by provider (agy|codex|gemini)'], + ['quota --provider ', 'Filter by provider (agy|codex|gemini|ghcp)'], ], ], [ diff --git a/src/commands/cliproxy/index.ts b/src/commands/cliproxy/index.ts index 308f9838..60a24aec 100644 --- a/src/commands/cliproxy/index.ts +++ b/src/commands/cliproxy/index.ts @@ -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, + }; } /** diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index 8bdf4830..d3445377 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -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 { 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 { diff --git a/src/commands/config-command.ts b/src/commands/config-command.ts index 1db499e1..906fac3d 100644 --- a/src/commands/config-command.ts +++ b/src/commands/config-command.ts @@ -68,6 +68,14 @@ function showHelp(): void { console.log(' --timeout Set analysis timeout (seconds)'); console.log(' --set-model

Set model for provider'); console.log(''); + console.log(' thinking Manage thinking/reasoning settings'); + console.log(' --mode Set mode (auto, off, manual)'); + console.log(' --override Set persistent override level'); + console.log(' --clear-override Remove persistent override'); + console.log(' --tier Set tier default level'); + console.log(' --provider-override

Set provider tier override'); + console.log(' --clear-provider-override

[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 { 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); diff --git a/src/commands/config-thinking-command.ts b/src/commands/config-thinking-command.ts new file mode 100644 index 00000000..36b55b41 --- /dev/null +++ b/src/commands/config-thinking-command.ts @@ -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 ', 'command')} Set mode (auto, off, manual)` + ); + console.log( + ` ${color('--override ', 'command')} Set persistent override (manual mode)` + ); + console.log( + ` ${color('--clear-override', 'command')} Remove persistent override` + ); + console.log( + ` ${color('--tier ', 'command')} Set tier default (opus/sonnet/haiku)` + ); + console.log( + ` ${color('--provider-override

', 'command')} Set provider-specific tier override` + ); + console.log( + ` ${color('--clear-provider-override

[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 { + 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(); +} diff --git a/src/commands/config-thinking-parser.ts b/src/commands/config-thinking-parser.ts new file mode 100644 index 00000000..15ffd83a --- /dev/null +++ b/src/commands/config-thinking-parser.ts @@ -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>; +export type ThinkingProviderOverrides = Record; + +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: ` }; + 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: ` }; + } + 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: [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, + }; +} diff --git a/src/commands/copilot-command.ts b/src/commands/copilot-command.ts index 306d272e..7ce38232 100644 --- a/src/commands/copilot-command.ts +++ b/src/commands/copilot-command.ts @@ -7,6 +7,7 @@ import { startAuthFlow, getCopilotStatus, + getCopilotUsage, startDaemon, stopDaemon, getAvailableModels, @@ -29,6 +30,8 @@ export async function handleCopilotCommand(args: string[]): Promise { 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 { 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 { + 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. */ diff --git a/src/commands/env-command.ts b/src/commands/env-command.ts index 6ef1f48a..f18a5f1a 100644 --- a/src/commands/env-command.ts +++ b/src/commands/env-command.ts @@ -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 | 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 { 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 diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 3a4c0340..e2568651 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -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 ', '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 ', 'Remote proxy hostname/IP'], - ['--proxy-port ', 'Proxy port (default: 8317)'], + ['--proxy-port ', `Proxy port (default: ${CLIPROXY_DEFAULT_PORT})`], ['--proxy-protocol ', 'Protocol: http or https (default: http)'], ['--proxy-auth-token ', 'Auth token for remote proxy'], ['--proxy-timeout ', '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 diff --git a/src/commands/persist-command.ts b/src/commands/persist-command.ts index 0a627334..514afa8f 100644 --- a/src/commands/persist-command.ts +++ b/src/commands/persist-command.ts @@ -390,7 +390,7 @@ async function showHelp(): Promise { ); 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)`); diff --git a/src/commands/setup-command.ts b/src/commands/setup-command.ts index bd17af07..681ea9ad 100644 --- a/src/commands/setup-command.ts +++ b/src/commands/setup-command.ts @@ -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 { 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 { auth_token: '', }, local: { - port: 8317, + port: CLIPROXY_DEFAULT_PORT, auto_start: true, }, }; @@ -370,7 +371,7 @@ async function runSetupWizard(force: boolean = false): Promise { 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.'); diff --git a/src/commands/version-command.ts b/src/commands/version-command.ts index c83cd9d1..3e8e350c 100644 --- a/src/commands/version-command.ts +++ b/src/commands/version-command.ts @@ -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 { 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 } } diff --git a/src/config/migration-manager.ts b/src/config/migration-manager.ts index e14342a2..3910a71a 100644 --- a/src/config/migration-manager.ts +++ b/src/config/migration-manager.ts @@ -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; 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 { // 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)[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 { 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)[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; } diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 5e5e0d2d..1b1d1d9c 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -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; diff --git a/src/copilot/copilot-executor.ts b/src/copilot/copilot-executor.ts index 98908496..2fd90074 100644 --- a/src/copilot/copilot-executor.ts +++ b/src/copilot/copilot-executor.ts @@ -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(''); diff --git a/src/copilot/copilot-usage.ts b/src/copilot/copilot-usage.ts new file mode 100644 index 00000000..e498cda4 --- /dev/null +++ b/src/copilot/copilot-usage.ts @@ -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 { + 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 { + return fetchCopilotUsageFromDaemon(port); +} diff --git a/src/copilot/index.ts b/src/copilot/index.ts index b1f9d184..e692e09d 100644 --- a/src/copilot/index.ts +++ b/src/copilot/index.ts @@ -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'; diff --git a/src/copilot/types.ts b/src/copilot/types.ts index e0429a1d..e8d8b5f7 100644 --- a/src/copilot/types.ts +++ b/src/copilot/types.ts @@ -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; + }; +} diff --git a/src/delegation/delegation-handler.ts b/src/delegation/delegation-handler.ts index cfe85138..998e247b 100644 --- a/src/delegation/delegation-handler.ts +++ b/src/delegation/delegation-handler.ts @@ -320,7 +320,7 @@ export class DelegationHandler { if (!profile) { console.error(fail('No profile specified')); console.error(' Usage: ccs -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); } diff --git a/src/delegation/headless-executor.ts b/src/delegation/headless-executor.ts index da95e746..a4695560 100644 --- a/src/delegation/headless-executor.ts +++ b/src/delegation/headless-executor.ts @@ -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 = ''; diff --git a/src/management/checks/system-check.ts b/src/management/checks/system-check.ts index f25c60ca..aaa8d458 100644 --- a/src/management/checks/system-check.ts +++ b/src/management/checks/system-check.ts @@ -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 = ''; diff --git a/src/shared/provider-preset-catalog.ts b/src/shared/provider-preset-catalog.ts new file mode 100644 index 00000000..b6c017e9 --- /dev/null +++ b/src/shared/provider-preset-catalog.ts @@ -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; + /** 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> = 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://.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> +): void { + const presetIdSet = new Set(); + 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(); + 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; +} diff --git a/src/targets/claude-adapter.ts b/src/targets/claude-adapter.ts index 4fee3d80..4ba0c40c 100644 --- a/src/targets/claude-adapter.ts +++ b/src/targets/claude-adapter.ts @@ -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( diff --git a/src/utils/claude-spawner.ts b/src/utils/claude-spawner.ts index e6e169bd..9f82ff8b 100644 --- a/src/utils/claude-spawner.ts +++ b/src/utils/claude-spawner.ts @@ -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) { diff --git a/src/utils/delegation-validator.ts b/src/utils/delegation-validator.ts index 93d669dd..1ae2df86 100644 --- a/src/utils/delegation-validator.ts +++ b/src/utils/delegation-validator.ts @@ -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` + diff --git a/src/utils/percentage.ts b/src/utils/percentage.ts new file mode 100644 index 00000000..5808b6de --- /dev/null +++ b/src/utils/percentage.ts @@ -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)); +} diff --git a/src/utils/profile-compat.ts b/src/utils/profile-compat.ts new file mode 100644 index 00000000..b1ff6a7b --- /dev/null +++ b/src/utils/profile-compat.ts @@ -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> = 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); +} diff --git a/src/utils/shell-executor.ts b/src/utils/shell-executor.ts index 017cd709..d8ea89d3 100644 --- a/src/utils/shell-executor.ts +++ b/src/utils/shell-executor.ts @@ -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) { diff --git a/src/web-server/health/config-checks.ts b/src/web-server/health/config-checks.ts index 17fb04ac..f8ff910b 100644 --- a/src/web-server/health/config-checks.ts +++ b/src/web-server/health/config-checks.ts @@ -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; diff --git a/src/web-server/model-pricing.ts b/src/web-server/model-pricing.ts index 3af43b91..dfc654a6 100644 --- a/src/web-server/model-pricing.ts +++ b/src/web-server/model-pricing.ts @@ -450,37 +450,50 @@ const PRICING_REGISTRY: Record = { }, // --------------------------------------------------------------------------- - // 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, diff --git a/src/web-server/routes/cliproxy-stats-routes.ts b/src/web-server/routes/cliproxy-stats-routes.ts index 4fd41249..571fbc86 100644 --- a/src/web-server/routes/cliproxy-stats-routes.ts +++ b/src/web-server/routes/cliproxy-stats-routes.ts @@ -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 = 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 // 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 => { + 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('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 => { diff --git a/src/web-server/routes/copilot-routes.ts b/src/web-server/routes/copilot-routes.ts index 71ac1817..3d4bae9e 100644 --- a/src/web-server/routes/copilot-routes.ts +++ b/src/web-server/routes/copilot-routes.ts @@ -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 => { } }); +/** + * GET /api/copilot/usage - Get Copilot quota usage from copilot-api /usage endpoint + */ +router.get('/usage', async (_req: Request, res: Response): Promise => { + 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 */ diff --git a/src/web-server/routes/misc-routes.ts b/src/web-server/routes/misc-routes.ts index 98f933fe..fb3037c9 100644 --- a/src/web-server/routes/misc-routes.ts +++ b/src/web-server/routes/misc-routes.ts @@ -26,6 +26,20 @@ import { validateFilePath } from './route-helpers'; const router = Router(); +export function resolveThinkingProviderOverridesForSave( + currentProviderOverrides: ThinkingConfig['provider_overrides'] | undefined, + updatesProviderOverrides: Record> | 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 & { + const { + lastModified, + clear_override: clearOverrideFlag, + clear_provider_overrides: clearProviderOverridesFlag, + ...updates + } = req.body as Omit, 'override' | 'provider_overrides'> & { lastModified?: number; + override?: string | number | null; + provider_overrides?: Record> | 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> + | 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>) - 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> = {}; 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, }; diff --git a/tests/unit/api/provider-presets.test.ts b/tests/unit/api/provider-presets.test.ts new file mode 100644 index 00000000..b51576c2 --- /dev/null +++ b/tests/unit/api/provider-presets.test.ts @@ -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); + }); +}); diff --git a/tests/unit/auth/profile-detector.test.ts b/tests/unit/auth/profile-detector.test.ts index 91cefcd0..b8533a7c 100644 --- a/tests/unit/auth/profile-detector.test.ts +++ b/tests/unit/auth/profile-detector.test.ts @@ -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, diff --git a/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts b/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts new file mode 100644 index 00000000..c8fe0d1d --- /dev/null +++ b/tests/unit/cliproxy/backend-ui-default-ports-sync.test.ts @@ -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)); + } + }); +}); diff --git a/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts new file mode 100644 index 00000000..1e6f0cd3 --- /dev/null +++ b/tests/unit/cliproxy/codex-reasoning-proxy-extended-context.test.ts @@ -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; + +function closeServer(server: http.Server): Promise { + return new Promise((resolve) => { + server.close(() => resolve()); + }); +} + +function listenOnRandomPort(server: http.Server): Promise { + 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(); + }); +}); diff --git a/tests/unit/cliproxy/composite-thinking.test.ts b/tests/unit/cliproxy/composite-thinking.test.ts index f217de64..4f66dad4 100644 --- a/tests/unit/cliproxy/composite-thinking.test.ts +++ b/tests/unit/cliproxy/composite-thinking.test.ts @@ -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', diff --git a/tests/unit/cliproxy/env-builder-provider-url.test.ts b/tests/unit/cliproxy/env-builder-provider-url.test.ts new file mode 100644 index 00000000..0705638c --- /dev/null +++ b/tests/unit/cliproxy/env-builder-provider-url.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/cliproxy/management-api-client.test.ts b/tests/unit/cliproxy/management-api-client.test.ts index e3b6b7b4..58edbdb2 100644 --- a/tests/unit/cliproxy/management-api-client.test.ts +++ b/tests/unit/cliproxy/management-api-client.test.ts @@ -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', () => { diff --git a/tests/unit/cliproxy/model-catalog.test.js b/tests/unit/cliproxy/model-catalog.test.js index cbf631cc..b3fcfa68 100644 --- a/tests/unit/cliproxy/model-catalog.test.js +++ b/tests/unit/cliproxy/model-catalog.test.js @@ -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', () => { diff --git a/tests/unit/cliproxy/provider-capabilities.test.ts b/tests/unit/cliproxy/provider-capabilities.test.ts index 13f3e8f7..bbc8ae8b 100644 --- a/tests/unit/cliproxy/provider-capabilities.test.ts +++ b/tests/unit/cliproxy/provider-capabilities.test.ts @@ -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', () => { diff --git a/tests/unit/cliproxy/quota-fetcher-ghcp.test.ts b/tests/unit/cliproxy/quota-fetcher-ghcp.test.ts new file mode 100644 index 00000000..963c6f11 --- /dev/null +++ b/tests/unit/cliproxy/quota-fetcher-ghcp.test.ts @@ -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, + 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); + }); + }); +}); diff --git a/tests/unit/cliproxy/thinking-override-resolver.test.ts b/tests/unit/cliproxy/thinking-override-resolver.test.ts new file mode 100644 index 00000000..7a86c1d8 --- /dev/null +++ b/tests/unit/cliproxy/thinking-override-resolver.test.ts @@ -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', + }); + }); +}); diff --git a/tests/unit/commands/config-thinking-command.test.ts b/tests/unit/commands/config-thinking-command.test.ts new file mode 100644 index 00000000..e98efedb --- /dev/null +++ b/tests/unit/commands/config-thinking-command.test.ts @@ -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(); + }); +}); diff --git a/tests/unit/config/migration-manager.test.ts b/tests/unit/config/migration-manager.test.ts new file mode 100644 index 00000000..450640d9 --- /dev/null +++ b/tests/unit/config/migration-manager.test.ts @@ -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); + }); +}); diff --git a/tests/unit/ui-api-client.test.ts b/tests/unit/ui-api-client.test.ts new file mode 100644 index 00000000..c5edc307 --- /dev/null +++ b/tests/unit/ui-api-client.test.ts @@ -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); + }); +}); diff --git a/tests/unit/utils/claudecode-env-stripping.test.ts b/tests/unit/utils/claudecode-env-stripping.test.ts new file mode 100644 index 00000000..3950ae52 --- /dev/null +++ b/tests/unit/utils/claudecode-env-stripping.test.ts @@ -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 | 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 + | undefined; + + if (!shouldMockCommand(command)) { + return realSpawn(command, args, options as Parameters[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 + | undefined; + + return realSpawnSync(command, args, options as Parameters[2]); + }, + execSync: (...execArgs: unknown[]) => + realExecSync( + execArgs[0] as Parameters[0], + execArgs[1] as Parameters[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; + } + )._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'); + }); +}); diff --git a/tests/unit/utils/profile-compat.test.ts b/tests/unit/utils/profile-compat.test.ts new file mode 100644 index 00000000..52eaa03b --- /dev/null +++ b/tests/unit/utils/profile-compat.test.ts @@ -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'); + }); + }); +}); diff --git a/tests/unit/web-server/config-checks.test.ts b/tests/unit/web-server/config-checks.test.ts new file mode 100644 index 00000000..c8202e95 --- /dev/null +++ b/tests/unit/web-server/config-checks.test.ts @@ -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'); + }); +}); diff --git a/tests/unit/web-server/thinking-routes-logic.test.ts b/tests/unit/web-server/thinking-routes-logic.test.ts new file mode 100644 index 00000000..e98b734c --- /dev/null +++ b/tests/unit/web-server/thinking-routes-logic.test.ts @@ -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); + }); +}); diff --git a/ui/src/components/account/flow-viz/account-card.tsx b/ui/src/components/account/flow-viz/account-card.tsx index 6e71c344..4fdf42a8 100644 --- a/ui/src/components/account/flow-viz/account-card.tsx +++ b/ui/src/components/account/flow-viz/account-card.tsx @@ -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}% {account.provider === 'codex' && codexQuotaRows.length > 0 && ( diff --git a/ui/src/components/cliproxy/cliproxy-table.tsx b/ui/src/components/cliproxy/cliproxy-table.tsx index e3f5996e..15de4b23 100644 --- a/ui/src/components/cliproxy/cliproxy-table.tsx +++ b/ui/src/components/cliproxy/cliproxy-table.tsx @@ -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 = { - 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(null); @@ -59,7 +50,7 @@ export function CliproxyTable({ data }: CliproxyTableProps) { if (row.original.type === 'composite') { return composite; } - return providerLabels[row.original.provider] || row.original.provider; + return getProviderDisplayName(row.original.provider); }, }, { diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index 6fcce60f..a92125b0 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -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(null); - const [isLoading, setIsLoading] = useState(true); + const [loadedUrl, setLoadedUrl] = useState(null); + const [iframeRevision, setIframeRevision] = useState(0); const [error, setError] = useState(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({ 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 */}