mirror of
https://github.com/tiennm99/ccs.git
synced 2026-08-06 04:22:40 +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 { Badge } from '@/components/ui/badge';
|
||||||
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
import { ProviderLogo } from '@/components/cliproxy/provider-logo';
|
||||||
|
import { getAiProviderFamilyVisual } from '@/lib/provider-config';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import type {
|
import type {
|
||||||
AiProviderFamilyId,
|
AiProviderFamilyId,
|
||||||
@@ -13,21 +14,6 @@ interface FamilyRailProps {
|
|||||||
onSelect: (family: AiProviderFamilyId) => void;
|
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']) {
|
function getStatusState(status: AiProviderFamilyState['status']) {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'ready':
|
case 'ready':
|
||||||
@@ -72,7 +58,7 @@ export function FamilyRail({ families, selectedFamily, onSelect }: FamilyRailPro
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<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="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate text-sm font-medium">{family.displayName}</span>
|
<span className="truncate text-sm font-medium">{family.displayName}</span>
|
||||||
|
|||||||
@@ -54,9 +54,11 @@ export function ProviderEntryCard({
|
|||||||
role={onSelect ? 'button' : undefined}
|
role={onSelect ? 'button' : undefined}
|
||||||
tabIndex={onSelect ? 0 : undefined}
|
tabIndex={onSelect ? 0 : undefined}
|
||||||
className={cn(
|
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',
|
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}
|
onClick={onSelect}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
@@ -84,18 +86,25 @@ export function ProviderEntryCard({
|
|||||||
<span className="truncate text-sm font-medium">{entry.label}</span>
|
<span className="truncate text-sm font-medium">{entry.label}</span>
|
||||||
{renderSecretBadge(entry)}
|
{renderSecretBadge(entry)}
|
||||||
</div>
|
</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}
|
{entry.baseUrl || family.routePath}
|
||||||
</p>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
|
<div className="mt-2 flex flex-wrap gap-1.5">
|
||||||
<span>{renderCountLabel(entry.models.length, 'alias')}</span>
|
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||||
<span className="text-border">•</span>
|
{renderCountLabel(entry.models.length, 'alias')}
|
||||||
<span>{renderCountLabel(entry.headers.length, 'header')}</span>
|
</Badge>
|
||||||
|
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||||
|
{renderCountLabel(entry.headers.length, 'header')}
|
||||||
|
</Badge>
|
||||||
{entry.excludedModels.length > 0 && (
|
{entry.excludedModels.length > 0 && (
|
||||||
<>
|
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||||
<span className="text-border">•</span>
|
{renderCountLabel(entry.excludedModels.length, 'rule')}
|
||||||
<span>{renderCountLabel(entry.excludedModels.length, 'exclusion')}</span>
|
</Badge>
|
||||||
</>
|
)}
|
||||||
|
{(entry.proxyUrl || entry.prefix) && (
|
||||||
|
<Badge variant="outline" className="h-5 px-1.5 text-[10px]">
|
||||||
|
Routed
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { useMemo, useState } from 'react';
|
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 { Button } from '@/components/ui/button';
|
||||||
|
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
@@ -10,6 +14,8 @@ import {
|
|||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { ChevronDown, KeyRound, SlidersHorizontal } from 'lucide-react';
|
||||||
import type {
|
import type {
|
||||||
AiProviderEntryView,
|
AiProviderEntryView,
|
||||||
AiProviderFamilyId,
|
AiProviderFamilyId,
|
||||||
@@ -25,6 +31,146 @@ interface ProviderEntryDialogProps {
|
|||||||
isSaving: boolean;
|
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[] {
|
function parseDelimitedLines(value: string): string[] {
|
||||||
return value
|
return value
|
||||||
.split('\n')
|
.split('\n')
|
||||||
@@ -51,8 +197,15 @@ function parseModelAliasLines(value: string) {
|
|||||||
.map((line) => line.trim())
|
.map((line) => line.trim())
|
||||||
.filter((line) => line.length > 0)
|
.filter((line) => line.length > 0)
|
||||||
.map((line) => {
|
.map((line) => {
|
||||||
const [name, ...rest] = line.split('=');
|
const separatorIndex = line.indexOf('=');
|
||||||
return { name: name.trim(), alias: rest.join('=').trim() };
|
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);
|
.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 {
|
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({
|
export function ProviderEntryDialog({
|
||||||
@@ -99,9 +283,11 @@ export function ProviderEntryDialog({
|
|||||||
onSubmit,
|
onSubmit,
|
||||||
isSaving,
|
isSaving,
|
||||||
}: ProviderEntryDialogProps) {
|
}: ProviderEntryDialogProps) {
|
||||||
|
const guide = useMemo(() => getDialogGuide(family), [family]);
|
||||||
const isEditing = Boolean(entry);
|
const isEditing = Boolean(entry);
|
||||||
const supportsOpenAiCompat = family === 'openai-compatibility';
|
const supportsOpenAiCompat = family === 'openai-compatibility';
|
||||||
const supportsClaudeAdvanced = family === 'claude-api-key';
|
const supportsClaudeAdvanced = family === 'claude-api-key';
|
||||||
|
|
||||||
const [name, setName] = useState(() => entry?.name || '');
|
const [name, setName] = useState(() => entry?.name || '');
|
||||||
const [baseUrl, setBaseUrl] = useState(() => entry?.baseUrl || '');
|
const [baseUrl, setBaseUrl] = useState(() => entry?.baseUrl || '');
|
||||||
const [proxyUrl, setProxyUrl] = useState(() => entry?.proxyUrl || '');
|
const [proxyUrl, setProxyUrl] = useState(() => entry?.proxyUrl || '');
|
||||||
@@ -111,15 +297,25 @@ export function ProviderEntryDialog({
|
|||||||
const [headers, setHeaders] = useState(() => formatHeaders(entry));
|
const [headers, setHeaders] = useState(() => formatHeaders(entry));
|
||||||
const [excludedModels, setExcludedModels] = useState(() => formatExcludedModels(entry));
|
const [excludedModels, setExcludedModels] = useState(() => formatExcludedModels(entry));
|
||||||
const [modelAliases, setModelAliases] = useState(() => formatModelAliases(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(() => {
|
const secretHelper = useMemo(() => {
|
||||||
if (!isEditing || !entry?.secretConfigured) return null;
|
if (!isEditing || !entry?.secretConfigured) return null;
|
||||||
return supportsOpenAiCompat
|
return supportsOpenAiCompat
|
||||||
? 'Leave API keys blank to keep the stored connector secrets.'
|
? '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]);
|
}, [entry?.secretConfigured, isEditing, supportsOpenAiCompat]);
|
||||||
|
|
||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
|
const nextApiKey = apiKey.trim();
|
||||||
|
const nextApiKeys = parseDelimitedLines(apiKeys);
|
||||||
|
const preserveSecrets =
|
||||||
|
isEditing && entry?.secretConfigured && !nextApiKey.length && nextApiKeys.length === 0;
|
||||||
|
|
||||||
const payload: UpsertAiProviderEntryInput = {
|
const payload: UpsertAiProviderEntryInput = {
|
||||||
name: supportsOpenAiCompat ? name : undefined,
|
name: supportsOpenAiCompat ? name : undefined,
|
||||||
baseUrl,
|
baseUrl,
|
||||||
@@ -128,9 +324,14 @@ export function ProviderEntryDialog({
|
|||||||
headers: parseKeyValueLines(headers),
|
headers: parseKeyValueLines(headers),
|
||||||
excludedModels: supportsClaudeAdvanced ? parseDelimitedLines(excludedModels) : undefined,
|
excludedModels: supportsClaudeAdvanced ? parseDelimitedLines(excludedModels) : undefined,
|
||||||
models: parseModelAliasLines(modelAliases),
|
models: parseModelAliasLines(modelAliases),
|
||||||
preserveSecrets: isEditing && entry?.secretConfigured && !apiKey.trim() && !apiKeys.trim(),
|
preserveSecrets,
|
||||||
apiKey: supportsOpenAiCompat ? undefined : apiKey,
|
...(supportsOpenAiCompat
|
||||||
apiKeys: supportsOpenAiCompat ? parseDelimitedLines(apiKeys) : undefined,
|
? nextApiKeys.length > 0
|
||||||
|
? { apiKeys: nextApiKeys }
|
||||||
|
: {}
|
||||||
|
: nextApiKey.length > 0
|
||||||
|
? { apiKey: nextApiKey }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
await onSubmit(payload);
|
await onSubmit(payload);
|
||||||
@@ -138,126 +339,251 @@ export function ProviderEntryDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
<DialogContent className="sm:max-w-2xl">
|
<DialogContent className="overflow-hidden p-0 sm:max-w-3xl">
|
||||||
<DialogHeader>
|
<div className="max-h-[85vh] overflow-y-auto">
|
||||||
<DialogTitle>{isEditing ? 'Edit provider entry' : 'Add provider entry'}</DialogTitle>
|
<div className="border-b bg-muted/20 px-6 py-5">
|
||||||
<DialogDescription>
|
<DialogHeader className="gap-4 text-left">
|
||||||
Configure this AI provider family without creating a separate CCS API Profile.
|
<div className="flex items-start gap-4">
|
||||||
</DialogDescription>
|
<div className="flex h-12 w-12 items-center justify-center rounded-xl border bg-background">
|
||||||
</DialogHeader>
|
<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">
|
<div className="grid gap-3 md:grid-cols-2">
|
||||||
{supportsOpenAiCompat && (
|
<ChecklistCard
|
||||||
<div className="space-y-1.5">
|
title="Required now"
|
||||||
<Label htmlFor="connector-name">Connector Name</Label>
|
items={guide.requiredNow}
|
||||||
<Input
|
icon={<KeyRound className="h-4 w-4 text-primary" />}
|
||||||
id="connector-name"
|
/>
|
||||||
value={name}
|
<ChecklistCard
|
||||||
onChange={(event) => setName(event.target.value)}
|
title="Optional later"
|
||||||
placeholder="openrouter"
|
items={guide.optionalLater}
|
||||||
/>
|
icon={<SlidersHorizontal className="h-4 w-4 text-primary" />}
|
||||||
</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>
|
</div>
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Label htmlFor="proxy-url">Proxy URL</Label>
|
{secretHelper ? (
|
||||||
<Input
|
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs leading-5 text-amber-800">
|
||||||
id="proxy-url"
|
{secretHelper}
|
||||||
value={proxyUrl}
|
</div>
|
||||||
onChange={(event) => setProxyUrl(event.target.value)}
|
) : null}
|
||||||
placeholder="http://127.0.0.1:8080"
|
</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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
{supportsOpenAiCompat ? (
|
||||||
<Label>Headers</Label>
|
<div className="grid gap-4">
|
||||||
<TextArea
|
<div className="space-y-1.5">
|
||||||
value={headers}
|
<Label htmlFor="connector-name">Connector Name</Label>
|
||||||
onChange={setHeaders}
|
<Input
|
||||||
rows={3}
|
id="connector-name"
|
||||||
placeholder="Authorization: Bearer ... X-Project: demo"
|
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>
|
</div>
|
||||||
|
|
||||||
{supportsClaudeAdvanced && (
|
<DialogFooter className="border-t bg-muted/10 px-6 py-4">
|
||||||
<div className="space-y-1.5">
|
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
<Label>Excluded Models</Label>
|
Cancel
|
||||||
<TextArea
|
</Button>
|
||||||
value={excludedModels}
|
<Button type="button" onClick={() => void handleSubmit()} disabled={isSaving}>
|
||||||
onChange={setExcludedModels}
|
{isSaving
|
||||||
rows={3}
|
? 'Saving...'
|
||||||
placeholder="claude-opus-4-1 claude-sonnet-4-5"
|
: supportsOpenAiCompat
|
||||||
/>
|
? isEditing
|
||||||
</div>
|
? 'Save Connector'
|
||||||
)}
|
: 'Create Connector'
|
||||||
|
: 'Save Entry'}
|
||||||
<div className="space-y-1.5">
|
</Button>
|
||||||
<Label>Model Aliases</Label>
|
</DialogFooter>
|
||||||
<TextArea
|
|
||||||
value={modelAliases}
|
|
||||||
onChange={setModelAliases}
|
|
||||||
rows={4}
|
|
||||||
placeholder="claude-sonnet=gemini-2.5-pro claude-haiku=gpt-4.1-mini"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</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>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getProviderFallbackVisual,
|
getProviderFallbackVisual,
|
||||||
getProviderLogoAsset,
|
getProviderLogoAsset,
|
||||||
providerNeedsDarkLogoBackground,
|
providerNeedsDarkLogoBackground,
|
||||||
|
providerUsesSelfContainedLogo,
|
||||||
} from '@/lib/provider-config';
|
} from '@/lib/provider-config';
|
||||||
|
|
||||||
interface ProviderLogoProps {
|
interface ProviderLogoProps {
|
||||||
@@ -27,12 +28,14 @@ export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoP
|
|||||||
const fallback = getProviderFallbackVisual(provider);
|
const fallback = getProviderFallbackVisual(provider);
|
||||||
const sizeConfig = SIZE_CONFIG[size];
|
const sizeConfig = SIZE_CONFIG[size];
|
||||||
const imageSrc = getProviderLogoAsset(provider);
|
const imageSrc = getProviderLogoAsset(provider);
|
||||||
|
const usesSelfContainedLogo = providerUsesSelfContainedLogo(provider);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex items-center justify-center rounded-md',
|
'flex items-center justify-center rounded-md',
|
||||||
imageSrc &&
|
imageSrc &&
|
||||||
|
!usesSelfContainedLogo &&
|
||||||
(providerNeedsDarkLogoBackground(provider) ? 'bg-gray-900 p-1' : 'bg-white p-1'),
|
(providerNeedsDarkLogoBackground(provider) ? 'bg-gray-900 p-1' : 'bg-white p-1'),
|
||||||
sizeConfig.container,
|
sizeConfig.container,
|
||||||
className
|
className
|
||||||
@@ -42,7 +45,10 @@ export function ProviderLogo({ provider, className, size = 'md' }: ProviderLogoP
|
|||||||
<img
|
<img
|
||||||
src={imageSrc}
|
src={imageSrc}
|
||||||
alt={`${provider} logo`}
|
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)}>
|
<span className={cn('font-semibold', fallback.textClass, sizeConfig.text)}>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
PROVIDER_CAPABILITIES,
|
PROVIDER_CAPABILITIES,
|
||||||
getProvidersByOAuthFlow,
|
getProvidersByOAuthFlow,
|
||||||
} from '../../../src/cliproxy/provider-capabilities';
|
} from '../../../src/cliproxy/provider-capabilities';
|
||||||
|
import type { AiProviderFamilyId } from '../../../src/cliproxy/ai-providers';
|
||||||
|
|
||||||
// Monorepo contract: UI consumes provider capability constants directly from backend
|
// Monorepo contract: UI consumes provider capability constants directly from backend
|
||||||
// to enforce one source of truth and prevent provider drift across surfaces.
|
// 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 */
|
/** Union type for CLIProxy provider IDs */
|
||||||
export type CLIProxyProvider = (typeof CLIPROXY_PROVIDERS)[number];
|
export type CLIProxyProvider = (typeof CLIPROXY_PROVIDERS)[number];
|
||||||
|
export type ProviderVisualId = CLIProxyProvider | 'openai' | 'vertex';
|
||||||
|
|
||||||
/** Check if a string is a valid CLIProxy provider */
|
/** Check if a string is a valid CLIProxy provider */
|
||||||
export function isValidProvider(provider: string): provider is CLIProxyProvider {
|
export function isValidProvider(provider: string): provider is CLIProxyProvider {
|
||||||
@@ -33,6 +35,15 @@ interface ProviderMetadata {
|
|||||||
description: string;
|
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(
|
export const PROVIDER_METADATA: Record<CLIProxyProvider, ProviderMetadata> = Object.freeze(
|
||||||
Object.fromEntries(
|
Object.fromEntries(
|
||||||
CLIPROXY_PROVIDERS.map((provider) => [
|
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)
|
// 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',
|
gemini: '/assets/providers/gemini-color.svg',
|
||||||
agy: '/assets/providers/agy.png',
|
agy: '/assets/providers/agy.png',
|
||||||
codex: '/assets/providers/openai.svg',
|
codex: '/assets/providers/codex-color.svg',
|
||||||
qwen: '/assets/providers/qwen-color.svg',
|
qwen: '/assets/providers/qwen-color.svg',
|
||||||
iflow: '/assets/providers/iflow.png',
|
iflow: '/assets/providers/iflow.png',
|
||||||
kiro: '/assets/providers/kiro.png',
|
kiro: '/assets/providers/kiro.png',
|
||||||
ghcp: '/assets/providers/copilot.svg',
|
ghcp: '/assets/providers/copilot.svg',
|
||||||
claude: '/assets/providers/claude.svg',
|
claude: '/assets/providers/claude.svg',
|
||||||
kimi: '/assets/providers/kimi.svg',
|
kimi: '/assets/providers/kimi.svg',
|
||||||
|
openai: '/assets/providers/openai.svg',
|
||||||
|
vertex: '/assets/providers/vertex.svg',
|
||||||
};
|
};
|
||||||
|
|
||||||
interface ProviderFallbackVisual {
|
interface ProviderFallbackVisual {
|
||||||
@@ -69,7 +82,7 @@ const DEFAULT_PROVIDER_FALLBACK_VISUAL: ProviderFallbackVisual = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** Fallback visual style when a provider logo asset is unavailable. */
|
/** 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' },
|
gemini: { textClass: 'text-blue-600', letter: 'G' },
|
||||||
claude: { textClass: 'text-orange-600', letter: 'C' },
|
claude: { textClass: 'text-orange-600', letter: 'C' },
|
||||||
codex: { textClass: 'text-emerald-600', letter: 'X' },
|
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' },
|
kiro: { textClass: 'text-teal-600', letter: 'K' },
|
||||||
ghcp: { textClass: 'text-green-600', letter: 'C' },
|
ghcp: { textClass: 'text-green-600', letter: 'C' },
|
||||||
kimi: { textClass: 'text-orange-500', letter: 'K' },
|
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. */
|
/** 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 {
|
export function getProviderLogoAsset(provider: unknown): string | undefined {
|
||||||
const normalized = normalizeProviderInput(provider);
|
const normalized = normalizeProviderInput(provider);
|
||||||
if (!isValidProvider(normalized)) {
|
if (!isProviderVisualId(normalized)) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return PROVIDER_ASSETS[normalized];
|
return PROVIDER_ASSETS[normalized];
|
||||||
@@ -94,7 +125,7 @@ export function getProviderLogoAsset(provider: unknown): string | undefined {
|
|||||||
|
|
||||||
export function getProviderFallbackVisual(provider: unknown): ProviderFallbackVisual {
|
export function getProviderFallbackVisual(provider: unknown): ProviderFallbackVisual {
|
||||||
const normalized = normalizeProviderInput(provider);
|
const normalized = normalizeProviderInput(provider);
|
||||||
if (isValidProvider(normalized)) {
|
if (isProviderVisualId(normalized)) {
|
||||||
return PROVIDER_FALLBACK_VISUALS[normalized];
|
return PROVIDER_FALLBACK_VISUALS[normalized];
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -105,7 +136,12 @@ export function getProviderFallbackVisual(provider: unknown): ProviderFallbackVi
|
|||||||
|
|
||||||
export function providerNeedsDarkLogoBackground(provider: unknown): boolean {
|
export function providerNeedsDarkLogoBackground(provider: unknown): boolean {
|
||||||
const normalized = normalizeProviderInput(provider);
|
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
|
// Provider brand colors
|
||||||
@@ -113,6 +149,7 @@ export const PROVIDER_COLORS: Record<string, string> = {
|
|||||||
gemini: '#4285F4',
|
gemini: '#4285F4',
|
||||||
agy: '#f3722c',
|
agy: '#f3722c',
|
||||||
codex: '#10a37f',
|
codex: '#10a37f',
|
||||||
|
openai: '#111827',
|
||||||
vertex: '#4285F4',
|
vertex: '#4285F4',
|
||||||
iflow: '#f94144',
|
iflow: '#f94144',
|
||||||
qwen: '#6236FF',
|
qwen: '#6236FF',
|
||||||
@@ -126,6 +163,7 @@ const PROVIDER_NAMES: Record<string, string> = {
|
|||||||
...Object.fromEntries(
|
...Object.fromEntries(
|
||||||
CLIPROXY_PROVIDERS.map((provider) => [provider, PROVIDER_METADATA[provider].displayName])
|
CLIPROXY_PROVIDERS.map((provider) => [provider, PROVIDER_METADATA[provider].displayName])
|
||||||
),
|
),
|
||||||
|
openai: 'OpenAI',
|
||||||
vertex: 'Vertex AI',
|
vertex: 'Vertex AI',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+1521
-219
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user