diff --git a/studio/frontend/src/features/chat/api-provider-logo.tsx b/studio/frontend/src/features/chat/api-provider-logo.tsx index fb7ba75d64..bd4f05b2ff 100644 --- a/studio/frontend/src/features/chat/api-provider-logo.tsx +++ b/studio/frontend/src/features/chat/api-provider-logo.tsx @@ -2,6 +2,8 @@ // Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 import { cn } from "@/lib/utils"; +import { DashboardSquare01Icon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; /** * Registry logos live at `public/provider-logos/{provider_type}.{ext}` where `provider_type` @@ -40,6 +42,14 @@ interface ApiProviderLogoProps { * OpenAI's asset is black-on-transparent; it is inverted in dark mode for contrast. */ export function ApiProviderLogo({ providerType, className, title }: ApiProviderLogoProps) { + if (providerType === "custom") { + return ( + + + + ); + } + const src = apiProviderLogoSrc(providerType); if (!src) return null; return ( diff --git a/studio/frontend/src/features/chat/api/chat-adapter.ts b/studio/frontend/src/features/chat/api/chat-adapter.ts index 0f80993a0c..41d5c047ef 100644 --- a/studio/frontend/src/features/chat/api/chat-adapter.ts +++ b/studio/frontend/src/features/chat/api/chat-adapter.ts @@ -642,6 +642,10 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { try { const { supportsReasoning, reasoningEnabled } = runtime; + const externalBackendProviderType = + externalProvider?.providerType === "custom" + ? "openai" + : externalProvider?.providerType; const buildRequestPayload = async (forceRefreshPublicKey = false) => isExternalRequest ? { @@ -653,7 +657,7 @@ export function createOpenAIStreamAdapter(): ChatModelAdapter { max_tokens: params.maxTokens, presence_penalty: params.presencePenalty, provider_id: externalProvider?.id, - provider_type: externalProvider?.providerType, + provider_type: externalBackendProviderType, external_model: externalSelection.modelId, encrypted_api_key: await encryptProviderApiKey( externalApiKey, diff --git a/studio/frontend/src/features/chat/chat-providers-dialog.tsx b/studio/frontend/src/features/chat/chat-providers-dialog.tsx index 75c721b422..e36f972da2 100644 --- a/studio/frontend/src/features/chat/chat-providers-dialog.tsx +++ b/studio/frontend/src/features/chat/chat-providers-dialog.tsx @@ -22,7 +22,12 @@ import { } from "@/components/ui/select"; import { Separator } from "@/components/ui/separator"; import { Spinner } from "@/components/ui/spinner"; -import { CloudIcon, Delete02Icon, Wifi02Icon } from "@hugeicons/core-free-icons"; +import { + DashboardSquare01Icon, + CloudIcon, + Delete02Icon, + Wifi02Icon, +} from "@hugeicons/core-free-icons"; import { HugeiconsIcon } from "@hugeicons/react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useEffect, useMemo, useState } from "react"; @@ -48,6 +53,52 @@ import { ApiProviderLogo } from "./api-provider-logo"; /** Matches navbar / thread layout easing (see index.css --ease-out-quart) */ const PROVIDER_FORM_EASE: [number, number, number, number] = [0.165, 0.84, 0.44, 1]; const PROVIDER_FORM_DURATION = 0.2; +const CUSTOM_PROVIDER_TYPE = "custom"; +const CUSTOM_BACKEND_PROVIDER_TYPE = "openai"; +const CUSTOM_PROVIDER_MISSING_KEY_MESSAGE = + "No API key found, please make sure API key is added and valid for this provider."; + +function normalizeUrl(input: string): string { + return input.trim().replace(/\/+$/, ""); +} + +function resolveUiProviderTypeFromConfig( + configProviderType: string, + configDisplayName: string | null | undefined, + configBaseUrl: string | null | undefined, + registryRows: ProviderRegistryEntry[], + existingProviderType: string | undefined, +): string { + if (existingProviderType === CUSTOM_PROVIDER_TYPE) { + return CUSTOM_PROVIDER_TYPE; + } + if (configProviderType !== CUSTOM_BACKEND_PROVIDER_TYPE) { + return configProviderType; + } + const openAiRegistry = registryRows.find( + (entry) => entry.provider_type === CUSTOM_BACKEND_PROVIDER_TYPE, + ); + if (!openAiRegistry) { + return configProviderType; + } + const displayName = (configDisplayName ?? "").trim().toLowerCase(); + const openAiDisplayName = openAiRegistry.display_name.trim().toLowerCase(); + if (displayName.length > 0 && displayName !== openAiDisplayName) { + return CUSTOM_PROVIDER_TYPE; + } + const configUrl = normalizeUrl(configBaseUrl ?? ""); + const defaultUrl = normalizeUrl(openAiRegistry.base_url ?? ""); + if (configUrl.length > 0 && configUrl !== defaultUrl) { + return CUSTOM_PROVIDER_TYPE; + } + return configProviderType; +} + +function toBackendProviderType(uiProviderType: string): string { + return uiProviderType === CUSTOM_PROVIDER_TYPE + ? CUSTOM_BACKEND_PROVIDER_TYPE + : uiProviderType; +} function parseManualModelIds(text: string): string[] { const seen = new Set(); @@ -86,18 +137,26 @@ export function ChatProvidersDialog({ const [modelsLoading, setModelsLoading] = useState(false); const [mutatingProvider, setMutatingProvider] = useState(false); const [manualModelIds, setManualModelIds] = useState(""); + const [customProviderName, setCustomProviderName] = useState("Custom"); const reduceMotion = useReducedMotion(); + const isCustomProvider = providerType === CUSTOM_PROVIDER_TYPE; const registryByType = useMemo( () => new Map(registry.map((entry) => [entry.provider_type, entry])), [registry], ); + const hasCustomInRegistry = registryByType.has(CUSTOM_PROVIDER_TYPE); const isCuratedModelList = useMemo(() => { return registryByType.get(providerType)?.model_list_mode === "curated"; }, [registryByType, providerType]); + const isManualModelList = isCustomProvider || isCuratedModelList; - const modelsPanelKey = isCuratedModelList ? "curated" : "remote"; + const modelsPanelKey = isCustomProvider + ? "custom" + : isCuratedModelList + ? "curated" + : "remote"; useEffect(() => { if (!providerType || editingProviderId) return; @@ -142,6 +201,13 @@ export function ChatProvidersDialog({ .filter((config) => config.is_enabled) .map((config) => { const existing = existingById.get(config.id); + const uiProviderType = resolveUiProviderTypeFromConfig( + config.provider_type, + config.display_name, + config.base_url, + registryRows, + existing?.providerType, + ); const createdAt = Number.isFinite(Date.parse(config.created_at)) ? Date.parse(config.created_at) : Date.now(); @@ -150,7 +216,7 @@ export function ChatProvidersDialog({ : Date.now(); return { id: config.id, - providerType: config.provider_type, + providerType: uiProviderType, name: config.display_name, baseUrl: config.base_url ?? "", models: existing?.models ?? [], @@ -182,6 +248,7 @@ export function ChatProvidersDialog({ setAvailableModels([]); setSelectedModelIds([]); setManualModelIds(""); + setCustomProviderName("Custom"); } function toggleModel(modelId: string) { @@ -213,24 +280,39 @@ export function ChatProvidersDialog({ return parsed.toString().replace(/\/+$/, ""); } + function parseBaseUrlForProvider(input: string, required: boolean): string | null { + const trimmed = input.trim(); + if (!trimmed) { + if (required) { + throw new Error("Base URL is required for custom providers."); + } + return null; + } + return parseOptionalBaseUrl(trimmed); + } + async function loadModels() { if (!providerType) { toast.error("Choose a provider first."); return; } + if (isCustomProvider) { + toast.info("Custom providers use manual model IDs."); + return; + } if (isCuratedModelList) { toast.info( "This provider has a very large model catalog. Use the suggestions and add model IDs manually — full list is not fetched.", ); return; } - if (!apiKey.trim()) { + if (!isCustomProvider && !apiKey.trim()) { toast.error("Add an API key first."); return; } setModelsLoading(true); try { - const baseUrl = parseOptionalBaseUrl(baseUrlDraft); + const baseUrl = parseBaseUrlForProvider(baseUrlDraft, isCustomProvider); const models = await listProviderModels({ providerType, apiKey: apiKey.trim(), @@ -254,21 +336,23 @@ export function ChatProvidersDialog({ toast.error("Choose a provider first."); return; } - const selectedRegistryEntry = registryByType.get(providerType); - const displayName = selectedRegistryEntry?.display_name ?? providerType; - if (!apiKey.trim()) { + const backendProviderType = toBackendProviderType(providerType); + const selectedRegistryEntry = registryByType.get(backendProviderType); + const displayName = isCustomProvider + ? (customProviderName.trim() || "Custom") + : (selectedRegistryEntry?.display_name ?? providerType); + if (!isCustomProvider && !apiKey.trim()) { toast.error("API key is required."); return; } const curated = selectedRegistryEntry?.model_list_mode === "curated"; - const modelsToSave = curated + const manualModels = isCustomProvider || curated; + const modelsToSave = manualModels ? [...new Set([...selectedModelIds, ...parseManualModelIds(manualModelIds)])] : [...selectedModelIds]; - if (curated) { + if (manualModels) { if (modelsToSave.length === 0) { - toast.error( - "Select at least one suggested model and/or add model IDs in the box below.", - ); + toast.error("Add at least one model ID."); return; } } else { @@ -283,9 +367,9 @@ export function ChatProvidersDialog({ } setMutatingProvider(true); try { - const baseUrl = parseOptionalBaseUrl(baseUrlDraft); + const baseUrl = parseBaseUrlForProvider(baseUrlDraft, isCustomProvider); const created = await createProviderConfig({ - providerType, + providerType: backendProviderType, displayName, baseUrl, }); @@ -297,14 +381,16 @@ export function ChatProvidersDialog({ : Date.now(); const provider: ExternalProviderConfig = { id: created.id, - providerType: created.provider_type, + providerType: isCustomProvider ? CUSTOM_PROVIDER_TYPE : created.provider_type, name: created.display_name, baseUrl: created.base_url ?? "", models: modelsToSave, createdAt, updatedAt, }; - await setExternalProviderApiKey(created.id, apiKey.trim()); + if (apiKey.trim()) { + await setExternalProviderApiKey(created.id, apiKey.trim()); + } onProvidersChange([...providers.filter((p) => p.id !== created.id), provider]); resetForm(); toast.success("Provider added."); @@ -323,20 +409,20 @@ export function ChatProvidersDialog({ toast.error("Provider not found."); return; } - if (!apiKey.trim()) { + const isEditingCustomProvider = existing.providerType === CUSTOM_PROVIDER_TYPE; + if (!isEditingCustomProvider && !apiKey.trim()) { toast.error("API key is required."); return; } const entry = registryByType.get(existing.providerType); const curated = entry?.model_list_mode === "curated"; - const modelsToSave = curated + const manualModels = isEditingCustomProvider || curated; + const modelsToSave = manualModels ? [...new Set([...selectedModelIds, ...parseManualModelIds(manualModelIds)])] : [...selectedModelIds]; - if (curated) { + if (manualModels) { if (modelsToSave.length === 0) { - toast.error( - "Select at least one suggested model and/or add model IDs in the box below.", - ); + toast.error("Add at least one model ID."); return; } } else { @@ -351,12 +437,18 @@ export function ChatProvidersDialog({ } setMutatingProvider(true); try { - const baseUrl = parseOptionalBaseUrl(baseUrlDraft); + const baseUrl = parseBaseUrlForProvider(baseUrlDraft, isEditingCustomProvider); const updated = await updateProviderConfig(editingProviderId, { - displayName: existing.name, + displayName: isEditingCustomProvider + ? (customProviderName.trim() || "Custom") + : existing.name, baseUrl, }); - await setExternalProviderApiKey(editingProviderId, apiKey.trim()); + if (apiKey.trim()) { + await setExternalProviderApiKey(editingProviderId, apiKey.trim()); + } else if (isEditingCustomProvider) { + removeExternalProviderApiKey(editingProviderId); + } const updatedAt = Number.isFinite(Date.parse(updated.updated_at)) ? Date.parse(updated.updated_at) : Date.now(); @@ -365,6 +457,7 @@ export function ChatProvidersDialog({ provider.id === editingProviderId ? { ...provider, + name: updated.display_name, baseUrl: updated.base_url ?? "", models: modelsToSave, updatedAt, @@ -385,8 +478,15 @@ export function ChatProvidersDialog({ async function editProvider(provider: ExternalProviderConfig) { setEditingProviderId(provider.id); setProviderType(provider.providerType); + setCustomProviderName(provider.name || "Custom"); setApiKey(await getExternalProviderApiKey(provider.id)); setBaseUrlDraft(provider.baseUrl); + if (provider.providerType === CUSTOM_PROVIDER_TYPE) { + setAvailableModels([]); + setSelectedModelIds([]); + setManualModelIds(provider.models.join("\n")); + return; + } const entry = registryByType.get(provider.providerType); if (entry?.model_list_mode === "curated") { const defaults = new Set(entry.default_models); @@ -419,23 +519,42 @@ export function ChatProvidersDialog({ async function testProvider(provider: ExternalProviderConfig) { const savedKey = (await getExternalProviderApiKey(provider.id)).trim(); if (!savedKey) { + if (provider.providerType === CUSTOM_PROVIDER_TYPE) { + await editProvider(provider); + toast.info(CUSTOM_PROVIDER_MISSING_KEY_MESSAGE); + return; + } await editProvider(provider); toast.info(`No API key found for ${provider.name}. Add one and save.`); return; } try { const result = await testProviderConnection({ - providerType: provider.providerType, + providerType: toBackendProviderType(provider.providerType), apiKey: savedKey, baseUrl: provider.baseUrl || null, }); if (result.success) { toast.success(result.message); } else { + if ( + provider.providerType === CUSTOM_PROVIDER_TYPE && + result.message.includes("Illegal header value b'Bearer '") + ) { + toast.error(CUSTOM_PROVIDER_MISSING_KEY_MESSAGE); + return; + } toast.error(result.message); } } catch (error) { const message = error instanceof Error ? error.message : "Unknown error"; + if ( + provider.providerType === CUSTOM_PROVIDER_TYPE && + message.includes("Illegal header value b'Bearer '") + ) { + toast.error(CUSTOM_PROVIDER_MISSING_KEY_MESSAGE); + return; + } toast.error(`Test failed: ${message}`); } } @@ -603,16 +722,24 @@ export function ChatProvidersDialog({ ))} + {!hasCustomInRegistry ? ( + + + + Custom + + + ) : null}

- Choose a provider from Studio's supported list. + Choose a provider from Studio's supported list, or Custom.

-
- - setBaseUrlDraft(event.target.value)} - placeholder="https://my-vllm-server.com/v1" - className="h-10 text-sm" - /> -

- Leave blank to use the registry default for this provider type. -

-
+ {isCustomProvider ? ( +
+ + setCustomProviderName(event.target.value)} + placeholder="Custom" + className="h-10 text-sm" + /> +
+ ) : null} + + {isCustomProvider ? ( +
+ + setBaseUrlDraft(event.target.value)} + placeholder="https://my-vllm-server.com/v1" + className="h-10 text-sm" + /> +

+ Set this to target a custom OpenAI-compatible endpoint. +

+
+ ) : null}
@@ -675,11 +820,13 @@ export function ChatProvidersDialog({ variant="outline" size="sm" className="h-8" - disabled={modelsLoading || mutatingProvider || isCuratedModelList} + disabled={modelsLoading || mutatingProvider || isManualModelList} title={ - isCuratedModelList - ? "Full catalog is not fetched for this provider" - : undefined + isCustomProvider + ? "Custom providers use manual model IDs" + : isCuratedModelList + ? "Full catalog is not fetched for this provider" + : undefined } onClick={() => void loadModels()} > @@ -693,7 +840,26 @@ export function ChatProvidersDialog({ )}
- {isCuratedModelList ? ( + {isCustomProvider ? ( +
+

+ Enter exact model IDs served by your custom endpoint. +

+
+ +