Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions PR_BODY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
## Summary

Adds Ollama as a new LLM provider option, enabling CashClaw to use local models. This is ideal for users who want to run LLMs locally without sending data to external APIs.

## Changes

### Backend (`src/`)
- `config.ts`: Add `ollama` to `LLMConfig.provider` type, default model `llama3.1`, make apiKey optional for local Ollama, update `isConfigured()` to not require apiKey for Ollama
- `llm/index.ts`: Create dedicated `createOllamaProvider()` function with Ollama-specific handling (localhost:11434, no auth required)

### Frontend (`src/ui/`)
- `pages/setup/LLMStep.tsx`: Add Ollama option to setup wizard
- `pages/Settings.tsx`: Add Ollama option to settings page

### Tests (`test/`)
- `llm.test.ts`: Add 15 comprehensive tests for Ollama provider

## Important: Tool Calling Support

Ollama's tool calling support **varies by model**:

| Model | Tool Calling |
|-------|--------------|
| llama3.1 | ✅ Supported |
| qwen2.5 | ✅ Supported |
| mistral | ✅ Supported |
| llama3 | ❌ Not supported |
| llama2 | ❌ Not supported |

**The provider includes helpful error messages** when tool calling isn't supported by the model.

## Requirements

- Ollama >= 0.1.20
- A model with tool calling support (llama3.1, qwen2.5, mistral)
- Ollama must be running (`ollama serve`)

## Usage

```bash
# 1. Install Ollama
curl -fsSL https://ollama.com/install.sh

# 2. Pull a model with tool support (recommended)
ollama pull llama3.1

# 3. Start Ollama server
ollama serve

# 4. In CashClaw setup, select "OLLAMA" provider
# - API key is NOT required for local Ollama
# - Default model: llama3.1
```

## Example Config

```json
{
"llm": {
"provider": "ollama",
"model": "llama3.1",
"apiKey": ""
}
}
```

## Testing

All tests pass (22 total):

```
✓ test/llm.test.ts (15 tests)
✓ test/loop.test.ts (7 tests)
```

