* 🎨 feat(web/default): add shadcn-style theme presets, radius prefs, and fix selection badges Integrate the qn-platform–style OKLCH color system into the default frontend while keeping the existing blue-tinted dark tokens for the default theme. Add [data-theme-preset] palettes for seven named presets plus the default zinc-like scale, define [data-theme-radius] overrides so user radius beats preset --radius, and align the Tailwind @custom-variant dark helper with .dark usage. Introduce ThemeCustomizationProvider to own preset and radius state, persist choices in cookies (theme-preset, theme-radius), and sync data-theme-preset / data-theme-radius on <html>. Wrap the tree in main.tsx. Extend ConfigDrawer with theme preset swatches (scoped data-theme-preset) and radius previews wired to context; refactor swatch/card markup so selected CircleCheck badges sit outside clipped rows (remove outer overflow-hidden that hid the centered checkmark). Add i18n keys for preset names, radius, and accessibility labels across en, zh, fr, ja, ru, vi. * 🎨 fix(web): align segmented controls with theme radius tokens - Replace hard-coded inner pill radii (rounded-[5px]) on dashboard chart toolbars with radius-md so the active state follows --radius when users change Radius in Theme Settings. - Use nested radii consistent with TabsList/TabsTrigger: outer rounded-lg (var(--radius)) and inner rounded-md (calc(var(--radius) - 2px)) so the track and active thumb stay concentric at small scales (e.g. 0.3rem) instead of a squared “focus” block inside a rounded shell. - Apply the same pattern to pricing SegmentedControl and the segmented groups in consumption-distribution-chart, model-charts, and user-charts. Verified: bun run typecheck (web/default) * ✨ feat(pricing): enrich model details with uptime sparkline and API documentation Add a compact 30-day uptime sparkline (OpenRouter-style bars + aggregate %) with per-day tooltips, surface it in a status row under quick stats and in the per-group performance table, and extend mock data so uptime series are stable and optionally scoped by group. Introduce an API tab with Shiki-highlighted code samples (cURL, Python, TypeScript, JavaScript), endpoint-type switching, authentication guidance, a supported-parameters table, and mock per-group RPM/TPM/RPD limits. Infer vendor, tokenizer, license, and data-retention hints for a provider & data privacy card on the Overview tab (capabilities/modalities stay with model identity; rate limits stay with the API tab). Update i18n for all new user-facing strings across en, zh, fr, ja, ru, and vi. * 🏆 feat(rankings): add comprehensive rankings dashboard Add a mock-data powered rankings experience with period tabs, model, app, and vendor leaderboards, market share and history charts, movers, new releases, and per-category sections while backend analytics are pending. Link ranked models to pricing details and ranked vendors to filtered pricing results, and include localized copy for all supported frontend locales. * fix(theme): correct theme preset selection state - update Base UI Radio selectors to use data-checked/data-unchecked states. - fix unchecked theme options still showing selected indicators. - isolate the default theme preview tokens to prevent preset changes from leaking into it. * fix(setup): correct usage mode radio state - use Base UI data-checked/data-unchecked states for RadioGroup styling. - hide radio indicators when options are unchecked to avoid setup page display issues. - drive usage mode card and icon selection styles from Base UI state. * fix(auth): submit sign-in and sign-up forms * 🎨 refactor: Align default theme with shadcn Base Nova and prune legacy customization Migrate shadcn UI to Base UI primitives via CLI (`base-nova` / `components.json`) and reinstall full component registry with `--overwrite`, including Hugeicons-backed widgets and newly added registry components. - Remove custom multi-preset/theme-radius system (`ThemeCustomizationProvider`, cookies, preset UI from config drawer); rely on official semantic CSS tokens + light/dark only. - Replace `theme.css` with shadcn’s documented neutral `:root`/`.dark` palette and `@theme inline` mappings (plus skeleton token vars for existing shimmer usage). - Update global styles for Base UI: collapsible animation uses `--collapsible-panel-height`; clarify scroll-lock override comment. Application compatibility: - Keep minimal shims where app code diverged from official APIs (popover collision props, combobox legacy `options` callers, Spinner prop typing). - Switch interactive styling from Radix-era `data-state` / `--radix-*` selectors to Base UI semantics (`data-open`, `data-popup-open`, `data-panel-open`, `--anchor-width`, etc.) Tooling / docs / build: - Rename Rsbuild vendor chunk grouping to `@base-ui` + transitive `@radix-ui`. - Refresh AGENTS.md / CLAUDE.md / classic→default sync skill for Base UI stack. - Bump `package.json` / lockfile for shadcn-postinstall deps (Hugeicons, chart stack, themes, etc.) Verified: `bun run typecheck` passes. Note: `bun run lint` still reports pre-existing hooks rule violations elsewhere; not addressed in this change. * 🎨 chore(web/default): unify table toolbar, relocate usage stats, refine filters - Refactor DataTableToolbar to a single wrapping flex row with a right-aligned action cluster (Reset / Search / View / Expand) for a cleaner Ant Design Pro–style filter bar; remove the dedicated stats row and the toolbar `stats` prop. - Move Common Logs summary badges (Usage / RPM / TPM) and the sensitive- data visibility toggle into the page header via CommonLogsHeaderActions and SectionPageLayout.Actions so the toolbar stays focused on filters. - Slim CommonLogsFilterBar props (no stats / preActions eye control). - Improve CompactDateTimeRangePicker: show minute-precision labels on the trigger (seconds omitted; aligns with datetime-local inputs); widen the trigger on sm+ breakpoints so the full range is visible without truncation; apply the same width in task logs filters. - Simplify DataTableViewOptions: text-only “View” trigger, no sliders icon. - Earlier layout tweak: extra top padding on SectionPageLayout scroll content so control focus rings are not clipped by overflow. * feat(web/default): Base UI migration and component foundation Migrate from Radix UI to Base UI, rewrite core UI primitives, update dependencies (recharts, date-fns, next-themes), add shadcn agent skill documentation, and refresh AI element components. This is the foundational work from the v2/localmain lineage that was not covered by the individual feature commits above. --------- Co-authored-by: t0ng7u <dev@aiass.cc> Co-authored-by: QuentinHsu <xuquentinyang@gmail.com>
3363 lines
131 KiB
TypeScript
Vendored
3363 lines
131 KiB
TypeScript
Vendored
import {
|
|
type ReactNode,
|
|
useEffect,
|
|
useState,
|
|
useMemo,
|
|
useCallback,
|
|
useRef,
|
|
} from 'react'
|
|
import { useForm } from 'react-hook-form'
|
|
import { zodResolver } from '@hookform/resolvers/zod'
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import {
|
|
ArrowRight,
|
|
HelpCircle,
|
|
Loader2,
|
|
Sparkles,
|
|
Trash2,
|
|
Copy,
|
|
FileText,
|
|
Eraser,
|
|
Plus,
|
|
Eye,
|
|
Link2,
|
|
RefreshCw,
|
|
ChevronDown,
|
|
Code,
|
|
Boxes,
|
|
KeyRound,
|
|
Route,
|
|
Server,
|
|
Settings,
|
|
SlidersHorizontal,
|
|
Wand2,
|
|
} from 'lucide-react'
|
|
import { useTranslation } from 'react-i18next'
|
|
import { toast } from 'sonner'
|
|
import { getLobeIcon } from '@/lib/lobe-icon'
|
|
import { cn } from '@/lib/utils'
|
|
import { useCopyToClipboard } from '@/hooks/use-copy-to-clipboard'
|
|
import { useHiddenClickUnlock } from '@/hooks/use-hidden-click-unlock'
|
|
import { Alert, AlertDescription } from '@/components/ui/alert'
|
|
import { Button } from '@/components/ui/button'
|
|
import {
|
|
Collapsible,
|
|
CollapsibleContent,
|
|
CollapsibleTrigger,
|
|
} from '@/components/ui/collapsible'
|
|
import { Combobox } from '@/components/ui/combobox'
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormDescription,
|
|
FormField,
|
|
FormItem,
|
|
FormLabel,
|
|
FormMessage,
|
|
} from '@/components/ui/form'
|
|
import { Input } from '@/components/ui/input'
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from '@/components/ui/select'
|
|
import {
|
|
Sheet,
|
|
SheetClose,
|
|
SheetContent,
|
|
SheetDescription,
|
|
SheetFooter,
|
|
SheetHeader,
|
|
SheetTitle,
|
|
} from '@/components/ui/sheet'
|
|
import { Skeleton } from '@/components/ui/skeleton'
|
|
import { Switch } from '@/components/ui/switch'
|
|
import { Textarea } from '@/components/ui/textarea'
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from '@/components/ui/tooltip'
|
|
import { JsonEditor } from '@/components/json-editor'
|
|
import { MultiSelect } from '@/components/multi-select'
|
|
import {
|
|
SecureVerificationDialog,
|
|
useSecureVerification,
|
|
} from '@/features/auth/secure-verification'
|
|
import {
|
|
createChannel,
|
|
fetchModels,
|
|
getAllModels,
|
|
getChannel,
|
|
getChannelKey,
|
|
getGroups,
|
|
getPrefillGroups,
|
|
refreshCodexCredential,
|
|
updateChannel,
|
|
} from '../../api'
|
|
import {
|
|
ADD_MODE_OPTIONS,
|
|
CHANNEL_TYPE_OPTIONS,
|
|
CHANNEL_TYPE_WARNINGS,
|
|
ERROR_MESSAGES,
|
|
FIELD_DESCRIPTIONS,
|
|
FIELD_PLACEHOLDERS,
|
|
MODEL_FETCHABLE_TYPES,
|
|
SUCCESS_MESSAGES,
|
|
} from '../../constants'
|
|
import {
|
|
CHANNEL_FORM_DEFAULT_VALUES,
|
|
channelFormSchema,
|
|
channelsQueryKeys,
|
|
transformChannelToFormDefaults,
|
|
transformFormDataToCreatePayload,
|
|
transformFormDataToUpdatePayload,
|
|
type ChannelFormValues,
|
|
deduplicateKeys,
|
|
getChannelTypeIcon,
|
|
getKeyPromptForType,
|
|
parseModelsString,
|
|
formatModelsArray,
|
|
extractRedirectModels,
|
|
extractMappingSourceModels,
|
|
hasModelConfigChanged,
|
|
findMissingModelsInMapping,
|
|
validateModelMappingJson,
|
|
} from '../../lib'
|
|
import {
|
|
collectInvalidStatusCodeEntries,
|
|
collectNewDisallowedStatusCodeRedirects,
|
|
} from '../../lib/status-code-risk-guard'
|
|
import type { Channel } from '../../types'
|
|
import { useChannels } from '../channels-provider'
|
|
import { CodexOAuthDialog } from '../dialogs/codex-oauth-dialog'
|
|
import { FetchModelsDialog } from '../dialogs/fetch-models-dialog'
|
|
import {
|
|
MissingModelsConfirmationDialog,
|
|
type MissingModelsAction,
|
|
} from '../dialogs/missing-models-confirmation-dialog'
|
|
import { ParamOverrideEditorDialog } from '../dialogs/param-override-editor-dialog'
|
|
import { StatusCodeRiskDialog } from '../dialogs/status-code-risk-dialog'
|
|
import { ModelMappingEditor } from '../model-mapping-editor'
|
|
|
|
type ChannelMutateDrawerProps = {
|
|
open: boolean
|
|
onOpenChange: (open: boolean) => void
|
|
currentRow?: Channel | null
|
|
}
|
|
|
|
type ModelMappingGuardrail = {
|
|
invalidJson: boolean
|
|
entries: Array<{ source: string; target: string }>
|
|
missingSourceModels: string[]
|
|
exposedTargetModels: string[]
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === 'object' && value !== null
|
|
}
|
|
|
|
function getErrorMessage(error: unknown): string | undefined {
|
|
if (error instanceof Error && typeof error.message === 'string') {
|
|
return error.message
|
|
}
|
|
|
|
if (!isRecord(error)) return undefined
|
|
|
|
const response = error.response
|
|
if (isRecord(response)) {
|
|
const data = response.data
|
|
if (isRecord(data)) {
|
|
const message = data.message
|
|
if (typeof message === 'string') return message
|
|
}
|
|
}
|
|
|
|
const message = error.message
|
|
if (typeof message === 'string') return message
|
|
return undefined
|
|
}
|
|
|
|
// Helper functions
|
|
const createEmptyModelMappingGuardrail = (): ModelMappingGuardrail => ({
|
|
invalidJson: false,
|
|
entries: [],
|
|
missingSourceModels: [],
|
|
exposedTargetModels: [],
|
|
})
|
|
|
|
const formatModelNames = (models: string[]): string =>
|
|
models.map((model) => `"${model}"`).join(', ')
|
|
|
|
const MODEL_MAPPING_PREVIEW_FALLBACK: Array<{
|
|
source: string
|
|
target: string
|
|
}> = [{ source: 'client-model', target: 'upstream-model' }]
|
|
|
|
const ADVANCED_SETTINGS_EXPANDED_KEY = 'channel-advanced-settings-expanded'
|
|
const UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT = 8
|
|
|
|
function readAdvancedSettingsPreference(): boolean {
|
|
if (typeof window === 'undefined') return false
|
|
return window.localStorage.getItem(ADVANCED_SETTINGS_EXPANDED_KEY) === 'true'
|
|
}
|
|
|
|
function hasAdvancedSettingsValues(values: ChannelFormValues): boolean {
|
|
return Boolean(
|
|
values.model_mapping?.trim() ||
|
|
values.param_override?.trim() ||
|
|
values.header_override?.trim() ||
|
|
values.status_code_mapping?.trim() ||
|
|
values.tag?.trim() ||
|
|
values.remark?.trim() ||
|
|
values.priority ||
|
|
values.weight ||
|
|
values.proxy?.trim() ||
|
|
values.system_prompt?.trim() ||
|
|
values.force_format ||
|
|
values.thinking_to_content ||
|
|
values.pass_through_body_enabled ||
|
|
values.system_prompt_override ||
|
|
values.claude_beta_query ||
|
|
values.upstream_model_update_check_enabled ||
|
|
values.upstream_model_update_auto_sync_enabled ||
|
|
values.upstream_model_update_ignored_models?.trim()
|
|
)
|
|
}
|
|
|
|
function parseSettingsRecord(
|
|
settings: string | undefined
|
|
): Record<string, unknown> {
|
|
if (!settings?.trim()) return {}
|
|
try {
|
|
const parsed = JSON.parse(settings)
|
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
return parsed as Record<string, unknown>
|
|
}
|
|
} catch {
|
|
return {}
|
|
}
|
|
return {}
|
|
}
|
|
|
|
function formatUnixTime(timestamp: unknown): string {
|
|
const seconds = Number(timestamp)
|
|
if (!Number.isFinite(seconds) || seconds <= 0) return '-'
|
|
return new Date(seconds * 1000).toLocaleString()
|
|
}
|
|
|
|
function CardHeading({ title, icon }: { title: string; icon?: ReactNode }) {
|
|
return (
|
|
<div className='flex items-center gap-2.5'>
|
|
{icon && (
|
|
<span className='bg-primary/10 text-primary flex h-8 w-8 items-center justify-center rounded-lg'>
|
|
{icon}
|
|
</span>
|
|
)}
|
|
<h3 className='text-sm font-semibold tracking-tight'>{title}</h3>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SubHeading({ title, icon }: { title: string; icon?: ReactNode }) {
|
|
return (
|
|
<div className='flex items-center gap-2'>
|
|
{icon && <span className='text-muted-foreground'>{icon}</span>}
|
|
<h4 className='text-muted-foreground text-xs font-medium tracking-wide uppercase'>
|
|
{title}
|
|
</h4>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export function ChannelMutateDrawer({
|
|
open,
|
|
onOpenChange,
|
|
currentRow,
|
|
}: ChannelMutateDrawerProps) {
|
|
const { t } = useTranslation()
|
|
const queryClient = useQueryClient()
|
|
const { setOpen } = useChannels()
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [customModel, setCustomModel] = useState('')
|
|
const [isFetchingModels, setIsFetchingModels] = useState(false)
|
|
const [fetchModelsDialogOpen, setFetchModelsDialogOpen] = useState(false)
|
|
const [channelKey, setChannelKey] = useState<string | null>(null)
|
|
const [isChannelKeyLoading, setIsChannelKeyLoading] = useState(false)
|
|
const [codexOAuthDialogOpen, setCodexOAuthDialogOpen] = useState(false)
|
|
const [isCodexCredentialRefreshing, setIsCodexCredentialRefreshing] =
|
|
useState(false)
|
|
const initialModelsRef = useRef<string[]>([])
|
|
const initialModelMappingRef = useRef<string>('')
|
|
const initialStatusCodeMappingRef = useRef<string>('')
|
|
const [statusCodeRiskOpen, setStatusCodeRiskOpen] = useState(false)
|
|
const [statusCodeRiskDetailItems, setStatusCodeRiskDetailItems] = useState<
|
|
string[]
|
|
>([])
|
|
const statusCodeRiskResolveRef = useRef<
|
|
((confirmed: boolean) => void) | null
|
|
>(null)
|
|
const [missingModelsDialogOpen, setMissingModelsDialogOpen] = useState(false)
|
|
const [missingModelsList, setMissingModelsList] = useState<string[]>([])
|
|
const missingModelsResolveRef = useRef<
|
|
((action: MissingModelsAction) => void) | null
|
|
>(null)
|
|
const [advancedSettingsOpen, setAdvancedSettingsOpen] = useState(false)
|
|
const [paramOverrideEditorOpen, setParamOverrideEditorOpen] = useState(false)
|
|
|
|
const isEditing = Boolean(currentRow)
|
|
const channelId = currentRow?.id ?? null
|
|
|
|
// Fetch channel details if editing
|
|
const { data: channelData } = useQuery({
|
|
queryKey: channelsQueryKeys.detail(currentRow?.id || 0),
|
|
queryFn: () => getChannel(currentRow!.id),
|
|
enabled: isEditing && Boolean(currentRow?.id),
|
|
})
|
|
|
|
// Fetch available groups
|
|
const { data: groupsData, isLoading: isLoadingGroups } = useQuery({
|
|
queryKey: ['groups'],
|
|
queryFn: getGroups,
|
|
})
|
|
|
|
// Fetch all available models
|
|
const { data: allModelsData } = useQuery({
|
|
queryKey: ['channel_models'],
|
|
queryFn: getAllModels,
|
|
})
|
|
|
|
// Fetch prefill model groups
|
|
const { data: prefillGroupsData } = useQuery({
|
|
queryKey: ['prefill_groups', 'model'],
|
|
queryFn: () => getPrefillGroups('model'),
|
|
})
|
|
|
|
const { copyToClipboard } = useCopyToClipboard()
|
|
|
|
const {
|
|
open: verificationOpen,
|
|
methods: verificationMethods,
|
|
state: verificationState,
|
|
executeVerification,
|
|
withVerification,
|
|
cancel: cancelVerification,
|
|
setCode: setVerificationCode,
|
|
switchMethod: switchVerificationMethod,
|
|
} = useSecureVerification()
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
setChannelKey(null)
|
|
setIsChannelKeyLoading(false)
|
|
} else if (channelId) {
|
|
setChannelKey(null)
|
|
}
|
|
}, [open, channelId])
|
|
|
|
// Check if this is a multi-key channel
|
|
const isMultiKeyChannel =
|
|
isEditing && channelData?.data?.channel_info?.is_multi_key === true
|
|
|
|
// Form setup
|
|
const form = useForm<ChannelFormValues>({
|
|
resolver: zodResolver(channelFormSchema),
|
|
defaultValues: CHANNEL_FORM_DEFAULT_VALUES,
|
|
})
|
|
|
|
// Watch form values for conditional rendering
|
|
const multiKeyMode = form.watch('multi_key_mode')
|
|
const multiKeyType = form.watch('multi_key_type')
|
|
const keyMode = form.watch('key_mode')
|
|
const currentGroups = form.watch('group')
|
|
const currentType = form.watch('type')
|
|
const currentBaseUrl = form.watch('base_url')
|
|
const currentModels = form.watch('models')
|
|
const currentModelMapping = form.watch('model_mapping')
|
|
const awsKeyType = form.watch('aws_key_type')
|
|
const upstreamModelUpdateCheckEnabled = form.watch(
|
|
'upstream_model_update_check_enabled'
|
|
)
|
|
const currentSettings = form.watch('settings')
|
|
const {
|
|
unlocked: doubaoApiEditUnlocked,
|
|
handleClick: handleApiConfigSecretClick,
|
|
reset: resetDoubaoApiUnlock,
|
|
} = useHiddenClickUnlock({
|
|
requiredClicks: 10,
|
|
disabled: currentType !== 45,
|
|
onUnlock: () => {
|
|
toast.info(t('Doubao custom API address editing unlocked'))
|
|
},
|
|
})
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
resetDoubaoApiUnlock()
|
|
}
|
|
}, [open, resetDoubaoApiUnlock])
|
|
|
|
// Helper computed values
|
|
const isBatchMode =
|
|
multiKeyMode === 'batch' || multiKeyMode === 'multi_to_single'
|
|
|
|
// Get all models list
|
|
const allModelsList = useMemo(
|
|
() => allModelsData?.data?.map((model) => model.id).filter(Boolean) || [],
|
|
[allModelsData]
|
|
)
|
|
|
|
// Get basic models for the current channel type
|
|
const basicModels = useMemo(() => {
|
|
if (!allModelsList.length) return []
|
|
// Filter models based on common patterns for specific types
|
|
if (currentType === 1) {
|
|
return allModelsList.filter(
|
|
(model) => model.startsWith('gpt-') || model.startsWith('text-')
|
|
)
|
|
}
|
|
return allModelsList
|
|
}, [allModelsList, currentType])
|
|
|
|
// Get prefill groups
|
|
const prefillGroups = useMemo(
|
|
() => prefillGroupsData?.data || [],
|
|
[prefillGroupsData]
|
|
)
|
|
|
|
// Transform groups to multi-select options
|
|
const groupOptions = useMemo(() => {
|
|
if (!groupsData?.data) return []
|
|
const allGroups = new Set([...groupsData.data, ...(currentGroups || [])])
|
|
return Array.from(allGroups).map((group) => ({
|
|
value: group,
|
|
label: group,
|
|
}))
|
|
}, [groupsData, currentGroups])
|
|
|
|
// Parse current models as array
|
|
const currentModelsArray = useMemo(
|
|
() => parseModelsString(currentModels),
|
|
[currentModels]
|
|
)
|
|
|
|
const currentTypeLabel = useMemo(
|
|
() =>
|
|
CHANNEL_TYPE_OPTIONS.find((option) => option.value === currentType)
|
|
?.label || `#${currentType}`,
|
|
[currentType]
|
|
)
|
|
|
|
const channelTypeOptions = useMemo(() => {
|
|
const options = CHANNEL_TYPE_OPTIONS.map((option) => ({
|
|
value: String(option.value),
|
|
label: t(option.label),
|
|
icon: getLobeIcon(`${getChannelTypeIcon(option.value)}.Color`, 16),
|
|
}))
|
|
if (!options.some((option) => Number(option.value) === currentType)) {
|
|
options.push({
|
|
value: String(currentType),
|
|
label: `#${currentType}`,
|
|
icon: getLobeIcon(`${getChannelTypeIcon(currentType)}.Color`, 16),
|
|
})
|
|
}
|
|
return options
|
|
}, [currentType, t])
|
|
|
|
// Extract redirect models from model_mapping (target values)
|
|
const redirectModelList = useMemo(
|
|
() => extractRedirectModels(currentModelMapping || ''),
|
|
[currentModelMapping]
|
|
)
|
|
|
|
// Extract source keys from model_mapping (models being remapped FROM)
|
|
const redirectModelKeyList = useMemo(
|
|
() => extractMappingSourceModels(currentModelMapping || ''),
|
|
[currentModelMapping]
|
|
)
|
|
|
|
// Transform models to multi-select options
|
|
const modelOptions = useMemo(() => {
|
|
const allModels = new Set([...allModelsList, ...currentModelsArray])
|
|
return Array.from(allModels).map((model) => ({
|
|
value: model,
|
|
label: model,
|
|
}))
|
|
}, [allModelsList, currentModelsArray])
|
|
|
|
const modelMappingGuardrail = useMemo<ModelMappingGuardrail>(() => {
|
|
if (!currentModelMapping?.trim()) {
|
|
return createEmptyModelMappingGuardrail()
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(currentModelMapping)
|
|
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
return { ...createEmptyModelMappingGuardrail(), invalidJson: true }
|
|
}
|
|
|
|
const entries = Object.entries(parsed).reduce<
|
|
Array<{ source: string; target: string }>
|
|
>((acc, [rawSource, rawTarget]) => {
|
|
const source = String(rawSource).trim()
|
|
const target = String(rawTarget ?? '').trim()
|
|
|
|
if (!source || !target) {
|
|
return acc
|
|
}
|
|
|
|
acc.push({ source, target })
|
|
return acc
|
|
}, [])
|
|
|
|
const missingSourceModels = Array.from(
|
|
new Set(
|
|
entries
|
|
.filter(
|
|
(entry) =>
|
|
Boolean(entry.source) &&
|
|
!currentModelsArray.includes(entry.source)
|
|
)
|
|
.map((entry) => entry.source)
|
|
)
|
|
)
|
|
|
|
const exposedTargetModels = Array.from(
|
|
new Set(
|
|
entries
|
|
.filter(
|
|
(entry) =>
|
|
Boolean(entry.target) &&
|
|
currentModelsArray.includes(entry.target)
|
|
)
|
|
.map((entry) => entry.target)
|
|
)
|
|
)
|
|
|
|
return {
|
|
invalidJson: false,
|
|
entries,
|
|
missingSourceModels,
|
|
exposedTargetModels,
|
|
}
|
|
} catch {
|
|
return { ...createEmptyModelMappingGuardrail(), invalidJson: true }
|
|
}
|
|
}, [currentModelMapping, currentModelsArray])
|
|
|
|
const mappingPreviewPairs =
|
|
modelMappingGuardrail.entries.length > 0
|
|
? modelMappingGuardrail.entries.slice(0, 3)
|
|
: MODEL_MAPPING_PREVIEW_FALLBACK
|
|
const remainingMappingCount =
|
|
modelMappingGuardrail.entries.length > 3
|
|
? modelMappingGuardrail.entries.length - 3
|
|
: 0
|
|
|
|
const upstreamUpdateMeta = useMemo(() => {
|
|
const settings = parseSettingsRecord(currentSettings)
|
|
const detectedModels = Array.isArray(
|
|
settings.upstream_model_update_last_detected_models
|
|
)
|
|
? settings.upstream_model_update_last_detected_models
|
|
.map((model) => String(model || '').trim())
|
|
.filter(Boolean)
|
|
: []
|
|
|
|
return {
|
|
lastCheckTime: settings.upstream_model_update_last_check_time,
|
|
detectedModels: Array.from(new Set(detectedModels)),
|
|
}
|
|
}, [currentSettings])
|
|
|
|
const upstreamDetectedModelsPreview = upstreamUpdateMeta.detectedModels.slice(
|
|
0,
|
|
UPSTREAM_DETECTED_MODEL_PREVIEW_LIMIT
|
|
)
|
|
const upstreamDetectedModelsOmittedCount =
|
|
upstreamUpdateMeta.detectedModels.length -
|
|
upstreamDetectedModelsPreview.length
|
|
|
|
// Load channel data into form when editing
|
|
useEffect(() => {
|
|
if (isEditing && channelData?.data) {
|
|
const defaults = transformChannelToFormDefaults(channelData.data)
|
|
form.reset(defaults)
|
|
setAdvancedSettingsOpen(
|
|
readAdvancedSettingsPreference() || hasAdvancedSettingsValues(defaults)
|
|
)
|
|
// Store initial values for comparison
|
|
initialModelsRef.current = parseModelsString(
|
|
channelData.data.models || ''
|
|
)
|
|
initialModelMappingRef.current = channelData.data.model_mapping || ''
|
|
initialStatusCodeMappingRef.current =
|
|
channelData.data.status_code_mapping || ''
|
|
} else if (!isEditing) {
|
|
form.reset(CHANNEL_FORM_DEFAULT_VALUES)
|
|
setAdvancedSettingsOpen(false)
|
|
initialModelsRef.current = []
|
|
initialModelMappingRef.current = ''
|
|
initialStatusCodeMappingRef.current = ''
|
|
}
|
|
}, [isEditing, channelData, form])
|
|
|
|
// Handle type change - set default values for specific types
|
|
useEffect(() => {
|
|
if (isEditing) return // Don't auto-set defaults when editing
|
|
|
|
// Type 45 (VolcEngine) - set default base_url
|
|
if (currentType === 45) {
|
|
const currentBaseUrlValue = form.getValues('base_url')
|
|
if (!currentBaseUrlValue || currentBaseUrlValue === '') {
|
|
form.setValue('base_url', 'https://ark.cn-beijing.volces.com')
|
|
}
|
|
}
|
|
|
|
// Type 18 (Xunfei) - set default other (version)
|
|
if (currentType === 18) {
|
|
const currentOther = form.getValues('other')
|
|
if (!currentOther || currentOther === '') {
|
|
form.setValue('other', 'v2.1')
|
|
}
|
|
}
|
|
}, [currentType, isEditing, form])
|
|
|
|
// Validate base_url - warn if it ends with /v1
|
|
useEffect(() => {
|
|
if (!currentBaseUrl || !currentBaseUrl.endsWith('/v1')) return
|
|
|
|
// Show warning toast
|
|
const timer = setTimeout(() => {
|
|
toast.warning(
|
|
t(
|
|
'Warning: Base URL should not end with /v1. New API will handle it automatically. This may cause request failures.'
|
|
),
|
|
{ duration: 5000 }
|
|
)
|
|
}, 500)
|
|
|
|
return () => clearTimeout(timer)
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [currentBaseUrl])
|
|
|
|
// Handle key deduplication
|
|
const handleDeduplicateKeys = () => {
|
|
const currentKey = form.getValues('key')
|
|
if (!currentKey || currentKey.trim() === '') {
|
|
toast.info(t('Please enter keys first'))
|
|
return
|
|
}
|
|
|
|
const result = deduplicateKeys(currentKey)
|
|
|
|
if (result.removedCount === 0) {
|
|
toast.info(t('No duplicate keys found'))
|
|
} else {
|
|
form.setValue('key', result.deduplicatedText)
|
|
toast.success(
|
|
t(
|
|
'Removed {{removed}} duplicate key(s). Before: {{before}}, After: {{after}}',
|
|
{
|
|
removed: result.removedCount,
|
|
before: result.beforeCount,
|
|
after: result.afterCount,
|
|
}
|
|
)
|
|
)
|
|
}
|
|
}
|
|
|
|
const fetchChannelKey = useCallback(async () => {
|
|
if (!channelId) {
|
|
throw new Error('Channel is not selected')
|
|
}
|
|
|
|
setIsChannelKeyLoading(true)
|
|
try {
|
|
const res = await getChannelKey(channelId)
|
|
if (!res.success) {
|
|
throw new Error(res.message || 'Failed to fetch channel key')
|
|
}
|
|
|
|
const keyValue = res.data?.key ?? ''
|
|
setChannelKey(keyValue)
|
|
toast.success(t('Channel key unlocked'))
|
|
return res
|
|
} finally {
|
|
setIsChannelKeyLoading(false)
|
|
}
|
|
}, [channelId, t])
|
|
|
|
const handleRevealKey = useCallback(async () => {
|
|
if (!channelId) return
|
|
|
|
try {
|
|
await withVerification(fetchChannelKey, {
|
|
preferredMethod: 'passkey',
|
|
title: 'Verify to view channel key',
|
|
description:
|
|
'Use Passkey or 2FA to confirm your identity before revealing this channel key.',
|
|
})
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
toast.error(error.message)
|
|
}
|
|
}
|
|
}, [channelId, withVerification, fetchChannelKey])
|
|
|
|
const handleRefreshCodexCredential = useCallback(async () => {
|
|
if (!channelId) return
|
|
setIsCodexCredentialRefreshing(true)
|
|
try {
|
|
const res = await refreshCodexCredential(channelId)
|
|
if (!res.success) {
|
|
throw new Error(res.message || 'Failed to refresh credential')
|
|
}
|
|
toast.success(t('Credential refreshed'))
|
|
queryClient.invalidateQueries({
|
|
queryKey: channelsQueryKeys.detail(channelId),
|
|
})
|
|
} catch (error) {
|
|
toast.error(error instanceof Error ? error.message : t('Refresh failed'))
|
|
} finally {
|
|
setIsCodexCredentialRefreshing(false)
|
|
}
|
|
}, [channelId, queryClient, t])
|
|
|
|
// Unified function to update models
|
|
const updateModels = useCallback(
|
|
(newModels: string[], merge: boolean = false) => {
|
|
const finalModels = merge
|
|
? formatModelsArray([...currentModelsArray, ...newModels])
|
|
: formatModelsArray(newModels)
|
|
form.setValue('models', finalModels)
|
|
return newModels.length
|
|
},
|
|
[currentModelsArray, form]
|
|
)
|
|
|
|
// Handle fetching models from upstream
|
|
const handleFetchModels = useCallback(async () => {
|
|
const type = form.getValues('type')
|
|
|
|
if (!MODEL_FETCHABLE_TYPES.has(type)) {
|
|
toast.error(t('This channel type does not support fetching models'))
|
|
return
|
|
}
|
|
|
|
// For editing mode, open FetchModelsDialog to let user select
|
|
if (isEditing && currentRow) {
|
|
setFetchModelsDialogOpen(true)
|
|
return
|
|
}
|
|
|
|
// For creation mode, fetch and fill all models
|
|
const key = form.getValues('key')
|
|
if (!key?.trim()) {
|
|
toast.error(t('Please enter API key first'))
|
|
return
|
|
}
|
|
|
|
setIsFetchingModels(true)
|
|
try {
|
|
const response = await fetchModels({
|
|
type,
|
|
key,
|
|
base_url: form.getValues('base_url') || '',
|
|
})
|
|
|
|
if (response.success && response.data) {
|
|
updateModels(response.data, true)
|
|
toast.success(
|
|
t('Fetched {{count}} model(s) from upstream', {
|
|
count: response.data.length,
|
|
})
|
|
)
|
|
} else {
|
|
toast.error(t('No models fetched from upstream'))
|
|
}
|
|
} catch (error: unknown) {
|
|
toast.error(getErrorMessage(error) || t('Failed to fetch models'))
|
|
} finally {
|
|
setIsFetchingModels(false)
|
|
}
|
|
}, [isEditing, currentRow, form, t, updateModels])
|
|
|
|
// Handle adding custom models
|
|
const handleAddCustomModels = useCallback(() => {
|
|
if (!customModel?.trim()) return
|
|
|
|
const modelArray = parseModelsString(customModel)
|
|
const count = updateModels(modelArray, true)
|
|
setCustomModel('')
|
|
toast.success(t('Added {{count}} custom model(s)', { count }))
|
|
}, [customModel, t, updateModels])
|
|
|
|
// Handle model operations
|
|
const handleFillRelatedModels = useCallback(() => {
|
|
if (!basicModels.length) {
|
|
toast.info(t('No related models available for this channel type'))
|
|
return
|
|
}
|
|
updateModels(basicModels)
|
|
toast.success(
|
|
t('Filled {{count}} related model(s)', { count: basicModels.length })
|
|
)
|
|
}, [basicModels, updateModels, t])
|
|
|
|
const handleFillAllModels = useCallback(() => {
|
|
if (!allModelsList.length) {
|
|
toast.info(t('No models available'))
|
|
return
|
|
}
|
|
updateModels(allModelsList)
|
|
toast.success(
|
|
t('Filled {{count}} model(s)', { count: allModelsList.length })
|
|
)
|
|
}, [allModelsList, updateModels, t])
|
|
|
|
const handleClearModels = useCallback(() => {
|
|
form.setValue('models', '')
|
|
toast.success(t('Cleared all models'))
|
|
}, [form, t])
|
|
|
|
const handleCopyModels = useCallback(async () => {
|
|
const models = form.getValues('models')
|
|
if (!models?.trim()) {
|
|
toast.info(t('No models to copy'))
|
|
return
|
|
}
|
|
await copyToClipboard(models)
|
|
}, [form, copyToClipboard, t])
|
|
|
|
// Handle adding prefill group models
|
|
const handleAddPrefillGroup = useCallback(
|
|
(group: { id: number; name: string; items: string | string[] }) => {
|
|
try {
|
|
const items = Array.isArray(group.items)
|
|
? group.items
|
|
: JSON.parse(group.items)
|
|
|
|
if (!Array.isArray(items)) {
|
|
throw new Error('Invalid items format')
|
|
}
|
|
|
|
const count = updateModels(items, true)
|
|
toast.success(
|
|
t('Added {{count}} models from "{{name}}"', {
|
|
count,
|
|
name: group.name,
|
|
})
|
|
)
|
|
} catch {
|
|
toast.error(t('Failed to parse group items'))
|
|
}
|
|
},
|
|
[updateModels, t]
|
|
)
|
|
|
|
// Handle model selection change from MultiSelect
|
|
const handleModelsChange = useCallback(
|
|
(selected: string[]) => {
|
|
form.setValue('models', selected.join(','))
|
|
},
|
|
[form]
|
|
)
|
|
|
|
// Handle successful submission
|
|
const handleSuccess = useCallback(() => {
|
|
queryClient.invalidateQueries({ queryKey: channelsQueryKeys.lists() })
|
|
onOpenChange(false)
|
|
setOpen(null)
|
|
}, [queryClient, onOpenChange, setOpen])
|
|
|
|
// Show missing models confirmation dialog
|
|
const confirmMissingModelMappings = useCallback(
|
|
(missingModels: string[]): Promise<MissingModelsAction> => {
|
|
return new Promise((resolve) => {
|
|
setMissingModelsList(missingModels)
|
|
setMissingModelsDialogOpen(true)
|
|
missingModelsResolveRef.current = resolve
|
|
})
|
|
},
|
|
[]
|
|
)
|
|
|
|
// Handle missing models dialog action
|
|
const handleMissingModelsAction = useCallback(
|
|
(action: MissingModelsAction) => {
|
|
setMissingModelsDialogOpen(false)
|
|
if (missingModelsResolveRef.current) {
|
|
missingModelsResolveRef.current(action)
|
|
missingModelsResolveRef.current = null
|
|
}
|
|
},
|
|
[]
|
|
)
|
|
|
|
const confirmStatusCodeRisk = useCallback(
|
|
(detailItems: string[]): Promise<boolean> =>
|
|
new Promise((resolve) => {
|
|
statusCodeRiskResolveRef.current = resolve
|
|
setStatusCodeRiskDetailItems(detailItems)
|
|
setStatusCodeRiskOpen(true)
|
|
}),
|
|
[]
|
|
)
|
|
|
|
const handleStatusCodeRiskAction = useCallback((confirmed: boolean) => {
|
|
setStatusCodeRiskOpen(false)
|
|
setStatusCodeRiskDetailItems([])
|
|
if (statusCodeRiskResolveRef.current) {
|
|
statusCodeRiskResolveRef.current(confirmed)
|
|
statusCodeRiskResolveRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (statusCodeRiskResolveRef.current) {
|
|
statusCodeRiskResolveRef.current(false)
|
|
statusCodeRiskResolveRef.current = null
|
|
}
|
|
}
|
|
}, [])
|
|
|
|
// Submit handler
|
|
const onSubmit = useCallback(
|
|
async (data: ChannelFormValues) => {
|
|
// Validate key is required when creating
|
|
if (!isEditing && !data.key?.trim()) {
|
|
form.setError('key', {
|
|
type: 'manual',
|
|
message: 'API key is required',
|
|
})
|
|
return
|
|
}
|
|
|
|
// Validate status_code_mapping entries
|
|
if (data.status_code_mapping?.trim()) {
|
|
const invalidEntries = collectInvalidStatusCodeEntries(
|
|
data.status_code_mapping
|
|
)
|
|
if (invalidEntries.length > 0) {
|
|
toast.error(
|
|
t('Invalid status code mapping entries: {{entries}}', {
|
|
entries: invalidEntries.join(', '),
|
|
})
|
|
)
|
|
return
|
|
}
|
|
|
|
const riskyRedirects = collectNewDisallowedStatusCodeRedirects(
|
|
initialStatusCodeMappingRef.current,
|
|
data.status_code_mapping
|
|
)
|
|
if (riskyRedirects.length > 0) {
|
|
const confirmed = await confirmStatusCodeRisk(riskyRedirects)
|
|
if (!confirmed) return
|
|
}
|
|
}
|
|
|
|
// Validate model_mapping JSON format
|
|
const hasModelMapping =
|
|
typeof data.model_mapping === 'string' &&
|
|
data.model_mapping.trim() !== ''
|
|
|
|
if (hasModelMapping) {
|
|
const validation = validateModelMappingJson(data.model_mapping!)
|
|
if (!validation.valid) {
|
|
toast.error(t(validation.error || 'Invalid model mapping'))
|
|
return
|
|
}
|
|
}
|
|
|
|
// Normalize models array
|
|
const normalizedModels = parseModelsString(data.models || '')
|
|
|
|
// Check for missing models in model_mapping
|
|
if (hasModelMapping) {
|
|
const missingModels = findMissingModelsInMapping(
|
|
data.model_mapping!,
|
|
normalizedModels
|
|
)
|
|
|
|
const shouldPromptMissing =
|
|
missingModels.length > 0 &&
|
|
hasModelConfigChanged(
|
|
normalizedModels,
|
|
data.model_mapping || '',
|
|
initialModelsRef.current,
|
|
initialModelMappingRef.current
|
|
)
|
|
|
|
if (shouldPromptMissing) {
|
|
const confirmAction = await confirmMissingModelMappings(missingModels)
|
|
if (confirmAction === 'cancel') {
|
|
return
|
|
}
|
|
if (confirmAction === 'add') {
|
|
const updatedModels = Array.from(
|
|
new Set([...normalizedModels, ...missingModels])
|
|
)
|
|
data.models = formatModelsArray(updatedModels)
|
|
form.setValue('models', data.models)
|
|
}
|
|
}
|
|
}
|
|
|
|
setIsSubmitting(true)
|
|
try {
|
|
if (isEditing && currentRow) {
|
|
// Update existing channel
|
|
const payload = transformFormDataToUpdatePayload(data, currentRow.id)
|
|
const payloadWithKeyMode =
|
|
isMultiKeyChannel && data.key_mode
|
|
? {
|
|
...payload,
|
|
key_mode: data.key_mode,
|
|
}
|
|
: payload
|
|
|
|
const response = await updateChannel(
|
|
currentRow.id,
|
|
payloadWithKeyMode
|
|
)
|
|
if (response.success) {
|
|
toast.success(t(SUCCESS_MESSAGES.UPDATED))
|
|
handleSuccess()
|
|
}
|
|
} else {
|
|
// Create new channel(s)
|
|
const payload = transformFormDataToCreatePayload(data)
|
|
const response = await createChannel(payload)
|
|
if (response.success) {
|
|
toast.success(t(SUCCESS_MESSAGES.CREATED))
|
|
handleSuccess()
|
|
}
|
|
}
|
|
} catch (error: unknown) {
|
|
toast.error(getErrorMessage(error) || t(ERROR_MESSAGES.CREATE_FAILED))
|
|
} finally {
|
|
setIsSubmitting(false)
|
|
}
|
|
},
|
|
[
|
|
isEditing,
|
|
currentRow,
|
|
isMultiKeyChannel,
|
|
form,
|
|
handleSuccess,
|
|
confirmMissingModelMappings,
|
|
confirmStatusCodeRisk,
|
|
t,
|
|
]
|
|
)
|
|
|
|
// Handle drawer close
|
|
const handleOpenChange = useCallback(
|
|
(v: boolean) => {
|
|
onOpenChange(v)
|
|
if (!v) {
|
|
form.reset(CHANNEL_FORM_DEFAULT_VALUES)
|
|
setAdvancedSettingsOpen(false)
|
|
}
|
|
},
|
|
[onOpenChange, form]
|
|
)
|
|
|
|
const handleAdvancedSettingsOpenChange = useCallback((nextOpen: boolean) => {
|
|
setAdvancedSettingsOpen(nextOpen)
|
|
if (typeof window !== 'undefined') {
|
|
window.localStorage.setItem(
|
|
ADVANCED_SETTINGS_EXPANDED_KEY,
|
|
String(nextOpen)
|
|
)
|
|
}
|
|
}, [])
|
|
|
|
return (
|
|
<>
|
|
<Sheet open={open} onOpenChange={handleOpenChange}>
|
|
<SheetContent className='flex h-dvh w-full flex-col gap-0 overflow-hidden p-0 sm:max-w-3xl'>
|
|
<SheetHeader className='border-b px-4 py-3 text-start sm:px-6 sm:py-4'>
|
|
<SheetTitle className='flex items-center gap-3'>
|
|
<span className='bg-muted flex h-9 w-9 shrink-0 items-center justify-center rounded-xl border'>
|
|
{getLobeIcon(`${getChannelTypeIcon(currentType)}.Color`, 22)}
|
|
</span>
|
|
<span>
|
|
{isEditing ? t('Edit Channel') : t('Create Channel')}
|
|
<span className='text-muted-foreground ml-2 text-sm font-normal'>
|
|
{t(currentTypeLabel)}
|
|
</span>
|
|
</span>
|
|
</SheetTitle>
|
|
<SheetDescription>
|
|
{isEditing
|
|
? t(
|
|
"Update channel configuration and click save when you're done."
|
|
)
|
|
: t(
|
|
'Add a new channel by providing the necessary information.'
|
|
)}
|
|
</SheetDescription>
|
|
</SheetHeader>
|
|
|
|
<Form {...form}>
|
|
<form
|
|
id='channel-form'
|
|
onSubmit={form.handleSubmit(onSubmit)}
|
|
className='flex-1 space-y-4 overflow-y-auto px-3 py-3 pb-4 sm:space-y-5 sm:px-4'
|
|
>
|
|
{/* ── Basic Information ── */}
|
|
<div className='bg-card space-y-4 rounded-xl border p-3 sm:p-5'>
|
|
<CardHeading
|
|
title={t('Basic Information')}
|
|
icon={<Server className='h-4 w-4' />}
|
|
/>
|
|
<div className='grid gap-4 sm:grid-cols-2'>
|
|
<FormField
|
|
control={form.control}
|
|
name='name'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Name *')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t(FIELD_PLACEHOLDERS.NAME)}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='type'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Type *')}</FormLabel>
|
|
<FormControl>
|
|
<Combobox
|
|
options={channelTypeOptions}
|
|
value={String(field.value)}
|
|
onValueChange={(value) => {
|
|
const nextType = Number(value)
|
|
if (Number.isInteger(nextType) && nextType > 0) {
|
|
field.onChange(nextType)
|
|
}
|
|
}}
|
|
placeholder={t('Select channel type')}
|
|
searchPlaceholder={t('Search channel type...')}
|
|
emptyText={t('No channel type found.')}
|
|
allowCustomValue
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='status'
|
|
render={({ field }) => (
|
|
<FormItem className='flex items-center justify-between rounded-lg border px-4 py-3'>
|
|
<div className='space-y-0.5'>
|
|
<FormLabel>{t('Enabled')}</FormLabel>
|
|
<FormDescription className='text-xs'>
|
|
{t('Enable or disable this channel')}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value === 1}
|
|
onCheckedChange={(checked) =>
|
|
field.onChange(checked ? 1 : 2)
|
|
}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
{currentType === 1 && (
|
|
<FormField
|
|
control={form.control}
|
|
name='openai_organization'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('OpenAI Organization')}</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder={t('org-...')} {...field} />
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(FIELD_DESCRIPTIONS.OPENAI_ORG)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── API Access ── */}
|
|
<div className='bg-card space-y-4 rounded-xl border p-5'>
|
|
<CardHeading
|
|
title={t('API Access')}
|
|
icon={<Link2 className='h-4 w-4' />}
|
|
/>
|
|
{CHANNEL_TYPE_WARNINGS[currentType] && (
|
|
<Alert>
|
|
<AlertDescription>
|
|
{t(CHANNEL_TYPE_WARNINGS[currentType])}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Azure (type 3) */}
|
|
{currentType === 3 && (
|
|
<>
|
|
<FormField
|
|
control={form.control}
|
|
name='base_url'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('AZURE_OPENAI_ENDPOINT *')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t(
|
|
'e.g., https://docs-test-001.openai.azure.com'
|
|
)}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t('Your Azure OpenAI endpoint URL')}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name='other'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Default API Version *')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t('e.g., 2025-04-01-preview')}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t('Default API version for this channel')}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name='azure_responses_version'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Responses API Version')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t('e.g., preview')}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'Default Responses API version, if empty, will use the API version above'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* Custom (type 8) */}
|
|
{currentType === 8 && (
|
|
<FormField
|
|
control={form.control}
|
|
name='base_url'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
{t('Full Base URL (supports')} {'{'}
|
|
{t('model')}
|
|
{'}'} {t('variable) *')}
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t(
|
|
'e.g., https://api.openai.com/v1/chat/completions'
|
|
)}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t('Enter the complete URL, supports')} {'{'}
|
|
{t('model')}
|
|
{'}'} {t('variable')}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* Xunfei/Spark (type 18) */}
|
|
{currentType === 18 && (
|
|
<FormField
|
|
control={form.control}
|
|
name='other'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Model Version *')}</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder={t('e.g., v2.1')} {...field} />
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'Spark model version, e.g., v2.1 (version number in API URL)'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* OpenRouter (type 20) */}
|
|
{currentType === 20 && (
|
|
<FormField
|
|
control={form.control}
|
|
name='is_enterprise_account'
|
|
render={({ field }) => (
|
|
<FormItem className='flex items-center justify-between'>
|
|
<div className='space-y-0.5'>
|
|
<FormLabel>{t('Enterprise Account')}</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
'Enable if this is an OpenRouter enterprise account with special response format'
|
|
)}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* AWS (type 33) */}
|
|
{currentType === 33 && (
|
|
<FormField
|
|
control={form.control}
|
|
name='aws_key_type'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('AWS Key Format')}</FormLabel>
|
|
<Select
|
|
onValueChange={field.onChange}
|
|
value={field.value}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue
|
|
placeholder={t('Select key format')}
|
|
/>
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value='ak_sk'>
|
|
{t('AccessKey / SecretAccessKey')}
|
|
</SelectItem>
|
|
<SelectItem value='api_key'>
|
|
{t('API Key')}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormDescription>
|
|
{field.value === 'api_key'
|
|
? t('API Key mode: use APIKey|Region')
|
|
: t(
|
|
'AK/SK mode: use AccessKey|SecretAccessKey|Region'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* AI Proxy Library (type 21) */}
|
|
{currentType === 21 && (
|
|
<FormField
|
|
control={form.control}
|
|
name='other'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Knowledge Base ID *')}</FormLabel>
|
|
<FormControl>
|
|
<Input placeholder={t('e.g., 123456')} {...field} />
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t('Enter the knowledge base ID')}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* FastGPT (type 22) */}
|
|
{currentType === 22 && (
|
|
<FormField
|
|
control={form.control}
|
|
name='base_url'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Private Deployment URL')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t(
|
|
'e.g., https://fastgpt.run/api/openapi'
|
|
)}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'For private deployments, format: https://fastgpt.run/api/openapi'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* SunoAPI (type 36) */}
|
|
{currentType === 36 && (
|
|
<FormField
|
|
control={form.control}
|
|
name='base_url'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
{t('API Base URL (Important: Not Chat API) *')}
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t(
|
|
'e.g., https://api.example.com (path before /suno)'
|
|
)}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'Enter the path before /suno, usually just the domain'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* Cloudflare Workers AI (type 39) */}
|
|
{currentType === 39 && (
|
|
<FormField
|
|
control={form.control}
|
|
name='other'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Account ID *')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t('e.g., d6b5da8hk1awo8nap34ube6gh')}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t('Your Cloudflare Account ID')}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* SiliconFlow (type 40) */}
|
|
{currentType === 40 && (
|
|
<Alert>
|
|
<AlertDescription>
|
|
{t('Referral link:')}{' '}
|
|
<a
|
|
href='https://cloud.siliconflow.cn/i/hij0YNTZ'
|
|
target='_blank'
|
|
rel='noopener noreferrer'
|
|
className='text-primary underline'
|
|
>
|
|
{t('https://cloud.siliconflow.cn/i/hij0YNTZ')}
|
|
</a>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{/* Vertex AI (type 41) */}
|
|
{currentType === 41 && (
|
|
<>
|
|
<FormField
|
|
control={form.control}
|
|
name='vertex_key_type'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Vertex AI Key Format')}</FormLabel>
|
|
<Select
|
|
onValueChange={field.onChange}
|
|
value={field.value}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value='json'>{t('JSON')}</SelectItem>
|
|
<SelectItem value='api_key'>
|
|
{t('API Key')}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormDescription>
|
|
{field.value === 'json'
|
|
? t(
|
|
'JSON format supports service account JSON files'
|
|
)
|
|
: t(
|
|
'API Key mode (does not support batch creation)'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
{form.watch('vertex_key_type') === 'json' && (
|
|
<FormItem>
|
|
<FormLabel>
|
|
{t('Service account JSON file(s)')}
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type='file'
|
|
accept='.json,application/json'
|
|
multiple={isBatchMode}
|
|
onChange={async (e) => {
|
|
const fileList = e.target.files
|
|
const files = fileList ? Array.from(fileList) : []
|
|
// allow re-selecting the same file
|
|
e.target.value = ''
|
|
|
|
if (files.length === 0) {
|
|
toast.info(t('Please upload key file(s)'))
|
|
return
|
|
}
|
|
|
|
const keys: unknown[] = []
|
|
for (const file of files) {
|
|
try {
|
|
const txt = await file.text()
|
|
keys.push(JSON.parse(txt))
|
|
} catch {
|
|
toast.error(
|
|
t('Failed to parse JSON file: {{name}}', {
|
|
name: file.name,
|
|
})
|
|
)
|
|
return
|
|
}
|
|
}
|
|
|
|
if (keys.length === 0) {
|
|
toast.info(t('Please upload key file(s)'))
|
|
return
|
|
}
|
|
|
|
const keyValue = isBatchMode
|
|
? JSON.stringify(keys)
|
|
: JSON.stringify(keys[0])
|
|
|
|
form.setValue('key', keyValue, {
|
|
shouldDirty: true,
|
|
shouldValidate: true,
|
|
})
|
|
|
|
toast.success(
|
|
t('Parsed {{count}} service account file(s)', {
|
|
count: keys.length,
|
|
})
|
|
)
|
|
}}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{isBatchMode
|
|
? t('Upload multiple JSON files in batch modes')
|
|
: t('Upload a single service account JSON file')}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
<FormField
|
|
control={form.control}
|
|
name='other'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Deployment Region *')}</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder={t(
|
|
'e.g., us-central1 or JSON format for model-specific regions'
|
|
)}
|
|
rows={3}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t('Enter deployment region or JSON mapping:')}{' '}
|
|
{'{'}
|
|
{t(
|
|
'"default": "us-central1", "claude-3-5-sonnet-20240620": "europe-west1"'
|
|
)}
|
|
{'}'}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{/* VolcEngine (type 45) */}
|
|
{currentType === 45 && !doubaoApiEditUnlocked && (
|
|
<FormField
|
|
control={form.control}
|
|
name='base_url'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel
|
|
className='cursor-pointer select-none'
|
|
onClick={handleApiConfigSecretClick}
|
|
>
|
|
{t('API Base URL *')}
|
|
</FormLabel>
|
|
<Select
|
|
onValueChange={field.onChange}
|
|
value={
|
|
field.value || 'https://ark.cn-beijing.volces.com'
|
|
}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value='https://ark.cn-beijing.volces.com'>
|
|
{t('https://ark.cn-beijing.volces.com')}
|
|
</SelectItem>
|
|
<SelectItem value='https://ark.ap-southeast.bytepluses.com'>
|
|
{t('https://ark.ap-southeast.bytepluses.com')}
|
|
</SelectItem>
|
|
<SelectItem value='doubao-coding-plan'>
|
|
{t('Doubao Coding Plan')}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormDescription>
|
|
{t('Select the API endpoint region')}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* VolcEngine (type 45) - Custom API URL (unlocked) */}
|
|
{currentType === 45 && doubaoApiEditUnlocked && (
|
|
<FormField
|
|
control={form.control}
|
|
name='base_url'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('API Base URL *')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t(
|
|
'e.g., https://ark.cn-beijing.volces.com'
|
|
)}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t('Enter custom API endpoint URL')}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* Coze (type 49) */}
|
|
{currentType === 49 && (
|
|
<FormField
|
|
control={form.control}
|
|
name='other'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Agent ID *')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t('e.g., 7342866812345')}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t('Enter the Coze agent ID')}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{/* General base_url for other types */}
|
|
{![3, 8, 22, 36, 45].includes(currentType) && (
|
|
<FormField
|
|
control={form.control}
|
|
name='base_url'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Base URL')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t(FIELD_PLACEHOLDERS.BASE_URL)}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'Custom API base URL. For official channels, New API has built-in addresses. Only fill this for third-party proxy sites or special endpoints. Do not add /v1 or trailing slash.'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
<div className='border-border/60 border-t pt-4'>
|
|
<SubHeading
|
|
title={t('Authentication')}
|
|
icon={<KeyRound className='h-3.5 w-3.5' />}
|
|
/>
|
|
</div>
|
|
{!isEditing && (
|
|
<FormField
|
|
control={form.control}
|
|
name='multi_key_mode'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Add Mode')}</FormLabel>
|
|
<Select
|
|
onValueChange={field.onChange}
|
|
value={field.value}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
{ADD_MODE_OPTIONS.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
value={option.value}
|
|
>
|
|
{t(option.label)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<FormDescription>
|
|
{t(FIELD_DESCRIPTIONS.BATCH_ADD)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='key'
|
|
render={({ field }) => {
|
|
const keyPlaceholder = (() => {
|
|
if (isEditing) {
|
|
return t('Leave empty to keep existing key')
|
|
}
|
|
if (currentType === 33) {
|
|
if (awsKeyType === 'api_key') {
|
|
return isBatchMode
|
|
? t(
|
|
'Enter API Key, one per line, format: APIKey|Region'
|
|
)
|
|
: t('Enter API Key, format: APIKey|Region')
|
|
}
|
|
return isBatchMode
|
|
? t(
|
|
'Enter key, one per line, format: AccessKey|SecretAccessKey|Region'
|
|
)
|
|
: t(
|
|
'Enter key, format: AccessKey|SecretAccessKey|Region'
|
|
)
|
|
}
|
|
if (isBatchMode) {
|
|
return t('Enter one key per line for batch creation')
|
|
}
|
|
return t(getKeyPromptForType(currentType))
|
|
})()
|
|
return (
|
|
<FormItem>
|
|
<FormLabel>{t('API Key *')}</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder={keyPlaceholder}
|
|
rows={isBatchMode ? 8 : 4}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
<div className='flex flex-col gap-2'>
|
|
<span>
|
|
{isEditing ? (
|
|
<>
|
|
{t(
|
|
'Enter new key to update, or leave empty to keep current key'
|
|
)}
|
|
{isMultiKeyChannel && (
|
|
<span className='text-warning mt-1 block'>
|
|
{t('Multi-key channel: Keys will be')}{' '}
|
|
{keyMode === 'replace'
|
|
? t('replaced')
|
|
: t('appended')}
|
|
</span>
|
|
)}
|
|
</>
|
|
) : isBatchMode ? (
|
|
t(
|
|
'Enter one API key per line for batch creation'
|
|
)
|
|
) : (
|
|
t(FIELD_DESCRIPTIONS.KEY)
|
|
)}
|
|
</span>
|
|
{isBatchMode && (
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={handleDeduplicateKeys}
|
|
className='w-fit'
|
|
>
|
|
<Trash2 className='mr-2 h-4 w-4' />
|
|
{t('Remove Duplicates')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</FormDescription>
|
|
{isEditing && (
|
|
<div className='mt-4 space-y-3 rounded-lg border border-dashed p-4'>
|
|
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
|
<div>
|
|
<p className='text-sm font-medium'>
|
|
{t('Current key')}
|
|
</p>
|
|
<p className='text-muted-foreground text-xs'>
|
|
{t(
|
|
'Verification required to reveal the saved key.'
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className='flex items-center gap-2'>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={handleRevealKey}
|
|
disabled={
|
|
isChannelKeyLoading ||
|
|
verificationState.loading
|
|
}
|
|
>
|
|
{isChannelKeyLoading ||
|
|
verificationState.loading ? (
|
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
|
) : (
|
|
<Eye className='mr-2 h-4 w-4' />
|
|
)}
|
|
{t('Reveal key')}
|
|
</Button>
|
|
<Button
|
|
type='button'
|
|
variant='ghost'
|
|
size='sm'
|
|
onClick={async () => {
|
|
if (channelKey) {
|
|
await copyToClipboard(channelKey)
|
|
}
|
|
}}
|
|
disabled={!channelKey}
|
|
>
|
|
<Copy className='mr-2 h-4 w-4' />
|
|
{t('Copy')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<Input
|
|
readOnly
|
|
value={channelKey ?? ''}
|
|
placeholder={t('Hidden — verify to reveal')}
|
|
className='font-mono'
|
|
/>
|
|
</div>
|
|
)}
|
|
<FormMessage />
|
|
</FormItem>
|
|
)
|
|
}}
|
|
/>
|
|
|
|
{currentType === 57 && (
|
|
<div className='bg-muted/20 space-y-3 rounded-lg border p-4'>
|
|
<div className='flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between'>
|
|
<div className='space-y-0.5'>
|
|
<div className='text-sm font-semibold'>
|
|
{t('Codex Authorization')}
|
|
</div>
|
|
<div className='text-muted-foreground text-xs'>
|
|
{t(
|
|
'Codex channels use an OAuth JSON credential as the key.'
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className='flex flex-wrap items-center gap-2'>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() => setCodexOAuthDialogOpen(true)}
|
|
>
|
|
<Link2 className='mr-2 h-4 w-4' />
|
|
{t('Authorize')}
|
|
</Button>
|
|
{isEditing && channelId && (
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={handleRefreshCodexCredential}
|
|
disabled={isCodexCredentialRefreshing}
|
|
>
|
|
{isCodexCredentialRefreshing ? (
|
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
|
) : (
|
|
<RefreshCw className='mr-2 h-4 w-4' />
|
|
)}
|
|
{isCodexCredentialRefreshing
|
|
? t('Refreshing...')
|
|
: t('Refresh credential')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Alert>
|
|
<AlertDescription>
|
|
{t(
|
|
'If authorization succeeds, the generated JSON will be inserted into the key field. You still need to save the channel to persist it.'
|
|
)}
|
|
</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
)}
|
|
|
|
<CodexOAuthDialog
|
|
open={codexOAuthDialogOpen}
|
|
onOpenChange={setCodexOAuthDialogOpen}
|
|
onKeyGenerated={(key) => {
|
|
form.setValue('key', key, { shouldDirty: true })
|
|
}}
|
|
/>
|
|
|
|
{isEditing && isMultiKeyChannel && (
|
|
<FormField
|
|
control={form.control}
|
|
name='key_mode'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Key Update Mode')}</FormLabel>
|
|
<Select
|
|
onValueChange={field.onChange}
|
|
value={field.value}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value='append'>
|
|
{t('Append to existing keys')}
|
|
</SelectItem>
|
|
<SelectItem value='replace'>
|
|
{t('Replace all existing keys')}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormDescription>
|
|
{field.value === 'replace'
|
|
? t(
|
|
'Replace mode: Will completely replace all existing keys'
|
|
)
|
|
: t(
|
|
'Append mode: New keys will be added to the end of the existing key list'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
{!isEditing && multiKeyMode === 'multi_to_single' && (
|
|
<FormField
|
|
control={form.control}
|
|
name='multi_key_type'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Multi-Key Strategy')}</FormLabel>
|
|
<Select
|
|
onValueChange={field.onChange}
|
|
value={field.value}
|
|
>
|
|
<FormControl>
|
|
<SelectTrigger>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
</FormControl>
|
|
<SelectContent>
|
|
<SelectItem value='random'>
|
|
{t('Random')}
|
|
</SelectItem>
|
|
<SelectItem value='polling'>
|
|
{t('Polling')}
|
|
</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
<FormDescription>
|
|
{multiKeyType === 'polling' ? (
|
|
<span className='text-warning'>
|
|
{t(
|
|
'Polling mode requires Redis and memory cache, otherwise performance will be significantly degraded'
|
|
)}
|
|
</span>
|
|
) : (
|
|
t(
|
|
'Randomly select a key from the pool for each request'
|
|
)
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* ── Models & Groups ── */}
|
|
<div className='bg-card space-y-4 rounded-xl border p-5'>
|
|
<CardHeading
|
|
title={t('Models & Groups')}
|
|
icon={<Boxes className='h-4 w-4' />}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name='models'
|
|
render={() => (
|
|
<FormItem>
|
|
<FormLabel>{t('Models *')}</FormLabel>
|
|
<FormControl>
|
|
<MultiSelect
|
|
options={modelOptions}
|
|
selected={currentModelsArray}
|
|
onChange={handleModelsChange}
|
|
placeholder={t('Select models or add custom ones')}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
<div className='flex flex-col gap-2'>
|
|
<span>{t(FIELD_DESCRIPTIONS.MODELS)}</span>
|
|
<div className='flex flex-wrap gap-2'>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={handleFillRelatedModels}
|
|
disabled={!basicModels.length}
|
|
>
|
|
<FileText className='mr-2 h-4 w-4' />
|
|
{t('Fill Related Models')}
|
|
</Button>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={handleFillAllModels}
|
|
disabled={!allModelsList.length}
|
|
>
|
|
<Plus className='mr-2 h-4 w-4' />
|
|
{t('Fill All Models')}
|
|
</Button>
|
|
{MODEL_FETCHABLE_TYPES.has(currentType) && (
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={handleFetchModels}
|
|
disabled={isFetchingModels}
|
|
>
|
|
{isFetchingModels ? (
|
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
|
) : (
|
|
<Sparkles className='mr-2 h-4 w-4' />
|
|
)}
|
|
{t('Fetch from Upstream')}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={handleClearModels}
|
|
>
|
|
<Eraser className='mr-2 h-4 w-4' />
|
|
{t('Clear All')}
|
|
</Button>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={handleCopyModels}
|
|
>
|
|
<Copy className='mr-2 h-4 w-4' />
|
|
{t('Copy All')}
|
|
</Button>
|
|
{prefillGroups.map((group) => (
|
|
<Button
|
|
key={group.id}
|
|
type='button'
|
|
variant='secondary'
|
|
size='sm'
|
|
onClick={() => handleAddPrefillGroup(group)}
|
|
>
|
|
{group.name}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</FormDescription>
|
|
{modelMappingGuardrail.exposedTargetModels.length > 0 && (
|
|
<Alert className='mt-3 border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
|
|
<AlertDescription>
|
|
{t('The mapped upstream model(s)')}{' '}
|
|
{formatModelNames(
|
|
modelMappingGuardrail.exposedTargetModels
|
|
)}{' '}
|
|
{t(
|
|
'are also listed here. Remove them from Models to keep the `/v1/models` response user-friendly and hide vendor-specific names.'
|
|
)}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
{/* Custom Model Input */}
|
|
<div className='flex gap-2'>
|
|
<Input
|
|
placeholder={t('Add custom model(s), comma-separated')}
|
|
value={customModel}
|
|
onChange={(e) => setCustomModel(e.target.value)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter') {
|
|
e.preventDefault()
|
|
handleAddCustomModels()
|
|
}
|
|
}}
|
|
/>
|
|
<Button
|
|
type='button'
|
|
variant='secondary'
|
|
onClick={handleAddCustomModels}
|
|
disabled={!customModel}
|
|
>
|
|
{t('Add')}
|
|
</Button>
|
|
</div>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='model_mapping'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<div className='flex items-center gap-2'>
|
|
<FormLabel className='mb-0'>
|
|
{t('Model Mapping')}
|
|
</FormLabel>
|
|
<Tooltip>
|
|
<TooltipTrigger
|
|
render={
|
|
<Button
|
|
type='button'
|
|
variant='ghost'
|
|
size='icon-sm'
|
|
className='text-muted-foreground hover:text-foreground size-auto p-0'
|
|
aria-label='How model mapping works'
|
|
/>
|
|
}
|
|
>
|
|
<HelpCircle className='h-4 w-4' />
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
side='top'
|
|
align='start'
|
|
className='max-w-xs space-y-2 text-left'
|
|
>
|
|
<p className='text-xs font-semibold tracking-wide uppercase'>
|
|
{t('Request flow')}
|
|
</p>
|
|
<div className='space-y-1 font-mono text-xs'>
|
|
{mappingPreviewPairs.map((pair) => (
|
|
<div
|
|
key={`${pair.source}-${pair.target}`}
|
|
className='flex items-center gap-1'
|
|
>
|
|
<span>{pair.source}</span>
|
|
<ArrowRight className='h-3.5 w-3.5 opacity-70' />
|
|
<span>{pair.target}</span>
|
|
</div>
|
|
))}
|
|
{remainingMappingCount > 0 && (
|
|
<div className='text-[11px] opacity-70'>
|
|
+{remainingMappingCount} {t('more mapping')}
|
|
{remainingMappingCount > 1 ? 's' : ''}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<p className='text-[11px] leading-relaxed opacity-80'>
|
|
{t(
|
|
'Users call the model on the left. The platform forwards the request to the upstream model on the right.'
|
|
)}
|
|
</p>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
<FormControl>
|
|
<ModelMappingEditor
|
|
value={field.value || ''}
|
|
onChange={field.onChange}
|
|
disabled={isSubmitting}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(FIELD_DESCRIPTIONS.MODEL_MAPPING)}
|
|
</FormDescription>
|
|
{modelMappingGuardrail.invalidJson && (
|
|
<Alert variant='destructive' className='mt-3'>
|
|
<AlertDescription>
|
|
{t('Model Mapping must be a JSON object like')}{' '}
|
|
<code className='font-mono'>
|
|
{'{"gpt-4":"Azure-GPT4"}'}
|
|
</code>
|
|
{t('. Please fix the JSON before saving.')}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
{modelMappingGuardrail.missingSourceModels.length > 0 && (
|
|
<Alert className='mt-3 border-amber-200 bg-amber-50 text-amber-900 dark:border-amber-500/40 dark:bg-amber-500/10 dark:text-amber-50'>
|
|
<AlertDescription>
|
|
{t('Add')}{' '}
|
|
{formatModelNames(
|
|
modelMappingGuardrail.missingSourceModels
|
|
)}{' '}
|
|
{t(
|
|
'to the Models list so users can use them before the mapping sends traffic upstream.'
|
|
)}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='group'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Groups *')}</FormLabel>
|
|
<FormControl>
|
|
{isLoadingGroups ? (
|
|
<Skeleton className='h-10 w-full' />
|
|
) : (
|
|
<MultiSelect
|
|
options={groupOptions}
|
|
selected={field.value}
|
|
onChange={field.onChange}
|
|
placeholder={t(FIELD_PLACEHOLDERS.GROUP)}
|
|
/>
|
|
)}
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(FIELD_DESCRIPTIONS.GROUP)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<Collapsible
|
|
open={advancedSettingsOpen}
|
|
onOpenChange={handleAdvancedSettingsOpenChange}
|
|
>
|
|
<CollapsibleTrigger
|
|
render={
|
|
<button
|
|
type='button'
|
|
className='bg-card hover:bg-accent/50 flex w-full items-center justify-between rounded-xl border px-5 py-4 text-left transition-colors'
|
|
/>
|
|
}
|
|
>
|
|
<div className='space-y-0.5'>
|
|
<div className='text-[13px] font-semibold'>
|
|
{t('Advanced Settings')}
|
|
</div>
|
|
<div className='text-muted-foreground text-xs'>
|
|
{t(
|
|
'Request overrides, routing behavior, and upstream model automation'
|
|
)}
|
|
</div>
|
|
</div>
|
|
<ChevronDown
|
|
className={cn(
|
|
'text-muted-foreground h-4 w-4 shrink-0 transition-transform',
|
|
advancedSettingsOpen && 'rotate-180'
|
|
)}
|
|
/>
|
|
</CollapsibleTrigger>
|
|
|
|
<CollapsibleContent className='mt-5 space-y-5'>
|
|
{/* ── Routing & Overrides ── */}
|
|
<div className='bg-card space-y-4 rounded-xl border p-5'>
|
|
<CardHeading
|
|
title={t('Routing & Overrides')}
|
|
icon={<Route className='h-4 w-4' />}
|
|
/>
|
|
<div className='space-y-4'>
|
|
<SubHeading
|
|
title={t('Routing Strategy')}
|
|
icon={<Route className='h-3.5 w-3.5' />}
|
|
/>
|
|
<div className='grid gap-4 sm:grid-cols-2'>
|
|
<FormField
|
|
control={form.control}
|
|
name='priority'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Priority')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type='number'
|
|
placeholder='0'
|
|
{...field}
|
|
onChange={(e) =>
|
|
field.onChange(Number(e.target.value))
|
|
}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(FIELD_DESCRIPTIONS.PRIORITY)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='weight'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Weight')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
type='number'
|
|
placeholder='0'
|
|
{...field}
|
|
onChange={(e) =>
|
|
field.onChange(Number(e.target.value))
|
|
}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(FIELD_DESCRIPTIONS.WEIGHT)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='test_model'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Test Model')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t(FIELD_PLACEHOLDERS.TEST_MODEL)}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(FIELD_DESCRIPTIONS.TEST_MODEL)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='auto_ban'
|
|
render={({ field }) => (
|
|
<FormItem className='flex items-center justify-between'>
|
|
<div className='space-y-0.5'>
|
|
<FormLabel>{t('Auto Ban')}</FormLabel>
|
|
<FormDescription>
|
|
{t(FIELD_DESCRIPTIONS.AUTO_BAN)}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value === 1}
|
|
onCheckedChange={(checked) =>
|
|
field.onChange(checked ? 1 : 0)
|
|
}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<div className='space-y-4 border-t pt-4'>
|
|
<SubHeading
|
|
title={t('Internal Notes')}
|
|
icon={<FileText className='h-3.5 w-3.5' />}
|
|
/>
|
|
<div className='grid gap-4 sm:grid-cols-2'>
|
|
<FormField
|
|
control={form.control}
|
|
name='tag'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Tag')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t(FIELD_PLACEHOLDERS.TAG)}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(FIELD_DESCRIPTIONS.TAG)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='remark'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Remark')}</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder={t(FIELD_PLACEHOLDERS.REMARK)}
|
|
rows={2}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(FIELD_DESCRIPTIONS.REMARK)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className='space-y-4 border-t pt-4'>
|
|
<SubHeading
|
|
title={t('Override Rules')}
|
|
icon={<Code className='h-3.5 w-3.5' />}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='status_code_mapping'
|
|
render={({ field }) => (
|
|
<FormItem className='space-y-3'>
|
|
<div className='space-y-1'>
|
|
<FormLabel>{t('Status Code Mapping')}</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
'Map upstream status codes to different codes'
|
|
)}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<JsonEditor
|
|
value={field.value || ''}
|
|
onChange={field.onChange}
|
|
disabled={isSubmitting}
|
|
keyPlaceholder='400'
|
|
valuePlaceholder='500'
|
|
keyLabel='Original Code'
|
|
valueLabel='Mapped Code'
|
|
emptyMessage={t(
|
|
'No status code mappings configured.'
|
|
)}
|
|
template={{ '400': '500', '429': '503' }}
|
|
valueType='string'
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='param_override'
|
|
render={({ field }) => (
|
|
<FormItem className='space-y-3 border-t pt-4'>
|
|
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
|
|
<div className='space-y-1'>
|
|
<FormLabel>{t('Parameter Override')}</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
'Override request parameters. Cannot override stream parameter.'
|
|
)}
|
|
</FormDescription>
|
|
</div>
|
|
<div className='flex flex-wrap gap-2'>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() =>
|
|
setParamOverrideEditorOpen(true)
|
|
}
|
|
>
|
|
<Wand2 className='mr-2 h-4 w-4' />
|
|
{t('Visual edit')}
|
|
</Button>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() => {
|
|
field.onChange(
|
|
JSON.stringify(
|
|
{
|
|
operations: [
|
|
{
|
|
path: 'temperature',
|
|
mode: 'set',
|
|
value: 0.7,
|
|
conditions: [
|
|
{
|
|
path: 'model',
|
|
mode: 'prefix',
|
|
value: 'gpt',
|
|
},
|
|
],
|
|
logic: 'AND',
|
|
},
|
|
],
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
)
|
|
}}
|
|
>
|
|
<Code className='mr-2 h-4 w-4' />
|
|
{t('New Format Template')}
|
|
</Button>
|
|
<Button
|
|
type='button'
|
|
variant='ghost'
|
|
size='sm'
|
|
onClick={() => field.onChange('')}
|
|
>
|
|
{t('Clear')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<FormControl>
|
|
<JsonEditor
|
|
value={field.value || ''}
|
|
onChange={field.onChange}
|
|
disabled={isSubmitting}
|
|
keyPlaceholder='temperature'
|
|
valuePlaceholder='0.7'
|
|
keyLabel='Parameter'
|
|
valueLabel='Value'
|
|
emptyMessage={t(
|
|
'No parameter overrides configured.'
|
|
)}
|
|
template={{
|
|
temperature: 0.7,
|
|
max_tokens: 2000,
|
|
top_p: 1,
|
|
}}
|
|
valueType='any'
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='header_override'
|
|
render={({ field }) => (
|
|
<FormItem className='space-y-3 border-t pt-4'>
|
|
<div className='flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between'>
|
|
<div className='space-y-1'>
|
|
<FormLabel>
|
|
{t('Request Header Override')}
|
|
</FormLabel>
|
|
<FormDescription>
|
|
{t('Override request headers')}
|
|
</FormDescription>
|
|
</div>
|
|
<div className='flex flex-wrap gap-2'>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() =>
|
|
field.onChange(
|
|
JSON.stringify(
|
|
{
|
|
'*': true,
|
|
're:^X-Trace-.*$': true,
|
|
'X-Foo': '{client_header:X-Foo}',
|
|
Authorization: 'Bearer {api_key}',
|
|
},
|
|
null,
|
|
2
|
|
)
|
|
)
|
|
}
|
|
>
|
|
{t('Fill Template')}
|
|
</Button>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() =>
|
|
field.onChange(
|
|
JSON.stringify({ '*': true }, null, 2)
|
|
)
|
|
}
|
|
>
|
|
{t('Passthrough Template')}
|
|
</Button>
|
|
<Button
|
|
type='button'
|
|
variant='outline'
|
|
size='sm'
|
|
onClick={() => {
|
|
try {
|
|
const parsed = JSON.parse(
|
|
field.value || '{}'
|
|
)
|
|
field.onChange(
|
|
JSON.stringify(parsed, null, 2)
|
|
)
|
|
} catch (_e) {
|
|
/* ignore invalid JSON */
|
|
}
|
|
}}
|
|
>
|
|
{t('Format')}
|
|
</Button>
|
|
<Button
|
|
type='button'
|
|
variant='ghost'
|
|
size='sm'
|
|
onClick={() => field.onChange('')}
|
|
>
|
|
{t('Clear')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<FormControl>
|
|
<Textarea
|
|
className='font-mono text-sm'
|
|
rows={6}
|
|
value={field.value || ''}
|
|
onChange={field.onChange}
|
|
disabled={isSubmitting}
|
|
placeholder={t(
|
|
'Enter JSON to override request headers'
|
|
)}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription className='text-xs'>
|
|
{t('Supported variables')}:{' '}
|
|
<code className='bg-muted rounded px-1 py-0.5'>
|
|
{'{api_key}'}
|
|
</code>{' '}
|
|
— {t('Channel key')},{' '}
|
|
<code className='bg-muted rounded px-1 py-0.5'>
|
|
{'{client_header:NAME}'}
|
|
</code>{' '}
|
|
— {t('Client header value')}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Extra Settings ── */}
|
|
<div className='bg-card space-y-4 rounded-xl border p-5'>
|
|
<CardHeading
|
|
title={t('Channel Extra Settings')}
|
|
icon={<Settings className='h-4 w-4' />}
|
|
/>
|
|
{(currentType === 1 || currentType === 14) && (
|
|
<div className='space-y-3 rounded-lg border p-4'>
|
|
<SubHeading
|
|
title={t('Field passthrough controls')}
|
|
icon={<SlidersHorizontal className='h-3.5 w-3.5' />}
|
|
/>
|
|
|
|
<div className='divide-border space-y-0 divide-y border-y'>
|
|
<FormField
|
|
control={form.control}
|
|
name='allow_service_tier'
|
|
render={({ field }) => (
|
|
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
|
|
<div className='space-y-0.5'>
|
|
<FormLabel className='text-sm'>
|
|
{t('Allow service_tier passthrough')}
|
|
</FormLabel>
|
|
<FormDescription>
|
|
{t('Pass through the service_tier field')}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
{currentType === 1 && (
|
|
<>
|
|
<FormField
|
|
control={form.control}
|
|
name='disable_store'
|
|
render={({ field }) => (
|
|
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
|
|
<div className='space-y-0.5'>
|
|
<FormLabel className='text-sm'>
|
|
{t('Disable store passthrough')}
|
|
</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
'When enabled, the store field will be blocked'
|
|
)}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='allow_safety_identifier'
|
|
render={({ field }) => (
|
|
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
|
|
<div className='space-y-0.5'>
|
|
<FormLabel className='text-sm'>
|
|
{t(
|
|
'Allow safety_identifier passthrough'
|
|
)}
|
|
</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
'Pass through the safety_identifier field'
|
|
)}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='allow_include_obfuscation'
|
|
render={({ field }) => (
|
|
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
|
|
<div className='space-y-0.5'>
|
|
<FormLabel className='text-sm'>
|
|
{t(
|
|
'Allow include usage obfuscation passthrough'
|
|
)}
|
|
</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
'Pass through the include field for usage obfuscation'
|
|
)}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='allow_inference_geo'
|
|
render={({ field }) => (
|
|
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
|
|
<div className='space-y-0.5'>
|
|
<FormLabel className='text-sm'>
|
|
{t(
|
|
'Allow inference geography passthrough'
|
|
)}
|
|
</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
'Pass through the inference_geo field for geographic routing'
|
|
)}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
{currentType === 14 && (
|
|
<>
|
|
<FormField
|
|
control={form.control}
|
|
name='allow_inference_geo'
|
|
render={({ field }) => (
|
|
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
|
|
<div className='space-y-0.5'>
|
|
<FormLabel className='text-sm'>
|
|
{t('Allow inference_geo passthrough')}
|
|
</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
'Pass through the inference_geo field for Claude data residency region control'
|
|
)}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='allow_speed'
|
|
render={({ field }) => (
|
|
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
|
|
<div className='space-y-0.5'>
|
|
<FormLabel className='text-sm'>
|
|
{t('Allow speed passthrough')}
|
|
</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
'Pass through the speed field for Claude inference speed mode control'
|
|
)}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='claude_beta_query'
|
|
render={({ field }) => (
|
|
<FormItem className='flex items-center justify-between gap-3 px-4 py-3'>
|
|
<div className='space-y-0.5'>
|
|
<FormLabel className='text-sm'>
|
|
{t(
|
|
'Allow Claude beta query passthrough'
|
|
)}
|
|
</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
'Pass through the anthropic-beta header for beta features'
|
|
)}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div className='divide-border space-y-0 divide-y border-y'>
|
|
{currentType === 1 && (
|
|
<FormField
|
|
control={form.control}
|
|
name='force_format'
|
|
render={({ field }) => (
|
|
<FormItem className='flex items-center justify-between px-4 py-3'>
|
|
<div className='space-y-0.5'>
|
|
<FormLabel>{t('Force Format')}</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
'Force format response to OpenAI standard (OpenAI channel only)'
|
|
)}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='thinking_to_content'
|
|
render={({ field }) => (
|
|
<FormItem className='flex items-center justify-between px-4 py-3'>
|
|
<div className='space-y-0.5'>
|
|
<FormLabel>{t('Thinking to Content')}</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
'Convert reasoning_content to <think> tag in content'
|
|
)}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='pass_through_body_enabled'
|
|
render={({ field }) => (
|
|
<FormItem className='flex items-center justify-between px-4 py-3'>
|
|
<div className='space-y-0.5'>
|
|
<FormLabel>{t('Pass Through Body')}</FormLabel>
|
|
<FormDescription>
|
|
{t('Pass request body directly to upstream')}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='proxy'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('Proxy Address')}</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t('socks5://user:pass@host:port')}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'Network proxy for this channel (supports socks5 protocol)'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='system_prompt'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>{t('System Prompt')}</FormLabel>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder={t(
|
|
'Enter system prompt (user prompt takes priority)'
|
|
)}
|
|
rows={3}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t('Default system prompt for this channel')}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name='system_prompt_override'
|
|
render={({ field }) => (
|
|
<FormItem className='flex items-center justify-between'>
|
|
<div className='space-y-0.5'>
|
|
<FormLabel>
|
|
{t('System Prompt Concatenation')}
|
|
</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
'Concatenate channel system prompt with user's prompt'
|
|
)}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
{MODEL_FETCHABLE_TYPES.has(currentType) && (
|
|
<div className='space-y-3 rounded-lg border p-4'>
|
|
<SubHeading
|
|
title={t('Upstream Model Detection Settings')}
|
|
icon={<RefreshCw className='h-3.5 w-3.5' />}
|
|
/>
|
|
<div className='divide-border space-y-0 divide-y border-y'>
|
|
<FormField
|
|
control={form.control}
|
|
name='upstream_model_update_check_enabled'
|
|
render={({ field }) => (
|
|
<FormItem className='flex items-center justify-between px-4 py-3'>
|
|
<div className='space-y-0.5'>
|
|
<FormLabel>
|
|
{t('Upstream Model Update Check')}
|
|
</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
'Periodically check for upstream model changes'
|
|
)}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name='upstream_model_update_auto_sync_enabled'
|
|
render={({ field }) => (
|
|
<FormItem className='flex items-center justify-between px-4 py-3'>
|
|
<div className='space-y-0.5'>
|
|
<FormLabel>
|
|
{t('Auto Sync Upstream Models')}
|
|
</FormLabel>
|
|
<FormDescription>
|
|
{t(
|
|
'Automatically sync model list when upstream changes are detected'
|
|
)}
|
|
</FormDescription>
|
|
</div>
|
|
<FormControl>
|
|
<Switch
|
|
checked={field.value}
|
|
disabled={!upstreamModelUpdateCheckEnabled}
|
|
onCheckedChange={field.onChange}
|
|
/>
|
|
</FormControl>
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
</div>
|
|
<FormField
|
|
control={form.control}
|
|
name='upstream_model_update_ignored_models'
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<FormLabel>
|
|
{t('Ignored upstream models')}
|
|
</FormLabel>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t(
|
|
'e.g., gpt-4.1-nano,regex:^claude-.*$,regex:^sora-.*$'
|
|
)}
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormDescription>
|
|
{t(
|
|
'Comma-separated exact model names. Prefix with regex: to ignore by regular expression.'
|
|
)}
|
|
</FormDescription>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<div className='text-muted-foreground space-y-2 border-t pt-3 text-xs'>
|
|
<div>
|
|
<span className='text-foreground font-medium'>
|
|
{t('Last check time')}:
|
|
</span>{' '}
|
|
{formatUnixTime(upstreamUpdateMeta.lastCheckTime)}
|
|
</div>
|
|
<div>
|
|
<span className='text-foreground font-medium'>
|
|
{t('Last detected addable models')}:
|
|
</span>{' '}
|
|
{upstreamUpdateMeta.detectedModels.length === 0 ? (
|
|
t('None')
|
|
) : (
|
|
<>
|
|
<span className='break-all'>
|
|
{upstreamDetectedModelsPreview.join(', ')}
|
|
</span>
|
|
{upstreamDetectedModelsOmittedCount > 0 && (
|
|
<span className='ml-1'>
|
|
{t('({{total}} total, {{omit}} omitted)', {
|
|
total:
|
|
upstreamUpdateMeta.detectedModels
|
|
.length,
|
|
omit: upstreamDetectedModelsOmittedCount,
|
|
})}
|
|
</span>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CollapsibleContent>
|
|
</Collapsible>
|
|
</form>
|
|
</Form>
|
|
|
|
<SheetFooter className='grid grid-cols-2 gap-2 border-t px-4 py-3 sm:flex sm:px-6 sm:py-4'>
|
|
<SheetClose
|
|
render={<Button variant='outline' disabled={isSubmitting} />}
|
|
>
|
|
{t('Cancel')}
|
|
</SheetClose>
|
|
<Button form='channel-form' type='submit' disabled={isSubmitting}>
|
|
{isSubmitting && (
|
|
<Loader2 className='mr-2 h-4 w-4 animate-spin' />
|
|
)}
|
|
{isEditing ? t('Update Channel') : t('Save changes')}
|
|
</Button>
|
|
</SheetFooter>
|
|
</SheetContent>
|
|
</Sheet>
|
|
|
|
{paramOverrideEditorOpen && (
|
|
<ParamOverrideEditorDialog
|
|
open={paramOverrideEditorOpen}
|
|
value={form.watch('param_override') || ''}
|
|
onOpenChange={setParamOverrideEditorOpen}
|
|
onSave={(nextValue) => {
|
|
form.setValue('param_override', nextValue, {
|
|
shouldDirty: true,
|
|
shouldValidate: true,
|
|
})
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{/* Fetch Models Dialog (for editing mode) */}
|
|
{isEditing && currentRow && (
|
|
<FetchModelsDialog
|
|
open={fetchModelsDialogOpen}
|
|
onOpenChange={setFetchModelsDialogOpen}
|
|
onModelsSelected={(models) => {
|
|
// Fill selected models to form
|
|
form.setValue('models', formatModelsArray(models))
|
|
}}
|
|
redirectModels={redirectModelList}
|
|
redirectSourceModels={redirectModelKeyList}
|
|
/>
|
|
)}
|
|
|
|
<SecureVerificationDialog
|
|
open={verificationOpen}
|
|
onOpenChange={(open) => {
|
|
if (!open) {
|
|
cancelVerification()
|
|
}
|
|
}}
|
|
methods={verificationMethods}
|
|
state={verificationState}
|
|
onVerify={async (method, code) => {
|
|
await executeVerification(method, code)
|
|
}}
|
|
onCancel={cancelVerification}
|
|
onCodeChange={setVerificationCode}
|
|
onMethodChange={switchVerificationMethod}
|
|
/>
|
|
|
|
{/* Missing Models Confirmation Dialog */}
|
|
<MissingModelsConfirmationDialog
|
|
open={missingModelsDialogOpen}
|
|
missingModels={missingModelsList}
|
|
onConfirm={handleMissingModelsAction}
|
|
onOpenChange={setMissingModelsDialogOpen}
|
|
/>
|
|
|
|
<StatusCodeRiskDialog
|
|
open={statusCodeRiskOpen}
|
|
onOpenChange={(v) => {
|
|
if (!v) handleStatusCodeRiskAction(false)
|
|
}}
|
|
detailItems={statusCodeRiskDetailItems}
|
|
onConfirm={() => handleStatusCodeRiskAction(true)}
|
|
/>
|
|
</>
|
|
)
|
|
}
|