Skip to content

Commit 20d489f

Browse files
committed
📦 new (provider): add Ollama provider plugin with model pairing and catalog
1 parent 8525e0f commit 20d489f

18 files changed

Lines changed: 1120 additions & 30 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Tiny Claw is inspired by personal AI companions from science fiction like **Cods
5353
- **Own personality.** Ships with a personality (Heartware system) that's uniquely its own.
5454
- **Native, not wrapped.** Every component is built from scratch with zero dependency on external AI frameworks.
5555
- **Easy to start.** Uses Ollama Cloud with two built-in models — kimi-k2.5:cloud (default) and gpt-oss:120b-cloud. Choose your model during setup and switch anytime via conversation.
56+
- **Flexible Ollama support.** Add the Ollama provider plugin for local Ollama or extra cloud models without duplicating the built-in starter models, while reusing the Ollama API key already stored during setup.
5657
- **Cost-conscious.** Smart routing tiers queries across your installed providers. Cheap models handle simple stuff, powerful models only fire when needed.
5758

5859
## ✨ Features

bun.lock

Lines changed: 11 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/config/src/types.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export type { ConfigManagerConfig, ConfigManagerInterface } from '@tinyclaw/type
2222
* stored in secrets-engine (never the actual secret).
2323
*/
2424
const ProviderEntrySchema = z.object({
25+
providerId: z.string().optional(),
26+
mode: z.enum(['local', 'cloud']).optional(),
2527
model: z.string().optional(),
2628
baseUrl: z.string().url().optional(),
2729
apiKeyRef: z.string().optional(),
@@ -61,7 +63,7 @@ export const TinyClawConfigSchema = z
6163
starterBrain: ProviderEntrySchema.optional(),
6264
primary: ProviderEntrySchema.optional(),
6365
})
64-
.passthrough()
66+
.catchall(ProviderEntrySchema)
6567
.optional(),
6668

6769
/** Channel configurations (telegram, discord, slack, etc.) */

packages/config/tests/types.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,16 @@ describe('TinyClawConfigSchema — valid data', () => {
6161
baseUrl: 'http://localhost:11434',
6262
},
6363
primary: {
64+
providerId: 'openai',
6465
model: 'gpt-4',
6566
baseUrl: 'https://api.openai.com/v1',
6667
apiKeyRef: 'provider.openai.apiKey',
6768
},
69+
ollama: {
70+
mode: 'local',
71+
model: 'llama3.2:3b',
72+
baseUrl: 'http://127.0.0.1:11434',
73+
},
6874
},
6975
channels: {
7076
telegram: { enabled: true, tokenRef: 'channel.telegram.token' },
@@ -116,6 +122,25 @@ describe('TinyClawConfigSchema — valid data', () => {
116122
});
117123
expect(result.success).toBe(true);
118124
});
125+
126+
test('accepts provider mode and providerId fields', () => {
127+
const result = TinyClawConfigSchema.safeParse({
128+
providers: {
129+
primary: {
130+
providerId: 'ollama',
131+
model: 'llama3.2:3b',
132+
},
133+
ollama: {
134+
mode: 'cloud',
135+
model: 'qwen3:32b',
136+
baseUrl: 'https://ollama.com',
137+
apiKeyRef: 'provider.ollama.apiKey',
138+
},
139+
},
140+
});
141+
142+
expect(result.success).toBe(true);
143+
});
119144
});
120145

121146
// -----------------------------------------------------------------------
@@ -173,4 +198,14 @@ describe('TinyClawConfigSchema — invalid data', () => {
173198
});
174199
expect(result.success).toBe(false);
175200
});
201+
202+
test('rejects invalid provider mode', () => {
203+
const result = TinyClawConfigSchema.safeParse({
204+
providers: {
205+
ollama: { mode: 'edge' },
206+
},
207+
});
208+
209+
expect(result.success).toBe(false);
210+
});
176211
});

