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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ui/litellm-dashboard/eslint-metrics.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"@typescript-eslint/no-explicit-any": 2014,
"@typescript-eslint/no-explicit-any": 2013,
"complexity": 126,
"max-depth": 61
}
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@
// non-React helpers like createCredentialFromModel.
const providerFieldsByDisplayName: Record<string, ProviderCredentialField[]> = {};

export const createCredentialFromModel = (provider: string, modelData: any): CredentialItem => {

Check warning on line 72 in ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
console.log("provider", provider);
console.log("modelData", modelData);
const enumKey = Object.keys(provider_map).find((key) => provider_map[key].toLowerCase() === provider.toLowerCase());
Expand Down Expand Up @@ -206,30 +206,20 @@
const handleUpload = {
name: "file",
accept: ".json",
beforeUpload: (file: any) => {

Check warning on line 209 in ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
if (file.type === "application/json") {
const reader = new FileReader();
reader.onload = (e) => {
if (e.target) {
const jsonStr = e.target.result as string;
console.log(`Setting field value from JSON, length: ${jsonStr.length}`);
form.setFieldsValue({ vertex_credentials: jsonStr });
console.log("Form values after setting:", form.getFieldsValue());
}
};
reader.readAsText(file);
}
// Prevent upload
return false;
},
onChange(info: any) {
console.log("Upload onChange triggered in ProviderSpecificFields");
console.log("Current form values:", form.getFieldsValue());

if (info.file.status !== "uploading") {
console.log(info.file, info.fileList);
}
},
};

return (
Expand Down Expand Up @@ -271,16 +261,9 @@
<Upload
{...handleUpload}
onChange={(info) => {
// First call the original onChange
if (uploadProps?.onChange) {
uploadProps.onChange(info);
}

// Check the field value after a short delay
setTimeout(() => {
const value = form.getFieldValue(field.key);
console.log(`${field.key} value after upload:`, JSON.stringify(value));
}, 500);
}}
>
<Button2 icon={<UploadOutlined />}>Click to Upload</Button2>
Expand Down
45 changes: 45 additions & 0 deletions ui/litellm-dashboard/src/components/model_info_view.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,13 @@
const mockUseModelHub = vi.fn();

vi.mock("@/app/(dashboard)/hooks/models/useModels", () => ({
useModelsInfo: (...args: any[]) => mockUseModelsInfo(...args),

Check warning on line 39 in ui/litellm-dashboard/src/components/model_info_view.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
useModelHub: (...args: any[]) => mockUseModelHub(...args),

Check warning on line 40 in ui/litellm-dashboard/src/components/model_info_view.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
}));

const mockUseModelCostMap = vi.fn();
vi.mock("@/app/(dashboard)/hooks/models/useModelCostMap", () => ({
useModelCostMap: (...args: any[]) => mockUseModelCostMap(...args),

Check warning on line 45 in ui/litellm-dashboard/src/components/model_info_view.test.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
}));

const mockNotificationsManager = vi.mocked(NotificationsManager);
Expand Down Expand Up @@ -633,6 +633,51 @@
expect(updatePayload.litellm_params).not.toHaveProperty("output_cost_per_token");
});

