Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 186 additions & 42 deletions ee/admin/src/components/provider-credentials-manager.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import {
CheckCircle2,
ClipboardPaste,
Loader2,
MinusCircle,
Pencil,
Expand All @@ -27,6 +28,7 @@ import {
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
Expand All @@ -49,6 +51,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import { thrownErrorMessage } from "@/lib/api-error";
import { formatUsd, isInRotation } from "@/lib/provider-key-spend";
import { parseProviderModelList } from "@/lib/provider-model-list";
import { cn } from "@/lib/utils";

import { getProviderIcon } from "@llmgateway/shared";
Expand Down Expand Up @@ -1057,6 +1060,138 @@ interface SelfTestOutcome {
error?: string;
}

function PasteAllowedModelsDialog({
availableIds,
providerName,
value,
onChange,
}: {
availableIds: readonly string[];
providerName: string;
value: string[];
onChange: (ids: string[]) => void;
}) {
const [open, setOpen] = useState(false);
const [text, setText] = useState("");
const parsed = useMemo(
() => parseProviderModelList(text, availableIds),
[text, availableIds],
);
const newModelIds = useMemo(() => {
const selected = new Set(value);
return parsed.modelIds.filter((modelId) => !selected.has(modelId));
}, [parsed.modelIds, value]);

const handleOpenChange = (next: boolean) => {
setOpen(next);
if (next) {
setText("");
}
};

const handleAdd = (event: React.FormEvent) => {
event.preventDefault();
if (parsed.unknownIds.length > 0 || newModelIds.length === 0) {
return;
}

onChange([...value, ...newModelIds]);
setOpen(false);
setText("");
};

return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<ClipboardPaste />
Paste model IDs
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-lg">
<form className="flex flex-col gap-4" onSubmit={handleAdd}>
<DialogHeader>
<DialogTitle>Paste model IDs</DialogTitle>
<DialogDescription>
Add a complete list at once. Existing selections stay selected,
and duplicate IDs are ignored.
</DialogDescription>
</DialogHeader>

<div className="flex flex-col gap-2">
<Label htmlFor="allowed-model-list">Model IDs</Label>
<Textarea
id="allowed-model-list"
value={text}
onChange={(event) => setText(event.target.value)}
placeholder={"model-id-one\nmodel-id-two\nmodel-id-three"}
rows={9}
className="min-h-48 resize-y font-mono text-xs"
aria-describedby="allowed-model-list-hint"
aria-invalid={parsed.unknownIds.length > 0}
autoFocus
/>
<p
id="allowed-model-list-hint"
className="text-xs text-muted-foreground"
>
Separate IDs with new lines, commas, spaces, or semicolons.
Provider-prefixed IDs are accepted too.
</p>
</div>

{parsed.unknownIds.length > 0 ? (
<div
className="flex flex-col gap-2 rounded-md bg-destructive/10 p-3 text-sm text-destructive"
role="alert"
>
<p>
{parsed.unknownIds.length === 1
? `This model ID is not available for ${providerName}:`
: `These ${parsed.unknownIds.length} model IDs are not available for ${providerName}:`}
</p>
<ul className="max-h-28 space-y-1 overflow-y-auto">
{parsed.unknownIds.map((modelId) => (
<li key={modelId} className="break-all font-mono text-xs">
{modelId}
</li>
))}
</ul>
<p className="text-xs">Remove or correct them to continue.</p>
</div>
) : parsed.modelIds.length > 0 ? (
<p className="text-sm text-muted-foreground" aria-live="polite">
{newModelIds.length > 0
? `${newModelIds.length} new model${newModelIds.length === 1 ? "" : "s"} ready to add.`
: "Every model in this list is already selected."}
</p>
) : null}

<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setOpen(false)}
>
Cancel
</Button>
<Button
type="submit"
disabled={
parsed.unknownIds.length > 0 || newModelIds.length === 0
}
>
{newModelIds.length === 0
? "Add models"
: `Add ${newModelIds.length} model${newModelIds.length === 1 ? "" : "s"}`}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

/**
* Why the self-test failed, in the order the reason is most specific: the
* gateway could not run the probe at all, the provider explained itself, or it
Expand Down Expand Up @@ -1576,47 +1711,15 @@ function CredentialDialog({
<div className="flex flex-col gap-3 rounded-md border border-border/60 p-3">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">Allowed models</p>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleSelfTest}
disabled={
selfTestLoading ||
verifyLoading ||
!provider ||
(!isEdit && !token)
}
title="Sends one minimal request through the key using the provider's default validation model, without saving anything."
aria-busy={selfTestLoading}
>
{selfTestLoading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : null}
Self-test key
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleVerifyModels}
disabled={
selfTestLoading ||
verifyLoading ||
allowedModels.length === 0 ||
!provider ||
(!isEdit && !token)
}
title="Probes every listed model through the key and reports which ones the account can actually serve."
aria-busy={verifyLoading}
>
{verifyLoading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : null}
Verify models
</Button>
</div>
<PasteAllowedModelsDialog
availableIds={selectedEntry?.models ?? []}
providerName={selectedEntry?.name ?? "this provider"}
value={allowedModels}
onChange={(next) => {
setAllowedModels(next);
setVerifyOutcome(undefined);
}}
/>
</div>
<MultiModelIdSelector
availableIds={selectedEntry?.models ?? []}
Expand All @@ -1630,9 +1733,50 @@ function CredentialDialog({
/>
<p className="text-xs text-muted-foreground">
{allowedModels.length === 0
? "Empty means the key serves every model of the provider. Restrict it when the upstream account only has some models enabled, so routing never picks this key for a model it cannot serve. Paste a comma-separated list to fill it quickly."
? "Empty means the key serves every model of the provider. Restrict it when the upstream account only has some models enabled, so routing never picks this key for a model it cannot serve."
: `Routing will only use this credential for the ${allowedModels.length === 1 ? "listed model" : `${allowedModels.length} listed models`}.`}
</p>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleSelfTest}
disabled={
selfTestLoading ||
verifyLoading ||
!provider ||
(!isEdit && !token)
}
title="Sends one minimal request through the key using the provider's default validation model, without saving anything."
aria-busy={selfTestLoading}
>
{selfTestLoading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : null}
Self-test key
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleVerifyModels}
disabled={
selfTestLoading ||
verifyLoading ||
allowedModels.length === 0 ||
!provider ||
(!isEdit && !token)
}
title="Probes every listed model through the key and reports which ones the account can actually serve."
aria-busy={verifyLoading}
>
{verifyLoading ? (
<Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />
) : null}
Verify models
</Button>
</div>
{/* Live region so the async probe results are announced to
assistive technology when they arrive. empty:hidden keeps the
parent's gap from rendering around it before any result. */}
Expand Down
40 changes: 40 additions & 0 deletions ee/admin/src/lib/provider-model-list.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, test } from "vitest";

