fix: preserve live Ollama catalog metadata

This commit is contained in:
Peter Steinberger
2026-06-07 14:00:09 -07:00
committed by GitHub
parent 3b6bcbfb50
commit af79cd6a9d
7 changed files with 987 additions and 13 deletions
+606
View File
@@ -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<void>((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;
+288 -6
View File
@@ -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<string, ProviderRuntimeModel[]>();
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<ProviderRuntimeModel | undefined> {
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<ProviderAugmentModelCatalogContext["entries"]> {
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,
+1
View File
@@ -67,6 +67,7 @@
}
],
"modelCatalog": {
"runtimeAugment": true,
"providers": {
"ollama-cloud": {
"baseUrl": "https://ollama.com",
+6 -1
View File
@@ -118,14 +118,19 @@ export function parseOllamaNumCtxParameter(parameters: unknown): number | undefi
export async function queryOllamaModelShowInfo(
apiBase: string,
modelName: string,
opts?: { apiKey?: string },
): Promise<OllamaModelShowInfo> {
const normalizedApiBase = resolveOllamaApiBase(apiBase);
try {
const headers: Record<string, string> = { "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),
},
+55
View File
@@ -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();
+14 -6
View File
@@ -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<typeof ensureAuthProfileStoreWithoutExternalProfiles> | 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.
+17
View File
@@ -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({