diff --git a/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.test.tsx b/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.test.tsx index 4590121acf2..8b0057a1ffe 100644 --- a/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.test.tsx +++ b/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.test.tsx @@ -183,6 +183,46 @@ describe("ProviderSpecificFields", () => { }); }); + it("excludeKeys removes parent-owned fields and reports only the remaining keys", async () => { + const queryClient = createQueryClient(); + const onFieldsResolved = vi.fn(); + render( + +
+ + +
, + ); + + await waitFor(() => { + // api_key still rendered, the parent-owned fields are gone + expect(screen.getByLabelText("OpenAI API Key")).toBeInTheDocument(); + }); + expect(screen.queryByPlaceholderText("https://api.openai.com/v1")).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText("[OPTIONAL] my-unique-org")).not.toBeInTheDocument(); + expect(onFieldsResolved).toHaveBeenCalledWith(["api_key"]); + }); + + it("onFieldsResolved reports the full key set when nothing is excluded", async () => { + const queryClient = createQueryClient(); + const onFieldsResolved = vi.fn(); + render( + +
+ + +
, + ); + + await waitFor(() => { + expect(onFieldsResolved).toHaveBeenCalledWith(["api_base", "organization", "api_key"]); + }); + }); + it("should render the provider specific fields for Azure", async () => { const queryClient = createQueryClient(); render( diff --git a/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx b/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx index 24df0ac21ef..440dc775fd2 100644 --- a/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx +++ b/ui/litellm-dashboard/src/components/add_model/provider_specific_fields.tsx @@ -10,6 +10,13 @@ const { Link } = Typography; interface ProviderSpecificFieldsProps { selectedProvider: Providers; uploadProps?: UploadProps; + // Field keys to NOT render — used when a parent form already owns these + // inputs (e.g. the model edit form has a dedicated "API Base" field) so we + // don't create a duplicate Form.Item bound to the same name. + excludeKeys?: string[]; + // Called whenever the set of rendered field keys changes, so a parent can + // know exactly which form values belong to this provider's auth fields. + onFieldsResolved?: (keys: string[]) => void; } interface ProviderCredentialField { @@ -92,7 +99,12 @@ export const createCredentialFromModel = (provider: string, modelData: any): Cre return credential; }; -const ProviderSpecificFields: React.FC = ({ selectedProvider, uploadProps }) => { +const ProviderSpecificFields: React.FC = ({ + selectedProvider, + uploadProps, + excludeKeys, + onFieldsResolved, +}) => { const selectedProviderEnum = Providers[selectedProvider as keyof typeof Providers] as Providers; const form = Form.useFormInstance(); // Get form instance from context @@ -167,6 +179,20 @@ const ProviderSpecificFields: React.FC = ({ selecte return mapped; }, [selectedProviderEnum, selectedProvider, providerMetadata]); + const excludeKeySet = React.useMemo(() => new Set(excludeKeys ?? []), [excludeKeys?.join(",")]); + + const visibleFields = React.useMemo( + () => (excludeKeySet.size > 0 ? allFields.filter((f) => !excludeKeySet.has(f.key)) : allFields), + [allFields, excludeKeySet], + ); + + // Report the rendered field keys upward (string-joined so the effect only + // fires when the actual set changes, not on every parent render). + const visibleKeyList = React.useMemo(() => visibleFields.map((f) => f.key).join(","), [visibleFields]); + React.useEffect(() => { + onFieldsResolved?.(visibleKeyList ? visibleKeyList.split(",") : []); + }, [visibleKeyList, onFieldsResolved]); + const handleUpload = { name: "file", accept: ".json", @@ -214,7 +240,7 @@ const ProviderSpecificFields: React.FC = ({ selecte )} - {allFields.map((field) => ( + {visibleFields.map((field) => ( ([]); const [tagsList, setTagsList] = useState>({}); const [credentialsList, setCredentialsList] = useState([]); + // Provider auth-field keys reported by ProviderSpecificFields, so the save + // handler knows which form values are this provider's credential fields. + const [authFieldKeys, setAuthFieldKeys] = useState([]); // Fetch model data using hook const { data: rawModelDataResponse, isLoading: isLoadingModel } = useModelsInfo(1, 50, undefined, modelId); @@ -241,9 +245,22 @@ export default function ModelInfoView({ return; } + // Provider auth fields the user actually entered. Blank/null/undefined + // are omitted (never sent as "") so an untouched secret is preserved by + // the backend's merge instead of being overwritten. Mirrors the submit + // filter used by EditCredentialModal. + const authFieldUpdates = authFieldKeys.reduce>((acc, key) => { + const v = values[key]; + if (v !== "" && v !== undefined && v !== null) { + acc[key] = v; + } + return acc; + }, {}); + let updatedLitellmParams = { ...values.litellm_params, ...parsedExtraParams, + ...authFieldUpdates, model: values.litellm_model_name, api_base: values.api_base, custom_llm_provider: values.custom_llm_provider, @@ -1064,6 +1081,35 @@ export default function ModelInfoView({ )} + {isEditing && ( +
+ Authentication + {usingExistingCredential ? ( +
+ This model uses the shared credential{" "} + + {localModelData.litellm_params?.litellm_credential_name} + + . Update its keys from the LLM Credentials tab. +
+ ) : ( +
+ + Leave a field blank to keep its current value. Enter a new value to rotate it. + + +
+ )} +
+ )} + {isWildcardModel && (
Health Check Model