diff --git a/README.md b/README.md index 815aa9fb..dd0d0dd4 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,8 @@ The dashboard provides visual management for all account types: - **Claude Accounts**: Isolation-first by default (work, personal, client), with explicit shared context opt-in - **OAuth Providers**: One-click auth for Gemini, Codex, Antigravity, Kiro, Copilot -- **API Profiles**: Configure GLM, Kimi with your keys +- **AI Providers**: Configure Gemini, Codex, Claude, Vertex, and OpenAI-compatible API keys under `CLIProxy -> AI Providers` +- **API Profiles**: Configure GLM, Kimi, OpenRouter, and other Anthropic-compatible APIs as CCS-native profiles - **Factory Droid**: Track Droid install location and BYOK settings health - **Updates Center**: Track support rollouts (Droid target, CLIProxy provider changes, WebSearch integrations) - **Health Monitor**: Real-time status across all profiles @@ -148,6 +149,8 @@ The dashboard provides visual management for all account types: > **OAuth providers** authenticate via browser on first run. Tokens are cached in `~/.ccs/cliproxy/auth/`. +> **AI Providers dashboard:** Configure CLIProxy-managed API key families at `ccs config` -> `CLIProxy` -> `AI Providers`. Use `API Profiles` only for CCS-native Anthropic-compatible profiles. + **Powered by:** - [CLIProxyAPIPlus](https://github.com/router-for-me/CLIProxyAPIPlus) - Extended OAuth proxy with Kiro ([@fuko2935](https://github.com/fuko2935), [@Ravens2121](https://github.com/Ravens2121)) and Copilot ([@em4go](https://github.com/em4go)) support - [CLIProxyAPI](https://github.com/router-for-me/CLIProxyAPI) - Core OAuth proxy for Gemini, Codex, Antigravity diff --git a/docs/project-overview-pdr.md b/docs/project-overview-pdr.md index f6384588..2849246f 100644 --- a/docs/project-overview-pdr.md +++ b/docs/project-overview-pdr.md @@ -1,6 +1,6 @@ # CCS Product Development Requirements (PDR) -Last Updated: 2026-02-04 +Last Updated: 2026-03-19 ## Product Overview @@ -32,10 +32,11 @@ CCS provides: 1. **Multi-Account Claude**: Isolated instances via `CLAUDE_CONFIG_DIR` 2. **OAuth Providers**: Zero-config Gemini, Codex, Antigravity, Copilot, Kiro (ghcp) integration -3. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API -4. **Visual Dashboard**: React SPA for configuration management -5. **Automatic WebSearch**: MCP fallback for third-party providers -6. **Usage Analytics**: Token tracking, cost analysis, model breakdown +3. **AI Providers**: Dedicated CLIProxy dashboard for Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key families +4. **API Profiles**: GLM, Kimi, OpenRouter, any Anthropic-compatible API +5. **Visual Dashboard**: React SPA for configuration management +6. **Automatic WebSearch**: MCP fallback for third-party providers +7. **Usage Analytics**: Token tracking, cost analysis, model breakdown --- @@ -74,6 +75,11 @@ CCS provides: - Model mapping and configuration - OpenRouter integration with 300+ models +### FR-004A: CLIProxy AI Provider Management +- Configure CLIProxy-managed Gemini, Codex, Claude, Vertex, and OpenAI-compatible API-key entries +- Keep provider authoring separate from CCS API Profile creation +- Support local config editing and remote CLIProxy management parity where available + ### FR-005: Dashboard UI - Visual profile management - Real-time health monitoring @@ -332,5 +338,5 @@ CCS provides: - [Codebase Summary](./codebase-summary.md) - Technical structure - [Code Standards](./code-standards.md) - Development conventions -- [System Architecture](./system-architecture.md) - Architecture diagrams +- [System Architecture](./system-architecture/index.md) - Architecture diagrams - [Project Roadmap](./project-roadmap.md) - Development phases and GitHub issues diff --git a/docs/project-roadmap.md b/docs/project-roadmap.md index ebaef666..4ca846c8 100644 --- a/docs/project-roadmap.md +++ b/docs/project-roadmap.md @@ -1,6 +1,6 @@ # CCS Project Roadmap -Last Updated: 2026-03-18 +Last Updated: 2026-03-19 Forward-looking roadmap documenting current priorities, GitHub issues, and future feature plans. @@ -41,6 +41,7 @@ All major modularization work is complete. The codebase evolved from monolithic ### Recent Fixes +- **2026-03-19**: **#649** CCS splits CLIProxy provider-key authoring into a dedicated `CLIProxy -> AI Providers` dashboard route. `/cliproxy` now stays focused on OAuth accounts and variants, `/cliproxy/ai-providers` owns Gemini/Codex/Claude/Vertex/OpenAI-compatible key management, and `/providers` stays reserved for CCS-native API Profiles. - **2026-03-18**: **#755** Marketplace refresh no longer reuses one shared `known_marketplaces.json` across isolated instances. CCS now keeps marketplace payload directories shared while reconciling per-instance marketplace metadata so Claude Code validation succeeds for alternating or concurrent profiles, including Windows copy fallback. - **2026-03-17**: Deprecated user-facing GLMT discovery across CLI help, completions, presets, and docs. Existing `glmt` profiles now run through a compatibility path that normalizes legacy proxy settings to the direct GLM endpoint. - **#748**: API profile creation now keeps provider selection compact by collapsing advanced presets behind an explicit toggle, shrinking chooser cards so the form fields stay visually primary, and giving `llama.cpp` a dedicated provider logo. @@ -240,6 +241,6 @@ The check mode supports a maintainability regression gate that blocks increases - [Codebase Summary](./codebase-summary.md) - Current structure - [Code Standards](./code-standards.md) - Patterns and conventions -- [System Architecture](./system-architecture.md) - Architecture diagrams +- [System Architecture](./system-architecture/index.md) - Architecture diagrams - [Hardening Debt Burndown Tracker](./hardening-debt-burndown.md) - Legacy shim + sync-fs debt tracking - [CLAUDE.md](../CLAUDE.md) - AI development guidance diff --git a/package.json b/package.json index 4932b383..d27beb6e 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "test": "bun run build && bun run test:all", "test:ci": "bun run test:all", "test:all": "bun test tests/unit tests/integration tests/npm", - "test:unit": "bun test tests/unit/", + "test:unit": "bun test tests/unit", "test:npm": "bun test tests/npm/", "test:native": "bash tests/native/unix/edge-cases.sh", "test:e2e": "bun test tests/e2e/ --bail --timeout 60000", diff --git a/src/api/services/cliproxy-profile-bridge.ts b/src/api/services/cliproxy-profile-bridge.ts new file mode 100644 index 00000000..e903d222 --- /dev/null +++ b/src/api/services/cliproxy-profile-bridge.ts @@ -0,0 +1,153 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import { getCcsDir, loadConfigSafe } from '../../utils/config-manager'; +import { buildProxyUrl, getProxyTarget } from '../../cliproxy/proxy-target-resolver'; +import { getEffectiveApiKey } from '../../cliproxy/auth-token-manager'; +import { getModelMappingFromConfig } from '../../cliproxy/base-config-loader'; +import { + CLIPROXY_PROVIDER_IDS, + getProviderDescription, + getProviderDisplayName, + mapExternalProviderName, +} from '../../cliproxy/provider-capabilities'; +import { extractProviderFromPathname } from '../../cliproxy/model-id-normalizer'; +import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; +import type { TargetType } from '../../targets/target-adapter'; +import type { Settings } from '../../types/config'; +import type { CLIProxyProvider } from '../../cliproxy/types'; +import type { + CliproxyBridgeMetadata, + CliproxyBridgeProviderInfo, + ModelMapping, + ResolvedCliproxyBridgeProfile, +} from './profile-types'; + +const DEFAULT_PROFILE_SUFFIX = '-api'; + +function normalizeBridgeUrl(value: string): string { + try { + const parsed = new URL(value); + const hostname = parsed.hostname === 'localhost' ? '127.0.0.1' : parsed.hostname; + const pathname = parsed.pathname.replace(/\/+$/, '') || '/'; + return `${parsed.protocol}//${hostname}:${parsed.port}${pathname}`; + } catch { + return value.trim().replace(/\/+$/, ''); + } +} + +function resolveProviderFromBaseUrl(baseUrl: unknown): CLIProxyProvider | null { + if (typeof baseUrl !== 'string' || baseUrl.trim().length === 0) { + return null; + } + + try { + const parsed = new URL(baseUrl); + const extracted = extractProviderFromPathname(parsed.pathname); + return extracted ? mapExternalProviderName(extracted) : null; + } catch { + const extracted = extractProviderFromPathname(baseUrl); + return extracted ? mapExternalProviderName(extracted) : null; + } +} + +function hasConfiguredProfile(name: string): boolean { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + return name in config.profiles; + } + + const config = loadConfigSafe(); + return name in config.profiles; +} + +function hasSettingsFile(name: string): boolean { + return fs.existsSync(path.join(getCcsDir(), `${name}.settings.json`)); +} + +export function getDefaultCliproxyBridgeName(provider: CLIProxyProvider): string { + return `${provider}${DEFAULT_PROFILE_SUFFIX}`; +} + +export function suggestCliproxyBridgeName(provider: CLIProxyProvider): string { + const baseName = getDefaultCliproxyBridgeName(provider); + if (!hasConfiguredProfile(baseName) && !hasSettingsFile(baseName)) { + return baseName; + } + + for (let index = 2; index < 1000; index += 1) { + const candidate = `${baseName}-${index}`; + if (!hasConfiguredProfile(candidate) && !hasSettingsFile(candidate)) { + return candidate; + } + } + + return `${baseName}-${Date.now()}`; +} + +function resolveBridgeModelMapping(provider: CLIProxyProvider): ModelMapping { + const mapping = getModelMappingFromConfig(provider); + return { + default: mapping.defaultModel, + opus: mapping.opusModel || mapping.defaultModel, + sonnet: mapping.sonnetModel || mapping.defaultModel, + haiku: mapping.haikuModel || mapping.defaultModel, + }; +} + +export function listCliproxyBridgeProviders(): CliproxyBridgeProviderInfo[] { + return CLIPROXY_PROVIDER_IDS.map((provider) => ({ + provider, + displayName: getProviderDisplayName(provider), + description: getProviderDescription(provider), + defaultProfileName: getDefaultCliproxyBridgeName(provider), + routePath: `/api/provider/${provider}`, + })); +} + +export function resolveCliproxyBridgeProfile( + provider: CLIProxyProvider, + options: { + name?: string; + target?: TargetType; + } = {} +): ResolvedCliproxyBridgeProfile { + const target = getProxyTarget(); + const profileName = options.name?.trim() || suggestCliproxyBridgeName(provider); + const baseUrl = buildProxyUrl(target, `/api/provider/${provider}`); + const apiKey = target.authToken ?? getEffectiveApiKey(); + + return { + name: profileName, + provider, + providerDisplayName: getProviderDisplayName(provider), + baseUrl, + apiKey, + models: resolveBridgeModelMapping(provider), + target: options.target || 'claude', + routePath: `/api/provider/${provider}`, + source: target.isRemote ? 'remote' : 'local', + }; +} + +export function resolveCliproxyBridgeMetadata( + settings: Pick | null | undefined +): CliproxyBridgeMetadata | null { + const provider = resolveProviderFromBaseUrl(settings?.env?.ANTHROPIC_BASE_URL); + if (!provider) { + return null; + } + + const resolved = resolveCliproxyBridgeProfile(provider); + const actualBaseUrl = settings?.env?.ANTHROPIC_BASE_URL?.trim() || ''; + const actualAuthToken = settings?.env?.ANTHROPIC_AUTH_TOKEN?.trim() || ''; + + return { + provider, + providerDisplayName: resolved.providerDisplayName, + routePath: resolved.routePath, + currentBaseUrl: resolved.baseUrl, + source: resolved.source, + usesCurrentTarget: normalizeBridgeUrl(actualBaseUrl) === normalizeBridgeUrl(resolved.baseUrl), + usesCurrentAuthToken: actualAuthToken.length > 0 && actualAuthToken === resolved.apiKey, + }; +} diff --git a/src/api/services/index.ts b/src/api/services/index.ts index ae519d48..fb6ad360 100644 --- a/src/api/services/index.ts +++ b/src/api/services/index.ts @@ -14,8 +14,12 @@ export { type CliproxyVariantInfo, type ApiListResult, type CreateApiProfileResult, + type CreateCliproxyBridgeProfileResult, type RemoveApiProfileResult, type UpdateApiProfileTargetResult, + type CliproxyBridgeProviderInfo, + type CliproxyBridgeMetadata, + type ResolvedCliproxyBridgeProfile, type ProfileValidationIssue, type ProfileValidationSummary, type ApiProfileOrphanCandidate, @@ -38,6 +42,14 @@ export { // Profile write operations export { createApiProfile, removeApiProfile, updateApiProfileTarget } from './profile-writer'; +export { createCliproxyBridgeProfile } from './profile-writer'; +export { + getDefaultCliproxyBridgeName, + listCliproxyBridgeProviders, + resolveCliproxyBridgeMetadata, + resolveCliproxyBridgeProfile, + suggestCliproxyBridgeName, +} from './cliproxy-profile-bridge'; // Lifecycle validation and operations export { validateApiProfileSettingsPayload } from './profile-lifecycle-validation'; diff --git a/src/api/services/profile-reader.ts b/src/api/services/profile-reader.ts index ee0efc2f..4bb99b31 100644 --- a/src/api/services/profile-reader.ts +++ b/src/api/services/profile-reader.ts @@ -6,11 +6,13 @@ */ import * as fs from 'fs'; -import * as path from 'path'; -import { getCcsDir, loadConfigSafe } from '../../utils/config-manager'; +import { loadConfigSafe } from '../../utils/config-manager'; import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader'; +import { expandPath } from '../../utils/helpers'; import type { TargetType } from '../../targets/target-adapter'; +import type { Settings } from '../../types/config'; import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types'; +import { resolveCliproxyBridgeMetadata } from './cliproxy-profile-bridge'; const VALID_TARGETS: ReadonlySet = new Set(['claude', 'droid']); @@ -37,23 +39,43 @@ export function apiProfileExists(name: string): boolean { } } +/** + * Load settings file from a config reference such as ~/.ccs/name.settings.json. + */ +function loadProfileSettings(settingsReference: string | undefined): Settings | null { + if (!settingsReference || settingsReference === 'config.yaml') { + return null; + } + + try { + const settingsPath = expandPath(settingsReference); + if (!fs.existsSync(settingsPath)) return null; + return JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as Settings; + } catch { + return null; + } +} + +function isConfiguredFromSettings(settings: Settings | null): boolean { + const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || settings?.env?.ANTHROPIC_API_KEY || ''; + return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-'); +} + +function resolveProfileSettingsReference(name: string): string | undefined { + if (isUnifiedMode()) { + const config = loadOrCreateUnifiedConfig(); + return config.profiles[name]?.settings; + } + + const config = loadConfigSafe(); + return config.profiles[name]; +} + /** * Check if API profile has real API key (not placeholder) */ export function isApiProfileConfigured(apiName: string): boolean { - try { - const ccsDir = getCcsDir(); - const settingsPath = path.join(ccsDir, `${apiName}.settings.json`); - - // Check settings.json file for API key - if (!fs.existsSync(settingsPath)) return false; - - const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8')); - const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || settings?.env?.ANTHROPIC_API_KEY || ''; - return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-'); - } catch { - return false; - } + return isConfiguredFromSettings(loadProfileSettings(resolveProfileSettingsReference(apiName))); } /** @@ -73,12 +95,15 @@ export function listApiProfiles(): ApiListResult { if (name === 'default' && profile.settings?.includes('.claude/settings.json')) { continue; } + const settingsPath = profile.settings || 'config.yaml'; + const settings = loadProfileSettings(settingsPath); profiles.push({ name, - settingsPath: profile.settings || 'config.yaml', - isConfigured: isApiProfileConfigured(name), + settingsPath, + isConfigured: isConfiguredFromSettings(settings), configSource: 'unified', target: sanitizeTarget(profile.target), + cliproxyBridge: resolveCliproxyBridgeMetadata(settings), }); } // CLIProxy variants @@ -103,12 +128,14 @@ export function listApiProfiles(): ApiListResult { if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) { continue; } + const settings = loadProfileSettings(settingsPath as string); profiles.push({ name, settingsPath: settingsPath as string, - isConfigured: isApiProfileConfigured(name), + isConfigured: isConfiguredFromSettings(settings), configSource: 'legacy', target: sanitizeTarget(legacyTargetMap?.[name]), + cliproxyBridge: resolveCliproxyBridgeMetadata(settings), }); } // CLIProxy variants diff --git a/src/api/services/profile-types.ts b/src/api/services/profile-types.ts index 54d63ebe..2f8bd40a 100644 --- a/src/api/services/profile-types.ts +++ b/src/api/services/profile-types.ts @@ -5,6 +5,7 @@ */ import type { TargetType } from '../../targets/target-adapter'; +import type { CLIProxyProvider } from '../../cliproxy/types'; /** Model mapping for API profiles */ export interface ModelMapping { @@ -21,6 +22,7 @@ export interface ApiProfileInfo { isConfigured: boolean; configSource: 'unified' | 'legacy'; target: TargetType; + cliproxyBridge?: CliproxyBridgeMetadata | null; } /** CLIProxy variant info */ @@ -44,6 +46,43 @@ export interface CreateApiProfileResult { error?: string; } +export interface CliproxyBridgeProviderInfo { + provider: CLIProxyProvider; + displayName: string; + description: string; + defaultProfileName: string; + routePath: string; +} + +export interface CliproxyBridgeMetadata { + provider: CLIProxyProvider; + providerDisplayName: string; + routePath: string; + currentBaseUrl: string; + source: 'local' | 'remote'; + usesCurrentTarget: boolean; + usesCurrentAuthToken: boolean; +} + +export interface ResolvedCliproxyBridgeProfile { + name: string; + provider: CLIProxyProvider; + providerDisplayName: string; + baseUrl: string; + apiKey: string; + models: ModelMapping; + target: TargetType; + routePath: string; + source: 'local' | 'remote'; +} + +export interface CreateCliproxyBridgeProfileResult extends CreateApiProfileResult { + name?: string; + provider?: CLIProxyProvider; + target?: TargetType; + cliproxyBridge?: CliproxyBridgeMetadata | null; +} + /** Result from remove operation */ export interface RemoveApiProfileResult { success: boolean; diff --git a/src/api/services/profile-writer.ts b/src/api/services/profile-writer.ts index 2404b202..ae77908c 100644 --- a/src/api/services/profile-writer.ts +++ b/src/api/services/profile-writer.ts @@ -6,6 +6,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager'; import { expandPath } from '../../utils/helpers'; +import { validateApiName } from './validation-service'; import { loadOrCreateUnifiedConfig, saveUnifiedConfig, @@ -14,6 +15,7 @@ import { import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; import type { TargetType } from '../../targets/target-adapter'; import { resolveDroidProvider } from '../../targets/droid-provider'; +import { isReservedName } from '../../config/reserved-names'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import { extractProviderFromPathname, @@ -23,9 +25,15 @@ import type { CLIProxyProvider } from '../../cliproxy/types'; import type { ModelMapping, CreateApiProfileResult, + CreateCliproxyBridgeProfileResult, RemoveApiProfileResult, UpdateApiProfileTargetResult, } from './profile-types'; +import { apiProfileExists } from './profile-reader'; +import { + resolveCliproxyBridgeMetadata, + resolveCliproxyBridgeProfile, +} from './cliproxy-profile-bridge'; /** Check if URL is an OpenRouter endpoint */ function isOpenRouterUrl(baseUrl: string): boolean { @@ -233,6 +241,63 @@ export function createApiProfile( } } +export function createCliproxyBridgeProfile( + provider: CLIProxyProvider, + options: { + name?: string; + force?: boolean; + target?: TargetType; + } = {} +): CreateCliproxyBridgeProfileResult { + const providedName = options.name?.trim(); + if (providedName) { + const nameError = validateApiName(providedName); + if (nameError) { + return { success: false, settingsFile: '', error: nameError }; + } + if (isReservedName(providedName)) { + return { + success: false, + settingsFile: '', + error: `Profile name '${providedName}' is reserved`, + }; + } + } + + const resolved = resolveCliproxyBridgeProfile(provider, options); + const settingsPath = path.join(getCcsDir(), `${resolved.name}.settings.json`); + if (!options.force && (apiProfileExists(resolved.name) || fs.existsSync(settingsPath))) { + return { + success: false, + settingsFile: '', + error: `Profile already exists: ${resolved.name}`, + }; + } + + const result = createApiProfile( + resolved.name, + resolved.baseUrl, + resolved.apiKey, + resolved.models, + resolved.target, + provider + ); + + return { + ...result, + name: resolved.name, + provider, + target: resolved.target, + cliproxyBridge: + resolveCliproxyBridgeMetadata({ + env: { + ANTHROPIC_BASE_URL: resolved.baseUrl, + ANTHROPIC_AUTH_TOKEN: resolved.apiKey, + }, + }) ?? null, + }; +} + /** * Update API profile target (claude/droid). * Persists to config.yaml in unified mode and config.json profile_targets in legacy mode. diff --git a/src/cliproxy/ai-providers/config-store.ts b/src/cliproxy/ai-providers/config-store.ts new file mode 100644 index 00000000..2cc665f4 --- /dev/null +++ b/src/cliproxy/ai-providers/config-store.ts @@ -0,0 +1,128 @@ +import * as fs from 'fs'; +import * as yaml from 'js-yaml'; +import { configExists, getCliproxyConfigPath, regenerateConfig } from '../config-generator'; +import { getProxyTarget } from '../proxy-target-resolver'; +import { createManagementClient } from '../management-api-client'; +import { rewriteTopLevelYamlSection } from './config-yaml-sections'; +import type { + AiProviderApiKeyEntry, + AiProviderFamilyId, + LocalAiProviderConfig, + OpenAICompatEntry, +} from './types'; + +type FamilyEntriesMap = { + 'gemini-api-key': AiProviderApiKeyEntry[]; + 'codex-api-key': AiProviderApiKeyEntry[]; + 'claude-api-key': AiProviderApiKeyEntry[]; + 'vertex-api-key': AiProviderApiKeyEntry[]; + 'openai-compatibility': OpenAICompatEntry[]; +}; + +export type FamilyEntries = FamilyEntriesMap[F]; + +function ensureLocalConfigPath(): string { + if (!configExists()) { + regenerateConfig(); + } + return getCliproxyConfigPath(); +} + +function readLocalConfig(): LocalAiProviderConfig { + const configPath = ensureLocalConfigPath(); + if (!fs.existsSync(configPath)) { + return {}; + } + + try { + const content = fs.readFileSync(configPath, 'utf8'); + return (yaml.load(content) as LocalAiProviderConfig) || {}; + } catch { + return {}; + } +} + +function writeLocalFamilySection( + family: F, + entries: FamilyEntries +): void { + const configPath = ensureLocalConfigPath(); + const content = fs.existsSync(configPath) ? fs.readFileSync(configPath, 'utf8') : ''; + const sectionYaml = + entries.length > 0 + ? yaml.dump( + { [family]: entries }, + { + indent: 2, + lineWidth: -1, + quotingType: "'", + forceQuotes: false, + } + ) + : null; + const nextContent = rewriteTopLevelYamlSection(content, family, sectionYaml); + const tempPath = `${configPath}.tmp`; + fs.writeFileSync(tempPath, nextContent, { mode: 0o600 }); + fs.renameSync(tempPath, configPath); +} + +export function getAiProvidersSourceSummary() { + const target = getProxyTarget(); + const managementAuth = target.isRemote + ? target.managementKey + ? 'configured' + : target.authToken + ? 'fallback' + : 'missing' + : 'configured'; + + return { + mode: target.isRemote ? 'remote' : 'local', + label: target.isRemote ? 'Remote CLIProxy' : 'Local CLIProxy', + target: `${target.protocol}://${target.host}:${target.port}`, + managementAuth, + } as const; +} + +export async function readFamilyEntries( + family: F +): Promise> { + const target = getProxyTarget(); + + if (!target.isRemote) { + const config = readLocalConfig(); + return (config[family] || []) as FamilyEntries; + } + + const client = createManagementClient({ + host: target.host, + port: target.port, + protocol: target.protocol, + management_key: target.managementKey, + auth_token: target.authToken, + }); + + return client.getSection[number]>(family) as Promise>; +} + +export async function writeFamilyEntries( + family: F, + entries: FamilyEntries +): Promise { + const target = getProxyTarget(); + + if (!target.isRemote) { + writeLocalFamilySection(family, entries); + return; + } + + const client = createManagementClient({ + host: target.host, + port: target.port, + protocol: target.protocol, + management_key: target.managementKey, + auth_token: target.authToken, + }); + + await client.putSection[number]>(family, entries as FamilyEntries[number][]); +} diff --git a/src/cliproxy/ai-providers/config-yaml-sections.ts b/src/cliproxy/ai-providers/config-yaml-sections.ts new file mode 100644 index 00000000..a5502b22 --- /dev/null +++ b/src/cliproxy/ai-providers/config-yaml-sections.ts @@ -0,0 +1,49 @@ +export function rewriteTopLevelYamlSection( + content: string, + sectionKey: string, + newSection: string | null +): string { + const lines = content.split('\n'); + const result: string[] = []; + let inSection = false; + let sectionFound = false; + + for (const line of lines) { + const trimmed = line.trimStart(); + if (trimmed.startsWith(`${sectionKey}:`)) { + inSection = true; + sectionFound = true; + if (newSection) { + result.push(newSection.trimEnd()); + } + continue; + } + + if (inSection) { + const isTopLevelKey = + line.length > 0 && + !line.startsWith(' ') && + !line.startsWith('\t') && + !line.startsWith('#') && + /^[a-zA-Z_][a-zA-Z0-9_-]*\s*:/.test(line); + + if (isTopLevelKey) { + inSection = false; + result.push(line); + } + continue; + } + + result.push(line); + } + + if (!sectionFound && newSection) { + result.push(''); + result.push(newSection.trimEnd()); + } + + return `${result + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trimEnd()}\n`; +} diff --git a/src/cliproxy/ai-providers/index.ts b/src/cliproxy/ai-providers/index.ts new file mode 100644 index 00000000..e052a979 --- /dev/null +++ b/src/cliproxy/ai-providers/index.ts @@ -0,0 +1,19 @@ +export type { + AiProviderApiKeyEntry, + AiProviderEntryView, + AiProviderFamilyDefinition, + AiProviderFamilyId, + AiProviderFamilyState, + AiProviderModelAlias, + AiProvidersSourceSummary, + ListAiProvidersResult, + OpenAICompatEntry, + UpsertAiProviderEntryInput, +} from './types'; +export { AI_PROVIDER_FAMILY_DEFINITIONS, AI_PROVIDER_FAMILY_IDS } from './types'; +export { + listAiProviders, + createAiProviderEntry, + updateAiProviderEntry, + deleteAiProviderEntry, +} from './service'; diff --git a/src/cliproxy/ai-providers/service.ts b/src/cliproxy/ai-providers/service.ts new file mode 100644 index 00000000..a03f959a --- /dev/null +++ b/src/cliproxy/ai-providers/service.ts @@ -0,0 +1,246 @@ +import { + AI_PROVIDER_FAMILY_DEFINITIONS, + AI_PROVIDER_FAMILY_IDS, + type AiProviderApiKeyEntry, + type AiProviderEntryView, + type AiProviderFamilyId, + type AiProviderFamilyState, + type AiProviderModelAlias, + type ListAiProvidersResult, + type OpenAICompatEntry, + type UpsertAiProviderEntryInput, +} from './types'; +import { getAiProvidersSourceSummary, readFamilyEntries, writeFamilyEntries } from './config-store'; + +function maskSecret(value: string | undefined): string | undefined { + if (!value) return undefined; + return value.length > 8 ? `...${value.slice(-4)}` : '***'; +} + +function normalizeHeaders( + headers: Array<{ key: string; value: string }> | undefined +): Record | undefined { + if (!headers) return undefined; + const normalized = headers.reduce>((acc, header) => { + const key = header.key.trim(); + if (!key) return acc; + acc[key] = header.value; + return acc; + }, {}); + return Object.keys(normalized).length > 0 ? normalized : undefined; +} + +function toHeaderPairs( + headers: Record | undefined +): Array<{ key: string; value: string }> { + return Object.entries(headers || {}).map(([key, value]) => ({ key, value })); +} + +function normalizeModelAliases(models: AiProviderModelAlias[] | undefined): AiProviderModelAlias[] { + return (models || []) + .map((model) => ({ + name: model.name.trim(), + alias: model.alias.trim(), + })) + .filter((model) => model.name.length > 0 || model.alias.length > 0); +} + +function buildApiKeyEntryView( + family: AiProviderFamilyId, + entry: AiProviderApiKeyEntry, + index: number +): AiProviderEntryView { + return { + id: `${family}:${index}`, + index, + label: entry.prefix?.trim() || entry['base-url']?.trim() || `Entry ${index + 1}`, + baseUrl: entry['base-url']?.trim() || undefined, + proxyUrl: entry['proxy-url']?.trim() || undefined, + prefix: entry.prefix?.trim() || undefined, + headers: toHeaderPairs(entry.headers), + excludedModels: [...(entry['excluded-models'] || [])], + models: normalizeModelAliases(entry.models), + apiKeyMasked: maskSecret(entry['api-key']), + secretConfigured: Boolean(entry['api-key']), + }; +} + +function buildOpenAiCompatEntryView(entry: OpenAICompatEntry, index: number): AiProviderEntryView { + return { + id: `openai-compatibility:${index}`, + index, + name: entry.name, + label: entry.name, + baseUrl: entry['base-url']?.trim() || undefined, + headers: toHeaderPairs(entry.headers), + excludedModels: [], + models: normalizeModelAliases(entry.models), + apiKeysMasked: (entry['api-key-entries'] || []).map( + (apiKeyEntry) => maskSecret(apiKeyEntry['api-key']) || '***' + ), + secretConfigured: (entry['api-key-entries'] || []).length > 0, + }; +} + +function resolveFamilyStatus(entries: AiProviderEntryView[]): AiProviderFamilyState['status'] { + if (entries.length === 0) return 'empty'; + return entries.every((entry) => entry.secretConfigured) ? 'ready' : 'partial'; +} + +export async function listAiProviders(): Promise { + const families = await Promise.all( + AI_PROVIDER_FAMILY_IDS.map(async (familyId) => { + const definition = AI_PROVIDER_FAMILY_DEFINITIONS[familyId]; + if (familyId === 'openai-compatibility') { + const entries = await readFamilyEntries(familyId); + const entryViews = entries.map((entry, index) => buildOpenAiCompatEntryView(entry, index)); + return { + ...definition, + status: resolveFamilyStatus(entryViews), + entries: entryViews, + }; + } + + const entries = await readFamilyEntries(familyId); + const entryViews = entries.map((entry, index) => + buildApiKeyEntryView(familyId, entry, index) + ); + return { + ...definition, + status: resolveFamilyStatus(entryViews), + entries: entryViews, + }; + }) + ); + + return { + source: getAiProvidersSourceSummary(), + families, + }; +} + +function toApiKeyEntry( + input: UpsertAiProviderEntryInput, + existing?: AiProviderApiKeyEntry +): AiProviderApiKeyEntry { + const nextSecret = + input.apiKey !== undefined + ? input.apiKey.trim() + : input.preserveSecrets + ? existing?.['api-key'] || '' + : existing?.['api-key'] || ''; + + return { + 'api-key': nextSecret, + 'base-url': input.baseUrl?.trim() || undefined, + 'proxy-url': input.proxyUrl?.trim() || undefined, + prefix: input.prefix?.trim() || undefined, + headers: normalizeHeaders(input.headers), + 'excluded-models': (input.excludedModels || []) + .map((value) => value.trim()) + .filter((value) => value.length > 0), + models: normalizeModelAliases(input.models), + }; +} + +function toOpenAiCompatEntry( + input: UpsertAiProviderEntryInput, + existing?: OpenAICompatEntry +): OpenAICompatEntry { + const nextApiKeys = + input.apiKeys !== undefined + ? input.apiKeys.map((value) => value.trim()).filter((value) => value.length > 0) + : input.preserveSecrets + ? (existing?.['api-key-entries'] || []).map((entry) => entry['api-key']) + : (existing?.['api-key-entries'] || []).map((entry) => entry['api-key']); + + return { + name: input.name?.trim() || existing?.name || 'connector', + 'base-url': input.baseUrl?.trim() || existing?.['base-url'] || '', + headers: normalizeHeaders(input.headers), + 'api-key-entries': nextApiKeys.map((apiKey) => ({ 'api-key': apiKey })), + models: normalizeModelAliases(input.models), + }; +} + +function assertIndex(entries: unknown[], index: number): void { + if (!Number.isInteger(index) || index < 0 || index >= entries.length) { + throw new Error('Entry not found'); + } +} + +function validateFamilyInput(family: AiProviderFamilyId, input: UpsertAiProviderEntryInput): void { + if (family === 'openai-compatibility') { + if (!(input.name?.trim() || '').length) { + throw new Error('name is required'); + } + if (!(input.baseUrl?.trim() || '').length) { + throw new Error('baseUrl is required'); + } + if (!input.preserveSecrets && !(input.apiKeys || []).some((value) => value.trim().length > 0)) { + throw new Error('At least one api key is required'); + } + return; + } + + if (!input.preserveSecrets && !(input.apiKey?.trim() || '').length) { + throw new Error('apiKey is required'); + } +} + +export async function createAiProviderEntry( + family: AiProviderFamilyId, + input: UpsertAiProviderEntryInput +): Promise { + validateFamilyInput(family, input); + + if (family === 'openai-compatibility') { + const entries = await readFamilyEntries(family); + entries.push(toOpenAiCompatEntry(input)); + await writeFamilyEntries(family, entries); + return; + } + + const entries = await readFamilyEntries(family); + entries.push(toApiKeyEntry(input)); + await writeFamilyEntries(family, entries); +} + +export async function updateAiProviderEntry( + family: AiProviderFamilyId, + index: number, + input: UpsertAiProviderEntryInput +): Promise { + if (family === 'openai-compatibility') { + const entries = await readFamilyEntries(family); + assertIndex(entries, index); + validateFamilyInput(family, input); + entries[index] = toOpenAiCompatEntry(input, entries[index]); + await writeFamilyEntries(family, entries); + return; + } + + const entries = await readFamilyEntries(family); + assertIndex(entries, index); + validateFamilyInput(family, input); + entries[index] = toApiKeyEntry(input, entries[index]); + await writeFamilyEntries(family, entries); +} + +export async function deleteAiProviderEntry( + family: AiProviderFamilyId, + index: number +): Promise { + if (family === 'openai-compatibility') { + const entries = await readFamilyEntries(family); + assertIndex(entries, index); + entries.splice(index, 1); + await writeFamilyEntries(family, entries); + return; + } + + const entries = await readFamilyEntries(family); + assertIndex(entries, index); + entries.splice(index, 1); + await writeFamilyEntries(family, entries); +} diff --git a/src/cliproxy/ai-providers/types.ts b/src/cliproxy/ai-providers/types.ts new file mode 100644 index 00000000..3fe9cff6 --- /dev/null +++ b/src/cliproxy/ai-providers/types.ts @@ -0,0 +1,153 @@ +export const AI_PROVIDER_FAMILY_IDS = [ + 'gemini-api-key', + 'codex-api-key', + 'claude-api-key', + 'vertex-api-key', + 'openai-compatibility', +] as const; + +export type AiProviderFamilyId = (typeof AI_PROVIDER_FAMILY_IDS)[number]; + +export interface AiProviderModelAlias { + name: string; + alias: string; +} + +export interface AiProviderApiKeyEntry { + 'api-key': string; + 'base-url'?: string; + 'proxy-url'?: string; + prefix?: string; + headers?: Record; + 'excluded-models'?: string[]; + models?: AiProviderModelAlias[]; +} + +export interface OpenAICompatApiKeyEntry { + 'api-key': string; + 'proxy-url'?: string; +} + +export interface OpenAICompatEntry { + name: string; + 'base-url': string; + headers?: Record; + 'api-key-entries': OpenAICompatApiKeyEntry[]; + models?: AiProviderModelAlias[]; +} + +export interface AiProviderFamilyDefinition { + id: AiProviderFamilyId; + displayName: string; + description: string; + authMode: 'api-key' | 'hybrid' | 'connector'; + supportsNamedEntries: boolean; + routePath: string; +} + +export interface AiProviderEntryView { + id: string; + index: number; + name?: string; + label: string; + baseUrl?: string; + proxyUrl?: string; + prefix?: string; + headers: Array<{ key: string; value: string }>; + excludedModels: string[]; + models: AiProviderModelAlias[]; + apiKeyMasked?: string; + apiKeysMasked?: string[]; + secretConfigured: boolean; +} + +export interface AiProviderFamilyState { + id: AiProviderFamilyId; + displayName: string; + description: string; + authMode: 'api-key' | 'hybrid' | 'connector'; + routePath: string; + status: 'empty' | 'partial' | 'ready'; + supportsNamedEntries: boolean; + entries: AiProviderEntryView[]; +} + +export interface AiProvidersSourceSummary { + mode: 'local' | 'remote'; + label: string; + target: string; + managementAuth: 'configured' | 'fallback' | 'missing'; +} + +export interface ListAiProvidersResult { + source: AiProvidersSourceSummary; + families: AiProviderFamilyState[]; +} + +export interface UpsertAiProviderEntryInput { + name?: string; + baseUrl?: string; + proxyUrl?: string; + prefix?: string; + headers?: Array<{ key: string; value: string }>; + excludedModels?: string[]; + models?: AiProviderModelAlias[]; + apiKey?: string; + apiKeys?: string[]; + preserveSecrets?: boolean; +} + +export interface LocalAiProviderConfig { + 'gemini-api-key'?: AiProviderApiKeyEntry[]; + 'codex-api-key'?: AiProviderApiKeyEntry[]; + 'claude-api-key'?: AiProviderApiKeyEntry[]; + 'vertex-api-key'?: AiProviderApiKeyEntry[]; + 'openai-compatibility'?: OpenAICompatEntry[]; + [key: string]: unknown; +} + +export const AI_PROVIDER_FAMILY_DEFINITIONS: Record< + AiProviderFamilyId, + AiProviderFamilyDefinition +> = { + 'gemini-api-key': { + id: 'gemini-api-key', + displayName: 'Gemini', + description: 'Google Gemini API keys and route defaults', + authMode: 'hybrid', + supportsNamedEntries: false, + routePath: '/api/provider/gemini', + }, + 'codex-api-key': { + id: 'codex-api-key', + displayName: 'Codex', + description: 'OpenAI Codex API keys and endpoint overrides', + authMode: 'hybrid', + supportsNamedEntries: false, + routePath: '/api/provider/codex', + }, + 'claude-api-key': { + id: 'claude-api-key', + displayName: 'Claude', + description: 'Anthropic-compatible routing entries with aliases and filters', + authMode: 'api-key', + supportsNamedEntries: false, + routePath: '/api/provider/claude', + }, + 'vertex-api-key': { + id: 'vertex-api-key', + displayName: 'Vertex', + description: 'Vertex AI API keys and regional endpoint overrides', + authMode: 'api-key', + supportsNamedEntries: false, + routePath: '/api/provider/vertex', + }, + 'openai-compatibility': { + id: 'openai-compatibility', + displayName: 'OpenAI-Compatible', + description: 'Named connectors for OpenRouter, Together, and custom OpenAI-style APIs', + authMode: 'connector', + supportsNamedEntries: true, + routePath: '/api/provider/openai-compat', + }, +}; diff --git a/src/cliproxy/base-config-loader.ts b/src/cliproxy/base-config-loader.ts index 09de1603..ab774d06 100644 --- a/src/cliproxy/base-config-loader.ts +++ b/src/cliproxy/base-config-loader.ts @@ -9,7 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { CLIProxyProvider, ProviderModelMapping } from './types'; +import type { CLIProxyProvider, ProviderModelMapping } from './types'; /** Base settings file structure */ interface BaseSettings { diff --git a/src/cliproxy/composite-validator.ts b/src/cliproxy/composite-validator.ts index 52b24f3b..e2e24a53 100644 --- a/src/cliproxy/composite-validator.ts +++ b/src/cliproxy/composite-validator.ts @@ -3,7 +3,8 @@ * Used by API routes, service layer, and config loader to avoid contract drift. */ -import { CLIPROXY_SUPPORTED_PROVIDERS, CompositeTierConfig } from '../config/unified-config-types'; +import { CLIPROXY_SUPPORTED_PROVIDERS } from '../config/unified-config-types'; +import type { CompositeTierConfig } from '../config/unified-config-types'; import type { CLIProxyProvider } from './types'; import { getDeniedModelIdReasonForProvider } from './model-id-normalizer'; diff --git a/src/cliproxy/config/env-builder.ts b/src/cliproxy/config/env-builder.ts index 3a985f6b..dcace13b 100644 --- a/src/cliproxy/config/env-builder.ts +++ b/src/cliproxy/config/env-builder.ts @@ -5,13 +5,13 @@ import * as fs from 'fs'; import * as path from 'path'; -import { CLIProxyProvider, ProviderModelMapping } from '../types'; +import type { CLIProxyProvider, ProviderModelMapping } from '../types'; import { getModelMappingFromConfig, getEnvVarsFromConfig } from '../base-config-loader'; import { getGlobalEnvConfig } from '../../config/unified-config-loader'; import { getEffectiveApiKey } from '../auth-token-manager'; import { expandPath } from '../../utils/helpers'; import { warn } from '../../utils/ui'; -import { CompositeTierConfig } from '../../config/unified-config-types'; +import type { CompositeTierConfig } from '../../config/unified-config-types'; import { validatePort, validateRemotePort, diff --git a/src/cliproxy/config/extended-context-config.ts b/src/cliproxy/config/extended-context-config.ts index 60616e09..38727e7f 100644 --- a/src/cliproxy/config/extended-context-config.ts +++ b/src/cliproxy/config/extended-context-config.ts @@ -9,7 +9,7 @@ * - Claude (Anthropic): Opt-in via --1m flag */ -import { CLIProxyProvider } from '../types'; +import type { CLIProxyProvider } from '../types'; import { supportsExtendedContext } from '../model-catalog'; import { warn } from '../../utils/ui'; import { diff --git a/src/cliproxy/config/generator.ts b/src/cliproxy/config/generator.ts index e10a7671..dd4e65a7 100644 --- a/src/cliproxy/config/generator.ts +++ b/src/cliproxy/config/generator.ts @@ -5,7 +5,7 @@ import * as fs from 'fs'; import * as path from 'path'; -import { CLIProxyProvider, ProviderConfig } from '../types'; +import type { CLIProxyProvider, ProviderConfig } from '../types'; import { getProviderDisplayName } from '../provider-capabilities'; import { getModelMappingFromConfig } from '../base-config-loader'; import { loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; diff --git a/src/cliproxy/config/path-resolver.ts b/src/cliproxy/config/path-resolver.ts index 70615d35..21746fc7 100644 --- a/src/cliproxy/config/path-resolver.ts +++ b/src/cliproxy/config/path-resolver.ts @@ -5,7 +5,7 @@ import * as path from 'path'; import { getCcsDir } from '../../utils/config-manager'; -import { CLIProxyProvider } from '../types'; +import type { CLIProxyProvider } from '../types'; import { CLIPROXY_DEFAULT_PORT } from './port-manager'; /** diff --git a/src/cliproxy/config/thinking-config.ts b/src/cliproxy/config/thinking-config.ts index 1162b561..3988ccf7 100644 --- a/src/cliproxy/config/thinking-config.ts +++ b/src/cliproxy/config/thinking-config.ts @@ -3,8 +3,9 @@ * Manages thinking budget suffixes for CLIProxyAPIPlus */ -import { CLIProxyProvider } from '../types'; -import { ThinkingConfig, DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types'; +import type { CLIProxyProvider } from '../types'; +import { DEFAULT_THINKING_TIER_DEFAULTS } from '../../config/unified-config-types'; +import type { ThinkingConfig } from '../../config/unified-config-types'; import { getThinkingConfig } from '../../config/unified-config-loader'; import { supportsThinking } from '../model-catalog'; import { isThinkingOffValue, validateThinking } from '../thinking-validator'; diff --git a/src/cliproxy/index.ts b/src/cliproxy/index.ts index 375a2b34..aef15b13 100644 --- a/src/cliproxy/index.ts +++ b/src/cliproxy/index.ts @@ -143,6 +143,23 @@ export { TOGETHER_TEMPLATE, } from './openai-compat-manager'; +// AI provider management +export type { + AiProviderFamilyId, + AiProviderFamilyState, + AiProviderEntryView, + ListAiProvidersResult, + UpsertAiProviderEntryInput, +} from './ai-providers'; +export { + AI_PROVIDER_FAMILY_DEFINITIONS, + AI_PROVIDER_FAMILY_IDS, + listAiProviders, + createAiProviderEntry, + updateAiProviderEntry, + deleteAiProviderEntry, +} from './ai-providers'; + // Service manager (background CLIProxy for dashboard) export type { ServiceStartResult } from './service-manager'; export { ensureCliproxyService, stopCliproxyService, getServiceStatus } from './service-manager'; diff --git a/src/cliproxy/management-api-client.ts b/src/cliproxy/management-api-client.ts index f33edd21..7afff06f 100644 --- a/src/cliproxy/management-api-client.ts +++ b/src/cliproxy/management-api-client.ts @@ -11,7 +11,6 @@ import type { ManagementHealthStatus, ManagementApiErrorCode, ClaudeKey, - GetClaudeKeysResponse, ClaudeKeyPatch, RemoteModelInfo, GetModelDefinitionsResponse, @@ -189,11 +188,7 @@ export class ManagementApiClient { * Get all claude-api-key entries from remote CLIProxy. */ async getClaudeKeys(): Promise { - const response = await this.request( - 'GET', - '/v0/management/claude-api-key' - ); - return response.data?.['claude-api-key'] ?? []; + return this.getSection('claude-api-key'); } /** @@ -201,7 +196,7 @@ export class ManagementApiClient { * This is an atomic operation - all entries are replaced at once. */ async putClaudeKeys(keys: ClaudeKey[]): Promise { - await this.request('PUT', '/v0/management/claude-api-key', keys); + await this.putSection('claude-api-key', keys); } /** @@ -232,6 +227,22 @@ export class ManagementApiClient { return response.data?.models ?? []; } + /** + * Get a management section from CLIProxyAPI. + * Example sections: claude-api-key, gemini-api-key, codex-api-key. + */ + async getSection(section: string): Promise { + const response = await this.request>('GET', `/v0/management/${section}`); + return response.data?.[section] ?? []; + } + + /** + * Replace an entire management section on CLIProxyAPI. + */ + async putSection(section: string, entries: T[]): Promise { + await this.request('PUT', `/v0/management/${section}`, entries); + } + /** * Make an HTTP request to the Management API. */ diff --git a/src/cliproxy/model-catalog.ts b/src/cliproxy/model-catalog.ts index d9644ed5..a9d7970e 100644 --- a/src/cliproxy/model-catalog.ts +++ b/src/cliproxy/model-catalog.ts @@ -5,7 +5,7 @@ * Models are mapped to their internal names used by the proxy backend. */ -import { CLIProxyProvider } from './types'; +import type { CLIProxyProvider } from './types'; import { isAntigravityProvider, migrateDeniedAntigravityModelAliases, diff --git a/src/cliproxy/model-id-normalizer.ts b/src/cliproxy/model-id-normalizer.ts index d291def2..99761bfa 100644 --- a/src/cliproxy/model-id-normalizer.ts +++ b/src/cliproxy/model-id-normalizer.ts @@ -5,7 +5,7 @@ * model version formats (e.g., 4.6 vs 4-6). */ -import { CLIProxyProvider } from './types'; +import type { CLIProxyProvider } from './types'; /** Env vars that carry model identifiers. */ export const MODEL_ENV_VAR_KEYS = [ diff --git a/src/cliproxy/thinking-validator.ts b/src/cliproxy/thinking-validator.ts index e749ecb7..5c58994f 100644 --- a/src/cliproxy/thinking-validator.ts +++ b/src/cliproxy/thinking-validator.ts @@ -7,8 +7,9 @@ * - Warns when model doesn't support thinking */ -import { getModelThinkingSupport, ThinkingSupport } from './model-catalog'; -import { CLIProxyProvider } from './types'; +import { getModelThinkingSupport } from './model-catalog'; +import type { ThinkingSupport } from './model-catalog'; +import type { CLIProxyProvider } from './types'; /** * Thinking budget bounds (used for validation across API and CLI) diff --git a/src/cliproxy/types.ts b/src/cliproxy/types.ts index f3b246de..2cb7b292 100644 --- a/src/cliproxy/types.ts +++ b/src/cliproxy/types.ts @@ -162,11 +162,33 @@ export interface CLIProxyConfig { 'api-key': string; 'base-url'?: string; }>; + 'claude-api-key'?: Array<{ + 'api-key': string; + 'base-url'?: string; + 'proxy-url'?: string; + prefix?: string; + headers?: Record; + 'excluded-models'?: string[]; + models?: Array<{ + name: string; + alias: string; + }>; + }>; + 'vertex-api-key'?: Array<{ + 'api-key': string; + 'base-url'?: string; + }>; 'openai-compatibility'?: Array<{ name: string; 'base-url': string; + headers?: Record; 'api-key-entries': Array<{ 'api-key': string; + 'proxy-url'?: string; + }>; + models?: Array<{ + name: string; + alias: string; }>; }>; } diff --git a/src/commands/api-command/create-command.ts b/src/commands/api-command/create-command.ts index 5941e9d1..0bcce273 100644 --- a/src/commands/api-command/create-command.ts +++ b/src/commands/api-command/create-command.ts @@ -1,6 +1,8 @@ import { apiProfileExists, + createCliproxyBridgeProfile, createApiProfile, + getDefaultCliproxyBridgeName, getPresetById, getPresetIds, getUrlWarning, @@ -8,11 +10,17 @@ import { isUsingUnifiedConfig, pickOpenRouterModel, sanitizeBaseUrl, + suggestCliproxyBridgeName, validateApiName, validateUrl, type ModelMapping, type ProviderPreset, } from '../../api/services'; +import { + CLIPROXY_PROVIDER_IDS, + getProviderDisplayName, + isCLIProxyProvider, +} from '../../cliproxy/provider-capabilities'; import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync'; import type { TargetType } from '../../targets/target-adapter'; import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui'; @@ -66,6 +74,34 @@ async function resolveProfileName( return name; } +async function resolveCliproxyProfileName( + provider: string, + providedName: string | undefined, + yes: boolean | undefined +): Promise { + if (providedName) { + const error = validateApiName(providedName); + if (error) { + console.log(fail(error)); + process.exit(1); + } + return providedName; + } + + const suggestedName = isCLIProxyProvider(provider) + ? suggestCliproxyBridgeName(provider) + : getDefaultCliproxyBridgeName('gemini'); + + if (yes) { + return suggestedName; + } + + return InteractivePrompt.input('API name', { + default: suggestedName, + validate: validateApiName, + }); +} + async function resolveBaseUrl( providedBaseUrl: string | undefined, preset: ProviderPreset | null @@ -248,6 +284,101 @@ export async function handleApiCreateCommand(args: string[]): Promise { console.log(header('Create API Profile')); console.log(''); + if (parsedArgs.cliproxyProvider) { + const cliproxyProvider = parsedArgs.cliproxyProvider.trim().toLowerCase(); + if (!isCLIProxyProvider(cliproxyProvider)) { + console.log(fail(`Unknown CLIProxy provider: ${cliproxyProvider}`)); + console.log(''); + console.log(`Available providers: ${CLIPROXY_PROVIDER_IDS.join(', ')}`); + process.exit(1); + } + + const incompatibleFlags = [ + parsedArgs.baseUrl && '--base-url', + parsedArgs.apiKey && '--api-key', + parsedArgs.model && '--model', + parsedArgs.preset && '--preset', + ].filter(Boolean); + + if (incompatibleFlags.length > 0) { + console.log( + fail(`--cliproxy-provider cannot be combined with ${incompatibleFlags.join(', ')}`) + ); + process.exit(1); + } + + const name = await resolveCliproxyProfileName( + cliproxyProvider, + parsedArgs.name, + parsedArgs.yes + ); + const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes); + + if (name && apiProfileExists(name) && !parsedArgs.force) { + console.log(fail(`API '${name}' already exists`)); + console.log(` Use ${color('--force', 'command')} to overwrite`); + process.exit(1); + } + + console.log( + info( + `Using CLIProxy provider: ${getProviderDisplayName(cliproxyProvider)} (${cliproxyProvider})` + ) + ); + console.log( + dim(' CCS will create a routed API profile. Provider credentials stay managed by CLIProxy.') + ); + console.log(''); + console.log(info('Creating API profile...')); + + const result = createCliproxyBridgeProfile(cliproxyProvider, { + force: parsedArgs.force === true, + name, + target, + }); + if (!result.success || !result.name || !result.cliproxyBridge) { + console.log(fail(`Failed to create CLIProxy bridge profile: ${result.error}`)); + process.exit(1); + } + + try { + syncToLocalConfig(); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.log(`[i] Auto-sync to CLIProxy config skipped: ${message}`); + } + + const details = + `API: ${result.name}\n` + + `Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` + + `Settings: ${result.settingsFile}\n` + + `Provider: ${result.cliproxyBridge.providerDisplayName}\n` + + `Route: ${result.cliproxyBridge.routePath}\n` + + `Proxy: ${result.cliproxyBridge.currentBaseUrl}\n` + + `Target: ${target}`; + + console.log(''); + console.log(infoBox(details, 'CLIProxy Bridge Created')); + console.log(''); + console.log(header('Usage')); + if (target === 'droid') { + console.log( + ` ${color(`ccs ${result.name} "your prompt"`, 'command')} ${dim('# uses droid by default')}` + ); + console.log( + ` ${color(`ccsd ${result.name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}` + ); + } else { + console.log(` ${color(`ccs ${result.name} "your prompt"`, 'command')}`); + console.log( + ` ${color(`ccs ${result.name} --target droid "your prompt"`, 'command')} ${dim('# optional target override')}` + ); + } + console.log(''); + console.log(dim('Manage provider accounts, keys, and models in: ccs cliproxy')); + return; + } + showPresetDeprecationNotice(parsedArgs.preset); const preset = resolvePresetOrExit(parsedArgs.preset); const name = await resolveProfileName(parsedArgs.name, preset); diff --git a/src/commands/api-command/help.ts b/src/commands/api-command/help.ts index e9c5f26a..c9b3fb99 100644 --- a/src/commands/api-command/help.ts +++ b/src/commands/api-command/help.ts @@ -1,5 +1,6 @@ import { PROVIDER_PRESETS, + listCliproxyBridgeProviders, getPresetAliases, getPresetIds, type ProviderPreset, @@ -20,6 +21,7 @@ export async function showApiCommandHelp(): Promise { const presetIds = getPresetIds() .map((id) => sanitizeHelpText(id)) .filter(Boolean); + const cliproxyProviderIds = listCliproxyBridgeProviders().map((provider) => provider.provider); const presetAliases = getPresetAliases(); const presetIdWidth = Math.max(0, ...presetIds.map((id) => id.length)) + 2; @@ -47,6 +49,9 @@ export async function showApiCommandHelp(): Promise { console.log( ` ${color('--preset ', 'command')} Use provider preset (${presetIds.join(', ')})` ); + console.log( + ` ${color('--cliproxy-provider ', 'command')} Use routed CLIProxy provider (${cliproxyProviderIds.join(', ')})` + ); console.log(` ${color('--base-url ', 'command')} API base URL (create)`); console.log(` ${color('--api-key ', 'command')} API key (create)`); console.log(` ${color('--model ', 'command')} Default model (create)`); @@ -84,6 +89,12 @@ export async function showApiCommandHelp(): Promise { console.log(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`); console.log(` ${color('ccs api create --preset glm', 'command')}`); console.log(''); + console.log(` ${dim('# Create routed profile from existing CLIProxy provider config')}`); + console.log(` ${color('ccs api create --cliproxy-provider gemini', 'command')}`); + console.log( + ` ${color('ccs api create gemini-droid --cliproxy-provider gemini --target droid', 'command')}` + ); + console.log(''); console.log(` ${dim('# Create with name')}`); console.log(` ${color('ccs api create myapi', 'command')}`); console.log(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`); diff --git a/src/commands/api-command/shared.ts b/src/commands/api-command/shared.ts index d01a3107..de42e70b 100644 --- a/src/commands/api-command/shared.ts +++ b/src/commands/api-command/shared.ts @@ -9,6 +9,7 @@ export interface ApiCommandArgs { apiKey?: string; model?: string; preset?: string; + cliproxyProvider?: string; target?: TargetType; force?: boolean; yes?: boolean; @@ -21,6 +22,7 @@ export const API_VALUE_FLAGS = [ '--api-key', '--model', '--preset', + '--cliproxy-provider', '--target', ] as const; export const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS]; @@ -210,6 +212,18 @@ export function parseApiCommandArgs( false ); + remaining = applyRepeatedOption( + remaining, + ['--cliproxy-provider'], + (value) => { + result.cliproxyProvider = value.trim().toLowerCase(); + }, + () => { + result.errors.push('Missing value for --cliproxy-provider'); + }, + false + ); + remaining = applyRepeatedOption( remaining, ['--target'], diff --git a/src/commands/config-dashboard-host.ts b/src/commands/config-dashboard-host.ts index e4dd8703..7a29242c 100644 --- a/src/commands/config-dashboard-host.ts +++ b/src/commands/config-dashboard-host.ts @@ -1,7 +1,7 @@ import * as os from 'os'; -const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1', '[::1]']); -const WILDCARD_HOSTS = new Set(['0.0.0.0', '::', '[::]']); +const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']); +const WILDCARD_HOSTS = new Set(['0.0.0.0', '::']); interface NetworkInterfaceCandidate { address: string; diff --git a/src/commands/help-command.ts b/src/commands/help-command.ts index 97e2af09..41d365aa 100644 --- a/src/commands/help-command.ts +++ b/src/commands/help-command.ts @@ -141,6 +141,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim(); ['ccs ollama-cloud', 'Ollama Cloud (API key required)'], ['', ''], // Spacer ['ccs api create --preset anthropic', 'Anthropic direct API key (sk-ant-...)'], + [ + 'ccs api create --cliproxy-provider gemini', + 'Create routed API profile from CLIProxy Gemini', + ], ['ccs api create', 'Create custom API profile'], ['ccs api discover --register', 'Discover/register orphan settings files'], ['ccs api copy ', 'Duplicate API profile'], diff --git a/src/config/unified-config-loader.ts b/src/config/unified-config-loader.ts index 8bcc9445..19bd3266 100644 --- a/src/config/unified-config-loader.ts +++ b/src/config/unified-config-loader.ts @@ -10,7 +10,6 @@ import * as path from 'path'; import * as yaml from 'js-yaml'; import { getCcsDir } from '../utils/config-manager'; import { - UnifiedConfig, isUnifiedConfig, createEmptyUnifiedConfig, UNIFIED_CONFIG_VERSION, @@ -23,6 +22,9 @@ import { DEFAULT_THINKING_CONFIG, DEFAULT_DASHBOARD_AUTH_CONFIG, DEFAULT_IMAGE_ANALYSIS_CONFIG, +} from './unified-config-types'; +import type { + UnifiedConfig, CLIProxySafetyConfig, GlobalEnvConfig, ThinkingConfig, diff --git a/src/types/cli.ts b/src/types/cli.ts index baf1870b..6737932c 100644 --- a/src/types/cli.ts +++ b/src/types/cli.ts @@ -1,4 +1,4 @@ -import { SpawnOptions as NodeSpawnOptions } from 'child_process'; +import type { SpawnOptions as NodeSpawnOptions } from 'child_process'; /** * CLI Runtime Types @@ -43,11 +43,13 @@ export interface ClaudeCliInfo { /** * Exit codes */ -export enum ExitCode { - SUCCESS = 0, - GENERIC_ERROR = 1, - CLAUDE_NOT_FOUND = 127, - CONFIG_ERROR = 2, - DELEGATION_ERROR = 3, - TIMEOUT = 124, -} +export const ExitCode = { + SUCCESS: 0, + GENERIC_ERROR: 1, + CLAUDE_NOT_FOUND: 127, + CONFIG_ERROR: 2, + DELEGATION_ERROR: 3, + TIMEOUT: 124, +} as const; + +export type ExitCode = (typeof ExitCode)[keyof typeof ExitCode]; diff --git a/src/types/utils.ts b/src/types/utils.ts index 51ccc6a7..fd573f03 100644 --- a/src/types/utils.ts +++ b/src/types/utils.ts @@ -9,12 +9,14 @@ export type { ErrorCode } from '../utils/error-codes'; /** * Log levels */ -export enum LogLevel { - DEBUG = 'debug', - INFO = 'info', - WARN = 'warn', - ERROR = 'error', -} +export const LogLevel = { + DEBUG: 'debug', + INFO: 'info', + WARN: 'warn', + ERROR: 'error', +} as const; + +export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]; /** * Color codes (TTY-aware) diff --git a/src/utils/claude-config-path.ts b/src/utils/claude-config-path.ts index e084cceb..0cd853ae 100644 --- a/src/utils/claude-config-path.ts +++ b/src/utils/claude-config-path.ts @@ -1,5 +1,5 @@ -import * as os from 'os'; import * as path from 'path'; +import { getCcsHome } from './config-manager'; /** * Resolve Claude config directory with test/dev overrides. @@ -13,11 +13,7 @@ export function getClaudeConfigDir(): string { return path.resolve(process.env.CLAUDE_CONFIG_DIR); } - if (process.env.CCS_HOME) { - return path.join(path.resolve(process.env.CCS_HOME), '.claude'); - } - - return path.join(os.homedir(), '.claude'); + return path.join(getCcsHome(), '.claude'); } /** Resolve Claude settings.json path. */ diff --git a/src/utils/config-manager.ts b/src/utils/config-manager.ts index 7b8ce3cb..e20e3660 100644 --- a/src/utils/config-manager.ts +++ b/src/utils/config-manager.ts @@ -1,14 +1,9 @@ +import { AsyncLocalStorage } from 'async_hooks'; import * as fs from 'fs'; import * as path from 'path'; import * as os from 'os'; -import { - Config, - isConfig, - Settings, - isSettings, - CLIProxyVariantsConfig, - CLIProxyVariantConfig, -} from '../types'; +import { isConfig, isSettings } from '../types'; +import type { Config, Settings, CLIProxyVariantsConfig, CLIProxyVariantConfig } from '../types'; import { expandPath, error } from './helpers'; import { info } from './ui'; import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-config-loader'; @@ -19,6 +14,38 @@ import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../config/unified-conf // Module-level state for --config-dir CLI flag override let _globalConfigDir: string | undefined; +const configScopeStorage = new AsyncLocalStorage<{ ccsHome?: string; ccsDir?: string }>(); + +function normalizeScopedPath(dir: string | undefined): string | undefined { + return dir ? path.resolve(dir) : undefined; +} + +export async function runWithScopedConfig( + scope: { ccsHome?: string; ccsDir?: string }, + fn: () => Promise | T +): Promise { + return await configScopeStorage.run( + { + ccsHome: normalizeScopedPath(scope.ccsHome), + ccsDir: normalizeScopedPath(scope.ccsDir), + }, + fn + ); +} + +export async function runWithScopedCcsHome( + ccsHome: string, + fn: () => Promise | T +): Promise { + return await runWithScopedConfig({ ccsHome }, fn); +} + +export async function runWithScopedConfigDir( + ccsDir: string, + fn: () => Promise | T +): Promise { + return await runWithScopedConfig({ ccsDir }, fn); +} /** * Set global config directory from --config-dir CLI flag. @@ -34,6 +61,10 @@ export function setGlobalConfigDir(dir: string | undefined): void { * @returns Home directory path */ export function getCcsHome(): string { + const scopedHome = configScopeStorage.getStore()?.ccsHome; + if (scopedHome) { + return scopedHome; + } return process.env.CCS_HOME || os.homedir(); } @@ -42,6 +73,10 @@ export function getCcsHome(): string { * Single source of truth for precedence logic. */ function _resolveCcsDir(): { source: string; dir: string } { + const scopedConfig = configScopeStorage.getStore(); + if (scopedConfig?.ccsDir) return { source: 'scoped:CCS_DIR', dir: scopedConfig.ccsDir }; + if (scopedConfig?.ccsHome) + return { source: 'scoped:CCS_HOME', dir: path.join(scopedConfig.ccsHome, '.ccs') }; if (_globalConfigDir) return { source: '--config-dir', dir: _globalConfigDir }; if (process.env.CCS_DIR) return { source: 'CCS_DIR', dir: path.resolve(process.env.CCS_DIR) }; if (process.env.CCS_HOME) diff --git a/src/web-server/routes/account-routes.ts b/src/web-server/routes/account-routes.ts index 147de4c8..f8c74e6d 100644 --- a/src/web-server/routes/account-routes.ts +++ b/src/web-server/routes/account-routes.ts @@ -8,7 +8,7 @@ import { Router, Request, Response } from 'express'; import ProfileRegistry from '../../auth/profile-registry'; import InstanceManager from '../../management/instance-manager'; -import { isUnifiedMode } from '../../config/unified-config-loader'; +import { isUnifiedMode, loadOrCreateUnifiedConfig } from '../../config/unified-config-loader'; import { getAllAccountsSummary, setDefaultAccount as setCliproxyDefault, @@ -30,12 +30,28 @@ import { parseCliproxyKey, type MergedAccountEntry, } from './account-route-helpers'; +import type { AccountConfig } from '../../config/unified-config-types'; const router = Router(); -const registry = new ProfileRegistry(); -const instanceMgr = new InstanceManager(); + +function createProfileRegistry(): ProfileRegistry { + return new ProfileRegistry(); +} + +function createInstanceManager(): InstanceManager { + return new InstanceManager(); +} + +function getUnifiedAccountsRaw(): Record { + if (!isUnifiedMode()) { + return {}; + } + + return loadOrCreateUnifiedConfig().accounts; +} function hasAuthAccount(name: string): boolean { + const registry = createProfileRegistry(); return registry.hasAccountUnified(name) || registry.hasProfile(name); } @@ -44,8 +60,11 @@ function hasAuthAccount(name: string): boolean { */ router.get('/', (_req: Request, res: Response): void => { try { + const registry = createProfileRegistry(); + // Get profiles from both legacy and unified config (same logic as CLI) const legacyProfiles = registry.getAllProfiles(); + const rawUnifiedAccounts = getUnifiedAccountsRaw(); const unifiedAccounts = registry.getAllAccountsUnified(); // Get CLIProxy OAuth accounts (gemini, codex, agy, etc.) @@ -76,11 +95,12 @@ router.get('/', (_req: Request, res: Response): void => { // Override with unified config accounts (takes precedence) for (const [name, account] of Object.entries(unifiedAccounts)) { + const rawAccount = rawUnifiedAccounts[name]; const contextPolicy = resolveAccountContextPolicy(account); const hasExplicitContextMode = - account.context_mode === 'isolated' || account.context_mode === 'shared'; + rawAccount?.context_mode === 'isolated' || rawAccount?.context_mode === 'shared'; const hasExplicitContinuityMode = - account.continuity_mode === 'standard' || account.continuity_mode === 'deeper'; + rawAccount?.continuity_mode === 'standard' || rawAccount?.continuity_mode === 'deeper'; merged[name] = { type: 'account', created: account.created, @@ -138,6 +158,7 @@ router.get('/', (_req: Request, res: Response): void => { */ router.post('/default', (req: Request, res: Response): void => { try { + const registry = createProfileRegistry(); const { name } = req.body; if (!name) { @@ -175,6 +196,8 @@ router.post('/default', (req: Request, res: Response): void => { */ router.put('/:name/context', async (req: Request, res: Response): Promise => { try { + const registry = createProfileRegistry(); + const instanceMgr = createInstanceManager(); const { name } = req.params; if (!name) { @@ -310,6 +333,7 @@ router.put('/:name/context', async (req: Request, res: Response): Promise */ router.delete('/reset-default', (_req: Request, res: Response): void => { try { + const registry = createProfileRegistry(); if (isUnifiedMode()) { registry.clearDefaultUnified(); } else { @@ -326,6 +350,8 @@ router.delete('/reset-default', (_req: Request, res: Response): void => { */ router.delete('/:name', async (req: Request, res: Response): Promise => { try { + const registry = createProfileRegistry(); + const instanceMgr = createInstanceManager(); const { name } = req.params; if (!name) { diff --git a/src/web-server/routes/ai-provider-routes.ts b/src/web-server/routes/ai-provider-routes.ts new file mode 100644 index 00000000..cb2a3492 --- /dev/null +++ b/src/web-server/routes/ai-provider-routes.ts @@ -0,0 +1,128 @@ +import { Router, type Request, type Response } from 'express'; +import { + AI_PROVIDER_FAMILY_IDS, + createAiProviderEntry, + deleteAiProviderEntry, + listAiProviders, + updateAiProviderEntry, + type AiProviderFamilyId, + type UpsertAiProviderEntryInput, +} from '../../cliproxy/ai-providers'; + +const router = Router(); + +function isAiProviderFamilyId(value: string): value is AiProviderFamilyId { + return AI_PROVIDER_FAMILY_IDS.includes(value as AiProviderFamilyId); +} + +function parseFamily(req: Request, res: Response): AiProviderFamilyId | null { + const family = req.params.family?.trim(); + if (!family || !isAiProviderFamilyId(family)) { + res.status(400).json({ error: 'Invalid AI provider family' }); + return null; + } + return family; +} + +function parseIndex(req: Request, res: Response): number | null { + const index = Number.parseInt(req.params.index || '', 10); + if (!Number.isInteger(index) || index < 0) { + res.status(400).json({ error: 'Invalid entry index' }); + return null; + } + return index; +} + +function parseInput(body: unknown): UpsertAiProviderEntryInput { + const payload = + typeof body === 'object' && body !== null ? (body as Record) : {}; + return { + name: typeof payload.name === 'string' ? payload.name : undefined, + baseUrl: typeof payload.baseUrl === 'string' ? payload.baseUrl : undefined, + proxyUrl: typeof payload.proxyUrl === 'string' ? payload.proxyUrl : undefined, + prefix: typeof payload.prefix === 'string' ? payload.prefix : undefined, + headers: Array.isArray(payload.headers) + ? payload.headers + .filter( + (item): item is { key?: unknown; value?: unknown } => + typeof item === 'object' && item !== null + ) + .map((item) => ({ + key: typeof item.key === 'string' ? item.key : '', + value: typeof item.value === 'string' ? item.value : '', + })) + : undefined, + excludedModels: Array.isArray(payload.excludedModels) + ? payload.excludedModels.filter((item): item is string => typeof item === 'string') + : undefined, + models: Array.isArray(payload.models) + ? payload.models + .filter( + (item): item is { name?: unknown; alias?: unknown } => + typeof item === 'object' && item !== null + ) + .map((item) => ({ + name: typeof item.name === 'string' ? item.name : '', + alias: typeof item.alias === 'string' ? item.alias : '', + })) + : undefined, + apiKey: typeof payload.apiKey === 'string' ? payload.apiKey : undefined, + apiKeys: Array.isArray(payload.apiKeys) + ? payload.apiKeys.filter((item): item is string => typeof item === 'string') + : undefined, + preserveSecrets: payload.preserveSecrets === true, + }; +} + +router.get('/', async (_req: Request, res: Response) => { + try { + res.json(await listAiProviders()); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.post('/:family', async (req: Request, res: Response) => { + const family = parseFamily(req, res); + if (!family) return; + + try { + await createAiProviderEntry(family, parseInput(req.body)); + res.status(201).json({ success: true }); + } catch (error) { + const message = (error as Error).message; + res.status(message === 'Entry not found' ? 404 : 400).json({ error: message }); + } +}); + +router.put('/:family/:index', async (req: Request, res: Response) => { + const family = parseFamily(req, res); + if (!family) return; + const index = parseIndex(req, res); + if (index === null) return; + + try { + await updateAiProviderEntry(family, index, parseInput(req.body)); + res.json({ success: true }); + } catch (error) { + const message = (error as Error).message; + res.status(message === 'Entry not found' ? 404 : 400).json({ error: message }); + } +}); + +router.delete('/:family/:index', async (req: Request, res: Response) => { + const family = parseFamily(req, res); + if (!family) return; + const index = parseIndex(req, res); + if (index === null) return; + + try { + await deleteAiProviderEntry(family, index); + res.json({ success: true }); + } catch (error) { + const message = (error as Error).message; + res.status(message === 'Entry not found' ? 404 : 400).json({ error: message }); + } +}); + +export default router; diff --git a/src/web-server/routes/index.ts b/src/web-server/routes/index.ts index 9fafe3f7..49066a51 100644 --- a/src/web-server/routes/index.ts +++ b/src/web-server/routes/index.ts @@ -19,6 +19,7 @@ import websearchRoutes from './websearch-routes'; import cliproxyAuthRoutes from './cliproxy-auth-routes'; import cliproxyStatsRoutes from './cliproxy-stats-routes'; import cliproxySyncRoutes from './cliproxy-sync-routes'; +import aiProviderRoutes from './ai-provider-routes'; import copilotRoutes from './copilot-routes'; import cursorRoutes from './cursor-routes'; import droidRoutes from './droid-routes'; @@ -59,6 +60,7 @@ apiRoutes.use('/cliproxy/auth', cliproxyAuthRoutes); apiRoutes.use('/cliproxy', cliproxyStatsRoutes); apiRoutes.use('/cliproxy/sync', cliproxySyncRoutes); apiRoutes.use('/cliproxy/catalog', catalogRoutes); +apiRoutes.use('/cliproxy/ai-providers', aiProviderRoutes); apiRoutes.use('/cliproxy/openai-compat', providerRoutes); // ==================== WebSearch ==================== diff --git a/src/web-server/routes/profile-routes.ts b/src/web-server/routes/profile-routes.ts index 81a63055..800ca5c1 100644 --- a/src/web-server/routes/profile-routes.ts +++ b/src/web-server/routes/profile-routes.ts @@ -9,6 +9,7 @@ import { Router, Request, Response } from 'express'; import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names'; import { createApiProfile, + createCliproxyBridgeProfile, removeApiProfile, updateApiProfileTarget, discoverApiProfileOrphans, @@ -17,10 +18,12 @@ import { exportApiProfile, importApiProfileBundle, apiProfileExists, + listCliproxyBridgeProviders, listApiProfiles, validateApiName, } from '../../api/services'; import { normalizeDroidProvider } from '../../targets/droid-provider'; +import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities'; import { isAnthropicDirectProfile, updateSettingsFile, parseTarget } from './route-helpers'; const router = Router(); @@ -66,6 +69,7 @@ router.get('/', (_req: Request, res: Response): void => { settingsPath: p.settingsPath, configured: p.isConfigured, target: p.target, + cliproxyBridge: p.cliproxyBridge ?? null, })); res.json({ profiles }); } catch (error) { @@ -73,6 +77,54 @@ router.get('/', (_req: Request, res: Response): void => { } }); +router.get('/cliproxy-bridge/providers', (_req: Request, res: Response): void => { + try { + res.json({ providers: listCliproxyBridgeProviders() }); + } catch (error) { + res.status(500).json({ error: (error as Error).message }); + } +}); + +router.post('/cliproxy-bridge', (req: Request, res: Response): void => { + const shape = validatePayloadShape(req.body, ['provider', 'name', 'target']); + if (!shape.ok) { + res.status(400).json({ error: shape.error }); + return; + } + + const provider = typeof shape.payload.provider === 'string' ? shape.payload.provider.trim() : ''; + if (!isCLIProxyProvider(provider)) { + res.status(400).json({ error: 'Invalid provider. Expected a supported CLIProxy provider ID.' }); + return; + } + + const target = parseTarget(shape.payload.target); + if (shape.payload.target !== undefined && target === null) { + res.status(400).json({ error: 'Invalid target. Expected: claude or droid' }); + return; + } + + const result = createCliproxyBridgeProfile(provider, { + name: typeof shape.payload.name === 'string' ? shape.payload.name : undefined, + target: target || 'claude', + }); + + if (!result.success || !result.name) { + const errorMessage = result.error || 'Failed to create CLIProxy bridge profile'; + res.status(errorMessage.toLowerCase().includes('already exists') ? 409 : 400).json({ + error: errorMessage, + }); + return; + } + + res.status(201).json({ + name: result.name, + settingsPath: result.settingsFile, + target: result.target || 'claude', + cliproxyBridge: result.cliproxyBridge ?? null, + }); +}); + /** * POST /api/profiles - Create new profile */ @@ -160,6 +212,7 @@ router.post('/', (req: Request, res: Response): void => { name, settingsPath: result.settingsFile, target: parsedTarget || 'claude', + cliproxyBridge: null, }); }); diff --git a/src/web-server/routes/settings-routes.ts b/src/web-server/routes/settings-routes.ts index 4f293f7b..9f2eec1d 100644 --- a/src/web-server/routes/settings-routes.ts +++ b/src/web-server/routes/settings-routes.ts @@ -19,6 +19,7 @@ import { } from '../../cliproxy'; import { regenerateConfig } from '../../cliproxy/config-generator'; import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils'; +import { resolveCliproxyBridgeMetadata } from '../../api/services'; import { getDashboardAuthConfig, loadOrCreateUnifiedConfig, @@ -327,6 +328,7 @@ router.get('/:profile', (req: Request, res: Response): void => { settings: masked, mtime: stat.mtime.getTime(), path: settingsPath, + cliproxyBridge: resolveCliproxyBridgeMetadata(settings), }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); @@ -354,6 +356,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => { settings, mtime: stat.mtime.getTime(), path: settingsPath, + cliproxyBridge: resolveCliproxyBridgeMetadata(settings), }); } catch (error) { respondInternalError(res, error, 'Internal server error.'); diff --git a/tests/unit/api/cliproxy-profile-bridge.test.ts b/tests/unit/api/cliproxy-profile-bridge.test.ts new file mode 100644 index 00000000..07c3be60 --- /dev/null +++ b/tests/unit/api/cliproxy-profile-bridge.test.ts @@ -0,0 +1,63 @@ +import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { getEffectiveApiKey } from '../../../src/cliproxy/auth-token-manager'; +import { + resolveCliproxyBridgeMetadata, + resolveCliproxyBridgeProfile, + suggestCliproxyBridgeName, +} from '../../../src/api/services/cliproxy-profile-bridge'; + +describe('cliproxy-profile-bridge', () => { + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-bridge-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome === undefined) { + delete process.env.CCS_HOME; + } else { + process.env.CCS_HOME = originalCcsHome; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('resolves routed profile payload for a local CLIProxy provider', () => { + const bridge = resolveCliproxyBridgeProfile('gemini'); + + expect(bridge.name).toBe('gemini-api'); + expect(bridge.baseUrl).toBe('http://127.0.0.1:8317/api/provider/gemini'); + expect(bridge.routePath).toBe('/api/provider/gemini'); + expect(bridge.models.default.length).toBeGreaterThan(0); + }); + + it('suggests a unique name when the default bridge settings file already exists', () => { + const ccsDir = path.join(tempHome, '.ccs'); + fs.mkdirSync(ccsDir, { recursive: true }); + fs.writeFileSync(path.join(ccsDir, 'gemini-api.settings.json'), '{}\n'); + + expect(suggestCliproxyBridgeName('gemini')).toBe('gemini-api-2'); + }); + + it('detects CLIProxy-backed profile metadata and normalizes localhost loopback URLs', () => { + const metadata = resolveCliproxyBridgeMetadata({ + env: { + ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/gemini', + ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(), + }, + }); + + expect(metadata?.provider).toBe('gemini'); + expect(metadata?.usesCurrentTarget).toBe(true); + expect(metadata?.usesCurrentAuthToken).toBe(true); + }); +}); diff --git a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts index b22ac1fa..7c104741 100644 --- a/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts +++ b/tests/unit/cliproxy/codex-plan-compatibility-reconcile.test.ts @@ -2,8 +2,10 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it, mock, spyOn } from 'bun:test'; +import { clearQuotaCache } from '../../../src/cliproxy/quota-response-cache'; afterEach(() => { + clearQuotaCache(); mock.restore(); }); @@ -57,10 +59,6 @@ describe('codex plan compatibility reconcile', () => { accountId: 'free@example.com', }), })); - mock.module('../../../src/cliproxy/quota-response-cache', () => ({ - getCachedQuota: () => null, - setCachedQuota: () => {}, - })); mock.module('../../../src/utils/ui', () => ({ info: (message: string) => message, warn: (message: string) => message, @@ -103,10 +101,6 @@ describe('codex plan compatibility reconcile', () => { throw new Error('should not fetch quota without a default account'); }, })); - mock.module('../../../src/cliproxy/quota-response-cache', () => ({ - getCachedQuota: () => null, - setCachedQuota: () => {}, - })); mock.module('../../../src/utils/ui', () => ({ info: (message: string) => message, warn: (message: string) => message, @@ -153,10 +147,6 @@ describe('codex plan compatibility reconcile', () => { accountId: `${planType}@example.com`, }), })); - mock.module('../../../src/cliproxy/quota-response-cache', () => ({ - getCachedQuota: () => null, - setCachedQuota: () => {}, - })); mock.module('../../../src/utils/ui', () => ({ info: (message: string) => message, warn: (message: string) => message, @@ -202,10 +192,6 @@ describe('codex plan compatibility reconcile', () => { error: 'network timeout', }), })); - mock.module('../../../src/cliproxy/quota-response-cache', () => ({ - getCachedQuota: () => null, - setCachedQuota: () => {}, - })); mock.module('../../../src/utils/ui', () => ({ info: (message: string) => message, warn: (message: string) => message, @@ -250,10 +236,6 @@ describe('codex plan compatibility reconcile', () => { accountId: 'missing-plan@example.com', }), })); - mock.module('../../../src/cliproxy/quota-response-cache', () => ({ - getCachedQuota: () => null, - setCachedQuota: () => {}, - })); mock.module('../../../src/utils/ui', () => ({ info: (message: string) => message, warn: (message: string) => message, diff --git a/tests/unit/cliproxy/composite-env-routing.test.ts b/tests/unit/cliproxy/composite-env-routing.test.ts index 281aae1b..0ef51fc7 100644 --- a/tests/unit/cliproxy/composite-env-routing.test.ts +++ b/tests/unit/cliproxy/composite-env-routing.test.ts @@ -90,6 +90,6 @@ describe('buildClaudeEnvironment - composite remote routing', () => { expect(env.ANTHROPIC_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/); expect(env.ANTHROPIC_DEFAULT_OPUS_MODEL).toMatch(/^claude-opus-4-6-thinking(\([^)]+\))?$/); expect(env.ANTHROPIC_DEFAULT_SONNET_MODEL).toMatch(/^gemini-2.5-pro(\([^)]+\))?$/); - expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toBe('gpt-5.1-codex-mini'); + expect(env.ANTHROPIC_DEFAULT_HAIKU_MODEL).toMatch(/^gpt-5.1-codex-mini(?:-medium)?$/); }); }); diff --git a/tests/unit/commands/api-command-args.test.ts b/tests/unit/commands/api-command-args.test.ts index 3ee6916b..94032699 100644 --- a/tests/unit/commands/api-command-args.test.ts +++ b/tests/unit/commands/api-command-args.test.ts @@ -55,6 +55,14 @@ describe('api-command arg parser', () => { expect(parsed.errors).toEqual([]); }); + test('parses --cliproxy-provider for routed API profile creation', () => { + const parsed = parseApiCommandArgs(['my-api', '--cliproxy-provider', 'Gemini']); + + expect(parsed.name).toBe('my-api'); + expect(parsed.cliproxyProvider).toBe('gemini'); + expect(parsed.errors).toEqual([]); + }); + test('validates invalid --target values', () => { const parsed = parseApiCommandArgs(['my-api', '--target', 'invalid-target']); diff --git a/tests/unit/commands/config-command.test.ts b/tests/unit/commands/config-command.test.ts index 27c5b44c..d5c539f9 100644 --- a/tests/unit/commands/config-command.test.ts +++ b/tests/unit/commands/config-command.test.ts @@ -1,7 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, mock } from 'bun:test'; const startServerCalls: Array> = []; -const resolveDashboardUrlsCalls: Array<[string | undefined, number]> = []; const configAuthCalls: string[][] = []; let logLines: string[] = []; let errorLines: string[] = []; @@ -14,7 +13,6 @@ let originalProcessExit: typeof process.exit; beforeEach(() => { startServerCalls.length = 0; - resolveDashboardUrlsCalls.length = 0; configAuthCalls.length = 0; logLines = []; errorLines = []; @@ -91,42 +89,6 @@ beforeEach(() => { mock.module('../../../src/utils/ui', () => uiModule); mock.module('../../../src/utils/ui.ts', () => uiModule); - mock.module('../../../src/commands/config-dashboard-host', () => ({ - normalizeDashboardHost: (host: string | undefined) => { - if (!host) { - return undefined; - } - - if (host.startsWith('[') && host.endsWith(']') && host.includes(':')) { - return host.slice(1, -1); - } - - return host; - }, - isLoopbackHost: (host: string) => - ['localhost', '127.0.0.1', '::1', '[::1]'].includes(host.trim().toLowerCase()), - isWildcardHost: (host: string) => ['0.0.0.0', '::', '[::]'].includes(host.trim().toLowerCase()), - resolveDashboardUrls: (host: string | undefined, port: number) => { - resolveDashboardUrlsCalls.push([host, port]); - if (!host) { - return { browserUrl: `http://localhost:${port}` }; - } - - if (host === '0.0.0.0' || host === '::') { - return { - bindHost: host, - browserUrl: `http://localhost:${port}`, - networkUrls: [`http://192.168.1.25:${port}`, `http://100.64.0.12:${port}`], - }; - } - - return { - bindHost: host, - browserUrl: `http://${host}:${port}`, - }; - }, - })); - mock.module('../../../src/commands/config-auth', () => ({ handleConfigAuthCommand: async (args: string[]) => { configAuthCalls.push([...args]); @@ -158,7 +120,6 @@ describe('config command dashboard startup', () => { await expect(handleConfigCommand(['help'])).rejects.toThrow('process.exit(0)'); expect(startServerCalls).toHaveLength(0); - expect(resolveDashboardUrlsCalls).toHaveLength(0); expect(logLines.join('\n')).toContain('Usage: ccs config [command] [options]'); }); @@ -169,7 +130,6 @@ describe('config command dashboard startup', () => { expect(configAuthCalls).toEqual([['setup']]); expect(startServerCalls).toHaveLength(0); - expect(resolveDashboardUrlsCalls).toHaveLength(0); }); it('rejects unknown config subcommands before dashboard startup', async () => { @@ -181,7 +141,6 @@ describe('config command dashboard startup', () => { await expect(handleConfigCommand(['bogus'])).rejects.toThrow('process.exit(1)'); expect(startServerCalls).toHaveLength(0); - expect(resolveDashboardUrlsCalls).toHaveLength(0); expect(errorLines.join('\n')).toContain('Unexpected arguments: bogus'); }); @@ -192,12 +151,11 @@ describe('config command dashboard startup', () => { expect(startServerCalls).toHaveLength(1); expect(startServerCalls[0]).toEqual({ port: 3000, dev: false }); - expect(resolveDashboardUrlsCalls).toEqual([['::', 3000]]); const rendered = logLines.join('\n'); expect(rendered).toContain('Dashboard: http://localhost:3000'); expect(rendered).toContain('Bind host: ::'); - expect(rendered).toContain('Network URLs:'); + expect(rendered).toContain('Dashboard may be reachable from other devices that can connect to this machine.'); expect(rendered).toContain('Protect it before sharing: ccs config auth setup'); expect(errorLines).toHaveLength(0); }); @@ -210,13 +168,10 @@ describe('config command dashboard startup', () => { expect(startServerCalls).toHaveLength(1); expect(startServerCalls[0]).toEqual({ port: 4100, dev: false, host: '0.0.0.0' }); - expect(resolveDashboardUrlsCalls).toEqual([['0.0.0.0', 4100]]); const rendered = logLines.join('\n'); + expect(rendered).toContain('Dashboard: http://localhost:4100'); expect(rendered).toContain('Bind host: 0.0.0.0'); - expect(rendered).toContain('Network URLs:'); - expect(rendered).toContain('http://192.168.1.25:4100'); - expect(rendered).toContain('http://100.64.0.12:4100'); expect(rendered).toContain( 'Dashboard may be reachable from other devices that can connect to this machine.' ); diff --git a/tests/unit/commands/persist-command-handler.test.ts b/tests/unit/commands/persist-command-handler.test.ts index a298c09d..6235be55 100644 --- a/tests/unit/commands/persist-command-handler.test.ts +++ b/tests/unit/commands/persist-command-handler.test.ts @@ -6,6 +6,7 @@ import * as lockfile from 'proper-lockfile'; import { handlePersistCommand } from '../../../src/commands/persist-command'; import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types'; import { saveUnifiedConfig } from '../../../src/config/unified-config-loader'; +import { runWithScopedCcsHome } from '../../../src/utils/config-manager'; interface RestoreFixture { claudeDir: string; @@ -18,11 +19,14 @@ interface RestoreFixture { let tempRoot: string; let originalClaudeConfigDir: string | undefined; -let originalCcsHome: string | undefined; let originalProcessExit: typeof process.exit; let originalFsOpen: typeof fs.promises.open; let originalFsRename: typeof fs.promises.rename; +async function withScopedHome(fn: () => Promise): Promise { + return await runWithScopedCcsHome(tempRoot, fn); +} + async function pathExists(filePath: string): Promise { try { await fs.promises.access(filePath, fs.constants.F_OK); @@ -69,11 +73,9 @@ function stubProcessExit(): void { beforeEach(async () => { tempRoot = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'ccs-persist-handler-test-')); originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; - originalCcsHome = process.env.CCS_HOME; originalProcessExit = process.exit; originalFsOpen = fs.promises.open; originalFsRename = fs.promises.rename; - process.env.CCS_HOME = tempRoot; }); afterEach(async () => { @@ -87,12 +89,6 @@ afterEach(async () => { process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; } - if (originalCcsHome === undefined) { - delete process.env.CCS_HOME; - } else { - process.env.CCS_HOME = originalCcsHome; - } - if (tempRoot) { await fs.promises.rm(tempRoot, { recursive: true, force: true }); } @@ -100,50 +96,50 @@ afterEach(async () => { describe('persist command real handler paths', () => { it('throws parseError for missing --permission-mode before profile detection', async () => { - await expect(handlePersistCommand(['glm', '--permission-mode'])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode']))).rejects.toThrow( 'Missing value for --permission-mode' ); }); it('throws parseError for empty inline --permission-mode before profile detection', async () => { - await expect(handlePersistCommand(['glm', '--permission-mode='])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode=']))).rejects.toThrow( 'Missing value for --permission-mode' ); }); it('throws parseError for invalid --permission-mode before profile detection', async () => { - await expect(handlePersistCommand(['glm', '--permission-mode', 'invalid-mode'])).rejects.toThrow( - /Invalid --permission-mode/ - ); + await expect( + withScopedHome(() => handlePersistCommand(['glm', '--permission-mode', 'invalid-mode'])) + ).rejects.toThrow(/Invalid --permission-mode/); }); it('throws parseError for unknown flags on real handler path', async () => { - await expect(handlePersistCommand(['glm', '--unknown-flag'])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['glm', '--unknown-flag']))).rejects.toThrow( /Unknown option\(s\)/ ); }); it('throws parseError for list/restore conflict on real handler path', async () => { - await expect(handlePersistCommand(['--list-backups', '--restore'])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['--list-backups', '--restore']))).rejects.toThrow( '--list-backups cannot be used with --restore' ); }); it('throws parseError for permission flags with --restore on real handler path', async () => { - await expect(handlePersistCommand(['--restore', '--auto-approve'])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['--restore', '--auto-approve']))).rejects.toThrow( /Permission flags are not valid with backup operations/ ); }); it('shows help when --help is present even with other invalid args', async () => { - await expect(handlePersistCommand(['--help', '--permission-mode'])).resolves.toBeUndefined(); + await expect(withScopedHome(() => handlePersistCommand(['--help', '--permission-mode']))).resolves.toBeUndefined(); }); it('does not create CLAUDE_CONFIG_DIR on parseError path', async () => { const isolatedClaudeDir = path.join(tempRoot, '.claude-parse-early'); process.env.CLAUDE_CONFIG_DIR = isolatedClaudeDir; - await expect(handlePersistCommand(['glm', '--permission-mode='])).rejects.toThrow( + await expect(withScopedHome(() => handlePersistCommand(['glm', '--permission-mode=']))).rejects.toThrow( 'Missing value for --permission-mode' ); expect(await pathExists(isolatedClaudeDir)).toBe(false); @@ -163,9 +159,9 @@ describe('persist command restore failure handling', () => { stubProcessExit(); try { - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); } finally { await release(); } @@ -186,9 +182,9 @@ describe('persist command restore failure handling', () => { }) as typeof fs.promises.open; stubProcessExit(); - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); }); it('exits when backup read fails with ELOOP (symlink rejection)', async () => { @@ -206,9 +202,9 @@ describe('persist command restore failure handling', () => { }) as typeof fs.promises.open; stubProcessExit(); - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); }); it('exits when backup path resolves to a non-regular file', async () => { @@ -229,9 +225,9 @@ describe('persist command restore failure handling', () => { }) as typeof fs.promises.open; stubProcessExit(); - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); }); it('rolls back settings when restore write fails mid-flight', async () => { @@ -248,9 +244,9 @@ describe('persist command restore failure handling', () => { }) as typeof fs.promises.rename; stubProcessExit(); - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); const finalContent = await fs.promises.readFile(fixture.settingsPath, 'utf8'); const finalSettings = JSON.parse(finalContent); @@ -273,9 +269,9 @@ describe('persist command restore failure handling', () => { stubProcessExit(); try { - await expect(handlePersistCommand(['--restore', fixture.timestamp, '--yes'])).rejects.toThrow( - 'process.exit(1)' - ); + await expect( + withScopedHome(() => handlePersistCommand(['--restore', fixture.timestamp, '--yes'])) + ).rejects.toThrow('process.exit(1)'); expect(capturedLogs.some((line) => line.includes('Rollback also failed'))).toBe(true); } finally { console.log = originalConsoleLog; @@ -313,7 +309,9 @@ describe('persist command Claude extension parity', () => { ) + '\n', 'utf8' ); - saveUnifiedConfig(config); + await withScopedHome(async () => { + saveUnifiedConfig(config); + }); } it('persists account profiles via CLAUDE_CONFIG_DIR and clears stale managed env keys', async () => { @@ -337,7 +335,7 @@ describe('persist command Claude extension parity', () => { 'utf8' ); - await handlePersistCommand(['work', '--yes']); + await withScopedHome(() => handlePersistCommand(['work', '--yes'])); const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as { env: Record; @@ -371,7 +369,7 @@ describe('persist command Claude extension parity', () => { 'utf8' ); - await handlePersistCommand(['default', '--yes']); + await withScopedHome(() => handlePersistCommand(['default', '--yes'])); const persisted = JSON.parse(await fs.promises.readFile(settingsPath, 'utf8')) as { env: Record; diff --git a/tests/unit/commands/proxy-lifecycle-subcommand.test.ts b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts index 3488ecfe..0cc8835b 100644 --- a/tests/unit/commands/proxy-lifecycle-subcommand.test.ts +++ b/tests/unit/commands/proxy-lifecycle-subcommand.test.ts @@ -4,9 +4,9 @@ import * as os from 'os'; import * as path from 'path'; import { resolveLifecyclePort } from '../../../src/commands/cliproxy/proxy-lifecycle-subcommand'; import { CLIPROXY_DEFAULT_PORT } from '../../../src/cliproxy/config/port-manager'; +import { runWithScopedConfigDir } from '../../../src/utils/config-manager'; let tempDir: string; -let originalCcsDir: string | undefined; function writeUnifiedConfig(localPort: number): void { const configPath = path.join(tempDir, 'config.yaml'); @@ -34,33 +34,31 @@ cliproxy_server: describe('resolveLifecyclePort', () => { beforeEach(() => { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-proxy-lifecycle-')); - originalCcsDir = process.env.CCS_DIR; - process.env.CCS_DIR = tempDir; }); afterEach(() => { - if (originalCcsDir !== undefined) { - process.env.CCS_DIR = originalCcsDir; - } else { - delete process.env.CCS_DIR; - } - if (tempDir && fs.existsSync(tempDir)) { fs.rmSync(tempDir, { recursive: true, force: true }); } }); - it('uses configured cliproxy_server.local.port', () => { + it('uses configured cliproxy_server.local.port', async () => { writeUnifiedConfig(9456); - expect(resolveLifecyclePort()).toBe(9456); + await runWithScopedConfigDir(tempDir, () => { + expect(resolveLifecyclePort()).toBe(9456); + }); }); - it('falls back to default port when configured local port is invalid', () => { + it('falls back to default port when configured local port is invalid', async () => { writeUnifiedConfig(70000); - expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + await runWithScopedConfigDir(tempDir, () => { + expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + }); }); - it('falls back to default port when config file is missing', () => { - expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + it('falls back to default port when config file is missing', async () => { + await runWithScopedConfigDir(tempDir, () => { + expect(resolveLifecyclePort()).toBe(CLIPROXY_DEFAULT_PORT); + }); }); }); diff --git a/tests/unit/web-server/claude-extension-routes.test.ts b/tests/unit/web-server/claude-extension-routes.test.ts index 8dbc458a..753e40fd 100644 --- a/tests/unit/web-server/claude-extension-routes.test.ts +++ b/tests/unit/web-server/claude-extension-routes.test.ts @@ -14,18 +14,30 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { Server } from 'http'; -import claudeExtensionRoutes from '../../../src/web-server/routes/claude-extension-routes'; -import SharedManager from '../../../src/management/shared-manager'; -import { createEmptyUnifiedConfig } from '../../../src/config/unified-config-types'; -import { saveUnifiedConfig } from '../../../src/config/unified-config-loader'; describe('web-server claude-extension-routes', () => { let server: Server; let baseUrl = ''; let tempHome = ''; let originalCcsHome: string | undefined; + let originalClaudeConfigDir: string | undefined; + let setGlobalConfigDir: (dir: string | undefined) => void; + let claudeExtensionRoutes: ReturnType; + let SharedManager: typeof import('../../../src/management/shared-manager').default; + let createEmptyUnifiedConfig: typeof import('../../../src/config/unified-config-types').createEmptyUnifiedConfig; + let saveUnifiedConfig: typeof import('../../../src/config/unified-config-loader').saveUnifiedConfig; beforeAll(async () => { + originalCcsHome = process.env.CCS_HOME; + originalClaudeConfigDir = process.env.CLAUDE_CONFIG_DIR; + ({ setGlobalConfigDir } = await import('../../../src/utils/config-manager')); + ({ default: SharedManager } = await import('../../../src/management/shared-manager')); + ({ createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types')); + ({ saveUnifiedConfig } = await import('../../../src/config/unified-config-loader')); + ({ default: claudeExtensionRoutes } = await import( + '../../../src/web-server/routes/claude-extension-routes' + )); + const app = express(); app.use(express.json()); app.use('/api/claude-extension', claudeExtensionRoutes); @@ -48,13 +60,23 @@ describe('web-server claude-extension-routes', () => { }); afterAll(async () => { - await new Promise((resolve) => server.close(() => resolve())); + if (server) { + await new Promise((resolve) => server.close(() => resolve())); + } + setGlobalConfigDir(undefined); + + if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; + else delete process.env.CCS_HOME; + + if (originalClaudeConfigDir !== undefined) process.env.CLAUDE_CONFIG_DIR = originalClaudeConfigDir; + else delete process.env.CLAUDE_CONFIG_DIR; }); beforeEach(() => { tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-claude-extension-routes-')); - originalCcsHome = process.env.CCS_HOME; process.env.CCS_HOME = tempHome; + process.env.CLAUDE_CONFIG_DIR = path.join(tempHome, '.claude'); + setGlobalConfigDir(path.join(tempHome, '.ccs')); const ccsDir = path.join(tempHome, '.ccs'); fs.mkdirSync(ccsDir, { recursive: true }); @@ -110,9 +132,9 @@ describe('web-server claude-extension-routes', () => { afterEach(() => { mock.restore(); - - if (originalCcsHome !== undefined) process.env.CCS_HOME = originalCcsHome; - else delete process.env.CCS_HOME; + setGlobalConfigDir(undefined); + delete process.env.CCS_HOME; + delete process.env.CLAUDE_CONFIG_DIR; if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true }); diff --git a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts index a64465e7..40578506 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-install.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-install.test.ts @@ -1,81 +1,57 @@ -import { afterAll, beforeAll, beforeEach, describe, expect, it, mock } from 'bun:test'; +import { afterAll, beforeAll, describe, expect, it } from 'bun:test'; import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; import type { Server } from 'http'; -const installSpy = { - calls: 0, -}; - -mock.module('../../../src/config/unified-config-loader', () => ({ - loadOrCreateUnifiedConfig: () => ({ - cliproxy: { backend: 'plus' }, - }), -})); - -mock.module('../../../src/cliproxy/binary-manager', () => ({ - checkCliproxyUpdate: async () => ({ - hasUpdate: false, - currentVersion: '6.6.80', - latestVersion: '6.6.89', - fromCache: false, - checkedAt: Date.now(), - backend: 'plus', - backendLabel: 'CLIProxy Plus', - isStable: true, - maxStableVersion: '9.9.999-0', - }), - getInstalledCliproxyVersion: () => '6.6.80', - installCliproxyVersion: async () => {}, -})); - -mock.module('../../../src/cliproxy/binary/version-checker', () => ({ - fetchAllVersions: async () => ({ - versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'], - latestStable: '6.6.89', - latest: '6.6.89', - fromCache: false, - checkedAt: Date.now(), - }), - isNewerVersion: (version: string, maxStable: string) => { - const normalize = (value: string) => value.replace(/-\d+$/, '').split('.').map(Number); - const versionParts = normalize(version); - const maxStableParts = normalize(maxStable); - - for (let index = 0; index < 3; index += 1) { - const versionPart = versionParts[index] || 0; - const maxStablePart = maxStableParts[index] || 0; - - if (versionPart > maxStablePart) return true; - if (versionPart < maxStablePart) return false; - } - - return false; - }, - isVersionFaulty: (version: string) => - ['6.6.81', '6.6.82', '6.6.83', '6.6.84', '6.6.85', '6.6.86', '6.6.87', '6.6.88'].includes( - version - ), -})); - -mock.module('../../../src/web-server/services/cliproxy-dashboard-install-service', () => ({ - installDashboardCliproxyVersion: async () => { - installSpy.calls += 1; - return { - success: true, - restarted: true, - port: 8317, - message: 'installed', - }; - }, -})); - let cliproxyStatsRoutes: typeof import('../../../src/web-server/routes/cliproxy-stats-routes').default; +let createEmptyUnifiedConfig: typeof import('../../../src/config/unified-config-types').createEmptyUnifiedConfig; +let saveUnifiedConfig: typeof import('../../../src/config/unified-config-loader').saveUnifiedConfig; +let setGlobalConfigDir: typeof import('../../../src/utils/config-manager').setGlobalConfigDir; +let writeInstalledVersion: typeof import('../../../src/cliproxy/binary/version-cache').writeInstalledVersion; +let writeVersionListCache: typeof import('../../../src/cliproxy/binary/version-cache').writeVersionListCache; + let server: Server; let baseUrl = ''; +let tempHome = ''; +let originalCcsHome: string | undefined; beforeAll(async () => { - cliproxyStatsRoutes = (await import('../../../src/web-server/routes/cliproxy-stats-routes')) - .default; + originalCcsHome = process.env.CCS_HOME; + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-install-route-')); + process.env.CCS_HOME = tempHome; + + ({ setGlobalConfigDir } = await import('../../../src/utils/config-manager')); + ({ createEmptyUnifiedConfig } = await import('../../../src/config/unified-config-types')); + ({ saveUnifiedConfig } = await import('../../../src/config/unified-config-loader')); + ({ writeInstalledVersion, writeVersionListCache } = await import( + '../../../src/cliproxy/binary/version-cache' + )); + + const ccsDir = path.join(tempHome, '.ccs'); + const plusBinDir = path.join(ccsDir, 'cliproxy', 'bin', 'plus'); + fs.mkdirSync(plusBinDir, { recursive: true }); + setGlobalConfigDir(ccsDir); + + const config = createEmptyUnifiedConfig(); + config.cliproxy = { backend: 'plus' }; + saveUnifiedConfig(config); + + writeInstalledVersion(plusBinDir, '6.6.80'); + writeVersionListCache( + { + versions: ['6.6.89', '6.6.88', '6.6.81', '6.6.80'], + latestStable: '6.6.89', + latest: '6.6.89', + checkedAt: Date.now(), + }, + 'plus' + ); + + ({ default: cliproxyStatsRoutes } = await import( + '../../../src/web-server/routes/cliproxy-stats-routes' + )); const app = express(); app.use(express.json()); @@ -95,16 +71,26 @@ beforeAll(async () => { if (!address || typeof address === 'string') { throw new Error('Unable to resolve test server port'); } + baseUrl = `http://127.0.0.1:${address.port}`; }); -beforeEach(() => { - installSpy.calls = 0; -}); - afterAll(async () => { - await new Promise((resolve) => server.close(() => resolve())); - mock.restore(); + if (server) { + await new Promise((resolve) => server.close(() => resolve())); + } + + setGlobalConfigDir(undefined); + + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } }); describe('cliproxy-stats-routes install contract', () => { @@ -120,7 +106,7 @@ describe('cliproxy-stats-routes install contract', () => { expect(body.faultyRange).toEqual({ min: '6.6.81-0', max: '6.6.88-0' }); }); - it('returns faulty confirmation metadata without calling the installer', async () => { + it('returns faulty confirmation metadata without attempting the install', async () => { const response = await fetch(`${baseUrl}/api/cliproxy/install`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -140,10 +126,9 @@ describe('cliproxy-stats-routes install contract', () => { expect(body.isFaulty).toBe(true); expect(body.isExperimental).toBe(false); expect(body.message).toContain('known bugs'); - expect(installSpy.calls).toBe(0); }); - it('returns experimental confirmation metadata without calling the installer', async () => { + it('returns experimental confirmation metadata without attempting the install', async () => { const response = await fetch(`${baseUrl}/api/cliproxy/install`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -163,6 +148,5 @@ describe('cliproxy-stats-routes install contract', () => { expect(body.isFaulty).toBe(false); expect(body.isExperimental).toBe(true); expect(body.message).toContain('experimental'); - expect(installSpy.calls).toBe(0); }); }); diff --git a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts index 09029bd8..84cba3c9 100644 --- a/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts +++ b/tests/unit/web-server/cliproxy-stats-routes-model-update.test.ts @@ -4,7 +4,6 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { Server } from 'http'; -import cliproxyStatsRoutes from '../../../src/web-server/routes/cliproxy-stats-routes'; function writeSettings(filePath: string, env: Record): void { fs.mkdirSync(path.dirname(filePath), { recursive: true }); @@ -16,8 +15,16 @@ describe('cliproxy-stats-routes model update canonicalization', () => { let baseUrl = ''; let tempHome = ''; let originalCcsHome: string | undefined; + let setGlobalConfigDir: (dir: string | undefined) => void; + let cliproxyStatsRoutes: ReturnType; beforeAll(async () => { + originalCcsHome = process.env.CCS_HOME; + ({ setGlobalConfigDir } = await import('../../../src/utils/config-manager')); + ({ default: cliproxyStatsRoutes } = await import( + '../../../src/web-server/routes/cliproxy-stats-routes' + )); + const app = express(); app.use(express.json()); app.use('/api/cliproxy', cliproxyStatsRoutes); @@ -41,20 +48,24 @@ describe('cliproxy-stats-routes model update canonicalization', () => { afterAll(async () => { await new Promise((resolve) => server.close(() => resolve())); - }); + setGlobalConfigDir(undefined); - beforeEach(() => { - tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-model-route-')); - originalCcsHome = process.env.CCS_HOME; - process.env.CCS_HOME = tempHome; - }); - - afterEach(() => { if (originalCcsHome !== undefined) { process.env.CCS_HOME = originalCcsHome; } else { delete process.env.CCS_HOME; } + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-model-route-')); + process.env.CCS_HOME = tempHome; + setGlobalConfigDir(path.join(tempHome, '.ccs')); + }); + + afterEach(() => { + setGlobalConfigDir(undefined); + delete process.env.CCS_HOME; if (tempHome && fs.existsSync(tempHome)) { fs.rmSync(tempHome, { recursive: true, force: true }); diff --git a/tests/unit/web-server/cliproxy-usage-syncer.test.ts b/tests/unit/web-server/cliproxy-usage-syncer.test.ts index 1d3f165e..d8f3fa34 100644 --- a/tests/unit/web-server/cliproxy-usage-syncer.test.ts +++ b/tests/unit/web-server/cliproxy-usage-syncer.test.ts @@ -3,15 +3,12 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import type { CliproxyUsageApiResponse } from '../../../src/cliproxy/stats-fetcher'; +import { runWithScopedConfigDir } from '../../../src/utils/config-manager'; let ccsDir = ''; let rawResponse: CliproxyUsageApiResponse | null = null; let fetchCalls = 0; -mock.module('../../../src/utils/config-manager', () => ({ - getCcsDir: () => ccsDir, -})); - mock.module('../../../src/cliproxy/stats-fetcher', () => ({ fetchCliproxyUsageRaw: async () => { fetchCalls++; @@ -69,12 +66,16 @@ afterAll(() => { describe('cliproxy usage syncer', () => { it('writes and loads snapshot data', async () => { - await syncer.syncCliproxyUsage(); + await runWithScopedConfigDir(ccsDir, async () => { + await syncer.syncCliproxyUsage(); + }); const snapshotPath = path.join(ccsDir, 'cache', 'cliproxy-usage', 'latest.json'); expect(fs.existsSync(snapshotPath)).toBe(true); - const cached = await syncer.loadCachedCliproxyData(); + const cached = await runWithScopedConfigDir(ccsDir, async () => { + return await syncer.loadCachedCliproxyData(); + }); expect(cached.daily).toHaveLength(1); expect(cached.daily[0].source).toBe('cliproxy'); expect(cached.daily[0].inputTokens).toBe(100); @@ -82,11 +83,13 @@ describe('cliproxy usage syncer', () => { expect(cached.monthly).toHaveLength(1); }); - it('startCliproxySync is idempotent and starts only one interval', () => { + it('startCliproxySync is idempotent and starts only one interval', async () => { const intervalSpy = spyOn(globalThis, 'setInterval'); - syncer.startCliproxySync(); - syncer.startCliproxySync(); + await runWithScopedConfigDir(ccsDir, async () => { + syncer.startCliproxySync(); + syncer.startCliproxySync(); + }); expect(intervalSpy).toHaveBeenCalledTimes(1); expect(fetchCalls).toBeGreaterThan(0); diff --git a/tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts b/tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts new file mode 100644 index 00000000..d12a28d6 --- /dev/null +++ b/tests/unit/web-server/profile-routes-cliproxy-bridge.test.ts @@ -0,0 +1,99 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test'; +import express from 'express'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import type { Server } from 'http'; +import profileRoutes from '../../../src/web-server/routes/profile-routes'; + +describe('profile-routes cliproxy bridge', () => { + let server: Server; + let baseUrl = ''; + let tempHome = ''; + let originalCcsHome: string | undefined; + + beforeAll(async () => { + const app = express(); + app.use(express.json()); + app.use('/api/profiles', profileRoutes); + + await new Promise((resolve, reject) => { + server = app.listen(0, '127.0.0.1'); + const onError = (error: Error) => reject(error); + server.once('error', onError); + server.once('listening', () => { + server.off('error', onError); + resolve(); + }); + }); + + const address = server.address(); + if (!address || typeof address === 'string') { + throw new Error('Unable to resolve test server port'); + } + baseUrl = `http://127.0.0.1:${address.port}`; + }); + + afterAll(async () => { + await new Promise((resolve) => server.close(() => resolve())); + }); + + beforeEach(() => { + tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-routes-cliproxy-bridge-')); + originalCcsHome = process.env.CCS_HOME; + process.env.CCS_HOME = tempHome; + }); + + afterEach(() => { + if (originalCcsHome !== undefined) { + process.env.CCS_HOME = originalCcsHome; + } else { + delete process.env.CCS_HOME; + } + + if (tempHome && fs.existsSync(tempHome)) { + fs.rmSync(tempHome, { recursive: true, force: true }); + } + }); + + it('creates a routed CLIProxy-backed API profile and returns bridge metadata', async () => { + const response = await fetch(`${baseUrl}/api/profiles/cliproxy-bridge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: 'gemini' }), + }); + + expect(response.status).toBe(201); + const body = (await response.json()) as { + name: string; + settingsPath: string; + cliproxyBridge: { provider: string; usesCurrentTarget: boolean }; + }; + expect(body.name).toBe('gemini-api'); + expect(body.settingsPath).toBe('~/.ccs/gemini-api.settings.json'); + expect(body.cliproxyBridge.provider).toBe('gemini'); + expect(body.cliproxyBridge.usesCurrentTarget).toBe(true); + + const settingsPath = path.join(tempHome, '.ccs', 'gemini-api.settings.json'); + expect(fs.existsSync(settingsPath)).toBe(true); + }); + + it('auto-suggests the next routed profile name when the default bridge name is taken', async () => { + const firstResponse = await fetch(`${baseUrl}/api/profiles/cliproxy-bridge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: 'gemini' }), + }); + expect(firstResponse.status).toBe(201); + + const secondResponse = await fetch(`${baseUrl}/api/profiles/cliproxy-bridge`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ provider: 'gemini' }), + }); + + expect(secondResponse.status).toBe(201); + const body = (await secondResponse.json()) as { name: string }; + expect(body.name).toBe('gemini-api-2'); + }); +}); diff --git a/ui/public/assets/providers/codex-color.svg b/ui/public/assets/providers/codex-color.svg new file mode 100644 index 00000000..c2e67e43 --- /dev/null +++ b/ui/public/assets/providers/codex-color.svg @@ -0,0 +1,24 @@ + + Codex + + + + + + + + + + diff --git a/ui/public/assets/providers/vertex.svg b/ui/public/assets/providers/vertex.svg new file mode 100644 index 00000000..6413e233 --- /dev/null +++ b/ui/public/assets/providers/vertex.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 31b3f343..b065cbc1 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -23,6 +23,9 @@ const ApiPage = lazy(() => import('@/pages/api').then((m) => ({ default: m.ApiPa const CliproxyPage = lazy(() => import('@/pages/cliproxy').then((m) => ({ default: m.CliproxyPage })) ); +const CliproxyAiProvidersPage = lazy(() => + import('@/pages/cliproxy-ai-providers').then((m) => ({ default: m.CliproxyAiProvidersPage })) +); const CliproxyControlPanelPage = lazy(() => import('@/pages/cliproxy-control-panel').then((m) => ({ default: m.CliproxyControlPanelPage })) ); @@ -98,6 +101,14 @@ export default function App() { } /> + }> + + + } + /> void; +} + +function getStatusState(status: AiProviderFamilyState['status']) { + switch (status) { + case 'ready': + return { + icon: Check, + text: 'Ready', + className: 'text-green-600', + }; + case 'partial': + return { + icon: AlertCircle, + text: 'Needs attention', + className: 'text-amber-600', + }; + default: + return { + icon: Circle, + text: 'Not configured', + className: 'text-muted-foreground', + }; + } +} + +export function FamilyRail({ families, selectedFamily, onSelect }: FamilyRailProps) { + return ( +
+ {families.map((family) => { + const isSelected = family.id === selectedFamily; + const statusState = getStatusState(family.status); + const StatusIcon = statusState.icon; + + return ( + + ); + })} +
+ ); +} diff --git a/ui/src/components/cliproxy/ai-providers/index.ts b/ui/src/components/cliproxy/ai-providers/index.ts new file mode 100644 index 00000000..c3747f41 --- /dev/null +++ b/ui/src/components/cliproxy/ai-providers/index.ts @@ -0,0 +1,3 @@ +export { FamilyRail } from './family-rail'; +export { ProviderEntryCard } from './provider-entry-card'; +export { ProviderEntryDialog } from './provider-entry-dialog'; diff --git a/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx b/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx new file mode 100644 index 00000000..7457caae --- /dev/null +++ b/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx @@ -0,0 +1,291 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; +import type { + AiProviderEntryView, + AiProviderFamilyState, +} from '../../../../../src/cliproxy/ai-providers'; +import { Check, ChevronRight, Circle, KeyRound, Pencil, Trash2 } from 'lucide-react'; + +interface ProviderEntryCardProps { + family: AiProviderFamilyState; + entry: AiProviderEntryView; + onEdit: () => void; + onDelete: () => void; + onSelect?: () => void; + isSelected?: boolean; + variant?: 'row' | 'detail'; +} + +function renderCountLabel(count: number, singular: string, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; +} + +function renderSecretBadge(entry: AiProviderEntryView) { + return ( + + {entry.secretConfigured ? 'Configured' : 'Missing secret'} + + ); +} + +export function ProviderEntryCard({ + family, + entry, + onEdit, + onDelete, + onSelect, + isSelected = false, + variant = 'detail', +}: ProviderEntryCardProps) { + const hasAdvancedRouting = entry.prefix || entry.proxyUrl || entry.excludedModels.length > 0; + + if (variant === 'row') { + return ( +
{ + if (!onSelect) return; + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + onSelect(); + } + }} + > +
+
+ +
+ +
+
+ {entry.label} + {renderSecretBadge(entry)} +
+
+ {entry.baseUrl || family.routePath} +
+
+ + {renderCountLabel(entry.models.length, 'alias')} + + + {renderCountLabel(entry.headers.length, 'header')} + + {entry.excludedModels.length > 0 && ( + + {renderCountLabel(entry.excludedModels.length, 'rule')} + + )} + {(entry.proxyUrl || entry.prefix) && ( + + Routed + + )} +
+
+ +
+ + + {onSelect ? ( +
+ +
+ ) : null} +
+
+
+ ); + } + + return ( +
+
+
+
+

{entry.label}

+ {renderSecretBadge(entry)} +
+

+ Routed through {family.routePath} +

+
+
+ + +
+
+ +
+
+
Secret
+
+ + {entry.apiKeyMasked || entry.apiKeysMasked?.join(', ') || 'Not stored'} +
+
+
+
Base URL
+
+ {entry.baseUrl || 'Default runtime endpoint'} +
+
+
+
Aliases
+
{renderCountLabel(entry.models.length, 'mapping')}
+
+
+
Headers
+
{renderCountLabel(entry.headers.length, 'header')}
+
+
+ + {hasAdvancedRouting && ( +
+ {entry.prefix && Prefix {entry.prefix}} + {entry.proxyUrl && Proxy URL set} + {entry.excludedModels.length > 0 && ( + + {renderCountLabel(entry.excludedModels.length, 'excluded model')} + + )} +
+ )} + + {(entry.models.length > 0 || entry.headers.length > 0 || entry.excludedModels.length > 0) && ( +
+
+
+ Route metadata +
+
+
+ {entry.secretConfigured ? ( + + ) : ( + + )} + + {entry.secretConfigured ? 'Secret stored in CLIProxy' : 'Secret missing'} + +
+
+ {entry.proxyUrl || entry.baseUrl || 'Default runtime endpoint'} +
+
+
+ + {entry.models.length > 0 ? ( +
+
+ Model aliases +
+
+ {entry.models.map((model) => ( +
+ {model.name} + → + {model.alias} +
+ ))} +
+
+ ) : null} + + {entry.headers.length > 0 ? ( +
+
+ Headers +
+
+ {entry.headers.map((header) => ( +
+ {header.key} + : + {header.value} +
+ ))} +
+
+ ) : null} + + {entry.excludedModels.length > 0 ? ( +
+
+ Excluded models +
+
+ {entry.excludedModels.map((model) => ( + + {model} + + ))} +
+
+ ) : null} +
+ )} +
+ ); +} diff --git a/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx new file mode 100644 index 00000000..d9d602d7 --- /dev/null +++ b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx @@ -0,0 +1,590 @@ +import { useMemo, useState } from 'react'; +import { ProviderLogo } from '@/components/cliproxy/provider-logo'; +import { getAiProviderFamilyVisual } from '@/lib/provider-config'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; +import { ChevronDown, KeyRound, SlidersHorizontal } from 'lucide-react'; +import type { + AiProviderEntryView, + AiProviderFamilyId, + UpsertAiProviderEntryInput, +} from '../../../../../src/cliproxy/ai-providers'; + +interface ProviderEntryDialogProps { + family: AiProviderFamilyId; + entry?: AiProviderEntryView | null; + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (data: UpsertAiProviderEntryInput) => Promise | void; + isSaving: boolean; +} + +type DialogGuide = { + familyName: string; + description: string; + requiredNow: string[]; + optionalLater: string[]; + keyLabel: string; + keyPlaceholder: string; + keyHelper: string; + connectorPlaceholder?: string; + connectorHelper?: string; + baseUrlPlaceholder: string; + baseUrlHelper: string; + aliasesPlaceholder: string; + aliasesHelper: string; + headersPlaceholder: string; +}; + +function getDialogGuide(family: AiProviderFamilyId): DialogGuide { + switch (family) { + case 'gemini-api-key': + return { + familyName: 'Gemini', + description: + 'Store the Gemini key here so CLIProxy can route Gemini requests without creating a separate CCS API Profile.', + requiredNow: [ + 'Paste the Gemini API key.', + 'Leave Base URL empty unless you use a custom Gemini host.', + ], + optionalLater: [ + 'Model mappings only when requested names and Gemini names differ.', + 'Headers only when your provider setup requires them.', + ], + keyLabel: 'Gemini API Key', + keyPlaceholder: 'AIza...', + keyHelper: 'This is the only field most Gemini setups need.', + baseUrlPlaceholder: 'https://generativelanguage.googleapis.com', + baseUrlHelper: 'Optional. Leave blank to keep the default Gemini endpoint.', + aliasesPlaceholder: 'claude-sonnet-4-5=gemini-2.5-pro', + aliasesHelper: + 'Format: requested=upstream. Leave this blank unless the upstream Gemini model name differs.', + headersPlaceholder: 'X-Goog-User-Project: your-project', + }; + case 'codex-api-key': + return { + familyName: 'Codex', + description: + 'Store the Codex or OpenAI key here so CLIProxy can route Codex requests without duplicating the setup in API Profiles.', + requiredNow: [ + 'Paste the Codex or OpenAI API key.', + 'Leave Base URL empty unless this route should target another OpenAI-style endpoint.', + ], + optionalLater: [ + 'Model mappings only when the upstream model ID differs.', + 'Headers only when org or project routing needs them.', + ], + keyLabel: 'Codex API Key', + keyPlaceholder: 'sk-...', + keyHelper: 'This is the only field most Codex setups need.', + baseUrlPlaceholder: 'https://api.openai.com/v1', + baseUrlHelper: 'Optional. Leave blank to keep the default Codex endpoint.', + aliasesPlaceholder: 'claude-sonnet-4-5=gpt-5', + aliasesHelper: + 'Format: requested=upstream. Add a mapping only when the upstream model name differs.', + headersPlaceholder: 'OpenAI-Organization: org_...', + }; + case 'claude-api-key': + return { + familyName: 'Claude', + description: + 'Store the Anthropic or compatible key here for CLIProxy-managed Claude routing. Save the key first, then add rewrites only if this route needs them.', + requiredNow: [ + 'Paste the Claude or Anthropic-compatible API key.', + 'Leave Base URL empty unless this route should target another compatible endpoint.', + ], + optionalLater: [ + 'Model mappings only when the requested and upstream Claude model IDs differ.', + 'Proxy, prefix, exclusions, and headers only for advanced routing cases.', + ], + keyLabel: 'Claude API Key', + keyPlaceholder: 'sk-ant-...', + keyHelper: 'Most Claude routes can start with the key only.', + baseUrlPlaceholder: 'https://api.anthropic.com', + baseUrlHelper: 'Optional. Leave blank to keep the default Claude-compatible endpoint.', + aliasesPlaceholder: 'claude-sonnet-4-5=claude-3-7-sonnet-latest', + aliasesHelper: + 'Format: requested=upstream. Add a mapping only when the upstream model ID should differ.', + headersPlaceholder: 'X-Project: internal-routing', + }; + case 'vertex-api-key': + return { + familyName: 'Vertex', + description: + 'Store the Vertex key here so CLIProxy can route Vertex traffic without creating a separate CCS API Profile.', + requiredNow: [ + 'Paste the Vertex API key.', + 'Leave Base URL empty unless a regional or gateway endpoint is required.', + ], + optionalLater: [ + 'Model mappings only when the upstream name differs.', + 'Headers only when the provider expects extra routing context.', + ], + keyLabel: 'Vertex API Key', + keyPlaceholder: 'AIza...', + keyHelper: 'Most Vertex routes only need the key.', + baseUrlPlaceholder: 'https://vertex.googleapis.com', + baseUrlHelper: 'Optional. Leave blank to keep the default Vertex endpoint.', + aliasesPlaceholder: 'claude-sonnet-4-5=gemini-2.5-pro', + aliasesHelper: + 'Format: requested=upstream. Leave blank unless the upstream model name differs.', + headersPlaceholder: 'X-Goog-User-Project: your-project', + }; + case 'openai-compatibility': + return { + familyName: 'OpenAI-Compatible Connector', + description: + 'Create a named connector for OpenRouter, Together, or any OpenAI-style endpoint. This page owns the connector setup directly inside CLIProxy.', + requiredNow: [ + 'Pick a connector name such as openrouter or together.', + 'Set the connector Base URL.', + 'Add at least one API key before saving.', + ], + optionalLater: [ + 'Model mappings only when requested and upstream model names differ.', + 'Headers only when the connector requires provider-specific auth or routing.', + ], + keyLabel: 'API Keys', + keyPlaceholder: 'sk-...', + keyHelper: 'Add one key per line. Most connectors start with a single key.', + connectorPlaceholder: 'openrouter', + connectorHelper: 'This becomes the connector label in the saved entries list.', + baseUrlPlaceholder: 'https://openrouter.ai/api/v1', + baseUrlHelper: 'Required for connectors. This is the upstream OpenAI-style endpoint.', + aliasesPlaceholder: 'claude-sonnet-4-5=gpt-4.1', + aliasesHelper: + 'Format: requested=upstream. Leave blank unless the connector expects a different model ID.', + headersPlaceholder: 'HTTP-Referer: https://your-app.example', + }; + } +} + +function parseDelimitedLines(value: string): string[] { + return value + .split('\n') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +function parseKeyValueLines(value: string): Array<{ key: string; value: string }> { + return value + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const separator = line.includes(':') ? ':' : '='; + const [key, ...rest] = line.split(separator); + return { key: key.trim(), value: rest.join(separator).trim() }; + }) + .filter((item) => item.key.length > 0); +} + +function parseModelAliasLines(value: string) { + return value + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0) + .map((line) => { + const separatorIndex = line.indexOf('='); + if (separatorIndex === -1) { + return { name: line.trim(), alias: '' }; + } + + return { + name: line.slice(0, separatorIndex).trim(), + alias: line.slice(separatorIndex + 1).trim(), + }; + }) + .filter((item) => item.name.length > 0 || item.alias.length > 0); +} + +function TextArea({ + value, + onChange, + placeholder, + rows = 4, +}: { + value: string; + onChange: (value: string) => void; + placeholder?: string; + rows?: number; +}) { + return ( +