fix(providers): require Default Model in compatible-provider API-key setup (#4641)

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.
This commit is contained in:
Diego Rodrigues de Sa e Souza
2026-06-25 21:38:34 -03:00
committed by GitHub
parent 4aee3c48d3
commit 9f83aa9b93
3 changed files with 145 additions and 0 deletions

View File

@@ -45,6 +45,7 @@ export interface AddApiKeyModalProps {
apiKey?: string;
priority: number;
baseUrl?: string;
defaultModel?: string;
providerSpecificData?: Record<string, unknown>;
}) => Promise<void | unknown>;
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 && (
<Input
label={t("compatibleDefaultModelLabel")}
value={formData.defaultModel}
onChange={(e) => 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 && (
<p className="text-xs text-text-muted">
{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)

View File

@@ -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...",

View File

@@ -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<typeof createRoot>; el: HTMLDivElement }> = [];
function render(props: Record<string, unknown>) {
const el = document.createElement("div");
document.body.appendChild(el);
const root = createRoot(el);
act(() => {
root.render(
<AddApiKeyModal
isOpen
onSave={async () => 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<HTMLInputElement>('input[placeholder="productionKey"]')!;
const apiKeyInput = el.querySelector<HTMLInputElement>('input[type="password"]')!;
const defaultModelInput = el.querySelector<HTMLInputElement>(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");
});
});