mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-28 01:32:17 +00:00
fix(ui): key router source map to the session and surface source load failures
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
d943d556c4
commit
5cf2abfc7c
5 changed files with 103 additions and 42 deletions
|
|
@ -417,6 +417,15 @@ describe("Fallbacks", () => {
|
|||
expect(screen.queryByTestId("delete-modal")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps fallbacks visible and explains why edits are unavailable when the source request fails", async () => {
|
||||
vi.mocked(networkingModule.getRouterSettingsCall).mockRejectedValue(new Error("boom"));
|
||||
renderWithQueryClient(<Fallbacks {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load router settings");
|
||||
expect(screen.getAllByText("gpt-4").length).toBeGreaterThan(0);
|
||||
expect(screen.queryByTestId("add-fallbacks-button")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides write actions until the source map has loaded", async () => {
|
||||
let resolveSources: (value: { fields: never[]; source: Record<string, string> }) => void = () => {};
|
||||
vi.mocked(networkingModule.getRouterSettingsCall).mockReturnValue(
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import DeleteResourceModal from "../../../common_components/DeleteResourceModal"
|
|||
import { ProviderLogo } from "../../../molecules/models/ProviderLogo";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { getCallbacksCall, getRouterSettingsCall, setCallbacksCall } from "../../../networking";
|
||||
import { CONFIG_OWNED_MESSAGE, type FieldSourceMap, isConfigOwned } from "@/components/shared/ConfigOwnedField";
|
||||
import { CONFIG_OWNED_MESSAGE, isConfigOwned, type SourcesState } from "@/components/shared/ConfigOwnedField";
|
||||
import { isProxyAdminRole } from "@/utils/roles";
|
||||
import AddFallbacks from "./AddFallbacks";
|
||||
import EditFallbacks from "./EditFallbacks";
|
||||
|
|
@ -122,7 +122,11 @@ async function testFallbackModelResponse(selectedModel: string, accessToken: str
|
|||
|
||||
const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID }) => {
|
||||
const [routerSettings, setRouterSettings] = useState<{ [key: string]: any }>({});
|
||||
const [routerSources, setRouterSources] = useState<FieldSourceMap | null>(null);
|
||||
const [sourcesState, setSourcesState] = useState<SourcesState | null>(null);
|
||||
const sessionKey = `${accessToken}:${userRole}:${userID}`;
|
||||
const loadedSources = sourcesState?.sessionKey === sessionKey ? sourcesState : null;
|
||||
const routerSources = loadedSources?.sources ?? null;
|
||||
const sourcesFailed = loadedSources?.failed ?? false;
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [fallbackToDelete, setFallbackToDelete] = useState<FallbackEntry | null>(null);
|
||||
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
|
||||
|
|
@ -147,10 +151,10 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
|
|||
}
|
||||
setRouterSettings(router_settings);
|
||||
});
|
||||
getRouterSettingsCall(accessToken).then((data) => {
|
||||
setRouterSources(data.source ?? {});
|
||||
});
|
||||
}, [accessToken, userRole, userID]);
|
||||
getRouterSettingsCall(accessToken)
|
||||
.then((data) => setSourcesState({ sessionKey, sources: data.source ?? {}, failed: false }))
|
||||
.catch(() => setSourcesState({ sessionKey, sources: null, failed: true }));
|
||||
}, [accessToken, userRole, userID, sessionKey]);
|
||||
|
||||
const handleDeleteClick = (fallbackEntry: FallbackEntry) => {
|
||||
setFallbackToDelete(fallbackEntry);
|
||||
|
|
@ -260,6 +264,11 @@ const Fallbacks: React.FC<FallbacksProps> = ({ accessToken, userRole, userID })
|
|||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
{sourcesFailed && (
|
||||
<div role="alert" className="text-sm text-destructive">
|
||||
Failed to load router settings. Reload the page to edit fallbacks
|
||||
</div>
|
||||
)}
|
||||
{canModify && (
|
||||
<AddFallbacks
|
||||
accessToken={accessToken || ""}
|
||||
|
|
|
|||
|
|
@ -203,6 +203,25 @@ describe("RouterSettings", () => {
|
|||
expect(await screen.findByText("Set in config.yaml and cannot be changed here")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("drops the previous source map while a new session is loading", async () => {
|
||||
vi.mocked(getRouterSettingsCall).mockResolvedValueOnce({ ...mockRouterSettingsResponse, source: {} });
|
||||
const { rerender } = renderWithProviders(<RouterSettings {...defaultProps} />);
|
||||
expect(await screen.findByRole("button", { name: /save changes/i })).toBeInTheDocument();
|
||||
|
||||
vi.mocked(getRouterSettingsCall).mockReturnValue(new Promise(() => {}));
|
||||
rerender(<RouterSettings {...defaultProps} accessToken="other-token" />);
|
||||
|
||||
expect(screen.queryByRole("button", { name: /save changes/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows an error instead of a blank page when the source request fails", async () => {
|
||||
vi.mocked(getRouterSettingsCall).mockRejectedValue(new Error("boom"));
|
||||
renderWithProviders(<RouterSettings {...defaultProps} />);
|
||||
|
||||
expect(await screen.findByRole("alert")).toHaveTextContent("Failed to load router settings");
|
||||
expect(screen.queryByRole("button", { name: /save changes/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("holds the form until the source map has loaded so config owned fields never render editable", async () => {
|
||||
let resolveSources: (
|
||||
value: typeof mockRouterSettingsResponse & { source: Record<string, string> },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import React, { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { FieldSourceMap } from "@/components/shared/ConfigOwnedField";
|
||||
import type { SourcesState } from "@/components/shared/ConfigOwnedField";
|
||||
import { toast } from "@/lib/toast";
|
||||
import { getCallbacksCall, getRouterSettingsCall, setCallbacksCall } from "../networking";
|
||||
import RouterSettingsForm, { RouterSettingsFormValue } from "./RouterSettingsForm";
|
||||
|
|
@ -25,7 +25,11 @@ const RouterSettings: React.FC<RouterSettingsProps> = ({ accessToken, userRole,
|
|||
const [availableRoutingStrategies, setAvailableRoutingStrategies] = useState<string[]>([]);
|
||||
const [routerFieldsMetadata, setRouterFieldsMetadata] = useState<{ [key: string]: any }>({});
|
||||
const [routingStrategyDescriptions, setRoutingStrategyDescriptions] = useState<{ [key: string]: string }>({});
|
||||
const [routerSources, setRouterSources] = useState<FieldSourceMap | null>(null);
|
||||
const [sourcesState, setSourcesState] = useState<SourcesState | null>(null);
|
||||
const sessionKey = `${accessToken}:${userRole}:${userID}`;
|
||||
const loadedSources = sourcesState?.sessionKey === sessionKey ? sourcesState : null;
|
||||
const routerSources = loadedSources?.sources ?? null;
|
||||
const sourcesFailed = loadedSources?.failed ?? false;
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken || !userRole || !userID) {
|
||||
|
|
@ -44,43 +48,45 @@ const RouterSettings: React.FC<RouterSettingsProps> = ({ accessToken, userRole,
|
|||
selectedStrategy: initialStrategy,
|
||||
}));
|
||||
});
|
||||
getRouterSettingsCall(accessToken).then((data) => {
|
||||
setRouterSources(data.source ?? {});
|
||||
if (data.fields) {
|
||||
// Build metadata map for easy lookup
|
||||
const fieldsMap: { [key: string]: any } = {};
|
||||
data.fields.forEach((field: any) => {
|
||||
fieldsMap[field.field_name] = {
|
||||
ui_field_name: field.ui_field_name,
|
||||
field_description: field.field_description,
|
||||
options: field.options,
|
||||
link: field.link,
|
||||
};
|
||||
});
|
||||
setRouterFieldsMetadata(fieldsMap);
|
||||
getRouterSettingsCall(accessToken)
|
||||
.then((data) => {
|
||||
setSourcesState({ sessionKey, sources: data.source ?? {}, failed: false });
|
||||
if (data.fields) {
|
||||
// Build metadata map for easy lookup
|
||||
const fieldsMap: { [key: string]: any } = {};
|
||||
data.fields.forEach((field: any) => {
|
||||
fieldsMap[field.field_name] = {
|
||||
ui_field_name: field.ui_field_name,
|
||||
field_description: field.field_description,
|
||||
options: field.options,
|
||||
link: field.link,
|
||||
};
|
||||
});
|
||||
setRouterFieldsMetadata(fieldsMap);
|
||||
|
||||
// Extract routing strategies from the routing_strategy field's options
|
||||
const routingStrategyField = data.fields.find((field: any) => field.field_name === "routing_strategy");
|
||||
if (routingStrategyField?.options) {
|
||||
setAvailableRoutingStrategies(routingStrategyField.options);
|
||||
}
|
||||
// Extract routing strategies from the routing_strategy field's options
|
||||
const routingStrategyField = data.fields.find((field: any) => field.field_name === "routing_strategy");
|
||||
if (routingStrategyField?.options) {
|
||||
setAvailableRoutingStrategies(routingStrategyField.options);
|
||||
}
|
||||
|
||||
// Store routing strategy descriptions
|
||||
if (data.routing_strategy_descriptions) {
|
||||
setRoutingStrategyDescriptions(data.routing_strategy_descriptions);
|
||||
}
|
||||
// Store routing strategy descriptions
|
||||
if (data.routing_strategy_descriptions) {
|
||||
setRoutingStrategyDescriptions(data.routing_strategy_descriptions);
|
||||
}
|
||||
|
||||
// Set enable_tag_filtering value
|
||||
const tagFilteringField = data.fields.find((field: any) => field.field_name === "enable_tag_filtering");
|
||||
if (tagFilteringField?.field_value !== null && tagFilteringField?.field_value !== undefined) {
|
||||
setFormValue((prev) => ({
|
||||
...prev,
|
||||
enableTagFiltering: tagFilteringField.field_value,
|
||||
}));
|
||||
// Set enable_tag_filtering value
|
||||
const tagFilteringField = data.fields.find((field: any) => field.field_name === "enable_tag_filtering");
|
||||
if (tagFilteringField?.field_value !== null && tagFilteringField?.field_value !== undefined) {
|
||||
setFormValue((prev) => ({
|
||||
...prev,
|
||||
enableTagFiltering: tagFilteringField.field_value,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}, [accessToken, userRole, userID]);
|
||||
})
|
||||
.catch(() => setSourcesState({ sessionKey, sources: null, failed: true }));
|
||||
}, [accessToken, userRole, userID, sessionKey]);
|
||||
|
||||
const handleSaveChanges = async () => {
|
||||
if (!accessToken) {
|
||||
|
|
@ -177,7 +183,19 @@ const RouterSettings: React.FC<RouterSettingsProps> = ({ accessToken, userRole,
|
|||
}
|
||||
};
|
||||
|
||||
if (!accessToken || routerSources === null) {
|
||||
if (!accessToken) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (sourcesFailed) {
|
||||
return (
|
||||
<div role="alert" className="text-sm text-destructive">
|
||||
Failed to load router settings. Reload the page to try again
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (routerSources === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,12 @@ import type { components } from "@/lib/http/schema";
|
|||
export type FieldSource = components["schemas"]["RouterSettingsResponse"]["source"][string];
|
||||
export type FieldSourceMap = Partial<Record<string, FieldSource>>;
|
||||
|
||||
export interface SourcesState {
|
||||
sessionKey: string;
|
||||
sources: FieldSourceMap | null;
|
||||
failed: boolean;
|
||||
}
|
||||
|
||||
export const CONFIG_OWNED_MESSAGE = "Set in config.yaml and cannot be changed here";
|
||||
|
||||
export const isConfigOwned = (sources: FieldSourceMap | null | undefined, fieldName: string): boolean =>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue