fix(ollama): register custom api for compaction and summarization (#39332)

* fix(agents): add custom api registry helper

* fix(ollama): register native api for embedded runs

* fix(ollama): register custom api before compaction

* fix(tts): register custom api before summarization

* changelog: note ollama compaction registration fix

* fix(ollama): honor resolved base urls in custom api paths
This commit is contained in:
Vincent Koc
2026-03-07 20:40:34 -05:00
committed by GitHub
parent 01833c5111
commit 7e946b3c6c
11 changed files with 405 additions and 37 deletions

View File

@@ -113,6 +113,7 @@ Docs: https://docs.openclaw.ai
- Models/custom provider headers: propagate `models.providers.<name>.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)

View File

@@ -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);
});
});

View File

@@ -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<ReturnType<typeof getApiProvider>>["stream"]
>,
streamSimple: (model, context, options) =>
streamFn(model, context, options as StreamOptions) as unknown as ReturnType<
NonNullable<ReturnType<typeof getApiProvider>>["stream"]
>,
},
getCustomApiRegistrySourceId(api),
);
return true;
}

View File

@@ -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<string, string>;
options?: { maxTokens?: number; signal?: AbortSignal; headers?: Record<string, string> };
options?: {
apiKey?: string;
maxTokens?: number;
signal?: AbortSignal;
headers?: Record<string, string>;
};
}) {
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",
});
},
);
});
});

View File

@@ -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<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>,
@@ -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),
);
}

View File

@@ -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<typeof getApiProvider>[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);
});
});

View File

@@ -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({

View File

@@ -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,

View File

@@ -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<string>,
@@ -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) {

View File

@@ -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,
{

View File

@@ -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 },