diff --git a/.github/workflows/ai-review.yml b/.github/workflows/ai-review.yml index b3305dbb..9c8b47fd 100644 --- a/.github/workflows/ai-review.yml +++ b/.github/workflows/ai-review.yml @@ -277,6 +277,7 @@ jobs: - Use simple, single-purpose bash commands only claude_args: | + --bare --model ${{ env.REVIEW_MODEL }} --allowedTools "Edit,Glob,Grep,LS,Read,Write,Bash(gh pr diff *),Bash(gh pr view *),Bash(gh issue view *),Bash(git diff *),Bash(git log *),Bash(git status *),Bash(cat *),Bash(ls *)" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3cc7fc24..145d2545 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,16 +6,20 @@ on: jobs: validate: - runs-on: ubuntu-latest + runs-on: [self-hosted, linux, x64] steps: - name: Checkout code uses: actions/checkout@v4 + - name: Clean stale artifacts + run: rm -rf node_modules ui/node_modules dist + - name: Setup Bun uses: oven-sh/setup-bun@v2 with: bun-version: '1.3.9' + no-cache: true - name: Setup Node.js uses: actions/setup-node@v4 diff --git a/package.json b/package.json index 6b3cf540..e962725a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@kaitranntt/ccs", - "version": "7.58.0", + "version": "7.58.0-dev.4", "description": "Claude Code Switch - Instant profile switching between Claude, GLM, Kimi, and more", "keywords": [ "cli", diff --git a/src/cliproxy/account-stats-key.ts b/src/cliproxy/account-stats-key.ts new file mode 100644 index 00000000..207155d6 --- /dev/null +++ b/src/cliproxy/account-stats-key.ts @@ -0,0 +1,3 @@ +export function buildQualifiedAccountStatsKey(provider: string, source: string): string { + return `${provider.trim().toLowerCase()}:${source.trim()}`; +} diff --git a/src/cliproxy/config/extended-context-config.ts b/src/cliproxy/config/extended-context-config.ts index 38727e7f..7c63c68c 100644 --- a/src/cliproxy/config/extended-context-config.ts +++ b/src/cliproxy/config/extended-context-config.ts @@ -13,9 +13,10 @@ import type { CLIProxyProvider } from '../types'; import { supportsExtendedContext } from '../model-catalog'; import { warn } from '../../utils/ui'; import { + applyExtendedContextPreferenceToAnthropicModels, applyExtendedContextSuffix as applyExtendedContextSuffixShared, isNativeGeminiModel, - stripExtendedContextSuffix, + stripModelConfigurationSuffixes, } from '../../shared/extended-context-utils'; // Backward-compatible export retained for tests/importers that reference this module. @@ -72,57 +73,20 @@ export function applyExtendedContextConfig( provider: CLIProxyProvider, extendedContextOverride?: boolean ): void { - // Get base model to check support (strip any existing suffixes for lookup) - const baseModel = envVars.ANTHROPIC_MODEL || ''; - const cleanModelId = stripModelSuffixes(baseModel); - - // Tier model env vars to apply/strip extended context suffix - const tierModels = [ - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', - ] as const; - - if (!shouldApplyExtendedContext(provider, cleanModelId, extendedContextOverride)) { - // Strip [1m] suffix from models that no longer support extended context - // (e.g., user had it enabled before backend dropped support) - if (envVars.ANTHROPIC_MODEL?.toLowerCase().endsWith('[1m]')) { - envVars.ANTHROPIC_MODEL = envVars.ANTHROPIC_MODEL.replace(/\[1m\]$/i, ''); - } - for (const tierVar of tierModels) { - const model = envVars[tierVar]; - if (model?.toLowerCase().endsWith('[1m]')) { - envVars[tierVar] = model.replace(/\[1m\]$/i, ''); - } - } + if (extendedContextOverride === false) { + Object.assign(envVars, applyExtendedContextPreferenceToAnthropicModels(envVars, false)); return; } - // Apply suffix to main model - if (envVars.ANTHROPIC_MODEL) { - envVars.ANTHROPIC_MODEL = applyExtendedContextSuffixShared(envVars.ANTHROPIC_MODEL); - } - - // Apply to tier models if they support extended context - - for (const tierVar of tierModels) { - const model = envVars[tierVar]; - if (model) { - const tierCleanId = stripModelSuffixes(model); - if (shouldApplyExtendedContext(provider, tierCleanId, extendedContextOverride)) { - envVars[tierVar] = applyExtendedContextSuffixShared(model); - } - } - } -} - -/** - * Strip thinking and extended context suffixes from model ID for catalog lookup. - * Examples: - * "gemini-2.5-pro(high)[1m]" -> "gemini-2.5-pro" - * "gemini-2.5-pro(8192)" -> "gemini-2.5-pro" - * "gemini-2.5-pro" -> "gemini-2.5-pro" - */ -function stripModelSuffixes(modelId: string): string { - return stripExtendedContextSuffix(modelId.trim()).replace(/\([^)]+\)$/, ''); + Object.assign( + envVars, + applyExtendedContextPreferenceToAnthropicModels(envVars, true, { + supportsExtendedContext: (modelId) => + shouldApplyExtendedContext( + provider, + stripModelConfigurationSuffixes(modelId), + extendedContextOverride + ), + }) + ); } diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts index 99761bfa..d2719d2c 100644 --- a/src/cliproxy/model-id-normalizer.ts +++ b/src/cliproxy/model-id-normalizer.ts @@ -6,14 +6,10 @@ */ import type { CLIProxyProvider } from './types'; +import { ANTHROPIC_MODEL_ENV_KEYS } from '../shared/extended-context-utils'; /** Env vars that carry model identifiers. */ -export const MODEL_ENV_VAR_KEYS = [ - 'ANTHROPIC_MODEL', - 'ANTHROPIC_DEFAULT_OPUS_MODEL', - 'ANTHROPIC_DEFAULT_SONNET_MODEL', - 'ANTHROPIC_DEFAULT_HAIKU_MODEL', -] as const; +export const MODEL_ENV_VAR_KEYS = ANTHROPIC_MODEL_ENV_KEYS; type ProviderLike = CLIProxyProvider | string | null | undefined; diff --git a/src/cliproxy/quota-fetcher-gemini-cli.ts b/src/cliproxy/quota-fetcher-gemini-cli.ts index b29f218c..f8364a91 100644 --- a/src/cliproxy/quota-fetcher-gemini-cli.ts +++ b/src/cliproxy/quota-fetcher-gemini-cli.ts @@ -16,6 +16,8 @@ import type { GeminiCliQuotaResult, GeminiCliBucket } from './quota-types'; /** Google Cloud Code API endpoints */ const GEMINI_CLI_API_BASE = 'https://cloudcode-pa.googleapis.com'; const GEMINI_CLI_API_VERSION = 'v1internal'; +const GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH = 320; +const GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX = '...[truncated]'; /** * Model groups for quota consolidation. @@ -68,6 +70,12 @@ interface GeminiCliQuotaResponse { buckets?: RawGeminiCliBucket[]; } +interface ParsedGeminiCliErrorBody { + errorCode?: string; + errorDetail?: string; + message?: string; +} + /** * Extract project ID from account field * Input: "user@example.com (cloudaicompanion-abc-123)" @@ -240,6 +248,228 @@ function shouldIgnoreModel(modelId: string): boolean { return IGNORED_MODEL_PREFIXES.some((prefix) => modelId.startsWith(prefix)); } +function buildGeminiCliFailureResult( + accountId: string, + projectId: string | null, + options: { + error: string; + httpStatus?: number; + errorCode?: string; + errorDetail?: string; + actionHint?: string; + retryable?: boolean; + needsReauth?: boolean; + isForbidden?: boolean; + } +): GeminiCliQuotaResult { + return { + success: false, + buckets: [], + projectId, + lastUpdated: Date.now(), + accountId, + error: options.error, + httpStatus: options.httpStatus, + errorCode: options.errorCode, + errorDetail: options.errorDetail, + actionHint: options.actionHint, + retryable: options.retryable, + needsReauth: options.needsReauth, + isForbidden: options.isForbidden, + }; +} + +function sanitizeGeminiCliErrorDetail(bodyText: string): string | undefined { + const trimmed = bodyText.trim(); + if (!trimmed) { + return undefined; + } + + if (/^]+>/.test(trimmed)) { + return '[HTML error response omitted]'; + } + + let sanitized = trimmed + .replace( + /"(access[_-]?token|refresh[_-]?token|authorization|cookie|set-cookie|api[_-]?key|session[_-]?token|token)"\s*:\s*"[^"]*"/gi, + '"$1":"[redacted]"' + ) + .replace(/Bearer\s+[A-Za-z0-9._-]+/g, 'Bearer [redacted]') + .replace(/\s+/g, ' '); + + if (sanitized.length > GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH) { + sanitized = `${sanitized.slice( + 0, + GEMINI_CLI_ERROR_DETAIL_MAX_LENGTH - GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX.length + )}${GEMINI_CLI_ERROR_DETAIL_TRUNCATION_SUFFIX}`; + } + + return sanitized; +} + +function extractGeminiCliNestedMessage(value: unknown): string | undefined { + if (Array.isArray(value)) { + for (const entry of value) { + const nested = extractGeminiCliNestedMessage(entry); + if (nested) return nested; + } + return undefined; + } + + if (!value || typeof value !== 'object') { + return undefined; + } + + const record = value as Record; + const directMessage = [ + record.message, + record.localizedMessage, + record.description, + record.reason, + record.error, + ].find( + (candidate): candidate is string => typeof candidate === 'string' && candidate.trim().length > 0 + ); + if (directMessage) { + return directMessage; + } + + return undefined; +} + +function parseGeminiCliErrorBody(bodyText: string): ParsedGeminiCliErrorBody { + const trimmed = bodyText.trim(); + if (!trimmed) { + return {}; + } + + const sanitizedDetail = sanitizeGeminiCliErrorDetail(trimmed); + + try { + const parsed = JSON.parse(trimmed) as Record; + const topLevelMessage = [parsed.message, parsed.error].find( + (candidate): candidate is string => + typeof candidate === 'string' && candidate.trim().length > 0 + ); + const topLevelCode = [parsed.code, parsed.status].find( + (candidate): candidate is string => + typeof candidate === 'string' && candidate.trim().length > 0 + ); + + if (parsed.error && typeof parsed.error === 'object') { + const error = parsed.error as Record; + return { + errorCode: + [error.status, error.code, topLevelCode].find( + (candidate): candidate is string => + typeof candidate === 'string' && candidate.trim().length > 0 + ) || undefined, + errorDetail: sanitizedDetail, + message: + [ + error.message, + error.error, + extractGeminiCliNestedMessage(error.details), + topLevelMessage, + ].find( + (candidate): candidate is string => + typeof candidate === 'string' && candidate.trim().length > 0 + ) || undefined, + }; + } + + return { + errorCode: topLevelCode, + errorDetail: sanitizedDetail, + message: + [topLevelMessage, extractGeminiCliNestedMessage(parsed.details)].find( + (candidate): candidate is string => + typeof candidate === 'string' && candidate.trim().length > 0 + ) || undefined, + }; + } catch { + return { + errorDetail: sanitizedDetail, + message: sanitizedDetail === '[HTML error response omitted]' ? undefined : trimmed, + }; + } +} + +function buildGeminiCliForbiddenActionHint(parsed: ParsedGeminiCliErrorBody): string { + const combined = `${parsed.message || ''} ${parsed.errorDetail || ''}`.toLowerCase(); + if (combined.includes('verify') || combined.includes('verification')) { + return 'Complete the Google account verification mentioned above, then retry quota refresh.'; + } + if (combined.includes('project')) { + return 'Confirm this Google project still has Gemini CLI quota access, then retry.'; + } + return 'Check the Google account or workspace access shown above, then retry quota refresh.'; +} + +function buildGeminiCliHttpFailureResult( + accountId: string, + projectId: string | null, + status: number, + bodyText: string +): GeminiCliQuotaResult { + const parsed = parseGeminiCliErrorBody(bodyText); + + if (status === 401) { + return buildGeminiCliFailureResult(accountId, projectId, { + error: parsed.message || 'Token expired or invalid', + httpStatus: 401, + errorCode: parsed.errorCode || 'reauth_required', + errorDetail: parsed.errorDetail, + actionHint: 'Run ccs gemini --auth to reconnect this account.', + needsReauth: true, + retryable: false, + }); + } + + if (status === 403) { + return buildGeminiCliFailureResult(accountId, projectId, { + error: parsed.message || 'Quota access forbidden for this account', + httpStatus: 403, + errorCode: parsed.errorCode || 'quota_api_forbidden', + errorDetail: parsed.errorDetail, + actionHint: buildGeminiCliForbiddenActionHint(parsed), + isForbidden: true, + retryable: false, + }); + } + + if (status === 429) { + return buildGeminiCliFailureResult(accountId, projectId, { + error: parsed.message || 'Rate limited - try again later', + httpStatus: 429, + errorCode: parsed.errorCode || 'rate_limited', + errorDetail: parsed.errorDetail, + actionHint: 'Retry after a short delay.', + retryable: true, + }); + } + + if (status >= 500) { + return buildGeminiCliFailureResult(accountId, projectId, { + error: parsed.message || `Gemini quota service unavailable (HTTP ${status})`, + httpStatus: status, + errorCode: parsed.errorCode || 'provider_unavailable', + errorDetail: parsed.errorDetail, + actionHint: 'Retry later. This looks like a temporary Google upstream problem.', + retryable: true, + }); + } + + return buildGeminiCliFailureResult(accountId, projectId, { + error: parsed.message || `Gemini quota request failed (HTTP ${status})`, + httpStatus: status, + errorCode: parsed.errorCode || 'quota_request_failed', + errorDetail: parsed.errorDetail, + actionHint: 'Inspect the upstream response details and retry if appropriate.', + retryable: false, + }); +} + /** * Build GeminiCliBucket array from API response * Groups buckets by model series and token type @@ -334,14 +564,12 @@ async function fetchWithAuthData( if (!authData.projectId) { const error = 'Cannot resolve project ID from auth file'; if (verbose) console.error(`[!] Error: ${error}`); - return { - success: false, - buckets: [], - projectId: null, - lastUpdated: Date.now(), + return buildGeminiCliFailureResult(accountId, null, { error, - accountId, - }; + errorCode: 'missing_project_id', + actionHint: 'Run ccs gemini --auth to reconnect this account and recover the project ID.', + retryable: false, + }); } const url = `${GEMINI_CLI_API_BASE}/${GEMINI_CLI_API_VERSION}:retrieveUserQuota`; @@ -363,49 +591,14 @@ async function fetchWithAuthData( if (verbose) console.error(`[i] Gemini CLI API status: ${response.status}`); - if (response.status === 401) { - return { - success: false, - buckets: [], - projectId: authData.projectId, - lastUpdated: Date.now(), - error: 'Token expired or invalid', - accountId, - needsReauth: true, - }; - } - - if (response.status === 403) { - return { - success: false, - buckets: [], - projectId: authData.projectId, - lastUpdated: Date.now(), - error: 'Quota access forbidden for this account', - accountId, - }; - } - - if (response.status === 429) { - return { - success: false, - buckets: [], - projectId: authData.projectId, - lastUpdated: Date.now(), - error: 'Rate limited - try again later', - accountId, - }; - } - if (!response.ok) { - return { - success: false, - buckets: [], - projectId: authData.projectId, - lastUpdated: Date.now(), - error: `API error: ${response.status}`, + const bodyText = await response.text(); + return buildGeminiCliHttpFailureResult( accountId, - }; + authData.projectId, + response.status, + bodyText + ); } const data = (await response.json()) as GeminiCliQuotaResponse; @@ -432,14 +625,14 @@ async function fetchWithAuthData( if (verbose) console.error(`[!] Gemini CLI quota error: ${errorMsg}`); - return { - success: false, - buckets: [], - projectId: authData.projectId, - lastUpdated: Date.now(), + return buildGeminiCliFailureResult(accountId, authData.projectId, { error: errorMsg, - accountId, - }; + errorCode: + err instanceof Error && err.name === 'AbortError' ? 'network_timeout' : 'network_error', + actionHint: 'Retry later. This looks temporary.', + retryable: true, + httpStatus: err instanceof Error && err.name === 'AbortError' ? 408 : undefined, + }); } } @@ -460,14 +653,12 @@ export async function fetchGeminiCliQuota( if (!authData) { const error = 'Auth file not found for Gemini account'; if (verbose) console.error(`[!] Error: ${error}`); - return { - success: false, - buckets: [], - projectId: null, - lastUpdated: Date.now(), + return buildGeminiCliFailureResult(accountId, null, { error, - accountId, - }; + errorCode: 'auth_file_missing', + actionHint: 'Run ccs gemini --auth to reconnect this account.', + retryable: false, + }); } // Proactive refresh: refresh if expired OR expiring within 5 minutes @@ -497,15 +688,14 @@ export async function fetchGeminiCliQuota( // Only fail if token is actually expired (not just expiring soon) const error = refreshResult.error || 'Token refresh failed'; if (verbose) console.error(`[!] Refresh failed: ${error}`); - return { - success: false, - buckets: [], - projectId: null, - lastUpdated: Date.now(), + return buildGeminiCliFailureResult(accountId, authData.projectId, { error, - accountId, + errorCode: 'reauth_required', + errorDetail: error, + actionHint: 'Run ccs gemini --auth to reconnect this account.', needsReauth: true, - }; + retryable: false, + }); } // If proactive refresh fails but token isn't expired yet, continue with existing token } @@ -553,5 +743,12 @@ export async function fetchAllGeminiCliQuotas( return results; } +export const __testExports = { + sanitizeGeminiCliErrorDetail, + extractGeminiCliNestedMessage, + parseGeminiCliErrorBody, + buildGeminiCliForbiddenActionHint, +}; + // Export for testing export { resolveGeminiCliProjectId, buildGeminiCliBuckets }; diff --git a/src/cliproxy/stats-fetcher.ts b/src/cliproxy/stats-fetcher.ts index 72aacd0c..ce3f82e1 100644 --- a/src/cliproxy/stats-fetcher.ts +++ b/src/cliproxy/stats-fetcher.ts @@ -12,10 +12,15 @@ import { buildProxyHeaders, buildManagementHeaders, } from './proxy-target-resolver'; +import { buildCliproxyStatsFromUsageResponse } from './stats-transformer'; /** Per-account usage statistics */ export interface AccountUsageStats { - /** Account email or identifier */ + /** Provider-qualified lookup key (for example: "codex:user@example.com") */ + accountKey: string; + /** Canonical provider name reported by CLIProxyAPI */ + provider: string; + /** Raw account email or identifier */ source: string; /** Number of successful requests */ successCount: number; @@ -59,7 +64,7 @@ export interface CliproxyStats { export interface CliproxyRequestDetail { timestamp: string; source: string; - auth_index: number; + auth_index: string | number; tokens: { input_tokens: number; output_tokens: number; @@ -99,6 +104,14 @@ export interface CliproxyUsageApiResponse { }; } +/** Auth file metadata from CLIProxyAPI /v0/management/auth-files */ +export interface CliproxyManagementAuthFile { + auth_index?: string | number; + provider?: string; + email?: string; + name?: string; +} + /** * Fetch usage statistics from CLIProxyAPI management API * @param port CLIProxyAPI port (default: 8317) @@ -106,107 +119,16 @@ export interface CliproxyUsageApiResponse { */ export async function fetchCliproxyStats(port?: number): Promise { try { - const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 3000); // 3s timeout + const [data, authFiles] = await Promise.all([ + fetchCliproxyUsageRaw(port), + fetchCliproxyAuthFiles(port), + ]); - // Dynamic target resolution - const target = getProxyTarget(); - // Allow port override for local testing only - if (port !== undefined && !target.isRemote) { - target.port = port; - } - const url = buildProxyUrl(target, '/v0/management/usage'); - - // For management endpoints, use management key for remote, local management secret for local - const headers = target.isRemote - ? buildManagementHeaders(target) - : { Accept: 'application/json', Authorization: `Bearer ${getEffectiveManagementSecret()}` }; - - const response = await fetch(url, { - signal: controller.signal, - headers, - }); - - clearTimeout(timeoutId); - - if (!response.ok) { + if (!data) { return null; } - const data = (await response.json()) as CliproxyUsageApiResponse; - const usage = data.usage; - - // Extract models, providers, and per-account stats from the nested API structure - const requestsByModel: Record = {}; - const requestsByProvider: Record = {}; - const accountStats: Record = {}; - let totalSuccessCount = 0; - let totalFailureCount = 0; - let totalInputTokens = 0; - let totalOutputTokens = 0; - - if (usage?.apis) { - for (const [provider, providerData] of Object.entries(usage.apis)) { - requestsByProvider[provider] = providerData.total_requests ?? 0; - if (providerData.models) { - for (const [model, modelData] of Object.entries(providerData.models)) { - requestsByModel[model] = modelData.total_requests ?? 0; - - // Aggregate per-account stats from request details - if (modelData.details) { - for (const detail of modelData.details) { - const source = detail.source || 'unknown'; - - // Initialize account stats if not exists - if (!accountStats[source]) { - accountStats[source] = { - source, - successCount: 0, - failureCount: 0, - totalTokens: 0, - }; - } - - // Update account stats - if (detail.failed) { - accountStats[source].failureCount++; - totalFailureCount++; - } else { - accountStats[source].successCount++; - totalSuccessCount++; - } - - const tokens = detail.tokens?.total_tokens ?? 0; - accountStats[source].totalTokens += tokens; - accountStats[source].lastUsedAt = detail.timestamp; - - // Aggregate token breakdowns - totalInputTokens += detail.tokens?.input_tokens ?? 0; - totalOutputTokens += detail.tokens?.output_tokens ?? 0; - } - } - } - } - } - } - - // Normalize the response to our interface - return { - totalRequests: usage?.total_requests ?? 0, - successCount: totalSuccessCount, - failureCount: totalFailureCount, - tokens: { - input: totalInputTokens, - output: totalOutputTokens, - total: usage?.total_tokens ?? 0, - }, - requestsByModel, - requestsByProvider, - accountStats, - quotaExceededCount: usage?.failure_count ?? data.failed_requests ?? 0, - retryCount: 0, // API doesn't track retries separately - collectedAt: new Date().toISOString(), - }; + return buildCliproxyStatsFromUsageResponse(data, { authFiles: authFiles ?? [] }); } catch { // CLIProxyAPI not running or stats endpoint not available return null; @@ -251,6 +173,39 @@ export async function fetchCliproxyUsageRaw( } } +async function fetchCliproxyAuthFiles(port?: number): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 5000); + + const target = getProxyTarget(); + if (port !== undefined && !target.isRemote) { + target.port = port; + } + const url = buildProxyUrl(target, '/v0/management/auth-files'); + + const headers = target.isRemote + ? buildManagementHeaders(target) + : { Accept: 'application/json', Authorization: `Bearer ${getEffectiveManagementSecret()}` }; + + const response = await fetch(url, { + signal: controller.signal, + headers, + }); + + clearTimeout(timeoutId); + + if (!response.ok) { + return null; + } + + const data = (await response.json()) as { files?: CliproxyManagementAuthFile[] }; + return Array.isArray(data.files) ? data.files : null; + } catch { + return null; + } +} + /** OpenAI-compatible model object from /v1/models endpoint */ export interface CliproxyModel { id: string; diff --git a/src/cliproxy/stats-transformer.ts b/src/cliproxy/stats-transformer.ts new file mode 100644 index 00000000..74c10aa0 --- /dev/null +++ b/src/cliproxy/stats-transformer.ts @@ -0,0 +1,171 @@ +import { buildQualifiedAccountStatsKey } from './account-stats-key'; +import { mapExternalProviderName } from './provider-capabilities'; +import type { + AccountUsageStats, + CliproxyManagementAuthFile, + CliproxyRequestDetail, + CliproxyStats, + CliproxyUsageApiResponse, +} from './stats-fetcher'; + +interface BuildCliproxyStatsOptions { + authFiles?: CliproxyManagementAuthFile[]; +} + +interface ResolvedAuthFile { + provider?: string; + source?: string; +} + +function normalizeProvider(provider: string): string { + const normalized = provider.trim().toLowerCase(); + if (!normalized) { + return 'unknown'; + } + + return mapExternalProviderName(normalized) ?? normalized; +} + +function buildAuthIndexLookup( + authFiles: CliproxyManagementAuthFile[] | undefined +): ReadonlyMap { + const lookup = new Map(); + + for (const authFile of authFiles ?? []) { + if (authFile.auth_index === undefined || authFile.auth_index === null) { + continue; + } + + const provider = authFile.provider ? normalizeProvider(authFile.provider) : undefined; + const source = authFile.email?.trim() || authFile.name?.trim() || undefined; + if (!provider && !source) { + continue; + } + + lookup.set(String(authFile.auth_index), { + provider, + source, + }); + } + + return lookup; +} + +function resolveProviderForDetail( + usageProvider: string, + detail: CliproxyRequestDetail, + authIndexLookup: ReadonlyMap +): string { + const resolvedAuthFile = authIndexLookup.get(String(detail.auth_index)); + if (resolvedAuthFile?.provider) { + return resolvedAuthFile.provider; + } + + return normalizeProvider(usageProvider); +} + +function resolveSourceForDetail( + detail: CliproxyRequestDetail, + authIndexLookup: ReadonlyMap +): string { + const source = detail.source?.trim(); + if (source) { + return source; + } + + return authIndexLookup.get(String(detail.auth_index))?.source ?? 'unknown'; +} + +export function buildCliproxyStatsFromUsageResponse( + data: CliproxyUsageApiResponse, + options: BuildCliproxyStatsOptions = {} +): CliproxyStats { + const usage = data.usage; + const requestsByModel: Record = {}; + const requestsByProvider: Record = {}; + const accountStats: Record = {}; + const authIndexLookup = buildAuthIndexLookup(options.authFiles); + let totalSuccessCount = 0; + let totalFailureCount = 0; + let totalInputTokens = 0; + let totalOutputTokens = 0; + let sawAnyDetail = false; + + if (usage?.apis) { + for (const [provider, providerData] of Object.entries(usage.apis)) { + let sawProviderDetail = false; + if (!providerData.models) { + const normalizedProvider = normalizeProvider(provider); + requestsByProvider[normalizedProvider] = + (requestsByProvider[normalizedProvider] ?? 0) + (providerData.total_requests ?? 0); + continue; + } + + for (const [model, modelData] of Object.entries(providerData.models)) { + requestsByModel[model] = modelData.total_requests ?? 0; + if (!modelData.details) { + continue; + } + + for (const detail of modelData.details) { + sawAnyDetail = true; + sawProviderDetail = true; + const source = resolveSourceForDetail(detail, authIndexLookup); + const resolvedProvider = resolveProviderForDetail(provider, detail, authIndexLookup); + const accountKey = buildQualifiedAccountStatsKey(resolvedProvider, source); + requestsByProvider[resolvedProvider] = (requestsByProvider[resolvedProvider] ?? 0) + 1; + + if (!accountStats[accountKey]) { + accountStats[accountKey] = { + accountKey, + provider: resolvedProvider, + source, + successCount: 0, + failureCount: 0, + totalTokens: 0, + }; + } + + if (detail.failed) { + accountStats[accountKey].failureCount++; + totalFailureCount++; + } else { + accountStats[accountKey].successCount++; + totalSuccessCount++; + } + + const tokens = detail.tokens?.total_tokens ?? 0; + accountStats[accountKey].totalTokens += tokens; + accountStats[accountKey].lastUsedAt = detail.timestamp; + totalInputTokens += detail.tokens?.input_tokens ?? 0; + totalOutputTokens += detail.tokens?.output_tokens ?? 0; + } + } + + if (!sawProviderDetail) { + const normalizedProvider = normalizeProvider(provider); + requestsByProvider[normalizedProvider] = + (requestsByProvider[normalizedProvider] ?? 0) + (providerData.total_requests ?? 0); + } + } + } + + return { + totalRequests: usage?.total_requests ?? 0, + successCount: sawAnyDetail ? totalSuccessCount : (usage?.success_count ?? 0), + failureCount: sawAnyDetail + ? totalFailureCount + : (usage?.failure_count ?? data.failed_requests ?? 0), + tokens: { + input: totalInputTokens, + output: totalOutputTokens, + total: usage?.total_tokens ?? 0, + }, + requestsByModel, + requestsByProvider, + accountStats, + quotaExceededCount: usage?.failure_count ?? data.failed_requests ?? 0, + retryCount: 0, + collectedAt: new Date().toISOString(), + }; +} diff --git a/src/commands/api-command.ts b/src/commands/api-command.ts deleted file mode 100644 index 6aa43a58..00000000 --- a/src/commands/api-command.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './api-command/index'; diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index dbd798a6..9583b8f8 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -25,7 +25,13 @@ import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync'; import type { TargetType } from '../../targets/target-adapter'; import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui'; import { InteractivePrompt } from '../../utils/prompt'; -import { exitOnApiCommandErrors, parseApiCommandArgs } from './shared'; +import { + applyClaudeExtendedContextPreference, + exitOnApiCommandErrors, + hasClaudeModelMapping, + hasExplicitClaudeExtendedContext, + parseApiCommandArgs, +} from './shared'; function resolvePresetOrExit(presetId?: string): ProviderPreset | null { if (!presetId) { @@ -276,6 +282,37 @@ async function resolveDefaultTarget( return useDroidByDefault ? 'droid' : 'claude'; } +async function resolveClaudeLongContextPreference( + models: ModelMapping, + explicitPreference: boolean | undefined, + yes: boolean | undefined +): Promise { + if (explicitPreference !== undefined) { + return explicitPreference; + } + + if (hasExplicitClaudeExtendedContext(models)) { + return true; + } + + if (yes) { + return false; + } + + console.log(''); + console.log(info('Claude long context is explicit in CCS.')); + console.log(dim(' Plain Claude model IDs stay on standard context unless you opt into [1m].')); + console.log( + dim(' Some providers/accounts still require extra usage or PAYG for long-context requests.') + ); + console.log(dim(' If that entitlement is missing, upstream requests can still return 429.')); + console.log(''); + + return InteractivePrompt.confirm('Enable [1m] for compatible Claude mappings?', { + default: false, + }); +} + export async function handleApiCreateCommand(args: string[]): Promise { await initUI(); const parsedArgs = parseApiCommandArgs(args); @@ -403,17 +440,31 @@ export async function handleApiCreateCommand(args: string[]): Promise { } const apiKey = await resolveApiKey(parsedArgs.apiKey, preset); - const { model, models } = await resolveModelConfiguration( + const { models } = await resolveModelConfiguration( baseUrl, preset, parsedArgs.model, parsedArgs.yes ); + const hasClaudeMappings = hasClaudeModelMapping(models); + const shouldEnableClaudeLongContext = hasClaudeMappings + ? await resolveClaudeLongContextPreference(models, parsedArgs.extendedContext, parsedArgs.yes) + : false; + const finalModels = hasClaudeMappings + ? applyClaudeExtendedContextPreference(models, shouldEnableClaudeLongContext) + : models; const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes); + if (parsedArgs.extendedContext !== undefined && !hasClaudeMappings) { + console.log(''); + console.log( + dim('No compatible Claude mappings were detected, so --1m/--no-1m did not change models.') + ); + } + console.log(''); console.log(info('Creating API profile...')); - const result = createApiProfile(name, baseUrl || '', apiKey, models, target); + const result = createApiProfile(name, baseUrl || '', apiKey, finalModels, target); if (!result.success) { console.log(fail(`Failed to create API profile: ${result.error}`)); process.exit(1); @@ -427,25 +478,45 @@ export async function handleApiCreateCommand(args: string[]): Promise { } const hasCustomMapping = - models.opus !== model || models.sonnet !== model || models.haiku !== model; + finalModels.opus !== finalModels.default || + finalModels.sonnet !== finalModels.default || + finalModels.haiku !== finalModels.default; let details = `API: ${name}\n` + `Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` + `Settings: ${result.settingsFile}\n` + `Base URL: ${baseUrl}\n` + - `Model: ${model}\n` + + `Model: ${finalModels.default}\n` + `Target: ${target}`; + if (hasClaudeMappings) { + details += `\nLongCtx: ${ + shouldEnableClaudeLongContext + ? 'compatible Claude mappings use [1m]' + : 'standard Claude context' + }`; + } + if (hasCustomMapping) { details += `\n\nModel Mapping:\n` + - ` Opus: ${models.opus}\n` + - ` Sonnet: ${models.sonnet}\n` + - ` Haiku: ${models.haiku}`; + ` Opus: ${finalModels.opus}\n` + + ` Sonnet: ${finalModels.sonnet}\n` + + ` Haiku: ${finalModels.haiku}`; } console.log(''); console.log(infoBox(details, 'API Profile Created')); + if (hasClaudeMappings) { + console.log(''); + console.log( + dim( + shouldEnableClaudeLongContext + ? 'CCS saved [1m] on compatible Claude mappings. Provider-side extra-usage requirements can still reject long-context requests.' + : 'Claude mappings were saved with standard context. Use --1m later if you want CCS to write [1m] explicitly.' + ) + ); + } console.log(''); console.log(header('Usage')); if (target === 'droid') { diff --git a/src/commands/api-command/handler.ts b/src/commands/api-command/handler.ts new file mode 100644 index 00000000..2ad906e7 --- /dev/null +++ b/src/commands/api-command/handler.ts @@ -0,0 +1,51 @@ +import { dispatchNamedCommand, type NamedCommandRoute } from '../named-command-router'; + +type ApiCommandHandler = (args: string[]) => Promise; +type ApiCommandHelpHandler = () => Promise; +type ApiCommandUnknownHandler = (command: string) => Promise; + +export interface ApiCommandDependencies { + help: ApiCommandHelpHandler; + unknown: ApiCommandUnknownHandler; + create: ApiCommandHandler; + list: ApiCommandHandler; + discover: ApiCommandHandler; + copy: ApiCommandHandler; + export: ApiCommandHandler; + import: ApiCommandHandler; + remove: ApiCommandHandler; +} + +function createApiCommandRoutes( + dependencies: ApiCommandDependencies +): readonly NamedCommandRoute[] { + return [ + { name: 'create', handle: dependencies.create }, + { name: 'list', handle: dependencies.list }, + { name: 'discover', handle: dependencies.discover }, + { name: 'copy', handle: dependencies.copy }, + { name: 'export', handle: dependencies.export }, + { name: 'import', handle: dependencies.import }, + { name: 'remove', aliases: ['delete', 'rm'], handle: dependencies.remove }, + ]; +} + +/** + * Factory for building an api-command handler with injectable dependencies. + * Extracted from index.ts so tests can import without loading all subcommand modules. + */ +export function createApiCommandHandler( + dependencies: ApiCommandDependencies +): (args: string[]) => Promise { + const routes = createApiCommandRoutes(dependencies); + + return async (args: string[]) => { + await dispatchNamedCommand({ + args, + routes, + onHelp: dependencies.help, + onUnknown: dependencies.unknown, + allowEmptyHelp: true, + }); + }; +} diff --git a/src/commands/api-command/help.ts b/src/commands/api-command/help.ts index c9b3fb99..37ddfccf 100644 --- a/src/commands/api-command/help.ts +++ b/src/commands/api-command/help.ts @@ -8,6 +8,8 @@ import { import { color, dim, fail, header, initUI, subheader } from '../../utils/ui'; import { sanitizeHelpText } from './shared'; +type HelpWriter = (line: string) => void; + function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string { const presetId = sanitizeHelpText(preset.id) || 'unknown'; const paddedId = presetId.padEnd(idWidth); @@ -16,7 +18,7 @@ function renderPresetHelpLine(preset: ProviderPreset, idWidth: number): string { return ` ${color(paddedId, 'command')} ${presetName} - ${presetDescription}`; } -export async function showApiCommandHelp(): Promise { +export async function showApiCommandHelp(writeLine: HelpWriter = console.log): Promise { await initUI(); const presetIds = getPresetIds() .map((id) => sanitizeHelpText(id)) @@ -25,97 +27,108 @@ export async function showApiCommandHelp(): Promise { const presetAliases = getPresetAliases(); const presetIdWidth = Math.max(0, ...presetIds.map((id) => id.length)) + 2; - console.log(header('CCS API Management')); - console.log(''); - console.log(subheader('Usage')); - console.log(` ${color('ccs api', 'command')} [options]`); - console.log(''); - console.log(subheader('Commands')); - console.log(` ${color('create [name]', 'command')} Create new API profile (interactive)`); - console.log(` ${color('list', 'command')} List all API profiles`); - console.log( + writeLine(header('CCS API Management')); + writeLine(''); + writeLine(subheader('Usage')); + writeLine(` ${color('ccs api', 'command')} [options]`); + writeLine(''); + writeLine(subheader('Commands')); + writeLine(` ${color('create [name]', 'command')} Create new API profile (interactive)`); + writeLine(` ${color('list', 'command')} List all API profiles`); + writeLine( ` ${color('discover', 'command')} Discover orphan *.settings.json and register` ); - console.log(` ${color('copy ', 'command')} Duplicate API profile settings + config`); - console.log( + writeLine(` ${color('copy ', 'command')} Duplicate API profile settings + config`); + writeLine( ` ${color('export ', 'command')} Export profile bundle for cross-device transfer` ); - console.log( - ` ${color('import ', 'command')} Import profile bundle and register profile` - ); - console.log(` ${color('remove ', 'command')} Remove an API profile`); - console.log(''); - console.log(subheader('Options')); - console.log( + writeLine(` ${color('import ', 'command')} Import profile bundle and register profile`); + writeLine(` ${color('remove ', 'command')} Remove an API profile`); + writeLine(''); + writeLine(subheader('Options')); + writeLine( ` ${color('--preset ', 'command')} Use provider preset (${presetIds.join(', ')})` ); - console.log( + writeLine( ` ${color('--cliproxy-provider ', 'command')} Use routed CLIProxy provider (${cliproxyProviderIds.join(', ')})` ); - console.log(` ${color('--base-url ', 'command')} API base URL (create)`); - console.log(` ${color('--api-key ', 'command')} API key (create)`); - console.log(` ${color('--model ', 'command')} Default model (create)`); - console.log( + writeLine(` ${color('--base-url ', 'command')} API base URL (create)`); + writeLine(` ${color('--api-key ', 'command')} API key (create)`); + writeLine(` ${color('--model ', 'command')} Default model (create)`); + writeLine( + ` ${color('--1m / --no-1m', 'command')} Write or clear [1m] on compatible Claude mappings` + ); + writeLine( ` ${color('--target ', 'command')} Default target: claude or droid (create)` ); - console.log(` ${color('--register', 'command')} Register discovered orphan settings`); - console.log(` ${color('--json', 'command')} JSON output for discover command`); - console.log(` ${color('--out ', 'command')} Export bundle output path`); - console.log(` ${color('--include-secrets', 'command')} Include token in export bundle`); - console.log(` ${color('--name ', 'command')} Override profile name during import`); - console.log( + writeLine(` ${color('--register', 'command')} Register discovered orphan settings`); + writeLine(` ${color('--json', 'command')} JSON output for discover command`); + writeLine(` ${color('--out ', 'command')} Export bundle output path`); + writeLine(` ${color('--include-secrets', 'command')} Include token in export bundle`); + writeLine(` ${color('--name ', 'command')} Override profile name during import`); + writeLine( ` ${color('--force', 'command')} Overwrite existing or bypass validation (create/discover/copy/import)` ); - console.log(` ${color('--yes, -y', 'command')} Skip confirmation prompts`); - console.log(''); - console.log(subheader('Provider Presets')); - PROVIDER_PRESETS.forEach((preset) => console.log(renderPresetHelpLine(preset, presetIdWidth))); + writeLine(` ${color('--yes, -y', 'command')} Skip confirmation prompts`); + writeLine(''); + writeLine(subheader('Provider Presets')); + PROVIDER_PRESETS.forEach((preset) => writeLine(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})`)}` - ); + writeLine(` ${dim(`Legacy alias: --preset ${safeAlias} (auto-mapped to ${safeCanonical})`)}`); }); - console.log(''); - console.log(subheader('Examples')); - console.log(` ${dim('# Interactive wizard')}`); - console.log(` ${color('ccs api create', 'command')}`); - console.log(''); - console.log(` ${dim('# Quick setup with preset')}`); - console.log(` ${color('ccs api create --preset anthropic', 'command')}`); - console.log(` ${color('ccs api create --preset openrouter', 'command')}`); - console.log(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`); - console.log(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`); - console.log(` ${color('ccs api create --preset glm', 'command')}`); - console.log(''); - console.log(` ${dim('# Create routed profile from existing CLIProxy provider config')}`); - console.log(` ${color('ccs api create --cliproxy-provider gemini', 'command')}`); - console.log( + writeLine(''); + writeLine(subheader('Examples')); + writeLine(` ${dim('# Interactive wizard')}`); + writeLine(` ${color('ccs api create', 'command')}`); + writeLine(''); + writeLine(` ${dim('# Quick setup with preset')}`); + writeLine(` ${color('ccs api create --preset anthropic', 'command')}`); + writeLine( + ` ${color('ccs api create --preset anthropic --1m', 'command')} ${dim('# explicit Claude [1m] opt-in')}` + ); + writeLine(` ${color('ccs api create --preset openrouter', 'command')}`); + writeLine(` ${color('ccs api create --preset alibaba-coding-plan', 'command')}`); + writeLine(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`); + writeLine(` ${color('ccs api create --preset glm', 'command')}`); + writeLine(''); + writeLine(subheader('Claude Long Context')); + writeLine(` ${dim('Plain Claude model IDs stay on standard context by default.')}`); + writeLine( + ` ${dim('Use --1m during create to append [1m] to compatible Claude mappings, or --no-1m to force plain IDs.')}` + ); + writeLine( + ` ${dim('CCS controls only the saved [1m] suffix. Provider pricing/entitlement stay upstream, and some accounts can still return 429 for long-context requests.')}` + ); + writeLine(''); + writeLine(` ${dim('# Create routed profile from existing CLIProxy provider config')}`); + writeLine(` ${color('ccs api create --cliproxy-provider gemini', 'command')}`); + writeLine( ` ${color('ccs api create gemini-droid --cliproxy-provider gemini --target droid', 'command')}` ); - console.log(''); - console.log(` ${dim('# Create with name')}`); - console.log(` ${color('ccs api create myapi', 'command')}`); - console.log(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`); - console.log(''); - console.log(` ${dim('# Remove API profile')}`); - console.log(` ${color('ccs api remove myapi', 'command')}`); - console.log(''); - console.log(` ${dim('# Discover and register orphan settings files')}`); - console.log(` ${color('ccs api discover', 'command')}`); - console.log(` ${color('ccs api discover --register', 'command')}`); - console.log(''); - console.log(` ${dim('# Duplicate an existing API profile')}`); - console.log(` ${color('ccs api copy glm glm-backup', 'command')}`); - console.log(''); - console.log(` ${dim('# Export and import across devices')}`); - console.log(` ${color('ccs api export glm --out ./glm.ccs-profile.json', 'command')}`); - console.log(` ${color('ccs api import ./glm.ccs-profile.json', 'command')}`); - console.log(''); - console.log(` ${dim('# Show all API profiles')}`); - console.log(` ${color('ccs api list', 'command')}`); - console.log(''); + writeLine(''); + writeLine(` ${dim('# Create with name')}`); + writeLine(` ${color('ccs api create myapi', 'command')}`); + writeLine(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`); + writeLine(''); + writeLine(` ${dim('# Remove API profile')}`); + writeLine(` ${color('ccs api remove myapi', 'command')}`); + writeLine(''); + writeLine(` ${dim('# Discover and register orphan settings files')}`); + writeLine(` ${color('ccs api discover', 'command')}`); + writeLine(` ${color('ccs api discover --register', 'command')}`); + writeLine(''); + writeLine(` ${dim('# Duplicate an existing API profile')}`); + writeLine(` ${color('ccs api copy glm glm-backup', 'command')}`); + writeLine(''); + writeLine(` ${dim('# Export and import across devices')}`); + writeLine(` ${color('ccs api export glm --out ./glm.ccs-profile.json', 'command')}`); + writeLine(` ${color('ccs api import ./glm.ccs-profile.json', 'command')}`); + writeLine(''); + writeLine(` ${dim('# Show all API profiles')}`); + writeLine(` ${color('ccs api list', 'command')}`); + writeLine(''); } export async function showUnknownApiCommand(command: string): Promise { diff --git a/src/commands/api-command/index.ts b/src/commands/api-command/index.ts index 79e6b560..c014b91a 100644 --- a/src/commands/api-command/index.ts +++ b/src/commands/api-command/index.ts @@ -1,31 +1,28 @@ -import { dispatchNamedCommand, type NamedCommandRoute } from '../named-command-router'; import { handleApiCopyCommand } from './copy-command'; import { handleApiCreateCommand } from './create-command'; import { handleApiDiscoverCommand } from './discover-command'; import { handleApiExportCommand } from './export-command'; +import { createApiCommandHandler, type ApiCommandDependencies } from './handler'; import { showApiCommandHelp, showUnknownApiCommand } from './help'; import { handleApiImportCommand } from './import-command'; import { handleApiListCommand } from './list-command'; import { handleApiRemoveCommand } from './remove-command'; +export { createApiCommandHandler, type ApiCommandDependencies } from './handler'; export { parseApiCommandArgs } from './shared'; -const API_COMMAND_ROUTES: readonly NamedCommandRoute[] = [ - { name: 'create', handle: handleApiCreateCommand }, - { name: 'list', handle: handleApiListCommand }, - { name: 'discover', handle: handleApiDiscoverCommand }, - { name: 'copy', handle: handleApiCopyCommand }, - { name: 'export', handle: handleApiExportCommand }, - { name: 'import', handle: handleApiImportCommand }, - { name: 'remove', aliases: ['delete', 'rm'], handle: handleApiRemoveCommand }, -]; +const DEFAULT_API_COMMAND_DEPENDENCIES: ApiCommandDependencies = { + help: showApiCommandHelp, + unknown: showUnknownApiCommand, + create: handleApiCreateCommand, + list: handleApiListCommand, + discover: handleApiDiscoverCommand, + copy: handleApiCopyCommand, + export: handleApiExportCommand, + import: handleApiImportCommand, + remove: handleApiRemoveCommand, +}; export async function handleApiCommand(args: string[]): Promise { - await dispatchNamedCommand({ - args, - routes: API_COMMAND_ROUTES, - onHelp: showApiCommandHelp, - onUnknown: showUnknownApiCommand, - allowEmptyHelp: true, - }); + await createApiCommandHandler(DEFAULT_API_COMMAND_DEPENDENCIES)(args); } diff --git a/src/commands/api-command/shared.ts b/src/commands/api-command/shared.ts index de42e70b..06ae02c1 100644 --- a/src/commands/api-command/shared.ts +++ b/src/commands/api-command/shared.ts @@ -1,4 +1,12 @@ +import type { ModelMapping } from '../../api/services'; import type { TargetType } from '../../targets/target-adapter'; +import { + applyExtendedContextSuffix, + hasExtendedContextSuffix, + isClaudeModelId, + likelySupportsClaudeExtendedContext, + stripExtendedContextSuffix, +} from '../../shared/extended-context-utils'; import { fail } from '../../utils/ui'; import { extractOption, hasAnyFlag, scanCommandArgs } from '../arg-extractor'; @@ -11,12 +19,15 @@ export interface ApiCommandArgs { preset?: string; cliproxyProvider?: string; target?: TargetType; + extendedContext?: boolean; force?: boolean; yes?: boolean; errors: string[]; } -export const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y'] as const; +const MODEL_MAPPING_KEYS = ['default', 'opus', 'sonnet', 'haiku'] as const; + +export const API_BOOLEAN_FLAGS = ['--force', '--yes', '-y', '--1m', '--no-1m'] as const; export const API_VALUE_FLAGS = [ '--base-url', '--api-key', @@ -155,6 +166,8 @@ export function parseApiCommandArgs( args: string[], options: ParseApiCommandArgsOptions = {} ): ApiCommandArgs { + const enableExtendedContext = hasAnyFlag(args, ['--1m']); + const disableExtendedContext = hasAnyFlag(args, ['--no-1m']); const result: ApiCommandArgs = { positionals: [], force: hasAnyFlag(args, ['--force']), @@ -162,6 +175,14 @@ export function parseApiCommandArgs( errors: [], }; + if (enableExtendedContext && disableExtendedContext) { + result.errors.push('Cannot combine --1m and --no-1m'); + } else if (enableExtendedContext) { + result.extendedContext = true; + } else if (disableExtendedContext) { + result.extendedContext = false; + } + let remaining = [...args]; remaining = applyRepeatedOption( @@ -251,6 +272,37 @@ export function parseApiCommandArgs( return result; } +export function hasClaudeModelMapping(models: ModelMapping): boolean { + return MODEL_MAPPING_KEYS.some((key) => isClaudeModelId(models[key])); +} + +export function hasExplicitClaudeExtendedContext(models: ModelMapping): boolean { + return MODEL_MAPPING_KEYS.some( + (key) => isClaudeModelId(models[key]) && hasExtendedContextSuffix(models[key]) + ); +} + +export function applyClaudeExtendedContextPreference( + models: ModelMapping, + enabled: boolean +): ModelMapping { + const nextModels = { ...models }; + + for (const key of MODEL_MAPPING_KEYS) { + const value = nextModels[key]; + if (!isClaudeModelId(value)) { + continue; + } + + nextModels[key] = + enabled && likelySupportsClaudeExtendedContext(value) + ? applyExtendedContextSuffix(value) + : stripExtendedContextSuffix(value); + } + + return nextModels; +} + export function exitOnApiCommandErrors(errors: string[]): void { if (errors.length === 0) { return; diff --git a/src/commands/cliproxy/quota-subcommand.ts b/src/commands/cliproxy/quota-subcommand.ts index c19dec31..ae0c8573 100644 --- a/src/commands/cliproxy/quota-subcommand.ts +++ b/src/commands/cliproxy/quota-subcommand.ts @@ -27,6 +27,7 @@ import type { ClaudeQuotaResult, GeminiCliQuotaResult, GhcpQuotaResult, + QuotaErrorMetadata, } from '../../cliproxy/quota-types'; import { isOnCooldown } from '../../cliproxy/quota-manager'; import { CLIProxyProvider } from '../../cliproxy/types'; @@ -96,6 +97,75 @@ function formatResetTimeISO(isoTime: string): string { return formatResetTime(seconds); } +interface QuotaFailureDisplayEntry { + tone: 'error' | 'info' | 'dim'; + text: string; +} + +function getQuotaFailureDisplayEntries( + quota: QuotaErrorMetadata & { + error?: string; + } +): QuotaFailureDisplayEntry[] { + const entries: QuotaFailureDisplayEntry[] = [ + { + tone: 'error', + text: quota.error || 'Failed to fetch quota', + }, + ]; + + if (quota.actionHint) { + entries.push({ + tone: 'info', + text: quota.actionHint, + }); + } + + const diagnostics: string[] = []; + if (typeof quota.httpStatus === 'number') { + diagnostics.push(`HTTP ${quota.httpStatus}`); + } + if (quota.errorCode) { + diagnostics.push(`Code: ${quota.errorCode}`); + } + if (quota.retryable) { + diagnostics.push('Retryable'); + } + if (diagnostics.length > 0) { + entries.push({ + tone: 'dim', + text: diagnostics.join(' | '), + }); + } + + const normalizedError = quota.error?.trim(); + const normalizedDetail = quota.errorDetail?.trim(); + if (normalizedDetail && normalizedDetail !== normalizedError) { + entries.push({ + tone: 'dim', + text: `Detail: ${normalizedDetail}`, + }); + } + + return entries; +} + +function displayQuotaFailure( + quota: QuotaErrorMetadata & { + error?: string; + } +): void { + for (const entry of getQuotaFailureDisplayEntries(quota)) { + const rendered = + entry.tone === 'error' + ? color(entry.text, 'error') + : entry.tone === 'info' + ? info(entry.text) + : dim(entry.text); + console.log(` ${rendered}`); + } +} + function formatAbsoluteResetTime(isoTime: string): string | null { if (!isoTime) return null; const resetDate = new Date(isoTime); @@ -318,7 +388,7 @@ function displayCodexQuotaSection(results: { account: string; quota: CodexQuotaR if (!quota.success) { console.log(` ${fail(account)}${defaultMark}`); - console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + displayQuotaFailure(quota); console.log(''); continue; } @@ -473,7 +543,7 @@ function displayClaudeQuotaSection(results: { account: string; quota: ClaudeQuot if (!quota.success) { console.log(` ${fail(account)}${defaultMark}`); - console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + displayQuotaFailure(quota); console.log(''); continue; } @@ -550,7 +620,7 @@ function displayGeminiCliQuotaSection( if (!quota.success) { console.log(` ${fail(account)}${defaultMark}`); - console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + displayQuotaFailure(quota); console.log(''); continue; } @@ -601,7 +671,7 @@ function displayGhcpQuotaSection(results: { account: string; quota: GhcpQuotaRes if (!quota.success) { console.log(` ${fail(account)}${defaultMark}`); - console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + displayQuotaFailure(quota); console.log(''); continue; } @@ -697,6 +767,10 @@ const QUOTA_PROVIDER_RUNTIME: Record { if (!quota.success) { console.log(` ${fail(accountLabel)}${defaultBadge}`); - console.log(` ${color(quota.error || 'Failed to fetch quota', 'error')}`); + displayQuotaFailure(quota); if (quota.isUnprovisioned) { console.log( ` ${warn('Account not provisioned - open Gemini Code Assist in IDE first')}` diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 756d8022..e3bef4e6 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -5,6 +5,8 @@ import { isUnifiedMode } from '../config/unified-config-loader'; import { getCcsDir, getCcsDirSource } from '../utils/config-manager'; import { CLIPROXY_DEFAULT_PORT } from '../cliproxy/config/port-manager'; +type HelpWriter = (line: string) => void; + // Get version from package.json (same as version-command.ts) const VERSION = JSON.parse( fs.readFileSync(path.join(__dirname, '../../package.json'), 'utf8') @@ -19,28 +21,33 @@ const VERSION = JSON.parse( * * command Description */ -function printMajorSection(title: string, subtitles: string[], items: [string, string][]): void { +function printMajorSection( + title: string, + subtitles: string[], + items: [string, string][], + writeLine: HelpWriter = console.log +): void { // Section header with ═══ borders - console.log(sectionHeader(title)); + writeLine(sectionHeader(title)); // Subtitles on separate lines (dim) for (const subtitle of subtitles) { - console.log(` ${dim(subtitle)}`); + writeLine(` ${dim(subtitle)}`); } // Empty line before items - console.log(''); + writeLine(''); // Calculate max command length for alignment const maxCmdLen = Math.max(...items.map(([cmd]) => cmd.length)); for (const [cmd, desc] of items) { const paddedCmd = cmd.padEnd(maxCmdLen + 2); - console.log(` ${color(paddedCmd, 'command')} ${desc}`); + writeLine(` ${color(paddedCmd, 'command')} ${desc}`); } // Extra spacing after section - console.log(''); + writeLine(''); } /** @@ -49,20 +56,24 @@ function printMajorSection(title: string, subtitles: string[], items: [string, s * Title (context): * command Description */ -function printSubSection(title: string, items: [string, string][]): void { +function printSubSection( + title: string, + items: [string, string][], + writeLine: HelpWriter = console.log +): void { // Sub-section header (colored, no borders) - console.log(subheader(`${title}:`)); + writeLine(subheader(`${title}:`)); // Calculate max command length for alignment const maxCmdLen = Math.max(...items.map(([cmd]) => cmd.length)); for (const [cmd, desc] of items) { const paddedCmd = cmd.padEnd(maxCmdLen + 2); - console.log(` ${color(paddedCmd, 'command')} ${desc}`); + writeLine(` ${color(paddedCmd, 'command')} ${desc}`); } // Spacing after section - console.log(''); + writeLine(''); } /** @@ -71,24 +82,28 @@ function printSubSection(title: string, items: [string, string][]): void { * Title: * Label: path */ -function printConfigSection(title: string, items: [string, string][]): void { - console.log(subheader(`${title}:`)); +function printConfigSection( + title: string, + items: [string, string][], + writeLine: HelpWriter = console.log +): void { + writeLine(subheader(`${title}:`)); // Calculate max label length for alignment const maxLabelLen = Math.max(...items.map(([label]) => label.length)); for (const [label, path] of items) { const paddedLabel = label.padEnd(maxLabelLen); - console.log(` ${paddedLabel} ${color(path, 'path')}`); + writeLine(` ${paddedLabel} ${color(path, 'path')}`); } - console.log(''); + writeLine(''); } /** * Display comprehensive help information for CCS (Claude Code Switch) */ -export async function handleHelpCommand(): Promise { +export async function handleHelpCommand(writeLine: HelpWriter = console.log): Promise { // Initialize UI (if not already) await initUI(); @@ -103,24 +118,24 @@ Claude Code Profile & Model Switcher Run ${color('ccs config', 'command')} for web dashboard`.trim(); - console.log( + writeLine( box(logo, { padding: 1, borderStyle: 'round', titleAlignment: 'center', }) ); - console.log(''); + writeLine(''); // Resolve display path for dynamic sections const [dirSource] = getCcsDirSource(); const dirDisplay = dirSource === 'default' ? '~/.ccs' : getCcsDir(); // Usage section - console.log(subheader('Usage:')); - console.log(` ${color('ccs', 'command')} [profile] [claude-args...]`); - console.log(` ${color('ccs', 'command')} [flags]`); - console.log(''); + writeLine(subheader('Usage:')); + writeLine(` ${color('ccs', 'command')} [profile] [claude-args...]`); + writeLine(` ${color('ccs', 'command')} [flags]`); + writeLine(''); // ═══════════════════════════════════════════════════════════════════════════ // MAJOR SECTION 1: API Key Profiles @@ -152,7 +167,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs api import ', 'Import profile bundle'], ['ccs api remove', 'Remove an API profile'], ['ccs api list', 'List all API profiles'], - ] + ], + writeLine ); // ═══════════════════════════════════════════════════════════════════════════ @@ -176,7 +192,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs auth default ', 'Set default profile'], ['ccs auth reset-default', 'Restore original CCS default'], ['ccs cliproxy auth claude', 'Alternative: authenticate Claude account pool via CLIProxy'], - ] + ], + writeLine ); // ═══════════════════════════════════════════════════════════════════════════ @@ -220,8 +237,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); 'Set thinking budget (low/medium/high/xhigh/auto/off or number)', ], ['ccs codex --effort ', 'Set codex reasoning effort (medium/high/xhigh)'], - ['ccs --1m', 'Enable 1M token context window'], - ['ccs --no-1m', 'Disable 1M context (use 200K default)'], + ['ccs --1m', 'Request explicit 1M context when the selected model supports [1m]'], + ['ccs --no-1m', 'Force standard context / clear [1m]'], ['ccs --logout', 'Clear authentication'], ['ccs --headless', 'Headless auth (for SSH)'], ['ccs --port-forward', 'Force port-forwarding mode (skip prompt)'], @@ -232,7 +249,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs kiro --import', 'Import token from Kiro IDE'], ['ccs kiro --incognito', 'Use incognito browser (default: normal)'], ['ccs codex "explain code"', 'Use with prompt'], - ] + ], + writeLine ); // ═══════════════════════════════════════════════════════════════════════════ @@ -255,7 +273,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs copilot stop', 'Stop copilot-api daemon'], ['ccs copilot enable', 'Enable integration'], ['ccs copilot disable', 'Disable integration'], - ] + ], + writeLine ); // ═══════════════════════════════════════════════════════════════════════════ @@ -277,7 +296,8 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs cursor stop', 'Stop proxy daemon'], ['ccs cursor enable', 'Enable cursor integration'], ['ccs cursor disable', 'Disable cursor integration'], - ] + ], + writeLine ); // ═══════════════════════════════════════════════════════════════════════════ @@ -285,280 +305,351 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); // ═══════════════════════════════════════════════════════════════════════════ // Delegation - printSubSection('Delegation (inside Claude Code CLI)', [ - ['/ccs "task"', 'Delegate task (auto-selects profile)'], - ['/ccs --glm "task"', 'Force GLM-5 for simple tasks'], - ['/ccs --kimi "task"', 'Force Kimi OAuth for long context'], - ['/ccs --km "task"', 'Force Kimi API key for long context'], - ['/ccs:continue "follow-up"', 'Continue last delegation session'], - ]); + printSubSection( + 'Delegation (inside Claude Code CLI)', + [ + ['/ccs "task"', 'Delegate task (auto-selects profile)'], + ['/ccs --glm "task"', 'Force GLM-5 for simple tasks'], + ['/ccs --kimi "task"', 'Force Kimi OAuth for long context'], + ['/ccs --km "task"', 'Force Kimi API key for long context'], + ['/ccs:continue "follow-up"', 'Continue last delegation session'], + ], + writeLine + ); // Delegation CLI Flags (Claude Code passthrough) - printSubSection('Delegation Flags (Claude Code passthrough)', [ - ['--max-turns ', 'Limit agentic turns (prevents loops)'], - ['--fallback-model ', 'Auto-fallback on overload (sonnet)'], - ['--agents ', 'Inject dynamic subagents'], - ['--betas ', 'Enable experimental features'], - ['--allowedTools ', 'Restrict available tools'], - ['--disallowedTools ', 'Block specific tools'], - ]); + printSubSection( + 'Delegation Flags (Claude Code passthrough)', + [ + ['--max-turns ', 'Limit agentic turns (prevents loops)'], + ['--fallback-model ', 'Auto-fallback on overload (sonnet)'], + ['--agents ', 'Inject dynamic subagents'], + ['--betas ', 'Enable experimental features'], + ['--allowedTools ', 'Restrict available tools'], + ['--disallowedTools ', 'Block specific tools'], + ], + writeLine + ); // Diagnostics - printSubSection('Diagnostics', [ - ['ccs setup', 'First-time setup wizard'], - ['ccs doctor', 'Run health check and diagnostics'], - ['ccs cleanup', 'Remove old CLIProxy logs'], - ['ccs config', 'Open web dashboard (includes Claude IDE Extension setup page)'], - ['ccs config auth setup', 'Configure dashboard login'], - ['ccs config auth show', 'Show dashboard auth status'], - ['ccs config channels', 'Show Official Channels status'], + printSubSection( + 'Diagnostics', [ - 'ccs config channels --set telegram,discord', - 'Auto-add Telegram + Discord on supported native Claude runs', + ['ccs setup', 'First-time setup wizard'], + ['ccs doctor', 'Run health check and diagnostics'], + ['ccs cleanup', 'Remove old CLIProxy logs'], + ['ccs config', 'Open web dashboard (includes Claude IDE Extension setup page)'], + ['ccs config auth setup', 'Configure dashboard login'], + ['ccs config auth show', 'Show dashboard auth status'], + ['ccs config channels', 'Show Official Channels status'], + [ + 'ccs config channels --set telegram,discord', + 'Auto-add Telegram + Discord on supported native Claude runs', + ], + ['ccs config channels --set-token telegram=', 'Save TELEGRAM_BOT_TOKEN'], + ['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 config --host 0.0.0.0', 'Force all-interface binding for remote devices'], + ['ccs persist ', 'Write profile setup to ~/.claude/settings.json'], + ['ccs persist --list-backups', 'List available settings.json backups'], + ['ccs persist --restore', 'Restore settings.json from latest backup'], + ['ccs sync', 'Sync delegation commands and skills'], + ['ccs update', 'Update CCS to latest version'], + ['ccs update --force', 'Force reinstall current version'], + ['ccs update --beta', 'Install from dev channel (unstable)'], ], - ['ccs config channels --set-token telegram=', 'Save TELEGRAM_BOT_TOKEN'], - ['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 config --host 0.0.0.0', 'Force all-interface binding for remote devices'], - ['ccs persist ', 'Write profile setup to ~/.claude/settings.json'], - ['ccs persist --list-backups', 'List available settings.json backups'], - ['ccs persist --restore', 'Restore settings.json from latest backup'], - ['ccs sync', 'Sync delegation commands and skills'], - ['ccs update', 'Update CCS to latest version'], - ['ccs update --force', 'Force reinstall current version'], - ['ccs update --beta', 'Install from dev channel (unstable)'], - ]); + writeLine + ); // Environment export - printSubSection('Environment Export', [ - ['ccs env ', 'Export env vars for third-party tools'], - ['ccs env --format openai', 'OpenAI-compatible vars (OpenCode/Cursor)'], - ['ccs env --format anthropic', 'Anthropic vars (default)'], - ['ccs env --format raw', 'All effective env vars'], + printSubSection( + 'Environment Export', [ - 'ccs env --format claude-extension --ide vscode', - 'VS Code/Cursor Claude extension settings JSON', + ['ccs env ', 'Export env vars for third-party tools'], + ['ccs env --format openai', 'OpenAI-compatible vars (OpenCode/Cursor)'], + ['ccs env --format anthropic', 'Anthropic vars (default)'], + ['ccs env --format raw', 'All effective env vars'], + [ + 'ccs env --format claude-extension --ide vscode', + 'VS Code/Cursor Claude extension settings JSON', + ], + [ + 'ccs env --format claude-extension --ide windsurf', + 'Windsurf Claude extension settings JSON', + ], + ['ccs env --shell fish', 'Fish shell syntax'], ], - [ - 'ccs env --format claude-extension --ide windsurf', - 'Windsurf Claude extension settings JSON', - ], - ['ccs env --shell fish', 'Fish shell syntax'], - ]); + writeLine + ); // Flags - printSubSection('Flags', [ - ['--config-dir ', 'Use custom CCS config directory'], - ['--target ', 'Target CLI: claude (default), droid'], - ['-h, --help', 'Show this help message'], - ['-v, --version', 'Show version and installation info'], - ['-sc, --shell-completion', 'Install shell auto-completion'], - ]); + printSubSection( + 'Flags', + [ + ['--config-dir ', 'Use custom CCS config directory'], + ['--target ', 'Target CLI: claude (default), droid'], + ['-h, --help', 'Show this help message'], + ['-v, --version', 'Show version and installation info'], + ['-sc, --shell-completion', 'Install shell auto-completion'], + ], + writeLine + ); // Aliases - printSubSection('Aliases', [ - ['ccs-droid [args]', 'Explicit Droid runtime alias'], - ['ccsd [args]', 'Legacy shortcut for: ccs-droid [args]'], - ]); + printSubSection( + 'Aliases', + [ + ['ccs-droid [args]', 'Explicit Droid runtime alias'], + ['ccsd [args]', 'Legacy shortcut for: ccs-droid [args]'], + ], + writeLine + ); // Multi-target examples - printSubSection('Multi-Target', [ - ['ccs glm --target droid', 'Run GLM profile on Droid CLI'], - ['ccs-droid glm', 'Same as above (explicit alias)'], - ['ccsd glm', 'Legacy shortcut for ccs-droid'], - ['ccs-droid codex', 'Run built-in CLIProxy Codex profile on Droid'], - ['ccs-droid agy', 'Run built-in CLIProxy Antigravity profile on Droid'], + printSubSection( + 'Multi-Target', [ - 'ccs-droid codex exec --skip-permissions-unsafe "fix failing tests"', - 'Pass through Droid exec mode', + ['ccs glm --target droid', 'Run GLM profile on Droid CLI'], + ['ccs-droid glm', 'Same as above (explicit alias)'], + ['ccsd glm', 'Legacy shortcut for ccs-droid'], + ['ccs-droid codex', 'Run built-in CLIProxy Codex profile on Droid'], + ['ccs-droid agy', 'Run built-in CLIProxy Antigravity profile on Droid'], + [ + 'ccs-droid codex exec --skip-permissions-unsafe "fix failing tests"', + 'Pass through Droid exec mode', + ], + [ + 'ccs-droid codex -m custom:gpt-5.3-codex "fix failing tests"', + 'Auto-routes short exec flags', + ], + [ + 'ccs-droid codex --skip-permissions-unsafe "fix failing tests"', + 'Auto-routes to Droid exec when exec-only flags are detected', + ], + [ + 'ccs cliproxy create my-codex --provider codex --target droid', + 'Create CLIProxy variant with Droid as default target', + ], + ['ccs glm', 'Run GLM profile on Claude Code (default)'], ], - ['ccs-droid codex -m custom:gpt-5.3-codex "fix failing tests"', 'Auto-routes short exec flags'], - [ - 'ccs-droid codex --skip-permissions-unsafe "fix failing tests"', - 'Auto-routes to Droid exec when exec-only flags are detected', - ], - [ - 'ccs cliproxy create my-codex --provider codex --target droid', - 'Create CLIProxy variant with Droid as default target', - ], - ['ccs glm', 'Run GLM profile on Claude Code (default)'], - ]); + writeLine + ); // Configuration - printConfigSection('Configuration', [ - ['Config File:', isUnifiedMode() ? `${dirDisplay}/config.yaml` : `${dirDisplay}/config.json`], - ['Profiles:', `${dirDisplay}/profiles.json`], - ['Instances:', `${dirDisplay}/instances/`], - ['Settings:', `${dirDisplay}/*.settings.json`], - ]); + printConfigSection( + 'Configuration', + [ + ['Config File:', isUnifiedMode() ? `${dirDisplay}/config.yaml` : `${dirDisplay}/config.json`], + ['Profiles:', `${dirDisplay}/profiles.json`], + ['Instances:', `${dirDisplay}/instances/`], + ['Settings:', `${dirDisplay}/*.settings.json`], + ], + writeLine + ); // CLI Proxy management - printSubSection('CLI Proxy Plus Management', [ - ['ccs cliproxy', 'Show CLIProxy Plus status and version'], - ['ccs cliproxy --help', 'Full CLIProxy Plus management help'], - ['ccs cliproxy doctor', 'Quota diagnostics (Antigravity)'], - ['ccs cliproxy --install ', 'Install specific version (e.g., 6.6.6)'], - ['ccs cliproxy --latest', 'Update to latest version'], - ['', ''], // Spacer - ['ccs cliproxy pause

', 'Pause account from rotation'], - ['ccs cliproxy resume

', 'Resume paused account'], - ['ccs cliproxy status', 'Show CLIProxy process status'], - ['ccs cliproxy quota', 'Show quota/tier/pause status for all providers'], - ['ccs cliproxy quota --provider ', 'Show quota/tier/pause status for one provider'], - ]); + printSubSection( + 'CLI Proxy Plus Management', + [ + ['ccs cliproxy', 'Show CLIProxy Plus status and version'], + ['ccs cliproxy --help', 'Full CLIProxy Plus management help'], + ['ccs cliproxy doctor', 'Quota diagnostics (Antigravity)'], + ['ccs cliproxy --install ', 'Install specific version (e.g., 6.6.6)'], + ['ccs cliproxy --latest', 'Update to latest version'], + ['', ''], // Spacer + ['ccs cliproxy pause

', 'Pause account from rotation'], + ['ccs cliproxy resume

', 'Resume paused account'], + ['ccs cliproxy status', 'Show CLIProxy process status'], + ['ccs cliproxy quota', 'Show quota/tier/pause status for all providers'], + ['ccs cliproxy quota --provider ', 'Show quota/tier/pause status for one provider'], + ], + writeLine + ); // CLI Proxy configuration flags (new) - printSubSection('CLI Proxy Configuration', [ - ['--proxy-host ', 'Remote proxy hostname/IP'], - ['--proxy-port ', `Proxy port (default: ${CLIPROXY_DEFAULT_PORT})`], - ['--proxy-protocol ', 'Protocol: http or https (default: http)'], - ['--proxy-auth-token ', 'Auth token for remote proxy'], - ['--proxy-timeout ', 'Connection timeout in ms (default: 2000)'], - ['--local-proxy', 'Force local mode, ignore remote config'], - ['--remote-only', 'Fail if remote unreachable (no fallback)'], - ['--allow-self-signed', 'Allow self-signed certs (for dev proxies)'], - ]); + printSubSection( + 'CLI Proxy Configuration', + [ + ['--proxy-host ', 'Remote proxy hostname/IP'], + ['--proxy-port ', `Proxy port (default: ${CLIPROXY_DEFAULT_PORT})`], + ['--proxy-protocol ', 'Protocol: http or https (default: http)'], + ['--proxy-auth-token ', 'Auth token for remote proxy'], + ['--proxy-timeout ', 'Connection timeout in ms (default: 2000)'], + ['--local-proxy', 'Force local mode, ignore remote config'], + ['--remote-only', 'Fail if remote unreachable (no fallback)'], + ['--allow-self-signed', 'Allow self-signed certs (for dev proxies)'], + ], + writeLine + ); // W3: Thinking Budget explanation - printSubSection('Extended Thinking / Reasoning', [ - ['--thinking off', 'Disable extended thinking'], - ['--thinking auto', 'Let model decide dynamically'], - ['--thinking low', '1K tokens - Quick responses'], - ['--thinking medium', '8K tokens - Standard analysis'], - ['--thinking high', '24K tokens - Deep reasoning'], - ['--thinking xhigh', '32K tokens - Maximum depth'], - ['--thinking ', 'Custom token budget (512-100000)'], - ['', ''], - ['--effort ', 'Codex alias for reasoning effort (medium/high/xhigh)'], - ['--effort xhigh', 'Pin Codex effort to xhigh for this run'], - ['', ''], - ['Droid exec:', 'Use native Droid flag: --reasoning-effort '], - ['', 'CCS auto-maps --thinking/--effort to --reasoning-effort in droid exec mode.'], - ['', 'For interactive droid sessions, CCS applies reasoning via Droid BYOK model config.'], - ['', 'When multiple reasoning flags are provided, the first flag wins.'], - ['', ''], - ['Note:', 'Extended thinking allocates compute for step-by-step reasoning'], - ['', 'before responding.'], - ['', 'Providers: agy/gemini use --thinking, codex uses --effort (or --thinking alias).'], - ['', 'Codex model suffixes also pin effort: -medium / -high / -xhigh.'], - ]); + printSubSection( + 'Extended Thinking / Reasoning', + [ + ['--thinking off', 'Disable extended thinking'], + ['--thinking auto', 'Let model decide dynamically'], + ['--thinking low', '1K tokens - Quick responses'], + ['--thinking medium', '8K tokens - Standard analysis'], + ['--thinking high', '24K tokens - Deep reasoning'], + ['--thinking xhigh', '32K tokens - Maximum depth'], + ['--thinking ', 'Custom token budget (512-100000)'], + ['', ''], + ['--effort ', 'Codex alias for reasoning effort (medium/high/xhigh)'], + ['--effort xhigh', 'Pin Codex effort to xhigh for this run'], + ['', ''], + ['Droid exec:', 'Use native Droid flag: --reasoning-effort '], + ['', 'CCS auto-maps --thinking/--effort to --reasoning-effort in droid exec mode.'], + ['', 'For interactive droid sessions, CCS applies reasoning via Droid BYOK model config.'], + ['', 'When multiple reasoning flags are provided, the first flag wins.'], + ['', ''], + ['Note:', 'Extended thinking allocates compute for step-by-step reasoning'], + ['', 'before responding.'], + ['', 'Providers: agy/gemini use --thinking, codex uses --effort (or --thinking alias).'], + ['', 'Codex model suffixes also pin effort: -medium / -high / -xhigh.'], + ], + writeLine + ); // Extended Context (1M) - printSubSection('Extended Context (--1m)', [ - ['--1m', 'Enable 1M token context window'], - ['--no-1m', 'Disable 1M context (use 200K default)'], - ['', ''], - ['Auto behavior:', 'Gemini models: auto-enabled by default'], - ['', 'Claude models: opt-in with --1m flag'], - ['', ''], - ['Note:', 'Extended context enables 1M token window via [1m] suffix.'], - ['', 'Premium pricing: 2x input for >200K tokens.'], - ]); + printSubSection( + 'Extended Context (--1m)', + [ + ['--1m', 'Request 1M token context when the selected model supports [1m]'], + ['--no-1m', 'Force standard context (Claude default stays plain)'], + ['', ''], + ['Auto behavior:', 'Gemini models: CCS auto-adds [1m] when supported'], + ['', 'Claude models: plain by default, opt-in with --1m or saved [1m]'], + ['', ''], + ['Note:', 'CCS only controls the saved [1m] suffix.'], + ['', 'Provider pricing and entitlement stay upstream.'], + [ + '', + 'Some accounts/providers can still return 429 extra-usage errors for long-context requests.', + ], + ], + writeLine + ); // Image Analysis - printSubSection('Image Analysis (CLIProxy vision)', [ - ['ccs config image-analysis', 'Show current settings'], - ['ccs config image-analysis --enable', 'Enable for CLIProxy providers'], - ['ccs config image-analysis --disable', 'Disable (use native Read)'], - ['ccs config image-analysis --timeout 120', 'Set analysis timeout'], - ['ccs config image-analysis --set-model

', 'Set provider model'], - ['', ''], - ['Note:', 'When enabled, images/PDFs are analyzed via vision models'], - ['', 'instead of passing raw data to Claude. Works with CLIProxy'], - ['', 'providers (agy, gemini, codex, kiro, ghcp).'], - ]); - - printSubSection('Official Channels (official Claude plugins)', [ - ['ccs config', 'Dashboard -> Settings -> Channels (fastest path)'], - ['ccs config channels', 'Show current status'], + printSubSection( + 'Image Analysis (CLIProxy vision)', [ - 'ccs config channels --set telegram,discord', - 'Auto-add selected channels on native Claude default/account sessions', + ['ccs config image-analysis', 'Show current settings'], + ['ccs config image-analysis --enable', 'Enable for CLIProxy providers'], + ['ccs config image-analysis --disable', 'Disable (use native Read)'], + ['ccs config image-analysis --timeout 120', 'Set analysis timeout'], + ['ccs config image-analysis --set-model

', 'Set provider model'], + ['', ''], + ['Note:', 'When enabled, images/PDFs are analyzed via vision models'], + ['', 'instead of passing raw data to Claude. Works with CLIProxy'], + ['', 'providers (agy, gemini, codex, kiro, ghcp).'], ], - ['ccs config channels --set all', 'Enable Telegram, Discord, and iMessage'], - ['ccs config channels --unattended', 'Also add --dangerously-skip-permissions'], - ['ccs config channels --set-token telegram=', 'Save TELEGRAM_BOT_TOKEN'], - ['ccs config channels --set-token discord=', 'Save DISCORD_BOT_TOKEN'], - ['ccs config channels --clear-token [channel]', 'Remove one or all saved channel tokens'], - ['', ''], - ['', 'Fastest path: turn on the channel, save the token if needed, then run ccs.'], - ['Note:', 'Runtime-only. Applies to native Claude default/account sessions only.'], - ['', 'Not supported for ccs glm, other API/OAuth profiles, or Droid targets.'], - ['', 'Telegram/Discord tokens live in ~/.claude/channels//.env.'], - ['', 'Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work for that launch.'], - ['', 'iMessage is macOS-only and requires local OS permissions instead of a bot token.'], - ]); + writeLine + ); + + printSubSection( + 'Official Channels (official Claude plugins)', + [ + ['ccs config', 'Dashboard -> Settings -> Channels (fastest path)'], + ['ccs config channels', 'Show current status'], + [ + 'ccs config channels --set telegram,discord', + 'Auto-add selected channels on native Claude default/account sessions', + ], + ['ccs config channels --set all', 'Enable Telegram, Discord, and iMessage'], + ['ccs config channels --unattended', 'Also add --dangerously-skip-permissions'], + ['ccs config channels --set-token telegram=', 'Save TELEGRAM_BOT_TOKEN'], + ['ccs config channels --set-token discord=', 'Save DISCORD_BOT_TOKEN'], + ['ccs config channels --clear-token [channel]', 'Remove one or all saved channel tokens'], + ['', ''], + ['', 'Fastest path: turn on the channel, save the token if needed, then run ccs.'], + ['Note:', 'Runtime-only. Applies to native Claude default/account sessions only.'], + ['', 'Not supported for ccs glm, other API/OAuth profiles, or Droid targets.'], + ['', 'Telegram/Discord tokens live in ~/.claude/channels//.env.'], + ['', 'Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work for that launch.'], + ['', 'iMessage is macOS-only and requires local OS permissions instead of a bot token.'], + ], + writeLine + ); // CCS Environment Variables - printSubSection('Environment Variables', [ - ['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)'], - ]); + printSubSection( + 'Environment Variables', + [ + ['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)'], + ], + writeLine + ); // CLI Proxy env vars - printSubSection('CLI Proxy Environment Variables', [ - ['CCS_PROXY_HOST', 'Remote proxy hostname'], - ['CCS_PROXY_PORT', 'Proxy port'], - ['CCS_PROXY_PROTOCOL', 'Protocol (http/https)'], - ['CCS_PROXY_AUTH_TOKEN', 'Auth token'], - ['CCS_PROXY_TIMEOUT', 'Connection timeout in ms'], - ['CCS_PROXY_FALLBACK_ENABLED', 'Enable local fallback (1/0)'], - ['CCS_ALLOW_SELF_SIGNED', 'Allow self-signed certs (1/0)'], - ]); + printSubSection( + 'CLI Proxy Environment Variables', + [ + ['CCS_PROXY_HOST', 'Remote proxy hostname'], + ['CCS_PROXY_PORT', 'Proxy port'], + ['CCS_PROXY_PROTOCOL', 'Protocol (http/https)'], + ['CCS_PROXY_AUTH_TOKEN', 'Auth token'], + ['CCS_PROXY_TIMEOUT', 'Connection timeout in ms'], + ['CCS_PROXY_FALLBACK_ENABLED', 'Enable local fallback (1/0)'], + ['CCS_ALLOW_SELF_SIGNED', 'Allow self-signed certs (1/0)'], + ], + writeLine + ); // CLI Proxy paths - console.log(subheader('CLI Proxy:')); - 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: ${CLIPROXY_DEFAULT_PORT} (default)`)}`); - console.log(''); + writeLine(subheader('CLI Proxy:')); + writeLine(` Binary: ${color(`${dirDisplay}/cliproxy/bin/cli-proxy-api-plus`, 'path')}`); + writeLine(` Config: ${color(`${dirDisplay}/cliproxy/config.yaml`, 'path')}`); + writeLine(` Auth: ${color(`${dirDisplay}/cliproxy/auth/`, 'path')}`); + writeLine(` ${dim(`Port: ${CLIPROXY_DEFAULT_PORT} (default)`)}`); + writeLine(''); // Shared Data - console.log(subheader('Shared Data:')); - console.log(` Commands: ${color(`${dirDisplay}/shared/commands/`, 'path')}`); - console.log(` Skills: ${color(`${dirDisplay}/shared/skills/`, 'path')}`); - console.log(` Agents: ${color(`${dirDisplay}/shared/agents/`, 'path')}`); - console.log(` ${dim('Note: Symlinked across all profiles')}`); - console.log(''); + writeLine(subheader('Shared Data:')); + writeLine(` Commands: ${color(`${dirDisplay}/shared/commands/`, 'path')}`); + writeLine(` Skills: ${color(`${dirDisplay}/shared/skills/`, 'path')}`); + writeLine(` Agents: ${color(`${dirDisplay}/shared/agents/`, 'path')}`); + writeLine(` ${dim('Note: Symlinked across all profiles')}`); + writeLine(''); // Examples (aligned with consistent spacing) - console.log(subheader('Examples:')); - console.log(` $ ${color('ccs', 'command')} ${dim('# Use default account')}`); - console.log( + writeLine(subheader('Examples:')); + writeLine(` $ ${color('ccs', 'command')} ${dim('# Use default account')}`); + writeLine( ` $ ${color('ccs gemini', 'command')} ${dim('# OAuth (browser opens first time)')}` ); - console.log(` $ ${color('ccs glm "implement API"', 'command')} ${dim('# API key model')}`); - console.log(` $ ${color('ccs config', 'command')} ${dim('# Open web dashboard')}`); - console.log(''); + writeLine(` $ ${color('ccs glm "implement API"', 'command')} ${dim('# API key model')}`); + writeLine(` $ ${color('ccs config', 'command')} ${dim('# Open web dashboard')}`); + writeLine(''); // Update examples - console.log(subheader('Update:')); - console.log( + writeLine(subheader('Update:')); + writeLine( ` $ ${color('ccs update', 'command')} ${dim('# Update to latest stable')}` ); - console.log( + writeLine( ` $ ${color('ccs update --force', 'command')} ${dim('# Force reinstall current')}` ); - console.log(` $ ${color('ccs update --beta', 'command')} ${dim('# Install dev channel')}`); - console.log(''); + writeLine(` $ ${color('ccs update --beta', 'command')} ${dim('# Install dev channel')}`); + writeLine(''); // Docs link - console.log(` ${dim('Docs: https://github.com/kaitranntt/ccs')}`); - console.log(''); + writeLine(` ${dim('Docs: https://github.com/kaitranntt/ccs')}`); + writeLine(''); // Uninstall - console.log(subheader('Uninstall:')); - console.log(` ${color('npm uninstall -g @kaitranntt/ccs', 'command')}`); - console.log(''); + writeLine(subheader('Uninstall:')); + writeLine(` ${color('npm uninstall -g @kaitranntt/ccs', 'command')}`); + writeLine(''); // License - console.log(dim('License: MIT')); - console.log(''); + writeLine(dim('License: MIT')); + writeLine(''); } diff --git a/src/commands/index.ts b/src/commands/index.ts index 39e1651b..d6d6f2c2 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -2,7 +2,7 @@ * Commands module barrel export */ -export { handleApiCommand } from './api-command'; +export { handleApiCommand } from './api-command/index'; export { handleCleanupCommand } from './cleanup-command'; export { handleCliproxyCommand } from './cliproxy-command'; export { handleConfigCommand } from './config-command'; diff --git a/src/commands/root-command-router.ts b/src/commands/root-command-router.ts index 07d72885..202878d5 100644 --- a/src/commands/root-command-router.ts +++ b/src/commands/root-command-router.ts @@ -118,7 +118,7 @@ const ROOT_COMMAND_ROUTES: readonly NamedCommandRoute[] = [ { name: 'api', handle: async (args) => { - const { handleApiCommand } = await import('./api-command'); + const { handleApiCommand } = await import('./api-command/index'); await handleApiCommand(args); }, }, diff --git a/src/config/unified-config-types.ts b/src/config/unified-config-types.ts index 8ff88160..cdfcd639 100644 --- a/src/config/unified-config-types.ts +++ b/src/config/unified-config-types.ts @@ -173,7 +173,7 @@ export interface CLIProxyLoggingConfig { * Controls high-risk flow safeguards for supported providers. */ export interface CLIProxySafetyConfig { - /** Allow skipping AGY responsibility acknowledgement flow (default: false) */ + /** Allow skipping AGY responsibility checks and Gemini dashboard typed acknowledgement */ antigravity_ack_bypass?: boolean; } diff --git a/src/shared/extended-context-utils.ts b/src/shared/extended-context-utils.ts index 88b0486a..d8008462 100644 --- a/src/shared/extended-context-utils.ts +++ b/src/shared/extended-context-utils.ts @@ -4,6 +4,16 @@ /** Extended context suffix recognized by Claude Code. */ export const EXTENDED_CONTEXT_SUFFIX = '[1m]'; +export const ANTHROPIC_MODEL_ENV_KEYS = [ + 'ANTHROPIC_MODEL', + 'ANTHROPIC_DEFAULT_OPUS_MODEL', + 'ANTHROPIC_DEFAULT_SONNET_MODEL', + 'ANTHROPIC_DEFAULT_HAIKU_MODEL', +] as const; + +export type AnthropicModelEnvKey = (typeof ANTHROPIC_MODEL_ENV_KEYS)[number]; + +const ANTHROPIC_MODEL_ENV_KEY_SET = new Set(ANTHROPIC_MODEL_ENV_KEYS); /** Check if model is a native Gemini model (auto-enabled behavior). */ export function isNativeGeminiModel(modelId: string): boolean { @@ -27,3 +37,64 @@ export function stripExtendedContextSuffix(model: string): string { if (!model) return model; return hasExtendedContextSuffix(model) ? model.slice(0, -EXTENDED_CONTEXT_SUFFIX.length) : model; } + +/** True when key belongs to Anthropic model mapping state. */ +export function isAnthropicModelEnvKey(key: string): key is AnthropicModelEnvKey { + return ANTHROPIC_MODEL_ENV_KEY_SET.has(key); +} + +/** Strip transient config suffixes so model IDs can be checked against catalogs. */ +export function stripModelConfigurationSuffixes(modelId: string): string { + return stripExtendedContextSuffix(modelId.trim()).replace(/\([^)]+\)$/, ''); +} + +/** Whether any saved Anthropic model mapping explicitly requests [1m]. */ +export function hasAnthropicExtendedContextEnabled( + env: Partial> +): boolean { + return ANTHROPIC_MODEL_ENV_KEYS.some((key) => { + const value = env[key]; + return typeof value === 'string' && hasExtendedContextSuffix(value); + }); +} + +/** Apply or strip [1m] across Anthropic model mappings while honoring compatibility. */ +export function applyExtendedContextPreferenceToAnthropicModels< + T extends Record, +>( + env: T, + enabled: boolean, + options: { + supportsExtendedContext?: (modelId: string, key: AnthropicModelEnvKey) => boolean; + } = {} +): T { + const nextEnv: Record = { ...env }; + + for (const key of ANTHROPIC_MODEL_ENV_KEYS) { + const value = nextEnv[key]; + if (typeof value !== 'string' || value.trim().length === 0) { + continue; + } + + const modelId = stripModelConfigurationSuffixes(value); + const supported = options.supportsExtendedContext?.(modelId, key) ?? true; + nextEnv[key] = + enabled && supported ? applyExtendedContextSuffix(value) : stripExtendedContextSuffix(value); + } + + return nextEnv as T; +} + +/** Detect Claude model identifiers, regardless of transient [1m]/(thinking) suffixes. */ +export function isClaudeModelId(modelId: string): boolean { + return stripModelConfigurationSuffixes(modelId).toLowerCase().startsWith('claude-'); +} + +/** + * Conservative Claude long-context support heuristic for generic API profile flows. + * Haiku stays plain; Opus/Sonnet default to opt-in [1m]. + */ +export function likelySupportsClaudeExtendedContext(modelId: string): boolean { + const baseModel = stripModelConfigurationSuffixes(modelId).toLowerCase(); + return baseModel.startsWith('claude-') && !baseModel.startsWith('claude-haiku-'); +} diff --git a/src/web-server/routes/cliproxy-auth-routes.ts b/src/web-server/routes/cliproxy-auth-routes.ts index 9db43f01..471174c7 100644 --- a/src/web-server/routes/cliproxy-auth-routes.ts +++ b/src/web-server/routes/cliproxy-auth-routes.ts @@ -51,7 +51,7 @@ import { normalizeKiroAuthMethod, toKiroManagementMethod, } from '../../cliproxy/auth/auth-types'; -import { getOAuthFlowType } from '../../cliproxy/provider-capabilities'; +import { getOAuthFlowType, mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import type { CLIProxyProvider } from '../../cliproxy/types'; import { CLIPROXY_PROFILES } from '../../auth/profile-detector'; import { @@ -294,24 +294,11 @@ router.get('/', async (_req: Request, res: Response): Promise => { // Fetch CLIProxyAPI usage stats to determine active providers const stats = await fetchCliproxyStats(); - // Map CLIProxyAPI provider names to our internal provider names - const statsProviderMap: Record = { - gemini: 'gemini', - antigravity: 'agy', - codex: 'codex', - qwen: 'qwen', - iflow: 'iflow', - kiro: 'kiro', - copilot: 'ghcp', // CLIProxyAPI returns 'copilot', we map to 'ghcp' - anthropic: 'claude', // CLIProxyAPI returns 'anthropic', we map to 'claude' - claude: 'claude', - }; - // Update lastUsedAt for providers with recent activity if (stats?.requestsByProvider) { for (const [statsProvider, requestCount] of Object.entries(stats.requestsByProvider)) { if (requestCount > 0) { - const provider = statsProviderMap[statsProvider.toLowerCase()]; + const provider = mapExternalProviderName(statsProvider.toLowerCase()); if (provider) { // Touch the default account for this provider (or all accounts) const accounts = getProviderAccounts(provider); diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 6d60fd32..193080b4 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -625,7 +625,7 @@ router.delete('/:profile/presets/:name', (req: Request, res: Response): void => // ==================== Auth Tokens ==================== /** - * GET /api/settings/auth/antigravity-risk - Get AGY responsibility bypass setting + * GET /api/settings/auth/antigravity-risk - Get shared power user bypass setting */ router.get('/auth/antigravity-risk', (req: Request, res: Response): void => { if (!requireSensitiveLocalAccess(req, res)) return; @@ -636,12 +636,12 @@ router.get('/auth/antigravity-risk', (req: Request, res: Response): void => { antigravityAckBypass: config.cliproxy?.safety?.antigravity_ack_bypass === true, }); } catch (error) { - respondInternalError(res, error, 'Failed to load Antigravity power user mode.'); + respondInternalError(res, error, 'Failed to load power user mode.'); } }); /** - * PUT /api/settings/auth/antigravity-risk - Update AGY responsibility bypass setting + * PUT /api/settings/auth/antigravity-risk - Update shared power user bypass setting */ router.put('/auth/antigravity-risk', (req: Request, res: Response): void => { if (!requireSensitiveLocalAccess(req, res)) return; diff --git a/tests/unit/cliproxy/extended-context-config.test.ts b/tests/unit/cliproxy/extended-context-config.test.ts index 17e93a10..0de42ab0 100644 --- a/tests/unit/cliproxy/extended-context-config.test.ts +++ b/tests/unit/cliproxy/extended-context-config.test.ts @@ -136,6 +136,22 @@ describe('applyExtendedContextConfig', () => { expect(env.ANTHROPIC_MODEL).toBe('claude-opus-4-5-20251101[1m]'); }); + it('applies explicit Claude [1m] per tier and leaves unsupported Haiku plain', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-haiku-4-5-20251001', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }; + + applyExtendedContextConfig(env, 'claude', true); + + expect(env.ANTHROPIC_MODEL).toBe('claude-haiku-4-5-20251001'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-20251101[1m]'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-20250929[1m]'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('claude-haiku-4-5-20251001'); + }); + it('strips existing suffixes before catalog lookup', () => { const env: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'gemini-2.5-pro(high)', @@ -169,6 +185,20 @@ describe('applyExtendedContextConfig', () => { expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-6-thinking'); }); + it('strips stale Claude [1m] from unsupported mappings while preserving compatible ones', () => { + const env: NodeJS.ProcessEnv = { + ANTHROPIC_MODEL: 'claude-haiku-4-5-20251001[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-5-20251101[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-5-20250929[1m]', + }; + + applyExtendedContextConfig(env, 'claude', true); + + expect(env.ANTHROPIC_MODEL).toBe('claude-haiku-4-5-20251001'); + expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toBe('claude-opus-4-5-20251101[1m]'); + expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toBe('claude-sonnet-4-5-20250929[1m]'); + }); + it('strips [1m] suffix when --no-1m is explicit even if model has it', () => { const env: NodeJS.ProcessEnv = { ANTHROPIC_MODEL: 'gemini-2.5-pro[1m]', diff --git a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts index 76850fb5..e8cb4e46 100644 --- a/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts +++ b/tests/unit/cliproxy/quota-fetcher-gemini-cli.test.ts @@ -11,6 +11,8 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; import { getCapturedFetchRequests, mockFetch, restoreFetch } from '../../mocks'; describe('Gemini CLI Quota Fetcher', () => { + const GEMINI_QUOTA_URL = 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota'; + const GOOGLE_TOKEN_URL = 'https://oauth2.googleapis.com/token'; let tempHome: string; let originalCcsHome: string | undefined; let originalCcsDir: string | undefined; @@ -18,7 +20,9 @@ describe('Gemini CLI Quota Fetcher', () => { let originalGeminiClientSecret: string | undefined; let moduleVersion = 0; let buildGeminiCliBuckets: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').buildGeminiCliBuckets; + let fetchGeminiCliQuota: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').fetchGeminiCliQuota; let resolveGeminiCliProjectId: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').resolveGeminiCliProjectId; + let geminiTestExports: typeof import('../../../src/cliproxy/quota-fetcher-gemini-cli').__testExports; let refreshGeminiToken: typeof import('../../../src/cliproxy/auth/gemini-token-refresh').refreshGeminiToken; let getProviderAuthDir: typeof import('../../../src/cliproxy/config-generator').getProviderAuthDir; @@ -30,6 +34,26 @@ describe('Gemini CLI Quota Fetcher', () => { return tokenPath; } + function writeActiveGeminiAccount( + accountId: string, + overrides: Record = {} + ): string { + return writeGeminiToken({ + type: 'gemini', + email: accountId, + project_id: 'cloudaicompanion-test-123', + token: { + access_token: 'access-token', + refresh_token: 'refresh-token', + expiry: Date.now() + 60 * 60 * 1000, + client_id: 'test-client-id', + client_secret: 'test-client-secret', + token_uri: GOOGLE_TOKEN_URL, + }, + ...overrides, + }); + } + beforeEach(async () => { moduleVersion += 1; tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-gemini-refresh-')); @@ -46,7 +70,12 @@ describe('Gemini CLI Quota Fetcher', () => { const configGenerator = await import( `../../../src/cliproxy/config-generator?gemini-config-generator=${moduleVersion}` ); - ({ buildGeminiCliBuckets, resolveGeminiCliProjectId } = await import( + ({ + buildGeminiCliBuckets, + fetchGeminiCliQuota, + resolveGeminiCliProjectId, + __testExports: geminiTestExports, + } = await import( `../../../src/cliproxy/quota-fetcher-gemini-cli?gemini-quota-fetcher=${moduleVersion}` )); ({ refreshGeminiToken } = await import( @@ -265,6 +294,226 @@ describe('Gemini CLI Quota Fetcher', () => { }); }); + describe('fetchGeminiCliQuota failure metadata', () => { + it('maps 401 responses to reauth-required metadata', async () => { + writeActiveGeminiAccount('reauth@example.com'); + + mockFetch([ + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 401, + response: { + error: { + message: 'Session expired', + status: 'UNAUTHENTICATED', + }, + }, + }, + { + url: GOOGLE_TOKEN_URL, + method: 'POST', + status: 400, + response: { + error: 'invalid_grant', + }, + }, + ]); + + const result = await fetchGeminiCliQuota('reauth@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(401); + expect(result.errorCode).toBe('UNAUTHENTICATED'); + expect(result.needsReauth).toBe(true); + expect(result.retryable).toBe(false); + expect(result.actionHint).toContain('ccs gemini --auth'); + expect(result.error).toBe('Session expired'); + }); + + it('preserves 403 verification detail and exposes a helpful action hint', async () => { + writeActiveGeminiAccount('verify@example.com'); + + mockFetch([ + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 403, + response: { + error: { + message: 'Google requires you to verify this account before using Gemini CLI quota.', + status: 'PERMISSION_DENIED', + details: [ + { + reason: 'ACCOUNT_VERIFICATION_REQUIRED', + }, + ], + }, + }, + }, + ]); + + const result = await fetchGeminiCliQuota('verify@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(403); + expect(result.isForbidden).toBe(true); + expect(result.retryable).toBe(false); + expect(result.error).toContain('verify this account'); + expect(result.actionHint).toContain('verification'); + expect(result.errorDetail).toContain('ACCOUNT_VERIFICATION_REQUIRED'); + }); + + it('marks 429 responses as retryable', async () => { + writeActiveGeminiAccount('rate-limit@example.com'); + + mockFetch([ + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 429, + response: { + error: { + message: 'Too many quota requests', + status: 'RESOURCE_EXHAUSTED', + }, + }, + }, + ]); + + const result = await fetchGeminiCliQuota('rate-limit@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(429); + expect(result.retryable).toBe(true); + expect(result.errorCode).toBe('RESOURCE_EXHAUSTED'); + expect(result.actionHint).toContain('Retry'); + expect(result.error).toBe('Too many quota requests'); + }); + + it('preserves non-JSON upstream error text when Gemini returns a plain-text failure', async () => { + writeActiveGeminiAccount('plaintext@example.com'); + + mockFetch([ + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 418, + headers: { 'Content-Type': 'text/plain' }, + response: 'Internal Server Error', + }, + ]); + + const result = await fetchGeminiCliQuota('plaintext@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(418); + expect(result.errorCode).toBe('quota_request_failed'); + expect(result.retryable).toBe(false); + expect(result.error).toBe('Internal Server Error'); + expect(result.errorDetail).toBe('Internal Server Error'); + }); + + it('marks 5xx Gemini quota responses as retryable provider outages', async () => { + writeActiveGeminiAccount('outage@example.com'); + + mockFetch([ + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 503, + headers: { 'Content-Type': 'text/plain' }, + response: 'Service temporarily unavailable', + }, + ]); + + const result = await fetchGeminiCliQuota('outage@example.com'); + + expect(result.success).toBe(false); + expect(result.httpStatus).toBe(503); + expect(result.errorCode).toBe('provider_unavailable'); + expect(result.retryable).toBe(true); + expect(result.actionHint).toContain('temporary Google upstream problem'); + expect(result.error).toBe('Service temporarily unavailable'); + }); + + it('omits raw HTML upstream bodies from Gemini quota error detail', async () => { + writeActiveGeminiAccount('html@example.com'); + + mockFetch([ + { + url: GEMINI_QUOTA_URL, + method: 'POST', + status: 502, + headers: { 'Content-Type': 'text/html' }, + response: 'bad gateway', + }, + ]); + + const result = await fetchGeminiCliQuota('html@example.com'); + + expect(result.success).toBe(false); + expect(result.error).toBe('Gemini quota service unavailable (HTTP 502)'); + expect(result.errorDetail).toBe('[HTML error response omitted]'); + }); + }); + + describe('direct Gemini error helper coverage', () => { + it('sanitizes HTML and truncates oversized token-bearing error details', () => { + const longTokenBody = JSON.stringify({ + access_token: 'super-secret-token', + detail: `Bearer top-secret ${'x'.repeat(400)}`, + }); + + const sanitized = geminiTestExports.sanitizeGeminiCliErrorDetail(longTokenBody); + + expect(sanitized).toContain('[redacted]'); + expect(sanitized).toContain('Bearer [redacted]'); + expect(sanitized?.endsWith('...[truncated]')).toBe(true); + expect(sanitized?.length).toBeLessThanOrEqual(320); + expect(geminiTestExports.sanitizeGeminiCliErrorDetail('bad gateway')).toBe( + '[HTML error response omitted]' + ); + }); + + it('extracts nested messages and parses structured JSON error bodies', () => { + expect( + geminiTestExports.extractGeminiCliNestedMessage([ + { reason: 'ACCOUNT_VERIFICATION_REQUIRED' }, + ]) + ).toBe('ACCOUNT_VERIFICATION_REQUIRED'); + + const parsed = geminiTestExports.parseGeminiCliErrorBody( + JSON.stringify({ + error: { + message: 'Verification required', + status: 'PERMISSION_DENIED', + details: [{ reason: 'ACCOUNT_VERIFICATION_REQUIRED' }], + }, + }) + ); + + expect(parsed.message).toBe('Verification required'); + expect(parsed.errorCode).toBe('PERMISSION_DENIED'); + expect(parsed.errorDetail).toContain('ACCOUNT_VERIFICATION_REQUIRED'); + }); + + it('builds verification and project-specific forbidden action hints', () => { + expect( + geminiTestExports.buildGeminiCliForbiddenActionHint({ + message: 'Please verify this account', + errorDetail: 'ACCOUNT_VERIFICATION_REQUIRED', + }) + ).toContain('verification'); + + expect( + geminiTestExports.buildGeminiCliForbiddenActionHint({ + message: 'Project no longer has access', + }) + ).toContain('project'); + }); + }); + describe('refreshGeminiToken', () => { it('uses OAuth client metadata stored in the token file', async () => { writeGeminiToken({ diff --git a/tests/unit/cliproxy/stats-transformer.test.ts b/tests/unit/cliproxy/stats-transformer.test.ts new file mode 100644 index 00000000..a58482be --- /dev/null +++ b/tests/unit/cliproxy/stats-transformer.test.ts @@ -0,0 +1,291 @@ +import { describe, expect, it } from 'bun:test'; +import type { + CliproxyManagementAuthFile, + CliproxyRequestDetail, + CliproxyUsageApiResponse, +} from '../../../src/cliproxy/stats-fetcher'; +import { buildCliproxyStatsFromUsageResponse } from '../../../src/cliproxy/stats-transformer'; + +function createDetail(overrides: Partial = {}): CliproxyRequestDetail { + return { + timestamp: '2025-03-26T10:00:00.000Z', + source: 'shared@example.com', + auth_index: 'shared-auth-index', + tokens: { + input_tokens: 10, + output_tokens: 5, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 15, + }, + failed: false, + ...overrides, + }; +} + +function createInternallyBucketedUsage( + details: CliproxyRequestDetail[] +): CliproxyUsageApiResponse { + return { + usage: { + total_requests: details.length, + success_count: details.filter((detail) => !detail.failed).length, + failure_count: details.filter((detail) => detail.failed).length, + apis: { + 'ccs-internal-managed': { + total_requests: details.length, + models: { + 'gpt-5': { + total_requests: details.length, + details, + }, + }, + }, + }, + }, + }; +} + +describe('buildCliproxyStatsFromUsageResponse', () => { + it('keeps duplicate emails isolated by provider', () => { + const usage: CliproxyUsageApiResponse = { + usage: { + total_requests: 5, + apis: { + codex: { + total_requests: 3, + models: { + 'gpt-5': { + total_requests: 3, + details: [ + { + timestamp: '2026-03-26T10:00:00.000Z', + source: 'shared@example.com', + auth_index: 0, + tokens: { + input_tokens: 10, + output_tokens: 5, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 15, + }, + failed: false, + }, + { + timestamp: '2026-03-26T10:01:00.000Z', + source: 'shared@example.com', + auth_index: 0, + tokens: { + input_tokens: 12, + output_tokens: 7, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 19, + }, + failed: false, + }, + { + timestamp: '2026-03-26T10:02:00.000Z', + source: 'shared@example.com', + auth_index: 0, + tokens: { + input_tokens: 8, + output_tokens: 2, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 10, + }, + failed: true, + }, + ], + }, + }, + }, + gemini: { + total_requests: 2, + models: { + 'gemini-2.5-pro': { + total_requests: 2, + details: [ + { + timestamp: '2026-03-26T11:00:00.000Z', + source: 'shared@example.com', + auth_index: 0, + tokens: { + input_tokens: 20, + output_tokens: 10, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 30, + }, + failed: false, + }, + { + timestamp: '2026-03-26T11:01:00.000Z', + source: 'shared@example.com', + auth_index: 0, + tokens: { + input_tokens: 14, + output_tokens: 6, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 20, + }, + failed: true, + }, + ], + }, + }, + }, + }, + }, + }; + + const stats = buildCliproxyStatsFromUsageResponse(usage); + + expect(stats.accountStats['codex:shared@example.com']).toMatchObject({ + accountKey: 'codex:shared@example.com', + provider: 'codex', + source: 'shared@example.com', + successCount: 2, + failureCount: 1, + totalTokens: 44, + lastUsedAt: '2026-03-26T10:02:00.000Z', + }); + expect(stats.accountStats['gemini:shared@example.com']).toMatchObject({ + accountKey: 'gemini:shared@example.com', + provider: 'gemini', + source: 'shared@example.com', + successCount: 1, + failureCount: 1, + totalTokens: 50, + lastUsedAt: '2026-03-26T11:01:00.000Z', + }); + expect(stats.successCount).toBe(3); + expect(stats.failureCount).toBe(2); + expect(stats.requestsByProvider).toEqual({ codex: 3, gemini: 2 }); + }); + + it('resolves canonical providers from auth_index when usage is internally bucketed', () => { + const usage = createInternallyBucketedUsage([ + createDetail({ auth_index: 'codex-1' }), + createDetail({ + timestamp: '2025-03-26T10:01:00.000Z', + auth_index: 'gemini-1', + tokens: { + input_tokens: 12, + output_tokens: 7, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 19, + }, + }), + createDetail({ + timestamp: '2025-03-26T10:02:00.000Z', + auth_index: 'agy-1', + tokens: { + input_tokens: 8, + output_tokens: 2, + reasoning_tokens: 0, + cached_tokens: 0, + total_tokens: 10, + }, + failed: true, + }), + ]); + const authFiles: CliproxyManagementAuthFile[] = [ + { auth_index: 'codex-1', provider: 'codex', email: 'shared@example.com' }, + { auth_index: 'gemini-1', provider: 'gemini-cli', email: 'shared@example.com' }, + { auth_index: 'agy-1', provider: 'antigravity', email: 'shared@example.com' }, + ]; + + const stats = buildCliproxyStatsFromUsageResponse(usage, { authFiles }); + + expect(stats.accountStats['codex:shared@example.com']).toMatchObject({ + provider: 'codex', + successCount: 1, + failureCount: 0, + }); + expect(stats.accountStats['gemini:shared@example.com']).toMatchObject({ + provider: 'gemini', + successCount: 1, + failureCount: 0, + }); + expect(stats.accountStats['agy:shared@example.com']).toMatchObject({ + provider: 'agy', + successCount: 0, + failureCount: 1, + }); + expect(stats.requestsByProvider).toEqual({ codex: 1, gemini: 1, agy: 1 }); + }); + + it('falls back to the usage provider when auth_index lookup cannot resolve a provider', () => { + const usage = createInternallyBucketedUsage([createDetail({ auth_index: 'codex-1' })]); + const scenarios: Array<{ label: string; authFiles: CliproxyManagementAuthFile[] }> = [ + { label: 'empty authFiles', authFiles: [] }, + { + label: 'missing auth_index match', + authFiles: [{ auth_index: 'other-auth-index', provider: 'codex', email: 'shared@example.com' }], + }, + { + label: 'matching auth_index without provider metadata', + authFiles: [{ auth_index: 'codex-1', email: 'shared@example.com' }], + }, + ]; + + for (const scenario of scenarios) { + const stats = buildCliproxyStatsFromUsageResponse(usage, { authFiles: scenario.authFiles }); + + expect(stats.accountStats['ccs-internal-managed:shared@example.com'], scenario.label).toMatchObject({ + provider: 'ccs-internal-managed', + source: 'shared@example.com', + successCount: 1, + failureCount: 0, + }); + expect(stats.requestsByProvider, scenario.label).toEqual({ 'ccs-internal-managed': 1 }); + } + }); + + it('falls back to auth-file source metadata when detail source is blank and supports mixed auth_index outcomes', () => { + const usage = createInternallyBucketedUsage([ + createDetail({ source: ' ', auth_index: 'codex-1' }), + createDetail({ + timestamp: '2025-03-26T10:01:00.000Z', + source: 'unmatched@example.com', + auth_index: 'missing-auth-index', + }), + createDetail({ + timestamp: '2025-03-26T10:02:00.000Z', + source: ' ', + auth_index: 'providerless-auth-index', + failed: true, + }), + ]); + const authFiles: CliproxyManagementAuthFile[] = [ + { auth_index: 'codex-1', provider: 'codex', email: 'fallback@example.com' }, + { auth_index: 'providerless-auth-index', email: 'providerless-fallback@example.com' }, + ]; + + const stats = buildCliproxyStatsFromUsageResponse(usage, { authFiles }); + + expect(stats.accountStats['codex:fallback@example.com']).toMatchObject({ + provider: 'codex', + source: 'fallback@example.com', + successCount: 1, + failureCount: 0, + }); + expect(stats.accountStats['ccs-internal-managed:unmatched@example.com']).toMatchObject({ + provider: 'ccs-internal-managed', + source: 'unmatched@example.com', + successCount: 1, + failureCount: 0, + }); + expect(stats.accountStats['ccs-internal-managed:providerless-fallback@example.com']).toMatchObject({ + provider: 'ccs-internal-managed', + source: 'providerless-fallback@example.com', + successCount: 0, + failureCount: 1, + }); + expect(stats.requestsByProvider).toEqual({ codex: 1, 'ccs-internal-managed': 2 }); + }); +}); diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index 94032699..19727ec2 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from 'bun:test'; import { + applyClaudeExtendedContextPreference, collectUnexpectedApiArgs, + hasClaudeModelMapping, + hasExplicitClaudeExtendedContext, parseApiCommandArgs, } from '../../../src/commands/api-command/shared'; @@ -132,6 +135,27 @@ describe('api-command arg parser', () => { expect(parsed.model).toBe('-preview'); expect(parsed.errors).toEqual([]); }); + + test('parses --1m for explicit Claude long context', () => { + const parsed = parseApiCommandArgs(['my-api', '--1m']); + + expect(parsed.extendedContext).toBe(true); + expect(parsed.errors).toEqual([]); + }); + + test('parses --no-1m for explicit standard-context preference', () => { + const parsed = parseApiCommandArgs(['my-api', '--no-1m']); + + expect(parsed.extendedContext).toBe(false); + expect(parsed.errors).toEqual([]); + }); + + test('rejects conflicting --1m and --no-1m flags', () => { + const parsed = parseApiCommandArgs(['my-api', '--1m', '--no-1m']); + + expect(parsed.extendedContext).toBeUndefined(); + expect(parsed.errors).toEqual(['Cannot combine --1m and --no-1m']); + }); }); describe('collectUnexpectedApiArgs', () => { @@ -155,3 +179,55 @@ describe('collectUnexpectedApiArgs', () => { expect(parsed.errors).toEqual(['Unknown option: --bogus', 'Unexpected arguments: value']); }); }); + +describe('Claude long-context mapping helpers', () => { + test('detects Claude mappings and explicit [1m] state across all tiers', () => { + const models = { + default: 'gpt-5.4', + opus: 'claude-opus-4-6[1m]', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4-5-20251001', + }; + + expect(hasClaudeModelMapping(models)).toBe(true); + expect(hasExplicitClaudeExtendedContext(models)).toBe(true); + }); + + test('applies [1m] only to compatible Claude mappings and leaves Haiku plain', () => { + const models = applyClaudeExtendedContextPreference( + { + default: 'claude-sonnet-4-6', + opus: 'claude-opus-4-6', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4-5-20251001', + }, + true + ); + + expect(models).toEqual({ + default: 'claude-sonnet-4-6[1m]', + opus: 'claude-opus-4-6[1m]', + sonnet: 'claude-sonnet-4-6[1m]', + haiku: 'claude-haiku-4-5-20251001', + }); + }); + + test('strips [1m] from Claude mappings when standard context is requested', () => { + const models = applyClaudeExtendedContextPreference( + { + default: 'claude-sonnet-4-6[1m]', + opus: 'claude-opus-4-6[1m]', + sonnet: 'claude-sonnet-4-6[1m]', + haiku: 'claude-haiku-4-5-20251001', + }, + false + ); + + expect(models).toEqual({ + default: 'claude-sonnet-4-6', + opus: 'claude-opus-4-6', + sonnet: 'claude-sonnet-4-6', + haiku: 'claude-haiku-4-5-20251001', + }); + }); +}); diff --git a/tests/unit/commands/api-command-router.test.ts b/tests/unit/commands/api-command-router.test.ts index 25d33812..54176bea 100644 --- a/tests/unit/commands/api-command-router.test.ts +++ b/tests/unit/commands/api-command-router.test.ts @@ -1,74 +1,48 @@ -import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { beforeEach, describe, expect, it } from 'bun:test'; + +import { createApiCommandHandler } from '../../../src/commands/api-command/handler'; let calls: string[] = []; beforeEach(() => { calls = []; +}); - mock.module('../../../src/commands/api-command/help', () => ({ - showApiCommandHelp: async () => { +function buildHandleApiCommand() { + return createApiCommandHandler({ + help: async () => { calls.push('help'); }, - showUnknownApiCommand: async (command: string) => { + unknown: async (command: string) => { calls.push(`unknown:${command}`); }, - })); - - mock.module('../../../src/commands/api-command/create-command', () => ({ - handleApiCreateCommand: async (args: string[]) => { + create: async (args: string[]) => { calls.push(`create:${args.join(' ')}`); }, - })); - - mock.module('../../../src/commands/api-command/list-command', () => ({ - handleApiListCommand: async (args: string[]) => { + list: async (args: string[]) => { calls.push(`list:${args.join(' ')}`); }, - })); - - mock.module('../../../src/commands/api-command/remove-command', () => ({ - handleApiRemoveCommand: async (args: string[]) => { - calls.push(`remove:${args.join(' ')}`); - }, - })); - - mock.module('../../../src/commands/api-command/discover-command', () => ({ - handleApiDiscoverCommand: async (args: string[]) => { + discover: async (args: string[]) => { calls.push(`discover:${args.join(' ')}`); }, - })); - - mock.module('../../../src/commands/api-command/copy-command', () => ({ - handleApiCopyCommand: async (args: string[]) => { + copy: async (args: string[]) => { calls.push(`copy:${args.join(' ')}`); }, - })); - - mock.module('../../../src/commands/api-command/export-command', () => ({ - handleApiExportCommand: async (args: string[]) => { + export: async (args: string[]) => { calls.push(`export:${args.join(' ')}`); }, - })); - - mock.module('../../../src/commands/api-command/import-command', () => ({ - handleApiImportCommand: async (args: string[]) => { + import: async (args: string[]) => { calls.push(`import:${args.join(' ')}`); }, - })); -}); - -afterEach(() => { - mock.restore(); -}); - -async function loadHandleApiCommand() { - const mod = await import(`../../../src/commands/api-command?test=${Date.now()}-${Math.random()}`); - return mod.handleApiCommand; + remove: async (args: string[]) => { + calls.push(`remove:${args.join(' ')}`); + }, + }); } describe('api-command router', () => { it('defaults to help when no subcommand is provided', async () => { - const handleApiCommand = await loadHandleApiCommand(); + const handleApiCommand = buildHandleApiCommand(); await handleApiCommand([]); @@ -76,7 +50,7 @@ describe('api-command router', () => { }); it('routes remove aliases through the named command dispatcher', async () => { - const handleApiCommand = await loadHandleApiCommand(); + const handleApiCommand = buildHandleApiCommand(); await handleApiCommand(['rm', 'profile-a']); @@ -84,7 +58,7 @@ describe('api-command router', () => { }); it('forwards list arguments to the handler for validation', async () => { - const handleApiCommand = await loadHandleApiCommand(); + const handleApiCommand = buildHandleApiCommand(); await handleApiCommand(['list', 'unexpected']); @@ -92,7 +66,7 @@ describe('api-command router', () => { }); it('routes hardened subcommands through their handlers', async () => { - const handleApiCommand = await loadHandleApiCommand(); + const handleApiCommand = buildHandleApiCommand(); await handleApiCommand(['discover', '--json']); await handleApiCommand(['copy', 'source', 'dest']); @@ -108,7 +82,7 @@ describe('api-command router', () => { }); it('delegates unknown commands to the shared unknown handler', async () => { - const handleApiCommand = await loadHandleApiCommand(); + const handleApiCommand = buildHandleApiCommand(); await handleApiCommand(['bogus']); diff --git a/tests/unit/commands/cliproxy-quota-subcommand.test.ts b/tests/unit/commands/cliproxy-quota-subcommand.test.ts new file mode 100644 index 00000000..0b49cfef --- /dev/null +++ b/tests/unit/commands/cliproxy-quota-subcommand.test.ts @@ -0,0 +1,78 @@ +import { describe, expect, it } from 'bun:test'; + +async function loadQuotaCommandTestExports() { + const moduleId = Date.now() + Math.random(); + const mod = await import( + `../../../src/commands/cliproxy/quota-subcommand?cliproxy-quota-subcommand=${moduleId}` + ); + return mod.__testExports; +} + +describe('cliproxy quota subcommand failure formatting', () => { + it('builds Gemini failure lines with the remediation hint, code, and detail', async () => { + const { getQuotaFailureDisplayEntries } = await loadQuotaCommandTestExports(); + + const entries = getQuotaFailureDisplayEntries({ + error: 'Google requires you to verify this account before using Gemini CLI quota.', + actionHint: + 'Complete the Google account verification mentioned above, then retry quota refresh.', + httpStatus: 403, + errorCode: 'PERMISSION_DENIED', + errorDetail: 'ACCOUNT_VERIFICATION_REQUIRED', + retryable: false, + }); + + expect(entries).toEqual([ + { + tone: 'error', + text: 'Google requires you to verify this account before using Gemini CLI quota.', + }, + { + tone: 'info', + text: 'Complete the Google account verification mentioned above, then retry quota refresh.', + }, + { + tone: 'dim', + text: 'HTTP 403 | Code: PERMISSION_DENIED', + }, + { + tone: 'dim', + text: 'Detail: ACCOUNT_VERIFICATION_REQUIRED', + }, + ]); + }); + + it('marks retryable failures in the CLI diagnostics line', async () => { + const { getQuotaFailureDisplayEntries } = await loadQuotaCommandTestExports(); + + const entries = getQuotaFailureDisplayEntries({ + error: 'Gemini quota service unavailable (HTTP 503)', + actionHint: 'Retry later. This looks like a temporary Google upstream problem.', + httpStatus: 503, + errorCode: 'provider_unavailable', + errorDetail: 'Service temporarily unavailable', + retryable: true, + }); + + expect(entries[2]).toEqual({ + tone: 'dim', + text: 'HTTP 503 | Code: provider_unavailable | Retryable', + }); + }); + + it('suppresses duplicate error detail lines', async () => { + const { getQuotaFailureDisplayEntries } = await loadQuotaCommandTestExports(); + + const entries = getQuotaFailureDisplayEntries({ + error: 'Internal Server Error', + errorDetail: 'Internal Server Error', + }); + + expect(entries).toEqual([ + { + tone: 'error', + text: 'Internal Server Error', + }, + ]); + }); +}); diff --git a/tests/unit/commands/config-auth-command.test.ts b/tests/unit/commands/config-auth-command.test.ts index 3f3182a7..5674b652 100644 --- a/tests/unit/commands/config-auth-command.test.ts +++ b/tests/unit/commands/config-auth-command.test.ts @@ -15,20 +15,6 @@ beforeEach(() => { logLines.push(args.map(String).join(' ')); }; - const uiModule = { - initUI: async () => {}, - header: (message: string) => message, - subheader: (message: string) => message, - color: (message: string) => message, - dim: (message: string) => message, - ok: (message: string) => message, - info: (message: string) => message, - warn: (message: string) => message, - fail: (message: string) => message, - }; - mock.module('../../../src/utils/ui', () => uiModule); - mock.module('../../../src/utils/ui.ts', () => uiModule); - mock.module('../../../src/commands/config-auth/setup-command', () => ({ handleSetup: async () => { calls.push('setup'); @@ -55,9 +41,7 @@ afterEach(() => { }); async function loadHandleConfigAuthCommand() { - const mod = await import( - `../../../src/commands/config-auth?test=${Date.now()}-${Math.random()}` - ); + const mod = await import(`../../../src/commands/config-auth?test=${Date.now()}-${Math.random()}`); return mod.handleConfigAuthCommand; } diff --git a/tests/unit/commands/help-command-parity.test.ts b/tests/unit/commands/help-command-parity.test.ts index e7afd2ce..512bb5f8 100644 --- a/tests/unit/commands/help-command-parity.test.ts +++ b/tests/unit/commands/help-command-parity.test.ts @@ -1,5 +1,6 @@ -import { afterEach, describe, expect, test } from 'bun:test'; +import { describe, expect, test } from 'bun:test'; +import { showApiCommandHelp } from '../../../src/commands/api-command/help'; import { handleHelpCommand } from '../../../src/commands/help-command'; function stripAnsi(input: string): string { @@ -7,19 +8,9 @@ function stripAnsi(input: string): string { } describe('help command parity', () => { - const originalLog = console.log; - - afterEach(() => { - console.log = originalLog; - }); - test('root help documents cliproxy provider filter under quota command', async () => { const lines: string[] = []; - console.log = (...args: unknown[]) => { - lines.push(args.map((arg) => String(arg)).join(' ')); - }; - - await handleHelpCommand(); + await handleHelpCommand((line) => lines.push(line)); const rendered = stripAnsi(lines.join('\n')); expect(rendered.includes('ccs cliproxy status [provider]')).toBe(false); @@ -30,11 +21,7 @@ describe('help command parity', () => { test('root help documents llama.cpp as a local API profile', async () => { const lines: string[] = []; - console.log = (...args: unknown[]) => { - lines.push(args.map((arg) => String(arg)).join(' ')); - }; - - await handleHelpCommand(); + await handleHelpCommand((line) => lines.push(line)); const rendered = stripAnsi(lines.join('\n')); expect(rendered.includes('ccs llamacpp')).toBe(true); @@ -43,11 +30,7 @@ describe('help command parity', () => { test('root help no longer markets glmt as a supported profile', async () => { const lines: string[] = []; - console.log = (...args: unknown[]) => { - lines.push(args.map((arg) => String(arg)).join(' ')); - }; - - await handleHelpCommand(); + await handleHelpCommand((line) => lines.push(line)); const rendered = stripAnsi(lines.join('\n')); expect(rendered.includes('ccs glmt')).toBe(false); @@ -56,11 +39,7 @@ describe('help command parity', () => { test('root help documents Claude IDE extension setup surfaces', async () => { const lines: string[] = []; - console.log = (...args: unknown[]) => { - lines.push(args.map((arg) => String(arg)).join(' ')); - }; - - await handleHelpCommand(); + await handleHelpCommand((line) => lines.push(line)); const rendered = stripAnsi(lines.join('\n')); expect(rendered.includes('Claude IDE Extension setup page')).toBe(true); @@ -74,11 +53,7 @@ describe('help command parity', () => { test('root help documents dashboard host binding example', async () => { const lines: string[] = []; - console.log = (...args: unknown[]) => { - lines.push(args.map((arg) => String(arg)).join(' ')); - }; - - await handleHelpCommand(); + await handleHelpCommand((line) => lines.push(line)); const rendered = stripAnsi(lines.join('\n')); expect(rendered.includes('ccs config --host 0.0.0.0')).toBe(true); @@ -87,19 +62,44 @@ describe('help command parity', () => { test('root help documents official channels native-only scope and process-env tokens', async () => { const lines: string[] = []; - console.log = (...args: unknown[]) => { - lines.push(args.map((arg) => String(arg)).join(' ')); - }; - - await handleHelpCommand(); + await handleHelpCommand((line) => lines.push(line)); const rendered = stripAnsi(lines.join('\n')); expect(rendered.includes('Dashboard -> Settings -> Channels (fastest path)')).toBe(true); expect( - rendered.includes('Fastest path: turn on the channel, save the token if needed, then run ccs.') + rendered.includes( + 'Fastest path: turn on the channel, save the token if needed, then run ccs.' + ) ).toBe(true); expect(rendered.includes('Not supported for ccs glm')).toBe(true); - expect(rendered.includes('Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work')).toBe( + expect( + rendered.includes('Current-process TELEGRAM_BOT_TOKEN / DISCORD_BOT_TOKEN also work') + ).toBe(true); + }); + + test('root help explains Claude [1m] as an explicit CCS suffix with upstream limits', async () => { + const lines: string[] = []; + await handleHelpCommand((line) => lines.push(line)); + + const rendered = stripAnsi(lines.join('\n')); + expect( + rendered.includes('Claude models: plain by default, opt-in with --1m or saved [1m]') + ).toBe(true); + expect(rendered.includes('CCS only controls the saved [1m] suffix.')).toBe(true); + expect(rendered.includes('return 429 extra-usage errors for long-context requests')).toBe(true); + }); + + test('api help documents create-time Claude [1m] flags and entitlement warning', async () => { + const lines: string[] = []; + await showApiCommandHelp((line) => lines.push(line)); + + const rendered = stripAnsi(lines.join('\n')); + expect(rendered.includes('--1m / --no-1m')).toBe(true); + expect(rendered.includes('ccs api create --preset anthropic --1m')).toBe(true); + expect(rendered.includes('Plain Claude model IDs stay on standard context by default.')).toBe( + true + ); + expect(rendered.includes('some accounts can still return 429 for long-context requests')).toBe( true ); }); diff --git a/ui/src/components/account/add-account-dialog.tsx b/ui/src/components/account/add-account-dialog.tsx index 6ec2d361..49bb0448 100644 --- a/ui/src/components/account/add-account-dialog.tsx +++ b/ui/src/components/account/add-account-dialog.tsx @@ -60,6 +60,11 @@ function normalizeRiskPhrase(value: string): string { return value.trim().replace(/\s+/g, ' ').toUpperCase(); } +interface PowerUserModeSyncOptions { + pendingMessage?: string | null; + disabledMessage?: string | null; +} + export function AddAccountDialog({ open, onClose, @@ -73,18 +78,21 @@ export function AddAccountDialog({ const [localError, setLocalError] = useState(null); const [riskAcknowledgementText, setRiskAcknowledgementText] = useState(''); const [agyRiskChecklist, setAgyRiskChecklist] = useState(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST); - const [agyAckBypassEnabled, setAgyAckBypassEnabled] = useState(false); - const [agyAckBypassLoading, setAgyAckBypassLoading] = useState(false); + const [powerUserModeEnabled, setPowerUserModeEnabled] = useState(false); + const [powerUserModeLoading, setPowerUserModeLoading] = useState(false); const [kiroAuthMethod, setKiroAuthMethod] = useState(DEFAULT_KIRO_AUTH_METHOD); const { t } = useTranslation(); const wasAuthenticatingRef = useRef(false); + const powerUserModeRequestIdRef = useRef(0); + const powerUserModeLoadErrorShownRef = useRef(false); const authFlow = useCliproxyAuthFlow(); const kiroImportMutation = useKiroImport(); const isKiro = provider === 'kiro'; - const requiresSafetyAcknowledgement = provider === 'gemini'; - const requiresAgyResponsibilityFlow = provider === 'agy' && !agyAckBypassEnabled; - const isAgyBypassStatePending = provider === 'agy' && agyAckBypassLoading; + const supportsPowerUserMode = provider === 'agy' || provider === 'gemini'; + const requiresGeminiSafetyAcknowledgement = provider === 'gemini' && !powerUserModeEnabled; + const requiresAgyResponsibilityFlow = provider === 'agy' && !powerUserModeEnabled; + const isPowerUserModePending = supportsPowerUserMode && powerUserModeLoading; const isAgyRiskChecklistComplete = isAntigravityRiskChecklistComplete(agyRiskChecklist); const isGeminiRiskAcknowledged = normalizeRiskPhrase(riskAcknowledgementText) === RISK_ACK_PHRASE; const defaultDeviceCode = isDeviceCodeProvider(provider); @@ -94,15 +102,62 @@ export function AddAccountDialog({ const nicknameTrimmed = nickname.trim(); const errorMessage = localError || authFlow.error; - const fetchAgyBypassState = useCallback(async (): Promise => { + const fetchPowerUserModeState = useCallback(async (): Promise => { const response = await fetch('/api/settings/auth/antigravity-risk'); if (!response.ok) { - throw new Error('Failed to load Antigravity power user setting'); + throw new Error('Failed to load power user mode setting'); } const data = (await response.json()) as { antigravityAckBypass?: boolean }; return data.antigravityAckBypass === true; }, []); + const syncPowerUserModeState = useCallback( + async ({ pendingMessage = null, disabledMessage = null }: PowerUserModeSyncOptions = {}) => { + const requestId = ++powerUserModeRequestIdRef.current; + setPowerUserModeLoading(true); + + if (pendingMessage !== null) { + setLocalError(pendingMessage); + } + + try { + const enabled = await fetchPowerUserModeState(); + if (powerUserModeRequestIdRef.current !== requestId) { + return enabled; + } + + setPowerUserModeEnabled(enabled); + + if (disabledMessage) { + setLocalError(enabled ? null : disabledMessage); + } else if (pendingMessage !== null) { + setLocalError(null); + } + + return enabled; + } catch { + if (powerUserModeRequestIdRef.current !== requestId) { + return false; + } + + setPowerUserModeEnabled(false); + setLocalError(disabledMessage ?? t('addAccountDialog.powerUserLoadFailed')); + + if (!powerUserModeLoadErrorShownRef.current) { + powerUserModeLoadErrorShownRef.current = true; + toast.error(t('addAccountDialog.powerUserLoadFailed')); + } + + return false; + } finally { + if (powerUserModeRequestIdRef.current === requestId) { + setPowerUserModeLoading(false); + } + } + }, + [fetchPowerUserModeState, t] + ); + const resetAndClose = () => { setNickname(''); setCallbackUrl(''); @@ -110,9 +165,11 @@ export function AddAccountDialog({ setLocalError(null); setRiskAcknowledgementText(''); setAgyRiskChecklist(DEFAULT_ANTIGRAVITY_RISK_CHECKLIST); - setAgyAckBypassEnabled(false); - setAgyAckBypassLoading(false); + setPowerUserModeEnabled(false); + setPowerUserModeLoading(false); setKiroAuthMethod(DEFAULT_KIRO_AUTH_METHOD); + powerUserModeRequestIdRef.current += 1; + powerUserModeLoadErrorShownRef.current = false; wasAuthenticatingRef.current = false; onClose(); }; @@ -126,41 +183,24 @@ export function AddAccountDialog({ }, [provider, open]); useEffect(() => { - let cancelled = false; + return () => { + powerUserModeRequestIdRef.current += 1; + }; + }, []); - if (!open || provider !== 'agy') { - setAgyAckBypassEnabled(false); - setAgyAckBypassLoading(false); + useEffect(() => { + if (!open || !supportsPowerUserMode) { + powerUserModeRequestIdRef.current += 1; + setPowerUserModeEnabled(false); + setPowerUserModeLoading(false); return; } - const loadAgyBypassState = async () => { - try { - setAgyAckBypassLoading(true); - const enabled = await fetchAgyBypassState(); - if (!cancelled) { - setAgyAckBypassEnabled(enabled); - } - } catch { - if (!cancelled) { - setAgyAckBypassEnabled(false); - } - } finally { - if (!cancelled) { - setAgyAckBypassLoading(false); - } - } - }; - - loadAgyBypassState(); - - return () => { - cancelled = true; - }; - }, [fetchAgyBypassState, open, provider]); + void syncPowerUserModeState(); + }, [open, provider, supportsPowerUserMode, syncPowerUserModeState]); useEffect(() => { - if (!open || provider !== 'agy' || !authFlow.error || !agyAckBypassEnabled) { + if (!open || provider !== 'agy' || !authFlow.error || !powerUserModeEnabled) { return; } @@ -171,34 +211,11 @@ export function AddAccountDialog({ normalizedError.includes('responsibility checklist'); if (!ackRequired) return; - let cancelled = false; - - const syncBypassState = async () => { - try { - setAgyAckBypassLoading(true); - const enabled = await fetchAgyBypassState(); - if (cancelled) return; - setAgyAckBypassEnabled(enabled); - if (!enabled) { - setLocalError('Power user mode is off. Complete the AGY checklist and retry.'); - } - } catch { - if (cancelled) return; - setAgyAckBypassEnabled(false); - setLocalError('Power user mode is off. Complete the AGY checklist and retry.'); - } finally { - if (!cancelled) { - setAgyAckBypassLoading(false); - } - } - }; - - void syncBypassState(); - - return () => { - cancelled = true; - }; - }, [agyAckBypassEnabled, authFlow.error, fetchAgyBypassState, open, provider]); + void syncPowerUserModeState({ + pendingMessage: t('addAccountDialog.powerUserLoading'), + disabledMessage: t('addAccountDialog.powerUserUnavailableRetry'), + }); + }, [authFlow.error, open, powerUserModeEnabled, provider, syncPowerUserModeState, t]); // When authFlow completes successfully (polling detected success), apply preset and close useEffect(() => { @@ -248,8 +265,8 @@ export function AddAccountDialog({ * - Authorization code providers use /start-url and polling. */ const handleAuthenticate = () => { - if (isAgyBypassStatePending) { - setLocalError('Loading Antigravity safety settings. Please wait a moment and retry.'); + if (isPowerUserModePending) { + setLocalError(t('addAccountDialog.powerUserLoading')); return; } if (requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) { @@ -258,7 +275,7 @@ export function AddAccountDialog({ ); return; } - if (requiresSafetyAcknowledgement && !isGeminiRiskAcknowledged) { + if (requiresGeminiSafetyAcknowledgement && !isGeminiRiskAcknowledged) { setLocalError( `Type "${RISK_ACK_PHRASE}" to acknowledge the account safety warning before authenticating this provider.` ); @@ -336,7 +353,7 @@ export function AddAccountDialog({ /> )} - {provider === 'agy' && agyAckBypassEnabled && !showAuthUI && ( + {supportsPowerUserMode && powerUserModeEnabled && !showAuthUI && (

@@ -346,7 +363,7 @@ export function AddAccountDialog({
)} - {requiresSafetyAcknowledgement && !showAuthUI && ( + {requiresGeminiSafetyAcknowledgement && !showAuthUI && ( diff --git a/ui/src/components/cliproxy/extended-context-toggle.tsx b/ui/src/components/cliproxy/extended-context-toggle.tsx index a5ec5f22..435d3d90 100644 --- a/ui/src/components/cliproxy/extended-context-toggle.tsx +++ b/ui/src/components/cliproxy/extended-context-toggle.tsx @@ -1,7 +1,6 @@ /** * Extended Context Toggle Component - * Shows toggle for models that support 1M token context window. - * Only visible when selected model has extendedContext: true. + * Shows toggle when any selected mapping supports 1M token context. */ import { Zap, Info } from 'lucide-react'; @@ -12,8 +11,8 @@ import { isNativeGeminiModel } from '@/lib/extended-context-utils'; import type { ModelEntry } from './provider-model-selector'; interface ExtendedContextToggleProps { - /** Currently selected model */ - model: ModelEntry | undefined; + /** Compatible selected models */ + models: ModelEntry[]; /** Provider name for display */ provider: string; /** Whether extended context is enabled */ @@ -27,19 +26,28 @@ interface ExtendedContextToggleProps { } export function ExtendedContextToggle({ - model, - provider, + models, + provider: _provider, enabled, onToggle, disabled, className, }: ExtendedContextToggleProps) { - // Only show if model supports extended context - if (!model?.extendedContext) { + if (models.length === 0) { return null; } - const isAutoEnabled = isNativeGeminiModel(model.id); + const hasGeminiModels = models.some((model) => isNativeGeminiModel(model.id)); + const hasClaudeModels = models.some((model) => !isNativeGeminiModel(model.id)); + + let behaviorHint = 'Compatible mappings stay on standard context unless you turn this on.'; + if (hasGeminiModels && hasClaudeModels) { + behaviorHint = + 'Gemini-compatible mappings can use 1M automatically. Claude mappings stay plain unless you turn this on.'; + } else if (hasGeminiModels) { + behaviorHint = + 'Gemini-compatible mappings can use 1M by default. Turn this off to keep standard context.'; + } return (
-

Enables 1M token context window instead of default 200K.

-

- {isAutoEnabled ? ( - Auto-enabled for {provider} Gemini models - ) : ( - Opt-in for {provider} Claude models via --1m flag - )} +

Applies the explicit [1m] long-context suffix to compatible saved mappings.

+

{behaviorHint}

+

+ CCS only saves [1m]. Provider pricing and entitlement are separate, and + some accounts can still return 429 extra-usage errors for long-context requests.

- {enabled && ( -

- Note: 2x input pricing applies for tokens beyond 200K -

- )}
diff --git a/ui/src/components/cliproxy/overview/credential-health-list.tsx b/ui/src/components/cliproxy/overview/credential-health-list.tsx index 4e484036..08d7f9b3 100644 --- a/ui/src/components/cliproxy/overview/credential-health-list.tsx +++ b/ui/src/components/cliproxy/overview/credential-health-list.tsx @@ -9,6 +9,7 @@ import { Badge } from '@/components/ui/badge'; import { CheckCircle2, AlertCircle, XCircle, MinusCircle, RefreshCw, Clock } from 'lucide-react'; import { useCliproxyAuth } from '@/hooks/use-cliproxy'; import { useCliproxyStats } from '@/hooks/use-cliproxy-stats'; +import { getAccountStats } from '@/lib/cliproxy-account-stats'; import { cn } from '@/lib/utils'; import { usePrivacy, PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { useTranslation } from 'react-i18next'; @@ -155,8 +156,7 @@ export function CredentialHealthList() { const credentials = authData?.authStatus.flatMap((status) => (status.accounts ?? []).map((account) => { - const accountKey = account.email || account.id; - const runtimeLastUsed = stats?.accountStats?.[accountKey]?.lastUsedAt; + const runtimeLastUsed = getAccountStats(stats, account)?.lastUsedAt; return { name: account.id, provider: status.provider, diff --git a/ui/src/components/cliproxy/provider-editor/account-item.tsx b/ui/src/components/cliproxy/provider-editor/account-item.tsx index 0c3520cc..85ae03c5 100644 --- a/ui/src/components/cliproxy/provider-editor/account-item.tsx +++ b/ui/src/components/cliproxy/provider-editor/account-item.tsx @@ -40,6 +40,7 @@ import { isClaudeQuotaResult, isCodexQuotaResult, } from '@/lib/utils'; +import { getAccountStats } from '@/lib/cliproxy-account-stats'; import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context'; import { useAccountQuota, useCliproxyStats } from '@/hooks/use-cliproxy-stats'; import { QuotaTooltipContent } from '@/components/shared/quota-tooltip-content'; @@ -124,7 +125,7 @@ export function AccountItem({ ); // Get last used time from runtime stats (more accurate than file) - const runtimeLastUsed = stats?.accountStats?.[account.email || account.id]?.lastUsedAt; + const runtimeLastUsed = getAccountStats(stats, account)?.lastUsedAt; const wasRecentlyUsed = isRecentlyUsed(runtimeLastUsed); // Use shared utility functions for provider-specific quota handling diff --git a/ui/src/components/cliproxy/provider-editor/index.tsx b/ui/src/components/cliproxy/provider-editor/index.tsx index 502b8583..55d858f0 100644 --- a/ui/src/components/cliproxy/provider-editor/index.tsx +++ b/ui/src/components/cliproxy/provider-editor/index.tsx @@ -234,8 +234,8 @@ export function ProviderEditor({ Loading settings... ) : ( -
-
+
+
@@ -303,7 +303,7 @@ export function ProviderEditor({
-
+
diff --git a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx index d22a9632..d93df27a 100644 --- a/ui/src/components/cliproxy/provider-editor/model-config-section.tsx +++ b/ui/src/components/cliproxy/provider-editor/model-config-section.tsx @@ -11,6 +11,7 @@ import { Sparkles, Zap, Star, X, Plus } from 'lucide-react'; import { FlexibleModelSelector } from '../provider-model-selector'; import { ExtendedContextToggle } from '../extended-context-toggle'; import { stripExtendedContextSuffix } from '@/lib/extended-context-utils'; +import { findCatalogModel } from '@/lib/model-catalogs'; import type { ModelConfigSectionProps } from './types'; type CatalogPresetModel = NonNullable['models'][number]; @@ -48,13 +49,18 @@ export function ModelConfigSection({ onDeletePreset, isDeletePending, }: ModelConfigSectionProps) { - // Find current model entry to check for extended context support - // Strip [1m] suffix when looking up in catalog since catalog IDs don't have suffix - const currentModelEntry = useMemo(() => { - if (!catalog || !currentModel) return undefined; - const baseModelId = stripExtendedContextSuffix(currentModel); - return catalog.models.find((m) => m.id === baseModelId); - }, [catalog, currentModel]); + const extendedContextModels = useMemo(() => { + if (!catalog) return []; + + const selectedModels = [currentModel, opusModel, sonnetModel, haikuModel] + .filter((modelId): modelId is string => Boolean(modelId)) + .map((modelId) => stripExtendedContextSuffix(modelId)); + + const uniqueIds = [...new Set(selectedModels)]; + return uniqueIds + .map((modelId) => findCatalogModel(catalog.provider, modelId)) + .filter((model): model is NonNullable => Boolean(model?.extendedContext)); + }, [catalog, currentModel, opusModel, sonnetModel, haikuModel]); const presetGroups = useMemo(() => { const presetModels = (catalog?.models ?? []).filter((model) => model.presetMapping); @@ -199,10 +205,10 @@ export function ModelConfigSection({ catalog={catalog} allModels={providerModels} /> - {/* Extended Context Toggle - only shows for models that support it */} - {currentModelEntry?.extendedContext && onExtendedContextToggle && ( + {/* Extended Context Toggle - shows when any saved mapping supports it */} + {extendedContextModels.length > 0 && onExtendedContextToggle && ( } > -
+
{!isRawJsonValid && rawJsonEdits !== null && (
@@ -56,13 +56,14 @@ export function RawEditorSection({
)} -
+
diff --git a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts index 15861210..b2073d18 100644 --- a/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts +++ b/ui/src/components/cliproxy/provider-editor/use-provider-editor.ts @@ -8,13 +8,11 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; import type { SettingsResponse, UseProviderEditorReturn } from './types'; import { - applyExtendedContextSuffix, - stripExtendedContextSuffix, - hasExtendedContextSuffix, + applyExtendedContextPreferenceToAnthropicModels, + hasAnthropicExtendedContextEnabled, + isAnthropicModelEnvKey, } from '@/lib/extended-context-utils'; - -/** Model env keys that should have [1m] suffix applied */ -const MODEL_ENV_KEYS = ['ANTHROPIC_MODEL'] as const; +import { supportsExtendedContext } from '@/lib/model-catalogs'; /** Required env vars for CLIProxy providers (informational only - runtime fills defaults) */ const REQUIRED_ENV_KEYS = ['ANTHROPIC_BASE_URL', 'ANTHROPIC_AUTH_TOKEN'] as const; @@ -78,56 +76,59 @@ export function useProviderEditor(provider: string): UseProviderEditorReturn { // Extended context is enabled if any model has [1m] suffix const extendedContextEnabled = useMemo(() => { - const env = currentSettings?.env || {}; - return MODEL_ENV_KEYS.some((key) => { - const value = env[key]; - return value && hasExtendedContextSuffix(value); - }); + return hasAnthropicExtendedContextEnabled(currentSettings?.env || {}); }, [currentSettings]); + const applySavedLongContextIntent = useCallback( + (env: Record, enabled: boolean) => + applyExtendedContextPreferenceToAnthropicModels(env, enabled, { + supportsExtendedContext: (modelId) => supportsExtendedContext(provider, modelId), + }), + [provider] + ); + // Update a single setting value const updateEnvValue = useCallback( (key: string, value: string) => { const newEnv = { ...(currentSettings?.env || {}), [key]: value }; - const newSettings = { ...currentSettings, env: newEnv }; + const envWithIntent = isAnthropicModelEnvKey(key) + ? applySavedLongContextIntent(newEnv, extendedContextEnabled) + : newEnv; + delete envWithIntent['CCS_EXTENDED_CONTEXT']; + + const newSettings = { ...currentSettings, env: envWithIntent }; setRawJsonEdits(JSON.stringify(newSettings, null, 2)); }, - [currentSettings] + [applySavedLongContextIntent, currentSettings, extendedContextEnabled] ); // Toggle extended context - applies/strips [1m] suffix to all model env vars const toggleExtendedContext = useCallback( (enabled: boolean) => { const env = currentSettings?.env || {}; - const updates: Record = {}; - - for (const key of MODEL_ENV_KEYS) { - const value = env[key]; - if (value) { - updates[key] = enabled - ? applyExtendedContextSuffix(value) - : stripExtendedContextSuffix(value); - } - } - - // Remove the legacy flag if present - const newEnv = { ...env, ...updates }; + const newEnv = applySavedLongContextIntent(env, enabled); delete newEnv['CCS_EXTENDED_CONTEXT']; const newSettings = { ...currentSettings, env: newEnv }; setRawJsonEdits(JSON.stringify(newSettings, null, 2)); }, - [currentSettings] + [applySavedLongContextIntent, currentSettings] ); // Batch update multiple env values at once const updateEnvValues = useCallback( (updates: Record) => { const newEnv = { ...(currentSettings?.env || {}), ...updates }; - const newSettings = { ...currentSettings, env: newEnv }; + const touchesAnthropicModel = Object.keys(updates).some(isAnthropicModelEnvKey); + const envWithIntent = touchesAnthropicModel + ? applySavedLongContextIntent(newEnv, extendedContextEnabled) + : newEnv; + delete envWithIntent['CCS_EXTENDED_CONTEXT']; + + const newSettings = { ...currentSettings, env: envWithIntent }; setRawJsonEdits(JSON.stringify(newSettings, null, 2)); }, - [currentSettings] + [applySavedLongContextIntent, currentSettings, extendedContextEnabled] ); // Check if JSON is valid diff --git a/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx index 6eec468b..0a84103a 100644 --- a/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx +++ b/ui/src/components/compatible-cli/raw-json-settings-editor-panel.tsx @@ -85,15 +85,21 @@ export function RawJsonSettingsEditorPanel({ Loading settings.json...
) : ( -
+
{parseWarning && (
Parse warning: {parseWarning}
)} -
+
- +
diff --git a/ui/src/components/copilot/config-form/raw-editor-section.tsx b/ui/src/components/copilot/config-form/raw-editor-section.tsx index 6a13c9e1..b1369951 100644 --- a/ui/src/components/copilot/config-form/raw-editor-section.tsx +++ b/ui/src/components/copilot/config-form/raw-editor-section.tsx @@ -40,7 +40,7 @@ export function RawEditorSection({
} > -
+
{!isRawJsonValid && rawJsonEdits !== null && (
@@ -63,13 +63,14 @@ export function RawEditorSection({
)} -
+
diff --git a/ui/src/components/monitoring/auth-monitor/hooks.ts b/ui/src/components/monitoring/auth-monitor/hooks.ts index 14868be5..a077a07e 100644 --- a/ui/src/components/monitoring/auth-monitor/hooks.ts +++ b/ui/src/components/monitoring/auth-monitor/hooks.ts @@ -4,8 +4,9 @@ import { useState, useMemo, useEffect } from 'react'; import { useCliproxyAuth } from '@/hooks/use-cliproxy'; -import { useCliproxyStats, type AccountUsageStats } from '@/hooks/use-cliproxy-stats'; +import { useCliproxyStats } from '@/hooks/use-cliproxy-stats'; import { getProviderDisplayName } from '@/lib/provider-config'; +import { getAccountStats } from '@/lib/cliproxy-account-stats'; import type { AuthStatus, OAuthAccount } from '@/lib/api-client'; import type { AccountRow, ProviderStats } from './types'; import { ACCOUNT_COLORS } from './utils'; @@ -44,12 +45,6 @@ export function useAuthMonitorData(): AuthMonitorData { return () => clearInterval(interval); }, [dataUpdatedAt]); - // Build a map of account email -> usage stats from CLIProxy - const accountStatsMap = useMemo(() => { - if (!statsData?.accountStats) return new Map(); - return new Map(Object.entries(statsData.accountStats)); - }, [statsData?.accountStats]); - // Transform auth status data into account rows const { accounts, totalSuccess, totalFailure, totalRequests, providerStats } = useMemo(() => { if (!data?.authStatus) { @@ -80,8 +75,7 @@ export function useAuthMonitorData(): AuthMonitorData { if (!providerData) return; status.accounts?.forEach((account: OAuthAccount) => { - const accountEmail = account.email || account.id; - const realStats = accountStatsMap.get(accountEmail); + const realStats = getAccountStats(statsData, account); const success = realStats?.successCount ?? 0; const failure = realStats?.failureCount ?? 0; tSuccess += success; @@ -132,7 +126,7 @@ export function useAuthMonitorData(): AuthMonitorData { totalRequests: tSuccess + tFailure, providerStats: providerStatsArr, }; - }, [data?.authStatus, accountStatsMap]); + }, [data?.authStatus, statsData]); const overallSuccessRate = totalRequests > 0 ? Math.round((totalSuccess / totalRequests) * 100) : 100; diff --git a/ui/src/components/profiles/editor/index.tsx b/ui/src/components/profiles/editor/index.tsx index d13c4353..f9709845 100644 --- a/ui/src/components/profiles/editor/index.tsx +++ b/ui/src/components/profiles/editor/index.tsx @@ -226,8 +226,8 @@ export function ProfileEditor({
) : ( -
-
+
+
-
+
diff --git a/ui/src/components/profiles/editor/raw-editor-section.tsx b/ui/src/components/profiles/editor/raw-editor-section.tsx index d4bbe4ac..686fb2e7 100644 --- a/ui/src/components/profiles/editor/raw-editor-section.tsx +++ b/ui/src/components/profiles/editor/raw-editor-section.tsx @@ -41,7 +41,7 @@ export function RawEditorSection({
} > -
+
{!isRawJsonValid && rawJsonEdits !== null && (
@@ -64,13 +64,14 @@ export function RawEditorSection({
)} -
+
diff --git a/ui/src/components/shared/code-editor.tsx b/ui/src/components/shared/code-editor.tsx index 47f95edc..5dbc3fd4 100644 --- a/ui/src/components/shared/code-editor.tsx +++ b/ui/src/components/shared/code-editor.tsx @@ -20,6 +20,7 @@ interface CodeEditorProps { readonly?: boolean; className?: string; minHeight?: string; + heightMode?: 'content' | 'fill-parent'; } interface ValidationResult { @@ -70,10 +71,12 @@ export function CodeEditor({ readonly = false, className, minHeight = '300px', + heightMode = 'content', }: CodeEditorProps) { const { isDark } = useTheme(); const [isFocused, setIsFocused] = useState(false); const [isMasked, setIsMasked] = useState(true); + const isFillParent = heightMode === 'fill-parent'; // Validate on every change for JSON const validation = useMemo(() => { @@ -153,38 +156,47 @@ export function CodeEditor({ ); return ( -
+
{/* Editor container */}
- {} : onChange} - highlight={highlightCode} - key={isDark ? 'dark-editor' : 'light-editor'} - padding={12} - disabled={readonly} - onFocus={() => setIsFocused(true)} - onBlur={() => setIsFocused(false)} - textareaClassName={cn( - 'focus:outline-none font-mono text-sm', - readonly && 'cursor-not-allowed' - )} - preClassName="font-mono text-sm" - style={{ - fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', - fontSize: '0.875rem', - minHeight, - }} - /> +
+ {} : onChange} + highlight={highlightCode} + key={isDark ? 'dark-editor' : 'light-editor'} + padding={12} + disabled={readonly} + onFocus={() => setIsFocused(true)} + onBlur={() => setIsFocused(false)} + textareaClassName={cn( + 'focus:outline-none font-mono text-sm', + readonly && 'cursor-not-allowed' + )} + preClassName="font-mono text-sm" + style={{ + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', + fontSize: '0.875rem', + minHeight, + }} + /> +
{/* Secrets Toggle Overlay */}
diff --git a/ui/src/components/shared/settings-dialog.tsx b/ui/src/components/shared/settings-dialog.tsx index faeffb54..dac1321d 100644 --- a/ui/src/components/shared/settings-dialog.tsx +++ b/ui/src/components/shared/settings-dialog.tsx @@ -279,7 +279,7 @@ function SettingsDialogContent({ - + @@ -293,6 +293,7 @@ function SettingsDialogContent({ onChange={handleRawJsonChange} language="json" minHeight="calc(60vh - 120px)" + heightMode="fill-parent" /> diff --git a/ui/src/hooks/use-cliproxy-stats.ts b/ui/src/hooks/use-cliproxy-stats.ts index 105049b6..ceeb6ce0 100644 --- a/ui/src/hooks/use-cliproxy-stats.ts +++ b/ui/src/hooks/use-cliproxy-stats.ts @@ -15,7 +15,11 @@ import type { UnifiedQuotaResult } from '@/lib/utils'; /** Per-account usage statistics */ export interface AccountUsageStats { - /** Account email or identifier */ + /** Provider-qualified lookup key (for example: "codex:user@example.com") */ + accountKey?: string; + /** Canonical provider name reported by CLIProxyAPI */ + provider?: string; + /** Raw account email or identifier */ source: string; /** Number of successful requests */ successCount: number; diff --git a/ui/src/index.css b/ui/src/index.css index 1e0a0d23..b15fdb8e 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -342,3 +342,35 @@ .scrollbar-thin::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); } + +.scrollbar-editor { + scrollbar-width: thin; + scrollbar-color: var(--border) transparent; + scrollbar-gutter: stable; +} + +.scrollbar-editor::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.scrollbar-editor::-webkit-scrollbar-track { + background: transparent; +} + +.scrollbar-editor::-webkit-scrollbar-thumb { + background: var(--border); + border: 2px solid transparent; + border-radius: 9999px; + background-clip: content-box; + min-height: 32px; +} + +.scrollbar-editor::-webkit-scrollbar-thumb:hover { + background: var(--muted-foreground); + background-clip: content-box; +} + +.scrollbar-editor::-webkit-scrollbar-corner { + background: transparent; +} diff --git a/ui/src/lib/cliproxy-account-stats.ts b/ui/src/lib/cliproxy-account-stats.ts new file mode 100644 index 00000000..a9acd8ec --- /dev/null +++ b/ui/src/lib/cliproxy-account-stats.ts @@ -0,0 +1,16 @@ +import type { OAuthAccount } from '@/lib/api-client'; +import type { AccountUsageStats, CliproxyStats } from '@/hooks/use-cliproxy-stats'; + +export function buildQualifiedAccountStatsKey(provider: string, source: string): string { + return `${provider.trim().toLowerCase()}:${source.trim()}`; +} + +export function getAccountStats( + stats: Pick | null | undefined, + account: Pick +): AccountUsageStats | undefined { + const source = account.email || account.id; + const qualifiedKey = buildQualifiedAccountStatsKey(account.provider, source); + + return stats?.accountStats?.[qualifiedKey] ?? stats?.accountStats?.[source]; +} diff --git a/ui/src/lib/extended-context-utils.ts b/ui/src/lib/extended-context-utils.ts index 3e09e846..2ce76d69 100644 --- a/ui/src/lib/extended-context-utils.ts +++ b/ui/src/lib/extended-context-utils.ts @@ -3,9 +3,16 @@ */ export { + ANTHROPIC_MODEL_ENV_KEYS, EXTENDED_CONTEXT_SUFFIX, + applyExtendedContextPreferenceToAnthropicModels, isNativeGeminiModel, + isAnthropicModelEnvKey, + hasAnthropicExtendedContextEnabled, hasExtendedContextSuffix, applyExtendedContextSuffix, + isClaudeModelId, + likelySupportsClaudeExtendedContext, + stripModelConfigurationSuffixes, stripExtendedContextSuffix, } from '../../../src/shared/extended-context-utils'; diff --git a/ui/src/lib/i18n.ts b/ui/src/lib/i18n.ts index 099156d6..b5c14ad7 100644 --- a/ui/src/lib/i18n.ts +++ b/ui/src/lib/i18n.ts @@ -483,7 +483,12 @@ const resources = { descOauth: 'Click Authenticate to get an OAuth URL. Open it in any browser to sign in.', powerUserEnabled: 'Power user mode enabled', powerUserSkipped: - 'AGY responsibility checklist is skipped from Settings > Proxy. You accept full responsibility for OAuth/account risk.', + 'Settings > Proxy power user mode is skipping the AGY responsibility checklist and Gemini dashboard risk phrase. You accept full responsibility for OAuth/account risk.', + powerUserLoadFailed: + 'Failed to load power user mode settings. Check Settings > Proxy and try again.', + powerUserLoading: 'Loading power user safety settings. Please wait a moment and retry.', + powerUserUnavailableRetry: + 'Power user mode is unavailable. Complete the required provider safety step and retry.', authMethod: 'Auth Method', selectKiroAuthMethod: 'Select Kiro auth method', nicknameRequired: 'Nickname (required)', @@ -732,13 +737,13 @@ const resources = { viewDocs: 'View documentation', }, settingsProxy: { - failedLoadAgyMode: 'Failed to load AGY power user mode', - failedUpdateAgyMode: 'Failed to update AGY power user mode', - failedVerifyAgyMode: 'Failed to verify AGY power user mode persistence', + failedLoadAgyMode: 'Failed to load power user mode', + failedUpdateAgyMode: 'Failed to update power user mode', + failedVerifyAgyMode: 'Failed to verify power user mode persistence', notPersistedAgyMode: - 'AGY power user mode was not persisted. Config may have been modified by another process.', - agyModeEnabled: 'AGY power user mode enabled.', - agyModeDisabled: 'AGY power user mode disabled.', + 'Power user mode was not persisted. Config may have been modified by another process.', + agyModeEnabled: 'Power user mode enabled.', + agyModeDisabled: 'Power user mode disabled.', typePhraseToContinue: 'Type "{{value}}" to continue.', description: 'Configure local or remote {{backend}} connection for proxy-based profiles', backendPlus: 'CLIProxy Plus', @@ -757,22 +762,23 @@ const resources = { variantsIncompatible: 'Existing Kiro/Copilot variants will not work with CLIProxyAPI. Switch to CLIProxyAPIPlus or remove those variants.', safety: 'Safety', - agyModeTitle: 'Antigravity Power User Mode', - agyModeDesc: 'Skip AGY responsibility checklist in Add Account and `ccs agy` flows.', + agyModeTitle: 'Antigravity + Gemini Power User Mode', + agyModeDesc: + 'Skip the AGY responsibility checklist and the Gemini dashboard typed acknowledgement.', agyWarning: 'Use only if you fully understand the OAuth suspension/ban risk pattern (#509). CCS cannot assume responsibility for account loss.', finalConfirm: 'Final confirmation required', finalConfirmDesc: - 'Enabling this will skip AGY safety checkpoints in both dashboard and CLI. Review issue #509 and type the exact phrase to proceed.', + 'Enabling this will skip AGY safety checkpoints and the Gemini dashboard risk phrase gate. Review issue #509 and type the exact phrase to proceed.', step1: 'Step 1', readIssue: 'Read issue #509', step2: 'Step 2', typePrefix: 'Type', typeSuffix: 'to enable.', - typePhraseAria: 'Type I ACCEPT RISK to enable Antigravity power user mode', + typePhraseAria: 'Type I ACCEPT RISK to enable Antigravity + Gemini power user mode', exactPhrase: 'Exact phrase required.', - enableAgyMode: 'Enable Power User Mode', - toggleAgyMode: 'Toggle AGY power user mode', + enableAgyMode: 'Enable Antigravity + Gemini Power User Mode', + toggleAgyMode: 'Toggle Antigravity + Gemini power user mode', fallbackSettings: 'Fallback Settings', enableFallback: 'Enable fallback to local', enableFallbackDesc: 'Use local proxy if remote is unreachable', @@ -1665,7 +1671,11 @@ const resources = { descDeviceCode: '点击认证后,验证码将显示,请在提供商网站输入。', descOauth: '点击认证获取 OAuth URL,在任意浏览器中打开即可登录。', powerUserEnabled: '已启用高级用户模式', - powerUserSkipped: '已在设置 > 代理 中跳过 AGY 责任清单,您需自行承担 OAuth/账号风险。', + powerUserSkipped: + '设置 > 代理 中的高级用户模式会跳过 AGY 责任确认清单和 Gemini Dashboard 的风险短语。OAuth / 账号风险需自行承担。', + powerUserLoadFailed: '加载高级用户模式设置失败。请检查“设置 > 代理”后重试。', + powerUserLoading: '正在加载高级用户安全设置。请稍候后重试。', + powerUserUnavailableRetry: '高级用户模式不可用。请完成当前提供商要求的安全步骤后重试。', authMethod: '认证方式', selectKiroAuthMethod: '选择 Kiro 认证方式', nicknameRequired: '昵称(必填)', @@ -1904,12 +1914,12 @@ const resources = { viewDocs: '查看文档', }, settingsProxy: { - failedLoadAgyMode: '加载 AGY 高级模式失败', - failedUpdateAgyMode: '更新 AGY 高级模式失败', - failedVerifyAgyMode: '校验 AGY 高级模式持久化失败', - notPersistedAgyMode: 'AGY 高级模式未成功持久化,配置可能被其他进程修改。', - agyModeEnabled: 'AGY 高级模式已启用。', - agyModeDisabled: 'AGY 高级模式已关闭。', + failedLoadAgyMode: '加载高级模式失败', + failedUpdateAgyMode: '更新高级模式失败', + failedVerifyAgyMode: '校验高级模式持久化失败', + notPersistedAgyMode: '高级模式未成功持久化,配置可能被其他进程修改。', + agyModeEnabled: '高级模式已启用。', + agyModeDisabled: '高级模式已关闭。', typePhraseToContinue: '请输入“{{value}}”后继续。', description: '为基于代理的配置设置本地或远程 {{backend}} 连接', backendPlus: 'CLIProxy Plus', @@ -1928,21 +1938,21 @@ const resources = { variantsIncompatible: '现有 Kiro/Copilot 变体与 CLIProxyAPI 不兼容。请切换到 CLIProxyAPIPlus 或移除这些变体。', safety: '安全', - agyModeTitle: 'Antigravity 高级模式', - agyModeDesc: '在 Add Account 与 `ccs agy` 流程中跳过 AGY 责任确认清单。', + agyModeTitle: 'Antigravity + Gemini 高级模式', + agyModeDesc: '跳过 AGY 责任确认清单,以及 Gemini Dashboard 中输入风险短语的步骤。', agyWarning: '仅在你充分理解 OAuth 封禁风险模式(#509)后使用。CCS 不对账号损失承担责任。', finalConfirm: '需要最终确认', finalConfirmDesc: - '启用后会跳过 Dashboard 与 CLI 的 AGY 安全检查。请先阅读 issue #509,并输入精确短语后继续。', + '启用后会跳过 Dashboard / CLI 中的 AGY 安全检查,以及 Gemini Dashboard 的风险短语确认。请先阅读 issue #509,并输入精确短语后继续。', step1: '步骤 1', readIssue: '阅读 issue #509', step2: '步骤 2', typePrefix: '输入', typeSuffix: '以启用。', - typePhraseAria: '输入 I ACCEPT RISK 以启用 Antigravity 高级模式', + typePhraseAria: '输入 I ACCEPT RISK 以启用 Antigravity + Gemini 高级模式', exactPhrase: '必须输入完全一致的短语。', - enableAgyMode: '启用高级模式', - toggleAgyMode: '切换 AGY 高级模式', + enableAgyMode: '启用 Antigravity + Gemini 高级模式', + toggleAgyMode: '切换 Antigravity + Gemini 高级模式', fallbackSettings: '回退设置', enableFallback: '启用本地回退', enableFallbackDesc: '远程不可达时使用本地代理', @@ -2859,7 +2869,13 @@ const resources = { 'Nhấp vào Xác thực để nhận URL OAuth. Mở nó trong bất kỳ trình duyệt nào để đăng nhập.', powerUserEnabled: 'Đã bật chế độ power user', powerUserSkipped: - 'Danh sách kiểm tra trách nhiệm của AGY bị bỏ qua khỏi Cài đặt > Proxy. Bạn chấp nhận hoàn toàn trách nhiệm về rủi ro tài khoản/OAuth.', + 'Chế độ power user trong Cài đặt > Proxy đang bỏ qua danh sách kiểm tra trách nhiệm AGY và bước nhập cụm từ rủi ro của Gemini trên dashboard. Bạn tự chịu hoàn toàn rủi ro OAuth/tài khoản.', + powerUserLoadFailed: + 'Không thể tải cài đặt chế độ power user. Hãy kiểm tra Cài đặt > Proxy rồi thử lại.', + powerUserLoading: + 'Đang tải cài đặt an toàn cho chế độ power user. Vui lòng đợi một chút rồi thử lại.', + powerUserUnavailableRetry: + 'Chế độ power user hiện không khả dụng. Hãy hoàn tất bước an toàn bắt buộc của nhà cung cấp rồi thử lại.', authMethod: 'Phương thức xác thực', selectKiroAuthMethod: 'Chọn phương thức xác thực Kiro', nicknameRequired: 'Biệt danh (bắt buộc)', @@ -3111,13 +3127,13 @@ const resources = { viewDocs: 'Xem tài liệu', }, settingsProxy: { - failedLoadAgyMode: 'Không thể tải chế độ power user AGY', - failedUpdateAgyMode: 'Không thể cập nhật chế độ power user AGY', - failedVerifyAgyMode: 'Không thể xác minh trạng thái lưu của chế độ power user AGY', + failedLoadAgyMode: 'Không thể tải chế độ power user', + failedUpdateAgyMode: 'Không thể cập nhật chế độ power user', + failedVerifyAgyMode: 'Không thể xác minh trạng thái lưu của chế độ power user', notPersistedAgyMode: - 'Chế độ power user AGY chưa được lưu bền vững. Cấu hình có thể đã bị tiến trình khác sửa.', - agyModeEnabled: 'Đã bật chế độ power user AGY.', - agyModeDisabled: 'Đã tắt chế độ power user AGY.', + 'Chế độ power user chưa được lưu bền vững. Cấu hình có thể đã bị tiến trình khác sửa.', + agyModeEnabled: 'Đã bật chế độ power user.', + agyModeDisabled: 'Đã tắt chế độ power user.', typePhraseToContinue: 'Nhập "{{value}}" để tiếp tục.', description: 'Định cấu hình kết nối {{backend}} cục bộ hoặc từ xa cho cấu hình dựa trên proxy', @@ -3138,23 +3154,23 @@ const resources = { variantsIncompatible: 'Các biến thể Kiro/Copilot hiện tại sẽ không hoạt động với CLIProxyAPI. Chuyển sang CLIProxyAPIPlus hoặc xóa các biến thể đó.', safety: 'An toàn', - agyModeTitle: 'Chế độ power user Antigravity', + agyModeTitle: 'Chế độ power user Antigravity + Gemini', agyModeDesc: - 'Bỏ qua danh sách kiểm tra trách nhiệm AGY trong quy trình Thêm tài khoản và `ccs agy`.', + 'Bỏ qua danh sách kiểm tra trách nhiệm AGY và bước nhập cụm từ xác nhận của Gemini trên dashboard.', agyWarning: 'Chỉ bật nếu bạn hiểu rõ rủi ro bị đình chỉ/cấm OAuth (#509). CCS không chịu trách nhiệm khi mất tài khoản.', finalConfirm: 'Xác nhận cuối cùng', finalConfirmDesc: - 'Việc bật tính năng này sẽ bỏ qua các điểm kiểm tra an toàn AGY trong cả bảng điều khiển và CLI. Xem lại vấn đề #509 và nhập cụm từ chính xác để tiếp tục.', + 'Việc bật tính năng này sẽ bỏ qua các điểm kiểm tra an toàn AGY và bước nhập cụm từ rủi ro của Gemini trên dashboard. Xem lại vấn đề #509 và nhập cụm từ chính xác để tiếp tục.', step1: 'Bước 1', readIssue: 'Đọc vấn đề #509', step2: 'Bước 2', typePrefix: 'Nhập', typeSuffix: 'để kích hoạt.', - typePhraseAria: 'Nhập I ACCEPT RISK để bật chế độ power user Antigravity', + typePhraseAria: 'Nhập I ACCEPT RISK để bật chế độ power user Antigravity + Gemini', exactPhrase: 'Phải nhập chính xác cụm từ.', - enableAgyMode: 'Bật chế độ power user', - toggleAgyMode: 'Chuyển đổi chế độ power user AGY', + enableAgyMode: 'Bật chế độ power user Antigravity + Gemini', + toggleAgyMode: 'Chuyển đổi chế độ power user Antigravity + Gemini', fallbackSettings: 'Cài đặt dự phòng', enableFallback: 'Cho phép dự phòng về cục bộ', enableFallbackDesc: 'Sử dụng proxy cục bộ nếu không thể truy cập được từ xa', @@ -4083,7 +4099,13 @@ const resources = { '認証をクリックすると OAuth URL を取得します。任意のブラウザーで開いてサインインしてください。', powerUserEnabled: '上級者モードが有効です', powerUserSkipped: - '設定 > プロキシで AGY の責任確認チェックをスキップしています。OAuth / アカウントに関するリスクはすべて自己責任となります。', + '設定 > プロキシのパワーユーザーモードにより、AGY の責任確認チェックと Gemini ダッシュボードのリスク文言入力をスキップしています。OAuth / アカウントに関するリスクはすべて自己責任です。', + powerUserLoadFailed: + 'パワーユーザーモード設定を読み込めませんでした。設定 > プロキシを確認してから再試行してください。', + powerUserLoading: + 'パワーユーザーモードの安全設定を読み込み中です。少し待ってから再試行してください。', + powerUserUnavailableRetry: + 'パワーユーザーモードは利用できません。必要なプロバイダーの安全確認を完了してから再試行してください。', authMethod: '認証方法', selectKiroAuthMethod: 'Kiro の認証方法を選択', nicknameRequired: 'ニックネーム(必須)', @@ -4339,13 +4361,13 @@ const resources = { viewDocs: 'ドキュメントを見る', }, settingsProxy: { - failedLoadAgyMode: 'AGY パワーユーザーモードの読み込みに失敗しました', - failedUpdateAgyMode: 'AGY パワーユーザーモードの更新に失敗しました', - failedVerifyAgyMode: 'AGY パワーユーザーモードの永続化確認に失敗しました', + failedLoadAgyMode: 'パワーユーザーモードの読み込みに失敗しました', + failedUpdateAgyMode: 'パワーユーザーモードの更新に失敗しました', + failedVerifyAgyMode: 'パワーユーザーモードの永続化確認に失敗しました', notPersistedAgyMode: - 'AGY パワーユーザーモードが保存されませんでした。別のプロセスが設定を変更した可能性があります。', - agyModeEnabled: 'AGY パワーユーザーモードを有効にしました。', - agyModeDisabled: 'AGY パワーユーザーモードを無効にしました。', + 'パワーユーザーモードが保存されませんでした。別のプロセスが設定を変更した可能性があります。', + agyModeEnabled: 'パワーユーザーモードを有効にしました。', + agyModeDisabled: 'パワーユーザーモードを無効にしました。', typePhraseToContinue: '続行するには「{{value}}」と入力してください。', description: 'プロキシベースのプロファイル用に、ローカルまたはリモートの {{backend}} 接続を設定します', @@ -4366,24 +4388,24 @@ const resources = { variantsIncompatible: '既存の Kiro/Copilot バリアントは CLIProxyAPI では動作しません。CLIProxyAPIPlus に切り替えるか、それらのバリアントを削除してください。', safety: '安全設定', - agyModeTitle: 'Antigravity パワーユーザーモード', + agyModeTitle: 'Antigravity + Gemini パワーユーザーモード', agyModeDesc: - 'アカウント追加画面と `ccs agy` フローで、AGY の責任確認チェックをスキップします。', + 'アカウント追加画面の AGY 責任確認チェックと、Gemini ダッシュボードの入力確認をスキップします。', agyWarning: 'OAuth の停止/BAN リスクパターン (#509) を十分理解している場合にのみ使用してください。CCS はアカウント喪失の責任を負いません。', finalConfirm: '最終確認', finalConfirmDesc: - '有効にすると、ダッシュボードと CLI の両方で AGY の安全チェックをスキップします。続行する前に issue #509 を確認し、正確なフレーズを入力してください。', + '有効にすると、ダッシュボード / CLI の AGY 安全チェックと Gemini ダッシュボードのリスク文言確認をスキップします。続行する前に issue #509 を確認し、正確なフレーズを入力してください。', step1: 'ステップ 1', readIssue: 'Issue #509 を読む', step2: 'ステップ 2', typePrefix: '有効にするには', typeSuffix: 'と入力してください。', typePhraseAria: - 'Antigravity パワーユーザーモードを有効にするには I ACCEPT RISK と入力してください', + 'Antigravity + Gemini パワーユーザーモードを有効にするには I ACCEPT RISK と入力してください', exactPhrase: '完全一致で入力してください。', - enableAgyMode: 'パワーユーザーモードを有効にする', - toggleAgyMode: 'AGY パワーユーザーモードを切り替え', + enableAgyMode: 'Antigravity + Gemini パワーユーザーモードを有効にする', + toggleAgyMode: 'Antigravity + Gemini パワーユーザーモードを切り替え', fallbackSettings: 'フォールバック設定', enableFallback: 'ローカルへのフォールバックを有効化', enableFallbackDesc: 'リモートに接続できない場合はローカルプロキシを使用します', diff --git a/ui/src/lib/model-catalogs.ts b/ui/src/lib/model-catalogs.ts index 9e711d72..8d9f64a2 100644 --- a/ui/src/lib/model-catalogs.ts +++ b/ui/src/lib/model-catalogs.ts @@ -4,6 +4,7 @@ */ import type { ProviderCatalog } from '@/components/cliproxy/provider-model-selector'; +import { stripModelConfigurationSuffixes } from '@/lib/extended-context-utils'; /** Model catalog data - mirrors src/cliproxy/model-catalog.ts */ export const MODEL_CATALOGS: Record = { @@ -553,3 +554,15 @@ export const MODEL_CATALOGS: Record = { ], }, }; + +export function findCatalogModel(provider: string, modelId: string) { + const catalog = MODEL_CATALOGS[provider.toLowerCase()]; + if (!catalog) return undefined; + + const normalizedModelId = stripModelConfigurationSuffixes(modelId); + return catalog.models.find((model) => model.id === normalizedModelId); +} + +export function supportsExtendedContext(provider: string, modelId: string): boolean { + return findCatalogModel(provider, modelId)?.extendedContext === true; +} diff --git a/ui/src/pages/api.tsx b/ui/src/pages/api.tsx index 2267a659..2a653f87 100644 --- a/ui/src/pages/api.tsx +++ b/ui/src/pages/api.tsx @@ -354,7 +354,7 @@ export function ApiPage() { />
-
+
{selectedProfileData ? ( <>
diff --git a/ui/src/pages/cliproxy-ai-providers.tsx b/ui/src/pages/cliproxy-ai-providers.tsx index f7a492f2..b7a8c79e 100644 --- a/ui/src/pages/cliproxy-ai-providers.tsx +++ b/ui/src/pages/cliproxy-ai-providers.tsx @@ -1248,6 +1248,7 @@ function EntryInspector({ onChange={handleRawJsonChange} language="json" minHeight="100%" + heightMode="fill-parent" />
@@ -1271,6 +1272,7 @@ function EntryInspector({ language="json" readonly minHeight="100%" + heightMode="fill-parent" />
diff --git a/ui/tests/unit/components/account/add-account-dialog.test.tsx b/ui/tests/unit/components/account/add-account-dialog.test.tsx new file mode 100644 index 00000000..b56aa08a --- /dev/null +++ b/ui/tests/unit/components/account/add-account-dialog.test.tsx @@ -0,0 +1,175 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import i18n from '@/lib/i18n'; +import { AddAccountDialog } from '@/components/account/add-account-dialog'; +import { render, screen, userEvent, waitFor } from '@tests/setup/test-utils'; +import { toast } from 'sonner'; + +const authMocks = vi.hoisted(() => ({ + startAuth: vi.fn(), + cancelAuth: vi.fn(), + submitCallback: vi.fn(), +})); + +const kiroImportMocks = vi.hoisted(() => ({ + mutate: vi.fn(), +})); + +vi.mock('@/hooks/use-cliproxy-auth-flow', () => ({ + useCliproxyAuthFlow: () => ({ + provider: null, + isAuthenticating: false, + error: null, + authUrl: null, + oauthState: null, + isSubmittingCallback: false, + isDeviceCodeFlow: false, + startAuth: authMocks.startAuth, + cancelAuth: authMocks.cancelAuth, + submitCallback: authMocks.submitCallback, + }), +})); + +vi.mock('@/hooks/use-cliproxy', () => ({ + useKiroImport: () => ({ + isPending: false, + mutate: kiroImportMocks.mutate, + }), +})); + +vi.mock('sonner', () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + warning: vi.fn(), + }, +})); + +function createJsonResponse(body: Record): Response { + return new Response(JSON.stringify(body), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }); +} + +describe('AddAccountDialog power user mode', () => { + const fetchMock = vi.fn(); + + beforeEach(async () => { + await i18n.changeLanguage('en'); + vi.clearAllMocks(); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('skips the Gemini typed acknowledgement when power user mode is enabled', async () => { + fetchMock.mockResolvedValue(createJsonResponse({ antigravityAckBypass: true })); + + render( + + ); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith('/api/settings/auth/antigravity-risk') + ); + + const authenticateButton = screen.getByRole('button', { name: 'Authenticate' }); + await waitFor(() => expect(authenticateButton).toBeEnabled()); + + expect(screen.getByText('Power user mode enabled')).toBeInTheDocument(); + expect(screen.queryByText(/Type exact phrase to continue/i)).not.toBeInTheDocument(); + + await userEvent.click(authenticateButton); + + expect(authMocks.startAuth).toHaveBeenCalledWith( + 'gemini', + expect.objectContaining({ + riskAcknowledgement: undefined, + }) + ); + }); + + it('keeps the Gemini typed acknowledgement when power user mode is disabled', async () => { + fetchMock.mockResolvedValue(createJsonResponse({ antigravityAckBypass: false })); + + render( + + ); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith('/api/settings/auth/antigravity-risk') + ); + + const authenticateButton = screen.getByRole('button', { name: 'Authenticate' }); + await waitFor(() => expect(authenticateButton).toBeDisabled()); + + expect(screen.getByText(/Type exact phrase to continue/i)).toBeInTheDocument(); + expect(screen.queryByText('Power user mode enabled')).not.toBeInTheDocument(); + expect(authMocks.startAuth).not.toHaveBeenCalled(); + }); + + it('surfaces a power user mode fetch failure and fails closed for Gemini', async () => { + fetchMock.mockRejectedValue(new Error('network down')); + + render( + + ); + + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + 'Failed to load power user mode settings. Check Settings > Proxy and try again.' + ) + ); + + expect( + screen.getByText('Failed to load power user mode settings. Check Settings > Proxy and try again.') + ).toBeInTheDocument(); + expect(screen.getByText(/Type exact phrase to continue/i)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Authenticate' })).toBeDisabled(); + }); + + it('skips the AGY responsibility checklist when power user mode is enabled', async () => { + fetchMock.mockResolvedValue(createJsonResponse({ antigravityAckBypass: true })); + + render(); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith('/api/settings/auth/antigravity-risk') + ); + + const authenticateButton = screen.getByRole('button', { name: 'Authenticate' }); + await waitFor(() => expect(authenticateButton).toBeEnabled()); + + expect(screen.getByText('Power user mode enabled')).toBeInTheDocument(); + expect(screen.queryByText(/Step 1: I reviewed issue #509/i)).not.toBeInTheDocument(); + + await userEvent.click(authenticateButton); + + expect(authMocks.startAuth).toHaveBeenCalledWith( + 'agy', + expect.objectContaining({ + riskAcknowledgement: undefined, + }) + ); + }); + + it('keeps the AGY responsibility checklist when power user mode is disabled', async () => { + fetchMock.mockResolvedValue(createJsonResponse({ antigravityAckBypass: false })); + + render(); + + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith('/api/settings/auth/antigravity-risk') + ); + + const authenticateButton = screen.getByRole('button', { name: 'Authenticate' }); + await waitFor(() => expect(authenticateButton).toBeDisabled()); + + expect(screen.getByText(/Step 1: I reviewed issue #509/i)).toBeInTheDocument(); + expect(screen.queryByText('Power user mode enabled')).not.toBeInTheDocument(); + expect(authMocks.startAuth).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx index 9fb5d916..ea31f449 100644 --- a/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx +++ b/ui/tests/unit/components/cliproxy/provider-editor/model-config-section.test.tsx @@ -26,6 +26,7 @@ describe('ModelConfigSection presets', () => { haikuModel="gpt-5-codex-mini" providerModels={[]} provider="codex" + onExtendedContextToggle={vi.fn()} onApplyPreset={onApplyPreset} onUpdateEnvValue={vi.fn()} onOpenCustomPreset={vi.fn()} @@ -55,10 +56,11 @@ describe('ModelConfigSection presets', () => { savedPresets={[]} currentModel="claude-opus-4-6-thinking" opusModel="claude-opus-4-6-thinking" - sonnetModel="claude-sonnet-4-6" - haikuModel="claude-sonnet-4-6" + sonnetModel="gemini-3-pro-preview" + haikuModel="gemini-3-flash-preview" providerModels={[]} provider="agy" + onExtendedContextToggle={vi.fn()} onApplyPreset={vi.fn()} onUpdateEnvValue={vi.fn()} onOpenCustomPreset={vi.fn()} @@ -69,5 +71,6 @@ describe('ModelConfigSection presets', () => { expect(screen.queryByText('Free Tier')).not.toBeInTheDocument(); expect(screen.queryByText('Paid Tier')).not.toBeInTheDocument(); expect(screen.getByRole('button', { name: 'Claude Opus 4.6 Thinking' })).toBeInTheDocument(); + expect(screen.getByTestId('extended-context-toggle')).toBeInTheDocument(); }); }); diff --git a/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx new file mode 100644 index 00000000..31391e58 --- /dev/null +++ b/ui/tests/unit/components/cliproxy/provider-editor/use-provider-editor.test.tsx @@ -0,0 +1,156 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { AllProviders } from '../../../../setup/test-utils'; +import { useProviderEditor } from '@/components/cliproxy/provider-editor/use-provider-editor'; + +function createJsonResponse(body: Record, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useProviderEditor', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('derives extended-context state from all Anthropic model env keys', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/api/settings/claude/raw')) { + return Promise.resolve( + createJsonResponse({ + profile: 'claude', + settings: { + env: { + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }, + }, + mtime: 1, + path: '~/.ccs/profiles/claude/settings.json', + }) + ); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useProviderEditor('claude'), { wrapper }); + + await waitFor(() => expect(result.current.currentModel).toBe('claude-sonnet-4-6')); + expect(result.current.extendedContextEnabled).toBe(true); + }); + + it('applies [1m] across compatible Claude mappings and leaves Haiku plain', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/api/settings/claude/raw')) { + return Promise.resolve( + createJsonResponse({ + profile: 'claude', + settings: { + env: { + ANTHROPIC_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }, + }, + mtime: 1, + path: '~/.ccs/profiles/claude/settings.json', + }) + ); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useProviderEditor('claude'), { wrapper }); + + await waitFor(() => expect(result.current.currentModel).toBe('claude-sonnet-4-6')); + + act(() => { + result.current.toggleExtendedContext(true); + }); + + const nextSettings = JSON.parse(result.current.rawJsonContent); + expect(nextSettings.env).toMatchObject({ + ANTHROPIC_MODEL: 'claude-sonnet-4-6[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6[1m]', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }); + }); + + it('preserves explicit long-context intent when preset-style updates replace mappings', async () => { + vi.stubGlobal( + 'fetch', + vi.fn((input: RequestInfo | URL) => { + const url = String(input); + + if (url.includes('/api/settings/claude/raw')) { + return Promise.resolve( + createJsonResponse({ + profile: 'claude', + settings: { + env: { + ANTHROPIC_MODEL: 'claude-sonnet-4-6[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6[1m]', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }, + }, + mtime: 1, + path: '~/.ccs/profiles/claude/settings.json', + }) + ); + } + + return Promise.reject(new Error(`Unexpected fetch: ${url}`)); + }) + ); + + const { result } = renderHook(() => useProviderEditor('claude'), { wrapper }); + + await waitFor(() => expect(result.current.extendedContextEnabled).toBe(true)); + + act(() => { + result.current.updateEnvValues({ + ANTHROPIC_MODEL: 'claude-opus-4-6', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }); + }); + + const nextSettings = JSON.parse(result.current.rawJsonContent); + expect(nextSettings.env).toMatchObject({ + ANTHROPIC_MODEL: 'claude-opus-4-6[1m]', + ANTHROPIC_DEFAULT_OPUS_MODEL: 'claude-opus-4-6[1m]', + ANTHROPIC_DEFAULT_SONNET_MODEL: 'claude-sonnet-4-6[1m]', + ANTHROPIC_DEFAULT_HAIKU_MODEL: 'claude-haiku-4-5-20251001', + }); + }); +}); diff --git a/ui/tests/unit/components/ui/code-editor-usage-contract.test.ts b/ui/tests/unit/components/ui/code-editor-usage-contract.test.ts new file mode 100644 index 00000000..57c638c6 --- /dev/null +++ b/ui/tests/unit/components/ui/code-editor-usage-contract.test.ts @@ -0,0 +1,74 @@ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const boundedConsumers = [ + { + file: 'src/pages/cliproxy-ai-providers.tsx', + expectedCount: 2, + }, + { + file: 'src/components/cliproxy/provider-editor/raw-editor-section.tsx', + expectedCount: 1, + }, + { + file: 'src/components/profiles/editor/raw-editor-section.tsx', + expectedCount: 1, + }, + { + file: 'src/components/copilot/config-form/raw-editor-section.tsx', + expectedCount: 1, + }, + { + file: 'src/components/compatible-cli/raw-json-settings-editor-panel.tsx', + expectedCount: 1, + }, + { + file: 'src/components/shared/settings-dialog.tsx', + expectedCount: 1, + }, +] as const; + +const boundedLayoutContracts = [ + { + file: 'src/components/profiles/editor/index.tsx', + snippets: [ + 'min-h-0 flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden', + 'flex min-h-0 min-w-0 flex-col overflow-hidden', + ], + }, + { + file: 'src/components/cliproxy/provider-editor/index.tsx', + snippets: [ + 'min-h-0 flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden', + 'flex min-h-0 min-w-0 flex-col overflow-hidden', + ], + }, + { + file: 'src/pages/api.tsx', + snippets: ['flex min-h-0 flex-1 flex-col min-w-0 overflow-hidden'], + }, +] as const; + +describe('bounded CodeEditor consumers', () => { + it.each(boundedConsumers)('$file opts into fill-parent mode for every bounded editor', ({ + file, + expectedCount, + }) => { + const source = readFileSync(resolve(process.cwd(), file), 'utf8'); + const matches = source.match(/heightMode="fill-parent"/g) ?? []; + + expect(matches).toHaveLength(expectedCount); + }); + + it.each(boundedLayoutContracts)( + '$file keeps bounded editor ancestors shrinkable', + ({ file, snippets }) => { + const source = readFileSync(resolve(process.cwd(), file), 'utf8'); + + for (const snippet of snippets) { + expect(source).toContain(snippet); + } + } + ); +}); diff --git a/ui/tests/unit/components/ui/code-editor.test.tsx b/ui/tests/unit/components/ui/code-editor.test.tsx new file mode 100644 index 00000000..67e8b4c8 --- /dev/null +++ b/ui/tests/unit/components/ui/code-editor.test.tsx @@ -0,0 +1,62 @@ +import { describe, expect, it, vi } from 'vitest'; +import { render, screen } from '@tests/setup/test-utils'; + +import { CodeEditor } from '@/components/shared/code-editor'; + +vi.mock('@/hooks/use-theme', () => ({ + useTheme: () => ({ isDark: false }), +})); + +describe('CodeEditor', () => { + it('creates an internal scroll viewport in fill-parent mode and keeps status outside it', () => { + const { container } = render( + + ); + + const viewport = container.querySelector('[data-slot="code-editor-viewport"]'); + const root = container.firstElementChild; + + expect(viewport).toBeInTheDocument(); + expect(root).toHaveStyle({ height: '100%' }); + expect(viewport).not.toContainElement(screen.getByText('Valid JSON')); + }); + + it('keeps readonly status outside the scroll viewport for bounded editors', () => { + const { container } = render( + + ); + + const viewport = container.querySelector('[data-slot="code-editor-viewport"]'); + const textarea = container.querySelector('textarea'); + const root = container.firstElementChild; + + expect(root).toHaveStyle({ height: 'calc(60vh - 120px)' }); + expect(textarea).toBeDisabled(); + expect(viewport).not.toContainElement(screen.getByText('(Read-only)')); + }); + + it('preserves content mode as the default layout contract', () => { + const { container } = render( + + ); + + expect(container.querySelector('[data-slot="code-editor-viewport"]')).not.toBeInTheDocument(); + }); +}); diff --git a/ui/tests/unit/ui/lib/cliproxy-account-stats.test.ts b/ui/tests/unit/ui/lib/cliproxy-account-stats.test.ts new file mode 100644 index 00000000..6e328302 --- /dev/null +++ b/ui/tests/unit/ui/lib/cliproxy-account-stats.test.ts @@ -0,0 +1,75 @@ +import { describe, expect, it } from 'vitest'; +import { getAccountStats } from '../../../../src/lib/cliproxy-account-stats'; +import type { CliproxyStats } from '../../../../src/hooks/use-cliproxy-stats'; +import type { OAuthAccount } from '../../../../src/lib/api-client'; + +describe('getAccountStats', () => { + const baseAccount = { + id: 'shared@example.com', + email: 'shared@example.com', + isDefault: true, + tokenFile: 'shared.json', + createdAt: '2026-03-26T00:00:00.000Z', + } as const; + + it('prefers provider-qualified stats when the same email exists across providers', () => { + const stats = { + accountStats: { + 'codex:shared@example.com': { + accountKey: 'codex:shared@example.com', + provider: 'codex', + source: 'shared@example.com', + successCount: 11, + failureCount: 1, + totalTokens: 0, + lastUsedAt: '2026-03-26T10:00:00.000Z', + }, + 'gemini:shared@example.com': { + accountKey: 'gemini:shared@example.com', + provider: 'gemini', + source: 'shared@example.com', + successCount: 3, + failureCount: 2, + totalTokens: 0, + lastUsedAt: '2026-03-26T11:00:00.000Z', + }, + }, + } as Pick; + + const codexAccount: OAuthAccount = { ...baseAccount, provider: 'codex' }; + const geminiAccount: OAuthAccount = { ...baseAccount, provider: 'gemini' }; + + expect(getAccountStats(stats, codexAccount)).toMatchObject({ + successCount: 11, + failureCount: 1, + provider: 'codex', + }); + expect(getAccountStats(stats, geminiAccount)).toMatchObject({ + successCount: 3, + failureCount: 2, + provider: 'gemini', + }); + }); + + it('falls back to legacy raw-source keys for older stats payloads', () => { + const stats = { + accountStats: { + 'shared@example.com': { + source: 'shared@example.com', + successCount: 7, + failureCount: 0, + totalTokens: 0, + lastUsedAt: '2026-03-26T12:00:00.000Z', + }, + }, + } as Pick; + + const account: OAuthAccount = { ...baseAccount, provider: 'codex' }; + + expect(getAccountStats(stats, account)).toMatchObject({ + source: 'shared@example.com', + successCount: 7, + failureCount: 0, + }); + }); +});