mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-03 02:22:24 +00:00
feat(ui): add provider auth editing to the model edit view
Provider API keys / auth could previously only be changed by hand-editing
the raw litellm_params JSON, so there was no first-class way to rotate a
model's key. Adds an Authentication section that renders the correct
provider-specific fields (reusing ProviderSpecificFields) keyed off the
model's custom_llm_provider; fields are blank ("leave blank to keep
current") so untouched secrets are preserved and only entered values are
PATCHed and encrypted at rest.
Resolves LIT-3169
This commit is contained in:
parent
0300333753
commit
8af663f990
3 changed files with 115 additions and 3 deletions
|
|
@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Form>
|
||||
<ProviderSpecificFields
|
||||
selectedProvider={Providers.OpenAI}
|
||||
excludeKeys={["api_base", "organization"]}
|
||||
onFieldsResolved={onFieldsResolved}
|
||||
/>
|
||||
</Form>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Form>
|
||||
<ProviderSpecificFields selectedProvider={Providers.OpenAI} onFieldsResolved={onFieldsResolved} />
|
||||
</Form>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(onFieldsResolved).toHaveBeenCalledWith(["api_base", "organization", "api_key"]);
|
||||
});
|
||||
});
|
||||
|
||||
it("should render the provider specific fields for Azure", async () => {
|
||||
const queryClient = createQueryClient();
|
||||
render(
|
||||
|
|
|
|||
|
|
@ -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<ProviderSpecificFieldsProps> = ({ selectedProvider, uploadProps }) => {
|
||||
const ProviderSpecificFields: React.FC<ProviderSpecificFieldsProps> = ({
|
||||
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<ProviderSpecificFieldsProps> = ({ 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<ProviderSpecificFieldsProps> = ({ selecte
|
|||
</Col>
|
||||
</Row>
|
||||
)}
|
||||
{allFields.map((field) => (
|
||||
{visibleFields.map((field) => (
|
||||
<React.Fragment key={field.key}>
|
||||
<Form.Item
|
||||
label={field.label}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ import {
|
|||
tagListCall,
|
||||
testConnectionRequest,
|
||||
} from "./networking";
|
||||
import { getProviderLogoAndName } from "./provider_info_helpers";
|
||||
import { getProviderLogoAndName, Providers } from "./provider_info_helpers";
|
||||
import ProviderSpecificFields from "./add_model/provider_specific_fields";
|
||||
import NumericalInput from "./shared/numerical_input";
|
||||
import { Tag } from "./tag_management/types";
|
||||
import { getDisplayModelName } from "./view_model/model_name_display";
|
||||
|
|
@ -78,6 +79,9 @@ export default function ModelInfoView({
|
|||
const [guardrailsList, setGuardrailsList] = useState<string[]>([]);
|
||||
const [tagsList, setTagsList] = useState<Record<string, Tag>>({});
|
||||
const [credentialsList, setCredentialsList] = useState<CredentialItem[]>([]);
|
||||
// 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<string[]>([]);
|
||||
|
||||
// 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<Record<string, any>>((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({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{isEditing && (
|
||||
<div>
|
||||
<Text className="font-medium">Authentication</Text>
|
||||
{usingExistingCredential ? (
|
||||
<div className="mt-1 p-2 bg-gray-50 rounded text-sm text-gray-600">
|
||||
This model uses the shared credential{" "}
|
||||
<span className="font-mono">
|
||||
{localModelData.litellm_params?.litellm_credential_name}
|
||||
</span>
|
||||
. Update its keys from the LLM Credentials tab.
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-2">
|
||||
<Text className="text-xs text-gray-500 mb-2">
|
||||
Leave a field blank to keep its current value. Enter a new value to rotate it.
|
||||
</Text>
|
||||
<ProviderSpecificFields
|
||||
selectedProvider={
|
||||
(localModelData.litellm_params?.custom_llm_provider ||
|
||||
modelData.provider) as Providers
|
||||
}
|
||||
excludeKeys={["api_base", "organization", "custom_llm_provider"]}
|
||||
onFieldsResolved={setAuthFieldKeys}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isWildcardModel && (
|
||||
<div>
|
||||
<Text className="font-medium">Health Check Model</Text>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue