mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-14 01:53:26 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
3
openclaw/extensions/ollama/README.md
Normal file
3
openclaw/extensions/ollama/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Ollama Provider
|
||||
|
||||
Bundled provider plugin for Ollama discovery and setup.
|
||||
34
openclaw/extensions/ollama/api.ts
Normal file
34
openclaw/extensions/ollama/api.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
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";
|
||||
587
openclaw/extensions/ollama/index.test.ts
Normal file
587
openclaw/extensions/ollama/index.test.ts
Normal file
|
|
@ -0,0 +1,587 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestPluginApi } from "../../test/helpers/plugins/plugin-api.js";
|
||||
import plugin from "./index.js";
|
||||
|
||||
const promptAndConfigureOllamaMock = vi.hoisted(() =>
|
||||
vi.fn(async () => ({
|
||||
credential: "ollama-local",
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})),
|
||||
);
|
||||
const ensureOllamaModelPulledMock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
const buildOllamaProviderMock = vi.hoisted(() => vi.fn());
|
||||
const createConfiguredOllamaStreamFnMock = vi.hoisted(() =>
|
||||
vi.fn((_params: { model: unknown; providerBaseUrl?: string }) => ({}) as never),
|
||||
);
|
||||
|
||||
vi.mock("./api.js", () => ({
|
||||
promptAndConfigureOllama: promptAndConfigureOllamaMock,
|
||||
ensureOllamaModelPulled: ensureOllamaModelPulledMock,
|
||||
configureOllamaNonInteractive: vi.fn(),
|
||||
buildOllamaProvider: buildOllamaProviderMock,
|
||||
}));
|
||||
|
||||
vi.mock("./src/stream.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./src/stream.js")>();
|
||||
return {
|
||||
...actual,
|
||||
createConfiguredOllamaStreamFn: createConfiguredOllamaStreamFnMock,
|
||||
};
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
promptAndConfigureOllamaMock.mockClear();
|
||||
ensureOllamaModelPulledMock.mockClear();
|
||||
buildOllamaProviderMock.mockReset();
|
||||
createConfiguredOllamaStreamFnMock.mockClear();
|
||||
});
|
||||
|
||||
function registerProvider() {
|
||||
return registerProviderWithPluginConfig({});
|
||||
}
|
||||
|
||||
function registerProviderWithPluginConfig(pluginConfig: Record<string, unknown>) {
|
||||
const registerProviderMock = vi.fn();
|
||||
|
||||
plugin.register(
|
||||
createTestPluginApi({
|
||||
id: "ollama",
|
||||
name: "Ollama",
|
||||
source: "test",
|
||||
config: {},
|
||||
pluginConfig,
|
||||
runtime: {} as never,
|
||||
registerProvider: registerProviderMock,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(registerProviderMock).toHaveBeenCalledTimes(1);
|
||||
return registerProviderMock.mock.calls[0]?.[0];
|
||||
}
|
||||
|
||||
describe("ollama plugin", () => {
|
||||
it("does not preselect a default model during provider auth setup", async () => {
|
||||
const provider = registerProvider();
|
||||
|
||||
const result = await provider.auth[0].run({
|
||||
config: {},
|
||||
prompter: {} as never,
|
||||
isRemote: false,
|
||||
openUrl: vi.fn(async () => undefined),
|
||||
});
|
||||
|
||||
expect(promptAndConfigureOllamaMock).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
env: undefined,
|
||||
opts: undefined,
|
||||
prompter: {},
|
||||
secretInputMode: undefined,
|
||||
allowSecretRefPrompt: undefined,
|
||||
});
|
||||
expect(result.configPatch).toEqual({
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(result.defaultModel).toBeUndefined();
|
||||
});
|
||||
|
||||
it("pulls the model the user actually selected", async () => {
|
||||
const provider = registerProvider();
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const prompter = {} as never;
|
||||
|
||||
await provider.onModelSelected?.({
|
||||
config,
|
||||
model: "ollama/gemma4",
|
||||
prompter,
|
||||
});
|
||||
|
||||
expect(ensureOllamaModelPulledMock).toHaveBeenCalledWith({
|
||||
config,
|
||||
model: "ollama/gemma4",
|
||||
prompter,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips ambient discovery when plugin discovery is disabled", async () => {
|
||||
const provider = registerProviderWithPluginConfig({ discovery: { enabled: false } });
|
||||
|
||||
const result = await provider.discovery.run({
|
||||
config: {},
|
||||
env: {},
|
||||
resolveProviderApiKey: () => ({ apiKey: "", discoveryApiKey: "" }),
|
||||
} as never);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(buildOllamaProviderMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps empty default-ish provider stubs quiet", async () => {
|
||||
const provider = registerProvider();
|
||||
buildOllamaProviderMock.mockResolvedValueOnce({
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
});
|
||||
|
||||
const result = await provider.discovery.run({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { NODE_ENV: "development" },
|
||||
resolveProviderApiKey: () => ({ apiKey: "" }),
|
||||
} as never);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(buildOllamaProviderMock).toHaveBeenCalledWith("http://127.0.0.1:11434", {
|
||||
quiet: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats non-default baseUrl as explicit discovery config", async () => {
|
||||
const provider = registerProvider();
|
||||
buildOllamaProviderMock.mockResolvedValueOnce({
|
||||
baseUrl: "http://remote-ollama:11434",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
});
|
||||
|
||||
const result = await provider.discovery.run({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
baseUrl: "http://remote-ollama:11434",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
env: { NODE_ENV: "development" },
|
||||
resolveProviderApiKey: () => ({ apiKey: "" }),
|
||||
} as never);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(buildOllamaProviderMock).toHaveBeenCalledWith("http://remote-ollama:11434", {
|
||||
quiet: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps stored ollama-local marker auth on the quiet ambient path", async () => {
|
||||
const provider = registerProvider();
|
||||
buildOllamaProviderMock.mockResolvedValueOnce({
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
});
|
||||
|
||||
const result = await provider.discovery.run({
|
||||
config: {},
|
||||
env: { NODE_ENV: "development" },
|
||||
resolveProviderApiKey: () => ({ apiKey: "ollama-local" }),
|
||||
} as never);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
provider: {
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
api: "ollama",
|
||||
apiKey: "ollama-local",
|
||||
models: [],
|
||||
},
|
||||
});
|
||||
expect(buildOllamaProviderMock).toHaveBeenCalledWith(undefined, {
|
||||
quiet: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not mint synthetic auth for empty default-ish provider stubs", () => {
|
||||
const provider = registerProvider();
|
||||
|
||||
const auth = provider.resolveSyntheticAuth?.({
|
||||
providerConfig: {
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(auth).toBeUndefined();
|
||||
});
|
||||
|
||||
it("mints synthetic auth for non-default explicit ollama config", () => {
|
||||
const provider = registerProvider();
|
||||
|
||||
const auth = provider.resolveSyntheticAuth?.({
|
||||
providerConfig: {
|
||||
baseUrl: "http://remote-ollama:11434",
|
||||
api: "ollama",
|
||||
models: [],
|
||||
},
|
||||
});
|
||||
|
||||
expect(auth).toEqual({
|
||||
apiKey: "ollama-local",
|
||||
source: "models.providers.ollama (synthetic local key)",
|
||||
mode: "api-key",
|
||||
});
|
||||
});
|
||||
|
||||
it("wraps OpenAI-compatible payloads with num_ctx for Ollama compat routes", () => {
|
||||
const provider = registerProvider();
|
||||
let payloadSeen: Record<string, unknown> | undefined;
|
||||
const baseStreamFn = vi.fn((_model, _context, options) => {
|
||||
const payload: Record<string, unknown> = { options: { temperature: 0.1 } };
|
||||
options?.onPayload?.(payload, _model);
|
||||
payloadSeen = payload;
|
||||
return {} as never;
|
||||
});
|
||||
|
||||
const wrapped = provider.wrapStreamFn?.({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
api: "openai-completions",
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "ollama",
|
||||
modelId: "qwen3:32b",
|
||||
model: {
|
||||
api: "openai-completions",
|
||||
provider: "ollama",
|
||||
id: "qwen3:32b",
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
contextWindow: 202_752,
|
||||
},
|
||||
streamFn: baseStreamFn,
|
||||
});
|
||||
|
||||
expect(typeof wrapped).toBe("function");
|
||||
void wrapped?.({} as never, {} as never, {});
|
||||
expect(baseStreamFn).toHaveBeenCalledTimes(1);
|
||||
expect((payloadSeen?.options as Record<string, unknown> | undefined)?.num_ctx).toBe(202752);
|
||||
});
|
||||
|
||||
it("declares streaming usage support for OpenAI-compatible Ollama routes", () => {
|
||||
const provider = registerProvider();
|
||||
|
||||
expect(
|
||||
provider.contributeResolvedModelCompat?.({
|
||||
modelId: "qwen3:32b",
|
||||
provider: "ollama",
|
||||
model: {
|
||||
api: "openai-completions",
|
||||
provider: "ollama",
|
||||
id: "qwen3:32b",
|
||||
baseUrl: "http://127.0.0.1:11434/v1",
|
||||
},
|
||||
} as never),
|
||||
).toEqual({ supportsUsageInStreaming: true });
|
||||
expect(
|
||||
provider.contributeResolvedModelCompat?.({
|
||||
modelId: "qwen3:32b",
|
||||
provider: "custom",
|
||||
model: {
|
||||
api: "openai-completions",
|
||||
provider: "custom",
|
||||
id: "qwen3:32b",
|
||||
baseUrl: "https://proxy.example.com/v1",
|
||||
},
|
||||
} as never),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("owns replay policy for OpenAI-compatible Ollama routes only", () => {
|
||||
const provider = registerProvider();
|
||||
|
||||
expect(
|
||||
provider.buildReplayPolicy?.({
|
||||
provider: "ollama",
|
||||
modelApi: "openai-completions",
|
||||
modelId: "qwen3:32b",
|
||||
} as never),
|
||||
).toMatchObject({
|
||||
sanitizeToolCallIds: true,
|
||||
toolCallIdMode: "strict",
|
||||
applyAssistantFirstOrderingFix: true,
|
||||
validateGeminiTurns: true,
|
||||
validateAnthropicTurns: true,
|
||||
});
|
||||
|
||||
expect(
|
||||
provider.buildReplayPolicy?.({
|
||||
provider: "ollama",
|
||||
modelApi: "openai-responses",
|
||||
modelId: "qwen3:32b",
|
||||
} as never),
|
||||
).toMatchObject({
|
||||
sanitizeToolCallIds: true,
|
||||
toolCallIdMode: "strict",
|
||||
applyAssistantFirstOrderingFix: false,
|
||||
validateGeminiTurns: false,
|
||||
validateAnthropicTurns: false,
|
||||
});
|
||||
|
||||
expect(
|
||||
provider.buildReplayPolicy?.({
|
||||
provider: "ollama",
|
||||
modelApi: "ollama",
|
||||
modelId: "qwen3.5:9b",
|
||||
} as never),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("routes createStreamFn to the correct provider baseUrl for ollama2", () => {
|
||||
const provider = registerProvider();
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
api: "ollama",
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
models: [],
|
||||
},
|
||||
ollama2: {
|
||||
api: "ollama",
|
||||
baseUrl: "http://127.0.0.1:11435",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const model = { id: "llama3.2", provider: "ollama2", baseUrl: undefined };
|
||||
|
||||
provider.createStreamFn?.({ config, model, provider: "ollama2" } as never);
|
||||
|
||||
expect(createConfiguredOllamaStreamFnMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providerBaseUrl: "http://127.0.0.1:11435" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses ollama provider baseUrl when provider is ollama (backward compat)", () => {
|
||||
const provider = registerProvider();
|
||||
const config = {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
api: "ollama",
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
models: [],
|
||||
},
|
||||
ollama2: {
|
||||
api: "ollama",
|
||||
baseUrl: "http://127.0.0.1:11435",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const model = { id: "llama3.2", provider: "ollama", baseUrl: undefined };
|
||||
|
||||
provider.createStreamFn?.({ config, model, provider: "ollama" } as never);
|
||||
|
||||
expect(createConfiguredOllamaStreamFnMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ providerBaseUrl: "http://127.0.0.1:11434" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("wraps native Ollama payloads with top-level think=false when thinking is off", () => {
|
||||
const provider = registerProvider();
|
||||
let payloadSeen: Record<string, unknown> | undefined;
|
||||
const baseStreamFn = vi.fn((_model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
messages: [],
|
||||
options: { num_ctx: 65536 },
|
||||
stream: true,
|
||||
};
|
||||
options?.onPayload?.(payload, _model);
|
||||
payloadSeen = payload;
|
||||
return {} as never;
|
||||
});
|
||||
|
||||
const wrapped = provider.wrapStreamFn?.({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
api: "ollama",
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "ollama",
|
||||
modelId: "qwen3.5:9b",
|
||||
thinkingLevel: "off",
|
||||
model: {
|
||||
api: "ollama",
|
||||
provider: "ollama",
|
||||
id: "qwen3.5:9b",
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
contextWindow: 131_072,
|
||||
},
|
||||
streamFn: baseStreamFn,
|
||||
});
|
||||
|
||||
expect(typeof wrapped).toBe("function");
|
||||
void wrapped?.(
|
||||
{
|
||||
api: "ollama",
|
||||
provider: "ollama",
|
||||
id: "qwen3.5:9b",
|
||||
} as never,
|
||||
{} as never,
|
||||
{},
|
||||
);
|
||||
expect(baseStreamFn).toHaveBeenCalledTimes(1);
|
||||
expect(payloadSeen?.think).toBe(false);
|
||||
expect((payloadSeen?.options as Record<string, unknown> | undefined)?.think).toBeUndefined();
|
||||
});
|
||||
|
||||
it("wraps native Ollama payloads with top-level think=true when thinking is enabled", () => {
|
||||
const provider = registerProvider();
|
||||
let payloadSeen: Record<string, unknown> | undefined;
|
||||
const baseStreamFn = vi.fn((_model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
messages: [],
|
||||
options: { num_ctx: 65536 },
|
||||
stream: true,
|
||||
};
|
||||
options?.onPayload?.(payload, _model);
|
||||
payloadSeen = payload;
|
||||
return {} as never;
|
||||
});
|
||||
|
||||
const wrapped = provider.wrapStreamFn?.({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
api: "ollama",
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "ollama",
|
||||
modelId: "qwen3.5:9b",
|
||||
thinkingLevel: "low",
|
||||
model: {
|
||||
api: "ollama",
|
||||
provider: "ollama",
|
||||
id: "qwen3.5:9b",
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
contextWindow: 131_072,
|
||||
},
|
||||
streamFn: baseStreamFn,
|
||||
});
|
||||
|
||||
expect(typeof wrapped).toBe("function");
|
||||
void wrapped?.(
|
||||
{
|
||||
api: "ollama",
|
||||
provider: "ollama",
|
||||
id: "qwen3.5:9b",
|
||||
} as never,
|
||||
{} as never,
|
||||
{},
|
||||
);
|
||||
expect(baseStreamFn).toHaveBeenCalledTimes(1);
|
||||
expect(payloadSeen?.think).toBe(true);
|
||||
expect((payloadSeen?.options as Record<string, unknown> | undefined)?.think).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not set think param when thinkingLevel is undefined", () => {
|
||||
const provider = registerProvider();
|
||||
let payloadSeen: Record<string, unknown> | undefined;
|
||||
const baseStreamFn = vi.fn((_model, _context, options) => {
|
||||
const payload: Record<string, unknown> = {
|
||||
messages: [],
|
||||
options: { num_ctx: 65536 },
|
||||
stream: true,
|
||||
};
|
||||
options?.onPayload?.(payload, _model);
|
||||
payloadSeen = payload;
|
||||
return {} as never;
|
||||
});
|
||||
|
||||
const wrapped = provider.wrapStreamFn?.({
|
||||
config: {
|
||||
models: {
|
||||
providers: {
|
||||
ollama: {
|
||||
api: "ollama",
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
models: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
provider: "ollama",
|
||||
modelId: "qwen3.5:9b",
|
||||
thinkingLevel: undefined,
|
||||
model: {
|
||||
api: "ollama",
|
||||
provider: "ollama",
|
||||
id: "qwen3.5:9b",
|
||||
baseUrl: "http://127.0.0.1:11434",
|
||||
contextWindow: 131_072,
|
||||
},
|
||||
streamFn: baseStreamFn,
|
||||
});
|
||||
|
||||
expect(typeof wrapped).toBe("function");
|
||||
void wrapped?.(
|
||||
{
|
||||
api: "ollama",
|
||||
provider: "ollama",
|
||||
id: "qwen3.5:9b",
|
||||
} as never,
|
||||
{} as never,
|
||||
{},
|
||||
);
|
||||
expect(baseStreamFn).toHaveBeenCalledTimes(1);
|
||||
expect(payloadSeen?.think).toBeUndefined();
|
||||
});
|
||||
});
|
||||
303
openclaw/extensions/ollama/index.ts
Normal file
303
openclaw/extensions/ollama/index.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import {
|
||||
definePluginEntry,
|
||||
type OpenClawPluginApi,
|
||||
type ProviderAuthContext,
|
||||
type ProviderAuthMethodNonInteractiveContext,
|
||||
type ProviderAuthResult,
|
||||
type ProviderDiscoveryContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { buildApiKeyCredential } from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
OPENAI_COMPATIBLE_REPLAY_HOOKS,
|
||||
type ModelProviderConfig,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeOptionalString, readStringValue } from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
buildOllamaProvider,
|
||||
configureOllamaNonInteractive,
|
||||
ensureOllamaModelPulled,
|
||||
promptAndConfigureOllama,
|
||||
} from "./api.js";
|
||||
import { OLLAMA_DEFAULT_BASE_URL } from "./src/defaults.js";
|
||||
import {
|
||||
DEFAULT_OLLAMA_EMBEDDING_MODEL,
|
||||
createOllamaEmbeddingProvider,
|
||||
} from "./src/embedding-provider.js";
|
||||
import { ollamaMemoryEmbeddingProviderAdapter } from "./src/memory-embedding-adapter.js";
|
||||
import { resolveOllamaApiBase } from "./src/provider-models.js";
|
||||
import {
|
||||
createConfiguredOllamaCompatStreamWrapper,
|
||||
createConfiguredOllamaStreamFn,
|
||||
isOllamaCompatProvider,
|
||||
resolveConfiguredOllamaProviderConfig,
|
||||
} from "./src/stream.js";
|
||||
import { createOllamaWebSearchProvider } from "./src/web-search-provider.js";
|
||||
|
||||
const PROVIDER_ID = "ollama";
|
||||
const DEFAULT_API_KEY = "ollama-local";
|
||||
|
||||
type OllamaPluginConfig = {
|
||||
discovery?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type OllamaProviderLikeConfig = ModelProviderConfig;
|
||||
|
||||
function resolveOllamaDiscoveryApiKey(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
explicitApiKey?: string;
|
||||
resolvedApiKey?: string;
|
||||
}): string {
|
||||
const envApiKey = params.env.OLLAMA_API_KEY?.trim() ? "OLLAMA_API_KEY" : undefined;
|
||||
const explicitApiKey = normalizeOptionalString(params.explicitApiKey);
|
||||
const resolvedApiKey = normalizeOptionalString(params.resolvedApiKey);
|
||||
return envApiKey ?? explicitApiKey ?? resolvedApiKey ?? DEFAULT_API_KEY;
|
||||
}
|
||||
|
||||
function shouldSkipAmbientOllamaDiscovery(env: NodeJS.ProcessEnv): boolean {
|
||||
return Boolean(env.VITEST) || env.NODE_ENV === "test";
|
||||
}
|
||||
|
||||
function hasMeaningfulExplicitOllamaConfig(providerConfig?: OllamaProviderLikeConfig): boolean {
|
||||
if (!providerConfig) {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(providerConfig.models) && providerConfig.models.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (typeof providerConfig.baseUrl === "string" && providerConfig.baseUrl.trim()) {
|
||||
return resolveOllamaApiBase(providerConfig.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;
|
||||
}
|
||||
|
||||
function usesOllamaOpenAICompatTransport(model: {
|
||||
api?: unknown;
|
||||
provider?: unknown;
|
||||
baseUrl?: unknown;
|
||||
}): boolean {
|
||||
return (
|
||||
model.api === "openai-completions" &&
|
||||
isOllamaCompatProvider({
|
||||
provider: typeof model.provider === "string" ? model.provider : undefined,
|
||||
baseUrl: typeof model.baseUrl === "string" ? model.baseUrl : undefined,
|
||||
api: "openai-completions",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export default definePluginEntry({
|
||||
id: "ollama",
|
||||
name: "Ollama Provider",
|
||||
description: "Bundled Ollama provider plugin",
|
||||
register(api: OpenClawPluginApi) {
|
||||
api.registerMemoryEmbeddingProvider(ollamaMemoryEmbeddingProviderAdapter);
|
||||
const pluginConfig = (api.pluginConfig ?? {}) as OllamaPluginConfig;
|
||||
api.registerWebSearchProvider(createOllamaWebSearchProvider());
|
||||
api.registerProvider({
|
||||
id: 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(
|
||||
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,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
discovery: {
|
||||
order: "late",
|
||||
run: async (ctx: ProviderDiscoveryContext) => {
|
||||
const explicit = ctx.config.models?.providers?.ollama;
|
||||
const hasExplicitModels = Array.isArray(explicit?.models) && explicit.models.length > 0;
|
||||
const hasMeaningfulExplicitConfig = hasMeaningfulExplicitOllamaConfig(explicit);
|
||||
const discoveryEnabled =
|
||||
pluginConfig.discovery?.enabled ?? ctx.config.models?.ollamaDiscovery?.enabled;
|
||||
if (!hasExplicitModels && discoveryEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
const ollamaKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
|
||||
const hasRealOllamaKey =
|
||||
typeof ollamaKey === "string" &&
|
||||
ollamaKey.trim().length > 0 &&
|
||||
ollamaKey.trim() !== DEFAULT_API_KEY;
|
||||
const explicitApiKey = readStringValue(explicit?.apiKey);
|
||||
if (hasExplicitModels && explicit) {
|
||||
return {
|
||||
provider: {
|
||||
...explicit,
|
||||
baseUrl:
|
||||
typeof explicit.baseUrl === "string" && explicit.baseUrl.trim()
|
||||
? resolveOllamaApiBase(explicit.baseUrl)
|
||||
: OLLAMA_DEFAULT_BASE_URL,
|
||||
api: explicit.api ?? "ollama",
|
||||
apiKey: resolveOllamaDiscoveryApiKey({
|
||||
env: ctx.env,
|
||||
explicitApiKey,
|
||||
resolvedApiKey: ollamaKey,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
!hasRealOllamaKey &&
|
||||
!hasMeaningfulExplicitConfig &&
|
||||
shouldSkipAmbientOllamaDiscovery(ctx.env)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = await buildOllamaProvider(explicit?.baseUrl, {
|
||||
quiet: !hasRealOllamaKey && !hasMeaningfulExplicitConfig,
|
||||
});
|
||||
if (provider.models.length === 0 && !ollamaKey && !explicit?.apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: {
|
||||
...provider,
|
||||
apiKey: resolveOllamaDiscoveryApiKey({
|
||||
env: ctx.env,
|
||||
explicitApiKey,
|
||||
resolvedApiKey: ollamaKey,
|
||||
}),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
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 }) => {
|
||||
return createConfiguredOllamaStreamFn({
|
||||
model,
|
||||
providerBaseUrl: resolveConfiguredOllamaProviderConfig({ config, providerId: provider })
|
||||
?.baseUrl,
|
||||
});
|
||||
},
|
||||
...OPENAI_COMPATIBLE_REPLAY_HOOKS,
|
||||
contributeResolvedModelCompat: ({ model }) =>
|
||||
usesOllamaOpenAICompatTransport(model) ? { supportsUsageInStreaming: true } : undefined,
|
||||
resolveReasoningOutputMode: () => "native",
|
||||
wrapStreamFn: createConfiguredOllamaCompatStreamWrapper,
|
||||
createEmbeddingProvider: async ({ config, model, remote }) => {
|
||||
const { provider, client } = await createOllamaEmbeddingProvider({
|
||||
config,
|
||||
remote,
|
||||
model: model || DEFAULT_OLLAMA_EMBEDDING_MODEL,
|
||||
});
|
||||
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: ({ providerConfig }) => {
|
||||
if (!hasMeaningfulExplicitOllamaConfig(providerConfig)) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
apiKey: DEFAULT_API_KEY,
|
||||
source: "models.providers.ollama (synthetic local key)",
|
||||
mode: "api-key",
|
||||
};
|
||||
},
|
||||
shouldDeferSyntheticProfileAuth: ({ resolvedApiKey }) =>
|
||||
resolvedApiKey?.trim() === DEFAULT_API_KEY,
|
||||
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",
|
||||
});
|
||||
},
|
||||
});
|
||||
50
openclaw/extensions/ollama/openclaw.plugin.json
Normal file
50
openclaw/extensions/ollama/openclaw.plugin.json
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"id": "ollama",
|
||||
"enabledByDefault": true,
|
||||
"providers": ["ollama"],
|
||||
"providerDiscoveryEntry": "./provider-discovery.ts",
|
||||
"syntheticAuthRefs": ["ollama"],
|
||||
"nonSecretAuthMarkers": ["ollama-local"],
|
||||
"providerAuthEnvVars": {
|
||||
"ollama": ["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"
|
||||
}
|
||||
],
|
||||
"contracts": {
|
||||
"memoryEmbeddingProviders": ["ollama"],
|
||||
"webSearchProviders": ["ollama"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"discovery": {
|
||||
"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."
|
||||
}
|
||||
}
|
||||
}
|
||||
15
openclaw/extensions/ollama/package.json
Normal file
15
openclaw/extensions/ollama/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "@openclaw/ollama-provider",
|
||||
"version": "2026.4.20",
|
||||
"private": true,
|
||||
"description": "OpenClaw Ollama provider plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
import { describePluginRegistrationContract } from "../../test/helpers/plugins/plugin-registration-contract.js";
|
||||
|
||||
describePluginRegistrationContract({
|
||||
pluginId: "ollama",
|
||||
providerIds: ["ollama"],
|
||||
webSearchProviderIds: ["ollama"],
|
||||
});
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
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/text-runtime",
|
||||
"openclaw/plugin-sdk/plugin-entry",
|
||||
]) {
|
||||
expect(source, `provider discovery must not import ${forbidden}`).not.toContain(forbidden);
|
||||
}
|
||||
});
|
||||
});
|
||||
476
openclaw/extensions/ollama/provider-discovery.test.ts
Normal file
476
openclaw/extensions/ollama/provider-discovery.test.ts
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
import { mkdtempSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import type { ModelDefinitionConfig } from "openclaw/plugin-sdk/provider-onboard";
|
||||
import { type OpenClawConfig, withFetchPreconnect } from "openclaw/plugin-sdk/testing";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ollamaProviderDiscovery } from "./provider-discovery.js";
|
||||
|
||||
const OLLAMA_LOCAL_AUTH_MARKER = "ollama-local";
|
||||
|
||||
afterEach(() => {
|
||||
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 expectDiscoveryCallCounts = (
|
||||
fetchMock: ReturnType<typeof vi.fn>,
|
||||
params: { tags: number; show: number },
|
||||
) => {
|
||||
const urls = fetchCallUrls(fetchMock);
|
||||
expect(urls.filter((url) => url.endsWith("/api/tags"))).toHaveLength(params.tags);
|
||||
expect(urls.filter((url) => url.endsWith("/api/show"))).toHaveLength(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 }) {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...process.env,
|
||||
VITEST: "1",
|
||||
NODE_ENV: "test",
|
||||
...params.env,
|
||||
};
|
||||
const result = await ollamaProviderDiscovery.discovery.run({
|
||||
config: params.config ?? {},
|
||||
agentDir: createAgentDir(),
|
||||
env,
|
||||
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 tagsResponse = (names: string[]) => ({
|
||||
ok: true,
|
||||
json: async () => ({ models: names.map((name) => createTagModel(name)) }),
|
||||
});
|
||||
|
||||
const notFoundJsonResponse = () => ({
|
||||
ok: false,
|
||||
status: 404,
|
||||
json: async () => ({}),
|
||||
});
|
||||
|
||||
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 = vi.fn(async (input: unknown) => {
|
||||
const url = String(input);
|
||||
if (url.endsWith("/api/tags")) {
|
||||
return tagsResponse([]);
|
||||
}
|
||||
return notFoundJsonResponse();
|
||||
});
|
||||
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
|
||||
|
||||
await withOllamaApiKey(async () => {
|
||||
const provider = await runOllamaCatalog({});
|
||||
|
||||
expect(provider).toBeDefined();
|
||||
expect(provider?.apiKey).toBe("OLLAMA_API_KEY");
|
||||
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 on implicit provider injection", 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));
|
||||
|
||||
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(fetchCallUrls(fetchMock).filter((url) => url.endsWith("/api/tags"))).toHaveLength(1);
|
||||
|
||||
// Native API strips /v1 suffix via resolveOllamaApiBase()
|
||||
expect(provider?.baseUrl).toBe("http://192.168.20.14:11434");
|
||||
});
|
||||
});
|
||||
|
||||
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 {
|
||||
ok: true,
|
||||
json: async () => ({ model_info: { "qwen3.context_length": 131072 } }),
|
||||
};
|
||||
}
|
||||
if (parsed.name === "llama3.3:70b") {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ 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 {
|
||||
ok: true,
|
||||
json: async () => ({ model_info: {} }),
|
||||
};
|
||||
}
|
||||
return notFoundJsonResponse();
|
||||
});
|
||||
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
|
||||
|
||||
const provider = await runOllamaCatalog({
|
||||
env: { 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(
|
||||
warnSpy.mock.calls.filter(([message]) => String(message).includes("Ollama")).length,
|
||||
).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 {
|
||||
ok: false,
|
||||
status: 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 {
|
||||
ok: true,
|
||||
json: async () => ({ models: manyModels }),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ 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 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");
|
||||
expect(provider?.api).toBe("openai-completions");
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
199
openclaw/extensions/ollama/provider-discovery.ts
Normal file
199
openclaw/extensions/ollama/provider-discovery.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import type { ProviderCatalogContext } from "openclaw/plugin-sdk/provider-catalog-shared";
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { OLLAMA_DEFAULT_BASE_URL } from "./src/defaults.js";
|
||||
import {
|
||||
buildOllamaModelDefinition,
|
||||
enrichOllamaModelsWithContext,
|
||||
fetchOllamaModels,
|
||||
resolveOllamaApiBase,
|
||||
} from "./src/provider-models.js";
|
||||
|
||||
const PROVIDER_ID = "ollama";
|
||||
const DEFAULT_API_KEY = "ollama-local";
|
||||
const OLLAMA_CONTEXT_ENRICH_LIMIT = 200;
|
||||
|
||||
type OllamaPluginConfig = {
|
||||
discovery?: {
|
||||
enabled?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type OllamaProviderLikeConfig = ModelProviderConfig;
|
||||
type OllamaProviderPlugin = {
|
||||
id: string;
|
||||
label: string;
|
||||
docsPath: string;
|
||||
envVars: string[];
|
||||
auth: [];
|
||||
discovery: {
|
||||
order: "late";
|
||||
run: (ctx: ProviderCatalogContext) => ReturnType<typeof runOllamaDiscovery>;
|
||||
};
|
||||
};
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
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 resolveOllamaDiscoveryApiKey(params: {
|
||||
env: NodeJS.ProcessEnv;
|
||||
explicitApiKey?: string;
|
||||
resolvedApiKey?: string;
|
||||
}): string {
|
||||
const envApiKey = params.env.OLLAMA_API_KEY?.trim() ? "OLLAMA_API_KEY" : undefined;
|
||||
return envApiKey ?? params.explicitApiKey ?? params.resolvedApiKey ?? DEFAULT_API_KEY;
|
||||
}
|
||||
|
||||
function shouldSkipAmbientOllamaDiscovery(env: NodeJS.ProcessEnv): boolean {
|
||||
return Boolean(env.VITEST) || env.NODE_ENV === "test";
|
||||
}
|
||||
|
||||
function hasMeaningfulExplicitOllamaConfig(
|
||||
providerConfig: OllamaProviderLikeConfig | undefined,
|
||||
): boolean {
|
||||
if (!providerConfig) {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(providerConfig.models) && providerConfig.models.length > 0) {
|
||||
return true;
|
||||
}
|
||||
if (typeof providerConfig.baseUrl === "string" && providerConfig.baseUrl.trim()) {
|
||||
return resolveOllamaApiBase(providerConfig.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;
|
||||
}
|
||||
|
||||
async function buildOllamaProvider(
|
||||
configuredBaseUrl?: string,
|
||||
opts?: { quiet?: boolean },
|
||||
): Promise<ModelProviderConfig> {
|
||||
const apiBase = resolveOllamaApiBase(configuredBaseUrl);
|
||||
const { reachable, models } = await fetchOllamaModels(apiBase);
|
||||
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),
|
||||
);
|
||||
return {
|
||||
baseUrl: apiBase,
|
||||
api: "ollama",
|
||||
models: discovered.map((model) =>
|
||||
buildOllamaModelDefinition(model.name, model.contextWindow, model.capabilities),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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) {
|
||||
const pluginConfig = resolveOllamaPluginConfig(ctx);
|
||||
const explicit = ctx.config.models?.providers?.ollama;
|
||||
const hasExplicitModels = Array.isArray(explicit?.models) && explicit.models.length > 0;
|
||||
const hasMeaningfulExplicitConfig = hasMeaningfulExplicitOllamaConfig(explicit);
|
||||
const discoveryEnabled =
|
||||
pluginConfig.discovery?.enabled ?? ctx.config.models?.ollamaDiscovery?.enabled;
|
||||
if (!hasExplicitModels && discoveryEnabled === false) {
|
||||
return null;
|
||||
}
|
||||
const ollamaKey = ctx.resolveProviderApiKey(PROVIDER_ID).apiKey;
|
||||
const hasRealOllamaKey =
|
||||
typeof ollamaKey === "string" &&
|
||||
ollamaKey.trim().length > 0 &&
|
||||
ollamaKey.trim() !== DEFAULT_API_KEY;
|
||||
const explicitApiKey = readStringValue(explicit?.apiKey);
|
||||
if (hasExplicitModels && explicit) {
|
||||
return {
|
||||
provider: {
|
||||
...explicit,
|
||||
baseUrl:
|
||||
typeof explicit.baseUrl === "string" && explicit.baseUrl.trim()
|
||||
? resolveOllamaApiBase(explicit.baseUrl)
|
||||
: OLLAMA_DEFAULT_BASE_URL,
|
||||
api: explicit.api ?? "ollama",
|
||||
apiKey: resolveOllamaDiscoveryApiKey({
|
||||
env: ctx.env,
|
||||
explicitApiKey,
|
||||
resolvedApiKey: ollamaKey,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
if (
|
||||
!hasRealOllamaKey &&
|
||||
!hasMeaningfulExplicitConfig &&
|
||||
shouldSkipAmbientOllamaDiscovery(ctx.env)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = await buildOllamaProvider(explicit?.baseUrl, {
|
||||
quiet: !hasRealOllamaKey && !hasMeaningfulExplicitConfig,
|
||||
});
|
||||
if (provider.models?.length === 0 && !ollamaKey && !explicit?.apiKey) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
provider: {
|
||||
...provider,
|
||||
apiKey: resolveOllamaDiscoveryApiKey({
|
||||
env: ctx.env,
|
||||
explicitApiKey,
|
||||
resolvedApiKey: ollamaKey,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export const ollamaProviderDiscovery: OllamaProviderPlugin = {
|
||||
id: PROVIDER_ID,
|
||||
label: "Ollama",
|
||||
docsPath: "/providers/ollama",
|
||||
envVars: ["OLLAMA_API_KEY"],
|
||||
auth: [],
|
||||
discovery: {
|
||||
order: "late",
|
||||
run: runOllamaDiscovery,
|
||||
},
|
||||
};
|
||||
|
||||
export default ollamaProviderDiscovery;
|
||||
22
openclaw/extensions/ollama/runtime-api.ts
Normal file
22
openclaw/extensions/ollama/runtime-api.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
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";
|
||||
14
openclaw/extensions/ollama/src/defaults.ts
Normal file
14
openclaw/extensions/ollama/src/defaults.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
export const OLLAMA_DEFAULT_BASE_URL = "http://127.0.0.1:11434";
|
||||
export const OLLAMA_CLOUD_BASE_URL = "https://ollama.com";
|
||||
|
||||
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";
|
||||
export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text";
|
||||
146
openclaw/extensions/ollama/src/embedding-provider.test.ts
Normal file
146
openclaw/extensions/ollama/src/embedding-provider.test.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuardMock: vi.fn(async ({ init, url }: { init?: RequestInit; url: string }) => ({
|
||||
response: await fetch(url, init),
|
||||
release: async () => {},
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
|
||||
fetchWithSsrFGuard: fetchWithSsrFGuardMock,
|
||||
formatErrorMessage: (error: unknown) => (error instanceof Error ? error.message : String(error)),
|
||||
}));
|
||||
|
||||
let createOllamaEmbeddingProvider: typeof import("./embedding-provider.js").createOllamaEmbeddingProvider;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createOllamaEmbeddingProvider } = await import("./embedding-provider.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchWithSsrFGuardMock.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
function mockEmbeddingFetch(embedding: number[]) {
|
||||
const fetchMock = vi.fn(
|
||||
async () =>
|
||||
new Response(JSON.stringify({ embedding }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
describe("ollama embedding provider", () => {
|
||||
it("calls /api/embeddings and returns normalized vectors", async () => {
|
||||
const fetchMock = mockEmbeddingFetch([3, 4]);
|
||||
|
||||
const { provider } = await createOllamaEmbeddingProvider({
|
||||
config: {} as OpenClawConfig,
|
||||
provider: "ollama",
|
||||
model: "nomic-embed-text",
|
||||
fallback: "none",
|
||||
remote: { baseUrl: "http://127.0.0.1:11434" },
|
||||
});
|
||||
|
||||
const vector = await provider.embedQuery("hi");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
expect(vector[0]).toBeCloseTo(0.6, 5);
|
||||
expect(vector[1]).toBeCloseTo(0.8, 5);
|
||||
});
|
||||
|
||||
it("resolves configured base URL, API key, and headers", 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");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:11434/api/embeddings",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
headers: expect.objectContaining({
|
||||
"Content-Type": "application/json",
|
||||
Authorization: "Bearer ollama-local",
|
||||
"X-Provider-Header": "provider",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"http://127.0.0.1:11434/api/embeddings",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
Authorization: "Bearer ollama-env",
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
207
openclaw/extensions/ollama/src/embedding-provider.ts
Normal file
207
openclaw/extensions/ollama/src/embedding-provider.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { normalizeOptionalSecretInput } from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
import {
|
||||
fetchWithSsrFGuard,
|
||||
formatErrorMessage,
|
||||
type SsrFPolicy,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { resolveOllamaApiBase } from "./provider-models.js";
|
||||
|
||||
export type OllamaEmbeddingProvider = {
|
||||
id: string;
|
||||
model: string;
|
||||
maxInputTokens?: number;
|
||||
embedQuery: (text: string) => Promise<number[]>;
|
||||
embedBatch: (texts: string[]) => 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;
|
||||
embedBatch: (texts: string[]) => Promise<number[][]>;
|
||||
};
|
||||
|
||||
type OllamaEmbeddingClientConfig = Omit<OllamaEmbeddingClient, "embedBatch">;
|
||||
|
||||
export const DEFAULT_OLLAMA_EMBEDDING_MODEL = "nomic-embed-text";
|
||||
|
||||
function sanitizeAndNormalizeEmbedding(vec: number[]): number[] {
|
||||
const sanitized = vec.map((value) => (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);
|
||||
}
|
||||
|
||||
function buildRemoteBaseUrlPolicy(baseUrl: string): SsrFPolicy | undefined {
|
||||
const trimmed = baseUrl.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(trimmed);
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
return { allowedHostnames: [parsed.hostname] };
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function withRemoteHttpResponse<T>(params: {
|
||||
url: string;
|
||||
init?: RequestInit;
|
||||
ssrfPolicy?: SsrFPolicy;
|
||||
onResponse: (response: Response) => Promise<T>;
|
||||
}): Promise<T> {
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: params.url,
|
||||
init: params.init,
|
||||
policy: params.ssrfPolicy,
|
||||
auditContext: "memory-remote",
|
||||
});
|
||||
try {
|
||||
return await params.onResponse(response);
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeEmbeddingModel(model: string): string {
|
||||
const trimmed = model.trim();
|
||||
if (!trimmed) {
|
||||
return DEFAULT_OLLAMA_EMBEDDING_MODEL;
|
||||
}
|
||||
return trimmed.startsWith("ollama/") ? trimmed.slice("ollama/".length) : trimmed;
|
||||
}
|
||||
|
||||
function resolveMemorySecretInputString(params: {
|
||||
value: unknown;
|
||||
path: string;
|
||||
}): string | undefined {
|
||||
if (!hasConfiguredSecretInput(params.value)) {
|
||||
return undefined;
|
||||
}
|
||||
return normalizeResolvedSecretInputString({
|
||||
value: params.value,
|
||||
path: params.path,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveOllamaApiKey(options: OllamaEmbeddingOptions): string | undefined {
|
||||
const remoteApiKey = resolveMemorySecretInputString({
|
||||
value: options.remote?.apiKey,
|
||||
path: "agents.*.memorySearch.remote.apiKey",
|
||||
});
|
||||
if (remoteApiKey) {
|
||||
return remoteApiKey;
|
||||
}
|
||||
const providerApiKey = normalizeOptionalSecretInput(
|
||||
options.config.models?.providers?.ollama?.apiKey,
|
||||
);
|
||||
if (providerApiKey) {
|
||||
return providerApiKey;
|
||||
}
|
||||
return resolveEnvApiKey("ollama")?.apiKey;
|
||||
}
|
||||
|
||||
function resolveOllamaEmbeddingClient(
|
||||
options: OllamaEmbeddingOptions,
|
||||
): OllamaEmbeddingClientConfig {
|
||||
const providerConfig = options.config.models?.providers?.ollama;
|
||||
const rawBaseUrl = options.remote?.baseUrl?.trim() || providerConfig?.baseUrl?.trim();
|
||||
const baseUrl = resolveOllamaApiBase(rawBaseUrl);
|
||||
const model = normalizeEmbeddingModel(options.model);
|
||||
const headerOverrides = Object.assign({}, providerConfig?.headers, options.remote?.headers);
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...headerOverrides,
|
||||
};
|
||||
const apiKey = resolveOllamaApiKey(options);
|
||||
if (apiKey) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
return {
|
||||
baseUrl,
|
||||
headers,
|
||||
ssrfPolicy: buildRemoteBaseUrlPolicy(baseUrl),
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createOllamaEmbeddingProvider(
|
||||
options: OllamaEmbeddingOptions,
|
||||
): Promise<{ provider: OllamaEmbeddingProvider; client: OllamaEmbeddingClient }> {
|
||||
const client = resolveOllamaEmbeddingClient(options);
|
||||
const embedUrl = `${client.baseUrl.replace(/\/$/, "")}/api/embeddings`;
|
||||
|
||||
const embedOne = async (text: string): Promise<number[]> => {
|
||||
const json = await withRemoteHttpResponse({
|
||||
url: embedUrl,
|
||||
ssrfPolicy: client.ssrfPolicy,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: client.headers,
|
||||
body: JSON.stringify({ model: client.model, prompt: text }),
|
||||
},
|
||||
onResponse: async (response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Ollama embeddings HTTP ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
return (await response.json()) as { embedding?: number[] };
|
||||
},
|
||||
});
|
||||
if (!Array.isArray(json.embedding)) {
|
||||
throw new Error("Ollama embeddings response missing embedding[]");
|
||||
}
|
||||
return sanitizeAndNormalizeEmbedding(json.embedding);
|
||||
};
|
||||
|
||||
const provider: OllamaEmbeddingProvider = {
|
||||
id: "ollama",
|
||||
model: client.model,
|
||||
embedQuery: embedOne,
|
||||
embedBatch: async (texts) => {
|
||||
return await Promise.all(texts.map(embedOne));
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
provider,
|
||||
client: {
|
||||
...client,
|
||||
embedBatch: async (texts) => {
|
||||
try {
|
||||
return await provider.embedBatch(texts);
|
||||
} catch (err) {
|
||||
throw new Error(formatErrorMessage(err), { cause: err });
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
29
openclaw/extensions/ollama/src/memory-embedding-adapter.ts
Normal file
29
openclaw/extensions/ollama/src/memory-embedding-adapter.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
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",
|
||||
cacheKeyData: {
|
||||
provider: "ollama",
|
||||
model: client.model,
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
143
openclaw/extensions/ollama/src/ollama-json.ts
Normal file
143
openclaw/extensions/ollama/src/ollama-json.ts
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
const MAX_SAFE_INTEGER_ABS_STR = String(Number.MAX_SAFE_INTEGER);
|
||||
|
||||
function isAsciiDigit(ch: string | undefined): boolean {
|
||||
return ch !== undefined && ch >= "0" && ch <= "9";
|
||||
}
|
||||
|
||||
function parseJsonNumberToken(
|
||||
input: string,
|
||||
start: number,
|
||||
): { token: string; end: number; isInteger: boolean } | null {
|
||||
let idx = start;
|
||||
if (input[idx] === "-") {
|
||||
idx += 1;
|
||||
}
|
||||
if (idx >= input.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (input[idx] === "0") {
|
||||
idx += 1;
|
||||
} else if (isAsciiDigit(input[idx]) && input[idx] !== "0") {
|
||||
while (isAsciiDigit(input[idx])) {
|
||||
idx += 1;
|
||||
}
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
|
||||
let isInteger = true;
|
||||
if (input[idx] === ".") {
|
||||
isInteger = false;
|
||||
idx += 1;
|
||||
if (!isAsciiDigit(input[idx])) {
|
||||
return null;
|
||||
}
|
||||
while (isAsciiDigit(input[idx])) {
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (input[idx] === "e" || input[idx] === "E") {
|
||||
isInteger = false;
|
||||
idx += 1;
|
||||
if (input[idx] === "+" || input[idx] === "-") {
|
||||
idx += 1;
|
||||
}
|
||||
if (!isAsciiDigit(input[idx])) {
|
||||
return null;
|
||||
}
|
||||
while (isAsciiDigit(input[idx])) {
|
||||
idx += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
token: input.slice(start, idx),
|
||||
end: idx,
|
||||
isInteger,
|
||||
};
|
||||
}
|
||||
|
||||
function isUnsafeIntegerLiteral(token: string): boolean {
|
||||
const digits = token[0] === "-" ? token.slice(1) : token;
|
||||
if (digits.length < MAX_SAFE_INTEGER_ABS_STR.length) {
|
||||
return false;
|
||||
}
|
||||
if (digits.length > MAX_SAFE_INTEGER_ABS_STR.length) {
|
||||
return true;
|
||||
}
|
||||
return digits > MAX_SAFE_INTEGER_ABS_STR;
|
||||
}
|
||||
|
||||
function quoteUnsafeIntegerLiterals(input: string): string {
|
||||
let out = "";
|
||||
let inString = false;
|
||||
let escaped = false;
|
||||
let idx = 0;
|
||||
|
||||
while (idx < input.length) {
|
||||
const ch = input[idx] ?? "";
|
||||
if (inString) {
|
||||
out += ch;
|
||||
if (escaped) {
|
||||
escaped = false;
|
||||
} else if (ch === "\\") {
|
||||
escaped = true;
|
||||
} else if (ch === '"') {
|
||||
inString = false;
|
||||
}
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === '"') {
|
||||
inString = true;
|
||||
out += ch;
|
||||
idx += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ch === "-" || isAsciiDigit(ch)) {
|
||||
const parsed = parseJsonNumberToken(input, idx);
|
||||
if (parsed) {
|
||||
if (parsed.isInteger && isUnsafeIntegerLiteral(parsed.token)) {
|
||||
out += `"${parsed.token}"`;
|
||||
} else {
|
||||
out += parsed.token;
|
||||
}
|
||||
idx = parsed.end;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
out += ch;
|
||||
idx += 1;
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
export function parseJsonPreservingUnsafeIntegers(input: string): unknown {
|
||||
return JSON.parse(quoteUnsafeIntegerLiterals(input)) as unknown;
|
||||
}
|
||||
|
||||
export function parseJsonObjectPreservingUnsafeIntegers(
|
||||
value: unknown,
|
||||
): Record<string, unknown> | null {
|
||||
if (typeof value === "string") {
|
||||
try {
|
||||
const parsed = parseJsonPreservingUnsafeIntegers(value);
|
||||
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (value && typeof value === "object" && !Array.isArray(value)) {
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
41
openclaw/extensions/ollama/src/provider-models.ssrf.test.ts
Normal file
41
openclaw/extensions/ollama/src/provider-models.ssrf.test.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
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();
|
||||
});
|
||||
});
|
||||
215
openclaw/extensions/ollama/src/provider-models.test.ts
Normal file
215
openclaw/extensions/ollama/src/provider-models.test.ts
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { jsonResponse, requestBodyText, requestUrl } from "../../../src/test-helpers/http.js";
|
||||
import {
|
||||
buildOllamaModelDefinition,
|
||||
enrichOllamaModelsWithContext,
|
||||
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 },
|
||||
]);
|
||||
});
|
||||
|
||||
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",
|
||||
]);
|
||||
expect(visionModel.input).toEqual(["text", "image"]);
|
||||
|
||||
const textModel = buildOllamaModelDefinition("glm-5.1:cloud", 202752, ["completion", "tools"]);
|
||||
expect(textModel.input).toEqual(["text"]);
|
||||
|
||||
const noCapabilities = buildOllamaModelDefinition("unknown-model", 65536);
|
||||
expect(noCapabilities.input).toEqual(["text"]);
|
||||
});
|
||||
});
|
||||
260
openclaw/extensions/ollama/src/provider-models.ts
Normal file
260
openclaw/extensions/ollama/src/provider-models.ts
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
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,
|
||||
} from "./defaults.js";
|
||||
|
||||
export type OllamaTagModel = {
|
||||
name: string;
|
||||
modified_at?: string;
|
||||
size?: number;
|
||||
digest?: string;
|
||||
remote_host?: string;
|
||||
details?: {
|
||||
family?: string;
|
||||
parameter_size?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type OllamaTagsResponse = {
|
||||
models?: OllamaTagModel[];
|
||||
};
|
||||
|
||||
export type OllamaModelWithContext = OllamaTagModel & {
|
||||
contextWindow?: number;
|
||||
capabilities?: string[];
|
||||
};
|
||||
|
||||
const OLLAMA_SHOW_CONCURRENCY = 8;
|
||||
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">,
|
||||
): string | undefined {
|
||||
const version = model.digest?.trim() || model.modified_at?.trim();
|
||||
if (!version) {
|
||||
return undefined;
|
||||
}
|
||||
return `${resolveOllamaApiBase(apiBase)}|${model.name}|${version}`;
|
||||
}
|
||||
|
||||
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 async function queryOllamaModelShowInfo(
|
||||
apiBase: string,
|
||||
modelName: string,
|
||||
): Promise<OllamaModelShowInfo> {
|
||||
const normalizedApiBase = resolveOllamaApiBase(apiBase);
|
||||
try {
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: `${normalizedApiBase}/api/show`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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 response.json()) as {
|
||||
model_info?: Record<string, unknown>;
|
||||
capabilities?: unknown;
|
||||
};
|
||||
|
||||
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 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">,
|
||||
): Promise<OllamaModelShowInfo> {
|
||||
const normalizedApiBase = resolveOllamaApiBase(apiBase);
|
||||
const cacheKey = buildOllamaModelShowCacheKey(normalizedApiBase, model);
|
||||
if (!cacheKey) {
|
||||
return await queryOllamaModelShowInfo(normalizedApiBase, model.name);
|
||||
}
|
||||
|
||||
const cached = ollamaModelShowInfoCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return await cached;
|
||||
}
|
||||
|
||||
const pending = queryOllamaModelShowInfo(normalizedApiBase, model.name).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?: { 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);
|
||||
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);
|
||||
}
|
||||
|
||||
export function buildOllamaModelDefinition(
|
||||
modelId: string,
|
||||
contextWindow?: number,
|
||||
capabilities?: string[],
|
||||
): ModelDefinitionConfig {
|
||||
const hasVision = capabilities?.includes("vision") ?? false;
|
||||
const input: ("text" | "image")[] = hasVision ? ["text", "image"] : ["text"];
|
||||
return {
|
||||
id: modelId,
|
||||
name: modelId,
|
||||
reasoning: isReasoningModelHeuristic(modelId),
|
||||
input,
|
||||
cost: OLLAMA_DEFAULT_COST,
|
||||
contextWindow: contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW,
|
||||
maxTokens: OLLAMA_DEFAULT_MAX_TOKENS,
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchOllamaModels(
|
||||
baseUrl: string,
|
||||
): Promise<{ reachable: boolean; models: OllamaTagModel[] }> {
|
||||
try {
|
||||
const apiBase = resolveOllamaApiBase(baseUrl);
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: `${apiBase}/api/tags`,
|
||||
init: {
|
||||
signal: AbortSignal.timeout(5000),
|
||||
},
|
||||
policy: buildOllamaBaseUrlSsrFPolicy(apiBase),
|
||||
auditContext: "ollama-provider-models.tags",
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
return { reachable: true, models: [] };
|
||||
}
|
||||
const data = (await response.json()) as OllamaTagsResponse;
|
||||
const models = (data.models ?? []).filter((m) => m.name);
|
||||
return { reachable: true, models };
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch {
|
||||
return { reachable: false, models: [] };
|
||||
}
|
||||
}
|
||||
|
||||
export function resetOllamaModelShowInfoCacheForTest(): void {
|
||||
ollamaModelShowInfoCache.clear();
|
||||
}
|
||||
537
openclaw/extensions/ollama/src/setup.test.ts
Normal file
537
openclaw/extensions/ollama/src/setup.test.ts
Normal file
|
|
@ -0,0 +1,537 @@
|
|||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
|
||||
import type { WizardPrompter } from "openclaw/plugin-sdk/setup";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { jsonResponse, requestBodyText, requestUrl } from "../../../src/test-helpers/http.js";
|
||||
import { resetOllamaModelShowInfoCacheForTest } from "./provider-models.js";
|
||||
import {
|
||||
configureOllamaNonInteractive,
|
||||
ensureOllamaModelPulled,
|
||||
promptAndConfigureOllama,
|
||||
} from "./setup.js";
|
||||
|
||||
const upsertAuthProfileWithLock = vi.hoisted(() => vi.fn(async () => {}));
|
||||
vi.mock("openclaw/plugin-sdk/provider-auth", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/provider-auth")>();
|
||||
return {
|
||||
...actual,
|
||||
upsertAuthProfileWithLock,
|
||||
};
|
||||
});
|
||||
|
||||
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 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();
|
||||
upsertAuthProfileWithLock.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("puts suggested cloud model first in cloud mode", async () => {
|
||||
const prompter = createCloudPrompter();
|
||||
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();
|
||||
|
||||
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",
|
||||
"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("cloud mode does not hit local Ollama endpoints", async () => {
|
||||
const prompter = createCloudPrompter();
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await promptAndConfigureOllama({
|
||||
cfg: {},
|
||||
env: {},
|
||||
prompter,
|
||||
allowSecretRefPrompt: false,
|
||||
});
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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).toHaveBeenCalledTimes(2);
|
||||
expect(fetchMock.mock.calls[0]?.[0]).toContain("/api/tags");
|
||||
expect(fetchMock.mock.calls.some((call) => requestUrl(call[0]).includes("/api/me"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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 seeds the hosted cloud model list", async () => {
|
||||
const prompter = createCloudPrompter();
|
||||
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"]);
|
||||
expect(models?.find((model) => model.id === "kimi-k2.5:cloud")?.input).toEqual([
|
||||
"text",
|
||||
"image",
|
||||
]);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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(fetchMock.mock.calls[1][0]).toContain("/api/pull");
|
||||
});
|
||||
|
||||
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 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 = fetchMock.mock.calls[1]?.[1];
|
||||
expect(JSON.parse(requestBodyText(pullRequest?.body))).toEqual({ name: "llama3.2:latest" });
|
||||
expect(result.agents?.defaults?.model).toEqual(
|
||||
expect.objectContaining({ primary: "ollama/llama3.2: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(
|
||||
expect.objectContaining({ 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(
|
||||
expect.stringContaining("Ollama could not be reached at http://127.0.0.1:11435."),
|
||||
);
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(result).toBe(nextConfig);
|
||||
});
|
||||
});
|
||||
654
openclaw/extensions/ollama/src/setup.ts
Normal file
654
openclaw/extensions/ollama/src/setup.ts
Normal file
|
|
@ -0,0 +1,654 @@
|
|||
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 { 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/text-runtime";
|
||||
import {
|
||||
OLLAMA_CLOUD_BASE_URL,
|
||||
OLLAMA_DEFAULT_BASE_URL,
|
||||
OLLAMA_DEFAULT_MODEL,
|
||||
} from "./defaults.js";
|
||||
import {
|
||||
buildOllamaBaseUrlSsrFPolicy,
|
||||
buildOllamaModelDefinition,
|
||||
enrichOllamaModelsWithContext,
|
||||
fetchOllamaModels,
|
||||
resolveOllamaApiBase,
|
||||
type OllamaModelWithContext,
|
||||
} from "./provider-models.js";
|
||||
|
||||
const OLLAMA_SUGGESTED_MODELS_LOCAL = [OLLAMA_DEFAULT_MODEL];
|
||||
const OLLAMA_SUGGESTED_MODELS_CLOUD = ["kimi-k2.5:cloud", "minimax-m2.7:cloud", "glm-5.1:cloud"];
|
||||
const OLLAMA_CONTEXT_ENRICH_LIMIT = 200;
|
||||
|
||||
type OllamaSetupOptions = {
|
||||
customBaseUrl?: string;
|
||||
customModelId?: string;
|
||||
};
|
||||
|
||||
type OllamaSetupResult = {
|
||||
config: OpenClawConfig;
|
||||
credential: SecretInput;
|
||||
credentialMode?: SecretInputMode;
|
||||
};
|
||||
|
||||
type ProviderConfig = {
|
||||
baseUrl: string;
|
||||
api: "ollama";
|
||||
models: ReturnType<typeof buildOllamaModelDefinition>[];
|
||||
};
|
||||
|
||||
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 response.json()) as { signin_url?: string };
|
||||
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 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();
|
||||
try {
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: `${baseUrl}/api/pull`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name: modelName }),
|
||||
},
|
||||
policy: buildOllamaBaseUrlSsrFPolicy(baseUrl),
|
||||
auditContext: "ollama-setup.pull",
|
||||
});
|
||||
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 reader.read();
|
||||
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}` };
|
||||
}
|
||||
}
|
||||
|
||||
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 }> {
|
||||
const captured: { credential?: SecretInput; credentialMode?: SecretInputMode } = {};
|
||||
const optionToken = normalizeOptionalSecretInput(params.opts?.ollamaApiKey);
|
||||
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 };
|
||||
}
|
||||
|
||||
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 mergeUniqueModelNames(...groups: string[][]): string[] {
|
||||
const seen = new Set<string>();
|
||||
const merged: string[] = [];
|
||||
for (const group of groups) {
|
||||
for (const name of group) {
|
||||
if (seen.has(name)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(name);
|
||||
merged.push(name);
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
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): Promise<string> {
|
||||
const baseUrlRaw = await prompter.text({
|
||||
message: "Ollama base URL",
|
||||
initialValue: OLLAMA_DEFAULT_BASE_URL,
|
||||
placeholder: OLLAMA_DEFAULT_BASE_URL,
|
||||
validate: (value) => (value?.trim() ? undefined : "Required"),
|
||||
});
|
||||
return resolveOllamaApiBase((baseUrlRaw ?? "").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;
|
||||
}): Promise<OllamaSetupResult> {
|
||||
const baseUrl = await promptForOllamaBaseUrl(params.prompter);
|
||||
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 buildOllamaProvider(
|
||||
configuredBaseUrl?: string,
|
||||
opts?: { quiet?: boolean },
|
||||
): Promise<ProviderConfig> {
|
||||
const apiBase = resolveOllamaApiBase(configuredBaseUrl);
|
||||
const { reachable, models } = await fetchOllamaModels(apiBase);
|
||||
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),
|
||||
);
|
||||
return {
|
||||
baseUrl: apiBase,
|
||||
api: "ollama",
|
||||
models: discovered.map((model) =>
|
||||
buildOllamaModelDefinition(model.name, model.contextWindow, model.capabilities),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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 } = await promptForOllamaCloudCredential({
|
||||
cfg: params.cfg,
|
||||
env: params.env,
|
||||
opts: params.opts,
|
||||
prompter: params.prompter,
|
||||
secretInputMode: params.secretInputMode,
|
||||
allowSecretRefPrompt: params.allowSecretRefPrompt,
|
||||
});
|
||||
return {
|
||||
credential,
|
||||
credentialMode,
|
||||
config: applyOllamaProviderConfig(
|
||||
params.cfg,
|
||||
OLLAMA_CLOUD_BASE_URL,
|
||||
OLLAMA_SUGGESTED_MODELS_CLOUD,
|
||||
undefined,
|
||||
credential,
|
||||
),
|
||||
};
|
||||
}
|
||||
return await promptAndConfigureHostBackedOllama({
|
||||
cfg: params.cfg,
|
||||
mode,
|
||||
prompter: params.prompter,
|
||||
});
|
||||
}
|
||||
|
||||
export async function configureOllamaNonInteractive(params: {
|
||||
nextConfig: OpenClawConfig;
|
||||
opts: OllamaSetupOptions;
|
||||
runtime: RuntimeEnv;
|
||||
agentDir?: string;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const baseUrl = resolveOllamaApiBase(
|
||||
(params.opts.customBaseUrl?.trim() || OLLAMA_DEFAULT_BASE_URL).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 = [
|
||||
...OLLAMA_SUGGESTED_MODELS_LOCAL,
|
||||
...modelNames.filter((name) => !OLLAMA_SUGGESTED_MODELS_LOCAL.includes(name)),
|
||||
];
|
||||
|
||||
const requestedDefaultModelId = explicitModel ?? OLLAMA_SUGGESTED_MODELS_LOCAL[0];
|
||||
const availableModelNames = new Set(modelNames);
|
||||
const requestedCloudModel = isOllamaCloudModel(requestedDefaultModelId);
|
||||
let pulledRequestedModel = false;
|
||||
|
||||
if (requestedCloudModel) {
|
||||
availableModelNames.add(requestedDefaultModelId);
|
||||
} else if (!modelNames.includes(requestedDefaultModelId)) {
|
||||
pulledRequestedModel = await pullOllamaModelNonInteractive(
|
||||
baseUrl,
|
||||
requestedDefaultModelId,
|
||||
params.runtime,
|
||||
);
|
||||
if (pulledRequestedModel) {
|
||||
availableModelNames.add(requestedDefaultModelId);
|
||||
}
|
||||
}
|
||||
|
||||
let allModelNames = orderedModelNames;
|
||||
let defaultModelId = requestedDefaultModelId;
|
||||
if (
|
||||
(pulledRequestedModel || requestedCloudModel) &&
|
||||
!allModelNames.includes(requestedDefaultModelId)
|
||||
) {
|
||||
allModelNames = [...allModelNames, requestedDefaultModelId];
|
||||
}
|
||||
|
||||
if (!availableModelNames.has(requestedDefaultModelId)) {
|
||||
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) => availableModelNames.has(name)) ??
|
||||
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 = params.config.models?.providers?.ollama?.baseUrl ?? OLLAMA_DEFAULT_BASE_URL;
|
||||
const modelName = params.model.slice("ollama/".length);
|
||||
if (isOllamaCloudModel(modelName)) {
|
||||
return;
|
||||
}
|
||||
const { models } = await fetchOllamaModels(baseUrl);
|
||||
if (models.some((model) => model.name === modelName)) {
|
||||
return;
|
||||
}
|
||||
if (!(await pullOllamaModel(baseUrl, modelName, params.prompter))) {
|
||||
throw new WizardCancelledError("Failed to download selected Ollama model");
|
||||
}
|
||||
}
|
||||
1070
openclaw/extensions/ollama/src/stream-runtime.test.ts
Normal file
1070
openclaw/extensions/ollama/src/stream-runtime.test.ts
Normal file
File diff suppressed because it is too large
Load diff
235
openclaw/extensions/ollama/src/stream.test.ts
Normal file
235
openclaw/extensions/ollama/src/stream.test.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { afterEach, 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;
|
||||
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,
|
||||
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" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOllamaStreamFn thinking events", () => {
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
});
|
||||
|
||||
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();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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 body = makeNdjsonBody(thinkingChunks);
|
||||
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,
|
||||
{},
|
||||
);
|
||||
|
||||
const events: Array<{ type: string; [key: string]: unknown }> = [];
|
||||
for await (const event of stream as AsyncIterable<{ type: string; [key: string]: unknown }>) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
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]).toMatchObject({ type: "thinking", thinking: "Step 1 and step 2" });
|
||||
expect(content[1]).toMatchObject({ 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 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,
|
||||
{},
|
||||
);
|
||||
|
||||
const events: Array<{ type: string }> = [];
|
||||
for await (const event of stream as AsyncIterable<{ type: string }>) {
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
887
openclaw/extensions/ollama/src/stream.ts
Normal file
887
openclaw/extensions/ollama/src/stream.ts
Normal file
|
|
@ -0,0 +1,887 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import type { StreamFn } from "@mariozechner/pi-agent-core";
|
||||
import type {
|
||||
AssistantMessage,
|
||||
StopReason,
|
||||
TextContent,
|
||||
ThinkingContent,
|
||||
ToolCall,
|
||||
Tool,
|
||||
Usage,
|
||||
} from "@mariozechner/pi-ai";
|
||||
import { createAssistantMessageEventStream, streamSimple } from "@mariozechner/pi-ai";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import type {
|
||||
OpenClawConfig,
|
||||
ProviderRuntimeModel,
|
||||
ProviderWrapStreamFnContext,
|
||||
} from "openclaw/plugin-sdk/plugin-entry";
|
||||
import { isNonSecretApiKeyMarker } from "openclaw/plugin-sdk/provider-auth";
|
||||
import {
|
||||
DEFAULT_CONTEXT_TOKENS,
|
||||
normalizeProviderId,
|
||||
} from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import {
|
||||
createMoonshotThinkingWrapper,
|
||||
resolveMoonshotThinkingType,
|
||||
streamWithPayloadPatch,
|
||||
} from "openclaw/plugin-sdk/provider-stream-shared";
|
||||
import { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
|
||||
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty, readStringValue } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { OLLAMA_DEFAULT_BASE_URL } from "./defaults.js";
|
||||
import {
|
||||
parseJsonObjectPreservingUnsafeIntegers,
|
||||
parseJsonPreservingUnsafeIntegers,
|
||||
} from "./ollama-json.js";
|
||||
import { buildOllamaBaseUrlSsrFPolicy } from "./provider-models.js";
|
||||
|
||||
const log = createSubsystemLogger("ollama-stream");
|
||||
|
||||
export const OLLAMA_NATIVE_BASE_URL = OLLAMA_DEFAULT_BASE_URL;
|
||||
|
||||
export function resolveOllamaBaseUrlForRun(params: {
|
||||
modelBaseUrl?: string;
|
||||
providerBaseUrl?: string;
|
||||
}): string {
|
||||
const providerBaseUrl = params.providerBaseUrl?.trim();
|
||||
if (providerBaseUrl) {
|
||||
return providerBaseUrl;
|
||||
}
|
||||
const modelBaseUrl = params.modelBaseUrl?.trim();
|
||||
if (modelBaseUrl) {
|
||||
return modelBaseUrl;
|
||||
}
|
||||
return OLLAMA_NATIVE_BASE_URL;
|
||||
}
|
||||
|
||||
export function resolveConfiguredOllamaProviderConfig(params: {
|
||||
config?: OpenClawConfig;
|
||||
providerId?: string;
|
||||
}) {
|
||||
const providerId = params.providerId?.trim();
|
||||
if (!providerId) {
|
||||
return undefined;
|
||||
}
|
||||
const providers = params.config?.models?.providers;
|
||||
if (!providers) {
|
||||
return undefined;
|
||||
}
|
||||
const direct = providers[providerId];
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const normalized = normalizeProviderId(providerId);
|
||||
for (const [candidateId, candidate] of Object.entries(providers)) {
|
||||
if (normalizeProviderId(candidateId) === normalized) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function isOllamaCompatProvider(model: {
|
||||
provider?: string;
|
||||
baseUrl?: string;
|
||||
api?: string;
|
||||
}): boolean {
|
||||
const providerId = normalizeProviderId(model.provider ?? "");
|
||||
if (providerId === "ollama") {
|
||||
return true;
|
||||
}
|
||||
if (!model.baseUrl) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(model.baseUrl);
|
||||
const hostname = normalizeLowercaseStringOrEmpty(parsed.hostname);
|
||||
const isLocalhost =
|
||||
hostname === "localhost" ||
|
||||
hostname === "127.0.0.1" ||
|
||||
hostname === "::1" ||
|
||||
hostname === "[::1]";
|
||||
if (isLocalhost && parsed.port === "11434") {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow remote/LAN Ollama OpenAI-compatible endpoints when the provider id
|
||||
// itself indicates Ollama usage (for example "my-ollama").
|
||||
const providerHintsOllama = providerId.includes("ollama");
|
||||
const isOllamaPort = parsed.port === "11434";
|
||||
const isOllamaCompatPath = parsed.pathname === "/" || /^\/v1\/?$/i.test(parsed.pathname);
|
||||
return providerHintsOllama && isOllamaPort && isOllamaCompatPath;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveOllamaCompatNumCtxEnabled(params: {
|
||||
config?: OpenClawConfig;
|
||||
providerId?: string;
|
||||
}): boolean {
|
||||
return resolveConfiguredOllamaProviderConfig(params)?.injectNumCtxForOpenAICompat ?? true;
|
||||
}
|
||||
|
||||
export function shouldInjectOllamaCompatNumCtx(params: {
|
||||
model: { api?: string; provider?: string; baseUrl?: string };
|
||||
config?: OpenClawConfig;
|
||||
providerId?: string;
|
||||
}): boolean {
|
||||
if (params.model.api !== "openai-completions") {
|
||||
return false;
|
||||
}
|
||||
if (!isOllamaCompatProvider(params.model)) {
|
||||
return false;
|
||||
}
|
||||
return resolveOllamaCompatNumCtxEnabled({
|
||||
config: params.config,
|
||||
providerId: params.providerId,
|
||||
});
|
||||
}
|
||||
|
||||
export function wrapOllamaCompatNumCtx(baseFn: StreamFn | undefined, numCtx: number): StreamFn {
|
||||
const streamFn = baseFn ?? streamSimple;
|
||||
return (model, context, options) =>
|
||||
streamWithPayloadPatch(streamFn, model, context, options, (payloadRecord) => {
|
||||
if (!payloadRecord.options || typeof payloadRecord.options !== "object") {
|
||||
payloadRecord.options = {};
|
||||
}
|
||||
(payloadRecord.options as Record<string, unknown>).num_ctx = numCtx;
|
||||
normalizeOllamaCompatMessageToolArgs(payloadRecord);
|
||||
});
|
||||
}
|
||||
|
||||
function createOllamaThinkingWrapper(baseFn: StreamFn | undefined, think: boolean): StreamFn {
|
||||
const streamFn = baseFn ?? streamSimple;
|
||||
return (model, context, options) => {
|
||||
if (model.api !== "ollama") {
|
||||
return streamFn(model, context, options);
|
||||
}
|
||||
return streamWithPayloadPatch(streamFn, model, context, options, (payloadRecord) => {
|
||||
payloadRecord.think = think;
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
function resolveOllamaCompatNumCtx(model: ProviderRuntimeModel): number {
|
||||
return Math.max(1, Math.floor(model.contextWindow ?? model.maxTokens ?? DEFAULT_CONTEXT_TOKENS));
|
||||
}
|
||||
|
||||
function isOllamaCloudKimiModelRef(modelId: string): boolean {
|
||||
const normalizedModelId = normalizeLowercaseStringOrEmpty(modelId);
|
||||
return normalizedModelId.startsWith("kimi-k") && normalizedModelId.includes(":cloud");
|
||||
}
|
||||
|
||||
export function createConfiguredOllamaCompatStreamWrapper(
|
||||
ctx: ProviderWrapStreamFnContext,
|
||||
): StreamFn | undefined {
|
||||
let streamFn = ctx.streamFn;
|
||||
const model = ctx.model;
|
||||
let injectNumCtx = false;
|
||||
|
||||
if (model) {
|
||||
const providerId =
|
||||
typeof model.provider === "string" && model.provider.trim().length > 0
|
||||
? model.provider
|
||||
: ctx.provider;
|
||||
if (
|
||||
shouldInjectOllamaCompatNumCtx({
|
||||
model,
|
||||
config: ctx.config,
|
||||
providerId,
|
||||
})
|
||||
) {
|
||||
injectNumCtx = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (injectNumCtx && model) {
|
||||
streamFn = wrapOllamaCompatNumCtx(streamFn, resolveOllamaCompatNumCtx(model));
|
||||
}
|
||||
|
||||
if (ctx.thinkingLevel === "off") {
|
||||
streamFn = createOllamaThinkingWrapper(streamFn, false);
|
||||
} else if (ctx.thinkingLevel) {
|
||||
// Any non-off ThinkLevel (minimal, low, medium, high, xhigh, adaptive)
|
||||
// should enable Ollama's native thinking mode.
|
||||
streamFn = createOllamaThinkingWrapper(streamFn, true);
|
||||
}
|
||||
|
||||
if (normalizeProviderId(ctx.provider) === "ollama" && isOllamaCloudKimiModelRef(ctx.modelId)) {
|
||||
const thinkingType = resolveMoonshotThinkingType({
|
||||
configuredThinking: ctx.extraParams?.thinking,
|
||||
thinkingLevel: ctx.thinkingLevel,
|
||||
});
|
||||
streamFn = createMoonshotThinkingWrapper(streamFn, thinkingType);
|
||||
}
|
||||
|
||||
return streamFn;
|
||||
}
|
||||
|
||||
// Backward-compatible alias for existing imports/tests while the broader
|
||||
// Ollama compat wrapper now owns more than num_ctx injection.
|
||||
export const createConfiguredOllamaCompatNumCtxWrapper = createConfiguredOllamaCompatStreamWrapper;
|
||||
|
||||
function normalizeOllamaWireModelId(modelId: string): string {
|
||||
const trimmed = modelId.trim();
|
||||
return trimmed.startsWith("ollama/") ? trimmed.slice("ollama/".length) : trimmed;
|
||||
}
|
||||
|
||||
export function buildOllamaChatRequest(params: {
|
||||
modelId: string;
|
||||
messages: OllamaChatMessage[];
|
||||
tools?: OllamaTool[];
|
||||
options?: Record<string, unknown>;
|
||||
stream?: boolean;
|
||||
}): OllamaChatRequest {
|
||||
return {
|
||||
model: normalizeOllamaWireModelId(params.modelId),
|
||||
messages: params.messages,
|
||||
stream: params.stream ?? true,
|
||||
...(params.tools && params.tools.length > 0 ? { tools: params.tools } : {}),
|
||||
...(params.options ? { options: params.options } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
type StreamModelDescriptor = {
|
||||
api: string;
|
||||
provider: string;
|
||||
id: string;
|
||||
};
|
||||
|
||||
function buildUsageWithNoCost(params: {
|
||||
input?: number;
|
||||
output?: number;
|
||||
cacheRead?: number;
|
||||
cacheWrite?: number;
|
||||
totalTokens?: number;
|
||||
}): Usage {
|
||||
const input = params.input ?? 0;
|
||||
const output = params.output ?? 0;
|
||||
const cacheRead = params.cacheRead ?? 0;
|
||||
const cacheWrite = params.cacheWrite ?? 0;
|
||||
return {
|
||||
input,
|
||||
output,
|
||||
cacheRead,
|
||||
cacheWrite,
|
||||
totalTokens: params.totalTokens ?? input + output,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
};
|
||||
}
|
||||
|
||||
function buildStreamAssistantMessage(params: {
|
||||
model: StreamModelDescriptor;
|
||||
content: AssistantMessage["content"];
|
||||
stopReason: StopReason;
|
||||
usage: Usage;
|
||||
timestamp?: number;
|
||||
}): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: params.content,
|
||||
stopReason: params.stopReason,
|
||||
api: params.model.api,
|
||||
provider: params.model.provider,
|
||||
model: params.model.id,
|
||||
usage: params.usage,
|
||||
timestamp: params.timestamp ?? Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
function buildStreamErrorAssistantMessage(params: {
|
||||
model: StreamModelDescriptor;
|
||||
errorMessage: string;
|
||||
timestamp?: number;
|
||||
}): AssistantMessage & { stopReason: "error"; errorMessage: string } {
|
||||
return {
|
||||
...buildStreamAssistantMessage({
|
||||
model: params.model,
|
||||
content: [],
|
||||
stopReason: "error",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
timestamp: params.timestamp,
|
||||
}),
|
||||
stopReason: "error",
|
||||
errorMessage: params.errorMessage,
|
||||
};
|
||||
}
|
||||
|
||||
interface OllamaChatRequest {
|
||||
model: string;
|
||||
messages: OllamaChatMessage[];
|
||||
stream: boolean;
|
||||
tools?: OllamaTool[];
|
||||
options?: Record<string, unknown>;
|
||||
think?: boolean;
|
||||
}
|
||||
|
||||
interface OllamaChatMessage {
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
images?: string[];
|
||||
tool_calls?: OllamaToolCall[];
|
||||
tool_name?: string;
|
||||
}
|
||||
|
||||
interface OllamaTool {
|
||||
type: "function";
|
||||
function: {
|
||||
name: string;
|
||||
description: string;
|
||||
parameters: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
interface OllamaToolCall {
|
||||
function: {
|
||||
name: string;
|
||||
arguments: Record<string, unknown>;
|
||||
};
|
||||
}
|
||||
|
||||
interface OllamaChatResponse {
|
||||
model: string;
|
||||
created_at: string;
|
||||
message: {
|
||||
role: "assistant";
|
||||
content: string;
|
||||
thinking?: string;
|
||||
reasoning?: string;
|
||||
tool_calls?: OllamaToolCall[];
|
||||
};
|
||||
done: boolean;
|
||||
done_reason?: string;
|
||||
total_duration?: number;
|
||||
load_duration?: number;
|
||||
prompt_eval_count?: number;
|
||||
prompt_eval_duration?: number;
|
||||
eval_count?: number;
|
||||
eval_duration?: number;
|
||||
}
|
||||
|
||||
type InputContentPart =
|
||||
| { type: "text"; text: string }
|
||||
| { type: "image"; data: string }
|
||||
| { type: "toolCall"; id: string; name: string; arguments: unknown }
|
||||
| { type: "tool_use"; id: string; name: string; input: unknown };
|
||||
|
||||
function extractTextContent(content: unknown): string {
|
||||
if (typeof content === "string") {
|
||||
return content;
|
||||
}
|
||||
if (!Array.isArray(content)) {
|
||||
return "";
|
||||
}
|
||||
return (content as InputContentPart[])
|
||||
.filter((part): part is { type: "text"; text: string } => part.type === "text")
|
||||
.map((part) => part.text)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function extractOllamaImages(content: unknown): string[] {
|
||||
if (!Array.isArray(content)) {
|
||||
return [];
|
||||
}
|
||||
return (content as InputContentPart[])
|
||||
.filter((part): part is { type: "image"; data: string } => part.type === "image")
|
||||
.map((part) => part.data);
|
||||
}
|
||||
|
||||
function ensureArgsObject(value: unknown): Record<string, unknown> {
|
||||
return parseJsonObjectPreservingUnsafeIntegers(value) ?? {};
|
||||
}
|
||||
|
||||
function normalizeOllamaCompatMessageToolArgs(payloadRecord: Record<string, unknown>): void {
|
||||
const messages = payloadRecord.messages;
|
||||
if (!Array.isArray(messages)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const message of messages) {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
||||
continue;
|
||||
}
|
||||
const messageRecord = message as Record<string, unknown>;
|
||||
|
||||
const functionCall = messageRecord.function_call;
|
||||
if (functionCall && typeof functionCall === "object" && !Array.isArray(functionCall)) {
|
||||
const functionCallRecord = functionCall as Record<string, unknown>;
|
||||
if (Object.hasOwn(functionCallRecord, "arguments")) {
|
||||
functionCallRecord.arguments = ensureArgsObject(functionCallRecord.arguments);
|
||||
}
|
||||
}
|
||||
|
||||
const toolCalls = messageRecord.tool_calls;
|
||||
if (!Array.isArray(toolCalls)) {
|
||||
continue;
|
||||
}
|
||||
for (const toolCall of toolCalls) {
|
||||
if (!toolCall || typeof toolCall !== "object" || Array.isArray(toolCall)) {
|
||||
continue;
|
||||
}
|
||||
const functionSpec = (toolCall as Record<string, unknown>).function;
|
||||
if (!functionSpec || typeof functionSpec !== "object" || Array.isArray(functionSpec)) {
|
||||
continue;
|
||||
}
|
||||
const functionRecord = functionSpec as Record<string, unknown>;
|
||||
if (Object.hasOwn(functionRecord, "arguments")) {
|
||||
functionRecord.arguments = ensureArgsObject(functionRecord.arguments);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractToolCalls(content: unknown): OllamaToolCall[] {
|
||||
if (!Array.isArray(content)) {
|
||||
return [];
|
||||
}
|
||||
const parts = content as InputContentPart[];
|
||||
const result: OllamaToolCall[] = [];
|
||||
for (const part of parts) {
|
||||
if (part.type === "toolCall") {
|
||||
result.push({ function: { name: part.name, arguments: ensureArgsObject(part.arguments) } });
|
||||
} else if (part.type === "tool_use") {
|
||||
result.push({ function: { name: part.name, arguments: ensureArgsObject(part.input) } });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function convertToOllamaMessages(
|
||||
messages: Array<{ role: string; content: unknown }>,
|
||||
system?: string,
|
||||
): OllamaChatMessage[] {
|
||||
const result: OllamaChatMessage[] = [];
|
||||
|
||||
if (system) {
|
||||
result.push({ role: "system", content: system });
|
||||
}
|
||||
|
||||
for (const msg of messages) {
|
||||
if (msg.role === "user") {
|
||||
const text = extractTextContent(msg.content);
|
||||
const images = extractOllamaImages(msg.content);
|
||||
result.push({
|
||||
role: "user",
|
||||
content: text,
|
||||
...(images.length > 0 ? { images } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "assistant") {
|
||||
const text = extractTextContent(msg.content);
|
||||
const toolCalls = extractToolCalls(msg.content);
|
||||
result.push({
|
||||
role: "assistant",
|
||||
content: text,
|
||||
...(toolCalls.length > 0 ? { tool_calls: toolCalls } : {}),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (msg.role === "tool" || msg.role === "toolResult") {
|
||||
const text = extractTextContent(msg.content);
|
||||
const toolName =
|
||||
typeof (msg as { toolName?: unknown }).toolName === "string"
|
||||
? (msg as { toolName?: string }).toolName
|
||||
: undefined;
|
||||
result.push({
|
||||
role: "tool",
|
||||
content: text,
|
||||
...(toolName ? { tool_name: toolName } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function extractOllamaTools(tools: Tool[] | undefined): OllamaTool[] {
|
||||
if (!tools || !Array.isArray(tools)) {
|
||||
return [];
|
||||
}
|
||||
const result: OllamaTool[] = [];
|
||||
for (const tool of tools) {
|
||||
if (typeof tool.name !== "string" || !tool.name) {
|
||||
continue;
|
||||
}
|
||||
result.push({
|
||||
type: "function",
|
||||
function: {
|
||||
name: tool.name,
|
||||
description: typeof tool.description === "string" ? tool.description : "",
|
||||
parameters: (tool.parameters ?? {}) as Record<string, unknown>,
|
||||
},
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function buildAssistantMessage(
|
||||
response: OllamaChatResponse,
|
||||
modelInfo: StreamModelDescriptor,
|
||||
): AssistantMessage {
|
||||
const content: (TextContent | ThinkingContent | ToolCall)[] = [];
|
||||
const thinking = response.message.thinking ?? response.message.reasoning ?? "";
|
||||
if (thinking) {
|
||||
content.push({ type: "thinking", thinking });
|
||||
}
|
||||
const text = response.message.content || "";
|
||||
if (text) {
|
||||
content.push({ type: "text", text });
|
||||
}
|
||||
|
||||
const toolCalls = response.message.tool_calls;
|
||||
if (toolCalls && toolCalls.length > 0) {
|
||||
for (const toolCall of toolCalls) {
|
||||
content.push({
|
||||
type: "toolCall",
|
||||
id: `ollama_call_${randomUUID()}`,
|
||||
name: toolCall.function.name,
|
||||
arguments: toolCall.function.arguments,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content,
|
||||
stopReason: toolCalls && toolCalls.length > 0 ? "toolUse" : "stop",
|
||||
usage: buildUsageWithNoCost({
|
||||
input: response.prompt_eval_count ?? 0,
|
||||
output: response.eval_count ?? 0,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export async function* parseNdjsonStream(
|
||||
reader: ReadableStreamDefaultReader<Uint8Array>,
|
||||
): AsyncGenerator<OllamaChatResponse> {
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = "";
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
break;
|
||||
}
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() ?? "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
yield parseJsonPreservingUnsafeIntegers(trimmed) as OllamaChatResponse;
|
||||
} catch {
|
||||
log.warn(`Skipping malformed NDJSON line: ${trimmed.slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (buffer.trim()) {
|
||||
try {
|
||||
yield parseJsonPreservingUnsafeIntegers(buffer.trim()) as OllamaChatResponse;
|
||||
} catch {
|
||||
log.warn(`Skipping malformed trailing data: ${buffer.trim().slice(0, 120)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveOllamaChatUrl(baseUrl: string): string {
|
||||
const trimmed = baseUrl.trim().replace(/\/+$/, "");
|
||||
const normalizedBase = trimmed.replace(/\/v1$/i, "");
|
||||
return `${normalizedBase || OLLAMA_NATIVE_BASE_URL}/api/chat`;
|
||||
}
|
||||
|
||||
function resolveOllamaModelHeaders(model: {
|
||||
headers?: unknown;
|
||||
}): Record<string, string> | undefined {
|
||||
if (!model.headers || typeof model.headers !== "object" || Array.isArray(model.headers)) {
|
||||
return undefined;
|
||||
}
|
||||
return model.headers as Record<string, string>;
|
||||
}
|
||||
|
||||
export function createOllamaStreamFn(
|
||||
baseUrl: string,
|
||||
defaultHeaders?: Record<string, string>,
|
||||
): StreamFn {
|
||||
const chatUrl = resolveOllamaChatUrl(baseUrl);
|
||||
const ssrfPolicy = buildOllamaBaseUrlSsrFPolicy(chatUrl);
|
||||
|
||||
return (model, context, options) => {
|
||||
const stream = createAssistantMessageEventStream();
|
||||
|
||||
const run = async () => {
|
||||
try {
|
||||
const ollamaMessages = convertToOllamaMessages(
|
||||
context.messages ?? [],
|
||||
context.systemPrompt,
|
||||
);
|
||||
const ollamaTools = extractOllamaTools(context.tools);
|
||||
|
||||
const ollamaOptions: Record<string, unknown> = { num_ctx: model.contextWindow ?? 65536 };
|
||||
if (typeof options?.temperature === "number") {
|
||||
ollamaOptions.temperature = options.temperature;
|
||||
}
|
||||
if (typeof options?.maxTokens === "number") {
|
||||
ollamaOptions.num_predict = options.maxTokens;
|
||||
}
|
||||
|
||||
const body = buildOllamaChatRequest({
|
||||
modelId: model.id,
|
||||
messages: ollamaMessages,
|
||||
stream: true,
|
||||
tools: ollamaTools,
|
||||
options: ollamaOptions,
|
||||
});
|
||||
options?.onPayload?.(body, model);
|
||||
const headers: Record<string, string> = {
|
||||
"Content-Type": "application/json",
|
||||
...defaultHeaders,
|
||||
...options?.headers,
|
||||
};
|
||||
if (
|
||||
options?.apiKey &&
|
||||
(!headers.Authorization || !isNonSecretApiKeyMarker(options.apiKey))
|
||||
) {
|
||||
headers.Authorization = `Bearer ${options.apiKey}`;
|
||||
}
|
||||
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: chatUrl,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(body),
|
||||
signal: options?.signal,
|
||||
},
|
||||
policy: ssrfPolicy,
|
||||
auditContext: "ollama-stream.chat",
|
||||
});
|
||||
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => "unknown error");
|
||||
throw new Error(`${response.status} ${errorText}`);
|
||||
}
|
||||
if (!response.body) {
|
||||
throw new Error("Ollama API returned empty response body");
|
||||
}
|
||||
|
||||
const reader = response.body.getReader();
|
||||
let accumulatedContent = "";
|
||||
let accumulatedThinking = "";
|
||||
const accumulatedToolCalls: OllamaToolCall[] = [];
|
||||
let finalResponse: OllamaChatResponse | undefined;
|
||||
const modelInfo = { api: model.api, provider: model.provider, id: model.id };
|
||||
let streamStarted = false;
|
||||
let thinkingStarted = false;
|
||||
let thinkingEnded = false;
|
||||
let textBlockStarted = false;
|
||||
let textBlockClosed = false;
|
||||
const textContentIndex = () => (thinkingStarted ? 1 : 0);
|
||||
|
||||
const buildCurrentContent = (): (TextContent | ThinkingContent | ToolCall)[] => {
|
||||
const parts: (TextContent | ThinkingContent | ToolCall)[] = [];
|
||||
if (accumulatedThinking) {
|
||||
parts.push({
|
||||
type: "thinking",
|
||||
thinking: accumulatedThinking,
|
||||
});
|
||||
}
|
||||
if (accumulatedContent) {
|
||||
parts.push({ type: "text", text: accumulatedContent });
|
||||
}
|
||||
return parts;
|
||||
};
|
||||
|
||||
const closeThinkingBlock = () => {
|
||||
if (!thinkingStarted || thinkingEnded) {
|
||||
return;
|
||||
}
|
||||
thinkingEnded = true;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({
|
||||
type: "thinking_end",
|
||||
contentIndex: 0,
|
||||
content: accumulatedThinking,
|
||||
partial,
|
||||
});
|
||||
};
|
||||
|
||||
const closeTextBlock = () => {
|
||||
if (!textBlockStarted || textBlockClosed) {
|
||||
return;
|
||||
}
|
||||
textBlockClosed = true;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({
|
||||
type: "text_end",
|
||||
contentIndex: textContentIndex(),
|
||||
content: accumulatedContent,
|
||||
partial,
|
||||
});
|
||||
};
|
||||
|
||||
for await (const chunk of parseNdjsonStream(reader)) {
|
||||
const thinkingDelta = chunk.message?.thinking ?? chunk.message?.reasoning;
|
||||
if (thinkingDelta) {
|
||||
if (!streamStarted) {
|
||||
streamStarted = true;
|
||||
const emptyPartial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: [],
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({ type: "start", partial: emptyPartial });
|
||||
}
|
||||
if (!thinkingStarted) {
|
||||
thinkingStarted = true;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({ type: "thinking_start", contentIndex: 0, partial });
|
||||
}
|
||||
accumulatedThinking += thinkingDelta;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({
|
||||
type: "thinking_delta",
|
||||
contentIndex: 0,
|
||||
delta: thinkingDelta,
|
||||
partial,
|
||||
});
|
||||
}
|
||||
|
||||
if (chunk.message?.content) {
|
||||
const delta = chunk.message.content;
|
||||
if (thinkingStarted && !thinkingEnded) {
|
||||
closeThinkingBlock();
|
||||
}
|
||||
|
||||
if (!streamStarted) {
|
||||
streamStarted = true;
|
||||
const emptyPartial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: [],
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({ type: "start", partial: emptyPartial });
|
||||
}
|
||||
if (!textBlockStarted) {
|
||||
textBlockStarted = true;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({ type: "text_start", contentIndex: textContentIndex(), partial });
|
||||
}
|
||||
|
||||
accumulatedContent += delta;
|
||||
const partial = buildStreamAssistantMessage({
|
||||
model: modelInfo,
|
||||
content: buildCurrentContent(),
|
||||
stopReason: "stop",
|
||||
usage: buildUsageWithNoCost({}),
|
||||
});
|
||||
stream.push({
|
||||
type: "text_delta",
|
||||
contentIndex: textContentIndex(),
|
||||
delta,
|
||||
partial,
|
||||
});
|
||||
}
|
||||
if (chunk.message?.tool_calls) {
|
||||
closeThinkingBlock();
|
||||
closeTextBlock();
|
||||
accumulatedToolCalls.push(...chunk.message.tool_calls);
|
||||
}
|
||||
if (chunk.done) {
|
||||
finalResponse = chunk;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!finalResponse) {
|
||||
throw new Error("Ollama API stream ended without a final response");
|
||||
}
|
||||
|
||||
finalResponse.message.content = accumulatedContent;
|
||||
if (accumulatedThinking) {
|
||||
finalResponse.message.thinking = accumulatedThinking;
|
||||
}
|
||||
if (accumulatedToolCalls.length > 0) {
|
||||
finalResponse.message.tool_calls = accumulatedToolCalls;
|
||||
}
|
||||
|
||||
const assistantMessage = buildAssistantMessage(finalResponse, modelInfo);
|
||||
closeThinkingBlock();
|
||||
closeTextBlock();
|
||||
|
||||
stream.push({
|
||||
type: "done",
|
||||
reason: assistantMessage.stopReason === "toolUse" ? "toolUse" : "stop",
|
||||
message: assistantMessage,
|
||||
});
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch (err) {
|
||||
stream.push({
|
||||
type: "error",
|
||||
reason: "error",
|
||||
error: buildStreamErrorAssistantMessage({
|
||||
model,
|
||||
errorMessage: formatErrorMessage(err),
|
||||
}),
|
||||
});
|
||||
} finally {
|
||||
stream.end();
|
||||
}
|
||||
};
|
||||
|
||||
queueMicrotask(() => void run());
|
||||
return stream;
|
||||
};
|
||||
}
|
||||
|
||||
export function createConfiguredOllamaStreamFn(params: {
|
||||
model: { baseUrl?: string; headers?: unknown };
|
||||
providerBaseUrl?: string;
|
||||
}): StreamFn {
|
||||
return createOllamaStreamFn(
|
||||
resolveOllamaBaseUrlForRun({
|
||||
modelBaseUrl: readStringValue(params.model.baseUrl),
|
||||
providerBaseUrl: params.providerBaseUrl,
|
||||
}),
|
||||
resolveOllamaModelHeaders(params.model),
|
||||
);
|
||||
}
|
||||
275
openclaw/extensions/ollama/src/web-search-provider.test.ts
Normal file
275
openclaw/extensions/ollama/src/web-search-provider.test.ts
Normal file
|
|
@ -0,0 +1,275 @@
|
|||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createOllamaWebSearchProvider as createContractOllamaWebSearchProvider } from "../web-search-contract-api.js";
|
||||
import {
|
||||
__testing as 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;
|
||||
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 });
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("ollama web search provider", () => {
|
||||
beforeEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
});
|
||||
|
||||
it("registers a keyless web search provider", () => {
|
||||
expect(createContractOllamaWebSearchProvider()).toMatchObject({
|
||||
id: "ollama",
|
||||
label: "Ollama Web Search",
|
||||
requiresCredential: false,
|
||||
envVars: [],
|
||||
});
|
||||
});
|
||||
|
||||
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("falls back to the local Ollama host when the model provider uses ollama cloud", () => {
|
||||
expect(
|
||||
testing.resolveOllamaWebSearchBaseUrl(
|
||||
createOllamaConfig({
|
||||
baseUrl: "https://ollama.com",
|
||||
}),
|
||||
),
|
||||
).toBe("http://127.0.0.1:11434");
|
||||
});
|
||||
|
||||
it("maps generic search args into the Ollama experimental search 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 });
|
||||
|
||||
expect(fetchWithSsrFGuardMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
url: "http://ollama.local:11434/api/experimental/web_search",
|
||||
auditContext: "ollama-web-search.search",
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
JSON.parse(
|
||||
String(
|
||||
(
|
||||
fetchWithSsrFGuardMock.mock.calls[0]?.[0] as {
|
||||
init?: { body?: string };
|
||||
}
|
||||
).init?.body,
|
||||
),
|
||||
),
|
||||
).toEqual({
|
||||
query: "openclaw docs",
|
||||
max_results: 3,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
query: "openclaw docs",
|
||||
provider: "ollama",
|
||||
count: 1,
|
||||
results: [{ url: "https://openclaw.ai/docs" }],
|
||||
});
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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("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([
|
||||
expect.objectContaining({
|
||||
title: "Ollama Web Search",
|
||||
message: expect.stringContaining("requires Ollama to be running"),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
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([
|
||||
expect.objectContaining({
|
||||
title: "Ollama Web Search",
|
||||
message: expect.stringContaining("Ollama Web Search requires `ollama signin`."),
|
||||
}),
|
||||
]);
|
||||
expect(notes[0]?.message).toContain("https://ollama.com/signin");
|
||||
});
|
||||
});
|
||||
250
openclaw/extensions/ollama/src/web-search-provider.ts
Normal file
250
openclaw/extensions/ollama/src/web-search-provider.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import { Type } from "@sinclair/typebox";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
|
||||
import {
|
||||
isNonSecretApiKeyMarker,
|
||||
normalizeOptionalSecretInput,
|
||||
} from "openclaw/plugin-sdk/provider-auth";
|
||||
import { resolveEnvApiKey } from "openclaw/plugin-sdk/provider-auth-runtime";
|
||||
import {
|
||||
enablePluginInConfig,
|
||||
readNumberParam,
|
||||
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/text-runtime";
|
||||
import { OLLAMA_CLOUD_BASE_URL, OLLAMA_DEFAULT_BASE_URL } from "./defaults.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.Number({
|
||||
description: "Number of results to return (1-10).",
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
}),
|
||||
),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
const OLLAMA_WEB_SEARCH_PATH = "/api/experimental/web_search";
|
||||
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[];
|
||||
};
|
||||
|
||||
function resolveOllamaWebSearchApiKey(config?: OpenClawConfig): string | undefined {
|
||||
const providerApiKey = normalizeOptionalSecretInput(config?.models?.providers?.ollama?.apiKey);
|
||||
if (providerApiKey && !isNonSecretApiKeyMarker(providerApiKey)) {
|
||||
return providerApiKey;
|
||||
}
|
||||
return resolveEnvApiKey("ollama")?.apiKey;
|
||||
}
|
||||
|
||||
function resolveOllamaWebSearchBaseUrl(config?: OpenClawConfig): string {
|
||||
const pluginBaseUrl = normalizeOptionalString(
|
||||
resolveProviderWebSearchPluginConfig(config, "ollama")?.baseUrl,
|
||||
);
|
||||
if (pluginBaseUrl) {
|
||||
return resolveOllamaApiBase(pluginBaseUrl);
|
||||
}
|
||||
const configuredBaseUrl = config?.models?.providers?.ollama?.baseUrl;
|
||||
if (normalizeOptionalString(configuredBaseUrl)) {
|
||||
const baseUrl = resolveOllamaApiBase(configuredBaseUrl);
|
||||
if (baseUrl !== OLLAMA_CLOUD_BASE_URL) {
|
||||
return baseUrl;
|
||||
}
|
||||
}
|
||||
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) ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
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 apiKey = resolveOllamaWebSearchApiKey(params.config);
|
||||
const count = resolveSearchCount(params.count, DEFAULT_OLLAMA_WEB_SEARCH_COUNT);
|
||||
const startedAt = Date.now();
|
||||
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
||||
if (apiKey) {
|
||||
headers.Authorization = `Bearer ${apiKey}`;
|
||||
}
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url: `${baseUrl}${OLLAMA_WEB_SEARCH_PATH}`,
|
||||
init: {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ query, max_results: count }),
|
||||
signal: AbortSignal.timeout(DEFAULT_OLLAMA_WEB_SEARCH_TIMEOUT_MS),
|
||||
},
|
||||
policy: buildOllamaBaseUrlSsrFPolicy(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 });
|
||||
throw new Error(`Ollama web search failed (${response.status}): ${detail.text || ""}`.trim());
|
||||
}
|
||||
|
||||
const payload = (await response.json()) as OllamaWebSearchResponse;
|
||||
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,
|
||||
};
|
||||
}),
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
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 experimental 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: readNumberParam(args, "count", { integer: true }),
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
normalizeOllamaWebSearchResult,
|
||||
resolveOllamaWebSearchApiKey,
|
||||
resolveOllamaWebSearchBaseUrl,
|
||||
warnOllamaWebSearchPrereqs,
|
||||
};
|
||||
16
openclaw/extensions/ollama/tsconfig.json
Normal file
16
openclaw/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"
|
||||
]
|
||||
}
|
||||
26
openclaw/extensions/ollama/web-search-contract-api.ts
Normal file
26
openclaw/extensions/ollama/web-search-contract-api.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
1
openclaw/extensions/ollama/web-search-provider.ts
Normal file
1
openclaw/extensions/ollama/web-search-provider.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { createOllamaWebSearchProvider } from "./src/web-search-provider.js";
|
||||
Loading…
Add table
Add a link
Reference in a new issue