mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-25 20:05:46 -06:00
fix: preserve live Ollama catalog metadata
This commit is contained in:
committed by
GitHub
parent
3b6bcbfb50
commit
af79cd6a9d
@@ -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
@@ -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,
|
||||
|
||||
@@ -67,6 +67,7 @@
|
||||
}
|
||||
],
|
||||
"modelCatalog": {
|
||||
"runtimeAugment": true,
|
||||
"providers": {
|
||||
"ollama-cloud": {
|
||||
"baseUrl": "https://ollama.com",
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user