Merge branch 'litellm_internal_staging' into litellm_cost_tracking_removal_pending_state

This commit is contained in:
Yuneng Jiang 2026-08-14 10:33:53 -07:00
commit 62b072dcf7
No known key found for this signature in database
15 changed files with 1584 additions and 1436 deletions

View file

@ -47,10 +47,10 @@ test.describe("Proxy Admin - Teams", () => {
// Fill Team Name — the input has id="team_alias"
await dialog.locator("#team_alias").fill(uniqueAlias);
// Select models — the models multi-select is inside the modal
// Click to open dropdown, select "All Proxy Models"
await dialog.locator(".ant-select-selection-overflow").first().click();
await page.locator(".ant-select-dropdown:visible").getByText("All Proxy Models").click();
// Select models — the models multi-select is inside the modal. Its popup is
// portaled to the body, so scope the option lookup to the page, not the dialog.
await dialog.getByTestId("create-team-models-select").getByRole("combobox").click();
await page.getByRole("option", { name: "All Proxy Models", exact: true }).click();
await page.keyboard.press("Escape");
// Submit — click the submit button inside the dialog (not the header button)
@ -191,11 +191,11 @@ test.describe("Proxy Admin - Teams", () => {
const modelsSelect = page.locator("[data-testid='models-select']");
await expect(modelsSelect).toBeVisible({ timeout: 10_000 });
const anthropicTag = modelsSelect
.locator(".ant-select-selection-item")
const anthropicChip = modelsSelect
.locator('[data-slot="combobox-chip"]')
.filter({ hasText: "fake-anthropic-claude" });
await expect(anthropicTag).toBeVisible({ timeout: 5_000 });
await anthropicTag.locator(".ant-select-selection-item-remove").click();
await expect(anthropicChip).toBeVisible({ timeout: 5_000 });
await anthropicChip.locator('[data-slot="combobox-chip-remove"]').click();
await page.getByRole("button", { name: "Save Changes" }).click();

View file

@ -1393,9 +1393,6 @@
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 2
},
"prefer-const": {
"count": 2
}
@ -1721,11 +1718,6 @@
"count": 1
}
},
"src/components/BetaBadge.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/CloudZeroCostTracking/CloudZeroCreateModal.tsx": {
"no-restricted-imports": {
"count": 1
@ -1744,11 +1736,6 @@
"count": 1
}
},
"src/components/DeprecationBanner.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/EntityUsageExport/ExportSummary.tsx": {
"no-restricted-imports": {
"count": 1
@ -1785,9 +1772,6 @@
"src/components/GuardrailsMonitor/LogViewer.tsx": {
"no-nested-ternary": {
"count": 1
},
"no-restricted-imports": {
"count": 1
}
},
"src/components/HelpLink.test.tsx": {
@ -1795,11 +1779,6 @@
"count": 1
}
},
"src/components/ModelSelect/ModelSelect.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/Navbar/BlogDropdown/BlogDropdown.test.tsx": {
"max-nested-callbacks": {
"count": 12
@ -1917,11 +1896,6 @@
"count": 1
}
},
"src/components/Settings/RouterSettings/Fallbacks/EditFallbacks.tsx": {
"no-restricted-imports": {
"count": 1
}
},
"src/components/Settings/RouterSettings/Fallbacks/FallbackGroupConfig.tsx": {
"local/no-complex-jsx-arrow": {
"count": 1
@ -1939,9 +1913,6 @@
}
},
"src/components/Settings/RouterSettings/Fallbacks/Fallbacks.tsx": {
"no-restricted-imports": {
"count": 2
},
"prefer-const": {
"count": 2
}
@ -2260,9 +2231,6 @@
}
},
"src/components/claude_code_plugins/MakeSkillPublicForm.tsx": {
"no-restricted-imports": {
"count": 2
},
"react-hooks/set-state-in-effect": {
"count": 1
}
@ -2781,9 +2749,6 @@
},
"max-lines": {
"count": 1
},
"no-restricted-imports": {
"count": 2
}
},
"src/components/query_param_input.tsx": {

View file

@ -62,6 +62,8 @@ const settingsRow = async (fieldName: string) => {
return row as HTMLElement;
};
const numericValueIn = (row: HTMLElement) => Number((within(row).getByRole("spinbutton") as HTMLInputElement).value);
describe("GeneralSettings General tab", () => {
beforeEach(() => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([...SETTINGS_FIXTURE.map((s) => ({ ...s }))]);
@ -87,7 +89,7 @@ describe("GeneralSettings General tab", () => {
await user.click(screen.getByText("General"));
const row = await settingsRow("max_ui_session_budget");
expect(within(row).getByRole("spinbutton")).toHaveValue("7.50");
expect(numericValueIn(row)).toBe(7.5);
const actionCell = row.querySelectorAll("td")[3];
const resetIcon = actionCell.querySelector("svg");
@ -95,7 +97,7 @@ describe("GeneralSettings General tab", () => {
await user.click(resetIcon as unknown as Element);
expect(deleteConfigFieldSetting).toHaveBeenCalledWith("token", "max_ui_session_budget");
expect(within(row).getByRole("spinbutton")).toHaveValue("1.00");
expect(numericValueIn(row)).toBe(1);
});
});

View file

@ -1,22 +1,14 @@
import React, { useState, useEffect } from "react";
import {
Card,
Table,
TableHead,
TableRow,
TableHeaderCell,
TableCell,
TableBody,
Title,
Text,
Button,
Icon,
Switch,
} from "@tremor/react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { InputGroup, InputGroupAddon, InputGroupInput } from "@/components/ui/input-group";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting } from "@/components/networking";
import { InputNumber, Select as AntdSelect } from "antd";
import { TrashIcon } from "@heroicons/react/outline";
import { Trash2 } from "lucide-react";
import { StatusBadge } from "@/components/shared/table_cells";
import RouterSettings from "@/components/router_settings";
@ -44,16 +36,22 @@ export interface generalSettingsItem {
field_default_value?: any;
}
const NUMERIC_INPUT_WIDTH = "w-36";
const toNumericValue = (raw: string): number | null => (raw === "" ? null : Number(raw));
const SettingValueEditor: React.FC<{
setting: generalSettingsItem;
onChange: (fieldName: string, newValue: any) => void;
}> = ({ setting, onChange }) => {
if (setting.field_type === "Integer") {
return (
<InputNumber
<Input
type="number"
step={1}
value={setting.field_value}
onChange={(newValue) => onChange(setting.field_name, newValue)}
className={NUMERIC_INPUT_WIDTH}
value={setting.field_value ?? ""}
onChange={(event) => onChange(setting.field_name, toNumericValue(event.target.value))}
/>
);
}
@ -61,42 +59,55 @@ const SettingValueEditor: React.FC<{
return (
<Switch
checked={setting.field_value === true || setting.field_value === "true"}
onChange={(checked) => onChange(setting.field_name, checked)}
onCheckedChange={(checked) => onChange(setting.field_name, checked)}
/>
);
}
if (setting.field_type === "Float") {
return (
<InputNumber
<Input
type="number"
min={0}
max={1}
step={0.05}
value={setting.field_value}
onChange={(newValue) => onChange(setting.field_name, newValue)}
className={NUMERIC_INPUT_WIDTH}
value={setting.field_value ?? ""}
onChange={(event) => onChange(setting.field_name, toNumericValue(event.target.value))}
/>
);
}
if (setting.field_type === "Dollar") {
return (
<InputNumber
min={0.01}
step={0.25}
prefix="$"
value={setting.field_value}
onChange={(newValue) => onChange(setting.field_name, newValue)}
/>
<InputGroup className={NUMERIC_INPUT_WIDTH}>
<InputGroupAddon>$</InputGroupAddon>
<InputGroupInput
type="number"
min={0.01}
step={0.25}
value={setting.field_value ?? ""}
onChange={(event) => onChange(setting.field_name, toNumericValue(event.target.value))}
/>
</InputGroup>
);
}
if (setting.field_type === "Select") {
return (
<AntdSelect
allowClear
style={{ minWidth: "8rem" }}
placeholder="Default"
value={setting.field_value || undefined}
options={(setting.field_options ?? []).map((option) => ({ label: option, value: option }))}
onChange={(newValue) => onChange(setting.field_name, newValue ?? "")}
/>
<Select
value={setting.field_value || null}
onValueChange={(newValue) => onChange(setting.field_name, newValue ?? "")}
>
<SelectTrigger className="min-w-32">
<SelectValue placeholder="Default" />
</SelectTrigger>
<SelectContent>
<SelectItem value={null}>Default</SelectItem>
{(setting.field_options ?? []).map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
return null;
@ -131,33 +142,43 @@ export const PromptCachingPanel: React.FC<{
return (
<Card>
<Title>Prompt Caching</Title>
<CardContent>
<CardTitle>Prompt Caching</CardTitle>
<div className="mt-6 flex items-start justify-between gap-8">
<div className="max-w-2xl">
<Text className="font-medium">Automatic Anthropic prompt caching</Text>
<p className="mt-1 text-xs text-gray-500">{enableSetting.field_description}</p>
</div>
<Switch checked={enabled} onChange={(checked) => persist(ENABLE_ANTHROPIC_PROMPT_CACHING, checked)} />
</div>
{ttlSetting && (
<div className="mt-6 flex items-start justify-between gap-8">
<div className="max-w-2xl">
<Text className={`font-medium ${enabled ? "" : "text-gray-400"}`}>Cache lifetime (TTL)</Text>
<p className="mt-1 text-xs text-gray-500">{ttlSetting.field_description}</p>
<div className="min-w-0 max-w-2xl">
<p className="font-medium">Automatic Anthropic prompt caching</p>
<p className="mt-1 break-words text-xs text-gray-500">{enableSetting.field_description}</p>
</div>
<AntdSelect
allowClear
disabled={!enabled}
style={{ minWidth: "10rem" }}
placeholder="5m (default)"
value={ttlSetting.field_value || undefined}
options={(ttlSetting.field_options ?? []).map((option) => ({ label: option, value: option }))}
onChange={(newValue) => persist(ANTHROPIC_PROMPT_CACHING_TTL, newValue ?? "")}
/>
<Switch checked={enabled} onCheckedChange={(checked) => persist(ENABLE_ANTHROPIC_PROMPT_CACHING, checked)} />
</div>
)}
{ttlSetting && (
<div className="mt-6 flex items-start justify-between gap-8">
<div className="min-w-0 max-w-2xl">
<p className={`font-medium ${enabled ? "" : "text-gray-400"}`}>Cache lifetime (TTL)</p>
<p className="mt-1 break-words text-xs text-gray-500">{ttlSetting.field_description}</p>
</div>
<Select
disabled={!enabled}
value={ttlSetting.field_value || null}
onValueChange={(newValue) => persist(ANTHROPIC_PROMPT_CACHING_TTL, newValue ?? "")}
>
<SelectTrigger className="min-w-40">
<SelectValue placeholder="5m (default)" />
</SelectTrigger>
<SelectContent>
<SelectItem value={null}>5m (default)</SelectItem>
{(ttlSetting.field_options ?? []).map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</CardContent>
</Card>
);
};
@ -254,55 +275,60 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
</TabsContent>
<TabsContent value="general" className="px-8 py-6">
<Card>
<Table>
<TableHead>
<TableRow>
<TableHeaderCell>Setting</TableHeaderCell>
<TableHeaderCell>Value</TableHeaderCell>
<TableHeaderCell>Status</TableHeaderCell>
<TableHeaderCell>Action</TableHeaderCell>
</TableRow>
</TableHead>
<TableBody>
{generalSettings
.filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB)
.map((value, index) => (
<TableRow key={index}>
<TableCell>
<Text>{value.field_name}</Text>
<p
style={{
fontSize: "0.65rem",
color: "#808080",
fontStyle: "italic",
}}
className="mt-1"
>
{value.field_description}
</p>
</TableCell>
<TableCell>
<SettingValueEditor setting={value} onChange={handleInputChange} />
</TableCell>
<TableCell>
{value.stored_in_db == true ? (
<StatusBadge tone="success" label="In DB" />
) : value.stored_in_db == false ? (
<StatusBadge tone="neutral" label="In Config" />
) : (
<StatusBadge tone="neutral" label="Not Set" />
)}
</TableCell>
<TableCell>
<Button onClick={() => handleUpdateField(value.field_name)}>Update</Button>
<Icon icon={TrashIcon} color="red" onClick={() => handleResetField(value.field_name)}>
Reset
</Icon>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Setting</TableHead>
<TableHead>Value</TableHead>
<TableHead>Status</TableHead>
<TableHead>Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{generalSettings
.filter((value) => value.field_type !== "TypedDictionary" && value.field_tab !== PROMPT_CACHING_TAB)
.map((value, index) => (
<TableRow key={index}>
<TableCell className="whitespace-normal">
<p className="break-words">{value.field_name}</p>
<p
style={{
fontSize: "0.65rem",
color: "#808080",
fontStyle: "italic",
}}
className="mt-1 break-words"
>
{value.field_description}
</p>
</TableCell>
<TableCell>
<SettingValueEditor setting={value} onChange={handleInputChange} />
</TableCell>
<TableCell>
{value.stored_in_db == true ? (
<StatusBadge tone="success" label="In DB" />
) : value.stored_in_db == false ? (
<StatusBadge tone="neutral" label="In Config" />
) : (
<StatusBadge tone="neutral" label="Not Set" />
)}
</TableCell>
<TableCell>
<Button onClick={() => handleUpdateField(value.field_name)}>Update</Button>
<span
onClick={() => handleResetField(value.field_name)}
className="inline-flex shrink-0 cursor-pointer items-center justify-center px-1.5 py-1.5 text-red-500"
>
<Trash2 className="h-5 w-5 shrink-0" />
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</TabsContent>
</Tabs>

View file

@ -1,4 +1,4 @@
import { Badge } from "antd";
import { Badge } from "@/components/ui/badge";
import { useDisableShowNewBadge } from "@/app/(dashboard)/hooks/useDisableShowNewBadge";
export default function BetaBadge({ children, dot = false }: { children?: React.ReactNode; dot?: boolean }) {
@ -8,11 +8,14 @@ export default function BetaBadge({ children, dot = false }: { children?: React.
return children ? <>{children}</> : null;
}
const badge = dot ? <Badge className="size-1.5 p-0" /> : <Badge>Beta</Badge>;
return children ? (
<Badge color="blue" count={dot ? undefined : "Beta"} dot={dot}>
<span className="inline-flex items-center gap-1.5">
{children}
</Badge>
{badge}
</span>
) : (
<Badge color="blue" count={dot ? undefined : "Beta"} dot={dot} />
badge
);
}

View file

@ -0,0 +1,44 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it } from "vitest";
import { DeprecationBanner } from "./DeprecationBanner";
describe("DeprecationBanner", () => {
it("names the deprecated feature in the heading and the body", () => {
render(<DeprecationBanner featureName="Memory" />);
expect(screen.getByText("Memory is on a draft deprecation list")).toBeInTheDocument();
expect(screen.getByText(/Memory is one of several experimental features/)).toBeInTheDocument();
});
it("states the target removal date and that the list is not final", () => {
render(<DeprecationBanner featureName="Memory" />);
expect(screen.getByText(/as early as September 1, 2026/)).toBeInTheDocument();
expect(screen.getByText(/This list is a draft and is not final/)).toBeInTheDocument();
});
it("links to the deprecation discussion in a new tab without leaking the opener", () => {
render(<DeprecationBanner featureName="Memory" />);
const link = screen.getByRole("link", { name: "deprecation discussion" });
expect(link).toHaveAttribute("href", "https://github.com/BerriAI/litellm/discussions/32090");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noopener noreferrer");
});
it("exposes a named close control", () => {
render(<DeprecationBanner featureName="Memory" />);
expect(screen.getByRole("button", { name: /close/i })).toBeInTheDocument();
});
it("hides the banner once the close control is used", async () => {
const user = userEvent.setup();
render(<DeprecationBanner featureName="Memory" />);
await user.click(screen.getByRole("button", { name: /close/i }));
expect(screen.queryByText("Memory is on a draft deprecation list")).not.toBeInTheDocument();
});
});

View file

@ -1,8 +1,8 @@
"use client";
import React from "react";
import React, { useState } from "react";
import Link from "next/link";
import { Alert } from "antd";
import { Info, X } from "lucide-react";
const DEPRECATION_DISCUSSION_URL = "https://github.com/BerriAI/litellm/discussions/32090";
const DEPRECATION_TARGET_DATE = "September 1, 2026";
@ -11,21 +11,42 @@ interface DeprecationBannerProps {
featureName: string;
}
export const DeprecationBanner: React.FC<DeprecationBannerProps> = ({ featureName }) => (
<Alert
message={`${featureName} is on a draft deprecation list`}
description={
<>
{`${featureName} is one of several experimental features we're considering removing, potentially as early as ${DEPRECATION_TARGET_DATE}. This list is a draft and is not final. If you rely on this feature, please share feedback on the `}
<Link href={DEPRECATION_DISCUSSION_URL} target="_blank" rel="noopener noreferrer">
deprecation discussion
</Link>
.
</>
}
type="info"
showIcon
closable
style={{ marginBottom: 16 }}
/>
);
export const DeprecationBanner: React.FC<DeprecationBannerProps> = ({ featureName }) => {
const [isClosed, setIsClosed] = useState(false);
if (isClosed) {
return null;
}
return (
<div
role="alert"
className="mb-4 flex items-start gap-3 rounded-lg border border-border bg-muted/50 px-4 py-3 text-sm"
>
<Info className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="min-w-0 flex-1">
<p className="font-medium">{`${featureName} is on a draft deprecation list`}</p>
<p className="mt-1 break-words text-muted-foreground">
{`${featureName} is one of several experimental features we're considering removing, potentially as early as ${DEPRECATION_TARGET_DATE}. This list is a draft and is not final. If you rely on this feature, please share feedback on the `}
<Link
href={DEPRECATION_DISCUSSION_URL}
target="_blank"
rel="noopener noreferrer"
className="underline underline-offset-4"
>
deprecation discussion
</Link>
.
</p>
</div>
<button
type="button"
aria-label="Close"
onClick={() => setIsClosed(true)}
className="shrink-0 rounded-md p-0.5 text-muted-foreground transition-colors hover:text-foreground"
>
<X className="size-4" />
</button>
</div>
);
};

View file

@ -1,8 +1,9 @@
import { CheckCircleOutlined, CloseOutlined, DownOutlined, WarningOutlined } from "@ant-design/icons";
import { CircleCheck, ChevronDown, TriangleAlert, X } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import moment from "moment";
import { Button, Spin } from "antd";
import React, { useState } from "react";
import { Button } from "@/components/ui/button";
import { UiLoadingSpinner } from "@/components/ui/ui-loading-spinner";
import { uiSpendLogsCall } from "@/components/networking";
import { LogDetailsDrawer } from "@/components/view_logs/LogDetailsDrawer";
import type { LogEntry as ViewLogsLogEntry } from "@/components/view_logs/columns";
@ -13,21 +14,21 @@ const actionConfig: Record<
{ icon: React.ElementType; color: string; bg: string; border: string; label: string }
> = {
blocked: {
icon: CloseOutlined,
icon: X,
color: "text-red-600",
bg: "bg-red-50",
border: "border-red-200",
label: "Blocked",
},
passed: {
icon: CheckCircleOutlined,
icon: CircleCheck,
color: "text-green-600",
bg: "bg-green-50",
border: "border-green-200",
label: "Passed",
},
flagged: {
icon: WarningOutlined,
icon: TriangleAlert,
color: "text-amber-600",
bg: "bg-amber-50",
border: "border-amber-200",
@ -125,8 +126,8 @@ export function LogViewer({
{filters.map((f) => (
<Button
key={f}
type={activeFilter === f ? "primary" : "default"}
size="small"
variant={activeFilter === f ? "default" : "outline"}
size="sm"
onClick={() => setActiveFilter(f)}
>
{f.charAt(0).toUpperCase() + f.slice(1)}
@ -139,8 +140,8 @@ export function LogViewer({
{sampleSizes.map((size) => (
<Button
key={size}
type={sampleSize === size ? "primary" : "default"}
size="small"
variant={sampleSize === size ? "default" : "outline"}
size="sm"
onClick={() => setSampleSize(size)}
>
{size}
@ -154,7 +155,7 @@ export function LogViewer({
{logsLoading && (
<div className="flex items-center justify-center py-12">
<Spin />
<UiLoadingSpinner className="size-5" />
</div>
)}
{!logsLoading && displayLogs.length === 0 && (
@ -182,11 +183,11 @@ export function LogViewer({
</span>
<span className="text-xs text-gray-400">{log.timestamp}</span>
<span className="text-xs text-gray-400">·</span>
{log.model && <span className="text-xs text-gray-500">{log.model}</span>}
{log.model && <span className="min-w-0 text-xs break-words text-gray-500">{log.model}</span>}
</div>
<p className="text-sm text-gray-800 truncate">{log.input_snippet ?? log.input ?? "—"}</p>
</div>
<DownOutlined className="w-4 h-4 text-gray-400 shrink-0 mt-1" />
<ChevronDown className="w-4 h-4 text-gray-400 shrink-0 mt-1" />
</button>
);
})}

View file

@ -1,6 +1,6 @@
import type { ProxyModel } from "@/app/(dashboard)/hooks/models/useModels";
import type { Organization } from "@/components/networking";
import { screen, waitFor } from "@testing-library/react";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
@ -22,64 +22,6 @@ vi.mock("@/app/(dashboard)/hooks/users/useCurrentUser", () => ({
useCurrentUser: vi.fn(),
}));
vi.mock("antd", async (importOriginal) => {
const actual = await importOriginal<typeof import("antd")>();
return {
...actual,
Select: ({
value,
onChange,
options,
"data-testid": dataTestId,
allowClear,
maxTagCount,
maxTagPlaceholder,
mode,
...props
}: any) => {
// Simulate maxTagCount responsive behavior - if value length > 5, call maxTagPlaceholder
const shouldShowPlaceholder = maxTagCount === "responsive" && Array.isArray(value) && value.length > 5;
const visibleValues = shouldShowPlaceholder ? value.slice(0, 5) : value;
const omittedValues = shouldShowPlaceholder ? value.slice(5).map((v: string) => ({ value: v, label: v })) : [];
return (
<div data-testid={dataTestId || "model-select"}>
<select
multiple={mode === "multiple"}
role="listbox"
value={visibleValues}
onChange={(e) => {
const selectedValues = Array.from(e.target.selectedOptions, (option) => option.value);
onChange(mode === "multiple" ? selectedValues : selectedValues[0]);
}}
{...props}
>
{options?.map((group: any) => (
<optgroup
key={group.label?.props?.children || group.title}
label={group.title || group.label?.props?.children}
>
{group.options?.map((option: any) => (
<option key={option.value} value={option.value} disabled={option.disabled}>
{typeof option.label === "string" ? option.label : option.label?.props?.children}
</option>
))}
</optgroup>
))}
</select>
{shouldShowPlaceholder && maxTagPlaceholder && (
<div data-testid="max-tag-placeholder">{maxTagPlaceholder(omittedValues)}</div>
)}
</div>
);
},
Skeleton: {
Input: ({ active, block }: any) => <div data-testid="skeleton-input" data-active={active} data-block={block} />,
},
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
};
});
import { useAllProxyModels } from "@/app/(dashboard)/hooks/models/useModels";
import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
@ -108,6 +50,14 @@ const createMockOrganization = (models: string[]): Organization => ({
members: null,
});
const openModelList = async (user: ReturnType<typeof userEvent.setup>) => {
await user.click(screen.getAllByRole("combobox")[0]);
await screen.findByRole("listbox");
};
const expectOffered = (label: string) => expect(screen.queryAllByText(label).length).toBeGreaterThan(0);
const expectNotOffered = (label: string) => expect(screen.queryAllByText(label)).toHaveLength(0);
describe("ModelSelect", () => {
const mockProxyModels: ProxyModel[] = [
{ id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" },
@ -138,21 +88,26 @@ describe("ModelSelect", () => {
} as any);
});
it("should render with all option groups", async () => {
it("should offer every model and wildcard under its group heading", async () => {
const user = userEvent.setup();
renderWithProviders(
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
);
await waitFor(() => {
expect(screen.getByTestId("model-select")).toBeInTheDocument();
expect(screen.getByText("gpt-4")).toBeInTheDocument();
expect(screen.getByText("claude-3")).toBeInTheDocument();
expect(screen.getByText("All Openai models")).toBeInTheDocument();
expect(screen.getByText("All Anthropic models")).toBeInTheDocument();
});
await openModelList(user);
expectOffered("Wildcard Options");
expectOffered("gpt-4");
expectOffered("claude-3");
expectOffered("All Openai models");
expectOffered("All Anthropic models");
});
it("should show skeleton loader when any data is loading", () => {
it("should offer nothing to select while any dependency is loading", () => {
const { unmount: unmountReady } = renderWithProviders(<ModelSelect onChange={mockOnChange} context="user" />);
expect(screen.getAllByRole("combobox")).toHaveLength(1);
unmountReady();
const loadingScenarios = [
{ hook: mockUseAllProxyModels, context: "user" as const },
{ hook: mockUseTeam, context: "team" as const, props: { teamID: "team-1" } },
@ -168,30 +123,41 @@ describe("ModelSelect", () => {
const { unmount } = renderWithProviders(<ModelSelect onChange={mockOnChange} context={context} {...props} />);
expect(screen.getByTestId("skeleton-input")).toBeInTheDocument();
expect(screen.queryAllByRole("combobox")).toHaveLength(0);
unmount();
});
});
it("should handle model selection and onChange", async () => {
it("should report the picked model to onChange", async () => {
const user = userEvent.setup();
renderWithProviders(
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
);
await waitFor(() => {
expect(screen.getByTestId("model-select")).toBeInTheDocument();
});
await openModelList(user);
await user.click(screen.getAllByText("gpt-4")[0]);
const select = screen.getByRole("listbox");
await user.selectOptions(select, "gpt-4");
expect(mockOnChange).toHaveBeenCalledWith(["gpt-4"]);
await user.selectOptions(select, ["gpt-4", "claude-3"]);
expect(mockOnChange).toHaveBeenCalled();
});
it("should handle special options correctly", async () => {
it("should append a second model to the existing selection", async () => {
const user = userEvent.setup();
renderWithProviders(
<ModelSelect
onChange={mockOnChange}
value={["gpt-4"]}
context="user"
options={{ showAllProxyModelsOverride: true }}
/>,
);
await openModelList(user);
await user.click(screen.getAllByText("claude-3")[0]);
expect(mockOnChange).toHaveBeenCalledWith(["gpt-4", "claude-3"]);
});
it("should offer both special options when they are enabled", async () => {
const user = userEvent.setup();
mockUseOrganization.mockReturnValue({
data: createMockOrganization(["all-proxy-models"]),
@ -207,33 +173,32 @@ describe("ModelSelect", () => {
/>,
);
await waitFor(() => {
expect(screen.getByText("All Proxy Models")).toBeInTheDocument();
expect(screen.getByText("No Default Models")).toBeInTheDocument();
});
await openModelList(user);
const select = screen.getByRole("listbox");
await user.selectOptions(select, ["all-proxy-models", "no-default-models"]);
expect(mockOnChange).toHaveBeenCalledWith(["no-default-models"]);
expectOffered("Special Options");
expectOffered("All Proxy Models");
expectOffered("No Default Models");
});
it("should disable models when special option is selected", async () => {
it("should replace an existing selection when a special option is picked", async () => {
const user = userEvent.setup();
renderWithProviders(
<ModelSelect
onChange={mockOnChange}
value={["all-proxy-models"]}
value={["gpt-4"]}
context="user"
options={{ showAllProxyModelsOverride: true }}
options={{ showAllProxyModelsOverride: true, includeSpecialOptions: true }}
/>,
);
await waitFor(() => {
expect(screen.getByRole("option", { name: "gpt-4" })).toBeDisabled();
expect(screen.getByRole("option", { name: "All Openai models" })).toBeDisabled();
});
await openModelList(user);
await user.click(screen.getAllByText("No Default Models")[0]);
expect(mockOnChange).toHaveBeenCalledWith(["no-default-models"]);
});
it("should filter models based on context", async () => {
it("should filter the offered models by context", async () => {
const testCases = [
{
name: "user context with includeUserModels",
@ -340,6 +305,7 @@ describe("ModelSelect", () => {
];
for (const testCase of testCases) {
const user = userEvent.setup();
testCase.setup();
const { unmount } = renderWithProviders(
<ModelSelect
@ -350,14 +316,9 @@ describe("ModelSelect", () => {
/>,
);
await waitFor(() => {
testCase.expectedVisible.forEach((model) => {
expect(screen.getByText(model)).toBeInTheDocument();
});
testCase.expectedHidden.forEach((model) => {
expect(screen.queryByText(model)).not.toBeInTheDocument();
});
});
await openModelList(user);
testCase.expectedVisible.forEach(expectOffered);
testCase.expectedHidden.forEach(expectNotOffered);
unmount();
vi.clearAllMocks();
@ -368,7 +329,7 @@ describe("ModelSelect", () => {
}
});
it("should show All Proxy Models option based on conditions", async () => {
it("should offer All Proxy Models only when the context allows it", async () => {
const testCases = [
{
name: "when showAllProxyModelsOverride is true",
@ -426,6 +387,7 @@ describe("ModelSelect", () => {
];
for (const testCase of testCases) {
const user = userEvent.setup();
testCase.setup();
const { unmount } = renderWithProviders(
<ModelSelect
@ -436,14 +398,13 @@ describe("ModelSelect", () => {
/>,
);
await waitFor(() => {
if (testCase.shouldShow) {
expect(screen.getByText("All Proxy Models")).toBeInTheDocument();
} else {
expect(screen.queryByText("All Proxy Models")).not.toBeInTheDocument();
expect(screen.getByText("No Default Models")).toBeInTheDocument();
}
});
await openModelList(user);
if (testCase.shouldShow) {
expectOffered("All Proxy Models");
} else {
expectNotOffered("All Proxy Models");
expectOffered("No Default Models");
}
unmount();
vi.clearAllMocks();
@ -454,27 +415,6 @@ describe("ModelSelect", () => {
}
});
it("should deduplicate models with same id", async () => {
const duplicateModels: ProxyModel[] = [
{ id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" },
{ id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" },
];
mockUseAllProxyModels.mockReturnValue({
data: { data: duplicateModels },
isLoading: false,
} as any);
renderWithProviders(
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
);
await waitFor(() => {
const gpt4Options = screen.getAllByText("gpt-4");
expect(gpt4Options.length).toBeGreaterThan(0);
});
});
it("should use custom dataTestId when provided", async () => {
renderWithProviders(
<ModelSelect
@ -485,12 +425,11 @@ describe("ModelSelect", () => {
/>,
);
await waitFor(() => {
expect(screen.getByTestId("custom-test-id")).toBeInTheDocument();
});
expect(await screen.findByTestId("custom-test-id")).toBeInTheDocument();
});
it("should return all proxy models for team context when organization has empty models array", async () => {
const user = userEvent.setup();
mockUseTeam.mockReturnValue({
data: { team_id: "team-1", team_alias: "Test Team", models: [] },
isLoading: false,
@ -503,52 +442,66 @@ describe("ModelSelect", () => {
renderWithProviders(<ModelSelect onChange={mockOnChange} context="team" teamID="team-1" organizationID="org-1" />);
await waitFor(() => {
expect(screen.getByText("gpt-4")).toBeInTheDocument();
expect(screen.getByText("claude-3")).toBeInTheDocument();
});
await openModelList(user);
expectOffered("gpt-4");
expectOffered("claude-3");
});
it("should disable No Default Models when all-proxy-models is selected", async () => {
mockUseOrganization.mockReturnValue({
data: createMockOrganization(["all-proxy-models"]),
isLoading: false,
} as any);
it("should not offer a special options group when includeSpecialOptions is omitted", async () => {
const user = userEvent.setup();
renderWithProviders(<ModelSelect onChange={mockOnChange} context="global" />);
await openModelList(user);
expectNotOffered("Special Options");
expectNotOffered("All Proxy Models");
expectNotOffered("No Default Models");
expectOffered("Models");
});
it("should mark models and wildcards unselectable while a special option is selected", async () => {
const user = userEvent.setup();
renderWithProviders(
<ModelSelect
onChange={mockOnChange}
value={["all-proxy-models"]}
context="organization"
organizationID="org-1"
options={{ includeSpecialOptions: true }}
context="user"
options={{ showAllProxyModelsOverride: true, includeSpecialOptions: true }}
/>,
);
await waitFor(() => {
const noDefaultOption = screen.getByRole("option", { name: "No Default Models" });
expect(noDefaultOption).toBeDisabled();
});
await openModelList(user);
expect(screen.getByRole("option", { name: "gpt-4" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("option", { name: "All Openai models" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("option", { name: "No Default Models" })).toHaveAttribute("aria-disabled", "true");
expect(screen.getByRole("option", { name: "All Proxy Models" })).not.toHaveAttribute("aria-disabled", "true");
});
it("should not render an empty optgroup when includeSpecialOptions is omitted", async () => {
renderWithProviders(<ModelSelect onChange={mockOnChange} context="global" />);
it("should list a duplicated proxy model only once", async () => {
const user = userEvent.setup();
mockUseAllProxyModels.mockReturnValue({
data: {
data: [
{ id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" },
{ id: "gpt-4", object: "model", created: 1234567890, owned_by: "openai" },
],
},
isLoading: false,
} as any);
await waitFor(() => {
expect(screen.getByTestId("model-select")).toBeInTheDocument();
});
renderWithProviders(
<ModelSelect onChange={mockOnChange} context="user" options={{ showAllProxyModelsOverride: true }} />,
);
const optgroups = document.querySelectorAll("optgroup");
// Wildcard Options + Models — no blank leading group
expect(optgroups.length).toBe(2);
optgroups.forEach((g) => {
expect(g.getAttribute("label")).toBeTruthy();
});
await openModelList(user);
expect(screen.getAllByRole("option", { name: "gpt-4" })).toHaveLength(1);
});
it("should render maxTagPlaceholder when many items are selected", async () => {
// Create many models to trigger maxTagCount responsive behavior
const manyModels: ProxyModel[] = Array.from({ length: 20 }, (_, i) => ({
it("should collapse selections past the chip limit into a labelled overflow count", async () => {
const manyModels: ProxyModel[] = Array.from({ length: 8 }, (_, i) => ({
id: `model-${i}`,
object: "model",
created: 1234567890,
@ -560,22 +513,18 @@ describe("ModelSelect", () => {
isLoading: false,
} as any);
const selectedValues = manyModels.slice(0, 10).map((m) => m.id);
renderWithProviders(
<ModelSelect
onChange={mockOnChange}
value={selectedValues}
value={manyModels.map((m) => m.id)}
context="user"
options={{ showAllProxyModelsOverride: true }}
/>,
);
await waitFor(() => {
expect(screen.getByTestId("model-select")).toBeInTheDocument();
// Verify maxTagPlaceholder is rendered with omitted values
expect(screen.getByTestId("max-tag-placeholder")).toBeInTheDocument();
expect(screen.getByText(/\+5 more/)).toBeInTheDocument();
});
expect(await screen.findByText("+3 more")).toBeInTheDocument();
expect(screen.getByLabelText("model-0")).toBeInTheDocument();
expect(screen.getByLabelText("model-4")).toBeInTheDocument();
expect(screen.queryByLabelText("model-5")).not.toBeInTheDocument();
});
});

View file

@ -2,7 +2,22 @@ import { ProxyModel, useAllProxyModels } from "@/app/(dashboard)/hooks/models/us
import { useOrganization } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useTeam } from "@/app/(dashboard)/hooks/teams/useTeams";
import { useCurrentUser } from "@/app/(dashboard)/hooks/users/useCurrentUser";
import { Select, Skeleton, Tooltip } from "antd";
import {
Combobox,
ComboboxChip,
ComboboxChips,
ComboboxChipsInput,
ComboboxCollection,
ComboboxContent,
ComboboxEmpty,
ComboboxGroup,
ComboboxItem,
ComboboxLabel,
ComboboxList,
ComboboxValue,
} from "@/components/ui/combobox";
import { Skeleton } from "@/components/ui/skeleton";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { Organization, Team } from "../networking";
import { splitWildcardModels } from "./modelUtils";
@ -21,6 +36,8 @@ export const MODEL_SENTINEL_OPTIONS = [
MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE,
] as const;
const MAX_VISIBLE_MODEL_CHIPS = 5;
export interface ModelSelectProps {
teamID?: string;
organizationID?: string;
@ -37,6 +54,17 @@ export interface ModelSelectProps {
style?: React.CSSProperties;
}
type ModelOption = {
label: string;
value: string;
disabled?: boolean;
};
type ModelOptionGroup = {
label: string;
items: ModelOption[];
};
type FilterContextArgs = {
allProxyModels: string[];
selectedTeam?: Team;
@ -109,10 +137,11 @@ export const ModelSelect = (props: ModelSelectProps) => {
showAllProxyModelsOverride || (organizationHasAllProxyModels && includeSpecialOptions) || context === "global";
if (isLoading) {
return <Skeleton.Input active block />;
return <Skeleton className="h-9 w-full" />;
}
const handleChange = (values: string[]) => {
const handleChange = (selected: ModelOption[]) => {
const values = selected.map((option) => option.value);
const specialValues = values.filter(isSpecialOption);
let finalValues: string[];
@ -133,85 +162,122 @@ export const ModelSelect = (props: ModelSelectProps) => {
});
const { wildcard, regular } = splitWildcardModels(filteredModels);
return (
<Select
data-testid={dataTestId}
value={value}
onChange={handleChange}
style={style}
options={[
...(includeSpecialOptions
? [
{
label: <span>Special Options</span>,
title: "Special Options",
options: [
...(shouldShowAllProxyModels
? [
{
label: <span>All Proxy Models</span>,
value: MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value,
disabled:
value.length > 0 &&
value.some(
(v) => isSpecialOption(v) && v !== MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value,
),
key: MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value,
},
]
: []),
{
label: <span>No Default Models</span>,
value: MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.value,
disabled:
value.length > 0 &&
value.some((v) => isSpecialOption(v) && v !== MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.value),
key: MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.value,
},
],
},
]
: []),
...(wildcard.length > 0
? [
{
label: <span>Wildcard Options</span>,
title: "Wildcard Options",
options: wildcard.map((model) => {
const provider = model.replace("/*", "");
const capitalizedProvider = provider.charAt(0).toUpperCase() + provider.slice(1);
return {
label: <span>{`All ${capitalizedProvider} models`}</span>,
value: model,
disabled: hasSpecialOptionSelected,
};
}),
const groups: ModelOptionGroup[] = [
...(includeSpecialOptions
? [
{
label: "Special Options",
items: [
...(shouldShowAllProxyModels
? [
{
label: MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.label,
value: MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value,
disabled:
value.length > 0 &&
value.some(
(v) => isSpecialOption(v) && v !== MODEL_SELECT_ALL_PROXY_MODELS_SPECIAL_VALUE.value,
),
},
]
: []),
{
label: MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.label,
value: MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.value,
disabled:
value.length > 0 &&
value.some((v) => isSpecialOption(v) && v !== MODEL_SELECT_NO_DEFAULT_MODELS_SPECIAL_VALUE.value),
},
]
: []),
{
label: <span>Models</span>,
title: "Models",
options: regular.map((model) => ({
label: <span>{model}</span>,
value: model,
disabled: hasSpecialOptionSelected,
})),
},
]}
mode="multiple"
placeholder="Select Models"
allowClear
maxTagCount="responsive"
maxTagPlaceholder={(omittedValues) => (
<Tooltip
styles={{ root: { pointerEvents: "none" } }}
title={omittedValues.map(({ value }) => value).join(", ")}
>
<span>+{omittedValues.length} more</span>
</Tooltip>
)}
/>
],
},
]
: []),
...(wildcard.length > 0
? [
{
label: "Wildcard Options",
items: wildcard.map((model) => {
const provider = model.replace("/*", "");
const capitalizedProvider = provider.charAt(0).toUpperCase() + provider.slice(1);
return {
label: `All ${capitalizedProvider} models`,
value: model,
disabled: hasSpecialOptionSelected,
};
}),
},
]
: []),
{
label: "Models",
items: regular.map((model) => ({
label: model,
value: model,
disabled: hasSpecialOptionSelected,
})),
},
];
const optionsByValue = new Map(groups.flatMap((group) => group.items).map((option) => [option.value, option]));
const selectedOptions = value.map((v) => optionsByValue.get(v) ?? { label: v, value: v });
const overflowOptions = selectedOptions.slice(MAX_VISIBLE_MODEL_CHIPS);
return (
<TooltipProvider>
<Combobox
multiple
items={groups}
value={selectedOptions}
onValueChange={handleChange}
isItemEqualToValue={(option: ModelOption, selected: ModelOption) => option.value === selected.value}
itemToStringLabel={(option: ModelOption) => option.label}
>
<ComboboxChips data-testid={dataTestId} style={style} className="w-full">
<ComboboxValue>
{(selected: ModelOption[]) => (
<>
{selected.slice(0, MAX_VISIBLE_MODEL_CHIPS).map((option) => (
<ComboboxChip key={option.value} aria-label={option.label}>
{option.label}
</ComboboxChip>
))}
{overflowOptions.length > 0 && (
<Tooltip>
<TooltipTrigger
render={<span className="px-1 text-xs text-muted-foreground" />}
>{`+${overflowOptions.length} more`}</TooltipTrigger>
<TooltipContent>{overflowOptions.map((option) => option.value).join(", ")}</TooltipContent>
</Tooltip>
)}
</>
)}
</ComboboxValue>
<ComboboxChipsInput
placeholder="Select Models"
aria-label="Select Models"
className="h-5 min-w-24 flex-1 border-0 bg-transparent py-0 text-sm"
/>
</ComboboxChips>
<ComboboxContent>
<ComboboxEmpty>No models found</ComboboxEmpty>
<ComboboxList>
{(group: ModelOptionGroup) => (
<ComboboxGroup key={group.label} items={group.items}>
<ComboboxLabel>{group.label}</ComboboxLabel>
<ComboboxCollection>
{(option: ModelOption) => (
<ComboboxItem key={option.value} value={option} disabled={option.disabled}>
<span className="min-w-0 break-words">{option.label}</span>
</ComboboxItem>
)}
</ComboboxCollection>
</ComboboxGroup>
)}
</ComboboxList>
</ComboboxContent>
</Combobox>
</TooltipProvider>
);
};

View file

@ -4,9 +4,9 @@
* Reuses FallbackGroupConfig with the primary model locked
*/
import { Button } from "antd";
import { Button } from "@/components/ui/button";
import { useQuery } from "@tanstack/react-query";
import { Pencil } from "lucide-react";
import { LoaderCircle, Pencil } from "lucide-react";
import React, { useMemo, useState } from "react";
import { fetchAvailableModels } from "@/components/llm_calls/fetch_models";
import NotificationManager from "../../../molecules/notifications_manager";
@ -88,16 +88,11 @@ export default function EditFallbacks({
disablePrimaryModel
/>
<div className="flex items-center justify-end space-x-3 pt-6 mt-6 border-t border-gray-100">
<Button type="default" onClick={onClose} disabled={isSaving}>
<Button variant="outline" onClick={onClose} disabled={isSaving}>
Cancel
</Button>
<Button
type="primary"
icon={<Pencil className="w-4 h-4" />}
onClick={handleSave}
disabled={isSaving || group.fallbackModels.length === 0}
loading={isSaving}
>
<Button onClick={handleSave} disabled={isSaving || group.fallbackModels.length === 0}>
{isSaving ? <LoaderCircle className="w-4 h-4 animate-spin" /> : <Pencil className="w-4 h-4" />}
{isSaving ? "Saving Changes..." : "Save Changes"}
</Button>
</div>

View file

@ -1,7 +1,7 @@
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
import { ArrowRightIcon, PencilAltIcon, PlayIcon, TrashIcon } from "@heroicons/react/outline";
import { Icon, Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "@tremor/react";
import { Tooltip, Typography } from "antd";
import { ArrowRight, Pencil, Play, Trash2 } from "lucide-react";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import openai from "openai";
import React, { useEffect, useState } from "react";
import DeleteResourceModal from "../../../common_components/DeleteResourceModal";
@ -18,12 +18,14 @@ type Fallbacks = FallbackEntry[];
const modelCardClass =
"inline-flex items-center gap-2 px-2.5 py-1 rounded-md border border-gray-200 bg-gray-50 text-sm font-medium text-gray-800 shrink-0";
const iconWrapperClass = "inline-flex shrink-0 items-center justify-center px-1.5 py-1.5";
function renderModelNameCell(modelName: string, getProviderFromModel?: (modelName: string) => string): React.ReactNode {
const provider = getProviderFromModel?.(modelName) ?? modelName;
return (
<span className={modelCardClass}>
<ProviderLogo provider={provider} className="w-4 h-4 shrink-0" />
<span>{modelName}</span>
<span className="break-words">{modelName}</span>
</span>
);
}
@ -41,19 +43,23 @@ function renderFallbacksChain(
return (
<span className={modelCardClass}>
<ProviderLogo provider={provider} className="w-4 h-4 shrink-0" />
<span>{modelName}</span>
<span className="break-words">{modelName}</span>
</span>
);
};
return (
<span className="grid grid-cols-[auto_1fr] items-start gap-x-2 w-full min-w-0">
<span className="inline-flex items-center justify-center w-8 h-8 shrink-0 self-start text-blue-600" aria-hidden>
<ArrowRightIcon className="w-5 h-5 stroke-[2.5]" />
<ArrowRight className="w-5 h-5 stroke-[2.5]" />
</span>
<span className="flex flex-wrap items-start gap-1 min-w-0">
{list.map((model, i) => (
<React.Fragment key={model}>
{i > 0 && <Icon icon={ArrowRightIcon} size="xs" className="shrink-0 text-gray-400" />}
{i > 0 && (
<span className={`${iconWrapperClass} text-gray-400`}>
<ArrowRight className="h-3 w-3 shrink-0" />
</span>
)}
<ChainCard modelName={model} />
</React.Fragment>
))}
@ -248,7 +254,7 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
const canModify = isProxyAdminRole(userRole ?? "");
return (
<>
<TooltipProvider>
{canModify && (
<AddFallbacks
accessToken={accessToken || ""}
@ -258,62 +264,79 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
)}
{!hasFallbacks ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-6 text-center">
<Typography.Text type="secondary">
<span className="text-muted-foreground">
No fallbacks configured. Add fallbacks to automatically try another model when the primary fails.
</Typography.Text>
</span>
</div>
) : (
<Table>
<TableHead>
<TableHeader>
<TableRow>
<TableHeaderCell>Model Name</TableHeaderCell>
<TableHeaderCell>Fallbacks</TableHeaderCell>
<TableHeaderCell>Actions</TableHeaderCell>
<TableHead>Model Name</TableHead>
<TableHead>Fallbacks</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHead>
</TableHeader>
<TableBody>
{routerSettings["fallbacks"].map((item: FallbackEntry, index: number) =>
Object.entries(item).map(([key, value]) => (
<TableRow key={index.toString() + key}>
<TableCell className="align-top">{renderModelNameCell(key, getProviderFromModel)}</TableCell>
<TableCell className="align-top">
<TableCell className="align-top whitespace-normal">
{renderModelNameCell(key, getProviderFromModel)}
</TableCell>
<TableCell className="align-top whitespace-normal">
{renderFallbacksChain(key, Array.isArray(value) ? value : [], getProviderFromModel)}
</TableCell>
<TableCell className="align-top">
{canModify && (
<>
<Tooltip title="Test fallback">
<Icon
icon={PlayIcon}
size="sm"
onClick={() => testFallbackModelResponse(Object.keys(item)[0], accessToken || "")}
className="cursor-pointer hover:text-blue-600"
/>
</Tooltip>
<Tooltip title="Edit fallback">
<span
data-testid="edit-fallback-button"
role="button"
tabIndex={0}
onClick={() => handleEditClick(item)}
onKeyDown={(e) => e.key === "Enter" && handleEditClick(item)}
className="cursor-pointer inline-flex"
<Tooltip>
<TooltipTrigger
render={
<span
onClick={() => testFallbackModelResponse(Object.keys(item)[0], accessToken || "")}
className={`${iconWrapperClass} cursor-pointer hover:text-blue-600`}
/>
}
>
<Icon icon={PencilAltIcon} size="sm" className="hover:text-blue-600" />
</span>
<Play className="h-5 w-5 shrink-0" />
</TooltipTrigger>
<TooltipContent>Test fallback</TooltipContent>
</Tooltip>
<Tooltip title="Delete fallback">
<span
data-testid="delete-fallback-button"
role="button"
tabIndex={0}
onClick={() => handleDeleteClick(item)}
onKeyDown={(e) => e.key === "Enter" && handleDeleteClick(item)}
className="cursor-pointer inline-flex"
<Tooltip>
<TooltipTrigger
render={
<span
data-testid="edit-fallback-button"
role="button"
tabIndex={0}
onClick={() => handleEditClick(item)}
onKeyDown={(e) => e.key === "Enter" && handleEditClick(item)}
className={`${iconWrapperClass} cursor-pointer hover:text-blue-600`}
/>
}
>
<Icon icon={TrashIcon} size="sm" className="hover:text-red-600" />
</span>
<Pencil className="h-5 w-5 shrink-0" />
</TooltipTrigger>
<TooltipContent>Edit fallback</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<span
data-testid="delete-fallback-button"
role="button"
tabIndex={0}
onClick={() => handleDeleteClick(item)}
onKeyDown={(e) => e.key === "Enter" && handleDeleteClick(item)}
className={`${iconWrapperClass} cursor-pointer hover:text-red-600`}
/>
}
>
<Trash2 className="h-5 w-5 shrink-0" />
</TooltipTrigger>
<TooltipContent>Delete fallback</TooltipContent>
</Tooltip>
</>
)}
@ -350,7 +373,7 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
onOk={handleDeleteConfirm}
confirmLoading={isDeleting}
/>
</>
</TooltipProvider>
);
};

View file

@ -1,11 +1,15 @@
import React, { useState, useEffect } from "react";
import { Modal, Form, Steps, Button, Checkbox } from "antd";
import { Text, Title, Badge } from "@tremor/react";
import { Loader2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { cn } from "@/lib/cva.config";
import { enableClaudeCodePlugin, disableClaudeCodePlugin } from "../networking";
import NotificationsManager from "../molecules/notifications_manager";
import { Plugin } from "./types";
const { Step } = Steps;
const STEP_TITLES = ["Select Skills", "Confirm"];
interface MakeSkillPublicFormProps {
visible: boolean;
@ -25,12 +29,10 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
const [currentStep, setCurrentStep] = useState(0);
const [selectedSkills, setSelectedSkills] = useState<Set<string>>(new Set());
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const handleClose = () => {
setCurrentStep(0);
setSelectedSkills(new Set());
form.resetFields();
onClose();
};
@ -106,52 +108,44 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
const renderStep1 = () => (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Title>Select Skills to Publish</Title>
<Checkbox
checked={allSelected}
indeterminate={isIndeterminate}
onChange={(e) => handleSelectAll(e.target.checked)}
disabled={skillsList.length === 0}
>
<h3 className="text-lg font-semibold">Select Skills to Publish</h3>
<label className="flex items-center gap-2 text-sm">
<Checkbox
checked={allSelected}
indeterminate={isIndeterminate}
onCheckedChange={(checked) => handleSelectAll(checked === true)}
disabled={skillsList.length === 0}
/>
Select All ({skillsList.length})
</Checkbox>
</label>
</div>
<Text className="text-sm text-gray-600">
<p className="text-sm text-gray-600">
Selected skills will be visible to all users in the Skill Hub. Deselected skills will be unpublished.
</Text>
</p>
<div className="max-h-96 overflow-y-auto border rounded-lg p-4">
<div className="space-y-3">
{skillsList.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<Text>No skills registered yet.</Text>
<p>No skills registered yet.</p>
</div>
) : (
skillsList.map((skill) => (
<div key={skill.name} className="flex items-center space-x-3 p-3 border rounded-lg hover:bg-gray-50">
<Checkbox
aria-label={skill.name}
checked={selectedSkills.has(skill.name)}
onChange={(e) => handleSkillSelection(skill.name, e.target.checked)}
onCheckedChange={(checked) => handleSkillSelection(skill.name, checked === true)}
/>
<div className="flex-1">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<Text className="font-medium font-mono text-sm">{skill.name}</Text>
{skill.enabled && (
<Badge color="green" size="xs">
Public
</Badge>
)}
<p className="font-medium font-mono text-sm break-words">{skill.name}</p>
{skill.enabled && <Badge variant="secondary">Public</Badge>}
</div>
{skill.description && (
<Text className="text-xs text-gray-500 truncate max-w-sm">{skill.description}</Text>
)}
{skill.description && <p className="text-xs text-gray-500 truncate max-w-sm">{skill.description}</p>}
</div>
{skill.domain && (
<Badge color="blue" size="xs">
{skill.domain}
</Badge>
)}
{skill.domain && <Badge variant="outline">{skill.domain}</Badge>}
</div>
))
)}
@ -160,9 +154,9 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
{selectedSkills.size > 0 && (
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-sm text-blue-800">
<p className="text-sm text-blue-800">
<strong>{selectedSkills.size}</strong> skill{selectedSkills.size !== 1 ? "s" : ""} will be published
</Text>
</p>
</div>
)}
</div>
@ -170,29 +164,25 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
const renderStep2 = () => (
<div className="space-y-4">
<Title>Confirm Publish to Skill Hub</Title>
<h3 className="text-lg font-semibold">Confirm Publish to Skill Hub</h3>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<Text className="text-sm text-yellow-800">
<p className="text-sm text-yellow-800">
<strong>Note:</strong> Published skills will be visible to all users in the Skill Hub tab. Skills not in the
list below will be unpublished.
</Text>
</p>
</div>
<div className="space-y-3">
<Text className="font-medium">Skills to be published:</Text>
<p className="font-medium">Skills to be published:</p>
<div className="max-h-48 overflow-y-auto border rounded-lg p-3">
<div className="space-y-2">
{Array.from(selectedSkills).map((name) => {
const skill = skillsList.find((s) => s.name === name);
return (
<div key={name} className="flex items-center justify-between p-2 bg-gray-50 rounded-sm">
<Text className="font-mono text-sm">{name}</Text>
{skill?.domain && (
<Badge color="blue" size="xs">
{skill.domain}
</Badge>
)}
<div key={name} className="flex items-center justify-between gap-2 p-2 bg-gray-50 rounded-sm">
<p className="font-mono text-sm min-w-0 break-words">{name}</p>
{skill?.domain && <Badge variant="outline">{skill.domain}</Badge>}
</div>
);
})}
@ -201,49 +191,68 @@ const MakeSkillPublicForm: React.FC<MakeSkillPublicFormProps> = ({
</div>
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
<Text className="text-sm text-blue-800">
<p className="text-sm text-blue-800">
Total: <strong>{selectedSkills.size}</strong> skill{selectedSkills.size !== 1 ? "s" : ""} will be published
</Text>
</p>
</div>
</div>
);
return (
<Modal
title="Publish to Skill Hub"
open={visible}
onCancel={handleClose}
footer={null}
width={700}
maskClosable={false}
>
<Form form={form} layout="vertical">
<Steps current={currentStep} className="mb-6">
<Step title="Select Skills" />
<Step title="Confirm" />
</Steps>
<Dialog open={visible} onOpenChange={(open) => !open && handleClose()} disablePointerDismissal>
<DialogContent className="max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-[700px]">
<DialogHeader>
<DialogTitle>Publish to Skill Hub</DialogTitle>
</DialogHeader>
{currentStep === 0 ? renderStep1() : renderStep2()}
<div>
<ol className="mb-6 flex items-center gap-6">
{STEP_TITLES.map((title, index) => (
<li
key={title}
className="flex items-center gap-2"
aria-current={currentStep === index ? "step" : undefined}
>
<span
className={cn(
"flex size-6 items-center justify-center rounded-full border text-xs",
currentStep === index
? "border-primary bg-primary text-primary-foreground"
: "border-border text-muted-foreground",
)}
>
{index + 1}
</span>
<span className={cn("text-sm", currentStep === index ? "font-medium" : "text-muted-foreground")}>
{title}
</span>
</li>
))}
</ol>
<div className="flex justify-between mt-6">
<Button onClick={currentStep === 0 ? handleClose : () => setCurrentStep(0)}>
{currentStep === 0 ? "Cancel" : "Previous"}
</Button>
<div className="flex space-x-2">
{currentStep === 0 && (
<Button onClick={handleNext} disabled={selectedSkills.size === 0}>
Next
</Button>
)}
{currentStep === 1 && (
<Button onClick={handleSubmit} loading={loading}>
Publish to Hub
</Button>
)}
{currentStep === 0 ? renderStep1() : renderStep2()}
<div className="flex justify-between mt-6">
<Button variant="outline" onClick={currentStep === 0 ? handleClose : () => setCurrentStep(0)}>
{currentStep === 0 ? "Cancel" : "Previous"}
</Button>
<div className="flex space-x-2">
{currentStep === 0 && (
<Button onClick={handleNext} disabled={selectedSkills.size === 0}>
Next
</Button>
)}
{currentStep === 1 && (
<Button onClick={handleSubmit} disabled={loading}>
{loading && <Loader2 className="size-4 animate-spin" />}
Publish to Hub
</Button>
)}
</div>
</div>
</div>
</Form>
</Modal>
</DialogContent>
</Dialog>
);
};

View file

@ -226,4 +226,19 @@ describe("public hub MCP details modal", () => {
await screen.findByText("Server Overview");
expect(screen.queryByText(PUBLIC_SERVER_URL)).not.toBeInTheDocument();
});
it("closes the server details modal from its close control", async () => {
const networkingModule = await import("./networking");
vi.mocked(networkingModule.mcpHubPublicServersCall).mockResolvedValue([mockMcpServer]);
render(<PublicModelHub />);
fireEvent.click(await screen.findByRole("tab", { name: /MCP Hub/i }));
fireEvent.click(await screen.findByRole("button", { name: "exa_test" }));
await screen.findByText("Server Overview");
fireEvent.click(screen.getByRole("button", { name: /close/i }));
await waitFor(() => expect(screen.queryByText("Server Overview")).not.toBeInTheDocument());
});
});

File diff suppressed because it is too large Load diff