it("never re-sends a masked secret on save (regression: masked auth value must not overwrite the real secret)", async () => {
// /model/info redacts secrets by masking (e.g. "azur****BBCC"), not removing them.
// A plain save re-PATCHes the whole litellm_params blob; if the masked value were
// sent, the backend would encrypt the asterisks over the real azure_ad_token and
// silently destroy the credential. The edit form must strip masked values entirely.
const maskedSecret = "azur********************************************BBCC";
const maskedModelData = {
...defaultModelData,
litellm_params: {
model: "azure/gpt-4o",
api_base: "https://example-az.openai.azure.com",
custom_llm_provider: "azure",
azure_ad_token: maskedSecret,
},
};
mockUseModelsInfo.mockReturnValue({
data: { data: [maskedModelData] },
isLoading: false,
error: null,
});
mockModelInfoV1Call.mockResolvedValue({ data: [maskedModelData] });

const user = userEvent.setup();
render(<ModelInfoView {...DEFAULT_ADMIN_PROPS} />, { wrapper });

await waitFor(() => {
expect(screen.getByRole("button", { name: /edit settings/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: /edit settings/i }));

await waitFor(() => {
expect(screen.getByRole("button", { name: /save changes/i })).toBeInTheDocument();
});
await user.click(screen.getByRole("button", { name: /save changes/i }));

await waitFor(() => {
expect(mockModelPatchUpdateCall).toHaveBeenCalled();
});

const updatePayload = mockModelPatchUpdateCall.mock.calls[0][1];
expect(updatePayload.litellm_params.azure_ad_token).not.toBe(maskedSecret);
// No masked value may appear anywhere in the outbound params.
expect(JSON.stringify(updatePayload.litellm_params)).not.toContain("**");
});

it("should display health check model field for wildcard models", async () => {
const wildcardModelData = {
...defaultModelData,
Expand Down
74 changes: 58 additions & 16 deletions ui/litellm-dashboard/src/components/model_info_view.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useModelCostMap } from "@/app/(dashboard)/hooks/models/useModelCostMap";
import { useModelHub, useModelsInfo } from "@/app/(dashboard)/hooks/models/useModels";
import { useQueryClient } from "@tanstack/react-query";
import { transformModelData } from "@/app/(dashboard)/models-and-endpoints/utils/modelDataTransformer";
import { InfoCircleOutlined } from "@ant-design/icons";
import { ArrowLeftIcon, KeyIcon, RefreshIcon, TrashIcon } from "@heroicons/react/outline";
Expand Down Expand Up @@ -40,6 +41,7 @@
testConnectionRequest,
} from "./networking";
import { getProviderLogoAndName } from "./provider_info_helpers";
import UpdateModelCredentialsModal from "./update_model_credentials_modal";
import NumericalInput from "./shared/numerical_input";
import { Tag } from "./tag_management/types";
import { getDisplayModelName } from "./view_model/model_name_display";
Expand All @@ -50,11 +52,23 @@
accessToken: string | null;
userID: string | null;
userRole: string | null;
onModelUpdate?: (updatedModel: any) => void;

Check warning on line 55 in ui/litellm-dashboard/src/components/model_info_view.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
modelAccessGroups: string[] | null;
}

// The /model/info response redacts secrets by masking them (e.g. "sk-1****2345"),
// not by removing them. The edit form must never echo a masked value back on save:
// the backend would encrypt the asterisks and overwrite the real secret. A run of
// 2+ mask chars only appears in masker output (real config — incl. wildcard model
// names like "openai/*" — carries at most a single "*"), so this reliably detects a
// redacted value without a provider-metadata lookup. API-key rotation goes through
// UpdateModelCredentialsModal instead, which sends only the new key.
const isMaskedSecret = (value: unknown): boolean => typeof value === "string" && /\*{2,}/.test(value);

const stripMaskedSecrets = (params: Record<string, unknown>): Record<string, unknown> =>
Object.fromEntries(Object.entries(params).filter(([, value]) => !isMaskedSecret(value)));

export default function ModelInfoView({

Check warning on line 71 in ui/litellm-dashboard/src/components/model_info_view.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Function 'ModelInfoView' has a complexity of 190. Maximum allowed is 20
modelId,
onClose,
accessToken,
Expand All @@ -64,10 +78,12 @@
modelAccessGroups,
}: ModelInfoViewProps) {
const [form] = Form.useForm();
const queryClient = useQueryClient();
const [localModelData, setLocalModelData] = useState<any>(null);

Check warning on line 82 in ui/litellm-dashboard/src/components/model_info_view.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

Unexpected any. Specify a different type
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [deleteLoading, setDeleteLoading] = useState(false);
const [isCredentialModalOpen, setIsCredentialModalOpen] = useState(false);
const [isUpdateCredentialsModalOpen, setIsUpdateCredentialsModalOpen] = useState(false);
const [isDirty, setIsDirty] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isEditing, setIsEditing] = useState(false);
Expand Down Expand Up @@ -100,7 +116,7 @@
}
const transformed = transformModelData(rawModelDataResponse, getProviderFromModel);
return transformed.data[0] || null;
}, [rawModelDataResponse, modelCostMapData]);

