From 583616f9d03c097868f80ba68bba55bc3ae2006f Mon Sep 17 00:00:00 2001
From: prosolis <5590409+prosolis@users.noreply.github.com>
Date: Sun, 26 Jul 2026 10:16:33 -0700
Subject: [PATCH] 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.
---
internal/llm/llm.go | 161 +++++++++++++++++++++++++
internal/llm/ollama.go | 103 ++++++++++++++++
internal/llm/ping.go | 29 +++++
internal/llm/vllm.go | 116 ++++++++++++++++++
internal/plugin/botinfo.go | 55 ++-------
internal/plugin/hangman.go | 6 +-
internal/plugin/holdem_tips.go | 56 +++------
internal/plugin/howami.go | 75 +++---------
internal/plugin/llm_client.go | 45 +++++++
internal/plugin/llm_passive.go | 112 ++++++-----------
internal/plugin/market.go | 79 +++---------
internal/plugin/pete_dispatch_voice.go | 64 +++-------
internal/plugin/pete_run_summary.go | 9 +-
internal/plugin/tarot.go | 13 +-
internal/plugin/vibe.go | 12 +-
internal/plugin/wotd.go | 95 +++------------
main.go | 7 +-
17 files changed, 603 insertions(+), 434 deletions(-)
create mode 100644 internal/llm/llm.go
create mode 100644 internal/llm/ollama.go
create mode 100644 internal/llm/ping.go
create mode 100644 internal/llm/vllm.go
create mode 100644 internal/plugin/llm_client.go
diff --git a/internal/llm/llm.go b/internal/llm/llm.go
new file mode 100644
index 0000000..412c923
--- /dev/null
+++ b/internal/llm/llm.go
@@ -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 ... 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, "")
+ if i < 0 {
+ break
+ }
+ j := strings.Index(s, "")
+ if j < 0 || j < i {
+ break
+ }
+ s = s[:i] + s[j+len(""):]
+ }
+ return strings.TrimSpace(s)
+}
+
+func firstNonEmpty(vals ...string) string {
+ for _, v := range vals {
+ if v = strings.TrimSpace(v); v != "" {
+ return v
+ }
+ }
+ return ""
+}
diff --git a/internal/llm/ollama.go b/internal/llm/ollama.go
new file mode 100644
index 0000000..53a8cad
--- /dev/null
+++ b/internal/llm/ollama.go
@@ -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
+}
diff --git a/internal/llm/ping.go b/internal/llm/ping.go
new file mode 100644
index 0000000..7e0d304
--- /dev/null
+++ b/internal/llm/ping.go
@@ -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)
+}
diff --git a/internal/llm/vllm.go b/internal/llm/vllm.go
new file mode 100644
index 0000000..ef1fa71
--- /dev/null
+++ b/internal/llm/vllm.go
@@ -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
+}
diff --git a/internal/plugin/botinfo.go b/internal/plugin/botinfo.go
index deffe34..d9666d3 100644
--- a/internal/plugin/botinfo.go
+++ b/internal/plugin/botinfo.go
@@ -1,12 +1,9 @@
package plugin
import (
- "encoding/json"
+ "context"
"fmt"
- "io"
"log/slog"
- "net/http"
- "os"
"strings"
"sync/atomic"
"time"
@@ -121,10 +118,8 @@ func (p *BotInfoPlugin) handleBotInfo(ctx MessageContext) error {
sb.WriteString(fmt.Sprintf("Active reminders: %d\n", activeReminders))
// LLM status
- ollamaHost := os.Getenv("OLLAMA_HOST")
- if ollamaHost != "" {
- llmStatus := p.checkLLMStatus(ollamaHost)
- sb.WriteString(fmt.Sprintf("LLM status: %s\n", llmStatus))
+ if llmConfigured() {
+ sb.WriteString(fmt.Sprintf("LLM status: %s\n", p.checkLLMStatus()))
} else {
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())
}
-func (p *BotInfoPlugin) checkLLMStatus(ollamaHost string) string {
- client := &http.Client{Timeout: 5 * time.Second}
- apiURL := strings.TrimRight(ollamaHost, "/") + "/api/tags"
-
- resp, err := client.Get(apiURL)
+// checkLLMStatus reports backend liveness for /botinfo. The endpoint it probes
+// differs per backend, which the llm package hides behind Ping.
+func (p *BotInfoPlugin) checkLLMStatus() string {
+ c := llmClient()
+ models, err := c.Ping(context.Background())
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 resp.StatusCode != 200 {
- return fmt.Sprintf("error (HTTP %d)", resp.StatusCode)
+ if len(models) == 0 {
+ return fmt.Sprintf("online (%s, no models loaded)", c.Backend())
}
-
- 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, ", "))
+ return fmt.Sprintf("online (%s, %d models: %s)", c.Backend(), len(models), strings.Join(models, ", "))
}
diff --git a/internal/plugin/hangman.go b/internal/plugin/hangman.go
index b82f857..016fe11 100644
--- a/internal/plugin/hangman.go
+++ b/internal/plugin/hangman.go
@@ -856,9 +856,7 @@ func (p *HangmanPlugin) handleSubmit(ctx MessageContext, phrase string) error {
}
// LLM screening
- ollamaHost := os.Getenv("OLLAMA_HOST")
- ollamaModel := os.Getenv("OLLAMA_MODEL")
- if ollamaHost == "" || ollamaModel == "" {
+ if !llmConfigured() {
// No LLM available — add directly
if err := p.addPhrase(phrase); err != nil {
if err.Error() == "duplicate phrase" {
@@ -879,7 +877,7 @@ or
Phrase: %s`, phrase)
- result, err := callOllama(ollamaHost, ollamaModel, prompt)
+ result, err := callLLM(prompt)
if err != nil {
slog.Error("hangman: LLM screening failed", "err", err)
// Fail open — add it
diff --git a/internal/plugin/holdem_tips.go b/internal/plugin/holdem_tips.go
index 62c760d..3b4fc2e 100644
--- a/internal/plugin/holdem_tips.go
+++ b/internal/plugin/holdem_tips.go
@@ -1,24 +1,24 @@
package plugin
import (
- "bytes"
- "encoding/json"
+ "context"
"fmt"
- "io"
"log/slog"
- "net/http"
- "os"
"regexp"
"strings"
"time"
"gogobee/internal/db"
+ "gogobee/internal/llm"
"github.com/chehsunliu/poker"
"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.
func loadTipsPref(userID id.UserID) bool {
@@ -491,11 +491,8 @@ func cardSuitIndex(c poker.Card) int {
func generateTip(ctx holdemTipContext) string {
base := generateRulesTip(ctx)
- host := os.Getenv("OLLAMA_HOST")
- model := os.Getenv("OLLAMA_MODEL")
-
- if host != "" && model != "" {
- rewritten, err := rewriteTipWithLLM(host, model, ctx, base)
+ if llmConfigured() {
+ rewritten, err := rewriteTipWithLLM(ctx, base)
if err != nil {
slog.Warn("holdem: LLM tip rewrite failed, using rules tip", "err", err)
} 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
// (empty, action vocabulary changed, etc.) we reject it and the caller falls
// 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"
- 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 {
- return "", fmt.Errorf("marshal: %w", err)
+ return "", err
}
- url := strings.TrimRight(host, "/") + "/api/chat"
- 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)
+ tip := extractTipFromResponse(raw)
if tip == "" {
return "", fmt.Errorf("empty response")
}
diff --git a/internal/plugin/howami.go b/internal/plugin/howami.go
index 85c14f7..31239ab 100644
--- a/internal/plugin/howami.go
+++ b/internal/plugin/howami.go
@@ -1,18 +1,15 @@
package plugin
import (
- "bytes"
+ "context"
"database/sql"
- "encoding/json"
"fmt"
- "io"
"log/slog"
- "net/http"
"os"
"strings"
- "time"
"gogobee/internal/db"
+ "gogobee/internal/llm"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
@@ -47,9 +44,7 @@ func (p *HowAmIPlugin) OnMessage(ctx MessageContext) error {
return nil
}
- ollamaHost := os.Getenv("OLLAMA_HOST")
- ollamaModel := os.Getenv("OLLAMA_MODEL")
- if ollamaHost == "" || ollamaModel == "" {
+ if !llmConfigured() {
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,
)
- response, err := callOllama(ollamaHost, ollamaModel, prompt)
+ response, err := callLLM(prompt)
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.")
return
}
@@ -181,55 +176,13 @@ func (p *HowAmIPlugin) gatherProfile(userID id.UserID) string {
return sb.String()
}
-// callOllama sends a prompt to the Ollama generate endpoint and returns the response.
-func callOllama(host, model, prompt string) (string, error) {
- apiURL := strings.TrimRight(host, "/") + "/api/generate"
-
- payload := map[string]interface{}{
- "model": model,
- "prompt": prompt,
- "stream": false,
- "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 ... blocks (Qwen 3.5 reasoning)
- if i := strings.Index(response, ""); i != -1 {
- if j := strings.Index(response, ""); j != -1 {
- response = response[:i] + response[j+len(""):]
- }
- }
-
- return strings.TrimSpace(response), nil
+// callLLM sends a prompt to whichever backend is configured and returns the
+// completion. Reasoning blocks are stripped by the client. The 8192 context
+// hint is what this path has always asked Ollama for; vLLM ignores it and uses
+// the window fixed at server launch.
+func callLLM(prompt string) (string, error) {
+ return llmGenerate(context.Background(), llm.Request{
+ Prompt: prompt,
+ NumCtx: 8192,
+ })
}
diff --git a/internal/plugin/llm_client.go b/internal/plugin/llm_client.go
new file mode 100644
index 0000000..235f9d5
--- /dev/null
+++ b/internal/plugin/llm_client.go
@@ -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)
+}
diff --git a/internal/plugin/llm_passive.go b/internal/plugin/llm_passive.go
index b0c94d3..7e52157 100644
--- a/internal/plugin/llm_passive.go
+++ b/internal/plugin/llm_passive.go
@@ -1,13 +1,11 @@
package plugin
import (
- "bytes"
+ "context"
"encoding/json"
"fmt"
- "io"
"log/slog"
"math/rand"
- "net/http"
"os"
"regexp"
"strconv"
@@ -18,6 +16,7 @@ import (
"gogobee/internal/db"
"gogobee/internal/dreamclient"
+ "gogobee/internal/llm"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/id"
@@ -54,29 +53,31 @@ type queueItem struct {
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 {
Base
- xp *XPPlugin
- dict *dreamclient.Client
- ollamaHost string
- ollamaModel string
- sampleRate float64
- enabled bool
+ xp *XPPlugin
+ dict *dreamclient.Client
+ sampleRate float64
+ enabled bool
mu sync.Mutex
queue []queueItem
backoff time.Duration
- httpClient *http.Client
- stopCh chan struct{}
+ stopCh chan struct{}
}
// NewLLMPassivePlugin creates a new LLM passive classification plugin.
func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin, dict *dreamclient.Client) *LLMPassivePlugin {
- host := os.Getenv("OLLAMA_HOST")
- model := os.Getenv("OLLAMA_MODEL")
- enabled := host != "" && model != ""
+ enabled := llmConfigured()
sampleRate := 0.15
if v := os.Getenv("LLM_SAMPLE_RATE"); v != "" {
@@ -86,16 +87,13 @@ func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin, dict *dreamclient
}
p := &LLMPassivePlugin{
- Base: NewBase(client),
- xp: xp,
- dict: dict,
- ollamaHost: host,
- ollamaModel: model,
- sampleRate: sampleRate,
- enabled: enabled,
- backoff: 5 * time.Second,
- httpClient: &http.Client{Timeout: 30 * time.Second},
- stopCh: make(chan struct{}),
+ Base: NewBase(client),
+ xp: xp,
+ dict: dict,
+ sampleRate: sampleRate,
+ enabled: enabled,
+ backoff: 5 * time.Second,
+ stopCh: make(chan struct{}),
}
return p
@@ -116,11 +114,11 @@ func (p *LLMPassivePlugin) Commands() []CommandDef {
func (p *LLMPassivePlugin) Init() error {
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()
} else {
- slog.Warn("llm_passive: disabled (OLLAMA_HOST or OLLAMA_MODEL not set)",
- "host", p.ollamaHost, "model", p.ollamaModel)
+ slog.Warn("llm_passive: disabled (LLM endpoint or model not set)")
}
return nil
}
@@ -329,9 +327,9 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
var todayWOTD string
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 {
- return fmt.Errorf("ollama call: %w", err)
+ return fmt.Errorf("llm call: %w", err)
}
// Resolve any display names in LLM targets back to MXIDs
@@ -464,21 +462,9 @@ func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
return nil
}
-// ollamaRequest is the request body for the Ollama API.
-type ollamaRequest struct {
- Model string `json:"model"`
- 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) {
+// classify sends a classification prompt to the configured backend and parses
+// the JSON result.
+func (p *LLMPassivePlugin) classify(messageText, wotd string) (*classificationResult, error) {
wotdInstruction := `"wotd_used": false`
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)
@@ -500,36 +486,18 @@ JSON schema:
Message: %s`, wotdInstruction, messageText)
- reqBody := ollamaRequest{
- Model: p.ollamaModel,
- Prompt: prompt,
- Stream: false,
- }
-
- body, err := json.Marshal(reqBody)
+ slog.Debug("llm_passive: calling backend", "backend", llmClient().Backend(), "model", llmClient().Model())
+ // Classification rides the passive path on every sampled message, so it keeps
+ // the tighter budget it always had rather than the interactive default.
+ raw, err := llmGenerate(context.Background(), llm.Request{
+ Prompt: prompt,
+ Timeout: classifyTimeout,
+ })
if err != nil {
- return nil, fmt.Errorf("marshal request: %w", err)
+ return nil, err
}
- url := strings.TrimRight(p.ollamaHost, "/") + "/api/generate"
- 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)
+ result, err := parseClassification(raw)
if err != nil {
return nil, fmt.Errorf("parse classification: %w", err)
}
diff --git a/internal/plugin/market.go b/internal/plugin/market.go
index 774a3f1..023224a 100644
--- a/internal/plugin/market.go
+++ b/internal/plugin/market.go
@@ -1,10 +1,9 @@
package plugin
import (
- "bytes"
+ "context"
"encoding/json"
"fmt"
- "io"
"log/slog"
"math"
"net/http"
@@ -15,6 +14,7 @@ import (
"time"
"gogobee/internal/db"
+ "gogobee/internal/llm"
"maunium.net/go/mautrix"
"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.`
func (p *MarketPlugin) generateDailySummary(date string) string {
- host := os.Getenv("OLLAMA_HOST")
- model := os.Getenv("OLLAMA_MODEL")
- if host == "" || model == "" {
+ if !llmConfigured() {
return ""
}
@@ -349,7 +347,7 @@ func (p *MarketPlugin) generateDailySummary(date string) string {
}
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 {
slog.Error("market: ollama summary failed", "err", err)
return ""
@@ -358,9 +356,7 @@ func (p *MarketPlugin) generateDailySummary(date string) string {
}
func (p *MarketPlugin) generateReportSummary(snapsByDate map[string][]marketSnapshot, dateRange string) string {
- host := os.Getenv("OLLAMA_HOST")
- model := os.Getenv("OLLAMA_MODEL")
- if host == "" || model == "" {
+ if !llmConfigured() {
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.")
- result, err := p.callOllamaChat(host, model, marketSystemPrompt, prompt.String())
+ result, err := p.chatLLM(marketSystemPrompt, prompt.String())
if err != nil {
slog.Warn("market: ollama report summary failed", "err", err)
return ""
@@ -403,49 +399,14 @@ func (p *MarketPlugin) generateReportSummary(snapsByDate map[string][]marketSnap
return result
}
-// callOllamaChat calls the Ollama /api/chat endpoint with a system and user message.
-// Uses the types already defined in holdem_tips.go (same package).
-func (p *MarketPlugin) callOllamaChat(host, model, systemPrompt, userPrompt string) (string, error) {
- req := ollamaChatRequest{
- Model: model,
- Messages: []chatMessage{
- {Role: "system", Content: systemPrompt},
- {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 ... blocks (reasoning models)
- if i := strings.Index(text, ""); i != -1 {
- if j := strings.Index(text, ""); j != -1 {
- text = text[:i] + text[j+len(""):]
- }
- }
-
- return strings.TrimSpace(text), nil
+// chatLLM sends a system+user pair to the configured backend. Both backends
+// map this onto their own chat shape, so the market summaries read the same
+// whichever one is serving.
+func (p *MarketPlugin) chatLLM(systemPrompt, userPrompt string) (string, error) {
+ return llmGenerate(context.Background(), llm.Request{
+ System: systemPrompt,
+ Prompt: userPrompt,
+ })
}
// ── DB Helpers ───────────────────────────────────────────────────────────────
@@ -916,12 +877,10 @@ func (p *MarketPlugin) handleVixReport(ctx MessageContext) error {
}
var summary string
- host := os.Getenv("OLLAMA_HOST")
- model := os.Getenv("OLLAMA_MODEL")
- if host != "" && model != "" {
+ if llmConfigured() {
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, ", "))
- summary, _ = p.callOllamaChat(host, model, marketSystemPrompt, prompt)
+ summary, _ = p.chatLLM(marketSystemPrompt, prompt)
}
var sb strings.Builder
@@ -1020,12 +979,10 @@ func (p *MarketPlugin) handleCompare(ctx MessageContext, args []string) error {
for _, e := range entries {
prices = append(prices, fmt.Sprintf("%.2f", e.Price))
}
- host := os.Getenv("OLLAMA_HOST")
- model := os.Getenv("OLLAMA_MODEL")
- if host != "" && model != "" {
+ if llmConfigured() {
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, ", "))
- 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("\n\n")
}
diff --git a/internal/plugin/pete_dispatch_voice.go b/internal/plugin/pete_dispatch_voice.go
index 21aa831..6922e55 100644
--- a/internal/plugin/pete_dispatch_voice.go
+++ b/internal/plugin/pete_dispatch_voice.go
@@ -1,16 +1,14 @@
package plugin
import (
- "bytes"
+ "context"
"encoding/json"
"fmt"
- "io"
"log/slog"
- "net/http"
- "os"
"strings"
"time"
+ "gogobee/internal/llm"
"gogobee/internal/peteclient"
)
@@ -39,22 +37,18 @@ const (
maxDispatchLede = 800
)
-var dispatchHTTP = &http.Client{Timeout: dispatchLLMTimeout}
-
// 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
// 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,
// and it is what Pete's guard checks the output against.
func authorDispatch(f peteclient.Fact) (headline, lede string) {
- host := os.Getenv("OLLAMA_HOST")
- model := os.Getenv("OLLAMA_MODEL")
- if host == "" || model == "" {
+ if !llmConfigured() {
return "", ""
}
prompt := buildDispatchPrompt(f)
- raw, err := callOllamaDispatch(dispatchHTTP, host, model, prompt)
+ raw, err := callLLMDispatch(dispatchLLMTimeout, prompt)
if err != nil {
slog.Warn("pete dispatch: LLM authoring failed, Pete will template", "guid", f.GUID, "err", err)
return "", ""
@@ -128,45 +122,17 @@ The event:
%s`, names, facts.String())
}
-// callOllamaDispatch posts a single non-streaming generation and returns the raw
-// completion (think-tags stripped). The client is a parameter because the two
-// callers have genuinely different patience: a dispatch is authored on a game
-// chokepoint and must not stall it, while a run summary rides a background
-// ticker and can afford to wait for a bigger model. See runSummaryHTTP.
-func callOllamaDispatch(client *http.Client, host, model, prompt string) (string, error) {
- payload := map[string]interface{}{
- "model": model,
- "prompt": prompt,
- "stream": false,
- "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
+// callLLMDispatch posts a single non-streaming generation and returns the raw
+// completion. The timeout is a parameter because the two callers have genuinely
+// different patience: a dispatch is authored on a game chokepoint and must not
+// stall it, while a run summary rides a background ticker and can afford to wait
+// for a bigger model. See runSummaryTimeout.
+func callLLMDispatch(timeout time.Duration, prompt string) (string, error) {
+ return llmGenerate(context.Background(), llm.Request{
+ Prompt: prompt,
+ NumCtx: 4096,
+ Timeout: timeout,
+ })
}
// parseDispatch pulls {headline, lede} out of the model's completion, tolerating
diff --git a/internal/plugin/pete_run_summary.go b/internal/plugin/pete_run_summary.go
index 0bfab14..d9285df 100644
--- a/internal/plugin/pete_run_summary.go
+++ b/internal/plugin/pete_run_summary.go
@@ -4,8 +4,6 @@ import (
"encoding/json"
"fmt"
"log/slog"
- "net/http"
- "os"
"sort"
"strings"
"sync/atomic"
@@ -70,8 +68,6 @@ const maxRunSummary = 1200
// for load-then-generate, and a timeout here really does mean the box is down.
const runSummaryTimeout = 5 * time.Minute
-var runSummaryHTTP = &http.Client{Timeout: runSummaryTimeout}
-
// 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
// 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() {
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
}
runID := nextRunNeedingSummary()
@@ -174,8 +170,7 @@ func authorRunSummary(runID string) (summary, name string) {
return "", ""
}
- raw, err := callOllamaDispatch(runSummaryHTTP, os.Getenv("OLLAMA_HOST"), os.Getenv("OLLAMA_MODEL"),
- buildRunSummaryPrompt(name, beats))
+ raw, err := callLLMDispatch(runSummaryTimeout, buildRunSummaryPrompt(name, beats))
if err != nil {
slog.Warn("run summary: LLM authoring failed", "run", runID, "err", err)
return "", name
diff --git a/internal/plugin/tarot.go b/internal/plugin/tarot.go
index 38b15bf..abf7ebd 100644
--- a/internal/plugin/tarot.go
+++ b/internal/plugin/tarot.go
@@ -4,7 +4,6 @@ import (
"fmt"
"log/slog"
"math/rand"
- "os"
"strings"
"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.")
}
- ollamaHost := os.Getenv("OLLAMA_HOST")
- ollamaModel := os.Getenv("OLLAMA_MODEL")
- if ollamaHost == "" || ollamaModel == "" {
+ if !llmConfigured() {
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)
- response, err := callOllama(ollamaHost, ollamaModel, prompt)
+ response, err := callLLM(prompt)
if err != nil {
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.")
@@ -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.")
}
- ollamaHost := os.Getenv("OLLAMA_HOST")
- ollamaModel := os.Getenv("OLLAMA_MODEL")
- if ollamaHost == "" || ollamaModel == "" {
+ if !llmConfigured() {
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.",
tarotBasePrompt, tarotSpreadSuffix, tarotFewShot, cards[0], cards[1], cards[2], extraLines)
- response, err := callOllama(ollamaHost, ollamaModel, prompt)
+ response, err := callLLM(prompt)
if err != nil {
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.")
diff --git a/internal/plugin/vibe.go b/internal/plugin/vibe.go
index 50a5307..878472b 100644
--- a/internal/plugin/vibe.go
+++ b/internal/plugin/vibe.go
@@ -120,9 +120,7 @@ func (p *VibePlugin) resetCooldown(roomID id.RoomID) {
}
func (p *VibePlugin) handleVibe(ctx MessageContext) error {
- ollamaHost := os.Getenv("OLLAMA_HOST")
- ollamaModel := os.Getenv("OLLAMA_MODEL")
- if ollamaHost == "" || ollamaModel == "" {
+ if !llmConfigured() {
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)
}
- response, err := callOllama(ollamaHost, ollamaModel, prompt)
+ response, err := callLLM(prompt)
if err != nil {
slog.Error("vibe: ollama call", "err", err)
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 {
- ollamaHost := os.Getenv("OLLAMA_HOST")
- ollamaModel := os.Getenv("OLLAMA_MODEL")
- if ollamaHost == "" || ollamaModel == "" {
+ if !llmConfigured() {
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)
}
- response, err := callOllama(ollamaHost, ollamaModel, prompt)
+ response, err := callLLM(prompt)
if err != nil {
slog.Error("vibe: ollama call", "err", err)
p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure
diff --git a/internal/plugin/wotd.go b/internal/plugin/wotd.go
index 5f05319..605e7fb 100644
--- a/internal/plugin/wotd.go
+++ b/internal/plugin/wotd.go
@@ -1,19 +1,17 @@
package plugin
import (
- "bytes"
+ "context"
"database/sql"
"encoding/json"
"fmt"
- "io"
"log/slog"
- "net/http"
- "os"
"strings"
"time"
"gogobee/internal/db"
"gogobee/internal/dreamclient"
+ "gogobee/internal/llm"
"maunium.net/go/mautrix"
"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.
// Returns false if LLM is not configured or on any error.
func (p *WOTDPlugin) verifyUsage(word, message string) bool {
- host := os.Getenv("OLLAMA_HOST")
- model := os.Getenv("OLLAMA_MODEL")
- if host == "" || model == "" {
+ if !llmConfigured() {
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)
- payload := map[string]interface{}{
- "model": model,
- "prompt": prompt,
- "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))
+ response, err := llmGenerate(context.Background(), llm.Request{
+ Prompt: prompt,
+ Timeout: wotdLLMTimeout,
+ })
if err != nil {
slog.Error("wotd: LLM verify request failed", "err", err)
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 ... blocks (Qwen 3.5 reasoning)
- if i := strings.Index(response, ""); i != -1 {
- if j := strings.Index(response, ""); j != -1 {
- response = response[:i] + response[j+len(""):]
- }
- }
answer := strings.ToLower(strings.TrimSpace(response))
accepted := strings.HasPrefix(answer, "yes")
slog.Debug("wotd: LLM verification", "word", word, "answer", answer, "accepted", 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.
// Returns empty string on failure.
func (p *WOTDPlugin) llmTranslate(word, lang string) string {
- host := os.Getenv("OLLAMA_HOST")
- model := os.Getenv("OLLAMA_MODEL")
- if host == "" || model == "" {
+ if !llmConfigured() {
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.`,
langName, word)
- payload := map[string]interface{}{
- "model": model,
- "prompt": prompt,
- "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))
+ response, err := llmGenerate(context.Background(), llm.Request{
+ Prompt: prompt,
+ Timeout: wotdLLMTimeout,
+ })
if err != nil {
slog.Error("wotd: LLM translate request failed", "err", err)
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, ""); i != -1 {
- if j := strings.Index(response, ""); j != -1 {
- response = response[:i] + response[j+len(""):]
- }
- }
translation := strings.TrimSpace(response)
if translation == "" || len(translation) > 50 {
return ""
diff --git a/main.go b/main.go
index 48342ef..c406f41 100644
--- a/main.go
+++ b/main.go
@@ -13,6 +13,7 @@ import (
"gogobee/internal/bot"
"gogobee/internal/db"
"gogobee/internal/dreamclient"
+ "gogobee/internal/llm"
"gogobee/internal/peteclient"
"gogobee/internal/plugin"
"gogobee/internal/util"
@@ -37,9 +38,11 @@ func main() {
logLevel = "info"
}
util.InitLogger(logLevel)
+ llmCfg := llm.ConfigFromEnv()
slog.Info(version.Full(), "level", logLevel,
- "ollama_host", os.Getenv("OLLAMA_HOST"),
- "ollama_model", os.Getenv("OLLAMA_MODEL"))
+ "llm_backend", llmCfg.Backend,
+ "llm_endpoint", llmCfg.Endpoint,
+ "llm_model", llmCfg.Model)
dataDir := os.Getenv("DATA_DIR")
if dataDir == "" {