Compare commits

...

1 Commits

Author SHA1 Message Date
Markus Hartung
79cbf397bf fix(dashboard): skip full /sync-models catalog fetch when caller opts out (#11324) 2026-08-26 13:02:52 -03:00
3 changed files with 127 additions and 2 deletions

View File

@@ -0,0 +1 @@
- **fix(dashboard):** `useApiKeySave.handleSaveApiKey` no longer forces a full upstream `/models` catalog sync on every non-curated provider connection save — callers can now pass `skipModelSync: true` to opt out, so a workflow that only wants to add one manual model no longer floods the provider's available-models list with hundreds/thousands of synced entries. The flag is a client-side intent signal only and is stripped before the connection payload is POSTed to `/api/providers`; default behavior (full sync on save) is unchanged when the flag is omitted (#11324)

View File

@@ -0,0 +1,117 @@
// @vitest-environment jsdom
// Regression for issue #11324: adding a custom/manual model connection for a
// non-curated provider must not force a full upstream /models catalog sync
// when the caller explicitly opts out via `skipModelSync`.
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useApiKeySave } from "../hooks/useApiKeySave";
const t = ((key: string) => key) as Parameters<typeof useApiKeySave>[0]["t"];
function response(ok: boolean, body: unknown): Response {
return { ok, json: async () => body } as Response;
}
function renderApiKeySaveHook(): {
hookResult: () => ReturnType<typeof useApiKeySave>;
root: ReturnType<typeof createRoot>;
container: HTMLDivElement;
} {
const container = document.createElement("div");
document.body.appendChild(container);
let hookResult: ReturnType<typeof useApiKeySave> | null = null;
function Wrapper() {
hookResult = useApiKeySave({
providerId: "huge-catalog-openai-compatible",
fetchConnections: vi.fn().mockResolvedValue(undefined),
fetchProviderModelMeta: vi.fn().mockResolvedValue(undefined),
setImportProgress: vi.fn(),
setShowImportModal: vi.fn(),
setShowAddApiKeyModal: vi.fn(),
setSiliconFlowInitialBaseUrl: vi.fn(),
notify: { success: vi.fn(), error: vi.fn() },
t,
});
return null;
}
const root = createRoot(container);
act(() => root.render(<Wrapper />));
return { hookResult: () => hookResult as ReturnType<typeof useApiKeySave>, root, container };
}
describe("useApiKeySave.handleSaveApiKey — full-sync opt-out (#11324)", () => {
let roots: ReturnType<typeof createRoot>[] = [];
let containers: HTMLDivElement[] = [];
beforeEach(() => {
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
roots = [];
containers = [];
});
afterEach(() => {
for (const root of roots) act(() => root.unmount());
for (const container of containers) container.remove();
roots = [];
containers = [];
vi.unstubAllGlobals();
});
it("does not auto-trigger a full /sync-models catalog fetch when the caller asks to add just one manual model", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/providers") return response(true, { connection: { id: "conn-1" } });
if (url.includes("/sync-models")) {
return response(true, {
syncedModels: 1200,
availableModelsCount: 1200,
models: Array.from({ length: 1200 }, (_, i) => ({ id: `model-${i}` })),
});
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const { hookResult, root, container } = renderApiKeySaveHook();
roots.push(root);
containers.push(container);
await act(async () => {
await hookResult().handleSaveApiKey({ apiKey: "sk-test", skipModelSync: true });
});
const syncCalls = fetchMock.mock.calls.filter(([input]) => String(input).includes("/sync-models"));
expect(syncCalls).toHaveLength(0);
// The opt-out is a client-side intent signal only — it must never leak into the
// persisted connection payload sent to the server.
const providersCall = fetchMock.mock.calls.find(([input]) => String(input) === "/api/providers");
const postedBody = JSON.parse((providersCall?.[1] as RequestInit).body as string);
expect(postedBody).not.toHaveProperty("skipModelSync");
});
it("still auto-triggers the full /sync-models catalog fetch by default (legacy behavior preserved)", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = String(input);
if (url === "/api/providers") return response(true, { connection: { id: "conn-1" } });
if (url.includes("/sync-models")) {
return response(true, { syncedModels: 3, availableModelsCount: 3, models: [] });
}
throw new Error(`Unexpected fetch: ${url}`);
});
vi.stubGlobal("fetch", fetchMock);
const { hookResult, root, container } = renderApiKeySaveHook();
roots.push(root);
containers.push(container);
await act(async () => {
await hookResult().handleSaveApiKey({ apiKey: "sk-test" });
});
const syncCalls = fetchMock.mock.calls.filter(([input]) => String(input).includes("/sync-models"));
expect(syncCalls).toHaveLength(1);
});
});

View File

@@ -57,13 +57,19 @@ export function useApiKeySave({
}: UseApiKeySaveParams) {
const handleSaveApiKey = useCallback(
async (formData: Record<string, unknown>) => {
// Issue #11324: callers that only want to add one manual model (rather than
// importing an upstream provider's entire catalog) can pass `skipModelSync: true`
// to opt out of the automatic post-save full /sync-models call. This flag is a
// client-side intent signal only — strip it before it reaches the connection
// creation payload.
const { skipModelSync, ...connectionFormData } = formData;
try {
const res = await fetch("/api/providers", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
provider: resolveApiKeySaveProviderId(providerId),
...formData,
...connectionFormData,
}),
});
if (res.ok) {
@@ -75,7 +81,8 @@ export function useApiKeySave({
// Most providers sync their live catalog after connection creation. Curated-only
// providers intentionally use the registry list and must not show an import flow.
if (newConnection?.id && !providerUsesCuratedModelsOnly(providerId)) {
// Issue #11324: callers may also opt out explicitly via `skipModelSync`.
if (newConnection?.id && !providerUsesCuratedModelsOnly(providerId) && !skipModelSync) {
setShowImportModal(true);
setImportProgress({
current: 0,