llm: route every LLM caller through a shared backend client

Replaces the per-plugin Ollama HTTP calls with internal/llm, which picks a
backend from the environment (vLLM or Ollama) behind one Chat interface, plus
internal/plugin/llm_client.go as the plugin-facing wrapper.

Startup now logs llm_backend/llm_endpoint/llm_model instead of the two
OLLAMA_* vars, which no longer describe where inference actually goes.

These files were already running in prod from the vLLM migration but had never
been committed; this is that live state, byte-for-byte.
This commit is contained in:
prosolis
2026-07-26 10:16:33 -07:00
parent 3f9c338e67
commit 583616f9d0
17 changed files with 603 additions and 434 deletions
+161
View File
@@ -0,0 +1,161 @@
// Package llm wraps the local inference endpoint behind a backend-agnostic
// interface. Two concrete backends — Ollama (native /api/generate) and vLLM
// (OpenAI-compatible /v1/chat/completions) — implement Client; plugin code
// calls the interface only and never knows which one is active.
//
// Deliberately not routed through internal/safehttp: that client blocks
// RFC1918 and loopback destinations to defend against SSRF from feed-supplied
// URLs, and the inference endpoint is precisely such a destination. The URL
// here comes from our own config, never from user input.
package llm
import (
"context"
"os"
"strings"
"time"
)
// Request is the backend-neutral generation request. Zero-valued fields fall
// back to backend defaults.
type Request struct {
// Prompt is a single raw instruction. The vLLM backend wraps it as one
// user message so the model's chat template still applies; sending it to
// /v1/completions instead would bypass the template and degrade an
// instruction-tuned model badly.
Prompt string
// System is an optional system message. Empty means none, which keeps the
// single-message shape the majority of callers use.
System string
// NumCtx is the per-request context window. Ollama honours it directly;
// vLLM fixes the window server-side at launch (--max-model-len), so this
// is ignored there rather than silently misapplied.
NumCtx int
// MaxTokens caps the completion length. 0 means the backend default.
MaxTokens int
// Temperature is passed through when non-zero.
Temperature float64
// Timeout overrides the client's default per-request budget.
Timeout time.Duration
}
// Client is the single surface plugin code depends on.
type Client interface {
// Generate returns the full completion in one shot. Reasoning blocks are
// stripped before returning (see StripThink) — every caller in this repo
// wants the visible answer, not the chain of thought.
Generate(ctx context.Context, req Request) (string, error)
// Model reports the configured model id, for logging and /botinfo.
Model() string
// Ping reports the model ids the backend is currently serving. Used by
// /botinfo for a liveness line; the two backends expose this on different
// paths (/api/tags vs /v1/models), which is exactly the sort of difference
// this interface exists to hide.
Ping(ctx context.Context) ([]string, error)
// Backend reports "ollama" or "vllm", for logging and /botinfo.
Backend() string
}
// Config selects and configures a backend.
type Config struct {
Backend string // "ollama" | "vllm"
Endpoint string
Model string
Timeout time.Duration
}
// DefaultTimeout matches the budget the pre-refactor callOllama used.
const DefaultTimeout = 120 * time.Second
// ConfigFromEnv reads backend settings, preferring the new LLM_* names and
// falling back to the legacy OLLAMA_* pair so an existing deployment keeps
// working untouched after this refactor.
func ConfigFromEnv() Config {
backend := strings.ToLower(strings.TrimSpace(os.Getenv("LLM_BACKEND")))
if backend == "" {
backend = "ollama"
}
endpoint := firstNonEmpty(os.Getenv("LLM_ENDPOINT"), os.Getenv("OLLAMA_HOST"))
model := firstNonEmpty(os.Getenv("LLM_MODEL"), os.Getenv("OLLAMA_MODEL"))
timeout := DefaultTimeout
if d, err := time.ParseDuration(os.Getenv("LLM_TIMEOUT")); err == nil && d > 0 {
timeout = d
}
return Config{Backend: backend, Endpoint: endpoint, Model: model, Timeout: timeout}
}
// Configured reports whether enough config is present to talk to a backend.
// Plugins check this to stay dormant rather than erroring on every invocation,
// which is what the old `if ollamaHost == "" || ollamaModel == ""` guards did.
func (c Config) Configured() bool {
return c.Endpoint != "" && c.Model != ""
}
// New builds the client for cfg.Backend. An unrecognised backend falls back to
// Ollama, which is what every existing deployment runs.
func New(cfg Config) Client {
if cfg.Timeout <= 0 {
cfg.Timeout = DefaultTimeout
}
base := backend{
endpoint: strings.TrimRight(cfg.Endpoint, "/"),
model: cfg.Model,
timeout: cfg.Timeout,
}
switch cfg.Backend {
case "vllm":
return &VLLMClient{base}
default:
return &OllamaClient{base}
}
}
// backend holds the fields shared by both concrete clients.
type backend struct {
endpoint string
model string
timeout time.Duration
}
func (b backend) Model() string { return b.model }
// timeoutFor lets a single call widen or narrow the client default. The two
// dispatch-voice callers rely on this: a dispatch is authored on a game
// chokepoint and must not stall it, while a run summary rides a background
// ticker and can afford a bigger model.
func (b backend) timeoutFor(req Request) time.Duration {
if req.Timeout > 0 {
return req.Timeout
}
return b.timeout
}
// StripThink removes a leading <think>...</think> reasoning block, which Qwen
// models emit even when thinking is disabled by some backends. Callers that
// parse JSON out of the completion depend on this running first.
func StripThink(s string) string {
for {
i := strings.Index(s, "<think>")
if i < 0 {
break
}
j := strings.Index(s, "</think>")
if j < 0 || j < i {
break
}
s = s[:i] + s[j+len("</think>"):]
}
return strings.TrimSpace(s)
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v = strings.TrimSpace(v); v != "" {
return v
}
}
return ""
}
+103
View File
@@ -0,0 +1,103 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
// OllamaClient talks to Ollama's native /api/generate endpoint.
type OllamaClient struct {
backend
}
func (c *OllamaClient) Backend() string { return "ollama" }
type ollamaOptions struct {
NumCtx int `json:"num_ctx,omitempty"`
NumPredict int `json:"num_predict,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
}
type ollamaRequest struct {
Model string `json:"model"`
Prompt string `json:"prompt"`
System string `json:"system,omitempty"`
Stream bool `json:"stream"`
Think bool `json:"think"`
Options ollamaOptions `json:"options,omitempty"`
}
// Generate posts a single non-streaming generation and returns the completion.
func (c *OllamaClient) Generate(ctx context.Context, req Request) (string, error) {
ctx, cancel := context.WithTimeout(ctx, c.timeoutFor(req))
defer cancel()
body, err := json.Marshal(ollamaRequest{
Model: c.model,
Prompt: req.Prompt,
System: req.System,
Stream: false,
Think: false,
Options: ollamaOptions{
NumCtx: req.NumCtx,
NumPredict: req.MaxTokens,
Temperature: req.Temperature,
},
})
if err != nil {
return "", fmt.Errorf("ollama: marshal payload: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.endpoint+"/api/generate", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("ollama: build request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return "", fmt.Errorf("ollama request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("ollama: read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
Response string `json:"response"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("ollama: parse response: %w", err)
}
return StripThink(result.Response), nil
}
// Ping lists locally installed models via Ollama's native /api/tags.
func (c *OllamaClient) Ping(ctx context.Context) ([]string, error) {
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
defer cancel()
var out struct {
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
if err := getJSON(ctx, c.endpoint+"/api/tags", &out); err != nil {
return nil, err
}
names := make([]string, 0, len(out.Models))
for _, m := range out.Models {
names = append(names, m.Name)
}
return names, nil
}
+29
View File
@@ -0,0 +1,29 @@
package llm
import (
"context"
"encoding/json"
"fmt"
"net/http"
"time"
)
// pingTimeout keeps a liveness probe short — /botinfo renders synchronously and
// a hung endpoint must not hold the reply.
const pingTimeout = 5 * time.Second
func getJSON(ctx context.Context, url string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("HTTP %d", resp.StatusCode)
}
return json.NewDecoder(resp.Body).Decode(out)
}
+116
View File
@@ -0,0 +1,116 @@
package llm
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
// VLLMClient talks to an OpenAI-compatible /v1/chat/completions endpoint.
type VLLMClient struct {
backend
}
func (c *VLLMClient) Backend() string { return "vllm" }
type vllmMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type vllmRequest struct {
Model string `json:"model"`
Messages []vllmMessage `json:"messages"`
MaxTokens int `json:"max_tokens,omitempty"`
Temperature float64 `json:"temperature,omitempty"`
Stream bool `json:"stream"`
// ChatTemplateKwargs is a vLLM extension to the OpenAI schema. It is how
// Qwen3-family reasoning is switched off; the Ollama backend spells the
// same intent as its native "think": false.
ChatTemplateKwargs map[string]any `json:"chat_template_kwargs,omitempty"`
}
// Generate posts a single non-streaming completion and returns the message
// content. The raw prompt is sent as one user message so the server-side chat
// template still wraps it.
func (c *VLLMClient) Generate(ctx context.Context, req Request) (string, error) {
ctx, cancel := context.WithTimeout(ctx, c.timeoutFor(req))
defer cancel()
msgs := make([]vllmMessage, 0, 2)
if req.System != "" {
msgs = append(msgs, vllmMessage{Role: "system", Content: req.System})
}
msgs = append(msgs, vllmMessage{Role: "user", Content: req.Prompt})
body, err := json.Marshal(vllmRequest{
Model: c.model,
Messages: msgs,
MaxTokens: req.MaxTokens,
Temperature: req.Temperature,
Stream: false,
ChatTemplateKwargs: map[string]any{"enable_thinking": false},
})
if err != nil {
return "", fmt.Errorf("vllm: marshal payload: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost,
c.endpoint+"/v1/chat/completions", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("vllm: build request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return "", fmt.Errorf("vllm request: %w", err)
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("vllm: read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("vllm HTTP %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return "", fmt.Errorf("vllm: parse response: %w", err)
}
if len(result.Choices) == 0 {
return "", fmt.Errorf("vllm: empty choices in response")
}
return StripThink(result.Choices[0].Message.Content), nil
}
// Ping lists served models via the OpenAI-compatible /v1/models.
func (c *VLLMClient) Ping(ctx context.Context) ([]string, error) {
ctx, cancel := context.WithTimeout(ctx, pingTimeout)
defer cancel()
var out struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
if err := getJSON(ctx, c.endpoint+"/v1/models", &out); err != nil {
return nil, err
}
names := make([]string, 0, len(out.Data))
for _, m := range out.Data {
names = append(names, m.ID)
}
return names, nil
}
+12 -43
View File
@@ -1,12 +1,9 @@
package plugin package plugin
import ( import (
"encoding/json" "context"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http"
"os"
"strings" "strings"
"sync/atomic" "sync/atomic"
"time" "time"
@@ -121,10 +118,8 @@ func (p *BotInfoPlugin) handleBotInfo(ctx MessageContext) error {
sb.WriteString(fmt.Sprintf("Active reminders: %d\n", activeReminders)) sb.WriteString(fmt.Sprintf("Active reminders: %d\n", activeReminders))
// LLM status // LLM status
ollamaHost := os.Getenv("OLLAMA_HOST") if llmConfigured() {
if ollamaHost != "" { sb.WriteString(fmt.Sprintf("LLM status: %s\n", p.checkLLMStatus()))
llmStatus := p.checkLLMStatus(ollamaHost)
sb.WriteString(fmt.Sprintf("LLM status: %s\n", llmStatus))
} else { } else {
sb.WriteString("LLM status: not configured\n") sb.WriteString("LLM status: not configured\n")
} }
@@ -159,42 +154,16 @@ func (p *BotInfoPlugin) handleBotInfo(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String()) return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
} }
func (p *BotInfoPlugin) checkLLMStatus(ollamaHost string) string { // checkLLMStatus reports backend liveness for /botinfo. The endpoint it probes
client := &http.Client{Timeout: 5 * time.Second} // differs per backend, which the llm package hides behind Ping.
apiURL := strings.TrimRight(ollamaHost, "/") + "/api/tags" func (p *BotInfoPlugin) checkLLMStatus() string {
c := llmClient()
resp, err := client.Get(apiURL) models, err := c.Ping(context.Background())
if err != nil { if err != nil {
return fmt.Sprintf("offline (%s)", err.Error()) return fmt.Sprintf("offline (%s: %s)", c.Backend(), err.Error())
} }
defer resp.Body.Close() if len(models) == 0 {
return fmt.Sprintf("online (%s, no models loaded)", c.Backend())
if resp.StatusCode != 200 {
return fmt.Sprintf("error (HTTP %d)", resp.StatusCode)
} }
return fmt.Sprintf("online (%s, %d models: %s)", c.Backend(), len(models), strings.Join(models, ", "))
body, err := io.ReadAll(resp.Body)
if err != nil {
return "online (could not read response)"
}
var result struct {
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "online (could not parse response)"
}
modelNames := make([]string, 0, len(result.Models))
for _, m := range result.Models {
modelNames = append(modelNames, m.Name)
}
if len(modelNames) == 0 {
return "online (no models loaded)"
}
return fmt.Sprintf("online (%d models: %s)", len(modelNames), strings.Join(modelNames, ", "))
} }
+2 -4
View File
@@ -856,9 +856,7 @@ func (p *HangmanPlugin) handleSubmit(ctx MessageContext, phrase string) error {
} }
// LLM screening // LLM screening
ollamaHost := os.Getenv("OLLAMA_HOST") if !llmConfigured() {
ollamaModel := os.Getenv("OLLAMA_MODEL")
if ollamaHost == "" || ollamaModel == "" {
// No LLM available — add directly // No LLM available — add directly
if err := p.addPhrase(phrase); err != nil { if err := p.addPhrase(phrase); err != nil {
if err.Error() == "duplicate phrase" { if err.Error() == "duplicate phrase" {
@@ -879,7 +877,7 @@ or
Phrase: %s`, phrase) Phrase: %s`, phrase)
result, err := callOllama(ollamaHost, ollamaModel, prompt) result, err := callLLM(prompt)
if err != nil { if err != nil {
slog.Error("hangman: LLM screening failed", "err", err) slog.Error("hangman: LLM screening failed", "err", err)
// Fail open — add it // Fail open — add it
+16 -40
View File
@@ -1,24 +1,24 @@
package plugin package plugin
import ( import (
"bytes" "context"
"encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http"
"os"
"regexp" "regexp"
"strings" "strings"
"time" "time"
"gogobee/internal/db" "gogobee/internal/db"
"gogobee/internal/llm"
"github.com/chehsunliu/poker" "github.com/chehsunliu/poker"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
) )
var holdemTipsClient = &http.Client{Timeout: 60 * time.Second} // holdemTipTimeout preserves the 60s budget the tip rewriter's own http.Client
// enforced. Tips are delivered as private messages during a hand, so this sits
// between the passive 30s paths and the interactive 120s default.
const holdemTipTimeout = 60 * time.Second
// loadTipsPref loads a user's tip preference from the database. // loadTipsPref loads a user's tip preference from the database.
func loadTipsPref(userID id.UserID) bool { func loadTipsPref(userID id.UserID) bool {
@@ -491,11 +491,8 @@ func cardSuitIndex(c poker.Card) int {
func generateTip(ctx holdemTipContext) string { func generateTip(ctx holdemTipContext) string {
base := generateRulesTip(ctx) base := generateRulesTip(ctx)
host := os.Getenv("OLLAMA_HOST") if llmConfigured() {
model := os.Getenv("OLLAMA_MODEL") rewritten, err := rewriteTipWithLLM(ctx, base)
if host != "" && model != "" {
rewritten, err := rewriteTipWithLLM(host, model, ctx, base)
if err != nil { if err != nil {
slog.Warn("holdem: LLM tip rewrite failed, using rules tip", "err", err) slog.Warn("holdem: LLM tip rewrite failed, using rules tip", "err", err)
} else if rewritten != "" { } else if rewritten != "" {
@@ -626,40 +623,19 @@ func buildTipUserPrompt(ctx holdemTipContext) string {
// variety. The rules tip is the source of truth — if the rewrite diverges // variety. The rules tip is the source of truth — if the rewrite diverges
// (empty, action vocabulary changed, etc.) we reject it and the caller falls // (empty, action vocabulary changed, etc.) we reject it and the caller falls
// back to the original. // back to the original.
func rewriteTipWithLLM(host, model string, ctx holdemTipContext, base string) (string, error) { func rewriteTipWithLLM(ctx holdemTipContext, base string) (string, error) {
userMsg := buildTipUserPrompt(ctx) + "\nTIP:\n" + base + "\n" userMsg := buildTipUserPrompt(ctx) + "\nTIP:\n" + base + "\n"
req := ollamaChatRequest{
Model: model,
Messages: []chatMessage{
{Role: "system", Content: buildTipSystemPrompt()},
{Role: "user", Content: userMsg},
},
Stream: false,
}
body, err := json.Marshal(req) raw, err := llmGenerate(context.Background(), llm.Request{
System: buildTipSystemPrompt(),
Prompt: userMsg,
Timeout: holdemTipTimeout,
})
if err != nil { if err != nil {
return "", fmt.Errorf("marshal: %w", err) return "", err
} }
url := strings.TrimRight(host, "/") + "/api/chat" tip := extractTipFromResponse(raw)
resp, err := holdemTipsClient.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
}
var ollamaResp ollamaChatResponse
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
return "", fmt.Errorf("decode: %w", err)
}
tip := extractTipFromResponse(ollamaResp.Message.Content)
if tip == "" { if tip == "" {
return "", fmt.Errorf("empty response") return "", fmt.Errorf("empty response")
} }
+14 -61
View File
@@ -1,18 +1,15 @@
package plugin package plugin
import ( import (
"bytes" "context"
"database/sql" "database/sql"
"encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http"
"os" "os"
"strings" "strings"
"time"
"gogobee/internal/db" "gogobee/internal/db"
"gogobee/internal/llm"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@@ -47,9 +44,7 @@ func (p *HowAmIPlugin) OnMessage(ctx MessageContext) error {
return nil return nil
} }
ollamaHost := os.Getenv("OLLAMA_HOST") if !llmConfigured() {
ollamaModel := os.Getenv("OLLAMA_MODEL")
if ollamaHost == "" || ollamaModel == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.") return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
} }
@@ -84,9 +79,9 @@ Write the roast now. Do not include any preamble or explanation, just the roast
botName, string(target), profile, botName, string(target), profile,
) )
response, err := callOllama(ollamaHost, ollamaModel, prompt) response, err := callLLM(prompt)
if err != nil { if err != nil {
slog.Error("howami: ollama call", "err", err) slog.Error("howami: llm call", "err", err)
p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't generate the profile. Thanks, Ollama.") p.SendReply(ctx.RoomID, ctx.EventID, "Couldn't generate the profile. Thanks, Ollama.")
return return
} }
@@ -181,55 +176,13 @@ func (p *HowAmIPlugin) gatherProfile(userID id.UserID) string {
return sb.String() return sb.String()
} }
// callOllama sends a prompt to the Ollama generate endpoint and returns the response. // callLLM sends a prompt to whichever backend is configured and returns the
func callOllama(host, model, prompt string) (string, error) { // completion. Reasoning blocks are stripped by the client. The 8192 context
apiURL := strings.TrimRight(host, "/") + "/api/generate" // hint is what this path has always asked Ollama for; vLLM ignores it and uses
// the window fixed at server launch.
payload := map[string]interface{}{ func callLLM(prompt string) (string, error) {
"model": model, return llmGenerate(context.Background(), llm.Request{
"prompt": prompt, Prompt: prompt,
"stream": false, NumCtx: 8192,
"think": false, })
"options": map[string]interface{}{
"num_ctx": 8192,
},
}
data, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal payload: %w", err)
}
client := &http.Client{Timeout: 120 * time.Second}
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
if err != nil {
return "", fmt.Errorf("ollama request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != 200 {
return "", fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, string(body))
}
var result struct {
Response string `json:"response"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
response := result.Response
// Strip <think>...</think> blocks (Qwen 3.5 reasoning)
if i := strings.Index(response, "<think>"); i != -1 {
if j := strings.Index(response, "</think>"); j != -1 {
response = response[:i] + response[j+len("</think>"):]
}
}
return strings.TrimSpace(response), nil
} }
+45
View File
@@ -0,0 +1,45 @@
package plugin
import (
"context"
"sync"
"gogobee/internal/llm"
)
// The inference backend is process-wide: one endpoint, one model, selected by
// env at startup. Plugins share a single client rather than each rebuilding one
// per invocation, and read config through llmConfigured/llmGenerate so that
// swapping Ollama for vLLM is a config change rather than a code change.
var (
llmOnce sync.Once
llmShared llm.Client
llmCfg llm.Config
)
func llmInit() {
llmOnce.Do(func() {
llmCfg = llm.ConfigFromEnv()
llmShared = llm.New(llmCfg)
})
}
// llmConfigured reports whether an endpoint and model are set. Plugins call
// this to stay dormant instead of erroring on every invocation — the same role
// the old `if ollamaHost == "" || ollamaModel == ""` guards played.
func llmConfigured() bool {
llmInit()
return llmCfg.Configured()
}
// llmClient returns the shared backend client.
func llmClient() llm.Client {
llmInit()
return llmShared
}
// llmGenerate is the one-line path for the common case: a raw prompt in,
// visible completion out, reasoning blocks already stripped.
func llmGenerate(ctx context.Context, req llm.Request) (string, error) {
return llmClient().Generate(ctx, req)
}
+40 -72
View File
@@ -1,13 +1,11 @@
package plugin package plugin
import ( import (
"bytes" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"math/rand" "math/rand"
"net/http"
"os" "os"
"regexp" "regexp"
"strconv" "strconv"
@@ -18,6 +16,7 @@ import (
"gogobee/internal/db" "gogobee/internal/db"
"gogobee/internal/dreamclient" "gogobee/internal/dreamclient"
"gogobee/internal/llm"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@@ -54,29 +53,31 @@ type queueItem struct {
FormattedBody string FormattedBody string
} }
// LLMPassivePlugin classifies messages using Ollama and reacts accordingly. // classifyTimeout is the per-message budget for passive classification. It
// preserves the 30s cap the plugin's own http.Client used to enforce, which is
// deliberately tighter than the interactive default: classification runs on
// sampled traffic and must never back up the queue.
const classifyTimeout = 30 * time.Second
// LLMPassivePlugin classifies messages using the configured LLM backend and
// reacts accordingly.
type LLMPassivePlugin struct { type LLMPassivePlugin struct {
Base Base
xp *XPPlugin xp *XPPlugin
dict *dreamclient.Client dict *dreamclient.Client
ollamaHost string sampleRate float64
ollamaModel string enabled bool
sampleRate float64
enabled bool
mu sync.Mutex mu sync.Mutex
queue []queueItem queue []queueItem
backoff time.Duration backoff time.Duration
httpClient *http.Client stopCh chan struct{}
stopCh chan struct{}
} }
// NewLLMPassivePlugin creates a new LLM passive classification plugin. // NewLLMPassivePlugin creates a new LLM passive classification plugin.
func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin, dict *dreamclient.Client) *LLMPassivePlugin { func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin, dict *dreamclient.Client) *LLMPassivePlugin {
host := os.Getenv("OLLAMA_HOST") enabled := llmConfigured()
model := os.Getenv("OLLAMA_MODEL")
enabled := host != "" && model != ""
sampleRate := 0.15 sampleRate := 0.15
if v := os.Getenv("LLM_SAMPLE_RATE"); v != "" { if v := os.Getenv("LLM_SAMPLE_RATE"); v != "" {
@@ -86,16 +87,13 @@ func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin, dict *dreamclient
} }
p := &LLMPassivePlugin{ p := &LLMPassivePlugin{
Base: NewBase(client), Base: NewBase(client),
xp: xp, xp: xp,
dict: dict, dict: dict,
ollamaHost: host, sampleRate: sampleRate,
ollamaModel: model, enabled: enabled,
sampleRate: sampleRate, backoff: 5 * time.Second,
enabled: enabled, stopCh: make(chan struct{}),
backoff: 5 * time.Second,
httpClient: &http.Client{Timeout: 30 * time.Second},
stopCh: make(chan struct{}),
} }
return p return p
@@ -116,11 +114,11 @@ func (p *LLMPassivePlugin) Commands() []CommandDef {
func (p *LLMPassivePlugin) Init() error { func (p *LLMPassivePlugin) Init() error {
if p.enabled { if p.enabled {
slog.Info("llm_passive: enabled", "host", p.ollamaHost, "model", p.ollamaModel, "sample_rate", p.sampleRate) slog.Info("llm_passive: enabled", "backend", llmClient().Backend(),
"model", llmClient().Model(), "sample_rate", p.sampleRate)
go p.processQueue() go p.processQueue()
} else { } else {
slog.Warn("llm_passive: disabled (OLLAMA_HOST or OLLAMA_MODEL not set)", slog.Warn("llm_passive: disabled (LLM endpoint or model not set)")
"host", p.ollamaHost, "model", p.ollamaModel)
} }
return nil return nil
} }
@@ -329,9 +327,9 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
var todayWOTD string var todayWOTD string
db.Get().QueryRow(`SELECT word FROM wotd_log WHERE date = ?`, today).Scan(&todayWOTD) db.Get().QueryRow(`SELECT word FROM wotd_log WHERE date = ?`, today).Scan(&todayWOTD)
result, err := p.callOllama(item.Body+mentionHint, todayWOTD) result, err := p.classify(item.Body+mentionHint, todayWOTD)
if err != nil { if err != nil {
return fmt.Errorf("ollama call: %w", err) return fmt.Errorf("llm call: %w", err)
} }
// Resolve any display names in LLM targets back to MXIDs // Resolve any display names in LLM targets back to MXIDs
@@ -464,21 +462,9 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
return nil return nil
} }
// ollamaRequest is the request body for the Ollama API. // classify sends a classification prompt to the configured backend and parses
type ollamaRequest struct { // the JSON result.
Model string `json:"model"` func (p *LLMPassivePlugin) classify(messageText, wotd string) (*classificationResult, error) {
Prompt string `json:"prompt"`
Stream bool `json:"stream"`
Think bool `json:"think"`
}
// ollamaResponse is the response from the Ollama API.
type ollamaResponse struct {
Response string `json:"response"`
}
// callOllama sends a classification prompt to Ollama and parses the JSON result.
func (p *LLMPassivePlugin) callOllama(messageText, wotd string) (*classificationResult, error) {
wotdInstruction := `"wotd_used": false` wotdInstruction := `"wotd_used": false`
if wotd != "" { if wotd != "" {
wotdInstruction = fmt.Sprintf(`"wotd_used": true | false (whether the message uses the word "%s" correctly and meaningfully — not just mentioning or quoting it)`, wotd) wotdInstruction = fmt.Sprintf(`"wotd_used": true | false (whether the message uses the word "%s" correctly and meaningfully — not just mentioning or quoting it)`, wotd)
@@ -500,36 +486,18 @@ JSON schema:
Message: %s`, wotdInstruction, messageText) Message: %s`, wotdInstruction, messageText)
reqBody := ollamaRequest{ slog.Debug("llm_passive: calling backend", "backend", llmClient().Backend(), "model", llmClient().Model())
Model: p.ollamaModel, // Classification rides the passive path on every sampled message, so it keeps
Prompt: prompt, // the tighter budget it always had rather than the interactive default.
Stream: false, raw, err := llmGenerate(context.Background(), llm.Request{
} Prompt: prompt,
Timeout: classifyTimeout,
body, err := json.Marshal(reqBody) })
if err != nil { if err != nil {
return nil, fmt.Errorf("marshal request: %w", err) return nil, err
} }
url := strings.TrimRight(p.ollamaHost, "/") + "/api/generate" result, err := parseClassification(raw)
slog.Debug("llm_passive: calling ollama", "url", url, "model", p.ollamaModel)
resp, err := p.httpClient.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("ollama request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("ollama status %d: %s", resp.StatusCode, string(respBody))
}
var ollamaResp ollamaResponse
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
return nil, fmt.Errorf("decode ollama response: %w", err)
}
result, err := parseClassification(ollamaResp.Response)
if err != nil { if err != nil {
return nil, fmt.Errorf("parse classification: %w", err) return nil, fmt.Errorf("parse classification: %w", err)
} }
+18 -61
View File
@@ -1,10 +1,9 @@
package plugin package plugin
import ( import (
"bytes" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"math" "math"
"net/http" "net/http"
@@ -15,6 +14,7 @@ import (
"time" "time"
"gogobee/internal/db" "gogobee/internal/db"
"gogobee/internal/llm"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@@ -311,9 +311,7 @@ Do not use em dashes. Do not use exclamation marks. Do not offer financial advic
If markets are closed or data is stale, note it briefly and move on.` If markets are closed or data is stale, note it briefly and move on.`
func (p *MarketPlugin) generateDailySummary(date string) string { func (p *MarketPlugin) generateDailySummary(date string) string {
host := os.Getenv("OLLAMA_HOST") if !llmConfigured() {
model := os.Getenv("OLLAMA_MODEL")
if host == "" || model == "" {
return "" return ""
} }
@@ -349,7 +347,7 @@ func (p *MarketPlugin) generateDailySummary(date string) string {
} }
prompt.WriteString("\nWrite a 2-3 sentence summary.") prompt.WriteString("\nWrite a 2-3 sentence summary.")
result, err := p.callOllamaChat(host, model, marketSystemPrompt, prompt.String()) result, err := p.chatLLM(marketSystemPrompt, prompt.String())
if err != nil { if err != nil {
slog.Error("market: ollama summary failed", "err", err) slog.Error("market: ollama summary failed", "err", err)
return "" return ""
@@ -358,9 +356,7 @@ func (p *MarketPlugin) generateDailySummary(date string) string {
} }
func (p *MarketPlugin) generateReportSummary(snapsByDate map[string][]marketSnapshot, dateRange string) string { func (p *MarketPlugin) generateReportSummary(snapsByDate map[string][]marketSnapshot, dateRange string) string {
host := os.Getenv("OLLAMA_HOST") if !llmConfigured() {
model := os.Getenv("OLLAMA_MODEL")
if host == "" || model == "" {
return "" return ""
} }
@@ -395,7 +391,7 @@ func (p *MarketPlugin) generateReportSummary(snapsByDate map[string][]marketSnap
} }
prompt.WriteString("\nDescribe the trend in 2-3 sentences. Note any significant moves or divergences between indices.\nBe sardonic but accurate. Reference the VIX trajectory when relevant.") prompt.WriteString("\nDescribe the trend in 2-3 sentences. Note any significant moves or divergences between indices.\nBe sardonic but accurate. Reference the VIX trajectory when relevant.")
result, err := p.callOllamaChat(host, model, marketSystemPrompt, prompt.String()) result, err := p.chatLLM(marketSystemPrompt, prompt.String())
if err != nil { if err != nil {
slog.Warn("market: ollama report summary failed", "err", err) slog.Warn("market: ollama report summary failed", "err", err)
return "" return ""
@@ -403,49 +399,14 @@ func (p *MarketPlugin) generateReportSummary(snapsByDate map[string][]marketSnap
return result return result
} }
// callOllamaChat calls the Ollama /api/chat endpoint with a system and user message. // chatLLM sends a system+user pair to the configured backend. Both backends
// Uses the types already defined in holdem_tips.go (same package). // map this onto their own chat shape, so the market summaries read the same
func (p *MarketPlugin) callOllamaChat(host, model, systemPrompt, userPrompt string) (string, error) { // whichever one is serving.
req := ollamaChatRequest{ func (p *MarketPlugin) chatLLM(systemPrompt, userPrompt string) (string, error) {
Model: model, return llmGenerate(context.Background(), llm.Request{
Messages: []chatMessage{ System: systemPrompt,
{Role: "system", Content: systemPrompt}, Prompt: userPrompt,
{Role: "user", Content: userPrompt}, })
},
Stream: false,
}
body, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("marshal: %w", err)
}
url := strings.TrimRight(host, "/") + "/api/chat"
resp, err := p.httpClient.Post(url, "application/json", bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
return "", fmt.Errorf("status %d: %s", resp.StatusCode, string(respBody))
}
var ollamaResp ollamaChatResponse
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
return "", fmt.Errorf("decode: %w", err)
}
text := ollamaResp.Message.Content
// Strip <think>...</think> blocks (reasoning models)
if i := strings.Index(text, "<think>"); i != -1 {
if j := strings.Index(text, "</think>"); j != -1 {
text = text[:i] + text[j+len("</think>"):]
}
}
return strings.TrimSpace(text), nil
} }
// ── DB Helpers ─────────────────────────────────────────────────────────────── // ── DB Helpers ───────────────────────────────────────────────────────────────
@@ -916,12 +877,10 @@ func (p *MarketPlugin) handleVixReport(ctx MessageContext) error {
} }
var summary string var summary string
host := os.Getenv("OLLAMA_HOST") if llmConfigured() {
model := os.Getenv("OLLAMA_MODEL")
if host != "" && model != "" {
prompt := fmt.Sprintf("VIX (fear index) data over %d days (%s to %s):\n%s\n\nDescribe the fear/greed trajectory in 2-3 sentences. Be sardonic but accurate.", prompt := fmt.Sprintf("VIX (fear index) data over %d days (%s to %s):\n%s\n\nDescribe the fear/greed trajectory in 2-3 sentences. Be sardonic but accurate.",
len(entries), entries[0].Date, entries[len(entries)-1].Date, strings.Join(prices, ", ")) len(entries), entries[0].Date, entries[len(entries)-1].Date, strings.Join(prices, ", "))
summary, _ = p.callOllamaChat(host, model, marketSystemPrompt, prompt) summary, _ = p.chatLLM(marketSystemPrompt, prompt)
} }
var sb strings.Builder var sb strings.Builder
@@ -1020,12 +979,10 @@ func (p *MarketPlugin) handleCompare(ctx MessageContext, args []string) error {
for _, e := range entries { for _, e := range entries {
prices = append(prices, fmt.Sprintf("%.2f", e.Price)) prices = append(prices, fmt.Sprintf("%.2f", e.Price))
} }
host := os.Getenv("OLLAMA_HOST") if llmConfigured() {
model := os.Getenv("OLLAMA_MODEL")
if host != "" && model != "" {
prompt := fmt.Sprintf("%s over %d days (%s to %s):\n%s\n\nDescribe the trend in 2-3 sentences. Be sardonic but accurate.", prompt := fmt.Sprintf("%s over %d days (%s to %s):\n%s\n\nDescribe the trend in 2-3 sentences. Be sardonic but accurate.",
idx.DisplayName, len(entries), entries[0].Date, entries[len(entries)-1].Date, strings.Join(prices, ", ")) idx.DisplayName, len(entries), entries[0].Date, entries[len(entries)-1].Date, strings.Join(prices, ", "))
if summary, err := p.callOllamaChat(host, model, marketSystemPrompt, prompt); err == nil && summary != "" { if summary, err := p.chatLLM(marketSystemPrompt, prompt); err == nil && summary != "" {
sb.WriteString(summary) sb.WriteString(summary)
sb.WriteString("\n\n") sb.WriteString("\n\n")
} }
+15 -49
View File
@@ -1,16 +1,14 @@
package plugin package plugin
import ( import (
"bytes" "context"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http"
"os"
"strings" "strings"
"time" "time"
"gogobee/internal/llm"
"gogobee/internal/peteclient" "gogobee/internal/peteclient"
) )
@@ -39,22 +37,18 @@ const (
maxDispatchLede = 800 maxDispatchLede = 800
) )
var dispatchHTTP = &http.Client{Timeout: dispatchLLMTimeout}
// authorDispatch turns a fact into a headline+lede in Pete's voice, or returns // authorDispatch turns a fact into a headline+lede in Pete's voice, or returns
// two empty strings if the model is unconfigured, errors, times out, or produces // two empty strings if the model is unconfigured, errors, times out, or produces
// anything malformed. The fact must already have its FINAL Actors set (post // anything malformed. The fact must already have its FINAL Actors set (post
// opt-out anonymisation) — that list is the only set of names the prose may use, // opt-out anonymisation) — that list is the only set of names the prose may use,
// and it is what Pete's guard checks the output against. // and it is what Pete's guard checks the output against.
func authorDispatch(f peteclient.Fact) (headline, lede string) { func authorDispatch(f peteclient.Fact) (headline, lede string) {
host := os.Getenv("OLLAMA_HOST") if !llmConfigured() {
model := os.Getenv("OLLAMA_MODEL")
if host == "" || model == "" {
return "", "" return "", ""
} }
prompt := buildDispatchPrompt(f) prompt := buildDispatchPrompt(f)
raw, err := callOllamaDispatch(dispatchHTTP, host, model, prompt) raw, err := callLLMDispatch(dispatchLLMTimeout, prompt)
if err != nil { if err != nil {
slog.Warn("pete dispatch: LLM authoring failed, Pete will template", "guid", f.GUID, "err", err) slog.Warn("pete dispatch: LLM authoring failed, Pete will template", "guid", f.GUID, "err", err)
return "", "" return "", ""
@@ -128,45 +122,17 @@ The event:
%s`, names, facts.String()) %s`, names, facts.String())
} }
// callOllamaDispatch posts a single non-streaming generation and returns the raw // callLLMDispatch posts a single non-streaming generation and returns the raw
// completion (think-tags stripped). The client is a parameter because the two // completion. The timeout is a parameter because the two callers have genuinely
// callers have genuinely different patience: a dispatch is authored on a game // different patience: a dispatch is authored on a game chokepoint and must not
// chokepoint and must not stall it, while a run summary rides a background // stall it, while a run summary rides a background ticker and can afford to wait
// ticker and can afford to wait for a bigger model. See runSummaryHTTP. // for a bigger model. See runSummaryTimeout.
func callOllamaDispatch(client *http.Client, host, model, prompt string) (string, error) { func callLLMDispatch(timeout time.Duration, prompt string) (string, error) {
payload := map[string]interface{}{ return llmGenerate(context.Background(), llm.Request{
"model": model, Prompt: prompt,
"prompt": prompt, NumCtx: 4096,
"stream": false, Timeout: timeout,
"think": false, })
"options": map[string]interface{}{
"num_ctx": 4096,
},
}
data, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal payload: %w", err)
}
apiURL := strings.TrimRight(host, "/") + "/api/generate"
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
if err != nil {
return "", fmt.Errorf("ollama request: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("read response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, string(body))
}
var result struct {
Response string `json:"response"`
}
if err := json.Unmarshal(body, &result); err != nil {
return "", fmt.Errorf("parse response: %w", err)
}
return result.Response, nil
} }
// parseDispatch pulls {headline, lede} out of the model's completion, tolerating // parseDispatch pulls {headline, lede} out of the model's completion, tolerating
+2 -7
View File
@@ -4,8 +4,6 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"net/http"
"os"
"sort" "sort"
"strings" "strings"
"sync/atomic" "sync/atomic"
@@ -70,8 +68,6 @@ const maxRunSummary = 1200
// for load-then-generate, and a timeout here really does mean the box is down. // for load-then-generate, and a timeout here really does mean the box is down.
const runSummaryTimeout = 5 * time.Minute const runSummaryTimeout = 5 * time.Minute
var runSummaryHTTP = &http.Client{Timeout: runSummaryTimeout}
// runSummaryBusy is the whole concurrency story: at most one sweep in flight, // runSummaryBusy is the whole concurrency story: at most one sweep in flight,
// ever. The ticker starts one and moves on, so a cold model loading for minutes // ever. The ticker starts one and moves on, so a cold model loading for minutes
// costs the board nothing, and the ticks that fire meanwhile find the flag set // costs the board nothing, and the ticks that fire meanwhile find the flag set
@@ -105,7 +101,7 @@ func (p *AdventurePlugin) sweepRunSummaries() {
if !peteclient.Enabled() || !newsEmissionOn() { if !peteclient.Enabled() || !newsEmissionOn() {
return return
} }
if os.Getenv("OLLAMA_HOST") == "" || os.Getenv("OLLAMA_MODEL") == "" { if !llmConfigured() {
return // no model, no summary, no wasted queries asking which run needs one return // no model, no summary, no wasted queries asking which run needs one
} }
runID := nextRunNeedingSummary() runID := nextRunNeedingSummary()
@@ -174,8 +170,7 @@ func authorRunSummary(runID string) (summary, name string) {
return "", "" return "", ""
} }
raw, err := callOllamaDispatch(runSummaryHTTP, os.Getenv("OLLAMA_HOST"), os.Getenv("OLLAMA_MODEL"), raw, err := callLLMDispatch(runSummaryTimeout, buildRunSummaryPrompt(name, beats))
buildRunSummaryPrompt(name, beats))
if err != nil { if err != nil {
slog.Warn("run summary: LLM authoring failed", "run", runID, "err", err) slog.Warn("run summary: LLM authoring failed", "run", runID, "err", err)
return "", name return "", name
+4 -9
View File
@@ -4,7 +4,6 @@ import (
"fmt" "fmt"
"log/slog" "log/slog"
"math/rand" "math/rand"
"os"
"strings" "strings"
"gogobee/internal/db" "gogobee/internal/db"
@@ -116,9 +115,7 @@ func (p *TarotPlugin) handleTarot(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, "You've used up your readings for today. The cards need rest, even if you don't.") return p.SendReply(ctx.RoomID, ctx.EventID, "You've used up your readings for today. The cards need rest, even if you don't.")
} }
ollamaHost := os.Getenv("OLLAMA_HOST") if !llmConfigured() {
ollamaModel := os.Getenv("OLLAMA_MODEL")
if ollamaHost == "" || ollamaModel == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.") return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
} }
@@ -151,7 +148,7 @@ func (p *TarotPlugin) handleTarot(ctx MessageContext) error {
} }
prompt := fmt.Sprintf("%s\n%s\n\n%s\n\nCard drawn: %s%s\nGive the reading.", tarotBasePrompt, tarotSingleSuffix, tarotFewShot, card, extraLines) prompt := fmt.Sprintf("%s\n%s\n\n%s\n\nCard drawn: %s%s\nGive the reading.", tarotBasePrompt, tarotSingleSuffix, tarotFewShot, card, extraLines)
response, err := callOllama(ollamaHost, ollamaModel, prompt) response, err := callLLM(prompt)
if err != nil { if err != nil {
slog.Error("tarot: ollama call", "err", err) slog.Error("tarot: ollama call", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.") return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
@@ -166,9 +163,7 @@ func (p *TarotPlugin) handleSpread(ctx MessageContext) error {
return p.SendReply(ctx.RoomID, ctx.EventID, "You've used up your readings for today. The cards need rest, even if you don't.") return p.SendReply(ctx.RoomID, ctx.EventID, "You've used up your readings for today. The cards need rest, even if you don't.")
} }
ollamaHost := os.Getenv("OLLAMA_HOST") if !llmConfigured() {
ollamaModel := os.Getenv("OLLAMA_MODEL")
if ollamaHost == "" || ollamaModel == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.") return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
} }
@@ -202,7 +197,7 @@ func (p *TarotPlugin) handleSpread(ctx MessageContext) error {
prompt := fmt.Sprintf("%s\n%s\n\n%s\n\nCards drawn:\n- Past: %s\n- Present: %s\n- Future: %s%s\nGive the reading.", prompt := fmt.Sprintf("%s\n%s\n\n%s\n\nCards drawn:\n- Past: %s\n- Present: %s\n- Future: %s%s\nGive the reading.",
tarotBasePrompt, tarotSpreadSuffix, tarotFewShot, cards[0], cards[1], cards[2], extraLines) tarotBasePrompt, tarotSpreadSuffix, tarotFewShot, cards[0], cards[1], cards[2], extraLines)
response, err := callOllama(ollamaHost, ollamaModel, prompt) response, err := callLLM(prompt)
if err != nil { if err != nil {
slog.Error("tarot: ollama call", "err", err) slog.Error("tarot: ollama call", "err", err)
return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.") return p.SendReply(ctx.RoomID, ctx.EventID, "Tarot reader is on a union-mandated vacation and will return when morale improves.")
+4 -8
View File
@@ -120,9 +120,7 @@ func (p *VibePlugin) resetCooldown(roomID id.RoomID) {
} }
func (p *VibePlugin) handleVibe(ctx MessageContext) error { func (p *VibePlugin) handleVibe(ctx MessageContext) error {
ollamaHost := os.Getenv("OLLAMA_HOST") if !llmConfigured() {
ollamaModel := os.Getenv("OLLAMA_MODEL")
if ollamaHost == "" || ollamaModel == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.") return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
} }
@@ -154,7 +152,7 @@ Describe the room's current vibe:`, botName, transcript)
slog.Error("vibe: send thinking", "err", err) slog.Error("vibe: send thinking", "err", err)
} }
response, err := callOllama(ollamaHost, ollamaModel, prompt) response, err := callLLM(prompt)
if err != nil { if err != nil {
slog.Error("vibe: ollama call", "err", err) slog.Error("vibe: ollama call", "err", err)
p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure
@@ -165,9 +163,7 @@ Describe the room's current vibe:`, botName, transcript)
} }
func (p *VibePlugin) handleTLDR(ctx MessageContext) error { func (p *VibePlugin) handleTLDR(ctx MessageContext) error {
ollamaHost := os.Getenv("OLLAMA_HOST") if !llmConfigured() {
ollamaModel := os.Getenv("OLLAMA_MODEL")
if ollamaHost == "" || ollamaModel == "" {
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.") return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
} }
@@ -199,7 +195,7 @@ Summary:`, tldrBotName, transcript)
slog.Error("vibe: send thinking", "err", err) slog.Error("vibe: send thinking", "err", err)
} }
response, err := callOllama(ollamaHost, ollamaModel, prompt) response, err := callLLM(prompt)
if err != nil { if err != nil {
slog.Error("vibe: ollama call", "err", err) slog.Error("vibe: ollama call", "err", err)
p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure
+17 -78
View File
@@ -1,19 +1,17 @@
package plugin package plugin
import ( import (
"bytes" "context"
"database/sql" "database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http"
"os"
"strings" "strings"
"time" "time"
"gogobee/internal/db" "gogobee/internal/db"
"gogobee/internal/dreamclient" "gogobee/internal/dreamclient"
"gogobee/internal/llm"
"maunium.net/go/mautrix" "maunium.net/go/mautrix"
"maunium.net/go/mautrix/id" "maunium.net/go/mautrix/id"
@@ -533,9 +531,7 @@ func (p *WOTDPlugin) trackUsage(ctx MessageContext) {
// verifyUsage asks the LLM whether the word was used correctly in context. // verifyUsage asks the LLM whether the word was used correctly in context.
// Returns false if LLM is not configured or on any error. // Returns false if LLM is not configured or on any error.
func (p *WOTDPlugin) verifyUsage(word, message string) bool { func (p *WOTDPlugin) verifyUsage(word, message string) bool {
host := os.Getenv("OLLAMA_HOST") if !llmConfigured() {
model := os.Getenv("OLLAMA_MODEL")
if host == "" || model == "" {
return false return false
} }
@@ -545,58 +541,30 @@ The Word of the Day is "%s". Was this word used correctly and meaningfully in th
Respond with ONLY "yes" or "no".`, message, word) Respond with ONLY "yes" or "no".`, message, word)
payload := map[string]interface{}{ response, err := llmGenerate(context.Background(), llm.Request{
"model": model, Prompt: prompt,
"prompt": prompt, Timeout: wotdLLMTimeout,
"stream": false, })
"think": false,
}
data, err := json.Marshal(payload)
if err != nil {
return false
}
apiURL := strings.TrimRight(host, "/") + "/api/generate"
slog.Debug("wotd: sending LLM verification request", "url", apiURL, "word", word)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
if err != nil { if err != nil {
slog.Error("wotd: LLM verify request failed", "err", err) slog.Error("wotd: LLM verify request failed", "err", err)
return false return false
} }
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
return false
}
var result struct {
Response string `json:"response"`
}
if json.Unmarshal(body, &result) != nil {
return false
}
response := result.Response
// Strip <think>...</think> blocks (Qwen 3.5 reasoning)
if i := strings.Index(response, "<think>"); i != -1 {
if j := strings.Index(response, "</think>"); j != -1 {
response = response[:i] + response[j+len("</think>"):]
}
}
answer := strings.ToLower(strings.TrimSpace(response)) answer := strings.ToLower(strings.TrimSpace(response))
accepted := strings.HasPrefix(answer, "yes") accepted := strings.HasPrefix(answer, "yes")
slog.Debug("wotd: LLM verification", "word", word, "answer", answer, "accepted", accepted) slog.Debug("wotd: LLM verification", "word", word, "answer", answer, "accepted", accepted)
return accepted return accepted
} }
// wotdLLMTimeout preserves the 30s cap both WOTD paths enforced with their own
// http.Client. These run on message traffic, so they stay well under the
// interactive default.
const wotdLLMTimeout = 30 * time.Second
// llmTranslate asks the LLM for a brief English translation of a foreign word. // llmTranslate asks the LLM for a brief English translation of a foreign word.
// Returns empty string on failure. // Returns empty string on failure.
func (p *WOTDPlugin) llmTranslate(word, lang string) string { func (p *WOTDPlugin) llmTranslate(word, lang string) string {
host := os.Getenv("OLLAMA_HOST") if !llmConfigured() {
model := os.Getenv("OLLAMA_MODEL")
if host == "" || model == "" {
return "" return ""
} }
@@ -612,44 +580,15 @@ func (p *WOTDPlugin) llmTranslate(word, lang string) string {
`Translate the %s word "%s" into English. Reply with ONLY the English translation — one or two words, no explanation, no punctuation.`, `Translate the %s word "%s" into English. Reply with ONLY the English translation — one or two words, no explanation, no punctuation.`,
langName, word) langName, word)
payload := map[string]interface{}{ response, err := llmGenerate(context.Background(), llm.Request{
"model": model, Prompt: prompt,
"prompt": prompt, Timeout: wotdLLMTimeout,
"stream": false, })
"think": false,
}
data, err := json.Marshal(payload)
if err != nil {
return ""
}
apiURL := strings.TrimRight(host, "/") + "/api/generate"
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
if err != nil { if err != nil {
slog.Error("wotd: LLM translate request failed", "err", err) slog.Error("wotd: LLM translate request failed", "err", err)
return "" return ""
} }
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil || resp.StatusCode != http.StatusOK {
return ""
}
var result struct {
Response string `json:"response"`
}
if json.Unmarshal(body, &result) != nil {
return ""
}
response := result.Response
if i := strings.Index(response, "<think>"); i != -1 {
if j := strings.Index(response, "</think>"); j != -1 {
response = response[:i] + response[j+len("</think>"):]
}
}
translation := strings.TrimSpace(response) translation := strings.TrimSpace(response)
if translation == "" || len(translation) > 50 { if translation == "" || len(translation) > 50 {
return "" return ""
+5 -2
View File
@@ -13,6 +13,7 @@ import (
"gogobee/internal/bot" "gogobee/internal/bot"
"gogobee/internal/db" "gogobee/internal/db"
"gogobee/internal/dreamclient" "gogobee/internal/dreamclient"
"gogobee/internal/llm"
"gogobee/internal/peteclient" "gogobee/internal/peteclient"
"gogobee/internal/plugin" "gogobee/internal/plugin"
"gogobee/internal/util" "gogobee/internal/util"
@@ -37,9 +38,11 @@ func main() {
logLevel = "info" logLevel = "info"
} }
util.InitLogger(logLevel) util.InitLogger(logLevel)
llmCfg := llm.ConfigFromEnv()
slog.Info(version.Full(), "level", logLevel, slog.Info(version.Full(), "level", logLevel,
"ollama_host", os.Getenv("OLLAMA_HOST"), "llm_backend", llmCfg.Backend,
"ollama_model", os.Getenv("OLLAMA_MODEL")) "llm_endpoint", llmCfg.Endpoint,
"llm_model", llmCfg.Model)
dataDir := os.Getenv("DATA_DIR") dataDir := os.Getenv("DATA_DIR")
if dataDir == "" { if dataDir == "" {