Merge pull request #808 from kaitranntt/dev

feat(release): promote dev to main
This commit is contained in:
Kai (Tam Nhu) Tran
2026-03-26 18:22:23 -04:00
committed by GitHub
60 changed files with 3010 additions and 948 deletions
+1
View File
@@ -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 *)"
+5 -1
View File
@@ -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
+1 -1
View File
@@ -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",
+3
View File
@@ -0,0 +1,3 @@
export function buildQualifiedAccountStatsKey(provider: string, source: string): string {
return `${provider.trim().toLowerCase()}:${source.trim()}`;
}
+15 -51
View File
@@ -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
),
})
);
}
+2 -6
View File
@@ -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;
+266 -69
View File
@@ -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 (/^<!doctype html/i.test(trimmed) || /^<html/i.test(trimmed) || /^<[^>]+>/.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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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 };
+54 -99
View File
@@ -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<CliproxyStats | null> {
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<string, number> = {};
const requestsByProvider: Record<string, number> = {};
const accountStats: Record<string, AccountUsageStats> = {};
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<CliproxyManagementAuthFile[] | null> {
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;
+171
View File
@@ -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<string, ResolvedAuthFile> {
const lookup = new Map<string, ResolvedAuthFile>();
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, ResolvedAuthFile>
): 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, ResolvedAuthFile>
): 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<string, number> = {};
const requestsByProvider: Record<string, number> = {};
const accountStats: Record<string, AccountUsageStats> = {};
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(),
};
}
-1
View File
@@ -1 +0,0 @@
export * from './api-command/index';
+79 -8
View File
@@ -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<boolean> {
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<void> {
await initUI();
const parsedArgs = parseApiCommandArgs(args);
@@ -403,17 +440,31 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
}
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<void> {
}
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') {
+51
View File
@@ -0,0 +1,51 @@
import { dispatchNamedCommand, type NamedCommandRoute } from '../named-command-router';
type ApiCommandHandler = (args: string[]) => Promise<void>;
type ApiCommandHelpHandler = () => Promise<void>;
type ApiCommandUnknownHandler = (command: string) => Promise<void>;
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<void> {
const routes = createApiCommandRoutes(dependencies);
return async (args: string[]) => {
await dispatchNamedCommand({
args,
routes,
onHelp: dependencies.help,
onUnknown: dependencies.unknown,
allowEmptyHelp: true,
});
};
}
+87 -74
View File
@@ -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<void> {
export async function showApiCommandHelp(writeLine: HelpWriter = console.log): Promise<void> {
await initUI();
const presetIds = getPresetIds()
.map((id) => sanitizeHelpText(id))
@@ -25,97 +27,108 @@ export async function showApiCommandHelp(): Promise<void> {
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')} <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')} <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 <src> <dest>', 'command')} Duplicate API profile settings + config`);
console.log(
writeLine(` ${color('copy <src> <dest>', 'command')} Duplicate API profile settings + config`);
writeLine(
` ${color('export <name>', 'command')} Export profile bundle for cross-device transfer`
);
console.log(
` ${color('import <file>', 'command')} Import profile bundle and register profile`
);
console.log(` ${color('remove <name>', 'command')} Remove an API profile`);
console.log('');
console.log(subheader('Options'));
console.log(
writeLine(` ${color('import <file>', 'command')} Import profile bundle and register profile`);
writeLine(` ${color('remove <name>', 'command')} Remove an API profile`);
writeLine('');
writeLine(subheader('Options'));
writeLine(
` ${color('--preset <id>', 'command')} Use provider preset (${presetIds.join(', ')})`
);
console.log(
writeLine(
` ${color('--cliproxy-provider <id>', 'command')} Use routed CLIProxy provider (${cliproxyProviderIds.join(', ')})`
);
console.log(` ${color('--base-url <url>', 'command')} API base URL (create)`);
console.log(` ${color('--api-key <key>', 'command')} API key (create)`);
console.log(` ${color('--model <model>', 'command')} Default model (create)`);
console.log(
writeLine(` ${color('--base-url <url>', 'command')} API base URL (create)`);
writeLine(` ${color('--api-key <key>', 'command')} API key (create)`);
writeLine(` ${color('--model <model>', 'command')} Default model (create)`);
writeLine(
` ${color('--1m / --no-1m', 'command')} Write or clear [1m] on compatible Claude mappings`
);
writeLine(
` ${color('--target <cli>', '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 <file>', 'command')} Export bundle output path`);
console.log(` ${color('--include-secrets', 'command')} Include token in export bundle`);
console.log(` ${color('--name <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 <file>', 'command')} Export bundle output path`);
writeLine(` ${color('--include-secrets', 'command')} Include token in export bundle`);
writeLine(` ${color('--name <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<void> {
+14 -17
View File
@@ -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<void> {
await dispatchNamedCommand({
args,
routes: API_COMMAND_ROUTES,
onHelp: showApiCommandHelp,
onUnknown: showUnknownApiCommand,
allowEmptyHelp: true,
});
await createApiCommandHandler(DEFAULT_API_COMMAND_DEPENDENCIES)(args);
}
+53 -1
View File
@@ -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;
+79 -5
View File
@@ -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<QuotaSupportedProvider, QuotaProviderRuntim
},
};
export const __testExports = {
getQuotaFailureDisplayEntries,
};
export async function handleQuotaStatus(
verbose = false,
providerFilter: QuotaSupportedProvider | 'all' = 'all'
@@ -771,7 +845,7 @@ export async function handleDoctor(verbose = false): Promise<void> {
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')}`
+339 -248
View File
@@ -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<void> {
export async function handleHelpCommand(writeLine: HelpWriter = console.log): Promise<void> {
// 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 <file>', '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 <name>', '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 <level>', 'Set codex reasoning effort (medium/high/xhigh)'],
['ccs <provider> --1m', 'Enable 1M token context window'],
['ccs <provider> --no-1m', 'Disable 1M context (use 200K default)'],
['ccs <provider> --1m', 'Request explicit 1M context when the selected model supports [1m]'],
['ccs <provider> --no-1m', 'Force standard context / clear [1m]'],
['ccs <provider> --logout', 'Clear authentication'],
['ccs <provider> --headless', 'Headless auth (for SSH)'],
['ccs <provider> --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 <n>', 'Limit agentic turns (prevents loops)'],
['--fallback-model <model>', 'Auto-fallback on overload (sonnet)'],
['--agents <json>', 'Inject dynamic subagents'],
['--betas <features>', 'Enable experimental features'],
['--allowedTools <list>', 'Restrict available tools'],
['--disallowedTools <list>', 'Block specific tools'],
]);
printSubSection(
'Delegation Flags (Claude Code passthrough)',
[
['--max-turns <n>', 'Limit agentic turns (prevents loops)'],
['--fallback-model <model>', 'Auto-fallback on overload (sonnet)'],
['--agents <json>', 'Inject dynamic subagents'],
['--betas <features>', 'Enable experimental features'],
['--allowedTools <list>', 'Restrict available tools'],
['--disallowedTools <list>', '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=<token>', '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 <profile>', '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=<token>', '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 <profile>', '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 <profile>', 'Export env vars for third-party tools'],
['ccs env <profile> --format openai', 'OpenAI-compatible vars (OpenCode/Cursor)'],
['ccs env <profile> --format anthropic', 'Anthropic vars (default)'],
['ccs env <profile> --format raw', 'All effective env vars'],
printSubSection(
'Environment Export',
[
'ccs env <profile> --format claude-extension --ide vscode',
'VS Code/Cursor Claude extension settings JSON',
['ccs env <profile>', 'Export env vars for third-party tools'],
['ccs env <profile> --format openai', 'OpenAI-compatible vars (OpenCode/Cursor)'],
['ccs env <profile> --format anthropic', 'Anthropic vars (default)'],
['ccs env <profile> --format raw', 'All effective env vars'],
[
'ccs env <profile> --format claude-extension --ide vscode',
'VS Code/Cursor Claude extension settings JSON',
],
[
'ccs env <profile> --format claude-extension --ide windsurf',
'Windsurf Claude extension settings JSON',
],
['ccs env <profile> --shell fish', 'Fish shell syntax'],
],
[
'ccs env <profile> --format claude-extension --ide windsurf',
'Windsurf Claude extension settings JSON',
],
['ccs env <profile> --shell fish', 'Fish shell syntax'],
]);
writeLine
);
// Flags
printSubSection('Flags', [
['--config-dir <path>', 'Use custom CCS config directory'],
['--target <cli>', '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 <path>', 'Use custom CCS config directory'],
['--target <cli>', '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 <profile> [args]', 'Explicit Droid runtime alias'],
['ccsd <profile> [args]', 'Legacy shortcut for: ccs-droid <profile> [args]'],
]);
printSubSection(
'Aliases',
[
['ccs-droid <profile> [args]', 'Explicit Droid runtime alias'],
['ccsd <profile> [args]', 'Legacy shortcut for: ccs-droid <profile> [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 <ver>', 'Install specific version (e.g., 6.6.6)'],
['ccs cliproxy --latest', 'Update to latest version'],
['', ''], // Spacer
['ccs cliproxy pause <p> <a>', 'Pause account from rotation'],
['ccs cliproxy resume <p> <a>', '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 <name>', '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 <ver>', 'Install specific version (e.g., 6.6.6)'],
['ccs cliproxy --latest', 'Update to latest version'],
['', ''], // Spacer
['ccs cliproxy pause <p> <a>', 'Pause account from rotation'],
['ccs cliproxy resume <p> <a>', '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 <name>', 'Show quota/tier/pause status for one provider'],
],
writeLine
);
// CLI Proxy configuration flags (new)
printSubSection('CLI Proxy Configuration', [
['--proxy-host <host>', 'Remote proxy hostname/IP'],
['--proxy-port <port>', `Proxy port (default: ${CLIPROXY_DEFAULT_PORT})`],
['--proxy-protocol <proto>', 'Protocol: http or https (default: http)'],
['--proxy-auth-token <token>', 'Auth token for remote proxy'],
['--proxy-timeout <ms>', 'Connection timeout in ms (default: 2000)'],
['--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 <host>', 'Remote proxy hostname/IP'],
['--proxy-port <port>', `Proxy port (default: ${CLIPROXY_DEFAULT_PORT})`],
['--proxy-protocol <proto>', 'Protocol: http or https (default: http)'],
['--proxy-auth-token <token>', 'Auth token for remote proxy'],
['--proxy-timeout <ms>', 'Connection timeout in ms (default: 2000)'],
['--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 <number>', 'Custom token budget (512-100000)'],
['', ''],
['--effort <level>', '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 <level>'],
['', '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 <number>', 'Custom token budget (512-100000)'],
['', ''],
['--effort <level>', '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 <level>'],
['', '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 <p> <m>', '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 <p> <m>', '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=<token>', 'Save TELEGRAM_BOT_TOKEN'],
['ccs config channels --set-token discord=<token>', '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/<channel>/.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=<token>', 'Save TELEGRAM_BOT_TOKEN'],
['ccs config channels --set-token discord=<token>', '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/<channel>/.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('');
}
+1 -1
View File
@@ -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';
+1 -1
View File
@@ -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);
},
},
+1 -1
View File
@@ -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;
}
+71
View File
@@ -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<string>(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<Record<string, string | undefined>>
): 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<string, string | undefined>,
>(
env: T,
enabled: boolean,
options: {
supportsExtendedContext?: (modelId: string, key: AnthropicModelEnvKey) => boolean;
} = {}
): T {
const nextEnv: Record<string, string | undefined> = { ...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-');
}
+2 -15
View File
@@ -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<void> => {
// Fetch CLIProxyAPI usage stats to determine active providers
const stats = await fetchCliproxyStats();
// Map CLIProxyAPI provider names to our internal provider names
const statsProviderMap: Record<string, CLIProxyProvider> = {
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);
+3 -3
View File
@@ -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;
@@ -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]',
@@ -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, unknown> = {}
): 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: '<!doctype html><html><body>bad gateway</body></html>',
},
]);
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('<html>bad gateway</html>')).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({
@@ -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> = {}): 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 });
});
});
@@ -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',
});
});
});
+23 -49
View File
@@ -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']);
@@ -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',
},
]);
});
});
@@ -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;
}
+39 -39
View File
@@ -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
);
});
@@ -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<string | null>(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<KiroAuthMethod>(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<boolean> => {
const fetchPowerUserModeState = useCallback(async (): Promise<boolean> => {
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 && (
<div className="rounded-lg border border-amber-400/35 bg-amber-50/70 p-3 text-xs text-amber-900 dark:border-amber-800/60 dark:bg-amber-950/25 dark:text-amber-100">
<div className="mb-1.5 flex items-center gap-1.5 font-semibold">
<ShieldAlert className="h-3.5 w-3.5" />
@@ -346,7 +363,7 @@ export function AddAccountDialog({
</div>
)}
{requiresSafetyAcknowledgement && !showAuthUI && (
{requiresGeminiSafetyAcknowledgement && !showAuthUI && (
<AccountSafetyWarningCard
showAcknowledgement
acknowledgementPhrase={RISK_ACK_PHRASE}
@@ -541,9 +558,9 @@ export function AddAccountDialog({
onClick={handleAuthenticate}
disabled={
isPending ||
isAgyBypassStatePending ||
isPowerUserModePending ||
(requiresAgyResponsibilityFlow && !isAgyRiskChecklistComplete) ||
(requiresSafetyAcknowledgement && !isGeminiRiskAcknowledged)
(requiresGeminiSafetyAcknowledgement && !isGeminiRiskAcknowledged)
}
>
<ExternalLink className="w-4 h-4 mr-2" />
@@ -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 (
<div
@@ -65,19 +73,12 @@ export function ExtendedContextToggle({
<div className="flex items-start gap-2 text-xs text-muted-foreground">
<Info className="w-3.5 h-3.5 mt-0.5 shrink-0" />
<div className="space-y-1">
<p>Enables 1M token context window instead of default 200K.</p>
<p className="text-[10px]">
{isAutoEnabled ? (
<span className="text-primary">Auto-enabled for {provider} Gemini models</span>
) : (
<span>Opt-in for {provider} Claude models via --1m flag</span>
)}
<p>Applies the explicit <code>[1m]</code> long-context suffix to compatible saved mappings.</p>
<p className="text-[10px]">{behaviorHint}</p>
<p className="text-amber-600 dark:text-amber-500">
CCS only saves <code>[1m]</code>. Provider pricing and entitlement are separate, and
some accounts can still return 429 extra-usage errors for long-context requests.
</p>
{enabled && (
<p className="text-amber-600 dark:text-amber-500">
Note: 2x input pricing applies for tokens beyond 200K
</p>
)}
</div>
</div>
</div>
@@ -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,
@@ -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
@@ -234,8 +234,8 @@ export function ProviderEditor({
<span className="ml-3 text-muted-foreground">Loading settings...</span>
</div>
) : (
<div className="flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
<div className="flex flex-col overflow-hidden bg-muted/5">
<div className="min-h-0 flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-muted/5">
<Tabs defaultValue="config" className="h-full flex flex-col">
<div className="px-4 pt-4 shrink-0">
<TabsList className="w-full">
@@ -303,7 +303,7 @@ export function ProviderEditor({
</Tabs>
</div>
<div className="flex flex-col overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden">
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
<Code2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">
@@ -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<ModelConfigSectionProps['catalog']>['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<typeof model> => 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 && (
<ExtendedContextToggle
model={currentModelEntry}
models={extendedContextModels}
provider={provider}
enabled={extendedContextEnabled ?? false}
onToggle={onExtendedContextToggle}
@@ -32,7 +32,7 @@ export function RawEditorSection({
</div>
}
>
<div className="h-full flex flex-col">
<div className="flex h-full min-h-0 flex-col">
{!isRawJsonValid && rawJsonEdits !== null && (
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
<X className="w-4 h-4" />
@@ -56,13 +56,14 @@ export function RawEditorSection({
</div>
</div>
)}
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
<div className="min-h-0 flex-1 overflow-hidden px-6 pb-4 pt-4">
<div className="h-full border rounded-md overflow-hidden bg-background">
<CodeEditor
value={rawJsonContent}
onChange={onRawJsonChange}
language="json"
minHeight="100%"
heightMode="fill-parent"
/>
</div>
</div>
@@ -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<string, string>, 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<string, string> = {};
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<string, string>) => {
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
@@ -85,15 +85,21 @@ export function RawJsonSettingsEditorPanel({
Loading settings.json...
</div>
) : (
<div className="h-full flex flex-col">
<div className="flex h-full min-h-0 flex-col">
{parseWarning && (
<div className="mx-4 mt-4 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-800 dark:bg-amber-950/20 dark:text-amber-300">
Parse warning: {parseWarning}
</div>
)}
<div className="flex-1 p-4 pt-3">
<div className="min-h-0 flex-1 p-4 pt-3">
<div className="h-full rounded-md border overflow-hidden bg-background">
<CodeEditor value={value} onChange={onChange} language="json" minHeight="100%" />
<CodeEditor
value={value}
onChange={onChange}
language="json"
minHeight="100%"
heightMode="fill-parent"
/>
</div>
</div>
</div>
@@ -40,7 +40,7 @@ export function RawEditorSection({
</div>
}
>
<div className="h-full flex flex-col">
<div className="flex h-full min-h-0 flex-col">
{!isRawJsonValid && rawJsonEdits !== null && (
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
<X className="w-4 h-4" />
@@ -63,13 +63,14 @@ export function RawEditorSection({
</div>
</div>
)}
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
<div className="min-h-0 flex-1 overflow-hidden px-6 pb-4 pt-4">
<div className="h-full border rounded-md overflow-hidden bg-background">
<CodeEditor
value={rawJsonContent}
onChange={onChange}
language="json"
minHeight="100%"
heightMode="fill-parent"
/>
</div>
</div>
@@ -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<string, AccountUsageStats>();
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;
+3 -3
View File
@@ -226,8 +226,8 @@ export function ProfileEditor({
</div>
</div>
) : (
<div className="flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
<div className="flex flex-col overflow-hidden bg-muted/5 min-w-0">
<div className="min-h-0 flex-1 grid grid-cols-[40%_60%] divide-x overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden bg-muted/5">
<FriendlyUISection
profileName={profileName}
target={resolvedTarget}
@@ -242,7 +242,7 @@ export function ProfileEditor({
onAddEnvVar={addNewEnvVar}
/>
</div>
<div className="flex flex-col overflow-hidden">
<div className="flex min-h-0 min-w-0 flex-col overflow-hidden">
<div className="px-6 py-2 bg-muted/30 border-b flex items-center gap-2 shrink-0 h-[45px]">
<Code2 className="w-4 h-4 text-muted-foreground" />
<span className="text-sm font-medium text-muted-foreground">
@@ -41,7 +41,7 @@ export function RawEditorSection({
</div>
}
>
<div className="h-full flex flex-col">
<div className="flex h-full min-h-0 flex-col">
{!isRawJsonValid && rawJsonEdits !== null && (
<div className="mb-2 px-3 py-2 bg-destructive/10 text-destructive text-sm rounded-md flex items-center gap-2 mx-6 mt-4 shrink-0">
<X className="w-4 h-4" />
@@ -64,13 +64,14 @@ export function RawEditorSection({
</div>
</div>
)}
<div className="flex-1 overflow-hidden px-6 pb-4 pt-4">
<div className="min-h-0 flex-1 overflow-hidden px-6 pb-4 pt-4">
<div className="h-full border rounded-md overflow-hidden bg-background">
<CodeEditor
value={rawJsonContent}
onChange={onChange}
language="json"
minHeight="100%"
heightMode="fill-parent"
/>
</div>
</div>
+34 -22
View File
@@ -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 (
<div className={cn('flex flex-col', className)}>
<div
className={cn('flex min-h-0 flex-col', isFillParent && 'h-full', className)}
style={isFillParent ? { height: minHeight === 'auto' ? undefined : minHeight } : undefined}
>
{/* Editor container */}
<div
className={cn(
'relative rounded-md border overflow-hidden',
'bg-muted/30',
isFillParent && 'flex min-h-0 flex-1 flex-col',
isFocused && 'ring-2 ring-ring ring-offset-2 ring-offset-background',
readonly && 'opacity-70 cursor-not-allowed',
!validation.valid && 'border-destructive'
)}
style={{ minHeight }}
data-slot="code-editor-surface"
>
<Editor
value={value}
onValueChange={readonly ? () => {} : 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,
}}
/>
<div
className={cn(isFillParent && 'scrollbar-editor min-h-0 flex-1 overflow-auto')}
data-slot={isFillParent ? 'code-editor-viewport' : undefined}
>
<Editor
value={value}
onValueChange={readonly ? () => {} : 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,
}}
/>
</div>
{/* Secrets Toggle Overlay */}
<div className="absolute top-2 right-2 z-10 opacity-50 hover:opacity-100 transition-opacity">
+2 -1
View File
@@ -279,7 +279,7 @@ function SettingsDialogContent({
</ScrollArea>
</TabsContent>
<TabsContent value="raw" className="flex-1 overflow-hidden p-4 pt-4 m-0">
<TabsContent value="raw" className="m-0 min-h-0 flex-1 overflow-hidden p-4 pt-4">
<Suspense
fallback={
<div className="flex items-center justify-center h-full">
@@ -293,6 +293,7 @@ function SettingsDialogContent({
onChange={handleRawJsonChange}
language="json"
minHeight="calc(60vh - 120px)"
heightMode="fill-parent"
/>
</Suspense>
</TabsContent>
+5 -1
View File
@@ -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;
+32
View File
@@ -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;
}
+16
View File
@@ -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<CliproxyStats, 'accountStats'> | null | undefined,
account: Pick<OAuthAccount, 'provider' | 'email' | 'id'>
): AccountUsageStats | undefined {
const source = account.email || account.id;
const qualifiedKey = buildQualifiedAccountStatsKey(account.provider, source);
return stats?.accountStats?.[qualifiedKey] ?? stats?.accountStats?.[source];
}
+7
View File
@@ -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';
+74 -52
View File
@@ -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: 'リモートに接続できない場合はローカルプロキシを使用します',
+13
View File
@@ -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<string, ProviderCatalog> = {
@@ -553,3 +554,15 @@ export const MODEL_CATALOGS: Record<string, ProviderCatalog> = {
],
},
};
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;
}
+1 -1
View File
@@ -354,7 +354,7 @@ export function ApiPage() {
/>
</div>
<div className="flex-1 flex flex-col min-w-0 overflow-hidden">
<div className="flex min-h-0 flex-1 flex-col min-w-0 overflow-hidden">
{selectedProfileData ? (
<>
<div className="px-4 py-2 border-b bg-background flex items-center justify-end gap-2">
+2
View File
@@ -1248,6 +1248,7 @@ function EntryInspector({
onChange={handleRawJsonChange}
language="json"
minHeight="100%"
heightMode="fill-parent"
/>
</div>
</div>
@@ -1271,6 +1272,7 @@ function EntryInspector({
language="json"
readonly
minHeight="100%"
heightMode="fill-parent"
/>
</div>
</div>
@@ -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<string, unknown>): Response {
return new Response(JSON.stringify(body), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}
describe('AddAccountDialog power user mode', () => {
const fetchMock = vi.fn<typeof fetch>();
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(
<AddAccountDialog open onClose={vi.fn()} provider="gemini" displayName="Gemini" />
);
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(
<AddAccountDialog open onClose={vi.fn()} provider="gemini" displayName="Gemini" />
);
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(
<AddAccountDialog open onClose={vi.fn()} provider="gemini" displayName="Gemini" />
);
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(<AddAccountDialog open onClose={vi.fn()} provider="agy" displayName="AGY" />);
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(<AddAccountDialog open onClose={vi.fn()} provider="agy" displayName="AGY" />);
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();
});
});
@@ -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();
});
});
@@ -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<string, unknown>, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
const wrapper = ({ children }: { children: ReactNode }) => (
<AllProviders>{children}</AllProviders>
);
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',
});
});
});
@@ -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);
}
}
);
});
@@ -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(
<CodeEditor
value={'{\n "provider": "openrouter"\n}'}
onChange={vi.fn()}
language="json"
minHeight="100%"
heightMode="fill-parent"
/>
);
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(
<CodeEditor
value={'{\n "provider": "openrouter"\n}'}
onChange={vi.fn()}
language="json"
readonly
minHeight="calc(60vh - 120px)"
heightMode="fill-parent"
/>
);
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(
<CodeEditor
value={'{\n "provider": "openrouter"\n}'}
onChange={vi.fn()}
language="json"
/>
);
expect(container.querySelector('[data-slot="code-editor-viewport"]')).not.toBeInTheDocument();
});
});
@@ -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<CliproxyStats, 'accountStats'>;
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<CliproxyStats, 'accountStats'>;
const account: OAuthAccount = { ...baseAccount, provider: 'codex' };
expect(getAccountStats(stats, account)).toMatchObject({
source: 'shared@example.com',
successCount: 7,
failureCount: 0,
});
});
});