feat(cliproxy): redesign ai providers configuration

- rebuild the AI Providers page around dedicated family workflows and saved-entry editing

- add OpenAI-compatible and Vertex visual mapping plus the upstream Vertex asset

- preserve stored secrets when editing existing entries and tighten Codex logo rendering

Refs #649
This commit is contained in:
Tam Nhu Tran
2026-03-19 17:28:52 -04:00
parent 50c55bb108
commit 77fcd45173
8 changed files with 2090 additions and 374 deletions
@@ -0,0 +1,24 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="1em"
height="1em"
viewBox="0 0 24 24"
style="flex:none;line-height:1"
>
<title>Codex</title>
<path
fill="#fff"
d="M19.503 0H4.496A4.496 4.496 0 0 0 0 4.496v15.007A4.496 4.496 0 0 0 4.496 24h15.007A4.496 4.496 0 0 0 24 19.503V4.496A4.496 4.496 0 0 0 19.503 0z"
/>
<path
fill="url(#codex-color-fill)"
d="M9.064 3.344a4.578 4.578 0 0 1 2.285-.312c1 .115 1.891.54 2.673 1.275.01.01.024.017.037.021a.09.09 0 0 0 .043 0 4.55 4.55 0 0 1 3.046.275l.047.022.116.057a4.581 4.581 0 0 1 2.188 2.399c.209.51.313 1.041.315 1.595a4.24 4.24 0 0 1-.134 1.223.123.123 0 0 0 .03.115c.594.607.988 1.33 1.183 2.17.289 1.425-.007 2.71-.887 3.854l-.136.166a4.548 4.548 0 0 1-2.201 1.388.123.123 0 0 0-.081.076c-.191.551-.383 1.023-.74 1.494-.9 1.187-2.222 1.846-3.711 1.838-1.187-.006-2.239-.44-3.157-1.302a.107.107 0 0 0-.105-.024c-.388.125-.78.143-1.204.138a4.441 4.441 0 0 1-1.945-.466 4.544 4.544 0 0 1-1.61-1.335c-.152-.202-.303-.392-.414-.617a5.81 5.81 0 0 1-.37-.961 4.582 4.582 0 0 1-.014-2.298.124.124 0 0 0 .006-.056.085.085 0 0 0-.027-.048 4.467 4.467 0 0 1-1.034-1.651 3.896 3.896 0 0 1-.251-1.192 5.189 5.189 0 0 1 .141-1.6c.337-1.112.982-1.985 1.933-2.618.212-.141.413-.251.601-.33.215-.089.43-.164.646-.227a.098.098 0 0 0 .065-.066 4.51 4.51 0 0 1 .829-1.615 4.535 4.535 0 0 1 1.837-1.388zm3.482 10.565a.637.637 0 0 0 0 1.272h3.636a.637.637 0 1 0 0-1.272h-3.636zM8.462 9.23a.637.637 0 0 0-1.106.631l1.272 2.224-1.266 2.136a.636.636 0 1 0 1.095.649l1.454-2.455a.636.636 0 0 0 .005-.64L8.462 9.23z"
/>
<defs>
<linearGradient id="codex-color-fill" x1="12" x2="12" y1="3" y2="21" gradientUnits="userSpaceOnUse">
<stop stop-color="#B1A7FF" />
<stop offset=".5" stop-color="#7A9DFF" />
<stop offset="1" stop-color="#3941FF" />
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.8 KiB