### Ollama-specific tests:
- Local base URL (http://localhost:11434)
- No API key required for local instance
- Empty string API key handled correctly
- Custom model configuration (llama3.1, qwen:30b, mistral)
- Tool call parsing from Ollama responses
- Helpful error messages for unsupported tool calling
- Error handling for API failures
- Connection error handling (Ollama not running)

## Implementation Notes

1. **Dedicated Provider**: Unlike OpenAI/OpenRouter which share a provider, Ollama has its own provider function to handle:
- No authentication required for local instances
- Different error messages
- Ollama-specific options in the request body

2. **Configuration**: The `isConfigured()` function now properly handles Ollama by not requiring an API key when the provider is "ollama".

3. **Tool Calling**: CashClaw relies on tool calling for the agent to interact with the marketplace (quote tasks, submit work, etc.). Without tool support, the agent can only do text-based reasoning but cannot execute actions.

---

Closes #12
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 7 additions & 3 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ import path from "node:path";
import os from "node:os";

export interface LLMConfig {
provider: "anthropic" | "openai" | "openrouter";
provider: "anthropic" | "openai" | "openrouter" | "ollama";
model: string;
apiKey: string;
apiKey?: string; // Optional for Ollama (local instance)
}

export interface PricingConfig {
Expand Down Expand Up @@ -90,7 +90,10 @@ export function saveConfig(config: CashClawConfig): void {
export function isConfigured(): boolean {
const config = loadConfig();
if (!config) return false;
return Boolean(config.agentId && config.llm?.apiKey && config.llm?.provider);
if (!config.agentId || !config.llm?.provider) return false;
// Ollama doesn't require API key for local instances
if (config.llm.provider === "ollama") return true;
return Boolean(config.llm?.apiKey);
}

/** Save partial config fields, merging with existing config or defaults */
Expand Down Expand Up @@ -118,6 +121,7 @@ export function initConfig(opts: {
anthropic: "claude-sonnet-4-20250514",
openai: "gpt-4o",
openrouter: "anthropic/claude-sonnet-4-20250514",
ollama: "llama3.1", // Default Ollama model (supports tool calling)
};

const config: CashClawConfig = {
Expand Down
123 changes: 123 additions & 0 deletions src/llm/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,123 @@ function createOpenAICompatibleProvider(
};
}

/**
* Ollama provider using OpenAI-compatible API
* Note: Tool calling support requires Ollama >= 0.1.20 and specific models (llama3.1, qwen2.5, mistral)
* For older models or versions, the agent will fall back to text-only mode
*/
function createOllamaProvider(config: LLMConfig): LLMProvider {
const baseUrl = "http://localhost:11434";

return {
async chat(messages, tools) {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};

// Ollama doesn't require API key for local instance
if (config.apiKey) {
headers["Authorization"] = `Bearer ${config.apiKey}`;
}

// Convert messages to OpenAI format
const openAIMessages = toOpenAIMessages(messages);

const body: Record<string, unknown> = {
model: config.model,
// Ollama defaults to 4096, but we can specify more for agent work
options: {
num_predict: 4096,
temperature: 0.7,
},
messages: openAIMessages,
stream: false,
};

// Add tools if provided - Ollama supports this in newer versions
// Note: Some Ollama models don't support tool calling
if (tools && tools.length > 0) {
body.tools = toOpenAITools(tools);
}

const res = await fetch(`${baseUrl}/v1/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify(body),
});

if (!res.ok) {
const err = await res.text();
// Provide helpful error message for common issues
if (err.includes("tool") || err.includes("function")) {
throw new Error(
`Ollama tool calling not supported for model ${config.model}. ` +
`Try using llama3.1, qwen2.5, or mistral model. ` +
`Error: ${err}`
);
}
throw new Error(`Ollama API ${res.status}: ${err}`);
}

const data = (await res.json()) as {
choices: Array<{
message: {
content: string | null;
tool_calls?: Array<{
id: string;
function: { name: string; arguments: string };
}>;
};
finish_reason: string;
}>;
usage?: { prompt_tokens: number; completion_tokens: number };
};

const choice = data.choices[0];
const content: ContentBlock[] = [];

if (choice.message.content) {
content.push({ type: "text", text: choice.message.content });
}

// Handle tool calls from Ollama
if (choice.message.tool_calls) {
for (const tc of choice.message.tool_calls) {
let input: Record<string, unknown>;
try {
input = JSON.parse(tc.function.arguments) as Record<string, unknown>;
} catch {
input = { _raw: tc.function.arguments, _error: "malformed JSON from Ollama" };
}
content.push({
type: "tool_use",
id: tc.id,
name: tc.function.name,
input,
});
}
}

// Map Ollama finish_reason to our stopReason
const stopReasonMap: Record<string, LLMResponse["stopReason"]> = {
stop: "end_turn",
tool_calls: "tool_use",
length: "max_tokens",
};

return {
content,
stopReason: stopReasonMap[choice.finish_reason] ?? "end_turn",
usage: {
// Ollama may not return usage info
inputTokens: data.usage?.prompt_tokens ?? 0,
outputTokens: data.usage?.completion_tokens ?? 0,
},
};
},
};
}

export function createLLMProvider(config: LLMConfig): LLMProvider {
switch (config.provider) {
case "anthropic":
Expand All @@ -236,6 +353,12 @@ export function createLLMProvider(config: LLMConfig): LLMProvider {
config,
"https://openrouter.ai/api/v1",
);
case "ollama":
// Ollama provider - uses OpenAI-compatible API at localhost:11434
// Note: Tool calling support in Ollama varies by model. Models like llama3.1,
// qwen2.5, and mistral support tools via the OpenAI compat API.
// For models without tool support, the agent will work in text-only mode.
return createOllamaProvider(config);
default:
throw new Error(`Unknown LLM provider: ${config.provider}`);
}
Expand Down
1 change: 1 addition & 0 deletions src/ui/pages/Settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,7 @@ export function Settings() {
<option value="anthropic">Anthropic</option>
<option value="openai">OpenAI</option>
<option value="openrouter">OpenRouter</option>
<option value="ollama">Ollama (Local)</option>
</select>
</Field>
<Field label="Model">
Expand Down
1 change: 1 addition & 0 deletions src/ui/pages/setup/LLMStep.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const PROVIDERS = [
{ value: "anthropic", label: "ANTHROPIC", desc: "Claude models", model: "claude-sonnet-4-20250514" },
{ value: "openai", label: "OPENAI", desc: "GPT-4o", model: "gpt-4o" },
{ value: "openrouter", label: "OPENROUTER", desc: "Multi-provider", model: "openai/gpt-5.4" },
{ value: "ollama", label: "OLLAMA", desc: "Local models (llama3, qwen, etc.)", model: "llama3" },
];

export function LLMStep({ onNext }: LLMStepProps) {
Expand Down
Loading