Files
petal/internal/llm/client.go
prosolis a4069d5755 Phase 3: LLM grammar checkpoint
Backend (internal/llm): backend-agnostic LLMClient interface + factory
with vLLM (OpenAI-compat) and Ollama (native) clients, each Complete +
Stream. prompts.go holds the checkpoint and Ask Petal templates;
checkpoint.go salvages JSON from model output (brace-matched), enforces a
per-doc 30s RateLimiter, and truncates the doc to a latency cap.

internal/suggestions: POST /api/docs/:id/check runs a checkpoint and
replaces the doc's pending suggestions in one tx (accepted/rejected kept
as history); GET /api/docs/:id/suggestions lists pending;
POST /api/suggestions/:id/{accept,dismiss} resolves one. Throttled checks
return the current set rather than erroring.

Frontend: useCheckpoint (4s debounce, loads existing on open, stale-guard
tokens); SuggestionHighlight renders ProseMirror decorations re-anchored
by the `original` string on every doc change (not stored marks), with
precise textblock-offset→PM-position mapping; SuggestionCard shows the
type tag + diff + explanation and applies the replacement in-editor on
accept; breathing rose checkpoint dot in the StatusBar; fade-float +
breathe animations.

Tests: llm parse/rate-limit/truncate; suggestions full flow + rate-limit
over httptest with a stub client. Smoke-tested end-to-end against a fake
vLLM endpoint (anchoring verified) and the LLM-unreachable 502 path.

Claude-Session: https://claude.ai/code/session_016Yr6jELuRc7hyzYLccQKZd
2026-06-25 20:45:30 -07:00

111 lines
3.7 KiB
Go

// 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
}