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 == "" {