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/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/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/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/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/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/src/components/cliproxy/api-profile-bridge-callout.tsx b/ui/src/components/cliproxy/api-profile-bridge-callout.tsx new file mode 100644 index 00000000..2cf7d20a --- /dev/null +++ b/ui/src/components/cliproxy/api-profile-bridge-callout.tsx @@ -0,0 +1,61 @@ +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { ProviderLogo } from '@/components/cliproxy/provider-logo'; +import { cn } from '@/lib/utils'; +import type { CLIProxyProvider } from '@/lib/provider-config'; +import { ArrowRight, Link2, ShieldCheck } from 'lucide-react'; +import { Link } from 'react-router-dom'; + +interface ApiProfileBridgeCalloutProps { + provider?: CLIProxyProvider; + className?: string; + compact?: boolean; +} + +export function ApiProfileBridgeCallout({ + provider, + className, + compact = false, +}: ApiProfileBridgeCalloutProps) { + const providerQuery = provider ? `&cliproxyProvider=${provider}` : ''; + + return ( +
+
+
+ {provider ? ( + + ) : ( + + )} +
+
+
+

Use this provider in API Profiles

+ + + No manual token copy + +
+

+ Configure keys, OAuth accounts, or models here, then create a routed API Profile in CCS. + The profile will point at the provider route automatically. +

+
+ + +
+
+
+
+ ); +} diff --git a/ui/src/components/cliproxy/control-panel-embed.tsx b/ui/src/components/cliproxy/control-panel-embed.tsx index a92125b0..113a6cbe 100644 --- a/ui/src/components/cliproxy/control-panel-embed.tsx +++ b/ui/src/components/cliproxy/control-panel-embed.tsx @@ -12,6 +12,7 @@ import { useQuery } from '@tanstack/react-query'; import { api, withApiBase } from '@/lib/api-client'; import type { CliproxyServerConfig } from '@/lib/api-client'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; +import { ApiProfileBridgeCallout } from './api-profile-bridge-callout'; interface AuthTokensResponse { apiKey: { value: string; isCustom: boolean }; @@ -200,6 +201,9 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel Retry +
+ +
@@ -219,67 +223,72 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel } return ( -
- {/* Remote indicator and login hint banner */} - {showLoginHint && !isLoading && ( -
-
- {isRemote && ( - <> - - Remote - | - - )} - - - Key:{' '} - - {authToken && authToken.length > 4 - ? `***${authToken.slice(-4)}` - : authToken || 'ccs'} - - - - - - +
+
+ +
+
+ {/* Remote indicator and login hint banner */} + {showLoginHint && !isLoading && ( +
+
+ {isRemote && ( + <> + + Remote + | + + )} + + + Key:{' '} + + {authToken && authToken.length > 4 + ? `***${authToken.slice(-4)}` + : authToken || 'ccs'} + + + + + + +
-
- )} + )} - {/* Loading overlay */} - {isLoading && ( -
-
- -

- {isRemote - ? `Loading Control Panel from ${displayHost}...` - : 'Loading Control Panel...'} -

+ {/* Loading overlay */} + {isLoading && ( +
+
+ +

+ {isRemote + ? `Loading Control Panel from ${displayHost}...` + : 'Loading Control Panel...'} +

+
-
- )} + )} - {/* Iframe */} -