+25
View File
@@ -0,0 +1,25 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<path d="M20 13.89a.77.77 0 0 0-1-.16L12 18.87v.22a.72.72 0 1 1 0 1.43v0a.74.74 0 0 0 .45-.15l7.41-5.47a.76.76 0 0 0 .14-1.01Z" fill="#669df6"/>
<path d="M12 20.52a.72.72 0 0 1 0-1.43h0v-.22L5 13.73a.76.76 0 0 0-1 .16.74.74 0 0 0 .16 1l7.41 5.47a.73.73 0 0 0 .44.15v0Z" fill="#aecbfa"/>
<path d="M12 18.34a1.47 1.47 0 1 0 1.47 1.47A1.47 1.47 0 0 0 12 18.34Zm0 2.18a.72.72 0 1 1 .72-.71.71.71 0 0 1-.72.71Z" fill="#4285f4"/>
<path d="M6 6.11a.76.76 0 0 1-.75-.75V3.48a.76.76 0 1 1 1.51 0v1.88A.76.76 0 0 1 6 6.11Z" fill="#aecbfa"/>
<circle cx="5.98" cy="12" r="0.76" fill="#aecbfa"/>
<circle cx="5.98" cy="9.79" r="0.76" fill="#aecbfa"/>
<circle cx="5.98" cy="7.57" r="0.76" fill="#aecbfa"/>
<path d="M18 8.31a.76.76 0 0 1-.75-.76V5.67a.75.75 0 1 1 1.5 0v1.88a.75.75 0 0 1-.75.76Z" fill="#4285f4"/>
<circle cx="18.02" cy="12.01" r="0.76" fill="#4285f4"/>
<circle cx="18.02" cy="9.76" r="0.76" fill="#4285f4"/>
<circle cx="18.02" cy="3.48" r="0.76" fill="#4285f4"/>
<path d="M12 15a.76.76 0 0 1-.75-.75v-1.89a.76.76 0 0 1 1.51 0v1.89A.76.76 0 0 1 12 15Z" fill="#669df6"/>
<circle cx="12" cy="16.45" r="0.76" fill="#669df6"/>
<circle cx="12" cy="10.14" r="0.76" fill="#669df6"/>
<circle cx="12" cy="7.92" r="0.76" fill="#669df6"/>
<path d="M15 10.54a.76.76 0 0 1-.75-.75V7.91a.76.76 0 1 1 1.51 0v1.88a.76.76 0 0 1-.76.75Z" fill="#4285f4"/>
<circle cx="15.01" cy="5.69" r="0.76" fill="#4285f4"/>
<circle cx="15.01" cy="14.19" r="0.76" fill="#4285f4"/>
<circle cx="15.01" cy="11.97" r="0.76" fill="#4285f4"/>
<circle cx="8.99" cy="14.19" r="0.76" fill="#aecbfa"/>
<circle cx="8.99" cy="7.92" r="0.76" fill="#aecbfa"/>
<circle cx="8.99" cy="5.69" r="0.76" fill="#aecbfa"/>
<path d="M9 12.73A.76.76 0 0 1 8.24 12V10.1a.75.75 0 1 1 1.5 0V12A.75.75 0 0 1 9 12.73Z" fill="#aecbfa"/>
</svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -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
)}
>
<div className="flex items-center gap-3">
<ProviderLogo provider={getFamilyProvider(family.id)} size="md" />
<ProviderLogo provider={getAiProviderFamilyVisual(family.id)} size="md" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{family.displayName}</span>
@@ -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({
<span className="truncate text-sm font-medium">{entry.label}</span>
{renderSecretBadge(entry)}
</div>
<p className="mt-1 truncate text-xs text-muted-foreground">
<div className="mt-1 truncate font-mono text-[11px] text-muted-foreground">
{entry.baseUrl || family.routePath}
</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<span>{renderCountLabel(entry.models.length, 'alias')}</span>
<span className="text-border"></span>
<span>{renderCountLabel(entry.headers.length, 'header')}</span>
</div>
<div className="mt-2 flex flex-wrap gap-1.5">
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{renderCountLabel(entry.models.length, 'alias')}
</Badge>
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{renderCountLabel(entry.headers.length, 'header')}
</Badge>
{entry.excludedModels.length > 0 && (
<>
<span className="text-border"></span>
<span>{renderCountLabel(entry.excludedModels.length, 'exclusion')}</span>
</>
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
{renderCountLabel(entry.excludedModels.length, 'rule')}
</Badge>
)}
{(entry.proxyUrl || entry.prefix) && (
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
Routed
</Badge>
)}
</div>
</div>
@@ -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 (
<div className="rounded-xl border bg-background/80 p-4">
<div className="flex items-center gap-2 text-sm font-medium">
{icon}
{title}
</div>
<div className="mt-3 space-y-3">
{items.map((item, index) => (
<div key={`${title}:${item}`} className="flex items-start gap-3">
<div className="mt-0.5 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border bg-muted/40 text-[11px] font-semibold text-muted-foreground">
{index + 1}
</div>
<div className="text-sm leading-6 text-muted-foreground">{item}</div>
</div>
))}
</div>
</div>
);
}
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{isEditing ? 'Edit provider entry' : 'Add provider entry'}</DialogTitle>
<DialogDescription>
Configure this AI provider family without creating a separate CCS API Profile.
</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0 sm:max-w-3xl">
<div className="max-h-[85vh] overflow-y-auto">
<div className="border-b bg-muted/20 px-6 py-5">
<DialogHeader className="gap-4 text-left">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl border bg-background">
<ProviderLogo provider={getAiProviderFamilyVisual(family)} size="md" />
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<DialogTitle>
{isEditing ? `Edit ${guide.familyName}` : `Set up ${guide.familyName}`}
</DialogTitle>
<Badge variant="outline" className="uppercase text-[11px]">
{supportsOpenAiCompat ? 'connector' : 'api-key'}
</Badge>
</div>
<DialogDescription className="mt-1 max-w-2xl leading-6">
{guide.description}
</DialogDescription>
</div>
</div>
<div className="space-y-4">
{supportsOpenAiCompat && (
<div className="space-y-1.5">
<Label htmlFor="connector-name">Connector Name</Label>
<Input
id="connector-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder="openrouter"
/>
</div>
)}
<div className="space-y-1.5">
<Label htmlFor="base-url">Base URL</Label>
<Input
id="base-url"
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder="https://example.com/v1"
/>
</div>
{supportsOpenAiCompat ? (
<div className="space-y-1.5">
<Label>API Keys</Label>
<TextArea
value={apiKeys}
onChange={setApiKeys}
rows={4}
placeholder="sk-...\nsk-..."
/>
{secretHelper && <p className="text-xs text-muted-foreground">{secretHelper}</p>}
</div>
) : (
<div className="space-y-1.5">
<Label htmlFor="api-key">API Key</Label>
<Input
id="api-key"
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder="sk-..."
/>
{secretHelper && <p className="text-xs text-muted-foreground">{secretHelper}</p>}
</div>
)}
{supportsClaudeAdvanced && (
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="prefix">Prefix</Label>
<Input
id="prefix"
value={prefix}
onChange={(event) => setPrefix(event.target.value)}
placeholder="glm-"
<div className="grid gap-3 md:grid-cols-2">
<ChecklistCard
title="Required now"
items={guide.requiredNow}
icon={<KeyRound className="h-4 w-4 text-primary" />}
/>
<ChecklistCard
title="Optional later"
items={guide.optionalLater}
icon={<SlidersHorizontal className="h-4 w-4 text-primary" />}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="proxy-url">Proxy URL</Label>
<Input
id="proxy-url"
value={proxyUrl}
onChange={(event) => setProxyUrl(event.target.value)}
placeholder="http://127.0.0.1:8080"
/>
{secretHelper ? (
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">
{secretHelper}
</div>
) : null}
</DialogHeader>
</div>
<div className="space-y-6 px-6 py-6">
<section className="space-y-4">
<div>
<div className="text-sm font-semibold">Required setup</div>
<div className="mt-1 text-sm text-muted-foreground">
Save the smallest working configuration first.
</div>
</div>
</div>
)}
<div className="space-y-1.5">
<Label>Headers</Label>
<TextArea
value={headers}
onChange={setHeaders}
rows={3}
placeholder="Authorization: Bearer ...&#10;X-Project: demo"
/>
{supportsOpenAiCompat ? (
<div className="grid gap-4">
<div className="space-y-1.5">
<Label htmlFor="connector-name">Connector Name</Label>
<Input
id="connector-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={guide.connectorPlaceholder}
/>
<p className="text-xs text-muted-foreground">{guide.connectorHelper}</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="base-url">Base URL</Label>
<Input
id="base-url"
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder={guide.baseUrlPlaceholder}
/>
<p className="text-xs text-muted-foreground">{guide.baseUrlHelper}</p>
</div>
<div className="space-y-1.5">
<Label>API Keys</Label>
<TextArea
value={apiKeys}
onChange={setApiKeys}
rows={4}
placeholder={`${guide.keyPlaceholder}\n${guide.keyPlaceholder}`}
/>
<p className="text-xs text-muted-foreground">{guide.keyHelper}</p>
</div>
</div>
) : (
<div className="space-y-1.5">
<Label htmlFor="api-key">{guide.keyLabel}</Label>
<Input
id="api-key"
type="password"
value={apiKey}
onChange={(event) => setApiKey(event.target.value)}
placeholder={guide.keyPlaceholder}
/>
<p className="text-xs text-muted-foreground">{guide.keyHelper}</p>
</div>
)}
</section>
<section className="space-y-4">
<div>
<div className="text-sm font-semibold">Optional routing</div>
<div className="mt-1 text-sm text-muted-foreground">
Only fill these when the route needs more than the default behavior.
</div>
</div>
{!supportsOpenAiCompat ? (
<div className="space-y-1.5">
<Label htmlFor="base-url">Base URL</Label>
<Input
id="base-url"
value={baseUrl}
onChange={(event) => setBaseUrl(event.target.value)}
placeholder={guide.baseUrlPlaceholder}
/>
<p className="text-xs text-muted-foreground">{guide.baseUrlHelper}</p>
</div>
) : null}
<div className="space-y-1.5">
<Label>Model Mappings</Label>
<TextArea
value={modelAliases}
onChange={setModelAliases}
rows={4}
placeholder={guide.aliasesPlaceholder}
/>
<p className="text-xs text-muted-foreground">{guide.aliasesHelper}</p>
</div>
</section>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<div className="rounded-xl border">
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center justify-between gap-4 px-4 py-4 text-left"
>
<div>
<div className="flex items-center gap-2 text-sm font-medium">
<SlidersHorizontal className="h-4 w-4 text-primary" />
Advanced routing
</div>
<div className="mt-1 text-sm text-muted-foreground">
Headers
{supportsClaudeAdvanced
? ', proxy, prefix, and exclusions.'
: ' and provider-specific overrides.'}
</div>
</div>
<ChevronDown
className={cn(
'h-4 w-4 text-muted-foreground transition-transform',
advancedOpen && 'rotate-180'
)}
/>
</button>
</CollapsibleTrigger>
<CollapsibleContent className="border-t px-4 py-4">
<div className="space-y-4">
<div className="space-y-1.5">
<Label>Headers</Label>
<TextArea
value={headers}
onChange={setHeaders}
rows={3}
placeholder={guide.headersPlaceholder}
/>
<p className="text-xs text-muted-foreground">
Use headers only when the provider requires extra routing or auth context.
</p>
</div>
{supportsClaudeAdvanced ? (
<>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1.5">
<Label htmlFor="prefix">Prefix</Label>
<Input
id="prefix"
value={prefix}
onChange={(event) => setPrefix(event.target.value)}
placeholder="glm-"
/>
<p className="text-xs text-muted-foreground">
Optional. Prepends model names before routing.
</p>
</div>
<div className="space-y-1.5">
<Label htmlFor="proxy-url">Proxy URL</Label>
<Input
id="proxy-url"
value={proxyUrl}
onChange={(event) => setProxyUrl(event.target.value)}
placeholder="http://127.0.0.1:8080"
/>
<p className="text-xs text-muted-foreground">
Optional. Sends requests through an intermediate proxy.
</p>
</div>
</div>
<div className="space-y-1.5">
<Label>Excluded Models</Label>
<TextArea
value={excludedModels}
onChange={setExcludedModels}
rows={3}
placeholder="claude-opus-4-1\nclaude-sonnet-4-5"
/>
<p className="text-xs text-muted-foreground">
Optional. One model ID per line when this route should reject specific
upstream models.
</p>
</div>
</>
) : null}
</div>
</CollapsibleContent>
</div>
</Collapsible>
</div>
{supportsClaudeAdvanced && (
<div className="space-y-1.5">
<Label>Excluded Models</Label>
<TextArea
value={excludedModels}
onChange={setExcludedModels}
rows={3}
placeholder="claude-opus-4-1&#10;claude-sonnet-4-5"
/>
</div>
)}
<div className="space-y-1.5">
<Label>Model Aliases</Label>
<TextArea
value={modelAliases}
onChange={setModelAliases}
rows={4}
placeholder="claude-sonnet=gemini-2.5-pro&#10;claude-haiku=gpt-4.1-mini"
/>
</div>
<DialogFooter className="border-t bg-muted/10 px-6 py-4">
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="button" onClick={() => void handleSubmit()} disabled={isSaving}>
{isSaving
? 'Saving...'
: supportsOpenAiCompat
? isEditing
? 'Save Connector'
: 'Create Connector'
: 'Save Entry'}
</Button>
</DialogFooter>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="button" onClick={() => void handleSubmit()} disabled={isSaving}>
{isSaving ? 'Saving...' : isEditing ? 'Save Changes' : 'Create Entry'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
+7 -1
View File
@@ -8,6 +8,7 @@ import {
getProviderFallbackVisual,
getProviderLogoAsset,
providerNeedsDarkLogoBackground,
providerUsesSelfContainedLogo,
} from '@/lib/provider-config';
interface ProviderLogoProps {
@@ -27,12 +28,14 @@ export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoP
const fallback = getProviderFallbackVisual(provider);
const sizeConfig = SIZE_CONFIG[size];
const imageSrc = getProviderLogoAsset(provider);
const usesSelfContainedLogo = providerUsesSelfContainedLogo(provider);
return (
<div
className={cn(
'flex items-center justify-center rounded-md',
imageSrc &&
!usesSelfContainedLogo &&
(providerNeedsDarkLogoBackground(provider) ? 'bg-gray-900 p-1' : 'bg-white p-1'),
sizeConfig.container,
className
@@ -42,7 +45,10 @@ export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoP
<img
src={imageSrc}
alt={`${provider} logo`}
className={cn(sizeConfig.icon, 'object-contain')}
className={cn(
usesSelfContainedLogo ? sizeConfig.container : sizeConfig.icon,
'object-contain'
)}
/>
) : (
<span className={cn('font-semibold', fallback.textClass, sizeConfig.text)}>
+45 -7
View File
@@ -9,6 +9,7 @@ import {
PROVIDER_CAPABILITIES,
getProvidersByOAuthFlow,
} from '../../../src/cliproxy/provider-capabilities';
import type { AiProviderFamilyId } from '../../../src/cliproxy/ai-providers';
// Monorepo contract: UI consumes provider capability constants directly from backend
// to enforce one source of truth and prevent provider drift across surfaces.
@@ -18,6 +19,7 @@ export const CLIPROXY_PROVIDERS = CLIPROXY_PROVIDER_IDS;
/** Union type for CLIProxy provider IDs */
export type CLIProxyProvider = (typeof CLIPROXY_PROVIDERS)[number];
export type ProviderVisualId = CLIProxyProvider | 'openai' | 'vertex';
/** Check if a string is a valid CLIProxy provider */
export function isValidProvider(provider: string): provider is CLIProxyProvider {
@@ -33,6 +35,15 @@ interface ProviderMetadata {
description: string;
}
const SPECIAL_PROVIDER_VISUAL_IDS = ['openai', 'vertex'] as const;
function isProviderVisualId(provider: string): provider is ProviderVisualId {
return (
isValidProvider(provider) ||
SPECIAL_PROVIDER_VISUAL_IDS.includes(provider as (typeof SPECIAL_PROVIDER_VISUAL_IDS)[number])
);
}
export const PROVIDER_METADATA: Record<CLIProxyProvider, ProviderMetadata> = Object.freeze(
Object.fromEntries(
CLIPROXY_PROVIDERS.map((provider) => [
@@ -46,16 +57,18 @@ export const PROVIDER_METADATA: Record<CLIProxyProvider, ProviderMetadata> = Obj
);
// Map provider names to asset filenames (only providers with actual logos)
export const PROVIDER_ASSETS: Record<CLIProxyProvider, string> = {
export const PROVIDER_ASSETS: Partial<Record<ProviderVisualId, string>> = {
gemini: '/assets/providers/gemini-color.svg',
agy: '/assets/providers/agy.png',
codex: '/assets/providers/openai.svg',
codex: '/assets/providers/codex-color.svg',
qwen: '/assets/providers/qwen-color.svg',
iflow: '/assets/providers/iflow.png',
kiro: '/assets/providers/kiro.png',
ghcp: '/assets/providers/copilot.svg',
claude: '/assets/providers/claude.svg',
kimi: '/assets/providers/kimi.svg',
openai: '/assets/providers/openai.svg',
vertex: '/assets/providers/vertex.svg',
};
interface ProviderFallbackVisual {
@@ -69,7 +82,7 @@ const DEFAULT_PROVIDER_FALLBACK_VISUAL: ProviderFallbackVisual = {
};
/** Fallback visual style when a provider logo asset is unavailable. */
export const PROVIDER_FALLBACK_VISUALS: Record<CLIProxyProvider, ProviderFallbackVisual> = {
export const PROVIDER_FALLBACK_VISUALS: Record<ProviderVisualId, ProviderFallbackVisual> = {
gemini: { textClass: 'text-blue-600', letter: 'G' },
claude: { textClass: 'text-orange-600', letter: 'C' },
codex: { textClass: 'text-emerald-600', letter: 'X' },
@@ -79,14 +92,32 @@ export const PROVIDER_FALLBACK_VISUALS: Record<CLIProxyProvider, ProviderFallbac
kiro: { textClass: 'text-teal-600', letter: 'K' },
ghcp: { textClass: 'text-green-600', letter: 'C' },
kimi: { textClass: 'text-orange-500', letter: 'K' },
openai: { textClass: 'text-slate-900', letter: 'O' },
vertex: { textClass: 'text-blue-600', letter: 'V' },
};
/** Providers whose logo looks better on dark background. */
export const PROVIDERS_WITH_DARK_LOGO_BG: ReadonlySet<CLIProxyProvider> = new Set(['kimi']);
export const PROVIDERS_WITH_DARK_LOGO_BG: ReadonlySet<ProviderVisualId> = new Set(['kimi']);
const PROVIDERS_WITH_SELF_CONTAINED_LOGO: ReadonlySet<ProviderVisualId> = new Set(['codex']);
export function getAiProviderFamilyVisual(familyId: AiProviderFamilyId): ProviderVisualId {
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 'openai';
}
}
export function getProviderLogoAsset(provider: unknown): string | undefined {
const normalized = normalizeProviderInput(provider);
if (!isValidProvider(normalized)) {
if (!isProviderVisualId(normalized)) {
return undefined;
}
return PROVIDER_ASSETS[normalized];
@@ -94,7 +125,7 @@ export function getProviderLogoAsset(provider: unknown): string | undefined {
export function getProviderFallbackVisual(provider: unknown): ProviderFallbackVisual {
const normalized = normalizeProviderInput(provider);
if (isValidProvider(normalized)) {
if (isProviderVisualId(normalized)) {
return PROVIDER_FALLBACK_VISUALS[normalized];
}
return {
@@ -105,7 +136,12 @@ export function getProviderFallbackVisual(provider: unknown): ProviderFallbackVi
export function providerNeedsDarkLogoBackground(provider: unknown): boolean {
const normalized = normalizeProviderInput(provider);
return isValidProvider(normalized) && PROVIDERS_WITH_DARK_LOGO_BG.has(normalized);
return isProviderVisualId(normalized) && PROVIDERS_WITH_DARK_LOGO_BG.has(normalized);
}
export function providerUsesSelfContainedLogo(provider: unknown): boolean {
const normalized = normalizeProviderInput(provider);
return isProviderVisualId(normalized) && PROVIDERS_WITH_SELF_CONTAINED_LOGO.has(normalized);
}
// Provider brand colors
@@ -113,6 +149,7 @@ export const PROVIDER_COLORS: Record<string, string> = {
gemini: '#4285F4',
agy: '#f3722c',
codex: '#10a37f',
openai: '#111827',
vertex: '#4285F4',
iflow: '#f94144',
qwen: '#6236FF',
@@ -126,6 +163,7 @@ const PROVIDER_NAMES: Record<string, string> = {
...Object.fromEntries(
CLIPROXY_PROVIDERS.map((provider) => [provider, PROVIDER_METADATA[provider].displayName])
),
openai: 'OpenAI',
vertex: 'Vertex AI',
};
File diff suppressed because it is too large Load Diff