mirror of
https://github.com/coder/coder.git
synced 2026-06-03 04:58:23 +00:00
2edf052d17
Provider deletion now disables associated models (enabled=false) instead of soft-deleting them. Disabled models remain visible in the admin model list so they can be reassigned to a different provider later. Changes: - ProviderForm: cascade calls onDisableModel (update with enabled:false) instead of onDeleteModel, dialog description says 'disable', escape guard prevents dismissal during cascade. - UpdateProviderPageView: replaced old DeleteDialog with type-to-confirm Dialog, cascade disables models via updateChatModelConfig, invalidates all 3 query keys via invalidateChatConfigurationQueries, guards against isLoading/isError on model configs query. - ModelsSection: disabled badge uses grey (variant=default) instead of orange (variant=warning). - AI Settings ProviderForm: name field description updated. - chats.ts: export invalidateChatConfigurationQueries for reuse.
472 lines
14 KiB
TypeScript
472 lines
14 KiB
TypeScript
import {
|
|
ChevronDownIcon,
|
|
CopyIcon,
|
|
PencilIcon,
|
|
PlusIcon,
|
|
StarIcon,
|
|
TriangleAlertIcon,
|
|
} from "lucide-react";
|
|
import { type FC, useEffect, useState } from "react";
|
|
import { Link, useLocation, useSearchParams } from "react-router";
|
|
import type * as TypesGen from "#/api/typesGenerated";
|
|
import { Badge } from "#/components/Badge/Badge";
|
|
import { Button } from "#/components/Button/Button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "#/components/DropdownMenu/DropdownMenu";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "#/components/Tooltip/Tooltip";
|
|
import { cn } from "#/utils/cn";
|
|
import { SectionHeader } from "../SectionHeader";
|
|
import type { ProviderState } from "./ChatModelAdminPanel";
|
|
import { normalizeProvider, readOptionalString } from "./helpers";
|
|
import { ModelForm } from "./ModelForm";
|
|
import { ProviderIcon } from "./ProviderIcon";
|
|
import { hasCustomPricing } from "./pricingFields";
|
|
|
|
type ModelView =
|
|
| { mode: "list" }
|
|
| { mode: "add"; provider: string }
|
|
| { mode: "edit"; model: TypesGen.ChatModelConfig }
|
|
| { mode: "duplicate"; sourceModel: TypesGen.ChatModelConfig };
|
|
|
|
const MODEL_VIEW_PARAMS = ["model", "newModel", "duplicate"] as const;
|
|
type ModelViewParam = (typeof MODEL_VIEW_PARAMS)[number];
|
|
|
|
const clearModelViewParams = (params: URLSearchParams) => {
|
|
for (const param of MODEL_VIEW_PARAMS) {
|
|
params.delete(param);
|
|
}
|
|
};
|
|
|
|
const modelConfigProviderKey = (
|
|
modelConfig: TypesGen.ChatModelConfig,
|
|
providerStates: readonly ProviderState[],
|
|
): string => {
|
|
const providerID = readOptionalString(modelConfig.ai_provider_id);
|
|
if (providerID) {
|
|
return providerID;
|
|
}
|
|
|
|
const provider = normalizeProvider(modelConfig.provider);
|
|
const providerMatches = providerStates.filter(
|
|
(providerState) => providerState.provider === provider,
|
|
);
|
|
if (providerMatches.length === 1) {
|
|
return providerMatches[0].key;
|
|
}
|
|
if (providerMatches.length > 1) {
|
|
return "";
|
|
}
|
|
return provider;
|
|
};
|
|
|
|
const canManageProviderModels = (providerState: ProviderState | undefined) => {
|
|
return Boolean(
|
|
providerState?.providerConfig &&
|
|
(providerState.hasEffectiveAPIKey ||
|
|
providerState.providerConfig.allow_user_api_key),
|
|
);
|
|
};
|
|
|
|
interface ModelsSectionProps {
|
|
sectionLabel?: string;
|
|
sectionDescription?: string;
|
|
providerStates: readonly ProviderState[];
|
|
modelConfigs: readonly TypesGen.ChatModelConfig[];
|
|
modelConfigsUnavailable: boolean;
|
|
isCreating: boolean;
|
|
isUpdating: boolean;
|
|
isDeleting: boolean;
|
|
onCreateModel: (
|
|
req: TypesGen.CreateChatModelConfigRequest,
|
|
) => Promise<unknown>;
|
|
onUpdateModel: (
|
|
modelConfigId: string,
|
|
req: TypesGen.UpdateChatModelConfigRequest,
|
|
) => Promise<unknown>;
|
|
onDeleteModel: (modelConfigId: string) => Promise<void>;
|
|
}
|
|
|
|
export const ModelsSection: FC<ModelsSectionProps> = ({
|
|
sectionLabel,
|
|
sectionDescription,
|
|
providerStates,
|
|
modelConfigs,
|
|
modelConfigsUnavailable,
|
|
isCreating,
|
|
isUpdating,
|
|
isDeleting,
|
|
onCreateModel,
|
|
onUpdateModel,
|
|
onDeleteModel,
|
|
}) => {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const [selectedProviderOverride, setSelectedProviderOverride] = useState<
|
|
string | null
|
|
>(null);
|
|
const location = useLocation();
|
|
|
|
// Derive the current view from URL search params so that
|
|
// browser back/forward navigation works as expected.
|
|
const view: ModelView = (() => {
|
|
const editModelId = searchParams.get("model");
|
|
if (editModelId) {
|
|
const model = modelConfigs.find((m) => m.id === editModelId);
|
|
return model ? { mode: "edit", model } : { mode: "list" };
|
|
}
|
|
const duplicateModelId = searchParams.get("duplicate");
|
|
if (duplicateModelId) {
|
|
const sourceModel = modelConfigs.find((m) => m.id === duplicateModelId);
|
|
return sourceModel
|
|
? { mode: "duplicate", sourceModel }
|
|
: { mode: "list" };
|
|
}
|
|
const addProvider = searchParams.get("newModel");
|
|
if (addProvider) {
|
|
return { mode: "add", provider: addProvider };
|
|
}
|
|
return { mode: "list" };
|
|
})();
|
|
|
|
const setModelViewParam = (
|
|
param: ModelViewParam,
|
|
value: string,
|
|
options?: { replace?: boolean },
|
|
) => {
|
|
const nextParams = new URLSearchParams(searchParams);
|
|
clearModelViewParams(nextParams);
|
|
nextParams.set(param, value);
|
|
setSearchParams(nextParams, {
|
|
replace: options?.replace,
|
|
state: options?.replace ? location.state : { pushed: true },
|
|
});
|
|
};
|
|
const modelViewIdentity = (() => {
|
|
switch (view.mode) {
|
|
case "add":
|
|
return `add:${view.provider}`;
|
|
case "edit":
|
|
return `edit:${view.model.id}`;
|
|
case "duplicate":
|
|
return `duplicate:${view.sourceModel.id}`;
|
|
default:
|
|
return "list";
|
|
}
|
|
})();
|
|
|
|
useEffect(() => {
|
|
void modelViewIdentity;
|
|
setSelectedProviderOverride(null);
|
|
}, [modelViewIdentity]);
|
|
|
|
// Clear model-related search params and return to the list.
|
|
const clearModelView = () => {
|
|
setSelectedProviderOverride(null);
|
|
setSearchParams((prev) => {
|
|
const next = new URLSearchParams(prev);
|
|
clearModelViewParams(next);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
const exitModelView = () => {
|
|
setSelectedProviderOverride(null);
|
|
setSearchParams(
|
|
(prev) => {
|
|
const next = new URLSearchParams(prev);
|
|
clearModelViewParams(next);
|
|
return next;
|
|
},
|
|
{ replace: true },
|
|
);
|
|
};
|
|
|
|
// When the form is open it takes over the full panel.
|
|
if (
|
|
view.mode === "add" ||
|
|
view.mode === "edit" ||
|
|
view.mode === "duplicate"
|
|
) {
|
|
const editingModel = view.mode === "edit" ? view.model : undefined;
|
|
const duplicateSourceModel =
|
|
view.mode === "duplicate" ? view.sourceModel : undefined;
|
|
const effectiveProvider =
|
|
selectedProviderOverride ??
|
|
(view.mode === "edit"
|
|
? modelConfigProviderKey(view.model, providerStates)
|
|
: view.mode === "duplicate"
|
|
? modelConfigProviderKey(view.sourceModel, providerStates)
|
|
: view.provider);
|
|
const effectiveProviderState =
|
|
providerStates.find((ps) => ps.key === effectiveProvider) ?? null;
|
|
const formKey =
|
|
view.mode === "edit"
|
|
? `edit:${view.model.id}`
|
|
: view.mode === "duplicate"
|
|
? `duplicate:${view.sourceModel.id}`
|
|
: `add:${view.provider}`;
|
|
|
|
return (
|
|
<ModelForm
|
|
key={formKey}
|
|
editingModel={editingModel}
|
|
duplicateSourceModel={duplicateSourceModel}
|
|
providerStates={providerStates}
|
|
selectedProvider={effectiveProvider}
|
|
selectedProviderState={effectiveProviderState}
|
|
onSelectedProviderChange={(provider) => {
|
|
if (view.mode === "add") {
|
|
setModelViewParam("newModel", provider, { replace: true });
|
|
return;
|
|
}
|
|
setSelectedProviderOverride(provider);
|
|
}}
|
|
modelConfigsUnavailable={modelConfigsUnavailable}
|
|
isSaving={isCreating || isUpdating}
|
|
isDeleting={isDeleting}
|
|
onCreateModel={async (req) => {
|
|
await onCreateModel(req);
|
|
exitModelView();
|
|
}}
|
|
onUpdateModel={async (id, req) => {
|
|
await onUpdateModel(id, req);
|
|
clearModelView();
|
|
}}
|
|
onCancel={clearModelView}
|
|
onDeleteModel={
|
|
editingModel
|
|
? async (id) => {
|
|
await onDeleteModel(id);
|
|
exitModelView();
|
|
}
|
|
: undefined
|
|
}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// ── List view ──────────────────────────────────────────────
|
|
|
|
// Only show providers that have a deployment key configured or allow
|
|
// end users to bring their own key.
|
|
const addableProviders = providerStates.filter(canManageProviderModels);
|
|
|
|
const addButton = addableProviders.length > 0 && (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button size="sm" className="gap-1.5" aria-label="Add model">
|
|
<PlusIcon className="size-4" />
|
|
Add
|
|
<ChevronDownIcon className="size-3.5 text-content-secondary" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
{addableProviders.map((ps) => (
|
|
<DropdownMenuItem
|
|
key={ps.key}
|
|
onClick={() => {
|
|
setModelViewParam("newModel", ps.key);
|
|
}}
|
|
className="gap-2"
|
|
>
|
|
<ProviderIcon provider={ps.provider} className="size-5" />
|
|
{ps.label}
|
|
</DropdownMenuItem>
|
|
))}
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
);
|
|
|
|
const handleSetDefault = (modelConfig: TypesGen.ChatModelConfig) => {
|
|
if (isUpdating || modelConfig.is_default || !modelConfig.enabled) return;
|
|
void onUpdateModel(modelConfig.id, { is_default: true });
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{sectionLabel && (
|
|
<SectionHeader
|
|
label={sectionLabel}
|
|
description={
|
|
sectionDescription ?? "Manage models available to Agents."
|
|
}
|
|
action={addButton || undefined}
|
|
/>
|
|
)}
|
|
|
|
{modelConfigs.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center gap-3 px-6 py-12 text-center">
|
|
<p className="m-0 text-sm text-content-secondary">
|
|
No models configured yet.
|
|
</p>
|
|
{addableProviders.length > 0 && addButton}
|
|
{addableProviders.length === 0 && (
|
|
<p className="m-0 text-xs text-content-secondary">
|
|
Connect a{" "}
|
|
<Link
|
|
to="/agents/settings/providers"
|
|
className="underline transition-colors hover:text-content-primary"
|
|
>
|
|
provider
|
|
</Link>{" "}
|
|
first to add models.
|
|
</p>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div>
|
|
{modelConfigs.map((modelConfig, i) => {
|
|
const showPricingWarning = !hasCustomPricing(
|
|
modelConfig.model_config,
|
|
);
|
|
const modelName = modelConfig.display_name || modelConfig.model;
|
|
const starLabel = modelConfig.is_default
|
|
? `Default model: ${modelName}`
|
|
: `Set as default model: ${modelName}`;
|
|
const starUnavailable =
|
|
isUpdating || modelConfig.is_default || !modelConfig.enabled;
|
|
const providerState = providerStates.find(
|
|
(ps) =>
|
|
ps.key === modelConfigProviderKey(modelConfig, providerStates),
|
|
);
|
|
const duplicateUnavailable = Boolean(
|
|
providerState && !canManageProviderModels(providerState),
|
|
);
|
|
|
|
return (
|
|
<div
|
|
key={modelConfig.id}
|
|
className={cn(
|
|
"flex items-center gap-3.5 px-3 py-3 transition-colors hover:bg-surface-secondary/30",
|
|
i > 0 && "border-0 border-t border-solid border-border/50",
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setModelViewParam("model", modelConfig.id)}
|
|
aria-label={`Open model: ${modelName}`}
|
|
className="flex min-w-0 flex-1 cursor-pointer items-center gap-3.5 border-0 bg-transparent p-0 text-left"
|
|
>
|
|
<ProviderIcon
|
|
provider={modelConfig.provider}
|
|
className="size-8 shrink-0"
|
|
/>
|
|
<div className="min-w-0 flex-1">
|
|
<span
|
|
className={cn(
|
|
"block truncate text-[15px] font-medium",
|
|
modelConfig.enabled === false
|
|
? "text-content-secondary"
|
|
: "text-content-primary",
|
|
)}
|
|
>
|
|
{modelName}
|
|
</span>
|
|
{showPricingWarning && (
|
|
<span className="mt-1 flex items-center gap-1 text-xs text-content-warning">
|
|
<TriangleAlertIcon className="size-3.5 shrink-0" />
|
|
Model pricing is not defined
|
|
</span>
|
|
)}
|
|
</div>
|
|
{modelConfig.enabled === false && (
|
|
<Badge size="xs" variant="default">
|
|
disabled
|
|
</Badge>
|
|
)}
|
|
</button>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
size="icon"
|
|
variant="subtle"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
handleSetDefault(modelConfig);
|
|
}}
|
|
aria-disabled={starUnavailable}
|
|
aria-label={starLabel}
|
|
className={cn(
|
|
"hover:bg-surface-secondary",
|
|
starUnavailable &&
|
|
"cursor-not-allowed text-content-secondary/40 hover:bg-transparent hover:text-content-secondary/40",
|
|
modelConfig.is_default && "text-content-primary",
|
|
)}
|
|
>
|
|
<StarIcon
|
|
className={cn(
|
|
modelConfig.is_default && "fill-current",
|
|
)}
|
|
/>
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
{!modelConfig.enabled
|
|
? "Cannot set a disabled model as default"
|
|
: modelConfig.is_default
|
|
? "Default for new conversations"
|
|
: "Set as default for new conversations"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
size="icon"
|
|
variant="subtle"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
setModelViewParam("model", modelConfig.id);
|
|
}}
|
|
aria-label={`Edit model: ${modelName}`}
|
|
className="hover:bg-surface-secondary"
|
|
>
|
|
<PencilIcon />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">Edit model</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
size="icon"
|
|
variant="subtle"
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
if (duplicateUnavailable) return;
|
|
setModelViewParam("duplicate", modelConfig.id);
|
|
}}
|
|
aria-disabled={duplicateUnavailable}
|
|
aria-label={`Duplicate model: ${modelName}`}
|
|
className={cn(
|
|
"hover:bg-surface-secondary",
|
|
duplicateUnavailable &&
|
|
"cursor-not-allowed text-content-secondary/40 hover:bg-transparent hover:text-content-secondary/40",
|
|
)}
|
|
>
|
|
<CopyIcon />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">
|
|
{duplicateUnavailable
|
|
? "Set an API key for this provider before duplicating models"
|
|
: "Duplicate model"}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|