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