diff --git a/CHANGELOG.md b/CHANGELOG.md index 352bd8048ad..5845d706a7b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -113,6 +113,7 @@ Docs: https://docs.openclaw.ai - Models/custom provider headers: propagate `models.providers..headers` across inline, fallback, and registry-found model resolution so header-authenticated proxies consistently receive configured request headers. (#27490) thanks @Sid-Qin. - Ollama/remote provider auth fallback: synthesize a local runtime auth key for explicitly configured `models.providers.ollama` entries that omit `apiKey`, so remote Ollama endpoints run without requiring manual dummy-key setup while preserving env/profile/config key precedence and missing-config failures. (#11283) Thanks @cpreecs. - Ollama/custom provider headers: forward resolved model headers into native Ollama stream requests so header-authenticated Ollama proxies receive configured request headers. (#24337) thanks @echoVic. +- Ollama/compaction and summarization: register custom `api: "ollama"` handling for compaction, branch-style internal summarization, and TTS text summarization on current `main`, so native Ollama models no longer fail with `No API provider registered for api: ollama` outside the main run loop. Thanks @JaviLib. - Daemon/systemd install robustness: treat `systemctl --user is-enabled` exit-code-4 `not-found` responses as not-enabled by combining stderr/stdout detail parsing, so Ubuntu fresh installs no longer fail with `systemctl is-enabled unavailable`. (#33634) Thanks @Yuandiaodiaodiao. - Slack/system-event session routing: resolve reaction/member/pin/interaction system-event session keys through channel/account bindings (with sender-aware DM routing) so inbound Slack events target the correct agent session in multi-account setups instead of defaulting to `agent:main`. (#34045) Thanks @paulomcg, @daht-mad and @vincentkoc. - Slack/native streaming markdown conversion: stop pre-normalizing text passed to Slack native `markdown_text` in streaming start/append/stop paths to prevent Markdown style corruption from double conversion. (#34931) diff --git a/src/agents/custom-api-registry.test.ts b/src/agents/custom-api-registry.test.ts new file mode 100644 index 00000000000..5cdc6f5f5fd --- /dev/null +++ b/src/agents/custom-api-registry.test.ts @@ -0,0 +1,44 @@ +import { + clearApiProviders, + createAssistantMessageEventStream, + getApiProvider, + registerBuiltInApiProviders, + unregisterApiProviders, +} from "@mariozechner/pi-ai"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ensureCustomApiRegistered, getCustomApiRegistrySourceId } from "./custom-api-registry.js"; + +describe("ensureCustomApiRegistered", () => { + afterEach(() => { + unregisterApiProviders(getCustomApiRegistrySourceId("test-custom-api")); + clearApiProviders(); + registerBuiltInApiProviders(); + }); + + it("registers a custom api provider once", () => { + const streamFn = vi.fn(() => createAssistantMessageEventStream()); + + expect(ensureCustomApiRegistered("test-custom-api", streamFn)).toBe(true); + expect(ensureCustomApiRegistered("test-custom-api", streamFn)).toBe(false); + + const provider = getApiProvider("test-custom-api"); + expect(provider).toBeDefined(); + }); + + it("delegates both stream entrypoints to the provided stream function", () => { + const stream = createAssistantMessageEventStream(); + const streamFn = vi.fn(() => stream); + ensureCustomApiRegistered("test-custom-api", streamFn); + + const provider = getApiProvider("test-custom-api"); + expect(provider).toBeDefined(); + + const model = { api: "test-custom-api", provider: "custom", id: "m" }; + const context = { messages: [] }; + const options = { maxTokens: 32 }; + + expect(provider?.stream(model as never, context as never, options as never)).toBe(stream); + expect(provider?.streamSimple(model as never, context as never, options as never)).toBe(stream); + expect(streamFn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/agents/custom-api-registry.ts b/src/agents/custom-api-registry.ts new file mode 100644 index 00000000000..72c056d6f5a --- /dev/null +++ b/src/agents/custom-api-registry.ts @@ -0,0 +1,35 @@ +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import { + getApiProvider, + registerApiProvider, + type Api, + type StreamOptions, +} from "@mariozechner/pi-ai"; + +const CUSTOM_API_SOURCE_PREFIX = "openclaw-custom-api:"; + +export function getCustomApiRegistrySourceId(api: Api): string { + return `${CUSTOM_API_SOURCE_PREFIX}${api}`; +} + +export function ensureCustomApiRegistered(api: Api, streamFn: StreamFn): boolean { + if (getApiProvider(api)) { + return false; + } + + registerApiProvider( + { + api, + stream: (model, context, options) => + streamFn(model, context, options) as unknown as ReturnType< + NonNullable>["stream"] + >, + streamSimple: (model, context, options) => + streamFn(model, context, options as StreamOptions) as unknown as ReturnType< + NonNullable>["stream"] + >, + }, + getCustomApiRegistrySourceId(api), + ); + return true; +} diff --git a/src/agents/ollama-stream.test.ts b/src/agents/ollama-stream.test.ts index 813381b35b1..f800bd4d6dc 100644 --- a/src/agents/ollama-stream.test.ts +++ b/src/agents/ollama-stream.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it, vi } from "vitest"; import { + createConfiguredOllamaStreamFn, createOllamaStreamFn, convertToOllamaMessages, buildAssistantMessage, parseNdjsonStream, + resolveOllamaBaseUrlForRun, } from "./ollama-stream.js"; describe("convertToOllamaMessages", () => { @@ -319,7 +321,12 @@ async function withMockNdjsonFetch( async function createOllamaTestStream(params: { baseUrl: string; defaultHeaders?: Record; - options?: { maxTokens?: number; signal?: AbortSignal; headers?: Record }; + options?: { + apiKey?: string; + maxTokens?: number; + signal?: AbortSignal; + headers?: Record; + }; }) { const streamFn = createOllamaStreamFn(params.baseUrl, params.defaultHeaders); return streamFn( @@ -413,6 +420,71 @@ describe("createOllamaStreamFn", () => { ); }); + it("preserves an explicit Authorization header when apiKey is a local marker", async () => { + await withMockNdjsonFetch( + [ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}', + '{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}', + ], + async (fetchMock) => { + const stream = await createOllamaTestStream({ + baseUrl: "http://ollama-host:11434", + defaultHeaders: { + Authorization: "Bearer proxy-token", + }, + options: { + apiKey: "ollama-local", + headers: { + Authorization: "Bearer proxy-token", + }, + }, + }); + + await collectStreamEvents(stream); + const [, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(requestInit.headers).toMatchObject({ + Authorization: "Bearer proxy-token", + }); + }, + ); + }); + + it("allows a real apiKey to override an explicit Authorization header", async () => { + await withMockNdjsonFetch( + [ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}', + '{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}', + ], + async (fetchMock) => { + const streamFn = createOllamaStreamFn("http://ollama-host:11434", { + Authorization: "Bearer proxy-token", + }); + const stream = await Promise.resolve( + streamFn( + { + id: "qwen3:32b", + api: "ollama", + provider: "custom-ollama", + contextWindow: 131072, + } as never, + { + messages: [{ role: "user", content: "hello" }], + } as never, + { + apiKey: "real-token", + } as never, + ), + ); + + await collectStreamEvents(stream); + const [, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(requestInit.headers).toMatchObject({ + Authorization: "Bearer real-token", + }); + }, + ); + }); + it("accumulates thinking chunks when content is empty", async () => { await withMockNdjsonFetch( [ @@ -499,3 +571,68 @@ describe("createOllamaStreamFn", () => { ); }); }); + +describe("resolveOllamaBaseUrlForRun", () => { + it("prefers provider baseUrl over model baseUrl", () => { + expect( + resolveOllamaBaseUrlForRun({ + modelBaseUrl: "http://model-host:11434", + providerBaseUrl: "http://provider-host:11434", + }), + ).toBe("http://provider-host:11434"); + }); + + it("falls back to model baseUrl when provider baseUrl is missing", () => { + expect( + resolveOllamaBaseUrlForRun({ + modelBaseUrl: "http://model-host:11434", + }), + ).toBe("http://model-host:11434"); + }); + + it("falls back to native default when neither baseUrl is configured", () => { + expect(resolveOllamaBaseUrlForRun({})).toBe("http://127.0.0.1:11434"); + }); +}); + +describe("createConfiguredOllamaStreamFn", () => { + it("uses provider-level baseUrl when model baseUrl is absent", async () => { + await withMockNdjsonFetch( + [ + '{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}', + '{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}', + ], + async (fetchMock) => { + const streamFn = createConfiguredOllamaStreamFn({ + model: { + headers: { Authorization: "Bearer proxy-token" }, + }, + providerBaseUrl: "http://provider-host:11434/v1", + }); + const stream = await Promise.resolve( + streamFn( + { + id: "qwen3:32b", + api: "ollama", + provider: "custom-ollama", + contextWindow: 131072, + } as never, + { + messages: [{ role: "user", content: "hello" }], + } as never, + { + apiKey: "ollama-local", + } as never, + ), + ); + + await collectStreamEvents(stream); + const [url, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe("http://provider-host:11434/api/chat"); + expect(requestInit.headers).toMatchObject({ + Authorization: "Bearer proxy-token", + }); + }, + ); + }); +}); diff --git a/src/agents/ollama-stream.ts b/src/agents/ollama-stream.ts index 4446b03acdf..9d23852bb31 100644 --- a/src/agents/ollama-stream.ts +++ b/src/agents/ollama-stream.ts @@ -9,6 +9,7 @@ import type { } from "@mariozechner/pi-ai"; import { createAssistantMessageEventStream } from "@mariozechner/pi-ai"; import { createSubsystemLogger } from "../logging/subsystem.js"; +import { isNonSecretApiKeyMarker } from "./model-auth-markers.js"; import { buildAssistantMessage as buildStreamAssistantMessage, buildStreamErrorAssistantMessage, @@ -19,6 +20,21 @@ const log = createSubsystemLogger("ollama-stream"); export const OLLAMA_NATIVE_BASE_URL = "http://127.0.0.1:11434"; +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; +} + // ── Ollama /api/chat request types ────────────────────────────────────────── interface OllamaChatRequest { @@ -406,6 +422,15 @@ function resolveOllamaChatUrl(baseUrl: string): string { return `${apiBase}/api/chat`; } +function resolveOllamaModelHeaders(model: { + headers?: unknown; +}): Record | undefined { + if (!model.headers || typeof model.headers !== "object" || Array.isArray(model.headers)) { + return undefined; + } + return model.headers as Record; +} + export function createOllamaStreamFn( baseUrl: string, defaultHeaders?: Record, @@ -447,7 +472,10 @@ export function createOllamaStreamFn( ...defaultHeaders, ...options?.headers, }; - if (options?.apiKey) { + if ( + options?.apiKey && + (!headers.Authorization || !isNonSecretApiKeyMarker(options.apiKey)) + ) { headers.Authorization = `Bearer ${options.apiKey}`; } @@ -539,3 +567,17 @@ export function createOllamaStreamFn( return stream; }; } + +export function createConfiguredOllamaStreamFn(params: { + model: { baseUrl?: string; headers?: unknown }; + providerBaseUrl?: string; +}): StreamFn { + const modelBaseUrl = typeof params.model.baseUrl === "string" ? params.model.baseUrl : undefined; + return createOllamaStreamFn( + resolveOllamaBaseUrlForRun({ + modelBaseUrl, + providerBaseUrl: params.providerBaseUrl, + }), + resolveOllamaModelHeaders(params.model), + ); +} diff --git a/src/agents/pi-embedded-runner/compact.hooks.test.ts b/src/agents/pi-embedded-runner/compact.hooks.test.ts index 9745071654d..c6eb54b0501 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.test.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.test.ts @@ -1,11 +1,29 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -const { hookRunner, triggerInternalHook, sanitizeSessionHistoryMock } = vi.hoisted(() => ({ +const { + hookRunner, + resolveModelMock, + sessionCompactImpl, + triggerInternalHook, + sanitizeSessionHistoryMock, +} = vi.hoisted(() => ({ hookRunner: { hasHooks: vi.fn(), runBeforeCompaction: vi.fn(), runAfterCompaction: vi.fn(), }, + resolveModelMock: vi.fn(() => ({ + model: { provider: "openai", api: "responses", id: "fake", input: [] }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, + })), + sessionCompactImpl: vi.fn(async () => ({ + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 120, + details: { ok: true }, + })), triggerInternalHook: vi.fn(), sanitizeSessionHistoryMock: vi.fn(async (params: { messages: unknown[] }) => params.messages), })); @@ -50,12 +68,7 @@ vi.mock("@mariozechner/pi-coding-agent", () => { compact: vi.fn(async () => { // simulate compaction trimming to a single message session.messages.splice(1); - return { - summary: "summary", - firstKeptEntryId: "entry-1", - tokensBefore: 120, - details: { ok: true }, - }; + return await sessionCompactImpl(); }), dispose: vi.fn(), }; @@ -210,12 +223,7 @@ vi.mock("./sandbox-info.js", () => ({ vi.mock("./model.js", () => ({ buildModelAliasLines: vi.fn(() => []), - resolveModel: vi.fn(() => ({ - model: { provider: "openai", api: "responses", id: "fake", input: [] }, - error: null, - authStorage: { setRuntimeApiKey: vi.fn() }, - modelRegistry: {}, - })), + resolveModel: resolveModelMock, })); vi.mock("./session-manager-cache.js", () => ({ @@ -235,6 +243,8 @@ vi.mock("./utils.js", () => ({ resolveExecToolDefaults: vi.fn(() => undefined), })); +import { getApiProvider, unregisterApiProviders } from "@mariozechner/pi-ai"; +import { getCustomApiRegistrySourceId } from "../custom-api-registry.js"; import { compactEmbeddedPiSessionDirect } from "./compact.js"; const sessionHook = (action: string) => @@ -248,10 +258,25 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { hookRunner.hasHooks.mockReset(); hookRunner.runBeforeCompaction.mockReset(); hookRunner.runAfterCompaction.mockReset(); + resolveModelMock.mockReset(); + resolveModelMock.mockReturnValue({ + model: { provider: "openai", api: "responses", id: "fake", input: [] }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, + }); + sessionCompactImpl.mockReset(); + sessionCompactImpl.mockResolvedValue({ + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 120, + details: { ok: true }, + }); sanitizeSessionHistoryMock.mockReset(); sanitizeSessionHistoryMock.mockImplementation(async (params: { messages: unknown[] }) => { return params.messages; }); + unregisterApiProviders(getCustomApiRegistrySourceId("ollama")); }); it("emits internal + plugin compaction hooks with counts", async () => { @@ -355,4 +380,39 @@ describe("compactEmbeddedPiSessionDirect hooks", () => { tokenCount: 0, }); }); + + it("registers the Ollama api provider before compaction", async () => { + resolveModelMock.mockReturnValue({ + model: { + provider: "ollama", + api: "ollama", + id: "qwen3:8b", + input: ["text"], + baseUrl: "http://127.0.0.1:11434", + headers: { Authorization: "Bearer ollama-cloud" }, + }, + error: null, + authStorage: { setRuntimeApiKey: vi.fn() }, + modelRegistry: {}, + } as never); + sessionCompactImpl.mockImplementation(async () => { + expect(getApiProvider("ollama" as Parameters[0])).toBeDefined(); + return { + summary: "summary", + firstKeptEntryId: "entry-1", + tokensBefore: 120, + details: { ok: true }, + }; + }); + + const result = await compactEmbeddedPiSessionDirect({ + sessionId: "session-1", + sessionKey: "agent:main:session-1", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + customInstructions: "focus on decisions", + }); + + expect(result.ok).toBe(true); + }); }); diff --git a/src/agents/pi-embedded-runner/compact.ts b/src/agents/pi-embedded-runner/compact.ts index a3d02596886..05fa3490658 100644 --- a/src/agents/pi-embedded-runner/compact.ts +++ b/src/agents/pi-embedded-runner/compact.ts @@ -34,12 +34,14 @@ import type { ExecElevatedDefaults } from "../bash-tools.js"; import { makeBootstrapWarn, resolveBootstrapContextForRun } from "../bootstrap-files.js"; import { listChannelSupportedActions, resolveChannelMessageToolHints } from "../channel-tools.js"; import { resolveContextWindowInfo } from "../context-window-guard.js"; +import { ensureCustomApiRegistered } from "../custom-api-registry.js"; import { formatUserTime, resolveUserTimeFormat, resolveUserTimezone } from "../date-time.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL, DEFAULT_PROVIDER } from "../defaults.js"; import { resolveOpenClawDocsPath } from "../docs-path.js"; import { getApiKeyForModel, resolveModelAuthMode } from "../model-auth.js"; import { supportsModelTools } from "../model-tool-support.js"; import { ensureOpenClawModelsJson } from "../models-config.js"; +import { createConfiguredOllamaStreamFn } from "../ollama-stream.js"; import { resolveOwnerDisplaySetting } from "../owner-display.js"; import { ensureSessionHeader, @@ -617,6 +619,19 @@ export async function compactEmbeddedPiSessionDirect( resourceLoader, }); applySystemPromptOverrideToSession(session, systemPromptOverride()); + if (model.api === "ollama") { + const providerBaseUrl = + typeof params.config?.models?.providers?.[model.provider]?.baseUrl === "string" + ? params.config.models.providers[model.provider]?.baseUrl + : undefined; + ensureCustomApiRegistered( + model.api, + createConfiguredOllamaStreamFn({ + model, + providerBaseUrl, + }), + ); + } try { const prior = await sanitizeSessionHistory({ diff --git a/src/agents/pi-embedded-runner/run/attempt.test.ts b/src/agents/pi-embedded-runner/run/attempt.test.ts index 41ef8614e9d..197a2903183 100644 --- a/src/agents/pi-embedded-runner/run/attempt.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt.test.ts @@ -1,12 +1,12 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../../config/config.js"; +import { resolveOllamaBaseUrlForRun } from "../../ollama-stream.js"; import { buildAfterTurnLegacyCompactionParams, composeSystemPromptWithHookContext, isOllamaCompatProvider, prependSystemPromptAddition, resolveAttemptFsWorkspaceOnly, - resolveOllamaBaseUrlForRun, resolveOllamaCompatNumCtxEnabled, resolvePromptBuildHookResult, resolvePromptModeForSession, diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index d7cfefea7bb..467b8e1501f 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -43,6 +43,7 @@ import { listChannelSupportedActions, resolveChannelMessageToolHints, } from "../../channel-tools.js"; +import { ensureCustomApiRegistered } from "../../custom-api-registry.js"; import { DEFAULT_CONTEXT_TOKENS } from "../../defaults.js"; import { resolveOpenClawDocsPath } from "../../docs-path.js"; import { isTimeoutError } from "../../failover-error.js"; @@ -50,7 +51,7 @@ import { resolveImageSanitizationLimits } from "../../image-sanitization.js"; import { resolveModelAuthMode } from "../../model-auth.js"; import { normalizeProviderId, resolveDefaultModelForAgent } from "../../model-selection.js"; import { supportsModelTools } from "../../model-tool-support.js"; -import { createOllamaStreamFn, OLLAMA_NATIVE_BASE_URL } from "../../ollama-stream.js"; +import { createConfiguredOllamaStreamFn } from "../../ollama-stream.js"; import { createOpenAIWebSocketStreamFn, releaseWsSession } from "../../openai-ws-stream.js"; import { resolveOwnerDisplaySetting } from "../../owner-display.js"; import { @@ -350,21 +351,6 @@ function normalizeToolCallIdsInMessage(message: unknown): void { } } -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; -} - function trimWhitespaceFromToolCallNamesInMessage( message: unknown, allowedToolNames?: Set, @@ -1244,15 +1230,14 @@ export async function runEmbeddedAttempt( if (params.model.api === "ollama") { // Prioritize configured provider baseUrl so Docker/remote Ollama hosts work reliably. const providerConfig = params.config?.models?.providers?.[params.model.provider]; - const modelBaseUrl = - typeof params.model.baseUrl === "string" ? params.model.baseUrl : undefined; const providerBaseUrl = typeof providerConfig?.baseUrl === "string" ? providerConfig.baseUrl : undefined; - const ollamaBaseUrl = resolveOllamaBaseUrlForRun({ - modelBaseUrl, + const ollamaStreamFn = createConfiguredOllamaStreamFn({ + model: params.model, providerBaseUrl, }); - activeSession.agent.streamFn = createOllamaStreamFn(ollamaBaseUrl, params.model.headers); + activeSession.agent.streamFn = ollamaStreamFn; + ensureCustomApiRegistered(params.model.api, ollamaStreamFn); } else if (params.model.api === "openai-responses" && params.provider === "openai") { const wsApiKey = await params.authStorage.getApiKey(params.provider); if (wsApiKey) { diff --git a/src/tts/tts-core.ts b/src/tts/tts-core.ts index a39eff698d6..08f80c3d60c 100644 --- a/src/tts/tts-core.ts +++ b/src/tts/tts-core.ts @@ -1,6 +1,7 @@ import { rmSync } from "node:fs"; import { completeSimple, type TextContent } from "@mariozechner/pi-ai"; import { EdgeTTS } from "node-edge-tts"; +import { ensureCustomApiRegistered } from "../agents/custom-api-registry.js"; import { getApiKeyForModel, requireApiKey } from "../agents/model-auth.js"; import { buildModelAliasIndex, @@ -8,6 +9,7 @@ import { resolveModelRefFromString, type ModelRef, } from "../agents/model-selection.js"; +import { createConfiguredOllamaStreamFn } from "../agents/ollama-stream.js"; import { resolveModel } from "../agents/pi-embedded-runner/model.js"; import type { OpenClawConfig } from "../config/config.js"; import type { @@ -455,6 +457,19 @@ export async function summarizeText(params: { const timeout = setTimeout(() => controller.abort(), timeoutMs); try { + if (resolved.model.api === "ollama") { + const providerBaseUrl = + typeof cfg.models?.providers?.[resolved.model.provider]?.baseUrl === "string" + ? cfg.models.providers[resolved.model.provider]?.baseUrl + : undefined; + ensureCustomApiRegistered( + resolved.model.api, + createConfiguredOllamaStreamFn({ + model: resolved.model, + providerBaseUrl, + }), + ); + } const res = await completeSimple( resolved.model, { diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index 0b4d7c56d49..733d34f5757 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -1,5 +1,6 @@ import { completeSimple, type AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi, beforeEach } from "vitest"; +import { ensureCustomApiRegistered } from "../agents/custom-api-registry.js"; import { getApiKeyForModel } from "../agents/model-auth.js"; import { resolveModel } from "../agents/pi-embedded-runner/model.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -40,6 +41,10 @@ vi.mock("../agents/model-auth.js", () => ({ requireApiKey: vi.fn((auth: { apiKey?: string }) => auth.apiKey ?? ""), })); +vi.mock("../agents/custom-api-registry.js", () => ({ + ensureCustomApiRegistered: vi.fn(), +})); + const { _test, resolveTtsConfig, maybeApplyTtsToPayload, getTtsProvider } = tts; const { @@ -372,6 +377,35 @@ describe("tts", () => { expect(resolveModel).toHaveBeenCalledWith("openai", "gpt-4.1-mini", undefined, cfg); }); + it("registers the Ollama api before direct summarization", async () => { + vi.mocked(resolveModel).mockReturnValue({ + model: { + provider: "ollama", + id: "qwen3:8b", + name: "qwen3:8b", + api: "ollama", + baseUrl: "http://127.0.0.1:11434", + reasoning: false, + input: ["text"], + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + contextWindow: 128000, + maxTokens: 8192, + }, + authStorage: { profiles: {} } as never, + modelRegistry: { find: vi.fn() } as never, + } as never); + + await summarizeText({ + text: "Long text to summarize", + targetLength: 500, + cfg: baseCfg, + config: baseConfig, + timeoutMs: 30_000, + }); + + expect(ensureCustomApiRegistered).toHaveBeenCalledWith("ollama", expect.any(Function)); + }); + it("validates targetLength bounds", async () => { const cases = [ { targetLength: 99, shouldThrow: true },