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
+1378 -39

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,6 +223,10 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
} }
return ( return (
<div className="flex-1 flex flex-col">
<div className="border-b bg-muted/20 p-4">
<ApiProfileBridgeCallout compact className="max-w-3xl mx-auto" />
</div>
<div className="flex-1 flex flex-col relative"> <div className="flex-1 flex flex-col relative">
{/* Remote indicator and login hint banner */} {/* Remote indicator and login hint banner */}
{showLoginHint && !isLoading && ( {showLoginHint && !isLoading && (
@@ -281,5 +289,6 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
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,7 +416,25 @@ 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="grid grid-cols-2 gap-2">
<Button
type="button"
variant={createKind === 'cliproxy' ? 'default' : 'outline'}
onClick={() => handleCreateKindChange('cliproxy')}
>
Use CLIProxy Provider
</Button>
<Button
type="button"
variant={createKind === 'preset' ? 'default' : 'outline'}
onClick={() => handleCreateKindChange('preset')}
>
Preset or Manual Setup
</Button>
</div>
{createKind === 'preset' && (
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
@@ -386,8 +509,87 @@ export function ProfileCreateDialog({
)} )}
</div> </div>
</div> </div>
)}
</div> </div>
{createKind === 'cliproxy' ? (
<>
<div className="flex-1 min-h-0 overflow-y-auto p-6 space-y-5">
<CliproxyBridgeCreatePanel
selectedProvider={selectedCliproxyProvider}
authStatuses={cliproxyAuthData?.authStatus}
onProviderSelect={setSelectedCliproxyProvider}
/>
<div className="space-y-1.5">
<Label htmlFor="name">
Profile Name <span className="text-destructive">*</span>
</Label>
<Input
id="name"
{...register('name')}
placeholder="my-api"
className="font-mono"
/>
{errors.name ? (
<p className="text-xs text-destructive">{errors.name.message}</p>
) : (
<p className="text-xs text-muted-foreground">
Used in CLI:{' '}
<code className="bg-muted px-1 rounded text-[10px]">ccs my-api "prompt"</code>
</p>
)}
</div>
<div className="space-y-1.5">
<Label htmlFor="target">Default Target CLI</Label>
<Select
value={targetValue}
onValueChange={(value) => setValue('target', value as CliTarget)}
>
<SelectTrigger id="target">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="claude">Claude Code (default)</SelectItem>
<SelectItem value="droid">Factory Droid</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
The routed profile still runs like a normal API Profile via{' '}
<code className="bg-muted px-1 rounded text-[10px]">
{targetValue === 'droid' ? 'ccsd' : 'ccs'}
</code>
. You can override per command with{' '}
<code className="bg-muted px-1 rounded text-[10px]">--target</code>.
</p>
</div>
</div>
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button
type="submit"
disabled={isCreating}
className={cn(isCreating && 'opacity-80')}
>
{isCreating ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating...
</>
) : (
<>
<Link2 className="w-4 h-4 mr-2" />
Create Routed Profile
</>
)}
</Button>
</DialogFooter>
</>
) : (
<Tabs <Tabs
value={activeTab} value={activeTab}
onValueChange={setActiveTab} onValueChange={setActiveTab}
@@ -428,7 +630,9 @@ export function ProfileCreateDialog({
) : ( ) : (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Used in CLI:{' '} Used in CLI:{' '}
<code className="bg-muted px-1 rounded text-[10px]">ccs my-api "prompt"</code> <code className="bg-muted px-1 rounded text-[10px]">
ccs my-api "prompt"
</code>
</p> </p>
)} )}
</div> </div>
@@ -469,7 +673,9 @@ export function ProfileCreateDialog({
<span className="text-destructive">*</span> <span className="text-destructive">*</span>
)} )}
{currentPreset?.requiresApiKey === false && ( {currentPreset?.requiresApiKey === false && (
<span className="text-muted-foreground text-xs font-normal">(optional)</span> <span className="text-muted-foreground text-xs font-normal">
(optional)
</span>
)} )}
</Label> </Label>
<div className="relative"> <div className="relative">
@@ -662,10 +868,10 @@ export function ProfileCreateDialog({
</Button> </Button>
<Button <Button
type="submit" type="submit"
disabled={createMutation.isPending} disabled={isCreating}
className={cn(createMutation.isPending && 'opacity-80')} className={cn(isCreating && 'opacity-80')}
> >
{createMutation.isPending ? ( {isCreating ? (
<> <>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <Loader2 className="w-4 h-4 mr-2 animate-spin" />
Creating... Creating...
@@ -679,6 +885,7 @@ export function ProfileCreateDialog({
</Button> </Button>
</DialogFooter> </DialogFooter>
</Tabs> </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">
+23 -2
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,7 +437,12 @@ export function CliproxyPage() {
)} )}
{selectedVariantData && parentAuthForVariant ? ( {selectedVariantData && parentAuthForVariant ? (
// Variant selected - show ProviderEditor with variant profile name <>
<ApiProfileBridgeCallout
provider={selectedVariantData.provider}
compact
className="mx-4 mt-4"
/>
<ProviderEditor <ProviderEditor
provider={selectedVariantData.name} provider={selectedVariantData.name}
displayName={t('cliproxyPage.variantDisplay', { displayName={t('cliproxyPage.variantDisplay', {
@@ -473,7 +479,9 @@ export function CliproxyPage() {
handlePauseToggle(selectedVariantData.provider, accountId, paused) handlePauseToggle(selectedVariantData.provider, accountId, paused)
} }
onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)} onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)}
onBulkPause={(accountIds) => handleBulkPause(selectedVariantData.provider, accountIds)} onBulkPause={(accountIds) =>
handleBulkPause(selectedVariantData.provider, accountIds)
}
onBulkResume={(accountIds) => onBulkResume={(accountIds) =>
handleBulkResume(selectedVariantData.provider, accountIds) handleBulkResume(selectedVariantData.provider, accountIds)
} }
@@ -483,7 +491,16 @@ export function CliproxyPage() {
isBulkPausing={bulkPauseMutation.isPending} isBulkPausing={bulkPauseMutation.isPending}
isBulkResuming={bulkResumeMutation.isPending} isBulkResuming={bulkResumeMutation.isPending}
/> />
</>
) : selectedStatus ? ( ) : selectedStatus ? (
<>
<ApiProfileBridgeCallout
provider={
isValidProvider(selectedStatus.provider) ? selectedStatus.provider : undefined
}
compact
className="mx-4 mt-4"
/>
<ProviderEditor <ProviderEditor
provider={selectedStatus.provider} provider={selectedStatus.provider}
displayName={selectedStatus.displayName} displayName={selectedStatus.displayName}
@@ -521,8 +538,12 @@ export function CliproxyPage() {
isBulkPausing={bulkPauseMutation.isPending} isBulkPausing={bulkPauseMutation.isPending}
isBulkResuming={bulkResumeMutation.isPending} isBulkResuming={bulkResumeMutation.isPending}
/> />
</>
) : ( ) : (
<div className="flex flex-1 flex-col min-h-0">
<ApiProfileBridgeCallout compact className="mx-4 mt-4" />
<EmptyProviderState onSetup={() => setWizardOpen(true)} /> <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();