mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
fix(ui): show team BYOK models in team fallback settings (#36241)
* fix(ui): show team BYOK models in team fallback settings Team router settings loaded fallback options from /model_group/info, which resolves models without a team, so a team's own BYOK deployments were never selectable in its own fallback config. Load the team-scoped listing when a team id is present. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(ui): ignore stale team model responses in router settings Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * refactor(ui): use react-query for fallback model listing in router settings accordion --------- Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: ryan-crabbe-berri <ryan@berri.ai>
This commit is contained in:
parent
8b16ee1dc2
commit
cfd64d45a8
5 changed files with 118 additions and 26 deletions
|
|
@ -1,7 +1,9 @@
|
|||
import { act, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { ReactNode } from "react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import type { ReactElement, ReactNode } from "react";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm";
|
||||
import { fetchAvailableModels, fetchAvailableModelsForTeam } from "@/components/llm_calls/fetch_models";
|
||||
import RouterSettingsAccordion, { RouterSettingsAccordionValue } from "./RouterSettingsAccordion";
|
||||
|
||||
vi.mock("../networking", () => ({
|
||||
|
|
@ -9,11 +11,14 @@ vi.mock("../networking", () => ({
|
|||
}));
|
||||
|
||||
vi.mock("@/components/llm_calls/fetch_models", () => ({
|
||||
fetchAvailableModels: vi.fn().mockResolvedValue([]),
|
||||
fetchAvailableModels: vi.fn().mockResolvedValue([{ model_group: "global-model" }]),
|
||||
fetchAvailableModelsForTeam: vi.fn().mockResolvedValue([{ model_group: "openai/*" }, { model_group: "gpt-5" }]),
|
||||
}));
|
||||
|
||||
vi.mock("../Settings/RouterSettings/Fallbacks/FallbackSelectionForm", () => ({
|
||||
FallbackSelectionForm: () => null,
|
||||
FallbackSelectionForm: ({ availableModels }: { availableModels: string[] }) => (
|
||||
<div data-testid="available-models">{availableModels.join(",")}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@tremor/react", () => ({
|
||||
|
|
@ -39,9 +44,19 @@ vi.mock("../router_settings/RouterSettingsForm", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
const renderWithQueryClient = (ui: ReactElement) => {
|
||||
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
return render(ui, {
|
||||
wrapper: ({ children }: { children: ReactNode }) => (
|
||||
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
describe("RouterSettingsAccordion", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
|
|
@ -58,7 +73,7 @@ describe("RouterSettingsAccordion", () => {
|
|||
|
||||
it("debounces propagation and calls onChange once with the last value", async () => {
|
||||
const onChange = vi.fn<(value: RouterSettingsAccordionValue) => void>();
|
||||
render(<RouterSettingsAccordion accessToken="test-token" onChange={onChange} />);
|
||||
renderWithQueryClient(<RouterSettingsAccordion accessToken="test-token" onChange={onChange} />);
|
||||
await flushInitialPropagation(onChange);
|
||||
|
||||
fireEvent.click(screen.getByText("set-least-busy"));
|
||||
|
|
@ -81,9 +96,51 @@ describe("RouterSettingsAccordion", () => {
|
|||
expect(onChange.mock.calls[0][0].router_settings.routing_strategy).toBe("usage-based-routing");
|
||||
});
|
||||
|
||||
it("offers the team's own models, including team-scoped BYOK ones, when a teamId is given", async () => {
|
||||
renderWithQueryClient(<RouterSettingsAccordion accessToken="test-token" teamId="team-123" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("available-models")).toHaveTextContent("gpt-5,openai/*");
|
||||
});
|
||||
expect(fetchAvailableModelsForTeam).toHaveBeenCalledWith("test-token", "team-123");
|
||||
expect(fetchAvailableModels).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to the proxy-wide model listing when no teamId is given", async () => {
|
||||
renderWithQueryClient(<RouterSettingsAccordion accessToken="test-token" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("available-models")).toHaveTextContent("global-model");
|
||||
});
|
||||
expect(fetchAvailableModelsForTeam).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a stale team's model response that resolves after a newer team was selected", async () => {
|
||||
const resolvers: ((models: { model_group: string }[]) => void)[] = [];
|
||||
vi.mocked(fetchAvailableModelsForTeam).mockImplementation(
|
||||
() => new Promise((resolve) => resolvers.push(resolve)) as Promise<{ model_group: string }[]>,
|
||||
);
|
||||
|
||||
const { rerender } = renderWithQueryClient(<RouterSettingsAccordion accessToken="test-token" teamId="team-slow" />);
|
||||
await waitFor(() => expect(resolvers).toHaveLength(1));
|
||||
|
||||
rerender(<RouterSettingsAccordion accessToken="test-token" teamId="team-fast" />);
|
||||
await waitFor(() => expect(resolvers).toHaveLength(2));
|
||||
|
||||
await act(async () => {
|
||||
resolvers[1]([{ model_group: "fast-team-model" }]);
|
||||
resolvers[0]([{ model_group: "slow-team-model" }]);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId("available-models")).toHaveTextContent("fast-team-model");
|
||||
});
|
||||
expect(screen.getByTestId("available-models")).not.toHaveTextContent("slow-team-model");
|
||||
});
|
||||
|
||||
it("does not call onChange when unmounted mid-wait", async () => {
|
||||
const onChange = vi.fn<(value: RouterSettingsAccordionValue) => void>();
|
||||
const { unmount } = render(<RouterSettingsAccordion accessToken="test-token" onChange={onChange} />);
|
||||
const { unmount } = renderWithQueryClient(<RouterSettingsAccordion accessToken="test-token" onChange={onChange} />);
|
||||
await flushInitialPropagation(onChange);
|
||||
|
||||
fireEvent.click(screen.getByText("set-least-busy"));
|
||||
|
|
|
|||
|
|
@ -1,12 +1,13 @@
|
|||
import React, { useEffect, useState, useImperativeHandle, forwardRef, useRef } from "react";
|
||||
import { TabPanel, TabPanels, TabGroup, TabList, Tab } from "@tremor/react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
|
||||
import { getRouterSettingsCall } from "../networking";
|
||||
import RouterSettingsForm, { RouterSettingsFormValue } from "../router_settings/RouterSettingsForm";
|
||||
import { Fallbacks } from "../Settings/RouterSettings/Fallbacks/AddFallbacks";
|
||||
import { FallbackSelectionForm } from "../Settings/RouterSettings/Fallbacks/FallbackSelectionForm";
|
||||
import { FallbackGroup } from "../Settings/RouterSettings/Fallbacks/FallbackGroupConfig";
|
||||
import { fetchAvailableModels, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
import { fetchAvailableModels, fetchAvailableModelsForTeam, ModelGroup } from "@/components/llm_calls/fetch_models";
|
||||
|
||||
export interface RouterSettingsAccordionValue {
|
||||
router_settings: {
|
||||
|
|
@ -30,6 +31,7 @@ interface RouterSettingsAccordionProps {
|
|||
value?: RouterSettingsAccordionValue;
|
||||
onChange?: (value: RouterSettingsAccordionValue) => void;
|
||||
modelData?: any;
|
||||
teamId?: string | null;
|
||||
}
|
||||
|
||||
export interface RouterSettingsAccordionRef {
|
||||
|
|
@ -39,7 +41,7 @@ export interface RouterSettingsAccordionRef {
|
|||
const PROPAGATE_WAIT_MS = 100;
|
||||
|
||||
const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSettingsAccordionProps>(
|
||||
({ accessToken, value, onChange, modelData }, ref) => {
|
||||
({ accessToken, value, onChange, modelData, teamId }, ref) => {
|
||||
const [formValue, setFormValue] = useState<RouterSettingsFormValue>({
|
||||
routerSettings: {},
|
||||
selectedStrategy: null,
|
||||
|
|
@ -47,7 +49,6 @@ const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSet
|
|||
});
|
||||
const [fallbacks, setFallbacks] = useState<Fallbacks>([]);
|
||||
const [fallbackGroups, setFallbackGroups] = useState<FallbackGroup[]>([]);
|
||||
const [modelInfo, setModelInfo] = useState<ModelGroup[]>([]);
|
||||
const [availableRoutingStrategies, setAvailableRoutingStrategies] = useState<string[]>([]);
|
||||
const [routerFieldsMetadata, setRouterFieldsMetadata] = useState<{ [key: string]: any }>({});
|
||||
const [routingStrategyDescriptions, setRoutingStrategyDescriptions] = useState<{ [key: string]: string }>({});
|
||||
|
|
@ -175,21 +176,11 @@ const RouterSettingsAccordion = forwardRef<RouterSettingsAccordionRef, RouterSet
|
|||
});
|
||||
}, [accessToken]);
|
||||
|
||||
// Fetch available models for fallbacks
|
||||
useEffect(() => {
|
||||
if (!accessToken) {
|
||||
return;
|
||||
}
|
||||
const loadModels = async () => {
|
||||
try {
|
||||
const uniqueModels = await fetchAvailableModels(accessToken);
|
||||
setModelInfo(uniqueModels);
|
||||
} catch (error) {
|
||||
console.error("Error fetching model info for fallbacks:", error);
|
||||
}
|
||||
};
|
||||
loadModels();
|
||||
}, [accessToken]);
|
||||
const { data: modelInfo = [] } = useQuery<ModelGroup[]>({
|
||||
queryKey: ["fallbackAvailableModels", accessToken, teamId ?? null],
|
||||
queryFn: () => (teamId ? fetchAvailableModelsForTeam(accessToken, teamId) : fetchAvailableModels(accessToken)),
|
||||
enabled: Boolean(accessToken),
|
||||
});
|
||||
|
||||
// Helper function to build router_settings from current state
|
||||
const buildRouterSettings = (): RouterSettingsAccordionValue["router_settings"] => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,33 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { modelAvailableCall } from "@/components/networking";
|
||||
import { fetchAvailableModelsForTeam } from "./fetch_models";
|
||||
|
||||
vi.mock("@/components/networking", () => ({
|
||||
modelAvailableCall: vi.fn(),
|
||||
modelHubCall: vi.fn(),
|
||||
}));
|
||||
|
||||
const modelAvailableCallMock = vi.mocked(modelAvailableCall);
|
||||
|
||||
describe("fetchAvailableModelsForTeam", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("requests the models scoped to the team so team-only BYOK models are included", async () => {
|
||||
modelAvailableCallMock.mockResolvedValue({
|
||||
data: [{ id: "all-proxy-models" }, { id: "openai/*" }, { id: "gpt-5-mini" }, { id: "openai/*" }],
|
||||
});
|
||||
|
||||
const models = await fetchAvailableModelsForTeam("token", "team-123");
|
||||
|
||||
expect(modelAvailableCallMock).toHaveBeenCalledWith("token", "", "", false, "team-123");
|
||||
expect(models).toEqual([{ model_group: "gpt-5-mini" }, { model_group: "openai/*" }]);
|
||||
});
|
||||
|
||||
it("returns an empty list when the team has no models", async () => {
|
||||
modelAvailableCallMock.mockResolvedValue({ data: [] });
|
||||
|
||||
expect(await fetchAvailableModelsForTeam("token", "team-123")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,12 +1,22 @@
|
|||
// fetch_models.ts
|
||||
|
||||
import { modelHubCall } from "@/components/networking";
|
||||
import { excludeProxyWideSentinel } from "@/components/key_team_helpers/fetch_available_models_team_key";
|
||||
import { modelAvailableCall, modelHubCall } from "@/components/networking";
|
||||
|
||||
export interface ModelGroup {
|
||||
model_group: string;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
export const fetchAvailableModelsForTeam = async (accessToken: string, teamId: string): Promise<ModelGroup[]> => {
|
||||
const response = await modelAvailableCall(accessToken, "", "", false, teamId);
|
||||
const modelNames: string[] = (response?.data ?? []).map((model: { id: string }) => model.id);
|
||||
|
||||
return excludeProxyWideSentinel(Array.from(new Set(modelNames)))
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((model) => ({ model_group: model }));
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches available models using modelHubCall and formats them for the selection dropdown.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1215,6 +1215,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
<RouterSettingsAccordion
|
||||
ref={routerSettingsRef}
|
||||
accessToken={accessToken || ""}
|
||||
teamId={teamId}
|
||||
value={info.router_settings ? { router_settings: info.router_settings } : undefined}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue