diff --git a/ui/public/assets/providers/codex-color.svg b/ui/public/assets/providers/codex-color.svg new file mode 100644 index 00000000..c2e67e43 --- /dev/null +++ b/ui/public/assets/providers/codex-color.svg @@ -0,0 +1,24 @@ + + Codex + + + + + + + + + + diff --git a/ui/public/assets/providers/vertex.svg b/ui/public/assets/providers/vertex.svg new file mode 100644 index 00000000..6413e233 --- /dev/null +++ b/ui/public/assets/providers/vertex.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ui/src/components/cliproxy/ai-providers/family-rail.tsx b/ui/src/components/cliproxy/ai-providers/family-rail.tsx index d866f102..28e9432d 100644 --- a/ui/src/components/cliproxy/ai-providers/family-rail.tsx +++ b/ui/src/components/cliproxy/ai-providers/family-rail.tsx @@ -1,5 +1,6 @@ import { Badge } from '@/components/ui/badge'; import { ProviderLogo } from '@/components/cliproxy/provider-logo'; +import { getAiProviderFamilyVisual } from '@/lib/provider-config'; import { cn } from '@/lib/utils'; import type { AiProviderFamilyId, @@ -13,21 +14,6 @@ interface FamilyRailProps { onSelect: (family: AiProviderFamilyId) => void; } -function getFamilyProvider(familyId: AiProviderFamilyId): string { - switch (familyId) { - case 'gemini-api-key': - return 'gemini'; - case 'codex-api-key': - return 'codex'; - case 'claude-api-key': - return 'claude'; - case 'vertex-api-key': - return 'vertex'; - case 'openai-compatibility': - return 'openrouter'; - } -} - function getStatusState(status: AiProviderFamilyState['status']) { switch (status) { case 'ready': @@ -72,7 +58,7 @@ export function FamilyRail({ families, selectedFamily, onSelect }: FamilyRailPro )} >
- +
{family.displayName} diff --git a/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx b/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx index abac67eb..7457caae 100644 --- a/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx +++ b/ui/src/components/cliproxy/ai-providers/provider-entry-card.tsx @@ -54,9 +54,11 @@ export function ProviderEntryCard({ role={onSelect ? 'button' : undefined} tabIndex={onSelect ? 0 : undefined} className={cn( - 'group rounded-lg border bg-background px-3 py-3 transition-colors', + 'group rounded-xl border bg-background px-3 py-3 transition-colors', onSelect && 'cursor-pointer', - isSelected ? 'border-primary/20 bg-primary/5 shadow-sm' : 'hover:bg-muted/40' + isSelected + ? 'border-primary/20 bg-primary/5 shadow-sm' + : 'border-border/60 hover:bg-muted/40' )} onClick={onSelect} onKeyDown={(event) => { @@ -84,18 +86,25 @@ export function ProviderEntryCard({ {entry.label} {renderSecretBadge(entry)}
-

+

{entry.baseUrl || family.routePath} -

-
- {renderCountLabel(entry.models.length, 'alias')} - - {renderCountLabel(entry.headers.length, 'header')} +
+
+ + {renderCountLabel(entry.models.length, 'alias')} + + + {renderCountLabel(entry.headers.length, 'header')} + {entry.excludedModels.length > 0 && ( - <> - - {renderCountLabel(entry.excludedModels.length, 'exclusion')} - + + {renderCountLabel(entry.excludedModels.length, 'rule')} + + )} + {(entry.proxyUrl || entry.prefix) && ( + + Routed + )}
diff --git a/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx index d23d710a..d9d602d7 100644 --- a/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx +++ b/ui/src/components/cliproxy/ai-providers/provider-entry-dialog.tsx @@ -1,5 +1,9 @@ import { useMemo, useState } from 'react'; +import { ProviderLogo } from '@/components/cliproxy/provider-logo'; +import { getAiProviderFamilyVisual } from '@/lib/provider-config'; +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; import { Dialog, DialogContent, @@ -10,6 +14,8 @@ import { } from '@/components/ui/dialog'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; +import { cn } from '@/lib/utils'; +import { ChevronDown, KeyRound, SlidersHorizontal } from 'lucide-react'; import type { AiProviderEntryView, AiProviderFamilyId, @@ -25,6 +31,146 @@ interface ProviderEntryDialogProps { isSaving: boolean; } +type DialogGuide = { + familyName: string; + description: string; + requiredNow: string[]; + optionalLater: string[]; + keyLabel: string; + keyPlaceholder: string; + keyHelper: string; + connectorPlaceholder?: string; + connectorHelper?: string; + baseUrlPlaceholder: string; + baseUrlHelper: string; + aliasesPlaceholder: string; + aliasesHelper: string; + headersPlaceholder: string; +}; + +function getDialogGuide(family: AiProviderFamilyId): DialogGuide { + switch (family) { + case 'gemini-api-key': + return { + familyName: 'Gemini', + description: + 'Store the Gemini key here so CLIProxy can route Gemini requests without creating a separate CCS API Profile.', + requiredNow: [ + 'Paste the Gemini API key.', + 'Leave Base URL empty unless you use a custom Gemini host.', + ], + optionalLater: [ + 'Model mappings only when requested names and Gemini names differ.', + 'Headers only when your provider setup requires them.', + ], + keyLabel: 'Gemini API Key', + keyPlaceholder: 'AIza...', + keyHelper: 'This is the only field most Gemini setups need.', + baseUrlPlaceholder: 'https://generativelanguage.googleapis.com', + baseUrlHelper: 'Optional. Leave blank to keep the default Gemini endpoint.', + aliasesPlaceholder: 'claude-sonnet-4-5=gemini-2.5-pro', + aliasesHelper: + 'Format: requested=upstream. Leave this blank unless the upstream Gemini model name differs.', + headersPlaceholder: 'X-Goog-User-Project: your-project', + }; + case 'codex-api-key': + return { + familyName: 'Codex', + description: + 'Store the Codex or OpenAI key here so CLIProxy can route Codex requests without duplicating the setup in API Profiles.', + requiredNow: [ + 'Paste the Codex or OpenAI API key.', + 'Leave Base URL empty unless this route should target another OpenAI-style endpoint.', + ], + optionalLater: [ + 'Model mappings only when the upstream model ID differs.', + 'Headers only when org or project routing needs them.', + ], + keyLabel: 'Codex API Key', + keyPlaceholder: 'sk-...', + keyHelper: 'This is the only field most Codex setups need.', + baseUrlPlaceholder: 'https://api.openai.com/v1', + baseUrlHelper: 'Optional. Leave blank to keep the default Codex endpoint.', + aliasesPlaceholder: 'claude-sonnet-4-5=gpt-5', + aliasesHelper: + 'Format: requested=upstream. Add a mapping only when the upstream model name differs.', + headersPlaceholder: 'OpenAI-Organization: org_...', + }; + case 'claude-api-key': + return { + familyName: 'Claude', + description: + 'Store the Anthropic or compatible key here for CLIProxy-managed Claude routing. Save the key first, then add rewrites only if this route needs them.', + requiredNow: [ + 'Paste the Claude or Anthropic-compatible API key.', + 'Leave Base URL empty unless this route should target another compatible endpoint.', + ], + optionalLater: [ + 'Model mappings only when the requested and upstream Claude model IDs differ.', + 'Proxy, prefix, exclusions, and headers only for advanced routing cases.', + ], + keyLabel: 'Claude API Key', + keyPlaceholder: 'sk-ant-...', + keyHelper: 'Most Claude routes can start with the key only.', + baseUrlPlaceholder: 'https://api.anthropic.com', + baseUrlHelper: 'Optional. Leave blank to keep the default Claude-compatible endpoint.', + aliasesPlaceholder: 'claude-sonnet-4-5=claude-3-7-sonnet-latest', + aliasesHelper: + 'Format: requested=upstream. Add a mapping only when the upstream model ID should differ.', + headersPlaceholder: 'X-Project: internal-routing', + }; + case 'vertex-api-key': + return { + familyName: 'Vertex', + description: + 'Store the Vertex key here so CLIProxy can route Vertex traffic without creating a separate CCS API Profile.', + requiredNow: [ + 'Paste the Vertex API key.', + 'Leave Base URL empty unless a regional or gateway endpoint is required.', + ], + optionalLater: [ + 'Model mappings only when the upstream name differs.', + 'Headers only when the provider expects extra routing context.', + ], + keyLabel: 'Vertex API Key', + keyPlaceholder: 'AIza...', + keyHelper: 'Most Vertex routes only need the key.', + baseUrlPlaceholder: 'https://vertex.googleapis.com', + baseUrlHelper: 'Optional. Leave blank to keep the default Vertex endpoint.', + aliasesPlaceholder: 'claude-sonnet-4-5=gemini-2.5-pro', + aliasesHelper: + 'Format: requested=upstream. Leave blank unless the upstream model name differs.', + headersPlaceholder: 'X-Goog-User-Project: your-project', + }; + case 'openai-compatibility': + return { + familyName: 'OpenAI-Compatible Connector', + description: + 'Create a named connector for OpenRouter, Together, or any OpenAI-style endpoint. This page owns the connector setup directly inside CLIProxy.', + requiredNow: [ + 'Pick a connector name such as openrouter or together.', + 'Set the connector Base URL.', + 'Add at least one API key before saving.', + ], + optionalLater: [ + 'Model mappings only when requested and upstream model names differ.', + 'Headers only when the connector requires provider-specific auth or routing.', + ], + keyLabel: 'API Keys', + keyPlaceholder: 'sk-...', + keyHelper: 'Add one key per line. Most connectors start with a single key.', + connectorPlaceholder: 'openrouter', + connectorHelper: 'This becomes the connector label in the saved entries list.', + baseUrlPlaceholder: 'https://openrouter.ai/api/v1', + baseUrlHelper: 'Required for connectors. This is the upstream OpenAI-style endpoint.', + aliasesPlaceholder: 'claude-sonnet-4-5=gpt-4.1', + aliasesHelper: + 'Format: requested=upstream. Leave blank unless the connector expects a different model ID.', + headersPlaceholder: 'HTTP-Referer: https://your-app.example', + }; + } +} + function parseDelimitedLines(value: string): string[] { return value .split('\n') @@ -51,8 +197,15 @@ function parseModelAliasLines(value: string) { .map((line) => line.trim()) .filter((line) => line.length > 0) .map((line) => { - const [name, ...rest] = line.split('='); - return { name: name.trim(), alias: rest.join('=').trim() }; + const separatorIndex = line.indexOf('='); + if (separatorIndex === -1) { + return { name: line.trim(), alias: '' }; + } + + return { + name: line.slice(0, separatorIndex).trim(), + alias: line.slice(separatorIndex + 1).trim(), + }; }) .filter((item) => item.name.length > 0 || item.alias.length > 0); } @@ -88,7 +241,38 @@ function formatExcludedModels(entry?: AiProviderEntryView | null): string { } function formatModelAliases(entry?: AiProviderEntryView | null): string { - return (entry?.models || []).map((item) => `${item.name}=${item.alias}`).join('\n'); + return (entry?.models || []) + .map((item) => (item.alias.trim() ? `${item.name}=${item.alias}` : item.name)) + .join('\n'); +} + +function ChecklistCard({ + title, + items, + icon, +}: { + title: string; + items: string[]; + icon: React.ReactNode; +}) { + return ( +
+
+ {icon} + {title} +
+
+ {items.map((item, index) => ( +
+
+ {index + 1} +
+
{item}
+
+ ))} +
+
+ ); } export function ProviderEntryDialog({ @@ -99,9 +283,11 @@ export function ProviderEntryDialog({ onSubmit, isSaving, }: ProviderEntryDialogProps) { + const guide = useMemo(() => getDialogGuide(family), [family]); const isEditing = Boolean(entry); const supportsOpenAiCompat = family === 'openai-compatibility'; const supportsClaudeAdvanced = family === 'claude-api-key'; + const [name, setName] = useState(() => entry?.name || ''); const [baseUrl, setBaseUrl] = useState(() => entry?.baseUrl || ''); const [proxyUrl, setProxyUrl] = useState(() => entry?.proxyUrl || ''); @@ -111,15 +297,25 @@ export function ProviderEntryDialog({ const [headers, setHeaders] = useState(() => formatHeaders(entry)); const [excludedModels, setExcludedModels] = useState(() => formatExcludedModels(entry)); const [modelAliases, setModelAliases] = useState(() => formatModelAliases(entry)); + const [advancedOpen, setAdvancedOpen] = useState(() => + Boolean( + entry?.headers.length || entry?.excludedModels.length || entry?.proxyUrl || entry?.prefix + ) + ); const secretHelper = useMemo(() => { if (!isEditing || !entry?.secretConfigured) return null; return supportsOpenAiCompat ? 'Leave API keys blank to keep the stored connector secrets.' - : 'Leave API key blank to keep the stored secret.'; + : 'Leave the API key blank to keep the stored secret.'; }, [entry?.secretConfigured, isEditing, supportsOpenAiCompat]); const handleSubmit = async () => { + const nextApiKey = apiKey.trim(); + const nextApiKeys = parseDelimitedLines(apiKeys); + const preserveSecrets = + isEditing && entry?.secretConfigured && !nextApiKey.length && nextApiKeys.length === 0; + const payload: UpsertAiProviderEntryInput = { name: supportsOpenAiCompat ? name : undefined, baseUrl, @@ -128,9 +324,14 @@ export function ProviderEntryDialog({ headers: parseKeyValueLines(headers), excludedModels: supportsClaudeAdvanced ? parseDelimitedLines(excludedModels) : undefined, models: parseModelAliasLines(modelAliases), - preserveSecrets: isEditing && entry?.secretConfigured && !apiKey.trim() && !apiKeys.trim(), - apiKey: supportsOpenAiCompat ? undefined : apiKey, - apiKeys: supportsOpenAiCompat ? parseDelimitedLines(apiKeys) : undefined, + preserveSecrets, + ...(supportsOpenAiCompat + ? nextApiKeys.length > 0 + ? { apiKeys: nextApiKeys } + : {} + : nextApiKey.length > 0 + ? { apiKey: nextApiKey } + : {}), }; await onSubmit(payload); @@ -138,126 +339,251 @@ export function ProviderEntryDialog({ return ( - - - {isEditing ? 'Edit provider entry' : 'Add provider entry'} - - Configure this AI provider family without creating a separate CCS API Profile. - - + +
+
+ +
+
+ +
+
+
+ + {isEditing ? `Edit ${guide.familyName}` : `Set up ${guide.familyName}`} + + + {supportsOpenAiCompat ? 'connector' : 'api-key'} + +
+ + {guide.description} + +
+
-
- {supportsOpenAiCompat && ( -
- - setName(event.target.value)} - placeholder="openrouter" - /> -
- )} - -
- - setBaseUrl(event.target.value)} - placeholder="https://example.com/v1" - /> -
- - {supportsOpenAiCompat ? ( -
- -