From af79cd6a9d35ac3463293b678fbc7bdb153cb599 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sun, 7 Jun 2026 14:00:09 -0700 Subject: [PATCH] fix: preserve live Ollama catalog metadata --- extensions/ollama/index.test.ts | 606 +++++++++++++++++++++++ extensions/ollama/index.ts | 294 ++++++++++- extensions/ollama/openclaw.plugin.json | 1 + extensions/ollama/src/provider-models.ts | 7 +- src/agents/model-catalog.test.ts | 55 ++ src/agents/model-catalog.ts | 20 +- src/plugins/providers.test.ts | 17 + 7 files changed, 987 insertions(+), 13 deletions(-) diff --git a/extensions/ollama/index.test.ts b/extensions/ollama/index.test.ts index 42eda99abe8e..be4b6854cf87 100644 --- a/extensions/ollama/index.test.ts +++ b/extensions/ollama/index.test.ts @@ -524,6 +524,612 @@ describe("ollama plugin", () => { } }); + it("augments exact configured Ollama refs with live show capabilities", async () => { + const provider = registerProvider(); + queryOllamaModelShowInfoMock.mockResolvedValueOnce({ + contextWindow: 1_048_576, + capabilities: ["completion", "tools", "thinking"], + }); + + const rows = await provider.augmentModelCatalog?.({ + config: { + agents: { + defaults: { + models: { + "ollama/minimax-m3:cloud@work": {}, + }, + }, + }, + }, + env: process.env, + entries: [], + } as never); + + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( + "http://127.0.0.1:11434", + "minimax-m3:cloud", + ); + expect(rows).toEqual([ + expect.objectContaining({ + provider: "ollama", + id: "minimax-m3:cloud", + api: "ollama", + reasoning: true, + contextWindow: 1_048_576, + compat: { + supportsTools: true, + supportsUsageInStreaming: true, + }, + }), + ]); + }); + + it("augments Ollama fallback and per-agent configured refs", async () => { + const provider = registerProvider(); + queryOllamaModelShowInfoMock.mockResolvedValue({ + contextWindow: 1_048_576, + capabilities: ["completion", "thinking"], + }); + + const rows = await provider.augmentModelCatalog?.({ + config: { + agents: { + defaults: { + heartbeat: { + model: "ollama/heartbeat:cloud", + }, + model: { + primary: "openai/gpt-5.5", + fallbacks: ["ollama/global-fallback:cloud"], + }, + }, + list: [ + { + id: "ops", + model: { + primary: "ollama/per-agent:cloud@work", + }, + }, + ], + }, + }, + env: process.env, + entries: [], + } as never); + + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( + "http://127.0.0.1:11434", + "global-fallback:cloud", + ); + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( + "http://127.0.0.1:11434", + "per-agent:cloud", + ); + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( + "http://127.0.0.1:11434", + "heartbeat:cloud", + ); + expect(rows).toEqual([ + expect.objectContaining({ + provider: "ollama", + id: "global-fallback:cloud", + reasoning: true, + contextWindow: 1_048_576, + }), + expect.objectContaining({ + provider: "ollama", + id: "heartbeat:cloud", + reasoning: true, + contextWindow: 1_048_576, + }), + expect.objectContaining({ + provider: "ollama", + id: "per-agent:cloud", + reasoning: true, + contextWindow: 1_048_576, + }), + ]); + }); + + it("augments configured Ollama Cloud refs with resolved auth", async () => { + const provider = registerProvider(); + queryOllamaModelShowInfoMock.mockResolvedValueOnce({ + contextWindow: 1_048_576, + capabilities: ["completion", "thinking"], + }); + + const rows = await provider.augmentModelCatalog?.({ + config: { + agents: { + defaults: { + models: { + "ollama/cloud-new:cloud": {}, + }, + }, + }, + models: { + providers: { + ollama: { + baseUrl: "https://ollama.com", + api: "ollama", + }, + }, + }, + }, + env: {}, + entries: [], + resolveProviderApiKey: vi.fn(() => ({ apiKey: "cloud-key" })), + } as never); + + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( + "https://ollama.com", + "cloud-new:cloud", + { apiKey: "cloud-key" }, + ); + expect(rows).toEqual([ + expect.objectContaining({ + provider: "ollama", + id: "cloud-new:cloud", + reasoning: true, + contextWindow: 1_048_576, + }), + ]); + }); + + it("augments configured remote Ollama refs with configured auth", async () => { + const provider = registerProvider(); + queryOllamaModelShowInfoMock.mockResolvedValueOnce({ + contextWindow: 1_048_576, + capabilities: ["completion", "tools", "thinking"], + }); + + const rows = await provider.augmentModelCatalog?.({ + config: { + agents: { + defaults: { + models: { + "ollama/remote-new": {}, + }, + }, + }, + models: { + providers: { + ollama: { + baseUrl: "https://ollama.example.test", + api: "ollama", + apiKey: "remote-key", + }, + }, + }, + }, + env: {}, + entries: [], + resolveProviderApiKey: vi.fn(() => ({ apiKey: "" })), + } as never); + + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( + "https://ollama.example.test", + "remote-new", + { apiKey: "remote-key" }, + ); + expect(rows).toEqual([ + expect.objectContaining({ + provider: "ollama", + id: "remote-new", + reasoning: true, + contextWindow: 1_048_576, + }), + ]); + }); + + it.each(["$OLLAMA_API_KEY", "${OLLAMA_API_KEY}"])( + "resolves configured Ollama Cloud SecretInput auth string %s", + async (apiKeyRef) => { + const provider = registerProvider(); + queryOllamaModelShowInfoMock.mockResolvedValueOnce({ + contextWindow: 1_048_576, + capabilities: ["completion", "thinking"], + }); + + await provider.augmentModelCatalog?.({ + config: { + agents: { + defaults: { + models: { + "ollama/cloud-new:cloud": {}, + }, + }, + }, + models: { + providers: { + ollama: { + baseUrl: "https://ollama.com", + api: "ollama", + apiKey: apiKeyRef, + }, + }, + }, + }, + env: { OLLAMA_API_KEY: "cloud-key" }, + entries: [], + resolveProviderApiKey: vi.fn(() => ({ + apiKey: "OLLAMA_API_KEY", + discoveryApiKey: "cloud-key", + })), + } as never); + + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( + "https://ollama.com", + "cloud-new:cloud", + { apiKey: "cloud-key" }, + ); + queryOllamaModelShowInfoMock.mockClear(); + }, + ); + + it("augments secured local Ollama refs with resolved configured auth", async () => { + const provider = registerProvider(); + queryOllamaModelShowInfoMock.mockResolvedValueOnce({ + contextWindow: 1_048_576, + capabilities: ["completion", "tools", "thinking"], + }); + + await provider.augmentModelCatalog?.({ + config: { + agents: { + defaults: { + models: { + "ollama/local-secured": {}, + }, + }, + }, + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + apiKey: { source: "env", provider: "default", id: "LOCAL_OLLAMA_API_KEY" }, + }, + }, + }, + }, + env: { + LOCAL_OLLAMA_API_KEY: "local-key", + OLLAMA_API_KEY: "ambient-cloud-key", + }, + entries: [], + resolveProviderApiKey: vi.fn(() => ({ + apiKey: "LOCAL_OLLAMA_API_KEY", + discoveryApiKey: "local-key", + })), + } as never); + + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( + "http://127.0.0.1:11434", + "local-secured", + { apiKey: "local-key" }, + ); + }); + + it("does not attach ambient OLLAMA_API_KEY to local show probes", async () => { + const provider = registerProvider(); + queryOllamaModelShowInfoMock.mockResolvedValueOnce({ + contextWindow: 1_048_576, + capabilities: ["completion", "tools"], + }); + + await provider.augmentModelCatalog?.({ + config: { + agents: { + defaults: { + models: { + "ollama/local-open": {}, + }, + }, + }, + }, + env: { + OLLAMA_API_KEY: "ambient-cloud-key", + }, + entries: [], + resolveProviderApiKey: vi.fn(() => ({ + apiKey: "OLLAMA_API_KEY", + discoveryApiKey: "ambient-cloud-key", + })), + } as never); + + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( + "http://127.0.0.1:11434", + "local-open", + ); + }); + + it("augments configured first-class Ollama Cloud provider refs", async () => { + const provider = registerOllamaCloudProvider(); + queryOllamaModelShowInfoMock.mockResolvedValueOnce({ + contextWindow: 1_048_576, + capabilities: ["completion", "thinking"], + }); + + const rows = await provider.augmentModelCatalog?.({ + config: { + agents: { + defaults: { + models: { + "ollama-cloud/cloud-new:cloud": {}, + }, + }, + }, + }, + env: {}, + entries: [], + resolveProviderApiKey: vi.fn(() => ({ apiKey: "cloud-key" })), + } as never); + + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( + "https://ollama.com", + "cloud-new:cloud", + { apiKey: "cloud-key" }, + ); + expect(rows).toEqual([ + expect.objectContaining({ + provider: "ollama-cloud", + id: "cloud-new:cloud", + reasoning: true, + contextWindow: 1_048_576, + }), + ]); + }); + + it("prefers explicit Ollama Cloud provider keys over local env markers", async () => { + const provider = registerOllamaCloudProvider(); + queryOllamaModelShowInfoMock.mockResolvedValueOnce({ + contextWindow: 1_048_576, + capabilities: ["completion", "thinking"], + }); + + await provider.augmentModelCatalog?.({ + config: { + agents: { + defaults: { + models: { + "ollama-cloud/cloud-new:cloud": {}, + }, + }, + }, + models: { + providers: { + "ollama-cloud": { + baseUrl: "https://ollama.com", + api: "ollama", + apiKey: "cloud-config-key", + }, + }, + }, + }, + env: { OLLAMA_API_KEY: "ollama-local" }, + entries: [], + resolveProviderApiKey: vi.fn(() => ({ apiKey: "ollama-local" })), + } as never); + + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( + "https://ollama.com", + "cloud-new:cloud", + { apiKey: "cloud-config-key" }, + ); + }); + + it("uses resolved discovery auth instead of non-secret markers for Ollama Cloud probes", async () => { + const provider = registerOllamaCloudProvider(); + queryOllamaModelShowInfoMock.mockResolvedValueOnce({ + contextWindow: 1_048_576, + capabilities: ["completion", "thinking"], + }); + + await provider.augmentModelCatalog?.({ + config: { + agents: { + defaults: { + models: { + "ollama-cloud/cloud-new:cloud": {}, + }, + }, + }, + }, + env: {}, + entries: [], + resolveProviderApiKey: vi.fn(() => ({ + apiKey: "secretref-managed", // pragma: allowlist secret + discoveryApiKey: "cloud-key", + })), + } as never); + + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( + "https://ollama.com", + "cloud-new:cloud", + { apiKey: "cloud-key" }, + ); + }); + + it("does not probe Ollama Cloud catalog with non-secret auth markers", async () => { + const provider = registerOllamaCloudProvider(); + + const rows = await provider.augmentModelCatalog?.({ + config: { + agents: { + defaults: { + models: { + "ollama-cloud/cloud-new:cloud": {}, + }, + }, + }, + }, + env: { OLLAMA_API_KEY: "secretref-managed" }, // pragma: allowlist secret + entries: [], + resolveProviderApiKey: vi.fn(() => ({ + apiKey: "secretref-managed", // pragma: allowlist secret + })), + } as never); + + expect(queryOllamaModelShowInfoMock).not.toHaveBeenCalled(); + expect(rows).toEqual([]); + }); + + it("augments id-only configured Ollama provider rows with live show capabilities", async () => { + const provider = registerProvider(); + queryOllamaModelShowInfoMock.mockResolvedValueOnce({ + contextWindow: 1_048_576, + capabilities: ["completion", "tools", "thinking", "vision"], + }); + + const rows = await provider.augmentModelCatalog?.({ + config: {}, + env: process.env, + entries: [ + { + provider: "ollama", + id: "minimax-m3:cloud", + name: "Configured Minimax M3", + api: "openai-completions", + }, + ], + } as never); + + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( + "http://127.0.0.1:11434", + "minimax-m3:cloud", + ); + expect(rows).toEqual([ + expect.objectContaining({ + provider: "ollama", + id: "minimax-m3:cloud", + name: "Configured Minimax M3", + api: "openai-completions", + reasoning: true, + input: ["text", "image"], + contextWindow: 1_048_576, + compat: { + supportsTools: true, + supportsUsageInStreaming: true, + }, + }), + ]); + }); + + it("fills missing metadata on partially configured Ollama provider rows", async () => { + const provider = registerProvider(); + queryOllamaModelShowInfoMock.mockResolvedValueOnce({ + contextWindow: 1_048_576, + capabilities: ["completion", "tools", "thinking", "vision"], + }); + + const rows = await provider.augmentModelCatalog?.({ + config: {}, + env: process.env, + entries: [ + { + provider: "ollama", + id: "minimax-m3:cloud", + name: "minimax-m3:cloud", + contextWindow: 128_000, + }, + ], + } as never); + + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledWith( + "http://127.0.0.1:11434", + "minimax-m3:cloud", + ); + expect(rows).toEqual([ + expect.objectContaining({ + provider: "ollama", + id: "minimax-m3:cloud", + reasoning: true, + input: ["text", "image"], + compat: { + supportsTools: true, + supportsUsageInStreaming: true, + }, + }), + ]); + }); + + it("does not override configured Ollama provider metadata", async () => { + const provider = registerProvider(); + + const rows = await provider.augmentModelCatalog?.({ + config: {}, + env: process.env, + entries: [ + { + provider: "ollama", + id: "minimax-m3:cloud", + name: "minimax-m3:cloud", + contextWindow: 128_000, + reasoning: false, + input: ["text"], + compat: { supportsTools: false }, + }, + ], + } as never); + + expect(queryOllamaModelShowInfoMock).not.toHaveBeenCalled(); + expect(rows).toEqual([]); + }); + + it("bounds configured Ollama show probes", async () => { + const provider = registerProvider(); + let active = 0; + let maxActive = 0; + queryOllamaModelShowInfoMock.mockImplementation(async () => { + active += 1; + maxActive = Math.max(maxActive, active); + await new Promise((resolve) => { + setTimeout(resolve, 0); + }); + active -= 1; + return { + contextWindow: 1_048_576, + capabilities: ["completion", "thinking"], + }; + }); + + const rows = await provider.augmentModelCatalog?.({ + config: {}, + env: process.env, + entries: Array.from({ length: 5 }, (_, index) => ({ + provider: "ollama", + id: `model-${index}:cloud`, + name: `model-${index}:cloud`, + })), + } as never); + + expect(rows).toHaveLength(5); + expect(maxActive).toBeGreaterThan(1); + expect(maxActive).toBeLessThanOrEqual(4); + }); + + it("caps configured Ollama show probes", async () => { + const provider = registerProvider(); + queryOllamaModelShowInfoMock.mockResolvedValue({ + contextWindow: 1_048_576, + capabilities: ["completion", "thinking"], + }); + + const rows = await provider.augmentModelCatalog?.({ + config: {}, + env: process.env, + entries: Array.from({ length: 10 }, (_, index) => ({ + provider: "ollama", + id: `model-${index}:cloud`, + name: `model-${index}:cloud`, + })), + } as never); + + expect(queryOllamaModelShowInfoMock).toHaveBeenCalledTimes(8); + expect(rows).toHaveLength(8); + }); + it("keeps unknown requested Ollama models unresolved when show has no metadata", async () => { const provider = registerProvider(); const previous = process.env.OLLAMA_API_KEY; diff --git a/extensions/ollama/index.ts b/extensions/ollama/index.ts index ff7a3cbc8bab..2279c19fa9e4 100644 --- a/extensions/ollama/index.ts +++ b/extensions/ollama/index.ts @@ -1,4 +1,5 @@ // Ollama plugin entrypoint registers its OpenClaw integration. +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"; import { @@ -7,11 +8,16 @@ import { type ProviderAuthContext, type ProviderAuthMethodNonInteractiveContext, type ProviderAuthResult, + type ProviderAugmentModelCatalogContext, type ProviderCatalogContext, type ProviderReplayPolicy, type ProviderRuntimeModel, } from "openclaw/plugin-sdk/plugin-entry"; -import { buildApiKeyCredential } from "openclaw/plugin-sdk/provider-auth"; +import { + buildApiKeyCredential, + coerceSecretRef, + isNonSecretApiKeyMarker, +} from "openclaw/plugin-sdk/provider-auth"; import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key"; import type { ModelDefinitionConfig, @@ -22,7 +28,6 @@ import { OPENAI_COMPATIBLE_REPLAY_HOOKS, } from "openclaw/plugin-sdk/provider-model-shared"; import { - OLLAMA_DEFAULT_BASE_URL, buildOllamaModelDefinition, buildOllamaProvider, configureOllamaNonInteractive, @@ -35,10 +40,12 @@ import { OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_DEFAULT_MODELS, OLLAMA_CLOUD_PROVIDER_ID, + OLLAMA_DEFAULT_BASE_URL, } from "./src/defaults.js"; import { OLLAMA_DEFAULT_API_KEY, OLLAMA_PROVIDER_ID, + isLocalOllamaBaseUrl, resolveOllamaDiscoveryResult, shouldUseSyntheticOllamaAuth, type OllamaPluginConfig, @@ -69,6 +76,9 @@ function buildNativeOllamaReplayPolicy(): ProviderReplayPolicy { const dynamicModelCache = new Map(); const OLLAMA_CLOUD_DEFAULT_MODEL_REF = `${OLLAMA_CLOUD_PROVIDER_ID}/${OLLAMA_CLOUD_DEFAULT_MODELS[0]}`; +const OLLAMA_CONFIGURED_SHOW_CONCURRENCY = 4; +const OLLAMA_CONFIGURED_SHOW_MAX_MODELS = 8; +const OLLAMA_API_KEY_ENV_REF_RE = /^[A-Z_][A-Z0-9_]*$/u; function buildDynamicCacheKey(provider: string, baseUrl: string | undefined): string { return `${provider}\0${baseUrl ?? ""}`; @@ -106,6 +116,187 @@ function toDynamicOllamaModel(params: { }; } +function stripTrailingAuthProfile(raw: string): string { + const trimmed = raw.trim(); + const lastSlash = trimmed.lastIndexOf("/"); + let delimiter = trimmed.indexOf("@", lastSlash + 1); + if (delimiter <= 0) { + return trimmed; + } + const suffix = () => trimmed.slice(delimiter + 1); + if (/^\d{8}(?:@|$)/.test(suffix())) { + const next = trimmed.indexOf("@", delimiter + 9); + if (next < 0) { + return trimmed; + } + delimiter = next; + } + if (/^(?:i?q\d+(?:_[a-z0-9]+)*|\d+bit)(?:@|$)/i.test(suffix())) { + const next = trimmed.indexOf("@", delimiter + 1); + if (next < 0) { + return trimmed; + } + delimiter = next; + } + const model = trimmed.slice(0, delimiter).trim(); + const profile = trimmed.slice(delimiter + 1).trim(); + return model && profile ? model : trimmed; +} + +function needsOllamaCatalogMetadata(entry: ProviderAugmentModelCatalogContext["entries"][number]) { + const hasContextLimit = entry.contextWindow !== undefined || entry.contextTokens !== undefined; + return ( + !hasContextLimit || + entry.reasoning === undefined || + entry.input === undefined || + entry.compat === undefined + ); +} + +function readConfiguredOllamaApiKey(value: unknown): string | undefined { + if (typeof value === "string") { + const trimmed = value.trim(); + return trimmed || undefined; + } + if (value && typeof value === "object" && "value" in value) { + const resolved = (value as { value?: unknown }).value; + if (typeof resolved === "string") { + const trimmed = resolved.trim(); + return trimmed || undefined; + } + } + return undefined; +} + +function readConcreteOllamaApiKey(value: unknown): string | undefined { + if (coerceSecretRef(value)) { + return undefined; + } + const apiKey = readConfiguredOllamaApiKey(value); + return apiKey && !isNonSecretApiKeyMarker(apiKey) ? apiKey : undefined; +} + +function readEnvBackedOllamaApiKey(value: unknown, env: NodeJS.ProcessEnv): string | undefined { + const ref = coerceSecretRef(value); + if (ref?.source === "env") { + return readConcreteOllamaApiKey(env[ref.id.trim()]); + } + const apiKey = readConfiguredOllamaApiKey(value); + return apiKey && OLLAMA_API_KEY_ENV_REF_RE.test(apiKey) + ? readConcreteOllamaApiKey(env[apiKey]) + : undefined; +} + +function isAmbientOllamaApiKeyMarker(value: string | undefined): boolean { + return value === OLLAMA_DEFAULT_API_KEY || value === "OLLAMA_API_KEY"; +} + +function readUsableOllamaShowApiKey(params: { + env: NodeJS.ProcessEnv; + allowAmbientEnvFallback: boolean; + explicitApiKey?: string; + resolved?: { apiKey?: unknown; discoveryApiKey?: unknown }; +}): string | undefined { + const explicitEnvApiKey = readEnvBackedOllamaApiKey(params.explicitApiKey, params.env); + if (explicitEnvApiKey) { + return explicitEnvApiKey; + } + const explicitApiKey = readConcreteOllamaApiKey(params.explicitApiKey); + if (explicitApiKey) { + return explicitApiKey; + } + const resolvedApiKey = readConfiguredOllamaApiKey(params.resolved?.apiKey); + const canUseResolvedDiscovery = + params.allowAmbientEnvFallback || !isAmbientOllamaApiKeyMarker(resolvedApiKey); + const discoveryApiKey = readConcreteOllamaApiKey(params.resolved?.discoveryApiKey); + if (discoveryApiKey && canUseResolvedDiscovery) { + return discoveryApiKey; + } + const resolvedEnvApiKey = readEnvBackedOllamaApiKey(params.resolved?.apiKey, params.env); + if (resolvedEnvApiKey && canUseResolvedDiscovery) { + return resolvedEnvApiKey; + } + const apiKey = readConcreteOllamaApiKey(params.resolved?.apiKey); + if (apiKey && !OLLAMA_API_KEY_ENV_REF_RE.test(apiKey)) { + return apiKey; + } + return params.allowAmbientEnvFallback + ? readConcreteOllamaApiKey(params.env.OLLAMA_API_KEY) + : undefined; +} + +function collectConfiguredOllamaModelIds(params: { + config?: OpenClawConfig; + provider: string; + entries?: ProviderAugmentModelCatalogContext["entries"]; +}): Array<{ + id: string; + api?: ProviderAugmentModelCatalogContext["entries"][number]["api"]; + name?: string; +}> { + const providerPrefix = `${params.provider.toLowerCase()}/`; + const models = new Map< + string, + { + id: string; + api?: ProviderAugmentModelCatalogContext["entries"][number]["api"]; + name?: string; + } + >(); + const addModelId = ( + modelId: string, + api?: ProviderAugmentModelCatalogContext["entries"][number]["api"], + name?: string, + ) => { + const trimmed = modelId.trim(); + if (!trimmed || trimmed === "*") { + return; + } + const trimmedName = typeof name === "string" ? name.trim() : ""; + const existing = models.get(trimmed); + if (existing) { + if ((!existing.api && api) || (!existing.name && trimmedName)) { + models.set(trimmed, { + ...existing, + ...(api && !existing.api ? { api } : {}), + ...(trimmedName && !existing.name ? { name: trimmedName } : {}), + }); + } + return; + } + models.set(trimmed, { + id: trimmed, + ...(api ? { api } : {}), + ...(trimmedName ? { name: trimmedName } : {}), + }); + }; + const addRef = (raw: unknown) => { + if (typeof raw !== "string") { + return; + } + const trimmed = stripTrailingAuthProfile(raw); + if (!trimmed.toLowerCase().startsWith(providerPrefix)) { + return; + } + const modelId = trimmed.slice(providerPrefix.length).trim(); + addModelId(modelId); + }; + + for (const ref of collectConfiguredModelRefValues(params.config)) { + addRef(ref); + } + for (const entry of params.entries ?? []) { + if ( + entry.provider.toLowerCase() === params.provider.toLowerCase() && + entry.id.trim() && + needsOllamaCatalogMetadata(entry) + ) { + addModelId(entry.id.trim(), entry.api, entry.name); + } + } + return [...models.values()]; +} + function buildStaticOllamaCloudProvider(): ModelProviderConfig { return { baseUrl: OLLAMA_CLOUD_BASE_URL, @@ -123,11 +314,12 @@ async function resolveRequestedDynamicOllamaModel(params: { provider: string; providerConfig: ModelProviderConfig; modelId: string; + showApiKey?: string; }): Promise { - const showInfo = await queryOllamaModelShowInfo( - readProviderBaseUrl(params.providerConfig) ?? OLLAMA_DEFAULT_BASE_URL, - params.modelId, - ); + const showBaseUrl = readProviderBaseUrl(params.providerConfig) ?? OLLAMA_DEFAULT_BASE_URL; + const showInfo = params.showApiKey + ? await queryOllamaModelShowInfo(showBaseUrl, params.modelId, { apiKey: params.showApiKey }) + : await queryOllamaModelShowInfo(showBaseUrl, params.modelId); if (typeof showInfo.contextWindow !== "number" && (showInfo.capabilities?.length ?? 0) === 0) { return undefined; } @@ -142,6 +334,78 @@ async function resolveRequestedDynamicOllamaModel(params: { }); } +async function augmentConfiguredOllamaCatalogModels(params: { + config?: OpenClawConfig; + defaultBaseUrl: string; + env: NodeJS.ProcessEnv; + provider: string; + entries: ProviderAugmentModelCatalogContext["entries"]; + resolveProviderApiKey: ProviderAugmentModelCatalogContext["resolveProviderApiKey"]; +}): Promise { + const models = collectConfiguredOllamaModelIds({ + config: params.config, + provider: params.provider, + entries: params.entries, + }); + if (models.length === 0) { + return []; + } + const configuredProvider = resolveConfiguredOllamaProviderConfig({ + config: params.config, + providerId: params.provider, + }); + const baseUrl = readProviderBaseUrl(configuredProvider) ?? params.defaultBaseUrl; + const isLocalBaseUrl = isLocalOllamaBaseUrl(baseUrl); + const showApiKey = readUsableOllamaShowApiKey({ + env: params.env, + allowAmbientEnvFallback: !isLocalBaseUrl, + explicitApiKey: readConfiguredOllamaApiKey(configuredProvider?.apiKey), + resolved: params.resolveProviderApiKey?.(params.provider), + }); + if (!isLocalBaseUrl && !showApiKey) { + return []; + } + const providerConfig: ModelProviderConfig = { + ...configuredProvider, + models: configuredProvider?.models ?? [], + baseUrl, + api: configuredProvider?.api ?? "ollama", + }; + const entries: ProviderAugmentModelCatalogContext["entries"] = []; + const modelsToProbe = models.slice(0, OLLAMA_CONFIGURED_SHOW_MAX_MODELS); + for (let index = 0; index < modelsToProbe.length; index += OLLAMA_CONFIGURED_SHOW_CONCURRENCY) { + const batch = modelsToProbe.slice(index, index + OLLAMA_CONFIGURED_SHOW_CONCURRENCY); + const rows = await Promise.all( + batch.map(async (model) => { + const requested = await resolveRequestedDynamicOllamaModel({ + provider: params.provider, + providerConfig, + modelId: model.id, + showApiKey, + }); + return requested + ? { + id: requested.id, + name: model.name ?? requested.name, + provider: requested.provider, + api: model.api ?? providerConfig.api, + reasoning: requested.reasoning, + input: requested.input, + contextWindow: requested.contextWindow, + compat: requested.compat, + } + : undefined; + }), + ); + for (const row of rows) { + if (row) { + entries.push(row); + } + } + } + return entries; +} + export default definePluginEntry({ id: "ollama", name: "Ollama Provider", @@ -227,6 +491,15 @@ export default definePluginEntry({ resolveReasoningOutputMode: () => "native", resolveThinkingProfile: resolveOllamaThinkingProfile, wrapStreamFn: createConfiguredOllamaCompatStreamWrapper, + augmentModelCatalog: async (ctx) => + await augmentConfiguredOllamaCatalogModels({ + config: ctx.config, + defaultBaseUrl: OLLAMA_CLOUD_BASE_URL, + env: ctx.env, + provider: OLLAMA_CLOUD_PROVIDER_ID, + entries: ctx.entries, + resolveProviderApiKey: ctx.resolveProviderApiKey, + }), matchesContextOverflowError: ({ errorMessage }) => /\bollama\b.*(?:context length|too many tokens|context window)/i.test(errorMessage) || /\btruncating input\b.*\btoo long\b/i.test(errorMessage), @@ -339,6 +612,15 @@ export default definePluginEntry({ resolveReasoningOutputMode: () => "native", resolveThinkingProfile: resolveOllamaThinkingProfile, wrapStreamFn: createConfiguredOllamaCompatStreamWrapper, + augmentModelCatalog: async (ctx) => + await augmentConfiguredOllamaCatalogModels({ + config: ctx.config, + defaultBaseUrl: OLLAMA_DEFAULT_BASE_URL, + env: ctx.env, + provider: OLLAMA_PROVIDER_ID, + entries: ctx.entries, + resolveProviderApiKey: ctx.resolveProviderApiKey, + }), createEmbeddingProvider: async ({ config, model, provider: embeddingProvider, remote }) => { const { provider, client } = await createOllamaEmbeddingProvider({ config, diff --git a/extensions/ollama/openclaw.plugin.json b/extensions/ollama/openclaw.plugin.json index 467ca5ef9ee0..5ee5a5105a36 100644 --- a/extensions/ollama/openclaw.plugin.json +++ b/extensions/ollama/openclaw.plugin.json @@ -67,6 +67,7 @@ } ], "modelCatalog": { + "runtimeAugment": true, "providers": { "ollama-cloud": { "baseUrl": "https://ollama.com", diff --git a/extensions/ollama/src/provider-models.ts b/extensions/ollama/src/provider-models.ts index 9631b127d977..0cbc5a930212 100644 --- a/extensions/ollama/src/provider-models.ts +++ b/extensions/ollama/src/provider-models.ts @@ -118,14 +118,19 @@ export function parseOllamaNumCtxParameter(parameters: unknown): number | undefi export async function queryOllamaModelShowInfo( apiBase: string, modelName: string, + opts?: { apiKey?: string }, ): Promise { const normalizedApiBase = resolveOllamaApiBase(apiBase); try { + const headers: Record = { "Content-Type": "application/json" }; + if (opts?.apiKey) { + headers.Authorization = `Bearer ${opts.apiKey}`; + } const { response, release } = await fetchWithSsrFGuard({ url: `${normalizedApiBase}/api/show`, init: { method: "POST", - headers: { "Content-Type": "application/json" }, + headers, body: JSON.stringify({ name: modelName }), signal: AbortSignal.timeout(3000), }, diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index 2d646e71b1e1..15d326740e50 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -1305,6 +1305,61 @@ describe("loadModelCatalog", () => { expect(entry.reasoning).toBe(true); }); + it("passes configured provider rows to provider catalog augment hooks", async () => { + mockAgentDiscoveryModels([]); + augmentCatalogMock.mockResolvedValueOnce([ + { + provider: "ollama", + id: "minimax-m3:cloud", + name: "Minimax M3 Live", + reasoning: true, + input: ["text", "image"], + contextWindow: 1_048_576, + compat: { supportsTools: true }, + }, + ]); + + const result = await loadModelCatalog({ + config: { + models: { + providers: { + ollama: { + baseUrl: "http://127.0.0.1:11434", + api: "ollama", + models: [ + { + id: "minimax-m3:cloud", + name: "Minimax M3 Configured", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128_000, + maxTokens: 8192, + compat: { supportsTools: false }, + }, + ], + }, + }, + }, + } as OpenClawConfig, + }); + + const entry = requireCatalogEntry(result, "ollama", "minimax-m3:cloud"); + expect(entry.name).toBe("Minimax M3 Live"); + expect(entry.contextWindow).toBe(128_000); + expect(entry.input).toEqual(["text"]); + expect(entry.reasoning).toBe(false); + expect(entry.compat).toEqual({ supportsTools: false }); + expect(augmentCatalogMock.mock.calls[0]?.[0]?.context.entries).toContainEqual( + expect.objectContaining({ + provider: "ollama", + id: "minimax-m3:cloud", + name: "Minimax M3 Configured", + contextWindow: 128_000, + }), + ); + }); + it("includes configured provider models missing from discovery", async () => { mockSingleOpenAiCatalogModel(); diff --git a/src/agents/model-catalog.ts b/src/agents/model-catalog.ts index cd892d61f315..9254a76b6326 100644 --- a/src/agents/model-catalog.ts +++ b/src/agents/model-catalog.ts @@ -597,6 +597,18 @@ export async function loadModelCatalog(params?: { }), ); logStage("manifest-models-merged", `entries=${models.length}`); + const configuredModels = buildConfiguredModelCatalog({ + cfg, + manifestPlugins: hasConfiguredProviderModelRows(cfg) ? getManifestPlugins() : undefined, + }); + let augmentEntries: ModelCatalogEntry[] | undefined; + if (configuredModels.length > 0) { + const entriesForAugment = [...models]; + mergeCatalogEntries(entriesForAugment, configuredModels); + augmentEntries = entriesForAugment; + } + logStage("configured-models-prepared", `entries=${models.length}`); + if (!readOnly) { const { createProviderApiKeyResolver } = await loadProviderApiKeyResolver(); let authStore: ReturnType | undefined; @@ -620,7 +632,7 @@ export async function loadModelCatalog(params?: { agentDir, env: process.env, resolveProviderApiKey, - entries: [...models], + entries: augmentEntries ?? [...models], }, }); if (supplemental.length > 0) { @@ -638,14 +650,10 @@ export async function loadModelCatalog(params?: { } logStage("plugin-models-merged", `entries=${models.length}`); - const configuredModels = buildConfiguredModelCatalog({ - cfg, - manifestPlugins: hasConfiguredProviderModelRows(cfg) ? getManifestPlugins() : undefined, - }); if (configuredModels.length > 0) { mergeCatalogEntries(models, configuredModels); } - logStage("configured-models-merged", `entries=${models.length}`); + logStage("configured-models-finalized", `entries=${models.length}`); if (models.length === 0) { // If we found nothing, don't cache this result so we can try again. diff --git a/src/plugins/providers.test.ts b/src/plugins/providers.test.ts index 9fa99dfc5e0c..17a660ee88d9 100644 --- a/src/plugins/providers.test.ts +++ b/src/plugins/providers.test.ts @@ -853,6 +853,23 @@ describe("resolvePluginProviders", () => { ).toEqual(["runtime-bundled"]); }); + it("loads bundled Ollama catalog augment hooks from the manifest runtime flag", () => { + setManifestPlugins([ + createManifestProviderPlugin({ + id: "ollama", + providerIds: ["ollama", "ollama-cloud"], + enabledByDefault: true, + modelCatalog: { + runtimeAugment: true, + }, + }), + ]); + + expect( + resolveCatalogHookProviderPluginIds({ config: {}, env: {} as NodeJS.ProcessEnv }), + ).toEqual(["ollama"]); + }); + it("resolves external auth hook plugin ids from manifest contracts without runtime loading", () => { setManifestPlugins([ createManifestProviderPlugin({