mirror of
https://github.com/tiennm99/ccs.git
synced 2026-07-16 04:18:05 +00:00
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:
@@ -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 |
@@ -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 ... 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 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 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>
|
||||
);
|
||||
|
||||
@@ -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)}>
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
|
||||
|
||||
+1521
-219
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user