// Package llm wraps the local inference endpoint behind a backend-agnostic // interface. Two concrete backends — vLLM (OpenAI-compatible) and Ollama // (native) — implement LLMClient; the rest of the app (the grammar checkpoint, // Ask Petal) calls the interface only and never knows which one is active. package llm import ( "context" "time" "gitea.parodia.dev/drwily/petal/internal/config" ) // Message is one chat turn sent to the model. type Message struct { Role string `json:"role"` Content string `json:"content"` } // CompletionRequest is the backend-neutral request shape. Each backend maps // these fields onto its own wire format (see the parameter cheatsheet in the // spec). Zero-valued sampling fields fall back to backend defaults. type CompletionRequest struct { Messages []Message MaxTokens int Temperature float64 RepetitionPenalty float64 TopP float64 Stop []string Stream bool } // LLMClient is the single surface handler code depends on. type LLMClient interface { // Complete returns the full response in one shot (used for the checkpoint // JSON, which we want whole before parsing). Complete(ctx context.Context, req CompletionRequest) (string, error) // Stream returns a channel of text chunks (used for Ask Petal SSE). The // channel closes when generation ends; on a mid-stream error it closes // early and the error is reported via the returned error of a future call. Stream(ctx context.Context, req CompletionRequest) (<-chan string, error) } // Truncation caps. These guard checkpoint latency (prefill time scales with // input length), not the model window — Qwen 3.5 ships 256K, far larger. const ( // maxDocChars ~ 10,000 tokens at ~4 chars/token. Grammar checkpoint only; // the voice pass sends the whole document uncut. maxDocChars = 40000 // maxHistoryMsgs caps the rolling Ask Petal history (5 turns); oldest pairs // drop first. maxHistoryMsgs = 10 ) // TruncateDoc keeps the recent end of a document — the user is actively writing // there — when it exceeds the grammar-checkpoint latency cap. Most documents // fit uncut. The voice pass deliberately does NOT call this. func TruncateDoc(contentText string) string { if len(contentText) > maxDocChars { return contentText[len(contentText)-maxDocChars:] } return contentText } // TrimHistory keeps the most recent maxHistoryMsgs messages, dropping the // oldest first so a long Ask Petal chat stays within budget. func TrimHistory(msgs []Message) []Message { if len(msgs) > maxHistoryMsgs { return msgs[len(msgs)-maxHistoryMsgs:] } return msgs } // NewLLMClient selects a backend from config. LLM_CHAT_MODEL falls back to // LLM_MODEL here, in the factory, so handlers never deal with the fallback. func NewLLMClient(cfg *config.Config) LLMClient { chatModel := cfg.LLMChatModel if chatModel == "" { chatModel = cfg.LLMModel } base := backend{ endpoint: cfg.LLMEndpoint, checkpointModel: cfg.LLMModel, chatModel: chatModel, timeout: cfg.LLMTimeout, } switch cfg.LLMBackend { case "ollama": return &OllamaClient{base} default: // "vllm" return &VLLMClient{base} } } // backend holds the fields shared by both concrete clients. type backend struct { endpoint string checkpointModel string // LLM_MODEL — small/fast checkpoint model chatModel string // LLM_CHAT_MODEL (or LLM_MODEL) — Ask Petal model timeout time.Duration } // model picks the right model id for a request. Streaming requests are Ask // Petal (chat); one-shot Complete calls are the grammar checkpoint. func (b backend) model(req CompletionRequest) string { if req.Stream { return b.chatModel } return b.checkpointModel }