aee20a6abc
llama-server is started without -np, so it serves one request at a time and everything else queues. Mail extraction is allowed two minutes on a Thinking 1.7B, and the reader hands core up to 25 messages back to back. A turn arriving mid-extraction therefore waited for whatever was left of that budget: the router timed out into the classifier cascade and its 36.8% floor, and the phraser, which has no floor, simply waited. Memory evaluation had the same shape with a five minute budget. llm.Gate is the bound. Foreground requests never wait. Background requests run one at a time and yield while a foreground request is in flight, plus a quiet window after it that covers the gap between the router call and the phraser call of one turn. Clients get their priority from llmClientFor or llmBackgroundClientFor, so which side a caller is on is decided at wiring time. It gates only what goes through those clients, which the comment on Gate says. mail intake: the extraction timeout no longer wraps the capture writes. A model answering at 119 seconds of a 120 second budget left the first CaptureTask one second and the third none, so candidates the model had already produced were dropped with a deadline error. The mailbox name is validated before it becomes provenance, since "email:" is not a source and neither is an arbitrary string posted at the socket. The enable log prints the normalised candidate bound rather than the configured one, which said "max 0" and then wrote three. Found in review of #64. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
155 lines
4.8 KiB
Go
155 lines
4.8 KiB
Go
// Package llm is the shared llama-server completion client — one seam both the
|
|
// phraser (talking back) and the router (routing) call. It does NOT spawn the
|
|
// server; the daemon owns one llama-server (spawned by the phraser) and hands
|
|
// its base URL here, so a single resident model serves both callers.
|
|
package llm
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
type Client struct {
|
|
// mu guards base only. The base URL changes when the daemon swaps the
|
|
// resident model (Vikunja #250): llama-server is relaunched on a fresh
|
|
// port, and every holder of this client — the LLM router, the replier, the
|
|
// mail extractor — must follow without being rebuilt. One mutexed field is
|
|
// the whole mechanism; a swap re-points the client, it does not replace it.
|
|
mu sync.RWMutex
|
|
base string
|
|
http *http.Client
|
|
|
|
// gate / background — priority on the single llama-server slot. Set once
|
|
// at wiring time (SetGate), read on every request. nil gate ⇒ no gating,
|
|
// which is what every test and every non-daemon caller gets.
|
|
gate *Gate
|
|
background bool
|
|
}
|
|
|
|
// SetGate gives this client a priority on the shared llama-server slot. Call it
|
|
// immediately after New, before the client is handed to anything: the fields are
|
|
// read under the same lock as base, but the intent is one-time wiring, not a
|
|
// knob to turn at runtime.
|
|
//
|
|
// background = false means "he is waiting for this" and never blocks.
|
|
// background = true means the request yields to voice turns and runs one at a
|
|
// time. See Gate.
|
|
func (c *Client) SetGate(g *Gate, background bool) {
|
|
c.mu.Lock()
|
|
c.gate, c.background = g, background
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
func (c *Client) gateFor() (*Gate, bool) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.gate, c.background
|
|
}
|
|
|
|
func New(baseURL string, timeout time.Duration) *Client {
|
|
return &Client{base: baseURL, http: &http.Client{Timeout: timeout}}
|
|
}
|
|
|
|
// SetBaseURL re-points the client at another llama-server. Safe to call while
|
|
// requests are in flight: a request that already read the old base finishes
|
|
// against the old base (or fails, and every caller of Complete has a fallback),
|
|
// and the next one uses the new base. It is deliberately NOT a queue-and-retry —
|
|
// the phraser quiesces around a swap, so the window is small and a lost turn
|
|
// degrades to the classifier rather than hanging.
|
|
func (c *Client) SetBaseURL(base string) {
|
|
c.mu.Lock()
|
|
c.base = base
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// BaseURL is the server this client currently talks to.
|
|
func (c *Client) BaseURL() string {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.base
|
|
}
|
|
|
|
type Req struct {
|
|
System string
|
|
User string
|
|
Grammar string // GBNF; empty ⇒ unconstrained
|
|
MaxTokens int
|
|
// RepeatPenalty > 0 ⇒ penalize token repetition (curbs the sub-1B "тоже
|
|
// тоже тоже" loop). 0 ⇒ server default (no extra penalty).
|
|
RepeatPenalty float64
|
|
// Stop — sequences that end generation early (e.g. newline for a one-liner).
|
|
Stop []string
|
|
}
|
|
|
|
type msg struct {
|
|
Role string `json:"role"`
|
|
Content string `json:"content"`
|
|
Reasoning string `json:"reasoning,omitempty"`
|
|
ReasoningContent string `json:"reasoning_content,omitempty"`
|
|
}
|
|
type body struct {
|
|
Messages []msg `json:"messages"`
|
|
MaxTokens int `json:"max_tokens,omitempty"`
|
|
Grammar string `json:"grammar,omitempty"`
|
|
Temp float64 `json:"temperature"`
|
|
RepeatPenalty float64 `json:"repeat_penalty,omitempty"`
|
|
Stop []string `json:"stop,omitempty"`
|
|
}
|
|
type resp struct {
|
|
Choices []struct {
|
|
Message msg `json:"message"`
|
|
} `json:"choices"`
|
|
}
|
|
|
|
func (c *Client) Complete(ctx context.Context, r Req) (string, error) {
|
|
if g, background := c.gateFor(); g != nil {
|
|
if background {
|
|
release, err := g.AcquireBackground(ctx)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer release()
|
|
} else {
|
|
defer g.Foreground()()
|
|
}
|
|
}
|
|
b, _ := json.Marshal(body{
|
|
Messages: []msg{{Role: "system", Content: r.System}, {Role: "user", Content: r.User}},
|
|
MaxTokens: r.MaxTokens,
|
|
Grammar: r.Grammar,
|
|
Temp: 0,
|
|
RepeatPenalty: r.RepeatPenalty,
|
|
Stop: r.Stop,
|
|
})
|
|
req, err := http.NewRequestWithContext(ctx, "POST", c.BaseURL()+"/v1/chat/completions", bytes.NewReader(b))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
httpResp, err := c.http.Do(req)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
defer httpResp.Body.Close()
|
|
if httpResp.StatusCode != 200 {
|
|
return "", fmt.Errorf("llm: status %d", httpResp.StatusCode)
|
|
}
|
|
var out resp
|
|
if err := json.NewDecoder(httpResp.Body).Decode(&out); err != nil {
|
|
return "", err
|
|
}
|
|
if len(out.Choices) == 0 {
|
|
return "", fmt.Errorf("llm: no choices")
|
|
}
|
|
content := out.Choices[0].Message.Content
|
|
if content == "" {
|
|
content = out.Choices[0].Message.ReasoningContent
|
|
}
|
|
return content, nil
|
|
}
|