Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
3
extensions/ollama/README.md
Normal file
3
extensions/ollama/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Ollama Provider
|
||||
|
||||
Bundled provider plugin for Ollama discovery and setup.
|
||||
35
extensions/ollama/api.ts
Normal file
35
extensions/ollama/api.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Ollama API module exposes the plugin public contract.
|
||||
export {
|
||||
OLLAMA_DEFAULT_BASE_URL,
|
||||
OLLAMA_DEFAULT_CONTEXT_WINDOW,
|
||||
OLLAMA_DEFAULT_COST,
|
||||
OLLAMA_DEFAULT_MAX_TOKENS,
|
||||
OLLAMA_DEFAULT_MODEL,
|
||||
} from "./src/defaults.js";
|
||||
export {
|
||||
buildOllamaModelDefinition,
|
||||
enrichOllamaModelsWithContext,
|
||||
fetchOllamaModels,
|
||||
isReasoningModelHeuristic,
|
||||
queryOllamaContextWindow,
|
||||
queryOllamaModelShowInfo,
|
||||
resolveOllamaApiBase,
|
||||
type OllamaModelShowInfo,
|
||||
type OllamaModelWithContext,
|
||||
type OllamaTagModel,
|
||||
type OllamaTagsResponse,
|
||||
} from "./src/provider-models.js";
|
||||
export {
|
||||
buildOllamaProvider,
|
||||
configureOllamaNonInteractive,
|
||||
ensureOllamaModelPulled,
|
||||
promptAndConfigureOllama,
|
||||
} from "./src/setup.js";
|
||||
export {
|
||||
buildOllamaChatRequest,
|
||||
createConfiguredOllamaCompatStreamWrapper,
|
||||
isOllamaCompatProvider,
|
||||
resolveOllamaCompatNumCtxEnabled,
|
||||
shouldInjectOllamaCompatNumCtx,
|
||||
wrapOllamaCompatNumCtx,
|
||||
} from "./src/stream.js";
|
||||
182
extensions/ollama/doctor-contract-api.test.ts
Normal file
182
extensions/ollama/doctor-contract-api.test.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
// Ollama tests cover doctor contract config compatibility.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { legacyConfigRules, normalizeCompatibilityConfig } from "./doctor-contract-api.js";
|
||||
|
||||
type ModelDefinition = NonNullable<
|
||||
NonNullable<OpenClawConfig["models"]>["providers"]
|
||||
>[string]["models"][number];
|
||||
|
||||
const cloudModel: ModelDefinition = {
|
||||
id: "kimi-k2.5:cloud",
|
||||
name: "Kimi K2.5 Cloud",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 131072,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
|
||||
function readOllamaCloudProvider(config: OpenClawConfig): Record<string, unknown> | undefined {
|
||||
return config.models?.providers?.["ollama-cloud"] as Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
describe("ollama doctor contract", () => {
|
||||
it("detects retired Ollama Cloud provider endpoints", () => {
|
||||
expect(legacyConfigRules[0]?.match({ baseUrl: "https://ai.ollama.com" })).toBe(true);
|
||||
expect(legacyConfigRules[0]?.match({ baseUrl: "https://ollama.com" })).toBe(false);
|
||||
});
|
||||
|
||||
it("migrates retired Ollama Cloud provider baseUrl to the canonical endpoint", () => {
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
"ollama-cloud": {
|
||||
baseUrl: "https://ai.ollama.com",
|
||||
api: "ollama",
|
||||
models: [cloudModel],
|
||||
},
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = normalizeCompatibilityConfig({ cfg: config });
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
"Updated models.providers.ollama-cloud.baseUrl from the retired Ollama Cloud endpoint to https://ollama.com.",
|
||||
]);
|
||||
expect(readOllamaCloudProvider(result.config)).toEqual({
|
||||
baseUrl: "https://ollama.com",
|
||||
api: "ollama",
|
||||
models: [cloudModel],
|
||||
});
|
||||
expect(readOllamaCloudProvider(config)?.baseUrl).toBe("https://ai.ollama.com");
|
||||
});
|
||||
|
||||
it("removes retired Ollama Cloud provider baseURL aliases when canonical baseUrl is present", () => {
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
"ollama-cloud": {
|
||||
baseUrl: "https://ollama.com",
|
||||
baseURL: "https://ai.ollama.com/",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = normalizeCompatibilityConfig({ cfg: config });
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
"Removed retired models.providers.ollama-cloud.baseURL while preserving models.providers.ollama-cloud.baseUrl.",
|
||||
]);
|
||||
expect(readOllamaCloudProvider(result.config)).toEqual({
|
||||
baseUrl: "https://ollama.com",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
});
|
||||
expect(readOllamaCloudProvider(config)).toEqual({
|
||||
baseUrl: "https://ollama.com",
|
||||
baseURL: "https://ai.ollama.com/",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates retired Ollama Cloud provider baseURL aliases when canonical baseUrl is blank", () => {
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
"ollama-cloud": {
|
||||
baseUrl: " ",
|
||||
baseURL: "https://ai.ollama.com/",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = normalizeCompatibilityConfig({ cfg: config });
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
"Updated models.providers.ollama-cloud.baseURL from the retired Ollama Cloud endpoint to https://ollama.com.",
|
||||
]);
|
||||
expect(readOllamaCloudProvider(result.config)).toEqual({
|
||||
baseUrl: "https://ollama.com",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
});
|
||||
expect(readOllamaCloudProvider(config)).toEqual({
|
||||
baseUrl: " ",
|
||||
baseURL: "https://ai.ollama.com/",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves custom canonical baseUrl when removing retired baseURL aliases", () => {
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
"ollama-cloud": {
|
||||
baseUrl: "https://custom-ollama-cloud.example.test",
|
||||
baseURL: "https://ai.ollama.com/",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = normalizeCompatibilityConfig({ cfg: config });
|
||||
|
||||
expect(result.changes).toEqual([
|
||||
"Removed retired models.providers.ollama-cloud.baseURL while preserving models.providers.ollama-cloud.baseUrl.",
|
||||
]);
|
||||
expect(readOllamaCloudProvider(result.config)).toEqual({
|
||||
baseUrl: "https://custom-ollama-cloud.example.test",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
});
|
||||
expect(readOllamaCloudProvider(config)).toEqual({
|
||||
baseUrl: "https://custom-ollama-cloud.example.test",
|
||||
baseURL: "https://ai.ollama.com/",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not expose credentials or query parameters from the retired URL", () => {
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
"ollama-cloud": {
|
||||
baseUrl: "https://user:password@ai.ollama.com/?token=secret",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const result = normalizeCompatibilityConfig({ cfg: config });
|
||||
|
||||
expect(result.changes.join("\n")).not.toContain("user");
|
||||
expect(result.changes.join("\n")).not.toContain("password");
|
||||
expect(result.changes.join("\n")).not.toContain("secret");
|
||||
expect(readOllamaCloudProvider(result.config)?.baseUrl).toBe("https://ollama.com");
|
||||
});
|
||||
});
|
||||
1
extensions/ollama/doctor-contract-api.ts
Normal file
1
extensions/ollama/doctor-contract-api.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { legacyConfigRules, normalizeCompatibilityConfig } from "./src/config-compat.js";
|
||||
1852
extensions/ollama/index.test.ts
Normal file
1852
extensions/ollama/index.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
760
extensions/ollama/index.ts
Normal file
760
extensions/ollama/index.ts
Normal file
@@ -0,0 +1,760 @@
|
||||
// Ollama plugin entrypoint registers its OpenClaw integration.
|
||||
import { collectConfiguredModelRefValues } from "@openclaw/model-catalog-core/configured-model-refs";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { resolvePluginConfigObject } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import {
|
||||
definePluginEntry,
|
||||
type OpenClawPluginApi,
|
||||
type ProviderAuthContext,
|
||||
type ProviderAuthMethodNonInteractiveContext,
|
||||
type ProviderAuthResult,
|
||||
type ProviderAugmentModelCatalogContext,
|
||||
type ProviderCatalogContext,
|
||||
type ProviderReplayPolicy,
|
||||
type ProviderRuntimeModel,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
buildApiKeyCredential,
|
||||
coerceSecretRef,
|
||||
isNonSecretApiKeyMarker,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import { createProviderApiKeyAuthMethod } from "openclaw/plugin-sdk/provider-auth-api-key";
|
||||
import type {
|
||||
ModelDefinitionConfig,
|
||||
ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
buildOpenAICompatibleReplayPolicy,
|
||||
OPENAI_COMPATIBLE_REPLAY_HOOKS,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
buildOllamaModelDefinition,
|
||||
buildOllamaProvider,
|
||||
configureOllamaNonInteractive,
|
||||
ensureOllamaModelPulled,
|
||||
promptAndConfigureOllama,
|
||||
queryOllamaModelShowInfo,
|
||||
} from "./api.js";
|
||||
import { resolveThinkingProfile as resolveOllamaThinkingProfile } from "./provider-policy-api.js";
|
||||
import {
|
||||
OLLAMA_CLOUD_BASE_URL,
|
||||
OLLAMA_CLOUD_DEFAULT_MODELS,
|
||||
OLLAMA_CLOUD_PROVIDER_ID,
|
||||
OLLAMA_DEFAULT_BASE_URL,
|
||||
OLLAMA_GLM52_CLOUD_MODEL_ID,
|
||||
} from "./src/defaults.js";
|
||||
import {
|
||||
OLLAMA_DEFAULT_API_KEY,
|
||||
OLLAMA_PROVIDER_ID,
|
||||
isLocalOllamaBaseUrl,
|
||||
resolveOllamaDiscoveryResult,
|
||||
resolveOllamaRuntimeBaseUrl,
|
||||
shouldUseSyntheticOllamaAuth,
|
||||
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,
|
||||
createOllamaNodeInvokePolicy,
|
||||
} from "./src/node-inference.js";
|
||||
import { readProviderBaseUrl } from "./src/provider-base-url.js";
|
||||
import {
|
||||
createConfiguredOllamaCompatStreamWrapper,
|
||||
createConfiguredOllamaStreamFn,
|
||||
resolveConfiguredOllamaProviderConfig,
|
||||
} from "./src/stream.js";
|
||||
import { createOllamaWebSearchProvider } from "./src/web-search-provider.js";
|
||||
import { checkWsl2CrashLoopRisk } from "./src/wsl2-crash-loop-check.js";
|
||||
|
||||
function buildNativeOllamaReplayPolicy(): ProviderReplayPolicy {
|
||||
return {
|
||||
...buildOpenAICompatibleReplayPolicy("openai-completions", {
|
||||
sanitizeToolCallIds: false,
|
||||
}),
|
||||
sanitizeToolCallIds: false,
|
||||
};
|
||||
}
|
||||
|
||||
const dynamicModelCache = new Map<string, ProviderRuntimeModel[]>();
|
||||
const OLLAMA_CLOUD_DEFAULT_MODEL_REF = `${OLLAMA_CLOUD_PROVIDER_ID}/${OLLAMA_CLOUD_DEFAULT_MODELS[0]}`;
|
||||
const OLLAMA_CONFIGURED_SHOW_CONCURRENCY = 4;
|
||||
const OLLAMA_CONFIGURED_SHOW_MAX_MODELS = 8;
|
||||
function buildDynamicCacheKey(provider: string, baseUrl: string | undefined): string {
|
||||
return `${provider}\0${baseUrl ?? ""}`;
|
||||
}
|
||||
|
||||
function hasOllamaDiscoverySignal(providerConfig: ModelProviderConfig | undefined): boolean {
|
||||
return (
|
||||
Boolean(process.env.OLLAMA_API_KEY?.trim()) ||
|
||||
shouldUseSyntheticOllamaAuth(providerConfig) ||
|
||||
Boolean(providerConfig?.apiKey)
|
||||
);
|
||||
}
|
||||
|
||||
function toDynamicOllamaModel(params: {
|
||||
provider: string;
|
||||
providerConfig: ModelProviderConfig;
|
||||
model: ModelDefinitionConfig;
|
||||
}): ProviderRuntimeModel {
|
||||
const input = (params.model.input ?? ["text"]).filter(
|
||||
(value): value is "text" | "image" => value === "text" || value === "image",
|
||||
);
|
||||
return {
|
||||
id: params.model.id,
|
||||
name: params.model.name ?? params.model.id,
|
||||
provider: params.provider,
|
||||
api: params.providerConfig.api ?? "ollama",
|
||||
baseUrl: readProviderBaseUrl(params.providerConfig) ?? "",
|
||||
reasoning: params.model.reasoning ?? false,
|
||||
input: input.length > 0 ? input : ["text"],
|
||||
cost: params.model.cost ?? { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: params.model.contextWindow ?? 8192,
|
||||
maxTokens: params.model.maxTokens ?? 8192,
|
||||
...(params.model.compat ? { compat: params.model.compat as never } : {}),
|
||||
...(params.model.params ? { params: params.model.params } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function stripTrailingAuthProfile(raw: string): string {
|
||||
const trimmed = raw.trim();
|
||||
const lastSlash = trimmed.lastIndexOf("/");
|
||||
let delimiter = trimmed.indexOf("@", lastSlash + 1);
|
||||
if (delimiter <= 0) {
|
||||
return trimmed;
|
||||
}
|
||||
const suffix = () => trimmed.slice(delimiter + 1);
|
||||
if (/^\d{8}(?:@|$)/.test(suffix())) {
|
||||
const next = trimmed.indexOf("@", delimiter + 9);
|
||||
if (next < 0) {
|
||||
return trimmed;
|
||||
}
|
||||
delimiter = next;
|
||||
}
|
||||
if (/^(?:i?q\d+(?:_[a-z0-9]+)*|\d+bit)(?:@|$)/i.test(suffix())) {
|
||||
const next = trimmed.indexOf("@", delimiter + 1);
|
||||
if (next < 0) {
|
||||
return trimmed;
|
||||
}
|
||||
delimiter = next;
|
||||
}
|
||||
const model = trimmed.slice(0, delimiter).trim();
|
||||
const profile = trimmed.slice(delimiter + 1).trim();
|
||||
return model && profile ? model : trimmed;
|
||||
}
|
||||
|
||||
function needsOllamaCatalogMetadata(entry: ProviderAugmentModelCatalogContext["entries"][number]) {
|
||||
const hasContextLimit = entry.contextWindow !== undefined || entry.contextTokens !== undefined;
|
||||
return (
|
||||
!hasContextLimit ||
|
||||
entry.reasoning === undefined ||
|
||||
entry.input === undefined ||
|
||||
entry.compat === undefined
|
||||
);
|
||||
}
|
||||
|
||||
function readConfiguredOllamaApiKey(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
if (value && typeof value === "object" && "value" in value) {
|
||||
const resolved = (value as { value?: unknown }).value;
|
||||
if (typeof resolved === "string") {
|
||||
const trimmed = resolved.trim();
|
||||
return trimmed || undefined;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function readConcreteOllamaApiKey(value: unknown): string | undefined {
|
||||
if (coerceSecretRef(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const apiKey = readConfiguredOllamaApiKey(value);
|
||||
return apiKey && !isNonSecretApiKeyMarker(apiKey) ? apiKey : undefined;
|
||||
}
|
||||
|
||||
function readEnvBackedOllamaApiKey(value: unknown, env: NodeJS.ProcessEnv): string | undefined {
|
||||
const ref = coerceSecretRef(value);
|
||||
if (ref?.source === "env") {
|
||||
return readConcreteOllamaApiKey(env[ref.id.trim()]);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isAmbientOllamaApiKeyMarker(value: string | undefined): boolean {
|
||||
return value === OLLAMA_DEFAULT_API_KEY || value === "OLLAMA_API_KEY";
|
||||
}
|
||||
|
||||
function readUsableOllamaShowApiKey(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
allowAmbientEnvFallback: boolean;
|
||||
explicitApiKey?: unknown;
|
||||
resolved?: { apiKey?: unknown; discoveryApiKey?: unknown };
|
||||
}): string | undefined {
|
||||
const explicitEnvApiKey = readEnvBackedOllamaApiKey(params.explicitApiKey, params.env);
|
||||
if (explicitEnvApiKey) {
|
||||
return explicitEnvApiKey;
|
||||
}
|
||||
const explicitApiKey = readConcreteOllamaApiKey(params.explicitApiKey);
|
||||
if (explicitApiKey) {
|
||||
return explicitApiKey;
|
||||
}
|
||||
const resolvedApiKey = readConfiguredOllamaApiKey(params.resolved?.apiKey);
|
||||
const canUseResolvedDiscovery =
|
||||
params.allowAmbientEnvFallback || !isAmbientOllamaApiKeyMarker(resolvedApiKey);
|
||||
const discoveryApiKey = readConcreteOllamaApiKey(params.resolved?.discoveryApiKey);
|
||||
if (discoveryApiKey && canUseResolvedDiscovery) {
|
||||
return discoveryApiKey;
|
||||
}
|
||||
const resolvedEnvApiKey = readEnvBackedOllamaApiKey(params.resolved?.apiKey, params.env);
|
||||
if (resolvedEnvApiKey && canUseResolvedDiscovery) {
|
||||
return resolvedEnvApiKey;
|
||||
}
|
||||
const apiKey = readConcreteOllamaApiKey(params.resolved?.apiKey);
|
||||
if (apiKey) {
|
||||
return apiKey;
|
||||
}
|
||||
return params.allowAmbientEnvFallback
|
||||
? readConcreteOllamaApiKey(params.env.OLLAMA_API_KEY)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function collectConfiguredOllamaModelIds(params: {
|
||||
config?: OpenClawConfig;
|
||||
provider: string;
|
||||
entries?: ProviderAugmentModelCatalogContext["entries"];
|
||||
}): Array<{
|
||||
id: string;
|
||||
api?: ProviderAugmentModelCatalogContext["entries"][number]["api"];
|
||||
name?: string;
|
||||
}> {
|
||||
const providerPrefix = `${params.provider.toLowerCase()}/`;
|
||||
const models = new Map<
|
||||
string,
|
||||
{
|
||||
id: string;
|
||||
api?: ProviderAugmentModelCatalogContext["entries"][number]["api"];
|
||||
name?: string;
|
||||
}
|
||||
>();
|
||||
const addModelId = (
|
||||
modelId: string,
|
||||
api?: ProviderAugmentModelCatalogContext["entries"][number]["api"],
|
||||
name?: string,
|
||||
) => {
|
||||
const trimmed = modelId.trim();
|
||||
if (!trimmed || trimmed === "*") {
|
||||
return;
|
||||
}
|
||||
const trimmedName = typeof name === "string" ? name.trim() : "";
|
||||
const existing = models.get(trimmed);
|
||||
if (existing) {
|
||||
if ((!existing.api && api) || (!existing.name && trimmedName)) {
|
||||
models.set(trimmed, {
|
||||
...existing,
|
||||
...(api && !existing.api ? { api } : {}),
|
||||
...(trimmedName && !existing.name ? { name: trimmedName } : {}),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
models.set(trimmed, {
|
||||
id: trimmed,
|
||||
...(api ? { api } : {}),
|
||||
...(trimmedName ? { name: trimmedName } : {}),
|
||||
});
|
||||
};
|
||||
const addRef = (raw: unknown) => {
|
||||
if (typeof raw !== "string") {
|
||||
return;
|
||||
}
|
||||
const trimmed = stripTrailingAuthProfile(raw);
|
||||
if (!trimmed.toLowerCase().startsWith(providerPrefix)) {
|
||||
return;
|
||||
}
|
||||
const modelId = trimmed.slice(providerPrefix.length).trim();
|
||||
addModelId(modelId);
|
||||
};
|
||||
|
||||
for (const ref of collectConfiguredModelRefValues(params.config)) {
|
||||
addRef(ref);
|
||||
}
|
||||
for (const entry of params.entries ?? []) {
|
||||
if (
|
||||
entry.provider.toLowerCase() === params.provider.toLowerCase() &&
|
||||
entry.id.trim() &&
|
||||
needsOllamaCatalogMetadata(entry)
|
||||
) {
|
||||
addModelId(entry.id.trim(), entry.api, entry.name);
|
||||
}
|
||||
}
|
||||
return [...models.values()];
|
||||
}
|
||||
|
||||
function buildStaticOllamaCloudProvider(): ModelProviderConfig {
|
||||
return {
|
||||
baseUrl: OLLAMA_CLOUD_BASE_URL,
|
||||
api: "ollama",
|
||||
models: OLLAMA_CLOUD_DEFAULT_MODELS.map((model) => buildOllamaModelDefinition(model)),
|
||||
};
|
||||
}
|
||||
|
||||
async function buildOllamaCloudProvider(apiKey?: string): Promise<ModelProviderConfig> {
|
||||
const discovered = await buildOllamaProvider(OLLAMA_CLOUD_BASE_URL, {
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
quiet: true,
|
||||
});
|
||||
if (!discovered.models?.length) {
|
||||
return buildStaticOllamaCloudProvider();
|
||||
}
|
||||
if (!apiKey || discovered.models.some((model) => model.id === OLLAMA_GLM52_CLOUD_MODEL_ID)) {
|
||||
return discovered;
|
||||
}
|
||||
const showInfo = await queryOllamaModelShowInfo(
|
||||
OLLAMA_CLOUD_BASE_URL,
|
||||
OLLAMA_GLM52_CLOUD_MODEL_ID,
|
||||
{ apiKey },
|
||||
);
|
||||
if (typeof showInfo.contextWindow !== "number" && (showInfo.capabilities?.length ?? 0) === 0) {
|
||||
return discovered;
|
||||
}
|
||||
return {
|
||||
...discovered,
|
||||
models: [
|
||||
...discovered.models,
|
||||
buildOllamaModelDefinition(
|
||||
OLLAMA_GLM52_CLOUD_MODEL_ID,
|
||||
showInfo.contextWindow,
|
||||
showInfo.capabilities,
|
||||
),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveRequestedDynamicOllamaModel(params: {
|
||||
provider: string;
|
||||
providerConfig: ModelProviderConfig;
|
||||
modelId: string;
|
||||
showApiKey?: string;
|
||||
}): Promise<ProviderRuntimeModel | undefined> {
|
||||
const showBaseUrl = readProviderBaseUrl(params.providerConfig) ?? OLLAMA_DEFAULT_BASE_URL;
|
||||
const showInfo = params.showApiKey
|
||||
? await queryOllamaModelShowInfo(showBaseUrl, params.modelId, { apiKey: params.showApiKey })
|
||||
: await queryOllamaModelShowInfo(showBaseUrl, params.modelId);
|
||||
if (typeof showInfo.contextWindow !== "number" && (showInfo.capabilities?.length ?? 0) === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return toDynamicOllamaModel({
|
||||
provider: params.provider,
|
||||
providerConfig: params.providerConfig,
|
||||
model: buildOllamaModelDefinition(
|
||||
params.modelId,
|
||||
showInfo.contextWindow,
|
||||
showInfo.capabilities,
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
async function augmentConfiguredOllamaCatalogModels(params: {
|
||||
config?: OpenClawConfig;
|
||||
defaultBaseUrl: string;
|
||||
env: NodeJS.ProcessEnv;
|
||||
provider: string;
|
||||
entries: ProviderAugmentModelCatalogContext["entries"];
|
||||
resolveProviderApiKey: ProviderAugmentModelCatalogContext["resolveProviderApiKey"];
|
||||
}): Promise<ProviderAugmentModelCatalogContext["entries"]> {
|
||||
const models = collectConfiguredOllamaModelIds({
|
||||
config: params.config,
|
||||
provider: params.provider,
|
||||
entries: params.entries,
|
||||
});
|
||||
if (models.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const configuredProvider = resolveConfiguredOllamaProviderConfig({
|
||||
config: params.config,
|
||||
providerId: params.provider,
|
||||
});
|
||||
const baseUrl = readProviderBaseUrl(configuredProvider) ?? params.defaultBaseUrl;
|
||||
const isLocalBaseUrl = isLocalOllamaBaseUrl(baseUrl);
|
||||
const showApiKey = readUsableOllamaShowApiKey({
|
||||
env: params.env,
|
||||
allowAmbientEnvFallback: !isLocalBaseUrl,
|
||||
explicitApiKey: configuredProvider?.apiKey,
|
||||
resolved: params.resolveProviderApiKey?.(params.provider),
|
||||
});
|
||||
if (!isLocalBaseUrl && !showApiKey) {
|
||||
return [];
|
||||
}
|
||||
const providerConfig: ModelProviderConfig = {
|
||||
...configuredProvider,
|
||||
models: configuredProvider?.models ?? [],
|
||||
baseUrl,
|
||||
api: configuredProvider?.api ?? "ollama",
|
||||
};
|
||||
const entries: ProviderAugmentModelCatalogContext["entries"] = [];
|
||||
const modelsToProbe = models.slice(0, OLLAMA_CONFIGURED_SHOW_MAX_MODELS);
|
||||
for (let index = 0; index < modelsToProbe.length; index += OLLAMA_CONFIGURED_SHOW_CONCURRENCY) {
|
||||
const batch = modelsToProbe.slice(index, index + OLLAMA_CONFIGURED_SHOW_CONCURRENCY);
|
||||
const rows = await Promise.all(
|
||||
batch.map(async (model) => {
|
||||
const requested = await resolveRequestedDynamicOllamaModel({
|
||||
provider: params.provider,
|
||||
providerConfig,
|
||||
modelId: model.id,
|
||||
showApiKey,
|
||||
});
|
||||
return requested
|
||||
? {
|
||||
id: requested.id,
|
||||
name: model.name ?? requested.name,
|
||||
provider: requested.provider,
|
||||
api: model.api ?? providerConfig.api,
|
||||
reasoning: requested.reasoning,
|
||||
input: requested.input,
|
||||
contextWindow: requested.contextWindow,
|
||||
compat: requested.compat,
|
||||
}
|
||||
: undefined;
|
||||
}),
|
||||
);
|
||||
for (const row of rows) {
|
||||
if (row) {
|
||||
entries.push(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "ollama",
|
||||
name: "Ollama Provider",
|
||||
description: "Bundled Ollama provider plugin",
|
||||
register(api: OpenClawPluginApi) {
|
||||
const startupPluginConfig = (api.pluginConfig ?? {}) as OllamaPluginConfig;
|
||||
if (api.registrationMode === "full") {
|
||||
void checkWsl2CrashLoopRisk(api.logger);
|
||||
}
|
||||
api.registerMemoryEmbeddingProvider(ollamaMemoryEmbeddingProviderAdapter);
|
||||
api.registerMediaUnderstandingProvider(ollamaMediaUnderstandingProvider);
|
||||
if (startupPluginConfig.nodeInference?.enabled !== false) {
|
||||
for (const command of createOllamaNodeHostCommands()) {
|
||||
api.registerNodeHostCommand(command);
|
||||
}
|
||||
}
|
||||
api.registerNodeInvokePolicy(createOllamaNodeInvokePolicy());
|
||||
api.registerTool(createOllamaNodeInferenceTool(api));
|
||||
const resolveCurrentPluginConfig = (config?: OpenClawConfig): OllamaPluginConfig => {
|
||||
const runtimePluginConfig = resolvePluginConfigObject(config, "ollama");
|
||||
if (runtimePluginConfig) {
|
||||
return runtimePluginConfig as OllamaPluginConfig;
|
||||
}
|
||||
return config ? {} : startupPluginConfig;
|
||||
};
|
||||
api.registerWebSearchProvider(createOllamaWebSearchProvider());
|
||||
api.registerProvider({
|
||||
id: OLLAMA_CLOUD_PROVIDER_ID,
|
||||
label: "Ollama Cloud",
|
||||
docsPath: "/providers/ollama",
|
||||
envVars: ["OLLAMA_API_KEY"],
|
||||
auth: [
|
||||
createProviderApiKeyAuthMethod({
|
||||
providerId: OLLAMA_CLOUD_PROVIDER_ID,
|
||||
methodId: "api-key",
|
||||
label: "Ollama Cloud API key",
|
||||
hint: "Hosted models via ollama.com",
|
||||
optionKey: "ollamaCloudApiKey",
|
||||
flagName: "--ollama-cloud-api-key",
|
||||
envVar: "OLLAMA_API_KEY",
|
||||
promptMessage: "Enter Ollama Cloud API key",
|
||||
defaultModel: OLLAMA_CLOUD_DEFAULT_MODEL_REF,
|
||||
noteTitle: "Ollama Cloud",
|
||||
noteMessage: "Manage API keys at https://ollama.com/settings/keys",
|
||||
wizard: {
|
||||
choiceId: "ollama-cloud",
|
||||
choiceLabel: "Ollama Cloud",
|
||||
choiceHint: "Hosted models via ollama.com",
|
||||
groupId: "ollama",
|
||||
groupLabel: "Ollama",
|
||||
groupHint: "Cloud and local open models",
|
||||
},
|
||||
}),
|
||||
],
|
||||
catalog: {
|
||||
order: "simple",
|
||||
run: async (ctx: ProviderCatalogContext) => {
|
||||
const resolvedAuth = ctx.resolveProviderApiKey(OLLAMA_CLOUD_PROVIDER_ID);
|
||||
const apiKey = resolvedAuth.apiKey ?? resolvedAuth.discoveryApiKey;
|
||||
if (!apiKey) {
|
||||
return null;
|
||||
}
|
||||
const discoveryApiKey = readUsableOllamaShowApiKey({
|
||||
env: ctx.env,
|
||||
allowAmbientEnvFallback: true,
|
||||
resolved: resolvedAuth,
|
||||
});
|
||||
return {
|
||||
provider: {
|
||||
...(await buildOllamaCloudProvider(discoveryApiKey)),
|
||||
apiKey,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
staticCatalog: {
|
||||
order: "simple",
|
||||
run: async () => ({
|
||||
provider: buildStaticOllamaCloudProvider(),
|
||||
}),
|
||||
},
|
||||
createStreamFn: ({ config, model, provider }) => {
|
||||
if (model.api !== "ollama") {
|
||||
return undefined;
|
||||
}
|
||||
return createConfiguredOllamaStreamFn({
|
||||
model,
|
||||
providerBaseUrl:
|
||||
readProviderBaseUrl(
|
||||
resolveConfiguredOllamaProviderConfig({ config, providerId: provider }),
|
||||
) ?? OLLAMA_CLOUD_BASE_URL,
|
||||
});
|
||||
},
|
||||
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
|
||||
buildReplayPolicy: (ctx) =>
|
||||
ctx.modelApi === "ollama"
|
||||
? buildNativeOllamaReplayPolicy()
|
||||
: buildOpenAICompatibleReplayPolicy(ctx.modelApi),
|
||||
resolveReasoningOutputMode: () => "native",
|
||||
resolveThinkingProfile: resolveOllamaThinkingProfile,
|
||||
wrapStreamFn: createConfiguredOllamaCompatStreamWrapper,
|
||||
resolveDynamicModel: ({ provider, modelId }) => {
|
||||
const cloudProvider = buildStaticOllamaCloudProvider();
|
||||
const model = cloudProvider.models?.find((entry) => entry.id === modelId);
|
||||
return model
|
||||
? toDynamicOllamaModel({ provider, providerConfig: cloudProvider, model })
|
||||
: undefined;
|
||||
},
|
||||
augmentModelCatalog: async (ctx) =>
|
||||
await augmentConfiguredOllamaCatalogModels({
|
||||
config: ctx.config,
|
||||
defaultBaseUrl: OLLAMA_CLOUD_BASE_URL,
|
||||
env: ctx.env,
|
||||
provider: OLLAMA_CLOUD_PROVIDER_ID,
|
||||
entries: ctx.entries,
|
||||
resolveProviderApiKey: ctx.resolveProviderApiKey,
|
||||
}),
|
||||
matchesContextOverflowError: ({ errorMessage }) =>
|
||||
/\bollama\b.*(?:context length|too many tokens|context window)/i.test(errorMessage) ||
|
||||
/\btruncating input\b.*\btoo long\b/i.test(errorMessage),
|
||||
buildUnknownModelHint: () =>
|
||||
"Ollama Cloud requires an API key. " +
|
||||
'Set OLLAMA_API_KEY or run "openclaw onboard --auth-choice ollama-cloud". ' +
|
||||
"See: https://docs.openclaw.ai/providers/ollama",
|
||||
});
|
||||
api.registerProvider({
|
||||
id: OLLAMA_PROVIDER_ID,
|
||||
label: "Ollama",
|
||||
docsPath: "/providers/ollama",
|
||||
envVars: ["OLLAMA_API_KEY"],
|
||||
auth: [
|
||||
{
|
||||
id: "local",
|
||||
label: "Ollama",
|
||||
hint: "Cloud and local open models",
|
||||
kind: "custom",
|
||||
run: async (ctx: ProviderAuthContext): Promise<ProviderAuthResult> => {
|
||||
const result = await promptAndConfigureOllama({
|
||||
cfg: ctx.config,
|
||||
env: ctx.env,
|
||||
opts: ctx.opts as Record<string, unknown> | undefined,
|
||||
prompter: ctx.prompter,
|
||||
secretInputMode: ctx.secretInputMode,
|
||||
allowSecretRefPrompt: ctx.allowSecretRefPrompt,
|
||||
});
|
||||
return {
|
||||
profiles: [
|
||||
{
|
||||
profileId: "ollama:default",
|
||||
credential: buildApiKeyCredential(
|
||||
OLLAMA_PROVIDER_ID,
|
||||
result.credential,
|
||||
undefined,
|
||||
result.credentialMode
|
||||
? {
|
||||
secretInputMode: result.credentialMode,
|
||||
config: ctx.config,
|
||||
}
|
||||
: undefined,
|
||||
),
|
||||
},
|
||||
],
|
||||
configPatch: result.config,
|
||||
};
|
||||
},
|
||||
runNonInteractive: async (ctx: ProviderAuthMethodNonInteractiveContext) => {
|
||||
return await configureOllamaNonInteractive({
|
||||
nextConfig: ctx.config,
|
||||
opts: {
|
||||
customBaseUrl: ctx.opts.customBaseUrl as string | undefined,
|
||||
customModelId: ctx.opts.customModelId as string | undefined,
|
||||
},
|
||||
runtime: ctx.runtime,
|
||||
agentDir: ctx.agentDir,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
catalog: {
|
||||
order: "late",
|
||||
run: async (ctx: ProviderCatalogContext) =>
|
||||
await resolveOllamaDiscoveryResult({
|
||||
ctx,
|
||||
pluginConfig: resolveCurrentPluginConfig(ctx.config),
|
||||
buildProvider: buildOllamaProvider,
|
||||
}),
|
||||
},
|
||||
wizard: {
|
||||
setup: {
|
||||
choiceId: "ollama",
|
||||
choiceLabel: "Ollama",
|
||||
choiceHint: "Cloud and local open models",
|
||||
groupId: "ollama",
|
||||
groupLabel: "Ollama",
|
||||
groupHint: "Cloud and local open models",
|
||||
methodId: "local",
|
||||
modelSelection: {
|
||||
promptWhenAuthChoiceProvided: true,
|
||||
allowKeepCurrent: false,
|
||||
},
|
||||
},
|
||||
modelPicker: {
|
||||
label: "Ollama (custom)",
|
||||
hint: "Detect models from a local or remote Ollama instance",
|
||||
methodId: "local",
|
||||
},
|
||||
},
|
||||
onModelSelected: async ({ config, model, prompter }) => {
|
||||
if (!model.startsWith("ollama/")) {
|
||||
return;
|
||||
}
|
||||
await ensureOllamaModelPulled({ config, model, prompter });
|
||||
},
|
||||
createStreamFn: ({ config, model, provider }) => {
|
||||
if (model.api !== "ollama") {
|
||||
return undefined;
|
||||
}
|
||||
return createConfiguredOllamaStreamFn({
|
||||
model,
|
||||
providerBaseUrl: readProviderBaseUrl(
|
||||
resolveConfiguredOllamaProviderConfig({ config, providerId: provider }),
|
||||
),
|
||||
});
|
||||
},
|
||||
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
|
||||
buildReplayPolicy: (ctx) =>
|
||||
ctx.modelApi === "ollama"
|
||||
? buildNativeOllamaReplayPolicy()
|
||||
: buildOpenAICompatibleReplayPolicy(ctx.modelApi),
|
||||
resolveReasoningOutputMode: () => "native",
|
||||
resolveThinkingProfile: resolveOllamaThinkingProfile,
|
||||
wrapStreamFn: createConfiguredOllamaCompatStreamWrapper,
|
||||
augmentModelCatalog: async (ctx) =>
|
||||
await augmentConfiguredOllamaCatalogModels({
|
||||
config: ctx.config,
|
||||
defaultBaseUrl: OLLAMA_DEFAULT_BASE_URL,
|
||||
env: ctx.env,
|
||||
provider: OLLAMA_PROVIDER_ID,
|
||||
entries: ctx.entries,
|
||||
resolveProviderApiKey: ctx.resolveProviderApiKey,
|
||||
}),
|
||||
createEmbeddingProvider: async ({ config, model, provider: embeddingProvider, remote }) => {
|
||||
const { provider, client } = await createOllamaEmbeddingProvider({
|
||||
config,
|
||||
remote,
|
||||
model: model || DEFAULT_OLLAMA_EMBEDDING_MODEL,
|
||||
provider: embeddingProvider || OLLAMA_PROVIDER_ID,
|
||||
});
|
||||
return {
|
||||
...provider,
|
||||
client,
|
||||
};
|
||||
},
|
||||
matchesContextOverflowError: ({ errorMessage }) =>
|
||||
/\bollama\b.*(?:context length|too many tokens|context window)/i.test(errorMessage) ||
|
||||
/\btruncating input\b.*\btoo long\b/i.test(errorMessage),
|
||||
resolveSyntheticAuth: ({ provider, providerConfig }) => {
|
||||
if (!shouldUseSyntheticOllamaAuth(providerConfig)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
apiKey: OLLAMA_DEFAULT_API_KEY,
|
||||
source: `models.providers.${provider ?? OLLAMA_PROVIDER_ID} (synthetic local key)`,
|
||||
mode: "api-key",
|
||||
};
|
||||
},
|
||||
shouldDeferSyntheticProfileAuth: ({ resolvedApiKey }) =>
|
||||
resolvedApiKey?.trim() === OLLAMA_DEFAULT_API_KEY,
|
||||
prepareDynamicModel: async (ctx) => {
|
||||
const providerConfig = resolveConfiguredOllamaProviderConfig({
|
||||
config: ctx.config,
|
||||
providerId: ctx.provider,
|
||||
});
|
||||
if (!hasOllamaDiscoverySignal(providerConfig)) {
|
||||
return;
|
||||
}
|
||||
const baseUrl = readProviderBaseUrl(providerConfig);
|
||||
const provider = await buildOllamaProvider(baseUrl, { quiet: true });
|
||||
const dynamicApi = providerConfig?.api ?? provider.api;
|
||||
const dynamicProvider = {
|
||||
...provider,
|
||||
baseUrl: resolveOllamaRuntimeBaseUrl({
|
||||
api: dynamicApi,
|
||||
configuredBaseUrl: baseUrl,
|
||||
discoveredBaseUrl: provider.baseUrl,
|
||||
}),
|
||||
api: dynamicApi,
|
||||
};
|
||||
const dynamicModels = (dynamicProvider.models ?? []).map((model) =>
|
||||
toDynamicOllamaModel({
|
||||
provider: ctx.provider,
|
||||
providerConfig: dynamicProvider,
|
||||
model,
|
||||
}),
|
||||
);
|
||||
if (!dynamicModels.some((model) => model.id === ctx.modelId)) {
|
||||
const requestedModel = await resolveRequestedDynamicOllamaModel({
|
||||
provider: ctx.provider,
|
||||
providerConfig: dynamicProvider,
|
||||
modelId: ctx.modelId,
|
||||
});
|
||||
if (requestedModel) {
|
||||
dynamicModels.push(requestedModel);
|
||||
}
|
||||
}
|
||||
dynamicModelCache.set(buildDynamicCacheKey(ctx.provider, baseUrl), dynamicModels);
|
||||
},
|
||||
resolveDynamicModel: (ctx) => {
|
||||
const providerConfig = resolveConfiguredOllamaProviderConfig({
|
||||
config: ctx.config,
|
||||
providerId: ctx.provider,
|
||||
});
|
||||
return dynamicModelCache
|
||||
.get(buildDynamicCacheKey(ctx.provider, readProviderBaseUrl(providerConfig)))
|
||||
?.find((model) => model.id === ctx.modelId);
|
||||
},
|
||||
buildUnknownModelHint: () =>
|
||||
"Ollama requires authentication to be registered as a provider. " +
|
||||
'Set OLLAMA_API_KEY="ollama-local" (any value works) or run "openclaw configure". ' +
|
||||
"See: https://docs.openclaw.ai/providers/ollama",
|
||||
});
|
||||
},
|
||||
});
|
||||
336
extensions/ollama/ollama.live.test.ts
Normal file
336
extensions/ollama/ollama.live.test.ts
Normal file
@@ -0,0 +1,336 @@
|
||||
// Ollama tests cover ollama plugin behavior.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import * as fsSync from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
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 { createOllamaWebSearchProvider } from "./src/web-search-provider.js";
|
||||
|
||||
const LIVE = process.env.OPENCLAW_LIVE_TEST === "1" && process.env.OPENCLAW_LIVE_OLLAMA === "1";
|
||||
const OLLAMA_BASE_URL =
|
||||
process.env.OPENCLAW_LIVE_OLLAMA_BASE_URL?.trim() || "http://127.0.0.1:11434";
|
||||
const CHAT_MODEL = process.env.OPENCLAW_LIVE_OLLAMA_MODEL?.trim() || "llama3.2:latest";
|
||||
const EMBEDDING_MODEL =
|
||||
process.env.OPENCLAW_LIVE_OLLAMA_EMBED_MODEL?.trim() || "embeddinggemma:latest";
|
||||
const PROVIDER_ID = process.env.OPENCLAW_LIVE_OLLAMA_PROVIDER_ID?.trim() || "ollama-live-custom";
|
||||
const RUN_WEB_SEARCH = process.env.OPENCLAW_LIVE_OLLAMA_WEB_SEARCH !== "0";
|
||||
const RUN_EMBEDDINGS =
|
||||
process.env.OPENCLAW_LIVE_OLLAMA_EMBEDDINGS === "1" ||
|
||||
(process.env.OPENCLAW_LIVE_OLLAMA_EMBEDDINGS !== "0" && !isOllamaCloudBaseUrl(OLLAMA_BASE_URL));
|
||||
const OLLAMA_CONFIG_API_KEY = isLocalOllamaBaseUrl(OLLAMA_BASE_URL)
|
||||
? "ollama-local"
|
||||
: "OLLAMA_API_KEY";
|
||||
|
||||
function isOllamaCloudBaseUrl(baseUrl: string): boolean {
|
||||
try {
|
||||
const parsed = new URL(baseUrl);
|
||||
return parsed.protocol === "https:" && parsed.hostname === "ollama.com";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function requireOllamaRuntimeApiKey(): string | undefined {
|
||||
if (OLLAMA_CONFIG_API_KEY !== "OLLAMA_API_KEY") {
|
||||
return undefined;
|
||||
}
|
||||
const apiKey = process.env.OLLAMA_API_KEY?.trim();
|
||||
if (!apiKey) {
|
||||
throw new Error(
|
||||
"OPENCLAW_LIVE_OLLAMA_BASE_URL points at a remote Ollama host; set OLLAMA_API_KEY.",
|
||||
);
|
||||
}
|
||||
return apiKey;
|
||||
}
|
||||
|
||||
function resolveOllamaDirectApiKey(): string {
|
||||
return requireOllamaRuntimeApiKey() ?? "ollama-local";
|
||||
}
|
||||
|
||||
async function collectStreamEvents<T>(stream: AsyncIterable<T>): Promise<T[]> {
|
||||
const events: T[] = [];
|
||||
for await (const event of stream) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
async function withTempOpenClawState<T>(run: (paths: { root: string }) => Promise<T>): Promise<T> {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-ollama-cli-live-"));
|
||||
try {
|
||||
await fs.writeFile(
|
||||
path.join(root, "openclaw.json"),
|
||||
JSON.stringify(
|
||||
{
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
api: "ollama",
|
||||
baseUrl: OLLAMA_BASE_URL,
|
||||
apiKey: OLLAMA_CONFIG_API_KEY,
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
return await run({ root });
|
||||
} finally {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function runOpenClawCli(args: string[], env: NodeJS.ProcessEnv) {
|
||||
const hasBuiltEntry = ["entry.js", "entry.mjs"].some((entry) =>
|
||||
fsSync.existsSync(path.join(process.cwd(), "dist", entry)),
|
||||
);
|
||||
const sourceRunnerAvailable = !hasBuiltEntry;
|
||||
const commandArgs = sourceRunnerAvailable
|
||||
? ["scripts/run-node.mjs", ...args]
|
||||
: ["openclaw.mjs", ...args];
|
||||
const outputRoot = fsSync.mkdtempSync(path.join(os.tmpdir(), "openclaw-ollama-cli-output-"));
|
||||
const stdoutPath = path.join(outputRoot, "stdout.txt");
|
||||
const stderrPath = path.join(outputRoot, "stderr.txt");
|
||||
const stdoutFd = fsSync.openSync(stdoutPath, "w");
|
||||
const stderrFd = fsSync.openSync(stderrPath, "w");
|
||||
let stdoutClosed = false;
|
||||
let stderrClosed = false;
|
||||
try {
|
||||
const result = spawnSync(process.execPath, commandArgs, {
|
||||
cwd: process.cwd(),
|
||||
env,
|
||||
timeout: sourceRunnerAvailable ? 180_000 : 90_000,
|
||||
stdio: ["ignore", stdoutFd, stderrFd],
|
||||
});
|
||||
fsSync.closeSync(stdoutFd);
|
||||
stdoutClosed = true;
|
||||
fsSync.closeSync(stderrFd);
|
||||
stderrClosed = true;
|
||||
return {
|
||||
exitCode: result.status ?? (result.error ? 1 : 0),
|
||||
stdout: fsSync.readFileSync(stdoutPath, "utf8"),
|
||||
stderr: fsSync.readFileSync(stderrPath, "utf8"),
|
||||
};
|
||||
} finally {
|
||||
if (!stdoutClosed) {
|
||||
fsSync.closeSync(stdoutFd);
|
||||
}
|
||||
if (!stderrClosed) {
|
||||
fsSync.closeSync(stderrFd);
|
||||
}
|
||||
fsSync.rmSync(outputRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function parseJsonEnvelope(stdout: string): Record<string, unknown> {
|
||||
const trimmed = stdout.trim();
|
||||
const jsonStart = trimmed.lastIndexOf("\n{");
|
||||
const rawJson = jsonStart >= 0 ? trimmed.slice(jsonStart + 1) : trimmed;
|
||||
return JSON.parse(rawJson) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function buildCliEnv(root: string): NodeJS.ProcessEnv {
|
||||
const apiKey = requireOllamaRuntimeApiKey();
|
||||
return {
|
||||
PATH: process.env.PATH,
|
||||
HOME: process.env.HOME,
|
||||
USER: process.env.USER,
|
||||
TMPDIR: process.env.TMPDIR,
|
||||
NODE_PATH: process.env.NODE_PATH,
|
||||
NODE_OPTIONS: process.env.NODE_OPTIONS,
|
||||
OPENCLAW_LIVE_TEST: "1",
|
||||
OPENCLAW_LIVE_OLLAMA: "1",
|
||||
OPENCLAW_LIVE_OLLAMA_WEB_SEARCH: "0",
|
||||
OPENCLAW_STATE_DIR: path.join(root, "state"),
|
||||
OPENCLAW_CONFIG_PATH: path.join(root, "openclaw.json"),
|
||||
OPENCLAW_NO_RESPAWN: "1",
|
||||
OPENCLAW_TEST_FAST: "1",
|
||||
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false",
|
||||
pnpm_config_verify_deps_before_run: "false",
|
||||
OLLAMA_API_KEY: apiKey ?? "ollama-local",
|
||||
};
|
||||
}
|
||||
|
||||
describe.skipIf(!LIVE)("ollama live", () => {
|
||||
it("runs infer model run through the local CLI path without static model discovery", async () => {
|
||||
await withTempOpenClawState(async ({ root }) => {
|
||||
const result = await runOpenClawCli(
|
||||
[
|
||||
"infer",
|
||||
"model",
|
||||
"run",
|
||||
"--local",
|
||||
"--model",
|
||||
`ollama/${CHAT_MODEL}`,
|
||||
"--prompt",
|
||||
"Reply with exactly one word: pong",
|
||||
"--json",
|
||||
],
|
||||
buildCliEnv(root),
|
||||
);
|
||||
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.stderr).not.toContain("[agents/auth-profiles]");
|
||||
expect(result.stdout.trim(), result.stderr).not.toHaveLength(0);
|
||||
const payload = parseJsonEnvelope(result.stdout) as {
|
||||
ok?: boolean;
|
||||
transport?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
outputs?: Array<{ text?: string }>;
|
||||
};
|
||||
expect(payload.ok).toBe(true);
|
||||
expect(payload.transport).toBe("local");
|
||||
expect(payload.provider).toBe("ollama");
|
||||
expect(payload.model).toBe(CHAT_MODEL);
|
||||
expect(payload.outputs?.[0]?.text?.trim().length ?? 0).toBeGreaterThan(0);
|
||||
});
|
||||
}, 120_000);
|
||||
|
||||
it("runs native chat with a custom provider prefix and normalized tool schemas", async () => {
|
||||
const streamFn = createOllamaStreamFn(OLLAMA_BASE_URL);
|
||||
let payload:
|
||||
| {
|
||||
model?: string;
|
||||
think?: boolean;
|
||||
keep_alive?: string;
|
||||
options?: { num_ctx?: number; top_p?: number };
|
||||
tools?: Array<{
|
||||
function?: {
|
||||
parameters?: {
|
||||
properties?: Record<string, { type?: string }>;
|
||||
};
|
||||
};
|
||||
}>;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
const stream = streamFn(
|
||||
{
|
||||
id: `${PROVIDER_ID}/${CHAT_MODEL}`,
|
||||
api: "ollama",
|
||||
provider: PROVIDER_ID,
|
||||
contextWindow: 8192,
|
||||
params: { num_ctx: 4096, top_p: 0.9, thinking: false, keep_alive: "5m" },
|
||||
requestTimeoutMs: 120_000,
|
||||
} as never,
|
||||
{
|
||||
messages: [{ role: "user", content: "Reply exactly OK." }],
|
||||
tools: [
|
||||
{
|
||||
name: "lookup_weather",
|
||||
description: "Lookup weather for a city.",
|
||||
parameters: {
|
||||
properties: {
|
||||
city: { enum: ["London", "Vienna"] },
|
||||
units: { enum: ["metric", "imperial"] },
|
||||
options: {
|
||||
properties: {
|
||||
includeWind: { type: "boolean" },
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["city"],
|
||||
},
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
{
|
||||
maxTokens: 32,
|
||||
temperature: 0,
|
||||
onPayload: (body: unknown) => {
|
||||
payload = body as NonNullable<typeof payload>;
|
||||
},
|
||||
apiKey: requireOllamaRuntimeApiKey(),
|
||||
} as never,
|
||||
);
|
||||
|
||||
const events = await collectStreamEvents(await Promise.resolve(stream));
|
||||
const error = events.find((event) => (event as { type?: string }).type === "error");
|
||||
|
||||
expect(error).toBeUndefined();
|
||||
expect(events.map((event) => (event as { type?: string }).type)).toContain("done");
|
||||
expect(payload?.model).toBe(CHAT_MODEL);
|
||||
expect(payload?.options?.num_ctx).toBe(4096);
|
||||
expect(payload?.options?.top_p).toBe(1);
|
||||
expect(payload?.think).toBe(false);
|
||||
expect(payload?.keep_alive).toBe("5m");
|
||||
const properties = payload?.tools?.[0]?.function?.parameters?.properties;
|
||||
expect(properties?.city?.type).toBe("string");
|
||||
expect(properties?.units?.type).toBe("string");
|
||||
expect(properties?.options?.type).toBe("object");
|
||||
}, 60_000);
|
||||
|
||||
it.skipIf(!RUN_EMBEDDINGS)(
|
||||
"embeds a batch through the current Ollama endpoint for custom providers",
|
||||
async () => {
|
||||
const { client } = await createOllamaEmbeddingProvider({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
[PROVIDER_ID]: {
|
||||
api: "ollama",
|
||||
baseUrl: OLLAMA_BASE_URL,
|
||||
apiKey: resolveOllamaDirectApiKey(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: PROVIDER_ID,
|
||||
model: `${PROVIDER_ID}/${EMBEDDING_MODEL}`,
|
||||
} as never);
|
||||
|
||||
const embeddings = await client.embedBatch(["hello", "world"]);
|
||||
|
||||
expect(embeddings).toHaveLength(2);
|
||||
expect(embeddings[0]?.length ?? 0).toBeGreaterThan(0);
|
||||
expect(embeddings[1]?.length).toBe(embeddings[0]?.length);
|
||||
expect(Math.hypot(...embeddings[0])).toBeGreaterThan(0.99);
|
||||
expect(Math.hypot(...embeddings[0])).toBeLessThan(1.01);
|
||||
},
|
||||
45_000,
|
||||
);
|
||||
|
||||
it.skipIf(!RUN_WEB_SEARCH)(
|
||||
"searches through Ollama web search fallback endpoints",
|
||||
async () => {
|
||||
const provider = createOllamaWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
api: "ollama",
|
||||
baseUrl: OLLAMA_BASE_URL,
|
||||
apiKey: resolveOllamaDirectApiKey(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
if (!tool) {
|
||||
throw new Error("Ollama web-search provider did not create a tool");
|
||||
}
|
||||
|
||||
const result = (await tool.execute({
|
||||
query: "OpenClaw documentation",
|
||||
count: 1,
|
||||
})) as {
|
||||
provider?: string;
|
||||
results?: Array<{ url?: string }>;
|
||||
};
|
||||
|
||||
expect(result.provider).toBe("ollama");
|
||||
expect(result.results?.length ?? 0).toBeGreaterThan(0);
|
||||
expect(result.results?.[0]?.url).toMatch(/^https?:\/\//);
|
||||
},
|
||||
45_000,
|
||||
);
|
||||
});
|
||||
199
extensions/ollama/openclaw.plugin.json
Normal file
199
extensions/ollama/openclaw.plugin.json
Normal file
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"id": "ollama",
|
||||
"icon": "https://cdn.simpleicons.org/ollama",
|
||||
"activation": {
|
||||
"onStartup": true
|
||||
},
|
||||
"enabledByDefault": true,
|
||||
"providers": ["ollama", "ollama-cloud"],
|
||||
"providerCatalogEntry": "./provider-discovery.ts",
|
||||
"providerRequest": {
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"family": "ollama"
|
||||
},
|
||||
"ollama-cloud": {
|
||||
"family": "ollama-cloud"
|
||||
}
|
||||
}
|
||||
},
|
||||
"modelPricing": {
|
||||
"providers": {
|
||||
"ollama": {
|
||||
"external": false
|
||||
},
|
||||
"ollama-cloud": {
|
||||
"external": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"syntheticAuthRefs": ["ollama"],
|
||||
"nonSecretAuthMarkers": ["ollama-local"],
|
||||
"setup": {
|
||||
"providers": [
|
||||
{
|
||||
"id": "ollama",
|
||||
"envVars": ["OLLAMA_API_KEY"]
|
||||
},
|
||||
{
|
||||
"id": "ollama-cloud",
|
||||
"envVars": ["OLLAMA_API_KEY"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"providerAuthChoices": [
|
||||
{
|
||||
"provider": "ollama",
|
||||
"method": "local",
|
||||
"choiceId": "ollama",
|
||||
"choiceLabel": "Ollama",
|
||||
"choiceHint": "Cloud and local open models",
|
||||
"groupId": "ollama",
|
||||
"groupLabel": "Ollama",
|
||||
"groupHint": "Cloud and local open models"
|
||||
},
|
||||
{
|
||||
"provider": "ollama-cloud",
|
||||
"method": "api-key",
|
||||
"choiceId": "ollama-cloud",
|
||||
"choiceLabel": "Ollama Cloud",
|
||||
"choiceHint": "Hosted models via ollama.com",
|
||||
"groupId": "ollama",
|
||||
"groupLabel": "Ollama",
|
||||
"groupHint": "Cloud and local open models",
|
||||
"optionKey": "ollamaCloudApiKey",
|
||||
"cliFlag": "--ollama-cloud-api-key",
|
||||
"cliOption": "--ollama-cloud-api-key <key>",
|
||||
"cliDescription": "Ollama Cloud API key"
|
||||
}
|
||||
],
|
||||
"modelCatalog": {
|
||||
"runtimeAugment": true,
|
||||
"providers": {
|
||||
"ollama-cloud": {
|
||||
"baseUrl": "https://ollama.com",
|
||||
"api": "ollama",
|
||||
"models": [
|
||||
{
|
||||
"id": "kimi-k2.5:cloud",
|
||||
"name": "kimi-k2.5:cloud",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 128000,
|
||||
"maxTokens": 8192,
|
||||
"compat": {
|
||||
"supportsTools": true,
|
||||
"supportsUsageInStreaming": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "minimax-m2.7:cloud",
|
||||
"name": "minimax-m2.7:cloud",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 128000,
|
||||
"maxTokens": 8192,
|
||||
"compat": {
|
||||
"supportsTools": true,
|
||||
"supportsUsageInStreaming": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-5.1:cloud",
|
||||
"name": "glm-5.1:cloud",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 128000,
|
||||
"maxTokens": 8192,
|
||||
"compat": {
|
||||
"supportsTools": true,
|
||||
"supportsUsageInStreaming": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "glm-5.2:cloud",
|
||||
"name": "glm-5.2:cloud",
|
||||
"reasoning": true,
|
||||
"input": ["text"],
|
||||
"cost": {
|
||||
"input": 0,
|
||||
"output": 0,
|
||||
"cacheRead": 0,
|
||||
"cacheWrite": 0
|
||||
},
|
||||
"contextWindow": 1000000,
|
||||
"maxTokens": 8192,
|
||||
"compat": {
|
||||
"supportsTools": true,
|
||||
"supportsUsageInStreaming": true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"discovery": {
|
||||
"ollama-cloud": "refreshable"
|
||||
}
|
||||
},
|
||||
"contracts": {
|
||||
"memoryEmbeddingProviders": ["ollama"],
|
||||
"tools": ["node_inference"],
|
||||
"webSearchProviders": ["ollama"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"discovery": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"nodeInference": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"uiHints": {
|
||||
"discovery": {
|
||||
"label": "Model Discovery",
|
||||
"help": "Plugin-owned controls for Ollama model auto-discovery."
|
||||
},
|
||||
"discovery.enabled": {
|
||||
"label": "Enable Discovery",
|
||||
"help": "When false, OpenClaw keeps the Ollama plugin available but skips implicit startup discovery of ambient local or remote Ollama models."
|
||||
},
|
||||
"nodeInference": {
|
||||
"label": "Node Inference",
|
||||
"help": "Controls whether this node host advertises its local Ollama models to agents."
|
||||
},
|
||||
"nodeInference.enabled": {
|
||||
"label": "Enable Node Inference",
|
||||
"help": "When false, this node host does not advertise or accept Ollama node-inference commands."
|
||||
}
|
||||
}
|
||||
}
|
||||
18
extensions/ollama/package.json
Normal file
18
extensions/ollama/package.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"name": "@openclaw/ollama-provider",
|
||||
"version": "2026.6.11",
|
||||
"private": true,
|
||||
"description": "OpenClaw Ollama provider plugin",
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"typebox": "1.3.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
30
extensions/ollama/provider-discovery.import-guard.test.ts
Normal file
30
extensions/ollama/provider-discovery.import-guard.test.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
// Ollama tests cover provider discovery.import guard plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const repoRoot = path.resolve(import.meta.dirname, "../..");
|
||||
|
||||
function readPluginSource(relativePath: string): string {
|
||||
return fs.readFileSync(path.join(repoRoot, relativePath), "utf8");
|
||||
}
|
||||
|
||||
describe("ollama provider discovery import surface", () => {
|
||||
it("stays off the full provider runtime graph", () => {
|
||||
const source = readPluginSource("extensions/ollama/provider-discovery.ts");
|
||||
|
||||
for (const forbidden of [
|
||||
"./index",
|
||||
"./api",
|
||||
"./runtime-api",
|
||||
"./src/setup",
|
||||
"./src/stream",
|
||||
"./src/embedding-provider",
|
||||
"./src/memory-embedding-adapter",
|
||||
"./src/web-search-provider",
|
||||
"openclaw/plugin-sdk/plugin-entry",
|
||||
]) {
|
||||
expect(source, `provider discovery must not import ${forbidden}`).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
});
|
||||
665
extensions/ollama/provider-discovery.test.ts
Normal file
665
extensions/ollama/provider-discovery.test.ts
Normal file
@@ -0,0 +1,665 @@
|
||||
// Ollama tests cover provider discovery plugin behavior.
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { clearLiveCatalogCacheForTests } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { withFetchPreconnect } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ollamaProviderDiscovery } from "./provider-discovery.js";
|
||||
|
||||
const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local";
|
||||
|
||||
afterEach(() => {
|
||||
clearLiveCatalogCacheForTests();
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("Ollama provider", () => {
|
||||
const createAgentDir = () => mkdtempSync(join(tmpdir(), "openclaw-test-"));
|
||||
|
||||
const enableDiscoveryEnv = () => {
|
||||
vi.stubEnv("VITEST", "");
|
||||
vi.stubEnv("NODE_ENV", "development");
|
||||
};
|
||||
|
||||
const fetchCallUrls = (fetchMock: ReturnType<typeof vi.fn>): string[] =>
|
||||
fetchMock.mock.calls.map(([input]) => String(input));
|
||||
|
||||
const countFetchCallUrls = (fetchMock: ReturnType<typeof vi.fn>, suffix: string): number =>
|
||||
fetchCallUrls(fetchMock).reduce((count, url) => count + (url.endsWith(suffix) ? 1 : 0), 0);
|
||||
|
||||
const countWarnCallsIncluding = (warnSpy: ReturnType<typeof vi.spyOn>, text: string): number => {
|
||||
let count = 0;
|
||||
for (const [message] of warnSpy.mock.calls) {
|
||||
if (String(message).includes(text)) {
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
};
|
||||
|
||||
const expectDiscoveryCallCounts = (
|
||||
fetchMock: ReturnType<typeof vi.fn>,
|
||||
params: { tags: number; show: number },
|
||||
) => {
|
||||
expect(countFetchCallUrls(fetchMock, "/api/tags")).toBe(params.tags);
|
||||
expect(countFetchCallUrls(fetchMock, "/api/show")).toBe(params.show);
|
||||
};
|
||||
|
||||
async function withOllamaApiKey<T>(run: () => Promise<T>): Promise<T> {
|
||||
process.env.OLLAMA_API_KEY = "test-key"; // pragma: allowlist secret
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
delete process.env.OLLAMA_API_KEY;
|
||||
}
|
||||
}
|
||||
|
||||
async function runOllamaCatalog(params: {
|
||||
config?: OpenClawConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
resolveProviderApiKey?: () => { apiKey: string | undefined; discoveryApiKey?: string };
|
||||
}) {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
VITEST: "1",
|
||||
NODE_ENV: "test",
|
||||
...params.env,
|
||||
};
|
||||
const result = await ollamaProviderDiscovery.catalog.run({
|
||||
config: params.config ?? {},
|
||||
agentDir: createAgentDir(),
|
||||
env,
|
||||
resolveProviderApiKey:
|
||||
params.resolveProviderApiKey ??
|
||||
(() => ({
|
||||
apiKey: env.OLLAMA_API_KEY?.trim() ? env.OLLAMA_API_KEY : undefined,
|
||||
})),
|
||||
resolveProviderAuth: () => ({
|
||||
apiKey: env.OLLAMA_API_KEY?.trim() ? env.OLLAMA_API_KEY : undefined,
|
||||
mode: env.OLLAMA_API_KEY?.trim() ? "api_key" : "none",
|
||||
source: env.OLLAMA_API_KEY?.trim() ? "env" : "none",
|
||||
}),
|
||||
});
|
||||
return result && "provider" in result ? result.provider : undefined;
|
||||
}
|
||||
|
||||
async function withoutAmbientOllamaEnv<T>(run: () => Promise<T>): Promise<T> {
|
||||
const previous = process.env.OLLAMA_API_KEY;
|
||||
delete process.env.OLLAMA_API_KEY;
|
||||
try {
|
||||
return await run();
|
||||
} finally {
|
||||
if (previous === undefined) {
|
||||
delete process.env.OLLAMA_API_KEY;
|
||||
} else {
|
||||
process.env.OLLAMA_API_KEY = previous;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const createTagModel = (name: string) => ({ name, modified_at: "", size: 1, digest: "" });
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
|
||||
const tagsResponse = (names: string[]) =>
|
||||
jsonResponse({ models: names.map((name) => createTagModel(name)) });
|
||||
|
||||
const notFoundJsonResponse = () => jsonResponse({}, 404);
|
||||
|
||||
const stubTagsFetch = (names: string[] = []) => {
|
||||
const fetchMock = vi.fn(async (input: unknown) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/tags")) {
|
||||
return tagsResponse(names);
|
||||
}
|
||||
return notFoundJsonResponse();
|
||||
});
|
||||
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
|
||||
return fetchMock;
|
||||
};
|
||||
|
||||
it("should not include ollama when no API key is configured", async () => {
|
||||
const provider = await runOllamaCatalog({
|
||||
env: { OLLAMA_API_KEY: undefined },
|
||||
});
|
||||
|
||||
expect(provider).toBeUndefined();
|
||||
});
|
||||
|
||||
it("should use native ollama api type", async () => {
|
||||
const fetchMock = stubTagsFetch();
|
||||
|
||||
await withOllamaApiKey(async () => {
|
||||
const provider = await runOllamaCatalog({});
|
||||
|
||||
if (!provider) {
|
||||
throw new Error("expected injected Ollama provider");
|
||||
}
|
||||
expect(provider.apiKey).toBe(OLLAMA_LOCAL_AUTH_MARKER);
|
||||
expect(provider.api).toBe("ollama");
|
||||
expect(provider.baseUrl).toBe("http://127.0.0.1:11434");
|
||||
expectDiscoveryCallCounts(fetchMock, { tags: 1, show: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve explicit ollama baseUrl and api on implicit provider injection", async () => {
|
||||
const fetchMock = stubTagsFetch();
|
||||
|
||||
await withOllamaApiKey(async () => {
|
||||
const provider = await runOllamaCatalog({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://192.168.20.14:11434/v1",
|
||||
api: "openai-completions",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { OLLAMA_API_KEY: "test-key" },
|
||||
});
|
||||
|
||||
expect(countFetchCallUrls(fetchMock, "/api/tags")).toBe(1);
|
||||
|
||||
expect(provider?.baseUrl).toBe("http://192.168.20.14:11434/v1");
|
||||
expect(provider?.api).toBe("openai-completions");
|
||||
});
|
||||
});
|
||||
|
||||
it("should normalize explicit native ollama baseUrl on implicit provider injection", async () => {
|
||||
const fetchMock = stubTagsFetch();
|
||||
|
||||
await withOllamaApiKey(async () => {
|
||||
const provider = await runOllamaCatalog({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://192.168.20.14:11434/v1",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { OLLAMA_API_KEY: "test-key" },
|
||||
});
|
||||
|
||||
expect(countFetchCallUrls(fetchMock, "/api/tags")).toBe(1);
|
||||
expect(provider?.baseUrl).toBe("http://192.168.20.14:11434");
|
||||
expect(provider?.api).toBe("ollama");
|
||||
});
|
||||
});
|
||||
|
||||
it("discovers per-model context windows from /api/show", async () => {
|
||||
enableDiscoveryEnv();
|
||||
const fetchMock = vi.fn(async (input: unknown, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/tags")) {
|
||||
return tagsResponse(["qwen3:32b", "llama3.3:70b"]);
|
||||
}
|
||||
if (url.endsWith("/api/show")) {
|
||||
const rawBody = init?.body;
|
||||
const bodyText = typeof rawBody === "string" ? rawBody : "{}";
|
||||
const parsed = JSON.parse(bodyText) as { name?: string };
|
||||
if (parsed.name === "qwen3:32b") {
|
||||
return jsonResponse({ model_info: { "qwen3.context_length": 131072 } });
|
||||
}
|
||||
if (parsed.name === "llama3.3:70b") {
|
||||
return jsonResponse({ model_info: { "llama.context_length": 65536 } });
|
||||
}
|
||||
}
|
||||
return notFoundJsonResponse();
|
||||
});
|
||||
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
|
||||
|
||||
const provider = await runOllamaCatalog({
|
||||
env: { OLLAMA_API_KEY: "test-key", VITEST: "", NODE_ENV: "development" },
|
||||
});
|
||||
const models = provider?.models ?? [];
|
||||
const qwen = models.find((model) => model.id === "qwen3:32b");
|
||||
const llama = models.find((model) => model.id === "llama3.3:70b");
|
||||
expect(qwen?.contextWindow).toBe(131072);
|
||||
expect(llama?.contextWindow).toBe(65536);
|
||||
expectDiscoveryCallCounts(fetchMock, { tags: 1, show: 2 });
|
||||
});
|
||||
|
||||
it("auto-registers ollama provider when models are discovered locally", async () => {
|
||||
await withoutAmbientOllamaEnv(async () => {
|
||||
enableDiscoveryEnv();
|
||||
const fetchMock = vi.fn(async (input: unknown) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/tags")) {
|
||||
return tagsResponse(["deepseek-r1:latest", "llama3.3:latest"]);
|
||||
}
|
||||
if (url.endsWith("/api/show")) {
|
||||
return jsonResponse({ model_info: {} });
|
||||
}
|
||||
return notFoundJsonResponse();
|
||||
});
|
||||
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
|
||||
|
||||
const provider = await runOllamaCatalog({
|
||||
env: { OLLAMA_API_KEY: OLLAMA_LOCAL_AUTH_MARKER, VITEST: "", NODE_ENV: "development" },
|
||||
});
|
||||
|
||||
expect(provider?.apiKey).toBe(OLLAMA_LOCAL_AUTH_MARKER);
|
||||
expect(provider?.api).toBe("ollama");
|
||||
expect(provider?.baseUrl).toBe("http://127.0.0.1:11434");
|
||||
expect(provider?.models).toHaveLength(2);
|
||||
expect(provider?.models?.[0]?.id).toBe("deepseek-r1:latest");
|
||||
expect(provider?.models?.[0]?.reasoning).toBe(true);
|
||||
expect(provider?.models?.[1]?.reasoning).toBe(false);
|
||||
expectDiscoveryCallCounts(fetchMock, { tags: 1, show: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
it("does not warn when Ollama is unreachable and not explicitly configured", async () => {
|
||||
await withoutAmbientOllamaEnv(async () => {
|
||||
enableDiscoveryEnv();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("connect ECONNREFUSED 127.0.0.1:11434"));
|
||||
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
|
||||
|
||||
const provider = await runOllamaCatalog({
|
||||
env: { VITEST: "", NODE_ENV: "development" },
|
||||
});
|
||||
|
||||
expect(provider).toBeUndefined();
|
||||
expect(
|
||||
warnSpy.mock.calls.filter(([message]) => String(message).includes("Ollama")),
|
||||
).toHaveLength(0);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when Ollama is unreachable and explicitly configured", async () => {
|
||||
await withoutAmbientOllamaEnv(async () => {
|
||||
enableDiscoveryEnv();
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error("connect ECONNREFUSED 127.0.0.1:11434"));
|
||||
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
|
||||
|
||||
await runOllamaCatalog({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11435/v1",
|
||||
api: "openai-completions",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { VITEST: "", NODE_ENV: "development" },
|
||||
});
|
||||
|
||||
expect(countWarnCallsIncluding(warnSpy, "Ollama")).toBeGreaterThan(0);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to default context window when /api/show fails", async () => {
|
||||
enableDiscoveryEnv();
|
||||
const fetchMock = vi.fn(async (input: unknown) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/tags")) {
|
||||
return tagsResponse(["qwen3:32b"]);
|
||||
}
|
||||
if (url.endsWith("/api/show")) {
|
||||
return jsonResponse({}, 500);
|
||||
}
|
||||
return notFoundJsonResponse();
|
||||
});
|
||||
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
|
||||
|
||||
const provider = await runOllamaCatalog({
|
||||
env: { OLLAMA_API_KEY: "test-key", VITEST: "", NODE_ENV: "development" },
|
||||
});
|
||||
const model = provider?.models?.find((entry) => entry.id === "qwen3:32b");
|
||||
expect(model?.contextWindow).toBe(128000);
|
||||
expectDiscoveryCallCounts(fetchMock, { tags: 1, show: 1 });
|
||||
});
|
||||
|
||||
it("caps /api/show requests when /api/tags returns a very large model list", async () => {
|
||||
enableDiscoveryEnv();
|
||||
const manyModels = Array.from({ length: 250 }, (_, idx) => ({
|
||||
name: `model-${idx}`,
|
||||
modified_at: "",
|
||||
size: 1,
|
||||
digest: "",
|
||||
}));
|
||||
const fetchMock = vi.fn(async (input: unknown) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/tags")) {
|
||||
return jsonResponse({ models: manyModels });
|
||||
}
|
||||
return jsonResponse({ model_info: { "llama.context_length": 65536 } });
|
||||
});
|
||||
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
|
||||
|
||||
const provider = await runOllamaCatalog({
|
||||
env: { OLLAMA_API_KEY: "test-key", VITEST: "", NODE_ENV: "development" },
|
||||
});
|
||||
const models = provider?.models ?? [];
|
||||
// 1 call for /api/tags + 200 capped /api/show calls.
|
||||
expectDiscoveryCallCounts(fetchMock, { tags: 1, show: 200 });
|
||||
expect(models).toHaveLength(200);
|
||||
});
|
||||
|
||||
it("should have correct model structure without streaming override", () => {
|
||||
const mockOllamaModel = {
|
||||
id: "llama3.3:latest",
|
||||
name: "llama3.3:latest",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
};
|
||||
|
||||
// Native Ollama provider does not need streaming: false workaround
|
||||
expect(mockOllamaModel).not.toHaveProperty("params");
|
||||
});
|
||||
|
||||
it("should skip discovery fetch when explicit models are configured", async () => {
|
||||
await withoutAmbientOllamaEnv(async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
|
||||
const explicitModels: ModelDefinitionConfig[] = [
|
||||
{
|
||||
id: "gpt-oss:20b",
|
||||
name: "GPT-OSS 20B",
|
||||
reasoning: false,
|
||||
input: ["text"] as Array<"text" | "image">,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 81920,
|
||||
},
|
||||
];
|
||||
|
||||
const provider = await runOllamaCatalog({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://remote-ollama:11434/v1",
|
||||
models: explicitModels,
|
||||
apiKey: "config-ollama-key", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { VITEST: "", NODE_ENV: "development" },
|
||||
});
|
||||
|
||||
const ollamaCalls = fetchMock.mock.calls.filter(([input]) => {
|
||||
const url = String(input);
|
||||
return url.endsWith("/api/tags") || url.endsWith("/api/show");
|
||||
});
|
||||
expect(ollamaCalls).toHaveLength(0);
|
||||
expect(provider?.models).toEqual(explicitModels);
|
||||
expect(provider?.baseUrl).toBe("http://remote-ollama:11434");
|
||||
expect(provider?.api).toBe("ollama");
|
||||
expect(provider?.apiKey).toBe("config-ollama-key");
|
||||
});
|
||||
});
|
||||
|
||||
it("should use synthetic local auth for configured remote providers without apiKey", async () => {
|
||||
await withoutAmbientOllamaEnv(async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
|
||||
|
||||
const provider = await runOllamaCatalog({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://remote-ollama:11434/v1",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-oss:20b",
|
||||
name: "GPT-OSS 20B",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 81920,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { VITEST: "", NODE_ENV: "development" },
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(provider?.baseUrl).toBe("http://remote-ollama:11434");
|
||||
expect(provider?.api).toBe("ollama");
|
||||
expect(provider?.apiKey).toBe(OLLAMA_LOCAL_AUTH_MARKER);
|
||||
expect(provider?.models).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not use synthetic local auth for configured cloud providers without apiKey", async () => {
|
||||
await withoutAmbientOllamaEnv(async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
|
||||
|
||||
const provider = await runOllamaCatalog({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "https://ollama.com/v1",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-oss:20b",
|
||||
name: "GPT-OSS 20B",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 81920,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { VITEST: "", NODE_ENV: "development" },
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(provider?.baseUrl).toBe("https://ollama.com");
|
||||
expect(provider?.api).toBe("ollama");
|
||||
expect(provider?.apiKey).toBeUndefined();
|
||||
expect(provider?.models).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses resolved discovery api key when configured cloud apiKey is an env marker", async () => {
|
||||
await withoutAmbientOllamaEnv(async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
|
||||
|
||||
const provider = await runOllamaCatalog({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "https://ollama.com/v1",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-oss:20b",
|
||||
name: "GPT-OSS 20B",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 81920,
|
||||
},
|
||||
],
|
||||
apiKey: "OLLAMA_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { OLLAMA_API_KEY: "real-secret", VITEST: "", NODE_ENV: "development" },
|
||||
resolveProviderApiKey: () => ({
|
||||
apiKey: "OLLAMA_API_KEY",
|
||||
discoveryApiKey: "real-secret",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(provider?.baseUrl).toBe("https://ollama.com");
|
||||
expect(provider?.api).toBe("ollama");
|
||||
expect(provider?.apiKey).toBe("real-secret");
|
||||
expect(provider?.models).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("uses resolved discovery api key for configured cloud providers without apiKey", async () => {
|
||||
await withoutAmbientOllamaEnv(async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
|
||||
|
||||
const provider = await runOllamaCatalog({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "https://ollama.com/v1",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-oss:20b",
|
||||
name: "GPT-OSS 20B",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 81920,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { OLLAMA_API_KEY: "real-secret", VITEST: "", NODE_ENV: "development" },
|
||||
resolveProviderApiKey: () => ({
|
||||
apiKey: "OLLAMA_API_KEY",
|
||||
discoveryApiKey: "real-secret",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(provider?.baseUrl).toBe("https://ollama.com");
|
||||
expect(provider?.api).toBe("ollama");
|
||||
expect(provider?.apiKey).toBe("real-secret");
|
||||
expect(provider?.models).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps synthetic local auth when a local provider also has a discovery key", async () => {
|
||||
await withoutAmbientOllamaEnv(async () => {
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
|
||||
|
||||
const provider = await runOllamaCatalog({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
models: [
|
||||
{
|
||||
id: "gpt-oss:20b",
|
||||
name: "GPT-OSS 20B",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 81920,
|
||||
},
|
||||
],
|
||||
apiKey: "OLLAMA_API_KEY",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { OLLAMA_API_KEY: "real-secret", VITEST: "", NODE_ENV: "development" },
|
||||
resolveProviderApiKey: () => ({
|
||||
apiKey: "OLLAMA_API_KEY",
|
||||
discoveryApiKey: "real-secret",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(provider?.baseUrl).toBe("http://127.0.0.1:11434");
|
||||
expect(provider?.api).toBe("ollama");
|
||||
expect(provider?.apiKey).toBe(OLLAMA_LOCAL_AUTH_MARKER);
|
||||
expect(provider?.models).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
it("should preserve explicit apiKey from configured remote providers", async () => {
|
||||
await withoutAmbientOllamaEnv(async () => {
|
||||
const fetchMock = vi.fn(async (input: unknown) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/tags")) {
|
||||
return tagsResponse([]);
|
||||
}
|
||||
return notFoundJsonResponse();
|
||||
});
|
||||
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
|
||||
|
||||
const provider = await runOllamaCatalog({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://remote-ollama:11434/v1",
|
||||
api: "openai-completions",
|
||||
models: [
|
||||
{
|
||||
id: "configured-remote-model",
|
||||
name: "Configured Remote Model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 8192,
|
||||
maxTokens: 8192,
|
||||
},
|
||||
],
|
||||
apiKey: "config-ollama-key", // pragma: allowlist secret
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { VITEST: "", NODE_ENV: "development" },
|
||||
});
|
||||
|
||||
expect(provider?.apiKey).toBe("config-ollama-key");
|
||||
expect(provider?.baseUrl).toBe("http://remote-ollama:11434/v1");
|
||||
expect(provider?.api).toBe("openai-completions");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
70
extensions/ollama/provider-discovery.ts
Normal file
70
extensions/ollama/provider-discovery.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
// Ollama provider module implements model/runtime integration.
|
||||
import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
OLLAMA_DEFAULT_API_KEY,
|
||||
OLLAMA_PROVIDER_ID,
|
||||
resolveOllamaDiscoveryResult,
|
||||
shouldUseSyntheticOllamaAuth,
|
||||
type OllamaPluginConfig,
|
||||
} from "./src/discovery-shared.js";
|
||||
import { buildOllamaProvider } from "./src/provider-models.js";
|
||||
|
||||
type OllamaProviderPlugin = {
|
||||
id: string;
|
||||
label: string;
|
||||
docsPath: string;
|
||||
envVars: string[];
|
||||
auth: [];
|
||||
resolveSyntheticAuth: (ctx: { provider?: string; providerConfig?: ModelProviderConfig }) =>
|
||||
| {
|
||||
apiKey: string;
|
||||
source: string;
|
||||
mode: "api-key";
|
||||
}
|
||||
| undefined;
|
||||
catalog: {
|
||||
order: "late";
|
||||
run: (ctx: ProviderCatalogContext) => ReturnType<typeof runOllamaDiscovery>;
|
||||
};
|
||||
};
|
||||
|
||||
function resolveOllamaPluginConfig(ctx: ProviderCatalogContext): OllamaPluginConfig {
|
||||
const entries = (ctx.config.plugins?.entries ?? {}) as Record<
|
||||
string,
|
||||
{ config?: OllamaPluginConfig }
|
||||
>;
|
||||
return entries.ollama?.config ?? {};
|
||||
}
|
||||
|
||||
async function runOllamaDiscovery(ctx: ProviderCatalogContext) {
|
||||
return await resolveOllamaDiscoveryResult({
|
||||
ctx,
|
||||
pluginConfig: resolveOllamaPluginConfig(ctx),
|
||||
buildProvider: buildOllamaProvider,
|
||||
});
|
||||
}
|
||||
|
||||
export const ollamaProviderDiscovery: OllamaProviderPlugin = {
|
||||
id: OLLAMA_PROVIDER_ID,
|
||||
label: "Ollama",
|
||||
docsPath: "/providers/ollama",
|
||||
envVars: ["OLLAMA_API_KEY"],
|
||||
auth: [],
|
||||
resolveSyntheticAuth: ({ provider, providerConfig }) => {
|
||||
if (!shouldUseSyntheticOllamaAuth(providerConfig)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
apiKey: OLLAMA_DEFAULT_API_KEY,
|
||||
source: `models.providers.${provider ?? OLLAMA_PROVIDER_ID} (synthetic local key)`,
|
||||
mode: "api-key",
|
||||
};
|
||||
},
|
||||
catalog: {
|
||||
order: "late",
|
||||
run: runOllamaDiscovery,
|
||||
},
|
||||
};
|
||||
|
||||
export default ollamaProviderDiscovery;
|
||||
73
extensions/ollama/provider-policy-api.test.ts
Normal file
73
extensions/ollama/provider-policy-api.test.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// Ollama tests cover provider policy api plugin behavior.
|
||||
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-model-types";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeConfig, resolveThinkingProfile } from "./provider-policy-api.js";
|
||||
import { OLLAMA_DEFAULT_BASE_URL } from "./src/defaults.js";
|
||||
|
||||
function createModel(id: string, name: string): ModelDefinitionConfig {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
},
|
||||
contextWindow: 128_000,
|
||||
maxTokens: 8_192,
|
||||
};
|
||||
}
|
||||
|
||||
describe("ollama provider policy public artifact", () => {
|
||||
it("injects defaults so implicit discovery can run before validation", () => {
|
||||
expect(
|
||||
normalizeConfig({
|
||||
provider: "ollama",
|
||||
providerConfig: {},
|
||||
}),
|
||||
).toStrictEqual({
|
||||
baseUrl: OLLAMA_DEFAULT_BASE_URL,
|
||||
models: [],
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves explicit Ollama config values", () => {
|
||||
const models = [createModel("llama3.2", "Llama 3.2")];
|
||||
|
||||
expect(
|
||||
normalizeConfig({
|
||||
provider: "ollama",
|
||||
providerConfig: {
|
||||
baseUrl: "http://ollama.internal:11434",
|
||||
models,
|
||||
},
|
||||
}),
|
||||
).toStrictEqual({
|
||||
baseUrl: "http://ollama.internal:11434",
|
||||
models,
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores other providers", () => {
|
||||
expect(
|
||||
normalizeConfig({
|
||||
provider: "openai",
|
||||
providerConfig: {},
|
||||
}),
|
||||
).toStrictEqual({});
|
||||
});
|
||||
|
||||
it("exposes max thinking for reasoning-capable models without full plugin activation", () => {
|
||||
expect(resolveThinkingProfile({ reasoning: true })).toEqual({
|
||||
levels: [{ id: "off" }, { id: "low" }, { id: "medium" }, { id: "high" }, { id: "max" }],
|
||||
defaultLevel: "off",
|
||||
});
|
||||
expect(resolveThinkingProfile({ reasoning: false })).toEqual({
|
||||
levels: [{ id: "off" }],
|
||||
defaultLevel: "off",
|
||||
});
|
||||
});
|
||||
});
|
||||
60
extensions/ollama/provider-policy-api.ts
Normal file
60
extensions/ollama/provider-policy-api.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Ollama API module exposes the plugin public contract.
|
||||
import type { ProviderThinkingProfile } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-types";
|
||||
import { OLLAMA_DEFAULT_BASE_URL } from "./src/defaults.js";
|
||||
|
||||
type OllamaProviderConfigDraft = Partial<ModelProviderConfig>;
|
||||
|
||||
const OLLAMA_REASONING_THINKING_PROFILE = {
|
||||
levels: [{ id: "off" }, { id: "low" }, { id: "medium" }, { id: "high" }, { id: "max" }],
|
||||
defaultLevel: "off",
|
||||
} satisfies ProviderThinkingProfile;
|
||||
|
||||
const OLLAMA_NON_REASONING_THINKING_PROFILE = {
|
||||
levels: [{ id: "off" }],
|
||||
defaultLevel: "off",
|
||||
} satisfies ProviderThinkingProfile;
|
||||
|
||||
/**
|
||||
* Provider policy surface for Ollama: normalize provider configs used by
|
||||
* core defaults/normalizers. This runs during config defaults application and
|
||||
* normalization paths (not Zod validation).
|
||||
*/
|
||||
export function normalizeConfig({
|
||||
provider,
|
||||
providerConfig,
|
||||
}: {
|
||||
provider: string;
|
||||
providerConfig: OllamaProviderConfigDraft;
|
||||
}): OllamaProviderConfigDraft {
|
||||
if (!providerConfig || typeof providerConfig !== "object") {
|
||||
return providerConfig;
|
||||
}
|
||||
|
||||
const normalizedProviderId = (provider ?? "").trim().toLowerCase();
|
||||
if (normalizedProviderId !== "ollama") {
|
||||
return providerConfig;
|
||||
}
|
||||
|
||||
const next: OllamaProviderConfigDraft = { ...providerConfig };
|
||||
|
||||
// If baseUrl is missing, empty, or whitespace-only, default to local Ollama host.
|
||||
if (typeof next.baseUrl !== "string" || !next.baseUrl.trim()) {
|
||||
next.baseUrl = OLLAMA_DEFAULT_BASE_URL;
|
||||
}
|
||||
|
||||
// If models is missing/not an array, default to empty array to signal discovery.
|
||||
if (!Array.isArray(next.models)) {
|
||||
next.models = [];
|
||||
}
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
export function resolveThinkingProfile({
|
||||
reasoning,
|
||||
}: {
|
||||
reasoning?: boolean;
|
||||
}): ProviderThinkingProfile {
|
||||
return reasoning ? OLLAMA_REASONING_THINKING_PROFILE : OLLAMA_NON_REASONING_THINKING_PROFILE;
|
||||
}
|
||||
23
extensions/ollama/runtime-api.ts
Normal file
23
extensions/ollama/runtime-api.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Ollama API module exposes the plugin public contract.
|
||||
export {
|
||||
buildAssistantMessage,
|
||||
buildOllamaChatRequest,
|
||||
createConfiguredOllamaCompatStreamWrapper,
|
||||
convertToOllamaMessages,
|
||||
createConfiguredOllamaCompatNumCtxWrapper,
|
||||
createConfiguredOllamaStreamFn,
|
||||
createOllamaStreamFn,
|
||||
isOllamaCompatProvider,
|
||||
OLLAMA_NATIVE_BASE_URL,
|
||||
parseNdjsonStream,
|
||||
resolveOllamaBaseUrlForRun,
|
||||
resolveOllamaCompatNumCtxEnabled,
|
||||
shouldInjectOllamaCompatNumCtx,
|
||||
wrapOllamaCompatNumCtx,
|
||||
} from "./src/stream.js";
|
||||
export {
|
||||
createOllamaEmbeddingProvider,
|
||||
DEFAULT_OLLAMA_EMBEDDING_MODEL,
|
||||
type OllamaEmbeddingClient,
|
||||
type OllamaEmbeddingProvider,
|
||||
} from "./src/embedding-provider.js";
|
||||
103
extensions/ollama/src/config-compat.ts
Normal file
103
extensions/ollama/src/config-compat.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
// Ollama helper module supports config compat behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { OLLAMA_CLOUD_BASE_URL, OLLAMA_CLOUD_PROVIDER_ID } from "./defaults.js";
|
||||
|
||||
type LegacyConfigRule = {
|
||||
path: Array<string | number>;
|
||||
message: string;
|
||||
match: (value: unknown) => boolean;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function isRetiredOllamaCloudBaseUrl(value: unknown): value is string {
|
||||
if (typeof value !== "string" || !value.trim()) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return new URL(value.trim()).hostname.toLowerCase() === "ai.ollama.com";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findRetiredOllamaCloudBaseUrl(provider: unknown): { key: "baseUrl" | "baseURL" } | null {
|
||||
const record = asRecord(provider);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
if (isRetiredOllamaCloudBaseUrl(record.baseUrl)) {
|
||||
return { key: "baseUrl" };
|
||||
}
|
||||
if (isRetiredOllamaCloudBaseUrl(record.baseURL)) {
|
||||
return { key: "baseURL" };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const legacyConfigRules: LegacyConfigRule[] = [
|
||||
{
|
||||
path: ["models", "providers", OLLAMA_CLOUD_PROVIDER_ID],
|
||||
message:
|
||||
'models.providers.ollama-cloud.baseUrl="https://ai.ollama.com" is retired; use "https://ollama.com". Run "openclaw doctor --fix".',
|
||||
match: (value) => findRetiredOllamaCloudBaseUrl(value) !== null,
|
||||
},
|
||||
];
|
||||
|
||||
export function migrateOllamaCloudRetiredBaseUrl(config: OpenClawConfig): {
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
} | null {
|
||||
const provider = config.models?.providers?.[OLLAMA_CLOUD_PROVIDER_ID];
|
||||
const retired = findRetiredOllamaCloudBaseUrl(provider);
|
||||
if (!retired) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextConfig = structuredClone(config);
|
||||
const nextModels = asRecord(nextConfig.models) ?? {};
|
||||
nextConfig.models = nextModels as OpenClawConfig["models"];
|
||||
const nextProviders = asRecord(nextModels.providers) ?? {};
|
||||
nextModels.providers = nextProviders;
|
||||
const nextProvider = asRecord(nextProviders[OLLAMA_CLOUD_PROVIDER_ID]) ?? {};
|
||||
nextProviders[OLLAMA_CLOUD_PROVIDER_ID] = nextProvider;
|
||||
|
||||
const canonicalBaseUrl = nextProvider.baseUrl;
|
||||
if (
|
||||
retired.key === "baseURL" &&
|
||||
typeof canonicalBaseUrl === "string" &&
|
||||
canonicalBaseUrl.trim() &&
|
||||
!isRetiredOllamaCloudBaseUrl(canonicalBaseUrl)
|
||||
) {
|
||||
delete nextProvider.baseURL;
|
||||
return {
|
||||
config: nextConfig,
|
||||
changes: [
|
||||
"Removed retired models.providers.ollama-cloud.baseURL while preserving models.providers.ollama-cloud.baseUrl.",
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
nextProvider.baseUrl = OLLAMA_CLOUD_BASE_URL;
|
||||
if (retired.key === "baseURL") {
|
||||
delete nextProvider.baseURL;
|
||||
}
|
||||
|
||||
return {
|
||||
config: nextConfig,
|
||||
changes: [
|
||||
`Updated models.providers.ollama-cloud.${retired.key} from the retired Ollama Cloud endpoint to ${OLLAMA_CLOUD_BASE_URL}.`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCompatibilityConfig({ cfg }: { cfg: OpenClawConfig }): {
|
||||
config: OpenClawConfig;
|
||||
changes: string[];
|
||||
} {
|
||||
return migrateOllamaCloudRetiredBaseUrl(cfg) ?? { config: cfg, changes: [] };
|
||||
}
|
||||
24
extensions/ollama/src/defaults.ts
Normal file
24
extensions/ollama/src/defaults.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// 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";
|
||||
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:cloud";
|
||||
export const OLLAMA_GLM52_CONTEXT_WINDOW = 1_000_000;
|
||||
export const OLLAMA_CLOUD_DEFAULT_MODELS = [
|
||||
"kimi-k2.5:cloud",
|
||||
"minimax-m2.7:cloud",
|
||||
"glm-5.1:cloud",
|
||||
OLLAMA_GLM52_CLOUD_MODEL_ID,
|
||||
] as const;
|
||||
|
||||
export const OLLAMA_DEFAULT_CONTEXT_WINDOW = 128000;
|
||||
export const OLLAMA_DEFAULT_MAX_TOKENS = 8192;
|
||||
export const OLLAMA_DEFAULT_COST = {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
};
|
||||
|
||||
export const OLLAMA_DEFAULT_MODEL = "gemma4";
|
||||
236
extensions/ollama/src/discovery-shared.test.ts
Normal file
236
extensions/ollama/src/discovery-shared.test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
// Ollama tests cover discovery shared plugin behavior.
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
isHostedOllamaCloud,
|
||||
isLocalOllamaBaseUrl,
|
||||
resolveOllamaDiscoveryResult,
|
||||
} from "./discovery-shared.js";
|
||||
|
||||
describe("isLocalOllamaBaseUrl", () => {
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"http://localhost:11434",
|
||||
"http://127.0.0.1:11434",
|
||||
"http://0.0.0.0:11434",
|
||||
"http://[::1]:11434",
|
||||
"http://10.0.0.5:11434",
|
||||
"http://172.16.0.10:11434",
|
||||
"http://172.31.255.254:11434",
|
||||
"http://192.168.1.100:11434",
|
||||
"http://gpu-node-1:11434",
|
||||
"http://mac-studio.local:11434",
|
||||
"http://docker.orb.internal:11434",
|
||||
"http://host.docker.internal:11434",
|
||||
"http://host.orb.internal:11434",
|
||||
"http://[fd00::1]:11434",
|
||||
"http://[fe90::1]:11434",
|
||||
])("classifies %s as local", (baseUrl) => {
|
||||
expect(isLocalOllamaBaseUrl(baseUrl)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://ollama.com",
|
||||
"https://api.ollama.com/v1",
|
||||
"https://ollama.example.com:11434",
|
||||
"http://8.8.8.8:11434",
|
||||
"http://172.15.255.254:11434",
|
||||
"http://172.32.0.1:11434",
|
||||
"http://193.168.1.1:11434",
|
||||
"http://[2001:4860:4860::8888]:11434",
|
||||
"http://10.example.com:11434",
|
||||
"not a url",
|
||||
])("classifies %s as remote", (baseUrl) => {
|
||||
expect(isLocalOllamaBaseUrl(baseUrl)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isHostedOllamaCloud", () => {
|
||||
it.each([
|
||||
"https://ollama.com",
|
||||
"https://ollama.com:11434",
|
||||
"https://api.ollama.com",
|
||||
"https://api.ollama.com/v1",
|
||||
"https://sub.ollama.com",
|
||||
])("classifies %s as hosted cloud", (baseUrl) => {
|
||||
expect(isHostedOllamaCloud(baseUrl)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
undefined,
|
||||
"",
|
||||
"http://localhost:11434",
|
||||
"http://127.0.0.1:11434",
|
||||
"https://ollama.mycompany.com",
|
||||
"https://ollama.example.com",
|
||||
"http://10.0.0.5:11434",
|
||||
"not a url",
|
||||
])("classifies %s as not hosted cloud", (baseUrl) => {
|
||||
expect(isHostedOllamaCloud(baseUrl)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveOllamaDiscoveryResult — hosted Ollama Cloud guard", () => {
|
||||
const discoveredModel = {
|
||||
id: "discovered-model",
|
||||
name: "discovered-model",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
compat: { supportsTools: true, supportsUsageInStreaming: true },
|
||||
params: { num_ctx: 128000 },
|
||||
} satisfies ModelProviderConfig["models"][number];
|
||||
|
||||
const cloudModel = {
|
||||
id: "minimax-m3:cloud",
|
||||
name: "minimax-m3:cloud",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
compat: { supportsTools: true, supportsUsageInStreaming: true },
|
||||
params: { num_ctx: 128000 },
|
||||
} satisfies ModelProviderConfig["models"][number];
|
||||
|
||||
const buildMockProvider = async (
|
||||
_configuredBaseUrl?: string,
|
||||
_opts?: { quiet?: boolean },
|
||||
): Promise<ModelProviderConfig> => ({
|
||||
baseUrl: "https://ollama.com",
|
||||
api: "ollama",
|
||||
models: [discoveredModel],
|
||||
});
|
||||
|
||||
it("returns null for remote base URL without explicit models", async () => {
|
||||
const result = await resolveOllamaDiscoveryResult({
|
||||
ctx: {
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "https://ollama.com",
|
||||
apiKey: "test-key",
|
||||
api: "ollama",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
resolveProviderApiKey: () => ({ apiKey: "test-key" }),
|
||||
},
|
||||
pluginConfig: {},
|
||||
buildProvider: buildMockProvider,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns explicit models for remote base URL when models are configured", async () => {
|
||||
const result = await resolveOllamaDiscoveryResult({
|
||||
ctx: {
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "https://ollama.com",
|
||||
apiKey: "test-key",
|
||||
api: "ollama",
|
||||
models: [cloudModel],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
resolveProviderApiKey: () => ({ apiKey: "test-key" }),
|
||||
},
|
||||
pluginConfig: {},
|
||||
buildProvider: buildMockProvider,
|
||||
});
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.provider.models).toHaveLength(1);
|
||||
expect(result!.provider.models[0].id).toBe("minimax-m3:cloud");
|
||||
});
|
||||
|
||||
it("does not call buildProvider for remote base URL without explicit models", async () => {
|
||||
let providerCalled = false;
|
||||
const trackingBuildProvider = async (
|
||||
_configuredBaseUrl?: string,
|
||||
_opts?: { quiet?: boolean },
|
||||
): Promise<ModelProviderConfig> => {
|
||||
providerCalled = true;
|
||||
return buildMockProvider();
|
||||
};
|
||||
|
||||
const result = await resolveOllamaDiscoveryResult({
|
||||
ctx: {
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "https://ollama.com",
|
||||
apiKey: "test-key",
|
||||
api: "ollama",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
resolveProviderApiKey: () => ({ apiKey: "test-key" }),
|
||||
},
|
||||
pluginConfig: {},
|
||||
buildProvider: trackingBuildProvider,
|
||||
});
|
||||
expect(result).toBeNull();
|
||||
expect(providerCalled).toBe(false);
|
||||
});
|
||||
|
||||
it("still auto-discovers for remote self-hosted base URL when no explicit models", async () => {
|
||||
const result = await resolveOllamaDiscoveryResult({
|
||||
ctx: {
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "https://ollama.mycompany.com",
|
||||
apiKey: "test-key",
|
||||
api: "ollama",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: {},
|
||||
resolveProviderApiKey: () => ({ apiKey: "test-key" }),
|
||||
},
|
||||
pluginConfig: {},
|
||||
buildProvider: buildMockProvider,
|
||||
});
|
||||
// Remote self-hosted base URL should still reach the discovery path
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
|
||||
it("still auto-discovers for local base URL when no explicit models", async () => {
|
||||
const result = await resolveOllamaDiscoveryResult({
|
||||
ctx: {
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://localhost:11434",
|
||||
api: "ollama",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { OLLAMA_API_KEY: "ollama-local" },
|
||||
resolveProviderApiKey: () => ({ apiKey: "ollama-local" }),
|
||||
},
|
||||
pluginConfig: {},
|
||||
buildProvider: buildMockProvider,
|
||||
});
|
||||
// Local base URL should still reach the discovery path
|
||||
expect(result).not.toBeNull();
|
||||
});
|
||||
});
|
||||
373
extensions/ollama/src/discovery-shared.ts
Normal file
373
extensions/ollama/src/discovery-shared.ts
Normal file
@@ -0,0 +1,373 @@
|
||||
// Ollama plugin module implements discovery shared behavior.
|
||||
import { getCachedLiveCatalogValue } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type {
|
||||
ModelProviderConfig,
|
||||
ModelDefinitionConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
/**
|
||||
* Provider config input type — partial config without required `models`.
|
||||
* Replaces the deprecated `openclaw/plugin-sdk/config-types` import.
|
||||
*/
|
||||
type OllamaProviderConfigInput = Omit<Partial<ModelProviderConfig>, "models"> & {
|
||||
models?: ModelDefinitionConfig[];
|
||||
};
|
||||
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 { resolveOllamaApiBase } from "./provider-models.js";
|
||||
|
||||
export const OLLAMA_PROVIDER_ID = "ollama";
|
||||
export const OLLAMA_DEFAULT_API_KEY = "ollama-local";
|
||||
|
||||
export type OllamaPluginConfig = {
|
||||
discovery?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
nodeInference?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type OllamaDiscoveryContext = {
|
||||
config: {
|
||||
models?: {
|
||||
providers?: Record<string, OllamaProviderConfigInput | undefined>;
|
||||
};
|
||||
};
|
||||
env: NodeJS.ProcessEnv;
|
||||
resolveProviderApiKey: (providerId: string) => {
|
||||
apiKey?: unknown;
|
||||
discoveryApiKey?: unknown;
|
||||
};
|
||||
};
|
||||
|
||||
function readStringValue(value: unknown): string | undefined {
|
||||
if (typeof value === "string") {
|
||||
return normalizeOptionalString(value);
|
||||
}
|
||||
if (value && typeof value === "object" && "value" in value) {
|
||||
return normalizeOptionalString((value as { value?: unknown }).value);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isOllamaApiKeyMarker(value: string): boolean {
|
||||
return value === "OLLAMA_API_KEY" || value === OLLAMA_DEFAULT_API_KEY;
|
||||
}
|
||||
|
||||
export function resolveOllamaRuntimeBaseUrl(params: {
|
||||
api?: ModelProviderConfig["api"];
|
||||
configuredBaseUrl?: string;
|
||||
discoveredBaseUrl: string;
|
||||
}): string {
|
||||
if (params.configuredBaseUrl && params.api && params.api !== "ollama") {
|
||||
return params.configuredBaseUrl;
|
||||
}
|
||||
return params.discoveredBaseUrl;
|
||||
}
|
||||
|
||||
function resolveOllamaDiscoveryApiKey(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
baseUrl?: string;
|
||||
explicitApiKey?: string;
|
||||
resolvedApiKey?: unknown;
|
||||
resolvedDiscoveryApiKey?: unknown;
|
||||
}): string | undefined {
|
||||
const envValue = normalizeOptionalString(params.env.OLLAMA_API_KEY);
|
||||
const resolvedApiKey = normalizeOptionalString(params.resolvedApiKey);
|
||||
const resolvedDiscoveryApiKey = normalizeOptionalString(params.resolvedDiscoveryApiKey);
|
||||
const explicitApiKey = normalizeOptionalString(params.explicitApiKey);
|
||||
if (explicitApiKey && !isOllamaApiKeyMarker(explicitApiKey)) {
|
||||
return explicitApiKey;
|
||||
}
|
||||
if (!isLocalOllamaBaseUrl(params.baseUrl)) {
|
||||
if (resolvedDiscoveryApiKey) {
|
||||
return resolvedDiscoveryApiKey;
|
||||
}
|
||||
if (resolvedApiKey && !isOllamaApiKeyMarker(resolvedApiKey)) {
|
||||
return resolvedApiKey;
|
||||
}
|
||||
return envValue && envValue !== OLLAMA_DEFAULT_API_KEY ? envValue : undefined;
|
||||
}
|
||||
if (resolvedApiKey && resolvedApiKey !== envValue && !isOllamaApiKeyMarker(resolvedApiKey)) {
|
||||
return resolvedApiKey;
|
||||
}
|
||||
return OLLAMA_DEFAULT_API_KEY;
|
||||
}
|
||||
|
||||
function shouldSkipAmbientOllamaDiscovery(env: NodeJS.ProcessEnv): boolean {
|
||||
return Boolean(env.VITEST) || env.NODE_ENV === "test";
|
||||
}
|
||||
|
||||
const LOCAL_OLLAMA_HOSTNAMES = new Set([
|
||||
"localhost",
|
||||
"127.0.0.1",
|
||||
"0.0.0.0",
|
||||
"::1",
|
||||
"::",
|
||||
"docker.orb.internal",
|
||||
"host.docker.internal",
|
||||
"host.orb.internal",
|
||||
]);
|
||||
const LOOPBACK_OLLAMA_HOSTNAMES = new Set(["localhost", "127.0.0.1", "0.0.0.0", "::1", "::"]);
|
||||
|
||||
function isIpv4Loopback(host: string): boolean {
|
||||
if (!/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
|
||||
return false;
|
||||
}
|
||||
const octets = host.split(".").map((part) => Number.parseInt(part, 10));
|
||||
if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||
return false;
|
||||
}
|
||||
return octets[0] === 127;
|
||||
}
|
||||
|
||||
function isIpv4PrivateRange(host: string): boolean {
|
||||
if (!/^\d+\.\d+\.\d+\.\d+$/.test(host)) {
|
||||
return false;
|
||||
}
|
||||
const octets = host.split(".").map((part) => Number.parseInt(part, 10));
|
||||
if (octets.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) {
|
||||
return false;
|
||||
}
|
||||
const [a, b] = octets;
|
||||
return a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168);
|
||||
}
|
||||
|
||||
function isIpv6LocalRange(host: string): boolean {
|
||||
const lower = host.toLowerCase();
|
||||
return /^fe[89ab][0-9a-f]:/.test(lower) || /^f[cd][0-9a-f]{2}:/.test(lower);
|
||||
}
|
||||
|
||||
export function isLocalOllamaBaseUrl(baseUrl: string | undefined | null): boolean {
|
||||
if (!baseUrl) {
|
||||
return true;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(baseUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
let host = parsed.hostname.toLowerCase();
|
||||
if (host.startsWith("[") && host.endsWith("]")) {
|
||||
host = host.slice(1, -1);
|
||||
}
|
||||
return (
|
||||
LOCAL_OLLAMA_HOSTNAMES.has(host) ||
|
||||
host.endsWith(".local") ||
|
||||
isIpv4PrivateRange(host) ||
|
||||
isIpv6LocalRange(host) ||
|
||||
(!host.includes(".") && !host.includes(":"))
|
||||
);
|
||||
}
|
||||
|
||||
const HOSTED_OLLAMA_CLOUD_HOSTNAMES = new Set(["ollama.com", "api.ollama.com"]);
|
||||
|
||||
export function isHostedOllamaCloud(baseUrl: string | undefined | null): boolean {
|
||||
if (!baseUrl) {
|
||||
return false;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(baseUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
const host = parsed.hostname.toLowerCase();
|
||||
return HOSTED_OLLAMA_CLOUD_HOSTNAMES.has(host) || host.endsWith(".ollama.com");
|
||||
}
|
||||
|
||||
function isLoopbackOllamaBaseUrl(baseUrl: string | undefined | null): boolean {
|
||||
if (!baseUrl) {
|
||||
return true;
|
||||
}
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(baseUrl);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
let host = parsed.hostname.toLowerCase();
|
||||
if (host.startsWith("[") && host.endsWith("]")) {
|
||||
host = host.slice(1, -1);
|
||||
}
|
||||
return LOOPBACK_OLLAMA_HOSTNAMES.has(host) || isIpv4Loopback(host);
|
||||
}
|
||||
|
||||
function hasExplicitRemoteOllamaApiProvider(
|
||||
providers: Record<string, OllamaProviderConfigInput | undefined> | undefined,
|
||||
): boolean {
|
||||
if (!providers) {
|
||||
return false;
|
||||
}
|
||||
for (const [providerId, provider] of Object.entries(providers)) {
|
||||
if (providerId === OLLAMA_PROVIDER_ID || !provider) {
|
||||
continue;
|
||||
}
|
||||
if (normalizeOptionalString(provider.api)?.toLowerCase() !== "ollama") {
|
||||
continue;
|
||||
}
|
||||
const baseUrl = readProviderBaseUrl(provider);
|
||||
if (baseUrl && !isLoopbackOllamaBaseUrl(baseUrl)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function shouldUseSyntheticOllamaAuth(
|
||||
providerConfig: OllamaProviderConfigInput | undefined,
|
||||
): boolean {
|
||||
if (!hasMeaningfulExplicitOllamaConfig(providerConfig)) {
|
||||
return false;
|
||||
}
|
||||
return isLocalOllamaBaseUrl(readProviderBaseUrl(providerConfig));
|
||||
}
|
||||
|
||||
function hasMeaningfulExplicitOllamaConfig(
|
||||
providerConfig: OllamaProviderConfigInput | undefined,
|
||||
): boolean {
|
||||
if (!providerConfig) {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(providerConfig.models) && providerConfig.models.length > 0) {
|
||||
return true;
|
||||
}
|
||||
const baseUrl = readProviderBaseUrl(providerConfig);
|
||||
if (baseUrl) {
|
||||
return resolveOllamaApiBase(baseUrl) !== OLLAMA_DEFAULT_BASE_URL;
|
||||
}
|
||||
if (readStringValue(providerConfig.apiKey)) {
|
||||
return true;
|
||||
}
|
||||
if (providerConfig.auth) {
|
||||
return true;
|
||||
}
|
||||
if (typeof providerConfig.authHeader === "boolean") {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
providerConfig.headers &&
|
||||
typeof providerConfig.headers === "object" &&
|
||||
Object.keys(providerConfig.headers).length > 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (providerConfig.request) {
|
||||
return true;
|
||||
}
|
||||
if (typeof providerConfig.injectNumCtxForOpenAICompat === "boolean") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function resolveOllamaDiscoveryResult(params: {
|
||||
ctx: OllamaDiscoveryContext;
|
||||
pluginConfig: OllamaPluginConfig;
|
||||
buildProvider: (
|
||||
configuredBaseUrl?: string,
|
||||
opts?: { quiet?: boolean },
|
||||
) => Promise<ModelProviderConfig>;
|
||||
}): Promise<{ provider: ModelProviderConfig } | null> {
|
||||
const explicit = params.ctx.config.models?.providers?.ollama;
|
||||
const hasExplicitModels = Array.isArray(explicit?.models) && explicit.models.length > 0;
|
||||
const hasMeaningfulExplicitConfig = hasMeaningfulExplicitOllamaConfig(explicit);
|
||||
const hasRemoteOllamaApiProvider = hasExplicitRemoteOllamaApiProvider(
|
||||
params.ctx.config.models?.providers,
|
||||
);
|
||||
const discoveryEnabled = params.pluginConfig.discovery?.enabled;
|
||||
if (!hasExplicitModels && discoveryEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
// When the base URL points to hosted Ollama Cloud, skip auto-discovery.
|
||||
// Cloud instances are shared tenants where available models are managed
|
||||
// by the provider; only use explicitly configured models.
|
||||
// Remote self-hosted Ollama endpoints still auto-discover as before.
|
||||
const configuredBaseUrl = readProviderBaseUrl(explicit);
|
||||
if (!hasExplicitModels && configuredBaseUrl && isHostedOllamaCloud(configuredBaseUrl)) {
|
||||
return null;
|
||||
}
|
||||
const resolvedOllamaAuth = params.ctx.resolveProviderApiKey(OLLAMA_PROVIDER_ID);
|
||||
const ollamaKey = resolvedOllamaAuth.apiKey;
|
||||
const ollamaDiscoveryKey = resolvedOllamaAuth.discoveryApiKey;
|
||||
const hasOllamaDiscoveryOptIn = typeof ollamaKey === "string" && ollamaKey.trim().length > 0;
|
||||
const hasRealOllamaKey =
|
||||
typeof ollamaKey === "string" &&
|
||||
ollamaKey.trim().length > 0 &&
|
||||
ollamaKey.trim() !== OLLAMA_DEFAULT_API_KEY;
|
||||
const explicitApiKey = readStringValue(explicit?.apiKey);
|
||||
if (hasExplicitModels && explicit) {
|
||||
const discoveredBaseUrl = resolveOllamaApiBase(configuredBaseUrl);
|
||||
const api = explicit.api ?? "ollama";
|
||||
const apiKey = resolveOllamaDiscoveryApiKey({
|
||||
env: params.ctx.env,
|
||||
baseUrl: discoveredBaseUrl,
|
||||
explicitApiKey,
|
||||
resolvedApiKey: ollamaKey,
|
||||
resolvedDiscoveryApiKey: ollamaDiscoveryKey,
|
||||
});
|
||||
return {
|
||||
provider: {
|
||||
...explicit,
|
||||
models: explicit.models ?? [],
|
||||
baseUrl: resolveOllamaRuntimeBaseUrl({ api, configuredBaseUrl, discoveredBaseUrl }),
|
||||
api,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (!hasMeaningfulExplicitConfig && hasRemoteOllamaApiProvider) {
|
||||
return null;
|
||||
}
|
||||
if (!hasOllamaDiscoveryOptIn && !hasMeaningfulExplicitConfig) {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!hasRealOllamaKey &&
|
||||
!hasMeaningfulExplicitConfig &&
|
||||
shouldSkipAmbientOllamaDiscovery(params.ctx.env)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const quiet = !hasRealOllamaKey && !hasMeaningfulExplicitConfig;
|
||||
const provider = await getCachedLiveCatalogValue({
|
||||
keyParts: [
|
||||
OLLAMA_PROVIDER_ID,
|
||||
"models",
|
||||
configuredBaseUrl ?? OLLAMA_DEFAULT_BASE_URL,
|
||||
ollamaKey,
|
||||
quiet,
|
||||
],
|
||||
load: async () =>
|
||||
await params.buildProvider(configuredBaseUrl, {
|
||||
quiet,
|
||||
}),
|
||||
});
|
||||
if (provider.models?.length === 0 && !ollamaKey && !explicit?.apiKey) {
|
||||
return null;
|
||||
}
|
||||
const apiKey = resolveOllamaDiscoveryApiKey({
|
||||
env: params.ctx.env,
|
||||
baseUrl: provider.baseUrl ?? configuredBaseUrl,
|
||||
explicitApiKey,
|
||||
resolvedApiKey: ollamaKey,
|
||||
resolvedDiscoveryApiKey: ollamaDiscoveryKey,
|
||||
});
|
||||
const api = explicit?.api ?? provider.api;
|
||||
return {
|
||||
provider: {
|
||||
...provider,
|
||||
baseUrl: resolveOllamaRuntimeBaseUrl({
|
||||
api,
|
||||
configuredBaseUrl,
|
||||
discoveredBaseUrl: provider.baseUrl,
|
||||
}),
|
||||
api,
|
||||
...(apiKey ? { apiKey } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
745
extensions/ollama/src/embedding-provider.test.ts
Normal file
745
extensions/ollama/src/embedding-provider.test.ts
Normal file
@@ -0,0 +1,745 @@
|
||||
// Ollama tests cover embedding provider plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
|
||||
|
||||
const { fetchConfiguredLocalOriginWithSsrFGuardMock } = vi.hoisted(() => ({
|
||||
fetchConfiguredLocalOriginWithSsrFGuardMock: vi.fn(
|
||||
async ({ init, url }: { init?: RequestInit; url: string }) => ({
|
||||
response: await fetch(url, init),
|
||||
release: async () => {},
|
||||
}),
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: vi.fn(),
|
||||
formatErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
ssrfPolicyFromHttpBaseUrlAllowedOrigin: (baseUrl: string) => {
|
||||
const parsed = new URL(baseUrl);
|
||||
return { allowedOrigins: [parsed.origin] };
|
||||
},
|
||||
}));
|
||||
|
||||
// Import-resolution gating for this private helper is covered in sdk-alias.test.ts.
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime-internal", () => ({
|
||||
fetchConfiguredLocalOriginWithSsrFGuard: fetchConfiguredLocalOriginWithSsrFGuardMock,
|
||||
}));
|
||||
|
||||
let createOllamaEmbeddingProvider: typeof import("./embedding-provider.js").createOllamaEmbeddingProvider;
|
||||
let ollamaMemoryEmbeddingProviderAdapter: typeof import("./memory-embedding-adapter.js").ollamaMemoryEmbeddingProviderAdapter;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createOllamaEmbeddingProvider } = await import("./embedding-provider.js"));
|
||||
({ ollamaMemoryEmbeddingProviderAdapter } = await import("./memory-embedding-adapter.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchConfiguredLocalOriginWithSsrFGuardMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function mockEmbeddingFetch(embedding: number[]) {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ embeddings: [embedding] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
function firstFetchInit(fetchMock: ReturnType<typeof mockEmbeddingFetch>): RequestInit | undefined {
|
||||
const call = fetchMock.mock.calls[0] as unknown[] | undefined;
|
||||
if (!call) {
|
||||
throw new Error("expected embedding fetch call");
|
||||
}
|
||||
return call[1] as RequestInit | undefined;
|
||||
}
|
||||
|
||||
function readEmbeddingRequestBody(init: RequestInit | undefined): { input?: unknown } {
|
||||
if (typeof init?.body !== "string") {
|
||||
throw new Error("expected JSON string request body");
|
||||
}
|
||||
return JSON.parse(init.body) as { input?: unknown };
|
||||
}
|
||||
|
||||
function readFirstEmbeddingInput(fetchMock: ReturnType<typeof mockEmbeddingFetch>): unknown {
|
||||
const init = firstFetchInit(fetchMock);
|
||||
const body = readEmbeddingRequestBody(init);
|
||||
return body.input;
|
||||
}
|
||||
|
||||
function firstGuardedFetchCall(): Record<string, unknown> {
|
||||
const call = fetchConfiguredLocalOriginWithSsrFGuardMock.mock.calls[0]?.[0];
|
||||
if (!call || typeof call !== "object") {
|
||||
throw new Error("expected guarded fetch call");
|
||||
}
|
||||
return call as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function cancelTrackedResponse(
|
||||
text: string,
|
||||
init: ResponseInit,
|
||||
): {
|
||||
response: Response;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
let canceled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, init),
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
function expectEmbeddingFetch(
|
||||
fetchMock: ReturnType<typeof mockEmbeddingFetch>,
|
||||
url: string,
|
||||
params: {
|
||||
model?: string;
|
||||
input?: unknown;
|
||||
headers?: Record<string, string>;
|
||||
} = {},
|
||||
) {
|
||||
expect(fetchMock).toHaveBeenCalledWith(url, {
|
||||
method: "POST",
|
||||
headers: params.headers ?? { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: params.model ?? "nomic-embed-text",
|
||||
input: params.input ?? "hello",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
describe("ollama embedding provider", () => {
|
||||
it("calls /api/embed and returns normalized vectors", async () => {
|
||||
const fetchMock = mockEmbeddingFetch([3, 4]);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "unknown-embedder",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434" },
|
||||
});
|
||||
|
||||
const vector = await provider.embedQuery("hi");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expectEmbeddingFetch(fetchMock, "http://127.0.0.1:11434/api/embed", {
|
||||
model: "unknown-embedder",
|
||||
input: "hi",
|
||||
});
|
||||
expect(vector[0]).toBeCloseTo(0.6, 5);
|
||||
expect(vector[1]).toBeCloseTo(0.8, 5);
|
||||
});
|
||||
|
||||
it("applies outputDimensionality before normalizing vectors", async () => {
|
||||
mockEmbeddingFetch([3, 4, 12]);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "unknown-embedder",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434" },
|
||||
outputDimensionality: 2,
|
||||
});
|
||||
|
||||
const vector = await provider.embedQuery("hi");
|
||||
|
||||
expect(vector).toHaveLength(2);
|
||||
expect(vector[0]).toBeCloseTo(0.6, 5);
|
||||
expect(vector[1]).toBeCloseTo(0.8, 5);
|
||||
});
|
||||
|
||||
it("marks the configured Ollama origin for managed-proxy direct routing", async () => {
|
||||
const fetchMock = mockEmbeddingFetch([1, 0]);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434/v1" },
|
||||
});
|
||||
|
||||
await provider.embedQuery("hello");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(firstGuardedFetchCall()).toMatchObject({
|
||||
url: "http://127.0.0.1:11434/api/embed",
|
||||
policy: { allowedOrigins: ["http://127.0.0.1:11434"] },
|
||||
configuredLocalOriginBaseUrl: "http://127.0.0.1:11434",
|
||||
auditContext: "ollama-memory-embedding",
|
||||
});
|
||||
});
|
||||
|
||||
it("passes cloud Ollama origins through the guarded fetch contract", async () => {
|
||||
const fetchMock = mockEmbeddingFetch([1, 0]);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "https://ollama.com" },
|
||||
});
|
||||
|
||||
await provider.embedQuery("hello");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(firstGuardedFetchCall()).toMatchObject({
|
||||
url: "https://ollama.com/api/embed",
|
||||
policy: { allowedOrigins: ["https://ollama.com"] },
|
||||
configuredLocalOriginBaseUrl: "https://ollama.com",
|
||||
auditContext: "ollama-memory-embedding",
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves configured base URL and headers without sending local marker auth", async () => {
|
||||
const fetchMock = mockEmbeddingFetch([1, 0]);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
apiKey: "ollama-\nlocal\r\n", // pragma: allowlist secret
|
||||
headers: {
|
||||
"X-Provider-Header": "provider",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "",
|
||||
fallback: "none",
|
||||
});
|
||||
|
||||
await provider.embedQuery("hello");
|
||||
|
||||
expectEmbeddingFetch(fetchMock, "http://127.0.0.1:11434/api/embed", {
|
||||
input: "search_query: hello",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Provider-Header": "provider",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("resolves configured baseURL alias", async () => {
|
||||
const fetchMock = mockEmbeddingFetch([1, 0]);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseURL: "http://remote-ollama:11434/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
});
|
||||
|
||||
await provider.embedQuery("hello");
|
||||
|
||||
expectEmbeddingFetch(fetchMock, "http://remote-ollama:11434/api/embed", {
|
||||
model: "nomic-embed-text",
|
||||
input: "search_query: hello",
|
||||
});
|
||||
});
|
||||
|
||||
it("fails fast when memory-search remote apiKey is an unresolved SecretRef", async () => {
|
||||
await expect(
|
||||
createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: {
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
apiKey: { source: "env", provider: "default", id: "OLLAMA_API_KEY" },
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow(/agents\.\*\.memorySearch\.remote\.apiKey: unresolved SecretRef/i);
|
||||
});
|
||||
|
||||
it("falls back to env key when provider apiKey is an unresolved SecretRef", async () => {
|
||||
const fetchMock = mockEmbeddingFetch([1, 0]);
|
||||
vi.stubEnv("OLLAMA_API_KEY", "ollama-env");
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
apiKey: { source: "env", provider: "default", id: "OLLAMA_API_KEY" },
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
});
|
||||
|
||||
await provider.embedQuery("hello");
|
||||
|
||||
expectEmbeddingFetch(fetchMock, "http://127.0.0.1:11434/api/embed", {
|
||||
input: "search_query: hello",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer ollama-env",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("sends batch embeddings in one Ollama request", async () => {
|
||||
const inputs: unknown[] = [];
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
const rawBody = typeof init?.body === "string" ? init.body : "{}";
|
||||
const body = JSON.parse(rawBody) as { input?: unknown };
|
||||
inputs.push(body.input);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
embeddings: [
|
||||
[1, 0],
|
||||
[1, 0],
|
||||
[1, 0],
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434" },
|
||||
});
|
||||
|
||||
await expect(provider.embedBatch(["a", "bb", "ccc"])).resolves.toHaveLength(3);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(inputs).toEqual([["a", "bb", "ccc"]]);
|
||||
expect(firstGuardedFetchCall()).toMatchObject({
|
||||
url: "http://127.0.0.1:11434/api/embed",
|
||||
policy: { allowedOrigins: ["http://127.0.0.1:11434"] },
|
||||
configuredLocalOriginBaseUrl: "http://127.0.0.1:11434",
|
||||
auditContext: "ollama-memory-embedding",
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds embed error bodies without using response.text()", async () => {
|
||||
const tracked = cancelTrackedResponse(`${"ollama embed unavailable ".repeat(1024)}tail`, {
|
||||
status: 503,
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => tracked.response),
|
||||
);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434" },
|
||||
});
|
||||
|
||||
let error: unknown;
|
||||
try {
|
||||
await provider.embedQuery("hello");
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
expect(String(error)).toContain("Ollama embed HTTP 503");
|
||||
expect(String(error)).toContain("ollama embed unavailable");
|
||||
expect(String(error)).not.toContain("tail");
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reports malformed embed JSON with a provider-owned error", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response("{not json", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434" },
|
||||
});
|
||||
|
||||
await expect(provider.embedQuery("hello")).rejects.toThrow(
|
||||
"Ollama embed response: malformed JSON response",
|
||||
);
|
||||
});
|
||||
|
||||
it("bounds successful embed JSON bodies before parsing", async () => {
|
||||
const streamed = createStreamingResponse({
|
||||
chunkCount: 32,
|
||||
chunkSize: 1024 * 1024,
|
||||
text: "x",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => streamed.response),
|
||||
);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434" },
|
||||
});
|
||||
|
||||
await expect(provider.embedQuery("hello")).rejects.toThrow(
|
||||
"Ollama embed response: JSON response exceeds 16777216 bytes",
|
||||
);
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(jsonSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects non-number embedding values instead of zeroing them", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ embeddings: [["0.1", 0.2]] }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434" },
|
||||
});
|
||||
|
||||
await expect(provider.embedQuery("hello")).rejects.toThrow(
|
||||
"Ollama embed response contains a non-number embedding value",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a retrieval query prefix for qwen3 embedding queries", async () => {
|
||||
const fetchMock = mockEmbeddingFetch([1, 0]);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "qwen3-embedding:0.6b",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434" },
|
||||
});
|
||||
|
||||
await provider.embedQuery("怀孕");
|
||||
|
||||
expect(readFirstEmbeddingInput(fetchMock)).toBe(
|
||||
"Instruct: Given a user query, retrieve relevant memory notes and documents\nQuery:怀孕",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the nomic search_query prefix for query embeddings", async () => {
|
||||
const fetchMock = mockEmbeddingFetch([1, 0]);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434" },
|
||||
});
|
||||
|
||||
await provider.embedQuery("What does $& mean?");
|
||||
|
||||
expect(readFirstEmbeddingInput(fetchMock)).toBe("search_query: What does $& mean?");
|
||||
});
|
||||
|
||||
it("uses the mixedbread retrieval prompt for query embeddings", async () => {
|
||||
const fetchMock = mockEmbeddingFetch([1, 0]);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "mxbai-embed-large:latest",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434" },
|
||||
});
|
||||
|
||||
await provider.embedQuery("capital of Australia");
|
||||
|
||||
expect(readFirstEmbeddingInput(fetchMock)).toBe(
|
||||
"Represent this sentence for searching relevant passages: capital of Australia",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps document batch embeddings raw", async () => {
|
||||
const inputs: unknown[] = [];
|
||||
const fetchMock = vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
const body = readEmbeddingRequestBody(init);
|
||||
inputs.push(body.input);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
embeddings: [
|
||||
[1, 0],
|
||||
[1, 0],
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
},
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "qwen3-embedding:0.6b",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434" },
|
||||
});
|
||||
|
||||
await expect(provider.embedBatch(["doc one", "doc two"])).resolves.toHaveLength(2);
|
||||
expect(inputs).toEqual([["doc one", "doc two"]]);
|
||||
});
|
||||
|
||||
it("uses custom Ollama provider config and strips that provider prefix", async () => {
|
||||
const fetchMock = mockEmbeddingFetch([1, 0]);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
"ollama-spark": {
|
||||
baseUrl: "http://spark.local:11434/v1",
|
||||
apiKey: "spark-key",
|
||||
headers: {
|
||||
"X-Custom-Ollama": "spark",
|
||||
},
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
provider: "ollama-spark",
|
||||
model: "ollama-spark/qwen3-embedding:4b",
|
||||
fallback: "none",
|
||||
});
|
||||
|
||||
await provider.embedQuery("hello");
|
||||
|
||||
expect(provider.model).toBe("qwen3-embedding:4b");
|
||||
expectEmbeddingFetch(fetchMock, "http://spark.local:11434/api/embed", {
|
||||
model: "qwen3-embedding:4b",
|
||||
input:
|
||||
"Instruct: Given a user query, retrieve relevant memory notes and documents\nQuery:hello",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-Custom-Ollama": "spark",
|
||||
Authorization: "Bearer spark-key",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not attach pure env OLLAMA_API_KEY to a local host", async () => {
|
||||
const fetchMock = mockEmbeddingFetch([1, 0]);
|
||||
vi.stubEnv("OLLAMA_API_KEY", "ollama-cloud-key");
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434" },
|
||||
});
|
||||
|
||||
await provider.embedQuery("hello");
|
||||
|
||||
const init = firstFetchInit(fetchMock);
|
||||
const headers = init?.headers as Record<string, string> | undefined;
|
||||
expect(headers?.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("attaches pure env OLLAMA_API_KEY to Ollama Cloud", async () => {
|
||||
const fetchMock = mockEmbeddingFetch([1, 0]);
|
||||
vi.stubEnv("OLLAMA_API_KEY", "ollama-cloud-key");
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "https://ollama.com" },
|
||||
});
|
||||
|
||||
await provider.embedQuery("hello");
|
||||
|
||||
expectEmbeddingFetch(fetchMock, "https://ollama.com/api/embed", {
|
||||
input: "search_query: hello",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer ollama-cloud-key",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("does not attach provider apiKey to a different remote embedding host", async () => {
|
||||
const fetchMock = mockEmbeddingFetch([1, 0]);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
apiKey: "provider-host-key",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "https://memory.example.com" },
|
||||
});
|
||||
|
||||
await provider.embedQuery("hello");
|
||||
|
||||
const init = firstFetchInit(fetchMock);
|
||||
const headers = init?.headers as Record<string, string> | undefined;
|
||||
expect(headers?.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("attaches remote apiKey to a remote embedding host", async () => {
|
||||
const fetchMock = mockEmbeddingFetch([1, 0]);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "https://memory.example.com", apiKey: "remote-host-key" },
|
||||
});
|
||||
|
||||
await provider.embedQuery("hello");
|
||||
|
||||
expectEmbeddingFetch(fetchMock, "https://memory.example.com/api/embed", {
|
||||
input: "search_query: hello",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer remote-host-key",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("honors remote local marker as an explicit no-auth opt-out", async () => {
|
||||
const fetchMock = mockEmbeddingFetch([1, 0]);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
apiKey: "provider-host-key",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { apiKey: "ollama-local" }, // pragma: allowlist secret
|
||||
});
|
||||
|
||||
await provider.embedQuery("hello");
|
||||
|
||||
const init = firstFetchInit(fetchMock);
|
||||
const headers = init?.headers as Record<string, string> | undefined;
|
||||
expect(headers?.Authorization).toBeUndefined();
|
||||
});
|
||||
|
||||
it("includes outputDimensionality in the memory embedding cache identity", async () => {
|
||||
const result = await ollamaMemoryEmbeddingProviderAdapter.create({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434" },
|
||||
outputDimensionality: 2,
|
||||
});
|
||||
|
||||
expect(result.runtime?.cacheKeyData).toMatchObject({
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
outputDimensionality: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("marks inline memory batches as local-server timeout work", async () => {
|
||||
const result = await ollamaMemoryEmbeddingProviderAdapter.create({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434" },
|
||||
});
|
||||
|
||||
expect(result.runtime?.inlineBatchTimeoutMs).toBe(600_000);
|
||||
});
|
||||
});
|
||||
408
extensions/ollama/src/embedding-provider.ts
Normal file
408
extensions/ollama/src/embedding-provider.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
// 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 {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
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";
|
||||
|
||||
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 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;
|
||||
};
|
||||
|
||||
export type OllamaEmbeddingClient = {
|
||||
baseUrl: string;
|
||||
headers: Record<string, string>;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
model: string;
|
||||
outputDimensionality?: number;
|
||||
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 direct;
|
||||
}
|
||||
const normalized = normalizeProviderId(providerId);
|
||||
for (const [candidateId, candidate] of Object.entries(providers)) {
|
||||
if (normalizeProviderId(candidateId) === normalized) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return providers.ollama;
|
||||
}
|
||||
|
||||
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;
|
||||
}): OllamaEmbeddingSourceResolution {
|
||||
if (params.configString !== undefined) {
|
||||
if (!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";
|
||||
}
|
||||
if (params.declared) {
|
||||
const envKey = resolveEnvApiKey("ollama")?.apiKey;
|
||||
return envKey && !isNonSecretApiKeyMarker(envKey) ? { apiKey: envKey } : "opt-out";
|
||||
}
|
||||
return "unset";
|
||||
}
|
||||
|
||||
function resolveOllamaEmbeddingResolvedKeys(
|
||||
options: OllamaEmbeddingOptions,
|
||||
providerConfig: ReturnType<typeof resolveConfiguredProvider>,
|
||||
): OllamaEmbeddingResolvedKeys {
|
||||
const remoteValue = options.remote?.apiKey;
|
||||
const remote = resolveSourcedOllamaEmbeddingKey({
|
||||
configString: resolveMemorySecretInputString({
|
||||
value: remoteValue,
|
||||
path: "agents.*.memorySearch.remote.apiKey",
|
||||
}),
|
||||
declared: hasConfiguredSecretInput(remoteValue),
|
||||
});
|
||||
const providerValue = providerConfig?.apiKey;
|
||||
const provider = resolveSourcedOllamaEmbeddingKey({
|
||||
configString: normalizeOptionalSecretInput(providerValue),
|
||||
declared: hasConfiguredSecretInput(providerValue),
|
||||
});
|
||||
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);
|
||||
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;
|
||||
baseUrlOrigin: OllamaEmbeddingBaseUrlOrigin;
|
||||
providerOwnedHost: string;
|
||||
}): string | undefined {
|
||||
if (params.resolved.remote !== "unset") {
|
||||
return typeof params.resolved.remote === "object" ? params.resolved.remote.apiKey : undefined;
|
||||
}
|
||||
const reachesProviderHost =
|
||||
params.baseUrlOrigin === "provider-config" ||
|
||||
params.baseUrlOrigin === "default" ||
|
||||
areOllamaHostsEquivalent(params.baseUrl, params.providerOwnedHost);
|
||||
if (params.resolved.provider !== "unset" && reachesProviderHost) {
|
||||
return typeof params.resolved.provider === "object"
|
||||
? params.resolved.provider.apiKey
|
||||
: undefined;
|
||||
}
|
||||
if (params.resolved.env && isOllamaCloudBaseUrl(params.baseUrl)) {
|
||||
return params.resolved.env;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveOllamaEmbeddingClient(
|
||||
options: OllamaEmbeddingOptions,
|
||||
): OllamaEmbeddingClientConfig {
|
||||
const providerConfig = resolveConfiguredProvider(options);
|
||||
const { baseUrl, origin: baseUrlOrigin } = resolveOllamaEmbeddingBaseUrl({
|
||||
remoteBaseUrl: options.remote?.baseUrl,
|
||||
providerConfig,
|
||||
});
|
||||
const model = normalizeEmbeddingModel(options.model, options.provider);
|
||||
const headerOverrides = Object.assign({}, providerConfig?.headers, options.remote?.headers);
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...headerOverrides,
|
||||
};
|
||||
const apiKey = selectOllamaEmbeddingApiKey({
|
||||
resolved: resolveOllamaEmbeddingResolvedKeys(options, providerConfig),
|
||||
baseUrl,
|
||||
baseUrlOrigin,
|
||||
providerOwnedHost: resolveOllamaApiBase(readProviderBaseUrl(providerConfig)),
|
||||
});
|
||||
if (apiKey) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
return {
|
||||
baseUrl,
|
||||
headers,
|
||||
ssrfPolicy: ssrfPolicyFromHttpBaseUrlAllowedOrigin(baseUrl),
|
||||
model,
|
||||
outputDimensionality: options.outputDimensionality,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createOllamaEmbeddingProvider(
|
||||
options: OllamaEmbeddingOptions,
|
||||
): Promise<{ provider: OllamaEmbeddingProvider; client: OllamaEmbeddingClient }> {
|
||||
const client = resolveOllamaEmbeddingClient(options);
|
||||
const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embed`;
|
||||
|
||||
const embedMany = async (input: string | string[], signal?: AbortSignal): Promise<number[][]> => {
|
||||
const 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);
|
||||
},
|
||||
});
|
||||
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 });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
19
extensions/ollama/src/media-understanding-provider.ts
Normal file
19
extensions/ollama/src/media-understanding-provider.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
// Ollama provider module implements model/runtime integration.
|
||||
import {
|
||||
describeImageWithModel,
|
||||
describeImagesWithModel,
|
||||
type MediaUnderstandingProvider,
|
||||
} from "openclaw/plugin-sdk/media-understanding";
|
||||
import { OLLAMA_PROVIDER_ID } from "./discovery-shared.js";
|
||||
|
||||
// Ollama vision support depends on which models the user has pulled (llava,
|
||||
// qwen2.5vl, llama3.2-vision, …) — there is no single canonical default. We
|
||||
// register the provider so the image tool can route `ollama/<vision-model>`
|
||||
// requests, but leave `defaultModels` and `autoPriority` unset so Ollama
|
||||
// only participates when the user explicitly configures an image model.
|
||||
export const ollamaMediaUnderstandingProvider: MediaUnderstandingProvider = {
|
||||
id: OLLAMA_PROVIDER_ID,
|
||||
capabilities: ["image"],
|
||||
describeImage: describeImageWithModel,
|
||||
describeImages: describeImagesWithModel,
|
||||
};
|
||||
32
extensions/ollama/src/memory-embedding-adapter.ts
Normal file
32
extensions/ollama/src/memory-embedding-adapter.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Ollama plugin module implements memory embedding adapter behavior.
|
||||
import type { MemoryEmbeddingProviderAdapter } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings";
|
||||
import {
|
||||
DEFAULT_OLLAMA_EMBEDDING_MODEL,
|
||||
createOllamaEmbeddingProvider,
|
||||
} from "./embedding-provider.js";
|
||||
|
||||
export const ollamaMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = {
|
||||
id: "ollama",
|
||||
defaultModel: DEFAULT_OLLAMA_EMBEDDING_MODEL,
|
||||
transport: "remote",
|
||||
authProviderId: "ollama",
|
||||
create: async (options) => {
|
||||
const { provider, client } = await createOllamaEmbeddingProvider({
|
||||
...options,
|
||||
provider: "ollama",
|
||||
fallback: "none",
|
||||
});
|
||||
return {
|
||||
provider,
|
||||
runtime: {
|
||||
id: "ollama",
|
||||
inlineBatchTimeoutMs: 10 * 60_000,
|
||||
cacheKeyData: {
|
||||
provider: "ollama",
|
||||
model: client.model,
|
||||
outputDimensionality: client.outputDimensionality,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
6
extensions/ollama/src/model-behavior.ts
Normal file
6
extensions/ollama/src/model-behavior.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Ollama plugin module implements model behavior behavior.
|
||||
import { isOllamaCloudKimiModelRef } from "./sanitizers/kimi-inline-reasoning.js";
|
||||
|
||||
export function shouldWrapOllamaCompatMoonshotThinking(modelId: string): boolean {
|
||||
return isOllamaCloudKimiModelRef(modelId);
|
||||
}
|
||||
26
extensions/ollama/src/model-id.ts
Normal file
26
extensions/ollama/src/model-id.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
// Ollama plugin module implements model id behavior.
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
const OLLAMA_PROVIDER_ID = "ollama";
|
||||
|
||||
function uniqueModelPrefixCandidates(providerId?: string): string[] {
|
||||
const candidates = [providerId, normalizeProviderId(providerId ?? ""), OLLAMA_PROVIDER_ID]
|
||||
.map((candidate) => candidate?.trim())
|
||||
.filter((candidate): candidate is string => Boolean(candidate));
|
||||
return uniqueStrings(candidates);
|
||||
}
|
||||
|
||||
export function normalizeOllamaWireModelId(modelId: string, providerId?: string): string {
|
||||
const trimmed = modelId.trim();
|
||||
if (!trimmed) {
|
||||
return trimmed;
|
||||
}
|
||||
for (const candidate of uniqueModelPrefixCandidates(providerId)) {
|
||||
const prefix = `${candidate}/`;
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return trimmed.slice(prefix.length);
|
||||
}
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
330
extensions/ollama/src/node-inference.test.ts
Normal file
330
extensions/ollama/src/node-inference.test.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
// Ollama node inference tests cover local discovery, chat, and agent tool routing.
|
||||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createOllamaNodeHostCommands,
|
||||
createOllamaNodeInferenceTool,
|
||||
createOllamaNodeInvokePolicy,
|
||||
OLLAMA_CHAT_COMMAND,
|
||||
OLLAMA_MODELS_COMMAND,
|
||||
} from "./node-inference.js";
|
||||
|
||||
async function readBody(request: IncomingMessage): Promise<unknown> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) {
|
||||
chunks.push(Buffer.from(chunk));
|
||||
}
|
||||
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
||||
}
|
||||
|
||||
async function withOllamaServer<T>(
|
||||
run: (
|
||||
baseUrl: string,
|
||||
chatRequests: Record<string, unknown>[],
|
||||
showRequests: string[],
|
||||
) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const chatRequests: Record<string, unknown>[] = [];
|
||||
const showRequests: string[] = [];
|
||||
const handleRequest = async (request: IncomingMessage, response: ServerResponse) => {
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
if (request.url === "/api/tags") {
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
models: [
|
||||
{
|
||||
name: "remote:cloud",
|
||||
size: 1,
|
||||
remote_host: "https://ollama.com",
|
||||
details: {},
|
||||
},
|
||||
{
|
||||
name: "chat:small",
|
||||
size: 500,
|
||||
modified_at: "2026-07-01T00:00:00Z",
|
||||
details: {
|
||||
family: "small",
|
||||
parameter_size: "0.5B",
|
||||
quantization_level: "Q4_K_M",
|
||||
},
|
||||
},
|
||||
{ name: "chat:large", size: 5000, details: { family: "large" } },
|
||||
{ name: "embedding:latest", size: 100, details: { family: "embed" } },
|
||||
{ name: "unknown:latest", size: 50, details: { family: "unknown" } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (request.url === "/api/ps") {
|
||||
response.end(JSON.stringify({ models: [{ name: "chat:large" }] }));
|
||||
return;
|
||||
}
|
||||
if (request.url === "/api/show") {
|
||||
const body = (await readBody(request)) as { name?: string };
|
||||
if (body.name) {
|
||||
showRequests.push(body.name);
|
||||
}
|
||||
if (body.name === "unknown:latest") {
|
||||
response.statusCode = 500;
|
||||
response.end(JSON.stringify({ error: "show failed" }));
|
||||
return;
|
||||
}
|
||||
const embedding = body.name === "embedding:latest";
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
capabilities: embedding ? ["embedding"] : ["completion", "tools"],
|
||||
model_info: embedding ? {} : { "test.context_length": 32768 },
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (request.url === "/api/chat") {
|
||||
const body = (await readBody(request)) as Record<string, unknown>;
|
||||
chatRequests.push(body);
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
model: body.model,
|
||||
message: { content: "local answer" },
|
||||
done_reason:
|
||||
(body.options as { num_predict?: unknown } | undefined)?.num_predict === 1
|
||||
? "length"
|
||||
: "stop",
|
||||
prompt_eval_count: 8,
|
||||
eval_count: 3,
|
||||
load_duration: 2_500_000,
|
||||
total_duration: 12_750_000,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
response.statusCode = 404;
|
||||
response.end(JSON.stringify({ error: "not found" }));
|
||||
};
|
||||
const server = createServer((request: IncomingMessage, response: ServerResponse) => {
|
||||
void handleRequest(request, response);
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("test server did not expose a TCP address");
|
||||
}
|
||||
try {
|
||||
return await run(`http://127.0.0.1:${address.port}`, chatRequests, showRequests);
|
||||
} finally {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) {
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function commandByName(baseUrl: string, command: string) {
|
||||
const entry = createOllamaNodeHostCommands({ baseUrl }).find(
|
||||
(candidate) => candidate.command === command,
|
||||
);
|
||||
if (!entry) {
|
||||
throw new Error(`missing ${command} test command`);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
describe("Ollama node host inference", () => {
|
||||
it("discovers local chat models and ranks loaded models first", async () => {
|
||||
await withOllamaServer(async (baseUrl) => {
|
||||
const result = JSON.parse(await commandByName(baseUrl, OLLAMA_MODELS_COMMAND).handle()) as {
|
||||
provider: string;
|
||||
models: Array<Record<string, unknown>>;
|
||||
};
|
||||
|
||||
expect(result.provider).toBe("ollama");
|
||||
expect(result.models.map((model) => model.name)).toEqual(["chat:large", "chat:small"]);
|
||||
expect(result.models[0]).toMatchObject({ loaded: true, contextWindow: 32768 });
|
||||
expect(result.models[1]).toMatchObject({
|
||||
loaded: false,
|
||||
family: "small",
|
||||
parameterSize: "0.5B",
|
||||
quantization: "Q4_K_M",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("runs bounded chat and returns compact usage", async () => {
|
||||
await withOllamaServer(async (baseUrl, chatRequests, showRequests) => {
|
||||
const result = JSON.parse(
|
||||
await commandByName(baseUrl, OLLAMA_CHAT_COMMAND).handle(
|
||||
JSON.stringify({
|
||||
model: "chat:small",
|
||||
prompt: "Summarize this",
|
||||
system: "Be concise",
|
||||
maxTokens: 64,
|
||||
temperature: 0.2,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
expect(chatRequests).toEqual([
|
||||
{
|
||||
model: "chat:small",
|
||||
messages: [
|
||||
{ role: "system", content: "Be concise" },
|
||||
{ role: "user", content: "Summarize this" },
|
||||
],
|
||||
stream: false,
|
||||
think: false,
|
||||
options: { num_predict: 64, temperature: 0.2 },
|
||||
},
|
||||
]);
|
||||
expect(showRequests).toEqual(["chat:small"]);
|
||||
expect(result).toEqual({
|
||||
provider: "ollama",
|
||||
model: "chat:small",
|
||||
response: "local answer",
|
||||
usage: { promptTokens: 8, completionTokens: 3 },
|
||||
timings: { loadMs: 2.5, totalMs: 12.75 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects remote and non-chat models before inference", async () => {
|
||||
await withOllamaServer(async (baseUrl, chatRequests) => {
|
||||
await expect(
|
||||
commandByName(baseUrl, OLLAMA_CHAT_COMMAND).handle(
|
||||
JSON.stringify({ model: "remote:cloud", prompt: "hello" }),
|
||||
),
|
||||
).rejects.toThrow("is not a local chat model");
|
||||
await expect(
|
||||
commandByName(baseUrl, OLLAMA_CHAT_COMMAND).handle(
|
||||
JSON.stringify({ model: "embedding:latest", prompt: "hello" }),
|
||||
),
|
||||
).rejects.toThrow("is not a local chat model");
|
||||
expect(chatRequests).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a token-limited partial answer", async () => {
|
||||
await withOllamaServer(async (baseUrl) => {
|
||||
await expect(
|
||||
commandByName(baseUrl, OLLAMA_CHAT_COMMAND).handle(
|
||||
JSON.stringify({ model: "chat:small", prompt: "long answer", maxTokens: 1 }),
|
||||
),
|
||||
).rejects.toThrow("reaching maxTokens (1)");
|
||||
});
|
||||
});
|
||||
|
||||
it("registers a desktop and server pass-through policy", async () => {
|
||||
const policy = createOllamaNodeInvokePolicy();
|
||||
const invokeNode = vi.fn(async () => ({ ok: true as const, payload: { ok: true } }));
|
||||
|
||||
expect(policy.commands).toEqual([OLLAMA_MODELS_COMMAND, OLLAMA_CHAT_COMMAND]);
|
||||
expect(policy.defaultPlatforms).toEqual(["macos", "linux", "windows"]);
|
||||
await expect(policy.handle({ invokeNode } as never)).resolves.toEqual({
|
||||
ok: true,
|
||||
payload: { ok: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("node_inference agent tool", () => {
|
||||
it("discovers models through the connected node runtime", async () => {
|
||||
const invoke = vi.fn(async () => ({
|
||||
payload: { provider: "ollama", models: [{ name: "chat:small", loaded: true }] },
|
||||
}));
|
||||
const api = createTestPluginApi({
|
||||
runtime: {
|
||||
nodes: {
|
||||
list: async () => ({
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "node-1",
|
||||
displayName: "Desk",
|
||||
connected: true,
|
||||
commands: [OLLAMA_MODELS_COMMAND, OLLAMA_CHAT_COMMAND],
|
||||
},
|
||||
],
|
||||
}),
|
||||
invoke,
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
const result = await createOllamaNodeInferenceTool(api).execute("call-1", {
|
||||
action: "discover",
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith({
|
||||
nodeId: "node-1",
|
||||
command: OLLAMA_MODELS_COMMAND,
|
||||
params: {},
|
||||
timeoutMs: 90_000,
|
||||
scopes: ["operator.write"],
|
||||
});
|
||||
expect(result.details).toEqual({
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "node-1",
|
||||
displayName: "Desk",
|
||||
ok: true,
|
||||
provider: "ollama",
|
||||
models: [{ name: "chat:small", loaded: true }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("routes a run to the sole capable node", async () => {
|
||||
const invoke = vi.fn(async () => ({
|
||||
payload: { provider: "ollama", model: "chat:small", response: "done" },
|
||||
}));
|
||||
const api = createTestPluginApi({
|
||||
runtime: {
|
||||
nodes: {
|
||||
list: async () => ({
|
||||
nodes: [
|
||||
{
|
||||
nodeId: "node-1",
|
||||
connected: true,
|
||||
commands: [OLLAMA_MODELS_COMMAND, OLLAMA_CHAT_COMMAND],
|
||||
},
|
||||
],
|
||||
}),
|
||||
invoke,
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
const result = await createOllamaNodeInferenceTool(api).execute("call-2", {
|
||||
action: "run",
|
||||
model: "chat:small",
|
||||
prompt: "answer fast",
|
||||
maxTokens: 32,
|
||||
});
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith({
|
||||
nodeId: "node-1",
|
||||
command: OLLAMA_CHAT_COMMAND,
|
||||
params: {
|
||||
model: "chat:small",
|
||||
prompt: "answer fast",
|
||||
maxTokens: 32,
|
||||
timeoutMs: 120_000,
|
||||
},
|
||||
timeoutMs: 130_000,
|
||||
scopes: ["operator.write"],
|
||||
});
|
||||
expect(result.details).toMatchObject({
|
||||
nodeId: "node-1",
|
||||
provider: "ollama",
|
||||
model: "chat:small",
|
||||
response: "done",
|
||||
});
|
||||
});
|
||||
});
|
||||
550
extensions/ollama/src/node-inference.ts
Normal file
550
extensions/ollama/src/node-inference.ts
Normal file
@@ -0,0 +1,550 @@
|
||||
// Ollama node inference exposes local models to agents through paired node hosts.
|
||||
import { jsonResult } from "openclaw/plugin-sdk/channel-actions";
|
||||
import {
|
||||
readFiniteNumberParam,
|
||||
readPositiveIntegerParam,
|
||||
readStringParam,
|
||||
} from "openclaw/plugin-sdk/param-readers";
|
||||
import type {
|
||||
AnyAgentTool,
|
||||
OpenClawPluginApi,
|
||||
OpenClawPluginNodeHostCommand,
|
||||
OpenClawPluginNodeInvokePolicy,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
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 {
|
||||
buildOllamaBaseUrlSsrFPolicy,
|
||||
enrichOllamaModelsWithContext,
|
||||
fetchOllamaModels,
|
||||
resolveOllamaApiBase,
|
||||
} from "./provider-models.js";
|
||||
|
||||
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;
|
||||
|
||||
const DEFAULT_INFERENCE_TIMEOUT_MS = 120_000;
|
||||
const DEFAULT_MAX_TOKENS = 512;
|
||||
const DISCOVERY_TRANSPORT_TIMEOUT_MS = 90_000;
|
||||
const INFERENCE_TRANSPORT_GRACE_MS = 10_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_DISCOVERED_MODELS = 200;
|
||||
const MAX_ERROR_BODY_BYTES = 500;
|
||||
|
||||
type NodeModel = {
|
||||
name: string;
|
||||
size?: number;
|
||||
modifiedAt?: string;
|
||||
family?: string;
|
||||
parameterSize?: string;
|
||||
quantization?: string;
|
||||
contextWindow?: number;
|
||||
capabilities?: string[];
|
||||
loaded: boolean;
|
||||
};
|
||||
|
||||
type OllamaModelsPayload = {
|
||||
provider: "ollama";
|
||||
models: NodeModel[];
|
||||
};
|
||||
|
||||
type OllamaChatPayload = {
|
||||
provider: "ollama";
|
||||
model: string;
|
||||
response: string;
|
||||
usage?: {
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
};
|
||||
timings?: {
|
||||
loadMs?: number;
|
||||
totalMs?: number;
|
||||
};
|
||||
};
|
||||
|
||||
type NodeSummary = Awaited<
|
||||
ReturnType<OpenClawPluginApi["runtime"]["nodes"]["list"]>
|
||||
>["nodes"][number];
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readNodeCommandParams(paramsJSON?: string | null): Record<string, unknown> {
|
||||
if (!paramsJSON) {
|
||||
return {};
|
||||
}
|
||||
const parsed = asRecord(JSON.parse(paramsJSON));
|
||||
if (!parsed) {
|
||||
throw new Error("node inference params must be a JSON object");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error && error.message ? error.message : String(error);
|
||||
}
|
||||
|
||||
function durationMs(value: unknown): number | undefined {
|
||||
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
||||
return undefined;
|
||||
}
|
||||
return Math.round((value / 1_000_000) * 100) / 100;
|
||||
}
|
||||
|
||||
function optionalNumber(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
async function requestOllamaJson<T>(params: {
|
||||
baseUrl: string;
|
||||
path: string;
|
||||
timeoutMs: number;
|
||||
init?: RequestInit;
|
||||
}): Promise<T> {
|
||||
const apiBase = resolveOllamaApiBase(params.baseUrl);
|
||||
let response: Response;
|
||||
let release: (() => Promise<void>) | undefined;
|
||||
try {
|
||||
const guarded = await fetchWithSsrFGuard({
|
||||
url: `${apiBase}${params.path}`,
|
||||
init: {
|
||||
...params.init,
|
||||
signal: AbortSignal.timeout(params.timeoutMs),
|
||||
},
|
||||
policy: buildOllamaBaseUrlSsrFPolicy(apiBase),
|
||||
auditContext: `ollama-node-inference${params.path}`,
|
||||
});
|
||||
response = guarded.response;
|
||||
release = guarded.release;
|
||||
} catch (error) {
|
||||
throw new Error(`Ollama is unavailable at ${apiBase}: ${errorMessage(error)}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const body = (await readResponseTextLimited(response, MAX_ERROR_BODY_BYTES)).trim();
|
||||
let detail = body;
|
||||
try {
|
||||
const parsed = asRecord(JSON.parse(body));
|
||||
detail = typeof parsed?.error === "string" ? parsed.error : body;
|
||||
} catch {
|
||||
// Keep the bounded response text when Ollama returns a non-JSON error.
|
||||
}
|
||||
throw new Error(
|
||||
`Ollama ${params.path} failed (HTTP ${response.status})${detail ? `: ${detail}` : ""}`,
|
||||
);
|
||||
}
|
||||
return await readProviderJsonResponse<T>(response, `ollama-node-inference${params.path}`);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchLoadedModelNames(baseUrl: string): Promise<Set<string>> {
|
||||
try {
|
||||
const data = await requestOllamaJson<{ models?: Array<{ name?: unknown; model?: unknown }> }>({
|
||||
baseUrl,
|
||||
path: "/api/ps",
|
||||
timeoutMs: 5000,
|
||||
});
|
||||
return new Set(
|
||||
(data.models ?? [])
|
||||
.map((model) =>
|
||||
typeof model.name === "string"
|
||||
? model.name.trim()
|
||||
: typeof model.model === "string"
|
||||
? model.model.trim()
|
||||
: "",
|
||||
)
|
||||
.filter(Boolean),
|
||||
);
|
||||
} catch {
|
||||
// Model discovery still works against Ollama versions without /api/ps.
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
export async function discoverOllamaNodeModels(
|
||||
baseUrl = OLLAMA_DEFAULT_BASE_URL,
|
||||
): Promise<OllamaModelsPayload> {
|
||||
const apiBase = resolveOllamaApiBase(baseUrl);
|
||||
const discovered = await fetchOllamaModels(apiBase);
|
||||
if (!discovered.reachable) {
|
||||
throw new Error(`Ollama is not running at ${apiBase}`);
|
||||
}
|
||||
const localModels = discovered.models
|
||||
.filter((model) => !model.remote_host?.trim())
|
||||
.slice(0, MAX_DISCOVERED_MODELS);
|
||||
const [models, loadedNames] = await Promise.all([
|
||||
enrichOllamaModelsWithContext(apiBase, localModels),
|
||||
fetchLoadedModelNames(apiBase),
|
||||
]);
|
||||
const rows = models
|
||||
// Nodes advertise only models Ollama positively identifies as chat-capable.
|
||||
// Failed /api/show probes must not turn embedding models into runnable choices.
|
||||
.filter((model) => model.capabilities?.includes("completion") === true)
|
||||
.map((model): NodeModel => {
|
||||
const details = model.details;
|
||||
const row: NodeModel = {
|
||||
name: model.name,
|
||||
loaded: loadedNames.has(model.name),
|
||||
};
|
||||
if (typeof model.size === "number") {
|
||||
row.size = model.size;
|
||||
}
|
||||
if (typeof model.modified_at === "string") {
|
||||
row.modifiedAt = model.modified_at;
|
||||
}
|
||||
if (details?.family) {
|
||||
row.family = details.family;
|
||||
}
|
||||
if (details?.parameter_size) {
|
||||
row.parameterSize = details.parameter_size;
|
||||
}
|
||||
if (details?.quantization_level) {
|
||||
row.quantization = details.quantization_level;
|
||||
}
|
||||
if (typeof model.contextWindow === "number") {
|
||||
row.contextWindow = model.contextWindow;
|
||||
}
|
||||
if (model.capabilities) {
|
||||
row.capabilities = model.capabilities;
|
||||
}
|
||||
return row;
|
||||
})
|
||||
.toSorted((left, right) => {
|
||||
if (left.loaded !== right.loaded) {
|
||||
return left.loaded ? -1 : 1;
|
||||
}
|
||||
const sizeDelta =
|
||||
(left.size ?? Number.MAX_SAFE_INTEGER) - (right.size ?? Number.MAX_SAFE_INTEGER);
|
||||
return sizeDelta || left.name.localeCompare(right.name);
|
||||
});
|
||||
return { provider: "ollama", models: rows };
|
||||
}
|
||||
|
||||
async function runOllamaNodeChat(params: {
|
||||
baseUrl: string;
|
||||
model: string;
|
||||
prompt: string;
|
||||
system?: string;
|
||||
temperature?: number;
|
||||
maxTokens: number;
|
||||
timeoutMs: number;
|
||||
}): Promise<OllamaChatPayload> {
|
||||
const apiBase = resolveOllamaApiBase(params.baseUrl);
|
||||
const discovered = await fetchOllamaModels(apiBase);
|
||||
const localModel = discovered.models.find(
|
||||
(model) => model.name === params.model && !model.remote_host?.trim(),
|
||||
);
|
||||
const [model] = localModel ? await enrichOllamaModelsWithContext(apiBase, [localModel]) : [];
|
||||
if (!discovered.reachable || model?.capabilities?.includes("completion") !== true) {
|
||||
throw new Error(
|
||||
`Ollama model ${JSON.stringify(params.model)} is not a local chat model; discover models first`,
|
||||
);
|
||||
}
|
||||
const messages = [
|
||||
...(params.system ? [{ role: "system", content: params.system }] : []),
|
||||
{ role: "user", content: params.prompt },
|
||||
];
|
||||
const data = await requestOllamaJson<{
|
||||
model?: unknown;
|
||||
message?: { content?: unknown };
|
||||
done_reason?: unknown;
|
||||
prompt_eval_count?: unknown;
|
||||
eval_count?: unknown;
|
||||
load_duration?: unknown;
|
||||
total_duration?: unknown;
|
||||
}>({
|
||||
baseUrl: params.baseUrl,
|
||||
path: "/api/chat",
|
||||
timeoutMs: params.timeoutMs,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: params.model,
|
||||
messages,
|
||||
stream: false,
|
||||
think: false,
|
||||
options: {
|
||||
num_predict: params.maxTokens,
|
||||
...(params.temperature !== undefined && { temperature: params.temperature }),
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
const response = typeof data.message?.content === "string" ? data.message.content : undefined;
|
||||
if (response === undefined) {
|
||||
throw new Error("Ollama /api/chat response did not contain message.content");
|
||||
}
|
||||
if (data.done_reason === "length") {
|
||||
throw new Error(
|
||||
`Ollama stopped after reaching maxTokens (${params.maxTokens}); retry with a larger maxTokens value`,
|
||||
);
|
||||
}
|
||||
const promptTokens = optionalNumber(data.prompt_eval_count);
|
||||
const completionTokens = optionalNumber(data.eval_count);
|
||||
const loadMs = durationMs(data.load_duration);
|
||||
const totalMs = durationMs(data.total_duration);
|
||||
return {
|
||||
provider: "ollama",
|
||||
model: typeof data.model === "string" && data.model.trim() ? data.model : params.model,
|
||||
response,
|
||||
...(promptTokens !== undefined || completionTokens !== undefined
|
||||
? { usage: { promptTokens, completionTokens } }
|
||||
: {}),
|
||||
...(loadMs !== undefined || totalMs !== undefined ? { timings: { loadMs, totalMs } } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function createOllamaNodeHostCommands(options?: {
|
||||
baseUrl?: string;
|
||||
}): OpenClawPluginNodeHostCommand[] {
|
||||
const baseUrl = options?.baseUrl ?? OLLAMA_DEFAULT_BASE_URL;
|
||||
return [
|
||||
{
|
||||
command: OLLAMA_MODELS_COMMAND,
|
||||
cap: OLLAMA_NODE_INFERENCE_CAPABILITY,
|
||||
handle: async () => JSON.stringify(await discoverOllamaNodeModels(baseUrl)),
|
||||
},
|
||||
{
|
||||
command: OLLAMA_CHAT_COMMAND,
|
||||
cap: OLLAMA_NODE_INFERENCE_CAPABILITY,
|
||||
handle: async (paramsJSON) => {
|
||||
const params = readNodeCommandParams(paramsJSON);
|
||||
const model = readStringParam(params, "model", { required: true });
|
||||
const prompt = readStringParam(params, "prompt", { required: true, trim: false });
|
||||
const system = readStringParam(params, "system", { trim: false });
|
||||
const maxTokens =
|
||||
readPositiveIntegerParam(params, "maxTokens", {
|
||||
max: MAX_TOKENS,
|
||||
message: `maxTokens must be an integer between 1 and ${MAX_TOKENS}`,
|
||||
}) ?? DEFAULT_MAX_TOKENS;
|
||||
const timeoutMs =
|
||||
readPositiveIntegerParam(params, "timeoutMs", {
|
||||
max: MAX_INFERENCE_TIMEOUT_MS,
|
||||
message: `timeoutMs must be an integer between 1 and ${MAX_INFERENCE_TIMEOUT_MS}`,
|
||||
}) ?? DEFAULT_INFERENCE_TIMEOUT_MS;
|
||||
const temperature = readFiniteNumberParam(params, "temperature", {
|
||||
min: 0,
|
||||
max: 2,
|
||||
message: "temperature must be between 0 and 2",
|
||||
});
|
||||
if (prompt.length > MAX_PROMPT_CHARS) {
|
||||
throw new Error(`prompt exceeds ${MAX_PROMPT_CHARS} characters`);
|
||||
}
|
||||
if (system && system.length > MAX_SYSTEM_PROMPT_CHARS) {
|
||||
throw new Error(`system exceeds ${MAX_SYSTEM_PROMPT_CHARS} characters`);
|
||||
}
|
||||
return JSON.stringify(
|
||||
await runOllamaNodeChat({
|
||||
baseUrl,
|
||||
model,
|
||||
prompt,
|
||||
system,
|
||||
temperature,
|
||||
maxTokens,
|
||||
timeoutMs,
|
||||
}),
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function createOllamaNodeInvokePolicy(): OpenClawPluginNodeInvokePolicy {
|
||||
return {
|
||||
commands: [...OLLAMA_NODE_INFERENCE_COMMANDS],
|
||||
defaultPlatforms: ["macos", "linux", "windows"],
|
||||
handle: async (ctx) => await ctx.invokeNode(),
|
||||
};
|
||||
}
|
||||
|
||||
function findNode(nodes: NodeSummary[], query: string): NodeSummary {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
const matches = nodes.filter(
|
||||
(node) =>
|
||||
node.nodeId.toLowerCase() === normalized || node.displayName?.toLowerCase() === normalized,
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
throw new Error(`node ${JSON.stringify(query)} is not connected with Ollama inference support`);
|
||||
}
|
||||
if (matches.length > 1) {
|
||||
throw new Error(`node ${JSON.stringify(query)} is ambiguous; use its nodeId`);
|
||||
}
|
||||
return matches[0];
|
||||
}
|
||||
|
||||
function parseInvokePayload(raw: unknown): Record<string, unknown> {
|
||||
const result = asRecord(raw);
|
||||
let payload = asRecord(result?.payload);
|
||||
if (!payload && typeof result?.payloadJSON === "string") {
|
||||
payload = asRecord(JSON.parse(result.payloadJSON));
|
||||
}
|
||||
if (!payload) {
|
||||
throw new Error("node returned an invalid Ollama inference payload");
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function invokeNode(
|
||||
api: OpenClawPluginApi,
|
||||
nodeId: string,
|
||||
command: string,
|
||||
params: Record<string, unknown>,
|
||||
timeoutMs: number,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const raw = await api.runtime.nodes.invoke({
|
||||
nodeId,
|
||||
command,
|
||||
params,
|
||||
timeoutMs,
|
||||
scopes: ["operator.write"],
|
||||
});
|
||||
return parseInvokePayload(raw);
|
||||
}
|
||||
|
||||
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: Type.Union([Type.Literal("discover"), Type.Literal("run")]),
|
||||
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,
|
||||
execute: async (_toolCallId, args) => {
|
||||
const params = asRecord(args) ?? {};
|
||||
const action = readStringParam(params, "action", { required: true });
|
||||
const nodeQuery = readStringParam(params, "node");
|
||||
const listed = await api.runtime.nodes.list({ connected: true });
|
||||
const modelNodes = listed.nodes.filter((node) =>
|
||||
node.commands?.includes(OLLAMA_MODELS_COMMAND),
|
||||
);
|
||||
|
||||
if (action === "discover") {
|
||||
const targets = nodeQuery ? [findNode(modelNodes, nodeQuery)] : modelNodes;
|
||||
const nodes = await Promise.all(
|
||||
targets.map(async (node) => {
|
||||
try {
|
||||
const payload = await invokeNode(
|
||||
api,
|
||||
node.nodeId,
|
||||
OLLAMA_MODELS_COMMAND,
|
||||
{},
|
||||
DISCOVERY_TRANSPORT_TIMEOUT_MS,
|
||||
);
|
||||
const result: Record<string, unknown> = { nodeId: node.nodeId, ok: true };
|
||||
if (node.displayName) {
|
||||
result.displayName = node.displayName;
|
||||
}
|
||||
return Object.assign(result, payload);
|
||||
} catch (error) {
|
||||
const result: Record<string, unknown> = {
|
||||
nodeId: node.nodeId,
|
||||
ok: false,
|
||||
error: errorMessage(error),
|
||||
};
|
||||
if (node.displayName) {
|
||||
result.displayName = node.displayName;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return jsonResult({
|
||||
nodes,
|
||||
...(modelNodes.length === 0 && {
|
||||
hint: "No connected node advertises Ollama inference. Start Ollama and `openclaw node run` on the target machine, then approve any request shown by `openclaw nodes pending`.",
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
if (action !== "run") {
|
||||
throw new Error("action must be discover or run");
|
||||
}
|
||||
const chatNodes = modelNodes.filter((node) => node.commands?.includes(OLLAMA_CHAT_COMMAND));
|
||||
const node = nodeQuery
|
||||
? findNode(chatNodes, nodeQuery)
|
||||
: chatNodes.length === 1
|
||||
? chatNodes[0]
|
||||
: undefined;
|
||||
if (!node) {
|
||||
throw new Error(
|
||||
chatNodes.length === 0
|
||||
? "no connected node advertises Ollama inference"
|
||||
: "multiple nodes advertise Ollama inference; specify node",
|
||||
);
|
||||
}
|
||||
const model = readStringParam(params, "model", { required: true });
|
||||
const prompt = readStringParam(params, "prompt", { required: true, trim: false });
|
||||
const maxTokens =
|
||||
readPositiveIntegerParam(params, "maxTokens", { max: MAX_TOKENS }) ?? DEFAULT_MAX_TOKENS;
|
||||
const timeoutMs =
|
||||
readPositiveIntegerParam(params, "timeoutMs", { max: MAX_INFERENCE_TIMEOUT_MS }) ??
|
||||
DEFAULT_INFERENCE_TIMEOUT_MS;
|
||||
const system = readStringParam(params, "system", { trim: false });
|
||||
const temperature = readFiniteNumberParam(params, "temperature", { min: 0, max: 2 });
|
||||
const commandParams: Record<string, unknown> = {
|
||||
model,
|
||||
prompt,
|
||||
maxTokens,
|
||||
timeoutMs,
|
||||
};
|
||||
if (system !== undefined) {
|
||||
commandParams.system = system;
|
||||
}
|
||||
if (temperature !== undefined) {
|
||||
commandParams.temperature = temperature;
|
||||
}
|
||||
const result = await invokeNode(
|
||||
api,
|
||||
node.nodeId,
|
||||
OLLAMA_CHAT_COMMAND,
|
||||
commandParams,
|
||||
// The command validates the selected model before starting its chat timeout.
|
||||
// Keep that bounded preflight outside the inference budget seen by users.
|
||||
timeoutMs + INFERENCE_TRANSPORT_GRACE_MS,
|
||||
);
|
||||
return jsonResult({
|
||||
nodeId: node.nodeId,
|
||||
...(node.displayName && { displayName: node.displayName }),
|
||||
...result,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
5
extensions/ollama/src/ollama-json.ts
Normal file
5
extensions/ollama/src/ollama-json.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Ollama plugin module implements ollama json behavior.
|
||||
export {
|
||||
parseJsonObjectPreservingUnsafeIntegers,
|
||||
parseJsonPreservingUnsafeIntegers,
|
||||
} from "openclaw/plugin-sdk/json-unsafe-integers";
|
||||
45
extensions/ollama/src/provider-base-url.test.ts
Normal file
45
extensions/ollama/src/provider-base-url.test.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Ollama tests cover provider base url plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readProviderBaseUrl } from "./provider-base-url.js";
|
||||
|
||||
describe("readProviderBaseUrl", () => {
|
||||
it("reads canonical baseUrl and trims whitespace", () => {
|
||||
expect(readProviderBaseUrl({ baseUrl: " http://host:11434/v1 ", models: [] })).toBe(
|
||||
"http://host:11434/v1",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to OpenAI SDK-style baseURL", () => {
|
||||
const provider = {
|
||||
baseURL: " http://remote-ollama:11434 ",
|
||||
models: [],
|
||||
} as unknown as Parameters<typeof readProviderBaseUrl>[0];
|
||||
|
||||
expect(readProviderBaseUrl(provider)).toBe("http://remote-ollama:11434");
|
||||
});
|
||||
|
||||
it("prefers canonical baseUrl over baseURL", () => {
|
||||
const provider = {
|
||||
baseUrl: "http://canonical:11434",
|
||||
baseURL: "http://alternate:11434",
|
||||
models: [],
|
||||
} as unknown as Parameters<typeof readProviderBaseUrl>[0];
|
||||
|
||||
expect(readProviderBaseUrl(provider)).toBe("http://canonical:11434");
|
||||
});
|
||||
|
||||
it("ignores inherited baseUrl aliases", () => {
|
||||
const provider = { models: [] } as unknown as Parameters<typeof readProviderBaseUrl>[0];
|
||||
Object.setPrototypeOf(provider, { baseUrl: "http://inherited:11434" });
|
||||
|
||||
expect(readProviderBaseUrl(provider)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for empty or missing values", () => {
|
||||
expect(readProviderBaseUrl(undefined)).toBeUndefined();
|
||||
expect(
|
||||
readProviderBaseUrl({ models: [] } as unknown as Parameters<typeof readProviderBaseUrl>[0]),
|
||||
).toBeUndefined();
|
||||
expect(readProviderBaseUrl({ baseUrl: " ", models: [] })).toBeUndefined();
|
||||
});
|
||||
});
|
||||
37
extensions/ollama/src/provider-base-url.ts
Normal file
37
extensions/ollama/src/provider-base-url.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Ollama provider module implements model/runtime integration.
|
||||
import type {
|
||||
ModelProviderConfig,
|
||||
ModelDefinitionConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
|
||||
/**
|
||||
* Provider config input type — partial config without required `models`.
|
||||
* Replaces the deprecated `openclaw/plugin-sdk/config-types` import.
|
||||
*/
|
||||
type OllamaProviderConfigInput = Omit<Partial<ModelProviderConfig>, "models"> & {
|
||||
models?: ModelDefinitionConfig[];
|
||||
};
|
||||
|
||||
export function readProviderBaseUrl(
|
||||
provider: OllamaProviderConfigInput | undefined,
|
||||
): string | undefined {
|
||||
if (!provider) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
Object.hasOwn(provider, "baseUrl") &&
|
||||
typeof provider.baseUrl === "string" &&
|
||||
provider.baseUrl.trim()
|
||||
) {
|
||||
return provider.baseUrl.trim();
|
||||
}
|
||||
const alternate = provider as OllamaProviderConfigInput & { baseURL?: unknown };
|
||||
if (
|
||||
Object.hasOwn(alternate, "baseURL") &&
|
||||
typeof alternate.baseURL === "string" &&
|
||||
alternate.baseURL.trim()
|
||||
) {
|
||||
return alternate.baseURL.trim();
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
42
extensions/ollama/src/provider-models.ssrf.test.ts
Normal file
42
extensions/ollama/src/provider-models.ssrf.test.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Ollama tests cover provider models.ssrf plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildOllamaBaseUrlSsrFPolicy } from "./provider-models.js";
|
||||
|
||||
describe("buildOllamaBaseUrlSsrFPolicy", () => {
|
||||
it("pins requests to the configured Ollama hostname for HTTP(S) URLs", () => {
|
||||
expect(buildOllamaBaseUrlSsrFPolicy("http://127.0.0.1:11434")).toEqual({
|
||||
hostnameAllowlist: ["127.0.0.1"],
|
||||
allowPrivateNetwork: true,
|
||||
});
|
||||
expect(buildOllamaBaseUrlSsrFPolicy("http://192.168.1.10:11434")).toEqual({
|
||||
hostnameAllowlist: ["192.168.1.10"],
|
||||
allowPrivateNetwork: true,
|
||||
});
|
||||
expect(buildOllamaBaseUrlSsrFPolicy("https://ollama.example.com/v1")).toEqual({
|
||||
hostnameAllowlist: ["ollama.example.com"],
|
||||
allowPrivateNetwork: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("opts into private-network access for explicit Ollama hosts", () => {
|
||||
expect(buildOllamaBaseUrlSsrFPolicy("http://localhost:11434")).toEqual({
|
||||
hostnameAllowlist: ["localhost"],
|
||||
allowPrivateNetwork: true,
|
||||
});
|
||||
expect(buildOllamaBaseUrlSsrFPolicy("http://[fd00::1]:11434")).toEqual({
|
||||
hostnameAllowlist: ["[fd00::1]"],
|
||||
allowPrivateNetwork: true,
|
||||
});
|
||||
expect(buildOllamaBaseUrlSsrFPolicy("https://ollama.local:11434")).toEqual({
|
||||
hostnameAllowlist: ["ollama.local"],
|
||||
allowPrivateNetwork: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns no allowlist for empty or invalid base URLs", () => {
|
||||
expect(buildOllamaBaseUrlSsrFPolicy("")).toBeUndefined();
|
||||
expect(buildOllamaBaseUrlSsrFPolicy("ftp://ollama.example.com")).toBeUndefined();
|
||||
expect(buildOllamaBaseUrlSsrFPolicy("not-a-url")).toBeUndefined();
|
||||
expect(buildOllamaBaseUrlSsrFPolicy("http://metadata.google.internal")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
438
extensions/ollama/src/provider-models.test.ts
Normal file
438
extensions/ollama/src/provider-models.test.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
// Ollama tests cover provider models plugin behavior.
|
||||
import { jsonResponse, requestBodyText, requestUrl } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildOllamaProvider,
|
||||
buildOllamaModelDefinition,
|
||||
enrichOllamaModelsWithContext,
|
||||
fetchOllamaModels,
|
||||
parseOllamaNumCtxParameter,
|
||||
queryOllamaModelShowInfo,
|
||||
resetOllamaModelShowInfoCacheForTest,
|
||||
resolveOllamaApiBase,
|
||||
type OllamaTagModel,
|
||||
} from "./provider-models.js";
|
||||
|
||||
describe("ollama provider models", () => {
|
||||
afterEach(() => {
|
||||
resetOllamaModelShowInfoCacheForTest();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("strips /v1 when resolving the Ollama API base", () => {
|
||||
expect(resolveOllamaApiBase("http://127.0.0.1:11434/v1")).toBe("http://127.0.0.1:11434");
|
||||
expect(resolveOllamaApiBase("http://127.0.0.1:11434///")).toBe("http://127.0.0.1:11434");
|
||||
});
|
||||
|
||||
it("sets discovered models with context windows from /api/show", async () => {
|
||||
const models: OllamaTagModel[] = [{ name: "llama3:8b" }, { name: "deepseek-r1:14b" }];
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = requestUrl(input);
|
||||
if (!url.endsWith("/api/show")) {
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}
|
||||
const body = JSON.parse(requestBodyText(init?.body)) as { name?: string };
|
||||
if (body.name === "llama3:8b") {
|
||||
return jsonResponse({ model_info: { "llama.context_length": 65536 } });
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const enriched = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", models);
|
||||
|
||||
expect(enriched).toEqual([
|
||||
{ name: "llama3:8b", contextWindow: 65536, capabilities: undefined },
|
||||
{ name: "deepseek-r1:14b", contextWindow: undefined, capabilities: undefined },
|
||||
]);
|
||||
expect(
|
||||
buildOllamaModelDefinition(
|
||||
enriched[1].name,
|
||||
enriched[1].contextWindow,
|
||||
enriched[1].capabilities,
|
||||
).compat?.supportsTools,
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("forwards remote auth to model listing and show probes", async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
expect(new Headers(init?.headers).get("Authorization")).toBe("Bearer cloud-key");
|
||||
const url = requestUrl(input);
|
||||
if (url.endsWith("/api/tags")) {
|
||||
return jsonResponse({ models: [{ name: "glm-5.2:cloud" }] });
|
||||
}
|
||||
if (url.endsWith("/api/show")) {
|
||||
return jsonResponse({
|
||||
model_info: { "glm5.2.context_length": 1_000_000 },
|
||||
capabilities: ["completion", "thinking", "tools"],
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const provider = await buildOllamaProvider("https://ollama.com", {
|
||||
apiKey: "cloud-key",
|
||||
});
|
||||
|
||||
expect(provider.models).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "glm-5.2:cloud",
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 8_192,
|
||||
reasoning: true,
|
||||
}),
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("scopes cached show metadata by credential", async () => {
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = requestUrl(input);
|
||||
if (url.endsWith("/api/tags")) {
|
||||
return jsonResponse({ models: [{ name: "private-model", digest: "stable" }] });
|
||||
}
|
||||
const apiKey = new Headers(init?.headers).get("Authorization");
|
||||
return jsonResponse({
|
||||
model_info: {
|
||||
"private.context_length": apiKey === "Bearer account-a" ? 16_000 : 32_000,
|
||||
},
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const first = await buildOllamaProvider("https://ollama.example.com", {
|
||||
apiKey: "account-a",
|
||||
});
|
||||
const second = await buildOllamaProvider("https://ollama.example.com", {
|
||||
apiKey: "account-b",
|
||||
});
|
||||
|
||||
expect(first.models?.[0]?.contextWindow).toBe(16_000);
|
||||
expect(second.models?.[0]?.contextWindow).toBe(32_000);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("recognizes the static Ollama Cloud GLM-5.2 model as reasoning-capable", () => {
|
||||
expect(buildOllamaModelDefinition("glm-5.2:cloud")).toEqual(
|
||||
expect.objectContaining({
|
||||
reasoning: true,
|
||||
contextWindow: 1_000_000,
|
||||
maxTokens: 8192,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Modelfile num_ctx when it expands the discovered context window", async () => {
|
||||
const models: OllamaTagModel[] = [{ name: "llama3-32k:latest" }];
|
||||
const fetchMock = vi.fn(async () =>
|
||||
jsonResponse({
|
||||
model_info: { "llama.context_length": 8192 },
|
||||
parameters: 'stop "<|eot_id|>"\nnum_ctx 32768\nnum_keep 5',
|
||||
capabilities: ["completion"],
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const enriched = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", models);
|
||||
|
||||
expect(enriched).toEqual([
|
||||
{
|
||||
name: "llama3-32k:latest",
|
||||
contextWindow: 32768,
|
||||
capabilities: ["completion"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the larger native context window when Modelfile num_ctx is smaller", async () => {
|
||||
const models: OllamaTagModel[] = [{ name: "llama3.2:latest" }];
|
||||
const fetchMock = vi.fn(async () =>
|
||||
jsonResponse({
|
||||
model_info: { "llama.context_length": 131072 },
|
||||
parameters: "num_ctx 4096",
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const enriched = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", models);
|
||||
|
||||
expect(enriched[0]?.contextWindow).toBe(131072);
|
||||
});
|
||||
|
||||
it("uses positive num_ctx when /api/show omits model context metadata", async () => {
|
||||
const models: OllamaTagModel[] = [{ name: "custom-model:latest" }];
|
||||
const fetchMock = vi.fn(async () =>
|
||||
jsonResponse({
|
||||
model_info: {},
|
||||
parameters: "num_ctx 16384",
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const enriched = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", models);
|
||||
|
||||
expect(enriched[0]?.contextWindow).toBe(16384);
|
||||
});
|
||||
|
||||
it("sets models with vision capability from /api/show capabilities", async () => {
|
||||
const models: OllamaTagModel[] = [{ name: "kimi-k2.5:cloud" }, { name: "glm-5.1:cloud" }];
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = requestUrl(input);
|
||||
if (!url.endsWith("/api/show")) {
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
}
|
||||
const body = JSON.parse(requestBodyText(init?.body)) as { name?: string };
|
||||
if (body.name === "kimi-k2.5:cloud") {
|
||||
return jsonResponse({
|
||||
model_info: { "kimi-k2.context_length": 262144 },
|
||||
capabilities: ["vision", "thinking", "completion", "tools"],
|
||||
});
|
||||
}
|
||||
if (body.name === "glm-5.1:cloud") {
|
||||
return jsonResponse({
|
||||
model_info: { "glm5.context_length": 202752 },
|
||||
capabilities: ["thinking", "completion", "tools"],
|
||||
});
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const enriched = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", models);
|
||||
|
||||
expect(enriched).toEqual([
|
||||
{
|
||||
name: "kimi-k2.5:cloud",
|
||||
contextWindow: 262144,
|
||||
capabilities: ["vision", "thinking", "completion", "tools"],
|
||||
},
|
||||
{
|
||||
name: "glm-5.1:cloud",
|
||||
contextWindow: 202752,
|
||||
capabilities: ["thinking", "completion", "tools"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reuses cached /api/show metadata when the model digest is unchanged", async () => {
|
||||
const models: OllamaTagModel[] = [
|
||||
{ name: "qwen3:32b", digest: "sha256:abc123", modified_at: "2026-04-11T00:00:00Z" },
|
||||
];
|
||||
const fetchMock = vi.fn(async () =>
|
||||
jsonResponse({
|
||||
model_info: { "qwen3.context_length": 131072 },
|
||||
capabilities: ["thinking", "tools"],
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const first = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", models);
|
||||
const second = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", models);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("refreshes cached /api/show metadata when the model digest changes", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
model_info: { "qwen3.context_length": 131072 },
|
||||
capabilities: ["thinking", "tools"],
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
model_info: { "qwen3.context_length": 262144 },
|
||||
capabilities: ["vision", "thinking", "tools"],
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const first = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", [
|
||||
{ name: "qwen3:32b", digest: "sha256:abc123" },
|
||||
]);
|
||||
const second = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", [
|
||||
{ name: "qwen3:32b", digest: "sha256:def456" },
|
||||
]);
|
||||
|
||||
expect(first).toEqual([
|
||||
{
|
||||
name: "qwen3:32b",
|
||||
digest: "sha256:abc123",
|
||||
contextWindow: 131072,
|
||||
capabilities: ["thinking", "tools"],
|
||||
},
|
||||
]);
|
||||
expect(second).toEqual([
|
||||
{
|
||||
name: "qwen3:32b",
|
||||
digest: "sha256:def456",
|
||||
contextWindow: 262144,
|
||||
capabilities: ["vision", "thinking", "tools"],
|
||||
},
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("retries /api/show after an empty result for the same digest", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(jsonResponse({}))
|
||||
.mockResolvedValueOnce(
|
||||
jsonResponse({
|
||||
model_info: { "qwen3.context_length": 131072 },
|
||||
capabilities: ["thinking", "tools"],
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const model: OllamaTagModel = { name: "qwen3:32b", digest: "sha256:abc123" };
|
||||
const first = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", [model]);
|
||||
const second = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", [model]);
|
||||
|
||||
expect(first).toEqual([
|
||||
{
|
||||
name: "qwen3:32b",
|
||||
digest: "sha256:abc123",
|
||||
contextWindow: undefined,
|
||||
capabilities: undefined,
|
||||
},
|
||||
]);
|
||||
expect(second).toEqual([
|
||||
{
|
||||
name: "qwen3:32b",
|
||||
digest: "sha256:abc123",
|
||||
contextWindow: 131072,
|
||||
capabilities: ["thinking", "tools"],
|
||||
},
|
||||
]);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("normalizes /v1 base URLs before fetching and reuses the same cache entry", async () => {
|
||||
const model: OllamaTagModel = { name: "qwen3:32b", digest: "sha256:abc123" };
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
expect(requestUrl(input)).toBe("http://127.0.0.1:11434/api/show");
|
||||
expect(JSON.parse(requestBodyText(init?.body))).toEqual({ name: "qwen3:32b" });
|
||||
return jsonResponse({
|
||||
model_info: { "qwen3.context_length": 131072 },
|
||||
capabilities: ["thinking", "tools"],
|
||||
});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const first = await enrichOllamaModelsWithContext("http://127.0.0.1:11434/v1/", [model]);
|
||||
const second = await enrichOllamaModelsWithContext("http://127.0.0.1:11434", [model]);
|
||||
|
||||
expect(first).toEqual(second);
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("buildOllamaModelDefinition sets input to text+image when vision capability is present", () => {
|
||||
const visionModel = buildOllamaModelDefinition("kimi-k2.5:cloud", 262144, [
|
||||
"vision",
|
||||
"completion",
|
||||
"tools",
|
||||
"thinking",
|
||||
]);
|
||||
expect(visionModel.input).toEqual(["text", "image"]);
|
||||
expect(visionModel.reasoning).toBe(true);
|
||||
expect(visionModel.compat?.supportsTools).toBe(true);
|
||||
expect(visionModel.compat?.supportsUsageInStreaming).toBe(true);
|
||||
|
||||
const textModel = buildOllamaModelDefinition("glm-5.1:cloud", 202752, ["completion", "tools"]);
|
||||
expect(textModel.input).toEqual(["text"]);
|
||||
expect(textModel.reasoning).toBe(false);
|
||||
expect(textModel.compat?.supportsTools).toBe(true);
|
||||
expect(textModel.compat?.supportsUsageInStreaming).toBe(true);
|
||||
|
||||
const deepseekCloudModel = buildOllamaModelDefinition("deepseek-v4-pro:cloud", 1048576, [
|
||||
"completion",
|
||||
"tools",
|
||||
]);
|
||||
expect(deepseekCloudModel.reasoning).toBe(true);
|
||||
expect(deepseekCloudModel.compat?.supportsTools).toBe(true);
|
||||
|
||||
const deepseekCloudModelWithoutCapabilities = buildOllamaModelDefinition(
|
||||
"deepseek-v4-flash:cloud",
|
||||
1048576,
|
||||
);
|
||||
expect(deepseekCloudModelWithoutCapabilities.reasoning).toBe(true);
|
||||
|
||||
const noCapabilities = buildOllamaModelDefinition("unknown-model", 65536);
|
||||
expect(noCapabilities.input).toEqual(["text"]);
|
||||
expect(noCapabilities.compat?.supportsTools).toBe(true);
|
||||
expect(noCapabilities.compat?.supportsUsageInStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it("disables tool support when Ollama capabilities omit tools", () => {
|
||||
const model = buildOllamaModelDefinition("embeddinggemma:latest", 2048, ["embedding"]);
|
||||
|
||||
expect(model.reasoning).toBe(false);
|
||||
expect(model.compat?.supportsTools).toBe(false);
|
||||
expect(model.compat?.supportsUsageInStreaming).toBe(true);
|
||||
});
|
||||
|
||||
it("parses the last positive Modelfile num_ctx value", () => {
|
||||
expect(parseOllamaNumCtxParameter("num_ctx 8192\nnum_ctx 32768")).toBe(32768);
|
||||
expect(parseOllamaNumCtxParameter("temperature 0.8\nnum_ctx -1\nnum_ctx 0")).toBeUndefined();
|
||||
expect(parseOllamaNumCtxParameter('stop "<|eot_id|>"')).toBeUndefined();
|
||||
expect(parseOllamaNumCtxParameter({ num_ctx: 8192 })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails soft and stops reading when discovery streams exceed the JSON byte cap", async () => {
|
||||
// Larger than the shared 16 MiB readProviderJsonResponse cap so the bounded reader cancels
|
||||
// the stream mid-flight; if the cap were removed the reader would buffer the whole payload.
|
||||
const ONE_MIB = 1024 * 1024;
|
||||
const TOTAL_CHUNKS = 32; // 32 MiB advertised body, double the cap.
|
||||
const chunk = new Uint8Array(ONE_MIB);
|
||||
|
||||
let bytesPulled = 0;
|
||||
let canceled = false;
|
||||
const makeOversizedJsonResponse = (): Response => {
|
||||
bytesPulled = 0;
|
||||
canceled = false;
|
||||
let pulled = 0;
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (pulled >= TOTAL_CHUNKS) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
pulled += 1;
|
||||
bytesPulled += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return new Response(body, {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
});
|
||||
};
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => makeOversizedJsonResponse()),
|
||||
);
|
||||
const tags = await fetchOllamaModels("http://127.0.0.1:11434");
|
||||
expect(tags).toEqual({ reachable: false, models: [] });
|
||||
expect(canceled).toBe(true);
|
||||
// Only the bounded prefix is pulled, never the full advertised 32 MiB stream.
|
||||
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
|
||||
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => makeOversizedJsonResponse()),
|
||||
);
|
||||
const showInfo = await queryOllamaModelShowInfo("http://127.0.0.1:11434", "evil-model:latest");
|
||||
expect(showInfo).toEqual({});
|
||||
expect(canceled).toBe(true);
|
||||
expect(bytesPulled).toBeLessThan(TOTAL_CHUNKS * ONE_MIB);
|
||||
});
|
||||
});
|
||||
359
extensions/ollama/src/provider-models.ts
Normal file
359
extensions/ollama/src/provider-models.ts
Normal file
@@ -0,0 +1,359 @@
|
||||
// Ollama provider module implements model/runtime integration.
|
||||
import { createHash } from "node:crypto";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
OLLAMA_DEFAULT_BASE_URL,
|
||||
OLLAMA_DEFAULT_CONTEXT_WINDOW,
|
||||
OLLAMA_DEFAULT_COST,
|
||||
OLLAMA_DEFAULT_MAX_TOKENS,
|
||||
OLLAMA_GLM52_CLOUD_MODEL_ID,
|
||||
OLLAMA_GLM52_CONTEXT_WINDOW,
|
||||
} from "./defaults.js";
|
||||
|
||||
export type OllamaTagModel = {
|
||||
name: string;
|
||||
modified_at?: string;
|
||||
size?: number;
|
||||
digest?: string;
|
||||
remote_host?: string;
|
||||
details?: {
|
||||
family?: string;
|
||||
parameter_size?: string;
|
||||
quantization_level?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type OllamaTagsResponse = {
|
||||
models?: OllamaTagModel[];
|
||||
};
|
||||
|
||||
export type OllamaModelWithContext = OllamaTagModel & {
|
||||
contextWindow?: number;
|
||||
capabilities?: string[];
|
||||
};
|
||||
|
||||
const OLLAMA_SHOW_CONCURRENCY = 8;
|
||||
const OLLAMA_CONTEXT_ENRICH_LIMIT = 200;
|
||||
const MAX_OLLAMA_SHOW_CACHE_ENTRIES = 256;
|
||||
const ollamaModelShowInfoCache = new Map<string, Promise<OllamaModelShowInfo>>();
|
||||
const OLLAMA_ALWAYS_BLOCKED_HOSTNAMES = new Set(["metadata.google.internal"]);
|
||||
|
||||
export function buildOllamaBaseUrlSsrFPolicy(baseUrl: string) {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
if (OLLAMA_ALWAYS_BLOCKED_HOSTNAMES.has(parsed.hostname)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
hostnameAllowlist: [parsed.hostname],
|
||||
allowPrivateNetwork: true,
|
||||
};
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOllamaApiBase(configuredBaseUrl?: string): string {
|
||||
if (!configuredBaseUrl) {
|
||||
return OLLAMA_DEFAULT_BASE_URL;
|
||||
}
|
||||
const trimmed = configuredBaseUrl.replace(/\/+$/, "");
|
||||
return trimmed.replace(/\/v1$/i, "");
|
||||
}
|
||||
|
||||
export type OllamaModelShowInfo = {
|
||||
contextWindow?: number;
|
||||
capabilities?: string[];
|
||||
};
|
||||
|
||||
function buildOllamaModelShowCacheKey(
|
||||
apiBase: string,
|
||||
model: Pick<OllamaTagModel, "name" | "digest" | "modified_at">,
|
||||
apiKey?: string,
|
||||
): string | undefined {
|
||||
const version = model.digest?.trim() || model.modified_at?.trim();
|
||||
if (!version) {
|
||||
return undefined;
|
||||
}
|
||||
const authScope = apiKey ? createHash("sha256").update(apiKey).digest("hex") : "anonymous";
|
||||
return `${resolveOllamaApiBase(apiBase)}|${model.name}|${version}|${authScope}`;
|
||||
}
|
||||
|
||||
function setOllamaModelShowCacheEntry(key: string, value: Promise<OllamaModelShowInfo>): void {
|
||||
if (ollamaModelShowInfoCache.size >= MAX_OLLAMA_SHOW_CACHE_ENTRIES) {
|
||||
const oldestKey = ollamaModelShowInfoCache.keys().next().value;
|
||||
if (typeof oldestKey === "string") {
|
||||
ollamaModelShowInfoCache.delete(oldestKey);
|
||||
}
|
||||
}
|
||||
ollamaModelShowInfoCache.set(key, value);
|
||||
}
|
||||
|
||||
function hasCachedOllamaModelShowInfo(info: OllamaModelShowInfo): boolean {
|
||||
return typeof info.contextWindow === "number" || (info.capabilities?.length ?? 0) > 0;
|
||||
}
|
||||
|
||||
export function parseOllamaNumCtxParameter(parameters: unknown): number | undefined {
|
||||
if (typeof parameters !== "string" || !parameters.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let lastValue: number | undefined;
|
||||
for (const rawLine of parameters.split(/\r?\n/)) {
|
||||
const match = rawLine.trim().match(/^num_ctx\s+(-?\d+)\b/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const parsed = Number.parseInt(match[1], 10);
|
||||
if (Number.isFinite(parsed) && parsed > 0) {
|
||||
lastValue = parsed;
|
||||
}
|
||||
}
|
||||
return lastValue;
|
||||
}
|
||||
|
||||
export async function queryOllamaModelShowInfo(
|
||||
apiBase: string,
|
||||
modelName: string,
|
||||
opts?: { apiKey?: string },
|
||||
): Promise<OllamaModelShowInfo> {
|
||||
const normalizedApiBase = resolveOllamaApiBase(apiBase);
|
||||
try {
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (opts?.apiKey) {
|
||||
headers.Authorization = `Bearer ${opts.apiKey}`;
|
||||
}
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: `${normalizedApiBase}/api/show`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ name: modelName }),
|
||||
signal: AbortSignal.timeout(3000),
|
||||
},
|
||||
policy: buildOllamaBaseUrlSsrFPolicy(normalizedApiBase),
|
||||
auditContext: "ollama-provider-models.show",
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
return {};
|
||||
}
|
||||
const data = await readProviderJsonResponse<{
|
||||
model_info?: Record<string, unknown>;
|
||||
capabilities?: unknown;
|
||||
parameters?: unknown;
|
||||
}>(response, "ollama-provider-models.show");
|
||||
|
||||
let contextWindow: number | undefined;
|
||||
if (data.model_info) {
|
||||
for (const [key, value] of Object.entries(data.model_info)) {
|
||||
if (
|
||||
key.endsWith(".context_length") &&
|
||||
typeof value === "number" &&
|
||||
Number.isFinite(value)
|
||||
) {
|
||||
const ctx = Math.floor(value);
|
||||
if (ctx > 0) {
|
||||
contextWindow = ctx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const paramCtx = parseOllamaNumCtxParameter(data.parameters);
|
||||
if (paramCtx !== undefined && (contextWindow === undefined || paramCtx > contextWindow)) {
|
||||
contextWindow = paramCtx;
|
||||
}
|
||||
|
||||
const capabilities = Array.isArray(data.capabilities)
|
||||
? (data.capabilities as unknown[]).filter((c): c is string => typeof c === "string")
|
||||
: undefined;
|
||||
|
||||
return { contextWindow, capabilities };
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function queryOllamaModelShowInfoCached(
|
||||
apiBase: string,
|
||||
model: Pick<OllamaTagModel, "name" | "digest" | "modified_at">,
|
||||
opts?: { apiKey?: string },
|
||||
): Promise<OllamaModelShowInfo> {
|
||||
const normalizedApiBase = resolveOllamaApiBase(apiBase);
|
||||
const cacheKey = buildOllamaModelShowCacheKey(normalizedApiBase, model, opts?.apiKey);
|
||||
if (!cacheKey) {
|
||||
return await queryOllamaModelShowInfo(normalizedApiBase, model.name, opts);
|
||||
}
|
||||
|
||||
const cached = ollamaModelShowInfoCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return await cached;
|
||||
}
|
||||
|
||||
const pending = queryOllamaModelShowInfo(normalizedApiBase, model.name, opts).then((result) => {
|
||||
if (!hasCachedOllamaModelShowInfo(result)) {
|
||||
ollamaModelShowInfoCache.delete(cacheKey);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
setOllamaModelShowCacheEntry(cacheKey, pending);
|
||||
return await pending;
|
||||
}
|
||||
|
||||
/** @deprecated Use queryOllamaModelShowInfo instead. */
|
||||
export async function queryOllamaContextWindow(
|
||||
apiBase: string,
|
||||
modelName: string,
|
||||
): Promise<number | undefined> {
|
||||
return (await queryOllamaModelShowInfo(apiBase, modelName)).contextWindow;
|
||||
}
|
||||
|
||||
export async function enrichOllamaModelsWithContext(
|
||||
apiBase: string,
|
||||
models: OllamaTagModel[],
|
||||
opts?: { apiKey?: string; concurrency?: number },
|
||||
): Promise<OllamaModelWithContext[]> {
|
||||
const concurrency = Math.max(1, Math.floor(opts?.concurrency ?? OLLAMA_SHOW_CONCURRENCY));
|
||||
const enriched: OllamaModelWithContext[] = [];
|
||||
for (let index = 0; index < models.length; index += concurrency) {
|
||||
const batch = models.slice(index, index + concurrency);
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(async (model) => {
|
||||
const showInfo = await queryOllamaModelShowInfoCached(
|
||||
apiBase,
|
||||
model,
|
||||
opts?.apiKey ? { apiKey: opts.apiKey } : undefined,
|
||||
);
|
||||
return Object.assign({}, model, {
|
||||
contextWindow: showInfo.contextWindow,
|
||||
capabilities: showInfo.capabilities,
|
||||
});
|
||||
}),
|
||||
);
|
||||
enriched.push(...batchResults);
|
||||
}
|
||||
return enriched;
|
||||
}
|
||||
|
||||
export function isReasoningModelHeuristic(modelId: string): boolean {
|
||||
return /r1|reasoning|think|reason/i.test(modelId);
|
||||
}
|
||||
|
||||
function isKnownOllamaCloudReasoningModel(modelId: string): boolean {
|
||||
const normalized = modelId.trim().toLowerCase();
|
||||
return (
|
||||
normalized === OLLAMA_GLM52_CLOUD_MODEL_ID ||
|
||||
/^deepseek-v4-(?:flash|pro):cloud$/.test(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
export function buildOllamaModelDefinition(
|
||||
modelId: string,
|
||||
contextWindow?: number,
|
||||
capabilities?: string[],
|
||||
): ModelDefinitionConfig {
|
||||
const hasVision = capabilities?.includes("vision") ?? false;
|
||||
const input: ("text" | "image")[] = hasVision ? ["text", "image"] : ["text"];
|
||||
const reasoning =
|
||||
isKnownOllamaCloudReasoningModel(modelId) ||
|
||||
(capabilities === undefined
|
||||
? isReasoningModelHeuristic(modelId)
|
||||
: capabilities.includes("thinking"));
|
||||
const compat =
|
||||
capabilities === undefined
|
||||
? { supportsTools: true, supportsUsageInStreaming: true }
|
||||
: {
|
||||
supportsTools: capabilities.includes("tools"),
|
||||
supportsUsageInStreaming: true,
|
||||
};
|
||||
return {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
reasoning,
|
||||
input,
|
||||
cost: OLLAMA_DEFAULT_COST,
|
||||
contextWindow:
|
||||
contextWindow ??
|
||||
(modelId.trim().toLowerCase() === OLLAMA_GLM52_CLOUD_MODEL_ID
|
||||
? OLLAMA_GLM52_CONTEXT_WINDOW
|
||||
: OLLAMA_DEFAULT_CONTEXT_WINDOW),
|
||||
maxTokens: OLLAMA_DEFAULT_MAX_TOKENS,
|
||||
compat,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchOllamaModels(
|
||||
baseUrl: string,
|
||||
opts?: { apiKey?: string },
|
||||
): Promise<{ reachable: boolean; models: OllamaTagModel[] }> {
|
||||
try {
|
||||
const apiBase = resolveOllamaApiBase(baseUrl);
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: `${apiBase}/api/tags`,
|
||||
init: {
|
||||
headers: opts?.apiKey ? { Authorization: `Bearer ${opts.apiKey}` } : undefined,
|
||||
signal: AbortSignal.timeout(5000),
|
||||
},
|
||||
policy: buildOllamaBaseUrlSsrFPolicy(apiBase),
|
||||
auditContext: "ollama-provider-models.tags",
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
return { reachable: true, models: [] };
|
||||
}
|
||||
const data = await readProviderJsonResponse<OllamaTagsResponse>(
|
||||
response,
|
||||
"ollama-provider-models.tags",
|
||||
);
|
||||
const models = (data.models ?? []).filter((m) => m.name);
|
||||
return { reachable: true, models };
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch {
|
||||
return { reachable: false, models: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export async function buildOllamaProvider(
|
||||
configuredBaseUrl?: string,
|
||||
opts?: { apiKey?: string; quiet?: boolean },
|
||||
): Promise<ModelProviderConfig> {
|
||||
const apiBase = resolveOllamaApiBase(configuredBaseUrl);
|
||||
const auth = opts?.apiKey ? { apiKey: opts.apiKey } : undefined;
|
||||
const { reachable, models } = await fetchOllamaModels(apiBase, auth);
|
||||
if (!reachable && !opts?.quiet) {
|
||||
console.warn(`Ollama could not be reached at ${apiBase}.`);
|
||||
}
|
||||
const discovered = await enrichOllamaModelsWithContext(
|
||||
apiBase,
|
||||
models.slice(0, OLLAMA_CONTEXT_ENRICH_LIMIT),
|
||||
auth,
|
||||
);
|
||||
return {
|
||||
baseUrl: apiBase,
|
||||
api: "ollama",
|
||||
models: discovered.map((model) =>
|
||||
buildOllamaModelDefinition(model.name, model.contextWindow, model.capabilities),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function resetOllamaModelShowInfoCacheForTest(): void {
|
||||
ollamaModelShowInfoCache.clear();
|
||||
}
|
||||
75
extensions/ollama/src/sanitizers/kimi-inline-reasoning.ts
Normal file
75
extensions/ollama/src/sanitizers/kimi-inline-reasoning.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
// Ollama plugin module implements kimi inline reasoning behavior.
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type {
|
||||
OllamaVisibleContentSanitizer,
|
||||
OllamaVisibleContentStreamResolution,
|
||||
} from "./visible-content-contract.js";
|
||||
|
||||
const INLINE_REASONING_MIN_PREFIX_CHARS = 80;
|
||||
const INLINE_REASONING_MAX_PENDING_CHARS = 512;
|
||||
const INLINE_REASONING_BOUNDARY_RE = /(^|\s)\uFE0F\s*/u;
|
||||
|
||||
type InlineReasoningVisibleTextResolution =
|
||||
| { kind: "visible"; text: string; bypassInlineReasoning?: boolean }
|
||||
| { kind: "pending" };
|
||||
|
||||
export function isOllamaCloudKimiModelRef(modelId: string): boolean {
|
||||
const normalizedModelId = normalizeLowercaseStringOrEmpty(modelId);
|
||||
const slashIndex = normalizedModelId.indexOf("/");
|
||||
const normalizedWireModelId =
|
||||
slashIndex === -1 ? normalizedModelId : normalizedModelId.slice(slashIndex + 1);
|
||||
return normalizedWireModelId.startsWith("kimi-k") && normalizedWireModelId.includes(":cloud");
|
||||
}
|
||||
|
||||
function resolveInlineReasoningVisibleText(params: {
|
||||
text: string;
|
||||
final: boolean;
|
||||
}): InlineReasoningVisibleTextResolution {
|
||||
const match = INLINE_REASONING_BOUNDARY_RE.exec(params.text);
|
||||
if (!match) {
|
||||
if (!params.final && params.text.length <= INLINE_REASONING_MAX_PENDING_CHARS) {
|
||||
return { kind: "pending" };
|
||||
}
|
||||
return {
|
||||
kind: "visible",
|
||||
text: params.text,
|
||||
bypassInlineReasoning:
|
||||
!params.final && params.text.length > INLINE_REASONING_MAX_PENDING_CHARS,
|
||||
};
|
||||
}
|
||||
|
||||
const boundaryStartIndex = match.index + match[1].length;
|
||||
const boundaryEndIndex = match.index + match[0].length;
|
||||
const prefix = params.text.slice(0, boundaryStartIndex).trim();
|
||||
const answer = params.text.slice(boundaryEndIndex).trim();
|
||||
if (prefix.length >= INLINE_REASONING_MIN_PREFIX_CHARS) {
|
||||
return { kind: "visible", text: answer };
|
||||
}
|
||||
|
||||
return params.final ? { kind: "visible", text: params.text } : { kind: "pending" };
|
||||
}
|
||||
|
||||
export function createKimiInlineReasoningSanitizer(): OllamaVisibleContentSanitizer {
|
||||
let bypassInlineReasoning = false;
|
||||
|
||||
return {
|
||||
resolveStreamText(params): OllamaVisibleContentStreamResolution {
|
||||
if (bypassInlineReasoning) {
|
||||
return { kind: "visible", text: params.text };
|
||||
}
|
||||
|
||||
const resolution = resolveInlineReasoningVisibleText(params);
|
||||
if (resolution.kind === "pending") {
|
||||
return resolution;
|
||||
}
|
||||
if (resolution.bypassInlineReasoning) {
|
||||
bypassInlineReasoning = true;
|
||||
}
|
||||
return { kind: "visible", text: resolution.text };
|
||||
},
|
||||
sanitizeFinalText(text) {
|
||||
const resolution = resolveInlineReasoningVisibleText({ text, final: true });
|
||||
return resolution.kind === "visible" ? resolution.text : text;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// Ollama plugin module implements visible content contract behavior.
|
||||
export type OllamaVisibleContentStreamResolution =
|
||||
| { kind: "visible"; text: string }
|
||||
| { kind: "pending" };
|
||||
|
||||
export type OllamaVisibleContentSanitizer = {
|
||||
resolveStreamText(params: { text: string; final: boolean }): OllamaVisibleContentStreamResolution;
|
||||
sanitizeFinalText(text: string): string;
|
||||
};
|
||||
31
extensions/ollama/src/sanitizers/visible-content.ts
Normal file
31
extensions/ollama/src/sanitizers/visible-content.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Ollama plugin module implements visible content behavior.
|
||||
import {
|
||||
createKimiInlineReasoningSanitizer,
|
||||
isOllamaCloudKimiModelRef,
|
||||
} from "./kimi-inline-reasoning.js";
|
||||
import type { OllamaVisibleContentSanitizer } from "./visible-content-contract.js";
|
||||
|
||||
const noopVisibleContentSanitizer: OllamaVisibleContentSanitizer = {
|
||||
resolveStreamText(params) {
|
||||
return { kind: "visible", text: params.text };
|
||||
},
|
||||
sanitizeFinalText(text) {
|
||||
return text;
|
||||
},
|
||||
};
|
||||
|
||||
export function createOllamaVisibleContentSanitizer(
|
||||
modelId: string,
|
||||
): OllamaVisibleContentSanitizer {
|
||||
if (isOllamaCloudKimiModelRef(modelId)) {
|
||||
return createKimiInlineReasoningSanitizer();
|
||||
}
|
||||
return noopVisibleContentSanitizer;
|
||||
}
|
||||
|
||||
export function sanitizeOllamaFinalVisibleContent(params: {
|
||||
modelId: string;
|
||||
text: string;
|
||||
}): string {
|
||||
return createOllamaVisibleContentSanitizer(params.modelId).sanitizeFinalText(params.text);
|
||||
}
|
||||
827
extensions/ollama/src/setup.test.ts
Normal file
827
extensions/ollama/src/setup.test.ts
Normal file
@@ -0,0 +1,827 @@
|
||||
// Ollama tests cover setup plugin behavior.
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
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 { resetOllamaModelShowInfoCacheForTest } from "./provider-models.js";
|
||||
import {
|
||||
checkOllamaCloudAuth,
|
||||
configureOllamaNonInteractive,
|
||||
ensureOllamaModelPulled,
|
||||
promptAndConfigureOllama,
|
||||
} from "./setup.js";
|
||||
|
||||
const upsertAuthProfileWithLock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const fetchWithSsrFGuardMock = vi.hoisted(() =>
|
||||
vi.fn(async (params: { url: string; init?: RequestInit; signal?: AbortSignal }) => ({
|
||||
response: await globalThis.fetch(params.url, {
|
||||
...params.init,
|
||||
...(params.signal ? { signal: params.signal } : {}),
|
||||
}),
|
||||
finalUrl: params.url,
|
||||
release: async () => {},
|
||||
})),
|
||||
);
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-auth")>();
|
||||
return {
|
||||
...actual,
|
||||
upsertAuthProfileWithLock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
fetchWithSsrFGuard: (...args: Parameters<typeof actual.fetchWithSsrFGuard>) =>
|
||||
fetchWithSsrFGuardMock(...args),
|
||||
};
|
||||
});
|
||||
|
||||
function createOllamaFetchMock(params: {
|
||||
tags?: string[];
|
||||
show?: Record<string, number | undefined>;
|
||||
pullResponse?: Response;
|
||||
tagsError?: Error;
|
||||
meResponse?: Response;
|
||||
}) {
|
||||
return vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = requestUrl(input);
|
||||
if (url.endsWith("/api/tags")) {
|
||||
if (params.tagsError) {
|
||||
throw params.tagsError;
|
||||
}
|
||||
return jsonResponse({ models: (params.tags ?? []).map((name) => ({ name })) });
|
||||
}
|
||||
if (url.endsWith("/api/show")) {
|
||||
const body = JSON.parse(requestBodyText(init?.body)) as { name?: string };
|
||||
const contextWindow = body.name ? params.show?.[body.name] : undefined;
|
||||
return contextWindow
|
||||
? jsonResponse({ model_info: { "llama.context_length": contextWindow } })
|
||||
: jsonResponse({});
|
||||
}
|
||||
if (url.endsWith("/api/me")) {
|
||||
return params.meResponse ?? jsonResponse({});
|
||||
}
|
||||
if (url.endsWith("/api/pull")) {
|
||||
return params.pullResponse ?? new Response('{"status":"success"}\n', { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
}
|
||||
|
||||
function mockCall(mock: { mock: { calls: unknown[][] } }, index = 0) {
|
||||
return mock.mock.calls.at(index);
|
||||
}
|
||||
|
||||
function mockCallArg(mock: { mock: { calls: unknown[][] } }, index = 0, argIndex = 0) {
|
||||
return mockCall(mock, index)?.at(argIndex);
|
||||
}
|
||||
|
||||
function createLocalPrompter(): WizardPrompter {
|
||||
return {
|
||||
select: vi.fn().mockResolvedValueOnce("local-only"),
|
||||
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
|
||||
note: vi.fn(async () => undefined),
|
||||
} as unknown as WizardPrompter;
|
||||
}
|
||||
|
||||
function createCloudPrompter(): WizardPrompter {
|
||||
return {
|
||||
select: vi.fn().mockResolvedValueOnce("cloud-only"),
|
||||
confirm: vi.fn().mockResolvedValueOnce(false),
|
||||
text: vi.fn().mockResolvedValueOnce("test-ollama-key"),
|
||||
note: vi.fn(async () => undefined),
|
||||
} as unknown as WizardPrompter;
|
||||
}
|
||||
|
||||
function createCloudLocalPrompter(): WizardPrompter {
|
||||
return {
|
||||
select: vi.fn().mockResolvedValueOnce("cloud-local"),
|
||||
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
|
||||
note: vi.fn(async () => undefined),
|
||||
} as unknown as WizardPrompter;
|
||||
}
|
||||
|
||||
function createDefaultOllamaConfig(primary: string) {
|
||||
return {
|
||||
agents: { defaults: { model: { primary } } },
|
||||
models: { providers: { ollama: { baseUrl: "http://127.0.0.1:11434", models: [] } } },
|
||||
};
|
||||
}
|
||||
|
||||
function createRuntime() {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as unknown as RuntimeEnv;
|
||||
}
|
||||
|
||||
describe("ollama setup", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
upsertAuthProfileWithLock.mockClear();
|
||||
fetchWithSsrFGuardMock.mockClear();
|
||||
resetOllamaModelShowInfoCacheForTest();
|
||||
});
|
||||
|
||||
it("puts suggested local model first in local mode", async () => {
|
||||
const prompter = createLocalPrompter();
|
||||
|
||||
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await promptAndConfigureOllama({
|
||||
cfg: {},
|
||||
prompter,
|
||||
});
|
||||
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
|
||||
|
||||
expect(modelIds?.[0]).toBe("gemma4");
|
||||
});
|
||||
|
||||
it("Docker setup defaults to the host Ollama endpoint", async () => {
|
||||
vi.stubEnv("OPENCLAW_DOCKER_SETUP", "1");
|
||||
const text = vi.fn().mockResolvedValueOnce("http://host.docker.internal:11434");
|
||||
const prompter = {
|
||||
select: vi.fn().mockResolvedValueOnce("local-only"),
|
||||
text,
|
||||
note: vi.fn(async () => undefined),
|
||||
} as unknown as WizardPrompter;
|
||||
|
||||
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await promptAndConfigureOllama({
|
||||
cfg: {},
|
||||
prompter,
|
||||
});
|
||||
|
||||
const baseUrlPrompt = mockCallArg(text) as {
|
||||
message?: string;
|
||||
initialValue?: string;
|
||||
placeholder?: string;
|
||||
validate?: unknown;
|
||||
};
|
||||
expect(baseUrlPrompt).toEqual({
|
||||
message: "Ollama base URL",
|
||||
initialValue: "http://host.docker.internal:11434",
|
||||
placeholder: "http://host.docker.internal:11434",
|
||||
validate: baseUrlPrompt.validate,
|
||||
});
|
||||
expect(typeof baseUrlPrompt.validate).toBe("function");
|
||||
expect(mockCallArg(fetchMock)).toBe("http://host.docker.internal:11434/api/tags");
|
||||
expect(result.config.models?.providers?.ollama?.baseUrl).toBe(
|
||||
"http://host.docker.internal:11434",
|
||||
);
|
||||
});
|
||||
|
||||
it("puts suggested cloud model first in cloud mode", async () => {
|
||||
const prompter = createCloudPrompter();
|
||||
vi.stubGlobal("fetch", createOllamaFetchMock({ tags: [] }));
|
||||
const result = await promptAndConfigureOllama({
|
||||
cfg: {},
|
||||
env: {},
|
||||
prompter,
|
||||
allowSecretRefPrompt: false,
|
||||
});
|
||||
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
|
||||
|
||||
expect(modelIds?.[0]).toBe("kimi-k2.5:cloud");
|
||||
expect(result.config.models?.providers?.ollama?.baseUrl).toBe("https://ollama.com");
|
||||
expect(result.config.models?.providers?.ollama?.apiKey).toBe("test-ollama-key");
|
||||
expect(result.credential).toBe("test-ollama-key");
|
||||
});
|
||||
|
||||
it("uses generic token flags for cloud-only setup", async () => {
|
||||
const prompter = createCloudPrompter();
|
||||
vi.stubGlobal("fetch", createOllamaFetchMock({ tags: [] }));
|
||||
|
||||
const result = await promptAndConfigureOllama({
|
||||
cfg: {},
|
||||
env: {},
|
||||
opts: {
|
||||
token: "generic-ollama-key",
|
||||
tokenProvider: "ollama",
|
||||
},
|
||||
prompter,
|
||||
allowSecretRefPrompt: false,
|
||||
});
|
||||
|
||||
expect(result.credential).toBe("generic-ollama-key");
|
||||
expect(prompter.text).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("puts hybrid cloud model suggestions after the local default when signed in", async () => {
|
||||
const prompter = createCloudLocalPrompter();
|
||||
const fetchMock = createOllamaFetchMock({
|
||||
tags: ["llama3:8b"],
|
||||
meResponse: jsonResponse({ user: "signed-in" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await promptAndConfigureOllama({
|
||||
cfg: {},
|
||||
prompter,
|
||||
});
|
||||
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
|
||||
|
||||
expect(modelIds).toEqual([
|
||||
"gemma4",
|
||||
"kimi-k2.5:cloud",
|
||||
"minimax-m2.7:cloud",
|
||||
"glm-5.1:cloud",
|
||||
"glm-5.2:cloud",
|
||||
"llama3:8b",
|
||||
]);
|
||||
expect(result.config.models?.providers?.ollama?.baseUrl).toBe("http://127.0.0.1:11434");
|
||||
expect(result.credential).toBe("ollama-local");
|
||||
});
|
||||
|
||||
it("mode selection affects model ordering (local)", async () => {
|
||||
const prompter = createLocalPrompter();
|
||||
|
||||
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b", "gemma4"] });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await promptAndConfigureOllama({
|
||||
cfg: {},
|
||||
prompter,
|
||||
});
|
||||
|
||||
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
|
||||
expect(modelIds?.[0]).toBe("gemma4");
|
||||
expect(modelIds).toContain("llama3:8b");
|
||||
});
|
||||
|
||||
it("dedupes the suggested local model against a discovered latest tag", async () => {
|
||||
const prompter = createLocalPrompter();
|
||||
|
||||
const fetchMock = createOllamaFetchMock({ tags: ["gemma4:latest", "llama3:8b"] });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await promptAndConfigureOllama({
|
||||
cfg: {},
|
||||
prompter,
|
||||
});
|
||||
|
||||
const modelIds = result.config.models?.providers?.ollama?.models?.map((m) => m.id);
|
||||
expect(modelIds).toEqual(["gemma4:latest", "llama3:8b"]);
|
||||
});
|
||||
|
||||
it("cloud mode does not hit local Ollama endpoints", async () => {
|
||||
const prompter = createCloudPrompter();
|
||||
const fetchMock = createOllamaFetchMock({ tags: [] });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await promptAndConfigureOllama({
|
||||
cfg: {},
|
||||
env: {},
|
||||
prompter,
|
||||
allowSecretRefPrompt: false,
|
||||
});
|
||||
|
||||
const requestUrls = fetchMock.mock.calls.map((call) => requestUrl(call[0]));
|
||||
expect(requestUrls).toEqual(["https://ollama.com/api/tags"]);
|
||||
expect(new Headers(fetchMock.mock.calls[0]?.[1]?.headers).get("Authorization")).toBe(
|
||||
"Bearer test-ollama-key",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects the local marker during cloud-only setup", async () => {
|
||||
const prompter = createCloudPrompter();
|
||||
|
||||
await expect(
|
||||
promptAndConfigureOllama({
|
||||
cfg: {},
|
||||
env: {},
|
||||
opts: {
|
||||
ollamaApiKey: "ollama-local",
|
||||
},
|
||||
prompter,
|
||||
allowSecretRefPrompt: false,
|
||||
}),
|
||||
).rejects.toThrow("Cloud-only Ollama setup requires a real OLLAMA_API_KEY.");
|
||||
});
|
||||
|
||||
it("local mode only hits local model discovery endpoints", async () => {
|
||||
const prompter = createLocalPrompter();
|
||||
|
||||
const fetchMock = createOllamaFetchMock({ tags: ["llama3:8b"] });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await promptAndConfigureOllama({
|
||||
cfg: {},
|
||||
prompter,
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls.map((call) => requestUrl(call[0]))).toEqual([
|
||||
"http://127.0.0.1:11434/api/tags",
|
||||
"http://127.0.0.1:11434/api/show",
|
||||
]);
|
||||
});
|
||||
|
||||
it("asks for Ollama mode before cloud api key", async () => {
|
||||
const events: string[] = [];
|
||||
const prompter = {
|
||||
select: vi.fn(async () => {
|
||||
events.push("select");
|
||||
return "cloud-only";
|
||||
}),
|
||||
confirm: vi.fn(async () => false),
|
||||
text: vi.fn(async () => {
|
||||
events.push("text");
|
||||
return "test-ollama-key";
|
||||
}),
|
||||
note: vi.fn(async () => undefined),
|
||||
} as unknown as WizardPrompter;
|
||||
vi.stubGlobal("fetch", createOllamaFetchMock({ tags: [] }));
|
||||
|
||||
await promptAndConfigureOllama({
|
||||
cfg: {},
|
||||
env: {},
|
||||
prompter,
|
||||
allowSecretRefPrompt: false,
|
||||
});
|
||||
|
||||
expect(events).toEqual(["select", "text"]);
|
||||
});
|
||||
|
||||
it("shows cloud-mode unreachable guidance when the host is down", async () => {
|
||||
const prompter = createLocalPrompter();
|
||||
const fetchMock = createOllamaFetchMock({ tagsError: new Error("down") });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(
|
||||
promptAndConfigureOllama({
|
||||
cfg: {},
|
||||
prompter,
|
||||
}),
|
||||
).rejects.toThrow("Ollama not reachable");
|
||||
|
||||
expect(prompter.note).toHaveBeenCalledWith(
|
||||
[
|
||||
"Ollama could not be reached at http://127.0.0.1:11434.",
|
||||
"Download it at https://ollama.com/download",
|
||||
"",
|
||||
"Start Ollama and re-run setup.",
|
||||
].join("\n"),
|
||||
"Ollama",
|
||||
);
|
||||
});
|
||||
|
||||
it("cloud + local mode falls back to local models when ollama signin is missing", async () => {
|
||||
const prompter = createCloudLocalPrompter();
|
||||
const fetchMock = createOllamaFetchMock({
|
||||
tags: ["llama3:8b"],
|
||||
meResponse: new Response(JSON.stringify({ signin_url: "https://ollama.com/signin" }), {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await promptAndConfigureOllama({
|
||||
cfg: {},
|
||||
prompter,
|
||||
});
|
||||
|
||||
expect(result.config.models?.providers?.ollama?.models?.map((m) => m.id)).toEqual([
|
||||
"gemma4",
|
||||
"llama3:8b",
|
||||
]);
|
||||
expect(prompter.note).toHaveBeenCalledWith(
|
||||
[
|
||||
"Cloud models on this Ollama host need `ollama signin`.",
|
||||
"https://ollama.com/signin",
|
||||
"",
|
||||
"Continuing with local models only for now.",
|
||||
].join("\n"),
|
||||
"Ollama Cloud + Local",
|
||||
);
|
||||
});
|
||||
|
||||
it("cloud mode falls back to the hardcoded cloud model list when /api/tags is empty", async () => {
|
||||
const prompter = createCloudPrompter();
|
||||
vi.stubGlobal("fetch", createOllamaFetchMock({ tags: [] }));
|
||||
const result = await promptAndConfigureOllama({
|
||||
cfg: {},
|
||||
env: {},
|
||||
prompter,
|
||||
allowSecretRefPrompt: false,
|
||||
});
|
||||
const models = result.config.models?.providers?.ollama?.models;
|
||||
const modelIds = models?.map((m) => m.id);
|
||||
|
||||
expect(modelIds).toEqual([
|
||||
"kimi-k2.5:cloud",
|
||||
"minimax-m2.7:cloud",
|
||||
"glm-5.1:cloud",
|
||||
"glm-5.2:cloud",
|
||||
]);
|
||||
expect(models?.find((model) => model.id === "kimi-k2.5:cloud")?.input).toEqual([
|
||||
"text",
|
||||
"image",
|
||||
]);
|
||||
});
|
||||
|
||||
it("cloud mode populates models from ollama.com /api/tags when reachable", async () => {
|
||||
const prompter = createCloudPrompter();
|
||||
const fetchMock = createOllamaFetchMock({
|
||||
tags: ["qwen3-coder:480b-cloud", "gpt-oss:120b-cloud"],
|
||||
show: { "qwen3-coder:480b-cloud": 262144 },
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await promptAndConfigureOllama({
|
||||
cfg: {},
|
||||
env: {},
|
||||
prompter,
|
||||
allowSecretRefPrompt: false,
|
||||
});
|
||||
const models = result.config.models?.providers?.ollama?.models;
|
||||
const modelIds = models?.map((m) => m.id);
|
||||
|
||||
expect(modelIds).toEqual([
|
||||
"kimi-k2.5:cloud",
|
||||
"minimax-m2.7:cloud",
|
||||
"glm-5.1:cloud",
|
||||
"glm-5.2:cloud",
|
||||
"qwen3-coder:480b-cloud",
|
||||
"gpt-oss:120b-cloud",
|
||||
]);
|
||||
const requestUrls = fetchMock.mock.calls.map((call) => requestUrl(call[0]));
|
||||
expect(requestUrls.filter((url) => url.endsWith("/api/show"))).toEqual([]);
|
||||
expect(requestUrls).toContain("https://ollama.com/api/tags");
|
||||
});
|
||||
|
||||
it("uses /api/show context windows when building Ollama model configs", async () => {
|
||||
const prompter = {
|
||||
text: vi.fn().mockResolvedValueOnce("http://127.0.0.1:11434"),
|
||||
select: vi.fn().mockResolvedValueOnce("local-only"),
|
||||
note: vi.fn(async () => undefined),
|
||||
} as unknown as WizardPrompter;
|
||||
|
||||
const fetchMock = createOllamaFetchMock({
|
||||
tags: ["llama3:8b"],
|
||||
show: { "llama3:8b": 65536 },
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const result = await promptAndConfigureOllama({
|
||||
cfg: {},
|
||||
prompter,
|
||||
});
|
||||
const model = result.config.models?.providers?.ollama?.models?.find(
|
||||
(m) => m.id === "llama3:8b",
|
||||
);
|
||||
|
||||
expect(model?.contextWindow).toBe(65536);
|
||||
});
|
||||
|
||||
describe("ensureOllamaModelPulled", () => {
|
||||
it("pulls model when not available locally", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const progress = { update: vi.fn(), stop: vi.fn() };
|
||||
const prompter = {
|
||||
progress: vi.fn(() => progress),
|
||||
} as unknown as WizardPrompter;
|
||||
|
||||
const fetchMock = createOllamaFetchMock({
|
||||
tags: ["llama3:8b"],
|
||||
pullResponse: new Response('{"status":"success"}\n', { status: 200 }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await ensureOllamaModelPulled({
|
||||
config: createDefaultOllamaConfig("ollama/gemma4"),
|
||||
model: "ollama/gemma4",
|
||||
prompter,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
expect(mockCallArg(fetchMock, 1)).toContain("/api/pull");
|
||||
const pullInit = mockCallArg(fetchMock, 1, 1) as RequestInit | undefined;
|
||||
expect(pullInit?.signal).toBeInstanceOf(AbortSignal);
|
||||
expect(pullInit?.signal?.aborted).toBe(false);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(pullInit?.signal?.aborted).toBe(false);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails stalled model pull streams after an idle timeout", async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const progress = { update: vi.fn(), stop: vi.fn() };
|
||||
const prompter = {
|
||||
progress: vi.fn(() => progress),
|
||||
} as unknown as WizardPrompter;
|
||||
const fetchMock = vi.fn(async (input: string | URL | Request) => {
|
||||
const url = requestUrl(input);
|
||||
if (url.endsWith("/api/tags")) {
|
||||
return jsonResponse({ models: [] });
|
||||
}
|
||||
if (url.endsWith("/api/pull")) {
|
||||
return new Response(new ReadableStream<Uint8Array>(), { status: 200 });
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const pullPromise = ensureOllamaModelPulled({
|
||||
config: createDefaultOllamaConfig("ollama/gemma4"),
|
||||
model: "ollama/gemma4",
|
||||
prompter,
|
||||
}).catch((err: unknown) => err);
|
||||
|
||||
await vi.waitFor(() => expect(mockCallArg(fetchMock, 1)).toContain("/api/pull"));
|
||||
|
||||
await vi.advanceTimersByTimeAsync(300_000);
|
||||
const pullError = await pullPromise;
|
||||
expect(pullError).toBeInstanceOf(Error);
|
||||
expect((pullError as Error).name).toBe("WizardCancelledError");
|
||||
expect((pullError as Error).message).toBe("Failed to download selected Ollama model");
|
||||
expect(progress.stop).toHaveBeenCalledWith(
|
||||
"Failed to download gemma4: Ollama pull stalled: no data received for 300s",
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it("skips pull when model is already available", async () => {
|
||||
const prompter = {} as unknown as WizardPrompter;
|
||||
|
||||
const fetchMock = createOllamaFetchMock({ tags: ["gemma4"] });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await ensureOllamaModelPulled({
|
||||
config: createDefaultOllamaConfig("ollama/gemma4"),
|
||||
model: "ollama/gemma4",
|
||||
prompter,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips pull when an untagged model is available as latest", async () => {
|
||||
const prompter = {} as unknown as WizardPrompter;
|
||||
|
||||
const fetchMock = createOllamaFetchMock({ tags: ["gemma4:latest"] });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await ensureOllamaModelPulled({
|
||||
config: createDefaultOllamaConfig("ollama/gemma4"),
|
||||
model: "ollama/gemma4",
|
||||
prompter,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses baseURL alias when checking and pulling models", async () => {
|
||||
const progress = { update: vi.fn(), stop: vi.fn() };
|
||||
const prompter = {
|
||||
progress: vi.fn(() => progress),
|
||||
} as unknown as WizardPrompter;
|
||||
|
||||
const fetchMock = createOllamaFetchMock({
|
||||
tags: [],
|
||||
pullResponse: new Response('{"status":"success"}\n', { status: 200 }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await ensureOllamaModelPulled({
|
||||
config: {
|
||||
agents: { defaults: { model: { primary: "ollama/gemma4" } } },
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseURL: "http://127.0.0.1:11435",
|
||||
models: [],
|
||||
} as never,
|
||||
},
|
||||
},
|
||||
},
|
||||
model: "ollama/gemma4",
|
||||
prompter,
|
||||
});
|
||||
|
||||
expect(mockCallArg(fetchMock)).toBe("http://127.0.0.1:11435/api/tags");
|
||||
expect(mockCallArg(fetchMock, 1)).toBe("http://127.0.0.1:11435/api/pull");
|
||||
});
|
||||
|
||||
it("skips pull for cloud models", async () => {
|
||||
const prompter = {} as unknown as WizardPrompter;
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await ensureOllamaModelPulled({
|
||||
config: createDefaultOllamaConfig("ollama/kimi-k2.5:cloud"),
|
||||
model: "ollama/kimi-k2.5:cloud",
|
||||
prompter,
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips when model is not an ollama model", async () => {
|
||||
const prompter = {} as unknown as WizardPrompter;
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await ensureOllamaModelPulled({
|
||||
config: {
|
||||
agents: { defaults: { model: { primary: "openai/gpt-4o" } } },
|
||||
},
|
||||
model: "openai/gpt-4o",
|
||||
prompter,
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("uses discovered model when requested non-interactive download fails", async () => {
|
||||
const fetchMock = createOllamaFetchMock({
|
||||
tags: ["qwen2.5-coder:7b"],
|
||||
pullResponse: new Response('{"error":"disk full"}\n', { status: 200 }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await configureOllamaNonInteractive({
|
||||
nextConfig: {
|
||||
agents: {
|
||||
defaults: {
|
||||
model: {
|
||||
primary: "openai/gpt-4o-mini",
|
||||
fallbacks: ["anthropic/claude-sonnet-4-5"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
opts: {
|
||||
customBaseUrl: "http://127.0.0.1:11434",
|
||||
customModelId: "missing-model",
|
||||
},
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith("Download failed: disk full");
|
||||
expect(result.agents?.defaults?.model).toEqual({
|
||||
primary: "ollama/qwen2.5-coder:7b",
|
||||
fallbacks: ["anthropic/claude-sonnet-4-5"],
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes ollama/ prefix in non-interactive custom model download", async () => {
|
||||
const fetchMock = createOllamaFetchMock({
|
||||
tags: [],
|
||||
pullResponse: new Response('{"status":"success"}\n', { status: 200 }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await configureOllamaNonInteractive({
|
||||
nextConfig: {},
|
||||
opts: {
|
||||
customBaseUrl: "http://127.0.0.1:11434",
|
||||
customModelId: "ollama/llama3.2:latest",
|
||||
},
|
||||
runtime,
|
||||
});
|
||||
|
||||
const pullRequest = mockCallArg(fetchMock, 1, 1) as RequestInit | undefined;
|
||||
expect(JSON.parse(requestBodyText(pullRequest?.body))).toEqual({ name: "llama3.2:latest" });
|
||||
expect(result.agents?.defaults?.model).toEqual({ primary: "ollama/llama3.2:latest" });
|
||||
});
|
||||
|
||||
it("uses the discovered latest tag as the non-interactive default without pulling", async () => {
|
||||
const fetchMock = createOllamaFetchMock({ tags: ["gemma4:latest"] });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await configureOllamaNonInteractive({
|
||||
nextConfig: {},
|
||||
opts: {
|
||||
customBaseUrl: "http://127.0.0.1:11434",
|
||||
},
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const requestUrls = fetchMock.mock.calls.map((call) => requestUrl(call[0]));
|
||||
expect(requestUrls.filter((url) => url.endsWith("/api/pull"))).toEqual([]);
|
||||
expect(result.models?.providers?.ollama?.models?.map((model) => model.id)).toEqual([
|
||||
"gemma4:latest",
|
||||
]);
|
||||
expect(result.agents?.defaults?.model).toEqual({ primary: "ollama/gemma4:latest" });
|
||||
expect(runtime.log).toHaveBeenCalledWith("Default Ollama model: gemma4:latest");
|
||||
});
|
||||
|
||||
it("accepts cloud models in non-interactive mode without pulling", async () => {
|
||||
const fetchMock = createOllamaFetchMock({ tags: [] });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const runtime = createRuntime();
|
||||
|
||||
const result = await configureOllamaNonInteractive({
|
||||
nextConfig: {},
|
||||
opts: {
|
||||
customBaseUrl: "http://127.0.0.1:11434",
|
||||
customModelId: "kimi-k2.5:cloud",
|
||||
},
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(result.models?.providers?.ollama?.models?.map((model) => model.id)).toContain(
|
||||
"kimi-k2.5:cloud",
|
||||
);
|
||||
expect(result.agents?.defaults?.model).toEqual({ primary: "ollama/kimi-k2.5:cloud" });
|
||||
});
|
||||
|
||||
it("exits when Ollama is unreachable", async () => {
|
||||
const fetchMock = createOllamaFetchMock({
|
||||
tagsError: new Error("connect ECONNREFUSED"),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
} as unknown as RuntimeEnv;
|
||||
const nextConfig = {};
|
||||
|
||||
const result = await configureOllamaNonInteractive({
|
||||
nextConfig,
|
||||
opts: {
|
||||
customBaseUrl: "http://127.0.0.1:11435",
|
||||
customModelId: "llama3.2:latest",
|
||||
},
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
[
|
||||
"Ollama could not be reached at http://127.0.0.1:11435.",
|
||||
"Download it at https://ollama.com/download",
|
||||
].join("\n"),
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(result).toBe(nextConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe("checkOllamaCloudAuth", () => {
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuardMock.mockClear();
|
||||
});
|
||||
|
||||
it("bounds oversized 401 body and cancels the stream", async () => {
|
||||
const chunk = new Uint8Array(1024 * 1024); // 1 MiB chunk
|
||||
let readCount = 0;
|
||||
let canceled = false;
|
||||
// 64 chunks × 1 MiB = 64 MiB — exceeds the 16 MiB cap
|
||||
const oversizedBody = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (readCount >= 64) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
readCount += 1;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: new Response(oversizedBody, {
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
finalUrl: "https://ollama.com/api/me",
|
||||
release: async () => {},
|
||||
});
|
||||
|
||||
await expect(checkOllamaCloudAuth("https://ollama.com")).resolves.toEqual({
|
||||
signedIn: false,
|
||||
signinUrl: undefined,
|
||||
});
|
||||
|
||||
// Stream must be cancelled before all 64 MiB are consumed
|
||||
expect(readCount).toBeLessThan(64);
|
||||
expect(canceled).toBe(true);
|
||||
});
|
||||
});
|
||||
773
extensions/ollama/src/setup.ts
Normal file
773
extensions/ollama/src/setup.ts
Normal file
@@ -0,0 +1,773 @@
|
||||
// Ollama setup module handles plugin onboarding behavior.
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-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 {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-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,
|
||||
buildOllamaModelDefinition,
|
||||
enrichOllamaModelsWithContext,
|
||||
fetchOllamaModels,
|
||||
resolveOllamaApiBase,
|
||||
type OllamaModelWithContext,
|
||||
} from "./provider-models.js";
|
||||
|
||||
export { buildOllamaProvider };
|
||||
|
||||
const OLLAMA_SUGGESTED_MODELS_LOCAL = [OLLAMA_DEFAULT_MODEL];
|
||||
const OLLAMA_SUGGESTED_MODELS_CLOUD = [...OLLAMA_CLOUD_DEFAULT_MODELS];
|
||||
const OLLAMA_CONTEXT_ENRICH_LIMIT = 200;
|
||||
const OLLAMA_CLOUD_MAX_DISCOVERED_MODELS = 500;
|
||||
const OLLAMA_PULL_RESPONSE_TIMEOUT_MS = 30_000;
|
||||
const OLLAMA_PULL_STREAM_IDLE_TIMEOUT_MS = 300_000;
|
||||
|
||||
type OllamaSetupOptions = {
|
||||
customBaseUrl?: string;
|
||||
customModelId?: string;
|
||||
};
|
||||
|
||||
type OllamaSetupResult = {
|
||||
config: OpenClawConfig;
|
||||
credential: SecretInput;
|
||||
credentialMode?: SecretInputMode;
|
||||
};
|
||||
|
||||
function isTruthyEnvValue(value: string | undefined): boolean {
|
||||
return ["1", "true", "yes", "on"].includes(value?.trim().toLowerCase() ?? "");
|
||||
}
|
||||
|
||||
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): string[] {
|
||||
return [
|
||||
`Ollama could not be reached at ${baseUrl}.`,
|
||||
"Download it at https://ollama.com/download",
|
||||
"",
|
||||
"Start Ollama and re-run setup.",
|
||||
];
|
||||
}
|
||||
|
||||
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.",
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeOllamaModelName(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
if (normalizeLowercaseStringOrEmpty(trimmed).startsWith("ollama/")) {
|
||||
const normalized = trimmed.slice("ollama/".length).trim();
|
||||
return normalized || undefined;
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
function isOllamaCloudModel(modelName: string | undefined): boolean {
|
||||
return normalizeOptionalLowercaseString(modelName)?.endsWith(":cloud") === true;
|
||||
}
|
||||
|
||||
function formatOllamaPullStatus(status: string): { text: string; hidePercent: boolean } {
|
||||
const trimmed = status.trim();
|
||||
const partStatusMatch = trimmed.match(/^([a-z-]+)\s+(?:sha256:)?[a-f0-9]{8,}$/i);
|
||||
if (partStatusMatch) {
|
||||
return { text: `${partStatusMatch[1]} part`, hidePercent: false };
|
||||
}
|
||||
if (/^verifying\b.*\bdigest\b/i.test(trimmed)) {
|
||||
return { text: "verifying digest", hidePercent: true };
|
||||
}
|
||||
return { text: trimmed, hidePercent: false };
|
||||
}
|
||||
|
||||
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",
|
||||
signal: AbortSignal.timeout(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 {
|
||||
await release();
|
||||
}
|
||||
} catch {
|
||||
return { signedIn: false };
|
||||
}
|
||||
}
|
||||
|
||||
type OllamaPullChunk = {
|
||||
status?: string;
|
||||
total?: number;
|
||||
completed?: number;
|
||||
error?: string;
|
||||
};
|
||||
|
||||
type OllamaPullResult = { ok: true } | { ok: false; message: string };
|
||||
|
||||
async function readOllamaPullChunkWithIdleTimeout(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
): Promise<ReadableStreamReadResult<Uint8Array>> {
|
||||
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
||||
let timedOut = false;
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
const clear = () => {
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
timeoutId = setTimeout(() => {
|
||||
timedOut = true;
|
||||
clear();
|
||||
void reader.cancel().catch(() => undefined);
|
||||
reject(
|
||||
new Error(
|
||||
`Ollama pull stalled: no data received for ${Math.round(OLLAMA_PULL_STREAM_IDLE_TIMEOUT_MS / 1000)}s`,
|
||||
),
|
||||
);
|
||||
}, OLLAMA_PULL_STREAM_IDLE_TIMEOUT_MS);
|
||||
|
||||
void reader.read().then(
|
||||
(result) => {
|
||||
clear();
|
||||
if (!timedOut) {
|
||||
resolve(result);
|
||||
}
|
||||
},
|
||||
(err: unknown) => {
|
||||
clear();
|
||||
if (!timedOut) {
|
||||
reject(toLintErrorObject(err, "Non-Error rejection"));
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async function pullOllamaModelCore(params: {
|
||||
baseUrl: string;
|
||||
modelName: string;
|
||||
onStatus?: (status: string, percent: number | null) => void;
|
||||
}): Promise<OllamaPullResult> {
|
||||
const baseUrl = resolveOllamaApiBase(params.baseUrl);
|
||||
const modelName = normalizeOllamaModelName(params.modelName) ?? params.modelName.trim();
|
||||
const responseController = new AbortController();
|
||||
const responseTimeout = setTimeout(
|
||||
responseController.abort.bind(responseController),
|
||||
OLLAMA_PULL_RESPONSE_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: `${baseUrl}/api/pull`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: modelName }),
|
||||
},
|
||||
signal: responseController.signal,
|
||||
policy: buildOllamaBaseUrlSsrFPolicy(baseUrl),
|
||||
auditContext: "ollama-setup.pull",
|
||||
});
|
||||
clearTimeout(responseTimeout);
|
||||
try {
|
||||
if (!response.ok) {
|
||||
return { ok: false, message: `Failed to download ${modelName} (HTTP ${response.status})` };
|
||||
}
|
||||
if (!response.body) {
|
||||
return { ok: false, message: `Failed to download ${modelName} (no response body)` };
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
const layers = new Map<string, { total: number; completed: number }>();
|
||||
|
||||
const parseLine = (line: string): OllamaPullResult => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
return { ok: true };
|
||||
}
|
||||
try {
|
||||
const chunk = JSON.parse(trimmed) as OllamaPullChunk;
|
||||
if (chunk.error) {
|
||||
return { ok: false, message: `Download failed: ${chunk.error}` };
|
||||
}
|
||||
if (!chunk.status) {
|
||||
return { ok: true };
|
||||
}
|
||||
if (chunk.total && chunk.completed !== undefined) {
|
||||
layers.set(chunk.status, { total: chunk.total, completed: chunk.completed });
|
||||
let totalSum = 0;
|
||||
let completedSum = 0;
|
||||
for (const layer of layers.values()) {
|
||||
totalSum += layer.total;
|
||||
completedSum += layer.completed;
|
||||
}
|
||||
params.onStatus?.(
|
||||
chunk.status,
|
||||
totalSum > 0 ? Math.round((completedSum / totalSum) * 100) : null,
|
||||
);
|
||||
} else {
|
||||
params.onStatus?.(chunk.status, null);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed streaming lines from Ollama.
|
||||
}
|
||||
return { ok: true };
|
||||
};
|
||||
|
||||
for (;;) {
|
||||
const { done, value } = await readOllamaPullChunkWithIdleTimeout(reader);
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
for (const line of lines) {
|
||||
const parsed = parseLine(line);
|
||||
if (!parsed.ok) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const trailing = buffer.trim();
|
||||
if (trailing) {
|
||||
const parsed = parseLine(trailing);
|
||||
if (!parsed.ok) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch (err) {
|
||||
const reason = formatErrorMessage(err);
|
||||
return { ok: false, message: `Failed to download ${modelName}: ${reason}` };
|
||||
} finally {
|
||||
clearTimeout(responseTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
async function pullOllamaModel(
|
||||
baseUrl: string,
|
||||
modelName: string,
|
||||
prompter: WizardPrompter,
|
||||
): Promise<boolean> {
|
||||
const spinner = prompter.progress(`Downloading ${modelName}...`);
|
||||
const result = await pullOllamaModelCore({
|
||||
baseUrl,
|
||||
modelName,
|
||||
onStatus: (status, percent) => {
|
||||
const displayStatus = formatOllamaPullStatus(status);
|
||||
if (displayStatus.hidePercent) {
|
||||
spinner.update(`Downloading ${modelName} - ${displayStatus.text}`);
|
||||
} else {
|
||||
spinner.update(`Downloading ${modelName} - ${displayStatus.text} - ${percent ?? 0}%`);
|
||||
}
|
||||
},
|
||||
});
|
||||
if (!result.ok) {
|
||||
spinner.stop(result.message);
|
||||
return false;
|
||||
}
|
||||
spinner.stop(`Downloaded ${modelName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function pullOllamaModelNonInteractive(
|
||||
baseUrl: string,
|
||||
modelName: string,
|
||||
runtime: RuntimeEnv,
|
||||
): Promise<boolean> {
|
||||
runtime.log(`Downloading ${modelName}...`);
|
||||
const result = await pullOllamaModelCore({ baseUrl, modelName });
|
||||
if (!result.ok) {
|
||||
runtime.error(result.message);
|
||||
return false;
|
||||
}
|
||||
runtime.log(`Downloaded ${modelName}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
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 buildOllamaModelsConfig(
|
||||
modelNames: string[],
|
||||
discoveredModelsByName?: Map<string, OllamaModelWithContext>,
|
||||
) {
|
||||
return modelNames.map((name) => {
|
||||
const discovered = discoveredModelsByName?.get(name);
|
||||
// Suggested cloud models may be injected before `/api/tags` exposes them,
|
||||
// so keep Kimi vision-capable during setup even without discovered metadata.
|
||||
const capabilities =
|
||||
discovered?.capabilities ?? (name === "kimi-k2.5:cloud" ? ["vision"] : undefined);
|
||||
return buildOllamaModelDefinition(name, discovered?.contextWindow, capabilities);
|
||||
});
|
||||
}
|
||||
|
||||
function getOllamaLatestDedupeKey(name: string): string {
|
||||
const normalized = normalizeLowercaseStringOrEmpty(name);
|
||||
return normalized.endsWith(":latest") ? normalized.slice(0, -":latest".length) : normalized;
|
||||
}
|
||||
|
||||
function isExplicitLatestOllamaModel(name: string): boolean {
|
||||
return normalizeLowercaseStringOrEmpty(name).endsWith(":latest");
|
||||
}
|
||||
|
||||
function shouldReplaceOllamaModelName(existing: string, candidate: string): boolean {
|
||||
return !isExplicitLatestOllamaModel(existing) && isExplicitLatestOllamaModel(candidate);
|
||||
}
|
||||
|
||||
function mergeUniqueModelNames(...groups: string[][]): string[] {
|
||||
const indexByKey = new Map<string, number>();
|
||||
const merged: string[] = [];
|
||||
for (const group of groups) {
|
||||
for (const name of group) {
|
||||
const key = getOllamaLatestDedupeKey(name);
|
||||
const existingIndex = indexByKey.get(key);
|
||||
if (existingIndex !== undefined) {
|
||||
if (shouldReplaceOllamaModelName(merged[existingIndex], name)) {
|
||||
merged[existingIndex] = name;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
indexByKey.set(key, merged.length);
|
||||
merged.push(name);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function findAvailableOllamaModelName(modelName: string, availableModelNames: Iterable<string>) {
|
||||
const wantedKey = getOllamaLatestDedupeKey(modelName);
|
||||
for (const available of availableModelNames) {
|
||||
if (getOllamaLatestDedupeKey(available) === wantedKey) {
|
||||
return available;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function applyOllamaProviderConfig(
|
||||
cfg: OpenClawConfig,
|
||||
baseUrl: string,
|
||||
modelNames: string[],
|
||||
discoveredModelsByName?: Map<string, OllamaModelWithContext>,
|
||||
apiKey: SecretInput = "OLLAMA_API_KEY",
|
||||
): OpenClawConfig {
|
||||
return {
|
||||
...cfg,
|
||||
models: {
|
||||
...cfg.models,
|
||||
mode: cfg.models?.mode ?? "merge",
|
||||
providers: {
|
||||
...cfg.models?.providers,
|
||||
ollama: {
|
||||
baseUrl,
|
||||
api: "ollama",
|
||||
apiKey,
|
||||
models: buildOllamaModelsConfig(modelNames, discoveredModelsByName),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
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_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;
|
||||
}): Promise<OllamaSetupResult> {
|
||||
const baseUrl = await promptForOllamaBaseUrl(params.prompter, params.env);
|
||||
const { reachable, models } = await fetchOllamaModels(baseUrl);
|
||||
|
||||
if (!reachable) {
|
||||
await params.prompter.note(buildOllamaUnreachableLines(baseUrl).join("\n"), "Ollama");
|
||||
throw new WizardCancelledError("Ollama not reachable");
|
||||
}
|
||||
|
||||
const enrichedModels = await enrichOllamaModelsWithContext(
|
||||
baseUrl,
|
||||
models.slice(0, OLLAMA_CONTEXT_ENRICH_LIMIT),
|
||||
);
|
||||
const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model]));
|
||||
const discoveredModelNames = models.map((model) => model.name);
|
||||
const suggestedModelNames = await resolveHostBackedSuggestedModelNames({
|
||||
mode: params.mode,
|
||||
baseUrl,
|
||||
prompter: params.prompter,
|
||||
});
|
||||
|
||||
return {
|
||||
credential: "ollama-local",
|
||||
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;
|
||||
}): 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;
|
||||
return {
|
||||
credential,
|
||||
credentialMode,
|
||||
config: applyOllamaProviderConfig(
|
||||
params.cfg,
|
||||
OLLAMA_CLOUD_BASE_URL,
|
||||
modelNames,
|
||||
undefined,
|
||||
credential,
|
||||
),
|
||||
};
|
||||
}
|
||||
return await promptAndConfigureHostBackedOllama({
|
||||
cfg: params.cfg,
|
||||
mode,
|
||||
prompter: params.prompter,
|
||||
env: params.env,
|
||||
});
|
||||
}
|
||||
|
||||
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 } = await fetchOllamaModels(baseUrl);
|
||||
const explicitModel = normalizeOllamaModelName(params.opts.customModelId);
|
||||
|
||||
if (!reachable) {
|
||||
params.runtime.error(buildOllamaUnreachableLines(baseUrl).slice(0, 2).join("\n"));
|
||||
params.runtime.exit(1);
|
||||
return params.nextConfig;
|
||||
}
|
||||
|
||||
await storeOllamaCredential(params.agentDir);
|
||||
|
||||
const enrichedModels = await enrichOllamaModelsWithContext(
|
||||
baseUrl,
|
||||
models.slice(0, OLLAMA_CONTEXT_ENRICH_LIMIT),
|
||||
);
|
||||
const discoveredModelsByName = new Map(enrichedModels.map((model) => [model.name, model]));
|
||||
const modelNames = models.map((model) => model.name);
|
||||
const orderedModelNames = mergeUniqueModelNames(OLLAMA_SUGGESTED_MODELS_LOCAL, modelNames);
|
||||
|
||||
const requestedDefaultModelId = explicitModel ?? OLLAMA_SUGGESTED_MODELS_LOCAL[0];
|
||||
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)) ??
|
||||
Array.from(availableModelNames)[0];
|
||||
params.runtime.log(
|
||||
`Ollama model ${requestedDefaultModelId} was not available; using ${defaultModelId} instead.`,
|
||||
);
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
}
|
||||
|
||||
function toLintErrorObject(value: unknown, fallbackMessage: string): Error {
|
||||
if (value instanceof Error) {
|
||||
return value;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return new Error(value);
|
||||
}
|
||||
const error = new Error(fallbackMessage, { cause: value });
|
||||
if ((typeof value === "object" && value !== null) || typeof value === "function") {
|
||||
Object.assign(error, value);
|
||||
}
|
||||
return error;
|
||||
}
|
||||
2962
extensions/ollama/src/stream-runtime.test.ts
Normal file
2962
extensions/ollama/src/stream-runtime.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
524
extensions/ollama/src/stream.test.ts
Normal file
524
extensions/ollama/src/stream.test.ts
Normal file
@@ -0,0 +1,524 @@
|
||||
// Ollama tests cover stream plugin behavior.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuardMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
}));
|
||||
|
||||
import { buildAssistantMessage, createOllamaStreamFn } from "./stream.js";
|
||||
|
||||
function makeOllamaResponse(params: {
|
||||
content?: string;
|
||||
thinking?: string;
|
||||
reasoning?: string;
|
||||
done_reason?: string;
|
||||
tool_calls?: Array<{ function: { name: string; arguments: Record<string, unknown> } }>;
|
||||
}) {
|
||||
return {
|
||||
model: "qwen3.5",
|
||||
created_at: new Date().toISOString(),
|
||||
message: {
|
||||
role: "assistant" as const,
|
||||
content: params.content ?? "",
|
||||
...(params.thinking != null ? { thinking: params.thinking } : {}),
|
||||
...(params.reasoning != null ? { reasoning: params.reasoning } : {}),
|
||||
...(params.tool_calls ? { tool_calls: params.tool_calls } : {}),
|
||||
},
|
||||
done: true,
|
||||
...(params.done_reason ? { done_reason: params.done_reason } : {}),
|
||||
prompt_eval_count: 100,
|
||||
eval_count: 50,
|
||||
};
|
||||
}
|
||||
|
||||
const MODEL_INFO = { api: "ollama", provider: "ollama", id: "qwen3.5" };
|
||||
|
||||
describe("buildAssistantMessage", () => {
|
||||
it("includes thinking block when response has thinking field", () => {
|
||||
const response = makeOllamaResponse({
|
||||
thinking: "Let me think about this",
|
||||
content: "The answer is 42",
|
||||
});
|
||||
const msg = buildAssistantMessage(response, MODEL_INFO);
|
||||
expect(msg.content).toHaveLength(2);
|
||||
expect(msg.content[0]).toEqual({ type: "thinking", thinking: "Let me think about this" });
|
||||
expect(msg.content[1]).toEqual({ type: "text", text: "The answer is 42" });
|
||||
});
|
||||
|
||||
it("includes thinking block when response has reasoning field", () => {
|
||||
const response = makeOllamaResponse({
|
||||
reasoning: "Step by step analysis",
|
||||
content: "Result is 7",
|
||||
});
|
||||
const msg = buildAssistantMessage(response, MODEL_INFO);
|
||||
expect(msg.content).toHaveLength(2);
|
||||
expect(msg.content[0]).toEqual({ type: "thinking", thinking: "Step by step analysis" });
|
||||
expect(msg.content[1]).toEqual({ type: "text", text: "Result is 7" });
|
||||
});
|
||||
|
||||
it("prefers thinking over reasoning when both are present", () => {
|
||||
const response = makeOllamaResponse({
|
||||
thinking: "From thinking field",
|
||||
reasoning: "From reasoning field",
|
||||
content: "Answer",
|
||||
});
|
||||
const msg = buildAssistantMessage(response, MODEL_INFO);
|
||||
expect(msg.content[0]).toEqual({ type: "thinking", thinking: "From thinking field" });
|
||||
});
|
||||
|
||||
it("omits thinking block when no thinking or reasoning field", () => {
|
||||
const response = makeOllamaResponse({
|
||||
content: "Just text",
|
||||
});
|
||||
const msg = buildAssistantMessage(response, MODEL_INFO);
|
||||
expect(msg.content).toHaveLength(1);
|
||||
expect(msg.content[0]).toEqual({ type: "text", text: "Just text" });
|
||||
});
|
||||
|
||||
it("omits thinking block when thinking field is empty", () => {
|
||||
const response = makeOllamaResponse({
|
||||
thinking: "",
|
||||
content: "Just text",
|
||||
});
|
||||
const msg = buildAssistantMessage(response, MODEL_INFO);
|
||||
expect(msg.content).toHaveLength(1);
|
||||
expect(msg.content[0]).toEqual({ type: "text", text: "Just text" });
|
||||
});
|
||||
|
||||
it("preserves output-budget length stops", () => {
|
||||
const response = makeOllamaResponse({
|
||||
content: "Partial answer",
|
||||
done_reason: "length",
|
||||
});
|
||||
const msg = buildAssistantMessage(response, MODEL_INFO);
|
||||
expect(msg.stopReason).toBe("length");
|
||||
});
|
||||
|
||||
it("keeps a length stop authoritative over complete-looking tool calls", () => {
|
||||
const response = makeOllamaResponse({
|
||||
done_reason: "length",
|
||||
tool_calls: [{ function: { name: "read", arguments: { path: "README.md" } } }],
|
||||
});
|
||||
const msg = buildAssistantMessage(response, MODEL_INFO);
|
||||
expect(msg.stopReason).toBe("length");
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOllamaStreamFn thinking events", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function makeNdjsonBody(chunks: Array<Record<string, unknown>>): ReadableStream<Uint8Array> {
|
||||
const encoder = new TextEncoder();
|
||||
const lines = chunks.map((c) => JSON.stringify(c) + "\n").join("");
|
||||
return new ReadableStream({
|
||||
start(controller) {
|
||||
controller.enqueue(encoder.encode(lines));
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function streamOllamaEvents(
|
||||
chunks: Array<Record<string, unknown>>,
|
||||
options: Parameters<ReturnType<typeof createOllamaStreamFn>>[2] = {},
|
||||
context: Parameters<ReturnType<typeof createOllamaStreamFn>>[1] = {
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
} as never,
|
||||
): Promise<Array<{ type: string; [key: string]: unknown }>> {
|
||||
const body = makeNdjsonBody(chunks);
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(body, { status: 200 }),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
const streamFn = createOllamaStreamFn("http://localhost:11434");
|
||||
const stream = streamFn(
|
||||
{ api: "ollama", provider: "ollama", id: "qwen3.5", contextWindow: 65536 } as never,
|
||||
context,
|
||||
options,
|
||||
);
|
||||
|
||||
const events: Array<{ type: string; [key: string]: unknown }> = [];
|
||||
for await (const event of stream as AsyncIterable<{
|
||||
type: string;
|
||||
[key: string]: unknown;
|
||||
}>) {
|
||||
events.push(event);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
it("emits thinking_start, thinking_delta, and thinking_end events for thinking content", async () => {
|
||||
const thinkingChunks = [
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
message: { role: "assistant", content: "", thinking: "Step 1" },
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:01Z",
|
||||
message: { role: "assistant", content: "", thinking: " and step 2" },
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:02Z",
|
||||
message: { role: "assistant", content: "The answer", thinking: "" },
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:03Z",
|
||||
message: { role: "assistant", content: "" },
|
||||
done: true,
|
||||
done_reason: "stop",
|
||||
prompt_eval_count: 10,
|
||||
eval_count: 5,
|
||||
},
|
||||
];
|
||||
|
||||
const events = await streamOllamaEvents(thinkingChunks);
|
||||
const eventTypes = events.map((e) => e.type);
|
||||
|
||||
expect(eventTypes).toContain("thinking_start");
|
||||
expect(eventTypes).toContain("thinking_delta");
|
||||
expect(eventTypes).toContain("thinking_end");
|
||||
expect(eventTypes).toContain("text_start");
|
||||
expect(eventTypes).toContain("text_delta");
|
||||
expect(eventTypes).toContain("done");
|
||||
|
||||
const thinkingStartIndex = eventTypes.indexOf("thinking_start");
|
||||
const textStartIndex = eventTypes.indexOf("text_start");
|
||||
expect(thinkingStartIndex).toBeLessThan(textStartIndex);
|
||||
|
||||
const thinkingEndIndex = eventTypes.indexOf("thinking_end");
|
||||
expect(thinkingEndIndex).toBeLessThan(textStartIndex);
|
||||
|
||||
const thinkingDeltas = events.filter((e) => e.type === "thinking_delta");
|
||||
expect(thinkingDeltas).toHaveLength(2);
|
||||
expect(thinkingDeltas[0].delta).toBe("Step 1");
|
||||
expect(thinkingDeltas[1].delta).toBe(" and step 2");
|
||||
|
||||
const thinkingStart = events.find((e) => e.type === "thinking_start");
|
||||
expect(thinkingStart?.contentIndex).toBe(0);
|
||||
const textStart = events.find((e) => e.type === "text_start");
|
||||
expect(textStart?.contentIndex).toBe(1);
|
||||
|
||||
const done = events.find((e) => e.type === "done") as { message?: { content: unknown[] } };
|
||||
const content = done?.message?.content ?? [];
|
||||
expect(content[0]).toEqual({ type: "thinking", thinking: "Step 1 and step 2" });
|
||||
expect(content[1]).toEqual({ type: "text", text: "The answer" });
|
||||
});
|
||||
|
||||
it("streams without thinking events when no thinking content is present", async () => {
|
||||
const chunks = [
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
message: { role: "assistant", content: "Hello" },
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:01Z",
|
||||
message: { role: "assistant", content: "" },
|
||||
done: true,
|
||||
done_reason: "stop",
|
||||
prompt_eval_count: 10,
|
||||
eval_count: 5,
|
||||
},
|
||||
];
|
||||
|
||||
const events = await streamOllamaEvents(chunks);
|
||||
const eventTypes = events.map((e) => e.type);
|
||||
expect(eventTypes).not.toContain("thinking_start");
|
||||
expect(eventTypes).not.toContain("thinking_delta");
|
||||
expect(eventTypes).not.toContain("thinking_end");
|
||||
expect(eventTypes).toContain("text_start");
|
||||
expect(eventTypes).toContain("text_delta");
|
||||
expect(eventTypes).toContain("done");
|
||||
|
||||
const textStart = events.find((e) => e.type === "text_start") as { contentIndex?: number };
|
||||
expect(textStart?.contentIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("emits length for a token-limited native stream", async () => {
|
||||
const events = await streamOllamaEvents([
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
message: { role: "assistant", content: "Partial answer" },
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:01Z",
|
||||
message: { role: "assistant", content: "" },
|
||||
done: true,
|
||||
done_reason: "length",
|
||||
prompt_eval_count: 10,
|
||||
eval_count: 5,
|
||||
},
|
||||
]);
|
||||
|
||||
const done = events.find((event) => event.type === "done") as {
|
||||
reason?: string;
|
||||
message?: { stopReason?: string };
|
||||
};
|
||||
expect(done.reason).toBe("length");
|
||||
expect(done.message?.stopReason).toBe("length");
|
||||
});
|
||||
|
||||
it("preserves a native length stop when the partial response contains tool calls", async () => {
|
||||
const events = await streamOllamaEvents(
|
||||
[
|
||||
makeOllamaResponse({
|
||||
done_reason: "length",
|
||||
tool_calls: [{ function: { name: "read", arguments: { path: "README.md" } } }],
|
||||
}),
|
||||
],
|
||||
{},
|
||||
{
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
tools: [{ name: "read", description: "Read files", parameters: { type: "object" } }],
|
||||
} as never,
|
||||
);
|
||||
|
||||
const done = events.find((event) => event.type === "done") as {
|
||||
reason?: string;
|
||||
message?: { content?: Array<Record<string, unknown>>; stopReason?: string };
|
||||
};
|
||||
expect(done.reason).toBe("length");
|
||||
expect(done.message?.stopReason).toBe("length");
|
||||
expect(done.message?.content).toEqual([
|
||||
expect.objectContaining({ type: "toolCall", name: "read" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses generic stream timeout for Ollama request timeout", async () => {
|
||||
await streamOllamaEvents([makeOllamaResponse({ content: "ok" })], { timeoutMs: 2500 });
|
||||
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith({
|
||||
url: "http://localhost:11434/api/chat",
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
model: "qwen3.5",
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
stream: true,
|
||||
options: {},
|
||||
}),
|
||||
},
|
||||
policy: {
|
||||
allowPrivateNetwork: true,
|
||||
hostnameAllowlist: ["localhost"],
|
||||
},
|
||||
timeoutMs: 2500,
|
||||
auditContext: "ollama-stream.chat",
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes standalone bracketed local-model tool text to a structured tool call", async () => {
|
||||
const rawToolText = [
|
||||
"[mempalace_mempalace_search]",
|
||||
'{"query":"codename","wing":"personal","room":"identities"}',
|
||||
"[END_TOOL_REQUEST]",
|
||||
].join("\n");
|
||||
|
||||
const events = await streamOllamaEvents(
|
||||
[
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
message: { role: "assistant", content: rawToolText },
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:01Z",
|
||||
message: { role: "assistant", content: "" },
|
||||
done: true,
|
||||
done_reason: "stop",
|
||||
prompt_eval_count: 10,
|
||||
eval_count: 5,
|
||||
},
|
||||
],
|
||||
{},
|
||||
{
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
tools: [
|
||||
{
|
||||
name: "mempalace_mempalace_search",
|
||||
description: "Search MemPalace",
|
||||
parameters: { type: "object", properties: {} },
|
||||
},
|
||||
],
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
"start",
|
||||
"toolcall_start",
|
||||
"toolcall_delta",
|
||||
"done",
|
||||
]);
|
||||
const done = events.find((event) => event.type === "done") as {
|
||||
message?: { content?: Array<Record<string, unknown>>; stopReason?: string };
|
||||
reason?: string;
|
||||
};
|
||||
expect(done.reason).toBe("toolUse");
|
||||
expect(done.message?.stopReason).toBe("toolUse");
|
||||
expect(done.message?.content?.[0]).toMatchObject({
|
||||
type: "toolCall",
|
||||
name: "mempalace_mempalace_search",
|
||||
arguments: { query: "codename", wing: "personal", room: "identities" },
|
||||
});
|
||||
});
|
||||
|
||||
it("promotes standalone Harmony local-model tool text to a structured tool call", async () => {
|
||||
const rawToolText =
|
||||
'commentary to=read code {"path":"/path/to/file","line_start":1,"line_end":400}';
|
||||
|
||||
const events = await streamOllamaEvents(
|
||||
[
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:00Z",
|
||||
message: { role: "assistant", content: rawToolText },
|
||||
done: false,
|
||||
},
|
||||
{
|
||||
model: "qwen3.5",
|
||||
created_at: "2026-01-01T00:00:01Z",
|
||||
message: { role: "assistant", content: "" },
|
||||
done: true,
|
||||
done_reason: "stop",
|
||||
prompt_eval_count: 10,
|
||||
eval_count: 5,
|
||||
},
|
||||
],
|
||||
{},
|
||||
{
|
||||
messages: [{ role: "user", content: "test" }],
|
||||
tools: [{ name: "read", description: "Read files", parameters: { type: "object" } }],
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(events.map((event) => event.type)).toEqual([
|
||||
"start",
|
||||
"toolcall_start",
|
||||
"toolcall_delta",
|
||||
"done",
|
||||
]);
|
||||
const done = events.find((event) => event.type === "done") as {
|
||||
message?: { content?: Array<Record<string, unknown>>; stopReason?: string };
|
||||
reason?: string;
|
||||
};
|
||||
expect(done.reason).toBe("toolUse");
|
||||
expect(done.message?.content?.[0]).toMatchObject({
|
||||
type: "toolCall",
|
||||
name: "read",
|
||||
arguments: { path: "/path/to/file", line_start: 1, line_end: 400 },
|
||||
});
|
||||
});
|
||||
|
||||
it("yields to the event loop while processing dense native stream chunks", async () => {
|
||||
const chunks = [
|
||||
...Array.from({ length: 65 }, (_value, index) => ({
|
||||
model: "qwen3.5",
|
||||
created_at: `2026-01-01T00:00:${String(index % 60).padStart(2, "0")}Z`,
|
||||
message: { role: "assistant" as const, content: "x" },
|
||||
done: false,
|
||||
})),
|
||||
makeOllamaResponse({ content: "" }),
|
||||
];
|
||||
const body = makeNdjsonBody(chunks);
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(body, { status: 200 }),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
const streamFn = createOllamaStreamFn("http://localhost:11434");
|
||||
const stream = streamFn(
|
||||
{ api: "ollama", provider: "ollama", id: "qwen3.5", contextWindow: 65536 } as never,
|
||||
{ messages: [{ role: "user", content: "test" }] } as never,
|
||||
{},
|
||||
);
|
||||
|
||||
let timerFired = false;
|
||||
const timerPromise = new Promise<void>((resolve) => {
|
||||
setTimeout(() => {
|
||||
timerFired = true;
|
||||
resolve();
|
||||
}, 0);
|
||||
});
|
||||
let yieldedBeforeDone = false;
|
||||
for await (const event of stream as AsyncIterable<{ type: string }>) {
|
||||
if (timerFired && event.type !== "done") {
|
||||
yieldedBeforeDone = true;
|
||||
}
|
||||
}
|
||||
await timerPromise;
|
||||
|
||||
expect(yieldedBeforeDone).toBe(true);
|
||||
});
|
||||
|
||||
it("reports caller aborts during dense native stream processing as aborted", async () => {
|
||||
const chunks = [
|
||||
...Array.from({ length: 65 }, (_value, index) => ({
|
||||
model: "qwen3.5",
|
||||
created_at: `2026-01-01T00:00:${String(index % 60).padStart(2, "0")}Z`,
|
||||
message: { role: "assistant" as const, content: "x" },
|
||||
done: false,
|
||||
})),
|
||||
makeOllamaResponse({ content: "" }),
|
||||
];
|
||||
const body = makeNdjsonBody(chunks);
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(body, { status: 200 }),
|
||||
release: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
const controller = new AbortController();
|
||||
const streamFn = createOllamaStreamFn("http://localhost:11434");
|
||||
const stream = streamFn(
|
||||
{ api: "ollama", provider: "ollama", id: "qwen3.5", contextWindow: 65536 } as never,
|
||||
{ messages: [{ role: "user", content: "test" }] } as never,
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
controller.abort();
|
||||
}, 0);
|
||||
|
||||
const events: Array<{ type: string; reason?: string; error?: { stopReason?: string } }> = [];
|
||||
for await (const event of stream as AsyncIterable<{
|
||||
type: string;
|
||||
reason?: string;
|
||||
error?: { stopReason?: string };
|
||||
}>) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
const lastEvent = events.at(-1);
|
||||
expect(lastEvent).toMatchObject({
|
||||
type: "error",
|
||||
reason: "aborted",
|
||||
error: { stopReason: "aborted" },
|
||||
});
|
||||
});
|
||||
});
|
||||
1466
extensions/ollama/src/stream.ts
Normal file
1466
extensions/ollama/src/stream.ts
Normal file
File diff suppressed because it is too large
Load Diff
515
extensions/ollama/src/web-search-provider.test.ts
Normal file
515
extensions/ollama/src/web-search-provider.test.ts
Normal file
@@ -0,0 +1,515 @@
|
||||
// Ollama tests cover web search provider plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createStreamingResponse } from "../../test-support/streaming-error-response.js";
|
||||
import { createOllamaWebSearchProvider as createContractOllamaWebSearchProvider } from "../web-search-contract-api.js";
|
||||
import {
|
||||
testing,
|
||||
createOllamaWebSearchProvider,
|
||||
runOllamaWebSearch,
|
||||
} from "./web-search-provider.js";
|
||||
|
||||
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuardMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
}));
|
||||
|
||||
type OllamaProviderConfigOverride = Partial<{
|
||||
api: "ollama";
|
||||
apiKey: string;
|
||||
baseUrl: string;
|
||||
baseURL: string;
|
||||
models: NonNullable<
|
||||
NonNullable<NonNullable<OpenClawConfig["models"]>["providers"]>[string]
|
||||
>["models"];
|
||||
}>;
|
||||
|
||||
function createOllamaConfig(provider: OllamaProviderConfigOverride = {}): OpenClawConfig {
|
||||
return {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://ollama.local:11434/v1",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
...provider,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createOllamaConfigWithWebSearchBaseUrl(baseUrl: string): OpenClawConfig {
|
||||
return {
|
||||
...createOllamaConfig(),
|
||||
plugins: {
|
||||
entries: {
|
||||
ollama: {
|
||||
config: {
|
||||
webSearch: {
|
||||
baseUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createSetupNotes() {
|
||||
const notes: Array<{ title?: string; message: string }> = [];
|
||||
return {
|
||||
notes,
|
||||
prompter: {
|
||||
note: async (message: string, title?: string) => {
|
||||
notes.push({ title, message });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function expectOllamaWebSearchRequest(
|
||||
call: unknown[] | undefined,
|
||||
params: {
|
||||
url: string;
|
||||
query?: string;
|
||||
maxResults?: number;
|
||||
headers?: Record<string, string>;
|
||||
policy: Record<string, unknown>;
|
||||
},
|
||||
) {
|
||||
if (!call?.[0] || typeof call[0] !== "object") {
|
||||
throw new Error("Expected fetchWithSsrFGuard call");
|
||||
}
|
||||
const request = call[0] as {
|
||||
url: string;
|
||||
init: {
|
||||
method: string;
|
||||
headers: Record<string, string>;
|
||||
body: string;
|
||||
signal: AbortSignal;
|
||||
};
|
||||
policy: Record<string, unknown>;
|
||||
auditContext: string;
|
||||
};
|
||||
expect(request).toEqual({
|
||||
url: params.url,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: params.headers ?? { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
query: params.query ?? "openclaw",
|
||||
max_results: params.maxResults ?? 5,
|
||||
}),
|
||||
signal: request.init.signal,
|
||||
},
|
||||
policy: params.policy,
|
||||
auditContext: "ollama-web-search.search",
|
||||
});
|
||||
expect(request.init.signal).toBeInstanceOf(AbortSignal);
|
||||
}
|
||||
|
||||
function fetchCall(index = 0): unknown[] {
|
||||
const call = fetchWithSsrFGuardMock.mock.calls.at(index);
|
||||
if (!call) {
|
||||
throw new Error(`expected guarded fetch call ${index}`);
|
||||
}
|
||||
return call;
|
||||
}
|
||||
|
||||
function fetchRequest(index = 0): {
|
||||
init?: { headers?: Record<string, string> };
|
||||
url?: string;
|
||||
} {
|
||||
const request = fetchCall(index).at(0);
|
||||
if (!request || typeof request !== "object") {
|
||||
throw new Error(`expected guarded fetch request ${index}`);
|
||||
}
|
||||
return request as {
|
||||
init?: { headers?: Record<string, string> };
|
||||
url?: string;
|
||||
};
|
||||
}
|
||||
|
||||
function expectSingleSearchResultUrl(results: unknown, url: string) {
|
||||
if (!Array.isArray(results)) {
|
||||
throw new Error("Expected search results array");
|
||||
}
|
||||
expect(results).toHaveLength(1);
|
||||
const [result] = results;
|
||||
if (!result || typeof result !== "object") {
|
||||
throw new Error("Expected search result object");
|
||||
}
|
||||
expect((result as { url?: unknown }).url).toBe(url);
|
||||
}
|
||||
|
||||
describe("ollama web search provider", () => {
|
||||
beforeEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
});
|
||||
|
||||
it("registers a keyless web search provider", () => {
|
||||
const provider = createContractOllamaWebSearchProvider();
|
||||
|
||||
expect(provider.id).toBe("ollama");
|
||||
expect(provider.label).toBe("Ollama Web Search");
|
||||
expect(provider.requiresCredential).toBe(false);
|
||||
expect(provider.envVars).toEqual([]);
|
||||
});
|
||||
|
||||
it("uses the configured Ollama host and enables the plugin in config", () => {
|
||||
const provider = createOllamaWebSearchProvider();
|
||||
if (!provider.applySelectionConfig) {
|
||||
throw new Error("Expected applySelectionConfig to be defined");
|
||||
}
|
||||
|
||||
const applied = provider.applySelectionConfig({});
|
||||
|
||||
expect(provider.credentialPath).toBe("");
|
||||
expect(applied.plugins?.entries?.ollama?.enabled).toBe(true);
|
||||
expect(
|
||||
testing.resolveOllamaWebSearchBaseUrl({
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://ollama.local:11434/v1",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe("http://ollama.local:11434");
|
||||
});
|
||||
|
||||
it("prefers the plugin web search base URL over the model provider host", () => {
|
||||
expect(
|
||||
testing.resolveOllamaWebSearchBaseUrl(
|
||||
createOllamaConfigWithWebSearchBaseUrl("http://localhost:11434/v1"),
|
||||
),
|
||||
).toBe("http://localhost:11434");
|
||||
});
|
||||
|
||||
it("uses the configured Ollama Cloud host for web search", () => {
|
||||
expect(
|
||||
testing.resolveOllamaWebSearchBaseUrl(
|
||||
createOllamaConfig({
|
||||
baseUrl: "https://ollama.com",
|
||||
}),
|
||||
),
|
||||
).toBe("https://ollama.com");
|
||||
});
|
||||
|
||||
it("uses the model provider baseURL alias for web search", () => {
|
||||
expect(
|
||||
testing.resolveOllamaWebSearchBaseUrl(
|
||||
createOllamaConfig({
|
||||
baseUrl: undefined,
|
||||
baseURL: "http://remote-ollama:11434/v1",
|
||||
} as OllamaProviderConfigOverride),
|
||||
),
|
||||
).toBe("http://remote-ollama:11434");
|
||||
});
|
||||
|
||||
it("maps generic search args into the local Ollama proxy endpoint", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
results: [
|
||||
{
|
||||
title: "OpenClaw",
|
||||
url: "https://openclaw.ai/docs",
|
||||
content: "Gateway docs and setup details",
|
||||
},
|
||||
],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
release,
|
||||
});
|
||||
|
||||
const provider = createOllamaWebSearchProvider();
|
||||
const tool = provider.createTool({
|
||||
config: createOllamaConfig(),
|
||||
} as never);
|
||||
if (!tool) {
|
||||
throw new Error("Expected tool definition");
|
||||
}
|
||||
const result = await tool.execute({ query: "openclaw docs", count: 3 });
|
||||
|
||||
expectOllamaWebSearchRequest(fetchCall(), {
|
||||
url: "http://ollama.local:11434/api/experimental/web_search",
|
||||
query: "openclaw docs",
|
||||
maxResults: 3,
|
||||
policy: {
|
||||
allowPrivateNetwork: true,
|
||||
hostnameAllowlist: ["ollama.local"],
|
||||
},
|
||||
});
|
||||
expect(result.query).toBe("openclaw docs");
|
||||
expect(result.provider).toBe("ollama");
|
||||
expect(result.count).toBe(1);
|
||||
expectSingleSearchResultUrl(result.results, "https://openclaw.ai/docs");
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("tries the future local direct endpoint when the local proxy endpoint is missing", async () => {
|
||||
fetchWithSsrFGuardMock
|
||||
.mockResolvedValueOnce({
|
||||
response: new Response("not found", { status: 404 }),
|
||||
release: vi.fn(async () => {}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
results: [{ title: "Legacy", url: "https://example.com", content: "result" }],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const result = await runOllamaWebSearch({
|
||||
config: createOllamaConfig(),
|
||||
query: "openclaw",
|
||||
});
|
||||
|
||||
expect(result.count).toBe(1);
|
||||
expectSingleSearchResultUrl(result.results, "https://example.com");
|
||||
|
||||
expect(fetchWithSsrFGuardMock.mock.calls.map((call) => call[0].url)).toEqual([
|
||||
"http://ollama.local:11434/api/experimental/web_search",
|
||||
"http://ollama.local:11434/api/web_search",
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses only the hosted endpoint for Ollama Cloud base URLs", async () => {
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
results: [{ title: "Cloud", url: "https://example.com", content: "result" }],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const result = await runOllamaWebSearch({
|
||||
config: createOllamaConfig({
|
||||
baseUrl: "https://ollama.com",
|
||||
apiKey: "cloud-config-secret",
|
||||
}),
|
||||
query: "openclaw",
|
||||
});
|
||||
|
||||
expect(result.count).toBe(1);
|
||||
expect(fetchWithSsrFGuardMock.mock.calls).toHaveLength(1);
|
||||
expect(fetchRequest().url).toBe("https://ollama.com/api/web_search");
|
||||
expectOllamaWebSearchRequest(fetchCall(), {
|
||||
url: "https://ollama.com/api/web_search",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer cloud-config-secret",
|
||||
},
|
||||
policy: {
|
||||
allowPrivateNetwork: true,
|
||||
hostnameAllowlist: ["ollama.com"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses an env Ollama key only for the cloud fallback from a local host", async () => {
|
||||
const original = process.env.OLLAMA_API_KEY;
|
||||
try {
|
||||
process.env.OLLAMA_API_KEY = "cloud-secret";
|
||||
fetchWithSsrFGuardMock
|
||||
.mockResolvedValueOnce({
|
||||
response: new Response("not found", { status: 404 }),
|
||||
release: vi.fn(async () => {}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
response: new Response("not found", { status: 404 }),
|
||||
release: vi.fn(async () => {}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
results: [{ title: "Cloud", url: "https://example.com", content: "result" }],
|
||||
}),
|
||||
{
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const result = await runOllamaWebSearch({
|
||||
config: createOllamaConfig(),
|
||||
query: "openclaw",
|
||||
});
|
||||
|
||||
expect(result.count).toBe(1);
|
||||
const firstHeaders = fetchRequest().init?.headers;
|
||||
const cloudHeaders = fetchRequest(2).init?.headers;
|
||||
expect(firstHeaders?.Authorization).toBeUndefined();
|
||||
expect(cloudHeaders?.Authorization).toBe("Bearer cloud-secret");
|
||||
expect(fetchWithSsrFGuardMock.mock.calls.map((call) => call[0].url)).toEqual([
|
||||
"http://ollama.local:11434/api/experimental/web_search",
|
||||
"http://ollama.local:11434/api/web_search",
|
||||
"https://ollama.com/api/web_search",
|
||||
]);
|
||||
expect(fetchRequest(2).url).toBe("https://ollama.com/api/web_search");
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.OLLAMA_API_KEY;
|
||||
} else {
|
||||
process.env.OLLAMA_API_KEY = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("surfaces Ollama signin guidance for 401 responses", async () => {
|
||||
fetchWithSsrFGuardMock.mockResolvedValue({
|
||||
response: new Response("", { status: 401 }),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
await expect(runOllamaWebSearch({ query: "latest openclaw release" })).rejects.toThrow(
|
||||
"ollama signin",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports malformed Ollama web search JSON with a stable provider error", async () => {
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: new Response("{ nope", { status: 200 }),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runOllamaWebSearch({
|
||||
config: createOllamaConfig(),
|
||||
query: "openclaw",
|
||||
}),
|
||||
).rejects.toThrow("Ollama web search: malformed JSON response");
|
||||
});
|
||||
|
||||
it("bounds successful Ollama web search JSON bodies before parsing", async () => {
|
||||
const streamed = createStreamingResponse({
|
||||
chunkCount: 32,
|
||||
chunkSize: 1024 * 1024,
|
||||
text: "x",
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
const jsonSpy = vi.spyOn(streamed.response, "json").mockRejectedValue(new Error("unbounded"));
|
||||
fetchWithSsrFGuardMock.mockResolvedValueOnce({
|
||||
response: streamed.response,
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
runOllamaWebSearch({
|
||||
config: createOllamaConfig(),
|
||||
query: "openclaw",
|
||||
}),
|
||||
).rejects.toThrow("Ollama web search: JSON response exceeds 16777216 bytes");
|
||||
|
||||
expect(streamed.getReadCount()).toBeLessThan(32);
|
||||
expect(streamed.wasCanceled()).toBe(true);
|
||||
expect(jsonSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("warns when Ollama is not reachable during setup without cancelling", async () => {
|
||||
fetchWithSsrFGuardMock.mockRejectedValueOnce(new Error("connect failed"));
|
||||
|
||||
const config = createOllamaConfig();
|
||||
const { notes, prompter } = createSetupNotes();
|
||||
|
||||
const next = await testing.warnOllamaWebSearchPrereqs({
|
||||
config,
|
||||
prompter,
|
||||
});
|
||||
|
||||
expect(next).toBe(config);
|
||||
expect(notes).toEqual([
|
||||
{
|
||||
title: "Ollama Web Search",
|
||||
message: [
|
||||
"Ollama Web Search requires Ollama to be running.",
|
||||
"Expected host: http://ollama.local:11434",
|
||||
"Start Ollama before using this provider.",
|
||||
].join("\n"),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves env var when config apiKey is a marker string", () => {
|
||||
const original = process.env.OLLAMA_API_KEY;
|
||||
try {
|
||||
process.env.OLLAMA_API_KEY = "real-secret-from-env";
|
||||
const key = testing.resolveOllamaWebSearchApiKey(
|
||||
createOllamaConfig({
|
||||
apiKey: "OLLAMA_API_KEY",
|
||||
baseUrl: "http://localhost:11434",
|
||||
}),
|
||||
);
|
||||
expect(key).toBe("real-secret-from-env");
|
||||
} finally {
|
||||
if (original === undefined) {
|
||||
delete process.env.OLLAMA_API_KEY;
|
||||
} else {
|
||||
process.env.OLLAMA_API_KEY = original;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("warns when ollama signin is missing during setup without cancelling", async () => {
|
||||
fetchWithSsrFGuardMock
|
||||
.mockResolvedValueOnce({
|
||||
response: new Response(JSON.stringify({ models: [] }), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
response: new Response(
|
||||
JSON.stringify({ error: "not signed in", signin_url: "https://ollama.com/signin" }),
|
||||
{
|
||||
status: 401,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const config = createOllamaConfig();
|
||||
const { notes, prompter } = createSetupNotes();
|
||||
|
||||
const next = await testing.warnOllamaWebSearchPrereqs({
|
||||
config,
|
||||
prompter,
|
||||
});
|
||||
|
||||
expect(next).toBe(config);
|
||||
expect(notes).toEqual([
|
||||
{
|
||||
title: "Ollama Web Search",
|
||||
message: "Ollama Web Search requires `ollama signin`.\nhttps://ollama.com/signin",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
351
extensions/ollama/src/web-search-provider.ts
Normal file
351
extensions/ollama/src/web-search-provider.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
// 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,
|
||||
truncateText,
|
||||
wrapWebContent,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
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 resolveConfiguredOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined {
|
||||
const providerApiKey = normalizeOptionalSecretInput(config?.models?.providers?.ollama?.apiKey);
|
||||
if (providerApiKey && !isNonSecretApiKeyMarker(providerApiKey)) {
|
||||
return providerApiKey;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resolveEnvOllamaWebSearchApiKey(): string | undefined {
|
||||
return resolveEnvApiKey("ollama")?.apiKey;
|
||||
}
|
||||
|
||||
function resolveOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined {
|
||||
return resolveConfiguredOllamaWebSearchApiKey(config) ?? resolveEnvOllamaWebSearchApiKey();
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export async function runOllamaWebSearch(params: {
|
||||
config?: OpenClawConfig;
|
||||
query: string;
|
||||
count?: number;
|
||||
}): 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);
|
||||
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) {
|
||||
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,
|
||||
signal: AbortSignal.timeout(DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS),
|
||||
},
|
||||
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);
|
||||
break;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
lastError = error;
|
||||
} else {
|
||||
lastError = new Error(String(error));
|
||||
}
|
||||
throw lastError;
|
||||
} finally {
|
||||
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) =>
|
||||
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.",
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const testing = {
|
||||
buildOllamaWebSearchAttempts,
|
||||
normalizeOllamaWebSearchResult,
|
||||
resolveConfiguredOllamaWebSearchApiKey,
|
||||
resolveEnvOllamaWebSearchApiKey,
|
||||
resolveOllamaWebSearchApiKey,
|
||||
resolveOllamaWebSearchBaseUrl,
|
||||
isOllamaCloudBaseUrl,
|
||||
readOllamaWebSearchResponse,
|
||||
warnOllamaWebSearchPrereqs,
|
||||
};
|
||||
export { testing as __testing };
|
||||
158
extensions/ollama/src/wsl2-crash-loop-check.test.ts
Normal file
158
extensions/ollama/src/wsl2-crash-loop-check.test.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
// Ollama tests cover wsl2 crash loop check plugin behavior.
|
||||
import { promisify } from "node:util";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { isWSL2SyncMock } = vi.hoisted(() => ({
|
||||
isWSL2SyncMock: vi.fn(() => false),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/runtime-env", () => ({
|
||||
isWSL2Sync: isWSL2SyncMock,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
access: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("node:child_process", async () => {
|
||||
const { promisify: realPromisify } = await import("node:util");
|
||||
const mockExecFile = vi.fn();
|
||||
const execFilePromise = vi.fn();
|
||||
(mockExecFile as unknown as Record<symbol, unknown>)[realPromisify.custom] = execFilePromise;
|
||||
return { execFile: mockExecFile };
|
||||
});
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { access } from "node:fs/promises";
|
||||
import {
|
||||
checkWsl2CrashLoopRisk,
|
||||
hasWslCuda,
|
||||
isOllamaEnabledWithRestartAlways,
|
||||
parseSystemctlShowProperties,
|
||||
} from "./wsl2-crash-loop-check.js";
|
||||
|
||||
const accessMock = vi.mocked(access);
|
||||
const execFileMock = execFile as unknown as ReturnType<typeof vi.fn> & {
|
||||
[key: symbol]: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
const execFilePromiseMock = vi.mocked(execFileMock[promisify.custom]);
|
||||
|
||||
function createLogger() {
|
||||
return {
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function mockSystemctl(stdout: string): void {
|
||||
execFilePromiseMock.mockResolvedValue({ stdout, stderr: "" });
|
||||
}
|
||||
|
||||
describe("wsl2 crash-loop check", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
isWSL2SyncMock.mockReturnValue(false);
|
||||
});
|
||||
|
||||
it("parses systemctl show properties", () => {
|
||||
expect(
|
||||
parseSystemctlShowProperties("UnitFileState=enabled\nRestart=always\nIgnoredLine\n"),
|
||||
).toEqual(
|
||||
new Map([
|
||||
["UnitFileState", "enabled"],
|
||||
["Restart", "always"],
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("detects enabled Restart=always ollama service", async () => {
|
||||
mockSystemctl("UnitFileState=enabled\nRestart=always\n");
|
||||
|
||||
await expect(isOllamaEnabledWithRestartAlways()).resolves.toBe(true);
|
||||
|
||||
expect(execFilePromiseMock).toHaveBeenCalledWith(
|
||||
"systemctl",
|
||||
["show", "ollama.service", "--property=UnitFileState,Restart", "--no-pager"],
|
||||
{ timeout: 5000 },
|
||||
);
|
||||
});
|
||||
|
||||
it("does not treat enabled-runtime as persistent autostart", async () => {
|
||||
mockSystemctl("UnitFileState=enabled-runtime\nRestart=always\n");
|
||||
|
||||
await expect(isOllamaEnabledWithRestartAlways()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("requires Restart=always", async () => {
|
||||
mockSystemctl("UnitFileState=enabled\nRestart=on-failure\n");
|
||||
|
||||
await expect(isOllamaEnabledWithRestartAlways()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when systemctl is unavailable", async () => {
|
||||
execFilePromiseMock.mockRejectedValue(new Error("systemd unavailable"));
|
||||
|
||||
await expect(isOllamaEnabledWithRestartAlways()).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("detects CUDA from the first available WSL marker", async () => {
|
||||
accessMock.mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(hasWslCuda()).resolves.toBe(true);
|
||||
expect(accessMock).toHaveBeenCalledWith("/dev/dxg");
|
||||
});
|
||||
|
||||
it("checks the remaining CUDA markers before returning false", async () => {
|
||||
accessMock.mockRejectedValue(new Error("missing"));
|
||||
|
||||
await expect(hasWslCuda()).resolves.toBe(false);
|
||||
expect(accessMock).toHaveBeenCalledTimes(4);
|
||||
});
|
||||
|
||||
it("warns for WSL2 plus Ollama autostart plus CUDA", async () => {
|
||||
isWSL2SyncMock.mockReturnValue(true);
|
||||
mockSystemctl("UnitFileState=enabled\nRestart=always\n");
|
||||
accessMock.mockResolvedValueOnce(undefined);
|
||||
const logger = createLogger();
|
||||
|
||||
await checkWsl2CrashLoopRisk(logger);
|
||||
|
||||
expect(logger.warn).toHaveBeenCalledTimes(1);
|
||||
const message = String(logger.warn.mock.calls.at(0)?.[0]);
|
||||
expect(message).toContain("WSL2 crash-loop risk");
|
||||
expect(message).toContain("sudo systemctl disable ollama");
|
||||
expect(message).toContain("autoMemoryReclaim=disabled");
|
||||
expect(message).toContain("OLLAMA_KEEP_ALIVE=5m");
|
||||
});
|
||||
|
||||
it("does not probe systemd outside WSL2", async () => {
|
||||
const logger = createLogger();
|
||||
|
||||
await checkWsl2CrashLoopRisk(logger);
|
||||
|
||||
expect(execFilePromiseMock).not.toHaveBeenCalled();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not warn when CUDA is not visible", async () => {
|
||||
isWSL2SyncMock.mockReturnValue(true);
|
||||
mockSystemctl("UnitFileState=enabled\nRestart=always\n");
|
||||
accessMock.mockRejectedValue(new Error("missing"));
|
||||
const logger = createLogger();
|
||||
|
||||
await checkWsl2CrashLoopRisk(logger);
|
||||
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never throws from advisory checks", async () => {
|
||||
isWSL2SyncMock.mockReturnValue(true);
|
||||
execFilePromiseMock.mockRejectedValue(new Error("boom"));
|
||||
const logger = createLogger();
|
||||
|
||||
await expect(checkWsl2CrashLoopRisk(logger)).resolves.toBeUndefined();
|
||||
expect(logger.warn).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
85
extensions/ollama/src/wsl2-crash-loop-check.ts
Normal file
85
extensions/ollama/src/wsl2-crash-loop-check.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// Ollama plugin module implements wsl2 crash loop check behavior.
|
||||
import { execFile } from "node:child_process";
|
||||
import { access } from "node:fs/promises";
|
||||
import { promisify } from "node:util";
|
||||
import type { PluginLogger } from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { isWSL2Sync } from "openclaw/plugin-sdk/runtime-env";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const SYSTEMCTL_TIMEOUT_MS = 5_000;
|
||||
const WSL_CUDA_MARKERS = [
|
||||
"/dev/dxg",
|
||||
"/usr/lib/wsl/lib/nvidia-smi",
|
||||
"/usr/lib/wsl/lib/libcuda.so.1",
|
||||
"/usr/local/cuda",
|
||||
];
|
||||
|
||||
export function parseSystemctlShowProperties(stdout: string): Map<string, string> {
|
||||
const properties = new Map<string, string>();
|
||||
for (const line of stdout.split(/\r?\n/u)) {
|
||||
const separator = line.indexOf("=");
|
||||
if (separator <= 0) {
|
||||
continue;
|
||||
}
|
||||
properties.set(line.slice(0, separator), line.slice(separator + 1));
|
||||
}
|
||||
return properties;
|
||||
}
|
||||
|
||||
export async function isOllamaEnabledWithRestartAlways(): Promise<boolean> {
|
||||
try {
|
||||
const { stdout } = await execFileAsync(
|
||||
"systemctl",
|
||||
["show", "ollama.service", "--property=UnitFileState,Restart", "--no-pager"],
|
||||
{ timeout: SYSTEMCTL_TIMEOUT_MS },
|
||||
);
|
||||
const properties = parseSystemctlShowProperties(stdout);
|
||||
return properties.get("UnitFileState") === "enabled" && properties.get("Restart") === "always";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function hasWslCuda(): Promise<boolean> {
|
||||
for (const marker of WSL_CUDA_MARKERS) {
|
||||
try {
|
||||
await access(marker);
|
||||
return true;
|
||||
} catch {
|
||||
// Try the next cheap marker.
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function checkWsl2CrashLoopRisk(logger: PluginLogger): Promise<void> {
|
||||
try {
|
||||
if (!isWSL2Sync()) {
|
||||
return;
|
||||
}
|
||||
if (!(await isOllamaEnabledWithRestartAlways())) {
|
||||
return;
|
||||
}
|
||||
if (!(await hasWslCuda())) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
[
|
||||
"[ollama] WSL2 crash-loop risk: ollama.service is enabled with Restart=always and CUDA is visible.",
|
||||
"On WSL2, GPU-backed Ollama can pin host memory while loading a model.",
|
||||
"Hyper-V memory reclaim cannot always reclaim those pinned pages, so Windows can terminate and restart the WSL2 VM.",
|
||||
"",
|
||||
"Common evidence: repeated WSL2 reboots, high CPU in app.slice at startup, and SIGTERM from systemd rather than the Linux OOM killer.",
|
||||
"See: https://github.com/ollama/ollama/issues/11317",
|
||||
"",
|
||||
"Mitigation:",
|
||||
" 1. Disable autostart: sudo systemctl disable ollama",
|
||||
" 2. Add [experimental] autoMemoryReclaim=disabled to %USERPROFILE%\\.wslconfig on Windows, then run wsl --shutdown",
|
||||
" 3. Set OLLAMA_KEEP_ALIVE=5m in the Ollama service environment or start ollama serve manually when needed",
|
||||
].join("\n"),
|
||||
);
|
||||
} catch {
|
||||
// Advisory only: never break provider registration or model discovery.
|
||||
}
|
||||
}
|
||||
16
extensions/ollama/tsconfig.json
Normal file
16
extensions/ollama/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../tsconfig.package-boundary.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "."
|
||||
},
|
||||
"include": ["./*.ts", "./src/**/*.ts"],
|
||||
"exclude": [
|
||||
"./**/*.test.ts",
|
||||
"./dist/**",
|
||||
"./node_modules/**",
|
||||
"./src/test-support/**",
|
||||
"./src/**/*test-helpers.ts",
|
||||
"./src/**/*test-harness.ts",
|
||||
"./src/**/*test-support.ts"
|
||||
]
|
||||
}
|
||||
27
extensions/ollama/web-search-contract-api.ts
Normal file
27
extensions/ollama/web-search-contract-api.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// Ollama API module exposes the plugin public contract.
|
||||
import {
|
||||
createWebSearchProviderContractFields,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
|
||||
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: "",
|
||||
...createWebSearchProviderContractFields({
|
||||
credentialPath: "",
|
||||
searchCredential: { type: "none" },
|
||||
selectionPluginId: "ollama",
|
||||
}),
|
||||
createTool: () => null,
|
||||
};
|
||||
}
|
||||
2
extensions/ollama/web-search-provider.ts
Normal file
2
extensions/ollama/web-search-provider.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Ollama provider module implements model/runtime integration.
|
||||
export { createOllamaWebSearchProvider } from "./src/web-search-provider.js";
|
||||
Reference in New Issue
Block a user