diff --git a/.changeset/fix-custom-provider-masked-api-key.md b/.changeset/fix-custom-provider-masked-api-key.md index cb51f2b191..329e1511cf 100644 --- a/.changeset/fix-custom-provider-masked-api-key.md +++ b/.changeset/fix-custom-provider-masked-api-key.md @@ -2,4 +2,6 @@ "@runfusion/fusion": patch --- -Fix custom provider message sends failing with a `ByteString` error (`character ... value 8226`). The settings UI displays the saved API key masked with `•` characters; saving the provider without retyping the key persisted that mask as the real credential, which then broke HTTP header encoding. Masked values echoed back on update are now treated as "unchanged" and the stored key is preserved; masked values on create/probe are rejected. Re-enter the real API key once to clear any already-corrupted key. +Fix custom provider message sends failing with a `ByteString` error (`character ... value 8226`). The settings UI displays the saved API key masked with `•` characters; saving the provider without retyping the key persisted that mask as the real credential, which then broke HTTP header encoding. Masked values echoed back on update are now treated as "unchanged" and the stored key is preserved; masked values on create/probe are rejected. + +The edit form no longer seeds the API key field with the masked value at all — it starts blank (with a "Leave blank to keep current key" hint) so the mask can never be echoed back to save or "Detect Models". Existing keys are preserved when the field is left empty. diff --git a/packages/dashboard/app/components/CustomProvidersSection.tsx b/packages/dashboard/app/components/CustomProvidersSection.tsx index 658d1f162a..1bc976e567 100644 --- a/packages/dashboard/app/components/CustomProvidersSection.tsx +++ b/packages/dashboard/app/components/CustomProvidersSection.tsx @@ -143,7 +143,11 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C setName(provider.name); setApiType(provider.apiType); setBaseUrl(provider.baseUrl); - setApiKey(provider.apiKey ?? ""); + // The loaded provider's apiKey is masked (e.g. "abc•••••wxyz") for display. + // Never seed the editable field with the mask — echoing it back would send a + // masked value to save/probe (which the server rejects). Start empty; an + // unchanged blank field leaves the stored key untouched on save. + setApiKey(""); setModels((provider.models ?? []).map((model) => model.id).join(", ")); setFormError(null); setDetectError(null); @@ -366,6 +370,9 @@ export function CustomProvidersSection({ embedded = false, onProviderChange }: C id="custom-provider-api-key" type="password" className="input" + placeholder={editingProvider?.apiKey + ? t("providers.apiKeyKeepPlaceholder", "Leave blank to keep current key") + : undefined} value={apiKey} onChange={(event) => setApiKey(event.target.value)} disabled={saving} diff --git a/packages/dashboard/app/components/__tests__/CustomProvidersSection.test.tsx b/packages/dashboard/app/components/__tests__/CustomProvidersSection.test.tsx index 236d11b34b..e41bdb430b 100644 --- a/packages/dashboard/app/components/__tests__/CustomProvidersSection.test.tsx +++ b/packages/dashboard/app/components/__tests__/CustomProvidersSection.test.tsx @@ -244,6 +244,49 @@ describe("CustomProvidersSection", () => { }); }); + it("does not echo the masked key back when editing without retyping", async () => { + mockFetchCustomProviders + .mockResolvedValueOnce([ + { + id: "test-id", + name: "Keyed Provider", + apiType: "openai-compatible", + baseUrl: "https://api.example.com", + // Server returns the key masked for display. + apiKey: "abc•••••wxyz", + }, + ]) + .mockResolvedValueOnce([ + { + id: "test-id", + name: "Keyed Provider", + apiType: "openai-compatible", + baseUrl: "https://api.example.com", + }, + ]); + + render(); + + await waitFor(() => { + expect(screen.getByLabelText("Edit Keyed Provider")).toBeTruthy(); + }); + + fireEvent.click(screen.getByLabelText("Edit Keyed Provider")); + + // The API key field must start empty, never seeded with the mask. + const apiKeyInput = screen.getByLabelText("API key") as HTMLInputElement; + expect(apiKeyInput.value).toBe(""); + + fireEvent.click(screen.getByRole("button", { name: "Save Changes" })); + + await waitFor(() => { + expect(mockUpdateCustomProvider).toHaveBeenCalledTimes(1); + }); + // apiKey is omitted entirely so the stored credential is preserved. + const [, payload] = mockUpdateCustomProvider.mock.calls[0]; + expect(payload).not.toHaveProperty("apiKey"); + }); + it("deletes provider after confirmation", async () => { mockFetchCustomProviders .mockResolvedValueOnce([ diff --git a/packages/i18n/locales/en/app.json b/packages/i18n/locales/en/app.json index b693b53bd8..0e13bfb021 100644 --- a/packages/i18n/locales/en/app.json +++ b/packages/i18n/locales/en/app.json @@ -4299,6 +4299,7 @@ "saving": "Saving..." }, "addCustom": "Add Custom Provider", + "apiKeyKeepPlaceholder": "Leave blank to keep current key", "apiKeyLabel": "API key", "apiTypeAnthropic": "Anthropic-compatible", "apiTypeInvalid": "API type is invalid.",