import { parseProviderModelList } from "./provider-model-list";

const availableIds = ["gpt-5.2", "gpt-5.2-mini", "text-embedding-3-small"];

describe("parseProviderModelList", () => {
test("parses common list formats and removes duplicates", () => {
expect(
parseProviderModelList(
'gpt-5.2, openai/gpt-5.2-mini\n"text-embedding-3-small"; gpt-5.2',
availableIds,
),
).toEqual({
modelIds: ["gpt-5.2", "gpt-5.2-mini", "text-embedding-3-small"],
unknownIds: [],
});
});

test("accepts a JSON-style array", () => {
expect(
parseProviderModelList('["gpt-5.2", "gpt-5.2-mini"]', availableIds),
).toEqual({
modelIds: ["gpt-5.2", "gpt-5.2-mini"],
unknownIds: [],
});
});

test("reports model ids outside the provider catalog", () => {
expect(
parseProviderModelList(
"gpt-5.2 unknown-model openai/another-model unknown-model",
availableIds,
),
).toEqual({
modelIds: ["gpt-5.2"],
unknownIds: ["unknown-model", "openai/another-model"],
});
});
});
48 changes: 48 additions & 0 deletions ee/admin/src/lib/provider-model-list.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
export interface ParsedProviderModelList {
modelIds: string[];
unknownIds: string[];
}

/**
* Parses a pasted model list while preserving order and accepting either bare
* ids or provider-prefixed ids. JSON-style arrays work as plain pasted text.
*/
export function parseProviderModelList(
text: string,
availableIds: readonly string[],
): ParsedProviderModelList {
const available = new Set(availableIds);
const seen = new Set<string>();
const modelIds: string[] = [];
const unknownIds: string[] = [];

for (const rawEntry of text.split(/[\s,;]+/)) {
const entry = rawEntry
.replace(/^(?:\[|"|')+/, "")
.replace(/(?:\]|"|')+$/, "")
.trim();
if (!entry) {
continue;
}

const slash = entry.indexOf("/");
const suffix = slash >= 0 ? entry.slice(slash + 1) : entry;
const modelId = available.has(entry)
? entry
: available.has(suffix)
? suffix
: entry;
if (seen.has(modelId)) {
continue;
}

seen.add(modelId);
if (available.has(modelId)) {
modelIds.push(modelId);
} else {
unknownIds.push(modelId);
}
}

return { modelIds, unknownIds };
}
Loading