diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-11 00:28:27 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-11 00:28:27 +0300 |
| commit | b4f06c656d8d8733a9bf2e2e2a5eb2a7a48389bb (patch) | |
| tree | 7df0eac26ded2b4854225711dfde8f40d80a0400 /internal/lsp | |
| parent | 2b2f4110da53a03742faff89cdec17c55e091a90 (diff) | |
Extract shared chatrun package to eliminate chat-runner DRY violations
The CLI, LSP server and tmux code-action tool each carried near-identical
chat-running logic: invoking the LLM (streaming-aware), collecting the
response, and accounting sent/received bytes into the stats package.
Introduce internal/chatrun with:
- Invoke: streaming-aware LLM call that collects the full response and
optionally mirrors chunks to a writer (nil writer = collect only).
- SentBytes / Account: shared byte counting and stats.Update.
Wire all three surfaces to it:
- hexaicli: runChatRequest delegates to chatrun.Invoke; summarizeChatRun
uses chatrun.Account. Removed the duplicated streaming/simple helpers.
- hexaiaction: runOnce uses chatrun.Invoke + chatrun.Account, keeping the
tmux status update local.
- lsp: chatWithStats and the completion path use chatrun.SentBytes/Invoke;
extracted unavailableClientError to keep chatWithStats small.
chatrun has 100% test coverage; full suite passes with -race.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Diffstat (limited to 'internal/lsp')
| -rw-r--r-- | internal/lsp/handlers_completion.go | 10 | ||||
| -rw-r--r-- | internal/lsp/handlers_utils.go | 43 |
2 files changed, 31 insertions, 22 deletions
diff --git a/internal/lsp/handlers_completion.go b/internal/lsp/handlers_completion.go index fced629..5ae6cd4 100644 --- a/internal/lsp/handlers_completion.go +++ b/internal/lsp/handlers_completion.go @@ -12,6 +12,7 @@ import ( "sync" "time" + "codeberg.org/snonux/hexai/internal/chatrun" "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/llmutils" "codeberg.org/snonux/hexai/internal/logging" @@ -326,12 +327,11 @@ func (cs *completionService) runCompletionForSpec(ctx context.Context, plan comp func (cs *completionService) executeChatCompletion(ctx context.Context, plan completionPlan, spec requestSpec, client llm.Client, sortPrefix string) ([]CompletionItem, bool) { s := cs.srv messages := cs.buildCompletionMessages(plan.inlinePrompt, plan.hasExtra, plan.extraText, plan.inParams, plan.params, plan.above, plan.current, plan.below, plan.funcCtx) - sentSize := 0 - for _, m := range messages { - sentSize += len(m.Content) - } + sentSize := chatrun.SentBytes(messages) s.incSentCounters(sentSize) - text, err := client.Chat(ctx, messages, spec.options...) + // Completion never streams to a writer; Invoke collects the full text and + // keeps this path aligned with chatWithStats and the other surfaces. + text, err := chatrun.Invoke(ctx, client, messages, spec.options, nil) if err != nil { logging.Logf("lsp ", "llm completion error: %v", err) return nil, false diff --git a/internal/lsp/handlers_utils.go b/internal/lsp/handlers_utils.go index a8e87e4..55affaf 100644 --- a/internal/lsp/handlers_utils.go +++ b/internal/lsp/handlers_utils.go @@ -10,6 +10,7 @@ import ( "unicode/utf8" "codeberg.org/snonux/hexai/internal/appconfig" + "codeberg.org/snonux/hexai/internal/chatrun" "codeberg.org/snonux/hexai/internal/llm" "codeberg.org/snonux/hexai/internal/llmutils" "codeberg.org/snonux/hexai/internal/logging" @@ -246,39 +247,34 @@ func isIdentChar(ch byte) bool { return (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || (ch >= '0' && ch <= '9') || ch == '_' } -// chatWithStats wraps llmClient.Chat to increment counters and emit a tmux heartbeat. +// chatWithStats wraps the LLM request to increment counters and emit a tmux +// heartbeat. The byte counting and the actual LLM invocation are delegated to +// the shared chatrun package so the LSP stays in lock-step with the CLI and +// code-action surfaces; the debounce/throttle gating, in-memory counters and +// stats-error logging remain here because they are LSP-specific. func (s *Server) chatWithStats(ctx context.Context, surface surfaceKind, spec requestSpec, msgs []llm.Message) (string, error) { - // Count bytes sent - sent := 0 - for _, m := range msgs { - sent += len(m.Content) - } + sent := chatrun.SentBytes(msgs) s.incSentCounters(sent) // Debounce/throttle if configured (reuse completion gates) s.completionSvc().waitForDebounce(ctx) if !s.waitForThrottle(ctx) { return "", context.Canceled } - // Perform request + // Resolve the client for this surface/spec. client := s.clientFor(spec) if client == nil { - provider := strings.TrimSpace(spec.provider) - if provider == "" { - provider = strings.TrimSpace(s.currentConfig().Provider) - } - if provider == "" { - return "", fmt.Errorf("llm client unavailable; check the configured provider and required API key") - } - return "", fmt.Errorf("llm client unavailable for provider %q; check the configured provider and required API key", provider) + return "", s.unavailableClientError(spec) } modelUsed := spec.effectiveModel(client.DefaultModel()) - txt, err := client.Chat(ctx, msgs, spec.options...) + // chatWithStats never streams to a writer; pass nil out. Invoke prefers + // streaming providers but collects the full text either way. + txt, err := chatrun.Invoke(ctx, client, msgs, spec.options, nil) if err != nil { s.logLLMStats(modelUsed) return "", err } s.incRecvCounters(len(txt)) - // Update global stats cache; log but don't fail on stats errors + // Update global stats cache; log but don't fail on stats errors. if err := stats.Update(ctx, client.Name(), modelUsed, sent, len(txt)); err != nil { logging.Logf("lsp ", "stats update error: %v", err) } @@ -286,6 +282,19 @@ func (s *Server) chatWithStats(ctx context.Context, surface surfaceKind, spec re return txt, nil } +// unavailableClientError builds the descriptive error returned when no LLM +// client could be resolved for spec, naming the configured provider when known. +func (s *Server) unavailableClientError(spec requestSpec) error { + provider := strings.TrimSpace(spec.provider) + if provider == "" { + provider = strings.TrimSpace(s.currentConfig().Provider) + } + if provider == "" { + return fmt.Errorf("llm client unavailable; check the configured provider and required API key") + } + return fmt.Errorf("llm client unavailable for provider %q; check the configured provider and required API key", provider) +} + // Inline prompt utilities func lineHasInlinePrompt(line string, openStr string, open, close byte) bool { |
