feat(profiles): add cliproxy api profile bridge

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

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

- cover bridge routes and dialog behavior with focused tests

Refs #649
This commit is contained in:
Tam Nhu Tran committed 2026-03-19 10:24:28 -04:00
1 parent 1f284132ce
commit 287691fa04
26 files changed
+1804 -465

No files matched your search

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