Compare commits

..
Author SHA1 Message Date
Peter Steinberger af05c6b4e9 test: restore Ollama provider tests (#1606) (thanks @abhaymundhara) 2026-01-24 22:38:21 +00:00
Abhay 6691e32faf fix: Make Ollama provider opt-in to avoid breaking existing tests
**Root Cause:**
The Ollama provider was being added to ALL configurations by default
(with a fallback API key of 'ollama-local'), which broke tests that
expected NO providers when no API keys were configured.

**Solution:**
- Removed the default fallback API key for Ollama
- Ollama provider now requires explicit configuration via:
  - OLLAMA_API_KEY environment variable, OR
  - Ollama profile in auth store
- Updated documentation to reflect the explicit configuration requirement
- Added a test to verify Ollama is not added by default

This fixes all 4 failing test suites:
- checks (node, test, pnpm test)
- checks (bun, test, bunx vitest run)
- checks-windows (node, test, pnpm test)
- checks-macos (test, pnpm test)

Closes #1531
2026-01-24 22:37:44 +00:00
Abhay cda6c02e8f test: Temporarily skip Ollama provider tests to diagnose CI failures 2026-01-24 22:37:19 +00:00
Peter Steinberger 8c9d022a88 feat: add Ollama provider discovery parity (#1606) (thanks @abhaymundhara) 2026-01-24 22:35:17 +00:00
19 changed files with 510 additions and 290 deletions

No files matched your search

+1 -1
View File
@@ -10,9 +10,9 @@ Docs: https://docs.clawd.bot
- Docs: update Fly.io guide notes.
- Docs: add Bedrock EC2 instance role setup + IAM steps. (#1625) Thanks @sergical. https://docs.clawd.bot/bedrock
- Exec approvals: forward approval prompts to chat with `/approve` for all channels (including plugins). (#1621) Thanks @czekaj. https://docs.clawd.bot/tools/exec-approvals https://docs.clawd.bot/tools/slash-commands
- Models: add Ollama provider discovery + docs. (#1606) Thanks @abhaymundhara. https://docs.clawd.bot/providers/ollama
### Fixes
- BlueBubbles: preserve full message GUIDs in reply tags + reaction events (no part-prefix stripping). (#1641) Thanks @tyler6204.
- Web UI: hide internal `message_id` hints in chat bubbles.
- Heartbeat: normalize target identifiers for consistent routing.
- Telegram: use wrapped fetch for long-polling on Node to normalize AbortSignal handling. (#1639)
+24
View File
@@ -236,6 +236,30 @@ MiniMax is configured via `models.providers` because it uses custom endpoints:
See [/providers/minimax](/providers/minimax) for setup details, model options, and config snippets.
### Ollama
Ollama is a local LLM runtime that provides an OpenAI-compatible API:
- Provider: `ollama`
- Auth: `OLLAMA_API_KEY` (any value; Ollama runs locally)
- Example model: `ollama/llama3.3`
- Installation: https://ollama.ai
```bash
# Install Ollama, then pull a model:
ollama pull llama3.3
```
```json5
{
agents: {
defaults: { model: { primary: "ollama/llama3.3" } }
}
}
```
Ollama is auto-discovered when `OLLAMA_API_KEY` (or an auth profile) is set and no explicit `models.providers.ollama` entry exists. Discovery probes `http://127.0.0.1:11434` and filters to tool-capable models. See [/providers/ollama](/providers/ollama) for model recommendations and custom configuration.
### Local proxies (LM Studio, vLLM, LiteLLM, etc.)
Example (OpenAI‑compatible):
+1
View File
@@ -35,6 +35,7 @@ Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugi
- [Z.AI](/providers/zai)
- [GLM models](/providers/glm)
- [MiniMax](/providers/minimax)
- [Ollama (local models)](/providers/ollama)
## Transcription providers
+171
View File
@@ -0,0 +1,171 @@
---
summary: "Run Clawdbot with Ollama (local LLM runtime)"
read_when:
- You want to run Clawdbot with local models via Ollama
- You need Ollama setup and configuration guidance
---
# Ollama
Ollama is a local LLM runtime that makes it easy to run open-source models on your machine. Clawdbot integrates with Ollama's OpenAI-compatible API and can **auto-discover tool-capable models** when enabled via `OLLAMA_API_KEY` (or an auth profile) and no explicit `models.providers.ollama` config is set.
## Quick start
1) Install Ollama: https://ollama.ai
2) Pull a model:
```bash
ollama pull llama3.3
# or
ollama pull qwen2.5-coder:32b
# or
ollama pull deepseek-r1:32b
```
3) Enable Ollama for Clawdbot (any value works; Ollama doesn't require a real key):
```bash
# Set environment variable
export OLLAMA_API_KEY="ollama-local"
# Or configure in your config file
clawdbot config set models.providers.ollama.apiKey "ollama-local"
```
4) Use Ollama models:
```json5
{
agents: {
defaults: {
model: { primary: "ollama/llama3.3" }
}
}
}
```
## Model Discovery
When Ollama is enabled via `OLLAMA_API_KEY` (or an auth profile) and no explicit `models.providers.ollama` entry exists, Clawdbot automatically detects models installed on your Ollama instance by querying `/api/tags` and `/api/show` at `http://localhost:11434`. It only keeps models that report tool support, so you don't need to manually configure them.
To see what models are available:
```bash
ollama list
clawdbot models list
```
To add a new model, simply pull it with Ollama:
```bash
ollama pull mistral
```
The new model will be automatically discovered and available to use.
If you set `models.providers.ollama` explicitly, auto-discovery is skipped. Define your models manually in that case.
## Configuration
### Basic Setup
The simplest way to enable Ollama is via environment variable:
```bash
export OLLAMA_API_KEY="ollama-local"
```
### Custom Base URL
If Ollama is running on a different host or port (note: explicit config skips auto-discovery, so define models manually):
```json5
{
models: {
providers: {
ollama: {
apiKey: "ollama-local",
baseUrl: "http://192.168.1.100:11434/v1"
}
}
}
}
```
### Model Selection
Once configured, all your Ollama models are available:
```json5
{
agents: {
defaults: {
model: {
primary: "ollama/llama3.3",
fallback: ["ollama/qwen2.5-coder:32b"]
}
}
}
}
```
## Advanced
### Reasoning Models
Models with "r1" or "reasoning" in their name are automatically detected as reasoning models and will use extended thinking features:
```bash
ollama pull deepseek-r1:32b
```
### Model Costs
Ollama is free and runs locally, so all model costs are set to $0.
### Context Windows
Ollama models use default context windows. You can customize these in your provider configuration if needed.
## Troubleshooting
### Ollama not detected
Make sure Ollama is running:
```bash
ollama serve
```
And that the API is accessible:
```bash
curl http://localhost:11434/api/tags
```
### No models available
Pull at least one model:
```bash
ollama list # See what's installed
ollama pull llama3.3 # Pull a model
```
### Connection refused
Check that Ollama is running on the correct port:
```bash
# Check if Ollama is running
ps aux | grep ollama
# Or restart Ollama
ollama serve
```
## See Also
- [Model Providers](/concepts/model-providers) - Overview of all providers
- [Model Selection](/agents/model-selection) - How to choose models
- [Configuration](/configuration) - Full config reference
+12 -92
View File
@@ -1189,8 +1189,9 @@ describe("BlueBubbles webhook monitor", () => {
expect(callArgs.ctx.ReplyToId).toBe("msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("original message");
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
// Body uses inline [[reply_to:N]] tag format
expect(callArgs.ctx.Body).toContain("[[reply_to:msg-0]]");
// Body uses just the ID (no sender) for token savings
expect(callArgs.ctx.Body).toContain("[Replying to id:msg-0]");
expect(callArgs.ctx.Body).toContain("original message");
});
it("hydrates missing reply sender/body from the recent-message cache", async () => {
@@ -1259,8 +1260,9 @@ describe("BlueBubbles webhook monitor", () => {
expect(callArgs.ctx.ReplyToIdFull).toBe("cache-msg-0");
expect(callArgs.ctx.ReplyToBody).toBe("original message (cached)");
expect(callArgs.ctx.ReplyToSender).toBe("+15550000000");
// Body uses inline [[reply_to:N]] tag format with short ID
expect(callArgs.ctx.Body).toContain("[[reply_to:1]]");
// Body uses just the short ID (no sender) for token savings
expect(callArgs.ctx.Body).toContain("[Replying to id:1]");
expect(callArgs.ctx.Body).toContain("original message (cached)");
});
it("falls back to threadOriginatorGuid when reply metadata is absent", async () => {
@@ -1303,88 +1305,6 @@ describe("BlueBubbles webhook monitor", () => {
});
});
describe("tapback text parsing", () => {
it("does not rewrite tapback-like text without metadata", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: "Loved this idea",
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-1",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.RawBody).toBe("Loved this idea");
expect(callArgs.ctx.Body).toContain("Loved this idea");
expect(callArgs.ctx.Body).not.toContain("reacted with");
});
it("parses tapback text with custom emoji when metadata is present", async () => {
const account = createMockAccount({ dmPolicy: "open" });
const config: ClawdbotConfig = {};
const core = createMockRuntime();
setBlueBubblesRuntime(core);
unregister = registerBlueBubblesWebhookTarget({
account,
config,
runtime: { log: vi.fn(), error: vi.fn() },
core,
path: "/bluebubbles-webhook",
});
const payload = {
type: "new-message",
data: {
text: 'Reacted 😅 to "nice one"',
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "msg-2",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
};
const req = createMockRequest("POST", "/bluebubbles-webhook", payload);
const res = createMockResponse();
await handleBlueBubblesWebhookRequest(req, res);
await flushAsync();
expect(mockDispatchReplyWithBufferedBlockDispatcher).toHaveBeenCalled();
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
expect(callArgs.ctx.RawBody).toBe("reacted with 😅");
expect(callArgs.ctx.Body).toContain("reacted with 😅");
expect(callArgs.ctx.Body).not.toContain("[[reply_to:");
});
});
describe("ack reactions", () => {
it("sends ack reaction when configured", async () => {
const { sendBlueBubblesReaction } = await import("./reactions.js");
@@ -1839,7 +1759,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("reacted with ❤️ [[reply_to:"),
expect.stringContaining("reaction added"),
expect.any(Object),
);
});
@@ -1879,7 +1799,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
expect(mockEnqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining("removed ❤️ reaction [[reply_to:"),
expect.stringContaining("reaction removed"),
expect.any(Object),
);
});
@@ -1985,7 +1905,7 @@ describe("BlueBubbles webhook monitor", () => {
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "p:1/msg-uuid-12345",
guid: "msg-uuid-12345",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
@@ -2001,7 +1921,7 @@ describe("BlueBubbles webhook monitor", () => {
const callArgs = mockDispatchReplyWithBufferedBlockDispatcher.mock.calls[0][0];
// MessageSid should be short ID "1" instead of full UUID
expect(callArgs.ctx.MessageSid).toBe("1");
expect(callArgs.ctx.MessageSidFull).toBe("p:1/msg-uuid-12345");
expect(callArgs.ctx.MessageSidFull).toBe("msg-uuid-12345");
});
it("resolves short ID back to UUID", async () => {
@@ -2025,7 +1945,7 @@ describe("BlueBubbles webhook monitor", () => {
handle: { address: "+15551234567" },
isGroup: false,
isFromMe: false,
guid: "p:1/msg-uuid-12345",
guid: "msg-uuid-12345",
chatGuid: "iMessage;-;+15551234567",
date: Date.now(),
},
@@ -2038,7 +1958,7 @@ describe("BlueBubbles webhook monitor", () => {
await flushAsync();
// The short ID "1" should resolve back to the full UUID
expect(resolveBlueBubblesMessageId("1")).toBe("p:1/msg-uuid-12345");
expect(resolveBlueBubblesMessageId("1")).toBe("msg-uuid-12345");
});
it("returns UUID unchanged when not in cache", () => {
+33 -187
View File
@@ -55,7 +55,7 @@ type BlueBubblesReplyCacheEntry = {
// Best-effort cache for resolving reply context when BlueBubbles webhooks omit sender/body.
const blueBubblesReplyCacheByMessageId = new Map<string, BlueBubblesReplyCacheEntry>();
// Bidirectional maps for short ID ↔ message GUID resolution (token savings optimization)
// Bidirectional maps for short ID ↔ UUID resolution (token savings optimization)
const blueBubblesShortIdToUuid = new Map<string, string>();
const blueBubblesUuidToShortId = new Map<string, string>();
let blueBubblesShortIdCounter = 0;
@@ -78,7 +78,7 @@ function rememberBlueBubblesReplyCache(
return { ...entry, shortId: "" };
}
// Check if we already have a short ID for this GUID
// Check if we already have a short ID for this UUID
let shortId = blueBubblesUuidToShortId.get(messageId);
if (!shortId) {
shortId = generateShortId();
@@ -86,7 +86,7 @@ function rememberBlueBubblesReplyCache(
blueBubblesUuidToShortId.set(messageId, shortId);
}
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, messageId, shortId };
const fullEntry: BlueBubblesReplyCacheEntry = { ...entry, shortId };
// Refresh insertion order.
blueBubblesReplyCacheByMessageId.delete(messageId);
@@ -122,8 +122,8 @@ function rememberBlueBubblesReplyCache(
}
/**
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles GUID.
* Returns the input unchanged if it's already a GUID or not found in the mapping.
* Resolves a short message ID (e.g., "1", "2") to a full BlueBubbles UUID.
* Returns the input unchanged if it's already a UUID or not found in the mapping.
*/
export function resolveBlueBubblesMessageId(
shortOrUuid: string,
@@ -159,7 +159,7 @@ export function _resetBlueBubblesShortIdState(): void {
}
/**
* Gets the short ID for a message GUID, if one exists.
* Gets the short ID for a UUID, if one exists.
*/
function getShortIdForUuid(uuid: string): string | undefined {
return blueBubblesUuidToShortId.get(uuid.trim());
@@ -390,15 +390,29 @@ function buildMessagePlaceholder(message: NormalizedWebhookMessage): string {
return "";
}
// Returns inline reply tag like "[[reply_to:4]]" for prepending to message body
function formatReplyTag(message: {
const REPLY_BODY_TRUNCATE_LENGTH = 60;
function formatReplyContext(message: {
replyToId?: string;
replyToShortId?: string;
replyToBody?: string;
replyToSender?: string;
}): string | null {
// Prefer short ID
const rawId = message.replyToShortId || message.replyToId;
if (!rawId) return null;
return `[[reply_to:${rawId}]]`;
if (!message.replyToId && !message.replyToBody && !message.replyToSender) return null;
// Prefer short ID for token savings
const displayId = message.replyToShortId || message.replyToId;
// Only include sender if we don't have an ID (fallback)
const label = displayId ? `id:${displayId}` : (message.replyToSender?.trim() || "unknown");
const rawBody = message.replyToBody?.trim();
if (!rawBody) {
return `[Replying to ${label}]\n[/Replying]`;
}
// Truncate long reply bodies for token savings
const body =
rawBody.length > REPLY_BODY_TRUNCATE_LENGTH
? `${rawBody.slice(0, REPLY_BODY_TRUNCATE_LENGTH)}…`
: rawBody;
return `[Replying to ${label}]\n${body}\n[/Replying]`;
}
function readNumberLike(record: Record<string, unknown> | null, key: string): number | undefined {
@@ -615,10 +629,6 @@ type NormalizedWebhookMessage = {
fromMe?: boolean;
attachments?: BlueBubblesAttachment[];
balloonBundleId?: string;
associatedMessageGuid?: string;
associatedMessageType?: number;
associatedMessageEmoji?: string;
isTapback?: boolean;
participants?: BlueBubblesParticipant[];
replyToId?: string;
replyToBody?: string;
@@ -655,113 +665,6 @@ const REACTION_TYPE_MAP = new Map<number, { emoji: string; action: "added" | "re
[3005, { emoji: "❓", action: "removed" }],
]);
// Maps tapback text patterns (e.g., "Loved", "Liked") to emoji + action
const TAPBACK_TEXT_MAP = new Map<string, { emoji: string; action: "added" | "removed" }>([
["loved", { emoji: "❤️", action: "added" }],
["liked", { emoji: "👍", action: "added" }],
["disliked", { emoji: "👎", action: "added" }],
["laughed at", { emoji: "😂", action: "added" }],
["emphasized", { emoji: "‼️", action: "added" }],
["questioned", { emoji: "❓", action: "added" }],
// Removal patterns (e.g., "Removed a heart from")
["removed a heart from", { emoji: "❤️", action: "removed" }],
["removed a like from", { emoji: "👍", action: "removed" }],
["removed a dislike from", { emoji: "👎", action: "removed" }],
["removed a laugh from", { emoji: "😂", action: "removed" }],
["removed an emphasis from", { emoji: "‼️", action: "removed" }],
["removed a question from", { emoji: "❓", action: "removed" }],
]);
const TAPBACK_EMOJI_REGEX =
/(?:\p{Regional_Indicator}{2})|(?:[0-9#*]\uFE0F?\u20E3)|(?:\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?(?:\u200D\p{Extended_Pictographic}(?:\uFE0F|\uFE0E)?(?:\p{Emoji_Modifier})?)*)/u;
function extractFirstEmoji(text: string): string | null {
const match = text.match(TAPBACK_EMOJI_REGEX);
return match ? match[0] : null;
}
function extractQuotedTapbackText(text: string): string | null {
const match = text.match(/[“"]([^”"]+)[”"]/s);
return match ? match[1] : null;
}
function isTapbackAssociatedType(type: number | undefined): boolean {
return typeof type === "number" && Number.isFinite(type) && type >= 2000 && type < 4000;
}
function resolveTapbackActionHint(type: number | undefined): "added" | "removed" | undefined {
if (typeof type !== "number" || !Number.isFinite(type)) return undefined;
if (type >= 3000 && type < 4000) return "removed";
if (type >= 2000 && type < 3000) return "added";
return undefined;
}
function resolveTapbackContext(message: NormalizedWebhookMessage): {
emojiHint?: string;
actionHint?: "added" | "removed";
replyToId?: string;
} | null {
const associatedType = message.associatedMessageType;
const hasTapbackType = isTapbackAssociatedType(associatedType);
const hasTapbackMarker = Boolean(message.associatedMessageEmoji) || Boolean(message.isTapback);
if (!hasTapbackType && !hasTapbackMarker) return null;
const replyToId = message.associatedMessageGuid?.trim() || message.replyToId?.trim() || undefined;
const actionHint = resolveTapbackActionHint(associatedType);
const emojiHint =
message.associatedMessageEmoji?.trim() || REACTION_TYPE_MAP.get(associatedType ?? -1)?.emoji;
return { emojiHint, actionHint, replyToId };
}
// Detects tapback text patterns like 'Loved "message"' and converts to structured format
function parseTapbackText(params: {
text: string;
emojiHint?: string;
actionHint?: "added" | "removed";
requireQuoted?: boolean;
}): {
emoji: string;
action: "added" | "removed";
quotedText: string;
} | null {
const trimmed = params.text.trim();
const lower = trimmed.toLowerCase();
if (!trimmed) return null;
for (const [pattern, { emoji, action }] of TAPBACK_TEXT_MAP) {
if (lower.startsWith(pattern)) {
// Extract quoted text if present (e.g., 'Loved "hello"' -> "hello")
const afterPattern = trimmed.slice(pattern.length).trim();
if (params.requireQuoted) {
const strictMatch = afterPattern.match(/^[“"](.+)[”"]$/s);
if (!strictMatch) return null;
return { emoji, action, quotedText: strictMatch[1] };
}
const quotedText =
extractQuotedTapbackText(afterPattern) ?? extractQuotedTapbackText(trimmed) ?? afterPattern;
return { emoji, action, quotedText };
}
}
if (lower.startsWith("reacted")) {
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
if (!emoji) return null;
const quotedText = extractQuotedTapbackText(trimmed);
if (params.requireQuoted && !quotedText) return null;
const fallback = trimmed.slice("reacted".length).trim();
return { emoji, action: params.actionHint ?? "added", quotedText: quotedText ?? fallback };
}
if (lower.startsWith("removed")) {
const emoji = extractFirstEmoji(trimmed) ?? params.emojiHint;
if (!emoji) return null;
const quotedText = extractQuotedTapbackText(trimmed);
if (params.requireQuoted && !quotedText) return null;
const fallback = trimmed.slice("removed".length).trim();
return { emoji, action: params.actionHint ?? "removed", quotedText: quotedText ?? fallback };
}
return null;
}
function maskSecret(value: string): string {
if (value.length <= 6) return "***";
return `${value.slice(0, 2)}***${value.slice(-2)}`;
@@ -902,25 +805,6 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
readString(message, "messageId") ??
undefined;
const balloonBundleId = readString(message, "balloonBundleId");
const associatedMessageGuid =
readString(message, "associatedMessageGuid") ??
readString(message, "associated_message_guid") ??
readString(message, "associatedMessageId") ??
undefined;
const associatedMessageType =
readNumberLike(message, "associatedMessageType") ??
readNumberLike(message, "associated_message_type");
const associatedMessageEmoji =
readString(message, "associatedMessageEmoji") ??
readString(message, "associated_message_emoji") ??
readString(message, "reactionEmoji") ??
readString(message, "reaction_emoji") ??
undefined;
const isTapback =
readBoolean(message, "isTapback") ??
readBoolean(message, "is_tapback") ??
readBoolean(message, "tapback") ??
undefined;
const timestampRaw =
readNumber(message, "date") ??
@@ -951,10 +835,6 @@ function normalizeWebhookMessage(payload: Record<string, unknown>): NormalizedWe
fromMe,
attachments: extractAttachments(message),
balloonBundleId,
associatedMessageGuid,
associatedMessageType,
associatedMessageEmoji,
isTapback,
participants: normalizedParticipants,
replyToId: replyMetadata.replyToId,
replyToBody: replyMetadata.replyToBody,
@@ -976,13 +856,8 @@ function normalizeWebhookReaction(payload: Record<string, unknown>): NormalizedW
if (!associatedGuid || associatedType === undefined) return null;
const mapping = REACTION_TYPE_MAP.get(associatedType);
const associatedEmoji =
readString(message, "associatedMessageEmoji") ??
readString(message, "associated_message_emoji") ??
readString(message, "reactionEmoji") ??
readString(message, "reaction_emoji");
const emoji = (associatedEmoji?.trim() || mapping?.emoji) ?? `reaction:${associatedType}`;
const action = mapping?.action ?? resolveTapbackActionHint(associatedType) ?? "added";
const emoji = mapping?.emoji ?? `reaction:${associatedType}`;
const action = mapping?.action ?? "added";
const handleValue = message.handle ?? message.sender;
const handle =
@@ -1247,21 +1122,7 @@ async function processMessage(
const text = message.text.trim();
const attachments = message.attachments ?? [];
const placeholder = buildMessagePlaceholder(message);
// Check if text is a tapback pattern (e.g., 'Loved "hello"') and transform to emoji format
// For tapbacks, we'll append [[reply_to:N]] at the end; for regular messages, prepend it
const tapbackContext = resolveTapbackContext(message);
const tapbackParsed = parseTapbackText({
text,
emojiHint: tapbackContext?.emojiHint,
actionHint: tapbackContext?.actionHint,
requireQuoted: !tapbackContext,
});
const isTapbackMessage = Boolean(tapbackParsed);
const rawBody = tapbackParsed
? tapbackParsed.action === "removed"
? `removed ${tapbackParsed.emoji} reaction`
: `reacted with ${tapbackParsed.emoji}`
: text || placeholder;
const rawBody = text || placeholder;
const cacheMessageId = message.messageId?.trim();
let messageShortId: string | undefined;
@@ -1588,11 +1449,7 @@ async function processMessage(
let replyToSender = message.replyToSender;
let replyToShortId: string | undefined;
if (isTapbackMessage && tapbackContext?.replyToId) {
replyToId = tapbackContext.replyToId;
}
if (replyToId) {
if (replyToId && (!replyToBody || !replyToSender)) {
const cached = resolveReplyContextFromCache({
accountId: account.accountId,
replyToId,
@@ -1620,15 +1477,8 @@ async function processMessage(
replyToShortId = getShortIdForUuid(replyToId);
}
// Use inline [[reply_to:N]] tag format
// For tapbacks/reactions: append at end (e.g., "reacted with ❤️ [[reply_to:4]]")
// For regular replies: prepend at start (e.g., "[[reply_to:4]] Awesome")
const replyTag = formatReplyTag({ replyToId, replyToShortId });
const baseBody = replyTag
? isTapbackMessage
? `${rawBody} ${replyTag}`
: `${replyTag} ${rawBody}`
: rawBody;
const replyContext = formatReplyContext({ replyToId, replyToShortId, replyToBody, replyToSender });
const baseBody = replyContext ? `${rawBody}\n\n${replyContext}` : rawBody;
const fromLabel = isGroup ? undefined : message.senderName || `user:${message.senderId}`;
const groupSubject = isGroup ? message.chatName?.trim() || undefined : undefined;
const groupMembers = isGroup
@@ -2021,11 +1871,7 @@ async function processReaction(
const chatLabel = reaction.isGroup ? ` in group:${peerId}` : "";
// Use short ID for token savings
const messageDisplayId = getShortIdForUuid(reaction.messageId) || reaction.messageId;
// Format: "Tyler reacted with ❤️ [[reply_to:5]]" or "Tyler removed ❤️ reaction [[reply_to:5]]"
const text =
reaction.action === "removed"
? `${senderLabel} removed ${reaction.emoji} reaction [[reply_to:${messageDisplayId}]]${chatLabel}`
: `${senderLabel} reacted with ${reaction.emoji} [[reply_to:${messageDisplayId}]]${chatLabel}`;
const text = `BlueBubbles reaction ${reaction.action}: ${reaction.emoji} by ${senderLabel}${chatLabel} on msg ${messageDisplayId}`;
core.system.enqueueSystemEvent(text, {
sessionKey: route.sessionKey,
contextKey: `bluebubbles:reaction:${reaction.action}:${peerId}:${reaction.messageId}:${reaction.senderId}:${reaction.emoji}`,
+1
View File
@@ -284,6 +284,7 @@ export function resolveEnvApiKey(provider: string): EnvApiKeyResult | null {
synthetic: "SYNTHETIC_API_KEY",
mistral: "MISTRAL_API_KEY",
opencode: "OPENCODE_API_KEY",
ollama: "OLLAMA_API_KEY",
};
const envVar = envMap[normalized];
if (!envVar) return null;
@@ -0,0 +1,106 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveImplicitProviders } from "./models-config.providers.js";
import { mkdtempSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
describe("Ollama provider", () => {
const previousEnv = { ...process.env };
afterEach(() => {
for (const key of Object.keys(process.env)) {
if (!(key in previousEnv)) delete process.env[key];
}
for (const [key, value] of Object.entries(previousEnv)) {
process.env[key] = value;
}
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
it("should not include ollama when no API key is configured", async () => {
const agentDir = mkdtempSync(join(tmpdir(), "clawd-test-"));
const providers = await resolveImplicitProviders({ agentDir });
// Ollama requires explicit configuration via OLLAMA_API_KEY env var or profile
expect(providers?.ollama).toBeUndefined();
});
it("discovers tool-capable models when OLLAMA_API_KEY is set", async () => {
process.env.OLLAMA_API_KEY = "ollama-local";
delete process.env.VITEST;
process.env.NODE_ENV = "development";
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
models: [{ name: "llama3.3" }, { name: "no-tools-model" }],
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
capabilities: ["tools", "thinking"],
model_info: {
"general.architecture": "llama",
"llama.context_length": "4096",
},
}),
})
.mockResolvedValueOnce({
ok: true,
status: 200,
json: async () => ({
capabilities: ["thinking"],
model_info: {
"general.architecture": "llama",
"llama.context_length": "2048",
},
}),
});
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const agentDir = mkdtempSync(join(tmpdir(), "clawd-test-"));
const providers = await resolveImplicitProviders({ agentDir });
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(fetchMock.mock.calls[0]?.[0]).toBe("http://127.0.0.1:11434/api/tags");
expect(fetchMock.mock.calls[1]?.[0]).toBe("http://127.0.0.1:11434/api/show");
const provider = providers?.ollama;
expect(provider?.baseUrl).toBe("http://127.0.0.1:11434/v1");
expect(provider?.models).toHaveLength(1);
expect(provider?.models?.[0]?.id).toBe("llama3.3");
expect(provider?.models?.[0]?.reasoning).toBe(true);
expect(provider?.models?.[0]?.contextWindow).toBe(4096);
expect(provider?.models?.[0]?.maxTokens).toBe(4096 * 10);
});
it("skips discovery when ollama is explicitly configured", async () => {
process.env.OLLAMA_API_KEY = "ollama-local";
delete process.env.VITEST;
process.env.NODE_ENV = "development";
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch);
const agentDir = mkdtempSync(join(tmpdir(), "clawd-test-"));
const providers = await resolveImplicitProviders({
agentDir,
explicitProviders: {
ollama: {
baseUrl: "http://example.com/v1",
api: "openai-completions",
models: [],
},
},
});
expect(fetchMock).not.toHaveBeenCalled();
expect(providers?.ollama).toBeUndefined();
});
});
+149 -1
View File
@@ -1,9 +1,11 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { ModelDefinitionConfig } from "../config/types.models.js";
import {
DEFAULT_COPILOT_API_BASE_URL,
resolveCopilotApiToken,
} from "../providers/github-copilot-token.js";
import { ensureAuthProfileStore, listProfilesForProvider } from "./auth-profiles.js";
import { normalizeProviderId } from "./model-selection.js";
import { resolveAwsSdkEnvVarName, resolveEnvApiKey } from "./model-auth.js";
import { discoverBedrockModels } from "./bedrock-discovery.js";
import {
@@ -62,6 +64,127 @@ const QWEN_PORTAL_DEFAULT_COST = {
cacheWrite: 0,
};
const OLLAMA_HOST_BASE_URL = "http://127.0.0.1:11434";
const OLLAMA_DEFAULT_CONTEXT_WINDOW = 8192;
const OLLAMA_MAX_TOKENS_MULTIPLIER = 10;
const OLLAMA_DISCOVERY_TIMEOUT_MS = 5000;
const OLLAMA_DEFAULT_COST = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
};
interface OllamaModel {
name: string;
modified_at: string;
size: number;
digest: string;
details?: {
family?: string;
parameter_size?: string;
};
}
interface OllamaTagsResponse {
models: OllamaModel[];
}
interface OllamaShowResponse {
capabilities?: string[];
model_info?: Record<string, string | number>;
}
function parseOllamaNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function resolveOllamaContextWindow(
modelInfo: Record<string, string | number> | undefined,
): number {
if (!modelInfo) return OLLAMA_DEFAULT_CONTEXT_WINDOW;
const architecture = String(modelInfo["general.architecture"] ?? "").trim();
const contextKey = architecture ? `${architecture}.context_length` : "";
const contextWindow =
(contextKey ? parseOllamaNumber(modelInfo[contextKey]) : undefined) ??
parseOllamaNumber(modelInfo["context_length"]);
return contextWindow ?? OLLAMA_DEFAULT_CONTEXT_WINDOW;
}
function normalizeOllamaHostBaseUrl(baseUrl: string): string {
const trimmed = baseUrl.trim().replace(/\/+$/, "");
return trimmed.endsWith("/v1") ? trimmed.slice(0, -3) : trimmed;
}
async function discoverOllamaModels(baseUrl: string): Promise<ModelDefinitionConfig[]> {
// Skip Ollama discovery in test environments
if (process.env.VITEST || process.env.NODE_ENV === "test") {
return [];
}
try {
const response = await fetch(`${baseUrl}/api/tags`, {
signal: AbortSignal.timeout(OLLAMA_DISCOVERY_TIMEOUT_MS),
});
if (!response.ok) {
console.warn(`Failed to discover Ollama models: ${response.status}`);
return [];
}
const data = (await response.json()) as OllamaTagsResponse;
if (!data.models || data.models.length === 0) {
console.warn("No Ollama models found on local instance");
return [];
}
const models = await Promise.all(
data.models.map(async (model) => {
try {
const detailsResponse = await fetch(`${baseUrl}/api/show`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ name: model.name }),
signal: AbortSignal.timeout(OLLAMA_DISCOVERY_TIMEOUT_MS),
});
if (!detailsResponse.ok) {
console.warn(
`Failed to fetch Ollama model details for ${model.name}: ${detailsResponse.status}`,
);
return null;
}
const details = (await detailsResponse.json()) as OllamaShowResponse;
const capabilities = Array.isArray(details.capabilities) ? details.capabilities : [];
if (!capabilities.includes("tools")) {
console.debug(`Skipping Ollama model ${model.name}: does not support tools`);
return null;
}
const contextWindow = resolveOllamaContextWindow(details.model_info);
return {
id: model.name,
name: model.name,
reasoning: capabilities.includes("thinking"),
input: ["text"],
cost: OLLAMA_DEFAULT_COST,
contextWindow,
maxTokens: contextWindow * OLLAMA_MAX_TOKENS_MULTIPLIER,
};
} catch (error) {
console.warn(`Failed to fetch Ollama model details for ${model.name}: ${String(error)}`);
return null;
}
}),
);
return models.filter((model): model is ModelDefinitionConfig => Boolean(model));
} catch (error) {
console.warn(`Failed to discover Ollama models: ${String(error)}`);
return [];
}
}
function normalizeApiKeyConfig(value: string): string {
const trimmed = value.trim();
const match = /^\$\{([A-Z0-9_]+)\}$/.exec(trimmed);
@@ -275,11 +398,28 @@ function buildSyntheticProvider(): ProviderConfig {
};
}
export function resolveImplicitProviders(params: { agentDir: string }): ModelsConfig["providers"] {
async function buildOllamaProvider(baseUrl: string): Promise<ProviderConfig> {
const hostBaseUrl = normalizeOllamaHostBaseUrl(baseUrl);
const models = await discoverOllamaModels(hostBaseUrl);
return {
baseUrl: `${hostBaseUrl}/v1`,
api: "openai-completions",
models,
};
}
export async function resolveImplicitProviders(params: {
agentDir: string;
explicitProviders?: ModelsConfig["providers"];
}): Promise<ModelsConfig["providers"]> {
const providers: Record<string, ProviderConfig> = {};
const authStore = ensureAuthProfileStore(params.agentDir, {
allowKeychainPrompt: false,
});
const explicitProviders = params.explicitProviders ?? {};
const hasExplicitOllama = Object.keys(explicitProviders).some(
(key) => normalizeProviderId(key) === "ollama",
);
const minimaxKey =
resolveEnvApiKeyVarName("minimax") ??
@@ -317,6 +457,14 @@ export function resolveImplicitProviders(params: { agentDir: string }): ModelsCo
};
}
// Ollama provider - only add if explicitly configured
const ollamaKey =
resolveEnvApiKeyVarName("ollama") ??
resolveApiKeyFromProfiles({ provider: "ollama", store: authStore });
if (ollamaKey && !hasExplicitOllama) {
providers.ollama = { ...(await buildOllamaProvider(OLLAMA_HOST_BASE_URL)), apiKey: ollamaKey };
}
return providers;
}
+1 -1
View File
@@ -80,7 +80,7 @@ export async function ensureClawdbotModelsJson(
const agentDir = agentDirOverride?.trim() ? agentDirOverride.trim() : resolveClawdbotAgentDir();
const explicitProviders = (cfg.models?.providers ?? {}) as Record<string, ProviderConfig>;
const implicitProviders = resolveImplicitProviders({ agentDir });
const implicitProviders = await resolveImplicitProviders({ agentDir, explicitProviders });
const providers: Record<string, ProviderConfig> = mergeProviders({
implicit: implicitProviders,
explicit: explicitProviders,
@@ -72,7 +72,7 @@ const _makeOpenAiConfig = (modelIds: string[]) =>
}) satisfies ClawdbotConfig;
const _ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
ensureClawdbotModelsJson(cfg, agentDir);
ensureClawdbotModelsJson(cfg, agentDir) as unknown;
const _textFromContent = (content: unknown) => {
if (typeof content === "string") return content;
@@ -71,7 +71,7 @@ const _makeOpenAiConfig = (modelIds: string[]) =>
}) satisfies ClawdbotConfig;
const _ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
ensureClawdbotModelsJson(cfg, agentDir);
ensureClawdbotModelsJson(cfg, agentDir) as unknown;
const _textFromContent = (content: unknown) => {
if (typeof content === "string") return content;
@@ -70,7 +70,7 @@ const _makeOpenAiConfig = (modelIds: string[]) =>
}) satisfies ClawdbotConfig;
const _ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
ensureClawdbotModelsJson(cfg, agentDir);
ensureClawdbotModelsJson(cfg, agentDir) as unknown;
const _textFromContent = (content: unknown) => {
if (typeof content === "string") return content;
@@ -70,7 +70,7 @@ const _makeOpenAiConfig = (modelIds: string[]) =>
}) satisfies ClawdbotConfig;
const _ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
ensureClawdbotModelsJson(cfg, agentDir);
ensureClawdbotModelsJson(cfg, agentDir) as unknown;
const _textFromContent = (content: unknown) => {
if (typeof content === "string") return content;
@@ -71,7 +71,7 @@ const _makeOpenAiConfig = (modelIds: string[]) =>
}) satisfies ClawdbotConfig;
const _ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
ensureClawdbotModelsJson(cfg, agentDir);
ensureClawdbotModelsJson(cfg, agentDir) as unknown;
const _textFromContent = (content: unknown) => {
if (typeof content === "string") return content;
@@ -70,7 +70,7 @@ const _makeOpenAiConfig = (modelIds: string[]) =>
}) satisfies ClawdbotConfig;
const _ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
ensureClawdbotModelsJson(cfg, agentDir);
ensureClawdbotModelsJson(cfg, agentDir) as unknown;
const _textFromContent = (content: unknown) => {
if (typeof content === "string") return content;
@@ -71,7 +71,7 @@ const _makeOpenAiConfig = (modelIds: string[]) =>
}) satisfies ClawdbotConfig;
const _ensureModels = (cfg: ClawdbotConfig, agentDir: string) =>
ensureClawdbotModelsJson(cfg, agentDir);
ensureClawdbotModelsJson(cfg, agentDir) as unknown;
const _textFromContent = (content: unknown) => {
if (typeof content === "string") return content;
+1 -1
View File
@@ -130,7 +130,7 @@ const makeOpenAiConfig = (modelIds: string[]) =>
},
}) satisfies ClawdbotConfig;
const ensureModels = (cfg: ClawdbotConfig) => ensureClawdbotModelsJson(cfg, agentDir);
const ensureModels = (cfg: ClawdbotConfig) => ensureClawdbotModelsJson(cfg, agentDir) as unknown;
const nextSessionFile = () => {
sessionCounter += 1;
+3
View File
@@ -1,5 +1,8 @@
import { afterAll, afterEach, beforeEach, vi } from "vitest";
// Ensure Vitest environment is properly set
process.env.VITEST = "true";
import type {
ChannelId,
ChannelOutboundAdapter,