diff --git a/changelog.d/fixes/11324-skip-full-sync-on-manual-model-add.md b/changelog.d/fixes/11324-skip-full-sync-on-manual-model-add.md new file mode 100644 index 0000000000..8518e52a93 --- /dev/null +++ b/changelog.d/fixes/11324-skip-full-sync-on-manual-model-add.md @@ -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) diff --git a/src/app/(dashboard)/dashboard/providers/[id]/__tests__/useApiKeySaveSkipsFullSync.test.tsx b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/useApiKeySaveSkipsFullSync.test.tsx new file mode 100644 index 0000000000..c38c9b4ee5 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/[id]/__tests__/useApiKeySaveSkipsFullSync.test.tsx @@ -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[0]["t"]; + +function response(ok: boolean, body: unknown): Response { + return { ok, json: async () => body } as Response; +} + +function renderApiKeySaveHook(): { + hookResult: () => ReturnType; + root: ReturnType; + container: HTMLDivElement; +} { + const container = document.createElement("div"); + document.body.appendChild(container); + let hookResult: ReturnType | 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()); + return { hookResult: () => hookResult as ReturnType, root, container }; +} + +describe("useApiKeySave.handleSaveApiKey — full-sync opt-out (#11324)", () => { + let roots: ReturnType[] = []; + 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); + }); +}); diff --git a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useApiKeySave.ts b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useApiKeySave.ts index 07732aefe6..9ac93a94a9 100644 --- a/src/app/(dashboard)/dashboard/providers/[id]/hooks/useApiKeySave.ts +++ b/src/app/(dashboard)/dashboard/providers/[id]/hooks/useApiKeySave.ts @@ -57,13 +57,19 @@ export function useApiKeySave({ }: UseApiKeySaveParams) { const handleSaveApiKey = useCallback( async (formData: Record) => { + // 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,