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:
Ryan Crabbe 2026-05-16 14:44:36 -07:00
parent 0300333753
commit 8af663f990
No known key found for this signature in database
3 changed files with 115 additions and 3 deletions

View file

@ -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(

View file

@ -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}

View file

@ -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>