From 709944b0b573a504ba69ca44d0e2654ddc8a6e4e Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 25 May 2026 07:26:01 +0100 Subject: [PATCH] fix: keep provider catalog entries on release live path --- .../gateway-models.profiles.live.test.ts | 71 ++++++++++++++++++- .../provider-discovery.runtime.test.ts | 33 +++++---- src/plugins/provider-discovery.runtime.ts | 16 +---- 3 files changed, 87 insertions(+), 33 deletions(-) diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts index c8054f71fbf..c65697c453c 100644 --- a/src/gateway/gateway-models.profiles.live.test.ts +++ b/src/gateway/gateway-models.profiles.live.test.ts @@ -1943,6 +1943,65 @@ function createStaticLiveModelRegistry(models: Array): LiveModelRegistry }; } +function toLiveModelConfig(model: Model): NonNullable[number] { + return { + id: model.id, + name: model.name, + api: model.api as ModelProviderConfig["api"], + baseUrl: model.baseUrl, + input: model.input ?? ["text"], + reasoning: model.reasoning, + cost: model.cost, + contextWindow: model.contextWindow, + maxTokens: model.maxTokens, + ...(model.compat ? { compat: model.compat } : {}), + }; +} + +function mergeLiveProviderConfig(params: { + base: ModelProviderConfig | undefined; + discovered: ModelProviderConfig; +}): ModelProviderConfig { + const baseModels = params.base?.models ?? []; + const discoveredModels = params.discovered.models ?? []; + const mergedModels = new Map[number]>(); + for (const model of discoveredModels) { + if (model.id) { + mergedModels.set(model.id, model); + } + } + for (const model of baseModels) { + if (model.id) { + mergedModels.set(model.id, model); + } + } + return { + ...params.discovered, + ...params.base, + api: params.base?.api ?? params.discovered.api, + baseUrl: params.base?.baseUrl ?? params.discovered.baseUrl, + models: [...mergedModels.values()], + }; +} + +function buildLiveProviderConfigs(candidates: Array): Record { + const providers: Record = {}; + for (const model of candidates) { + const existing = providers[model.provider]; + if (existing) { + existing.models ??= []; + existing.models.push(toLiveModelConfig(model)); + continue; + } + providers[model.provider] = { + api: model.api as ModelProviderConfig["api"], + baseUrl: model.baseUrl, + models: [toLiveModelConfig(model)], + }; + } + return providers; +} + function parseExplicitLiveModelRef( raw: string, providerFilter: Set | null, @@ -2045,8 +2104,16 @@ function buildLiveGatewayConfig(params: { const providerOverrides = params.providerOverrides ?? {}; const lmstudioProvider = params.cfg.models?.providers?.lmstudio; const baseProviders = params.cfg.models?.providers ?? {}; + const candidateProviders = buildLiveProviderConfigs(params.candidates); + const discoveredProviders = Object.fromEntries( + Object.entries(candidateProviders).map(([provider, discovered]) => [ + provider, + mergeLiveProviderConfig({ base: baseProviders[provider], discovered }), + ]), + ); const nextProviders = { ...baseProviders, + ...discoveredProviders, ...(lmstudioProvider ? { lmstudio: { @@ -2954,15 +3021,15 @@ describeLive("gateway live (dev agent, profile keys)", () => { ); const workspaceDir = resolveAgentWorkspaceDir(cfg, DEFAULT_AGENT_ID); logProgress("[all-models] preparing models.json"); - await withGatewayLiveSetupTimeout( + const modelsJsonResult = await withGatewayLiveSetupTimeout( ensureOpenClawModelsJson(cfg, undefined, { workspaceDir, ...(providerList ? { providerDiscoveryProviderIds: providerList } : {}), }), "[all-models] prepare models.json", ); + const agentDir = modelsJsonResult.agentDir; - const agentDir = resolveDefaultAgentDir(cfg); const rawModels = process.env.OPENCLAW_LIVE_GATEWAY_MODELS?.trim(); const useModern = !rawModels || rawModels === "modern" || rawModels === "all"; const useExplicit = Boolean(rawModels) && !useModern; diff --git a/src/plugins/provider-discovery.runtime.test.ts b/src/plugins/provider-discovery.runtime.test.ts index 45e0d7ef53f..55b35affe9b 100644 --- a/src/plugins/provider-discovery.runtime.test.ts +++ b/src/plugins/provider-discovery.runtime.test.ts @@ -157,23 +157,20 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => { ); }); - it("falls back to full provider plugins when discovery entries only expose static catalogs", () => { - const fullProvider = createProvider({ id: "deepseek", mode: "catalog" }); - mocks.loadSource.mockReturnValue(createProvider({ id: "deepseek", mode: "static" })); - mocks.resolvePluginProviders.mockReturnValue([fullProvider]); + it("uses static provider catalog entries without loading the full plugin", () => { + const staticProvider = createProvider({ id: "deepseek", mode: "static" }); + mocks.loadSource.mockReturnValue(staticProvider); - expect(resolvePluginDiscoveryProvidersRuntime({})).toEqual([fullProvider]); - expect(mocks.resolvePluginProviders).toHaveBeenCalledTimes(1); - const params = requireResolvePluginProvidersParams(); - expect(params.onlyPluginIds).toEqual(["deepseek"]); + expect(resolvePluginDiscoveryProvidersRuntime({})).toEqual([ + { ...staticProvider, pluginId: "deepseek" }, + ]); + expect(mocks.resolvePluginProviders).not.toHaveBeenCalled(); }); it("keeps unscoped discovery bounded for mixed live and static-only entries", () => { const codexEntryProvider = createProvider({ id: "codex", mode: "catalog" }); - const fullProviders = [ - createProvider({ id: "deepseek", mode: "catalog" }), - createProvider({ id: "kilocode", mode: "catalog" }), - ]; + const deepseekEntryProvider = createProvider({ id: "deepseek", mode: "static" }); + const fullProviders = [createProvider({ id: "kilocode", mode: "catalog" })]; mocks.resolveDiscoveredProviderPluginIds.mockReturnValue([ "codex", "deepseek", @@ -199,9 +196,7 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => { }, }); mocks.loadSource.mockImplementation((modulePath: string) => - modulePath.includes("/codex/") - ? codexEntryProvider - : createProvider({ id: "deepseek", mode: "static" }), + modulePath.includes("/codex/") ? codexEntryProvider : deepseekEntryProvider, ); mocks.resolvePluginProviders.mockReturnValue(fullProviders); @@ -209,10 +204,14 @@ describe("resolvePluginDiscoveryProvidersRuntime", () => { resolvePluginDiscoveryProvidersRuntime({ env: { KILOCODE_API_KEY: "sk-test" } as NodeJS.ProcessEnv, }), - ).toEqual([{ ...codexEntryProvider, pluginId: "codex" }, ...fullProviders]); + ).toEqual([ + { ...codexEntryProvider, pluginId: "codex" }, + { ...deepseekEntryProvider, pluginId: "deepseek" }, + ...fullProviders, + ]); expect(mocks.resolvePluginProviders).toHaveBeenCalledTimes(1); const params = requireResolvePluginProvidersParams(); - expect(params.onlyPluginIds).toEqual(["deepseek", "kilocode"]); + expect(params.onlyPluginIds).toEqual(["kilocode"]); }); it("falls back to full provider plugins when setup provider env vars are configured", () => { diff --git a/src/plugins/provider-discovery.runtime.ts b/src/plugins/provider-discovery.runtime.ts index 665f52d7a6b..dce74fe8dee 100644 --- a/src/plugins/provider-discovery.runtime.ts +++ b/src/plugins/provider-discovery.runtime.ts @@ -262,24 +262,13 @@ function resolveProviderDiscoveryEntryPlugins(params: { function resolveSelectiveFullPluginIds(params: { entryResult: ProviderDiscoveryEntryResult; - runtimeEntryProviders: ProviderPlugin[]; env: NodeJS.ProcessEnv; }): string[] { - const runtimeEntryProviderIds = new Set( - params.runtimeEntryProviders - .map((provider) => provider.pluginId) - .filter((pluginId): pluginId is string => typeof pluginId === "string" && pluginId !== ""), - ); - const staticOnlyEntryPluginIds = params.entryResult.providers - .filter((provider) => !runtimeEntryProviderIds.has(provider.pluginId ?? "")) - .filter((provider) => !hasLiveProviderDiscoveryHook(provider)) - .map((provider) => provider.pluginId) - .filter((pluginId): pluginId is string => typeof pluginId === "string" && pluginId !== ""); const missingEntryCredentialPluginIds = params.entryResult.pluginRecords .filter((plugin) => !params.entryResult.entryPluginIds.has(plugin.id)) .filter((plugin) => hasProviderAuthEnvCredential(plugin, params.env)) .map((plugin) => plugin.id); - return sortUniqueStrings([...staticOnlyEntryPluginIds, ...missingEntryCredentialPluginIds]); + return sortUniqueStrings(missingEntryCredentialPluginIds); } function resolveMissingEntryPluginIds(entryResult: ProviderDiscoveryEntryResult): string[] { @@ -295,7 +284,7 @@ function resolveRuntimeEntryProviders(entryResult: ProviderDiscoveryEntryResult) } return Boolean( provider.pluginId && - entryResult.manifestEntryPluginIds.has(provider.pluginId) && + entryResult.entryPluginIds.has(provider.pluginId) && typeof provider.staticCatalog?.run === "function", ); }); @@ -324,7 +313,6 @@ export function resolvePluginDiscoveryProvidersRuntime(params: { if (params.onlyPluginIds === undefined && runtimeEntryProviders.length > 0) { const fullPluginIds = resolveSelectiveFullPluginIds({ entryResult, - runtimeEntryProviders, env, }); const fullProviders =