Check warning on line 119 in ui/litellm-dashboard/src/components/model_info_view.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

React Hook useMemo has a missing dependency: 'getProviderFromModel'. Either include it or remove the dependency array

// Keep modelData variable name for backwards compatibility
const modelData = transformedModelData;
Expand Down Expand Up @@ -209,7 +225,7 @@
fetchGuardrails();
fetchTags();
fetchCredentials();
}, [accessToken, modelId]);

Check warning on line 228 in ui/litellm-dashboard/src/components/model_info_view.tsx

View workflow job for this annotation

GitHub Actions / frontend-lint

React Hook useEffect has missing dependencies: 'modelData' and 'usingExistingCredential'. Either include them or remove the dependency array

const handleReuseCredential = async (values: any) => {
if (!accessToken) return;
Expand Down Expand Up @@ -351,9 +367,15 @@
return;
}

// Final guard: never PATCH a redacted secret. The /model/info snapshot that
// seeds this form masks secrets, and any save re-sends the whole params blob;
// without this strip a masked value would be re-encrypted over the real secret.
// Credential rotation has its own dedicated path (UpdateModelCredentialsModal).
const safeLitellmParams = stripMaskedSecrets(updatedLitellmParams);

const updateData = {
model_name: values.model_name,
litellm_params: updatedLitellmParams,
litellm_params: safeLitellmParams,
model_info: updatedModelInfo,
};

Expand All @@ -363,7 +385,7 @@
...localModelData,
model_name: values.model_name,
litellm_model_name: values.litellm_model_name,
litellm_params: updatedLitellmParams,
litellm_params: safeLitellmParams,
model_info: updatedModelInfo,
};

Expand Down Expand Up @@ -511,36 +533,44 @@
</div>
</div>
<div className="flex gap-2">
<TremorButton
variant="secondary"
icon={RefreshIcon}
<Button
icon={<RefreshIcon className="h-4 w-4" />}
onClick={handleTestConnection}
className="flex items-center gap-2"
data-testid="test-connection-button"
>
Test Connection
</TremorButton>
</Button>

<Button
icon={<KeyIcon className="h-4 w-4" />}
onClick={() => setIsUpdateCredentialsModalOpen(true)}
className="flex items-center"
disabled={!canEditModel}
data-testid="update-api-key-button"
>
Update API Key
</Button>

<TremorButton
icon={KeyIcon}
variant="secondary"
<Button
icon={<KeyIcon className="h-4 w-4" />}
onClick={() => setIsCredentialModalOpen(true)}
className="flex items-center"
disabled={!isAdmin}
data-testid="reuse-credentials-button"
>
Re-use Credentials
</TremorButton>
<TremorButton
icon={TrashIcon}
variant="secondary"
</Button>
<Button
danger
icon={<TrashIcon className="h-4 w-4" />}
onClick={() => setIsDeleteModalOpen(true)}
className="flex items-center text-red-500 border-red-500 hover:text-red-700"
className="flex items-center"
disabled={!canEditModel}
data-testid="delete-model-button"
>
Delete Model
</TremorButton>
</Button>
</div>
</div>

Expand Down Expand Up @@ -715,7 +745,7 @@
litellm_extra_params: JSON.stringify(
Object.fromEntries(
Object.entries(localModelData.litellm_params || {}).filter(
([key]) => key !== "litellm_credential_name",
([key, value]) => key !== "litellm_credential_name" && !isMaskedSecret(value),
),
),
null,
Expand Down Expand Up @@ -1375,6 +1405,18 @@
</Modal>
)}

