Adding local LLM support to llm9p is highly feasible and architecturally straightforward. The existing Backend interface already provides the right abstraction, and the ecosystem has converged on OpenAI-compatible APIs as the standard interface for local model servers. A new OpenAIClient backend (~400-500 lines of Go) would enable llm9p to work with Ollama, llama.cpp, vLLM, LocalAI, LM Studio, and any other server exposing /v1/chat/completions.
llm9p uses a clean Backend interface (internal/llm/backend.go) with two implementations:
API Backend (Client) |
CLI Backend (CLIClient) |
|
|---|---|---|
| Transport | Anthropic HTTP API | claude subprocess |
| Auth | ANTHROPIC_API_KEY |
Claude Max subscription |
| Token counting | Exact (from API) | Estimated (chars/4) |
| Streaming | SSE via SDK | stdout pipe |
| Tool support | Native tool_use protocol |
Text-only |
The SessionManager wraps any Backend and provides per-session isolation via the stateless AskWithRequest method. This means a new backend only needs to implement the Backend interface -- everything above it (sessions, filesystem, 9P protocol) works unchanged.
Every major local LLM server now exposes an OpenAI-compatible /v1/chat/completions endpoint:
http://localhost:11434/v1/chat/completionsollama run llama3.1:8b (auto-downloads)http://localhost:8080/v1/chat/completions--jinja flag)llama-server -m model.gguf --port 8080/v1/messageshttp://localhost:8000/v1/chat/completions--enable-auto-tool-choice)vllm serve meta-llama/Llama-3.1-8B-Instructhttp://localhost:8080/v1/chat/completionsdocker run -p 8080:8080 localai/localai:latesthttp://localhost:1234/v1/chat/completionsCreate a new OpenAIClient in internal/llm/openai_client.go that implements the Backend interface using the OpenAI Chat Completions API. This is the right abstraction because:
github.com/sashabaranov/go-openai provides a mature, well-maintained Go client with streaming, tool calling, and custom base URL supportSince llama.cpp and LocalAI now support the Anthropic Messages API, we could point the existing Client at a local server using option.WithBaseURL(). However, this is worse because:
// internal/llm/openai_client.go
type OpenAIClient struct {
client *openai.Client
mu sync.RWMutex
model string // e.g., "llama3.1:8b", "mistral"
temperature float64
systemPrompt string
prefill string
messages []Message
lastTokens int
totalTokens int
thinkingTokens int
streaming bool
streamChan chan string
streamDone chan struct{}
}
func NewOpenAIClient(baseURL, apiKey, model string) *OpenAIClient {
config := openai.DefaultConfig(apiKey)
config.BaseURL = baseURL
return &OpenAIClient{
client: openai.NewClientWithConfig(config),
model: model,
temperature: 0.7,
messages: make([]Message, 0),
}
}
The implementation follows the same pattern as the existing CLIClient:
[]MessageCreateChatCompletionStreamAll 18 methods of the Backend interface map cleanly:
| Method | OpenAI Implementation |
|---|---|
Model() / SetModel() |
Local field; model name passed to API |
Temperature() / SetTemperature() |
Local field; sent in request |
SystemPrompt() / SetSystemPrompt() |
Sent as system role message |
ThinkingTokens() / SetThinkingTokens() |
Ignored (local models don't support this) |
Prefill() / SetPrefill() |
Simulated (prepend to response) |
LastTokens() / TotalTokens() |
From API response Usage field |
ContextLimit() |
Configurable per model (default 8K or 32K) |
Compact() |
Use self (local model) for summarization |
Messages() / MessagesJSON() |
Same as existing backends |
AddSystemMessage() / Reset() |
Same as existing backends |
Ask() |
CreateChatCompletion |
AskWithHistory() |
Same with explicit history |
AskWithRequest() |
Full stateless call with tools |
StartStream() / ReadStreamChunk() / IsStreaming() / WaitStream() |
CreateChatCompletionStream |
# Ollama (default port)
./llm9p -backend openai -openai-url http://localhost:11434/v1 -model llama3.1:8b
# llama-server
./llm9p -backend openai -openai-url http://localhost:8080/v1 -model default
# vLLM
./llm9p -backend openai -openai-url http://localhost:8000/v1 -model meta-llama/Llama-3.1-8B-Instruct
# LM Studio
./llm9p -backend openai -openai-url http://localhost:1234/v1 -model local-model
# With API key (for cloud OpenAI-compatible providers)
OPENAI_API_KEY=sk-... ./llm9p -backend openai -openai-url https://api.openai.com/v1 -model gpt-4o
github.com/sashabaranov/go-openai (MIT license, ~8.6k stars)
This library supports:
| Component | Scope |
|---|---|
openai_client.go |
~400-500 lines (following CLIClient patterns) |
main.go changes |
~15 lines (new flag case + validation) |
backend.go |
Add var _ Backend = (*OpenAIClient)(nil) |
| Tests | ~200 lines (unit tests with mock server) |
go.mod |
Add sashabaranov/go-openai dependency |
| Documentation | Update README, CLAUDE.md |
Total: ~700 lines of new code, mostly mechanical since it follows the existing CLIClient structure closely.
ThinkingTokens would be ignored (same as current API backend behavior).| Model | Size | Context | Tool Calling | Notes |
|---|---|---|---|---|
| Llama 3.1 8B Instruct | 4-8 GB | 128K | Yes | Best balance of quality and speed |
| Qwen 2.5 7B Instruct | 4-8 GB | 32K | Yes | Strong multilingual, good at tools |
| Mistral 7B Instruct | 4-8 GB | 32K | Yes | Fast, good instruction following |
| Phi-3 Mini 3.8B | 2-4 GB | 128K | Limited | Smallest usable model |
| DeepSeek-R1 7B | 4-8 GB | 64K | No | Strong reasoning, no tool calling |
Adding local LLM support is a well-scoped, low-risk enhancement. The Backend interface is already designed for exactly this kind of extension. The OpenAI-compatible API is the clear integration point since the entire ecosystem has standardized on it. The sashabaranov/go-openai Go library provides everything needed. Implementation would take roughly a day of development, following the established patterns in cli_client.go.
The result would make llm9p usable in fully offline/air-gapped environments, eliminate API costs for development and experimentation, and open the door to any model ecosystem (not just Claude).