mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 10:16:49 +00:00
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:
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -14,8 +14,12 @@ export {
|
||||
type CliproxyVariantInfo,
|
||||
type ApiListResult,
|
||||
type CreateApiProfileResult,
|
||||
type CreateCliproxyBridgeProfileResult,
|
||||
type RemoveApiProfileResult,
|
||||
type UpdateApiProfileTargetResult,
|
||||
type CliproxyBridgeProviderInfo,
|
||||
type CliproxyBridgeMetadata,
|
||||
type ResolvedCliproxyBridgeProfile,
|
||||
type ProfileValidationIssue,
|
||||
type ProfileValidationSummary,
|
||||
type ApiProfileOrphanCandidate,
|
||||
@@ -38,6 +42,14 @@ export {
|
||||
|
||||
// Profile write operations
|
||||
export { createApiProfile, removeApiProfile, updateApiProfileTarget } from './profile-writer';
|
||||
export { createCliproxyBridgeProfile } from './profile-writer';
|
||||
export {
|
||||
getDefaultCliproxyBridgeName,
|
||||
listCliproxyBridgeProviders,
|
||||
resolveCliproxyBridgeMetadata,
|
||||
resolveCliproxyBridgeProfile,
|
||||
suggestCliproxyBridgeName,
|
||||
} from './cliproxy-profile-bridge';
|
||||
|
||||
// Lifecycle validation and operations
|
||||
export { validateApiProfileSettingsPayload } from './profile-lifecycle-validation';
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir, loadConfigSafe } from '../../utils/config-manager';
|
||||
import { loadConfigSafe } from '../../utils/config-manager';
|
||||
import { loadOrCreateUnifiedConfig, isUnifiedMode } from '../../config/unified-config-loader';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import type { Settings } from '../../types/config';
|
||||
import type { ApiProfileInfo, CliproxyVariantInfo, ApiListResult } from './profile-types';
|
||||
import { resolveCliproxyBridgeMetadata } from './cliproxy-profile-bridge';
|
||||
|
||||
const VALID_TARGETS: ReadonlySet<TargetType> = new Set<TargetType>(['claude', 'droid']);
|
||||
|
||||
@@ -37,23 +39,43 @@ export function apiProfileExists(name: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load settings file from a config reference such as ~/.ccs/name.settings.json.
|
||||
*/
|
||||
function loadProfileSettings(settingsReference: string | undefined): Settings | null {
|
||||
if (!settingsReference || settingsReference === 'config.yaml') {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const settingsPath = expandPath(settingsReference);
|
||||
if (!fs.existsSync(settingsPath)) return null;
|
||||
return JSON.parse(fs.readFileSync(settingsPath, 'utf8')) as Settings;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function isConfiguredFromSettings(settings: Settings | null): boolean {
|
||||
const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || settings?.env?.ANTHROPIC_API_KEY || '';
|
||||
return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
|
||||
}
|
||||
|
||||
function resolveProfileSettingsReference(name: string): string | undefined {
|
||||
if (isUnifiedMode()) {
|
||||
const config = loadOrCreateUnifiedConfig();
|
||||
return config.profiles[name]?.settings;
|
||||
}
|
||||
|
||||
const config = loadConfigSafe();
|
||||
return config.profiles[name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if API profile has real API key (not placeholder)
|
||||
*/
|
||||
export function isApiProfileConfigured(apiName: string): boolean {
|
||||
try {
|
||||
const ccsDir = getCcsDir();
|
||||
const settingsPath = path.join(ccsDir, `${apiName}.settings.json`);
|
||||
|
||||
// Check settings.json file for API key
|
||||
if (!fs.existsSync(settingsPath)) return false;
|
||||
|
||||
const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
|
||||
const token = settings?.env?.ANTHROPIC_AUTH_TOKEN || settings?.env?.ANTHROPIC_API_KEY || '';
|
||||
return token.length > 0 && !token.includes('YOUR_') && !token.includes('your-');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
return isConfiguredFromSettings(loadProfileSettings(resolveProfileSettingsReference(apiName)));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,12 +95,15 @@ export function listApiProfiles(): ApiListResult {
|
||||
if (name === 'default' && profile.settings?.includes('.claude/settings.json')) {
|
||||
continue;
|
||||
}
|
||||
const settingsPath = profile.settings || 'config.yaml';
|
||||
const settings = loadProfileSettings(settingsPath);
|
||||
profiles.push({
|
||||
name,
|
||||
settingsPath: profile.settings || 'config.yaml',
|
||||
isConfigured: isApiProfileConfigured(name),
|
||||
settingsPath,
|
||||
isConfigured: isConfiguredFromSettings(settings),
|
||||
configSource: 'unified',
|
||||
target: sanitizeTarget(profile.target),
|
||||
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
|
||||
});
|
||||
}
|
||||
// CLIProxy variants
|
||||
@@ -103,12 +128,14 @@ export function listApiProfiles(): ApiListResult {
|
||||
if (name === 'default' && (settingsPath as string).includes('.claude/settings.json')) {
|
||||
continue;
|
||||
}
|
||||
const settings = loadProfileSettings(settingsPath as string);
|
||||
profiles.push({
|
||||
name,
|
||||
settingsPath: settingsPath as string,
|
||||
isConfigured: isApiProfileConfigured(name),
|
||||
isConfigured: isConfiguredFromSettings(settings),
|
||||
configSource: 'legacy',
|
||||
target: sanitizeTarget(legacyTargetMap?.[name]),
|
||||
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
|
||||
});
|
||||
}
|
||||
// CLIProxy variants
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
|
||||
/** Model mapping for API profiles */
|
||||
export interface ModelMapping {
|
||||
@@ -21,6 +22,7 @@ export interface ApiProfileInfo {
|
||||
isConfigured: boolean;
|
||||
configSource: 'unified' | 'legacy';
|
||||
target: TargetType;
|
||||
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
||||
}
|
||||
|
||||
/** CLIProxy variant info */
|
||||
@@ -44,6 +46,43 @@ export interface CreateApiProfileResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface CliproxyBridgeProviderInfo {
|
||||
provider: CLIProxyProvider;
|
||||
displayName: string;
|
||||
description: string;
|
||||
defaultProfileName: string;
|
||||
routePath: string;
|
||||
}
|
||||
|
||||
export interface CliproxyBridgeMetadata {
|
||||
provider: CLIProxyProvider;
|
||||
providerDisplayName: string;
|
||||
routePath: string;
|
||||
currentBaseUrl: string;
|
||||
source: 'local' | 'remote';
|
||||
usesCurrentTarget: boolean;
|
||||
usesCurrentAuthToken: boolean;
|
||||
}
|
||||
|
||||
export interface ResolvedCliproxyBridgeProfile {
|
||||
name: string;
|
||||
provider: CLIProxyProvider;
|
||||
providerDisplayName: string;
|
||||
baseUrl: string;
|
||||
apiKey: string;
|
||||
models: ModelMapping;
|
||||
target: TargetType;
|
||||
routePath: string;
|
||||
source: 'local' | 'remote';
|
||||
}
|
||||
|
||||
export interface CreateCliproxyBridgeProfileResult extends CreateApiProfileResult {
|
||||
name?: string;
|
||||
provider?: CLIProxyProvider;
|
||||
target?: TargetType;
|
||||
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
||||
}
|
||||
|
||||
/** Result from remove operation */
|
||||
export interface RemoveApiProfileResult {
|
||||
success: boolean;
|
||||
|
||||
@@ -6,6 +6,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { getCcsDir, getConfigPath, loadConfigSafe } from '../../utils/config-manager';
|
||||
import { expandPath } from '../../utils/helpers';
|
||||
import { validateApiName } from './validation-service';
|
||||
import {
|
||||
loadOrCreateUnifiedConfig,
|
||||
saveUnifiedConfig,
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
import { ensureProfileHooks } from '../../utils/websearch/profile-hook-injector';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import { resolveDroidProvider } from '../../targets/droid-provider';
|
||||
import { isReservedName } from '../../config/reserved-names';
|
||||
import { mapExternalProviderName } from '../../cliproxy/provider-capabilities';
|
||||
import {
|
||||
extractProviderFromPathname,
|
||||
@@ -23,9 +25,15 @@ import type { CLIProxyProvider } from '../../cliproxy/types';
|
||||
import type {
|
||||
ModelMapping,
|
||||
CreateApiProfileResult,
|
||||
CreateCliproxyBridgeProfileResult,
|
||||
RemoveApiProfileResult,
|
||||
UpdateApiProfileTargetResult,
|
||||
} from './profile-types';
|
||||
import { apiProfileExists } from './profile-reader';
|
||||
import {
|
||||
resolveCliproxyBridgeMetadata,
|
||||
resolveCliproxyBridgeProfile,
|
||||
} from './cliproxy-profile-bridge';
|
||||
|
||||
/** Check if URL is an OpenRouter endpoint */
|
||||
function isOpenRouterUrl(baseUrl: string): boolean {
|
||||
@@ -233,6 +241,63 @@ export function createApiProfile(
|
||||
}
|
||||
}
|
||||
|
||||
export function createCliproxyBridgeProfile(
|
||||
provider: CLIProxyProvider,
|
||||
options: {
|
||||
name?: string;
|
||||
force?: boolean;
|
||||
target?: TargetType;
|
||||
} = {}
|
||||
): CreateCliproxyBridgeProfileResult {
|
||||
const providedName = options.name?.trim();
|
||||
if (providedName) {
|
||||
const nameError = validateApiName(providedName);
|
||||
if (nameError) {
|
||||
return { success: false, settingsFile: '', error: nameError };
|
||||
}
|
||||
if (isReservedName(providedName)) {
|
||||
return {
|
||||
success: false,
|
||||
settingsFile: '',
|
||||
error: `Profile name '${providedName}' is reserved`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const resolved = resolveCliproxyBridgeProfile(provider, options);
|
||||
const settingsPath = path.join(getCcsDir(), `${resolved.name}.settings.json`);
|
||||
if (!options.force && (apiProfileExists(resolved.name) || fs.existsSync(settingsPath))) {
|
||||
return {
|
||||
success: false,
|
||||
settingsFile: '',
|
||||
error: `Profile already exists: ${resolved.name}`,
|
||||
};
|
||||
}
|
||||
|
||||
const result = createApiProfile(
|
||||
resolved.name,
|
||||
resolved.baseUrl,
|
||||
resolved.apiKey,
|
||||
resolved.models,
|
||||
resolved.target,
|
||||
provider
|
||||
);
|
||||
|
||||
return {
|
||||
...result,
|
||||
name: resolved.name,
|
||||
provider,
|
||||
target: resolved.target,
|
||||
cliproxyBridge:
|
||||
resolveCliproxyBridgeMetadata({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: resolved.baseUrl,
|
||||
ANTHROPIC_AUTH_TOKEN: resolved.apiKey,
|
||||
},
|
||||
}) ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update API profile target (claude/droid).
|
||||
* Persists to config.yaml in unified mode and config.json profile_targets in legacy mode.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
apiProfileExists,
|
||||
createCliproxyBridgeProfile,
|
||||
createApiProfile,
|
||||
getDefaultCliproxyBridgeName,
|
||||
getPresetById,
|
||||
getPresetIds,
|
||||
getUrlWarning,
|
||||
@@ -8,11 +10,17 @@ import {
|
||||
isUsingUnifiedConfig,
|
||||
pickOpenRouterModel,
|
||||
sanitizeBaseUrl,
|
||||
suggestCliproxyBridgeName,
|
||||
validateApiName,
|
||||
validateUrl,
|
||||
type ModelMapping,
|
||||
type ProviderPreset,
|
||||
} from '../../api/services';
|
||||
import {
|
||||
CLIPROXY_PROVIDER_IDS,
|
||||
getProviderDisplayName,
|
||||
isCLIProxyProvider,
|
||||
} from '../../cliproxy/provider-capabilities';
|
||||
import { syncToLocalConfig } from '../../cliproxy/sync/local-config-sync';
|
||||
import type { TargetType } from '../../targets/target-adapter';
|
||||
import { color, dim, fail, header, info, infoBox, initUI, warn } from '../../utils/ui';
|
||||
@@ -66,6 +74,34 @@ async function resolveProfileName(
|
||||
return name;
|
||||
}
|
||||
|
||||
async function resolveCliproxyProfileName(
|
||||
provider: string,
|
||||
providedName: string | undefined,
|
||||
yes: boolean | undefined
|
||||
): Promise<string | undefined> {
|
||||
if (providedName) {
|
||||
const error = validateApiName(providedName);
|
||||
if (error) {
|
||||
console.log(fail(error));
|
||||
process.exit(1);
|
||||
}
|
||||
return providedName;
|
||||
}
|
||||
|
||||
const suggestedName = isCLIProxyProvider(provider)
|
||||
? suggestCliproxyBridgeName(provider)
|
||||
: getDefaultCliproxyBridgeName('gemini');
|
||||
|
||||
if (yes) {
|
||||
return suggestedName;
|
||||
}
|
||||
|
||||
return InteractivePrompt.input('API name', {
|
||||
default: suggestedName,
|
||||
validate: validateApiName,
|
||||
});
|
||||
}
|
||||
|
||||
async function resolveBaseUrl(
|
||||
providedBaseUrl: string | undefined,
|
||||
preset: ProviderPreset | null
|
||||
@@ -248,6 +284,101 @@ export async function handleApiCreateCommand(args: string[]): Promise<void> {
|
||||
console.log(header('Create API Profile'));
|
||||
console.log('');
|
||||
|
||||
if (parsedArgs.cliproxyProvider) {
|
||||
const cliproxyProvider = parsedArgs.cliproxyProvider.trim().toLowerCase();
|
||||
if (!isCLIProxyProvider(cliproxyProvider)) {
|
||||
console.log(fail(`Unknown CLIProxy provider: ${cliproxyProvider}`));
|
||||
console.log('');
|
||||
console.log(`Available providers: ${CLIPROXY_PROVIDER_IDS.join(', ')}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const incompatibleFlags = [
|
||||
parsedArgs.baseUrl && '--base-url',
|
||||
parsedArgs.apiKey && '--api-key',
|
||||
parsedArgs.model && '--model',
|
||||
parsedArgs.preset && '--preset',
|
||||
].filter(Boolean);
|
||||
|
||||
if (incompatibleFlags.length > 0) {
|
||||
console.log(
|
||||
fail(`--cliproxy-provider cannot be combined with ${incompatibleFlags.join(', ')}`)
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const name = await resolveCliproxyProfileName(
|
||||
cliproxyProvider,
|
||||
parsedArgs.name,
|
||||
parsedArgs.yes
|
||||
);
|
||||
const target = await resolveDefaultTarget(parsedArgs.target, parsedArgs.yes);
|
||||
|
||||
if (name && apiProfileExists(name) && !parsedArgs.force) {
|
||||
console.log(fail(`API '${name}' already exists`));
|
||||
console.log(` Use ${color('--force', 'command')} to overwrite`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
info(
|
||||
`Using CLIProxy provider: ${getProviderDisplayName(cliproxyProvider)} (${cliproxyProvider})`
|
||||
)
|
||||
);
|
||||
console.log(
|
||||
dim(' CCS will create a routed API profile. Provider credentials stay managed by CLIProxy.')
|
||||
);
|
||||
console.log('');
|
||||
console.log(info('Creating API profile...'));
|
||||
|
||||
const result = createCliproxyBridgeProfile(cliproxyProvider, {
|
||||
force: parsedArgs.force === true,
|
||||
name,
|
||||
target,
|
||||
});
|
||||
if (!result.success || !result.name || !result.cliproxyBridge) {
|
||||
console.log(fail(`Failed to create CLIProxy bridge profile: ${result.error}`));
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
try {
|
||||
syncToLocalConfig();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.log(`[i] Auto-sync to CLIProxy config skipped: ${message}`);
|
||||
}
|
||||
|
||||
const details =
|
||||
`API: ${result.name}\n` +
|
||||
`Config: ${isUsingUnifiedConfig() ? '~/.ccs/config.yaml' : '~/.ccs/config.json'}\n` +
|
||||
`Settings: ${result.settingsFile}\n` +
|
||||
`Provider: ${result.cliproxyBridge.providerDisplayName}\n` +
|
||||
`Route: ${result.cliproxyBridge.routePath}\n` +
|
||||
`Proxy: ${result.cliproxyBridge.currentBaseUrl}\n` +
|
||||
`Target: ${target}`;
|
||||
|
||||
console.log('');
|
||||
console.log(infoBox(details, 'CLIProxy Bridge Created'));
|
||||
console.log('');
|
||||
console.log(header('Usage'));
|
||||
if (target === 'droid') {
|
||||
console.log(
|
||||
` ${color(`ccs ${result.name} "your prompt"`, 'command')} ${dim('# uses droid by default')}`
|
||||
);
|
||||
console.log(
|
||||
` ${color(`ccsd ${result.name} "your prompt"`, 'command')} ${dim('# explicit droid alias')}`
|
||||
);
|
||||
} else {
|
||||
console.log(` ${color(`ccs ${result.name} "your prompt"`, 'command')}`);
|
||||
console.log(
|
||||
` ${color(`ccs ${result.name} --target droid "your prompt"`, 'command')} ${dim('# optional target override')}`
|
||||
);
|
||||
}
|
||||
console.log('');
|
||||
console.log(dim('Manage provider accounts, keys, and models in: ccs cliproxy'));
|
||||
return;
|
||||
}
|
||||
|
||||
showPresetDeprecationNotice(parsedArgs.preset);
|
||||
const preset = resolvePresetOrExit(parsedArgs.preset);
|
||||
const name = await resolveProfileName(parsedArgs.name, preset);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
PROVIDER_PRESETS,
|
||||
listCliproxyBridgeProviders,
|
||||
getPresetAliases,
|
||||
getPresetIds,
|
||||
type ProviderPreset,
|
||||
@@ -20,6 +21,7 @@ export async function showApiCommandHelp(): Promise<void> {
|
||||
const presetIds = getPresetIds()
|
||||
.map((id) => sanitizeHelpText(id))
|
||||
.filter(Boolean);
|
||||
const cliproxyProviderIds = listCliproxyBridgeProviders().map((provider) => provider.provider);
|
||||
const presetAliases = getPresetAliases();
|
||||
const presetIdWidth = Math.max(0, ...presetIds.map((id) => id.length)) + 2;
|
||||
|
||||
@@ -47,6 +49,9 @@ export async function showApiCommandHelp(): Promise<void> {
|
||||
console.log(
|
||||
` ${color('--preset <id>', 'command')} Use provider preset (${presetIds.join(', ')})`
|
||||
);
|
||||
console.log(
|
||||
` ${color('--cliproxy-provider <id>', 'command')} Use routed CLIProxy provider (${cliproxyProviderIds.join(', ')})`
|
||||
);
|
||||
console.log(` ${color('--base-url <url>', 'command')} API base URL (create)`);
|
||||
console.log(` ${color('--api-key <key>', 'command')} API key (create)`);
|
||||
console.log(` ${color('--model <model>', 'command')} Default model (create)`);
|
||||
@@ -84,6 +89,12 @@ export async function showApiCommandHelp(): Promise<void> {
|
||||
console.log(` ${color('ccs api create --preset alibaba', 'command')} ${dim('# alias')}`);
|
||||
console.log(` ${color('ccs api create --preset glm', 'command')}`);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Create routed profile from existing CLIProxy provider config')}`);
|
||||
console.log(` ${color('ccs api create --cliproxy-provider gemini', 'command')}`);
|
||||
console.log(
|
||||
` ${color('ccs api create gemini-droid --cliproxy-provider gemini --target droid', 'command')}`
|
||||
);
|
||||
console.log('');
|
||||
console.log(` ${dim('# Create with name')}`);
|
||||
console.log(` ${color('ccs api create myapi', 'command')}`);
|
||||
console.log(` ${color('ccs api create mydroid --preset glm --target droid', 'command')}`);
|
||||
|
||||
@@ -9,6 +9,7 @@ export interface ApiCommandArgs {
|
||||
apiKey?: string;
|
||||
model?: string;
|
||||
preset?: string;
|
||||
cliproxyProvider?: string;
|
||||
target?: TargetType;
|
||||
force?: boolean;
|
||||
yes?: boolean;
|
||||
@@ -21,6 +22,7 @@ export const API_VALUE_FLAGS = [
|
||||
'--api-key',
|
||||
'--model',
|
||||
'--preset',
|
||||
'--cliproxy-provider',
|
||||
'--target',
|
||||
] as const;
|
||||
export const API_KNOWN_FLAGS: readonly string[] = [...API_BOOLEAN_FLAGS, ...API_VALUE_FLAGS];
|
||||
@@ -210,6 +212,18 @@ export function parseApiCommandArgs(
|
||||
false
|
||||
);
|
||||
|
||||
remaining = applyRepeatedOption(
|
||||
remaining,
|
||||
['--cliproxy-provider'],
|
||||
(value) => {
|
||||
result.cliproxyProvider = value.trim().toLowerCase();
|
||||
},
|
||||
() => {
|
||||
result.errors.push('Missing value for --cliproxy-provider');
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
remaining = applyRepeatedOption(
|
||||
remaining,
|
||||
['--target'],
|
||||
|
||||
@@ -141,6 +141,10 @@ Run ${color('ccs config', 'command')} for web dashboard`.trim();
|
||||
['ccs ollama-cloud', 'Ollama Cloud (API key required)'],
|
||||
['', ''], // Spacer
|
||||
['ccs api create --preset anthropic', 'Anthropic direct API key (sk-ant-...)'],
|
||||
[
|
||||
'ccs api create --cliproxy-provider gemini',
|
||||
'Create routed API profile from CLIProxy Gemini',
|
||||
],
|
||||
['ccs api create', 'Create custom API profile'],
|
||||
['ccs api discover --register', 'Discover/register orphan settings files'],
|
||||
['ccs api copy <src> <dest>', 'Duplicate API profile'],
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Router, Request, Response } from 'express';
|
||||
import { isReservedName, RESERVED_PROFILE_NAMES } from '../../config/reserved-names';
|
||||
import {
|
||||
createApiProfile,
|
||||
createCliproxyBridgeProfile,
|
||||
removeApiProfile,
|
||||
updateApiProfileTarget,
|
||||
discoverApiProfileOrphans,
|
||||
@@ -17,10 +18,12 @@ import {
|
||||
exportApiProfile,
|
||||
importApiProfileBundle,
|
||||
apiProfileExists,
|
||||
listCliproxyBridgeProviders,
|
||||
listApiProfiles,
|
||||
validateApiName,
|
||||
} from '../../api/services';
|
||||
import { normalizeDroidProvider } from '../../targets/droid-provider';
|
||||
import { isCLIProxyProvider } from '../../cliproxy/provider-capabilities';
|
||||
import { isAnthropicDirectProfile, updateSettingsFile, parseTarget } from './route-helpers';
|
||||
|
||||
const router = Router();
|
||||
@@ -66,6 +69,7 @@ router.get('/', (_req: Request, res: Response): void => {
|
||||
settingsPath: p.settingsPath,
|
||||
configured: p.isConfigured,
|
||||
target: p.target,
|
||||
cliproxyBridge: p.cliproxyBridge ?? null,
|
||||
}));
|
||||
res.json({ profiles });
|
||||
} catch (error) {
|
||||
@@ -73,6 +77,54 @@ router.get('/', (_req: Request, res: Response): void => {
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/cliproxy-bridge/providers', (_req: Request, res: Response): void => {
|
||||
try {
|
||||
res.json({ providers: listCliproxyBridgeProviders() });
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: (error as Error).message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/cliproxy-bridge', (req: Request, res: Response): void => {
|
||||
const shape = validatePayloadShape(req.body, ['provider', 'name', 'target']);
|
||||
if (!shape.ok) {
|
||||
res.status(400).json({ error: shape.error });
|
||||
return;
|
||||
}
|
||||
|
||||
const provider = typeof shape.payload.provider === 'string' ? shape.payload.provider.trim() : '';
|
||||
if (!isCLIProxyProvider(provider)) {
|
||||
res.status(400).json({ error: 'Invalid provider. Expected a supported CLIProxy provider ID.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const target = parseTarget(shape.payload.target);
|
||||
if (shape.payload.target !== undefined && target === null) {
|
||||
res.status(400).json({ error: 'Invalid target. Expected: claude or droid' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = createCliproxyBridgeProfile(provider, {
|
||||
name: typeof shape.payload.name === 'string' ? shape.payload.name : undefined,
|
||||
target: target || 'claude',
|
||||
});
|
||||
|
||||
if (!result.success || !result.name) {
|
||||
const errorMessage = result.error || 'Failed to create CLIProxy bridge profile';
|
||||
res.status(errorMessage.toLowerCase().includes('already exists') ? 409 : 400).json({
|
||||
error: errorMessage,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.status(201).json({
|
||||
name: result.name,
|
||||
settingsPath: result.settingsFile,
|
||||
target: result.target || 'claude',
|
||||
cliproxyBridge: result.cliproxyBridge ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/profiles - Create new profile
|
||||
*/
|
||||
@@ -160,6 +212,7 @@ router.post('/', (req: Request, res: Response): void => {
|
||||
name,
|
||||
settingsPath: result.settingsFile,
|
||||
target: parsedTarget || 'claude',
|
||||
cliproxyBridge: null,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../../cliproxy';
|
||||
import { regenerateConfig } from '../../cliproxy/config-generator';
|
||||
import { deduplicateCcsHooks } from '../../utils/websearch/hook-utils';
|
||||
import { resolveCliproxyBridgeMetadata } from '../../api/services';
|
||||
import {
|
||||
getDashboardAuthConfig,
|
||||
loadOrCreateUnifiedConfig,
|
||||
@@ -327,6 +328,7 @@ router.get('/:profile', (req: Request, res: Response): void => {
|
||||
settings: masked,
|
||||
mtime: stat.mtime.getTime(),
|
||||
path: settingsPath,
|
||||
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
|
||||
});
|
||||
} catch (error) {
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
@@ -354,6 +356,7 @@ router.get('/:profile/raw', (req: Request, res: Response): void => {
|
||||
settings,
|
||||
mtime: stat.mtime.getTime(),
|
||||
path: settingsPath,
|
||||
cliproxyBridge: resolveCliproxyBridgeMetadata(settings),
|
||||
});
|
||||
} catch (error) {
|
||||
respondInternalError(res, error, 'Internal server error.');
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { getEffectiveApiKey } from '../../../src/cliproxy/auth-token-manager';
|
||||
import {
|
||||
resolveCliproxyBridgeMetadata,
|
||||
resolveCliproxyBridgeProfile,
|
||||
suggestCliproxyBridgeName,
|
||||
} from '../../../src/api/services/cliproxy-profile-bridge';
|
||||
|
||||
describe('cliproxy-profile-bridge', () => {
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-cliproxy-bridge-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome === undefined) {
|
||||
delete process.env.CCS_HOME;
|
||||
} else {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
}
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('resolves routed profile payload for a local CLIProxy provider', () => {
|
||||
const bridge = resolveCliproxyBridgeProfile('gemini');
|
||||
|
||||
expect(bridge.name).toBe('gemini-api');
|
||||
expect(bridge.baseUrl).toBe('http://127.0.0.1:8317/api/provider/gemini');
|
||||
expect(bridge.routePath).toBe('/api/provider/gemini');
|
||||
expect(bridge.models.default.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('suggests a unique name when the default bridge settings file already exists', () => {
|
||||
const ccsDir = path.join(tempHome, '.ccs');
|
||||
fs.mkdirSync(ccsDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(ccsDir, 'gemini-api.settings.json'), '{}\n');
|
||||
|
||||
expect(suggestCliproxyBridgeName('gemini')).toBe('gemini-api-2');
|
||||
});
|
||||
|
||||
it('detects CLIProxy-backed profile metadata and normalizes localhost loopback URLs', () => {
|
||||
const metadata = resolveCliproxyBridgeMetadata({
|
||||
env: {
|
||||
ANTHROPIC_BASE_URL: 'http://localhost:8317/api/provider/gemini',
|
||||
ANTHROPIC_AUTH_TOKEN: getEffectiveApiKey(),
|
||||
},
|
||||
});
|
||||
|
||||
expect(metadata?.provider).toBe('gemini');
|
||||
expect(metadata?.usesCurrentTarget).toBe(true);
|
||||
expect(metadata?.usesCurrentAuthToken).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,14 @@ describe('api-command arg parser', () => {
|
||||
expect(parsed.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('parses --cliproxy-provider for routed API profile creation', () => {
|
||||
const parsed = parseApiCommandArgs(['my-api', '--cliproxy-provider', 'Gemini']);
|
||||
|
||||
expect(parsed.name).toBe('my-api');
|
||||
expect(parsed.cliproxyProvider).toBe('gemini');
|
||||
expect(parsed.errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('validates invalid --target values', () => {
|
||||
const parsed = parseApiCommandArgs(['my-api', '--target', 'invalid-target']);
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'bun:test';
|
||||
import express from 'express';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import type { Server } from 'http';
|
||||
import profileRoutes from '../../../src/web-server/routes/profile-routes';
|
||||
|
||||
describe('profile-routes cliproxy bridge', () => {
|
||||
let server: Server;
|
||||
let baseUrl = '';
|
||||
let tempHome = '';
|
||||
let originalCcsHome: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const app = express();
|
||||
app.use(express.json());
|
||||
app.use('/api/profiles', profileRoutes);
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server = app.listen(0, '127.0.0.1');
|
||||
const onError = (error: Error) => reject(error);
|
||||
server.once('error', onError);
|
||||
server.once('listening', () => {
|
||||
server.off('error', onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Unable to resolve test server port');
|
||||
}
|
||||
baseUrl = `http://127.0.0.1:${address.port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
tempHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccs-profile-routes-cliproxy-bridge-'));
|
||||
originalCcsHome = process.env.CCS_HOME;
|
||||
process.env.CCS_HOME = tempHome;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (originalCcsHome !== undefined) {
|
||||
process.env.CCS_HOME = originalCcsHome;
|
||||
} else {
|
||||
delete process.env.CCS_HOME;
|
||||
}
|
||||
|
||||
if (tempHome && fs.existsSync(tempHome)) {
|
||||
fs.rmSync(tempHome, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('creates a routed CLIProxy-backed API profile and returns bridge metadata', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/profiles/cliproxy-bridge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: 'gemini' }),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(201);
|
||||
const body = (await response.json()) as {
|
||||
name: string;
|
||||
settingsPath: string;
|
||||
cliproxyBridge: { provider: string; usesCurrentTarget: boolean };
|
||||
};
|
||||
expect(body.name).toBe('gemini-api');
|
||||
expect(body.settingsPath).toBe('~/.ccs/gemini-api.settings.json');
|
||||
expect(body.cliproxyBridge.provider).toBe('gemini');
|
||||
expect(body.cliproxyBridge.usesCurrentTarget).toBe(true);
|
||||
|
||||
const settingsPath = path.join(tempHome, '.ccs', 'gemini-api.settings.json');
|
||||
expect(fs.existsSync(settingsPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('auto-suggests the next routed profile name when the default bridge name is taken', async () => {
|
||||
const firstResponse = await fetch(`${baseUrl}/api/profiles/cliproxy-bridge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: 'gemini' }),
|
||||
});
|
||||
expect(firstResponse.status).toBe(201);
|
||||
|
||||
const secondResponse = await fetch(`${baseUrl}/api/profiles/cliproxy-bridge`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: 'gemini' }),
|
||||
});
|
||||
|
||||
expect(secondResponse.status).toBe(201);
|
||||
const body = (await secondResponse.json()) as { name: string };
|
||||
expect(body.name).toBe('gemini-api-2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { CLIProxyProvider } from '@/lib/provider-config';
|
||||
import { ArrowRight, Link2, ShieldCheck } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface ApiProfileBridgeCalloutProps {
|
||||
provider?: CLIProxyProvider;
|
||||
className?: string;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function ApiProfileBridgeCallout({
|
||||
provider,
|
||||
className,
|
||||
compact = false,
|
||||
}: ApiProfileBridgeCalloutProps) {
|
||||
const providerQuery = provider ? `&cliproxyProvider=${provider}` : '';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('rounded-xl border bg-card/95 shadow-sm', compact ? 'p-3' : 'p-4', className)}
|
||||
>
|
||||
<div className={cn('flex gap-3', compact ? 'items-start' : 'items-center')}>
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/10">
|
||||
{provider ? (
|
||||
<ProviderLogo provider={provider} size="md" />
|
||||
) : (
|
||||
<Link2 className="h-5 w-5 text-primary" />
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="text-sm font-semibold">Use this provider in API Profiles</h3>
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<ShieldCheck className="h-3 w-3" />
|
||||
No manual token copy
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Configure keys, OAuth accounts, or models here, then create a routed API Profile in CCS.
|
||||
The profile will point at the provider route automatically.
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" asChild>
|
||||
<Link to={`/providers?cliproxyBridge=1${providerQuery}`}>
|
||||
Create API Profile
|
||||
<ArrowRight className="ml-1 h-4 w-4" />
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to="/providers">Open API Profiles</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import { useQuery } from '@tanstack/react-query';
|
||||
import { api, withApiBase } from '@/lib/api-client';
|
||||
import type { CliproxyServerConfig } from '@/lib/api-client';
|
||||
import { CLIPROXY_DEFAULT_PORT } from '@/lib/preset-utils';
|
||||
import { ApiProfileBridgeCallout } from './api-profile-bridge-callout';
|
||||
|
||||
interface AuthTokensResponse {
|
||||
apiKey: { value: string; isCustom: boolean };
|
||||
@@ -200,6 +201,9 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
<div className="border-b bg-muted/20 p-4">
|
||||
<ApiProfileBridgeCallout compact className="max-w-3xl mx-auto" />
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center bg-muted/20">
|
||||
<div className="text-center max-w-md px-8">
|
||||
<div className="w-16 h-16 rounded-full bg-destructive/10 flex items-center justify-center mx-auto mb-6">
|
||||
@@ -219,67 +223,72 @@ export function ControlPanelEmbed({ port = CLIPROXY_DEFAULT_PORT }: ControlPanel
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col relative">
|
||||
{/* Remote indicator and login hint banner */}
|
||||
{showLoginHint && !isLoading && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
|
||||
{isRemote && (
|
||||
<>
|
||||
<Globe className="h-3.5 w-3.5 text-green-600" />
|
||||
<span className="text-green-600 font-medium">Remote</span>
|
||||
<span className="text-blue-300 dark:text-blue-700">|</span>
|
||||
</>
|
||||
)}
|
||||
<Key className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span>
|
||||
Key:{' '}
|
||||
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
|
||||
{authToken && authToken.length > 4
|
||||
? `***${authToken.slice(-4)}`
|
||||
: authToken || 'ccs'}
|
||||
</code>
|
||||
</span>
|
||||
<a
|
||||
href="/settings?tab=auth"
|
||||
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||
title="Manage auth tokens"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||
onClick={() => setShowLoginHint(false)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<div className="flex-1 flex flex-col">
|
||||
<div className="border-b bg-muted/20 p-4">
|
||||
<ApiProfileBridgeCallout compact className="max-w-3xl mx-auto" />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col relative">
|
||||
{/* Remote indicator and login hint banner */}
|
||||
{showLoginHint && !isLoading && (
|
||||
<div className="absolute top-2 left-1/2 -translate-x-1/2 z-20">
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-md text-sm">
|
||||
{isRemote && (
|
||||
<>
|
||||
<Globe className="h-3.5 w-3.5 text-green-600" />
|
||||
<span className="text-green-600 font-medium">Remote</span>
|
||||
<span className="text-blue-300 dark:text-blue-700">|</span>
|
||||
</>
|
||||
)}
|
||||
<Key className="h-3.5 w-3.5 text-blue-600" />
|
||||
<span>
|
||||
Key:{' '}
|
||||
<code className="bg-blue-100 dark:bg-blue-900 px-1 rounded font-mono font-semibold">
|
||||
{authToken && authToken.length > 4
|
||||
? `***${authToken.slice(-4)}`
|
||||
: authToken || 'ccs'}
|
||||
</code>
|
||||
</span>
|
||||
<a
|
||||
href="/settings?tab=auth"
|
||||
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||
title="Manage auth tokens"
|
||||
>
|
||||
<Settings className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
<button
|
||||
className="text-blue-600 hover:text-blue-800 dark:hover:text-blue-400"
|
||||
onClick={() => setShowLoginHint(false)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Loading overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isRemote
|
||||
? `Loading Control Panel from ${displayHost}...`
|
||||
: 'Loading Control Panel...'}
|
||||
</p>
|
||||
{/* Loading overlay */}
|
||||
{isLoading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
|
||||
<div className="text-center">
|
||||
<RefreshCw className="w-8 h-8 animate-spin text-primary mx-auto mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isRemote
|
||||
? `Loading Control Panel from ${displayHost}...`
|
||||
: 'Loading Control Panel...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Iframe */}
|
||||
<iframe
|
||||
key={`${managementUrl}:${iframeRevision}`}
|
||||
ref={iframeRef}
|
||||
src={managementUrl}
|
||||
className="flex-1 w-full border-0"
|
||||
title="CLIProxy Management Panel"
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
{/* Iframe */}
|
||||
<iframe
|
||||
key={`${managementUrl}:${iframeRevision}`}
|
||||
ref={iframeRef}
|
||||
src={managementUrl}
|
||||
className="flex-1 w-full border-0"
|
||||
title="CLIProxy Management Panel"
|
||||
onLoad={handleIframeLoad}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||
import type { AuthStatus } from '@/lib/api-client';
|
||||
import {
|
||||
CLIPROXY_PROVIDERS,
|
||||
getProviderDescription,
|
||||
getProviderDisplayName,
|
||||
type CLIProxyProvider,
|
||||
} from '@/lib/provider-config';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { CheckCircle2, Link2, Settings2 } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
interface CliproxyBridgeCreatePanelProps {
|
||||
selectedProvider: CLIProxyProvider;
|
||||
authStatuses?: AuthStatus[];
|
||||
onProviderSelect: (provider: CLIProxyProvider) => void;
|
||||
}
|
||||
|
||||
export function CliproxyBridgeCreatePanel({
|
||||
selectedProvider,
|
||||
authStatuses,
|
||||
onProviderSelect,
|
||||
}: CliproxyBridgeCreatePanelProps) {
|
||||
const statusMap = new Map((authStatuses || []).map((status) => [status.provider, status]));
|
||||
const selectedStatus = statusMap.get(selectedProvider);
|
||||
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
<div className="space-y-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">CLIProxy Provider</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Pick the provider already configured in CLIProxy. CCS will create a routed API Profile
|
||||
without asking you to copy the proxy URL or internal auth token.
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{CLIPROXY_PROVIDERS.map((provider) => {
|
||||
const status = statusMap.get(provider);
|
||||
return (
|
||||
<button
|
||||
key={provider}
|
||||
type="button"
|
||||
onClick={() => onProviderSelect(provider)}
|
||||
className={cn(
|
||||
'rounded-xl border p-3 text-left transition-colors',
|
||||
selectedProvider === provider
|
||||
? 'border-primary bg-primary/5 ring-1 ring-primary/15'
|
||||
: 'border-border hover:border-primary/35 hover:bg-accent/15'
|
||||
)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
<ProviderLogo provider={provider} size="md" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1 space-y-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">{getProviderDisplayName(provider)}</span>
|
||||
{status?.authenticated ? (
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<CheckCircle2 className="h-3 w-3" />
|
||||
Connected
|
||||
</Badge>
|
||||
) : status?.accounts?.length ? (
|
||||
<Badge variant="secondary">{status.accounts.length} account(s)</Badge>
|
||||
) : (
|
||||
<Badge variant="outline">Configured in CLIProxy</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{getProviderDescription(provider)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border bg-muted/20 p-4 text-sm">
|
||||
<div className="flex items-center gap-2 font-medium">
|
||||
<Link2 className="h-4 w-4 text-primary" />
|
||||
Routed profile summary
|
||||
</div>
|
||||
<div className="mt-3 space-y-2 text-xs text-muted-foreground">
|
||||
<p>
|
||||
Provider route:{' '}
|
||||
<code className="rounded bg-muted px-1.5 py-0.5">/api/provider/{selectedProvider}</code>
|
||||
</p>
|
||||
<p>CCS resolves the active CLIProxy target automatically for local and remote setups.</p>
|
||||
<p>
|
||||
{selectedStatus?.authenticated
|
||||
? 'CCS OAuth account detected for this provider.'
|
||||
: 'No CCS OAuth account is required here if you already configured provider API keys in CLIProxy Control Panel.'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to={`/cliproxy?provider=${selectedProvider}`}>
|
||||
<Settings2 className="mr-1 h-4 w-4" />
|
||||
Open CLIProxy
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" asChild>
|
||||
<Link to="/cliproxy/control-panel">Open Control Panel</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,7 +7,8 @@ import { useTranslation } from 'react-i18next';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
import { Info } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle2, Info, Link2, TriangleAlert } from 'lucide-react';
|
||||
import type { SettingsResponse } from './types';
|
||||
import type { CliTarget } from '@/lib/api-client';
|
||||
|
||||
@@ -62,6 +63,78 @@ export function InfoSection({ profileName, target, data }: InfoSectionProps) {
|
||||
</span>
|
||||
<span className="font-mono">{target}</span>
|
||||
</div>
|
||||
{data.cliproxyBridge && (
|
||||
<>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-start">
|
||||
<span className="font-medium text-muted-foreground">Source</span>
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Badge variant="secondary" className="gap-1">
|
||||
<Link2 className="h-3 w-3" />
|
||||
CLIProxy {data.cliproxyBridge.providerDisplayName}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="uppercase">
|
||||
{data.cliproxyBridge.source}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
This profile is routed through{' '}
|
||||
<code className="bg-muted px-1 rounded text-[10px]">
|
||||
{data.cliproxyBridge.routePath}
|
||||
</code>
|
||||
. Keys and account state stay managed in CLIProxy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-2 text-sm items-start">
|
||||
<span className="font-medium text-muted-foreground">Bridge Status</span>
|
||||
<div className="space-y-1.5">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
data.cliproxyBridge.usesCurrentTarget
|
||||
? 'border-green-200 text-green-700 bg-green-50'
|
||||
: 'border-amber-200 text-amber-700 bg-amber-50'
|
||||
}
|
||||
>
|
||||
{data.cliproxyBridge.usesCurrentTarget ? (
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<TriangleAlert className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{data.cliproxyBridge.usesCurrentTarget
|
||||
? 'Proxy target is current'
|
||||
: 'Proxy target changed'}
|
||||
</Badge>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
data.cliproxyBridge.usesCurrentAuthToken
|
||||
? 'border-green-200 text-green-700 bg-green-50'
|
||||
: 'border-amber-200 text-amber-700 bg-amber-50'
|
||||
}
|
||||
>
|
||||
{data.cliproxyBridge.usesCurrentAuthToken ? (
|
||||
<CheckCircle2 className="mr-1 h-3 w-3" />
|
||||
) : (
|
||||
<TriangleAlert className="mr-1 h-3 w-3" />
|
||||
)}
|
||||
{data.cliproxyBridge.usesCurrentAuthToken
|
||||
? 'Auth token matches current proxy'
|
||||
: 'Auth token may need refresh'}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Expected proxy URL:{' '}
|
||||
<code className="bg-muted px-1 rounded text-[10px] break-all">
|
||||
{data.cliproxyBridge.currentBaseUrl}
|
||||
</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Types for Profile Editor
|
||||
*/
|
||||
|
||||
import type { CliTarget } from '@/lib/api-client';
|
||||
import type { CliTarget, CliproxyBridgeMetadata } from '@/lib/api-client';
|
||||
|
||||
export interface Settings {
|
||||
env?: Record<string, string>;
|
||||
@@ -13,6 +13,7 @@ export interface SettingsResponse {
|
||||
settings: Settings;
|
||||
mtime: number;
|
||||
path: string;
|
||||
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
||||
}
|
||||
|
||||
export interface ProfileEditorProps {
|
||||
|
||||
@@ -8,18 +8,20 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { useOpenRouterReady } from '@/hooks/use-openrouter-models';
|
||||
import { Sparkles, ExternalLink, ArrowRight, Zap, CloudCog, KeyRound } from 'lucide-react';
|
||||
import { Sparkles, ExternalLink, ArrowRight, Zap, CloudCog, KeyRound, Link2 } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface OpenRouterQuickStartProps {
|
||||
onOpenRouterClick: () => void;
|
||||
onAlibabaCodingPlanClick: () => void;
|
||||
onCliproxyClick: () => void;
|
||||
onCustomClick: () => void;
|
||||
}
|
||||
|
||||
export function OpenRouterQuickStart({
|
||||
onOpenRouterClick,
|
||||
onAlibabaCodingPlanClick,
|
||||
onCliproxyClick,
|
||||
onCustomClick,
|
||||
}: OpenRouterQuickStartProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -145,6 +147,53 @@ export function OpenRouterQuickStart({
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="border-emerald-500/30 dark:border-emerald-500/40 bg-gradient-to-br from-emerald-500/5 to-background dark:from-emerald-500/10">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<div className="p-2 rounded-lg bg-emerald-500/10 dark:bg-emerald-500/20">
|
||||
<Link2 className="w-6 h-6 text-emerald-700 dark:text-emerald-300" />
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-emerald-500/10 text-emerald-700 dark:bg-emerald-500/20 dark:text-emerald-200"
|
||||
>
|
||||
Routed via CLIProxy
|
||||
</Badge>
|
||||
</div>
|
||||
<CardTitle className="text-xl">Use an existing CLIProxy provider</CardTitle>
|
||||
<CardDescription className="text-base">
|
||||
If you already configured Gemini, Codex, Claude, or other providers in CLIProxy,
|
||||
create an API Profile without copying internal URLs or auth tokens.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<Link2 className="w-4 h-4 text-emerald-600" />
|
||||
<span>Route /api/provider/<provider></span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-muted-foreground">
|
||||
<KeyRound className="w-4 h-4 text-emerald-600" />
|
||||
<span>Proxy token resolved automatically</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={onCliproxyClick}
|
||||
className="w-full bg-emerald-600 hover:bg-emerald-600/90 text-white"
|
||||
size="lg"
|
||||
>
|
||||
Create CLIProxy Profile
|
||||
<ArrowRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
|
||||
<p className="text-xs text-center text-muted-foreground">
|
||||
Configure providers under CLIProxy or the embedded Control Panel, then bridge them
|
||||
here.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Separator className="flex-1" />
|
||||
|
||||
@@ -29,7 +29,8 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||
import { useCreateProfile } from '@/hooks/use-profiles';
|
||||
import { useCreateCliproxyBridgeProfile, useCreateProfile } from '@/hooks/use-profiles';
|
||||
import { useCliproxyAuth } from '@/hooks/use-cliproxy';
|
||||
import { useOpenRouterCatalog } from '@/hooks/use-openrouter-models';
|
||||
import {
|
||||
Loader2,
|
||||
@@ -41,6 +42,7 @@ import {
|
||||
Settings2,
|
||||
Sparkles,
|
||||
ChevronDown,
|
||||
Link2,
|
||||
} from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
@@ -61,7 +63,9 @@ import {
|
||||
} from '@/lib/openrouter-utils';
|
||||
import type { CategorizedModel } from '@/lib/openrouter-types';
|
||||
import type { CliTarget } from '@/lib/api-client';
|
||||
import { isValidProvider, type CLIProxyProvider } from '@/lib/provider-config';
|
||||
import i18n from '@/lib/i18n';
|
||||
import { CliproxyBridgeCreatePanel } from './cliproxy-bridge-create-panel';
|
||||
|
||||
const optionalUrlSchema = z
|
||||
.string()
|
||||
@@ -90,7 +94,8 @@ interface ProfileCreateDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSuccess: (name: string) => void;
|
||||
initialMode?: 'normal' | 'openrouter' | 'alibaba-coding-plan';
|
||||
initialMode?: 'normal' | 'openrouter' | 'alibaba-coding-plan' | 'cliproxy';
|
||||
initialCliproxyProvider?: CLIProxyProvider | null;
|
||||
}
|
||||
|
||||
// Common URL mistakes to warn about
|
||||
@@ -122,15 +127,25 @@ export function ProfileCreateDialog({
|
||||
onOpenChange,
|
||||
onSuccess,
|
||||
initialMode = 'openrouter',
|
||||
initialCliproxyProvider = null,
|
||||
}: ProfileCreateDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const createMutation = useCreateProfile();
|
||||
const createCliproxyBridgeMutation = useCreateCliproxyBridgeProfile();
|
||||
const { data: cliproxyAuthData } = useCliproxyAuth();
|
||||
const [activeTab, setActiveTab] = useState('basic');
|
||||
const [createKind, setCreateKind] = useState<'preset' | 'cliproxy'>(
|
||||
initialMode === 'cliproxy' ? 'cliproxy' : 'preset'
|
||||
);
|
||||
const [urlWarning, setUrlWarning] = useState<string | null>(null);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [selectedPreset, setSelectedPreset] = useState<PresetSelection>(DEFAULT_PRESET_ID);
|
||||
const [showMorePresets, setShowMorePresets] = useState(false);
|
||||
const [modelSearch, setModelSearch] = useState('');
|
||||
const [selectedCliproxyProvider, setSelectedCliproxyProvider] = useState<CLIProxyProvider>(
|
||||
initialCliproxyProvider ?? 'gemini'
|
||||
);
|
||||
const [autoGeneratedCliproxyName, setAutoGeneratedCliproxyName] = useState('gemini-api');
|
||||
|
||||
// OpenRouter models for model picker
|
||||
const { models: openRouterModels } = useOpenRouterCatalog();
|
||||
@@ -140,6 +155,7 @@ export function ProfileCreateDialog({
|
||||
handleSubmit,
|
||||
formState: { errors },
|
||||
control,
|
||||
getValues,
|
||||
reset,
|
||||
setValue,
|
||||
} = useForm<FormData>({
|
||||
@@ -149,6 +165,21 @@ export function ProfileCreateDialog({
|
||||
|
||||
const baseUrlValue = useWatch({ control, name: 'baseUrl' });
|
||||
const targetValue = useWatch({ control, name: 'target' });
|
||||
const preferredCliproxyProvider = useMemo<CLIProxyProvider>(() => {
|
||||
if (initialCliproxyProvider && isValidProvider(initialCliproxyProvider)) {
|
||||
return initialCliproxyProvider;
|
||||
}
|
||||
|
||||
const connectedProvider = cliproxyAuthData?.authStatus.find(
|
||||
(status) =>
|
||||
isValidProvider(status.provider) &&
|
||||
(status.authenticated || (status.accounts?.length ?? 0) > 0)
|
||||
);
|
||||
|
||||
return connectedProvider && isValidProvider(connectedProvider.provider)
|
||||
? connectedProvider.provider
|
||||
: 'gemini';
|
||||
}, [cliproxyAuthData?.authStatus, initialCliproxyProvider]);
|
||||
const applyPresetToForm = useCallback(
|
||||
(preset: ProviderPreset | null) => {
|
||||
if (!preset) {
|
||||
@@ -190,11 +221,24 @@ export function ProfileCreateDialog({
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setActiveTab('basic');
|
||||
setCreateKind(initialMode === 'cliproxy' ? 'cliproxy' : 'preset');
|
||||
setUrlWarning(null);
|
||||
setShowApiKey(false);
|
||||
setShowMorePresets(false);
|
||||
setModelSearch('');
|
||||
|
||||
if (initialMode === 'cliproxy') {
|
||||
const provider = preferredCliproxyProvider;
|
||||
const generatedName = `${provider}-api`;
|
||||
setSelectedCliproxyProvider(provider);
|
||||
setAutoGeneratedCliproxyName(generatedName);
|
||||
reset({
|
||||
...EMPTY_FORM_VALUES,
|
||||
name: generatedName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Set initial preset based on initialMode
|
||||
if (initialMode === 'normal') {
|
||||
setSelectedPreset(CUSTOM_PRESET_ID);
|
||||
@@ -213,7 +257,20 @@ export function ProfileCreateDialog({
|
||||
applyPresetToForm(null);
|
||||
}
|
||||
}
|
||||
}, [open, initialMode, applyPresetToForm]);
|
||||
}, [open, initialMode, applyPresetToForm, preferredCliproxyProvider, reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || createKind !== 'cliproxy') {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextName = `${selectedCliproxyProvider}-api`;
|
||||
const currentName = getValues('name').trim();
|
||||
if (!currentName || currentName === autoGeneratedCliproxyName) {
|
||||
setValue('name', nextName);
|
||||
}
|
||||
setAutoGeneratedCliproxyName(nextName);
|
||||
}, [autoGeneratedCliproxyName, createKind, getValues, open, selectedCliproxyProvider, setValue]);
|
||||
|
||||
// Handle preset selection
|
||||
const handlePresetSelect = (presetId: string) => {
|
||||
@@ -265,6 +322,22 @@ export function ProfileCreateDialog({
|
||||
}, [baseUrlValue, selectedPreset]);
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
if (createKind === 'cliproxy') {
|
||||
try {
|
||||
const result = await createCliproxyBridgeMutation.mutateAsync({
|
||||
provider: selectedCliproxyProvider,
|
||||
name: data.name,
|
||||
target: data.target,
|
||||
});
|
||||
toast.success(`Profile "${result.name}" created`);
|
||||
onSuccess(result.name);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
toast.error((error as Error).message || 'Failed to create routed profile');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate API key - required unless preset has requiresApiKey: false
|
||||
if (currentPreset?.requiresApiKey !== false && !data.apiKey) {
|
||||
toast.error(i18n.t('commonToast.apiKeyRequired'));
|
||||
@@ -288,11 +361,41 @@ export function ProfileCreateDialog({
|
||||
const hasBasicErrors = !!errors.name || !!errors.baseUrl || !!errors.apiKey;
|
||||
const hasModelErrors =
|
||||
!!errors.model || !!errors.opusModel || !!errors.sonnetModel || !!errors.haikuModel;
|
||||
const isCreating =
|
||||
createKind === 'cliproxy' ? createCliproxyBridgeMutation.isPending : createMutation.isPending;
|
||||
|
||||
const isQuickTemplateSelected =
|
||||
selectedPreset !== CUSTOM_PRESET_ID && QUICK_TEMPLATE_PRESET_IDS.has(selectedPreset);
|
||||
const isOpenRouter = currentPreset?.id === DEFAULT_PRESET_ID;
|
||||
const showQuickTemplates = showMorePresets || isQuickTemplateSelected;
|
||||
const handleCreateKindChange = (nextKind: 'preset' | 'cliproxy') => {
|
||||
if (nextKind === createKind) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCreateKind(nextKind);
|
||||
setActiveTab('basic');
|
||||
setUrlWarning(null);
|
||||
|
||||
if (nextKind === 'cliproxy') {
|
||||
const provider = selectedCliproxyProvider || preferredCliproxyProvider;
|
||||
const generatedName = `${provider}-api`;
|
||||
setSelectedCliproxyProvider(provider);
|
||||
setAutoGeneratedCliproxyName(generatedName);
|
||||
reset({
|
||||
...EMPTY_FORM_VALUES,
|
||||
name: generatedName,
|
||||
target: targetValue || 'claude',
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentPreset) {
|
||||
applyPresetToForm(currentPreset);
|
||||
} else {
|
||||
applyPresetToForm(null);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
@@ -303,7 +406,9 @@ export function ProfileCreateDialog({
|
||||
Create API Profile
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a provider or configure a custom API endpoint.
|
||||
{createKind === 'cliproxy'
|
||||
? 'Bridge a provider already managed by CLIProxy into a usable CCS API Profile.'
|
||||
: 'Choose a provider or configure a custom API endpoint.'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -311,108 +416,111 @@ export function ProfileCreateDialog({
|
||||
onSubmit={handleSubmit(onSubmit)}
|
||||
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||
>
|
||||
<div className="border-b bg-muted/10 px-6 py-3">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t('profileEditor.provider')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('profileEditor.providerChooserHint')}
|
||||
</p>
|
||||
</div>
|
||||
<span className="pt-0.5 text-[11px] text-muted-foreground">
|
||||
{t('profileEditor.scrollHint')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="border-b bg-muted/10 px-6 py-3 space-y-3">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant={createKind === 'cliproxy' ? 'default' : 'outline'}
|
||||
onClick={() => handleCreateKindChange('cliproxy')}
|
||||
>
|
||||
Use CLIProxy Provider
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant={createKind === 'preset' ? 'default' : 'outline'}
|
||||
onClick={() => handleCreateKindChange('preset')}
|
||||
>
|
||||
Preset or Manual Setup
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium uppercase tracking-[0.12em] text-foreground/70">
|
||||
{t('profileEditor.featuredProviders')}
|
||||
</Label>
|
||||
<div className="-mx-1 overflow-x-auto pb-1">
|
||||
<div className="flex min-w-max items-stretch gap-2 px-1">
|
||||
{RECOMMENDED_PRESETS.map((preset) => (
|
||||
<CompactPresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isSelected={selectedPreset === preset.id}
|
||||
onClick={() => handlePresetSelect(preset.id)}
|
||||
density="featured"
|
||||
/>
|
||||
))}
|
||||
<div className="mx-1 hidden w-px rounded-full bg-border/70 sm:block" />
|
||||
<CustomPresetCard
|
||||
isSelected={selectedPreset === CUSTOM_PRESET_ID}
|
||||
onClick={() => handlePresetSelect(CUSTOM_PRESET_ID)}
|
||||
/>
|
||||
{createKind === 'preset' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs text-muted-foreground">
|
||||
{t('profileEditor.provider')}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('profileEditor.providerChooserHint')}
|
||||
</p>
|
||||
</div>
|
||||
<span className="pt-0.5 text-[11px] text-muted-foreground">
|
||||
{t('profileEditor.scrollHint')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowMorePresets((current) => !current)}
|
||||
aria-expanded={showQuickTemplates}
|
||||
className="h-8 px-2 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'mr-1 h-3.5 w-3.5 transition-transform',
|
||||
showQuickTemplates ? 'rotate-0' : '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
{t('profileEditor.morePresets')}
|
||||
</Button>
|
||||
|
||||
{showQuickTemplates && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-xs font-medium uppercase tracking-[0.12em] text-foreground/70">
|
||||
{t('profileEditor.featuredProviders')}
|
||||
</Label>
|
||||
<div className="-mx-1 overflow-x-auto pb-1">
|
||||
<div className="flex min-w-max items-stretch gap-2 px-1">
|
||||
{QUICK_TEMPLATE_PRESETS.map((preset) => (
|
||||
{RECOMMENDED_PRESETS.map((preset) => (
|
||||
<CompactPresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isSelected={selectedPreset === preset.id}
|
||||
onClick={() => handlePresetSelect(preset.id)}
|
||||
density="compact"
|
||||
density="featured"
|
||||
/>
|
||||
))}
|
||||
<div className="mx-1 hidden w-px rounded-full bg-border/70 sm:block" />
|
||||
<CustomPresetCard
|
||||
isSelected={selectedPreset === CUSTOM_PRESET_ID}
|
||||
onClick={() => handlePresetSelect(CUSTOM_PRESET_ID)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowMorePresets((current) => !current)}
|
||||
aria-expanded={showQuickTemplates}
|
||||
className="h-8 px-2 text-xs font-medium text-muted-foreground hover:text-foreground"
|
||||
>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'mr-1 h-3.5 w-3.5 transition-transform',
|
||||
showQuickTemplates ? 'rotate-0' : '-rotate-90'
|
||||
)}
|
||||
/>
|
||||
{t('profileEditor.morePresets')}
|
||||
</Button>
|
||||
|
||||
{showQuickTemplates && (
|
||||
<div className="-mx-1 overflow-x-auto pb-1">
|
||||
<div className="flex min-w-max items-stretch gap-2 px-1">
|
||||
{QUICK_TEMPLATE_PRESETS.map((preset) => (
|
||||
<CompactPresetCard
|
||||
key={preset.id}
|
||||
preset={preset}
|
||||
isSelected={selectedPreset === preset.id}
|
||||
onClick={() => handlePresetSelect(preset.id)}
|
||||
density="compact"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||
>
|
||||
<div className="px-6 pt-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="basic" className="relative">
|
||||
Basic Information
|
||||
{hasBasicErrors && (
|
||||
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models" className="relative">
|
||||
Model Configuration
|
||||
{hasModelErrors && (
|
||||
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
{createKind === 'cliproxy' ? (
|
||||
<>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-6 space-y-5">
|
||||
<CliproxyBridgeCreatePanel
|
||||
selectedProvider={selectedCliproxyProvider}
|
||||
authStatuses={cliproxyAuthData?.authStatus}
|
||||
onProviderSelect={setSelectedCliproxyProvider}
|
||||
/>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<TabsContent value="basic" className="p-6 space-y-4 mt-0">
|
||||
{/* Profile Name */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="name">
|
||||
Profile Name <span className="text-destructive">*</span>
|
||||
@@ -433,81 +541,6 @@ export function ProfileCreateDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Base URL - always editable, pre-filled from preset */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="baseUrl">API Base URL</Label>
|
||||
<Input
|
||||
id="baseUrl"
|
||||
{...register('baseUrl')}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
{errors.baseUrl ? (
|
||||
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
|
||||
) : urlWarning ? (
|
||||
<div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>{urlWarning}</span>
|
||||
</div>
|
||||
) : currentPreset ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{currentPreset.baseUrl
|
||||
? `Pre-filled from ${currentPreset.name}. You can customize if needed.`
|
||||
: `Optional for ${currentPreset.name}. Leave blank to use native Anthropic auth.`}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The endpoint that accepts OpenAI-compatible and Anthropic requests
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API Key - optional for presets that don't require it */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="apiKey">
|
||||
API Key{' '}
|
||||
{currentPreset?.requiresApiKey !== false && (
|
||||
<span className="text-destructive">*</span>
|
||||
)}
|
||||
{currentPreset?.requiresApiKey === false && (
|
||||
<span className="text-muted-foreground text-xs font-normal">(optional)</span>
|
||||
)}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="apiKey"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
{...register('apiKey')}
|
||||
placeholder={
|
||||
currentPreset?.requiresApiKey === false
|
||||
? 'Optional - only if auth is enabled'
|
||||
: (currentPreset?.apiKeyPlaceholder ?? 'sk-...')
|
||||
}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.apiKey ? (
|
||||
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
|
||||
) : currentPreset?.requiresApiKey === false ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only needed if your local endpoint has authentication enabled
|
||||
</p>
|
||||
) : (
|
||||
currentPreset?.apiKeyHint && (
|
||||
<p className="text-xs text-muted-foreground">{currentPreset.apiKeyHint}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="target">Default Target CLI</Label>
|
||||
<Select
|
||||
@@ -523,162 +556,336 @@ export function ProfileCreateDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run with{' '}
|
||||
The routed profile still runs like a normal API Profile via{' '}
|
||||
<code className="bg-muted px-1 rounded text-[10px]">
|
||||
{targetValue === 'droid' ? 'ccsd' : 'ccs'}
|
||||
</code>{' '}
|
||||
by default. You can still override each run with{' '}
|
||||
</code>
|
||||
. You can override per command with{' '}
|
||||
<code className="bg-muted px-1 rounded text-[10px]">--target</code>.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
<TabsContent value="models" className="p-6 mt-0 space-y-4">
|
||||
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
|
||||
<Info className="w-5 h-5 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium mb-1">Model Mapping</p>
|
||||
<p className="text-xs opacity-90">
|
||||
Map Claude Code tiers (Opus/Sonnet/Haiku) to models supported by your
|
||||
provider.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isCreating}
|
||||
className={cn(isCreating && 'opacity-80')}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link2 className="w-4 h-4 mr-2" />
|
||||
Create Routed Profile
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
) : (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
className="flex flex-col flex-1 min-h-0 overflow-hidden"
|
||||
>
|
||||
<div className="px-6 pt-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="basic" className="relative">
|
||||
Basic Information
|
||||
{hasBasicErrors && (
|
||||
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="models" className="relative">
|
||||
Model Configuration
|
||||
{hasModelErrors && (
|
||||
<span className="absolute top-1 right-2 w-2 h-2 rounded-full bg-destructive animate-pulse" />
|
||||
)}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{/* OpenRouter Model Picker */}
|
||||
{isOpenRouter && (
|
||||
<div className="space-y-2">
|
||||
<Label>Search Models</Label>
|
||||
<Input
|
||||
value={modelSearch}
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
placeholder="Type to search (e.g., opus, sonnet, gpt-4o)..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filteredModels.length > 0) {
|
||||
e.preventDefault();
|
||||
handleModelSelect(filteredModels[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="border rounded-md max-h-48 overflow-y-auto">
|
||||
{filteredModels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground p-3 text-center">
|
||||
{modelSearch
|
||||
? `No models found for "${modelSearch}"`
|
||||
: 'Loading models...'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{!modelSearch && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground">
|
||||
<Sparkles className="w-3 h-3 text-accent" />
|
||||
<span>Newest Models</span>
|
||||
</div>
|
||||
)}
|
||||
{filteredModels.map((model) => (
|
||||
<ModelSearchItem
|
||||
key={model.id}
|
||||
model={model}
|
||||
onClick={() => handleModelSelect(model)}
|
||||
showAge={!modelSearch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Inputs */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||
<TabsContent value="basic" className="p-6 space-y-4 mt-0">
|
||||
{/* Profile Name */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="model">
|
||||
Default Model
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
ANTHROPIC_MODEL
|
||||
</Badge>
|
||||
<Label htmlFor="name">
|
||||
Profile Name <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="model"
|
||||
{...register('model')}
|
||||
placeholder={currentPreset?.defaultModel ?? 'claude-sonnet-4'}
|
||||
className="font-mono text-sm"
|
||||
id="name"
|
||||
{...register('name')}
|
||||
placeholder="my-api"
|
||||
className="font-mono"
|
||||
/>
|
||||
{errors.name ? (
|
||||
<p className="text-xs text-destructive">{errors.name.message}</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Used in CLI:{' '}
|
||||
<code className="bg-muted px-1 rounded text-[10px]">
|
||||
ccs my-api "prompt"
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 pt-2 border-t">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sonnetModel" className="text-sm">
|
||||
Sonnet Mapping
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_SONNET
|
||||
</Badge>
|
||||
</Label>
|
||||
<Input
|
||||
id="sonnetModel"
|
||||
{...register('sonnetModel')}
|
||||
placeholder="e.g. gpt-4o, claude-sonnet-4"
|
||||
className="font-mono text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
{/* Base URL - always editable, pre-filled from preset */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="baseUrl">API Base URL</Label>
|
||||
<Input
|
||||
id="baseUrl"
|
||||
{...register('baseUrl')}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
{errors.baseUrl ? (
|
||||
<p className="text-xs text-destructive">{errors.baseUrl.message}</p>
|
||||
) : urlWarning ? (
|
||||
<div className="flex items-start gap-2 text-xs text-yellow-600 bg-yellow-50 dark:bg-yellow-900/20 p-2 rounded">
|
||||
<AlertTriangle className="w-4 h-4 shrink-0 mt-0.5" />
|
||||
<span>{urlWarning}</span>
|
||||
</div>
|
||||
) : currentPreset ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{currentPreset.baseUrl
|
||||
? `Pre-filled from ${currentPreset.name}. You can customize if needed.`
|
||||
: `Optional for ${currentPreset.name}. Leave blank to use native Anthropic auth.`}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The endpoint that accepts OpenAI-compatible and Anthropic requests
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="opusModel" className="text-sm">
|
||||
Opus Mapping
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_OPUS
|
||||
</Badge>
|
||||
</Label>
|
||||
{/* API Key - optional for presets that don't require it */}
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="apiKey">
|
||||
API Key{' '}
|
||||
{currentPreset?.requiresApiKey !== false && (
|
||||
<span className="text-destructive">*</span>
|
||||
)}
|
||||
{currentPreset?.requiresApiKey === false && (
|
||||
<span className="text-muted-foreground text-xs font-normal">
|
||||
(optional)
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="opusModel"
|
||||
{...register('opusModel')}
|
||||
placeholder="e.g. o1, claude-opus-4.5"
|
||||
className="font-mono text-sm h-9"
|
||||
id="apiKey"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
{...register('apiKey')}
|
||||
placeholder={
|
||||
currentPreset?.requiresApiKey === false
|
||||
? 'Optional - only if auth is enabled'
|
||||
: (currentPreset?.apiKeyPlaceholder ?? 'sk-...')
|
||||
}
|
||||
className="pr-10"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-9 w-9 text-muted-foreground hover:text-foreground"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
tabIndex={-1}
|
||||
>
|
||||
{showApiKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
|
||||
</Button>
|
||||
</div>
|
||||
{errors.apiKey ? (
|
||||
<p className="text-xs text-destructive">{errors.apiKey.message}</p>
|
||||
) : currentPreset?.requiresApiKey === false ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Only needed if your local endpoint has authentication enabled
|
||||
</p>
|
||||
) : (
|
||||
currentPreset?.apiKeyHint && (
|
||||
<p className="text-xs text-muted-foreground">{currentPreset.apiKeyHint}</p>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="haikuModel" className="text-sm">
|
||||
Haiku Mapping
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_HAIKU
|
||||
</Badge>
|
||||
</Label>
|
||||
<Input
|
||||
id="haikuModel"
|
||||
{...register('haikuModel')}
|
||||
placeholder="e.g. gpt-4o-mini, claude-3.5-haiku"
|
||||
className="font-mono text-sm h-9"
|
||||
/>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="target">Default Target CLI</Label>
|
||||
<Select
|
||||
value={targetValue}
|
||||
onValueChange={(value) => setValue('target', value as CliTarget)}
|
||||
>
|
||||
<SelectTrigger id="target">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="claude">Claude Code (default)</SelectItem>
|
||||
<SelectItem value="droid">Factory Droid</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Run with{' '}
|
||||
<code className="bg-muted px-1 rounded text-[10px]">
|
||||
{targetValue === 'droid' ? 'ccsd' : 'ccs'}
|
||||
</code>{' '}
|
||||
by default. You can still override each run with{' '}
|
||||
<code className="bg-muted px-1 rounded text-[10px]">--target</code>.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="models" className="p-6 mt-0 space-y-4">
|
||||
<div className="flex items-start gap-3 p-3 bg-blue-50 dark:bg-blue-950/20 text-blue-800 dark:text-blue-300 rounded-md text-sm border border-blue-100 dark:border-blue-900/30">
|
||||
<Info className="w-5 h-5 shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="font-medium mb-1">Model Mapping</p>
|
||||
<p className="text-xs opacity-90">
|
||||
Map Claude Code tiers (Opus/Sonnet/Haiku) to models supported by your
|
||||
provider.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={createMutation.isPending}
|
||||
className={cn(createMutation.isPending && 'opacity-80')}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Profile
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Tabs>
|
||||
{/* OpenRouter Model Picker */}
|
||||
{isOpenRouter && (
|
||||
<div className="space-y-2">
|
||||
<Label>Search Models</Label>
|
||||
<Input
|
||||
value={modelSearch}
|
||||
onChange={(e) => setModelSearch(e.target.value)}
|
||||
placeholder="Type to search (e.g., opus, sonnet, gpt-4o)..."
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && filteredModels.length > 0) {
|
||||
e.preventDefault();
|
||||
handleModelSelect(filteredModels[0]);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="border rounded-md max-h-48 overflow-y-auto">
|
||||
{filteredModels.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground p-3 text-center">
|
||||
{modelSearch
|
||||
? `No models found for "${modelSearch}"`
|
||||
: 'Loading models...'}
|
||||
</p>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
{!modelSearch && (
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-xs text-muted-foreground">
|
||||
<Sparkles className="w-3 h-3 text-accent" />
|
||||
<span>Newest Models</span>
|
||||
</div>
|
||||
)}
|
||||
{filteredModels.map((model) => (
|
||||
<ModelSearchItem
|
||||
key={model.id}
|
||||
model={model}
|
||||
onClick={() => handleModelSelect(model)}
|
||||
showAge={!modelSearch}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Model Inputs */}
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="model">
|
||||
Default Model
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
ANTHROPIC_MODEL
|
||||
</Badge>
|
||||
</Label>
|
||||
<Input
|
||||
id="model"
|
||||
{...register('model')}
|
||||
placeholder={currentPreset?.defaultModel ?? 'claude-sonnet-4'}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 pt-2 border-t">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="sonnetModel" className="text-sm">
|
||||
Sonnet Mapping
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_SONNET
|
||||
</Badge>
|
||||
</Label>
|
||||
<Input
|
||||
id="sonnetModel"
|
||||
{...register('sonnetModel')}
|
||||
placeholder="e.g. gpt-4o, claude-sonnet-4"
|
||||
className="font-mono text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="opusModel" className="text-sm">
|
||||
Opus Mapping
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_OPUS
|
||||
</Badge>
|
||||
</Label>
|
||||
<Input
|
||||
id="opusModel"
|
||||
{...register('opusModel')}
|
||||
placeholder="e.g. o1, claude-opus-4.5"
|
||||
className="font-mono text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="haikuModel" className="text-sm">
|
||||
Haiku Mapping
|
||||
<Badge variant="outline" className="ml-2 text-[10px] font-mono">
|
||||
DEFAULT_HAIKU
|
||||
</Badge>
|
||||
</Label>
|
||||
<Input
|
||||
id="haikuModel"
|
||||
{...register('haikuModel')}
|
||||
placeholder="e.g. gpt-4o-mini, claude-3.5-haiku"
|
||||
className="font-mono text-sm h-9"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="p-6 pt-4 border-t bg-muted/10">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isCreating}
|
||||
className={cn(isCreating && 'opacity-80')}
|
||||
>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Profile
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Tabs>
|
||||
)}
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
api,
|
||||
type CreateProfile,
|
||||
type CreateCliproxyBridgeProfileRequest,
|
||||
type UpdateProfile,
|
||||
type RegisterProfileOrphansRequest,
|
||||
type CopyProfileRequest,
|
||||
@@ -36,6 +37,22 @@ export function useCreateProfile() {
|
||||
});
|
||||
}
|
||||
|
||||
export function useCreateCliproxyBridgeProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (data: CreateCliproxyBridgeProfileRequest) =>
|
||||
api.profiles.createCliproxyBridge(data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||
toast.success('CLIProxy-routed profile created successfully');
|
||||
},
|
||||
onError: (error: Error) => {
|
||||
toast.error(error.message);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useUpdateProfile() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
|
||||
@@ -96,11 +96,22 @@ async function request<T>(url: string, options?: RequestInit): Promise<T> {
|
||||
// Types
|
||||
export type CliTarget = 'claude' | 'droid';
|
||||
|
||||
export interface CliproxyBridgeMetadata {
|
||||
provider: CLIProxyProvider;
|
||||
providerDisplayName: string;
|
||||
routePath: string;
|
||||
currentBaseUrl: string;
|
||||
source: 'local' | 'remote';
|
||||
usesCurrentTarget: boolean;
|
||||
usesCurrentAuthToken: boolean;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
name: string;
|
||||
settingsPath: string;
|
||||
configured: boolean;
|
||||
target?: CliTarget;
|
||||
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
||||
}
|
||||
|
||||
export interface CreateProfile {
|
||||
@@ -124,6 +135,19 @@ export interface UpdateProfile {
|
||||
target?: CliTarget;
|
||||
}
|
||||
|
||||
export interface CreateCliproxyBridgeProfileRequest {
|
||||
provider: CLIProxyProvider;
|
||||
name?: string;
|
||||
target?: CliTarget;
|
||||
}
|
||||
|
||||
export interface CreateCliproxyBridgeProfileResponse {
|
||||
name: string;
|
||||
settingsPath: string;
|
||||
target: CliTarget;
|
||||
cliproxyBridge?: CliproxyBridgeMetadata | null;
|
||||
}
|
||||
|
||||
export interface ProfileValidationIssue {
|
||||
level: 'error' | 'warning';
|
||||
code: string;
|
||||
@@ -762,6 +786,11 @@ export const api = {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
createCliproxyBridge: (data: CreateCliproxyBridgeProfileRequest) =>
|
||||
request<CreateCliproxyBridgeProfileResponse>('/profiles/cliproxy-bridge', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
update: (name: string, data: UpdateProfile) =>
|
||||
request(`/profiles/${name}`, {
|
||||
method: 'PUT',
|
||||
|
||||
+72
-6
@@ -38,9 +38,14 @@ import { cn } from '@/lib/utils';
|
||||
import { CopyButton } from '@/components/ui/copy-button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import type { CLIProxyProvider } from '@/lib/provider-config';
|
||||
import { isValidProvider } from '@/lib/provider-config';
|
||||
|
||||
export function ApiPage() {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, isError, refetch } = useProfiles();
|
||||
const deleteMutation = useDeleteProfile();
|
||||
const discoverOrphansMutation = useDiscoverProfileOrphans();
|
||||
@@ -51,9 +56,11 @@ export function ApiPage() {
|
||||
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [isCreateDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [createMode, setCreateMode] = useState<'normal' | 'openrouter' | 'alibaba-coding-plan'>(
|
||||
'normal'
|
||||
);
|
||||
const [createMode, setCreateMode] = useState<
|
||||
'normal' | 'openrouter' | 'alibaba-coding-plan' | 'cliproxy'
|
||||
>('normal');
|
||||
const [createDialogCliproxyProvider, setCreateDialogCliproxyProvider] =
|
||||
useState<CLIProxyProvider | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [editorHasChanges, setEditorHasChanges] = useState(false);
|
||||
const [pendingSwitch, setPendingSwitch] = useState<string | null>(null);
|
||||
@@ -68,6 +75,34 @@ export function ApiPage() {
|
||||
const selectedProfileData = selectedProfile
|
||||
? profiles.find((p) => p.name === selectedProfile)
|
||||
: null;
|
||||
const cliproxyBridgeIntent = useMemo(() => {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const rawProvider = params.get('cliproxyProvider');
|
||||
const wantsBridge = params.get('cliproxyBridge') === '1' || rawProvider !== null;
|
||||
if (!wantsBridge) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = rawProvider && isValidProvider(rawProvider) ? rawProvider : null;
|
||||
|
||||
return {
|
||||
provider,
|
||||
};
|
||||
}, [location.search]);
|
||||
|
||||
const clearCliproxyBridgeIntent = () => {
|
||||
if (!cliproxyBridgeIntent) {
|
||||
return;
|
||||
}
|
||||
|
||||
navigate({ pathname: location.pathname, search: '' }, { replace: true });
|
||||
};
|
||||
|
||||
const resolvedCreateMode = cliproxyBridgeIntent ? 'cliproxy' : createMode;
|
||||
const resolvedCreateDialogProvider = cliproxyBridgeIntent
|
||||
? cliproxyBridgeIntent.provider
|
||||
: createDialogCliproxyProvider;
|
||||
const isResolvedCreateDialogOpen = isCreateDialogOpen || !!cliproxyBridgeIntent;
|
||||
|
||||
const switchToProfile = (name: string) => {
|
||||
if (editorHasChanges && selectedProfile !== name) {
|
||||
@@ -91,6 +126,7 @@ export function ApiPage() {
|
||||
};
|
||||
|
||||
const handleCreateSuccess = (name: string) => {
|
||||
clearCliproxyBridgeIntent();
|
||||
setCreateDialogOpen(false);
|
||||
switchToProfile(name);
|
||||
};
|
||||
@@ -98,6 +134,13 @@ export function ApiPage() {
|
||||
switchToProfile(name);
|
||||
};
|
||||
|
||||
const handleCreateDialogOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
clearCliproxyBridgeIntent();
|
||||
}
|
||||
setCreateDialogOpen(open);
|
||||
};
|
||||
|
||||
const triggerDownload = (filename: string, bundle: ApiProfileExportBundle) => {
|
||||
const content = JSON.stringify(bundle, null, 2) + '\n';
|
||||
const blob = new Blob([content], { type: 'application/json' });
|
||||
@@ -292,6 +335,18 @@ export function ApiPage() {
|
||||
{t('apiProfiles.noProfilesDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setCreateMode('cliproxy');
|
||||
setCreateDialogCliproxyProvider(null);
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Server className="w-4 h-4 mr-1" />
|
||||
Use CLIProxy
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -385,6 +440,11 @@ export function ApiPage() {
|
||||
</>
|
||||
) : (
|
||||
<OpenRouterQuickStart
|
||||
onCliproxyClick={() => {
|
||||
setCreateMode('cliproxy');
|
||||
setCreateDialogCliproxyProvider(null);
|
||||
setCreateDialogOpen(true);
|
||||
}}
|
||||
onOpenRouterClick={() => {
|
||||
setCreateMode('openrouter');
|
||||
setCreateDialogOpen(true);
|
||||
@@ -411,10 +471,11 @@ export function ApiPage() {
|
||||
/>
|
||||
|
||||
<ProfileCreateDialog
|
||||
open={isCreateDialogOpen}
|
||||
onOpenChange={setCreateDialogOpen}
|
||||
open={isResolvedCreateDialogOpen}
|
||||
onOpenChange={handleCreateDialogOpenChange}
|
||||
onSuccess={handleCreateSuccess}
|
||||
initialMode={createMode}
|
||||
initialMode={resolvedCreateMode}
|
||||
initialCliproxyProvider={resolvedCreateDialogProvider}
|
||||
/>
|
||||
|
||||
<ConfirmDialog
|
||||
@@ -480,6 +541,11 @@ function ProfileListItem({
|
||||
<Badge variant="outline" className="text-[10px] h-4 px-1.5 uppercase">
|
||||
{profile.target || 'claude'}
|
||||
</Badge>
|
||||
{profile.cliproxyBridge && (
|
||||
<Badge variant="secondary" className="text-[10px] h-4 px-1.5">
|
||||
CLIProxy {profile.cliproxyBridge.provider}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 min-w-0">
|
||||
<div className="text-xs text-muted-foreground truncate flex-1">
|
||||
|
||||
+105
-84
@@ -15,6 +15,7 @@ import { QuickSetupWizard } from '@/components/quick-setup-wizard';
|
||||
import { AddAccountDialog } from '@/components/account/add-account-dialog';
|
||||
import { AccountSafetyWarningCard } from '@/components/account/account-safety-warning-card';
|
||||
import { ProviderEditor } from '@/components/cliproxy/provider-editor';
|
||||
import { ApiProfileBridgeCallout } from '@/components/cliproxy/api-profile-bridge-callout';
|
||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||
import { ProxyStatusWidget } from '@/components/monitoring/proxy-status-widget';
|
||||
import {
|
||||
@@ -436,93 +437,113 @@ export function CliproxyPage() {
|
||||
)}
|
||||
|
||||
{selectedVariantData && parentAuthForVariant ? (
|
||||
// Variant selected - show ProviderEditor with variant profile name
|
||||
<ProviderEditor
|
||||
provider={selectedVariantData.name}
|
||||
displayName={t('cliproxyPage.variantDisplay', {
|
||||
name: selectedVariantData.name,
|
||||
provider: selectedVariantData.provider,
|
||||
})}
|
||||
authStatus={parentAuthForVariant}
|
||||
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
|
||||
logoProvider={selectedVariantData.provider}
|
||||
baseProvider={selectedVariantData.provider}
|
||||
defaultTarget={selectedVariantData.target}
|
||||
isRemoteMode={isRemoteMode}
|
||||
port={selectedVariantData.port}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
<>
|
||||
<ApiProfileBridgeCallout
|
||||
provider={selectedVariantData.provider}
|
||||
compact
|
||||
className="mx-4 mt-4"
|
||||
/>
|
||||
<ProviderEditor
|
||||
provider={selectedVariantData.name}
|
||||
displayName={t('cliproxyPage.variantDisplay', {
|
||||
name: selectedVariantData.name,
|
||||
provider: selectedVariantData.provider,
|
||||
displayName: parentAuthForVariant.displayName,
|
||||
isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0,
|
||||
})
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onRemoveAccount={(accountId) =>
|
||||
removeMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onPauseToggle={(accountId, paused) =>
|
||||
handlePauseToggle(selectedVariantData.provider, accountId, paused)
|
||||
}
|
||||
onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)}
|
||||
onBulkPause={(accountIds) => handleBulkPause(selectedVariantData.provider, accountIds)}
|
||||
onBulkResume={(accountIds) =>
|
||||
handleBulkResume(selectedVariantData.provider, accountIds)
|
||||
}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
isSoloingAccount={soloMutation.isPending}
|
||||
isBulkPausing={bulkPauseMutation.isPending}
|
||||
isBulkResuming={bulkResumeMutation.isPending}
|
||||
/>
|
||||
})}
|
||||
authStatus={parentAuthForVariant}
|
||||
catalog={MODEL_CATALOGS[selectedVariantData.provider]}
|
||||
logoProvider={selectedVariantData.provider}
|
||||
baseProvider={selectedVariantData.provider}
|
||||
defaultTarget={selectedVariantData.target}
|
||||
isRemoteMode={isRemoteMode}
|
||||
port={selectedVariantData.port}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedVariantData.provider,
|
||||
displayName: parentAuthForVariant.displayName,
|
||||
isFirstAccount: (parentAuthForVariant.accounts?.length || 0) === 0,
|
||||
})
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onRemoveAccount={(accountId) =>
|
||||
removeMutation.mutate({
|
||||
provider: selectedVariantData.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onPauseToggle={(accountId, paused) =>
|
||||
handlePauseToggle(selectedVariantData.provider, accountId, paused)
|
||||
}
|
||||
onSoloMode={(accountId) => handleSoloMode(selectedVariantData.provider, accountId)}
|
||||
onBulkPause={(accountIds) =>
|
||||
handleBulkPause(selectedVariantData.provider, accountIds)
|
||||
}
|
||||
onBulkResume={(accountIds) =>
|
||||
handleBulkResume(selectedVariantData.provider, accountIds)
|
||||
}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
isSoloingAccount={soloMutation.isPending}
|
||||
isBulkPausing={bulkPauseMutation.isPending}
|
||||
isBulkResuming={bulkResumeMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
) : selectedStatus ? (
|
||||
<ProviderEditor
|
||||
provider={selectedStatus.provider}
|
||||
displayName={selectedStatus.displayName}
|
||||
authStatus={selectedStatus}
|
||||
catalog={MODEL_CATALOGS[selectedStatus.provider]}
|
||||
isRemoteMode={isRemoteMode}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedStatus.provider,
|
||||
displayName: selectedStatus.displayName,
|
||||
isFirstAccount: (selectedStatus.accounts?.length || 0) === 0,
|
||||
})
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onRemoveAccount={(accountId) =>
|
||||
removeMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onPauseToggle={(accountId, paused) =>
|
||||
handlePauseToggle(selectedStatus.provider, accountId, paused)
|
||||
}
|
||||
onSoloMode={(accountId) => handleSoloMode(selectedStatus.provider, accountId)}
|
||||
onBulkPause={(accountIds) => handleBulkPause(selectedStatus.provider, accountIds)}
|
||||
onBulkResume={(accountIds) => handleBulkResume(selectedStatus.provider, accountIds)}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
isSoloingAccount={soloMutation.isPending}
|
||||
isBulkPausing={bulkPauseMutation.isPending}
|
||||
isBulkResuming={bulkResumeMutation.isPending}
|
||||
/>
|
||||
<>
|
||||
<ApiProfileBridgeCallout
|
||||
provider={
|
||||
isValidProvider(selectedStatus.provider) ? selectedStatus.provider : undefined
|
||||
}
|
||||
compact
|
||||
className="mx-4 mt-4"
|
||||
/>
|
||||
<ProviderEditor
|
||||
provider={selectedStatus.provider}
|
||||
displayName={selectedStatus.displayName}
|
||||
authStatus={selectedStatus}
|
||||
catalog={MODEL_CATALOGS[selectedStatus.provider]}
|
||||
isRemoteMode={isRemoteMode}
|
||||
onAddAccount={() =>
|
||||
setAddAccountProvider({
|
||||
provider: selectedStatus.provider,
|
||||
displayName: selectedStatus.displayName,
|
||||
isFirstAccount: (selectedStatus.accounts?.length || 0) === 0,
|
||||
})
|
||||
}
|
||||
onSetDefault={(accountId) =>
|
||||
setDefaultMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onRemoveAccount={(accountId) =>
|
||||
removeMutation.mutate({
|
||||
provider: selectedStatus.provider,
|
||||
accountId,
|
||||
})
|
||||
}
|
||||
onPauseToggle={(accountId, paused) =>
|
||||
handlePauseToggle(selectedStatus.provider, accountId, paused)
|
||||
}
|
||||
onSoloMode={(accountId) => handleSoloMode(selectedStatus.provider, accountId)}
|
||||
onBulkPause={(accountIds) => handleBulkPause(selectedStatus.provider, accountIds)}
|
||||
onBulkResume={(accountIds) => handleBulkResume(selectedStatus.provider, accountIds)}
|
||||
isRemovingAccount={removeMutation.isPending}
|
||||
isPausingAccount={pauseMutation.isPending || resumeMutation.isPending}
|
||||
isSoloingAccount={soloMutation.isPending}
|
||||
isBulkPausing={bulkPauseMutation.isPending}
|
||||
isBulkResuming={bulkResumeMutation.isPending}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
|
||||
<div className="flex flex-1 flex-col min-h-0">
|
||||
<ApiProfileBridgeCallout compact className="mx-4 mt-4" />
|
||||
<EmptyProviderState onSetup={() => setWizardOpen(true)} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -10,6 +10,10 @@ vi.mock('@/hooks/use-profiles', () => ({
|
||||
mutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
useCreateCliproxyBridgeProfile: () => ({
|
||||
mutateAsync,
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-openrouter-models', () => ({
|
||||
@@ -18,6 +22,12 @@ vi.mock('@/hooks/use-openrouter-models', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@/hooks/use-cliproxy', () => ({
|
||||
useCliproxyAuth: () => ({
|
||||
data: { authStatus: [] },
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('ProfileCreateDialog', () => {
|
||||
beforeEach(() => {
|
||||
mutateAsync.mockReset();
|
||||
|
||||
Reference in New Issue
Block a user