From 43ff420492d1a367f935b261cf04ebad6c8ff6d7 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 27 Jul 2026 09:14:23 -0400 Subject: [PATCH] fix(providers): preserve local model discovery and retry ownership (#114582) --- extensions/lmstudio/src/stream.test.ts | 57 ++++ extensions/lmstudio/src/stream.ts | 1 - extensions/ollama/index.test.ts | 290 ++++++++++++++++++ extensions/ollama/index.ts | 124 +++++++- .../ollama/src/discovery-shared.test.ts | 160 +++++++++- extensions/ollama/src/discovery-shared.ts | 26 +- extensions/ollama/src/provider-models.ts | 34 +- .../src/model-catalog-refs.test.ts | 15 + .../src/model-catalog-refs.ts | 30 ++ .../run/llm-idle-timeout.test.ts | 18 ++ .../run/llm-idle-timeout.ts | 6 +- .../local/auth-choice.plugin-providers.ts | 34 +- src/config/local-model-lean-auto.test.ts | 23 ++ src/config/local-model-lean-auto.ts | 30 +- src/plugin-sdk/provider-model-shared.ts | 1 + 15 files changed, 751 insertions(+), 98 deletions(-) diff --git a/extensions/lmstudio/src/stream.test.ts b/extensions/lmstudio/src/stream.test.ts index 2d6ab678fc56..3776383deb5d 100644 --- a/extensions/lmstudio/src/stream.test.ts +++ b/extensions/lmstudio/src/stream.test.ts @@ -541,6 +541,31 @@ describe("lmstudio stream wrapper", () => { expect(baseStream).toHaveBeenCalledTimes(2); }); + it("preserves all 29 agent tools while preload failure backoff remains active", async () => { + ensureLmstudioModelLoadedMock.mockRejectedValueOnce(new Error("out of memory")); + const baseStream = buildDoneStreamFn(); + const wrapped = createWrappedLmstudioStream(baseStream); + const tools = Array.from({ length: 29 }, (_, index) => ({ + name: `agent_tool_${index}`, + description: `Agent tool ${index}`, + parameters: { type: "object" }, + })); + + for (let attempt = 0; attempt < 2; attempt += 1) { + const events = await collectEvents( + runWrappedLmstudioStream(wrapped, {}, undefined, { tools }), + ); + + expectSingleDoneEvent(events); + const call = (baseStream as unknown as { mock: { calls: unknown[][] } }).mock.calls[attempt]; + expect(call).toBeDefined(); + expect(requireRecord(call?.[1], "base stream context").tools).toEqual(tools); + } + + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(1); + expect(baseStream).toHaveBeenCalledTimes(2); + }); + it("retries preload once the cooldown expires", async () => { ensureLmstudioModelLoadedMock.mockRejectedValueOnce(new Error("out of memory")); ensureLmstudioModelLoadedMock.mockResolvedValueOnce(undefined); @@ -597,6 +622,38 @@ describe("lmstudio stream wrapper", () => { nowSpy.mockRestore(); }); + it("keeps increasing preload backoff across expired consecutive failures", async () => { + ensureLmstudioModelLoadedMock.mockRejectedValue(new Error("out of memory")); + const baseStream = buildDoneStreamFn(); + const wrapped = createWrappedLmstudioStream(baseStream); + const baseTime = 1_000_000; + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(baseTime); + + await collectEvents(runWrappedLmstudioStream(wrapped, {})); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(1); + + nowSpy.mockReturnValue(baseTime + 5_001); + await collectEvents(runWrappedLmstudioStream(wrapped, {})); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(2); + + nowSpy.mockReturnValue(baseTime + 10_001); + await collectEvents(runWrappedLmstudioStream(wrapped, {})); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(2); + + nowSpy.mockReturnValue(baseTime + 15_002); + await collectEvents(runWrappedLmstudioStream(wrapped, {})); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(3); + + nowSpy.mockReturnValue(baseTime + 30_002); + await collectEvents(runWrappedLmstudioStream(wrapped, {})); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(3); + + nowSpy.mockReturnValue(baseTime + 35_003); + await collectEvents(runWrappedLmstudioStream(wrapped, {})); + expect(ensureLmstudioModelLoadedMock).toHaveBeenCalledTimes(4); + expect(baseStream).toHaveBeenCalledTimes(6); + }); + it("forces supportsUsageInStreaming compat before calling the underlying stream", async () => { const baseStream = buildDoneStreamFn(); const wrapped = wrapLmstudioInferencePreload({ diff --git a/extensions/lmstudio/src/stream.ts b/extensions/lmstudio/src/stream.ts index 28c7fc791128..6d08347edee1 100644 --- a/extensions/lmstudio/src/stream.ts +++ b/extensions/lmstudio/src/stream.ts @@ -78,7 +78,6 @@ function isPreloadCoolingDown(preloadKey: string, now: number): PreloadCooldownE return undefined; } if (entry.untilMs <= now) { - preloadCooldown.delete(preloadKey); return undefined; } return entry; diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index 1f95d74ad145..3b1fb3dbf7c9 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -33,6 +33,7 @@ const configureOllamaNonInteractiveMock = vi.hoisted(() => vi.fn()); const fetchOllamaModelsMock = vi.hoisted(() => vi.fn()); const buildOllamaProviderMock = vi.hoisted(() => vi.fn()); const queryOllamaModelShowInfoMock = vi.hoisted(() => vi.fn()); +const resolveConfiguredSecretInputStringMock = vi.hoisted(() => vi.fn()); const buildOllamaModelDefinitionMock = vi.hoisted(() => vi.fn((modelId: string, contextWindow?: number, capabilities?: string[]) => { const normalized = modelId.trim().toLowerCase(); @@ -68,6 +69,16 @@ vi.mock("./api.js", () => ({ buildOllamaModelDefinition: buildOllamaModelDefinitionMock, })); +vi.mock("openclaw/plugin-sdk/secret-input-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveConfiguredSecretInputString: resolveConfiguredSecretInputStringMock.mockImplementation( + actual.resolveConfiguredSecretInputString, + ), + }; +}); + vi.mock("./src/setup.js", async (importOriginal) => ({ ...(await importOriginal()), checkOllamaCloudAuth: checkOllamaCloudAuthMock, @@ -91,6 +102,7 @@ beforeEach(() => { fetchOllamaModelsMock.mockReset(); buildOllamaProviderMock.mockReset(); queryOllamaModelShowInfoMock.mockReset(); + resolveConfiguredSecretInputStringMock.mockClear(); queryOllamaModelShowInfoMock.mockResolvedValue({ contextWindow: 32_768, capabilities: ["completion", "tools"], @@ -1042,6 +1054,7 @@ describe("ollama plugin", () => { expect(resolved?.baseUrl).toBe("https://ollama.example.com/v1"); expect(buildOllamaProviderMock).toHaveBeenCalledWith("https://ollama.example.com/v1", { quiet: true, + apiKey: "ollama-live", }); } finally { if (previous === undefined) { @@ -1052,6 +1065,283 @@ describe("ollama plugin", () => { } }); + it("authenticates configured dynamic Ollama discovery and model probes", async () => { + const provider = registerProvider(); + const baseUrl = "https://dynamic-ollama.example.com"; + const config = { + models: { + providers: { + ollama: { + baseUrl, + api: "ollama" as const, + apiKey: "dynamic-discovery-access", + models: [], + }, + }, + }, + }; + buildOllamaProviderMock.mockResolvedValueOnce({ baseUrl, api: "ollama", models: [] }); + + await provider.prepareDynamicModel?.({ + config, + provider: "ollama", + modelId: "private-dynamic-model", + modelRegistry: { find: vi.fn(() => null) }, + } as never); + + expect(buildOllamaProviderMock).toHaveBeenCalledWith(baseUrl, { + quiet: true, + apiKey: "dynamic-discovery-access", + }); + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith(baseUrl, "private-dynamic-model", { + apiKey: "dynamic-discovery-access", + }); + expect( + provider.resolveDynamicModel?.({ + config, + provider: "ollama", + modelId: "private-dynamic-model", + modelRegistry: { find: vi.fn(() => null) }, + } as never)?.id, + ).toBe("private-dynamic-model"); + }); + + it("scopes dynamic Ollama model caches to the effective credential", async () => { + const provider = registerProvider(); + const baseUrl = "https://shared-dynamic-ollama.example.com"; + const modelId = "tenant-dynamic-model"; + const configFor = (apiKey: string) => ({ + models: { + providers: { + ollama: { baseUrl, api: "ollama" as const, apiKey, models: [] }, + }, + }, + }); + const discoveredFor = (name: string) => ({ + baseUrl, + api: "ollama", + models: [{ id: modelId, name, contextWindow: 8192, maxTokens: 2048 }], + }); + buildOllamaProviderMock + .mockResolvedValueOnce(discoveredFor("First tenant model")) + .mockResolvedValueOnce(discoveredFor("Second tenant model")); + + for (const config of [configFor("first-tenant-access"), configFor("second-tenant-access")]) { + await provider.prepareDynamicModel?.({ + config, + provider: "ollama", + modelId, + modelRegistry: { find: vi.fn(() => null) }, + } as never); + } + + const resolveFor = (apiKey: string) => + provider.resolveDynamicModel?.({ + config: configFor(apiKey), + provider: "ollama", + modelId, + modelRegistry: { find: vi.fn(() => null) }, + } as never); + + expect(resolveFor("first-tenant-access")?.name).toBe("First tenant model"); + expect(resolveFor("second-tenant-access")?.name).toBe("Second tenant model"); + expect(resolveFor("unprepared-tenant-access")).toBeUndefined(); + expect(buildOllamaProviderMock).toHaveBeenNthCalledWith(1, baseUrl, { + quiet: true, + apiKey: "first-tenant-access", + }); + expect(buildOllamaProviderMock).toHaveBeenNthCalledWith(2, baseUrl, { + quiet: true, + apiKey: "second-tenant-access", + }); + }); + + it.each(["secretref-dynamic-access", "OLLAMA_API_KEY", OLLAMA_DEFAULT_API_KEY])( + "preserves opaque environment-backed SecretRef value %s for dynamic discovery", + async (secretValue) => { + const provider = registerProvider(); + const baseUrl = "https://secretref-dynamic-ollama.example.com"; + const envId = "VITEST_OLLAMA_DYNAMIC_DISCOVERY_KEY"; + const previous = process.env[envId]; + process.env[envId] = secretValue; + const config = { + models: { + providers: { + ollama: { + baseUrl, + api: "ollama" as const, + apiKey: { source: "env" as const, provider: "default", id: envId }, + models: [], + }, + }, + }, + }; + buildOllamaProviderMock.mockResolvedValueOnce({ baseUrl, api: "ollama", models: [] }); + + try { + await provider.prepareDynamicModel?.({ + config, + provider: "ollama", + modelId: "secretref-dynamic-model", + modelRegistry: { find: vi.fn(() => null) }, + } as never); + + expect(buildOllamaProviderMock).toHaveBeenCalledWith(baseUrl, { + quiet: true, + apiKey: secretValue, + }); + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( + baseUrl, + "secretref-dynamic-model", + { apiKey: secretValue }, + ); + } finally { + if (previous === undefined) { + delete process.env[envId]; + } else { + process.env[envId] = previous; + } + } + }, + ); + + it("fails closed when a dynamic Ollama SecretRef cannot be resolved", async () => { + const provider = registerProvider(); + const envId = "VITEST_OLLAMA_DYNAMIC_MISSING_KEY"; + const previous = process.env[envId]; + delete process.env[envId]; + + try { + await provider.prepareDynamicModel?.({ + config: { + models: { + providers: { + ollama: { + baseUrl: "https://missing-secretref-ollama.example.com", + api: "ollama", + apiKey: { source: "env", provider: "default", id: envId }, + models: [], + }, + }, + }, + }, + provider: "ollama", + modelId: "unreachable-private-model", + modelRegistry: { find: vi.fn(() => null) }, + } as never); + + expect(buildOllamaProviderMock).not.toHaveBeenCalled(); + expect(queryOllamaModelShowInfoMock).not.toHaveBeenCalled(); + } finally { + if (previous !== undefined) { + process.env[envId] = previous; + } + } + }); + + it("invalidates managed dynamic model caches when their SecretRef stops resolving", async () => { + const provider = registerProvider(); + const baseUrl = "https://managed-dynamic-ollama.example.com"; + const modelId = "managed-private-model"; + const config = { + models: { + providers: { + ollama: { + baseUrl, + api: "ollama" as const, + apiKey: { source: "file" as const, provider: "default", id: "/ollama/apiKey" }, + models: [], + }, + }, + }, + }; + resolveConfiguredSecretInputStringMock + .mockResolvedValueOnce({ value: "managed-dynamic-access" }) + .mockResolvedValueOnce({ unresolvedRefReason: "managed credential is unavailable" }); + buildOllamaProviderMock.mockResolvedValueOnce({ + baseUrl, + api: "ollama", + models: [{ id: modelId, name: "Managed private model", contextWindow: 8192 }], + }); + const context = { + config, + provider: "ollama", + modelId, + modelRegistry: { find: vi.fn(() => null) }, + }; + + await provider.prepareDynamicModel?.(context as never); + expect(provider.resolveDynamicModel?.(context as never)?.id).toBe(modelId); + + await provider.prepareDynamicModel?.(context as never); + + expect(provider.resolveDynamicModel?.(context as never)).toBeUndefined(); + expect(buildOllamaProviderMock).toHaveBeenCalledOnce(); + }); + + it("isolates identically named managed SecretRefs by their resolved configuration", async () => { + const provider = registerProvider(); + const baseUrl = "https://shared-managed-ollama.example.com"; + const modelId = "managed-tenant-model"; + const configFor = (tenant: string) => ({ + secrets: { + providers: { + default: { source: "file" as const, path: `/run/secrets/${tenant}.json` }, + }, + }, + models: { + providers: { + ollama: { + baseUrl, + api: "ollama" as const, + apiKey: { source: "file" as const, provider: "default", id: "/ollama/apiKey" }, + models: [], + }, + }, + }, + }); + const firstConfig = configFor("first-tenant"); + const secondConfig = configFor("second-tenant"); + resolveConfiguredSecretInputStringMock + .mockResolvedValueOnce({ value: "first-managed-tenant-access" }) + .mockResolvedValueOnce({ value: "second-managed-tenant-access" }); + buildOllamaProviderMock + .mockResolvedValueOnce({ + baseUrl, + api: "ollama", + models: [{ id: modelId, name: "First managed tenant model", contextWindow: 8192 }], + }) + .mockResolvedValueOnce({ + baseUrl, + api: "ollama", + models: [{ id: modelId, name: "Second managed tenant model", contextWindow: 8192 }], + }); + const contextFor = (config: typeof firstConfig) => ({ + config, + provider: "ollama", + modelId, + modelRegistry: { find: vi.fn(() => null) }, + }); + + await provider.prepareDynamicModel?.(contextFor(firstConfig) as never); + await provider.prepareDynamicModel?.(contextFor(secondConfig) as never); + + expect(provider.resolveDynamicModel?.(contextFor(firstConfig) as never)?.name).toBe( + "First managed tenant model", + ); + expect(provider.resolveDynamicModel?.(contextFor(secondConfig) as never)?.name).toBe( + "Second managed tenant model", + ); + expect(buildOllamaProviderMock).toHaveBeenNthCalledWith(1, baseUrl, { + quiet: true, + apiKey: "first-managed-tenant-access", + }); + expect(buildOllamaProviderMock).toHaveBeenNthCalledWith(2, baseUrl, { + quiet: true, + apiKey: "second-managed-tenant-access", + }); + }); + it("resolves requested Ollama cloud models that are omitted from tags but confirmed by show", async () => { const provider = registerProvider(); const previous = process.env.OLLAMA_API_KEY; diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index 939a5346b16d..0dad3c6a7d2f 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -1,4 +1,5 @@ // Ollama plugin entrypoint registers its OpenClaw integration. +import { createHash } from "node:crypto"; import { collectConfiguredModelRefValues } from "@openclaw/model-catalog-core/configured-model-refs"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime"; @@ -108,6 +109,7 @@ function classifyOllamaFailoverReason(errorMessage: string): "server_error" | un } const dynamicModelCache = new Map(); +const dynamicManagedCredentialFingerprints = new WeakMap>(); const OLLAMA_CLOUD_DEFAULT_MODEL_REF = `${OLLAMA_CLOUD_PROVIDER_ID}/${OLLAMA_CLOUD_DEFAULT_MODELS[0].id}`; const OLLAMA_CONFIGURED_SHOW_CONCURRENCY = 4; const OLLAMA_CONFIGURED_SHOW_MAX_MODELS = 8; @@ -290,8 +292,42 @@ async function discoverAppGuidedOllamaModel(ctx: ProviderAppGuidedSetupContext) }; } -function buildDynamicCacheKey(provider: string, baseUrl: string | undefined): string { - return `${provider}\0${baseUrl ?? ""}`; +function buildDynamicManagedSecretScope( + provider: string, + baseUrl: string | undefined, + configuredApiKey: unknown, +): string | undefined { + const secretRef = coerceSecretRef(configuredApiKey); + if (!secretRef || secretRef.source === "env") { + return undefined; + } + return `${provider}\0${resolveOllamaApiBase(baseUrl)}\0${secretRef.source}\0${secretRef.provider}\0${secretRef.id}`; +} + +function buildDynamicCacheKey( + provider: string, + baseUrl: string | undefined, + configuredApiKey: unknown, + config?: OpenClawConfig, +): string { + const secretRef = coerceSecretRef(configuredApiKey); + const managedSecretScope = buildDynamicManagedSecretScope(provider, baseUrl, configuredApiKey); + const apiKey = readUsableOllamaShowApiKey({ + env: process.env, + allowAmbientEnvFallback: !isLocalOllamaBaseUrl(baseUrl), + explicitApiKey: configuredApiKey, + }); + // Managed secrets resolve asynchronously; retain their resolved fingerprint + // per config so synchronous lookups cannot cross secret-provider ownership. + const managedCredentialFingerprint = + managedSecretScope && config + ? dynamicManagedCredentialFingerprints.get(config)?.get(managedSecretScope) + : undefined; + const credentialScope = + apiKey ?? (secretRef ? `${secretRef.source}\0${secretRef.provider}\0${secretRef.id}` : ""); + const credentialFingerprint = + managedCredentialFingerprint ?? createHash("sha256").update(credentialScope).digest("hex"); + return `${provider}\0${resolveOllamaApiBase(baseUrl)}\0${credentialFingerprint}`; } function hasOllamaDiscoverySignal(providerConfig: ModelProviderConfig | undefined): boolean { @@ -1000,7 +1036,77 @@ export default definePluginEntry({ return; } const baseUrl = readProviderBaseUrl(providerConfig); - const provider = await buildLocalOllamaProvider(baseUrl, { quiet: true }); + const managedSecretScope = buildDynamicManagedSecretScope( + ctx.provider, + baseUrl, + providerConfig?.apiKey, + ); + let dynamicCacheKey = buildDynamicCacheKey( + ctx.provider, + baseUrl, + providerConfig?.apiKey, + ctx.config, + ); + let discoveryApiKey: string | undefined; + if (providerConfig?.apiKey !== undefined && providerConfig.apiKey !== null) { + const resolved = await resolveConfiguredSecretInputString({ + config: ctx.config ?? {}, + env: process.env, + value: providerConfig.apiKey, + path: `models.providers.${ctx.provider}.apiKey`, + unresolvedReasonStyle: "detailed", + }); + if (resolved.unresolvedRefReason) { + dynamicModelCache.delete(dynamicCacheKey); + if (managedSecretScope && ctx.config) { + dynamicManagedCredentialFingerprints.get(ctx.config)?.delete(managedSecretScope); + } + return; + } + const resolvedApiKey = readConfiguredOllamaApiKey(resolved.value); + const configuredSecretRef = coerceSecretRef(providerConfig.apiKey); + discoveryApiKey = configuredSecretRef + ? resolvedApiKey + : resolvedApiKey === "OLLAMA_API_KEY" + ? readConcreteOllamaApiKey(process.env.OLLAMA_API_KEY) + : readConcreteOllamaApiKey(resolvedApiKey); + if (configuredSecretRef && !discoveryApiKey) { + dynamicModelCache.delete(dynamicCacheKey); + if (managedSecretScope && ctx.config) { + dynamicManagedCredentialFingerprints.get(ctx.config)?.delete(managedSecretScope); + } + return; + } + } else if (!isLocalOllamaBaseUrl(baseUrl)) { + discoveryApiKey = readConcreteOllamaApiKey(process.env.OLLAMA_API_KEY); + } + if (managedSecretScope && ctx.config && discoveryApiKey) { + let fingerprints = dynamicManagedCredentialFingerprints.get(ctx.config); + if (!fingerprints) { + fingerprints = new Map(); + dynamicManagedCredentialFingerprints.set(ctx.config, fingerprints); + } + const resolvedCredentialFingerprint = createHash("sha256") + .update(discoveryApiKey) + .digest("hex"); + if ( + fingerprints.has(managedSecretScope) && + fingerprints.get(managedSecretScope) !== resolvedCredentialFingerprint + ) { + dynamicModelCache.delete(dynamicCacheKey); + } + fingerprints.set(managedSecretScope, resolvedCredentialFingerprint); + dynamicCacheKey = buildDynamicCacheKey( + ctx.provider, + baseUrl, + providerConfig?.apiKey, + ctx.config, + ); + } + const provider = await buildLocalOllamaProvider(baseUrl, { + quiet: true, + ...(discoveryApiKey ? { apiKey: discoveryApiKey } : {}), + }); const dynamicApi = providerConfig?.api ?? provider.api; const dynamicProvider = { ...provider, @@ -1023,13 +1129,14 @@ export default definePluginEntry({ provider: ctx.provider, providerConfig: dynamicProvider, modelId: ctx.modelId, + showApiKey: discoveryApiKey, capContextTokens: true, }); if (requestedModel) { dynamicModels.push(requestedModel); } } - dynamicModelCache.set(buildDynamicCacheKey(ctx.provider, baseUrl), dynamicModels); + dynamicModelCache.set(dynamicCacheKey, dynamicModels); }, resolveDynamicModel: (ctx) => { const providerConfig = resolveConfiguredOllamaProviderConfig({ @@ -1037,7 +1144,14 @@ export default definePluginEntry({ providerId: ctx.provider, }); return dynamicModelCache - .get(buildDynamicCacheKey(ctx.provider, readProviderBaseUrl(providerConfig))) + .get( + buildDynamicCacheKey( + ctx.provider, + readProviderBaseUrl(providerConfig), + providerConfig?.apiKey, + ctx.config, + ), + ) ?.find((model) => model.id === ctx.modelId); }, buildUnknownModelHint: () => diff --git a/extensions/ollama/src/discovery-shared.test.ts b/extensions/ollama/src/discovery-shared.test.ts index 3ef8d455571f..fb95cfed2960 100644 --- a/extensions/ollama/src/discovery-shared.test.ts +++ b/extensions/ollama/src/discovery-shared.test.ts @@ -1,7 +1,7 @@ // Ollama tests cover discovery shared plugin behavior. import { expectDefined } from "@openclaw/normalization-core"; import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { isLocalOllamaBaseUrl, resolveOllamaDiscoveryResult, @@ -420,6 +420,164 @@ describe("resolveOllamaDiscoveryResult — hosted Ollama Cloud guard", () => { // Local base URL should still reach the discovery path expect(result).not.toBeNull(); }); + + it.each([ + { + name: "a remote endpoint", + baseUrl: "https://ollama-secure.example/v1", + discoveredBaseUrl: "https://ollama-secure.example", + }, + { + name: "a loopback endpoint", + baseUrl: "http://127.0.0.1:11434", + discoveredBaseUrl: "http://127.0.0.1:11434", + }, + { + name: "a private-network endpoint", + baseUrl: "http://192.168.10.8:11434", + discoveredBaseUrl: "http://192.168.10.8:11434", + }, + ])( + "authenticates live discovery at $name with its resolved SecretRef", + async ({ baseUrl, discoveredBaseUrl }) => { + const buildProvider = vi.fn( + async ( + _configuredBaseUrl?: string, + _opts?: { apiKey?: string; quiet?: boolean }, + ): Promise => ({ + baseUrl: discoveredBaseUrl, + api: "ollama", + models: [discoveredModel], + }), + ); + + const result = await resolveOllamaDiscoveryResult({ + ctx: { + config: { + models: { + providers: { + ollama: { + baseUrl, + api: "ollama", + apiKey: { source: "env", provider: "default", id: "OLLAMA_DISCOVERY_TOKEN" }, + }, + }, + }, + }, + env: {}, + resolveProviderApiKey: () => ({ + apiKey: "OLLAMA_DISCOVERY_TOKEN", + discoveryApiKey: "resolved-ollama-discovery-token", + }), + }, + pluginConfig: {}, + buildProvider, + }); + + expect(buildProvider).toHaveBeenCalledWith(baseUrl, { + quiet: false, + apiKey: "resolved-ollama-discovery-token", + }); + expect(result?.provider.apiKey).toBe("resolved-ollama-discovery-token"); + expect(result?.provider.models).toEqual([discoveredModel]); + }, + ); + + it.each(["OLLAMA_API_KEY", "ollama-local"])( + "preserves resolved opaque SecretRef credential %s during live discovery", + async (secretValue) => { + const baseUrl = `https://opaque-secretref-${secretValue.toLowerCase().replaceAll("_", "-")}.example`; + const buildProvider = vi.fn( + async ( + _configuredBaseUrl?: string, + _opts?: { apiKey?: string; quiet?: boolean }, + ): Promise => ({ + baseUrl, + api: "ollama", + models: [discoveredModel], + }), + ); + + const result = await resolveOllamaDiscoveryResult({ + ctx: { + config: { + models: { + providers: { + ollama: { + baseUrl, + api: "ollama", + apiKey: { source: "file", provider: "default", id: "/ollama/apiKey" }, + }, + }, + }, + }, + env: { OLLAMA_API_KEY: "different-ambient-ollama-credential" }, + resolveProviderApiKey: () => ({ + apiKey: "secretref-managed", + discoveryApiKey: secretValue, + }), + }, + pluginConfig: {}, + buildProvider, + }); + + expect(buildProvider).toHaveBeenCalledWith(baseUrl, { + quiet: false, + apiKey: secretValue, + }); + expect(result?.provider.apiKey).toBe(secretValue); + expect(result?.provider.models).toEqual([discoveredModel]); + }, + ); + + it("isolates discovered catalogs by their effective authentication credential", async () => { + const buildProvider = vi.fn( + async ( + _configuredBaseUrl?: string, + opts?: { apiKey?: string; quiet?: boolean }, + ): Promise => ({ + baseUrl: "https://ollama-cache-scope.example", + api: "ollama", + models: [ + { + ...discoveredModel, + id: `model-for-${opts?.apiKey}`, + name: `model-for-${opts?.apiKey}`, + }, + ], + }), + ); + + const discoverWithCredential = async (apiKey: string) => + await resolveOllamaDiscoveryResult({ + ctx: { + config: { + models: { + providers: { + ollama: { + baseUrl: "https://ollama-cache-scope.example/v1", + api: "ollama", + apiKey, + }, + }, + }, + }, + env: {}, + resolveProviderApiKey: () => ({ apiKey }), + }, + pluginConfig: {}, + buildProvider, + }); + + const first = await discoverWithCredential("ollama-cache-token-a"); + const second = await discoverWithCredential("ollama-cache-token-b"); + const cachedFirst = await discoverWithCredential("ollama-cache-token-a"); + + expect(buildProvider).toHaveBeenCalledTimes(2); + expect(first?.provider.models[0]?.id).toBe("model-for-ollama-cache-token-a"); + expect(second?.provider.models[0]?.id).toBe("model-for-ollama-cache-token-b"); + expect(cachedFirst?.provider.models[0]?.id).toBe("model-for-ollama-cache-token-a"); + }); }); describe("shouldUseSyntheticOllamaAuth", () => { diff --git a/extensions/ollama/src/discovery-shared.ts b/extensions/ollama/src/discovery-shared.ts index 8f68afeed078..1cc0da453d9b 100644 --- a/extensions/ollama/src/discovery-shared.ts +++ b/extensions/ollama/src/discovery-shared.ts @@ -69,6 +69,7 @@ function resolveOllamaDiscoveryApiKey(params: { env: NodeJS.ProcessEnv; baseUrl?: string; explicitApiKey?: string; + explicitApiKeyIsResolvedSecret?: boolean; resolvedApiKey?: unknown; resolvedDiscoveryApiKey?: unknown; }): string | undefined { @@ -76,7 +77,10 @@ function resolveOllamaDiscoveryApiKey(params: { const resolvedApiKey = normalizeOptionalString(params.resolvedApiKey); const resolvedDiscoveryApiKey = normalizeOptionalString(params.resolvedDiscoveryApiKey); const explicitApiKey = normalizeOptionalString(params.explicitApiKey); - if (explicitApiKey && !isOllamaApiKeyMarker(explicitApiKey)) { + if ( + explicitApiKey && + (params.explicitApiKeyIsResolvedSecret || !isOllamaApiKeyMarker(explicitApiKey)) + ) { return explicitApiKey; } if (!isLocalOllamaBaseUrl(params.baseUrl)) { @@ -273,7 +277,7 @@ export async function resolveOllamaDiscoveryResult(params: { pluginConfig: OllamaPluginConfig; buildProvider: ( configuredBaseUrl?: string, - opts?: { quiet?: boolean }, + opts?: { apiKey?: string; quiet?: boolean }, ) => Promise; }): Promise<{ provider: ModelProviderConfig } | null> { const explicit = params.ctx.config.models?.providers?.ollama; @@ -318,6 +322,7 @@ export async function resolveOllamaDiscoveryResult(params: { env: params.ctx.env, baseUrl: discoveredBaseUrl, explicitApiKey, + explicitApiKeyIsResolvedSecret: Boolean(explicitApiKeyRef), resolvedApiKey: ollamaKey, resolvedDiscoveryApiKey: ollamaDiscoveryKey, }); @@ -346,17 +351,30 @@ export async function resolveOllamaDiscoveryResult(params: { } const quiet = !hasRealOllamaKey && !hasMeaningfulExplicitConfig; + const resolvedDiscoveryApiKey = resolveOllamaDiscoveryApiKey({ + env: params.ctx.env, + baseUrl: configuredBaseUrl, + explicitApiKey, + explicitApiKeyIsResolvedSecret: Boolean(explicitApiKeyRef), + resolvedApiKey: ollamaKey, + resolvedDiscoveryApiKey: ollamaDiscoveryKey, + }); + const discoveryApiKey = + resolvedDiscoveryApiKey === OLLAMA_DEFAULT_API_KEY && !explicitApiKeyRef + ? undefined + : resolvedDiscoveryApiKey; const provider = await getCachedLiveCatalogValue({ keyParts: [ OLLAMA_PROVIDER_ID, "models", - configuredBaseUrl ?? OLLAMA_DEFAULT_BASE_URL, - ollamaKey, + resolveOllamaApiBase(configuredBaseUrl), + discoveryApiKey, quiet, ], load: async () => await params.buildProvider(configuredBaseUrl, { quiet, + ...(discoveryApiKey ? { apiKey: discoveryApiKey } : {}), }), }); if (provider.models?.length === 0 && !ollamaKey && !explicit?.apiKey) { diff --git a/extensions/ollama/src/provider-models.ts b/extensions/ollama/src/provider-models.ts index 271aae4d4d21..8a4099bfe4dc 100644 --- a/extensions/ollama/src/provider-models.ts +++ b/extensions/ollama/src/provider-models.ts @@ -1,7 +1,10 @@ // Ollama provider module implements model/runtime integration. import { createHash } from "node:crypto"; import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http"; -import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared"; +import { + isCloudModelRef, + type ModelProviderConfig, +} from "openclaw/plugin-sdk/provider-model-shared"; import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-onboard"; import { fetchWithSsrFGuard, type LookupFn } from "openclaw/plugin-sdk/ssrf-runtime"; import { @@ -255,35 +258,8 @@ export async function enrichOllamaModelsWithContext( return enriched; } -type OllamaModelSource = "cloud" | "local"; - -function parseOllamaModelSourceSuffix( - modelName: string, -): { base: string; source: OllamaModelSource } | undefined { - const sourceSeparator = modelName.lastIndexOf(":"); - if (sourceSeparator < 0) { - return undefined; - } - const source = modelName.slice(sourceSeparator + 1); - if (source === "cloud" || source === "local") { - return { base: modelName.slice(0, sourceSeparator), source }; - } - if (!source.includes("/") && source.endsWith("-cloud")) { - return { - base: modelName.slice(0, sourceSeparator + 1) + source.slice(0, -"-cloud".length), - source: "cloud", - }; - } - return undefined; -} - export function isOllamaCloudModel(modelName: string | undefined): boolean { - const normalized = modelName?.trim().toLowerCase(); - if (!normalized) { - return false; - } - const parsed = parseOllamaModelSourceSuffix(normalized); - return parsed?.source === "cloud" && parseOllamaModelSourceSuffix(parsed.base) === undefined; + return isCloudModelRef(modelName); } export function isReasoningModelHeuristic(modelId: string): boolean { diff --git a/packages/model-catalog-core/src/model-catalog-refs.test.ts b/packages/model-catalog-core/src/model-catalog-refs.test.ts index 293abc789c8b..eca86fe47110 100644 --- a/packages/model-catalog-core/src/model-catalog-refs.test.ts +++ b/packages/model-catalog-core/src/model-catalog-refs.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest"; import { buildModelCatalogMergeKey, buildModelCatalogRef, + isCloudModelRef, parseModelCatalogRef, parseProviderModelRef, } from "./model-catalog-refs.js"; @@ -33,4 +34,18 @@ describe("model catalog refs", () => { expect(parseModelCatalogRef(value)).toBeNull(); }, ); + + it.each([ + ["glm-5.2:cloud", true], + ["ollama/gpt-oss:120b-cloud", true], + [" OLLAMA/KIMI-K2.5:CLOUD ", true], + ["local-cloud", false], + ["invalid:cloud-cloud", false], + ["invalid:local:cloud", false], + ["invalid:local-cloud", false], + ["invalid:cloud:local", false], + [undefined, false], + ])("classifies hosted model source %j", (modelRef, expected) => { + expect(isCloudModelRef(modelRef)).toBe(expected); + }); }); diff --git a/packages/model-catalog-core/src/model-catalog-refs.ts b/packages/model-catalog-core/src/model-catalog-refs.ts index 92f65015ae78..4f8b22bb2be2 100644 --- a/packages/model-catalog-core/src/model-catalog-refs.ts +++ b/packages/model-catalog-core/src/model-catalog-refs.ts @@ -13,6 +13,36 @@ export type ProviderModelRef = { model: string; }; +type ModelSourceSuffix = { + base: string; + source: "cloud" | "local"; +}; + +function parseModelSourceSuffix(modelRef: string): ModelSourceSuffix | undefined { + const sourceSeparator = modelRef.lastIndexOf(":"); + if (sourceSeparator < 0) { + return undefined; + } + const source = modelRef.slice(sourceSeparator + 1); + if (source === "cloud" || source === "local") { + return { base: modelRef.slice(0, sourceSeparator), source }; + } + if (!source.includes("/") && source.endsWith("-cloud")) { + return { base: modelRef.slice(0, -"-cloud".length), source: "cloud" }; + } + return undefined; +} + +/** Recognizes one unambiguous hosted source suffix on a bare or qualified model ref. */ +export function isCloudModelRef(modelRef: string | undefined): boolean { + const normalized = modelRef?.trim().toLowerCase(); + if (!normalized) { + return false; + } + const source = parseModelSourceSuffix(normalized); + return source?.source === "cloud" && parseModelSourceSuffix(source.base) === undefined; +} + /** Normalize provider ids for catalog refs. */ export function normalizeModelCatalogProviderId(provider: string): string { return normalizeLowercaseStringOrEmpty(provider); diff --git a/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts b/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts index 8c61aa25b467..5af0fe5226e1 100644 --- a/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts +++ b/src/agents/embedded-agent-runner/run/llm-idle-timeout.test.ts @@ -421,6 +421,15 @@ describe("resolveLlmIdleTimeoutMs", () => { }, }), ).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS); + expect( + resolveLlmIdleTimeoutMs({ + model: { + provider: "ollama", + id: "ollama/gpt-oss:120b-cloud", + baseUrl: "http://127.0.0.1:11434", + }, + }), + ).toBe(DEFAULT_LLM_IDLE_TIMEOUT_MS); }); it.each([ @@ -588,6 +597,15 @@ describe("resolveLlmFirstEventTimeoutMs", () => { model: { provider: "ollama", id: "ollama/kimi-k2.6:cloud", baseUrl: "http://127.0.0.1" }, }), ).toBe(CLOUD_LLM_FIRST_EVENT_TIMEOUT_MS); + expect( + resolveLlmFirstEventTimeoutMs({ + model: { + provider: "ollama", + id: "ollama/gpt-oss:120b-cloud", + baseUrl: "http://127.0.0.1:11434", + }, + }), + ).toBe(CLOUD_LLM_FIRST_EVENT_TIMEOUT_MS); }); it("honors explicit provider request timeouts", () => { diff --git a/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts b/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts index 777ad9601a9e..fe31a87a62b6 100644 --- a/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts +++ b/src/agents/embedded-agent-runner/run/llm-idle-timeout.ts @@ -1,4 +1,5 @@ import { onLlmRequestActivity } from "@openclaw/ai/internal/runtime"; +import { isCloudModelRef } from "@openclaw/model-catalog-core/model-catalog-refs"; /** * Wraps LLM streams with idle-timeout detection and diagnostics. */ @@ -194,10 +195,7 @@ function isOllamaCloudModel(model: { id?: string; provider?: string } | undefine return false; } - const modelId = rawModelId.trim().toLowerCase(); - const slashIndex = modelId.indexOf("/"); - const bareModelId = slashIndex >= 0 ? modelId.slice(slashIndex + 1) : modelId; - return bareModelId.endsWith(":cloud"); + return isCloudModelRef(rawModelId); } type RuntimeModelLocality = { diff --git a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts index c2dd46c4d0fc..2aef6f2cf160 100644 --- a/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts +++ b/src/commands/onboard-non-interactive/local/auth-choice.plugin-providers.ts @@ -283,41 +283,15 @@ export async function applyNonInteractivePluginProviderChoice(params: { }); const previousModel = enableResult.config.agents?.defaults?.model; const previousAutoModel = enableResult.config.wizard?.localModelLeanAutoModel; - const restoreAutoModelOwnership = + const retainsAutoModelOwnership = previousAutoModel !== undefined && previousAutoModel === resolveAgentModelPrimaryValue(previousModel) && previousAutoModel === copilotInstall.cfg.wizard?.localModelLeanAutoModel; - // Provider setup already replaced the default model. Restore its old value - // only while checking whether onboarding still owns the lean setting. - const leanConfig = applyAutoLocalModelLean({ - config: restoreAutoModelOwnership - ? { - ...copilotInstall.cfg, - agents: { - ...copilotInstall.cfg.agents, - defaults: { - ...copilotInstall.cfg.agents?.defaults, - model: previousModel, - }, - }, - } - : copilotInstall.cfg, + return applyAutoLocalModelLean({ + config: copilotInstall.cfg, providerId: providerChoice.provider.id, modelRef: selectedModel, + ...(retainsAutoModelOwnership ? { previousModelRef: previousAutoModel } : {}), }).config; - - if (!restoreAutoModelOwnership) { - return leanConfig; - } - return { - ...leanConfig, - agents: { - ...leanConfig.agents, - defaults: { - ...leanConfig.agents?.defaults, - model: copilotInstall.cfg.agents?.defaults?.model, - }, - }, - }; } diff --git a/src/config/local-model-lean-auto.test.ts b/src/config/local-model-lean-auto.test.ts index acd5c97f3d67..f512806558aa 100644 --- a/src/config/local-model-lean-auto.test.ts +++ b/src/config/local-model-lean-auto.test.ts @@ -151,4 +151,27 @@ describe("local model lean onboarding defaults", () => { expect(result.config.agents?.defaults?.experimental?.localModelLean).toBe(true); expect(result.config.wizard?.localModelLeanAutoModel).toBeUndefined(); }); + + it("accepts explicit previous-model ownership after provider setup replaces the default", () => { + const previousModelRef = "ollama/qwen3:8b"; + const selectedModelRef = "openai/gpt-5.6-luna"; + const result = applyAutoLocalModelLean({ + config: { + wizard: { localModelLeanAutoModel: previousModelRef }, + agents: { + defaults: { + model: { primary: selectedModelRef }, + experimental: { localModelLean: true }, + }, + }, + }, + providerId: "openai", + modelRef: selectedModelRef, + previousModelRef, + }); + + expect(result.config.agents?.defaults?.model).toEqual({ primary: selectedModelRef }); + expect(result.config.agents?.defaults?.experimental?.localModelLean).toBeUndefined(); + expect(result.config.wizard?.localModelLeanAutoModel).toBeUndefined(); + }); }); diff --git a/src/config/local-model-lean-auto.ts b/src/config/local-model-lean-auto.ts index 39f001283338..c061183f7346 100644 --- a/src/config/local-model-lean-auto.ts +++ b/src/config/local-model-lean-auto.ts @@ -1,25 +1,9 @@ +import { isCloudModelRef } from "@openclaw/model-catalog-core/model-catalog-refs"; import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id"; import type { OpenClawConfig } from "./types.openclaw.js"; const AUTO_LOCAL_MODEL_LEAN_PROVIDER_IDS = new Set(["lmstudio", "ollama"]); -function parseOllamaModelSourceSuffix( - modelRef: string, -): { base: string; source: "cloud" | "local" } | undefined { - const sourceSeparator = modelRef.lastIndexOf(":"); - if (sourceSeparator < 0) { - return undefined; - } - const source = modelRef.slice(sourceSeparator + 1); - if (source === "cloud" || source === "local") { - return { base: modelRef.slice(0, sourceSeparator), source }; - } - if (!source.includes("/") && source.endsWith("-cloud")) { - return { base: modelRef.slice(0, -"-cloud".length), source: "cloud" }; - } - return undefined; -} - /** Returns true only for local runtimes that onboarding can identify without model-name guesses. */ function shouldAutoEnableLocalModelLean(providerId: string, modelRef: string): boolean { const normalizedProviderId = normalizeProviderId(providerId); @@ -29,12 +13,8 @@ function shouldAutoEnableLocalModelLean(providerId: string, modelRef: string): b if (normalizedProviderId !== "ollama") { return true; } - // Ollama can route hosted source-tagged models through the same local daemon. - // Nested source suffixes are ambiguous and must retain the owner's local classification. - const modelSource = parseOllamaModelSourceSuffix(modelRef.trim().toLowerCase()); - return ( - modelSource?.source !== "cloud" || parseOllamaModelSourceSuffix(modelSource.base) !== undefined - ); + // Hosted source-tagged models can be routed through the same local daemon. + return !isCloudModelRef(modelRef); } function resolveDefaultModelRef(config: OpenClawConfig): string | undefined { @@ -53,6 +33,7 @@ export function applyAutoLocalModelLean(params: { config: OpenClawConfig; providerId: string; modelRef: string; + previousModelRef?: string; }): { config: OpenClawConfig; changed: boolean; @@ -61,7 +42,8 @@ export function applyAutoLocalModelLean(params: { const localModelLean = params.config.agents?.defaults?.experimental?.localModelLean; const autoModel = params.config.wizard?.localModelLeanAutoModel; const onboardingOwnsSetting = - autoModel !== undefined && resolveDefaultModelRef(params.config) === autoModel; + autoModel !== undefined && + (params.previousModelRef ?? resolveDefaultModelRef(params.config)) === autoModel; if (!shouldAutoEnableLocalModelLean(params.providerId, params.modelRef)) { if (!autoModel) { return { config: params.config, changed: false, enabled: false }; diff --git a/src/plugin-sdk/provider-model-shared.ts b/src/plugin-sdk/provider-model-shared.ts index b8f48d123f09..109eef0264aa 100644 --- a/src/plugin-sdk/provider-model-shared.ts +++ b/src/plugin-sdk/provider-model-shared.ts @@ -47,6 +47,7 @@ export type { UnifiedModelCatalogKind, UnifiedModelCatalogSource, } from "@openclaw/model-catalog-core/model-catalog-types"; +export { isCloudModelRef } from "@openclaw/model-catalog-core/model-catalog-refs"; export type { BedrockDiscoveryConfig, ModelCompatConfig,