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