From 9f83aa9b9397f925df325bcf3216c8071e562126 Mon Sep 17 00:00:00 2001 From: Diego Rodrigues de Sa e Souza <8016841+diegosouzapw@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:38:34 -0300 Subject: [PATCH] fix(providers): require Default Model in compatible-provider API-key setup (#4641) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrated into release/v3.8.37 — require Default Model in compatible-provider API-key setup. Cherry-picked fix + test-move onto release tip (kept release providerSpecificData + QuotaScrapingFields; fixed moved-test import path; baseline rebaseline unneeded, 865<866); UI test 2/2 green. --- .../[id]/components/modals/AddApiKeyModal.tsx | 14 ++ src/i18n/messages/en.json | 2 + .../compatible-provider-apikey-setup.test.tsx | 129 ++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 tests/unit/ui/compatible-provider-apikey-setup.test.tsx diff --git a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx index 7f0447d4db..e4eb1c5498 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx +++ b/src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal.tsx @@ -45,6 +45,7 @@ export interface AddApiKeyModalProps { apiKey?: string; priority: number; baseUrl?: string; + defaultModel?: string; providerSpecificData?: Record; }) => Promise; onClose: () => void; @@ -100,6 +101,7 @@ export default function AddApiKeyModal({ const [formData, setFormData] = useState({ name: "", apiKey: "", + defaultModel: "", priority: 1, baseUrl: initialBaseUrl || defaultBaseUrl, cx: "", @@ -303,6 +305,7 @@ export default function AddApiKeyModal({ apiKey: credentialInput.trim() || undefined, priority: formData.priority, testStatus: "active", + defaultModel: isCompatible ? formData.defaultModel.trim() || undefined : undefined, providerSpecificData, }; @@ -696,6 +699,16 @@ export default function AddApiKeyModal({ onChange={(patch) => setFormData({ ...formData, ...patch })} t={t} /> + {isCompatible && ( + setFormData({ ...formData, defaultModel: e.target.value })} + placeholder={isAnthropic ? "claude-3-5-sonnet-latest" : "gpt-4o-mini"} + hint={t("compatibleDefaultModelHint")} + data-testid="compat-default-model-input" + /> + )} {isCompatible && !isCcCompatible && (

{isAnthropic @@ -832,6 +845,7 @@ export default function AddApiKeyModal({ disabled={ !formData.name || (!isCompatible && !apiKeyOptional && !formData.apiKey) || + (isCompatible && !formData.defaultModel.trim()) || (isGooglePse && !formData.cx.trim()) || saving || (usesBaseUrl && !formData.baseUrl.trim() && !defaultBaseUrl) diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 2b8b5edf35..4296f4eba3 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -4173,6 +4173,8 @@ "optional": "Optional", "anthropicCompatibleName": "Anthropic Compatible", "openaiCompatibleName": "OpenAI Compatible", + "compatibleDefaultModelLabel": "Default Model", + "compatibleDefaultModelHint": "Enter the model ID exactly as your compatible endpoint expects it. This model will be saved as the connection default.", "failedImportModels": "Failed to import models", "noModelsReturnedFromEndpoint": "No models returned from /models endpoint.", "importingModelsProgress": "Importing {current} of {total} models...", diff --git a/tests/unit/ui/compatible-provider-apikey-setup.test.tsx b/tests/unit/ui/compatible-provider-apikey-setup.test.tsx new file mode 100644 index 0000000000..720950ee02 --- /dev/null +++ b/tests/unit/ui/compatible-provider-apikey-setup.test.tsx @@ -0,0 +1,129 @@ +// @vitest-environment jsdom +/** + * Port of upstream decolua/9router PR #925. + * + * Bug: For openai-compatible / anthropic-compatible providers, the AddApiKeyModal + * had no "Default Model" input and the saved payload omitted `defaultModel`, so the + * created connection persisted with `defaultModel = null` and was effectively + * unusable (no model to bind requests to). The fix adds a required Default Model + * field for compatible providers and threads it through the save payload. + * + * Two TDD tests: + * 1) Compatible provider: the "Default Model" input is rendered and its value + * is forwarded as `defaultModel` in the onSave payload. + * 2) Non-compatible (first-party) provider: the field is NOT rendered, and + * `defaultModel` stays undefined in the payload (no regression for the + * existing first-party flow). + */ +import React, { act } from "react"; +import { createRoot } from "react-dom/client"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string) => key, +})); + +const { default: AddApiKeyModal } = await import( + "../../../src/app/(dashboard)/dashboard/providers/[id]/components/modals/AddApiKeyModal" +); + +const DEFAULT_MODEL_INPUT_SELECTOR = 'input[data-testid="compat-default-model-input"]'; + +const containers: Array<{ root: ReturnType; el: HTMLDivElement }> = []; + +function render(props: Record) { + const el = document.createElement("div"); + document.body.appendChild(el); + const root = createRoot(el); + act(() => { + root.render( + undefined} + onClose={() => {}} + {...(props as any)} + /> + ); + }); + containers.push({ root, el }); + return el; +} + +function setInputValue(input: HTMLInputElement, value: string) { + const setter = Object.getOwnPropertyDescriptor( + window.HTMLInputElement.prototype, + "value" + )!.set!; + act(() => { + setter.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + }); +} + +async function waitFor(fn: () => boolean, timeoutMs = 2000) { + const start = Date.now(); + while (!fn()) { + if (Date.now() - start > timeoutMs) throw new Error("waitFor timed out"); + await new Promise((r) => setTimeout(r, 20)); + } +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve({ ok: true, json: () => Promise.resolve({ valid: true }) } as Response) + ) + ); +}); + +afterEach(() => { + for (const { root, el } of containers.splice(0)) { + act(() => root.unmount()); + el.remove(); + } + vi.unstubAllGlobals(); +}); + +describe("AddApiKeyModal — compatible provider default-model field (PR #925)", () => { + it("renders the Default Model input only when the provider is compatible", () => { + const compatEl = render({ + provider: "openai-compatible:my-node", + providerName: "My OpenAI Compatible", + isCompatible: true, + }); + expect(compatEl.querySelector(DEFAULT_MODEL_INPUT_SELECTOR)).toBeTruthy(); + + const nonCompatEl = render({ provider: "openai", providerName: "OpenAI" }); + expect(nonCompatEl.querySelector(DEFAULT_MODEL_INPUT_SELECTOR)).toBeNull(); + }); + + it("threads defaultModel from the form into the save payload for compatible providers", async () => { + const onSave = vi.fn().mockResolvedValue(undefined); + const el = render({ + provider: "openai-compatible:my-node", + providerName: "My OpenAI Compatible", + isCompatible: true, + onSave, + }); + + const nameInput = el.querySelector('input[placeholder="productionKey"]')!; + const apiKeyInput = el.querySelector('input[type="password"]')!; + const defaultModelInput = el.querySelector(DEFAULT_MODEL_INPUT_SELECTOR)!; + setInputValue(nameInput, "My Connection"); + setInputValue(apiKeyInput, "sk-test-key"); + setInputValue(defaultModelInput, "gpt-4o-mini"); + + const saveBtn = Array.from(el.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "save" + )!; + act(() => { + saveBtn.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await waitFor(() => onSave.mock.calls.length > 0); + const payload = onSave.mock.calls[0][0]; + expect(payload.defaultModel).toBe("gpt-4o-mini"); + }); +});