mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
feat(cliproxy): add local config sync module
- add profile mapper to transform CCS profiles to ClaudeKey format - add model alias configuration with defaults for glm/kimi/qwen - add local config sync to write claude-api-key section - add auto-sync watcher with debouncing for profile changes - include null config handling and temp file cleanup
This commit is contained in:
@@ -0,0 +1,185 @@
|
||||
/**
|
||||
* 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('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) {
|
||||
await watcherInstance.close();
|
||||
watcherInstance = null;
|
||||
log('Watcher stopped');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
|
||||
await stopAutoSyncWatcher();
|
||||
startAutoSyncWatcher();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get watcher status.
|
||||
*/
|
||||
export function getAutoSyncStatus(): {
|
||||
enabled: boolean;
|
||||
watching: boolean;
|
||||
syncing: boolean;
|
||||
} {
|
||||
return {
|
||||
enabled: isAutoSyncEnabled(),
|
||||
watching: watcherInstance !== null,
|
||||
syncing: isSyncing,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* 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';
|
||||
|
||||
// Model alias config
|
||||
export type { ModelAlias, ModelAliasConfig } from './model-alias-config';
|
||||
export {
|
||||
getModelAliasesPath,
|
||||
loadModelAliases,
|
||||
saveModelAliases,
|
||||
getProfileAliases,
|
||||
addProfileAlias,
|
||||
removeProfileAlias,
|
||||
listAllAliases,
|
||||
DEFAULT_MODEL_ALIASES,
|
||||
} from './model-alias-config';
|
||||
|
||||
// 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,170 @@
|
||||
/**
|
||||
* Local Config Sync
|
||||
*
|
||||
* Syncs CCS API profiles to the local CLIProxy config.yaml.
|
||||
* Updates only the claude-api-key section, preserving other config.
|
||||
*/
|
||||
|
||||
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.
|
||||
* Merges/replaces the claude-api-key section.
|
||||
*
|
||||
* @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.',
|
||||
};
|
||||
}
|
||||
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
const parsedConfig = yaml.load(configContent);
|
||||
|
||||
// Validate config is an object
|
||||
if (!parsedConfig || typeof parsedConfig !== 'object' || Array.isArray(parsedConfig)) {
|
||||
return {
|
||||
success: false,
|
||||
syncedCount: 0,
|
||||
configPath,
|
||||
error: 'Invalid config.yaml format. Expected object, got ' + typeof parsedConfig,
|
||||
};
|
||||
}
|
||||
|
||||
const config = parsedConfig as Record<string, unknown>;
|
||||
|
||||
// Transform payload to config format
|
||||
const claudeApiKeys = payload.map(transformToConfigFormat);
|
||||
|
||||
// Update only claude-api-key section
|
||||
config['claude-api-key'] = claudeApiKeys;
|
||||
|
||||
// Write back with preserved formatting
|
||||
const newContent = yaml.dump(config, {
|
||||
indent: 2,
|
||||
lineWidth: -1, // No wrapping
|
||||
});
|
||||
|
||||
// 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'] = '';
|
||||
|
||||
if (key.models && key.models.length > 0) {
|
||||
entry.models = key.models.map((m) => ({
|
||||
name: m.name,
|
||||
alias: m.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>;
|
||||
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,176 @@
|
||||
/**
|
||||
* Model Alias Configuration
|
||||
*
|
||||
* Manages model alias mappings for CLIProxy sync.
|
||||
* Aliases map Claude model names to provider-specific models.
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir } from '../../utils/config-manager';
|
||||
|
||||
/** Model alias mapping */
|
||||
export interface ModelAlias {
|
||||
/** Claude model name (e.g., "claude-3-5-sonnet") */
|
||||
from: string;
|
||||
/** Target model (e.g., "glm-4.7-airx-thinking") */
|
||||
to: string;
|
||||
}
|
||||
|
||||
/** Model alias configuration file structure */
|
||||
export interface ModelAliasConfig {
|
||||
/** Version for future schema changes */
|
||||
version: number;
|
||||
/** Aliases for each profile name */
|
||||
aliases: Record<string, ModelAlias[]>;
|
||||
}
|
||||
|
||||
/** Default model aliases (common mappings) */
|
||||
export const DEFAULT_MODEL_ALIASES: Record<string, ModelAlias[]> = {
|
||||
glm: [
|
||||
{ from: 'claude-sonnet-4-20250514', to: 'glm-4.7-thinking' },
|
||||
{ from: 'claude-3-5-sonnet-20241022', to: 'glm-4.7' },
|
||||
{ from: 'claude-3-5-haiku-20241022', to: 'glm-4.7-flash' },
|
||||
],
|
||||
kimi: [
|
||||
{ from: 'claude-sonnet-4-20250514', to: 'moonshot-v1-auto' },
|
||||
{ from: 'claude-3-5-sonnet-20241022', to: 'moonshot-v1-128k' },
|
||||
{ from: 'claude-3-5-haiku-20241022', to: 'moonshot-v1-32k' },
|
||||
],
|
||||
qwen: [
|
||||
{ from: 'claude-sonnet-4-20250514', to: 'qwen-coder-plus' },
|
||||
{ from: 'claude-3-5-sonnet-20241022', to: 'qwen-plus' },
|
||||
{ from: 'claude-3-5-haiku-20241022', to: 'qwen-turbo' },
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Get path to model aliases config file.
|
||||
*/
|
||||
export function getModelAliasesPath(): string {
|
||||
return path.join(getCcsDir(), 'cliproxy', 'model-aliases.json');
|
||||
}
|
||||
|
||||
/**
|
||||
* Load model alias configuration.
|
||||
* Returns defaults if file doesn't exist.
|
||||
*/
|
||||
export function loadModelAliases(): ModelAliasConfig {
|
||||
const aliasPath = getModelAliasesPath();
|
||||
|
||||
try {
|
||||
if (fs.existsSync(aliasPath)) {
|
||||
const content = fs.readFileSync(aliasPath, 'utf8');
|
||||
const config = JSON.parse(content) as ModelAliasConfig;
|
||||
return config;
|
||||
}
|
||||
} catch {
|
||||
// Fall through to defaults
|
||||
}
|
||||
|
||||
// Return defaults
|
||||
return {
|
||||
version: 1,
|
||||
aliases: { ...DEFAULT_MODEL_ALIASES },
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save model alias configuration.
|
||||
* @returns Success status with optional error message
|
||||
*/
|
||||
export function saveModelAliases(config: ModelAliasConfig): { success: boolean; error?: string } {
|
||||
try {
|
||||
const aliasPath = getModelAliasesPath();
|
||||
const dir = path.dirname(aliasPath);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
fs.writeFileSync(aliasPath, JSON.stringify(config, null, 2), 'utf8');
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get aliases for a specific profile.
|
||||
*/
|
||||
export function getProfileAliases(profileName: string): ModelAlias[] {
|
||||
const config = loadModelAliases();
|
||||
return config.aliases[profileName] ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Add an alias for a profile.
|
||||
*/
|
||||
export function addProfileAlias(profileName: string, from: string, to: string): void {
|
||||
// Validate inputs
|
||||
if (!from || from.trim() === '') {
|
||||
throw new Error('Model alias "from" cannot be empty');
|
||||
}
|
||||
if (!to || to.trim() === '') {
|
||||
throw new Error('Model alias "to" cannot be empty');
|
||||
}
|
||||
|
||||
const config = loadModelAliases();
|
||||
|
||||
if (!config.aliases[profileName]) {
|
||||
config.aliases[profileName] = [];
|
||||
}
|
||||
|
||||
// Check if alias already exists (update if so)
|
||||
const existingIdx = config.aliases[profileName].findIndex((a) => a.from === from);
|
||||
if (existingIdx >= 0) {
|
||||
config.aliases[profileName][existingIdx].to = to;
|
||||
} else {
|
||||
config.aliases[profileName].push({ from, to });
|
||||
}
|
||||
|
||||
const result = saveModelAliases(config);
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to save model aliases: ${result.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove an alias for a profile.
|
||||
*/
|
||||
export function removeProfileAlias(profileName: string, from: string): boolean {
|
||||
const config = loadModelAliases();
|
||||
|
||||
if (!config.aliases[profileName]) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const initialLen = config.aliases[profileName].length;
|
||||
config.aliases[profileName] = config.aliases[profileName].filter((a) => a.from !== from);
|
||||
|
||||
if (config.aliases[profileName].length === initialLen) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Remove empty profile entry
|
||||
if (config.aliases[profileName].length === 0) {
|
||||
delete config.aliases[profileName];
|
||||
}
|
||||
|
||||
const result = saveModelAliases(config);
|
||||
if (!result.success) {
|
||||
throw new Error(`Failed to save model aliases: ${result.error}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* List all aliases.
|
||||
*/
|
||||
export function listAllAliases(): Record<string, ModelAlias[]> {
|
||||
const config = loadModelAliases();
|
||||
return config.aliases;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Profile Mapper for CLIProxy Sync
|
||||
*
|
||||
* Transforms CCS settings-based profiles into CLIProxy ClaudeKey format.
|
||||
* Handles model alias mapping and settings normalization.
|
||||
*/
|
||||
|
||||
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, ClaudeModel } from '../management-api-types';
|
||||
import { getProfileAliases, type ModelAlias } from './model-alias-config';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map model aliases to ClaudeModel format.
|
||||
*/
|
||||
function mapAliasesToClaudeModels(aliases: ModelAlias[]): ClaudeModel[] {
|
||||
return aliases.map((alias) => ({
|
||||
name: alias.from,
|
||||
alias: alias.to,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// Generate prefix from profile name (e.g., "glm" -> "glm-")
|
||||
const prefix = `${sanitizeProfileName(profile.name)}-`;
|
||||
|
||||
// Load model aliases for this profile
|
||||
const aliases = getProfileAliases(profile.name);
|
||||
const models = aliases.length > 0 ? mapAliasesToClaudeModels(aliases) : undefined;
|
||||
|
||||
const claudeKey: ClaudeKey = {
|
||||
'api-key': apiKey,
|
||||
prefix,
|
||||
};
|
||||
|
||||
if (baseUrl) {
|
||||
claudeKey['base-url'] = baseUrl;
|
||||
}
|
||||
|
||||
if (models && models.length > 0) {
|
||||
claudeKey.models = models;
|
||||
}
|
||||
|
||||
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;
|
||||
/** Whether profile has model aliases */
|
||||
hasAliases: boolean;
|
||||
/** Number of model aliases */
|
||||
aliasCount: number;
|
||||
}
|
||||
|
||||
export function generateSyncPreview(): SyncPreviewItem[] {
|
||||
const profiles = loadSyncableProfiles();
|
||||
const preview: SyncPreviewItem[] = [];
|
||||
|
||||
for (const profile of profiles) {
|
||||
const aliases = getProfileAliases(profile.name);
|
||||
|
||||
preview.push({
|
||||
name: profile.name,
|
||||
baseUrl: profile.env?.ANTHROPIC_BASE_URL,
|
||||
hasAliases: aliases.length > 0,
|
||||
aliasCount: aliases.length,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user