From 186eeed784bd62f9d8e36d43b0dd95f5c28a8cb0 Mon Sep 17 00:00:00 2001 From: miloschwartz Date: Mon, 10 Aug 2026 14:54:58 -0400 Subject: [PATCH] add richer model editor on provider --- messages/en-US.json | 45 +- .../ai-providers/[providerId]/models/page.tsx | 207 +++-- src/components/AiProviderModelListEditor.tsx | 875 ++++++++++++++++++ 3 files changed, 1028 insertions(+), 99 deletions(-) create mode 100644 src/components/AiProviderModelListEditor.tsx diff --git a/messages/en-US.json b/messages/en-US.json index 646a82664..ec25fde1a 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -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", diff --git a/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx b/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx index cba252dc8..c8a1a4483 100644 --- a/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx +++ b/src/app/[orgId]/settings/ai-providers/[providerId]/models/page.tsx @@ -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([]); - const [blockTags, setBlockTags] = useState([]); - const [activeAllowTagIndex, setActiveAllowTagIndex] = useState< - number | null - >(null); - const [activeBlockTagIndex, setActiveBlockTagIndex] = useState< - number | null - >(null); + const [allowItems, setAllowItems] = useState([]); + const [blockItems, setBlockItems] = useState([]); 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(); - 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(); - 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 ( @@ -192,30 +231,20 @@ export default function AiProviderModelsPage() { - +
- { - 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} />

{t("aiProviderModelsAllowDescription")} @@ -224,27 +253,17 @@ export default function AiProviderModelsPage() {

- { - 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} />

{t("aiProviderModelsBlockDescription")} diff --git a/src/components/AiProviderModelListEditor.tsx b/src/components/AiProviderModelListEditor.tsx new file mode 100644 index 000000000..e3bf2bf07 --- /dev/null +++ b/src/components/AiProviderModelListEditor.tsx @@ -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; + 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 | 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(); + 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(null); + const [addOpen, setAddOpen] = useState(false); + const [addQuery, setAddQuery] = useState(""); + const [selectedKeys, setSelectedKeys] = useState>(new Set()); + const [listExpanded, setListExpanded] = useState(false); + const [clipHeight, setClipHeight] = useState(null); + const gridRef = useRef(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(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(); + 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 ( +

+
+
+
+ {items.length === 0 ? ( +
+ + {emptyMessage} + +
+ ) : ( + items.map((item) => ( + + setEditingClientId(item.clientId) + } + onRemove={() => removeModel(item.clientId)} + /> + )) + )} +
+ {isCollapsed ? ( +
+ ) : null} +
+ {isCollapsed ? ( +
+ +
+ ) : null} + {hasOverflow && listExpanded ? ( +
+ +
+ ) : null} +
+ +
+ { + if (disabled) return; + setAddOpen(open); + if (!open) { + setAddQuery(""); + setSelectedKeys(new Set()); + } + }} + > + + + + + + { + if (e.key !== "Enter") return; + if (canAddBulkCustom) { + e.preventDefault(); + addBulkCustom(); + return; + } + if (canAddCustom) { + e.preventDefault(); + addModel(trimmedQuery, { + keepOpen: true + }); + } + }} + /> +
+

+ {t("aiProviderModelsBulkHint")} +

+ {availableCatalog.length > 0 ? ( +
+ + {selectedKeys.size > 0 ? ( + + ) : null} +
+ ) : null} +
+ + + {canAddBulkCustom + ? t("aiProviderModelsAddBulkHint", { + count: bulkKeys.length + }) + : canAddCustom + ? t("aiProviderModelsAddCustomHint") + : t("aiProviderModelsCatalogEmpty")} + + {showAllModelsOption ? ( + + + addModel(ALL_MODELS_KEY, { + keepOpen: true + }) + } + className="items-start gap-2 py-2" + > + +
+

+ {listType === "allow" + ? t( + "aiProviderModelsAddAllAllow" + ) + : t( + "aiProviderModelsAddAllBlock" + )} +

+

+ {t( + "aiProviderModelsAddAllDescription" + )} +

+
+
+
+ ) : null} + {canAddBulkCustom ? ( + + + + {t("aiProviderModelsAddBulk", { + count: bulkKeys.length + })} + + + ) : null} + {canAddCustom ? ( + + + addModel(trimmedQuery, { + keepOpen: true + }) + } + > + + {t("aiProviderModelsAddCustom", { + key: trimmedQuery + })} + + + ) : null} + {availableCatalog.length > 0 ? ( + + {availableCatalog.map((model) => { + const isSelected = + selectedKeys.has(model); + return ( + { + // Toggle selection for bulk; + // double-purpose: shift-free multi-pick. + toggleSelected(model); + }} + className="gap-2" + > + + + {model} + + + ); + })} + + ) : null} +
+ {selectedKeys.size > 0 ? ( +
+ + {t("aiProviderModelsSelectedCount", { + count: selectedKeys.size + })} + + +
+ ) : null} +
+
+
+ {items.length > 0 ? ( + + ) : null} +
+ + {editing && ( + { + if (!open) setEditingClientId(null); + }} + existingKeys={blockedKeys} + onSave={updateModel} + /> + )} +
+ ); +} + +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 ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + onEdit(); + } + } + } + role={disabled ? undefined : "button"} + tabIndex={disabled ? undefined : 0} + title={t("aiProviderModelsEditHint")} + > +
+ {source === "all" ? ( + <> + + {t("aiProviderModelsAllLabel")} + + + ) : ( + + {item.modelKey} + + )} +
+ +
+ + + e.stopPropagation()} + > + + + + {sourceLabel} + + {item.hasBudget ? ( + + + e.stopPropagation()} + > + + + + + {t("aiProviderModelsBudgetConfigured")} + + + ) : null} +
+
+ +
+ ); +} + +type EditFormValues = { + modelKey: string; +}; + +function EditModelCredenza({ + item, + open, + onOpenChange, + existingKeys, + onSave +}: { + item: AiProviderModelListItem; + open: boolean; + onOpenChange: (open: boolean) => void; + existingKeys: ReadonlySet; + 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({ + 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 ( + + + + + {t("aiProviderModelsEditTitle")} + + + {t("aiProviderModelsEditDescription")} + + +
+ + + ( + + + {t("aiProviderModelsKeyLabel")} + + + + + + + )} + /> + +
+ + + + + + + +
+
+ ); +}