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
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, ", "))
}
+2 -4
View File
@@ -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
+16 -40
View File
@@ -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")
}
+14 -61
View File
@@ -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 <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
// 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,
})
}
+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
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)
}
+18 -61
View File
@@ -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 <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
// 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")
}
+15 -49
View File
@@ -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
+2 -7
View File
@@ -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
+4 -9
View File
@@ -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.")
+4 -8
View File
@@ -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
+17 -78
View File
@@ -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 <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))
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, "<think>"); i != -1 {
if j := strings.Index(response, "</think>"); j != -1 {
response = response[:i] + response[j+len("</think>"):]
}
}
translation := strings.TrimSpace(response)
if translation == "" || len(translation) > 50 {
return ""