diff --git a/src/web-server/routes/variant-routes.ts b/src/web-server/routes/variant-routes.ts index 0feee3d7..e1efa7c7 100644 --- a/src/web-server/routes/variant-routes.ts +++ b/src/web-server/routes/variant-routes.ts @@ -13,6 +13,8 @@ import { listVariants, validateProfileName, updateVariant, + createCompositeVariant, + updateCompositeVariant, } from '../../cliproxy/services/variant-service'; const router = Router(); @@ -30,6 +32,9 @@ router.get('/', (_req: Request, res: Response) => { account: variant.account || 'default', port: variant.port, // Include port for port isolation model: variant.model, + type: variant.type, + default_tier: variant.default_tier, + tiers: variant.tiers, })); res.json({ variants: variantList }); @@ -40,10 +45,10 @@ router.get('/', (_req: Request, res: Response) => { * Uses variant-service for proper port allocation */ router.post('/', (req: Request, res: Response): void => { - const { name, provider, model, account } = req.body; + const { name, provider, model, account, type, default_tier, tiers } = req.body; - if (!name || !provider) { - res.status(400).json({ error: 'Missing required fields: name, provider' }); + if (!name) { + res.status(400).json({ error: 'Missing required field: name' }); return; } @@ -63,6 +68,37 @@ router.post('/', (req: Request, res: Response): void => { return; } + // Handle composite variant creation + if (type === 'composite') { + if (!default_tier || !tiers) { + res.status(400).json({ error: 'Missing required fields: default_tier, tiers' }); + return; + } + + const result = createCompositeVariant({ name, defaultTier: default_tier, tiers }); + + if (!result.success) { + res.status(409).json({ error: result.error }); + return; + } + + res.status(201).json({ + name, + type: 'composite', + default_tier, + tiers, + settings: result.settingsPath, + port: result.variant?.port, + }); + return; + } + + // Handle single provider variant creation + if (!provider) { + res.status(400).json({ error: 'Missing required field: provider' }); + return; + } + // Require model for variant creation (prevents empty model causing issues) if (!model || !model.trim()) { res.status(400).json({ error: 'Missing required field: model' }); @@ -94,9 +130,43 @@ router.post('/', (req: Request, res: Response): void => { router.put('/:name', (req: Request, res: Response): void => { try { const { name } = req.params; - const { provider, account, model } = req.body; + const { provider, account, model, default_tier, tiers } = req.body; - // Use variant-service for proper update handling + // Check if variant is composite - use updateCompositeVariant if so + const variants = listVariants(); + const existing = variants[name]; + + if (!existing) { + res.status(404).json({ error: `Variant '${name}' not found` }); + return; + } + + if (existing.type === 'composite') { + if (!default_tier || !tiers) { + res.status(400).json({ error: 'Missing required fields for composite update' }); + return; + } + + const result = updateCompositeVariant(name, { defaultTier: default_tier, tiers }); + + if (!result.success) { + res.status(404).json({ error: result.error }); + return; + } + + res.json({ + name, + type: 'composite', + default_tier, + tiers, + settings: result.variant?.settings, + port: result.variant?.port, + updated: true, + }); + return; + } + + // Use variant-service for proper update handling (single provider) const result = updateVariant(name, { provider, account, model }); if (!result.success) { diff --git a/ui/src/components/cliproxy/cliproxy-dialog.tsx b/ui/src/components/cliproxy/cliproxy-dialog.tsx index 6ad1cce9..41ee528d 100644 --- a/ui/src/components/cliproxy/cliproxy-dialog.tsx +++ b/ui/src/components/cliproxy/cliproxy-dialog.tsx @@ -1,21 +1,24 @@ /** * CLIProxy Variant Dialog Component * Phase 03: REST API Routes & CRUD + * Phase 05: Dashboard UI full CRUD for composite variants * Phase 06: Multi-Account Support */ import { useForm, useWatch } from 'react-hook-form'; import { zodResolver } from '@hookform/resolvers/zod'; import * as z from 'zod'; +import { useState } from 'react'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; import { useCreateVariant, useCliproxyAuth } from '@/hooks/use-cliproxy'; import { usePrivacy } from '@/contexts/privacy-context'; import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; -const schema = z.object({ +const singleProviderSchema = z.object({ name: z .string() .min(1, 'Name is required') @@ -25,7 +28,33 @@ const schema = z.object({ account: z.string().optional(), }); -type FormData = z.infer; +const compositeSchema = z.object({ + name: z + .string() + .min(1, 'Name is required') + .regex(/^[a-zA-Z][a-zA-Z0-9._-]*$/, 'Invalid variant name'), + default_tier: z.enum(['opus', 'sonnet', 'haiku'], { message: 'Default tier is required' }), + tiers: z.object({ + opus: z.object({ + provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), + model: z.string().min(1, 'Model is required'), + account: z.string().optional(), + }), + sonnet: z.object({ + provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), + model: z.string().min(1, 'Model is required'), + account: z.string().optional(), + }), + haiku: z.object({ + provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), + model: z.string().min(1, 'Model is required'), + account: z.string().optional(), + }), + }), +}); + +type SingleProviderFormData = z.infer; +type CompositeFormData = z.infer; interface CliproxyDialogProps { open: boolean; @@ -41,112 +70,235 @@ export function CliproxyDialog({ open, onClose }: CliproxyDialogProps) { const createMutation = useCreateVariant(); const { data: authData } = useCliproxyAuth(); const { privacyMode } = usePrivacy(); + const [mode, setMode] = useState<'single' | 'composite'>('single'); - const { - register, - handleSubmit, - control, - formState: { errors }, - reset, - } = useForm({ - resolver: zodResolver(schema), + const singleForm = useForm({ + resolver: zodResolver(singleProviderSchema), }); - // Watch provider to show relevant accounts - const selectedProvider = useWatch({ control, name: 'provider' }); + const compositeForm = useForm({ + resolver: zodResolver(compositeSchema), + defaultValues: { + default_tier: 'opus', + tiers: { + opus: { provider: 'gemini', model: '' }, + sonnet: { provider: 'gemini', model: '' }, + haiku: { provider: 'gemini', model: '' }, + }, + }, + }); - // Get accounts for selected provider + const selectedProvider = useWatch({ control: singleForm.control, name: 'provider' }); const providerAuth = authData?.authStatus.find((s) => s.provider === selectedProvider); const providerAccounts = providerAuth?.accounts || []; - const onSubmit = async (data: FormData) => { + const onSubmitSingle = async (data: SingleProviderFormData) => { try { await createMutation.mutateAsync(data); - reset(); + singleForm.reset(); onClose(); } catch (error) { console.error('Failed to create variant:', error); } }; + const onSubmitComposite = async (data: CompositeFormData) => { + try { + await createMutation.mutateAsync({ + name: data.name, + provider: 'gemini', + type: 'composite', + default_tier: data.default_tier, + tiers: data.tiers, + }); + compositeForm.reset(); + onClose(); + } catch (error) { + console.error('Failed to create composite variant:', error); + } + }; + return ( - + Create CLIProxy Variant -
-
- - - {errors.name && {errors.name.message}} -
-
- - - {errors.provider && ( - {errors.provider.message} - )} -
- - {/* Account selector - only show if provider has accounts */} - {selectedProvider && providerAccounts.length > 0 && ( -
- - - - Select which OAuth account this variant should use - -
- )} - - {/* Show message if provider selected but no accounts */} - {selectedProvider && providerAccounts.length === 0 && providerAuth && ( -
- No accounts authenticated for {providerAuth.displayName}. -
- ccs {selectedProvider} --auth -
- )} - -
- - -
- -
- -
-
+ + {mode === 'single' ? ( +
+
+ + + {singleForm.formState.errors.name && ( + + {singleForm.formState.errors.name.message} + + )} +
+ +
+ + + {singleForm.formState.errors.provider && ( + + {singleForm.formState.errors.provider.message} + + )} +
+ + {selectedProvider && providerAccounts.length > 0 && ( +
+ + +
+ )} + +
+ + +
+ +
+ + +
+
+ ) : ( +
+
+ + + {compositeForm.formState.errors.name && ( + + {compositeForm.formState.errors.name.message} + + )} +
+ +
+ + + + Opus + Sonnet + Haiku + + {(['opus', 'sonnet', 'haiku'] as const).map((tier) => ( + +
+ + +
+
+ + + {compositeForm.formState.errors.tiers?.[tier]?.model && ( + + {compositeForm.formState.errors.tiers[tier]?.model?.message} + + )} +
+
+ + +
+
+ ))} +
+
+ +
+ + +
+ +
+ + +
+
+ )} +
); diff --git a/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx new file mode 100644 index 00000000..de08aca8 --- /dev/null +++ b/ui/src/components/cliproxy/cliproxy-edit-dialog.tsx @@ -0,0 +1,245 @@ +/** + * CLIProxy Variant Edit Dialog Component + * Phase 05: Dashboard UI full CRUD for composite variants + */ + +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import * as z from 'zod'; +import { useEffect } from 'react'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { useUpdateVariant } from '@/hooks/use-cliproxy'; +import { CLIPROXY_PROVIDERS, getProviderDisplayName } from '@/lib/provider-config'; +import type { Variant } from '@/lib/api-client'; + +const singleProviderSchema = z.object({ + provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), + model: z.string().optional(), + account: z.string().optional(), +}); + +const compositeSchema = z.object({ + default_tier: z.enum(['opus', 'sonnet', 'haiku'], { message: 'Default tier is required' }), + tiers: z.object({ + opus: z.object({ + provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), + model: z.string().min(1, 'Model is required'), + account: z.string().optional(), + }), + sonnet: z.object({ + provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), + model: z.string().min(1, 'Model is required'), + account: z.string().optional(), + }), + haiku: z.object({ + provider: z.enum(CLIPROXY_PROVIDERS, { message: 'Provider is required' }), + model: z.string().min(1, 'Model is required'), + account: z.string().optional(), + }), + }), +}); + +type SingleProviderFormData = z.infer; +type CompositeFormData = z.infer; + +interface CliproxyEditDialogProps { + variant: Variant | null; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const providerOptions = CLIPROXY_PROVIDERS.map((id) => ({ + value: id, + label: getProviderDisplayName(id), +})); + +export function CliproxyEditDialog({ variant, open, onOpenChange }: CliproxyEditDialogProps) { + const updateMutation = useUpdateVariant(); + const isComposite = variant?.type === 'composite'; + + const singleForm = useForm({ + resolver: zodResolver(singleProviderSchema), + }); + + const compositeForm = useForm({ + resolver: zodResolver(compositeSchema), + }); + + // Pre-populate form when variant changes + useEffect(() => { + if (!variant) return; + + if (isComposite && variant.tiers && variant.default_tier) { + compositeForm.reset({ + default_tier: variant.default_tier, + tiers: variant.tiers, + }); + } else { + singleForm.reset({ + provider: variant.provider, + model: variant.model || '', + account: variant.account || '', + }); + } + }, [variant, isComposite, singleForm, compositeForm]); + + const onSubmitSingle = async (data: SingleProviderFormData) => { + if (!variant) return; + try { + await updateMutation.mutateAsync({ name: variant.name, data }); + onOpenChange(false); + } catch (error) { + console.error('Failed to update variant:', error); + } + }; + + const onSubmitComposite = async (data: CompositeFormData) => { + if (!variant) return; + try { + await updateMutation.mutateAsync({ + name: variant.name, + data: { + default_tier: data.default_tier, + tiers: data.tiers, + }, + }); + onOpenChange(false); + } catch (error) { + console.error('Failed to update composite variant:', error); + } + }; + + if (!variant) return null; + + return ( + + + + + Edit {isComposite ? 'Composite' : 'Single'} Variant: {variant.name} + + + + {isComposite ? ( +
+
+ + + + Opus + Sonnet + Haiku + + {(['opus', 'sonnet', 'haiku'] as const).map((tier) => ( + +
+ + +
+
+ + + {compositeForm.formState.errors.tiers?.[tier]?.model && ( + + {compositeForm.formState.errors.tiers[tier]?.model?.message} + + )} +
+
+ + +
+
+ ))} +
+
+ +
+ + +
+ +
+ + +
+
+ ) : ( +
+
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
+
+ )} +
+
+ ); +} diff --git a/ui/src/components/cliproxy/cliproxy-table.tsx b/ui/src/components/cliproxy/cliproxy-table.tsx index ab4c5ff2..e3f5996e 100644 --- a/ui/src/components/cliproxy/cliproxy-table.tsx +++ b/ui/src/components/cliproxy/cliproxy-table.tsx @@ -1,9 +1,11 @@ /** * CLIProxy Variants Table Component * Phase 03: REST API Routes & CRUD + * Phase 05: Dashboard UI full CRUD for composite variants * Phase 06: Multi-Account Support */ +import { useState } from 'react'; import { useReactTable, getCoreRowModel, flexRender, type ColumnDef } from '@tanstack/react-table'; import { Table, @@ -21,8 +23,9 @@ import { DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { MoreHorizontal, Trash2, User } from 'lucide-react'; +import { MoreHorizontal, Trash2, User, Pencil } from 'lucide-react'; import { useDeleteVariant } from '@/hooks/use-cliproxy'; +import { CliproxyEditDialog } from './cliproxy-edit-dialog'; import type { Variant } from '@/lib/api-client'; interface CliproxyTableProps { @@ -41,6 +44,7 @@ const providerLabels: Record = { export function CliproxyTable({ data }: CliproxyTableProps) { const deleteMutation = useDeleteVariant(); + const [editingVariant, setEditingVariant] = useState(null); const columns: ColumnDef[] = [ { @@ -51,7 +55,12 @@ export function CliproxyTable({ data }: CliproxyTableProps) { { accessorKey: 'provider', header: 'Provider', - cell: ({ row }) => providerLabels[row.original.provider] || row.original.provider, + cell: ({ row }) => { + if (row.original.type === 'composite') { + return composite; + } + return providerLabels[row.original.provider] || row.original.provider; + }, }, { accessorKey: 'account', @@ -91,6 +100,10 @@ export function CliproxyTable({ data }: CliproxyTableProps) { + setEditingVariant(row.original)}> + + Edit + deleteMutation.mutate(row.original.name)} @@ -124,33 +137,40 @@ export function CliproxyTable({ data }: CliproxyTableProps) { } return ( -
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender(header.column.columnDef.header, header.getContext())} - - ))} - - ))} - - - {table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender(cell.column.columnDef.cell, cell.getContext())} - - ))} - - ))} - -
-
+ <> + !open && setEditingVariant(null)} + /> +
+ + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender(header.column.columnDef.header, header.getContext())} + + ))} + + ))} + + + {table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender(cell.column.columnDef.cell, cell.getContext())} + + ))} + + ))} + +
+
+ ); } diff --git a/ui/src/lib/api-client.ts b/ui/src/lib/api-client.ts index a8d2bb87..f5e437d7 100644 --- a/ui/src/lib/api-client.ts +++ b/ui/src/lib/api-client.ts @@ -54,6 +54,13 @@ export interface Variant { account?: string; port?: number; model?: string; + type?: 'composite'; + default_tier?: 'opus' | 'sonnet' | 'haiku'; + tiers?: { + opus: { provider: string; model: string; account?: string; thinking?: string }; + sonnet: { provider: string; model: string; account?: string; thinking?: string }; + haiku: { provider: string; model: string; account?: string; thinking?: string }; + }; } export interface CreateVariant { @@ -61,6 +68,13 @@ export interface CreateVariant { provider: CLIProxyProvider; model?: string; account?: string; + type?: 'composite'; + default_tier?: 'opus' | 'sonnet' | 'haiku'; + tiers?: { + opus: { provider: string; model: string; account?: string; thinking?: string }; + sonnet: { provider: string; model: string; account?: string; thinking?: string }; + haiku: { provider: string; model: string; account?: string; thinking?: string }; + }; } export interface UpdateVariant {