mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-30 04:16:25 +00:00
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:
@@ -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)
|
||||
|
||||
44
src/agents/custom-api-registry.test.ts
Normal file
44
src/agents/custom-api-registry.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
35
src/agents/custom-api-registry.ts
Normal file
35
src/agents/custom-api-registry.ts
Normal 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;
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user