mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-17 20:17:20 +00:00
Merge pull request #391 from kaitranntt/dev
feat(release): v7.29.0 - account tier prioritization & local sync
This commit is contained in:
+1
-1
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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}`) : '';
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('');
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}`);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user