Merge pull request #391 from kaitranntt/dev

feat(release): v7.29.0 - account tier prioritization & local sync
This commit is contained in:
Kai (Tam Nhu) Tran
2026-01-28 16:59:56 -05:00
committed by GitHub
40 changed files with 3084 additions and 123 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@kaitranntt/ccs",
"version": "7.29.0",
"version": "7.29.0-dev.2",
"description": "Claude Code Switch - Instant profile switching between Claude Sonnet 4.5 and GLM 4.6",
"keywords": [
"cli",
+3 -3
View File
@@ -16,8 +16,8 @@ import { CLIPROXY_PROFILES } from '../auth/profile-detector';
import { getCliproxyDir, getAuthDir } from './config-generator';
import { PROVIDER_TYPE_VALUES } from './auth/auth-types';
/** Account tier for quota management (free vs paid - no Pro/Ultra distinction needed) */
export type AccountTier = 'free' | 'paid' | 'unknown';
/** Account tier for quota management: ultra > pro > free */
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
/**
* Providers that typically have empty email in OAuth token files.
@@ -47,7 +47,7 @@ export interface AccountInfo {
paused?: boolean;
/** ISO timestamp when paused */
pausedAt?: string;
/** Account tier: free or paid (Pro/Ultra combined) */
/** Account tier: ultra, pro, or free */
tier?: AccountTier;
/** GCP Project ID (Antigravity only) - read-only, fetched from auth token */
projectId?: string;
+23
View File
@@ -166,3 +166,26 @@ export {
THINKING_BUDGET_MAX,
THINKING_BUDGET_DEFAULT_MIN,
} from './thinking-validator';
// Management API client (for remote CLIProxy sync)
export type {
ClaudeKey,
ClaudeModel,
ManagementClientConfig,
ManagementHealthStatus,
ManagementApiErrorCode,
ClaudeKeyPatch,
SyncStatus,
} from './management-api-types';
export { ManagementApiClient, createManagementClient } from './management-api-client';
// Sync module (profile sync to remote CLIProxy)
export type { SyncableProfile, SyncPreviewItem } from './sync';
export {
loadSyncableProfiles,
mapProfileToClaudeKey,
generateSyncPayload,
generateSyncPreview,
getSyncableProfileCount,
isProfileSyncable,
} from './sync';
+405
View File
@@ -0,0 +1,405 @@
/**
* Management API Client for CLIProxyAPI
*
* HTTP client for CLIProxy Management API endpoints.
* Handles authentication, error mapping, and provides typed methods for CRUD operations.
*/
import * as https from 'https';
import type {
ManagementClientConfig,
ManagementHealthStatus,
ManagementApiErrorCode,
ClaudeKey,
GetClaudeKeysResponse,
ClaudeKeyPatch,
} from './management-api-types';
/** Default timeout for management operations (longer than health check) */
const DEFAULT_TIMEOUT_MS = 5000;
/** Default port for HTTP protocol */
const DEFAULT_HTTP_PORT = 8317;
/** Default port for HTTPS protocol */
const DEFAULT_HTTPS_PORT = 443;
/**
* Get effective port based on config and protocol.
*/
function getEffectivePort(port: number | undefined, protocol: 'http' | 'https'): number {
if (port !== undefined && Number.isInteger(port) && port > 0 && port <= 65535) {
return port;
}
return protocol === 'https' ? DEFAULT_HTTPS_PORT : DEFAULT_HTTP_PORT;
}
/**
* Build URL for Management API endpoint.
*/
function buildUrl(config: ManagementClientConfig, path: string): string {
const port = getEffectivePort(config.port, config.protocol);
// Only omit port if it matches standard web ports
if (
(config.protocol === 'https' && port === 443) ||
(config.protocol === 'http' && port === 80)
) {
return `${config.protocol}://${config.host}${path}`;
}
return `${config.protocol}://${config.host}:${port}${path}`;
}
/**
* Map error to ManagementApiErrorCode.
*/
function mapErrorToCode(error: Error, statusCode?: number): ManagementApiErrorCode {
const message = error.message.toLowerCase();
const rawCode = (error as NodeJS.ErrnoException).code;
const code = typeof rawCode === 'string' ? rawCode.toLowerCase() : undefined;
// DNS resolution failed
if (code === 'enotfound' || code === 'eai_again' || message.includes('dns')) {
return 'DNS_FAILED';
}
// Network unreachable
if (code === 'enetunreach' || code === 'ehostunreach' || message.includes('unreachable')) {
return 'NETWORK_UNREACHABLE';
}
// Connection refused
if (code === 'econnrefused' || message.includes('connection refused')) {
return 'CONNECTION_REFUSED';
}
// Timeout
if (code === 'etimedout' || message.includes('timeout') || message.includes('aborted')) {
return 'TIMEOUT';
}
// HTTP status codes
if (statusCode === 401 || statusCode === 403) {
return 'AUTH_FAILED';
}
if (statusCode === 404) {
return 'NOT_FOUND';
}
if (statusCode === 400) {
return 'BAD_REQUEST';
}
if (statusCode && statusCode >= 500) {
return 'SERVER_ERROR';
}
return 'UNKNOWN';
}
/**
* Get human-readable error message from error code.
*/
function getErrorMessage(errorCode: ManagementApiErrorCode, rawError?: string): string {
switch (errorCode) {
case 'CONNECTION_REFUSED':
return 'Connection refused - is CLIProxy running?';
case 'TIMEOUT':
return 'Request timed out - server may be slow or unreachable';
case 'AUTH_FAILED':
return 'Authentication failed - check management key';
case 'DNS_FAILED':
return 'DNS lookup failed - check hostname';
case 'NETWORK_UNREACHABLE':
return 'Network unreachable - check if host is accessible';
case 'NOT_FOUND':
return 'Endpoint not found - check CLIProxy version';
case 'BAD_REQUEST':
return 'Invalid request - check payload format';
case 'SERVER_ERROR':
return 'Server error - check CLIProxy logs';
default:
return rawError || 'Request failed';
}
}
/**
* Management API Client for CLIProxyAPI.
* Provides typed methods for CRUD operations on claude-api-key configuration.
*/
export class ManagementApiClient {
private readonly config: ManagementClientConfig;
private readonly timeout: number;
constructor(config: ManagementClientConfig) {
this.config = config;
this.timeout = config.timeout ?? DEFAULT_TIMEOUT_MS;
}
/**
* Build base URL for display purposes.
*/
getBaseUrl(): string {
return buildUrl(this.config, '');
}
/**
* Check health of Management API.
* Uses GET /v0/management/claude-api-key as health check.
*/
async health(): Promise<ManagementHealthStatus> {
const startTime = Date.now();
try {
const response = await this.request('GET', '/v0/management/claude-api-key');
const latencyMs = Date.now() - startTime;
return {
healthy: true,
latencyMs,
version: response.headers?.['x-cpa-version'],
commit: response.headers?.['x-cpa-commit'],
};
} catch (error) {
const err = error as Error & { statusCode?: number; errorCode?: ManagementApiErrorCode };
return {
healthy: false,
error: err.message,
errorCode: err.errorCode ?? 'UNKNOWN',
};
}
}
/**
* Get all claude-api-key entries from remote CLIProxy.
*/
async getClaudeKeys(): Promise<ClaudeKey[]> {
const response = await this.request<GetClaudeKeysResponse>(
'GET',
'/v0/management/claude-api-key'
);
return response.data?.['claude-api-key'] ?? [];
}
/**
* Replace all claude-api-key entries on remote CLIProxy.
* This is an atomic operation - all entries are replaced at once.
*/
async putClaudeKeys(keys: ClaudeKey[]): Promise<void> {
await this.request('PUT', '/v0/management/claude-api-key', keys);
}
/**
* Update a single claude-api-key entry by index or api-key match.
*/
async patchClaudeKey(patch: ClaudeKeyPatch): Promise<void> {
await this.request('PATCH', '/v0/management/claude-api-key', patch);
}
/**
* Delete a claude-api-key entry by api-key value.
*/
async deleteClaudeKey(apiKey: string): Promise<void> {
const encodedKey = encodeURIComponent(apiKey);
await this.request('DELETE', `/v0/management/claude-api-key?api-key=${encodedKey}`);
}
/**
* Make an HTTP request to the Management API.
*/
private async request<T>(
method: string,
path: string,
body?: unknown
): Promise<{ data?: T; headers?: Record<string, string> }> {
const url = buildUrl(this.config, path);
const headers: Record<string, string> = {
Accept: 'application/json',
Authorization: `Bearer ${this.config.managementKey}`,
};
if (body !== undefined) {
headers['Content-Type'] = 'application/json';
}
// Use native https for self-signed cert support
if (this.config.protocol === 'https' && this.config.allowSelfSigned) {
return this.requestWithHttps<T>(method, url, headers, body);
}
return this.requestWithFetch<T>(method, url, headers, body);
}
/**
* Make request using native fetch API.
*/
private async requestWithFetch<T>(
method: string,
url: string,
headers: Record<string, string>,
body?: unknown
): Promise<{ data?: T; headers?: Record<string, string> }> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
try {
const response = await fetch(url, {
method,
headers,
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
clearTimeout(timeoutId);
if (!response.ok) {
const errorCode = mapErrorToCode(new Error(response.statusText), response.status);
const error = new Error(getErrorMessage(errorCode)) as Error & {
statusCode: number;
errorCode: ManagementApiErrorCode;
};
error.statusCode = response.status;
error.errorCode = errorCode;
throw error;
}
// Extract headers we care about
const responseHeaders: Record<string, string> = {};
const version = response.headers.get('x-cpa-version');
const commit = response.headers.get('x-cpa-commit');
if (version) responseHeaders['x-cpa-version'] = version;
if (commit) responseHeaders['x-cpa-commit'] = commit;
// Parse JSON response if present
const text = await response.text();
let data: T | undefined;
if (text) {
try {
data = JSON.parse(text) as T;
} catch {
// Non-JSON response is ok for PUT/DELETE
}
}
return { data, headers: responseHeaders };
} catch (error) {
clearTimeout(timeoutId);
const err = error as Error & { statusCode?: number; errorCode?: ManagementApiErrorCode };
if (!err.errorCode) {
err.errorCode = mapErrorToCode(err, err.statusCode);
err.message = getErrorMessage(err.errorCode, err.message);
}
throw err;
}
}
/**
* Make request using native https module for self-signed cert support.
*/
private async requestWithHttps<T>(
method: string,
url: string,
headers: Record<string, string>,
body?: unknown
): Promise<{ data?: T; headers?: Record<string, string> }> {
return new Promise((resolve, reject) => {
const agent = new https.Agent({ rejectUnauthorized: false });
const bodyStr = body !== undefined ? JSON.stringify(body) : undefined;
if (bodyStr) {
headers['Content-Length'] = Buffer.byteLength(bodyStr).toString();
}
const reqTimeout = setTimeout(() => {
reject(new Error('Request timeout'));
}, this.timeout);
const req = https.request(
url,
{
method,
headers,
agent,
timeout: this.timeout,
},
(res) => {
clearTimeout(reqTimeout);
let data = '';
res.on('data', (chunk) => (data += chunk));
res.on('end', () => {
if (res.statusCode && (res.statusCode < 200 || res.statusCode >= 300)) {
const errorCode = mapErrorToCode(new Error(res.statusMessage || ''), res.statusCode);
const error = new Error(getErrorMessage(errorCode)) as Error & {
statusCode: number;
errorCode: ManagementApiErrorCode;
};
error.statusCode = res.statusCode;
error.errorCode = errorCode;
reject(error);
return;
}
const responseHeaders: Record<string, string> = {};
const version = res.headers['x-cpa-version'];
const commit = res.headers['x-cpa-commit'];
if (typeof version === 'string') responseHeaders['x-cpa-version'] = version;
if (typeof commit === 'string') responseHeaders['x-cpa-commit'] = commit;
let parsed: T | undefined;
if (data) {
try {
parsed = JSON.parse(data) as T;
} catch {
// Non-JSON response is ok
}
}
resolve({ data: parsed, headers: responseHeaders });
});
}
);
req.on('error', (err) => {
clearTimeout(reqTimeout);
const error = err as Error & { errorCode?: ManagementApiErrorCode };
error.errorCode = mapErrorToCode(err);
error.message = getErrorMessage(error.errorCode, err.message);
reject(error);
});
req.on('timeout', () => {
req.destroy();
const error = new Error('Request timeout') as Error & { errorCode: ManagementApiErrorCode };
error.errorCode = 'TIMEOUT';
reject(error);
});
if (bodyStr) {
req.write(bodyStr);
}
req.end();
});
}
}
/**
* Create a ManagementApiClient from CCS config.
* Uses cliproxy_server.remote settings.
*/
export function createManagementClient(
remoteConfig: {
host: string;
port?: number;
protocol: 'http' | 'https';
management_key?: string;
auth_token?: string;
timeout?: number;
},
allowSelfSigned = true
): ManagementApiClient {
return new ManagementApiClient({
host: remoteConfig.host,
port: remoteConfig.port,
protocol: remoteConfig.protocol,
managementKey: remoteConfig.management_key || remoteConfig.auth_token || '',
timeout: remoteConfig.timeout,
allowSelfSigned,
});
}
+123
View File
@@ -0,0 +1,123 @@
/**
* Management API Types for CLIProxyAPI
*
* Type definitions matching CLIProxyAPI Go structs for Management API CRUD operations.
* Used for syncing CCS API profiles to remote CLIProxy instance.
*/
/**
* Model alias within a ClaudeKey entry.
* Maps Claude model names to provider-specific models.
*/
export interface ClaudeModel {
/** Claude model name to match (e.g., "claude-3-5-sonnet") */
name: string;
/** Target model to use instead (e.g., "glm-4.7-airx-thinking") */
alias: string;
}
/**
* ClaudeKey configuration for CLIProxy.
* Maps to config.ClaudeKey in CLIProxyAPI Go code.
*/
export interface ClaudeKey {
/** API key for the provider */
'api-key': string;
/** Prefix for model name matching (e.g., "glm-" matches "glm-*" requests) */
prefix?: string;
/** Base URL for the provider API */
'base-url'?: string;
/** Optional proxy URL for requests */
'proxy-url'?: string;
/** Additional headers to include in requests */
headers?: Record<string, string>;
/** Models to exclude from this key */
'excluded-models'?: string[];
/** Model name to alias mappings */
models?: ClaudeModel[];
}
/**
* Configuration for the Management API client.
*/
export interface ManagementClientConfig {
/** Remote proxy host (IP or hostname) */
host: string;
/** Remote proxy port (default: 8317 for HTTP, 443 for HTTPS) */
port?: number;
/** Protocol to use (http or https) */
protocol: 'http' | 'https';
/** Management key for authentication (sent as Bearer token) */
managementKey: string;
/** Request timeout in milliseconds (default: 5000) */
timeout?: number;
/** Allow self-signed certificates for HTTPS (default: false) */
allowSelfSigned?: boolean;
}
/**
* Health check result from Management API.
*/
export interface ManagementHealthStatus {
/** Whether the Management API is reachable */
healthy: boolean;
/** Version of CLIProxyAPI (from X-CPA-VERSION header) */
version?: string;
/** Commit hash (from X-CPA-COMMIT header) */
commit?: string;
/** Latency in milliseconds */
latencyMs?: number;
/** Error message if not healthy */
error?: string;
/** Error code for programmatic handling */
errorCode?: ManagementApiErrorCode;
}
/**
* Error codes for Management API operations.
*/
export type ManagementApiErrorCode =
| 'CONNECTION_REFUSED'
| 'TIMEOUT'
| 'AUTH_FAILED'
| 'DNS_FAILED'
| 'NETWORK_UNREACHABLE'
| 'NOT_FOUND'
| 'BAD_REQUEST'
| 'SERVER_ERROR'
| 'UNKNOWN';
/**
* Response from GET /v0/management/claude-api-key
*/
export interface GetClaudeKeysResponse {
'claude-api-key': ClaudeKey[];
}
/**
* Patch request body for updating a single ClaudeKey.
*/
export interface ClaudeKeyPatch {
/** Index of the key to update (0-based) */
index?: number;
/** Match by api-key value */
match?: string;
/** Fields to update */
value: Partial<ClaudeKey>;
}
/**
* Sync status for tracking sync operations.
*/
export interface SyncStatus {
/** Last sync timestamp (ISO 8601) */
lastSyncAt?: string;
/** Number of profiles synced */
profileCount: number;
/** Whether sync was successful */
success: boolean;
/** Error message if failed */
error?: string;
/** Remote CLIProxy URL */
remoteUrl?: string;
}
+3 -3
View File
@@ -36,8 +36,8 @@ export interface ModelEntry {
id: string;
/** Human-readable name for display */
name: string;
/** Access tier indicator - 'paid' means requires paid Google account (not free tier) */
tier?: 'free' | 'paid';
/** Access tier indicator - 'ultra' for Claude, 'pro' for premium Gemini, 'free' for basic */
tier?: 'free' | 'pro' | 'ultra';
/** Optional description for the model */
description?: string;
/** Model has known issues - show warning when selected */
@@ -119,7 +119,7 @@ export const MODEL_CATALOG: Partial<Record<CLIProxyProvider, ProviderCatalog>> =
{
id: 'gemini-3-pro-preview',
name: 'Gemini 3 Pro',
tier: 'paid',
tier: 'pro',
description: 'Latest model, requires paid Google account',
thinking: { type: 'levels', levels: ['low', 'high'], dynamicAllowed: true },
},
+13 -3
View File
@@ -51,8 +51,13 @@ export function getCurrentModel(
* Format model entry for display in selection list
*/
function formatModelOption(model: ModelEntry): string {
// Tier badge: clarify that "paid" means paid Google account (not free tier)
const tierBadge = model.tier === 'paid' ? color(' [Paid Tier]', 'warning') : '';
// Tier badge: ultra/pro indicate paid tiers
const tierBadge =
model.tier === 'ultra'
? color(' [Ultra]', 'warning')
: model.tier === 'pro'
? color(' [Pro]', 'warning')
: '';
const brokenBadge = model.broken ? color(' [BROKEN]', 'error') : '';
const deprecatedBadge = model.deprecated ? color(' [DEPRECATED]', 'warning') : '';
return `${model.name}${tierBadge}${brokenBadge}${deprecatedBadge}`;
@@ -64,7 +69,12 @@ function formatModelOption(model: ModelEntry): string {
function formatModelDetailed(model: ModelEntry, isCurrent: boolean): string {
const marker = isCurrent ? color('>', 'success') : ' ';
const name = isCurrent ? bold(model.name) : model.name;
const tierBadge = model.tier === 'paid' ? color(' [Paid Tier]', 'warning') : '';
const tierBadge =
model.tier === 'ultra'
? color(' [Ultra]', 'warning')
: model.tier === 'pro'
? color(' [Pro]', 'warning')
: '';
const brokenBadge = model.broken ? color(' [BROKEN]', 'error') : '';
const deprecatedBadge = model.deprecated ? color(' [DEPRECATED]', 'warning') : '';
const desc = model.description ? dim(` - ${model.description}`) : '';
+23 -53
View File
@@ -303,48 +303,27 @@ function readAuthData(provider: CLIProxyProvider, accountId: string): AuthData |
/**
* Map tier ID string to AccountTier type
* Simplified: anything with 'pro' or 'ultra' = paid, 'free'/'legacy' = free
* API returns: "g1-ultra-tier", "g1-pro-tier", "standard-tier", etc.
* Priority: ultra > pro > free
*/
function mapTierString(tierStr: string | undefined): AccountTier {
if (!tierStr) return 'unknown';
const normalized = tierStr.toLowerCase();
if (normalized.includes('ultra') || normalized.includes('pro')) return 'paid';
// Match "g1-ultra-tier" or "ultra" anywhere in string
if (normalized.includes('ultra')) return 'ultra';
// Match "g1-pro-tier" or "pro" anywhere in string
if (normalized.includes('pro')) return 'pro';
// Match free/legacy tiers
if (normalized.includes('free') || normalized.includes('legacy')) {
return 'free';
}
// "standard-tier" and other unknown values should NOT map to 'free'
// Let inferTierFromModels handle the detection
// "standard-tier" and other unknown values = unknown
return 'unknown';
}
/**
* Infer tier from model access patterns.
* - Paid: Has access to Claude models OR premium Gemini models
* - Free: Only basic models
*
* Claude models are Ultra-exclusive, premium Gemini indicates Pro/Ultra.
* Both are "paid" tier for our purposes.
*/
function inferTierFromModels(models: ModelQuota[]): AccountTier {
if (models.length === 0) return 'unknown';
// Check for Claude models (Ultra-exclusive) or premium Gemini (Pro/Ultra)
const hasPaidAccess = models.some((m) => {
const name = m.name.toLowerCase();
return (
name.includes('claude') ||
name.includes('gemini-3-pro') ||
name.includes('gemini-2.5-pro') ||
name.includes('gemini-pro-high')
);
});
return hasPaidAccess ? 'paid' : 'unknown';
}
/**
* Get project ID and tier via loadCodeAssist endpoint
* Uses allowedTiers array with isDefault=true (CLIProxyAPIPlus approach)
* Uses paidTier.id for accurate tier detection (g1-ultra-tier, g1-pro-tier)
*/
async function getProjectId(accessToken: string): Promise<{
projectId: string | null;
@@ -406,17 +385,10 @@ async function getProjectId(accessToken: string): Promise<{
};
}
// Extract tier using CLIProxyAPIPlus approach:
// 1. Find tier with isDefault=true in allowedTiers array
// 2. Fallback to paidTier > currentTier
let tierStr: string | undefined;
if (data.allowedTiers && Array.isArray(data.allowedTiers)) {
const defaultTier = data.allowedTiers.find((t) => t.isDefault);
tierStr = defaultTier?.id;
}
if (!tierStr) {
tierStr = data.paidTier?.id || data.currentTier?.id;
}
// Extract tier - paidTier reflects actual subscription status, takes priority
// API returns: paidTier.id = "g1-ultra-tier" or "g1-pro-tier"
// allowedTiers/currentTier often return "standard-tier" which is not useful
const tierStr = data.paidTier?.id || data.currentTier?.id;
const tier = mapTierString(tierStr);
return { projectId: projectId.trim(), tier };
@@ -650,15 +622,14 @@ export async function fetchAccountQuota(
refreshResult.accessToken,
projectId as string
);
// Determine tier: model access (Claude = Ultra) > API tier > fallback
// Determine tier from API response only (model inference is unreliable)
if (retryResult.success) {
let finalTier = inferTierFromModels(retryResult.models);
if (finalTier === 'unknown') {
finalTier = apiTier !== 'unknown' ? apiTier : 'paid';
}
const finalTier = apiTier !== 'unknown' ? apiTier : 'unknown';
retryResult.tier = finalTier;
retryResult.accountId = accountId;
setAccountTier(provider, accountId, finalTier);
if (finalTier !== 'unknown') {
setAccountTier(provider, accountId, finalTier);
}
if (verbose && retryResult.error) {
console.log(`[!] Error: ${retryResult.error}`);
}
@@ -667,15 +638,14 @@ export async function fetchAccountQuota(
}
}
// Determine tier: model access > API tier > fallback to paid
// Determine tier from API response only
if (result.success) {
let finalTier = inferTierFromModels(result.models);
if (finalTier === 'unknown') {
finalTier = apiTier !== 'unknown' ? apiTier : 'paid';
}
const finalTier = apiTier !== 'unknown' ? apiTier : 'unknown';
result.tier = finalTier;
result.accountId = accountId;
setAccountTier(provider, accountId, finalTier);
if (finalTier !== 'unknown') {
setAccountTier(provider, accountId, finalTier);
}
}
if (verbose && result.error) {
+2 -2
View File
@@ -200,7 +200,7 @@ export async function findHealthyAccount(
exclude: string[]
): Promise<{ id: string; tier: string; lastQuota: number } | null> {
const config = loadOrCreateUnifiedConfig();
const tierPriority = config.quota_management?.auto?.tier_priority ?? ['paid'];
const tierPriority = config.quota_management?.auto?.tier_priority ?? ['ultra', 'pro', 'free'];
const threshold = config.quota_management?.auto?.exhaustion_threshold ?? 5;
const accounts = getProviderAccounts(provider);
@@ -225,7 +225,7 @@ export async function findHealthyAccount(
return {
id: account.id,
tier: account.tier || 'paid',
tier: account.tier || 'unknown',
lastQuota: avgQuota,
};
})
+212
View File
@@ -0,0 +1,212 @@
/**
* Auto-Sync Watcher
*
* Watches for profile settings changes and automatically syncs to local CLIProxy config.
* Uses debouncing to prevent sync storms during rapid edits.
*/
import * as chokidar from 'chokidar';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import { syncToLocalConfig } from './local-config-sync';
/** Debounce delay in milliseconds */
const DEBOUNCE_MS = 3000;
/** Singleton watcher instance */
let watcherInstance: chokidar.FSWatcher | null = null;
let syncTimeout: NodeJS.Timeout | null = null;
let isSyncing = false;
/**
* Check if auto-sync is enabled in config.
*/
export function isAutoSyncEnabled(): boolean {
try {
const config = loadOrCreateUnifiedConfig();
// For local sync, check cliproxy.auto_sync (simpler config location)
return config.cliproxy?.auto_sync === true;
} catch {
return false;
}
}
/**
* Log auto-sync message.
*/
function log(message: string): void {
console.log(`[auto-sync] ${message}`);
}
/**
* Execute sync to local CLIProxy config.
*/
async function triggerSync(): Promise<void> {
if (isSyncing) {
log('Sync already in progress, skipping');
return;
}
if (!isAutoSyncEnabled()) {
log('Auto-sync disabled, skipping');
return;
}
isSyncing = true;
try {
const result = syncToLocalConfig();
if (!result.success) {
log(`Sync failed: ${result.error}`);
return;
}
if (result.syncedCount === 0) {
log('No profiles to sync');
return;
}
log(`Success: ${result.syncedCount} profile(s) synced to ${result.configPath}`);
} catch (error) {
log(`Sync error: ${(error as Error).message}`);
} finally {
isSyncing = false;
}
}
/**
* Handle file change event with debouncing.
*/
function onFileChange(filePath: string): void {
const fileName = path.basename(filePath);
log(`Profile change detected: ${fileName}`);
// Clear existing timeout
if (syncTimeout) {
clearTimeout(syncTimeout);
}
log(`Waiting ${DEBOUNCE_MS / 1000}s for additional changes...`);
// Set new debounced timeout
syncTimeout = setTimeout(() => {
syncTimeout = null;
triggerSync().catch((err) => {
log(`Sync error: ${err.message}`);
});
}, DEBOUNCE_MS);
}
/**
* Start the auto-sync watcher.
* Watches ~/.ccs/*.settings.json for changes.
*/
export function startAutoSyncWatcher(): void {
if (watcherInstance) {
log('Watcher already running');
return;
}
if (!isAutoSyncEnabled()) {
// Don't start if disabled, but log nothing (called at startup)
return;
}
const ccsDir = getCcsDir();
const watchPattern = path.join(ccsDir, '*.settings.json');
log(`Starting watcher on ${watchPattern}`);
watcherInstance = chokidar.watch(watchPattern, {
ignoreInitial: true, // Don't trigger on initial scan
persistent: true,
awaitWriteFinish: {
stabilityThreshold: 500,
pollInterval: 100,
},
});
watcherInstance.on('change', onFileChange);
watcherInstance.on('add', onFileChange);
watcherInstance.on('unlink', onFileChange);
watcherInstance.on('error', (error) => {
log(`Watcher error: ${error.message}`);
});
log('Watcher started');
}
/**
* Stop the auto-sync watcher.
*/
export async function stopAutoSyncWatcher(): Promise<void> {
if (syncTimeout) {
clearTimeout(syncTimeout);
syncTimeout = null;
}
if (watcherInstance) {
const closePromise = watcherInstance.close();
const timeoutPromise = new Promise<void>((_, reject) =>
setTimeout(() => reject(new Error('Close timeout')), 5000)
);
try {
await Promise.race([closePromise, timeoutPromise]);
} catch (err) {
log(`Warning: ${(err as Error).message}, forcing cleanup`);
}
watcherInstance = null;
log('Watcher stopped');
}
// Reset flag to prevent stale state
isSyncing = false;
}
/**
* Restart the watcher (after config change).
*/
export async function restartAutoSyncWatcher(): Promise<void> {
// Wait for any active sync to complete (max 10s)
const maxWait = 10000;
const start = Date.now();
while (isSyncing && Date.now() - start < maxWait) {
await new Promise((resolve) => setTimeout(resolve, 100));
}
if (isSyncing) {
log('Warning: Sync still in progress after 10s timeout, proceeding with restart');
}
await stopAutoSyncWatcher();
startAutoSyncWatcher();
}
/**
* Get watcher status.
*/
export function getAutoSyncStatus(): {
enabled: boolean;
watching: boolean;
syncing: boolean;
} {
return {
enabled: isAutoSyncEnabled(),
watching: watcherInstance !== null,
syncing: isSyncing,
};
}
/**
* Reset watcher state for test cleanup.
* Stops watcher and clears all singleton state.
*/
export async function resetWatcherState(): Promise<void> {
await stopAutoSyncWatcher();
watcherInstance = null;
syncTimeout = null;
isSyncing = false;
}
+28
View File
@@ -0,0 +1,28 @@
/**
* CLIProxy Sync Module
*
* Profile sync functionality for syncing CCS API profiles to CLIProxy config.
*/
// Profile mapper
export type { SyncableProfile, SyncPreviewItem } from './profile-mapper';
export {
loadSyncableProfiles,
mapProfileToClaudeKey,
generateSyncPayload,
generateSyncPreview,
getSyncableProfileCount,
isProfileSyncable,
} from './profile-mapper';
// Local config sync
export { syncToLocalConfig, getLocalSyncStatus } from './local-config-sync';
// Auto-sync watcher
export {
startAutoSyncWatcher,
stopAutoSyncWatcher,
restartAutoSyncWatcher,
isAutoSyncEnabled,
getAutoSyncStatus,
} from './auto-sync-watcher';
+234
View File
@@ -0,0 +1,234 @@
/**
* Local Config Sync
*
* Syncs CCS API profiles to the local CLIProxy config.yaml.
* Uses section-based replacement to preserve comments and formatting.
*/
import * as fs from 'fs';
import * as yaml from 'js-yaml';
import { getCliproxyConfigPath } from '../config-generator';
import { generateSyncPayload } from './profile-mapper';
import type { ClaudeKey } from '../management-api-types';
/**
* Sync profiles to local CLIProxy config.yaml.
* Replaces only the claude-api-key section, preserving all other content.
*
* @returns Object with success status and synced count
*/
export function syncToLocalConfig(): {
success: boolean;
syncedCount: number;
configPath: string;
error?: string;
} {
const configPath = getCliproxyConfigPath();
try {
// Generate payload from CCS profiles
const payload = generateSyncPayload();
if (payload.length === 0) {
return {
success: true,
syncedCount: 0,
configPath,
};
}
// Read existing config
if (!fs.existsSync(configPath)) {
return {
success: false,
syncedCount: 0,
configPath,
error: 'CLIProxy config not found. Run ccs doctor to generate.',
};
}
let configContent: string;
try {
configContent = fs.readFileSync(configPath, 'utf8');
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
return {
success: false,
syncedCount: 0,
configPath,
error: 'CLIProxy config deleted during sync. Run ccs doctor to regenerate.',
};
}
throw error;
}
// Transform payload to config format
const claudeApiKeys = payload.map(transformToConfigFormat);
// Generate YAML for the claude-api-key section only
const newSection = yaml.dump(
{ 'claude-api-key': claudeApiKeys },
{
indent: 2,
lineWidth: -1,
quotingType: "'",
forceQuotes: false,
}
);
// Replace section in original content (preserves comments/formatting)
const newContent = replaceSectionInYaml(configContent, 'claude-api-key', newSection);
// Atomic write with cleanup on failure
const tempPath = configPath + '.tmp';
try {
fs.writeFileSync(tempPath, newContent, { mode: 0o600 });
fs.renameSync(tempPath, configPath);
} catch (writeError) {
// Clean up temp file if it exists
if (fs.existsSync(tempPath)) {
try {
fs.unlinkSync(tempPath);
} catch {
// Ignore cleanup errors
}
}
throw writeError;
}
return {
success: true,
syncedCount: payload.length,
configPath,
};
} catch (error) {
return {
success: false,
syncedCount: 0,
configPath,
error: (error as Error).message,
};
}
}
/**
* Replace a top-level section in YAML content while preserving rest of file.
* Finds the section by key name and replaces it (including nested content).
*/
function replaceSectionInYaml(content: string, sectionKey: string, newSection: string): string {
const lines = content.split('\n');
const result: string[] = [];
let inSection = false;
let sectionFound = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const trimmed = line.trimStart();
// Check if this is the start of our target section
if (trimmed.startsWith(`${sectionKey}:`)) {
inSection = true;
sectionFound = true;
// Insert new section here
result.push(newSection.trimEnd());
continue;
}
// If we're in the section, skip lines until we hit another top-level key
if (inSection) {
// Top-level key: starts at column 0, valid YAML key format (word chars + hyphens)
// Must match pattern like "key:", "my-key:", "key_name:" but not comments or strings
const isTopLevelKey =
line.length > 0 &&
!line.startsWith(' ') &&
!line.startsWith('\t') &&
!line.startsWith('#') &&
/^[a-zA-Z_][a-zA-Z0-9_-]*\s*:/.test(line);
if (isTopLevelKey) {
// We've exited the section, resume normal processing
inSection = false;
result.push(line);
}
// Otherwise skip this line (part of old section)
continue;
}
result.push(line);
}
// If section wasn't found, append it at the end
if (!sectionFound) {
result.push('');
result.push(newSection.trimEnd());
}
return result.join('\n');
}
/**
* Transform ClaudeKey to config.yaml format.
* The config format uses slightly different field names.
*/
function transformToConfigFormat(key: ClaudeKey): Record<string, unknown> {
const entry: Record<string, unknown> = {
'api-key': key['api-key'],
};
if (key['base-url']) {
entry['base-url'] = key['base-url'];
}
// Add empty proxy-url (required by CLIProxyAPI)
entry['proxy-url'] = '';
// Use model name directly (no alias mapping)
if (key.models && key.models.length > 0) {
entry.models = key.models.map((m) => ({
name: m.name,
alias: '',
}));
}
// Note: prefix is not used in local config - it's for remote routing only
return entry;
}
/**
* Get local sync status.
*/
export function getLocalSyncStatus(): {
configExists: boolean;
configPath: string;
currentKeyCount: number;
syncableProfileCount: number;
} {
const configPath = getCliproxyConfigPath();
let currentKeyCount = 0;
if (fs.existsSync(configPath)) {
try {
const content = fs.readFileSync(configPath, 'utf8');
const config = yaml.load(content) as Record<string, unknown> | null;
if (config && typeof config === 'object') {
const keys = config['claude-api-key'];
if (Array.isArray(keys)) {
currentKeyCount = keys.length;
}
}
} catch {
// Ignore parse errors
}
}
const payload = generateSyncPayload();
return {
configExists: fs.existsSync(configPath),
configPath,
currentKeyCount,
syncableProfileCount: payload.length,
};
}
+200
View File
@@ -0,0 +1,200 @@
/**
* Profile Mapper for CLIProxy Sync
*
* Transforms CCS settings-based profiles into CLIProxy ClaudeKey format.
*/
import * as fs from 'fs';
import * as path from 'path';
import { getCcsDir } from '../../utils/config-manager';
import { listApiProfiles, isApiProfileConfigured } from '../../api/services/profile-reader';
import type { ClaudeKey } from '../management-api-types';
/**
* Profile info with settings for sync.
*/
export interface SyncableProfile {
/** Profile name (e.g., "glm", "kimi") */
name: string;
/** Path to settings.json file */
settingsPath: string;
/** Whether profile has valid API key */
isConfigured: boolean;
/** Environment variables from settings.json */
env?: Record<string, string>;
}
/**
* Settings.json file structure (Claude compatible).
*/
interface SettingsJson {
env?: Record<string, string>;
}
/**
* Load syncable API profiles from CCS config.
* Filters to only configured profiles (with real API keys).
*/
export function loadSyncableProfiles(): SyncableProfile[] {
const { profiles } = listApiProfiles();
const syncable: SyncableProfile[] = [];
for (const profile of profiles) {
// Skip unconfigured profiles
if (!profile.isConfigured) {
continue;
}
// Load settings.json for env vars
const ccsDir = getCcsDir();
const settingsPath = path.join(ccsDir, `${profile.name}.settings.json`);
let env: Record<string, string> | undefined;
try {
if (fs.existsSync(settingsPath)) {
const content = fs.readFileSync(settingsPath, 'utf8');
const settings = JSON.parse(content) as SettingsJson;
env = settings.env;
}
} catch {
// Skip profiles with unreadable settings
continue;
}
// Must have ANTHROPIC_AUTH_TOKEN
const token = env?.ANTHROPIC_AUTH_TOKEN;
if (!token || token.includes('YOUR_') || token.includes('your-')) {
continue;
}
syncable.push({
name: profile.name,
settingsPath,
isConfigured: true,
env,
});
}
return syncable;
}
/**
* Sanitize profile name for YAML safety.
* Replaces non-alphanumeric chars (except - and _) with hyphens.
*/
function sanitizeProfileName(name: string): string {
return name.replace(/[^a-zA-Z0-9-_]/g, '-');
}
/**
* Map a single profile to ClaudeKey format.
*/
export function mapProfileToClaudeKey(profile: SyncableProfile): ClaudeKey | null {
const env = profile.env;
if (!env) return null;
const apiKey = env.ANTHROPIC_AUTH_TOKEN;
if (!apiKey) return null;
const baseUrl = env.ANTHROPIC_BASE_URL;
const modelName = env.ANTHROPIC_MODEL;
// Generate prefix from profile name (e.g., "glm" -> "glm-")
const sanitizedName = sanitizeProfileName(profile.name);
if (!sanitizedName || sanitizedName === '') {
return null; // Skip profiles with invalid names
}
// Skip if name is too long (>64 chars)
if (sanitizedName.length > 64) {
return null;
}
// Skip if name has no alphanumeric characters (e.g., only special chars -> "-----")
if (!/[a-zA-Z0-9]/.test(sanitizedName)) {
return null;
}
const prefix = `${sanitizedName}-`;
const claudeKey: ClaudeKey = {
'api-key': apiKey,
prefix,
};
if (baseUrl) {
claudeKey['base-url'] = baseUrl;
}
// Use model name directly from profile (no alias mapping)
if (modelName) {
claudeKey.models = [
{
name: modelName,
alias: '',
},
];
}
return claudeKey;
}
/**
* Generate sync payload from all configured profiles.
* Returns array of ClaudeKey ready to push to remote CLIProxy.
*/
export function generateSyncPayload(): ClaudeKey[] {
const profiles = loadSyncableProfiles();
const keys: ClaudeKey[] = [];
for (const profile of profiles) {
const key = mapProfileToClaudeKey(profile);
if (key) {
keys.push(key);
}
}
return keys;
}
/**
* Generate sync preview with profile details.
* Used for dry-run mode to show what would be synced.
*/
export interface SyncPreviewItem {
/** Profile name */
name: string;
/** Base URL (masked) */
baseUrl?: string;
/** Model name */
modelName?: string;
}
export function generateSyncPreview(): SyncPreviewItem[] {
const profiles = loadSyncableProfiles();
const preview: SyncPreviewItem[] = [];
for (const profile of profiles) {
preview.push({
name: profile.name,
baseUrl: profile.env?.ANTHROPIC_BASE_URL,
modelName: profile.env?.ANTHROPIC_MODEL,
});
}
return preview;
}
/**
* Get count of syncable profiles.
*/
export function getSyncableProfileCount(): number {
return loadSyncableProfiles().length;
}
/**
* Check if profile is syncable (configured with valid API key).
*/
export function isProfileSyncable(profileName: string): boolean {
return isApiProfileConfigured(profileName);
}
+9
View File
@@ -39,6 +39,7 @@ import {
getPresetIds,
type ModelMapping,
} from '../api/services';
import { syncToLocalConfig } from '../cliproxy/sync/local-config-sync';
interface ApiCommandArgs {
name?: string;
@@ -276,6 +277,14 @@ async function handleCreate(args: string[]): Promise<void> {
process.exit(1);
}
// Trigger sync to local CLIProxy config (best-effort)
try {
syncToLocalConfig();
} catch (err) {
const message = err instanceof Error ? err.message : 'Unknown error';
console.log(`[i] Auto-sync to CLIProxy config skipped: ${message}`);
}
// Display success
console.log('');
const hasCustomMapping = opusModel !== model || sonnetModel !== model || haikuModel !== model;
+22 -1
View File
@@ -64,6 +64,9 @@ import {
installLatest,
} from '../cliproxy/services';
// Import sync handler
import { handleSync } from './cliproxy-sync-handler';
// ============================================================================
// ARGUMENT PARSING
// ============================================================================
@@ -149,7 +152,12 @@ function parseProfileArgs(args: string[]): CliproxyProfileArgs {
}
function formatModelOption(model: ModelEntry): string {
const tierBadge = model.tier === 'paid' ? color(' [Paid Tier]', 'warning') : '';
const tierBadge =
model.tier === 'ultra'
? color(' [Ultra]', 'warning')
: model.tier === 'pro'
? color(' [Pro]', 'warning')
: '';
return `${model.name}${tierBadge}`;
}
@@ -613,6 +621,14 @@ async function showHelp(): Promise<void> {
['remove <name>', 'Remove a CLIProxy variant profile'],
],
],
[
'Local Sync:',
[
['sync', 'Sync API profiles to local CLIProxy config'],
['sync --dry-run', 'Preview sync without applying'],
['sync --verbose', 'Show detailed sync information'],
],
],
[
'Quota Management:',
[
@@ -1004,6 +1020,11 @@ export async function handleCliproxyCommand(args: string[]): Promise<void> {
return;
}
if (command === 'sync') {
await handleSync(remainingArgs.slice(1));
return;
}
if (command === 'stop') {
await handleStop();
return;
+117
View File
@@ -0,0 +1,117 @@
/**
* CLIProxy Sync Command Handler
*
* Handles `ccs cliproxy sync` command for syncing API profiles to local CLIProxy config.
*/
import { syncToLocalConfig, generateSyncPreview, getLocalSyncStatus } from '../cliproxy/sync';
import { initUI, header, subheader, color, dim, ok, fail, warn, info, table } from '../utils/ui';
interface SyncArgs {
dryRun: boolean;
verbose: boolean;
}
/**
* Parse sync command arguments.
*/
export function parseSyncArgs(args: string[]): SyncArgs {
return {
dryRun: args.includes('--dry-run') || args.includes('-n'),
verbose: args.includes('--verbose') || args.includes('-v'),
};
}
/**
* Handle `ccs cliproxy sync` command.
*/
export async function handleSync(args: string[]): Promise<void> {
await initUI();
const parsed = parseSyncArgs(args);
console.log(header('CLIProxy Profile Sync'));
console.log('');
// Check status
const status = getLocalSyncStatus();
if (!status.configExists) {
console.log(fail('CLIProxy config not found'));
console.log('');
console.log('Run to generate config:');
console.log(` ${color('ccs doctor --fix', 'command')}`);
console.log('');
process.exit(1);
}
// Get preview
const preview = generateSyncPreview();
if (preview.length === 0) {
console.log(warn('No API profiles configured to sync'));
console.log('');
console.log('Configure API profiles with:');
console.log(` ${color('ccs api create', 'command')}`);
console.log('');
return;
}
// Show preview
console.log(subheader(`Profiles to Sync (${preview.length})`));
console.log('');
const rows = preview.map((p) => {
const model = p.modelName ? color(p.modelName, 'info') : dim('default');
const url = p.baseUrl ? dim(p.baseUrl) : dim('-');
return [p.name, url, model];
});
console.log(table(rows, { head: ['Profile', 'Base URL', 'Model'], colWidths: [15, 40, 20] }));
console.log('');
if (parsed.verbose) {
console.log(dim(`Config path: ${status.configPath}`));
console.log(dim(`Current keys: ${status.currentKeyCount}`));
console.log('');
}
// Dry-run mode
if (parsed.dryRun) {
console.log(info('Dry-run mode - no changes will be made'));
console.log('');
console.log(`Would sync ${preview.length} profile(s) to:`);
console.log(` ${dim(status.configPath)}`);
console.log('');
console.log('Run without --dry-run to apply changes:');
console.log(` ${color('ccs cliproxy sync', 'command')}`);
console.log('');
return;
}
// Execute sync
console.log(info('Syncing profiles to local config...'));
const result = syncToLocalConfig();
if (!result.success) {
console.log('');
console.log(fail(`Sync failed: ${result.error}`));
console.log('');
process.exit(1);
}
console.log('');
console.log(ok(`Synced ${result.syncedCount} profile(s)`));
console.log(` ${dim(result.configPath)}`);
console.log('');
// Show synced profiles
for (const p of preview) {
console.log(` ${ok('')} ${p.name}`);
}
console.log('');
console.log(info('Restart CLIProxy to apply changes:'));
console.log(` ${color('ccs cliproxy stop && ccs gemini', 'command')}`);
console.log('');
}
+2
View File
@@ -159,6 +159,8 @@ function mergeWithDefaults(partial: Partial<UnifiedConfig>): UnifiedConfig {
partial.cliproxy?.backend === 'original' || partial.cliproxy?.backend === 'plus'
? partial.cliproxy.backend
: undefined, // Invalid values become undefined (defaults to 'plus' at runtime)
// Auto-sync - default to true
auto_sync: partial.cliproxy?.auto_sync ?? defaults.cliproxy.auto_sync ?? true,
},
preferences: {
...defaults.preferences,
+6 -1
View File
@@ -132,6 +132,8 @@ export interface CLIProxyConfig {
auth?: CLIProxyAuthConfig;
/** Background token refresh worker settings */
token_refresh?: TokenRefreshSettings;
/** Auto-sync API profiles to local CLIProxy config on settings change (default: true) */
auto_sync?: boolean;
}
/**
@@ -259,6 +261,8 @@ export interface ProxyRemoteConfig {
management_key?: string;
/** Connection timeout in milliseconds (default: 2000) */
timeout?: number;
/** Enable auto-sync profiles to remote on settings change (default: false) */
auto_sync?: boolean;
}
/**
@@ -405,7 +409,7 @@ export interface QuotaManagementConfig {
export const DEFAULT_AUTO_QUOTA_CONFIG: AutoQuotaConfig = {
preflight_check: true,
exhaustion_threshold: 5,
tier_priority: ['paid'],
tier_priority: ['ultra', 'pro', 'free'],
cooldown_minutes: 5,
};
@@ -605,6 +609,7 @@ export function createEmptyUnifiedConfig(): UnifiedConfig {
enabled: false,
request_log: false,
},
auto_sync: true,
},
preferences: {
theme: 'system',
+11 -1
View File
@@ -12,6 +12,7 @@ import path from 'path';
import { WebSocketServer } from 'ws';
import { setupWebSocket } from './websocket';
import { createSessionMiddleware, authMiddleware } from './middleware/auth-middleware';
import { startAutoSyncWatcher, stopAutoSyncWatcher } from '../cliproxy/sync';
export interface ServerOptions {
port: number;
@@ -93,7 +94,16 @@ export async function startServer(options: ServerOptions): Promise<ServerInstanc
}
// WebSocket connection handler + file watcher
const { cleanup } = setupWebSocket(wss);
const { cleanup: wsCleanup } = setupWebSocket(wss);
// Start auto-sync watcher (if enabled in config)
startAutoSyncWatcher();
// Combined cleanup function
const cleanup = () => {
wsCleanup();
stopAutoSyncWatcher().catch(() => {});
};
// Start listening
return new Promise<ServerInstance>((resolve) => {
@@ -0,0 +1,174 @@
/**
* CLIProxy Sync Routes - Local sync management for CLIProxy profiles
*/
import { Router, Request, Response } from 'express';
import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader';
import {
generateSyncPayload,
generateSyncPreview,
getAutoSyncStatus,
restartAutoSyncWatcher,
syncToLocalConfig,
getLocalSyncStatus,
} from '../../cliproxy/sync';
import { saveUnifiedConfig } from '../../config/unified-config-loader';
const router = Router();
/**
* GET /api/cliproxy/sync/status - Get local sync status
* Returns: { configExists, configPath, currentKeyCount, syncableProfileCount }
*/
router.get('/status', async (_req: Request, res: Response): Promise<void> => {
try {
const status = getLocalSyncStatus();
res.json({
configured: status.configExists,
configPath: status.configPath,
currentKeyCount: status.currentKeyCount,
syncableProfileCount: status.syncableProfileCount,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* GET /api/cliproxy/sync/preview - Get sync preview (dry run)
* Returns: { profiles: SyncPreviewItem[], payload: ClaudeKey[] }
*/
router.get('/preview', async (_req: Request, res: Response): Promise<void> => {
try {
const preview = generateSyncPreview();
const payload = generateSyncPayload();
// Mask API keys in payload for preview
const maskedPayload = payload.map((key) => ({
...key,
'api-key': maskApiKey(key['api-key']),
}));
res.json({
profiles: preview,
payload: maskedPayload,
count: payload.length,
});
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* POST /api/cliproxy/sync - Execute sync to local CLIProxy config
* Returns: { success, syncedCount, configPath, error? }
*/
router.post('/', async (_req: Request, res: Response): Promise<void> => {
try {
const result = syncToLocalConfig();
if (!result.success) {
res.status(500).json({
success: false,
error: result.error,
configPath: result.configPath,
});
return;
}
if (result.syncedCount === 0) {
res.json({
success: true,
syncedCount: 0,
message: 'No profiles to sync',
configPath: result.configPath,
});
return;
}
const preview = generateSyncPreview();
res.json({
success: true,
syncedCount: result.syncedCount,
configPath: result.configPath,
profiles: preview.map((p) => p.name),
});
} catch (error) {
res.status(500).json({
success: false,
error: (error as Error).message,
});
}
});
// ==================== Auto-Sync ====================
/**
* GET /api/cliproxy/sync/auto-sync - Get auto-sync status
* Returns: { enabled, watching, syncing }
*/
router.get('/auto-sync', async (_req: Request, res: Response): Promise<void> => {
try {
const status = getAutoSyncStatus();
res.json(status);
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* PUT /api/cliproxy/sync/auto-sync - Toggle auto-sync setting
* Body: { enabled: boolean }
* Returns: { success: true, enabled }
*/
router.put('/auto-sync', async (req: Request, res: Response): Promise<void> => {
try {
const { enabled } = req.body;
if (typeof enabled !== 'boolean') {
res.status(400).json({ error: 'Invalid field: enabled must be a boolean' });
return;
}
// Update config
const config = loadOrCreateUnifiedConfig();
if (!config.cliproxy) {
// Should not happen as loadOrCreate initializes it, but handle gracefully
res.status(500).json({ error: 'CLIProxy config not initialized' });
return;
}
// Save config
try {
config.cliproxy.auto_sync = enabled;
saveUnifiedConfig(config);
} catch (error) {
res.status(500).json({ error: `Failed to save config: ${(error as Error).message}` });
return;
}
// Restart watcher (separate operation)
try {
await restartAutoSyncWatcher();
} catch (watcherError) {
// Log but don't fail - config was saved successfully
console.warn('Watcher restart failed:', (watcherError as Error).message);
}
res.json({ success: true, enabled });
} catch (error) {
res.status(500).json({ error: (error as Error).message });
}
});
/**
* Mask API key for display (show first 4 and last 4 chars).
*/
function maskApiKey(key: string): string {
if (key.length <= 12) {
return '***';
}
return `${key.slice(0, 4)}...${key.slice(-4)}`;
}
export default router;
+2
View File
@@ -18,6 +18,7 @@ import settingsRoutes from './settings-routes';
import websearchRoutes from './websearch-routes';
import cliproxyAuthRoutes from './cliproxy-auth-routes';
import cliproxyStatsRoutes from './cliproxy-stats-routes';
import cliproxySyncRoutes from './cliproxy-sync-routes';
import copilotRoutes from './copilot-routes';
import miscRoutes from './misc-routes';
import cliproxyServerRoutes from './proxy-routes';
@@ -51,6 +52,7 @@ apiRoutes.use('/persist', persistRoutes);
apiRoutes.use('/cliproxy', variantRoutes);
apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes);
apiRoutes.use('/cliproxy', cliproxyStatsRoutes);
apiRoutes.use('/cliproxy/sync', cliproxySyncRoutes);
apiRoutes.use('/cliproxy/openai-compat', providerRoutes);
// ==================== WebSearch ====================
@@ -0,0 +1,123 @@
/**
* Tests for Auto-Sync Watcher
* Verifies debounce behavior, watcher lifecycle, and state management.
*/
import * as assert from 'assert';
describe('Auto-Sync Watcher', () => {
const autoSyncWatcher = require('../../../dist/cliproxy/sync/auto-sync-watcher');
beforeEach(async () => {
// Ensure clean state before each test
await autoSyncWatcher.resetWatcherState();
});
afterEach(async () => {
// Clean up after each test
await autoSyncWatcher.stopAutoSyncWatcher();
});
describe('isAutoSyncEnabled', () => {
it('returns a boolean', () => {
const result = autoSyncWatcher.isAutoSyncEnabled();
assert.ok(typeof result === 'boolean');
});
});
describe('getAutoSyncStatus', () => {
it('returns status object with required fields', () => {
const status = autoSyncWatcher.getAutoSyncStatus();
assert.ok(typeof status === 'object');
assert.ok(typeof status.enabled === 'boolean');
assert.ok(typeof status.watching === 'boolean');
assert.ok(typeof status.syncing === 'boolean');
});
it('reports not watching when watcher not started', () => {
const status = autoSyncWatcher.getAutoSyncStatus();
assert.strictEqual(status.watching, false);
});
it('reports not syncing initially', () => {
const status = autoSyncWatcher.getAutoSyncStatus();
assert.strictEqual(status.syncing, false);
});
});
describe('startAutoSyncWatcher', () => {
it('does not throw when called', () => {
assert.doesNotThrow(() => {
autoSyncWatcher.startAutoSyncWatcher();
});
});
it('is idempotent (can be called multiple times)', () => {
assert.doesNotThrow(() => {
autoSyncWatcher.startAutoSyncWatcher();
autoSyncWatcher.startAutoSyncWatcher();
});
});
});
describe('stopAutoSyncWatcher', () => {
it('resolves without error', async () => {
await assert.doesNotReject(async () => {
await autoSyncWatcher.stopAutoSyncWatcher();
});
});
it('can stop when not started', async () => {
await assert.doesNotReject(async () => {
await autoSyncWatcher.stopAutoSyncWatcher();
});
});
});
describe('restartAutoSyncWatcher', () => {
it('resolves without error', async () => {
await assert.doesNotReject(async () => {
await autoSyncWatcher.restartAutoSyncWatcher();
});
});
});
describe('resetWatcherState', () => {
it('clears all state', async () => {
// Start watcher to set state
autoSyncWatcher.startAutoSyncWatcher();
// Reset state
await autoSyncWatcher.resetWatcherState();
// Verify state is cleared
const status = autoSyncWatcher.getAutoSyncStatus();
assert.strictEqual(status.watching, false);
assert.strictEqual(status.syncing, false);
});
it('is safe to call multiple times', async () => {
await assert.doesNotReject(async () => {
await autoSyncWatcher.resetWatcherState();
await autoSyncWatcher.resetWatcherState();
await autoSyncWatcher.resetWatcherState();
});
});
});
describe('Debounce Behavior', () => {
/**
* Debounce is set to 3000ms (DEBOUNCE_MS constant).
* This prevents sync storms during rapid edits.
*
* Testing actual debounce requires file system manipulation
* and timing-based assertions, which are flaky in unit tests.
* Integration tests should cover this behavior.
*/
it('debounce constant is documented', () => {
// DEBOUNCE_MS = 3000 (3 seconds)
assert.ok(true, 'Debounce documented in code comments');
});
});
});
@@ -0,0 +1,98 @@
/**
* Tests for Local Config Sync
* Verifies YAML section replacement logic and sync behavior.
*/
import * as assert from 'assert';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
describe('Local Config Sync', () => {
const localConfigSync = require('../../../dist/cliproxy/sync/local-config-sync');
describe('syncToLocalConfig', () => {
it('returns success with zero count when no profiles', () => {
// If no profiles are configured, sync should succeed with 0 count
const result = localConfigSync.syncToLocalConfig();
assert.ok(typeof result === 'object');
assert.ok(typeof result.success === 'boolean');
assert.ok(typeof result.syncedCount === 'number');
assert.ok(typeof result.configPath === 'string');
});
it('returns error when config file not found', () => {
// Set CCS_HOME to temp dir without config
const originalHome = process.env.CCS_HOME;
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-test-'));
try {
process.env.CCS_HOME = tempDir;
// Create a mock settings file to trigger sync attempt
const settingsPath = path.join(tempDir, 'test.settings.json');
fs.writeFileSync(
settingsPath,
JSON.stringify({
env: {
ANTHROPIC_AUTH_TOKEN: 'sk-real-key',
ANTHROPIC_BASE_URL: 'https://api.example.com',
},
})
);
// Need to reload module to pick up new CCS_HOME
// For this test, we just verify the function doesn't throw
const result = localConfigSync.syncToLocalConfig();
assert.ok(typeof result === 'object');
} finally {
process.env.CCS_HOME = originalHome;
fs.rmSync(tempDir, { recursive: true });
}
});
});
describe('getLocalSyncStatus', () => {
it('returns status object with required fields', () => {
const result = localConfigSync.getLocalSyncStatus();
assert.ok(typeof result === 'object');
assert.ok(typeof result.configExists === 'boolean');
assert.ok(typeof result.configPath === 'string');
assert.ok(typeof result.currentKeyCount === 'number');
assert.ok(typeof result.syncableProfileCount === 'number');
});
it('returns non-negative counts', () => {
const result = localConfigSync.getLocalSyncStatus();
assert.ok(result.currentKeyCount >= 0);
assert.ok(result.syncableProfileCount >= 0);
});
});
describe('YAML section replacement', () => {
// Test the internal logic through integration
// The replaceSectionInYaml function is not exported, so we test via syncToLocalConfig
it('preserves file permissions on sync', () => {
// Verify atomic write behavior by checking syncToLocalConfig doesn't throw
const result = localConfigSync.syncToLocalConfig();
assert.ok(typeof result === 'object');
});
});
describe('Known Limitations (Documented)', () => {
/**
* YAML section replacement has known edge cases:
* - Comments between section key and first item may be lost
* - Multi-document YAML files (with ---) not supported
* - Inline comments on section key line may be lost
*
* These are acceptable trade-offs for the simple section-based replacement.
* Users with complex YAML structures should edit manually.
*/
it('documents known YAML edge cases', () => {
// This test serves as documentation - no assertions needed
assert.ok(true, 'Edge cases documented in test comments');
});
});
});
@@ -0,0 +1,522 @@
/**
* Unit tests for management-api-client module
*/
import { describe, it, expect, beforeEach, mock } from 'bun:test';
import { ManagementApiClient } from '../../../src/cliproxy/management-api-client';
import type {
ManagementClientConfig,
ManagementHealthStatus,
ClaudeKey,
} from '../../../src/cliproxy/management-api-types';
describe('management-api-client', () => {
describe('ManagementApiClient', () => {
let config: ManagementClientConfig;
beforeEach(() => {
config = {
host: 'localhost',
port: 8317,
protocol: 'http',
managementKey: 'test-management-key-12345',
timeout: 2000,
allowSelfSigned: false,
};
});
describe('constructor', () => {
it('should create client with provided config', () => {
const client = new ManagementApiClient(config);
expect(client).toBeDefined();
expect(client.getBaseUrl()).toBe('http://localhost:8317');
});
it('should use default timeout if not provided', () => {
const configWithoutTimeout = { ...config };
delete configWithoutTimeout.timeout;
const client = new ManagementApiClient(configWithoutTimeout);
expect(client).toBeDefined();
});
});
describe('getBaseUrl', () => {
it('should construct HTTP URL with port', () => {
const client = new ManagementApiClient(config);
expect(client.getBaseUrl()).toBe('http://localhost:8317');
});
it('should construct HTTPS URL with custom port', () => {
const httpsConfig = { ...config, protocol: 'https' as const, port: 8443 };
const client = new ManagementApiClient(httpsConfig);
expect(client.getBaseUrl()).toBe('https://localhost:8443');
});
it('should omit standard HTTP port 80', () => {
const configPort80 = { ...config, port: 80 };
const client = new ManagementApiClient(configPort80);
expect(client.getBaseUrl()).toBe('http://localhost');
});
it('should omit standard HTTPS port 443', () => {
const configPort443 = { ...config, protocol: 'https' as const, port: 443 };
const client = new ManagementApiClient(configPort443);
expect(client.getBaseUrl()).toBe('https://localhost');
});
it('should use default port 8317 for HTTP when port is undefined', () => {
const configNoPort = { ...config };
delete configNoPort.port;
const client = new ManagementApiClient(configNoPort);
expect(client.getBaseUrl()).toBe('http://localhost:8317');
});
it('should use default port 443 for HTTPS when port is undefined', () => {
const configNoPort = { ...config, protocol: 'https' as const };
delete configNoPort.port;
const client = new ManagementApiClient(configNoPort);
expect(client.getBaseUrl()).toBe('https://localhost');
});
});
describe('error code mapping', () => {
it('should map ENOTFOUND to DNS_FAILED', () => {
const error = new Error('getaddrinfo ENOTFOUND example.com') as NodeJS.ErrnoException;
error.code = 'ENOTFOUND';
// Test via health check which uses mapErrorToCode internally
const client = new ManagementApiClient(config);
const originalFetch = global.fetch;
global.fetch = mock(() => Promise.reject(error));
client.health().then((status: ManagementHealthStatus) => {
expect(status.healthy).toBe(false);
expect(status.errorCode).toBe('DNS_FAILED');
});
global.fetch = originalFetch;
});
it('should map ECONNREFUSED to CONNECTION_REFUSED', () => {
const error = new Error('connect ECONNREFUSED') as NodeJS.ErrnoException;
error.code = 'ECONNREFUSED';
const client = new ManagementApiClient(config);
const originalFetch = global.fetch;
global.fetch = mock(() => Promise.reject(error));
client.health().then((status: ManagementHealthStatus) => {
expect(status.healthy).toBe(false);
expect(status.errorCode).toBe('CONNECTION_REFUSED');
});
global.fetch = originalFetch;
});
it('should map ETIMEDOUT to TIMEOUT', () => {
const error = new Error('request timeout') as NodeJS.ErrnoException;
error.code = 'ETIMEDOUT';
const client = new ManagementApiClient(config);
const originalFetch = global.fetch;
global.fetch = mock(() => Promise.reject(error));
client.health().then((status: ManagementHealthStatus) => {
expect(status.healthy).toBe(false);
expect(status.errorCode).toBe('TIMEOUT');
});
global.fetch = originalFetch;
});
it('should map ENETUNREACH to NETWORK_UNREACHABLE', () => {
const error = new Error('network unreachable') as NodeJS.ErrnoException;
error.code = 'ENETUNREACH';
const client = new ManagementApiClient(config);
const originalFetch = global.fetch;
global.fetch = mock(() => Promise.reject(error));
client.health().then((status: ManagementHealthStatus) => {
expect(status.healthy).toBe(false);
expect(status.errorCode).toBe('NETWORK_UNREACHABLE');
});
global.fetch = originalFetch;
});
it('should map 401 status to AUTH_FAILED', async () => {
const client = new ManagementApiClient(config);
const originalFetch = global.fetch;
global.fetch = mock(() =>
Promise.resolve({
ok: false,
status: 401,
statusText: 'Unauthorized',
headers: new Headers(),
text: () => Promise.resolve(''),
} as Response)
);
const status = await client.health();
expect(status.healthy).toBe(false);
expect(status.errorCode).toBe('AUTH_FAILED');
global.fetch = originalFetch;
});
it('should map 403 status to AUTH_FAILED', async () => {
const client = new ManagementApiClient(config);
const originalFetch = global.fetch;
global.fetch = mock(() =>
Promise.resolve({
ok: false,
status: 403,
statusText: 'Forbidden',
headers: new Headers(),
text: () => Promise.resolve(''),
} as Response)
);
const status = await client.health();
expect(status.healthy).toBe(false);
expect(status.errorCode).toBe('AUTH_FAILED');
global.fetch = originalFetch;
});
it('should map 404 status to NOT_FOUND', async () => {
const client = new ManagementApiClient(config);
const originalFetch = global.fetch;
global.fetch = mock(() =>
Promise.resolve({
ok: false,
status: 404,
statusText: 'Not Found',
headers: new Headers(),
text: () => Promise.resolve(''),
} as Response)
);
const status = await client.health();
expect(status.healthy).toBe(false);
expect(status.errorCode).toBe('NOT_FOUND');
global.fetch = originalFetch;
});
it('should map 400 status to BAD_REQUEST', async () => {
const client = new ManagementApiClient(config);
const originalFetch = global.fetch;
global.fetch = mock(() =>
Promise.resolve({
ok: false,
status: 400,
statusText: 'Bad Request',
headers: new Headers(),
text: () => Promise.resolve(''),
} as Response)
);
const status = await client.health();
expect(status.healthy).toBe(false);
expect(status.errorCode).toBe('BAD_REQUEST');
global.fetch = originalFetch;
});
it('should map 500+ status to SERVER_ERROR', async () => {
const client = new ManagementApiClient(config);
const originalFetch = global.fetch;
global.fetch = mock(() =>
Promise.resolve({
ok: false,
status: 500,
statusText: 'Internal Server Error',
headers: new Headers(),
text: () => Promise.resolve(''),
} as Response)
);
const status = await client.health();
expect(status.healthy).toBe(false);
expect(status.errorCode).toBe('SERVER_ERROR');
global.fetch = originalFetch;
});
it('should map unknown errors to UNKNOWN', async () => {
const client = new ManagementApiClient(config);
const originalFetch = global.fetch;
global.fetch = mock(() => Promise.reject(new Error('Something weird happened')));
const status = await client.health();
expect(status.healthy).toBe(false);
expect(status.errorCode).toBe('UNKNOWN');
global.fetch = originalFetch;
});
});
describe('authentication header', () => {
it('should include Bearer token in Authorization header', async () => {
const client = new ManagementApiClient(config);
let capturedHeaders: Record<string, string> = {};
const originalFetch = global.fetch;
global.fetch = mock((url: string, options?: RequestInit) => {
if (options?.headers) {
capturedHeaders = options.headers as Record<string, string>;
}
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers(),
text: () => Promise.resolve('{"claude-api-key":[]}'),
} as Response);
});
await client.getClaudeKeys();
expect(capturedHeaders['Authorization']).toBe('Bearer test-management-key-12345');
global.fetch = originalFetch;
});
it('should mask management key in logs/errors', () => {
// Management key should never appear in plain text in error messages
const sensitiveKey = 'super-secret-key-abc123';
const clientConfig = { ...config, managementKey: sensitiveKey };
const client = new ManagementApiClient(clientConfig);
// The key should be used internally but not exposed
expect(client.getBaseUrl()).not.toContain(sensitiveKey);
});
});
describe('timeout handling', () => {
it('should respect custom timeout value', () => {
const customTimeout = 10000;
const configWithTimeout = { ...config, timeout: customTimeout };
const client = new ManagementApiClient(configWithTimeout);
expect(client).toBeDefined();
});
it('should abort request on timeout', async () => {
const client = new ManagementApiClient({ ...config, timeout: 100 });
const originalFetch = global.fetch;
global.fetch = mock(
() =>
new Promise((_, reject) => {
setTimeout(() => {
const error = new Error('The operation was aborted');
error.name = 'AbortError';
reject(error);
}, 150);
})
);
const status = await client.health();
expect(status.healthy).toBe(false);
expect(status.errorCode).toBe('TIMEOUT');
global.fetch = originalFetch;
});
});
describe('self-signed certificate option', () => {
it('should use fetch for HTTP regardless of allowSelfSigned', async () => {
const client = new ManagementApiClient({ ...config, allowSelfSigned: true });
const originalFetch = global.fetch;
let fetchCalled = false;
global.fetch = mock(() => {
fetchCalled = true;
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers(),
text: () => Promise.resolve('{"claude-api-key":[]}'),
} as Response);
});
await client.getClaudeKeys();
expect(fetchCalled).toBe(true);
global.fetch = originalFetch;
});
it('should use https module for HTTPS with allowSelfSigned', () => {
const httpsConfig = {
...config,
protocol: 'https' as const,
allowSelfSigned: true,
};
const client = new ManagementApiClient(httpsConfig);
expect(client).toBeDefined();
// Actual HTTPS request would use native https module with rejectUnauthorized: false
});
it('should use fetch for HTTPS without allowSelfSigned', async () => {
const httpsConfig = {
...config,
protocol: 'https' as const,
allowSelfSigned: false,
};
const client = new ManagementApiClient(httpsConfig);
const originalFetch = global.fetch;
let fetchCalled = false;
global.fetch = mock(() => {
fetchCalled = true;
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers(),
text: () => Promise.resolve('{"claude-api-key":[]}'),
} as Response);
});
await client.getClaudeKeys();
expect(fetchCalled).toBe(true);
global.fetch = originalFetch;
});
});
describe('health check', () => {
it('should return healthy status with version info', async () => {
const client = new ManagementApiClient(config);
const originalFetch = global.fetch;
global.fetch = mock(() =>
Promise.resolve({
ok: true,
status: 200,
headers: new Headers({
'x-cpa-version': '1.2.3',
'x-cpa-commit': 'abc123',
}),
text: () => Promise.resolve('{"claude-api-key":[]}'),
} as Response)
);
const status = await client.health();
expect(status.healthy).toBe(true);
expect(status.version).toBe('1.2.3');
expect(status.commit).toBe('abc123');
expect(status.latencyMs).toBeGreaterThanOrEqual(0);
global.fetch = originalFetch;
});
it('should return unhealthy status on error', async () => {
const client = new ManagementApiClient(config);
const originalFetch = global.fetch;
global.fetch = mock(() => Promise.reject(new Error('Connection failed')));
const status = await client.health();
expect(status.healthy).toBe(false);
expect(status.error).toBeDefined();
expect(status.errorCode).toBeDefined();
global.fetch = originalFetch;
});
});
describe('CRUD operations', () => {
it('should get claude keys', async () => {
const client = new ManagementApiClient(config);
const mockKeys: ClaudeKey[] = [
{ 'api-key': 'sk-test-123', prefix: 'glm-' },
];
const originalFetch = global.fetch;
global.fetch = mock(() =>
Promise.resolve({
ok: true,
status: 200,
headers: new Headers(),
text: () => Promise.resolve(JSON.stringify({ 'claude-api-key': mockKeys })),
} as Response)
);
const keys = await client.getClaudeKeys();
expect(keys).toEqual(mockKeys);
global.fetch = originalFetch;
});
it('should put claude keys', async () => {
const client = new ManagementApiClient(config);
const mockKeys: ClaudeKey[] = [
{ 'api-key': 'sk-test-456', prefix: 'kimi-' },
];
const originalFetch = global.fetch;
let requestBody: string | undefined;
global.fetch = mock((url: string, options?: RequestInit) => {
requestBody = options?.body as string;
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers(),
text: () => Promise.resolve(''),
} as Response);
});
await client.putClaudeKeys(mockKeys);
expect(requestBody).toBe(JSON.stringify(mockKeys));
global.fetch = originalFetch;
});
it('should patch claude key', async () => {
const client = new ManagementApiClient(config);
const patch = {
index: 0,
value: { prefix: 'updated-' },
};
const originalFetch = global.fetch;
let requestBody: string | undefined;
global.fetch = mock((url: string, options?: RequestInit) => {
requestBody = options?.body as string;
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers(),
text: () => Promise.resolve(''),
} as Response);
});
await client.patchClaudeKey(patch);
expect(requestBody).toBe(JSON.stringify(patch));
global.fetch = originalFetch;
});
it('should delete claude key', async () => {
const client = new ManagementApiClient(config);
const apiKey = 'sk-test-to-delete';
const originalFetch = global.fetch;
let requestUrl = '';
global.fetch = mock((url: string) => {
requestUrl = url;
return Promise.resolve({
ok: true,
status: 200,
headers: new Headers(),
text: () => Promise.resolve(''),
} as Response);
});
await client.deleteClaudeKey(apiKey);
expect(requestUrl).toContain(encodeURIComponent(apiKey));
global.fetch = originalFetch;
});
});
});
});
+3 -3
View File
@@ -88,12 +88,12 @@ describe('Model Catalog', () => {
assert.strictEqual(MODEL_CATALOG.gemini.defaultModel, 'gemini-2.5-pro');
});
it('includes Gemini 3 Pro with paid tier', () => {
it('includes Gemini 3 Pro with pro tier', () => {
const { MODEL_CATALOG } = modelCatalog;
const gem3 = MODEL_CATALOG.gemini.models.find((m) => m.id === 'gemini-3-pro-preview');
assert(gem3, 'Should include Gemini 3 Pro');
assert.strictEqual(gem3.name, 'Gemini 3 Pro');
assert.strictEqual(gem3.tier, 'paid');
assert.strictEqual(gem3.tier, 'pro');
});
it('includes Gemini 2.5 Pro without tier (free)', () => {
@@ -197,7 +197,7 @@ describe('Model Catalog', () => {
assert(typeof model.name === 'string', `Model name should be string`);
// tier is optional
if (model.tier !== undefined) {
assert(['free', 'paid'].includes(model.tier), `Invalid tier: ${model.tier}`);
assert(['free', 'pro', 'ultra'].includes(model.tier), `Invalid tier: ${model.tier}`);
}
}
}
+120
View File
@@ -0,0 +1,120 @@
/**
* Tests for Profile Mapper
* Verifies syncable profile detection and ClaudeKey mapping.
*/
import * as assert from 'assert';
describe('Profile Mapper', () => {
const profileMapper = require('../../../dist/cliproxy/sync/profile-mapper');
describe('mapProfileToClaudeKey', () => {
it('returns null when env is missing', () => {
const profile = { name: 'test', settingsPath: '/path', isConfigured: true };
const result = profileMapper.mapProfileToClaudeKey(profile);
assert.strictEqual(result, null);
});
it('returns null when ANTHROPIC_AUTH_TOKEN is missing', () => {
const profile = {
name: 'test',
settingsPath: '/path',
isConfigured: true,
env: { ANTHROPIC_BASE_URL: 'https://example.com' },
};
const result = profileMapper.mapProfileToClaudeKey(profile);
assert.strictEqual(result, null);
});
it('generates ClaudeKey with correct prefix', () => {
const profile = {
name: 'glm',
settingsPath: '/path',
isConfigured: true,
env: {
ANTHROPIC_AUTH_TOKEN: 'sk-test-key',
ANTHROPIC_BASE_URL: 'https://api.example.com',
ANTHROPIC_MODEL: 'gpt-4',
},
};
const result = profileMapper.mapProfileToClaudeKey(profile);
assert.ok(result, 'Should return ClaudeKey');
assert.strictEqual(result['api-key'], 'sk-test-key');
assert.strictEqual(result.prefix, 'glm-');
assert.strictEqual(result['base-url'], 'https://api.example.com');
assert.ok(result.models, 'Should have models');
assert.strictEqual(result.models[0].name, 'gpt-4');
});
it('handles special characters in profile name', () => {
const profile = {
name: 'my@profile!',
settingsPath: '/path',
isConfigured: true,
env: { ANTHROPIC_AUTH_TOKEN: 'sk-key' },
};
const result = profileMapper.mapProfileToClaudeKey(profile);
assert.ok(result);
assert.strictEqual(result.prefix, 'my-profile--');
});
it('omits base-url when not provided', () => {
const profile = {
name: 'test',
settingsPath: '/path',
isConfigured: true,
env: { ANTHROPIC_AUTH_TOKEN: 'sk-key' },
};
const result = profileMapper.mapProfileToClaudeKey(profile);
assert.ok(result);
assert.strictEqual(result['base-url'], undefined);
});
});
describe('loadSyncableProfiles', () => {
it('returns an array', () => {
const result = profileMapper.loadSyncableProfiles();
assert.ok(Array.isArray(result), 'Should return an array');
});
it('filters out profiles with placeholder tokens', () => {
// loadSyncableProfiles reads from disk, just verify it doesn't throw
// and returns array (actual filtering tested via integration)
const result = profileMapper.loadSyncableProfiles();
assert.ok(Array.isArray(result));
});
});
describe('generateSyncPayload', () => {
it('returns an array of ClaudeKey objects', () => {
const result = profileMapper.generateSyncPayload();
assert.ok(Array.isArray(result));
// Each item should have api-key if present
for (const key of result) {
assert.ok(key['api-key'], 'Each key should have api-key');
assert.ok(key.prefix, 'Each key should have prefix');
}
});
});
describe('generateSyncPreview', () => {
it('returns an array of preview items', () => {
const result = profileMapper.generateSyncPreview();
assert.ok(Array.isArray(result));
for (const item of result) {
assert.ok(typeof item.name === 'string', 'Each item should have name');
}
});
});
describe('getSyncableProfileCount', () => {
it('returns a number', () => {
const result = profileMapper.getSyncableProfileCount();
assert.ok(typeof result === 'number');
assert.ok(result >= 0);
});
});
});
@@ -99,6 +99,10 @@ export function AccountCard({
// Show minimum quota of Claude models (primary), fallback to min of all models
const minQuota = quota?.success ? getMinClaudeQuota(quota.models) : null;
// Tier badge (AGY only) - show P for Pro, U for Ultra
const showTierBadge =
isAgy && account.tier && account.tier !== 'unknown' && account.tier !== 'free';
return (
<div
data-account-index={originalIndex}
@@ -124,16 +128,33 @@ export function AccountCard({
transform: `translate(${offset.x}px, ${offset.y}px)${isDragging ? ' scale(1.05)' : ''}`,
}}
>
<GripVertical className="absolute top-2 right-2 w-4 h-4 text-muted-foreground/40" />
<div className="flex items-center gap-1.5 mb-1 mr-4">
<span
className={cn(
'text-xs font-semibold text-foreground tracking-tight truncate max-w-[100px]',
privacyMode && PRIVACY_BLUR_CLASS
{/* Header row: Email + Tier | Pause button | Drag handle */}
<div className="flex items-center gap-1.5 mb-1">
{/* Email with tier badge inline */}
<div className="flex items-center gap-1.5 flex-1 min-w-0">
<span
className={cn(
'text-xs font-semibold text-foreground tracking-tight truncate',
privacyMode && PRIVACY_BLUR_CLASS
)}
>
{cleanEmail(account.email)}
</span>
{showTierBadge && (
<span
className={cn(
'text-[7px] font-bold uppercase tracking-wide px-1 py-px rounded shrink-0',
account.tier === 'ultra'
? 'bg-violet-500/15 text-violet-600 dark:bg-violet-500/25 dark:text-violet-300'
: 'bg-yellow-500/15 text-yellow-700 dark:bg-yellow-500/20 dark:text-yellow-400'
)}
>
{account.tier}
</span>
)}
>
{cleanEmail(account.email)}
</span>
</div>
{/* Pause/Resume button */}
{onPauseToggle && (
<TooltipProvider>
<Tooltip>
@@ -167,7 +188,11 @@ export function AccountCard({
</Tooltip>
</TooltipProvider>
)}
{/* Drag handle */}
<GripVertical className="w-4 h-4 text-muted-foreground/40 shrink-0" />
</div>
<AccountCardStats
success={account.successCount}
failure={account.failureCount}
@@ -2,6 +2,9 @@
* Type definitions for Account Flow Visualization
*/
/** Account tier for subscription level */
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
/** Position offset for draggable cards */
export interface DragOffset {
x: number;
@@ -17,6 +20,8 @@ export interface AccountData {
lastUsedAt?: string;
color: string;
paused?: boolean;
/** Account tier (Antigravity only) */
tier?: AccountTier;
}
export interface ProviderData {
+4
View File
@@ -27,3 +27,7 @@ export { YamlEditor } from './config/yaml-editor';
export { CredentialHealthList } from './overview/credential-health-list';
export { ModelPreferencesGrid } from './overview/model-preferences-grid';
export { QuickStatsRow } from './overview/quick-stats-row';
// Sync components (from subdirectory)
export { SyncStatusCard } from './sync/sync-status-card';
export { SyncDialog } from './sync/sync-dialog';
@@ -128,14 +128,14 @@ export function AccountItem({
return (
<div
className={cn(
'flex flex-col gap-2 p-3 rounded-lg border transition-colors',
'flex flex-col gap-2 p-3 rounded-lg border transition-colors overflow-hidden',
account.isDefault ? 'border-primary/30 bg-primary/5' : 'border-border hover:bg-muted/30',
account.paused && 'opacity-75',
selected && 'ring-2 ring-primary/50 bg-primary/5'
)}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-3 min-w-0 flex-1">
{/* Selection checkbox for bulk actions */}
{selectable && (
<button
@@ -179,13 +179,30 @@ export function AccountItem({
</Tooltip>
</TooltipProvider>
)}
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full',
account.isDefault ? 'bg-primary/10' : 'bg-muted'
{/* Avatar with tier badge overlay */}
<div className="relative shrink-0">
<div
className={cn(
'flex items-center justify-center w-8 h-8 rounded-full',
account.isDefault ? 'bg-primary/10' : 'bg-muted'
)}
>
<User className="w-4 h-4" />
</div>
{/* Tier badge - fixed position on avatar */}
{account.tier && account.tier !== 'unknown' && account.tier !== 'free' && (
<span
className={cn(
'absolute -bottom-0.5 -right-0.5 text-[7px] font-bold uppercase px-1 py-px rounded',
'ring-1 ring-background',
account.tier === 'ultra'
? 'bg-violet-500/20 text-violet-600 dark:bg-violet-500/30 dark:text-violet-300'
: 'bg-yellow-500/20 text-yellow-700 dark:bg-yellow-500/25 dark:text-yellow-400'
)}
>
{account.tier === 'ultra' ? 'U' : 'P'}
</span>
)}
>
<User className="w-4 h-4" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
@@ -200,18 +217,6 @@ export function AccountItem({
Default
</Badge>
)}
{account.tier && account.tier !== 'unknown' && (
<Badge
variant="outline"
className={cn(
'text-[10px] h-4 px-1.5 uppercase',
account.tier === 'paid' && 'border-blue-500 text-blue-600',
account.tier === 'free' && 'border-gray-400 text-gray-500'
)}
>
{account.tier}
</Badge>
)}
{account.paused && (
<Badge
variant="outline"
@@ -281,7 +286,7 @@ export function AccountItem({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-7 w-7">
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
<MoreHorizontal className="w-4 h-4" />
</Button>
</DropdownMenuTrigger>
+6
View File
@@ -0,0 +1,6 @@
/**
* Sync Components Barrel Export
*/
export { SyncStatusCard } from './sync-status-card';
export { SyncDialog } from './sync-dialog';
@@ -0,0 +1,131 @@
/**
* Sync Dialog Component
* Dialog for managing sync configuration, preview, and execution
*/
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Loader2, Upload, CheckCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { useSyncPreview, useExecuteSync } from '@/hooks/use-cliproxy-sync';
interface SyncDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function SyncDialog({ open, onOpenChange }: SyncDialogProps) {
const { data: preview, isLoading: previewLoading } = useSyncPreview();
const { mutate: executeSync, isPending: isSyncing, isSuccess, reset } = useExecuteSync();
const handleSync = () => {
executeSync(undefined, {
onSuccess: () => {
// Keep dialog open to show success
},
});
};
const handleClose = () => {
reset();
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Upload className="w-5 h-5" />
Sync Profiles to Local CLIProxy
</DialogTitle>
<DialogDescription>
Sync your CCS API profiles to the local CLIProxy config.yaml.
</DialogDescription>
</DialogHeader>
<div className="mt-4">
{previewLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
) : preview?.count === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<p>No profiles configured to sync.</p>
<p className="text-sm mt-2">Create API profiles first using the Profiles tab.</p>
</div>
) : (
<ScrollArea className="h-[300px] pr-4">
<div className="space-y-3">
{preview?.profiles.map((profile) => (
<div
key={profile.name}
className="flex items-center justify-between p-3 rounded-lg border bg-muted/30"
>
<div className="flex-1">
<div className="font-medium">{profile.name}</div>
{profile.modelName && (
<div className="text-xs text-muted-foreground truncate max-w-[300px]">
Model: {profile.modelName}
</div>
)}
{profile.baseUrl && (
<div className="text-xs text-muted-foreground truncate max-w-[300px]">
{profile.baseUrl}
</div>
)}
</div>
<Badge variant="outline" className="text-xs">
Ready
</Badge>
</div>
))}
</div>
</ScrollArea>
)}
<div className="flex items-center justify-between mt-6 pt-4 border-t">
<div className="text-sm text-muted-foreground">
{preview?.count ?? 0} profile{(preview?.count ?? 0) !== 1 ? 's' : ''} to sync
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
onClick={handleSync}
disabled={isSyncing || (preview?.count ?? 0) === 0}
className={cn('gap-2', isSuccess && 'bg-green-600 hover:bg-green-700')}
>
{isSyncing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
Syncing...
</>
) : isSuccess ? (
<>
<CheckCircle className="w-4 h-4" />
Synced!
</>
) : (
<>
<Upload className="w-4 h-4" />
Sync Now
</>
)}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,115 @@
/**
* Sync Status Card Component
* Shows local CLIProxy config sync status and controls
*/
import { useState } from 'react';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Loader2, RefreshCw, FileDown, Check, AlertCircle } from 'lucide-react';
import { cn } from '@/lib/utils';
import { SyncDialog } from './sync-dialog';
import { useSyncStatus, useExecuteSync } from '@/hooks/use-cliproxy-sync';
export function SyncStatusCard() {
const [dialogOpen, setDialogOpen] = useState(false);
const { data: status, isLoading, refetch } = useSyncStatus();
const { mutate: executeSync, isPending: isSyncing } = useExecuteSync();
const handleQuickSync = () => {
executeSync(undefined, {
onSuccess: () => {
refetch();
},
});
};
if (isLoading) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<FileDown className="w-4 h-4" />
Profile Sync
</CardTitle>
</CardHeader>
<CardContent className="flex items-center justify-center py-6">
<Loader2 className="w-5 h-5 animate-spin text-muted-foreground" />
</CardContent>
</Card>
);
}
const isConfigured = status?.configured ?? false;
return (
<>
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-medium flex items-center gap-2">
<FileDown className="w-4 h-4" />
Profile Sync
</CardTitle>
<Badge
variant={isConfigured ? 'default' : 'secondary'}
className={cn(
'gap-1.5',
isConfigured
? 'bg-green-500/20 text-green-600 dark:text-green-400 border-green-500/30'
: 'bg-muted text-muted-foreground'
)}
>
{isConfigured ? <Check className="w-3 h-3" /> : <AlertCircle className="w-3 h-3" />}
{isConfigured ? 'Ready' : 'No Config'}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
{isConfigured && (
<div className="text-xs text-muted-foreground">
Syncs API profiles to local CLIProxy config
</div>
)}
{!isConfigured && (
<p className="text-xs text-muted-foreground">
Run <code className="bg-muted px-1 rounded">ccs doctor --fix</code> to generate
config.
</p>
)}
{status?.error && <p className="text-xs text-red-500">{status.error}</p>}
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="flex-1"
onClick={() => setDialogOpen(true)}
>
Details
</Button>
<Button
variant="default"
size="sm"
className="flex-1 gap-2"
onClick={handleQuickSync}
disabled={!isConfigured || isSyncing}
>
{isSyncing ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<RefreshCw className="w-3 h-3" />
)}
Sync Now
</Button>
</div>
</CardContent>
</Card>
<SyncDialog open={dialogOpen} onOpenChange={setDialogOpen} />
</>
);
}
@@ -101,6 +101,7 @@ export function useAuthMonitorData(): AuthMonitorData {
color: ACCOUNT_COLORS[colorIndex % ACCOUNT_COLORS.length],
projectId: account.projectId,
paused: account.paused,
tier: account.tier,
};
accountsList.push(row);
providerData.accounts.push(row);
@@ -2,6 +2,9 @@
* Type definitions for Auth Monitor components
*/
/** Account tier for subscription level */
export type AccountTier = 'free' | 'pro' | 'ultra' | 'unknown';
export interface AccountRow {
id: string;
email: string;
@@ -16,6 +19,8 @@ export interface AccountRow {
projectId?: string;
/** Whether account is paused (skipped in quota rotation) */
paused?: boolean;
/** Account tier (Antigravity only) */
tier?: AccountTier;
}
export interface ProviderStats {
@@ -24,6 +24,9 @@ import {
Settings,
X,
Download,
FileDown,
Check,
AlertCircle,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
@@ -57,6 +60,7 @@ import {
useInstallVersion,
useRestartProxy,
} from '@/hooks/use-cliproxy';
import { useSyncStatus, useExecuteSync } from '@/hooks/use-cliproxy-sync';
import { cn } from '@/lib/utils';
/** Client-side semver comparison (true if a > b) */
@@ -152,6 +156,10 @@ export function ProxyStatusWidget() {
const restartProxy = useRestartProxy();
const installVersion = useInstallVersion();
// Sync functionality
const { data: syncStatus } = useSyncStatus();
const { mutate: executeSync, isPending: isSyncing } = useExecuteSync();
// Version picker state (expanded section)
const [isExpanded, setIsExpanded] = useState(false);
const [selectedVersion, setSelectedVersion] = useState<string>('');
@@ -180,11 +188,16 @@ export function ProxyStatusWidget() {
stopProxy.isPending ||
restartProxy.isPending ||
installVersion.isPending ||
isBackendSwitching;
isBackendSwitching ||
isSyncing;
const hasUpdate = updateCheck?.hasUpdate ?? false;
const isUnstable = updateCheck?.isStable === false;
const currentVersion = updateCheck?.currentVersion;
// Sync status
const isSyncConfigured = syncStatus?.configured ?? false;
const syncStatusText = isSyncConfigured ? 'Sync Ready' : 'No Config';
// Target version for update/downgrade badge
const targetVersion = isUnstable
? updateCheck?.maxStableVersion || versionsData?.latestStable
@@ -295,6 +308,13 @@ export function ProxyStatusWidget() {
<RefreshCw className="w-3 h-3 animate-spin text-muted-foreground" />
) : isRunning ? (
<>
<IconButton
icon={isSyncing ? RefreshCw : FileDown}
tooltip="Sync profiles to CLIProxy"
onClick={() => executeSync()}
disabled={!isSyncConfigured || isActioning}
isPending={isSyncing}
/>
<IconButton
icon={RotateCw}
tooltip="Restart"
@@ -375,6 +395,23 @@ export function ProxyStatusWidget() {
</div>
)}
{/* Sync status row */}
<div className="mt-2 flex items-center gap-1.5 text-xs">
{isSyncConfigured ? (
<Check className="w-3 h-3 text-green-600 dark:text-green-400" />
) : (
<AlertCircle className="w-3 h-3 text-muted-foreground" />
)}
<span
className={cn(
'text-xs',
isSyncConfigured ? 'text-green-600 dark:text-green-400' : 'text-muted-foreground'
)}
>
{syncStatusText}
</span>
</div>
{/* Expanded section: Version Management (available even when not running) */}
<Collapsible open={isExpanded} onOpenChange={setIsExpanded}>
<CollapsibleContent className="mt-3 pt-3 border-t border-muted">
@@ -22,7 +22,8 @@ export function AccountStep({
Select an account ({accounts.length})
</div>
<div className="grid gap-2">
{/* Scrollable account list with max-height for many accounts */}
<div className="grid gap-2 max-h-[320px] overflow-y-auto pr-1">
{accounts.map((acc) => (
<button
key={acc.id}
@@ -2,6 +2,7 @@
* Variant Creation Step
*/
import { useState } from 'react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -18,6 +19,8 @@ import { PRIVACY_BLUR_CLASS } from '@/contexts/privacy-context';
import { MODEL_CATALOGS } from '@/lib/model-catalogs';
import type { VariantStepProps } from '../types';
const CUSTOM_MODEL_VALUE = '__custom__';
export function VariantStep({
selectedProvider,
selectedAccount,
@@ -31,6 +34,21 @@ export function VariantStep({
onSkip,
onCreate,
}: VariantStepProps) {
// Track if user selected custom model option
const catalogModels = MODEL_CATALOGS[selectedProvider]?.models || [];
const isCustomModel = modelName && !catalogModels.some((m) => m.id === modelName);
const [showCustomInput, setShowCustomInput] = useState(isCustomModel);
const handleModelSelect = (value: string) => {
if (value === CUSTOM_MODEL_VALUE) {
setShowCustomInput(true);
onModelChange(''); // Clear to let user type custom
} else {
setShowCustomInput(false);
onModelChange(value);
}
};
return (
<div className="space-y-4">
{selectedAccount && (
@@ -60,25 +78,50 @@ export function VariantStep({
<div className="space-y-2">
<Label>Model</Label>
<Select value={modelName} onValueChange={onModelChange}>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{MODEL_CATALOGS[selectedProvider]?.models.map((m) => (
<SelectItem key={m.id} value={m.id}>
<div className="flex items-center gap-2">
<span>{m.name}</span>
{m.description && (
<span className="text-xs text-muted-foreground">- {m.description}</span>
)}
</div>
{showCustomInput ? (
<div className="space-y-2">
<Input
value={modelName}
onChange={(e) => onModelChange(e.target.value)}
placeholder="e.g., gemini-2.5-pro, claude-opus-4-5"
/>
<button
type="button"
className="text-xs text-primary hover:underline"
onClick={() => setShowCustomInput(false)}
>
Choose from presets instead
</button>
</div>
) : (
<Select
value={catalogModels.some((m) => m.id === modelName) ? modelName : ''}
onValueChange={handleModelSelect}
>
<SelectTrigger>
<SelectValue placeholder="Select a model" />
</SelectTrigger>
<SelectContent>
{catalogModels.map((m) => (
<SelectItem key={m.id} value={m.id}>
<div className="flex items-center gap-2">
<span>{m.name}</span>
{m.description && (
<span className="text-xs text-muted-foreground">- {m.description}</span>
)}
</div>
</SelectItem>
))}
<SelectItem value={CUSTOM_MODEL_VALUE}>
<span className="text-primary">Custom model name...</span>
</SelectItem>
))}
</SelectContent>
</Select>
</SelectContent>
</Select>
)}
<div className="text-xs text-muted-foreground">
Default: {MODEL_CATALOGS[selectedProvider]?.defaultModel || 'provider default'}
{showCustomInput
? 'Enter any model name supported by CLIProxy'
: `Default: ${MODEL_CATALOGS[selectedProvider]?.defaultModel || 'provider default'}`}
</div>
</div>
+175
View File
@@ -0,0 +1,175 @@
/**
* React Query hooks for CLIProxy sync functionality
*/
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
/** Sync status response */
export interface SyncStatus {
connected: boolean;
configured: boolean;
remoteUrl?: string;
latencyMs?: number;
version?: string;
error?: string;
errorCode?: string;
}
/** Sync preview item */
export interface SyncPreviewItem {
name: string;
baseUrl?: string;
modelName?: string;
}
/** Masked payload item for preview */
interface MaskedPayloadItem {
'api-key': string;
'base-url'?: string;
prefix?: string;
models?: { name: string; alias: string }[];
}
/** Sync preview response */
export interface SyncPreview {
profiles: SyncPreviewItem[];
payload: MaskedPayloadItem[];
count: number;
}
/** Sync result response */
export interface SyncResult {
success: boolean;
syncedCount?: number;
remoteUrl?: string;
profiles?: string[];
error?: string;
errorCode?: string;
message?: string;
}
/**
* Fetch sync status from API
*/
async function fetchSyncStatus(): Promise<SyncStatus> {
const response = await fetch('/api/cliproxy/sync/status');
if (!response.ok) {
let message = 'Failed to fetch sync status';
try {
const error = await response.json();
message = error.error || error.message || message;
} catch {
// Non-JSON response (e.g., 502 Bad Gateway)
}
throw new Error(message);
}
return response.json();
}
/**
* Fetch sync preview from API
*/
async function fetchSyncPreview(): Promise<SyncPreview> {
const response = await fetch('/api/cliproxy/sync/preview');
if (!response.ok) {
let message = 'Failed to fetch sync preview';
try {
const error = await response.json();
message = error.error || error.message || message;
} catch {
// Non-JSON response (e.g., 502 Bad Gateway)
}
throw new Error(message);
}
return response.json();
}
/**
* Execute sync to remote CLIProxy
*/
async function executeSync(): Promise<SyncResult> {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 30000);
try {
const response = await fetch('/api/cliproxy/sync', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
signal: controller.signal,
});
if (!response.ok) {
let message = 'Sync failed';
try {
const data = await response.json();
message = data.error || data.message || message;
} catch {
// Non-JSON response (e.g., 502 Bad Gateway)
}
throw new Error(message);
}
return response.json();
} catch (error) {
if (error instanceof Error && error.name === 'AbortError') {
throw new Error('Sync request timed out after 30 seconds');
}
throw error;
} finally {
clearTimeout(timeoutId);
}
}
/**
* Hook to get sync status
*/
export function useSyncStatus() {
return useQuery({
queryKey: ['cliproxy-sync-status'],
queryFn: fetchSyncStatus,
refetchInterval: 30000, // Check every 30 seconds
retry: 1,
staleTime: 10000,
});
}
/**
* Hook to get sync preview
*/
export function useSyncPreview() {
return useQuery({
queryKey: ['cliproxy-sync-preview'],
queryFn: fetchSyncPreview,
staleTime: 5000,
retry: 1,
});
}
/**
* Hook to execute sync with toast feedback
*/
export function useExecuteSync() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: executeSync,
onSuccess: (data) => {
// Invalidate sync-related queries after successful sync
queryClient.invalidateQueries({ queryKey: ['cliproxy-sync-status'] });
queryClient.invalidateQueries({ queryKey: ['cliproxy-sync-preview'] });
// Show success toast with synced count
if (data.syncedCount === 0) {
toast.info('No profiles to sync');
} else {
toast.success(
`Synced ${data.syncedCount} profile${data.syncedCount === 1 ? '' : 's'} to CLIProxy`
);
}
},
onError: (error: Error) => {
toast.error(`Sync failed: ${error.message}`);
},
});
}
+2 -2
View File
@@ -83,8 +83,8 @@ export interface OAuthAccount {
paused?: boolean;
/** ISO timestamp when account was paused */
pausedAt?: string;
/** Account tier: free or paid (Pro/Ultra combined) */
tier?: 'free' | 'paid' | 'unknown';
/** Account tier: free, pro, ultra, or unknown */
tier?: 'free' | 'pro' | 'ultra' | 'unknown';
/** GCP Project ID (Antigravity only) - read-only */
projectId?: string;
}