improve(ollama): reduce inactive startup cost (#119745)

* improve(ollama): defer optional runtime imports

Punchcard-Session: coral-workshop-workshop-3f

* improve(ollama): isolate lazy runtime modules

* improve(ollama): preserve lazy runtime boundaries
This commit is contained in:
Vincent Koc
2026-08-06 07:40:22 +08:00
committed by GitHub
parent e41c574793
commit 2fc2fc930b
28 changed files with 2387 additions and 1837 deletions
+1 -1
View File
@@ -180,7 +180,7 @@ extensions/oc-path/src/oc-path/universal.ts
extensions/ollama/index.test.ts
extensions/ollama/index.ts
extensions/ollama/src/stream-runtime.test.ts
extensions/ollama/src/stream.ts
extensions/ollama/src/stream.runtime.ts
extensions/openai/image-generation-provider.test.ts
extensions/openai/image-generation-provider.ts
extensions/openai/openai-provider.test.ts
+1 -1
View File
@@ -33,4 +33,4 @@ export {
resolveOllamaCompatNumCtxEnabled,
shouldInjectOllamaCompatNumCtx,
wrapOllamaCompatNumCtx,
} from "./src/stream.js";
} from "./src/stream-api.js";
+15 -32
View File
@@ -1,9 +1,5 @@
// Ollama tests cover index plugin behavior.
import { expectDefined } from "@openclaw/normalization-core";
import {
describeImageWithModel,
describeImagesWithModel,
} from "openclaw/plugin-sdk/media-understanding";
import type { ProviderAuthMethod } from "openclaw/plugin-sdk/plugin-entry";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { clearLiveCatalogCacheForTests } from "openclaw/plugin-sdk/provider-catalog-shared";
@@ -56,28 +52,16 @@ const buildOllamaModelDefinitionMock = vi.hoisted(() =>
}),
);
const createConfiguredOllamaStreamFnMock = vi.hoisted(() =>
vi.fn((_params: { model: unknown; providerBaseUrl?: string }) => ({}) as never),
vi.fn((_params: { model: unknown; providerBaseUrl?: string }) => (() => ({})) as never),
);
vi.mock("./api.js", () => ({
promptAndConfigureOllama: promptAndConfigureOllamaMock,
ensureOllamaModelPulled: ensureOllamaModelPulledMock,
configureOllamaNonInteractive: configureOllamaNonInteractiveMock,
fetchOllamaModels: fetchOllamaModelsMock,
resolveOllamaApiBase: (baseUrl?: string) =>
(baseUrl ?? "http://127.0.0.1:11434").replace(/\/+$/, "").replace(/\/v1$/i, ""),
resolveOllamaSetupDefaultBaseUrl: (env: NodeJS.ProcessEnv = process.env) =>
["1", "true", "yes", "on"].includes(env.OPENCLAW_DOCKER_SETUP?.trim().toLowerCase() ?? "")
? "http://host.docker.internal:11434"
: "http://127.0.0.1:11434",
buildOllamaProvider: buildOllamaProviderMock,
queryOllamaModelShowInfo: queryOllamaModelShowInfoMock,
buildOllamaModelDefinition: buildOllamaModelDefinitionMock,
}));
vi.mock("./src/provider-models.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./src/provider-models.js")>()),
buildOllamaModelDefinition: buildOllamaModelDefinitionMock,
buildOllamaProvider: buildOllamaProviderMock,
fetchOllamaModels: fetchOllamaModelsMock,
fetchLoadedOllamaModelNames: fetchLoadedOllamaModelNamesMock,
queryOllamaModelShowInfo: queryOllamaModelShowInfoMock,
}));
vi.mock("openclaw/plugin-sdk/secret-input-runtime", async (importOriginal) => {
@@ -90,18 +74,17 @@ vi.mock("openclaw/plugin-sdk/secret-input-runtime", async (importOriginal) => {
};
});
vi.mock("./src/setup.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./src/setup.js")>()),
vi.mock("./src/setup.runtime.js", async (importOriginal) => ({
...(await importOriginal<typeof import("./src/setup.runtime.js")>()),
checkOllamaCloudAuth: checkOllamaCloudAuthMock,
configureOllamaNonInteractive: configureOllamaNonInteractiveMock,
ensureOllamaModelPulled: ensureOllamaModelPulledMock,
promptAndConfigureOllama: promptAndConfigureOllamaMock,
}));
vi.mock("./src/stream.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./src/stream.js")>();
return {
...actual,
createConfiguredOllamaStreamFn: createConfiguredOllamaStreamFnMock,
};
});
vi.mock("./src/stream-registration.js", () => ({
createLazyConfiguredOllamaStreamFn: createConfiguredOllamaStreamFnMock,
}));
beforeEach(() => {
clearLiveCatalogCacheForTests();
@@ -2520,8 +2503,8 @@ describe("ollama plugin", () => {
const ollamaMedia = expectDefined(mediaProviders[0], "Ollama media provider");
expect(ollamaMedia.id).toBe("ollama");
expect(ollamaMedia.capabilities).toEqual(["image"]);
expect(ollamaMedia.describeImage).toBe(describeImageWithModel);
expect(ollamaMedia.describeImages).toBe(describeImagesWithModel);
expect(ollamaMedia.describeImage).toBeTypeOf("function");
expect(ollamaMedia.describeImages).toBeTypeOf("function");
// Intentional: no defaultModels or autoPriority. Ollama vision models are
// user-installed (llava, qwen2.5vl, …) with no universal default, and we
// don't want Ollama to auto-steal image duty from configured providers.
+83 -33
View File
@@ -2,6 +2,9 @@
import { createHash } from "node:crypto";
import { collectConfiguredModelRefValues } from "@openclaw/model-catalog-core/configured-model-refs";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import type { MediaUnderstandingProvider } from "openclaw/plugin-sdk/media-understanding";
import type { MemoryEmbeddingProviderAdapter } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
import {
definePluginEntry,
@@ -30,25 +33,16 @@ import type {
import { buildOpenAICompatibleReplayPolicy } from "openclaw/plugin-sdk/provider-model-shared";
import { buildProviderToolCompatFamilyHooks } from "openclaw/plugin-sdk/provider-tools";
import { resolveConfiguredSecretInputString } from "openclaw/plugin-sdk/secret-input-runtime";
import {
buildOllamaModelDefinition,
buildOllamaProvider,
configureOllamaNonInteractive,
ensureOllamaModelPulled,
fetchOllamaModels,
promptAndConfigureOllama,
queryOllamaModelShowInfo,
resolveOllamaApiBase,
resolveOllamaSetupDefaultBaseUrl,
} from "./api.js";
import { resolveThinkingProfile as resolveOllamaThinkingProfile } from "./provider-policy-api.js";
import {
DEFAULT_OLLAMA_EMBEDDING_MODEL,
OLLAMA_CLOUD_BASE_URL,
OLLAMA_CLOUD_DEFAULT_MODELS,
OLLAMA_CLOUD_PROVIDER_ID,
OLLAMA_DEFAULT_BASE_URL,
OLLAMA_DEFAULT_MODEL,
OLLAMA_GLM52_CLOUD_MODEL_ID,
resolveOllamaSetupDefaultBaseUrl,
} from "./src/defaults.js";
import {
OLLAMA_DEFAULT_API_KEY,
@@ -60,23 +54,22 @@ import {
type OllamaPluginConfig,
} from "./src/discovery-shared.js";
import {
DEFAULT_OLLAMA_EMBEDDING_MODEL,
createOllamaEmbeddingProvider,
} from "./src/embedding-provider.js";
import { ollamaMediaUnderstandingProvider } from "./src/media-understanding-provider.js";
import { ollamaMemoryEmbeddingProviderAdapter } from "./src/memory-embedding-adapter.js";
import {
createOllamaNodeHostCommands,
createOllamaNodeInferenceTool,
createLazyOllamaNodeHostCommands,
createLazyOllamaNodeInferenceTool,
createOllamaNodeInvokePolicy,
} from "./src/node-inference.js";
} from "./src/node-inference-registration.js";
import { readProviderBaseUrl } from "./src/provider-base-url.js";
import {
buildOllamaModelDefinition,
buildOllamaProvider,
buildDefaultOllamaCloudModelDefinition,
capLocalOllamaModelContext,
capLocalOllamaProviderContext,
fetchOllamaModels,
fetchLoadedOllamaModelNames,
isOllamaCloudModel,
queryOllamaModelShowInfo,
resolveOllamaApiBase,
} from "./src/provider-models.js";
import {
findAvailableOllamaModelName,
@@ -84,13 +77,66 @@ import {
orderPreferredOllamaModelIds,
} from "./src/setup-model-selection.js";
import {
OLLAMA_INCOMPLETE_STREAM_ERROR,
createConfiguredOllamaCompatStreamWrapper,
createConfiguredOllamaStreamFn,
resolveConfiguredOllamaProviderConfig,
} from "./src/stream.js";
import { createOllamaWebSearchProvider } from "./src/web-search-provider.js";
import { checkWsl2CrashLoopRisk } from "./src/wsl2-crash-loop-check.js";
} from "./src/stream-compat.js";
import { OLLAMA_INCOMPLETE_STREAM_ERROR } from "./src/stream-contract.js";
import { createLazyConfiguredOllamaStreamFn } from "./src/stream-registration.js";
import { createLazyOllamaWebSearchProvider } from "./src/web-search-provider-registration.js";
const loadOllamaSetup = createLazyRuntimeModule(() => import("./src/setup.runtime.js"));
const loadOllamaEmbeddingProvider = createLazyRuntimeModule(
() => import("./src/embedding-provider.runtime.js"),
);
const loadOllamaMemoryEmbeddingProviderAdapter = createLazyRuntimeModule(
async () =>
(await import("./src/memory-embedding-adapter.js")).ollamaMemoryEmbeddingProviderAdapter,
);
const loadOllamaMediaUnderstandingProvider = createLazyRuntimeModule(
async () =>
(await import("./src/media-understanding-provider.js")).ollamaMediaUnderstandingProvider,
);
const lazyOllamaMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
id: OLLAMA_PROVIDER_ID,
defaultModel: DEFAULT_OLLAMA_EMBEDDING_MODEL,
transport: "remote",
authProviderId: OLLAMA_PROVIDER_ID,
create: async (options) =>
await (await loadOllamaMemoryEmbeddingProviderAdapter()).create(options),
};
const lazyOllamaMediaUnderstandingProvider: MediaUnderstandingProvider = {
id: OLLAMA_PROVIDER_ID,
capabilities: ["image"],
describeImage: async (request) => {
const provider = await loadOllamaMediaUnderstandingProvider();
if (!provider.describeImage) {
throw new Error("Ollama media understanding provider missing describeImage");
}
return await provider.describeImage(request);
},
describeImages: async (request) => {
const provider = await loadOllamaMediaUnderstandingProvider();
if (!provider.describeImages) {
throw new Error("Ollama media understanding provider missing describeImages");
}
return await provider.describeImages(request);
},
};
async function checkWsl2CrashLoopRiskLazily(api: OpenClawPluginApi): Promise<void> {
try {
const { isWSL2Sync } = await import("openclaw/plugin-sdk/runtime-env");
if (!isWSL2Sync()) {
return;
}
const { checkWsl2CrashLoopRisk } = await import("./src/wsl2-crash-loop-check.js");
await checkWsl2CrashLoopRisk(api.logger);
} catch {
// Advisory-only startup checks must not break provider registration.
}
}
function buildNativeOllamaReplayPolicy(): ProviderReplayPolicy {
return {
@@ -156,7 +202,7 @@ async function validateOllamaNonInteractive(
);
if (requestedModel && isOllamaCloudModel(requestedModel)) {
const { checkOllamaCloudAuth } = await import("./src/setup.js");
const { checkOllamaCloudAuth } = await loadOllamaSetup();
const cloudAuth = await checkOllamaCloudAuth(baseUrl);
if (!cloudAuth.signedIn) {
ctx.runtime.error(
@@ -809,7 +855,7 @@ const OLLAMA_SHARED_PROVIDER_HOOKS = {
if (model.api !== "ollama") {
return undefined;
}
return createConfiguredOllamaStreamFn({
return createLazyConfiguredOllamaStreamFn({
model,
providerBaseUrl:
readProviderBaseUrl(
@@ -847,17 +893,17 @@ export default definePluginEntry({
register(api: OpenClawPluginApi) {
const startupPluginConfig = (api.pluginConfig ?? {}) as OllamaPluginConfig;
if (api.registrationMode === "full") {
void checkWsl2CrashLoopRisk(api.logger);
void checkWsl2CrashLoopRiskLazily(api);
}
api.registerMemoryEmbeddingProvider(ollamaMemoryEmbeddingProviderAdapter);
api.registerMediaUnderstandingProvider(ollamaMediaUnderstandingProvider);
api.registerMemoryEmbeddingProvider(lazyOllamaMemoryEmbeddingProviderAdapter);
api.registerMediaUnderstandingProvider(lazyOllamaMediaUnderstandingProvider);
if (startupPluginConfig.nodeInference?.enabled !== false) {
for (const command of createOllamaNodeHostCommands()) {
for (const command of createLazyOllamaNodeHostCommands()) {
api.registerNodeHostCommand(command);
}
}
api.registerNodeInvokePolicy(createOllamaNodeInvokePolicy());
api.registerTool(createOllamaNodeInferenceTool(api));
api.registerTool(createLazyOllamaNodeInferenceTool(api));
const resolveCurrentPluginConfig = (config?: OpenClawConfig): OllamaPluginConfig => {
const runtimePluginConfig = resolvePluginConfigObject(config, "ollama");
if (runtimePluginConfig) {
@@ -865,7 +911,7 @@ export default definePluginEntry({
}
return config ? {} : startupPluginConfig;
};
api.registerWebSearchProvider(createOllamaWebSearchProvider());
api.registerWebSearchProvider(createLazyOllamaWebSearchProvider());
api.registerProvider({
id: OLLAMA_CLOUD_PROVIDER_ID,
label: "Ollama Cloud",
@@ -1002,6 +1048,7 @@ export default definePluginEntry({
},
},
run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
const { promptAndConfigureOllama } = await loadOllamaSetup();
const result = await promptAndConfigureOllama({
cfg: ctx.config,
env: ctx.env,
@@ -1034,6 +1081,7 @@ export default definePluginEntry({
},
validateNonInteractive: validateOllamaNonInteractive,
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => {
const { configureOllamaNonInteractive } = await loadOllamaSetup();
return await configureOllamaNonInteractive({
nextConfig: ctx.config,
opts: {
@@ -1079,6 +1127,7 @@ export default definePluginEntry({
if (!model.startsWith("ollama/")) {
return;
}
const { ensureOllamaModelPulled } = await loadOllamaSetup();
await ensureOllamaModelPulled({ config, model, prompter });
},
...OLLAMA_SHARED_PROVIDER_HOOKS,
@@ -1093,6 +1142,7 @@ export default definePluginEntry({
capContextTokens: true,
}),
createEmbeddingProvider: async ({ config, model, provider: embeddingProvider, remote }) => {
const { createOllamaEmbeddingProvider } = await loadOllamaEmbeddingProvider();
const { provider, client } = await createOllamaEmbeddingProvider({
config,
remote,
+271
View File
@@ -0,0 +1,271 @@
import type { MediaUnderstandingProvider } from "openclaw/plugin-sdk/media-understanding";
import type { MemoryEmbeddingProviderAdapter } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
import type {
AnyAgentTool,
OpenClawPluginNodeHostCommand,
ProviderPlugin,
} from "openclaw/plugin-sdk/plugin-entry";
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/plugin-test-runtime";
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
import { afterEach, describe, expect, it, vi } from "vitest";
describe("ollama lazy imports", () => {
afterEach(() => {
for (const moduleId of [
"./src/embedding-provider.runtime.js",
"./src/media-understanding-provider.js",
"./src/memory-embedding-adapter.js",
"./src/node-inference.js",
"./src/setup.runtime.js",
"./src/stream.runtime.js",
"./src/web-search-provider.runtime.js",
"./src/wsl2-crash-loop-check.js",
"openclaw/plugin-sdk/runtime-env",
]) {
vi.doUnmock(moduleId);
}
vi.resetModules();
});
it("loads optional runtime owners only on first use", async () => {
let embeddingImports = 0;
let mediaImports = 0;
let memoryImports = 0;
let nodeInferenceImports = 0;
let setupImports = 0;
let streamImports = 0;
let webSearchImports = 0;
let wslImports = 0;
let wslChecks = 0;
vi.doMock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => ({
...(await importOriginal<typeof import("openclaw/plugin-sdk/runtime-env")>()),
isWSL2Sync: () => {
wslChecks += 1;
return false;
},
}));
vi.doMock("./src/embedding-provider.runtime.js", () => {
embeddingImports += 1;
return {
createOllamaEmbeddingProvider: async () => ({
provider: { id: "ollama", model: "nomic-embed-text" },
client: { baseUrl: "http://127.0.0.1:11434" },
}),
};
});
vi.doMock("./src/memory-embedding-adapter.js", () => {
memoryImports += 1;
return {
ollamaMemoryEmbeddingProviderAdapter: {
id: "ollama",
defaultModel: "nomic-embed-text",
transport: "remote",
authProviderId: "ollama",
create: async () => ({ provider: null }),
},
};
});
vi.doMock("./src/media-understanding-provider.js", () => {
mediaImports += 1;
return {
ollamaMediaUnderstandingProvider: {
id: "ollama",
capabilities: ["image"],
describeImage: async () => ({ text: "image" }),
describeImages: async () => ({ text: "images" }),
},
};
});
vi.doMock("./src/node-inference.js", () => {
nodeInferenceImports += 1;
return {
createOllamaNodeHostCommands: () => [
{
command: "ollama.models",
cap: "local-inference",
handle: async () => JSON.stringify({ provider: "ollama", models: [] }),
},
{
command: "ollama.chat",
cap: "local-inference",
handle: async () => JSON.stringify({ provider: "ollama", response: "ok" }),
},
],
createOllamaNodeInferenceTool: () => ({
name: "node_inference",
label: "Node Inference",
description: "test",
parameters: { type: "object" },
execute: async () => ({ content: [] }),
}),
};
});
vi.doMock("./src/setup.runtime.js", () => {
setupImports += 1;
return {
promptAndConfigureOllama: async () => ({
credential: "ollama-local",
config: {},
}),
};
});
vi.doMock("./src/stream.runtime.js", () => {
streamImports += 1;
return {
createConfiguredOllamaStreamFn: () => async () => ({ transport: "ollama" }),
};
});
vi.doMock("./src/web-search-provider.runtime.js", () => {
webSearchImports += 1;
return {
createOllamaWebSearchProvider: () => ({
id: "ollama",
label: "Ollama Web Search",
runSetup: async ({ config }: { config: unknown }) => config,
createTool: () => ({
description: "test",
parameters: { type: "object" },
execute: async ({ query }: { query: string }) => ({ query, results: [] }),
}),
}),
};
});
vi.doMock("./src/wsl2-crash-loop-check.js", () => {
wslImports += 1;
return { checkWsl2CrashLoopRisk: async () => {} };
});
const { default: ollamaPlugin } = await import("./index.js");
let embeddingAdapter: MemoryEmbeddingProviderAdapter | undefined;
let mediaProvider: MediaUnderstandingProvider | undefined;
const nodeCommands: OpenClawPluginNodeHostCommand[] = [];
const providers: ProviderPlugin[] = [];
let nodeInferenceTool: AnyAgentTool | undefined;
let webSearchProvider: WebSearchProviderPlugin | undefined;
ollamaPlugin.register(
createTestPluginApi({
id: "ollama",
name: "Ollama",
source: "test",
config: {},
runtime: createPluginRuntimeMock(),
registerMemoryEmbeddingProvider: (adapter) => {
embeddingAdapter = adapter;
},
registerMediaUnderstandingProvider: (provider) => {
mediaProvider = provider;
},
registerNodeHostCommand: (command) => nodeCommands.push(command),
registerTool: (tool) => {
if (typeof tool !== "function" && tool.name === "node_inference") {
nodeInferenceTool = tool;
}
},
registerProvider: (provider) => providers.push(provider),
registerWebSearchProvider: (provider) => {
webSearchProvider = provider;
},
}),
);
await vi.waitFor(() => expect(wslChecks).toBe(1));
expect({
embeddingImports,
mediaImports,
memoryImports,
nodeInferenceImports,
setupImports,
streamImports,
webSearchImports,
wslImports,
}).toEqual({
embeddingImports: 0,
mediaImports: 0,
memoryImports: 0,
nodeInferenceImports: 0,
setupImports: 0,
streamImports: 0,
webSearchImports: 0,
wslImports: 0,
});
await expect(embeddingAdapter?.create({} as never)).resolves.toEqual({ provider: null });
await expect(mediaProvider?.describeImage?.({} as never)).resolves.toEqual({ text: "image" });
await expect(nodeCommands[0]?.handle()).resolves.toBe(
JSON.stringify({ provider: "ollama", models: [] }),
);
await expect(nodeInferenceTool?.execute("call-1", {}, undefined)).resolves.toEqual({
content: [],
});
await expect(
webSearchProvider?.createTool({ config: {} } as never)?.execute({ query: "openclaw" }),
).resolves.toEqual({
query: "openclaw",
results: [],
});
const localProvider = providers.find((provider) => provider.id === "ollama");
await expect(
localProvider?.createEmbeddingProvider?.({
config: {},
model: "",
provider: "ollama",
} as never),
).resolves.toMatchObject({
id: "ollama",
client: { baseUrl: "http://127.0.0.1:11434" },
});
await expect(
localProvider?.auth[0]?.run({
config: {},
prompter: {},
} as never),
).resolves.toMatchObject({
configPatch: {},
});
const streamFn = localProvider?.createStreamFn?.({
config: {
models: {
providers: {
ollama: {
api: "ollama",
baseUrl: "http://127.0.0.1:11434",
models: [],
},
},
},
},
model: {
api: "ollama",
id: "qwen3:32b",
provider: "ollama",
},
provider: "ollama",
} as never);
await expect(streamFn?.({} as never, {} as never, {})).resolves.toEqual({
transport: "ollama",
});
expect({
embeddingImports,
mediaImports,
memoryImports,
nodeInferenceImports,
setupImports,
streamImports,
webSearchImports,
wslImports,
}).toEqual({
embeddingImports: 1,
mediaImports: 1,
memoryImports: 1,
nodeInferenceImports: 1,
setupImports: 1,
streamImports: 1,
webSearchImports: 1,
wslImports: 0,
});
});
});
+1 -1
View File
@@ -8,7 +8,7 @@ import { expectDefined } from "@openclaw/normalization-core";
import { describe, expect, it } from "vitest";
import { isLocalOllamaBaseUrl } from "./src/discovery-shared.js";
import { createOllamaEmbeddingProvider } from "./src/embedding-provider.js";
import { createOllamaStreamFn } from "./src/stream.js";
import { createOllamaStreamFn } from "./src/stream.runtime.js";
import { createOllamaWebSearchProvider } from "./src/web-search-provider.js";
const LIVE = process.env.OPENCLAW_LIVE_TEST === "1" && process.env.OPENCLAW_LIVE_OLLAMA === "1";
+1 -1
View File
@@ -13,7 +13,7 @@ export {
resolveOllamaCompatNumCtxEnabled,
shouldInjectOllamaCompatNumCtx,
wrapOllamaCompatNumCtx,
} from "./src/stream.js";
} from "./src/stream-api.js";
export {
createOllamaEmbeddingProvider,
DEFAULT_OLLAMA_EMBEDDING_MODEL,
+8 -1
View File
@@ -1,6 +1,6 @@
// Ollama plugin module implements defaults behavior.
export const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434";
export const OLLAMA_DOCKER_HOST_BASE_URL = "http://host.docker.internal:11434";
const OLLAMA_DOCKER_HOST_BASE_URL = "http://host.docker.internal:11434";
export const OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
export const OLLAMA_CLOUD_PROVIDER_ID = "ollama-cloud";
export const OLLAMA_GLM52_CLOUD_MODEL_ID = "glm-5.2";
@@ -33,3 +33,10 @@ export const OLLAMA_DEFAULT_COST = {
};
export const OLLAMA_DEFAULT_MODEL = "gemma4";
export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text";
export function resolveOllamaSetupDefaultBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
return ["1", "true", "yes", "on"].includes(env.OPENCLAW_DOCKER_SETUP?.trim().toLowerCase() ?? "")
? OLLAMA_DOCKER_HOST_BASE_URL
: OLLAMA_DEFAULT_BASE_URL;
}
@@ -0,0 +1,501 @@
// Ollama embedding runtime implements provider integration.
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import {
isKnownEnvApiKeyMarker,
isNonSecretApiKeyMarker,
normalizeOptionalSecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import {
coerceSecretRef,
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
resolveConfiguredSecretInputString,
} from "openclaw/plugin-sdk/secret-input-runtime";
import {
formatErrorMessage,
ssrfPolicyFromHttpBaseUrlAllowedOrigin,
type SsrFPolicy,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { fetchConfiguredLocalOriginWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime-internal";
import { DEFAULT_OLLAMA_EMBEDDING_MODEL, OLLAMA_CLOUD_BASE_URL } from "./defaults.js";
import { normalizeOllamaWireModelId } from "./model-id.js";
import { readProviderBaseUrl } from "./provider-base-url.js";
import { resolveOllamaApiBase } from "./provider-models.js";
export type OllamaEmbeddingProvider = {
id: string;
model: string;
maxInputTokens?: number;
embedQuery: (text: string, options?: { signal?: AbortSignal }) => Promise<number[]>;
embedBatch: (texts: string[], options?: { signal?: AbortSignal }) => Promise<number[][]>;
};
type MemoryCoreAcquireLocalService = (
target: {
providerId: string;
baseUrl: string;
headers?: HeadersInit;
},
signal?: AbortSignal | null,
) => Promise<{ release: () => void } | undefined>;
type OllamaEmbeddingOptions = {
config: OpenClawConfig;
agentDir?: string;
provider?: string;
remote?: {
baseUrl?: string;
apiKey?: unknown;
headers?: Record<string, string>;
};
model: string;
fallback?: string;
local?: unknown;
outputDimensionality?: number;
taskType?: unknown;
acquireLocalService?: MemoryCoreAcquireLocalService;
};
export type OllamaEmbeddingClient = {
baseUrl: string;
headers: Record<string, string>;
ssrfPolicy?: SsrFPolicy;
model: string;
outputDimensionality?: number;
localServiceTarget?: Parameters<MemoryCoreAcquireLocalService>[0];
acquireLocalService?: MemoryCoreAcquireLocalService;
embedBatch: (texts: string[]) => Promise<number[][]>;
};
type OllamaEmbeddingClientConfig = Omit<OllamaEmbeddingClient, "embedBatch">;
export { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./defaults.js";
const OLLAMA_EMBED_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const QUERY_INSTRUCTION_TEMPLATES = [
{
prefix: "qwen3-embedding",
template:
"Instruct: Given a user query, retrieve relevant memory notes and documents\nQuery:{query}",
},
{
prefix: "nomic-embed-text",
template: "search_query: {query}",
},
{
prefix: "mxbai-embed-large",
template: "Represent this sentence for searching relevant passages: {query}",
},
] as const;
function sanitizeAndNormalizeEmbedding(vec: unknown[], outputDimensionality?: number): number[] {
const selected =
typeof outputDimensionality === "number" ? vec.slice(0, outputDimensionality) : vec;
const sanitized = selected.map((value) => {
if (typeof value !== "number") {
throw new Error("Ollama embed response contains a non-number embedding value");
}
return Number.isFinite(value) ? value : 0;
});
const magnitude = Math.sqrt(sanitized.reduce((sum, value) => sum + value * value, 0));
if (magnitude < 1e-10) {
return sanitized;
}
return sanitized.map((value) => value / magnitude);
}
async function withRemoteHttpResponse<T>(params: {
url: string;
init?: RequestInit;
signal?: AbortSignal;
ssrfPolicy?: SsrFPolicy;
configuredLocalOriginBaseUrl: string;
onResponse: (response: Response) => Promise<T>;
}): Promise<T> {
const { response, release } = await fetchConfiguredLocalOriginWithSsrFGuard({
url: params.url,
init: params.init,
signal: params.signal,
policy: params.ssrfPolicy,
configuredLocalOriginBaseUrl: params.configuredLocalOriginBaseUrl,
auditContext: "ollama-memory-embedding",
});
try {
return await params.onResponse(response);
} finally {
await release();
}
}
async function readOllamaEmbeddingJsonResponse(
response: Response,
): Promise<{ embeddings?: unknown }> {
const payload = await readProviderJsonResponse<unknown>(response, "Ollama embed response");
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
throw new Error("Ollama embed response returned a non-object JSON payload");
}
return payload as { embeddings?: unknown };
}
function normalizeEmbeddingModel(model: string, providerId?: string): string {
const trimmed = model.trim();
if (!trimmed) {
return DEFAULT_OLLAMA_EMBEDDING_MODEL;
}
return normalizeOllamaWireModelId(trimmed, providerId);
}
function applyQueryInstructionTemplate(model: string, queryText: string): string {
const normalizedModel = model.trim().toLowerCase();
const match = QUERY_INSTRUCTION_TEMPLATES.find(({ prefix }) =>
normalizedModel.startsWith(prefix),
);
return match ? match.template.replace("{query}", () => queryText) : queryText;
}
function resolveConfiguredProvider(options: OllamaEmbeddingOptions) {
const providers = options.config.models?.providers;
if (!providers) {
return undefined;
}
const providerId = options.provider?.trim() || "ollama";
const direct = providers[providerId];
if (direct) {
return { providerId, config: direct };
}
const normalized = normalizeProviderId(providerId);
for (const [candidateId, candidate] of Object.entries(providers)) {
if (normalizeProviderId(candidateId) === normalized) {
return { providerId: candidateId, config: candidate };
}
}
const fallback = providers.ollama;
return fallback ? { providerId: "ollama", config: fallback } : undefined;
}
function resolveMemorySecretInputString(params: {
value: unknown;
path: string;
}): string | undefined {
if (!hasConfiguredSecretInput(params.value)) {
return undefined;
}
return normalizeResolvedSecretInputString({
value: params.value,
path: params.path,
});
}
type OllamaEmbeddingBaseUrlOrigin = "remote-config" | "provider-config" | "default";
type OllamaEmbeddingSourceResolution = "unset" | "opt-out" | { apiKey: string };
type OllamaEmbeddingResolvedKeys = {
remote: OllamaEmbeddingSourceResolution;
provider: OllamaEmbeddingSourceResolution;
env: string | undefined;
};
function resolveSourcedOllamaEmbeddingKey(params: {
configString: string | undefined;
declared: boolean;
resolvedSecretRef?: boolean;
}): OllamaEmbeddingSourceResolution {
if (params.configString !== undefined) {
// Resolved SecretRefs are opaque credentials, even when their values happen
// to match an ambient env marker or the synthetic local-auth placeholder.
if (params.resolvedSecretRef || !isNonSecretApiKeyMarker(params.configString)) {
return { apiKey: params.configString };
}
if (!isKnownEnvApiKeyMarker(params.configString)) {
return "opt-out";
}
const envKey = resolveEnvApiKey("ollama")?.apiKey;
return envKey && !isNonSecretApiKeyMarker(envKey) ? { apiKey: envKey } : "opt-out";
}
return params.declared ? "opt-out" : "unset";
}
async function resolveConfiguredOllamaEmbeddingSecret(params: {
config: OpenClawConfig;
value: unknown;
path: string;
}): Promise<string | undefined> {
if (!coerceSecretRef(params.value, params.config.secrets?.defaults)) {
return normalizeOptionalSecretInput(params.value);
}
const resolved = await resolveConfiguredSecretInputString({
config: params.config,
env: process.env,
value: params.value,
path: params.path,
unresolvedReasonStyle: "detailed",
});
if (resolved.unresolvedRefReason) {
throw new Error(resolved.unresolvedRefReason);
}
return normalizeOptionalSecretInput(resolved.value);
}
async function resolveOllamaEmbeddingResolvedKeys(
options: OllamaEmbeddingOptions,
providerConfig: ReturnType<typeof resolveConfiguredProvider>,
providerOwnsHost: boolean,
): Promise<OllamaEmbeddingResolvedKeys> {
const remoteValue = options.remote?.apiKey;
const remote = resolveSourcedOllamaEmbeddingKey({
configString: resolveMemorySecretInputString({
value: remoteValue,
path: "memory.search.remote.apiKey",
}),
declared: hasConfiguredSecretInput(remoteValue),
});
const providerValue = providerConfig?.config.apiKey;
let provider: OllamaEmbeddingSourceResolution = "unset";
if (remote === "unset" && providerOwnsHost && providerConfig) {
provider = resolveSourcedOllamaEmbeddingKey({
configString: await resolveConfiguredOllamaEmbeddingSecret({
config: options.config,
value: providerValue,
path: `models.providers.${providerConfig.providerId}.apiKey`,
}),
declared: hasConfiguredSecretInput(providerValue),
resolvedSecretRef: Boolean(coerceSecretRef(providerValue, options.config.secrets?.defaults)),
});
}
const envKey = resolveEnvApiKey("ollama")?.apiKey;
const env = envKey && !isNonSecretApiKeyMarker(envKey) ? envKey : undefined;
return { remote, provider, env };
}
function resolveOllamaEmbeddingBaseUrl(params: {
remoteBaseUrl?: string;
providerConfig: ReturnType<typeof resolveConfiguredProvider>;
}): { baseUrl: string; origin: OllamaEmbeddingBaseUrlOrigin } {
const remoteBaseUrl = params.remoteBaseUrl?.trim();
if (remoteBaseUrl) {
return { baseUrl: resolveOllamaApiBase(remoteBaseUrl), origin: "remote-config" };
}
const providerBaseUrl = readProviderBaseUrl(params.providerConfig?.config);
if (providerBaseUrl) {
return { baseUrl: resolveOllamaApiBase(providerBaseUrl), origin: "provider-config" };
}
return { baseUrl: resolveOllamaApiBase(undefined), origin: "default" };
}
function normalizeOllamaHostKey(baseUrl: string): string | undefined {
try {
const parsed = new URL(baseUrl);
let hostname = parsed.hostname.toLowerCase();
if (hostname === "localhost" || hostname === "::1" || hostname === "[::1]") {
hostname = "127.0.0.1";
}
const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
const path = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, "");
return `${parsed.protocol}//${hostname}:${port}${path}`;
} catch {
return undefined;
}
}
function areOllamaHostsEquivalent(a: string, b: string): boolean {
const aKey = normalizeOllamaHostKey(a);
const bKey = normalizeOllamaHostKey(b);
return aKey !== undefined && bKey !== undefined && aKey === bKey;
}
function isOllamaCloudBaseUrl(baseUrl: string): boolean {
return areOllamaHostsEquivalent(baseUrl, OLLAMA_CLOUD_BASE_URL);
}
function selectOllamaEmbeddingApiKey(params: {
resolved: OllamaEmbeddingResolvedKeys;
baseUrl: string;
providerOwnsHost: boolean;
}): string | undefined {
if (params.resolved.remote !== "unset") {
return typeof params.resolved.remote === "object" ? params.resolved.remote.apiKey : undefined;
}
if (params.resolved.provider !== "unset" && params.providerOwnsHost) {
return typeof params.resolved.provider === "object"
? params.resolved.provider.apiKey
: undefined;
}
if (params.resolved.env && isOllamaCloudBaseUrl(params.baseUrl)) {
return params.resolved.env;
}
return undefined;
}
async function resolveOllamaEmbeddingClient(
options: OllamaEmbeddingOptions,
): Promise<OllamaEmbeddingClientConfig> {
const providerConfig = resolveConfiguredProvider(options);
const { baseUrl, origin: baseUrlOrigin } = resolveOllamaEmbeddingBaseUrl({
remoteBaseUrl: options.remote?.baseUrl,
providerConfig,
});
const model = normalizeEmbeddingModel(options.model, options.provider);
const providerOwnedHost = resolveOllamaApiBase(readProviderBaseUrl(providerConfig?.config));
// Provider keys and headers belong to this origin only; a remote override
// must neither resolve nor inherit another host's configured credentials.
const providerOwnsHost =
baseUrlOrigin !== "remote-config" || areOllamaHostsEquivalent(baseUrl, providerOwnedHost);
const remoteHeaderNames = new Set(
Object.keys(options.remote?.headers ?? {}).map((headerName) => headerName.toLowerCase()),
);
const headerOverrides: Record<string, string> = {};
if (providerOwnsHost && providerConfig?.config.headers) {
for (const [headerName, headerValue] of Object.entries(providerConfig.config.headers)) {
if (remoteHeaderNames.has(headerName.toLowerCase())) {
continue;
}
const resolvedValue = await resolveConfiguredOllamaEmbeddingSecret({
config: options.config,
value: headerValue,
path: `models.providers.${providerConfig.providerId}.headers.${headerName}`,
});
if (resolvedValue) {
headerOverrides[headerName] = resolvedValue;
}
}
}
Object.assign(headerOverrides, options.remote?.headers);
const headers: Record<string, string> = {
"Content-Type": "application/json",
...headerOverrides,
};
// Explicit HTTP auth owns its request; resolving a competing bearer can leak
// another tenant's key or fail on a SecretRef that is already inactive.
const hasAuthorizationHeader = Object.entries(headers).some(
([name, value]) => name.toLowerCase() === "authorization" && value.trim().length > 0,
);
const apiKey = hasAuthorizationHeader
? undefined
: selectOllamaEmbeddingApiKey({
resolved: await resolveOllamaEmbeddingResolvedKeys(
options,
providerConfig,
providerOwnsHost,
),
baseUrl,
providerOwnsHost,
});
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
const localService = providerConfig?.config.localService;
return {
baseUrl,
headers,
ssrfPolicy: ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl),
model,
outputDimensionality: options.outputDimensionality,
...(localService && baseUrlOrigin !== "remote-config"
? {
localServiceTarget: {
providerId: providerConfig.providerId,
baseUrl: `${baseUrl.replace(/\/+$/, "")}/v1`,
headers,
},
acquireLocalService: options.acquireLocalService,
}
: {}),
};
}
export async function createOllamaEmbeddingProvider(
options: OllamaEmbeddingOptions,
): Promise<{ provider: OllamaEmbeddingProvider; client: OllamaEmbeddingClient }> {
const client = await resolveOllamaEmbeddingClient(options);
const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embed`;
const embedMany = async (input: string | string[], signal?: AbortSignal): Promise<number[][]> => {
const localServiceLease =
client.localServiceTarget && client.acquireLocalService
? await client.acquireLocalService(client.localServiceTarget, signal)
: undefined;
let json: Awaited<ReturnType<typeof readOllamaEmbeddingJsonResponse>>;
try {
json = await withRemoteHttpResponse({
url: embedUrl,
ssrfPolicy: client.ssrfPolicy,
configuredLocalOriginBaseUrl: client.baseUrl,
signal,
init: {
method: "POST",
headers: client.headers,
body: JSON.stringify({ model: client.model, input }),
},
onResponse: async (response) => {
if (!response.ok) {
const detail = await readResponseTextLimited(
response,
OLLAMA_EMBED_ERROR_BODY_LIMIT_BYTES,
).catch(() => "unknown error");
throw new Error(`Ollama embed HTTP ${response.status}: ${detail}`);
}
return await readOllamaEmbeddingJsonResponse(response);
},
});
} finally {
localServiceLease?.release();
}
if (!Array.isArray(json.embeddings)) {
throw new Error("Ollama embed response missing embeddings[]");
}
const expectedCount = Array.isArray(input) ? input.length : 1;
if (json.embeddings.length !== expectedCount) {
throw new Error(
`Ollama embed response returned ${json.embeddings.length} embeddings for ${expectedCount} inputs`,
);
}
return json.embeddings.map((embedding) => {
if (!Array.isArray(embedding)) {
throw new Error("Ollama embed response contains a non-array embedding");
}
return sanitizeAndNormalizeEmbedding(embedding, client.outputDimensionality);
});
};
const embedOne = async (text: string, signal?: AbortSignal): Promise<number[]> => {
const [embedding] = await embedMany(text, signal);
if (!embedding) {
throw new Error("Ollama embed response returned no embedding");
}
return embedding;
};
const embedQuery = async (
text: string,
optionsValue?: { signal?: AbortSignal },
): Promise<number[]> =>
await embedOne(applyQueryInstructionTemplate(client.model, text), optionsValue?.signal);
const provider: OllamaEmbeddingProvider = {
id: "ollama",
model: client.model,
embedQuery,
embedBatch: async (texts, optionsLocal) =>
texts.length === 0 ? [] : await embedMany(texts, optionsLocal?.signal),
};
return {
provider,
client: {
...client,
embedBatch: async (texts) => {
try {
return await provider.embedBatch(texts);
} catch (err) {
throw new Error(formatErrorMessage(err), { cause: err });
}
},
},
};
}
+13 -497
View File
@@ -1,501 +1,17 @@
// Ollama provider module implements model/runtime integration.
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
import {
isKnownEnvApiKeyMarker,
isNonSecretApiKeyMarker,
normalizeOptionalSecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import {
coerceSecretRef,
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
resolveConfiguredSecretInputString,
} from "openclaw/plugin-sdk/secret-input-runtime";
import {
formatErrorMessage,
ssrfPolicyFromHttpBaseUrlAllowedOrigin,
type SsrFPolicy,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { fetchConfiguredLocalOriginWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime-internal";
import { OLLAMA_CLOUD_BASE_URL } from "./defaults.js";
import { normalizeOllamaWireModelId } from "./model-id.js";
import { readProviderBaseUrl } from "./provider-base-url.js";
import { resolveOllamaApiBase } from "./provider-models.js";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
export type OllamaEmbeddingProvider = {
id: string;
model: string;
maxInputTokens?: number;
embedQuery: (text: string, options?: { signal?: AbortSignal }) => Promise<number[]>;
embedBatch: (texts: string[], options?: { signal?: AbortSignal }) => Promise<number[][]>;
};
export { DEFAULT_OLLAMA_EMBEDDING_MODEL } from "./defaults.js";
export type {
OllamaEmbeddingClient,
OllamaEmbeddingProvider,
} from "./embedding-provider.runtime.js";
type MemoryCoreAcquireLocalService = (
target: {
providerId: string;
baseUrl: string;
headers?: HeadersInit;
},
signal?: AbortSignal | null,
) => Promise<{ release: () => void } | undefined>;
type OllamaEmbeddingRuntime = typeof import("./embedding-provider.runtime.js");
type OllamaEmbeddingOptions = {
config: OpenClawConfig;
agentDir?: string;
provider?: string;
remote?: {
baseUrl?: string;
apiKey?: unknown;
headers?: Record<string, string>;
};
model: string;
fallback?: string;
local?: unknown;
outputDimensionality?: number;
taskType?: unknown;
acquireLocalService?: MemoryCoreAcquireLocalService;
};
const loadOllamaEmbeddingRuntime = createLazyRuntimeModule(
() => import("./embedding-provider.runtime.js"),
);
export type OllamaEmbeddingClient = {
baseUrl: string;
headers: Record<string, string>;
ssrfPolicy?: SsrFPolicy;
model: string;
outputDimensionality?: number;
localServiceTarget?: Parameters<MemoryCoreAcquireLocalService>[0];
acquireLocalService?: MemoryCoreAcquireLocalService;
embedBatch: (texts: string[]) => Promise<number[][]>;
};
type OllamaEmbeddingClientConfig = Omit<OllamaEmbeddingClient, "embedBatch">;
export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text";
const OLLAMA_EMBED_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
const QUERY_INSTRUCTION_TEMPLATES = [
{
prefix: "qwen3-embedding",
template:
"Instruct: Given a user query, retrieve relevant memory notes and documents\nQuery:{query}",
},
{
prefix: "nomic-embed-text",
template: "search_query: {query}",
},
{
prefix: "mxbai-embed-large",
template: "Represent this sentence for searching relevant passages: {query}",
},
] as const;
function sanitizeAndNormalizeEmbedding(vec: unknown[], outputDimensionality?: number): number[] {
const selected =
typeof outputDimensionality === "number" ? vec.slice(0, outputDimensionality) : vec;
const sanitized = selected.map((value) => {
if (typeof value !== "number") {
throw new Error("Ollama embed response contains a non-number embedding value");
}
return Number.isFinite(value) ? value : 0;
});
const magnitude = Math.sqrt(sanitized.reduce((sum, value) => sum + value * value, 0));
if (magnitude < 1e-10) {
return sanitized;
}
return sanitized.map((value) => value / magnitude);
}
async function withRemoteHttpResponse<T>(params: {
url: string;
init?: RequestInit;
signal?: AbortSignal;
ssrfPolicy?: SsrFPolicy;
configuredLocalOriginBaseUrl: string;
onResponse: (response: Response) => Promise<T>;
}): Promise<T> {
const { response, release } = await fetchConfiguredLocalOriginWithSsrFGuard({
url: params.url,
init: params.init,
signal: params.signal,
policy: params.ssrfPolicy,
configuredLocalOriginBaseUrl: params.configuredLocalOriginBaseUrl,
auditContext: "ollama-memory-embedding",
});
try {
return await params.onResponse(response);
} finally {
await release();
}
}
async function readOllamaEmbeddingJsonResponse(
response: Response,
): Promise<{ embeddings?: unknown }> {
const payload = await readProviderJsonResponse<unknown>(response, "Ollama embed response");
if (typeof payload !== "object" || payload === null || Array.isArray(payload)) {
throw new Error("Ollama embed response returned a non-object JSON payload");
}
return payload as { embeddings?: unknown };
}
function normalizeEmbeddingModel(model: string, providerId?: string): string {
const trimmed = model.trim();
if (!trimmed) {
return DEFAULT_OLLAMA_EMBEDDING_MODEL;
}
return normalizeOllamaWireModelId(trimmed, providerId);
}
function applyQueryInstructionTemplate(model: string, queryText: string): string {
const normalizedModel = model.trim().toLowerCase();
const match = QUERY_INSTRUCTION_TEMPLATES.find(({ prefix }) =>
normalizedModel.startsWith(prefix),
);
return match ? match.template.replace("{query}", () => queryText) : queryText;
}
function resolveConfiguredProvider(options: OllamaEmbeddingOptions) {
const providers = options.config.models?.providers;
if (!providers) {
return undefined;
}
const providerId = options.provider?.trim() || "ollama";
const direct = providers[providerId];
if (direct) {
return { providerId, config: direct };
}
const normalized = normalizeProviderId(providerId);
for (const [candidateId, candidate] of Object.entries(providers)) {
if (normalizeProviderId(candidateId) === normalized) {
return { providerId: candidateId, config: candidate };
}
}
const fallback = providers.ollama;
return fallback ? { providerId: "ollama", config: fallback } : undefined;
}
function resolveMemorySecretInputString(params: {
value: unknown;
path: string;
}): string | undefined {
if (!hasConfiguredSecretInput(params.value)) {
return undefined;
}
return normalizeResolvedSecretInputString({
value: params.value,
path: params.path,
});
}
type OllamaEmbeddingBaseUrlOrigin = "remote-config" | "provider-config" | "default";
type OllamaEmbeddingSourceResolution = "unset" | "opt-out" | { apiKey: string };
type OllamaEmbeddingResolvedKeys = {
remote: OllamaEmbeddingSourceResolution;
provider: OllamaEmbeddingSourceResolution;
env: string | undefined;
};
function resolveSourcedOllamaEmbeddingKey(params: {
configString: string | undefined;
declared: boolean;
resolvedSecretRef?: boolean;
}): OllamaEmbeddingSourceResolution {
if (params.configString !== undefined) {
// Resolved SecretRefs are opaque credentials, even when their values happen
// to match an ambient env marker or the synthetic local-auth placeholder.
if (params.resolvedSecretRef || !isNonSecretApiKeyMarker(params.configString)) {
return { apiKey: params.configString };
}
if (!isKnownEnvApiKeyMarker(params.configString)) {
return "opt-out";
}
const envKey = resolveEnvApiKey("ollama")?.apiKey;
return envKey && !isNonSecretApiKeyMarker(envKey) ? { apiKey: envKey } : "opt-out";
}
return params.declared ? "opt-out" : "unset";
}
async function resolveConfiguredOllamaEmbeddingSecret(params: {
config: OpenClawConfig;
value: unknown;
path: string;
}): Promise<string | undefined> {
if (!coerceSecretRef(params.value, params.config.secrets?.defaults)) {
return normalizeOptionalSecretInput(params.value);
}
const resolved = await resolveConfiguredSecretInputString({
config: params.config,
env: process.env,
value: params.value,
path: params.path,
unresolvedReasonStyle: "detailed",
});
if (resolved.unresolvedRefReason) {
throw new Error(resolved.unresolvedRefReason);
}
return normalizeOptionalSecretInput(resolved.value);
}
async function resolveOllamaEmbeddingResolvedKeys(
options: OllamaEmbeddingOptions,
providerConfig: ReturnType<typeof resolveConfiguredProvider>,
providerOwnsHost: boolean,
): Promise<OllamaEmbeddingResolvedKeys> {
const remoteValue = options.remote?.apiKey;
const remote = resolveSourcedOllamaEmbeddingKey({
configString: resolveMemorySecretInputString({
value: remoteValue,
path: "memory.search.remote.apiKey",
}),
declared: hasConfiguredSecretInput(remoteValue),
});
const providerValue = providerConfig?.config.apiKey;
let provider: OllamaEmbeddingSourceResolution = "unset";
if (remote === "unset" && providerOwnsHost && providerConfig) {
provider = resolveSourcedOllamaEmbeddingKey({
configString: await resolveConfiguredOllamaEmbeddingSecret({
config: options.config,
value: providerValue,
path: `models.providers.${providerConfig.providerId}.apiKey`,
}),
declared: hasConfiguredSecretInput(providerValue),
resolvedSecretRef: Boolean(coerceSecretRef(providerValue, options.config.secrets?.defaults)),
});
}
const envKey = resolveEnvApiKey("ollama")?.apiKey;
const env = envKey && !isNonSecretApiKeyMarker(envKey) ? envKey : undefined;
return { remote, provider, env };
}
function resolveOllamaEmbeddingBaseUrl(params: {
remoteBaseUrl?: string;
providerConfig: ReturnType<typeof resolveConfiguredProvider>;
}): { baseUrl: string; origin: OllamaEmbeddingBaseUrlOrigin } {
const remoteBaseUrl = params.remoteBaseUrl?.trim();
if (remoteBaseUrl) {
return { baseUrl: resolveOllamaApiBase(remoteBaseUrl), origin: "remote-config" };
}
const providerBaseUrl = readProviderBaseUrl(params.providerConfig?.config);
if (providerBaseUrl) {
return { baseUrl: resolveOllamaApiBase(providerBaseUrl), origin: "provider-config" };
}
return { baseUrl: resolveOllamaApiBase(undefined), origin: "default" };
}
function normalizeOllamaHostKey(baseUrl: string): string | undefined {
try {
const parsed = new URL(baseUrl);
let hostname = parsed.hostname.toLowerCase();
if (hostname === "localhost" || hostname === "::1" || hostname === "[::1]") {
hostname = "127.0.0.1";
}
const port = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
const path = parsed.pathname === "/" ? "" : parsed.pathname.replace(/\/$/, "");
return `${parsed.protocol}//${hostname}:${port}${path}`;
} catch {
return undefined;
}
}
function areOllamaHostsEquivalent(a: string, b: string): boolean {
const aKey = normalizeOllamaHostKey(a);
const bKey = normalizeOllamaHostKey(b);
return aKey !== undefined && bKey !== undefined && aKey === bKey;
}
function isOllamaCloudBaseUrl(baseUrl: string): boolean {
return areOllamaHostsEquivalent(baseUrl, OLLAMA_CLOUD_BASE_URL);
}
function selectOllamaEmbeddingApiKey(params: {
resolved: OllamaEmbeddingResolvedKeys;
baseUrl: string;
providerOwnsHost: boolean;
}): string | undefined {
if (params.resolved.remote !== "unset") {
return typeof params.resolved.remote === "object" ? params.resolved.remote.apiKey : undefined;
}
if (params.resolved.provider !== "unset" && params.providerOwnsHost) {
return typeof params.resolved.provider === "object"
? params.resolved.provider.apiKey
: undefined;
}
if (params.resolved.env && isOllamaCloudBaseUrl(params.baseUrl)) {
return params.resolved.env;
}
return undefined;
}
async function resolveOllamaEmbeddingClient(
options: OllamaEmbeddingOptions,
): Promise<OllamaEmbeddingClientConfig> {
const providerConfig = resolveConfiguredProvider(options);
const { baseUrl, origin: baseUrlOrigin } = resolveOllamaEmbeddingBaseUrl({
remoteBaseUrl: options.remote?.baseUrl,
providerConfig,
});
const model = normalizeEmbeddingModel(options.model, options.provider);
const providerOwnedHost = resolveOllamaApiBase(readProviderBaseUrl(providerConfig?.config));
// Provider keys and headers belong to this origin only; a remote override
// must neither resolve nor inherit another host's configured credentials.
const providerOwnsHost =
baseUrlOrigin !== "remote-config" || areOllamaHostsEquivalent(baseUrl, providerOwnedHost);
const remoteHeaderNames = new Set(
Object.keys(options.remote?.headers ?? {}).map((headerName) => headerName.toLowerCase()),
);
const headerOverrides: Record<string, string> = {};
if (providerOwnsHost && providerConfig?.config.headers) {
for (const [headerName, headerValue] of Object.entries(providerConfig.config.headers)) {
if (remoteHeaderNames.has(headerName.toLowerCase())) {
continue;
}
const resolvedValue = await resolveConfiguredOllamaEmbeddingSecret({
config: options.config,
value: headerValue,
path: `models.providers.${providerConfig.providerId}.headers.${headerName}`,
});
if (resolvedValue) {
headerOverrides[headerName] = resolvedValue;
}
}
}
Object.assign(headerOverrides, options.remote?.headers);
const headers: Record<string, string> = {
"Content-Type": "application/json",
...headerOverrides,
};
// Explicit HTTP auth owns its request; resolving a competing bearer can leak
// another tenant's key or fail on a SecretRef that is already inactive.
const hasAuthorizationHeader = Object.entries(headers).some(
([name, value]) => name.toLowerCase() === "authorization" && value.trim().length > 0,
);
const apiKey = hasAuthorizationHeader
? undefined
: selectOllamaEmbeddingApiKey({
resolved: await resolveOllamaEmbeddingResolvedKeys(
options,
providerConfig,
providerOwnsHost,
),
baseUrl,
providerOwnsHost,
});
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
const localService = providerConfig?.config.localService;
return {
baseUrl,
headers,
ssrfPolicy: ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl),
model,
outputDimensionality: options.outputDimensionality,
...(localService && baseUrlOrigin !== "remote-config"
? {
localServiceTarget: {
providerId: providerConfig.providerId,
baseUrl: `${baseUrl.replace(/\/+$/, "")}/v1`,
headers,
},
acquireLocalService: options.acquireLocalService,
}
: {}),
};
}
export async function createOllamaEmbeddingProvider(
options: OllamaEmbeddingOptions,
): Promise<{ provider: OllamaEmbeddingProvider; client: OllamaEmbeddingClient }> {
const client = await resolveOllamaEmbeddingClient(options);
const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embed`;
const embedMany = async (input: string | string[], signal?: AbortSignal): Promise<number[][]> => {
const localServiceLease =
client.localServiceTarget && client.acquireLocalService
? await client.acquireLocalService(client.localServiceTarget, signal)
: undefined;
let json: Awaited<ReturnType<typeof readOllamaEmbeddingJsonResponse>>;
try {
json = await withRemoteHttpResponse({
url: embedUrl,
ssrfPolicy: client.ssrfPolicy,
configuredLocalOriginBaseUrl: client.baseUrl,
signal,
init: {
method: "POST",
headers: client.headers,
body: JSON.stringify({ model: client.model, input }),
},
onResponse: async (response) => {
if (!response.ok) {
const detail = await readResponseTextLimited(
response,
OLLAMA_EMBED_ERROR_BODY_LIMIT_BYTES,
).catch(() => "unknown error");
throw new Error(`Ollama embed HTTP ${response.status}: ${detail}`);
}
return await readOllamaEmbeddingJsonResponse(response);
},
});
} finally {
localServiceLease?.release();
}
if (!Array.isArray(json.embeddings)) {
throw new Error("Ollama embed response missing embeddings[]");
}
const expectedCount = Array.isArray(input) ? input.length : 1;
if (json.embeddings.length !== expectedCount) {
throw new Error(
`Ollama embed response returned ${json.embeddings.length} embeddings for ${expectedCount} inputs`,
);
}
return json.embeddings.map((embedding) => {
if (!Array.isArray(embedding)) {
throw new Error("Ollama embed response contains a non-array embedding");
}
return sanitizeAndNormalizeEmbedding(embedding, client.outputDimensionality);
});
};
const embedOne = async (text: string, signal?: AbortSignal): Promise<number[]> => {
const [embedding] = await embedMany(text, signal);
if (!embedding) {
throw new Error("Ollama embed response returned no embedding");
}
return embedding;
};
const embedQuery = async (
text: string,
optionsValue?: { signal?: AbortSignal },
): Promise<number[]> =>
await embedOne(applyQueryInstructionTemplate(client.model, text), optionsValue?.signal);
const provider: OllamaEmbeddingProvider = {
id: "ollama",
model: client.model,
embedQuery,
embedBatch: async (texts, optionsLocal) =>
texts.length === 0 ? [] : await embedMany(texts, optionsLocal?.signal),
};
return {
provider,
client: {
...client,
embedBatch: async (texts) => {
try {
return await provider.embedBatch(texts);
} catch (err) {
throw new Error(formatErrorMessage(err), { cause: err });
}
},
},
};
}
export const createOllamaEmbeddingProvider: OllamaEmbeddingRuntime["createOllamaEmbeddingProvider"] =
async (...args) =>
await (await loadOllamaEmbeddingRuntime()).createOllamaEmbeddingProvider(...args);
@@ -0,0 +1,41 @@
import { stringEnum } from "openclaw/plugin-sdk/channel-actions";
import type { AnyAgentTool } from "openclaw/plugin-sdk/plugin-entry";
import { Type } from "typebox";
export const OLLAMA_NODE_INFERENCE_CAPABILITY = "local-inference";
export const OLLAMA_MODELS_COMMAND = "ollama.models";
export const OLLAMA_CHAT_COMMAND = "ollama.chat";
export const OLLAMA_NODE_INFERENCE_COMMANDS = [OLLAMA_MODELS_COMMAND, OLLAMA_CHAT_COMMAND] as const;
export const OLLAMA_NODE_INFERENCE_DEFAULT_PLATFORMS = ["macos", "linux", "windows"] as const;
export const DEFAULT_INFERENCE_TIMEOUT_MS = 120_000;
export const DEFAULT_MAX_TOKENS = 512;
export const DISCOVERY_TRANSPORT_TIMEOUT_MS = 90_000;
export const MAX_INFERENCE_TIMEOUT_MS = 10 * 60_000;
export const MAX_TOKENS = 8192;
export const MAX_PROMPT_CHARS = 128_000;
export const MAX_SYSTEM_PROMPT_CHARS = 32_000;
export const ollamaNodeInferenceToolDefinition = {
name: "node_inference",
label: "Node Inference",
description:
"Discover and run chat-capable Ollama models installed on paired desktop/server nodes. Use action=discover first, then action=run with a node and model from that result. Inference stays on the selected node.",
parameters: Type.Object(
{
action: stringEnum(["discover", "run"] as const),
node: Type.Optional(
Type.String({ description: "Connected node id or display name. Required when ambiguous." }),
),
model: Type.Optional(
Type.String({ description: "Exact local model name returned by discover." }),
),
prompt: Type.Optional(Type.String({ description: "Prompt for action=run." })),
system: Type.Optional(Type.String({ description: "Optional system prompt for action=run." })),
temperature: Type.Optional(Type.Number({ minimum: 0, maximum: 2 })),
maxTokens: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_TOKENS })),
timeoutMs: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_INFERENCE_TIMEOUT_MS })),
},
{ additionalProperties: false },
),
} as const satisfies Pick<AnyAgentTool, "name" | "label" | "description" | "parameters">;
@@ -0,0 +1,71 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import type {
AnyAgentTool,
OpenClawPluginApi,
OpenClawPluginNodeHostCommand,
OpenClawPluginNodeInvokePolicy,
} from "openclaw/plugin-sdk/plugin-entry";
import {
OLLAMA_CHAT_COMMAND,
OLLAMA_MODELS_COMMAND,
OLLAMA_NODE_INFERENCE_CAPABILITY,
OLLAMA_NODE_INFERENCE_COMMANDS,
OLLAMA_NODE_INFERENCE_DEFAULT_PLATFORMS,
ollamaNodeInferenceToolDefinition,
} from "./node-inference-contract.js";
const loadOllamaNodeInference = createLazyRuntimeModule(() => import("./node-inference.js"));
function createLazyNodeHostCommand(
command: (typeof OLLAMA_NODE_INFERENCE_COMMANDS)[number],
): OpenClawPluginNodeHostCommand {
let runtimeCommandPromise: Promise<OpenClawPluginNodeHostCommand> | undefined;
const loadRuntimeCommand = () =>
(runtimeCommandPromise ??= loadOllamaNodeInference().then((runtime) => {
const runtimeCommand = runtime
.createOllamaNodeHostCommands()
.find((candidate) => candidate.command === command);
if (!runtimeCommand) {
throw new Error(`Ollama node inference runtime missing ${command}`);
}
return runtimeCommand;
}));
return {
command,
cap: OLLAMA_NODE_INFERENCE_CAPABILITY,
handle: async (paramsJSON, io, context) => {
const runtimeCommand = await loadRuntimeCommand();
return await runtimeCommand.handle(paramsJSON, io, context);
},
};
}
export function createLazyOllamaNodeHostCommands(): OpenClawPluginNodeHostCommand[] {
return [
createLazyNodeHostCommand(OLLAMA_MODELS_COMMAND),
createLazyNodeHostCommand(OLLAMA_CHAT_COMMAND),
];
}
export function createOllamaNodeInvokePolicy(): OpenClawPluginNodeInvokePolicy {
return {
commands: [...OLLAMA_NODE_INFERENCE_COMMANDS],
defaultPlatforms: [...OLLAMA_NODE_INFERENCE_DEFAULT_PLATFORMS],
handle: async (ctx) => await ctx.invokeNode(),
};
}
export function createLazyOllamaNodeInferenceTool(api: OpenClawPluginApi): AnyAgentTool {
let toolPromise: Promise<AnyAgentTool> | undefined;
const loadTool = () =>
(toolPromise ??= loadOllamaNodeInference().then((runtime) =>
runtime.createOllamaNodeInferenceTool(api),
));
return {
...ollamaNodeInferenceToolDefinition,
execute: async (...args) => {
const tool = await loadTool();
return await tool.execute(...args);
},
};
}
+17 -39
View File
@@ -1,4 +1,4 @@
import { jsonResult, stringEnum } from "openclaw/plugin-sdk/channel-actions";
import { jsonResult } from "openclaw/plugin-sdk/channel-actions";
import { formatErrorMessage as errorMessage } from "openclaw/plugin-sdk/error-runtime";
// Ollama node inference exposes local models to agents through paired node hosts.
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
@@ -18,8 +18,22 @@ import {
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { Type } from "typebox";
import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
import {
DEFAULT_INFERENCE_TIMEOUT_MS,
DEFAULT_MAX_TOKENS,
DISCOVERY_TRANSPORT_TIMEOUT_MS,
MAX_INFERENCE_TIMEOUT_MS,
MAX_PROMPT_CHARS,
MAX_SYSTEM_PROMPT_CHARS,
MAX_TOKENS,
OLLAMA_CHAT_COMMAND,
OLLAMA_MODELS_COMMAND,
OLLAMA_NODE_INFERENCE_CAPABILITY,
OLLAMA_NODE_INFERENCE_COMMANDS,
OLLAMA_NODE_INFERENCE_DEFAULT_PLATFORMS,
ollamaNodeInferenceToolDefinition,
} from "./node-inference-contract.js";
import {
buildOllamaBaseUrlSsrFPolicy,
enrichOllamaCompletionModels,
@@ -31,18 +45,6 @@ import {
throwIfOllamaRequestAborted,
} from "./provider-models.js";
const OLLAMA_NODE_INFERENCE_CAPABILITY = "local-inference";
const OLLAMA_MODELS_COMMAND = "ollama.models";
const OLLAMA_CHAT_COMMAND = "ollama.chat";
const OLLAMA_NODE_INFERENCE_COMMANDS = [OLLAMA_MODELS_COMMAND, OLLAMA_CHAT_COMMAND] as const;
const DEFAULT_INFERENCE_TIMEOUT_MS = 120_000;
const DEFAULT_MAX_TOKENS = 512;
const DISCOVERY_TRANSPORT_TIMEOUT_MS = 90_000;
const MAX_INFERENCE_TIMEOUT_MS = 10 * 60_000;
const MAX_TOKENS = 8192;
const MAX_PROMPT_CHARS = 128_000;
const MAX_SYSTEM_PROMPT_CHARS = 32_000;
const MAX_ERROR_BODY_BYTES = 500;
type NodeModel = {
@@ -378,7 +380,7 @@ export function createOllamaNodeHostCommands(options?: {
export function createOllamaNodeInvokePolicy(): OpenClawPluginNodeInvokePolicy {
return {
commands: [...OLLAMA_NODE_INFERENCE_COMMANDS],
defaultPlatforms: ["macos", "linux", "windows"],
defaultPlatforms: [...OLLAMA_NODE_INFERENCE_DEFAULT_PLATFORMS],
handle: async (ctx) => await ctx.invokeNode(),
};
}
@@ -430,30 +432,6 @@ async function invokeNode(
return parseInvokePayload(raw);
}
const ollamaNodeInferenceToolDefinition = {
name: "node_inference",
label: "Node Inference",
description:
"Discover and run chat-capable Ollama models installed on paired desktop/server nodes. Use action=discover first, then action=run with a node and model from that result. Inference stays on the selected node.",
parameters: Type.Object(
{
action: stringEnum(["discover", "run"] as const),
node: Type.Optional(
Type.String({ description: "Connected node id or display name. Required when ambiguous." }),
),
model: Type.Optional(
Type.String({ description: "Exact local model name returned by discover." }),
),
prompt: Type.Optional(Type.String({ description: "Prompt for action=run." })),
system: Type.Optional(Type.String({ description: "Optional system prompt for action=run." })),
temperature: Type.Optional(Type.Number({ minimum: 0, maximum: 2 })),
maxTokens: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_TOKENS })),
timeoutMs: Type.Optional(Type.Integer({ minimum: 1, maximum: MAX_INFERENCE_TIMEOUT_MS })),
},
{ additionalProperties: false },
),
} as const;
export function createOllamaNodeInferenceTool(api: OpenClawPluginApi): AnyAgentTool {
return {
...ollamaNodeInferenceToolDefinition,
@@ -5,7 +5,7 @@ import type { WizardPrompter } from "openclaw/plugin-sdk/setup";
import { afterEach, describe, expect, it, vi } from "vitest";
import { fetchOllamaModels, readOllamaModelShowInfo } from "./provider-models.js";
import { pullOllamaModel } from "./setup-pull.js";
import { checkOllamaCloudAuth } from "./setup.js";
import { checkOllamaCloudAuth } from "./setup.runtime.js";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
+596
View File
@@ -0,0 +1,596 @@
// Ollama setup runtime handles plugin onboarding behavior.
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
import type {
OpenClawConfig,
SecretInput,
SecretInputMode,
} from "openclaw/plugin-sdk/provider-auth";
import {
ensureApiKeyFromOptionEnvOrPrompt,
isNonSecretApiKeyMarker,
normalizeApiKeyInput,
normalizeOptionalSecretInput,
upsertAuthProfileWithLock,
validateApiKeyInput,
} from "openclaw/plugin-sdk/provider-auth";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { applyAgentDefaultModelPrimary } from "openclaw/plugin-sdk/provider-onboard";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { WizardCancelledError, type WizardPrompter } from "openclaw/plugin-sdk/setup";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import {
OLLAMA_CLOUD_BASE_URL,
OLLAMA_CLOUD_DEFAULT_MODELS,
OLLAMA_DEFAULT_BASE_URL,
OLLAMA_DEFAULT_MODEL,
resolveOllamaSetupDefaultBaseUrl,
} from "./defaults.js";
import { readProviderBaseUrl } from "./provider-base-url.js";
import {
buildOllamaBaseUrlSsrFPolicy,
buildOllamaProvider,
enrichOllamaModelsWithContext,
fetchOllamaModels,
isOllamaCloudModel,
resolveOllamaApiBase,
type OllamaModelWithContext,
} from "./provider-models.js";
import {
buildOllamaModelsConfig,
discoverOllamaModelsForSetup,
findAvailableOllamaModelName,
inspectOllamaModelsForSetup,
mergeUniqueModelNames,
normalizeOllamaModelName,
selectAppGuidedOllamaModelId,
} from "./setup-model-selection.js";
import { pullOllamaModel, pullOllamaModelNonInteractive } from "./setup-pull.js";
export { buildOllamaProvider, resolveOllamaSetupDefaultBaseUrl };
const OLLAMA_SUGGESTED_MODELS_LOCAL = [OLLAMA_DEFAULT_MODEL];
const OLLAMA_SUGGESTED_MODELS_CLOUD = OLLAMA_CLOUD_DEFAULT_MODELS.map((model) => model.id);
const OLLAMA_SUGGESTED_MODELS_LOCAL_CLOUD = OLLAMA_CLOUD_DEFAULT_MODELS.map(
(model) => `${model.id}:cloud`,
);
const OLLAMA_CLOUD_MAX_DISCOVERED_MODELS = 500;
const OLLAMA_RECOMMENDED_TOOLS_MODEL = "gemma4:e4b";
const OLLAMA_RECOMMENDED_TOOLS_MODEL_SIZE = "about 9.6 GB";
type OllamaCloudDefaultModel = (typeof OLLAMA_CLOUD_DEFAULT_MODELS)[number];
type OllamaSetupOptions = {
customBaseUrl?: string;
customModelId?: string;
};
type OllamaSetupResult = {
config: OpenClawConfig;
credential: SecretInput;
credentialMode?: SecretInputMode;
defaultModel?: string;
};
type OllamaInteractiveMode = "cloud-local" | "cloud-only" | "local-only";
type HostBackedOllamaInteractiveMode = Exclude<OllamaInteractiveMode, "cloud-only">;
const HOST_BACKED_OLLAMA_MODE_CONFIG: Record<
HostBackedOllamaInteractiveMode,
{ includeCloudModels: boolean; noteTitle: string }
> = {
"cloud-local": {
includeCloudModels: true,
noteTitle: "Ollama Cloud + Local",
},
"local-only": {
includeCloudModels: false,
noteTitle: "Ollama",
},
};
function buildOllamaUnreachableLines(baseUrl: string, retry: boolean): string[] {
return [
`Ollama could not be reached at ${baseUrl}.`,
"Start or restart the Ollama server for this address.",
"If Ollama is not installed on that machine, download it at https://ollama.com/download",
...(retry ? ["", "Continue when it is running. OpenClaw will retry this address."] : []),
];
}
function buildOllamaCloudSigninLines(signinUrl?: string): string[] {
return [
"Cloud models on this Ollama host need `ollama signin`.",
signinUrl ?? "Run `ollama signin` on the configured Ollama host.",
"",
"Continuing with local models only for now.",
];
}
export async function checkOllamaCloudAuth(
baseUrl: string,
): Promise<{ signedIn: boolean; signinUrl?: string }> {
try {
const apiBase = resolveOllamaApiBase(baseUrl);
const { response, release } = await fetchWithSsrFGuard({
url: `${apiBase}/api/me`,
init: {
method: "POST",
},
// Guard-owned timeoutMs also bounds DNS/proxy preflight; init.signal does not.
timeoutMs: 5000,
policy: buildOllamaBaseUrlSsrFPolicy(apiBase),
auditContext: "ollama-setup.me",
});
try {
if (response.status === 401) {
const data = await readProviderJsonResponse<{ signin_url?: string }>(
response,
"ollama.cloud-auth",
);
return { signedIn: false, signinUrl: data.signin_url };
}
if (!response.ok) {
return { signedIn: false };
}
return { signedIn: true };
} finally {
// Capture can retain a cloned tee branch, so cancellation must not delay
// the guard's bounded dispatcher release.
void response.body?.cancel().catch(() => undefined);
await release();
}
} catch {
return { signedIn: false };
}
}
async function promptForOllamaCloudCredential(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
opts?: Record<string, unknown>;
prompter: WizardPrompter;
secretInputMode?: SecretInputMode;
allowSecretRefPrompt?: boolean;
}): Promise<{
credential: SecretInput;
credentialMode?: SecretInputMode;
discoveryApiKey: string;
}> {
const captured: { credential?: SecretInput; credentialMode?: SecretInputMode } = {};
const optionToken = normalizeOptionalSecretInput(params.opts?.ollamaApiKey);
const discoveryApiKey = await ensureApiKeyFromOptionEnvOrPrompt({
token: optionToken ?? normalizeOptionalSecretInput(params.opts?.token),
tokenProvider: optionToken
? "ollama"
: normalizeOptionalSecretInput(params.opts?.tokenProvider),
secretInputMode:
params.allowSecretRefPrompt === false
? (params.secretInputMode ?? "plaintext")
: params.secretInputMode,
config: params.cfg,
env: params.env,
expectedProviders: ["ollama"],
provider: "ollama",
envLabel: "OLLAMA_API_KEY",
promptMessage: "Ollama API key",
normalize: normalizeApiKeyInput,
validate: validateApiKeyInput,
prompter: params.prompter,
setCredential: async (apiKey, mode) => {
captured.credential = apiKey;
captured.credentialMode = mode;
},
});
if (!captured.credential) {
throw new Error("Missing Ollama API key input.");
}
if (
typeof captured.credential === "string" &&
isNonSecretApiKeyMarker(captured.credential, { includeEnvVarName: false })
) {
throw new Error("Cloud-only Ollama setup requires a real OLLAMA_API_KEY.");
}
return {
credential: captured.credential,
credentialMode: captured.credentialMode,
discoveryApiKey,
};
}
function applyOllamaProviderConfig(
cfg: OpenClawConfig,
baseUrl: string,
modelNames: string[],
discoveredModelsByName?: Map<string, OllamaModelWithContext>,
apiKey: SecretInput = "OLLAMA_API_KEY",
defaultModels: readonly OllamaCloudDefaultModel[] = [],
): OpenClawConfig {
return {
...cfg,
models: {
...cfg.models,
mode: cfg.models?.mode ?? "merge",
providers: {
...cfg.models?.providers,
ollama: {
baseUrl,
api: "ollama",
apiKey,
models: buildOllamaModelsConfig(modelNames, discoveredModelsByName, defaultModels),
},
},
},
};
}
async function storeOllamaCredential(agentDir?: string): Promise<void> {
await upsertAuthProfileWithLock({
profileId: "ollama:default",
credential: { type: "api_key", provider: "ollama", key: "ollama-local" },
agentDir,
});
}
async function promptForOllamaBaseUrl(
prompter: WizardPrompter,
env: NodeJS.ProcessEnv = process.env,
): Promise<string> {
const defaultBaseUrl = resolveOllamaSetupDefaultBaseUrl(env);
const baseUrlRaw = await prompter.text({
message: "Ollama base URL",
initialValue: defaultBaseUrl,
placeholder: defaultBaseUrl,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
return resolveOllamaApiBase((baseUrlRaw ?? defaultBaseUrl).trim().replace(/\/+$/, ""));
}
async function resolveHostBackedSuggestedModelNames(params: {
mode: HostBackedOllamaInteractiveMode;
baseUrl: string;
prompter: WizardPrompter;
}): Promise<string[]> {
const modeConfig = HOST_BACKED_OLLAMA_MODE_CONFIG[params.mode];
if (!modeConfig.includeCloudModels) {
return OLLAMA_SUGGESTED_MODELS_LOCAL;
}
const auth = await checkOllamaCloudAuth(params.baseUrl);
if (auth.signedIn) {
return mergeUniqueModelNames(
OLLAMA_SUGGESTED_MODELS_LOCAL,
OLLAMA_SUGGESTED_MODELS_LOCAL_CLOUD,
);
}
await params.prompter.note(
buildOllamaCloudSigninLines(auth.signinUrl).join("\n"),
modeConfig.noteTitle,
);
return OLLAMA_SUGGESTED_MODELS_LOCAL;
}
async function promptAndConfigureHostBackedOllama(params: {
cfg: OpenClawConfig;
mode: HostBackedOllamaInteractiveMode;
prompter: WizardPrompter;
env?: NodeJS.ProcessEnv;
signal?: AbortSignal;
}): Promise<OllamaSetupResult> {
const baseUrl = await promptForOllamaBaseUrl(params.prompter, params.env);
let discovery = await discoverOllamaModelsForSetup({
baseUrl,
inspectTools: true,
...(params.signal ? { signal: params.signal } : {}),
});
if (!discovery.reachable) {
await params.prompter.note(buildOllamaUnreachableLines(baseUrl, true).join("\n"), "Ollama");
const shouldRetry = await params.prompter.confirm({
message: "Retry this Ollama address now?",
initialValue: true,
});
if (!shouldRetry) {
throw new WizardCancelledError("Ollama setup cancelled");
}
params.signal?.throwIfAborted();
discovery = await discoverOllamaModelsForSetup({
baseUrl,
inspectTools: true,
...(params.signal ? { signal: params.signal } : {}),
});
}
if (!discovery.reachable) {
throw new WizardCancelledError(`Ollama is still not reachable at ${baseUrl}`);
}
const {
models,
inspectedModels,
discoveredModelsByName,
inspectionFailures,
hasToolsCapableModel,
} = discovery;
if (inspectionFailures.length > 0) {
await params.prompter.note(
[
"Some installed models could not be inspected and were skipped:",
...inspectionFailures.slice(0, 5).map((line) => `- ${line}`),
...(inspectionFailures.length > 5 ? [`…and ${inspectionFailures.length - 5} more`] : []),
].join("\n"),
"Ollama",
);
}
let discoveredModelNames = models.map((model) => model.name);
// A pull offer is only meaningful when inspection actually worked: if every
// scan failed we cannot know what is installed, so recommending a multi-GB
// download would be guesswork against a misbehaving server.
const inspectionUsable =
inspectedModels.length === 0 || inspectionFailures.length < inspectedModels.length;
if (!hasToolsCapableModel && inspectionUsable) {
const shouldPullRecommended = await params.prompter.confirm({
message: `No tools-capable Ollama model is installed. Pull ${OLLAMA_RECOMMENDED_TOOLS_MODEL} (${OLLAMA_RECOMMENDED_TOOLS_MODEL_SIZE})?`,
initialValue: false,
});
if (shouldPullRecommended) {
if (
!(await pullOllamaModel(
baseUrl,
OLLAMA_RECOMMENDED_TOOLS_MODEL,
params.prompter,
params.signal,
))
) {
throw new WizardCancelledError("Failed to download recommended Ollama model");
}
params.signal?.throwIfAborted();
const recommendedScan = await inspectOllamaModelsForSetup(
baseUrl,
[{ name: OLLAMA_RECOMMENDED_TOOLS_MODEL }],
params.signal,
);
// Unlike the pre-existing-model scan, a just-pulled model that cannot be
// verified is a hard failure: configuring it unenriched would silently
// drop the tools capability the pull was for.
if (recommendedScan.inspectionFailures.length > 0) {
throw new WizardCancelledError(
`Failed to verify pulled Ollama model: ${recommendedScan.inspectionFailures[0]}`,
);
}
const [recommendedModel] = recommendedScan.inspected;
if (recommendedModel) {
discoveredModelsByName.set(recommendedModel.name, recommendedModel);
}
discoveredModelNames = mergeUniqueModelNames(discoveredModelNames, [
OLLAMA_RECOMMENDED_TOOLS_MODEL,
]);
}
}
const suggestedModelNames = await resolveHostBackedSuggestedModelNames({
mode: params.mode,
baseUrl,
prompter: params.prompter,
});
const localDefaultModelId = selectAppGuidedOllamaModelId(
[...discoveredModelsByName.values()].map((model) => ({
id: model.name,
contextWindow: model.contextWindow,
supportsTools: model.capabilities?.includes("tools") === true,
})),
);
const cloudDefaultModelId = suggestedModelNames.find(isOllamaCloudModel);
const defaultModelId = localDefaultModelId ?? cloudDefaultModelId;
return {
credential: "ollama-local",
...(defaultModelId ? { defaultModel: `ollama/${defaultModelId}` } : {}),
config: applyOllamaProviderConfig(
params.cfg,
baseUrl,
mergeUniqueModelNames(suggestedModelNames, discoveredModelNames),
discoveredModelsByName,
),
};
}
export async function promptAndConfigureOllama(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
opts?: Record<string, unknown>;
prompter: WizardPrompter;
secretInputMode?: SecretInputMode;
allowSecretRefPrompt?: boolean;
signal?: AbortSignal;
}): Promise<OllamaSetupResult> {
const mode = (await params.prompter.select({
message: "Ollama mode",
options: [
{
value: "cloud-local",
label: "Cloud + Local",
hint: "Route cloud and local models through your Ollama host",
},
{ value: "cloud-only", label: "Cloud only", hint: "Hosted Ollama models via ollama.com" },
{ value: "local-only", label: "Local only", hint: "Local models only" },
],
})) as OllamaInteractiveMode;
if (mode === "cloud-only") {
const { credential, credentialMode, discoveryApiKey } = await promptForOllamaCloudCredential({
cfg: params.cfg,
env: params.env,
opts: params.opts,
prompter: params.prompter,
secretInputMode: params.secretInputMode,
allowSecretRefPrompt: params.allowSecretRefPrompt,
});
const { models: rawDiscoveredModels } = await fetchOllamaModels(OLLAMA_CLOUD_BASE_URL, {
apiKey: discoveryApiKey,
});
const discoveredModels = rawDiscoveredModels.slice(0, OLLAMA_CLOUD_MAX_DISCOVERED_MODELS);
const discoveredModelNames = discoveredModels.map((model) => model.name);
const modelNames =
discoveredModelNames.length > 0
? mergeUniqueModelNames(OLLAMA_SUGGESTED_MODELS_CLOUD, discoveredModelNames)
: OLLAMA_SUGGESTED_MODELS_CLOUD;
const defaultModelId = modelNames[0];
return {
credential,
credentialMode,
...(defaultModelId ? { defaultModel: `ollama/${defaultModelId}` } : {}),
config: applyOllamaProviderConfig(
params.cfg,
OLLAMA_CLOUD_BASE_URL,
modelNames,
undefined,
credential,
OLLAMA_CLOUD_DEFAULT_MODELS,
),
};
}
return await promptAndConfigureHostBackedOllama({
cfg: params.cfg,
mode,
prompter: params.prompter,
env: params.env,
...(params.signal ? { signal: params.signal } : {}),
});
}
export async function configureOllamaNonInteractive(params: {
nextConfig: OpenClawConfig;
opts: OllamaSetupOptions;
runtime: RuntimeEnv;
agentDir?: string;
}): Promise<OpenClawConfig> {
const baseUrl = resolveOllamaApiBase(
(params.opts.customBaseUrl?.trim() || resolveOllamaSetupDefaultBaseUrl()).replace(/\/+$/, ""),
);
const { reachable, models, discoveredModelsByName } = await discoverOllamaModelsForSetup({
baseUrl,
});
const explicitModel = normalizeOllamaModelName(params.opts.customModelId);
if (!reachable) {
params.runtime.error(buildOllamaUnreachableLines(baseUrl, false).join("\n"));
params.runtime.exit(1);
return params.nextConfig;
}
const modelNames = models.map((model) => model.name);
// Configured local models are advertised as available, so suggested models
// belong in the inventory only when Ollama actually reports them as installed.
const orderedModelNames = mergeUniqueModelNames(
OLLAMA_SUGGESTED_MODELS_LOCAL.filter(
(modelName) => findAvailableOllamaModelName(modelName, modelNames) !== undefined,
),
modelNames,
);
const requestedDefaultModelId =
explicitModel ??
expectDefined(OLLAMA_SUGGESTED_MODELS_LOCAL[0], "default suggested Ollama model");
const availableModelNames = new Set(modelNames);
const availableDefaultModelId = findAvailableOllamaModelName(
requestedDefaultModelId,
availableModelNames,
);
const requestedCloudModel = isOllamaCloudModel(requestedDefaultModelId);
let pulledRequestedModel = false;
if (requestedCloudModel) {
availableModelNames.add(requestedDefaultModelId);
} else if (!availableDefaultModelId) {
pulledRequestedModel = await pullOllamaModelNonInteractive(
baseUrl,
requestedDefaultModelId,
params.runtime,
);
if (pulledRequestedModel) {
availableModelNames.add(requestedDefaultModelId);
}
}
let allModelNames = orderedModelNames;
let defaultModelId = availableDefaultModelId ?? requestedDefaultModelId;
if (
(pulledRequestedModel || requestedCloudModel) &&
!allModelNames.includes(requestedDefaultModelId)
) {
allModelNames = [...allModelNames, requestedDefaultModelId];
}
if (!findAvailableOllamaModelName(defaultModelId, availableModelNames)) {
if (availableModelNames.size === 0) {
params.runtime.error(
[
`No Ollama models are available at ${baseUrl}.`,
"Pull a model first, then re-run setup.",
].join("\n"),
);
params.runtime.exit(1);
return params.nextConfig;
}
defaultModelId =
allModelNames.find((name) => findAvailableOllamaModelName(name, availableModelNames)) ??
expectDefined(availableModelNames.values().next().value, "available Ollama setup model");
params.runtime.log(
`Ollama model ${requestedDefaultModelId} was not available; using ${defaultModelId} instead.`,
);
}
if (!requestedCloudModel && !discoveredModelsByName.has(defaultModelId)) {
// Explicit and newly pulled models can fall outside the bounded catalog scan.
const selectedModel = expectDefined(
(
await enrichOllamaModelsWithContext(baseUrl, [
models.find((model) => model.name === defaultModelId) ?? { name: defaultModelId },
])
)[0],
"selected Ollama setup model",
);
discoveredModelsByName.set(defaultModelId, selectedModel);
}
// Failed setup must not leave a durable local profile behind.
await storeOllamaCredential(params.agentDir);
const config = applyOllamaProviderConfig(
params.nextConfig,
baseUrl,
allModelNames,
discoveredModelsByName,
);
params.runtime.log(`Default Ollama model: ${defaultModelId}`);
return applyAgentDefaultModelPrimary(config, `ollama/${defaultModelId}`);
}
export async function ensureOllamaModelPulled(params: {
config: OpenClawConfig;
model: string;
prompter: WizardPrompter;
}): Promise<void> {
if (!params.model.startsWith("ollama/")) {
return;
}
const baseUrl =
readProviderBaseUrl(params.config.models?.providers?.ollama) ?? OLLAMA_DEFAULT_BASE_URL;
const modelName = params.model.slice("ollama/".length);
if (isOllamaCloudModel(modelName)) {
return;
}
const { models } = await fetchOllamaModels(baseUrl);
if (
findAvailableOllamaModelName(
modelName,
models.map((model) => model.name),
)
) {
return;
}
if (!(await pullOllamaModel(baseUrl, modelName, params.prompter))) {
throw new WizardCancelledError("Failed to download selected Ollama model");
}
}
+1 -1
View File
@@ -4,11 +4,11 @@ import type { WizardPrompter } from "openclaw/plugin-sdk/setup";
import { jsonResponse, requestBodyText, requestUrl } from "openclaw/plugin-sdk/test-env";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
checkOllamaCloudAuth,
configureOllamaNonInteractive,
ensureOllamaModelPulled,
promptAndConfigureOllama,
} from "./setup.js";
import { checkOllamaCloudAuth } from "./setup.runtime.js";
const upsertAuthProfileWithLock = vi.hoisted(() => vi.fn(async () => {}));
const fetchWithSsrFGuardMock = vi.hoisted(() =>
+13 -600
View File
@@ -1,606 +1,19 @@
// Ollama setup module handles plugin onboarding behavior.
import { expectDefined } from "openclaw/plugin-sdk/expect-runtime";
import type {
OpenClawConfig,
SecretInput,
SecretInputMode,
} from "openclaw/plugin-sdk/provider-auth";
import {
ensureApiKeyFromOptionEnvOrPrompt,
isNonSecretApiKeyMarker,
normalizeApiKeyInput,
normalizeOptionalSecretInput,
upsertAuthProfileWithLock,
validateApiKeyInput,
} from "openclaw/plugin-sdk/provider-auth";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import { applyAgentDefaultModelPrimary } from "openclaw/plugin-sdk/provider-onboard";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { WizardCancelledError, type WizardPrompter } from "openclaw/plugin-sdk/setup";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import {
OLLAMA_CLOUD_BASE_URL,
OLLAMA_CLOUD_DEFAULT_MODELS,
OLLAMA_DEFAULT_BASE_URL,
OLLAMA_DOCKER_HOST_BASE_URL,
OLLAMA_DEFAULT_MODEL,
} from "./defaults.js";
import { readProviderBaseUrl } from "./provider-base-url.js";
import {
buildOllamaBaseUrlSsrFPolicy,
buildOllamaProvider,
enrichOllamaModelsWithContext,
fetchOllamaModels,
isOllamaCloudModel,
resolveOllamaApiBase,
type OllamaModelWithContext,
} from "./provider-models.js";
import {
buildOllamaModelsConfig,
discoverOllamaModelsForSetup,
findAvailableOllamaModelName,
inspectOllamaModelsForSetup,
mergeUniqueModelNames,
normalizeOllamaModelName,
selectAppGuidedOllamaModelId,
} from "./setup-model-selection.js";
import { pullOllamaModel, pullOllamaModelNonInteractive } from "./setup-pull.js";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
export { buildOllamaProvider };
export { resolveOllamaSetupDefaultBaseUrl } from "./defaults.js";
export { buildOllamaProvider } from "./provider-models.js";
const OLLAMA_SUGGESTED_MODELS_LOCAL = [OLLAMA_DEFAULT_MODEL];
const OLLAMA_SUGGESTED_MODELS_CLOUD = OLLAMA_CLOUD_DEFAULT_MODELS.map((model) => model.id);
const OLLAMA_SUGGESTED_MODELS_LOCAL_CLOUD = OLLAMA_CLOUD_DEFAULT_MODELS.map(
(model) => `${model.id}:cloud`,
);
const OLLAMA_CLOUD_MAX_DISCOVERED_MODELS = 500;
const OLLAMA_RECOMMENDED_TOOLS_MODEL = "gemma4:e4b";
const OLLAMA_RECOMMENDED_TOOLS_MODEL_SIZE = "about 9.6 GB";
type OllamaSetupRuntime = typeof import("./setup.runtime.js");
type OllamaCloudDefaultModel = (typeof OLLAMA_CLOUD_DEFAULT_MODELS)[number];
const loadOllamaSetupRuntime = createLazyRuntimeModule(() => import("./setup.runtime.js"));
type OllamaSetupOptions = {
customBaseUrl?: string;
customModelId?: string;
};
export const promptAndConfigureOllama: OllamaSetupRuntime["promptAndConfigureOllama"] = async (
...args
) => await (await loadOllamaSetupRuntime()).promptAndConfigureOllama(...args);
type OllamaSetupResult = {
config: OpenClawConfig;
credential: SecretInput;
credentialMode?: SecretInputMode;
defaultModel?: string;
};
export const configureOllamaNonInteractive: OllamaSetupRuntime["configureOllamaNonInteractive"] =
async (...args) => await (await loadOllamaSetupRuntime()).configureOllamaNonInteractive(...args);
function isTruthyEnvValue(value: string | undefined): boolean {
return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
}
export function resolveOllamaSetupDefaultBaseUrl(env: NodeJS.ProcessEnv = process.env): string {
return isTruthyEnvValue(env.OPENCLAW_DOCKER_SETUP)
? OLLAMA_DOCKER_HOST_BASE_URL
: OLLAMA_DEFAULT_BASE_URL;
}
type OllamaInteractiveMode = "cloud-local" | "cloud-only" | "local-only";
type HostBackedOllamaInteractiveMode = Exclude<OllamaInteractiveMode, "cloud-only">;
const HOST_BACKED_OLLAMA_MODE_CONFIG: Record<
HostBackedOllamaInteractiveMode,
{ includeCloudModels: boolean; noteTitle: string }
> = {
"cloud-local": {
includeCloudModels: true,
noteTitle: "Ollama Cloud + Local",
},
"local-only": {
includeCloudModels: false,
noteTitle: "Ollama",
},
};
function buildOllamaUnreachableLines(baseUrl: string, retry: boolean): string[] {
return [
`Ollama could not be reached at ${baseUrl}.`,
"Start or restart the Ollama server for this address.",
"If Ollama is not installed on that machine, download it at https://ollama.com/download",
...(retry ? ["", "Continue when it is running. OpenClaw will retry this address."] : []),
];
}
function buildOllamaCloudSigninLines(signinUrl?: string): string[] {
return [
"Cloud models on this Ollama host need `ollama signin`.",
signinUrl ?? "Run `ollama signin` on the configured Ollama host.",
"",
"Continuing with local models only for now.",
];
}
export async function checkOllamaCloudAuth(
baseUrl: string,
): Promise<{ signedIn: boolean; signinUrl?: string }> {
try {
const apiBase = resolveOllamaApiBase(baseUrl);
const { response, release } = await fetchWithSsrFGuard({
url: `${apiBase}/api/me`,
init: {
method: "POST",
},
// Guard-owned timeoutMs also bounds DNS/proxy preflight; init.signal does not.
timeoutMs: 5000,
policy: buildOllamaBaseUrlSsrFPolicy(apiBase),
auditContext: "ollama-setup.me",
});
try {
if (response.status === 401) {
const data = await readProviderJsonResponse<{ signin_url?: string }>(
response,
"ollama.cloud-auth",
);
return { signedIn: false, signinUrl: data.signin_url };
}
if (!response.ok) {
return { signedIn: false };
}
return { signedIn: true };
} finally {
// Capture can retain a cloned tee branch, so cancellation must not delay
// the guard's bounded dispatcher release.
void response.body?.cancel().catch(() => undefined);
await release();
}
} catch {
return { signedIn: false };
}
}
async function promptForOllamaCloudCredential(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
opts?: Record<string, unknown>;
prompter: WizardPrompter;
secretInputMode?: SecretInputMode;
allowSecretRefPrompt?: boolean;
}): Promise<{
credential: SecretInput;
credentialMode?: SecretInputMode;
discoveryApiKey: string;
}> {
const captured: { credential?: SecretInput; credentialMode?: SecretInputMode } = {};
const optionToken = normalizeOptionalSecretInput(params.opts?.ollamaApiKey);
const discoveryApiKey = await ensureApiKeyFromOptionEnvOrPrompt({
token: optionToken ?? normalizeOptionalSecretInput(params.opts?.token),
tokenProvider: optionToken
? "ollama"
: normalizeOptionalSecretInput(params.opts?.tokenProvider),
secretInputMode:
params.allowSecretRefPrompt === false
? (params.secretInputMode ?? "plaintext")
: params.secretInputMode,
config: params.cfg,
env: params.env,
expectedProviders: ["ollama"],
provider: "ollama",
envLabel: "OLLAMA_API_KEY",
promptMessage: "Ollama API key",
normalize: normalizeApiKeyInput,
validate: validateApiKeyInput,
prompter: params.prompter,
setCredential: async (apiKey, mode) => {
captured.credential = apiKey;
captured.credentialMode = mode;
},
});
if (!captured.credential) {
throw new Error("Missing Ollama API key input.");
}
if (
typeof captured.credential === "string" &&
isNonSecretApiKeyMarker(captured.credential, { includeEnvVarName: false })
) {
throw new Error("Cloud-only Ollama setup requires a real OLLAMA_API_KEY.");
}
return {
credential: captured.credential,
credentialMode: captured.credentialMode,
discoveryApiKey,
};
}
function applyOllamaProviderConfig(
cfg: OpenClawConfig,
baseUrl: string,
modelNames: string[],
discoveredModelsByName?: Map<string, OllamaModelWithContext>,
apiKey: SecretInput = "OLLAMA_API_KEY",
defaultModels: readonly OllamaCloudDefaultModel[] = [],
): OpenClawConfig {
return {
...cfg,
models: {
...cfg.models,
mode: cfg.models?.mode ?? "merge",
providers: {
...cfg.models?.providers,
ollama: {
baseUrl,
api: "ollama",
apiKey,
models: buildOllamaModelsConfig(modelNames, discoveredModelsByName, defaultModels),
},
},
},
};
}
async function storeOllamaCredential(agentDir?: string): Promise<void> {
await upsertAuthProfileWithLock({
profileId: "ollama:default",
credential: { type: "api_key", provider: "ollama", key: "ollama-local" },
agentDir,
});
}
async function promptForOllamaBaseUrl(
prompter: WizardPrompter,
env: NodeJS.ProcessEnv = process.env,
): Promise<string> {
const defaultBaseUrl = resolveOllamaSetupDefaultBaseUrl(env);
const baseUrlRaw = await prompter.text({
message: "Ollama base URL",
initialValue: defaultBaseUrl,
placeholder: defaultBaseUrl,
validate: (value) => (value?.trim() ? undefined : "Required"),
});
return resolveOllamaApiBase((baseUrlRaw ?? defaultBaseUrl).trim().replace(/\/+$/, ""));
}
async function resolveHostBackedSuggestedModelNames(params: {
mode: HostBackedOllamaInteractiveMode;
baseUrl: string;
prompter: WizardPrompter;
}): Promise<string[]> {
const modeConfig = HOST_BACKED_OLLAMA_MODE_CONFIG[params.mode];
if (!modeConfig.includeCloudModels) {
return OLLAMA_SUGGESTED_MODELS_LOCAL;
}
const auth = await checkOllamaCloudAuth(params.baseUrl);
if (auth.signedIn) {
return mergeUniqueModelNames(
OLLAMA_SUGGESTED_MODELS_LOCAL,
OLLAMA_SUGGESTED_MODELS_LOCAL_CLOUD,
);
}
await params.prompter.note(
buildOllamaCloudSigninLines(auth.signinUrl).join("\n"),
modeConfig.noteTitle,
);
return OLLAMA_SUGGESTED_MODELS_LOCAL;
}
async function promptAndConfigureHostBackedOllama(params: {
cfg: OpenClawConfig;
mode: HostBackedOllamaInteractiveMode;
prompter: WizardPrompter;
env?: NodeJS.ProcessEnv;
signal?: AbortSignal;
}): Promise<OllamaSetupResult> {
const baseUrl = await promptForOllamaBaseUrl(params.prompter, params.env);
let discovery = await discoverOllamaModelsForSetup({
baseUrl,
inspectTools: true,
...(params.signal ? { signal: params.signal } : {}),
});
if (!discovery.reachable) {
await params.prompter.note(buildOllamaUnreachableLines(baseUrl, true).join("\n"), "Ollama");
const shouldRetry = await params.prompter.confirm({
message: "Retry this Ollama address now?",
initialValue: true,
});
if (!shouldRetry) {
throw new WizardCancelledError("Ollama setup cancelled");
}
params.signal?.throwIfAborted();
discovery = await discoverOllamaModelsForSetup({
baseUrl,
inspectTools: true,
...(params.signal ? { signal: params.signal } : {}),
});
}
if (!discovery.reachable) {
throw new WizardCancelledError(`Ollama is still not reachable at ${baseUrl}`);
}
const {
models,
inspectedModels,
discoveredModelsByName,
inspectionFailures,
hasToolsCapableModel,
} = discovery;
if (inspectionFailures.length > 0) {
await params.prompter.note(
[
"Some installed models could not be inspected and were skipped:",
...inspectionFailures.slice(0, 5).map((line) => `- ${line}`),
...(inspectionFailures.length > 5 ? [`…and ${inspectionFailures.length - 5} more`] : []),
].join("\n"),
"Ollama",
);
}
let discoveredModelNames = models.map((model) => model.name);
// A pull offer is only meaningful when inspection actually worked: if every
// scan failed we cannot know what is installed, so recommending a multi-GB
// download would be guesswork against a misbehaving server.
const inspectionUsable =
inspectedModels.length === 0 || inspectionFailures.length < inspectedModels.length;
if (!hasToolsCapableModel && inspectionUsable) {
const shouldPullRecommended = await params.prompter.confirm({
message: `No tools-capable Ollama model is installed. Pull ${OLLAMA_RECOMMENDED_TOOLS_MODEL} (${OLLAMA_RECOMMENDED_TOOLS_MODEL_SIZE})?`,
initialValue: false,
});
if (shouldPullRecommended) {
if (
!(await pullOllamaModel(
baseUrl,
OLLAMA_RECOMMENDED_TOOLS_MODEL,
params.prompter,
params.signal,
))
) {
throw new WizardCancelledError("Failed to download recommended Ollama model");
}
params.signal?.throwIfAborted();
const recommendedScan = await inspectOllamaModelsForSetup(
baseUrl,
[{ name: OLLAMA_RECOMMENDED_TOOLS_MODEL }],
params.signal,
);
// Unlike the pre-existing-model scan, a just-pulled model that cannot be
// verified is a hard failure: configuring it unenriched would silently
// drop the tools capability the pull was for.
if (recommendedScan.inspectionFailures.length > 0) {
throw new WizardCancelledError(
`Failed to verify pulled Ollama model: ${recommendedScan.inspectionFailures[0]}`,
);
}
const [recommendedModel] = recommendedScan.inspected;
if (recommendedModel) {
discoveredModelsByName.set(recommendedModel.name, recommendedModel);
}
discoveredModelNames = mergeUniqueModelNames(discoveredModelNames, [
OLLAMA_RECOMMENDED_TOOLS_MODEL,
]);
}
}
const suggestedModelNames = await resolveHostBackedSuggestedModelNames({
mode: params.mode,
baseUrl,
prompter: params.prompter,
});
const localDefaultModelId = selectAppGuidedOllamaModelId(
[...discoveredModelsByName.values()].map((model) => ({
id: model.name,
contextWindow: model.contextWindow,
supportsTools: model.capabilities?.includes("tools") === true,
})),
);
const cloudDefaultModelId = suggestedModelNames.find(isOllamaCloudModel);
const defaultModelId = localDefaultModelId ?? cloudDefaultModelId;
return {
credential: "ollama-local",
...(defaultModelId ? { defaultModel: `ollama/${defaultModelId}` } : {}),
config: applyOllamaProviderConfig(
params.cfg,
baseUrl,
mergeUniqueModelNames(suggestedModelNames, discoveredModelNames),
discoveredModelsByName,
),
};
}
export async function promptAndConfigureOllama(params: {
cfg: OpenClawConfig;
env?: NodeJS.ProcessEnv;
opts?: Record<string, unknown>;
prompter: WizardPrompter;
secretInputMode?: SecretInputMode;
allowSecretRefPrompt?: boolean;
signal?: AbortSignal;
}): Promise<OllamaSetupResult> {
const mode = (await params.prompter.select({
message: "Ollama mode",
options: [
{
value: "cloud-local",
label: "Cloud + Local",
hint: "Route cloud and local models through your Ollama host",
},
{ value: "cloud-only", label: "Cloud only", hint: "Hosted Ollama models via ollama.com" },
{ value: "local-only", label: "Local only", hint: "Local models only" },
],
})) as OllamaInteractiveMode;
if (mode === "cloud-only") {
const { credential, credentialMode, discoveryApiKey } = await promptForOllamaCloudCredential({
cfg: params.cfg,
env: params.env,
opts: params.opts,
prompter: params.prompter,
secretInputMode: params.secretInputMode,
allowSecretRefPrompt: params.allowSecretRefPrompt,
});
const { models: rawDiscoveredModels } = await fetchOllamaModels(OLLAMA_CLOUD_BASE_URL, {
apiKey: discoveryApiKey,
});
const discoveredModels = rawDiscoveredModels.slice(0, OLLAMA_CLOUD_MAX_DISCOVERED_MODELS);
const discoveredModelNames = discoveredModels.map((model) => model.name);
const modelNames =
discoveredModelNames.length > 0
? mergeUniqueModelNames(OLLAMA_SUGGESTED_MODELS_CLOUD, discoveredModelNames)
: OLLAMA_SUGGESTED_MODELS_CLOUD;
const defaultModelId = modelNames[0];
return {
credential,
credentialMode,
...(defaultModelId ? { defaultModel: `ollama/${defaultModelId}` } : {}),
config: applyOllamaProviderConfig(
params.cfg,
OLLAMA_CLOUD_BASE_URL,
modelNames,
undefined,
credential,
OLLAMA_CLOUD_DEFAULT_MODELS,
),
};
}
return await promptAndConfigureHostBackedOllama({
cfg: params.cfg,
mode,
prompter: params.prompter,
env: params.env,
...(params.signal ? { signal: params.signal } : {}),
});
}
export async function configureOllamaNonInteractive(params: {
nextConfig: OpenClawConfig;
opts: OllamaSetupOptions;
runtime: RuntimeEnv;
agentDir?: string;
}): Promise<OpenClawConfig> {
const baseUrl = resolveOllamaApiBase(
(params.opts.customBaseUrl?.trim() || resolveOllamaSetupDefaultBaseUrl()).replace(/\/+$/, ""),
);
const { reachable, models, discoveredModelsByName } = await discoverOllamaModelsForSetup({
baseUrl,
});
const explicitModel = normalizeOllamaModelName(params.opts.customModelId);
if (!reachable) {
params.runtime.error(buildOllamaUnreachableLines(baseUrl, false).join("\n"));
params.runtime.exit(1);
return params.nextConfig;
}
const modelNames = models.map((model) => model.name);
// Configured local models are advertised as available, so suggested models
// belong in the inventory only when Ollama actually reports them as installed.
const orderedModelNames = mergeUniqueModelNames(
OLLAMA_SUGGESTED_MODELS_LOCAL.filter(
(modelName) => findAvailableOllamaModelName(modelName, modelNames) !== undefined,
),
modelNames,
);
const requestedDefaultModelId =
explicitModel ??
expectDefined(OLLAMA_SUGGESTED_MODELS_LOCAL[0], "default suggested Ollama model");
const availableModelNames = new Set(modelNames);
const availableDefaultModelId = findAvailableOllamaModelName(
requestedDefaultModelId,
availableModelNames,
);
const requestedCloudModel = isOllamaCloudModel(requestedDefaultModelId);
let pulledRequestedModel = false;
if (requestedCloudModel) {
availableModelNames.add(requestedDefaultModelId);
} else if (!availableDefaultModelId) {
pulledRequestedModel = await pullOllamaModelNonInteractive(
baseUrl,
requestedDefaultModelId,
params.runtime,
);
if (pulledRequestedModel) {
availableModelNames.add(requestedDefaultModelId);
}
}
let allModelNames = orderedModelNames;
let defaultModelId = availableDefaultModelId ?? requestedDefaultModelId;
if (
(pulledRequestedModel || requestedCloudModel) &&
!allModelNames.includes(requestedDefaultModelId)
) {
allModelNames = [...allModelNames, requestedDefaultModelId];
}
if (!findAvailableOllamaModelName(defaultModelId, availableModelNames)) {
if (availableModelNames.size === 0) {
params.runtime.error(
[
`No Ollama models are available at ${baseUrl}.`,
"Pull a model first, then re-run setup.",
].join("\n"),
);
params.runtime.exit(1);
return params.nextConfig;
}
defaultModelId =
allModelNames.find((name) => findAvailableOllamaModelName(name, availableModelNames)) ??
expectDefined(availableModelNames.values().next().value, "available Ollama setup model");
params.runtime.log(
`Ollama model ${requestedDefaultModelId} was not available; using ${defaultModelId} instead.`,
);
}
if (!requestedCloudModel && !discoveredModelsByName.has(defaultModelId)) {
// Explicit and newly pulled models can fall outside the bounded catalog scan.
const selectedModel = expectDefined(
(
await enrichOllamaModelsWithContext(baseUrl, [
models.find((model) => model.name === defaultModelId) ?? { name: defaultModelId },
])
)[0],
"selected Ollama setup model",
);
discoveredModelsByName.set(defaultModelId, selectedModel);
}
// Failed setup must not leave a durable local profile behind.
await storeOllamaCredential(params.agentDir);
const config = applyOllamaProviderConfig(
params.nextConfig,
baseUrl,
allModelNames,
discoveredModelsByName,
);
params.runtime.log(`Default Ollama model: ${defaultModelId}`);
return applyAgentDefaultModelPrimary(config, `ollama/${defaultModelId}`);
}
export async function ensureOllamaModelPulled(params: {
config: OpenClawConfig;
model: string;
prompter: WizardPrompter;
}): Promise<void> {
if (!params.model.startsWith("ollama/")) {
return;
}
const baseUrl =
readProviderBaseUrl(params.config.models?.providers?.ollama) ?? OLLAMA_DEFAULT_BASE_URL;
const modelName = params.model.slice("ollama/".length);
if (isOllamaCloudModel(modelName)) {
return;
}
const { models } = await fetchOllamaModels(baseUrl);
if (
findAvailableOllamaModelName(
modelName,
models.map((model) => model.name),
)
) {
return;
}
if (!(await pullOllamaModel(baseUrl, modelName, params.prompter))) {
throw new WizardCancelledError("Failed to download selected Ollama model");
}
}
export const ensureOllamaModelPulled: OllamaSetupRuntime["ensureOllamaModelPulled"] = async (
...args
) => await (await loadOllamaSetupRuntime()).ensureOllamaModelPulled(...args);
+31
View File
@@ -0,0 +1,31 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
export {
createConfiguredOllamaCompatStreamWrapper,
isOllamaCompatProvider,
resolveOllamaCompatNumCtxEnabled,
shouldInjectOllamaCompatNumCtx,
wrapOllamaCompatNumCtx,
} from "./stream-compat.js";
type OllamaStreamRuntime = typeof import("./stream.runtime.js");
const loadOllamaStreamRuntime = createLazyRuntimeModule(() => import("./stream.runtime.js"));
const ollamaStreamRuntime = await loadOllamaStreamRuntime();
export const OLLAMA_NATIVE_BASE_URL: OllamaStreamRuntime["OLLAMA_NATIVE_BASE_URL"] =
ollamaStreamRuntime.OLLAMA_NATIVE_BASE_URL;
export const resolveOllamaBaseUrlForRun: OllamaStreamRuntime["resolveOllamaBaseUrlForRun"] =
ollamaStreamRuntime.resolveOllamaBaseUrlForRun;
export const buildOllamaChatRequest: OllamaStreamRuntime["buildOllamaChatRequest"] =
ollamaStreamRuntime.buildOllamaChatRequest;
export const convertToOllamaMessages: OllamaStreamRuntime["convertToOllamaMessages"] =
ollamaStreamRuntime.convertToOllamaMessages;
export const buildAssistantMessage: OllamaStreamRuntime["buildAssistantMessage"] =
ollamaStreamRuntime.buildAssistantMessage;
export const parseNdjsonStream: OllamaStreamRuntime["parseNdjsonStream"] =
ollamaStreamRuntime.parseNdjsonStream;
export const createOllamaStreamFn: OllamaStreamRuntime["createOllamaStreamFn"] =
ollamaStreamRuntime.createOllamaStreamFn;
export const createConfiguredOllamaStreamFn: OllamaStreamRuntime["createConfiguredOllamaStreamFn"] =
ollamaStreamRuntime.createConfiguredOllamaStreamFn;
+243
View File
@@ -0,0 +1,243 @@
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import { streamSimple } from "openclaw/plugin-sdk/llm";
import type {
OpenClawConfig,
ProviderRuntimeModel,
ProviderWrapStreamFnContext,
} from "openclaw/plugin-sdk/plugin-entry";
import {
DEFAULT_CONTEXT_TOKENS,
normalizeProviderId,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
createMoonshotThinkingWrapper,
resolveMoonshotThinkingType,
streamWithPayloadPatch,
} from "openclaw/plugin-sdk/provider-stream-shared";
import { isLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime";
import { shouldWrapOllamaCompatMoonshotThinking } from "./model-behavior.js";
export type OllamaThinkValue = boolean | "low" | "medium" | "high";
export function resolveConfiguredOllamaProviderConfig(params: {
config?: OpenClawConfig;
providerId?: string;
}) {
const providerId = params.providerId?.trim();
if (!providerId) {
return undefined;
}
const providers = params.config?.models?.providers;
if (!providers) {
return undefined;
}
const direct = providers[providerId];
if (direct) {
return direct;
}
const normalized = normalizeProviderId(providerId);
for (const [candidateId, candidate] of Object.entries(providers)) {
if (normalizeProviderId(candidateId) === normalized) {
return candidate;
}
}
return undefined;
}
export function isOllamaCompatProvider(model: {
provider?: string;
baseUrl?: string;
api?: string;
}): boolean {
const providerId = normalizeProviderId(model.provider ?? "");
if (providerId === "ollama") {
return true;
}
if (!model.baseUrl) {
return false;
}
try {
const parsed = new URL(model.baseUrl);
if (isLoopbackHost(parsed.hostname) && parsed.port === "11434") {
return true;
}
// Allow remote/LAN Ollama OpenAI-compatible endpoints when the provider id
// itself indicates Ollama usage (for example "my-ollama").
const providerHintsOllama = providerId.includes("ollama");
const isOllamaPort = parsed.port === "11434";
const isOllamaCompatPath = parsed.pathname === "/" || /^\/v1\/?$/i.test(parsed.pathname);
return providerHintsOllama && isOllamaPort && isOllamaCompatPath;
} catch {
return false;
}
}
export function resolveOllamaCompatNumCtxEnabled(params: {
config?: OpenClawConfig;
providerId?: string;
}): boolean {
return resolveConfiguredOllamaProviderConfig(params)?.injectNumCtxForOpenAICompat ?? true;
}
export function shouldInjectOllamaCompatNumCtx(params: {
model: { api?: string; provider?: string; baseUrl?: string };
config?: OpenClawConfig;
providerId?: string;
}): boolean {
if (params.model.api !== "openai-completions") {
return false;
}
if (!isOllamaCompatProvider(params.model)) {
return false;
}
return resolveOllamaCompatNumCtxEnabled({
config: params.config,
providerId: params.providerId,
});
}
export function wrapOllamaCompatNumCtx(baseFn: StreamFn | undefined, numCtx: number): StreamFn {
const streamFn = baseFn ?? streamSimple;
return (model, context, options) =>
streamWithPayloadPatch(streamFn, model, context, options, (payloadRecord) => {
if (!payloadRecord.options || typeof payloadRecord.options !== "object") {
payloadRecord.options = {};
}
(payloadRecord.options as Record<string, unknown>).num_ctx = numCtx;
});
}
function createOllamaThinkingWrapper(
baseFn: StreamFn | undefined,
think: OllamaThinkValue,
): StreamFn {
const streamFn = baseFn ?? streamSimple;
return (model, context, options) =>
streamWithPayloadPatch(streamFn, model, context, options, (payloadRecord) => {
payloadRecord.think = think;
});
}
function resolveOllamaThinkValue(thinkingLevel: unknown): OllamaThinkValue | undefined {
if (thinkingLevel === "off") {
return false;
}
if (thinkingLevel === "low" || thinkingLevel === "medium" || thinkingLevel === "high") {
return thinkingLevel;
}
if (thinkingLevel === "minimal") {
return "low";
}
if (thinkingLevel === "xhigh" || thinkingLevel === "adaptive" || thinkingLevel === "max") {
return "high";
}
return undefined;
}
export function resolveOllamaThinkParamValue(
params: Record<string, unknown> | undefined,
): OllamaThinkValue | undefined {
const raw = params?.think ?? params?.thinking;
if (typeof raw === "boolean") {
return raw;
}
if (raw === "off") {
return false;
}
if (raw === "low" || raw === "medium" || raw === "high") {
return raw;
}
if (raw === "minimal") {
return "low";
}
if (raw === "xhigh" || raw === "adaptive" || raw === "max") {
return "high";
}
return undefined;
}
export function shouldForwardNativeOllamaThink(
model: ProviderRuntimeModel | undefined,
think: OllamaThinkValue,
): boolean {
// Ollama accepts top-level `think` as the native chat contract, but rejects
// truthy values for models known not to expose thinking support.
return think === false || model?.reasoning !== false;
}
export function resolveOllamaConfiguredNumCtx(model: ProviderRuntimeModel): number | undefined {
const raw = model.params?.num_ctx;
if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) {
return undefined;
}
return Math.floor(raw);
}
function resolveOllamaNumCtx(model: ProviderRuntimeModel): number {
return (
resolveOllamaConfiguredNumCtx(model) ??
Math.max(
1,
Math.floor(
model.contextTokens ?? model.contextWindow ?? model.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
),
)
);
}
export function createConfiguredOllamaCompatStreamWrapper(
ctx: ProviderWrapStreamFnContext,
): StreamFn | undefined {
let streamFn = ctx.streamFn;
const model = ctx.model;
let injectNumCtx = false;
const isNativeOllamaTransport = model?.api === "ollama";
if (model) {
const providerId =
typeof model.provider === "string" && model.provider.trim().length > 0
? model.provider
: ctx.provider;
if (
shouldInjectOllamaCompatNumCtx({
model,
config: ctx.config,
providerId,
})
) {
injectNumCtx = true;
}
}
if (injectNumCtx && model) {
streamFn = wrapOllamaCompatNumCtx(streamFn, resolveOllamaNumCtx(model));
}
const configuredThinkValue = model ? resolveOllamaThinkParamValue(model.params) : undefined;
const runtimeThinkValue = isNativeOllamaTransport
? resolveOllamaThinkValue(ctx.thinkingLevel)
: undefined;
// "off" is also the implicit agent default. Preserve explicit native Ollama
// model config unless the active run requests a non-off thinking level.
const ollamaThinkValue =
runtimeThinkValue === false && configuredThinkValue !== undefined
? undefined
: runtimeThinkValue;
if (ollamaThinkValue !== undefined && shouldForwardNativeOllamaThink(model, ollamaThinkValue)) {
streamFn = createOllamaThinkingWrapper(streamFn, ollamaThinkValue);
}
if (
normalizeProviderId(ctx.provider) === "ollama" &&
shouldWrapOllamaCompatMoonshotThinking(ctx.modelId)
) {
const thinkingType = resolveMoonshotThinkingType({
configuredThinking: ctx.extraParams?.thinking,
thinkingLevel: ctx.thinkingLevel,
});
streamFn = createMoonshotThinkingWrapper(streamFn, thinkingType);
}
return streamFn;
}
+2
View File
@@ -0,0 +1,2 @@
// Lightweight stream constants used before the Ollama transport is activated.
export const OLLAMA_INCOMPLETE_STREAM_ERROR = "Ollama API stream ended without a final response";
@@ -0,0 +1,17 @@
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
const loadOllamaStreamRuntime = createLazyRuntimeModule(() => import("./stream.runtime.js"));
export function createLazyConfiguredOllamaStreamFn(params: {
model: { baseUrl?: string; headers?: unknown };
providerBaseUrl?: string;
}): StreamFn {
const streamFnPromise = loadOllamaStreamRuntime().then((runtime) =>
runtime.createConfiguredOllamaStreamFn(params),
);
return async (...args) => {
const streamFn = await streamFnPromise;
return streamFn(...args);
};
}
+2 -2
View File
@@ -19,8 +19,8 @@ vi.mock("openclaw/plugin-sdk/runtime-env", async (importOriginal) => {
};
});
import { OLLAMA_INCOMPLETE_STREAM_ERROR } from "./stream-contract.js";
import {
OLLAMA_INCOMPLETE_STREAM_ERROR,
buildOllamaChatRequest,
createConfiguredOllamaCompatStreamWrapper,
createConfiguredOllamaStreamFn,
@@ -29,7 +29,7 @@ import {
buildAssistantMessage,
parseNdjsonStream,
resolveOllamaBaseUrlForRun,
} from "./stream.js";
} from "./stream.runtime.js";
type GuardedFetchCall = {
url: string;
@@ -1,4 +1,4 @@
// Ollama plugin module implements stream behavior.
// Ollama stream runtime implements native transport behavior.
import { randomUUID } from "node:crypto";
import type { StreamFn } from "openclaw/plugin-sdk/agent-core";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
@@ -11,30 +11,16 @@ import type {
Tool,
Usage,
} from "openclaw/plugin-sdk/llm";
import { createAssistantMessageEventStream, streamSimple } from "openclaw/plugin-sdk/llm";
import type {
OpenClawConfig,
ProviderRuntimeModel,
ProviderWrapStreamFnContext,
} from "openclaw/plugin-sdk/plugin-entry";
import { createAssistantMessageEventStream } from "openclaw/plugin-sdk/llm";
import type { ProviderRuntimeModel } from "openclaw/plugin-sdk/plugin-entry";
import { isNonSecretApiKeyMarker } from "openclaw/plugin-sdk/provider-auth";
import { readResponseTextLimited } from "openclaw/plugin-sdk/provider-http";
import {
DEFAULT_CONTEXT_TOKENS,
normalizeProviderId,
} from "openclaw/plugin-sdk/provider-model-shared";
import {
createMoonshotThinkingWrapper,
createPlainTextToolCallCompatWrapper,
resolveMoonshotThinkingType,
streamWithPayloadPatch,
} from "openclaw/plugin-sdk/provider-stream-shared";
import { createPlainTextToolCallCompatWrapper } from "openclaw/plugin-sdk/provider-stream-shared";
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { fetchWithSsrFGuard, isLoopbackHost } from "openclaw/plugin-sdk/ssrf-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { isRecord, readStringValue } from "openclaw/plugin-sdk/string-coerce-runtime";
import { estimateStringChars, truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import { OLLAMA_CLOUD_BASE_URL, OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
import { shouldWrapOllamaCompatMoonshotThinking } from "./model-behavior.js";
import { normalizeOllamaWireModelId } from "./model-id.js";
import {
parseJsonObjectPreservingUnsafeIntegers,
@@ -45,11 +31,26 @@ import {
createOllamaVisibleContentSanitizer,
sanitizeOllamaFinalVisibleContent,
} from "./sanitizers/visible-content.js";
import {
type OllamaThinkValue,
resolveOllamaConfiguredNumCtx,
resolveOllamaThinkParamValue,
shouldForwardNativeOllamaThink,
} from "./stream-compat.js";
import { OLLAMA_INCOMPLETE_STREAM_ERROR } from "./stream-contract.js";
import { checkNdjsonRecordCap } from "./stream-ndjson-cap.js";
export {
createConfiguredOllamaCompatStreamWrapper,
isOllamaCompatProvider,
resolveOllamaCompatNumCtxEnabled,
shouldInjectOllamaCompatNumCtx,
wrapOllamaCompatNumCtx,
} from "./stream-compat.js";
const log = createSubsystemLogger("ollama-stream");
export const OLLAMA_NATIVE_BASE_URL = OLLAMA_DEFAULT_BASE_URL;
export const OLLAMA_INCOMPLETE_STREAM_ERROR = "Ollama API stream ended without a final response";
const OLLAMA_STREAM_COOPERATIVE_YIELD_INTERVAL_MS = 12;
const OLLAMA_STREAM_COOPERATIVE_YIELD_MAX_EVENTS = 64;
@@ -153,97 +154,6 @@ export function resolveOllamaBaseUrlForRun(params: {
return OLLAMA_NATIVE_BASE_URL;
}
export function resolveConfiguredOllamaProviderConfig(params: {
config?: OpenClawConfig;
providerId?: string;
}) {
const providerId = params.providerId?.trim();
if (!providerId) {
return undefined;
}
const providers = params.config?.models?.providers;
if (!providers) {
return undefined;
}
const direct = providers[providerId];
if (direct) {
return direct;
}
const normalized = normalizeProviderId(providerId);
for (const [candidateId, candidate] of Object.entries(providers)) {
if (normalizeProviderId(candidateId) === normalized) {
return candidate;
}
}
return undefined;
}
export function isOllamaCompatProvider(model: {
provider?: string;
baseUrl?: string;
api?: string;
}): boolean {
const providerId = normalizeProviderId(model.provider ?? "");
if (providerId === "ollama") {
return true;
}
if (!model.baseUrl) {
return false;
}
try {
const parsed = new URL(model.baseUrl);
if (isLoopbackHost(parsed.hostname) && parsed.port === "11434") {
return true;
}
// Allow remote/LAN Ollama OpenAI-compatible endpoints when the provider id
// itself indicates Ollama usage (for example "my-ollama").
const providerHintsOllama = providerId.includes("ollama");
const isOllamaPort = parsed.port === "11434";
const isOllamaCompatPath = parsed.pathname === "/" || /^\/v1\/?$/i.test(parsed.pathname);
return providerHintsOllama && isOllamaPort && isOllamaCompatPath;
} catch {
return false;
}
}
export function resolveOllamaCompatNumCtxEnabled(params: {
config?: OpenClawConfig;
providerId?: string;
}): boolean {
return resolveConfiguredOllamaProviderConfig(params)?.injectNumCtxForOpenAICompat ?? true;
}
export function shouldInjectOllamaCompatNumCtx(params: {
model: { api?: string; provider?: string; baseUrl?: string };
config?: OpenClawConfig;
providerId?: string;
}): boolean {
if (params.model.api !== "openai-completions") {
return false;
}
if (!isOllamaCompatProvider(params.model)) {
return false;
}
return resolveOllamaCompatNumCtxEnabled({
config: params.config,
providerId: params.providerId,
});
}
export function wrapOllamaCompatNumCtx(baseFn: StreamFn | undefined, numCtx: number): StreamFn {
const streamFn = baseFn ?? streamSimple;
return (model, context, options) =>
streamWithPayloadPatch(streamFn, model, context, options, (payloadRecord) => {
if (!payloadRecord.options || typeof payloadRecord.options !== "object") {
payloadRecord.options = {};
}
(payloadRecord.options as Record<string, unknown>).num_ctx = numCtx;
});
}
type OllamaThinkValue = boolean | "low" | "medium" | "high";
const OLLAMA_OPTION_PARAM_KEYS = new Set([
"num_keep",
"seed",
@@ -268,92 +178,14 @@ const OLLAMA_OPTION_PARAM_KEYS = new Set([
const OLLAMA_TOP_LEVEL_PARAM_KEYS = new Set(["format", "keep_alive", "truncate", "shift"]);
function createOllamaThinkingWrapper(
baseFn: StreamFn | undefined,
think: OllamaThinkValue,
): StreamFn {
const streamFn = baseFn ?? streamSimple;
return (model, context, options) =>
streamWithPayloadPatch(streamFn, model, context, options, (payloadRecord) => {
payloadRecord.think = think;
});
}
function resolveOllamaThinkValue(thinkingLevel: unknown): OllamaThinkValue | undefined {
if (thinkingLevel === "off") {
return false;
}
if (thinkingLevel === "low" || thinkingLevel === "medium" || thinkingLevel === "high") {
return thinkingLevel;
}
if (thinkingLevel === "minimal") {
return "low";
}
if (thinkingLevel === "xhigh" || thinkingLevel === "adaptive" || thinkingLevel === "max") {
return "high";
}
return undefined;
}
function resolveOllamaThinkParamValue(
params: Record<string, unknown> | undefined,
): OllamaThinkValue | undefined {
const raw = params?.think ?? params?.thinking;
if (typeof raw === "boolean") {
return raw;
}
if (raw === "off") {
return false;
}
if (raw === "low" || raw === "medium" || raw === "high") {
return raw;
}
if (raw === "minimal") {
return "low";
}
if (raw === "xhigh" || raw === "adaptive" || raw === "max") {
return "high";
}
return undefined;
}
function shouldForwardNativeOllamaThink(
model: ProviderRuntimeModel | undefined,
think: OllamaThinkValue,
): boolean {
// Ollama accepts top-level `think` as the native chat contract, but rejects
// truthy values for models known not to expose thinking support.
return think === false || model?.reasoning !== false;
}
function resolveOllamaConfiguredNumCtx(model: ProviderRuntimeModel): number | undefined {
const raw = model.params?.num_ctx;
if (typeof raw !== "number" || !Number.isFinite(raw) || raw <= 0) {
return undefined;
}
return Math.floor(raw);
}
function resolveOllamaNumCtx(model: ProviderRuntimeModel): number {
return (
resolveOllamaConfiguredNumCtx(model) ??
Math.max(
1,
Math.floor(
model.contextTokens ?? model.contextWindow ?? model.maxTokens ?? DEFAULT_CONTEXT_TOKENS,
),
)
);
}
/**
* Resolves num_ctx for native /api/chat requests:
* 1. explicit `params.num_ctx` set on the model wins,
* 2. the effective `contextTokens` runtime cap is forwarded when present,
* 3. otherwise Ollama's model, OLLAMA_CONTEXT_LENGTH, VRAM, or Modelfile policy decides.
*
* This intentionally differs from `resolveOllamaNumCtx` by not falling back
* to `DEFAULT_CONTEXT_TOKENS`: that constant is a sane wrapper-side guess for
* This intentionally differs from the OpenAI-compat resolver by not falling back
* to a default context size: that fallback is a sane wrapper-side guess for
* the OpenAI-compat path, but native `/api/chat` should not force the full
* advertised `contextWindow`; only an explicit runtime cap or operator override is forwarded.
*/
@@ -435,62 +267,6 @@ function resolveStreamingTextDelta(previousText: string, nextText: string): stri
return nextText;
}
export function createConfiguredOllamaCompatStreamWrapper(
ctx: ProviderWrapStreamFnContext,
): StreamFn | undefined {
let streamFn = ctx.streamFn;
const model = ctx.model;
let injectNumCtx = false;
const isNativeOllamaTransport = model?.api === "ollama";
if (model) {
const providerId =
typeof model.provider === "string" && model.provider.trim().length > 0
? model.provider
: ctx.provider;
if (
shouldInjectOllamaCompatNumCtx({
model,
config: ctx.config,
providerId,
})
) {
injectNumCtx = true;
}
}
if (injectNumCtx && model) {
streamFn = wrapOllamaCompatNumCtx(streamFn, resolveOllamaNumCtx(model));
}
const configuredThinkValue = model ? resolveOllamaThinkParamValue(model.params) : undefined;
const runtimeThinkValue = isNativeOllamaTransport
? resolveOllamaThinkValue(ctx.thinkingLevel)
: undefined;
// "off" is also the implicit agent default. Preserve explicit native Ollama
// model config unless the active run requests a non-off thinking level.
const ollamaThinkValue =
runtimeThinkValue === false && configuredThinkValue !== undefined
? undefined
: runtimeThinkValue;
if (ollamaThinkValue !== undefined && shouldForwardNativeOllamaThink(model, ollamaThinkValue)) {
streamFn = createOllamaThinkingWrapper(streamFn, ollamaThinkValue);
}
if (
normalizeProviderId(ctx.provider) === "ollama" &&
shouldWrapOllamaCompatMoonshotThinking(ctx.modelId)
) {
const thinkingType = resolveMoonshotThinkingType({
configuredThinking: ctx.extraParams?.thinking,
thinkingLevel: ctx.thinkingLevel,
});
streamFn = createMoonshotThinkingWrapper(streamFn, thinkingType);
}
return streamFn;
}
export function buildOllamaChatRequest(params: {
modelId: string;
providerId?: string;
+5 -1
View File
@@ -13,7 +13,11 @@ vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => ({
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
}));
import { buildAssistantMessage, createOllamaStreamFn, isOllamaCompatProvider } from "./stream.js";
import {
buildAssistantMessage,
createOllamaStreamFn,
isOllamaCompatProvider,
} from "./stream-api.js";
function makeOllamaResponse(params: {
content?: string;
@@ -0,0 +1,19 @@
import type { WebSearchProviderToolDefinition } from "openclaw/plugin-sdk/provider-web-search-contract";
export const OLLAMA_WEB_SEARCH_TOOL_DESCRIPTION =
"Search the web using Ollama's web search API. Returns titles, URLs, and snippets from the configured Ollama host.";
export const OLLAMA_WEB_SEARCH_TOOL_PARAMETERS = {
type: "object",
properties: {
query: { type: "string", description: "Search query string." },
count: {
type: "integer",
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
},
},
required: ["query"],
additionalProperties: false,
} as const satisfies WebSearchProviderToolDefinition["parameters"];
@@ -0,0 +1,42 @@
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
import { createOllamaWebSearchProvider as createOllamaWebSearchProviderContract } from "../web-search-contract-api.js";
import {
OLLAMA_WEB_SEARCH_TOOL_DESCRIPTION,
OLLAMA_WEB_SEARCH_TOOL_PARAMETERS,
} from "./web-search-contract.js";
const loadOllamaWebSearchProvider = createLazyRuntimeModule(
() => import("./web-search-provider.runtime.js"),
);
export function createLazyOllamaWebSearchProvider(): WebSearchProviderPlugin {
let providerPromise: Promise<WebSearchProviderPlugin> | undefined;
const loadProvider = () =>
(providerPromise ??= loadOllamaWebSearchProvider().then((runtime) =>
runtime.createOllamaWebSearchProvider(),
));
return {
...createOllamaWebSearchProviderContract(),
runSetup: async (ctx) => {
const provider = await loadProvider();
return provider.runSetup ? await provider.runSetup(ctx) : ctx.config;
},
createTool: (ctx) => {
let toolPromise: Promise<ReturnType<WebSearchProviderPlugin["createTool"]>> | undefined;
const loadTool = () =>
(toolPromise ??= loadProvider().then((provider) => provider.createTool(ctx)));
return {
description: OLLAMA_WEB_SEARCH_TOOL_DESCRIPTION,
parameters: OLLAMA_WEB_SEARCH_TOOL_PARAMETERS,
execute: async (args, executionContext) => {
const tool = await loadTool();
if (!tool) {
throw new Error("Ollama web search runtime did not create a tool");
}
return await tool.execute(args, executionContext);
},
};
},
};
}
@@ -0,0 +1,367 @@
// Ollama web-search runtime implements provider integration.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
isNonSecretApiKeyMarker,
normalizeOptionalSecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import {
enablePluginInConfig,
readPositiveIntegerParam,
readResponseText,
readStringParam,
resolveProviderWebSearchPluginConfig,
resolveSearchCount,
resolveSiteName,
resolveWebSearchProviderCredential,
truncateText,
wrapWebContent,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search";
import { coerceSecretRef } from "openclaw/plugin-sdk/secret-input";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
import { readProviderBaseUrl } from "./provider-base-url.js";
import {
buildOllamaBaseUrlSsrFPolicy,
fetchOllamaModels,
resolveOllamaApiBase,
} from "./provider-models.js";
import {
OLLAMA_WEB_SEARCH_TOOL_DESCRIPTION,
OLLAMA_WEB_SEARCH_TOOL_PARAMETERS,
} from "./web-search-contract.js";
const OLLAMA_HOSTED_WEB_SEARCH_PATH = "/api/web_search";
const OLLAMA_LOCAL_WEB_SEARCH_PROXY_PATH = "/api/experimental/web_search";
const OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
const DEFAULT_OLLAMA_WEB_SEARCH_COUNT = 5;
const DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS = 15_000;
const OLLAMA_WEB_SEARCH_SNIPPET_MAX_CHARS = 300;
type OllamaWebSearchResult = {
title?: string;
url?: string;
content?: string;
};
type OllamaWebSearchResponse = {
results?: OllamaWebSearchResult[];
};
type OllamaWebSearchAttempt = {
baseUrl: string;
path: string;
apiKey?: string;
};
async function readOllamaWebSearchResponse(response: Response): Promise<OllamaWebSearchResponse> {
return await readProviderJsonResponse<OllamaWebSearchResponse>(response, "Ollama web search");
}
function isOllamaCloudBaseUrl(baseUrl: string): boolean {
try {
const parsed = new URL(baseUrl);
return parsed.protocol === "https:" && parsed.hostname === "ollama.com";
} catch {
return false;
}
}
function normalizeOllamaWebSearchApiKey(value: unknown): string | undefined {
const apiKey = normalizeOptionalSecretInput(value);
return apiKey && !isNonSecretApiKeyMarker(apiKey) ? apiKey : undefined;
}
function resolveEnvOllamaWebSearchApiKey(): string | undefined {
return normalizeOllamaWebSearchApiKey(resolveEnvApiKey("ollama")?.apiKey);
}
function createOllamaWebSearchCredentialError(ref: { source: string; id: string }): Error {
return new Error(
ref.source === "env"
? `models.providers.ollama.apiKey env SecretRef ${ref.id} is not available for Ollama web search.`
: "models.providers.ollama.apiKey SecretRef cannot be resolved by Ollama web search. Use an env SecretRef for this path.",
);
}
// Delegate configured-key resolution (literal value or env-backed SecretRef) to the shared
// web-search resolver, then apply Ollama's marker filter so persisted non-secret placeholders
// (e.g. the OAuth/signin marker) fall through to the ambient OLLAMA_API_KEY instead of being sent.
function resolveConfiguredOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined {
const credentialValue = config?.models?.providers?.ollama?.apiKey;
const credentialRef = coerceSecretRef(credentialValue);
const resolvedValue = normalizeOllamaWebSearchApiKey(
resolveWebSearchProviderCredential({
credentialValue,
path: "models.providers.ollama.apiKey",
envVars: [],
}),
);
// An explicit ref selects one credential. Do not reinterpret an unavailable ref as no config,
// which would permit an unrelated ambient key and potentially route the query to Ollama Cloud.
if (credentialRef && !resolvedValue) {
throw createOllamaWebSearchCredentialError(credentialRef);
}
return resolvedValue;
}
function resolveOllamaWebSearchBaseUrl(config?: OpenClawConfig): string {
const pluginBaseUrl = normalizeOptionalString(
resolveProviderWebSearchPluginConfig(config, "ollama")?.baseUrl,
);
if (pluginBaseUrl) {
return resolveOllamaApiBase(pluginBaseUrl);
}
const configuredBaseUrl = readProviderBaseUrl(config?.models?.providers?.ollama);
if (configuredBaseUrl) {
return resolveOllamaApiBase(configuredBaseUrl);
}
return OLLAMA_DEFAULT_BASE_URL;
}
function normalizeOllamaWebSearchResult(
result: OllamaWebSearchResult,
): { title: string; url: string; content: string } | null {
const url = normalizeOptionalString(result.url) ?? "";
if (!url) {
return null;
}
return {
title: normalizeOptionalString(result.title) ?? "",
url,
content: normalizeOptionalString(result.content) ?? "",
};
}
function buildOllamaWebSearchAttempts(params: {
baseUrl: string;
configuredApiKey?: string;
envApiKey?: string;
}): OllamaWebSearchAttempt[] {
if (isOllamaCloudBaseUrl(params.baseUrl)) {
return [
{
baseUrl: params.baseUrl,
path: OLLAMA_HOSTED_WEB_SEARCH_PATH,
apiKey: params.configuredApiKey ?? params.envApiKey,
},
];
}
const attempts: OllamaWebSearchAttempt[] = [
{
baseUrl: params.baseUrl,
path: OLLAMA_LOCAL_WEB_SEARCH_PROXY_PATH,
apiKey: params.configuredApiKey,
},
{
baseUrl: params.baseUrl,
path: OLLAMA_HOSTED_WEB_SEARCH_PATH,
apiKey: params.configuredApiKey,
},
];
if (params.envApiKey) {
attempts.push({
baseUrl: OLLAMA_CLOUD_BASE_URL,
path: OLLAMA_HOSTED_WEB_SEARCH_PATH,
apiKey: params.envApiKey,
});
}
return attempts;
}
async function runOllamaWebSearch(params: {
config?: OpenClawConfig;
query: string;
count?: number;
signal?: AbortSignal;
}): Promise<Record<string, unknown>> {
const query = params.query.trim();
if (!query) {
throw new Error("query parameter is required");
}
const baseUrl = resolveOllamaWebSearchBaseUrl(params.config);
const configuredApiKey = resolveConfiguredOllamaWebSearchApiKey(params.config);
// Resolve the ambient cloud key independently of the configured selected-host key so a mixed
// setup still reaches the Ollama Cloud fallback with OLLAMA_API_KEY after the selected-host
// attempts fail. Gating this on configuredApiKey would drop that final authenticated attempt.
const envApiKey = resolveEnvOllamaWebSearchApiKey();
const count = resolveSearchCount(params.count, DEFAULT_OLLAMA_WEB_SEARCH_COUNT);
const startedAt = Date.now();
const body = JSON.stringify({ query, max_results: count });
const attempts = buildOllamaWebSearchAttempts({ baseUrl, configuredApiKey, envApiKey });
let payload: OllamaWebSearchResponse | undefined;
let lastError: Error | undefined;
for (const attempt of attempts) {
params.signal?.throwIfAborted();
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (attempt.apiKey) {
headers.Authorization = `Bearer ${attempt.apiKey}`;
}
const { response, release } = await fetchWithSsrFGuard({
url: `${attempt.baseUrl}${attempt.path}`,
init: {
method: "POST",
headers,
body,
},
// Guard-owned timeoutMs also bounds DNS/proxy preflight; init.signal does not.
timeoutMs: DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS,
...(params.signal ? { signal: params.signal } : {}),
policy: buildOllamaBaseUrlSsrFPolicy(attempt.baseUrl),
auditContext: "ollama-web-search.search",
});
try {
if (response.status === 401) {
throw new Error("Ollama web search authentication failed. Run `ollama signin`.");
}
if (response.status === 403) {
throw new Error(
"Ollama web search is unavailable. Ensure cloud-backed web search is enabled on the Ollama host.",
);
}
if (!response.ok) {
const detail = await readResponseText(response, { maxBytes: 64_000 });
const message =
`Ollama web search failed (${response.status}): ${detail.text || ""}`.trim();
if (response.status === 404) {
lastError = new Error(message);
continue;
}
throw new Error(message);
}
payload = await readOllamaWebSearchResponse(response);
params.signal?.throwIfAborted();
break;
} catch (error) {
if (error instanceof Error) {
lastError = error;
} else {
lastError = new Error(String(error));
}
throw lastError;
} finally {
// The 401/403 branches throw before the stream is touched, leaving release
// to force-close the active dispatcher. Start cancellation first; awaiting
// it can deadlock when capture tees the stream.
if (!response.bodyUsed) {
void response.body?.cancel().catch(() => undefined);
}
await release();
}
}
if (!payload) {
throw lastError ?? new Error("Ollama web search failed");
}
const results = Array.isArray(payload.results)
? payload.results
.map(normalizeOllamaWebSearchResult)
.filter((result): result is NonNullable<typeof result> => result !== null)
.slice(0, count)
: [];
return {
query,
provider: "ollama",
count: results.length,
tookMs: Date.now() - startedAt,
externalContent: {
untrusted: true,
source: "web_search",
provider: "ollama",
wrapped: true,
},
results: results.map((result) => {
const snippet = truncateText(result.content, OLLAMA_WEB_SEARCH_SNIPPET_MAX_CHARS).text;
return {
title: result.title ? wrapWebContent(result.title, "web_search") : "",
url: result.url,
snippet: snippet ? wrapWebContent(snippet, "web_search") : "",
siteName: resolveSiteName(result.url) || undefined,
};
}),
};
}
async function warnOllamaWebSearchPrereqs(params: {
config: OpenClawConfig;
prompter: {
note: (message: string, title?: string) => Promise<void>;
};
}): Promise<OpenClawConfig> {
const baseUrl = resolveOllamaWebSearchBaseUrl(params.config);
const { reachable } = await fetchOllamaModels(baseUrl);
if (!reachable) {
await params.prompter.note(
[
"Ollama Web Search requires Ollama to be running.",
`Expected host: ${baseUrl}`,
"Start Ollama before using this provider.",
].join("\n"),
"Ollama Web Search",
);
return params.config;
}
const { checkOllamaCloudAuth } = await import("./setup.runtime.js");
const auth = await checkOllamaCloudAuth(baseUrl);
if (!auth.signedIn) {
await params.prompter.note(
[
"Ollama Web Search requires `ollama signin`.",
...(auth.signinUrl ? [auth.signinUrl] : ["Run `ollama signin`."]),
].join("\n"),
"Ollama Web Search",
);
}
return params.config;
}
export function createOllamaWebSearchProvider(): WebSearchProviderPlugin {
return {
id: "ollama",
label: "Ollama Web Search",
hint: "Local Ollama host · requires ollama signin",
onboardingScopes: ["text-inference"],
requiresCredential: false,
envVars: [],
placeholder: "(run ollama signin)",
signupUrl: "https://ollama.com/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 110,
credentialPath: "",
getCredentialValue: () => undefined,
setCredentialValue: () => {},
applySelectionConfig: (config) => enablePluginInConfig(config, "ollama").config,
runSetup: async (ctx) =>
await warnOllamaWebSearchPrereqs({
config: ctx.config,
prompter: ctx.prompter,
}),
createTool: (ctx) => ({
description: OLLAMA_WEB_SEARCH_TOOL_DESCRIPTION,
parameters: OLLAMA_WEB_SEARCH_TOOL_PARAMETERS,
execute: async (args, context) => {
context?.signal?.throwIfAborted();
return await runOllamaWebSearch({
config: ctx.config,
query: readStringParam(args, "query", { required: true }),
count: readPositiveIntegerParam(args, "count", {
max: 10,
message: "count must be an integer from 1 to 10.",
}),
signal: context?.signal,
});
},
}),
};
}
+1 -379
View File
@@ -1,379 +1 @@
// Ollama provider module implements model/runtime integration.
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
isNonSecretApiKeyMarker,
normalizeOptionalSecretInput,
} from "openclaw/plugin-sdk/provider-auth";
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import {
enablePluginInConfig,
readPositiveIntegerParam,
readResponseText,
readStringParam,
resolveProviderWebSearchPluginConfig,
resolveSearchCount,
resolveSiteName,
resolveWebSearchProviderCredential,
truncateText,
wrapWebContent,
type WebSearchProviderPlugin,
} from "openclaw/plugin-sdk/provider-web-search";
import { coerceSecretRef } from "openclaw/plugin-sdk/secret-input";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { Type } from "typebox";
import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
import { readProviderBaseUrl } from "./provider-base-url.js";
import {
buildOllamaBaseUrlSsrFPolicy,
fetchOllamaModels,
resolveOllamaApiBase,
} from "./provider-models.js";
import { checkOllamaCloudAuth } from "./setup.js";
const OLLAMA_WEB_SEARCH_SCHEMA = Type.Object(
{
query: Type.String({ description: "Search query string." }),
count: Type.Optional(
Type.Integer({
description: "Number of results to return (1-10).",
minimum: 1,
maximum: 10,
}),
),
},
{ additionalProperties: false },
);
const OLLAMA_HOSTED_WEB_SEARCH_PATH = "/api/web_search";
const OLLAMA_LOCAL_WEB_SEARCH_PROXY_PATH = "/api/experimental/web_search";
const OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
const DEFAULT_OLLAMA_WEB_SEARCH_COUNT = 5;
const DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS = 15_000;
const OLLAMA_WEB_SEARCH_SNIPPET_MAX_CHARS = 300;
type OllamaWebSearchResult = {
title?: string;
url?: string;
content?: string;
};
type OllamaWebSearchResponse = {
results?: OllamaWebSearchResult[];
};
type OllamaWebSearchAttempt = {
baseUrl: string;
path: string;
apiKey?: string;
};
async function readOllamaWebSearchResponse(response: Response): Promise<OllamaWebSearchResponse> {
return await readProviderJsonResponse<OllamaWebSearchResponse>(response, "Ollama web search");
}
function isOllamaCloudBaseUrl(baseUrl: string): boolean {
try {
const parsed = new URL(baseUrl);
return parsed.protocol === "https:" && parsed.hostname === "ollama.com";
} catch {
return false;
}
}
function normalizeOllamaWebSearchApiKey(value: unknown): string | undefined {
const apiKey = normalizeOptionalSecretInput(value);
return apiKey && !isNonSecretApiKeyMarker(apiKey) ? apiKey : undefined;
}
function resolveEnvOllamaWebSearchApiKey(): string | undefined {
return normalizeOllamaWebSearchApiKey(resolveEnvApiKey("ollama")?.apiKey);
}
function createOllamaWebSearchCredentialError(ref: { source: string; id: string }): Error {
return new Error(
ref.source === "env"
? `models.providers.ollama.apiKey env SecretRef ${ref.id} is not available for Ollama web search.`
: "models.providers.ollama.apiKey SecretRef cannot be resolved by Ollama web search. Use an env SecretRef for this path.",
);
}
// Delegate configured-key resolution (literal value or env-backed SecretRef) to the shared
// web-search resolver, then apply Ollama's marker filter so persisted non-secret placeholders
// (e.g. the OAuth/signin marker) fall through to the ambient OLLAMA_API_KEY instead of being sent.
function resolveConfiguredOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined {
const credentialValue = config?.models?.providers?.ollama?.apiKey;
const credentialRef = coerceSecretRef(credentialValue);
const resolvedValue = normalizeOllamaWebSearchApiKey(
resolveWebSearchProviderCredential({
credentialValue,
path: "models.providers.ollama.apiKey",
envVars: [],
}),
);
// An explicit ref selects one credential. Do not reinterpret an unavailable ref as no config,
// which would permit an unrelated ambient key and potentially route the query to Ollama Cloud.
if (credentialRef && !resolvedValue) {
throw createOllamaWebSearchCredentialError(credentialRef);
}
return resolvedValue;
}
function resolveOllamaWebSearchBaseUrl(config?: OpenClawConfig): string {
const pluginBaseUrl = normalizeOptionalString(
resolveProviderWebSearchPluginConfig(config, "ollama")?.baseUrl,
);
if (pluginBaseUrl) {
return resolveOllamaApiBase(pluginBaseUrl);
}
const configuredBaseUrl = readProviderBaseUrl(config?.models?.providers?.ollama);
if (configuredBaseUrl) {
return resolveOllamaApiBase(configuredBaseUrl);
}
return OLLAMA_DEFAULT_BASE_URL;
}
function normalizeOllamaWebSearchResult(
result: OllamaWebSearchResult,
): { title: string; url: string; content: string } | null {
const url = normalizeOptionalString(result.url) ?? "";
if (!url) {
return null;
}
return {
title: normalizeOptionalString(result.title) ?? "",
url,
content: normalizeOptionalString(result.content) ?? "",
};
}
function buildOllamaWebSearchAttempts(params: {
baseUrl: string;
configuredApiKey?: string;
envApiKey?: string;
}): OllamaWebSearchAttempt[] {
if (isOllamaCloudBaseUrl(params.baseUrl)) {
return [
{
baseUrl: params.baseUrl,
path: OLLAMA_HOSTED_WEB_SEARCH_PATH,
apiKey: params.configuredApiKey ?? params.envApiKey,
},
];
}
const attempts: OllamaWebSearchAttempt[] = [
{
baseUrl: params.baseUrl,
path: OLLAMA_LOCAL_WEB_SEARCH_PROXY_PATH,
apiKey: params.configuredApiKey,
},
{
baseUrl: params.baseUrl,
path: OLLAMA_HOSTED_WEB_SEARCH_PATH,
apiKey: params.configuredApiKey,
},
];
if (params.envApiKey) {
attempts.push({
baseUrl: OLLAMA_CLOUD_BASE_URL,
path: OLLAMA_HOSTED_WEB_SEARCH_PATH,
apiKey: params.envApiKey,
});
}
return attempts;
}
async function runOllamaWebSearch(params: {
config?: OpenClawConfig;
query: string;
count?: number;
signal?: AbortSignal;
}): Promise<Record<string, unknown>> {
const query = params.query.trim();
if (!query) {
throw new Error("query parameter is required");
}
const baseUrl = resolveOllamaWebSearchBaseUrl(params.config);
const configuredApiKey = resolveConfiguredOllamaWebSearchApiKey(params.config);
// Resolve the ambient cloud key independently of the configured selected-host key so a mixed
// setup still reaches the Ollama Cloud fallback with OLLAMA_API_KEY after the selected-host
// attempts fail. Gating this on configuredApiKey would drop that final authenticated attempt.
const envApiKey = resolveEnvOllamaWebSearchApiKey();
const count = resolveSearchCount(params.count, DEFAULT_OLLAMA_WEB_SEARCH_COUNT);
const startedAt = Date.now();
const body = JSON.stringify({ query, max_results: count });
const attempts = buildOllamaWebSearchAttempts({ baseUrl, configuredApiKey, envApiKey });
let payload: OllamaWebSearchResponse | undefined;
let lastError: Error | undefined;
for (const attempt of attempts) {
params.signal?.throwIfAborted();
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (attempt.apiKey) {
headers.Authorization = `Bearer ${attempt.apiKey}`;
}
const { response, release } = await fetchWithSsrFGuard({
url: `${attempt.baseUrl}${attempt.path}`,
init: {
method: "POST",
headers,
body,
},
// Guard-owned timeoutMs also bounds DNS/proxy preflight; init.signal does not.
timeoutMs: DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS,
...(params.signal ? { signal: params.signal } : {}),
policy: buildOllamaBaseUrlSsrFPolicy(attempt.baseUrl),
auditContext: "ollama-web-search.search",
});
try {
if (response.status === 401) {
throw new Error("Ollama web search authentication failed. Run `ollama signin`.");
}
if (response.status === 403) {
throw new Error(
"Ollama web search is unavailable. Ensure cloud-backed web search is enabled on the Ollama host.",
);
}
if (!response.ok) {
const detail = await readResponseText(response, { maxBytes: 64_000 });
const message =
`Ollama web search failed (${response.status}): ${detail.text || ""}`.trim();
if (response.status === 404) {
lastError = new Error(message);
continue;
}
throw new Error(message);
}
payload = await readOllamaWebSearchResponse(response);
params.signal?.throwIfAborted();
break;
} catch (error) {
if (error instanceof Error) {
lastError = error;
} else {
lastError = new Error(String(error));
}
throw lastError;
} finally {
// The 401/403 branches throw before the stream is touched, leaving release
// to force-close the active dispatcher. Start cancellation first; awaiting
// it can deadlock when capture tees the stream.
if (!response.bodyUsed) {
void response.body?.cancel().catch(() => undefined);
}
await release();
}
}
if (!payload) {
throw lastError ?? new Error("Ollama web search failed");
}
const results = Array.isArray(payload.results)
? payload.results
.map(normalizeOllamaWebSearchResult)
.filter((result): result is NonNullable<typeof result> => result !== null)
.slice(0, count)
: [];
return {
query,
provider: "ollama",
count: results.length,
tookMs: Date.now() - startedAt,
externalContent: {
untrusted: true,
source: "web_search",
provider: "ollama",
wrapped: true,
},
results: results.map((result) => {
const snippet = truncateText(result.content, OLLAMA_WEB_SEARCH_SNIPPET_MAX_CHARS).text;
return {
title: result.title ? wrapWebContent(result.title, "web_search") : "",
url: result.url,
snippet: snippet ? wrapWebContent(snippet, "web_search") : "",
siteName: resolveSiteName(result.url) || undefined,
};
}),
};
}
async function warnOllamaWebSearchPrereqs(params: {
config: OpenClawConfig;
prompter: {
note: (message: string, title?: string) => Promise<void>;
};
}): Promise<OpenClawConfig> {
const baseUrl = resolveOllamaWebSearchBaseUrl(params.config);
const { reachable } = await fetchOllamaModels(baseUrl);
if (!reachable) {
await params.prompter.note(
[
"Ollama Web Search requires Ollama to be running.",
`Expected host: ${baseUrl}`,
"Start Ollama before using this provider.",
].join("\n"),
"Ollama Web Search",
);
return params.config;
}
const auth = await checkOllamaCloudAuth(baseUrl);
if (!auth.signedIn) {
await params.prompter.note(
[
"Ollama Web Search requires `ollama signin`.",
...(auth.signinUrl ? [auth.signinUrl] : ["Run `ollama signin`."]),
].join("\n"),
"Ollama Web Search",
);
}
return params.config;
}
export function createOllamaWebSearchProvider(): WebSearchProviderPlugin {
return {
id: "ollama",
label: "Ollama Web Search",
hint: "Local Ollama host · requires ollama signin",
onboardingScopes: ["text-inference"],
requiresCredential: false,
envVars: [],
placeholder: "(run ollama signin)",
signupUrl: "https://ollama.com/",
docsUrl: "https://docs.openclaw.ai/tools/web",
autoDetectOrder: 110,
credentialPath: "",
getCredentialValue: () => undefined,
setCredentialValue: () => {},
applySelectionConfig: (config) => enablePluginInConfig(config, "ollama").config,
runSetup: async (ctx) =>
await warnOllamaWebSearchPrereqs({
config: ctx.config,
prompter: ctx.prompter,
}),
createTool: (ctx) => ({
description:
"Search the web using Ollama's web search API. Returns titles, URLs, and snippets from the configured Ollama host.",
parameters: OLLAMA_WEB_SEARCH_SCHEMA,
execute: async (args, context) => {
context?.signal?.throwIfAborted();
return await runOllamaWebSearch({
config: ctx.config,
query: readStringParam(args, "query", { required: true }),
count: readPositiveIntegerParam(args, "count", {
max: 10,
message: "count must be an integer from 1 to 10.",
}),
signal: context?.signal,
});
},
}),
};
}
export { createLazyOllamaWebSearchProvider as createOllamaWebSearchProvider } from "./web-search-provider-registration.js";