mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
@@ -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
|
||||
|
||||
@@ -33,4 +33,4 @@ export {
|
||||
resolveOllamaCompatNumCtxEnabled,
|
||||
shouldInjectOllamaCompatNumCtx,
|
||||
wrapOllamaCompatNumCtx,
|
||||
} from "./src/stream.js";
|
||||
} from "./src/stream-api.js";
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
@@ -13,7 +13,7 @@ export {
|
||||
resolveOllamaCompatNumCtxEnabled,
|
||||
shouldInjectOllamaCompatNumCtx,
|
||||
wrapOllamaCompatNumCtx,
|
||||
} from "./src/stream.js";
|
||||
} from "./src/stream-api.js";
|
||||
export {
|
||||
createOllamaEmbeddingProvider,
|
||||
DEFAULT_OLLAMA_EMBEDDING_MODEL,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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 +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";
|
||||
|
||||
Reference in New Issue
Block a user