{isUpdateCredentialsModalOpen && accessToken && (
<UpdateModelCredentialsModal
open={isUpdateCredentialsModalOpen}
onCancel={() => setIsUpdateCredentialsModalOpen(false)}
accessToken={accessToken}
modelId={modelId}
onUpdated={() => {
queryClient.invalidateQueries({ queryKey: ["models", "list"] });
}}
/>
)}

{/* Edit Auto Router Modal */}
<EditAutoRouterModal
isVisible={isAutoRouterModalOpen}
Expand Down
5 changes: 2 additions & 3 deletions ui/litellm-dashboard/src/components/networking.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2781,8 +2781,8 @@ export const modelPatchUpdateCall = async (
modelId: string,
) => {
try {
console.log("Form Values in modelUpateCall:", formValues); // Log the form values before making the API call

// Intentionally not logging the payload: it can contain freshly-entered
// provider secrets (api_key, vertex_credentials, AWS creds).
const url = proxyBaseUrl ? `${proxyBaseUrl}/model/${modelId}/update` : `/model/${modelId}/update`;
const response = await fetch(url, {
method: "PATCH",
Expand All @@ -2802,7 +2802,6 @@ export const modelPatchUpdateCall = async (
throw new Error("Network response was not ok");
}
const data = await response.json();
console.log("Update model Response:", data);
return data;
// Handle success - you might want to update some state or UI based on the created key
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import UpdateModelCredentialsModal from "./update_model_credentials_modal";
import * as networking from "./networking";

vi.mock("./networking", async () => {
const actual = await vi.importActual("./networking");
return {
...actual,
modelPatchUpdateCall: vi.fn().mockResolvedValue({}),
};
});

vi.mock("./molecules/notifications_manager", () => ({
default: { success: vi.fn(), error: vi.fn(), info: vi.fn(), fromBackend: vi.fn() },
}));

const mockModelPatchUpdateCall = vi.mocked(networking.modelPatchUpdateCall);

beforeAll(() => {
Object.defineProperty(window, "matchMedia", {
writable: true,
value: (query: string) => ({
matches: false,
media: query,
onchange: null,
addListener: () => {},
removeListener: () => {},
addEventListener: () => {},
removeEventListener: () => {},
dispatchEvent: () => false,
}),
});
});

const renderModal = (overrides: Partial<Parameters<typeof UpdateModelCredentialsModal>[0]> = {}) =>
render(
<UpdateModelCredentialsModal
open
onCancel={vi.fn()}
accessToken="test-token"
modelId="model-123"
onUpdated={vi.fn()}
{...overrides}
/>,
);

describe("UpdateModelCredentialsModal", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("sends a minimal PATCH with only the new api_key", async () => {
const user = userEvent.setup();
const onUpdated = vi.fn();
const onCancel = vi.fn();
renderModal({ onUpdated, onCancel });

await user.type(screen.getByLabelText(/new api key/i), "sk-rotated-9988");
await user.click(screen.getByRole("button", { name: /update api key/i }));

await waitFor(() => expect(mockModelPatchUpdateCall).toHaveBeenCalledTimes(1));
const [token, payload, modelId] = mockModelPatchUpdateCall.mock.calls[0];
expect(token).toBe("test-token");
expect(modelId).toBe("model-123");
// Exactly the new key plus the id — nothing else from the deployment.
expect(payload).toEqual({ litellm_params: { api_key: "sk-rotated-9988" }, model_info: { id: "model-123" } });
expect(onUpdated).toHaveBeenCalledTimes(1);
expect(onCancel).toHaveBeenCalledTimes(1);
});

it("does not call the update API when the field is left blank", async () => {
const user = userEvent.setup();
renderModal();

await user.click(screen.getByRole("button", { name: /update api key/i }));

// Required-field validation blocks submit; give it a tick then assert no call.
await new Promise((resolve) => setTimeout(resolve, 50));
expect(mockModelPatchUpdateCall).not.toHaveBeenCalled();
});
});
Loading
Loading