();
@@ -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.
+
+
+
+
+
+ ) : isCuratedModelList ? (
This provider lists a huge number of models. Studio does not download the
@@ -743,7 +909,7 @@ export function ChatProvidersDialog({
) : null}
-
+