packages/types/src/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -646,7 +646,10 @@ export interface ChannelPlugin extends PluginMeta {
646646
export interface ProviderPlugin extends PluginMeta {
647647
readonly type: 'provider';
648648
/** Create and return an initialized Provider instance. */
649-
createProvider(secrets: SecretsManagerInterface): Promise<Provider>;
649+
createProvider(
650+
secrets: SecretsManagerInterface,
651+
configManager: ConfigManagerInterface,
652+
): Promise<Provider>;
650653
/** Optional pairing tools for conversational setup (API key, model config). */
651654
getPairingTools?(secrets: SecretsManagerInterface, configManager: ConfigManagerInterface): Tool[];
652655
}

plugins/provider/README.md

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ The startup flow is:
2020
1. CLI loads plugin IDs from the `plugins.enabled` config array.
2121
2. Plugin modules are imported dynamically by package name.
2222
3. Pairing tools from provider (and channel) plugins are merged into the agent tool list.
23-
4. For each enabled provider plugin, Tiny Claw calls `plugin.createProvider(secrets)` to obtain a `Provider` instance.
23+
4. For each enabled provider plugin, Tiny Claw calls `plugin.createProvider(secrets, configManager)` to obtain a `Provider` instance.
2424
5. The routing system maps query complexity tiers (simple, moderate, complex, reasoning) to provider IDs.
2525
6. At query time, the router selects the appropriate provider based on the tier mapping.
2626

@@ -31,7 +31,10 @@ Provider plugins must default-export an object that satisfies `ProviderPlugin` f
3131
```ts
3232
export interface ProviderPlugin extends PluginMeta {
3333
readonly type: 'provider';
34-
createProvider(secrets: SecretsManagerInterface): Promise<Provider>;
34+
createProvider(
35+
secrets: SecretsManagerInterface,
36+
configManager: ConfigManagerInterface,
37+
): Promise<Provider>;
3538
getPairingTools?(
3639
secrets: SecretsManagerInterface,
3740
configManager: ConfigManagerInterface,
@@ -59,7 +62,7 @@ Required fields on the plugin:
5962
| `description` | Short summary |
6063
| `type` | Must be `'provider'` |
6164
| `version` | SemVer string |
62-
| `createProvider(secrets)` | Factory that returns a `Provider` instance |
65+
| `createProvider(secrets, configManager)` | Factory that returns a `Provider` instance |
6366

6467
Optional field:
6568

@@ -130,8 +133,15 @@ const plugin: ProviderPlugin = {
130133
type: 'provider',
131134
version: '0.1.0',
132135

133-
async createProvider(secrets: SecretsManagerInterface) {
134-
return createMyProvider({ secrets });
136+
async createProvider(
137+
secrets: SecretsManagerInterface,
138+
configManager: ConfigManagerInterface,
139+
) {
140+
return createMyProvider({
141+
secrets,
142+
model: configManager.get<string>('providers.<name>.model') ?? undefined,
143+
baseUrl: configManager.get<string>('providers.<name>.baseUrl') ?? undefined,
144+
});
135145
},
136146

137147
getPairingTools(
@@ -294,6 +304,7 @@ configManager.set('routing.tierMapping.reasoning', '<name>');
294304
| Plugin | Path | Description |
295305
|--------|------|-------------|
296306
| **OpenAI** | `plugins/provider/plugin-provider-openai/` | OpenAI GPT models via raw fetch (no SDK) |
307+
| **Ollama** | `plugins/provider/plugin-provider-ollama/` | Local Ollama and custom Ollama Cloud models |
297308

298309
Key files in the OpenAI plugin:
299310

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# Changelog
2+
3+
All notable changes to this project will be documented in this file.
4+
5+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7+
8+
## [Unreleased]
9+
10+
### Added
11+
12+
- add Ollama provider plugin with local and custom cloud model support
13+
- add conversational model listing and switching tools for the Ollama plugin
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# @tinyclaw/plugin-provider-ollama
2+
3+
Ollama provider plugin for Tiny Claw. It supports both local Ollama instances and custom Ollama Cloud models while keeping the built-in starter models reserved for the built-in provider.
4+
5+
## What It Adds
6+
7+
- Local Ollama support with a configurable base URL
8+
- Ollama Cloud support for custom cloud models
9+
- Conversational pairing, model listing, and model switching tools
10+
- Filtering of built-in cloud starter models from the plugin's cloud model list
11+
12+
## Conversational Tools
13+
14+
- `ollama_pair` - pair local or cloud Ollama
15+
- `ollama_model_list` - show supported Ollama models for the current mode
16+
- `ollama_model_set` - switch the configured Ollama model or mode
17+
- `ollama_unpair` - disable the plugin and restore built-in routing
18+
19+
## Notes
20+
21+
- Cloud mode reuses the existing `provider.ollama.apiKey` from the built-in setup by default
22+
- You only need to pass `apiKey` if you want to replace that stored Ollama key
23+
- Cloud model listing excludes the built-in starter models
24+
- Use `tinyclaw_restart` after pairing or switching models
25+
26+
## License
27+
28+
GPLv3
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"name": "@tinyclaw/plugin-provider-ollama",
3+
"version": "2.0.0",
4+
"description": "Ollama provider plugin for Tiny Claw",
5+
"license": "GPL-3.0",
6+
"author": "Waren Gonzaga",
7+
"type": "module",
8+
"main": "./dist/index.js",
9+
"types": "./dist/index.d.ts",
10+
"exports": {
11+
".": "./dist/index.js"
12+
},
13+
"files": [
14+
"dist"
15+
],
16+
"repository": {
17+
"type": "git",
18+
"url": "git+https://github.com/warengonzaga/tinyclaw.git",
19+
"directory": "plugins/provider/plugin-provider-ollama"
20+
},
21+
"homepage": "https://github.com/warengonzaga/tinyclaw/tree/main/plugins/provider/plugin-provider-ollama#readme",
22+
"bugs": {
23+
"url": "https://github.com/warengonzaga/tinyclaw/issues"
24+
},
25+
"keywords": [
26+
"tinyclaw",
27+
"plugin",
28+
"provider",
29+
"ollama"
30+
],
31+
"scripts": {
32+
"build": "tsc -p tsconfig.json"
33+
},
34+
"dependencies": {
35+
"@tinyclaw/core": "workspace:*",
36+
"@tinyclaw/logger": "workspace:*",
37+
"@tinyclaw/types": "workspace:*"
38+
}
39+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { BUILTIN_MODEL_TAGS, DEFAULT_BASE_URL } from '@tinyclaw/core';
2+
import { buildProviderKeyName } from '@tinyclaw/types';
3+
4+
export const OLLAMA_PLUGIN_ID = '@tinyclaw/plugin-provider-ollama';
5+
export const OLLAMA_PROVIDER_ID = 'ollama';
6+
export const OLLAMA_SECRET_KEY = buildProviderKeyName(OLLAMA_PROVIDER_ID);
7+
export const OLLAMA_MODEL_CONFIG_KEY = 'providers.ollama.model';
8+
export const OLLAMA_BASE_URL_CONFIG_KEY = 'providers.ollama.baseUrl';
9+
export const OLLAMA_MODE_CONFIG_KEY = 'providers.ollama.mode';
10+
export const OLLAMA_API_KEY_REF_CONFIG_KEY = 'providers.ollama.apiKeyRef';
11+
export const OLLAMA_LOCAL_BASE_URL = 'http://127.0.0.1:11434';
12+
export const OLLAMA_LOCAL_FALLBACK_MODEL = 'llama3.2:3b';
13+
export const OLLAMA_CLOUD_FALLBACK_MODEL = 'qwen3:32b';
14+
15+
export type OllamaProviderMode = 'local' | 'cloud';
16+
17+
interface OllamaTagItem {
18+
name?: string;
19+
model?: string;
20+
}
21+
22+
interface OllamaTagsResponse {
23+
models?: OllamaTagItem[];
24+
}
25+
26+
export function normalizeOllamaMode(mode: string | null | undefined): OllamaProviderMode {
27+
return mode === 'cloud' ? 'cloud' : 'local';
28+
}
29+
30+
export function getDefaultBaseUrl(mode: OllamaProviderMode): string {
31+
return mode === 'cloud' ? DEFAULT_BASE_URL : OLLAMA_LOCAL_BASE_URL;
32+
}
33+
34+
export function getFallbackModel(mode: OllamaProviderMode): string {
35+
return mode === 'cloud' ? OLLAMA_CLOUD_FALLBACK_MODEL : OLLAMA_LOCAL_FALLBACK_MODEL;
36+
}
37+
38+
export function shouldFilterBuiltinModel(mode: OllamaProviderMode, model: string): boolean {
39+
return mode === 'cloud' && (BUILTIN_MODEL_TAGS as readonly string[]).includes(model);
40+
}
41+
42+
export function sortModels(models: string[]): string[] {
43+
return [...new Set(models)].sort((left, right) => left.localeCompare(right));
44+
}
45+
46+
export async function fetchOllamaModels(config: {
47+
baseUrl: string;
48+
mode: OllamaProviderMode;
49+
apiKey?: string | null;
50+
}): Promise<string[]> {
51+
const headers: Record<string, string> = {};
52+
if (config.apiKey) {
53+
headers.Authorization = `Bearer ${config.apiKey}`;
54+
}
55+
56+
const response = await fetch(`${config.baseUrl}/api/tags`, { headers });
57+
if (!response.ok) {
58+
throw new Error(`Ollama model listing failed with HTTP ${response.status}`);
59+
}
60+
61+
const data = (await response.json()) as OllamaTagsResponse;
62+
const models = (data.models ?? [])
63+
.map((entry) => entry.name ?? entry.model ?? '')
64+
.map((entry) => entry.trim())
65+
.filter(Boolean)
66+
.filter((entry) => !shouldFilterBuiltinModel(config.mode, entry));
67+
68+
return sortModels(models);
69+
}
70+
71+
export function formatModelList(models: string[], currentModel?: string): string {
72+
if (models.length === 0) {
73+
return 'No supported models were found.';
74+
}
75+
76+
return models
77+
.map((model) => (model === currentModel ? `• ${model} [current]` : `• ${model}`))
78+
.join('\n');
79+
}

0 commit comments

Comments
 (0)