Skip to content

Commit 3d3e568

Browse files
Alexey Panfilovclaude
andcommitted
feat: add local model provider for classifier routing (llama.cpp)
Support running the routing classifier on a local model (e.g. Gemma 3n 270M) via llama.cpp server, eliminating external API dependency for routing decisions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent dc6d654 commit 3d3e568

7 files changed

Lines changed: 38 additions & 7 deletions

File tree

CLAUDE.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ flowchart LR
4848
- `openai_compat.go` — shared OpenAI-compatible implementation using raw `net/http` (no go-openai). `buildMessages(messages, systemPrompt, vision bool)` serialises messages with full control: assistant messages with `tool_calls` and empty content use `"content": null` (not omitted) to satisfy all provider APIs. `image_url` parts are replaced with `[image]` for non-vision providers; `input_audio` parts likewise. Defines `APIError{StatusCode, Message}` for fallback routing.
4949
- `ollama.go` — native Ollama provider using `/api/chat` protocol (not OpenAI-compat). Supports Ollama Cloud (`https://ollama.com`) and local instances. Handles tool calling (Ollama returns `arguments` as object → serialised to JSON string; generates `call_N` IDs since Ollama omits them). Multimodal: base64 images sent via `images[]` field (strips data URI prefix). `stream: false` for synchronous responses.
5050
- `claude_bridge.go` — Claude Bridge provider. Sends prompts to `claude-bridge` HTTP service on the host, which wraps `claude -p` CLI. `buildPrompt(messages, systemPrompt)` flattens multi-turn history into `User: ... / Assistant: ...` text format (CLI doesn't support multi-turn). Multimodal parts replaced with `[image]`/`[audio]`/`[document]` placeholders. Returns `APIError` on bridge errors for fallback routing. No tool calling — Claude CLI handles MCP tools itself via `--project-dir`.
51+
- `local.go` — local model provider via llama.cpp server (OpenAI-compatible API). No API key required — only `base_url` and `model`. Used for classifier routing with lightweight models like Gemma 3n 270M.
5152
- `router.go` — thread-safe routing config (`mu` protects both `override` and `cfg`). Priority: multimodal → override → classifier → reasoner → primary → fallback on 5xx/429/network. `SetRole(role, model)` + `SetClassifierMinLen(n)` allow runtime changes; `saveOverrides()` persists to `persistPath` (JSON) on every change; `LoadPersistedOverrides()` applies saved values on startup. Classifier has 5 s timeout; input truncated to 500 chars; logs routing decisions at Info when routed to reasoner.
5253
- All providers are optional in `main.go` — any configured model can be `routing.default`. The bot exits with a clear error if the default provider is missing.
5354

@@ -190,7 +191,7 @@ Bot (Docker) → POST /ask → claude-bridge (host:9900) → claude -p → respo
190191

191192
1. Implement `llm.Provider` interface (or reuse `openai_compat.go` if OpenAI-compatible)
192193
2. Add `ModelConfig` with `base_url` to `config.yaml` and `ModelsConfig` struct
193-
3. Wire in `main.go` with `if cfg.Models.X.APIKey != "" { addProvider("key", ...) }`
194+
3. Wire in `main.go` with `if cfg.Models.X.APIKey != "" { addProvider("key", ...) }` (for local models without API key, check `BaseURL` instead)
194195
4. Set as `routing.default` or a routing role
195196

196197
### Adding multimodal content types

README.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ A lightweight Telegram bot that acts as a personal AI assistant. Written in Go
44

55
## Features
66

7-
- **Multi-model routing** — any configured model can be primary; automatic fallback on errors or rate limits; dedicated reasoner for complex tasks; vision model for images; classifier-based routing to reasoner
7+
- **Multi-model routing** — any configured model can be primary; automatic fallback on errors or rate limits; dedicated reasoner for complex tasks; vision model for images; classifier-based routing to reasoner (supports local models via llama.cpp server)
88
- **Claude via bridge** — use Claude (Anthropic Max subscription) as a provider through a lightweight host-side bridge service that wraps `claude -p` CLI; no separate API key needed
99
- **Ollama Cloud support** — native `/api/chat` provider for Ollama Cloud and local Ollama instances; tool calling, multimodal, Bearer auth
1010
- **Voice messages** — send a voice message in Telegram and it's automatically transcribed via the multimodal model (Gemini), then processed as text through the normal pipeline; replies include both text and a voice message via Edge TTS
@@ -213,12 +213,20 @@ models:
213213
# api_key: ${CLAUDE_BRIDGE_TOKEN}
214214
# max_tokens: 120 # timeout in seconds
215215

216+
# Local model via llama.cpp server (OpenAI-compatible API).
217+
# No API key needed — only base_url and model.
218+
# local:
219+
# provider: local
220+
# model: gemma-3n-E2B
221+
# max_tokens: 64
222+
# base_url: http://classifier:8080/v1
223+
216224
routing:
217225
default: deepseek # primary model — can be any configured model name
218226
fallback: gemini-flash # also used for multimodal
219227
multimodal: gemini-flash
220228
reasoner: deepseek-r1
221-
classifier: deepseek # model for reasoning detection; omit to disable
229+
classifier: local # local model for reasoning detection; omit to disable
222230
classifier_min_length: 100 # min chars to run classifier; 0 = disabled
223231
compaction_model: deepseek
224232

@@ -372,7 +380,7 @@ esphome run atom-echo.yaml # compile + flash via USB
372380
| 3 | `primary` | Default for all other messages |
373381
| 4 | `fallback` | Primary unavailable (5xx / 429 / network error) |
374382

375-
The classifier is a lightweight call with no history and no tools that returns `yes`/`no`. It only runs for messages longer than `classifier_min_length` characters (default: 100). Input is truncated to 500 chars to save tokens. Set `classifier_min_length: 0` to disable.
383+
The classifier is a lightweight call with no history and no tools that returns `yes`/`no`. It only runs for messages longer than `classifier_min_length` characters (default: 100). Input is truncated to 500 chars to save tokens. Set `classifier_min_length: 0` to disable. The classifier can run on a local model (e.g. Gemma 3n 270M via llama.cpp server) for zero-latency, zero-cost routing — configure `local` provider and set `classifier: local`.
376384

377385
All routing roles can be changed live via `/routing` — an inline keyboard menu. **Changes persist across restarts** in `config/routing.json`. On startup, the bot notifies the owner via Telegram if any routing role references an unavailable model.
378386

cmd/agent/main.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,10 @@ func main() {
9292
p, e := llm.NewClaudeBridge(cfg.Models.Claude)
9393
addProvider("claude", p, e)
9494
}
95+
if cfg.Models.Local.BaseURL != "" {
96+
p, e := llm.NewLocal(cfg.Models.Local)
97+
addProvider("local", p, e)
98+
}
9599

96100
// Ensure the default routing provider is available.
97101
if providers[cfg.Routing.Default] == nil {

config/config.yaml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ models:
6969
max_tokens: 8192
7070
base_url: https://dashscope-intl.aliyuncs.com/compatible-mode/v1
7171

72+
# Local model via llama.cpp server (OpenAI-compatible API).
73+
# Used as classifier for routing decisions. No API key needed.
74+
local:
75+
provider: local
76+
model: gemma-3n-E2B
77+
max_tokens: 64
78+
base_url: http://classifier:8080/v1
79+
7280
# Claude via bridge (host service wrapping `claude -p` CLI).
7381
# Requires claude-bridge running on host:9900.
7482
# max_tokens here is used as timeout_sec for the CLI call.
@@ -83,7 +91,7 @@ routing:
8391
multimodal: gemini-flash
8492
reasoner: claude
8593
compaction_model: deepseek
86-
classifier: qwen-flash
94+
classifier: local
8795
classifier_min_length: 100
8896

8997
tool_filter:

internal/config/config.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ type ModelsConfig struct {
8282
QwenMax ModelConfig `yaml:"qwen-max"`
8383
Ollama ModelConfig `yaml:"ollama"`
8484
Claude ModelConfig `yaml:"claude"`
85+
Local ModelConfig `yaml:"local"`
8586
}
8687

8788

internal/llm/local.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package llm
2+
3+
import "telegram-agent/internal/config"
4+
5+
// NewLocal creates an OpenAI-compatible provider for a local model (e.g. llama.cpp server).
6+
// Does not require an API key — only base_url and model are needed.
7+
func NewLocal(cfg config.ModelConfig) (*openAICompatProvider, error) {
8+
return newOpenAICompat(cfg, "", "local")
9+
}

internal/llm/openai_compat.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,8 @@ type openAICompatProvider struct {
3333
}
3434

3535
func newOpenAICompat(cfg config.ModelConfig, defaultBaseURL, providerName string, vision ...bool) (*openAICompatProvider, error) {
36-
if cfg.APIKey == "" {
37-
return nil, fmt.Errorf("%s: api_key is required", providerName)
36+
if cfg.APIKey == "" && cfg.BaseURL == "" {
37+
return nil, fmt.Errorf("%s: api_key or base_url is required", providerName)
3838
}
3939
baseURL := cfg.BaseURL
4040
if baseURL == "" {

0 commit comments

Comments
 (0)