feat(profiles): add cliproxy api profile bridge

- add backend and CLI flows for creating routed API profiles from CLIProxy providers

- add dashboard bridge CTAs, metadata, and guided create dialog mode

- cover bridge routes and dialog behavior with focused tests

Refs #649
This commit is contained in:
Tam Nhu Tran
2026-03-19 10:24:28 -04:00
parent 1f284132ce
commit 287691fa04
26 changed files with 1804 additions and 465 deletions
+153
View File
@@ -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<Settings, 'env'> | 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,
};
}
+12
View File
@@ -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';
+45 -18
View File
@@ -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<TargetType> = new Set<TargetType>(['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
+39
View File
@@ -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;
+65
View File
@@ -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.
+131
View File
@@ -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<string | undefined> {
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<void> {
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);
+11
View File
@@ -1,5 +1,6 @@
import {
PROVIDER_PRESETS,
listCliproxyBridgeProviders,
getPresetAliases,
getPresetIds,
type ProviderPreset,
@@ -20,6 +21,7 @@ export async function showApiCommandHelp(): Promise<void> {
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<void> {
console.log(
` ${color('--preset <id>', 'command')} Use provider preset (${presetIds.join(', ')})`
);
console.log(
` ${color('--cliproxy-provider <id>', 'command')} Use routed CLIProxy provider (${cliproxyProviderIds.join(', ')})`
);
console.log(` ${color('--base-url <url>', 'command')} API base URL (create)`);
console.log(` ${color('--api-key <key>', 'command')} API key (create)`);
console.log(` ${color('--model <model>', 'command')} Default model (create)`);
@@ -84,6 +89,12 @@ export async function showApiCommandHelp(): Promise<void> {
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')}`);
+14
View File
@@ -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'],
+4
View File
@@ -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 <src> <dest>', 'Duplicate API profile'],
+53
View File
@@ -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,
});
});
+3
View File
@@ -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.');
@@ -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);
});
});
@@ -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']);
@@ -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<void>((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<void>((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');
});
});
@@ -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 (
<div
className={cn('rounded-xl border bg-card/95 shadow-sm', compact ? 'p-3' : 'p-4', className)}
>
<div className={cn('flex gap-3', compact ? 'items-start' : 'items-center')}>
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
{provider ? (
<ProviderLogo provider={provider} size="md" />
) : (
<Link2 className="h-5 w-5 text-primary" />
)}
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-2">
<h3 className="text-sm font-semibold">Use this provider in API Profiles</h3>
<Badge variant="secondary" className="gap-1">
<ShieldCheck className="h-3 w-3" />
No manual token copy
</Badge>
</div>
<p className="text-xs text-muted-foreground">
Configure keys, OAuth accounts, or models here, then create a routed API Profile in CCS.
The profile will point at the provider route automatically.
</p>
<div className="flex flex-wrap gap-2">
<Button size="sm" asChild>
<Link to={`/providers?cliproxyBridge=1${providerQuery}`}>
Create API Profile
<ArrowRight className="ml-1 h-4 w-4" />
</Link>
</Button>
<Button size="sm" variant="outline" asChild>
<Link to="/providers">Open API Profiles</Link>
</Button>
</div>
</div>
</div>
</div>
);
}
@@ -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
</button>
</div>
<div className="border-b bg-muted/20 p-4">
<ApiProfileBridgeCallout compact className="max-w-3xl mx-auto" />
</div>
<div className="flex-1 flex items-center justify-center bg-muted/20">
<div className="text-center max-w-md px-8">
<div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-6">
@@ -219,67 +223,72 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
}
return (
<div className="flex-1 flex flex-col relative">
{/* Remote indicator and login hint banner */}
{showLoginHint && !isLoading && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
{isRemote && (
<>
<Globe className="h-3.5 w-3.5 text-green-600" />
<span className="text-green-600 font-medium">Remote</span>
<span className="text-blue-300 dark:text-blue-700">|</span>
</>
)}
<Key className="h-3.5 w-3.5 text-blue-600" />
<span>
Key:{' '}
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
{authToken && authToken.length > 4
? `***${authToken.slice(-4)}`
: authToken || 'ccs'}
</code>
</span>
<a
href="/settings?tab=auth"
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
title="Manage auth tokens"
>
<Settings className="h-3.5 w-3.5" />
</a>
<button
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
onClick={() => setShowLoginHint(false)}
>
<X className="h-3.5 w-3.5" />
</button>
<div className="flex-1 flex flex-col">
<div className="border-b bg-muted/20 p-4">
<ApiProfileBridgeCallout compact className="max-w-3xl mx-auto" />
</div>
<div className="flex-1 flex flex-col relative">
{/* Remote indicator and login hint banner */}
{showLoginHint && !isLoading && (
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
{isRemote && (
<>
<Globe className="h-3.5 w-3.5 text-green-600" />
<span className="text-green-600 font-medium">Remote</span>
<span className="text-blue-300 dark:text-blue-700">|</span>
</>
)}
<Key className="h-3.5 w-3.5 text-blue-600" />
<span>
Key:{' '}
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
{authToken && authToken.length > 4
? `***${authToken.slice(-4)}`
: authToken || 'ccs'}
</code>
</span>
<a
href="/settings?tab=auth"
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
title="Manage auth tokens"
>
<Settings className="h-3.5 w-3.5" />
</a>
<button
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
onClick={() => setShowLoginHint(false)}
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
)}
)}
{/* Loading overlay */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<div className="text-center">
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
{isRemote
? `Loading Control Panel from ${displayHost}...`
: 'Loading Control Panel...'}
</p>
{/* Loading overlay */}
{isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<div className="text-center">
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground">
{isRemote
? `Loading Control Panel from ${displayHost}...`
: 'Loading Control Panel...'}
</p>
</div>
</div>
</div>
)}
)}
{/* Iframe */}
<iframe
key={`${managementUrl}:${iframeRevision}`}
ref={iframeRef}
src={managementUrl}
className="flex-1 w-full border-0"
title="CLIProxy Management Panel"
onLoad={handleIframeLoad}
/>
{/* Iframe */}
<iframe
key={`${managementUrl}:${iframeRevision}`}
ref={iframeRef}
src={managementUrl}
className="flex-1 w-full border-0"
title="CLIProxy Management Panel"
onLoad={handleIframeLoad}
/>
</div>
</div>
);
}
@@ -0,0 +1,114 @@
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
import type { AuthStatus } from '@/lib/api-client';
import {
CLIPROXY_PROVIDERS,
getProviderDescription,
getProviderDisplayName,
type CLIProxyProvider,
} from '@/lib/provider-config';
import { cn } from '@/lib/utils';
import { CheckCircle2, Link2, Settings2 } from 'lucide-react';
import { Link } from 'react-router-dom';
interface CliproxyBridgeCreatePanelProps {
selectedProvider: CLIProxyProvider;
authStatuses?: AuthStatus[];
onProviderSelect: (provider: CLIProxyProvider) => void;
}
export function CliproxyBridgeCreatePanel({
selectedProvider,
authStatuses,
onProviderSelect,
}: CliproxyBridgeCreatePanelProps) {
const statusMap = new Map((authStatuses || []).map((status) => [status.provider, status]));
const selectedStatus = statusMap.get(selectedProvider);
return (
<div className="space-y-5">
<div className="space-y-2">
<div>
<p className="text-sm font-medium">CLIProxy Provider</p>
<p className="text-xs text-muted-foreground">
Pick the provider already configured in CLIProxy. CCS will create a routed API Profile
without asking you to copy the proxy URL or internal auth token.
</p>
</div>
<div className="grid gap-2 sm:grid-cols-2">
{CLIPROXY_PROVIDERS.map((provider) => {
const status = statusMap.get(provider);
return (
<button
key={provider}
type="button"
onClick={() => onProviderSelect(provider)}
className={cn(
'rounded-xl border p-3 text-left transition-colors',
selectedProvider === provider
? 'border-primary bg-primary/5 ring-1 ring-primary/15'
: 'border-border hover:border-primary/35 hover:bg-accent/15'
)}
>
<div className="flex items-start gap-3">
<div className="mt-0.5">
<ProviderLogo provider={provider} size="md" />
</div>
<div className="min-w-0 flex-1 space-y-1">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">{getProviderDisplayName(provider)}</span>
{status?.authenticated ? (
<Badge variant="secondary" className="gap-1">
<CheckCircle2 className="h-3 w-3" />
Connected
</Badge>
) : status?.accounts?.length ? (
<Badge variant="secondary">{status.accounts.length} account(s)</Badge>
) : (
<Badge variant="outline">Configured in CLIProxy</Badge>
)}
</div>
<p className="text-xs text-muted-foreground">
{getProviderDescription(provider)}
</p>
</div>
</div>
</button>
);
})}
</div>
</div>
<div className="rounded-xl border bg-muted/20 p-4 text-sm">
<div className="flex items-center gap-2 font-medium">
<Link2 className="h-4 w-4 text-primary" />
Routed profile summary
</div>
<div className="mt-3 space-y-2 text-xs text-muted-foreground">
<p>
Provider route:{' '}
<code className="rounded bg-muted px-1.5 py-0.5">/api/provider/{selectedProvider}</code>
</p>
<p>CCS resolves the active CLIProxy target automatically for local and remote setups.</p>
<p>
{selectedStatus?.authenticated
? 'CCS OAuth account detected for this provider.'
: 'No CCS OAuth account is required here if you already configured provider API keys in CLIProxy Control Panel.'}
</p>
</div>
<div className="mt-3 flex flex-wrap gap-2">
<Button size="sm" variant="outline" asChild>
<Link to={`/cliproxy?provider=${selectedProvider}`}>
<Settings2 className="mr-1 h-4 w-4" />
Open CLIProxy
</Link>
</Button>
<Button size="sm" variant="outline" asChild>
<Link to="/cliproxy/control-panel">Open Control Panel</Link>
</Button>
</div>
</div>
</div>
);
}
@@ -7,7 +7,8 @@ import { useTranslation } from 'react-i18next';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Label } from '@/components/ui/label';
import { CopyButton } from '@/components/ui/copy-button';
import { Info } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { CheckCircle2, Info, Link2, TriangleAlert } from 'lucide-react';
import type { SettingsResponse } from './types';
import type { CliTarget } from '@/lib/api-client';
@@ -62,6 +63,78 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
</span>
<span className="font-mono">{target}</span>
</div>
{data.cliproxyBridge && (
<>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-start">
<span className="font-medium text-muted-foreground">Source</span>
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-2">
<Badge variant="secondary" className="gap-1">
<Link2 className="h-3 w-3" />
CLIProxy {data.cliproxyBridge.providerDisplayName}
</Badge>
<Badge variant="outline" className="uppercase">
{data.cliproxyBridge.source}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
This profile is routed through{' '}
<code className="bg-muted px-1 rounded text-[10px]">
{data.cliproxyBridge.routePath}
</code>
. Keys and account state stay managed in CLIProxy.
</p>
</div>
</div>
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-start">
<span className="font-medium text-muted-foreground">Bridge Status</span>
<div className="space-y-1.5">
<div className="flex flex-wrap items-center gap-2 text-xs">
<Badge
variant="outline"
className={
data.cliproxyBridge.usesCurrentTarget
? 'border-green-200 text-green-700 bg-green-50'
: 'border-amber-200 text-amber-700 bg-amber-50'
}
>
{data.cliproxyBridge.usesCurrentTarget ? (
<CheckCircle2 className="mr-1 h-3 w-3" />
) : (
<TriangleAlert className="mr-1 h-3 w-3" />
)}
{data.cliproxyBridge.usesCurrentTarget
? 'Proxy target is current'
: 'Proxy target changed'}
</Badge>
<Badge
variant="outline"
className={
data.cliproxyBridge.usesCurrentAuthToken
? 'border-green-200 text-green-700 bg-green-50'
: 'border-amber-200 text-amber-700 bg-amber-50'
}
>
{data.cliproxyBridge.usesCurrentAuthToken ? (
<CheckCircle2 className="mr-1 h-3 w-3" />
) : (
<TriangleAlert className="mr-1 h-3 w-3" />
)}
{data.cliproxyBridge.usesCurrentAuthToken
? 'Auth token matches current proxy'
: 'Auth token may need refresh'}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
Expected proxy URL:{' '}
<code className="bg-muted px-1 rounded text-[10px] break-all">
{data.cliproxyBridge.currentBaseUrl}
</code>
</p>
</div>
</div>
</>
)}
</>
)}
</div>
+2 -1
View File
@@ -2,7 +2,7 @@
* Types for Profile Editor
*/
import type { CliTarget } from '@/lib/api-client';
import type { CliTarget, CliproxyBridgeMetadata } from '@/lib/api-client';
export interface Settings {
env?: Record<string, string>;
@@ -13,6 +13,7 @@ export interface SettingsResponse {
settings: Settings;
mtime: number;
path: string;
cliproxyBridge?: CliproxyBridgeMetadata | null;
}
export interface ProfileEditorProps {
@@ -8,18 +8,20 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator';
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
import { Sparkles, ExternalLink, ArrowRight, Zap, CloudCog, KeyRound } from 'lucide-react';
import { Sparkles, ExternalLink, ArrowRight, Zap, CloudCog, KeyRound, Link2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
interface OpenRouterQuickStartProps {
onOpenRouterClick: () => void;
onAlibabaCodingPlanClick: () => void;
onCliproxyClick: () => void;
onCustomClick: () => void;
}
export function OpenRouterQuickStart({
onOpenRouterClick,
onAlibabaCodingPlanClick,
onCliproxyClick,
onCustomClick,
}: OpenRouterQuickStartProps) {
const { t } = useTranslation();
@@ -145,6 +147,53 @@ export function OpenRouterQuickStart({
</CardContent>
</Card>
<Card className="border-emerald-500/30 dark:border-emerald-500/40 bg-gradient-to-br from-emerald-500/5 to-background dark:from-emerald-500/10">
<CardHeader className="pb-3">
<div className="flex items-center gap-3 mb-2">
<div className="p-2 rounded-lg bg-emerald-500/10 dark:bg-emerald-500/20">
<Link2 className="w-6 h-6 text-emerald-700 dark:text-emerald-300" />
</div>
<Badge
variant="secondary"
className="bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200"
>
Routed via CLIProxy
</Badge>
</div>
<CardTitle className="text-xl">Use an existing CLIProxy provider</CardTitle>
<CardDescription className="text-base">
If you already configured Gemini, Codex, Claude, or other providers in CLIProxy,
create an API Profile without copying internal URLs or auth tokens.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-3 text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Link2 className="w-4 h-4 text-emerald-600" />
<span>Route /api/provider/&lt;provider&gt;</span>
</div>
<div className="flex items-center gap-2 text-muted-foreground">
<KeyRound className="w-4 h-4 text-emerald-600" />
<span>Proxy token resolved automatically</span>
</div>
</div>
<Button
onClick={onCliproxyClick}
className="w-full bg-emerald-600 hover:bg-emerald-600/90 text-white"
size="lg"
>
Create CLIProxy Profile
<ArrowRight className="w-4 h-4 ml-2" />
</Button>
<p className="text-xs text-center text-muted-foreground">
Configure providers under CLIProxy or the embedded Control Panel, then bridge them
here.
</p>
</CardContent>
</Card>
{/* Divider */}
<div className="flex items-center gap-4">
<Separator className="flex-1" />
@@ -29,7 +29,8 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge';
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
import { useCreateProfile } from '@/hooks/use-profiles';
import { useCreateCliproxyBridgeProfile, useCreateProfile } from '@/hooks/use-profiles';
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
import {
Loader2,
@@ -41,6 +42,7 @@ import {
Settings2,
Sparkles,
ChevronDown,
Link2,
} from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
@@ -61,7 +63,9 @@ import {
} from '@/lib/openrouter-utils';
import type { CategorizedModel } from '@/lib/openrouter-types';
import type { CliTarget } from '@/lib/api-client';
import { isValidProvider, type CLIProxyProvider } from '@/lib/provider-config';
import i18n from '@/lib/i18n';
import { CliproxyBridgeCreatePanel } from './cliproxy-bridge-create-panel';
const optionalUrlSchema = z
.string()
@@ -90,7 +94,8 @@ interface ProfileCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSuccess: (name: string) => void;
initialMode?: 'normal' | 'openrouter' | 'alibaba-coding-plan';
initialMode?: 'normal' | 'openrouter' | 'alibaba-coding-plan' | 'cliproxy';
initialCliproxyProvider?: CLIProxyProvider | null;
}
// Common URL mistakes to warn about
@@ -122,15 +127,25 @@ export function ProfileCreateDialog({
onOpenChange,
onSuccess,
initialMode = 'openrouter',
initialCliproxyProvider = null,
}: ProfileCreateDialogProps) {
const { t } = useTranslation();
const createMutation = useCreateProfile();
const createCliproxyBridgeMutation = useCreateCliproxyBridgeProfile();
const { data: cliproxyAuthData } = useCliproxyAuth();
const [activeTab, setActiveTab] = useState('basic');
const [createKind, setCreateKind] = useState<'preset' | 'cliproxy'>(
initialMode === 'cliproxy' ? 'cliproxy' : 'preset'
);
const [urlWarning, setUrlWarning] = useState<string | null>(null);
const [showApiKey, setShowApiKey] = useState(false);
const [selectedPreset, setSelectedPreset] = useState<PresetSelection>(DEFAULT_PRESET_ID);
const [showMorePresets, setShowMorePresets] = useState(false);
const [modelSearch, setModelSearch] = useState('');
const [selectedCliproxyProvider, setSelectedCliproxyProvider] = useState<CLIProxyProvider>(
initialCliproxyProvider ?? 'gemini'
);
const [autoGeneratedCliproxyName, setAutoGeneratedCliproxyName] = useState('gemini-api');
// OpenRouter models for model picker
const { models: openRouterModels } = useOpenRouterCatalog();
@@ -140,6 +155,7 @@ export function ProfileCreateDialog({
handleSubmit,
formState: { errors },
control,
getValues,
reset,
setValue,
} = useForm<FormData>({
@@ -149,6 +165,21 @@ export function ProfileCreateDialog({
const baseUrlValue = useWatch({ control, name: 'baseUrl' });
const targetValue = useWatch({ control, name: 'target' });
const preferredCliproxyProvider = useMemo<CLIProxyProvider>(() => {
if (initialCliproxyProvider && isValidProvider(initialCliproxyProvider)) {
return initialCliproxyProvider;
}
const connectedProvider = cliproxyAuthData?.authStatus.find(
(status) =>
isValidProvider(status.provider) &&
(status.authenticated || (status.accounts?.length ?? 0) > 0)
);
return connectedProvider && isValidProvider(connectedProvider.provider)
? connectedProvider.provider
: 'gemini';
}, [cliproxyAuthData?.authStatus, initialCliproxyProvider]);
const applyPresetToForm = useCallback(
(preset: ProviderPreset | null) => {
if (!preset) {
@@ -190,11 +221,24 @@ export function ProfileCreateDialog({
useEffect(() => {
if (open) {
setActiveTab('basic');
setCreateKind(initialMode === 'cliproxy' ? 'cliproxy' : 'preset');
setUrlWarning(null);
setShowApiKey(false);
setShowMorePresets(false);
setModelSearch('');
if (initialMode === 'cliproxy') {
const provider = preferredCliproxyProvider;
const generatedName = `${provider}-api`;
setSelectedCliproxyProvider(provider);
setAutoGeneratedCliproxyName(generatedName);
reset({
...EMPTY_FORM_VALUES,
name: generatedName,
});
return;
}
// Set initial preset based on initialMode
if (initialMode === 'normal') {
setSelectedPreset(CUSTOM_PRESET_ID);
@@ -213,7 +257,20 @@ export function ProfileCreateDialog({
applyPresetToForm(null);
}
}
}, [open, initialMode, applyPresetToForm]);
}, [open, initialMode, applyPresetToForm, preferredCliproxyProvider, reset]);
useEffect(() => {
if (!open || createKind !== 'cliproxy') {
return;
}
const nextName = `${selectedCliproxyProvider}-api`;
const currentName = getValues('name').trim();
if (!currentName || currentName === autoGeneratedCliproxyName) {
setValue('name', nextName);
}
setAutoGeneratedCliproxyName(nextName);
}, [autoGeneratedCliproxyName, createKind, getValues, open, selectedCliproxyProvider, setValue]);
// Handle preset selection
const handlePresetSelect = (presetId: string) => {
@@ -265,6 +322,22 @@ export function ProfileCreateDialog({
}, [baseUrlValue, selectedPreset]);
const onSubmit = async (data: FormData) => {
if (createKind === 'cliproxy') {
try {
const result = await createCliproxyBridgeMutation.mutateAsync({
provider: selectedCliproxyProvider,
name: data.name,
target: data.target,
});
toast.success(`Profile "${result.name}" created`);
onSuccess(result.name);
onOpenChange(false);
} catch (error) {
toast.error((error as Error).message || 'Failed to create routed profile');
}
return;
}
// Validate API key - required unless preset has requiresApiKey: false
if (currentPreset?.requiresApiKey !== false && !data.apiKey) {
toast.error(i18n.t('commonToast.apiKeyRequired'));
@@ -288,11 +361,41 @@ export function ProfileCreateDialog({
const hasBasicErrors = !!errors.name || !!errors.baseUrl || !!errors.apiKey;
const hasModelErrors =
!!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel;
const isCreating =
createKind === 'cliproxy' ? createCliproxyBridgeMutation.isPending : createMutation.isPending;
const isQuickTemplateSelected =
selectedPreset !== CUSTOM_PRESET_ID && QUICK_TEMPLATE_PRESET_IDS.has(selectedPreset);
const isOpenRouter = currentPreset?.id === DEFAULT_PRESET_ID;
const showQuickTemplates = showMorePresets || isQuickTemplateSelected;
const handleCreateKindChange = (nextKind: 'preset' | 'cliproxy') => {
if (nextKind === createKind) {
return;
}
setCreateKind(nextKind);
setActiveTab('basic');
setUrlWarning(null);
if (nextKind === 'cliproxy') {
const provider = selectedCliproxyProvider || preferredCliproxyProvider;
const generatedName = `${provider}-api`;
setSelectedCliproxyProvider(provider);
setAutoGeneratedCliproxyName(generatedName);
reset({
...EMPTY_FORM_VALUES,
name: generatedName,
target: targetValue || 'claude',
});
return;
}
if (currentPreset) {
applyPresetToForm(currentPreset);
} else {
applyPresetToForm(null);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -303,7 +406,9 @@ export function ProfileCreateDialog({
Create API Profile
</DialogTitle>
<DialogDescription>
Choose a provider or configure a custom API endpoint.
{createKind === 'cliproxy'
? 'Bridge a provider already managed by CLIProxy into a usable CCS API Profile.'
: 'Choose a provider or configure a custom API endpoint.'}
</DialogDescription>
</DialogHeader>
@@ -311,108 +416,111 @@ export function ProfileCreateDialog({
onSubmit={handleSubmit(onSubmit)}
className="flex flex-col flex-1 min-h-0 overflow-hidden"
>
<div className="border-b bg-muted/10 px-6 py-3">
<div className="space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t('profileEditor.provider')}
</Label>
<p className="text-xs text-muted-foreground">
{t('profileEditor.providerChooserHint')}
</p>
</div>
<span className="pt-0.5 text-[11px] text-muted-foreground">
{t('profileEditor.scrollHint')}
</span>
</div>
<div className="border-b bg-muted/10 px-6 py-3 space-y-3">
<div className="grid grid-cols-2 gap-2">
<Button
type="button"
variant={createKind === 'cliproxy' ? 'default' : 'outline'}
onClick={() => handleCreateKindChange('cliproxy')}
>
Use CLIProxy Provider
</Button>
<Button
type="button"
variant={createKind === 'preset' ? 'default' : 'outline'}
onClick={() => handleCreateKindChange('preset')}
>
Preset or Manual Setup
</Button>
</div>
<div className="space-y-2">
<Label className="text-xs font-medium uppercase tracking-[0.12em] text-foreground/70">
{t('profileEditor.featuredProviders')}
</Label>
<div className="-mx-1 overflow-x-auto pb-1">
<div className="flex min-w-max items-stretch gap-2 px-1">
{RECOMMENDED_PRESETS.map((preset) => (
<CompactPresetCard
key={preset.id}
preset={preset}
isSelected={selectedPreset === preset.id}
onClick={() => handlePresetSelect(preset.id)}
density="featured"
/>
))}
<div className="mx-1 hidden w-px rounded-full bg-border/70 sm:block" />
<CustomPresetCard
isSelected={selectedPreset === CUSTOM_PRESET_ID}
onClick={() => handlePresetSelect(CUSTOM_PRESET_ID)}
/>
{createKind === 'preset' && (
<div className="space-y-3">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<Label className="text-xs text-muted-foreground">
{t('profileEditor.provider')}
</Label>
<p className="text-xs text-muted-foreground">
{t('profileEditor.providerChooserHint')}
</p>
</div>
<span className="pt-0.5 text-[11px] text-muted-foreground">
{t('profileEditor.scrollHint')}
</span>
</div>
</div>
<div className="space-y-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowMorePresets((current) => !current)}
aria-expanded={showQuickTemplates}
className="h-8 px-2 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<ChevronDown
className={cn(
'mr-1 h-3.5 w-3.5 transition-transform',
showQuickTemplates ? 'rotate-0' : '-rotate-90'
)}
/>
{t('profileEditor.morePresets')}
</Button>
{showQuickTemplates && (
<div className="space-y-2">
<Label className="text-xs font-medium uppercase tracking-[0.12em] text-foreground/70">
{t('profileEditor.featuredProviders')}
</Label>
<div className="-mx-1 overflow-x-auto pb-1">
<div className="flex min-w-max items-stretch gap-2 px-1">
{QUICK_TEMPLATE_PRESETS.map((preset) => (
{RECOMMENDED_PRESETS.map((preset) => (
<CompactPresetCard
key={preset.id}
preset={preset}
isSelected={selectedPreset === preset.id}
onClick={() => handlePresetSelect(preset.id)}
density="compact"
density="featured"
/>
))}
<div className="mx-1 hidden w-px rounded-full bg-border/70 sm:block" />
<CustomPresetCard
isSelected={selectedPreset === CUSTOM_PRESET_ID}
onClick={() => handlePresetSelect(CUSTOM_PRESET_ID)}
/>
</div>
</div>
)}
</div>
<div className="space-y-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setShowMorePresets((current) => !current)}
aria-expanded={showQuickTemplates}
className="h-8 px-2 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<ChevronDown
className={cn(
'mr-1 h-3.5 w-3.5 transition-transform',
showQuickTemplates ? 'rotate-0' : '-rotate-90'
)}
/>
{t('profileEditor.morePresets')}
</Button>
{showQuickTemplates && (
<div className="-mx-1 overflow-x-auto pb-1">
<div className="flex min-w-max items-stretch gap-2 px-1">
{QUICK_TEMPLATE_PRESETS.map((preset) => (
<CompactPresetCard
key={preset.id}
preset={preset}
isSelected={selectedPreset === preset.id}
onClick={() => handlePresetSelect(preset.id)}
density="compact"
/>
))}
</div>
</div>
)}
</div>
</div>
</div>
)}
</div>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-col flex-1 min-h-0 overflow-hidden"
>
<div className="px-6 pt-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="basic" className="relative">
Basic Information
{hasBasicErrors && (
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
)}
</TabsTrigger>
<TabsTrigger value="models" className="relative">
Model Configuration
{hasModelErrors && (
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
)}
</TabsTrigger>
</TabsList>
</div>
{createKind === 'cliproxy' ? (
<>
<div className="flex-1 min-h-0 overflow-y-auto p-6 space-y-5">
<CliproxyBridgeCreatePanel
selectedProvider={selectedCliproxyProvider}
authStatuses={cliproxyAuthData?.authStatus}
onProviderSelect={setSelectedCliproxyProvider}
/>
<div className="flex-1 min-h-0 overflow-y-auto">
<TabsContent value="basic" className="p-6 space-y-4 mt-0">
{/* Profile Name */}
<div className="space-y-1.5">
<Label htmlFor="name">
Profile Name <span className="text-destructive">*</span>
@@ -433,81 +541,6 @@ export function ProfileCreateDialog({
)}
</div>
{/* Base URL - always editable, pre-filled from preset */}
<div className="space-y-1.5">
<Label htmlFor="baseUrl">API Base URL</Label>
<Input
id="baseUrl"
{...register('baseUrl')}
placeholder="https://api.example.com/v1"
/>
{errors.baseUrl ? (
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
) : urlWarning ? (
<div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{urlWarning}</span>
</div>
) : currentPreset ? (
<p className="text-xs text-muted-foreground">
{currentPreset.baseUrl
? `Pre-filled from ${currentPreset.name}. You can customize if needed.`
: `Optional for ${currentPreset.name}. Leave blank to use native Anthropic auth.`}
</p>
) : (
<p className="text-xs text-muted-foreground">
The endpoint that accepts OpenAI-compatible and Anthropic requests
</p>
)}
</div>
{/* API Key - optional for presets that don't require it */}
<div className="space-y-1.5">
<Label htmlFor="apiKey">
API Key{' '}
{currentPreset?.requiresApiKey !== false && (
<span className="text-destructive">*</span>
)}
{currentPreset?.requiresApiKey === false && (
<span className="text-muted-foreground text-xs font-normal">(optional)</span>
)}
</Label>
<div className="relative">
<Input
id="apiKey"
type={showApiKey ? 'text' : 'password'}
{...register('apiKey')}
placeholder={
currentPreset?.requiresApiKey === false
? 'Optional - only if auth is enabled'
: (currentPreset?.apiKeyPlaceholder ?? 'sk-...')
}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
onClick={() => setShowApiKey(!showApiKey)}
tabIndex={-1}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
{errors.apiKey ? (
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
) : currentPreset?.requiresApiKey === false ? (
<p className="text-xs text-muted-foreground">
Only needed if your local endpoint has authentication enabled
</p>
) : (
currentPreset?.apiKeyHint && (
<p className="text-xs text-muted-foreground">{currentPreset.apiKeyHint}</p>
)
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="target">Default Target CLI</Label>
<Select
@@ -523,162 +556,336 @@ export function ProfileCreateDialog({
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Run with{' '}
The routed profile still runs like a normal API Profile via{' '}
<code className="bg-muted px-1 rounded text-[10px]">
{targetValue === 'droid' ? 'ccsd' : 'ccs'}
</code>{' '}
by default. You can still override each run with{' '}
</code>
. You can override per command with{' '}
<code className="bg-muted px-1 rounded text-[10px]">--target</code>.
</p>
</div>
</TabsContent>
</div>
<TabsContent value="models" className="p-6 mt-0 space-y-4">
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
<Info className="w-5 h-5 shrink-0 mt-0.5" />
<div>
<p className="font-medium mb-1">Model Mapping</p>
<p className="text-xs opacity-90">
Map Claude Code tiers (Opus/Sonnet/Haiku) to models supported by your
provider.
</p>
</div>
</div>
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={isCreating}
className={cn(isCreating && 'opacity-80')}
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Link2 className="w-4 h-4 mr-2" />
Create Routed Profile
</>
)}
</Button>
</DialogFooter>
</>
) : (
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex flex-col flex-1 min-h-0 overflow-hidden"
>
<div className="px-6 pt-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="basic" className="relative">
Basic Information
{hasBasicErrors && (
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
)}
</TabsTrigger>
<TabsTrigger value="models" className="relative">
Model Configuration
{hasModelErrors && (
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
)}
</TabsTrigger>
</TabsList>
</div>
{/* OpenRouter Model Picker */}
{isOpenRouter && (
<div className="space-y-2">
<Label>Search Models</Label>
<Input
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
placeholder="Type to search (e.g., opus, sonnet, gpt-4o)..."
onKeyDown={(e) => {
if (e.key === 'Enter' && filteredModels.length > 0) {
e.preventDefault();
handleModelSelect(filteredModels[0]);
}
}}
/>
<div className="border rounded-md max-h-48 overflow-y-auto">
{filteredModels.length === 0 ? (
<p className="text-sm text-muted-foreground p-3 text-center">
{modelSearch
? `No models found for "${modelSearch}"`
: 'Loading models...'}
</p>
) : (
<div className="p-1">
{!modelSearch && (
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground">
<Sparkles className="w-3 h-3 text-accent" />
<span>Newest Models</span>
</div>
)}
{filteredModels.map((model) => (
<ModelSearchItem
key={model.id}
model={model}
onClick={() => handleModelSelect(model)}
showAge={!modelSearch}
/>
))}
</div>
)}
</div>
</div>
)}
{/* Model Inputs */}
<div className="space-y-3">
<div className="flex-1 min-h-0 overflow-y-auto">
<TabsContent value="basic" className="p-6 space-y-4 mt-0">
{/* Profile Name */}
<div className="space-y-1.5">
<Label htmlFor="model">
Default Model
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
ANTHROPIC_MODEL
</Badge>
<Label htmlFor="name">
Profile Name <span className="text-destructive">*</span>
</Label>
<Input
id="model"
{...register('model')}
placeholder={currentPreset?.defaultModel ?? 'claude-sonnet-4'}
className="font-mono text-sm"
id="name"
{...register('name')}
placeholder="my-api"
className="font-mono"
/>
{errors.name ? (
<p className="text-xs text-destructive">{errors.name.message}</p>
) : (
<p className="text-xs text-muted-foreground">
Used in CLI:{' '}
<code className="bg-muted px-1 rounded text-[10px]">
ccs my-api "prompt"
</code>
</p>
)}
</div>
<div className="grid gap-3 pt-2 border-t">
<div className="space-y-1.5">
<Label htmlFor="sonnetModel" className="text-sm">
Sonnet Mapping
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_SONNET
</Badge>
</Label>
<Input
id="sonnetModel"
{...register('sonnetModel')}
placeholder="e.g. gpt-4o, claude-sonnet-4"
className="font-mono text-sm h-9"
/>
</div>
{/* Base URL - always editable, pre-filled from preset */}
<div className="space-y-1.5">
<Label htmlFor="baseUrl">API Base URL</Label>
<Input
id="baseUrl"
{...register('baseUrl')}
placeholder="https://api.example.com/v1"
/>
{errors.baseUrl ? (
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
) : urlWarning ? (
<div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
<span>{urlWarning}</span>
</div>
) : currentPreset ? (
<p className="text-xs text-muted-foreground">
{currentPreset.baseUrl
? `Pre-filled from ${currentPreset.name}. You can customize if needed.`
: `Optional for ${currentPreset.name}. Leave blank to use native Anthropic auth.`}
</p>
) : (
<p className="text-xs text-muted-foreground">
The endpoint that accepts OpenAI-compatible and Anthropic requests
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="opusModel" className="text-sm">
Opus Mapping
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_OPUS
</Badge>
</Label>
{/* API Key - optional for presets that don't require it */}
<div className="space-y-1.5">
<Label htmlFor="apiKey">
API Key{' '}
{currentPreset?.requiresApiKey !== false && (
<span className="text-destructive">*</span>
)}
{currentPreset?.requiresApiKey === false && (
<span className="text-muted-foreground text-xs font-normal">
(optional)
</span>
)}
</Label>
<div className="relative">
<Input
id="opusModel"
{...register('opusModel')}
placeholder="e.g. o1, claude-opus-4.5"
className="font-mono text-sm h-9"
id="apiKey"
type={showApiKey ? 'text' : 'password'}
{...register('apiKey')}
placeholder={
currentPreset?.requiresApiKey === false
? 'Optional - only if auth is enabled'
: (currentPreset?.apiKeyPlaceholder ?? 'sk-...')
}
className="pr-10"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
onClick={() => setShowApiKey(!showApiKey)}
tabIndex={-1}
>
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
{errors.apiKey ? (
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
) : currentPreset?.requiresApiKey === false ? (
<p className="text-xs text-muted-foreground">
Only needed if your local endpoint has authentication enabled
</p>
) : (
currentPreset?.apiKeyHint && (
<p className="text-xs text-muted-foreground">{currentPreset.apiKeyHint}</p>
)
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="haikuModel" className="text-sm">
Haiku Mapping
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_HAIKU
</Badge>
</Label>
<Input
id="haikuModel"
{...register('haikuModel')}
placeholder="e.g. gpt-4o-mini, claude-3.5-haiku"
className="font-mono text-sm h-9"
/>
<div className="space-y-1.5">
<Label htmlFor="target">Default Target CLI</Label>
<Select
value={targetValue}
onValueChange={(value) => setValue('target', value as CliTarget)}
>
<SelectTrigger id="target">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">Claude Code (default)</SelectItem>
<SelectItem value="droid">Factory Droid</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
Run with{' '}
<code className="bg-muted px-1 rounded text-[10px]">
{targetValue === 'droid' ? 'ccsd' : 'ccs'}
</code>{' '}
by default. You can still override each run with{' '}
<code className="bg-muted px-1 rounded text-[10px]">--target</code>.
</p>
</div>
</TabsContent>
<TabsContent value="models" className="p-6 mt-0 space-y-4">
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
<Info className="w-5 h-5 shrink-0 mt-0.5" />
<div>
<p className="font-medium mb-1">Model Mapping</p>
<p className="text-xs opacity-90">
Map Claude Code tiers (Opus/Sonnet/Haiku) to models supported by your
provider.
</p>
</div>
</div>
</div>
</TabsContent>
</div>
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={createMutation.isPending}
className={cn(createMutation.isPending && 'opacity-80')}
>
{createMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Create Profile
</>
)}
</Button>
</DialogFooter>
</Tabs>
{/* OpenRouter Model Picker */}
{isOpenRouter && (
<div className="space-y-2">
<Label>Search Models</Label>
<Input
value={modelSearch}
onChange={(e) => setModelSearch(e.target.value)}
placeholder="Type to search (e.g., opus, sonnet, gpt-4o)..."
onKeyDown={(e) => {
if (e.key === 'Enter' && filteredModels.length > 0) {
e.preventDefault();
handleModelSelect(filteredModels[0]);
}
}}
/>
<div className="border rounded-md max-h-48 overflow-y-auto">
{filteredModels.length === 0 ? (
<p className="text-sm text-muted-foreground p-3 text-center">
{modelSearch
? `No models found for "${modelSearch}"`
: 'Loading models...'}
</p>
) : (
<div className="p-1">
{!modelSearch && (
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground">
<Sparkles className="w-3 h-3 text-accent" />
<span>Newest Models</span>
</div>
)}
{filteredModels.map((model) => (
<ModelSearchItem
key={model.id}
model={model}
onClick={() => handleModelSelect(model)}
showAge={!modelSearch}
/>
))}
</div>
)}
</div>
</div>
)}
{/* Model Inputs */}
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="model">
Default Model
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
ANTHROPIC_MODEL
</Badge>
</Label>
<Input
id="model"
{...register('model')}
placeholder={currentPreset?.defaultModel ?? 'claude-sonnet-4'}
className="font-mono text-sm"
/>
</div>
<div className="grid gap-3 pt-2 border-t">
<div className="space-y-1.5">
<Label htmlFor="sonnetModel" className="text-sm">
Sonnet Mapping
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_SONNET
</Badge>
</Label>
<Input
id="sonnetModel"
{...register('sonnetModel')}
placeholder="e.g. gpt-4o, claude-sonnet-4"
className="font-mono text-sm h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="opusModel" className="text-sm">
Opus Mapping
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_OPUS
</Badge>
</Label>
<Input
id="opusModel"
{...register('opusModel')}
placeholder="e.g. o1, claude-opus-4.5"
className="font-mono text-sm h-9"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="haikuModel" className="text-sm">
Haiku Mapping
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
DEFAULT_HAIKU
</Badge>
</Label>
<Input
id="haikuModel"
{...register('haikuModel')}
placeholder="e.g. gpt-4o-mini, claude-3.5-haiku"
className="font-mono text-sm h-9"
/>
</div>
</div>
</div>
</TabsContent>
</div>
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={isCreating}
className={cn(isCreating && 'opacity-80')}
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Plus className="w-4 h-4 mr-2" />
Create Profile
</>
)}
</Button>
</DialogFooter>
</Tabs>
)}
</form>
</DialogContent>
</Dialog>
+17
View File
@@ -7,6 +7,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import {
api,
type CreateProfile,
type CreateCliproxyBridgeProfileRequest,
type UpdateProfile,
type RegisterProfileOrphansRequest,
type CopyProfileRequest,
@@ -36,6 +37,22 @@ export function useCreateProfile() {
});
}
export function useCreateCliproxyBridgeProfile() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: CreateCliproxyBridgeProfileRequest) =>
api.profiles.createCliproxyBridge(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
toast.success('CLIProxy-routed profile created successfully');
},
onError: (error: Error) => {
toast.error(error.message);
},
});
}
export function useUpdateProfile() {
const queryClient = useQueryClient();
+29
View File
@@ -96,11 +96,22 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
// Types
export type CliTarget = 'claude' | 'droid';
export interface CliproxyBridgeMetadata {
provider: CLIProxyProvider;
providerDisplayName: string;
routePath: string;
currentBaseUrl: string;
source: 'local' | 'remote';
usesCurrentTarget: boolean;
usesCurrentAuthToken: boolean;
}
export interface Profile {
name: string;
settingsPath: string;
configured: boolean;
target?: CliTarget;
cliproxyBridge?: CliproxyBridgeMetadata | null;
}
export interface CreateProfile {
@@ -124,6 +135,19 @@ export interface UpdateProfile {
target?: CliTarget;
}
export interface CreateCliproxyBridgeProfileRequest {
provider: CLIProxyProvider;
name?: string;
target?: CliTarget;
}
export interface CreateCliproxyBridgeProfileResponse {
name: string;
settingsPath: string;
target: CliTarget;
cliproxyBridge?: CliproxyBridgeMetadata | null;
}
export interface ProfileValidationIssue {
level: 'error' | 'warning';
code: string;
@@ -762,6 +786,11 @@ export const api = {
method: 'POST',
body: JSON.stringify(data),
}),
createCliproxyBridge: (data: CreateCliproxyBridgeProfileRequest) =>
request<CreateCliproxyBridgeProfileResponse>('/profiles/cliproxy-bridge', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (name: string, data: UpdateProfile) =>
request(`/profiles/${name}`, {
method: 'PUT',
+72 -6
View File
@@ -38,9 +38,14 @@ import { cn } from '@/lib/utils';
import { CopyButton } from '@/components/ui/copy-button';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { useLocation, useNavigate } from 'react-router-dom';
import type { CLIProxyProvider } from '@/lib/provider-config';
import { isValidProvider } from '@/lib/provider-config';
export function ApiPage() {
const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const { data, isLoading, isError, refetch } = useProfiles();
const deleteMutation = useDeleteProfile();
const discoverOrphansMutation = useDiscoverProfileOrphans();
@@ -51,9 +56,11 @@ export function ApiPage() {
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState('');
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
const [createMode, setCreateMode] = useState<'normal' | 'openrouter' | 'alibaba-coding-plan'>(
'normal'
);
const [createMode, setCreateMode] = useState<
'normal' | 'openrouter' | 'alibaba-coding-plan' | 'cliproxy'
>('normal');
const [createDialogCliproxyProvider, setCreateDialogCliproxyProvider] =
useState<CLIProxyProvider | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [editorHasChanges, setEditorHasChanges] = useState(false);
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
@@ -68,6 +75,34 @@ export function ApiPage() {
const selectedProfileData = selectedProfile
? profiles.find((p) => p.name === selectedProfile)
: null;
const cliproxyBridgeIntent = useMemo(() => {
const params = new URLSearchParams(location.search);
const rawProvider = params.get('cliproxyProvider');
const wantsBridge = params.get('cliproxyBridge') === '1' || rawProvider !== null;
if (!wantsBridge) {
return null;
}
const provider = rawProvider && isValidProvider(rawProvider) ? rawProvider : null;
return {
provider,
};
}, [location.search]);
const clearCliproxyBridgeIntent = () => {
if (!cliproxyBridgeIntent) {
return;
}
navigate({ pathname: location.pathname, search: '' }, { replace: true });
};
const resolvedCreateMode = cliproxyBridgeIntent ? 'cliproxy' : createMode;
const resolvedCreateDialogProvider = cliproxyBridgeIntent
? cliproxyBridgeIntent.provider
: createDialogCliproxyProvider;
const isResolvedCreateDialogOpen = isCreateDialogOpen || !!cliproxyBridgeIntent;
const switchToProfile = (name: string) => {
if (editorHasChanges && selectedProfile !== name) {
@@ -91,6 +126,7 @@ export function ApiPage() {
};
const handleCreateSuccess = (name: string) => {
clearCliproxyBridgeIntent();
setCreateDialogOpen(false);
switchToProfile(name);
};
@@ -98,6 +134,13 @@ export function ApiPage() {
switchToProfile(name);
};
const handleCreateDialogOpenChange = (open: boolean) => {
if (!open) {
clearCliproxyBridgeIntent();
}
setCreateDialogOpen(open);
};
const triggerDownload = (filename: string, bundle: ApiProfileExportBundle) => {
const content = JSON.stringify(bundle, null, 2) + '\n';
const blob = new Blob([content], { type: 'application/json' });
@@ -292,6 +335,18 @@ export function ApiPage() {
{t('apiProfiles.noProfilesDesc')}
</p>
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
setCreateMode('cliproxy');
setCreateDialogCliproxyProvider(null);
setCreateDialogOpen(true);
}}
>
<Server className="w-4 h-4 mr-1" />
Use CLIProxy
</Button>
<Button
size="sm"
variant="outline"
@@ -385,6 +440,11 @@ export function ApiPage() {
</>
) : (
<OpenRouterQuickStart
onCliproxyClick={() => {
setCreateMode('cliproxy');
setCreateDialogCliproxyProvider(null);
setCreateDialogOpen(true);
}}
onOpenRouterClick={() => {
setCreateMode('openrouter');
setCreateDialogOpen(true);
@@ -411,10 +471,11 @@ export function ApiPage() {
/>
<ProfileCreateDialog
open={isCreateDialogOpen}
onOpenChange={setCreateDialogOpen}
open={isResolvedCreateDialogOpen}
onOpenChange={handleCreateDialogOpenChange}
onSuccess={handleCreateSuccess}
initialMode={createMode}
initialMode={resolvedCreateMode}
initialCliproxyProvider={resolvedCreateDialogProvider}
/>
<ConfirmDialog
@@ -480,6 +541,11 @@ function ProfileListItem({
<Badge variant="outline" className="text-[10px] h-4 px-1.5 uppercase">
{profile.target || 'claude'}
</Badge>
{profile.cliproxyBridge && (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5">
CLIProxy {profile.cliproxyBridge.provider}
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 min-w-0">
<div className="text-xs text-muted-foreground truncate flex-1">
+105 -84
View File
@@ -15,6 +15,7 @@ import { QuickSetupWizard } from '@/components/quick-setup-wizard';
import { AddAccountDialog } from '@/components/account/add-account-dialog';
import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card';
import { ProviderEditor } from '@/components/cliproxy/provider-editor';
import { ApiProfileBridgeCallout } from '@/components/cliproxy/api-profile-bridge-callout';
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
import {
@@ -436,93 +437,113 @@ export function CliproxyPage() {
)}
{selectedVariantData && parentAuthForVariant ? (
// Variant selected - show ProviderEditor with variant profile name
<ProviderEditor
provider={selectedVariantData.name}
displayName={t('cliproxyPage.variantDisplay', {
name: selectedVariantData.name,
provider: selectedVariantData.provider,
})}
authStatus={parentAuthForVariant}
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
logoProvider={selectedVariantData.provider}
baseProvider={selectedVariantData.provider}
defaultTarget={selectedVariantData.target}
isRemoteMode={isRemoteMode}
port={selectedVariantData.port}
onAddAccount={() =>
setAddAccountProvider({
<>
<ApiProfileBridgeCallout
provider={selectedVariantData.provider}
compact
className="mx-4 mt-4"
/>
<ProviderEditor
provider={selectedVariantData.name}
displayName={t('cliproxyPage.variantDisplay', {
name: selectedVariantData.name,
provider: selectedVariantData.provider,
displayName: parentAuthForVariant.displayName,
isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0,
})
}
onSetDefault={(accountId) =>
setDefaultMutation.mutate({
provider: selectedVariantData.provider,
accountId,
})
}
onRemoveAccount={(accountId) =>
removeMutation.mutate({
provider: selectedVariantData.provider,
accountId,
})
}
onPauseToggle={(accountId, paused) =>
handlePauseToggle(selectedVariantData.provider, accountId, paused)
}
onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)}
onBulkPause={(accountIds) => handleBulkPause(selectedVariantData.provider, accountIds)}
onBulkResume={(accountIds) =>
handleBulkResume(selectedVariantData.provider, accountIds)
}
isRemovingAccount={removeMutation.isPending}
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
isSoloingAccount={soloMutation.isPending}
isBulkPausing={bulkPauseMutation.isPending}
isBulkResuming={bulkResumeMutation.isPending}
/>
})}
authStatus={parentAuthForVariant}
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
logoProvider={selectedVariantData.provider}
baseProvider={selectedVariantData.provider}
defaultTarget={selectedVariantData.target}
isRemoteMode={isRemoteMode}
port={selectedVariantData.port}
onAddAccount={() =>
setAddAccountProvider({
provider: selectedVariantData.provider,
displayName: parentAuthForVariant.displayName,
isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0,
})
}
onSetDefault={(accountId) =>
setDefaultMutation.mutate({
provider: selectedVariantData.provider,
accountId,
})
}
onRemoveAccount={(accountId) =>
removeMutation.mutate({
provider: selectedVariantData.provider,
accountId,
})
}
onPauseToggle={(accountId, paused) =>
handlePauseToggle(selectedVariantData.provider, accountId, paused)
}
onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)}
onBulkPause={(accountIds) =>
handleBulkPause(selectedVariantData.provider, accountIds)
}
onBulkResume={(accountIds) =>
handleBulkResume(selectedVariantData.provider, accountIds)
}
isRemovingAccount={removeMutation.isPending}
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
isSoloingAccount={soloMutation.isPending}
isBulkPausing={bulkPauseMutation.isPending}
isBulkResuming={bulkResumeMutation.isPending}
/>
</>
) : selectedStatus ? (
<ProviderEditor
provider={selectedStatus.provider}
displayName={selectedStatus.displayName}
authStatus={selectedStatus}
catalog={MODEL_CATALOGS[selectedStatus.provider]}
isRemoteMode={isRemoteMode}
onAddAccount={() =>
setAddAccountProvider({
provider: selectedStatus.provider,
displayName: selectedStatus.displayName,
isFirstAccount: (selectedStatus.accounts?.length || 0) === 0,
})
}
onSetDefault={(accountId) =>
setDefaultMutation.mutate({
provider: selectedStatus.provider,
accountId,
})
}
onRemoveAccount={(accountId) =>
removeMutation.mutate({
provider: selectedStatus.provider,
accountId,
})
}
onPauseToggle={(accountId, paused) =>
handlePauseToggle(selectedStatus.provider, accountId, paused)
}
onSoloMode={(accountId) => handleSoloMode(selectedStatus.provider, accountId)}
onBulkPause={(accountIds) => handleBulkPause(selectedStatus.provider, accountIds)}
onBulkResume={(accountIds) => handleBulkResume(selectedStatus.provider, accountIds)}
isRemovingAccount={removeMutation.isPending}
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
isSoloingAccount={soloMutation.isPending}
isBulkPausing={bulkPauseMutation.isPending}
isBulkResuming={bulkResumeMutation.isPending}
/>
<>
<ApiProfileBridgeCallout
provider={
isValidProvider(selectedStatus.provider) ? selectedStatus.provider : undefined
}
compact
className="mx-4 mt-4"
/>
<ProviderEditor
provider={selectedStatus.provider}
displayName={selectedStatus.displayName}
authStatus={selectedStatus}
catalog={MODEL_CATALOGS[selectedStatus.provider]}
isRemoteMode={isRemoteMode}
onAddAccount={() =>
setAddAccountProvider({
provider: selectedStatus.provider,
displayName: selectedStatus.displayName,
isFirstAccount: (selectedStatus.accounts?.length || 0) === 0,
})
}
onSetDefault={(accountId) =>
setDefaultMutation.mutate({
provider: selectedStatus.provider,
accountId,
})
}
onRemoveAccount={(accountId) =>
removeMutation.mutate({
provider: selectedStatus.provider,
accountId,
})
}
onPauseToggle={(accountId, paused) =>
handlePauseToggle(selectedStatus.provider, accountId, paused)
}
onSoloMode={(accountId) => handleSoloMode(selectedStatus.provider, accountId)}
onBulkPause={(accountIds) => handleBulkPause(selectedStatus.provider, accountIds)}
onBulkResume={(accountIds) => handleBulkResume(selectedStatus.provider, accountIds)}
isRemovingAccount={removeMutation.isPending}
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
isSoloingAccount={soloMutation.isPending}
isBulkPausing={bulkPauseMutation.isPending}
isBulkResuming={bulkResumeMutation.isPending}
/>
</>
) : (
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
<div className="flex flex-1 flex-col min-h-0">
<ApiProfileBridgeCallout compact className="mx-4 mt-4" />
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
</div>
)}
</div>
@@ -10,6 +10,10 @@ vi.mock('@/hooks/use-profiles', () => ({
mutateAsync,
isPending: false,
}),
useCreateCliproxyBridgeProfile: () => ({
mutateAsync,
isPending: false,
}),
}));
vi.mock('@/hooks/use-openrouter-models', () => ({
@@ -18,6 +22,12 @@ vi.mock('@/hooks/use-openrouter-models', () => ({
}),
}));
vi.mock('@/hooks/use-cliproxy', () => ({
useCliproxyAuth: () => ({
data: { authStatus: [] },
}),
}));
describe('ProfileCreateDialog', () => {
beforeEach(() => {
mutateAsync.mockReset();