add richer model editor on provider

This commit is contained in:
miloschwartz
2026-08-10 14:54:58 -04:00
parent 216c932cb9
commit 186eeed784
3 changed files with 1028 additions and 99 deletions
+40 -5
View File
@@ -1779,14 +1779,49 @@
"aiProviderMessageRemove": "This will permanently delete the provider and its models and targets. This cannot be undone.",
"aiProviderErrorNoUpdate": "AI provider is not available to update",
"aiProviderModels": "Models",
"aiProviderModelsDescription": "Define allow and block patterns for this provider. Requests must match an allow pattern and must not match a block pattern. Pick from known models for this provider, or type a custom key or wildcard (for example gpt-4* or claude-?). An empty allow list denies all models.",
"aiProviderModelsPlaceholder": "Select a model or type a pattern (e.g. gpt-4*)",
"aiProviderModelsDescription": "Define allow and block lists for this provider. Requests must match an allow entry and must not match a block entry.",
"aiProviderModelsPlaceholder": "Search models or type a custom key",
"aiProviderModelsAllow": "Allow List",
"aiProviderModelsAllowDescription": "Models that may be used through this provider. Empty means deny all.",
"aiProviderModelsAllowPlaceholder": "Select a model or type a pattern (e.g. gpt-4*)",
"aiProviderModelsAllowPlaceholder": "Enter model key",
"aiProviderModelsAllowEmpty": "No models allowed. All requests will be denied.",
"aiProviderModelsBlock": "Block List",
"aiProviderModelsBlockDescription": "Models to deny even if they match an allow pattern.",
"aiProviderModelsBlockPlaceholder": "Select a model or type a pattern (e.g. gpt-4o-mini)",
"aiProviderModelsBlockDescription": "Models to deny even if they match an allow entry.",
"aiProviderModelsBlockPlaceholder": "Enter model key",
"aiProviderModelsBlockEmpty": "No blocked models.",
"aiProviderModelsAdd": "Add Models",
"aiProviderModelsClearAll": "Clear All",
"aiProviderModelsAddCustom": "Add \"{key}\"",
"aiProviderModelsAddCustomHint": "Press Enter to add this custom model key.",
"aiProviderModelsAddBulk": "Add {count} custom keys",
"aiProviderModelsAddBulkHint": "Press Enter to add {count} custom keys.",
"aiProviderModelsAddOne": "Add {key} now",
"aiProviderModelsAddSelected": "Add Selected",
"aiProviderModelsSelectedCount": "{count} selected",
"aiProviderModelsSelectAll": "Select all",
"aiProviderModelsClearSelected": "Clear",
"aiProviderModelsBulkHint": "Select known models or type a custom key.",
"aiProviderModelsCatalogEmpty": "No matching catalog models.",
"aiProviderModelsCatalogHeading": "Known Models",
"aiProviderModelsAllLabel": "All models",
"aiProviderModelsAllPatternHint": "Wildcard: *",
"aiProviderModelsAddAllAllow": "Allow all models",
"aiProviderModelsAddAllBlock": "Block all models",
"aiProviderModelsAddAllDescription": "Uses the * wildcard so every model key matches.",
"aiProviderModelsViewMore": "View more ({count})",
"aiProviderModelsViewLess": "View less",
"aiProviderModelsRemove": "Remove model",
"aiProviderModelsEditHint": "Click to edit model settings",
"aiProviderModelsSourceCatalog": "Known catalog model",
"aiProviderModelsSourceCustom": "Custom model key",
"aiProviderModelsSourcePattern": "Wildcard pattern",
"aiProviderModelsSourceAll": "Matches every model key",
"aiProviderModelsBudgetConfigured": "Budget configured",
"aiProviderModelsEditTitle": "Edit Model",
"aiProviderModelsEditDescription": "Update the model key. Additional settings such as budgets will appear here later.",
"aiProviderModelsKeyLabel": "Model Key",
"aiProviderModelsKeyRequired": "Enter a model key",
"aiProviderModelsKeyDuplicate": "This model key is already on a list",
"aiProviderModelsOverlapError": "These patterns cannot be on both lists: {keys}",
"aiProviderModelsUpdated": "Models updated",
"aiProviderModelsErrorUpdate": "Failed to update models",
@@ -10,7 +10,11 @@ import {
SettingsSectionHeader,
SettingsSectionTitle
} from "@app/components/Settings";
import { TagInput, type Tag } from "@app/components/tags/tag-input";
import {
AiProviderModelListEditor,
type AiProviderModelListItem,
type ModelListType
} from "@app/components/AiProviderModelListEditor";
import { Button } from "@app/components/ui/button";
import { Label } from "@app/components/ui/label";
import { useAiProviderContext } from "@app/hooks/useAiProviderContext";
@@ -22,7 +26,22 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useTranslations } from "next-intl";
import { useEffect, useMemo, useState } from "react";
type ModelListType = "allow" | "block";
function toListItem(
model: {
modelId: number;
modelKey: string;
listType?: ModelListType | null;
},
listType: ModelListType
): AiProviderModelListItem {
return {
clientId: String(model.modelId),
modelId: model.modelId,
modelKey: model.modelKey,
listType,
hasBudget: false
};
}
export default function AiProviderModelsPage() {
const { provider } = useAiProviderContext();
@@ -31,14 +50,8 @@ export default function AiProviderModelsPage() {
const queryClient = useQueryClient();
const t = useTranslations();
const [saveLoading, setSaveLoading] = useState(false);
const [allowTags, setAllowTags] = useState<Tag[]>([]);
const [blockTags, setBlockTags] = useState<Tag[]>([]);
const [activeAllowTagIndex, setActiveAllowTagIndex] = useState<
number | null
>(null);
const [activeBlockTagIndex, setActiveBlockTagIndex] = useState<
number | null
>(null);
const [allowItems, setAllowItems] = useState<AiProviderModelListItem[]>([]);
const [blockItems, setBlockItems] = useState<AiProviderModelListItem[]>([]);
const modelsQuery = useQuery(
aiProviderQueries.providerModels({ providerId: provider.providerId })
@@ -47,32 +60,31 @@ export default function AiProviderModelsPage() {
aiProviderQueries.catalogModels({ providerId: provider.providerId })
);
const catalogTags = useMemo(
() =>
(catalogQuery.data ?? []).map((entry) => ({
id: entry.model,
text: entry.model
})),
const catalogModels = useMemo(
() => (catalogQuery.data ?? []).map((entry) => entry.model),
[catalogQuery.data]
);
const allowExcludeKeys = useMemo(
() => new Set(blockItems.map((item) => item.modelKey)),
[blockItems]
);
const blockExcludeKeys = useMemo(
() => new Set(allowItems.map((item) => item.modelKey)),
[allowItems]
);
useEffect(() => {
if (!modelsQuery.data) return;
setAllowTags(
setAllowItems(
modelsQuery.data
.filter((model) => (model.listType ?? "allow") === "allow")
.map((model) => ({
id: String(model.modelId),
text: model.modelKey
}))
.map((model) => toListItem(model, "allow"))
);
setBlockTags(
setBlockItems(
modelsQuery.data
.filter((model) => model.listType === "block")
.map((model) => ({
id: String(model.modelId),
text: model.modelKey
}))
.map((model) => toListItem(model, "block"))
);
}, [modelsQuery.data]);
@@ -80,15 +92,23 @@ export default function AiProviderModelsPage() {
setSaveLoading(true);
try {
const existing = modelsQuery.data ?? [];
const existingById = new Map(
existing.map((model) => [model.modelId, model])
);
const existingByKey = new Map(
existing.map((model) => [model.modelKey, model])
);
const desiredItems = [...allowItems, ...blockItems].map((item) => ({
...item,
modelKey: item.modelKey.trim()
}));
const nextAllow = new Set(
allowTags.map((tag) => tag.text.trim()).filter(Boolean)
allowItems.map((item) => item.modelKey.trim()).filter(Boolean)
);
const nextBlock = new Set(
blockTags.map((tag) => tag.text.trim()).filter(Boolean)
blockItems.map((item) => item.modelKey.trim()).filter(Boolean)
);
const overlap = [...nextAllow].filter((key) => nextBlock.has(key));
@@ -103,41 +123,58 @@ export default function AiProviderModelsPage() {
return;
}
const desired = new Map<string, ModelListType>();
for (const key of nextAllow) {
desired.set(key, "allow");
}
for (const key of nextBlock) {
desired.set(key, "block");
}
const toCreate: { modelKey: string; listType: ModelListType }[] =
[];
const toUpdate: {
modelId: number;
modelKey: string;
listType: ModelListType;
}[] = [];
const toDelete: number[] = [];
const retainedIds = new Set<number>();
for (const [modelKey, listType] of desired) {
const existingModel = existingByKey.get(modelKey);
if (!existingModel) {
toCreate.push({ modelKey, listType });
for (const item of desiredItems) {
const listType = item.listType;
const modelKey = item.modelKey;
if (!modelKey) continue;
if (item.modelId != null && existingById.has(item.modelId)) {
retainedIds.add(item.modelId);
const existingModel = existingById.get(item.modelId)!;
if (
existingModel.modelKey !== modelKey ||
(existingModel.listType ?? "allow") !== listType
) {
toUpdate.push({
modelId: item.modelId,
modelKey,
listType
});
}
continue;
}
if ((existingModel.listType ?? "allow") !== listType) {
toUpdate.push({
modelId: existingModel.modelId,
listType
});
const existingBySameKey = existingByKey.get(modelKey);
if (
existingBySameKey &&
!retainedIds.has(existingBySameKey.modelId)
) {
retainedIds.add(existingBySameKey.modelId);
if ((existingBySameKey.listType ?? "allow") !== listType) {
toUpdate.push({
modelId: existingBySameKey.modelId,
modelKey,
listType
});
}
continue;
}
toCreate.push({ modelKey, listType });
}
for (const model of existing) {
if (!desired.has(model.modelKey)) {
toDelete.push(model.modelId);
}
}
const toDelete = existing
.filter((model) => !retainedIds.has(model.modelId))
.map((model) => model.modelId);
await Promise.all([
...toCreate.map(({ modelKey, listType }) =>
@@ -147,8 +184,12 @@ export default function AiProviderModelsPage() {
listType
})
),
...toUpdate.map(({ modelId, listType }) =>
api.post(`/ai-model/${modelId}`, { listType })
...toUpdate.map(({ modelId, modelKey, listType }) =>
api.post(`/ai-model/${modelId}`, {
modelKey,
name: modelKey,
listType
})
),
...toDelete.map((modelId) => api.delete(`/ai-model/${modelId}`))
]);
@@ -177,8 +218,6 @@ export default function AiProviderModelsPage() {
}
}
const inputsDisabled = modelsQuery.isLoading || saveLoading;
return (
<SettingsContainer>
<SettingsSection>
@@ -192,30 +231,20 @@ export default function AiProviderModelsPage() {
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<SettingsSectionForm>
<div className="space-y-2">
<Label>{t("aiProviderModelsAllow")}</Label>
<TagInput
activeTagIndex={activeAllowTagIndex}
setActiveTagIndex={setActiveAllowTagIndex}
placeholder={t(
<AiProviderModelListEditor
listType="allow"
items={allowItems}
onChange={setAllowItems}
catalogModels={catalogModels}
excludeKeys={allowExcludeKeys}
disabled={modelsQuery.isLoading}
emptyMessage={t("aiProviderModelsAllowEmpty")}
addPlaceholder={t(
"aiProviderModelsAllowPlaceholder"
)}
size="sm"
tags={allowTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(allowTags)
: newTags;
setAllowTags(next as Tag[]);
}}
enableAutocomplete={catalogTags.length > 0}
autocompleteOptions={catalogTags}
allowDuplicates={false}
sortTags
delimiterList={[",", "Enter"]}
disabled={inputsDisabled}
/>
<p className="text-sm text-muted-foreground">
{t("aiProviderModelsAllowDescription")}
@@ -224,27 +253,17 @@ export default function AiProviderModelsPage() {
<div className="space-y-2">
<Label>{t("aiProviderModelsBlock")}</Label>
<TagInput
activeTagIndex={activeBlockTagIndex}
setActiveTagIndex={setActiveBlockTagIndex}
placeholder={t(
<AiProviderModelListEditor
listType="block"
items={blockItems}
onChange={setBlockItems}
catalogModels={catalogModels}
excludeKeys={blockExcludeKeys}
disabled={modelsQuery.isLoading}
emptyMessage={t("aiProviderModelsBlockEmpty")}
addPlaceholder={t(
"aiProviderModelsBlockPlaceholder"
)}
size="sm"
tags={blockTags}
setTags={(newTags) => {
const next =
typeof newTags === "function"
? newTags(blockTags)
: newTags;
setBlockTags(next as Tag[]);
}}
enableAutocomplete={catalogTags.length > 0}
autocompleteOptions={catalogTags}
allowDuplicates={false}
sortTags
delimiterList={[",", "Enter"]}
disabled={inputsDisabled}
/>
<p className="text-sm text-muted-foreground">
{t("aiProviderModelsBlockDescription")}
@@ -0,0 +1,875 @@
"use client";
import {
Credenza,
CredenzaBody,
CredenzaClose,
CredenzaContent,
CredenzaDescription,
CredenzaFooter,
CredenzaHeader,
CredenzaTitle
} from "@app/components/Credenza";
import { Button } from "@app/components/ui/button";
import { Checkbox } from "@app/components/ui/checkbox";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList
} from "@app/components/ui/command";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage
} from "@app/components/ui/form";
import { Input } from "@app/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger
} from "@app/components/ui/popover";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger
} from "@app/components/ui/tooltip";
import { cn } from "@app/lib/cn";
import { isModelKeyPattern } from "@server/lib/aiModelKeyMatch";
import { zodResolver } from "@hookform/resolvers/zod";
import {
Asterisk,
BookMarked,
Check,
Globe,
Pencil,
Plus,
Wallet,
XIcon
} from "lucide-react";
import { useTranslations } from "next-intl";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
export type ModelListType = "allow" | "block";
export type ModelSource = "catalog" | "custom" | "pattern" | "all";
/** Matches every model key via the provider policy wildcard. */
export const ALL_MODELS_KEY = "*";
const COLLAPSED_ROWS = 5;
export type AiProviderModelListItem = {
clientId: string;
modelId?: number;
modelKey: string;
listType: ModelListType;
hasBudget?: boolean;
};
export type AiProviderModelListEditorProps = {
listType: ModelListType;
items: AiProviderModelListItem[];
catalogModels: string[];
/** Keys already used on this list or the sibling list. */
excludeKeys?: ReadonlySet<string>;
onChange: (items: AiProviderModelListItem[]) => void;
disabled?: boolean;
emptyMessage: string;
addPlaceholder: string;
};
export function isAllModelsKey(modelKey: string): boolean {
return modelKey.trim() === ALL_MODELS_KEY;
}
export function resolveModelSource(
modelKey: string,
catalogModels: ReadonlySet<string> | readonly string[]
): ModelSource {
if (isAllModelsKey(modelKey)) {
return "all";
}
if (isModelKeyPattern(modelKey)) {
return "pattern";
}
const set =
catalogModels instanceof Set ? catalogModels : new Set(catalogModels);
return set.has(modelKey) ? "catalog" : "custom";
}
function newClientId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `tmp-${Date.now()}-${Math.random().toString(36).slice(2)}`;
}
function parseBulkKeys(raw: string): string[] {
const seen = new Set<string>();
const keys: string[] = [];
for (const part of raw.split(/[\n,]+/)) {
const key = part.trim();
if (!key || seen.has(key)) continue;
seen.add(key);
keys.push(key);
}
return keys;
}
function useModelGridColumns(): number {
const [columns, setColumns] = useState(1);
useEffect(() => {
const sm = window.matchMedia("(min-width: 640px)");
const xl = window.matchMedia("(min-width: 1280px)");
const update = () => {
setColumns(xl.matches ? 3 : sm.matches ? 2 : 1);
};
update();
sm.addEventListener("change", update);
xl.addEventListener("change", update);
return () => {
sm.removeEventListener("change", update);
xl.removeEventListener("change", update);
};
}, []);
return columns;
}
export function AiProviderModelListEditor({
listType,
items,
catalogModels,
excludeKeys,
onChange,
disabled,
emptyMessage,
addPlaceholder
}: AiProviderModelListEditorProps) {
const t = useTranslations();
const [editingClientId, setEditingClientId] = useState<string | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [addQuery, setAddQuery] = useState("");
const [selectedKeys, setSelectedKeys] = useState<Set<string>>(new Set());
const [listExpanded, setListExpanded] = useState(false);
const [clipHeight, setClipHeight] = useState<number | null>(null);
const gridRef = useRef<HTMLDivElement>(null);
const columns = useModelGridColumns();
const collapsedLimit = columns * COLLAPSED_ROWS;
const hasOverflow = items.length > collapsedLimit;
const isCollapsed = hasOverflow && !listExpanded;
const catalogSet = useMemo(() => new Set(catalogModels), [catalogModels]);
const blockedKeys = useMemo(() => {
const set = new Set<string>(excludeKeys ? [...excludeKeys] : []);
for (const item of items) {
set.add(item.modelKey);
}
return set;
}, [excludeKeys, items]);
const availableCatalog = useMemo(() => {
const q = addQuery.trim().toLowerCase();
return catalogModels
.filter((model) => !blockedKeys.has(model))
.filter((model) => (q ? model.toLowerCase().includes(q) : true));
}, [addQuery, blockedKeys, catalogModels]);
const trimmedQuery = addQuery.trim();
const bulkKeys = useMemo(
() =>
parseBulkKeys(addQuery).filter(
(key) => !blockedKeys.has(key) && !catalogSet.has(key)
),
[addQuery, blockedKeys, catalogSet]
);
const canAddCustom =
bulkKeys.length === 1 &&
!trimmedQuery.includes("\n") &&
!trimmedQuery.includes(",") &&
!isAllModelsKey(trimmedQuery) &&
!catalogSet.has(trimmedQuery) &&
!blockedKeys.has(trimmedQuery);
const canAddBulkCustom = bulkKeys.length > 1;
const allModelsLabel = t("aiProviderModelsAllLabel");
const showAllModelsOption =
!blockedKeys.has(ALL_MODELS_KEY) &&
(!trimmedQuery ||
trimmedQuery === ALL_MODELS_KEY ||
"all".includes(trimmedQuery.toLowerCase()) ||
allModelsLabel.toLowerCase().includes(trimmedQuery.toLowerCase()));
const editing = items.find((item) => item.clientId === editingClientId);
function appendModels(modelKeys: string[]) {
if (disabled) return;
const nextBlocked = new Set(blockedKeys);
const additions: AiProviderModelListItem[] = [];
for (const raw of modelKeys) {
const key = raw.trim();
if (!key || nextBlocked.has(key)) continue;
nextBlocked.add(key);
additions.push({
clientId: newClientId(),
modelKey: key,
listType,
hasBudget: false
});
}
if (additions.length === 0) return;
onChange([...items, ...additions]);
}
function addModel(modelKey: string, options?: { keepOpen?: boolean }) {
appendModels([modelKey]);
setAddQuery("");
setSelectedKeys(new Set());
if (!options?.keepOpen) {
setAddOpen(false);
}
}
function addSelected() {
appendModels([...selectedKeys]);
setSelectedKeys(new Set());
setAddQuery("");
// Keep open so more can be selected after filter refresh
}
function addBulkCustom() {
appendModels(bulkKeys);
setAddQuery("");
setSelectedKeys(new Set());
}
function toggleSelected(model: string) {
setSelectedKeys((prev) => {
const next = new Set(prev);
if (next.has(model)) {
next.delete(model);
} else {
next.add(model);
}
return next;
});
}
function selectAllVisible() {
setSelectedKeys((prev) => {
const next = new Set(prev);
for (const model of availableCatalog) {
next.add(model);
}
return next;
});
}
function clearSelected() {
setSelectedKeys(new Set());
}
function removeModel(clientId: string) {
onChange(items.filter((item) => item.clientId !== clientId));
}
function updateModel(updated: AiProviderModelListItem) {
onChange(
items.map((item) =>
item.clientId === updated.clientId ? updated : item
)
);
setEditingClientId(null);
}
// Drop selections that are no longer available (already added).
useEffect(() => {
setSelectedKeys((prev) => {
let changed = false;
const next = new Set<string>();
for (const key of prev) {
if (blockedKeys.has(key)) {
changed = true;
continue;
}
next.add(key);
}
return changed ? next : prev;
});
}, [blockedKeys]);
useEffect(() => {
if (!hasOverflow) {
setListExpanded(false);
}
}, [hasOverflow]);
useLayoutEffect(() => {
if (!isCollapsed || !gridRef.current) {
setClipHeight(null);
return;
}
const children = Array.from(gridRef.current.children) as HTMLElement[];
const lastVisible = children[collapsedLimit - 1];
if (!lastVisible) {
setClipHeight(null);
return;
}
const gridTop = gridRef.current.getBoundingClientRect().top;
const cardBottom = lastVisible.getBoundingClientRect().bottom;
// Peek slightly into the next row so the fade has content to soften.
setClipHeight(cardBottom - gridTop + 12);
}, [isCollapsed, collapsedLimit, items]);
return (
<div className="flex flex-col gap-3">
<div>
<div className="relative">
<div
ref={gridRef}
className={cn(
"grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3",
isCollapsed && "overflow-hidden"
)}
style={
isCollapsed && clipHeight != null
? { maxHeight: clipHeight }
: undefined
}
>
{items.length === 0 ? (
<div className="flex min-w-0 items-center justify-center rounded-md border border-dashed border-input px-2.5 py-2">
<span className="text-xs text-muted-foreground">
{emptyMessage}
</span>
</div>
) : (
items.map((item) => (
<ModelCard
key={item.clientId}
item={item}
source={resolveModelSource(
item.modelKey,
catalogSet
)}
disabled={disabled}
onEdit={() =>
setEditingClientId(item.clientId)
}
onRemove={() => removeModel(item.clientId)}
/>
))
)}
</div>
{isCollapsed ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-14 bg-gradient-to-t from-card from-25% via-card/80 to-transparent" />
) : null}
</div>
{isCollapsed ? (
<div className="relative z-10 flex justify-center pt-2">
<Button
type="button"
variant="text"
size="sm"
className="bg-card px-2 text-muted-foreground hover:text-foreground"
onClick={() => setListExpanded(true)}
>
{t("aiProviderModelsViewMore", {
count: items.length - collapsedLimit
})}
</Button>
</div>
) : null}
{hasOverflow && listExpanded ? (
<div className="flex justify-center pt-1">
<Button
type="button"
variant="text"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => setListExpanded(false)}
>
{t("aiProviderModelsViewLess")}
</Button>
</div>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<Popover
open={addOpen}
onOpenChange={(open) => {
if (disabled) return;
setAddOpen(open);
if (!open) {
setAddQuery("");
setSelectedKeys(new Set());
}
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
disabled={disabled}
>
<Plus className="size-4" />
{t("aiProviderModelsAdd")}
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
collisionPadding={8}
className="flex w-[min(100vw-2rem,24rem)] max-h-[min(24rem,var(--radix-popover-content-available-height))] flex-col overflow-hidden p-0"
>
<Command
shouldFilter={false}
className="flex min-h-0 flex-1 flex-col overflow-hidden"
>
<CommandInput
placeholder={addPlaceholder}
value={addQuery}
onValueChange={setAddQuery}
onKeyDown={(e) => {
if (e.key !== "Enter") return;
if (canAddBulkCustom) {
e.preventDefault();
addBulkCustom();
return;
}
if (canAddCustom) {
e.preventDefault();
addModel(trimmedQuery, {
keepOpen: true
});
}
}}
/>
<div className="flex shrink-0 items-center justify-between gap-2 border-b px-3 py-1.5">
<p className="text-xs text-muted-foreground">
{t("aiProviderModelsBulkHint")}
</p>
{availableCatalog.length > 0 ? (
<div className="flex shrink-0 gap-1">
<Button
type="button"
variant="text"
size="sm"
className="h-auto px-1 text-xs"
onClick={selectAllVisible}
>
{t("aiProviderModelsSelectAll")}
</Button>
{selectedKeys.size > 0 ? (
<Button
type="button"
variant="text"
size="sm"
className="h-auto px-1 text-xs"
onClick={clearSelected}
>
{t(
"aiProviderModelsClearSelected"
)}
</Button>
) : null}
</div>
) : null}
</div>
<CommandList className="max-h-none min-h-0 flex-1 overflow-y-auto overscroll-contain">
<CommandEmpty>
{canAddBulkCustom
? t("aiProviderModelsAddBulkHint", {
count: bulkKeys.length
})
: canAddCustom
? t("aiProviderModelsAddCustomHint")
: t("aiProviderModelsCatalogEmpty")}
</CommandEmpty>
{showAllModelsOption ? (
<CommandGroup className="overflow-visible">
<CommandItem
value={`all:${ALL_MODELS_KEY}`}
onSelect={() =>
addModel(ALL_MODELS_KEY, {
keepOpen: true
})
}
className="items-start gap-2 py-2"
>
<Globe className="mt-0.5 size-4 shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">
{listType === "allow"
? t(
"aiProviderModelsAddAllAllow"
)
: t(
"aiProviderModelsAddAllBlock"
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"aiProviderModelsAddAllDescription"
)}
</p>
</div>
</CommandItem>
</CommandGroup>
) : null}
{canAddBulkCustom ? (
<CommandGroup className="overflow-visible">
<CommandItem
value={`bulk:${bulkKeys.join(",")}`}
onSelect={addBulkCustom}
>
<Plus className="mr-2 size-4" />
{t("aiProviderModelsAddBulk", {
count: bulkKeys.length
})}
</CommandItem>
</CommandGroup>
) : null}
{canAddCustom ? (
<CommandGroup className="overflow-visible">
<CommandItem
value={`custom:${trimmedQuery}`}
onSelect={() =>
addModel(trimmedQuery, {
keepOpen: true
})
}
>
<Plus className="mr-2 size-4" />
{t("aiProviderModelsAddCustom", {
key: trimmedQuery
})}
</CommandItem>
</CommandGroup>
) : null}
{availableCatalog.length > 0 ? (
<CommandGroup
heading={t(
"aiProviderModelsCatalogHeading"
)}
className="overflow-visible"
>
{availableCatalog.map((model) => {
const isSelected =
selectedKeys.has(model);
return (
<CommandItem
key={model}
value={model}
onSelect={() => {
// Toggle selection for bulk;
// double-purpose: shift-free multi-pick.
toggleSelected(model);
}}
className="gap-2"
>
<Checkbox
checked={isSelected}
className="pointer-events-none"
tabIndex={-1}
aria-hidden
/>
<span className="min-w-0 flex-1 truncate font-mono text-xs">
{model}
</span>
</CommandItem>
);
})}
</CommandGroup>
) : null}
</CommandList>
{selectedKeys.size > 0 ? (
<div className="flex shrink-0 items-center justify-between gap-2 border-t p-2">
<span className="text-xs text-muted-foreground">
{t("aiProviderModelsSelectedCount", {
count: selectedKeys.size
})}
</span>
<Button
type="button"
size="sm"
onClick={addSelected}
>
{t("aiProviderModelsAddSelected")}
</Button>
</div>
) : null}
</Command>
</PopoverContent>
</Popover>
{items.length > 0 ? (
<Button
type="button"
variant="outline"
size="sm"
className="w-fit"
disabled={disabled}
onClick={() => onChange([])}
>
{t("aiProviderModelsClearAll")}
</Button>
) : null}
</div>
{editing && (
<EditModelCredenza
item={editing}
open={editingClientId !== null}
onOpenChange={(open) => {
if (!open) setEditingClientId(null);
}}
existingKeys={blockedKeys}
onSave={updateModel}
/>
)}
</div>
);
}
function ModelCard({
item,
source,
disabled,
onEdit,
onRemove
}: {
item: AiProviderModelListItem;
source: ModelSource;
disabled?: boolean;
onEdit: () => void;
onRemove: () => void;
}) {
const t = useTranslations();
const sourceLabel =
source === "all"
? t("aiProviderModelsSourceAll")
: source === "catalog"
? t("aiProviderModelsSourceCatalog")
: source === "pattern"
? t("aiProviderModelsSourcePattern")
: t("aiProviderModelsSourceCustom");
const SourceIcon =
source === "all"
? Globe
: source === "catalog"
? BookMarked
: source === "pattern"
? Asterisk
: Pencil;
return (
<div
className={cn(
"flex min-w-0 items-center gap-2 rounded-md border border-input px-2.5 py-2",
disabled && "opacity-60",
!disabled && "cursor-pointer hover:bg-muted/50"
)}
onClick={disabled ? undefined : onEdit}
onKeyDown={
disabled
? undefined
: (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onEdit();
}
}
}
role={disabled ? undefined : "button"}
tabIndex={disabled ? undefined : 0}
title={t("aiProviderModelsEditHint")}
>
<div className="min-w-0 flex-1">
{source === "all" ? (
<>
<span className="block truncate text-xs font-medium">
{t("aiProviderModelsAllLabel")}
</span>
</>
) : (
<span className="block truncate font-mono text-xs font-medium">
{item.modelKey}
</span>
)}
</div>
<TooltipProvider>
<div className="flex shrink-0 items-center gap-1 text-muted-foreground">
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex"
onClick={(e) => e.stopPropagation()}
>
<SourceIcon
className="size-3.5"
aria-label={sourceLabel}
/>
</span>
</TooltipTrigger>
<TooltipContent>{sourceLabel}</TooltipContent>
</Tooltip>
{item.hasBudget ? (
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex"
onClick={(e) => e.stopPropagation()}
>
<Wallet
className="size-3.5"
aria-label={t(
"aiProviderModelsBudgetConfigured"
)}
/>
</span>
</TooltipTrigger>
<TooltipContent>
{t("aiProviderModelsBudgetConfigured")}
</TooltipContent>
</Tooltip>
) : null}
</div>
</TooltipProvider>
<button
type="button"
className="shrink-0 p-0.5 text-muted-foreground hover:text-foreground cursor-pointer disabled:opacity-50"
disabled={disabled}
aria-label={t("aiProviderModelsRemove")}
onClick={(e) => {
e.stopPropagation();
onRemove();
}}
>
<XIcon className="size-3.5" />
</button>
</div>
);
}
type EditFormValues = {
modelKey: string;
};
function EditModelCredenza({
item,
open,
onOpenChange,
existingKeys,
onSave
}: {
item: AiProviderModelListItem;
open: boolean;
onOpenChange: (open: boolean) => void;
existingKeys: ReadonlySet<string>;
onSave: (item: AiProviderModelListItem) => void;
}) {
const t = useTranslations();
const editSchema = useMemo(
() =>
z.object({
modelKey: z
.string()
.trim()
.min(1, t("aiProviderModelsKeyRequired"))
.refine(
(key) =>
key === item.modelKey || !existingKeys.has(key),
t("aiProviderModelsKeyDuplicate")
)
}),
[existingKeys, item.modelKey, t]
);
const form = useForm<EditFormValues>({
resolver: zodResolver(editSchema),
defaultValues: { modelKey: item.modelKey }
});
useEffect(() => {
if (!open) return;
form.reset({ modelKey: item.modelKey });
}, [form, item.clientId, item.modelKey, open]);
function handleSubmit(values: EditFormValues) {
onSave({
...item,
modelKey: values.modelKey.trim()
});
}
return (
<Credenza open={open} onOpenChange={onOpenChange}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>
{t("aiProviderModelsEditTitle")}
</CredenzaTitle>
<CredenzaDescription>
{t("aiProviderModelsEditDescription")}
</CredenzaDescription>
</CredenzaHeader>
<Form {...form}>
<form
id="ai-provider-model-edit-form"
onSubmit={form.handleSubmit(handleSubmit)}
>
<CredenzaBody className="space-y-4">
<FormField
control={form.control}
name="modelKey"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("aiProviderModelsKeyLabel")}
</FormLabel>
<FormControl>
<Input
{...field}
autoComplete="off"
spellCheck={false}
className="font-mono"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</CredenzaBody>
</form>
</Form>
<CredenzaFooter>
<CredenzaClose asChild>
<Button type="button" variant="outline">
{t("cancel")}
</Button>
</CredenzaClose>
<Button type="submit" form="ai-provider-model-edit-form">
{t("save")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
);
}