mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge branch 'litellm_internal_staging' into litellm_cost_tracking_removal_pending_state
This commit is contained in:
commit
62b072dcf7
15 changed files with 1584 additions and 1436 deletions
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Reference in a new issue