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 committed 2026-03-19 10:24:28 -04:00
1 parent 1f284132ce
commit 287691fa04
26 files changed
+1804 -465

No files matched your search

+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 CliproxyVariantInfo,
type ApiListResult, type ApiListResult,
type CreateApiProfileResult, type CreateApiProfileResult,
type CreateCliproxyBridgeProfileResult,
type RemoveApiProfileResult, type RemoveApiProfileResult,
type UpdateApiProfileTargetResult, type UpdateApiProfileTargetResult,
type CliproxyBridgeProviderInfo,
type CliproxyBridgeMetadata,
type ResolvedCliproxyBridgeProfile,
type ProfileValidationIssue, type ProfileValidationIssue,
type ProfileValidationSummary, type ProfileValidationSummary,
type ApiProfileOrphanCandidate, type ApiProfileOrphanCandidate,
@@ -38,6 +42,14 @@ export {
// Profile write operations // Profile write operations
export { createApiProfile, removeApiProfile, updateApiProfileTarget } from './profile-writer'; 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 // Lifecycle validation and operations
export { validateApiProfileSettingsPayload } from './profile-lifecycle-validation'; export { validateApiProfileSettingsPayload } from './profile-lifecycle-validation';
+45 -18
View File
@@ -6,11 +6,13 @@
*/ */
import * as fs from 'fs'; import * as fs from 'fs';
import * as path from 'path'; import { loadConfigSafe } from '../../utils/config-manager';
import { getCcsDir, loadConfigSafe } from '../../utils/config-manager';
import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader'; import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
import { expandPath } from '../../utils/helpers';
import type { TargetType } from '../../targets/target-adapter'; import type { TargetType } from '../../targets/target-adapter';
import type { Settings } from '../../types/config';
import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types'; import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types';
import { resolveCliproxyBridgeMetadata } from './cliproxy-profile-bridge';
const VALID_TARGETS: ReadonlySet<TargetType> = new Set<TargetType>(['claude', 'droid']); 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) * Check if API profile has real API key (not placeholder)
*/ */
export function isApiProfileConfigured(apiName: string): boolean { export function isApiProfileConfigured(apiName: string): boolean {
try { return isConfiguredFromSettings(loadProfileSettings(resolveProfileSettingsReference(apiName)));
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;
}
} }
/** /**
@@ -73,12 +95,15 @@ export function listApiProfiles(): ApiListResult {
if (name === 'default' && profile.settings?.includes('.claude/settings.json')) { if (name === 'default' && profile.settings?.includes('.claude/settings.json')) {
continue; continue;
} }
const settingsPath = profile.settings || 'config.yaml';
const settings = loadProfileSettings(settingsPath);
profiles.push({ profiles.push({
name, name,
settingsPath: profile.settings || 'config.yaml', settingsPath,
isConfigured: isApiProfileConfigured(name), isConfigured: isConfiguredFromSettings(settings),
configSource: 'unified', configSource: 'unified',
target: sanitizeTarget(profile.target), target: sanitizeTarget(profile.target),
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
}); });
} }
// CLIProxy variants // CLIProxy variants
@@ -103,12 +128,14 @@ export function listApiProfiles(): ApiListResult {
if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) { if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) {
continue; continue;
} }
const settings = loadProfileSettings(settingsPath as string);
profiles.push({ profiles.push({
name, name,
settingsPath: settingsPath as string, settingsPath: settingsPath as string,
isConfigured: isApiProfileConfigured(name), isConfigured: isConfiguredFromSettings(settings),
configSource: 'legacy', configSource: 'legacy',
target: sanitizeTarget(legacyTargetMap?.[name]), target: sanitizeTarget(legacyTargetMap?.[name]),
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
}); });
} }
// CLIProxy variants // CLIProxy variants
+39
View File
@@ -5,6 +5,7 @@
*/ */
import type { TargetType } from '../../targets/target-adapter'; import type { TargetType } from '../../targets/target-adapter';
import type { CLIProxyProvider } from '../../cliproxy/types';
/** Model mapping for API profiles */ /** Model mapping for API profiles */
export interface ModelMapping { export interface ModelMapping {
@@ -21,6 +22,7 @@ export interface ApiProfileInfo {
isConfigured: boolean; isConfigured: boolean;
configSource: 'unified' | 'legacy'; configSource: 'unified' | 'legacy';
target: TargetType; target: TargetType;
cliproxyBridge?: CliproxyBridgeMetadata | null;
} }
/** CLIProxy variant info */ /** CLIProxy variant info */
@@ -44,6 +46,43 @@ export interface CreateApiProfileResult {
error?: string; 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 */ /** Result from remove operation */
export interface RemoveApiProfileResult { export interface RemoveApiProfileResult {
success: boolean; success: boolean;
+65
View File
@@ -6,6 +6,7 @@ import * as fs from 'fs';
import * as path from 'path'; import * as path from 'path';
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager'; import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
import { expandPath } from '../../utils/helpers'; import { expandPath } from '../../utils/helpers';
import { validateApiName } from './validation-service';
import { import {
loadOrCreateUnifiedConfig, loadOrCreateUnifiedConfig,
saveUnifiedConfig, saveUnifiedConfig,
@@ -14,6 +15,7 @@ import {
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector'; import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
import type { TargetType } from '../../targets/target-adapter'; import type { TargetType } from '../../targets/target-adapter';
import { resolveDroidProvider } from '../../targets/droid-provider'; import { resolveDroidProvider } from '../../targets/droid-provider';
import { isReservedName } from '../../config/reserved-names';
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities'; import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
import { import {
extractProviderFromPathname, extractProviderFromPathname,
@@ -23,9 +25,15 @@ import type { CLIProxyProvider } from '../../cliproxy/types';
import type { import type {
ModelMapping, ModelMapping,
CreateApiProfileResult, CreateApiProfileResult,
CreateCliproxyBridgeProfileResult,
RemoveApiProfileResult, RemoveApiProfileResult,
UpdateApiProfileTargetResult, UpdateApiProfileTargetResult,
} from './profile-types'; } from './profile-types';
import { apiProfileExists } from './profile-reader';
import {
resolveCliproxyBridgeMetadata,
resolveCliproxyBridgeProfile,
} from './cliproxy-profile-bridge';
/** Check if URL is an OpenRouter endpoint */ /** Check if URL is an OpenRouter endpoint */
function isOpenRouterUrl(baseUrl: string): boolean { 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). * Update API profile target (claude/droid).
* Persists to config.yaml in unified mode and config.json profile_targets in legacy mode. * Persists to config.yaml in unified mode and config.json profile_targets in legacy mode.
+131
View File
@@ -1,6 +1,8 @@
import { import {
apiProfileExists, apiProfileExists,
createCliproxyBridgeProfile,
createApiProfile, createApiProfile,
getDefaultCliproxyBridgeName,
getPresetById, getPresetById,
getPresetIds, getPresetIds,
getUrlWarning, getUrlWarning,
@@ -8,11 +10,17 @@ import {
isUsingUnifiedConfig, isUsingUnifiedConfig,
pickOpenRouterModel, pickOpenRouterModel,
sanitizeBaseUrl, sanitizeBaseUrl,
suggestCliproxyBridgeName,
validateApiName, validateApiName,
validateUrl, validateUrl,
type ModelMapping, type ModelMapping,
type ProviderPreset, type ProviderPreset,
} from '../../api/services'; } from '../../api/services';
import {
CLIPROXY_PROVIDER_IDS,
getProviderDisplayName,
isCLIProxyProvider,
} from '../../cliproxy/provider-capabilities';
import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync'; import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync';
import type { TargetType } from '../../targets/target-adapter'; import type { TargetType } from '../../targets/target-adapter';
import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui'; import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui';
@@ -66,6 +74,34 @@ async function resolveProfileName(
return name; 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( async function resolveBaseUrl(
providedBaseUrl: string | undefined, providedBaseUrl: string | undefined,
preset: ProviderPreset | null preset: ProviderPreset | null
@@ -248,6 +284,101 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
console.log(header('Create API Profile')); console.log(header('Create API Profile'));
console.log(''); 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); showPresetDeprecationNotice(parsedArgs.preset);
const preset = resolvePresetOrExit(parsedArgs.preset); const preset = resolvePresetOrExit(parsedArgs.preset);
const name = await resolveProfileName(parsedArgs.name, preset); const name = await resolveProfileName(parsedArgs.name, preset);
+11
View File
@@ -1,5 +1,6 @@
import { import {
PROVIDER_PRESETS, PROVIDER_PRESETS,
listCliproxyBridgeProviders,
getPresetAliases, getPresetAliases,
getPresetIds, getPresetIds,
type ProviderPreset, type ProviderPreset,
@@ -20,6 +21,7 @@ export async function showApiCommandHelp(): Promise<void> {
const presetIds = getPresetIds() const presetIds = getPresetIds()
.map((id) => sanitizeHelpText(id)) .map((id) => sanitizeHelpText(id))
.filter(Boolean); .filter(Boolean);
const cliproxyProviderIds = listCliproxyBridgeProviders().map((provider) => provider.provider);
const presetAliases = getPresetAliases(); const presetAliases = getPresetAliases();
const presetIdWidth = Math.max(0, ...presetIds.map((id) => id.length)) + 2; const presetIdWidth = Math.max(0, ...presetIds.map((id) => id.length)) + 2;
@@ -47,6 +49,9 @@ export async function showApiCommandHelp(): Promise<void> {
console.log( console.log(
` ${color('--preset <id>', 'command')} Use provider preset (${presetIds.join(', ')})` ` ${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('--base-url <url>', 'command')} API base URL (create)`);
console.log(` ${color('--api-key <key>', 'command')} API key (create)`); console.log(` ${color('--api-key <key>', 'command')} API key (create)`);
console.log(` ${color('--model <model>', 'command')} Default model (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 alibaba', 'command')} ${dim('# alias')}`);
console.log(` ${color('ccs api create --preset glm', 'command')}`); console.log(` ${color('ccs api create --preset glm', 'command')}`);
console.log(''); 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(` ${dim('# Create with name')}`);
console.log(` ${color('ccs api create myapi', 'command')}`); console.log(` ${color('ccs api create myapi', 'command')}`);
console.log(` ${color('ccs api create mydroid --preset glm --target droid', '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; apiKey?: string;
model?: string; model?: string;
preset?: string; preset?: string;
cliproxyProvider?: string;
target?: TargetType; target?: TargetType;
force?: boolean; force?: boolean;
yes?: boolean; yes?: boolean;
@@ -21,6 +22,7 @@ export const API_VALUE_FLAGS = [
'--api-key', '--api-key',
'--model', '--model',
'--preset', '--preset',
'--cliproxy-provider',
'--target', '--target',
] as const; ] as const;
export const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS]; export const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS];
@@ -210,6 +212,18 @@ export function parseApiCommandArgs(
false false
); );
remaining = applyRepeatedOption(
remaining,
['--cliproxy-provider'],
(value) => {
result.cliproxyProvider = value.trim().toLowerCase();
},
() => {
result.errors.push('Missing value for --cliproxy-provider');
},
false
);
remaining = applyRepeatedOption( remaining = applyRepeatedOption(
remaining, remaining,
['--target'], ['--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)'], ['ccs ollama-cloud', 'Ollama Cloud (API key required)'],
['', ''], // Spacer ['', ''], // Spacer
['ccs api create --preset anthropic', 'Anthropic direct API key (sk-ant-...)'], ['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 create', 'Create custom API profile'],
['ccs api discover --register', 'Discover/register orphan settings files'], ['ccs api discover --register', 'Discover/register orphan settings files'],
['ccs api copy <src> <dest>', 'Duplicate API profile'], ['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 { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
import { import {
createApiProfile, createApiProfile,
createCliproxyBridgeProfile,
removeApiProfile, removeApiProfile,
updateApiProfileTarget, updateApiProfileTarget,
discoverApiProfileOrphans, discoverApiProfileOrphans,
@@ -17,10 +18,12 @@ import {
exportApiProfile, exportApiProfile,
importApiProfileBundle, importApiProfileBundle,
apiProfileExists, apiProfileExists,
listCliproxyBridgeProviders,
listApiProfiles, listApiProfiles,
validateApiName, validateApiName,
} from '../../api/services'; } from '../../api/services';
import { normalizeDroidProvider } from '../../targets/droid-provider'; import { normalizeDroidProvider } from '../../targets/droid-provider';
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
import { isAnthropicDirectProfile, updateSettingsFile, parseTarget } from './route-helpers'; import { isAnthropicDirectProfile, updateSettingsFile, parseTarget } from './route-helpers';
const router = Router(); const router = Router();
@@ -66,6 +69,7 @@ router.get('/', (_req: Request, res: Response): void => {
settingsPath: p.settingsPath, settingsPath: p.settingsPath,
configured: p.isConfigured, configured: p.isConfigured,
target: p.target, target: p.target,
cliproxyBridge: p.cliproxyBridge ?? null,
})); }));
res.json({ profiles }); res.json({ profiles });
} catch (error) { } 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 * POST /api/profiles - Create new profile
*/ */
@@ -160,6 +212,7 @@ router.post('/', (req: Request, res: Response): void => {
name, name,
settingsPath: result.settingsFile, settingsPath: result.settingsFile,
target: parsedTarget || 'claude', target: parsedTarget || 'claude',
cliproxyBridge: null,
}); });
}); });
+3
View File
@@ -19,6 +19,7 @@ import {
} from '../../cliproxy'; } from '../../cliproxy';
import { regenerateConfig } from '../../cliproxy/config-generator'; import { regenerateConfig } from '../../cliproxy/config-generator';
import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils'; import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
import { resolveCliproxyBridgeMetadata } from '../../api/services';
import { import {
getDashboardAuthConfig, getDashboardAuthConfig,
loadOrCreateUnifiedConfig, loadOrCreateUnifiedConfig,
@@ -327,6 +328,7 @@ router.get('/:profile', (req: Request, res: Response): void => {
settings: masked, settings: masked,
mtime: stat.mtime.getTime(), mtime: stat.mtime.getTime(),
path: settingsPath, path: settingsPath,
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
}); });
} catch (error) { } catch (error) {
respondInternalError(res, error, 'Internal server error.'); respondInternalError(res, error, 'Internal server error.');
@@ -354,6 +356,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => {
settings, settings,
mtime: stat.mtime.getTime(), mtime: stat.mtime.getTime(),
path: settingsPath, path: settingsPath,
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
}); });
} catch (error) { } catch (error) {
respondInternalError(res, error, 'Internal server 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([]); 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', () => { test('validates invalid --target values', () => {
const parsed = parseApiCommandArgs(['my-api', '--target', 'invalid-target']); 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 { api, withApiBase } from '@/lib/api-client';
import type { CliproxyServerConfig } from '@/lib/api-client'; import type { CliproxyServerConfig } from '@/lib/api-client';
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils'; import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
import { ApiProfileBridgeCallout } from './api-profile-bridge-callout';
interface AuthTokensResponse { interface AuthTokensResponse {
apiKey: { value: string; isCustom: boolean }; apiKey: { value: string; isCustom: boolean };
@@ -200,6 +201,9 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
Retry Retry
</button> </button>
</div> </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="flex-1 flex items-center justify-center bg-muted/20">
<div className="text-center max-w-md px-8"> <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"> <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 ( return (
<div className="flex-1 flex flex-col relative"> <div className="flex-1 flex flex-col">
{/* Remote indicator and login hint banner */} <div className="border-b bg-muted/20 p-4">
{showLoginHint && !isLoading && ( <ApiProfileBridgeCallout compact className="max-w-3xl mx-auto" />
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20"> </div>
<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"> <div className="flex-1 flex flex-col relative">
{isRemote && ( {/* Remote indicator and login hint banner */}
<> {showLoginHint && !isLoading && (
<Globe className="h-3.5 w-3.5 text-green-600" /> <div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
<span className="text-green-600 font-medium">Remote</span> <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">
<span className="text-blue-300 dark:text-blue-700">|</span> {isRemote && (
</> <>
)} <Globe className="h-3.5 w-3.5 text-green-600" />
<Key className="h-3.5 w-3.5 text-blue-600" /> <span className="text-green-600 font-medium">Remote</span>
<span> <span className="text-blue-300 dark:text-blue-700">|</span>
Key:{' '} </>
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold"> )}
{authToken && authToken.length > 4 <Key className="h-3.5 w-3.5 text-blue-600" />
? `***${authToken.slice(-4)}` <span>
: authToken || 'ccs'} Key:{' '}
</code> <code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
</span> {authToken && authToken.length > 4
<a ? `***${authToken.slice(-4)}`
href="/settings?tab=auth" : authToken || 'ccs'}
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400" </code>
title="Manage auth tokens" </span>
> <a
<Settings className="h-3.5 w-3.5" /> href="/settings?tab=auth"
</a> className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
<button title="Manage auth tokens"
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400" >
onClick={() => setShowLoginHint(false)} <Settings className="h-3.5 w-3.5" />
> </a>
<X className="h-3.5 w-3.5" /> <button
</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>
</div> )}
)}
{/* Loading overlay */} {/* Loading overlay */}
{isLoading && ( {isLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10"> <div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
<div className="text-center"> <div className="text-center">
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" /> <RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{isRemote {isRemote
? `Loading Control Panel from ${displayHost}...` ? `Loading Control Panel from ${displayHost}...`
: 'Loading Control Panel...'} : 'Loading Control Panel...'}
</p> </p>
</div>
</div> </div>
</div> )}
)}
{/* Iframe */} {/* Iframe */}
<iframe <iframe
key={`${managementUrl}:${iframeRevision}`} key={`${managementUrl}:${iframeRevision}`}
ref={iframeRef} ref={iframeRef}
src={managementUrl} src={managementUrl}
className="flex-1 w-full border-0" className="flex-1 w-full border-0"
title="CLIProxy Management Panel" title="CLIProxy Management Panel"
onLoad={handleIframeLoad} onLoad={handleIframeLoad}
/> />
</div>
</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 { ScrollArea } from '@/components/ui/scroll-area';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { CopyButton } from '@/components/ui/copy-button'; 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 { SettingsResponse } from './types';
import type { CliTarget } from '@/lib/api-client'; import type { CliTarget } from '@/lib/api-client';
@@ -62,6 +63,78 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
</span> </span>
<span className="font-mono">{target}</span> <span className="font-mono">{target}</span>
</div> </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> </div>
+2 -1
View File
@@ -2,7 +2,7 @@
* Types for Profile Editor * Types for Profile Editor
*/ */
import type { CliTarget } from '@/lib/api-client'; import type { CliTarget, CliproxyBridgeMetadata } from '@/lib/api-client';
export interface Settings { export interface Settings {
env?: Record<string, string>; env?: Record<string, string>;
@@ -13,6 +13,7 @@ export interface SettingsResponse {
settings: Settings; settings: Settings;
mtime: number; mtime: number;
path: string; path: string;
cliproxyBridge?: CliproxyBridgeMetadata | null;
} }
export interface ProfileEditorProps { export interface ProfileEditorProps {
@@ -8,18 +8,20 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import { useOpenRouterReady } from '@/hooks/use-openrouter-models'; 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'; import { useTranslation } from 'react-i18next';
interface OpenRouterQuickStartProps { interface OpenRouterQuickStartProps {
onOpenRouterClick: () => void; onOpenRouterClick: () => void;
onAlibabaCodingPlanClick: () => void; onAlibabaCodingPlanClick: () => void;
onCliproxyClick: () => void;
onCustomClick: () => void; onCustomClick: () => void;
} }
export function OpenRouterQuickStart({ export function OpenRouterQuickStart({
onOpenRouterClick, onOpenRouterClick,
onAlibabaCodingPlanClick, onAlibabaCodingPlanClick,
onCliproxyClick,
onCustomClick, onCustomClick,
}: OpenRouterQuickStartProps) { }: OpenRouterQuickStartProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -145,6 +147,53 @@ export function OpenRouterQuickStart({
</CardContent> </CardContent>
</Card> </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 */} {/* Divider */}
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<Separator className="flex-1" /> <Separator className="flex-1" />
@@ -29,7 +29,8 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { ProviderLogo } from '@/components/cliproxy/provider-logo'; 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 { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
import { import {
Loader2, Loader2,
@@ -41,6 +42,7 @@ import {
Settings2, Settings2,
Sparkles, Sparkles,
ChevronDown, ChevronDown,
Link2,
} from 'lucide-react'; } from 'lucide-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -61,7 +63,9 @@ import {
} from '@/lib/openrouter-utils'; } from '@/lib/openrouter-utils';
import type { CategorizedModel } from '@/lib/openrouter-types'; import type { CategorizedModel } from '@/lib/openrouter-types';
import type { CliTarget } from '@/lib/api-client'; import type { CliTarget } from '@/lib/api-client';
import { isValidProvider, type CLIProxyProvider } from '@/lib/provider-config';
import i18n from '@/lib/i18n'; import i18n from '@/lib/i18n';
import { CliproxyBridgeCreatePanel } from './cliproxy-bridge-create-panel';
const optionalUrlSchema = z const optionalUrlSchema = z
.string() .string()
@@ -90,7 +94,8 @@ interface ProfileCreateDialogProps {
open: boolean; open: boolean;
onOpenChange: (open: boolean) => void; onOpenChange: (open: boolean) => void;
onSuccess: (name: string) => 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 // Common URL mistakes to warn about
@@ -122,15 +127,25 @@ export function ProfileCreateDialog({
onOpenChange, onOpenChange,
onSuccess, onSuccess,
initialMode = 'openrouter', initialMode = 'openrouter',
initialCliproxyProvider = null,
}: ProfileCreateDialogProps) { }: ProfileCreateDialogProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const createMutation = useCreateProfile(); const createMutation = useCreateProfile();
const createCliproxyBridgeMutation = useCreateCliproxyBridgeProfile();
const { data: cliproxyAuthData } = useCliproxyAuth();
const [activeTab, setActiveTab] = useState('basic'); const [activeTab, setActiveTab] = useState('basic');
const [createKind, setCreateKind] = useState<'preset' | 'cliproxy'>(
initialMode === 'cliproxy' ? 'cliproxy' : 'preset'
);
const [urlWarning, setUrlWarning] = useState<string | null>(null); const [urlWarning, setUrlWarning] = useState<string | null>(null);
const [showApiKey, setShowApiKey] = useState(false); const [showApiKey, setShowApiKey] = useState(false);
const [selectedPreset, setSelectedPreset] = useState<PresetSelection>(DEFAULT_PRESET_ID); const [selectedPreset, setSelectedPreset] = useState<PresetSelection>(DEFAULT_PRESET_ID);
const [showMorePresets, setShowMorePresets] = useState(false); const [showMorePresets, setShowMorePresets] = useState(false);
const [modelSearch, setModelSearch] = useState(''); const [modelSearch, setModelSearch] = useState('');
const [selectedCliproxyProvider, setSelectedCliproxyProvider] = useState<CLIProxyProvider>(
initialCliproxyProvider ?? 'gemini'
);
const [autoGeneratedCliproxyName, setAutoGeneratedCliproxyName] = useState('gemini-api');
// OpenRouter models for model picker // OpenRouter models for model picker
const { models: openRouterModels } = useOpenRouterCatalog(); const { models: openRouterModels } = useOpenRouterCatalog();
@@ -140,6 +155,7 @@ export function ProfileCreateDialog({
handleSubmit, handleSubmit,
formState: { errors }, formState: { errors },
control, control,
getValues,
reset, reset,
setValue, setValue,
} = useForm<FormData>({ } = useForm<FormData>({
@@ -149,6 +165,21 @@ export function ProfileCreateDialog({
const baseUrlValue = useWatch({ control, name: 'baseUrl' }); const baseUrlValue = useWatch({ control, name: 'baseUrl' });
const targetValue = useWatch({ control, name: 'target' }); 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( const applyPresetToForm = useCallback(
(preset: ProviderPreset | null) => { (preset: ProviderPreset | null) => {
if (!preset) { if (!preset) {
@@ -190,11 +221,24 @@ export function ProfileCreateDialog({
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setActiveTab('basic'); setActiveTab('basic');
setCreateKind(initialMode === 'cliproxy' ? 'cliproxy' : 'preset');
setUrlWarning(null); setUrlWarning(null);
setShowApiKey(false); setShowApiKey(false);
setShowMorePresets(false); setShowMorePresets(false);
setModelSearch(''); 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 // Set initial preset based on initialMode
if (initialMode === 'normal') { if (initialMode === 'normal') {
setSelectedPreset(CUSTOM_PRESET_ID); setSelectedPreset(CUSTOM_PRESET_ID);
@@ -213,7 +257,20 @@ export function ProfileCreateDialog({
applyPresetToForm(null); 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 // Handle preset selection
const handlePresetSelect = (presetId: string) => { const handlePresetSelect = (presetId: string) => {
@@ -265,6 +322,22 @@ export function ProfileCreateDialog({
}, [baseUrlValue, selectedPreset]); }, [baseUrlValue, selectedPreset]);
const onSubmit = async (data: FormData) => { 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 // Validate API key - required unless preset has requiresApiKey: false
if (currentPreset?.requiresApiKey !== false && !data.apiKey) { if (currentPreset?.requiresApiKey !== false && !data.apiKey) {
toast.error(i18n.t('commonToast.apiKeyRequired')); toast.error(i18n.t('commonToast.apiKeyRequired'));
@@ -288,11 +361,41 @@ export function ProfileCreateDialog({
const hasBasicErrors = !!errors.name || !!errors.baseUrl || !!errors.apiKey; const hasBasicErrors = !!errors.name || !!errors.baseUrl || !!errors.apiKey;
const hasModelErrors = const hasModelErrors =
!!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel; !!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel;
const isCreating =
createKind === 'cliproxy' ? createCliproxyBridgeMutation.isPending : createMutation.isPending;
const isQuickTemplateSelected = const isQuickTemplateSelected =
selectedPreset !== CUSTOM_PRESET_ID && QUICK_TEMPLATE_PRESET_IDS.has(selectedPreset); selectedPreset !== CUSTOM_PRESET_ID && QUICK_TEMPLATE_PRESET_IDS.has(selectedPreset);
const isOpenRouter = currentPreset?.id === DEFAULT_PRESET_ID; const isOpenRouter = currentPreset?.id === DEFAULT_PRESET_ID;
const showQuickTemplates = showMorePresets || isQuickTemplateSelected; 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 ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
@@ -303,7 +406,9 @@ export function ProfileCreateDialog({
Create API Profile Create API Profile
</DialogTitle> </DialogTitle>
<DialogDescription> <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> </DialogDescription>
</DialogHeader> </DialogHeader>
@@ -311,108 +416,111 @@ export function ProfileCreateDialog({
onSubmit={handleSubmit(onSubmit)} onSubmit={handleSubmit(onSubmit)}
className="flex flex-col flex-1 min-h-0 overflow-hidden" className="flex flex-col flex-1 min-h-0 overflow-hidden"
> >
<div className="border-b bg-muted/10 px-6 py-3"> <div className="border-b bg-muted/10 px-6 py-3 space-y-3">
<div className="space-y-3"> <div className="grid grid-cols-2 gap-2">
<div className="flex items-start justify-between gap-4"> <Button
<div className="space-y-1"> type="button"
<Label className="text-xs text-muted-foreground"> variant={createKind === 'cliproxy' ? 'default' : 'outline'}
{t('profileEditor.provider')} onClick={() => handleCreateKindChange('cliproxy')}
</Label> >
<p className="text-xs text-muted-foreground"> Use CLIProxy Provider
{t('profileEditor.providerChooserHint')} </Button>
</p> <Button
</div> type="button"
<span className="pt-0.5 text-[11px] text-muted-foreground"> variant={createKind === 'preset' ? 'default' : 'outline'}
{t('profileEditor.scrollHint')} onClick={() => handleCreateKindChange('preset')}
</span> >
</div> Preset or Manual Setup
</Button>
</div>
<div className="space-y-2"> {createKind === 'preset' && (
<Label className="text-xs font-medium uppercase tracking-[0.12em] text-foreground/70"> <div className="space-y-3">
{t('profileEditor.featuredProviders')} <div className="flex items-start justify-between gap-4">
</Label> <div className="space-y-1">
<div className="-mx-1 overflow-x-auto pb-1"> <Label className="text-xs text-muted-foreground">
<div className="flex min-w-max items-stretch gap-2 px-1"> {t('profileEditor.provider')}
{RECOMMENDED_PRESETS.map((preset) => ( </Label>
<CompactPresetCard <p className="text-xs text-muted-foreground">
key={preset.id} {t('profileEditor.providerChooserHint')}
preset={preset} </p>
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)}
/>
</div> </div>
<span className="pt-0.5 text-[11px] text-muted-foreground">
{t('profileEditor.scrollHint')}
</span>
</div> </div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Button <Label className="text-xs font-medium uppercase tracking-[0.12em] text-foreground/70">
type="button" {t('profileEditor.featuredProviders')}
variant="ghost" </Label>
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="-mx-1 overflow-x-auto pb-1">
<div className="flex min-w-max items-stretch gap-2 px-1"> <div className="flex min-w-max items-stretch gap-2 px-1">
{QUICK_TEMPLATE_PRESETS.map((preset) => ( {RECOMMENDED_PRESETS.map((preset) => (
<CompactPresetCard <CompactPresetCard
key={preset.id} key={preset.id}
preset={preset} preset={preset}
isSelected={selectedPreset === preset.id} isSelected={selectedPreset === preset.id}
onClick={() => handlePresetSelect(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>
)} </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> )}
</div> </div>
<Tabs {createKind === 'cliproxy' ? (
value={activeTab} <>
onValueChange={setActiveTab} <div className="flex-1 min-h-0 overflow-y-auto p-6 space-y-5">
className="flex flex-col flex-1 min-h-0 overflow-hidden" <CliproxyBridgeCreatePanel
> selectedProvider={selectedCliproxyProvider}
<div className="px-6 pt-4"> authStatuses={cliproxyAuthData?.authStatus}
<TabsList className="grid w-full grid-cols-2"> onProviderSelect={setSelectedCliproxyProvider}
<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>
<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"> <div className="space-y-1.5">
<Label htmlFor="name"> <Label htmlFor="name">
Profile Name <span className="text-destructive">*</span> Profile Name <span className="text-destructive">*</span>
@@ -433,81 +541,6 @@ export function ProfileCreateDialog({
)} )}
</div> </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"> <div className="space-y-1.5">
<Label htmlFor="target">Default Target CLI</Label> <Label htmlFor="target">Default Target CLI</Label>
<Select <Select
@@ -523,162 +556,336 @@ export function ProfileCreateDialog({
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-muted-foreground"> <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]"> <code className="bg-muted px-1 rounded text-[10px]">
{targetValue === 'droid' ? 'ccsd' : 'ccs'} {targetValue === 'droid' ? 'ccsd' : 'ccs'}
</code>{' '} </code>
by default. You can still override each run with{' '} . You can override per command with{' '}
<code className="bg-muted px-1 rounded text-[10px]">--target</code>. <code className="bg-muted px-1 rounded text-[10px]">--target</code>.
</p> </p>
</div> </div>
</TabsContent> </div>
<TabsContent value="models" className="p-6 mt-0 space-y-4"> <DialogFooter className="p-6 pt-4 border-t bg-muted/10">
<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"> <Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
<Info className="w-5 h-5 shrink-0 mt-0.5" /> Cancel
<div> </Button>
<p className="font-medium mb-1">Model Mapping</p> <Button
<p className="text-xs opacity-90"> type="submit"
Map Claude Code tiers (Opus/Sonnet/Haiku) to models supported by your disabled={isCreating}
provider. className={cn(isCreating && 'opacity-80')}
</p> >
</div> {isCreating ? (
</div> <>
<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 */} <div className="flex-1 min-h-0 overflow-y-auto">
{isOpenRouter && ( <TabsContent value="basic" className="p-6 space-y-4 mt-0">
<div className="space-y-2"> {/* Profile Name */}
<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"> <div className="space-y-1.5">
<Label htmlFor="model"> <Label htmlFor="name">
Default Model Profile Name <span className="text-destructive">*</span>
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
ANTHROPIC_MODEL
</Badge>
</Label> </Label>
<Input <Input
id="model" id="name"
{...register('model')} {...register('name')}
placeholder={currentPreset?.defaultModel ?? 'claude-sonnet-4'} placeholder="my-api"
className="font-mono text-sm" 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>
<div className="grid gap-3 pt-2 border-t"> {/* Base URL - always editable, pre-filled from preset */}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="sonnetModel" className="text-sm"> <Label htmlFor="baseUrl">API Base URL</Label>
Sonnet Mapping <Input
<Badge variant="outline" className="ml-2 text-[10px] font-mono"> id="baseUrl"
DEFAULT_SONNET {...register('baseUrl')}
</Badge> placeholder="https://api.example.com/v1"
</Label> />
<Input {errors.baseUrl ? (
id="sonnetModel" <p className="text-xs text-destructive">{errors.baseUrl.message}</p>
{...register('sonnetModel')} ) : urlWarning ? (
placeholder="e.g. gpt-4o, claude-sonnet-4" <div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
className="font-mono text-sm h-9" <AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
/> <span>{urlWarning}</span>
</div> </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"> {/* API Key - optional for presets that don't require it */}
<Label htmlFor="opusModel" className="text-sm"> <div className="space-y-1.5">
Opus Mapping <Label htmlFor="apiKey">
<Badge variant="outline" className="ml-2 text-[10px] font-mono"> API Key{' '}
DEFAULT_OPUS {currentPreset?.requiresApiKey !== false && (
</Badge> <span className="text-destructive">*</span>
</Label> )}
{currentPreset?.requiresApiKey === false && (
<span className="text-muted-foreground text-xs font-normal">
(optional)
</span>
)}
</Label>
<div className="relative">
<Input <Input
id="opusModel" id="apiKey"
{...register('opusModel')} type={showApiKey ? 'text' : 'password'}
placeholder="e.g. o1, claude-opus-4.5" {...register('apiKey')}
className="font-mono text-sm h-9" 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> </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"> <div className="space-y-1.5">
<Label htmlFor="haikuModel" className="text-sm"> <Label htmlFor="target">Default Target CLI</Label>
Haiku Mapping <Select
<Badge variant="outline" className="ml-2 text-[10px] font-mono"> value={targetValue}
DEFAULT_HAIKU onValueChange={(value) => setValue('target', value as CliTarget)}
</Badge> >
</Label> <SelectTrigger id="target">
<Input <SelectValue />
id="haikuModel" </SelectTrigger>
{...register('haikuModel')} <SelectContent>
placeholder="e.g. gpt-4o-mini, claude-3.5-haiku" <SelectItem value="claude">Claude Code (default)</SelectItem>
className="font-mono text-sm h-9" <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> </div>
</div>
</TabsContent>
</div>
<DialogFooter className="p-6 pt-4 border-t bg-muted/10"> {/* OpenRouter Model Picker */}
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}> {isOpenRouter && (
Cancel <div className="space-y-2">
</Button> <Label>Search Models</Label>
<Button <Input
type="submit" value={modelSearch}
disabled={createMutation.isPending} onChange={(e) => setModelSearch(e.target.value)}
className={cn(createMutation.isPending && 'opacity-80')} placeholder="Type to search (e.g., opus, sonnet, gpt-4o)..."
> onKeyDown={(e) => {
{createMutation.isPending ? ( if (e.key === 'Enter' && filteredModels.length > 0) {
<> e.preventDefault();
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> handleModelSelect(filteredModels[0]);
Creating... }
</> }}
) : ( />
<> <div className="border rounded-md max-h-48 overflow-y-auto">
<Plus className="w-4 h-4 mr-2" /> {filteredModels.length === 0 ? (
Create Profile <p className="text-sm text-muted-foreground p-3 text-center">
</> {modelSearch
)} ? `No models found for "${modelSearch}"`
</Button> : 'Loading models...'}
</DialogFooter> </p>
</Tabs> ) : (
<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> </form>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+17
View File
@@ -7,6 +7,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { import {
api, api,
type CreateProfile, type CreateProfile,
type CreateCliproxyBridgeProfileRequest,
type UpdateProfile, type UpdateProfile,
type RegisterProfileOrphansRequest, type RegisterProfileOrphansRequest,
type CopyProfileRequest, 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() { export function useUpdateProfile() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
+29
View File
@@ -96,11 +96,22 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
// Types // Types
export type CliTarget = 'claude' | 'droid'; 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 { export interface Profile {
name: string; name: string;
settingsPath: string; settingsPath: string;
configured: boolean; configured: boolean;
target?: CliTarget; target?: CliTarget;
cliproxyBridge?: CliproxyBridgeMetadata | null;
} }
export interface CreateProfile { export interface CreateProfile {
@@ -124,6 +135,19 @@ export interface UpdateProfile {
target?: CliTarget; 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 { export interface ProfileValidationIssue {
level: 'error' | 'warning'; level: 'error' | 'warning';
code: string; code: string;
@@ -762,6 +786,11 @@ export const api = {
method: 'POST', method: 'POST',
body: JSON.stringify(data), body: JSON.stringify(data),
}), }),
createCliproxyBridge: (data: CreateCliproxyBridgeProfileRequest) =>
request<CreateCliproxyBridgeProfileResponse>('/profiles/cliproxy-bridge', {
method: 'POST',
body: JSON.stringify(data),
}),
update: (name: string, data: UpdateProfile) => update: (name: string, data: UpdateProfile) =>
request(`/profiles/${name}`, { request(`/profiles/${name}`, {
method: 'PUT', method: 'PUT',
+72 -6
View File
@@ -38,9 +38,14 @@ import { cn } from '@/lib/utils';
import { CopyButton } from '@/components/ui/copy-button'; import { CopyButton } from '@/components/ui/copy-button';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; 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() { export function ApiPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const location = useLocation();
const navigate = useNavigate();
const { data, isLoading, isError, refetch } = useProfiles(); const { data, isLoading, isError, refetch } = useProfiles();
const deleteMutation = useDeleteProfile(); const deleteMutation = useDeleteProfile();
const discoverOrphansMutation = useDiscoverProfileOrphans(); const discoverOrphansMutation = useDiscoverProfileOrphans();
@@ -51,9 +56,11 @@ export function ApiPage() {
const [selectedProfile, setSelectedProfile] = useState<string | null>(null); const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false); const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
const [createMode, setCreateMode] = useState<'normal' | 'openrouter' | 'alibaba-coding-plan'>( const [createMode, setCreateMode] = useState<
'normal' 'normal' | 'openrouter' | 'alibaba-coding-plan' | 'cliproxy'
); >('normal');
const [createDialogCliproxyProvider, setCreateDialogCliproxyProvider] =
useState<CLIProxyProvider | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null); const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
const [editorHasChanges, setEditorHasChanges] = useState(false); const [editorHasChanges, setEditorHasChanges] = useState(false);
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null); const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
@@ -68,6 +75,34 @@ export function ApiPage() {
const selectedProfileData = selectedProfile const selectedProfileData = selectedProfile
? profiles.find((p) => p.name === selectedProfile) ? profiles.find((p) => p.name === selectedProfile)
: null; : 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) => { const switchToProfile = (name: string) => {
if (editorHasChanges && selectedProfile !== name) { if (editorHasChanges && selectedProfile !== name) {
@@ -91,6 +126,7 @@ export function ApiPage() {
}; };
const handleCreateSuccess = (name: string) => { const handleCreateSuccess = (name: string) => {
clearCliproxyBridgeIntent();
setCreateDialogOpen(false); setCreateDialogOpen(false);
switchToProfile(name); switchToProfile(name);
}; };
@@ -98,6 +134,13 @@ export function ApiPage() {
switchToProfile(name); switchToProfile(name);
}; };
const handleCreateDialogOpenChange = (open: boolean) => {
if (!open) {
clearCliproxyBridgeIntent();
}
setCreateDialogOpen(open);
};
const triggerDownload = (filename: string, bundle: ApiProfileExportBundle) => { const triggerDownload = (filename: string, bundle: ApiProfileExportBundle) => {
const content = JSON.stringify(bundle, null, 2) + '\n'; const content = JSON.stringify(bundle, null, 2) + '\n';
const blob = new Blob([content], { type: 'application/json' }); const blob = new Blob([content], { type: 'application/json' });
@@ -292,6 +335,18 @@ export function ApiPage() {
{t('apiProfiles.noProfilesDesc')} {t('apiProfiles.noProfilesDesc')}
</p> </p>
</div> </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 <Button
size="sm" size="sm"
variant="outline" variant="outline"
@@ -385,6 +440,11 @@ export function ApiPage() {
</> </>
) : ( ) : (
<OpenRouterQuickStart <OpenRouterQuickStart
onCliproxyClick={() => {
setCreateMode('cliproxy');
setCreateDialogCliproxyProvider(null);
setCreateDialogOpen(true);
}}
onOpenRouterClick={() => { onOpenRouterClick={() => {
setCreateMode('openrouter'); setCreateMode('openrouter');
setCreateDialogOpen(true); setCreateDialogOpen(true);
@@ -411,10 +471,11 @@ export function ApiPage() {
/> />
<ProfileCreateDialog <ProfileCreateDialog
open={isCreateDialogOpen} open={isResolvedCreateDialogOpen}
onOpenChange={setCreateDialogOpen} onOpenChange={handleCreateDialogOpenChange}
onSuccess={handleCreateSuccess} onSuccess={handleCreateSuccess}
initialMode={createMode} initialMode={resolvedCreateMode}
initialCliproxyProvider={resolvedCreateDialogProvider}
/> />
<ConfirmDialog <ConfirmDialog
@@ -480,6 +541,11 @@ function ProfileListItem({
<Badge variant="outline" className="text-[10px] h-4 px-1.5 uppercase"> <Badge variant="outline" className="text-[10px] h-4 px-1.5 uppercase">
{profile.target || 'claude'} {profile.target || 'claude'}
</Badge> </Badge>
{profile.cliproxyBridge && (
<Badge variant="secondary" className="text-[10px] h-4 px-1.5">
CLIProxy {profile.cliproxyBridge.provider}
</Badge>
)}
</div> </div>
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex items-center gap-1.5 min-w-0">
<div className="text-xs text-muted-foreground truncate flex-1"> <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 { AddAccountDialog } from '@/components/account/add-account-dialog';
import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card'; import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card';
import { ProviderEditor } from '@/components/cliproxy/provider-editor'; import { ProviderEditor } from '@/components/cliproxy/provider-editor';
import { ApiProfileBridgeCallout } from '@/components/cliproxy/api-profile-bridge-callout';
import { ProviderLogo } from '@/components/cliproxy/provider-logo'; import { ProviderLogo } from '@/components/cliproxy/provider-logo';
import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget'; import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
import { import {
@@ -436,93 +437,113 @@ export function CliproxyPage() {
)} )}
{selectedVariantData && parentAuthForVariant ? ( {selectedVariantData && parentAuthForVariant ? (
// Variant selected - show ProviderEditor with variant profile name <>
<ProviderEditor <ApiProfileBridgeCallout
provider={selectedVariantData.name} provider={selectedVariantData.provider}
displayName={t('cliproxyPage.variantDisplay', { compact
name: selectedVariantData.name, className="mx-4 mt-4"
provider: selectedVariantData.provider, />
})} <ProviderEditor
authStatus={parentAuthForVariant} provider={selectedVariantData.name}
catalog={MODEL_CATALOGS[selectedVariantData.provider]} displayName={t('cliproxyPage.variantDisplay', {
logoProvider={selectedVariantData.provider} name: selectedVariantData.name,
baseProvider={selectedVariantData.provider}
defaultTarget={selectedVariantData.target}
isRemoteMode={isRemoteMode}
port={selectedVariantData.port}
onAddAccount={() =>
setAddAccountProvider({
provider: selectedVariantData.provider, provider: selectedVariantData.provider,
displayName: parentAuthForVariant.displayName, })}
isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0, authStatus={parentAuthForVariant}
}) catalog={MODEL_CATALOGS[selectedVariantData.provider]}
} logoProvider={selectedVariantData.provider}
onSetDefault={(accountId) => baseProvider={selectedVariantData.provider}
setDefaultMutation.mutate({ defaultTarget={selectedVariantData.target}
provider: selectedVariantData.provider, isRemoteMode={isRemoteMode}
accountId, port={selectedVariantData.port}
}) onAddAccount={() =>
} setAddAccountProvider({
onRemoveAccount={(accountId) => provider: selectedVariantData.provider,
removeMutation.mutate({ displayName: parentAuthForVariant.displayName,
provider: selectedVariantData.provider, isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0,
accountId, })
}) }
} onSetDefault={(accountId) =>
onPauseToggle={(accountId, paused) => setDefaultMutation.mutate({
handlePauseToggle(selectedVariantData.provider, accountId, paused) provider: selectedVariantData.provider,
} accountId,
onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)} })
onBulkPause={(accountIds) => handleBulkPause(selectedVariantData.provider, accountIds)} }
onBulkResume={(accountIds) => onRemoveAccount={(accountId) =>
handleBulkResume(selectedVariantData.provider, accountIds) removeMutation.mutate({
} provider: selectedVariantData.provider,
isRemovingAccount={removeMutation.isPending} accountId,
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending} })
isSoloingAccount={soloMutation.isPending} }
isBulkPausing={bulkPauseMutation.isPending} onPauseToggle={(accountId, paused) =>
isBulkResuming={bulkResumeMutation.isPending} 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 ? ( ) : selectedStatus ? (
<ProviderEditor <>
provider={selectedStatus.provider} <ApiProfileBridgeCallout
displayName={selectedStatus.displayName} provider={
authStatus={selectedStatus} isValidProvider(selectedStatus.provider) ? selectedStatus.provider : undefined
catalog={MODEL_CATALOGS[selectedStatus.provider]} }
isRemoteMode={isRemoteMode} compact
onAddAccount={() => className="mx-4 mt-4"
setAddAccountProvider({ />
provider: selectedStatus.provider, <ProviderEditor
displayName: selectedStatus.displayName, provider={selectedStatus.provider}
isFirstAccount: (selectedStatus.accounts?.length || 0) === 0, displayName={selectedStatus.displayName}
}) authStatus={selectedStatus}
} catalog={MODEL_CATALOGS[selectedStatus.provider]}
onSetDefault={(accountId) => isRemoteMode={isRemoteMode}
setDefaultMutation.mutate({ onAddAccount={() =>
provider: selectedStatus.provider, setAddAccountProvider({
accountId, provider: selectedStatus.provider,
}) displayName: selectedStatus.displayName,
} isFirstAccount: (selectedStatus.accounts?.length || 0) === 0,
onRemoveAccount={(accountId) => })
removeMutation.mutate({ }
provider: selectedStatus.provider, onSetDefault={(accountId) =>
accountId, setDefaultMutation.mutate({
}) provider: selectedStatus.provider,
} accountId,
onPauseToggle={(accountId, paused) => })
handlePauseToggle(selectedStatus.provider, accountId, paused) }
} onRemoveAccount={(accountId) =>
onSoloMode={(accountId) => handleSoloMode(selectedStatus.provider, accountId)} removeMutation.mutate({
onBulkPause={(accountIds) => handleBulkPause(selectedStatus.provider, accountIds)} provider: selectedStatus.provider,
onBulkResume={(accountIds) => handleBulkResume(selectedStatus.provider, accountIds)} accountId,
isRemovingAccount={removeMutation.isPending} })
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending} }
isSoloingAccount={soloMutation.isPending} onPauseToggle={(accountId, paused) =>
isBulkPausing={bulkPauseMutation.isPending} handlePauseToggle(selectedStatus.provider, accountId, paused)
isBulkResuming={bulkResumeMutation.isPending} }
/> 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> </div>
@@ -10,6 +10,10 @@ vi.mock('@/hooks/use-profiles', () => ({
mutateAsync, mutateAsync,
isPending: false, isPending: false,
}), }),
useCreateCliproxyBridgeProfile: () => ({
mutateAsync,
isPending: false,
}),
})); }));
vi.mock('@/hooks/use-openrouter-models', () => ({ 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', () => { describe('ProfileCreateDialog', () => {
beforeEach(() => { beforeEach(() => {
mutateAsync.mockReset(); mutateAsync.mockReset();