mirror of
https://github.com/diegosouzapw/OmniRoute.git
synced 2026-08-19 21:52:21 +03:00
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:
committed by
GitHub
parent
4aee3c48d3
commit
9f83aa9b93
@@ -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)
|
||||
|
||||
@@ -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...",
|
||||
|
||||
129
tests/unit/ui/compatible-provider-apikey-setup.test.tsx
Normal file
129
tests/unit/ui/compatible-provider-apikey-setup.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user