mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-10 06:20:13 +00:00
Merge pull request #808 from kaitranntt/dev
feat(release): promote dev to main
This commit is contained in:
@@ -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 *)"
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export function buildQualifiedAccountStatsKey(provider: string, source: string): string {
|
||||
return `${provider.trim().toLowerCase()}:${source.trim()}`;
|
||||
}
|
||||
@@ -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
|
||||
),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
export * from './api-command/index';
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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('');
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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-');
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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
@@ -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: 'リモートに接続できない場合はローカルプロキシを使用します',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user