summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-19 09:05:29 +0200
committerPaul Buetow <paul@buetow.org>2026-03-19 09:05:29 +0200
commit0918aad469ac2ff5513a7131661f1106e5ec851c (patch)
tree90ecf1245c1d98ec5e3c8ead77978206f2e61155 /internal
parent31394385e72dd3a317585838ed1696076043cc60 (diff)
Improve actionable error guidance
Diffstat (limited to 'internal')
-rw-r--r--internal/hexaiaction/run.go9
-rw-r--r--internal/hexaiaction/run_more_test.go17
-rw-r--r--internal/llm/anthropic.go6
-rw-r--r--internal/llm/anthropic_test.go8
-rw-r--r--internal/llm/openai.go6
-rw-r--r--internal/llm/openai_test.go11
-rw-r--r--internal/llm/openrouter.go6
-rw-r--r--internal/llm/openrouter_test.go3
-rw-r--r--internal/llm/provider.go54
-rw-r--r--internal/llm/provider_test.go5
-rw-r--r--internal/lsp/handlers_utils.go9
11 files changed, 113 insertions, 21 deletions
diff --git a/internal/hexaiaction/run.go b/internal/hexaiaction/run.go
index f34a4cd..2a3ade1 100644
--- a/internal/hexaiaction/run.go
+++ b/internal/hexaiaction/run.go
@@ -38,7 +38,7 @@ func logTmuxActionError(stderr io.Writer, err error) error {
// requireInput validates that input selection is not empty.
func requireInput(sel string) error {
if strings.TrimSpace(sel) == "" {
- return fmt.Errorf("no input provided on stdin")
+ return fmt.Errorf("no input provided on stdin; pipe the selected text or pane contents into hexai-tmux-action")
}
return nil
}
@@ -193,11 +193,10 @@ func (r *Runner) Run(ctx context.Context, stdin io.Reader, stdout, stderr io.Wri
var client chatDoer = cli
parts, err := ParseInput(stdin)
if err != nil {
- _, _ = fmt.Fprintln(stderr, logging.AnsiBase+"hexai-tmux-action: failed to read input"+logging.AnsiReset)
- return err
+ return fmt.Errorf("hexai-tmux-action: failed to read action input from stdin (pipe the selected text or pane contents into hexai-tmux-action): %w", err)
}
- if strings.TrimSpace(parts.Selection) == "" {
- return fmt.Errorf("hexai-tmux-action: no input provided on stdin")
+ if err := requireInput(parts.Selection); err != nil {
+ return fmt.Errorf("hexai-tmux-action: %w", err)
}
choice, err := chooser(cfg)
if err != nil {
diff --git a/internal/hexaiaction/run_more_test.go b/internal/hexaiaction/run_more_test.go
index 6a4959e..67ffa96 100644
--- a/internal/hexaiaction/run_more_test.go
+++ b/internal/hexaiaction/run_more_test.go
@@ -3,6 +3,7 @@ package hexaiaction
import (
"bytes"
"context"
+ "log"
"os"
"strings"
"testing"
@@ -28,6 +29,22 @@ func TestRun_MissingAPIKey(t *testing.T) {
_ = os.Stderr
}
+func TestRun_NoInput_IsActionable(t *testing.T) {
+ runner := NewRunner()
+ runner.loadConfig = func(context.Context, *log.Logger) appconfig.App { return appconfig.Load(nil) }
+ runner.newClient = func(appconfig.App) (actionClient, error) { return llmFake{}, nil }
+ runner.chooseAction = func(appconfig.App) (actionChoice, error) {
+ return actionChoice{kind: ActionSkip}, nil
+ }
+ err := runner.Run(context.Background(), bytes.NewBufferString(""), &bytes.Buffer{}, &bytes.Buffer{})
+ if err == nil {
+ t.Fatal("expected actionable no-input error")
+ }
+ if !strings.Contains(err.Error(), "pipe the selected text or pane contents into hexai-tmux-action") {
+ t.Fatalf("expected actionable guidance, got %q", err.Error())
+ }
+}
+
type stubChatDoer struct {
calls int
msgs [][]llm.Message
diff --git a/internal/llm/anthropic.go b/internal/llm/anthropic.go
index 0f27dcc..7da72b3 100644
--- a/internal/llm/anthropic.go
+++ b/internal/llm/anthropic.go
@@ -91,7 +91,7 @@ func init() {
func anthropicProviderFactory(cfg Config, keys ProviderKeys) (Client, error) {
if strings.TrimSpace(keys.AnthropicAPIKey) == "" {
- return nil, errors.New("missing ANTHROPIC_API_KEY for provider anthropic")
+ return nil, missingAPIKeyError("anthropic", "ANTHROPIC_API_KEY", "HEXAI_ANTHROPIC_API_KEY")
}
return newAnthropicWithTimeout(
cfg.AnthropicBaseURL,
@@ -132,7 +132,7 @@ func newAnthropicWithTimeout(baseURL, model, apiKey string, defaultTemp *float64
// Chat sends a request to Anthropic and returns the response.
func (c anthropicClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) {
if c.apiKey == "" {
- return nilStringErr("missing Anthropic API key")
+ return "", missingAPIKeyError("anthropic", "ANTHROPIC_API_KEY", "HEXAI_ANTHROPIC_API_KEY")
}
o := c.resolveOptions(opts)
start := time.Now()
@@ -167,7 +167,7 @@ func (c anthropicClient) DefaultModel() string { return c.defaultModel }
// ChatStream sends a streaming request and invokes onDelta for each text chunk.
func (c anthropicClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error {
if c.apiKey == "" {
- return errors.New("missing Anthropic API key")
+ return missingAPIKeyError("anthropic", "ANTHROPIC_API_KEY", "HEXAI_ANTHROPIC_API_KEY")
}
o := c.resolveOptions(opts)
start := time.Now()
diff --git a/internal/llm/anthropic_test.go b/internal/llm/anthropic_test.go
index ffc5021..2459064 100644
--- a/internal/llm/anthropic_test.go
+++ b/internal/llm/anthropic_test.go
@@ -79,8 +79,8 @@ func TestAnthropicChat_NoAPIKey(t *testing.T) {
if err == nil {
t.Fatalf("expected error for missing API key")
}
- if !strings.Contains(err.Error(), "missing Anthropic API key") {
- t.Fatalf("expected 'missing Anthropic API key', got '%s'", err.Error())
+ if !strings.Contains(err.Error(), "missing Anthropic API key") || !strings.Contains(err.Error(), "ANTHROPIC_API_KEY") || !strings.Contains(err.Error(), "HEXAI_ANTHROPIC_API_KEY") {
+ t.Fatalf("expected actionable Anthropic API key hint, got '%s'", err.Error())
}
}
@@ -224,8 +224,8 @@ func TestAnthropicStream_NoAPIKey(t *testing.T) {
if err == nil {
t.Fatalf("expected error for missing API key")
}
- if !strings.Contains(err.Error(), "missing Anthropic API key") {
- t.Fatalf("expected 'missing Anthropic API key', got '%s'", err.Error())
+ if !strings.Contains(err.Error(), "missing Anthropic API key") || !strings.Contains(err.Error(), "ANTHROPIC_API_KEY") || !strings.Contains(err.Error(), "HEXAI_ANTHROPIC_API_KEY") {
+ t.Fatalf("expected actionable Anthropic API key hint, got '%s'", err.Error())
}
}
diff --git a/internal/llm/openai.go b/internal/llm/openai.go
index d2eff05..eccd558 100644
--- a/internal/llm/openai.go
+++ b/internal/llm/openai.go
@@ -78,7 +78,7 @@ func init() {
func openAIProviderFactory(cfg Config, keys ProviderKeys) (Client, error) {
if strings.TrimSpace(keys.OpenAIAPIKey) == "" {
- return nil, errors.New("missing OPENAI_API_KEY for provider openai")
+ return nil, missingAPIKeyError("openai", "OPENAI_API_KEY", "HEXAI_OPENAI_API_KEY")
}
return newOpenAIWithTimeout(
cfg.OpenAIBaseURL,
@@ -134,7 +134,7 @@ func newOpenAIWithTimeout(baseURL, model, apiKey string, defaultTemp *float64, t
func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) {
if c.apiKey == "" {
- return nilStringErr("missing OpenAI API key")
+ return "", missingAPIKeyError("openai", "OPENAI_API_KEY", "HEXAI_OPENAI_API_KEY")
}
o := Options{Model: c.defaultModel}
for _, opt := range opts {
@@ -189,7 +189,7 @@ func (c openAIClient) DefaultModel() string { return c.defaultModel }
func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error {
if c.apiKey == "" {
- return errors.New("missing OpenAI API key")
+ return missingAPIKeyError("openai", "OPENAI_API_KEY", "HEXAI_OPENAI_API_KEY")
}
o := Options{Model: c.defaultModel}
for _, opt := range opts {
diff --git a/internal/llm/openai_test.go b/internal/llm/openai_test.go
index 686d535..ffa6252 100644
--- a/internal/llm/openai_test.go
+++ b/internal/llm/openai_test.go
@@ -42,6 +42,17 @@ func TestOpenAIChatSuccess(t *testing.T) {
}
}
+func TestOpenAIChat_MissingKey_IsActionable(t *testing.T) {
+ client := openAIClient{defaultModel: "gpt-test"}
+ _, err := client.Chat(context.Background(), []Message{{Role: "user", Content: "hello"}})
+ if err == nil {
+ t.Fatal("expected missing key error")
+ }
+ if !strings.Contains(err.Error(), "OPENAI_API_KEY") || !strings.Contains(err.Error(), "HEXAI_OPENAI_API_KEY") {
+ t.Fatalf("expected actionable API key hint, got %q", err.Error())
+ }
+}
+
func TestOpenAIChatStreamDeliversChunks(t *testing.T) {
client := openAIClient{
httpClient: &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
diff --git a/internal/llm/openrouter.go b/internal/llm/openrouter.go
index 53d2957..60a594a 100644
--- a/internal/llm/openrouter.go
+++ b/internal/llm/openrouter.go
@@ -27,7 +27,7 @@ func init() {
func openRouterProviderFactory(cfg Config, keys ProviderKeys) (Client, error) {
if strings.TrimSpace(keys.OpenRouterAPIKey) == "" {
- return nil, errors.New("missing OPENROUTER_API_KEY for provider openrouter")
+ return nil, missingAPIKeyError("openrouter", "OPENROUTER_API_KEY", "HEXAI_OPENROUTER_API_KEY")
}
return newOpenRouterWithTimeout(
cfg.OpenRouterBaseURL,
@@ -64,7 +64,7 @@ func newOpenRouterWithTimeout(baseURL, model, apiKey string, defaultTemp *float6
func (c openRouterClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) {
if strings.TrimSpace(c.apiKey) == "" {
- return nilStringErr("missing OpenRouter API key")
+ return "", missingAPIKeyError("openrouter", "OPENROUTER_API_KEY", "HEXAI_OPENROUTER_API_KEY")
}
o := Options{Model: c.defaultModel}
for _, opt := range opts {
@@ -114,7 +114,7 @@ func (c openRouterClient) DefaultModel() string { return c.defaultModel }
func (c openRouterClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error {
if strings.TrimSpace(c.apiKey) == "" {
- return errors.New("missing OpenRouter API key")
+ return missingAPIKeyError("openrouter", "OPENROUTER_API_KEY", "HEXAI_OPENROUTER_API_KEY")
}
o := Options{Model: c.defaultModel}
for _, opt := range opts {
diff --git a/internal/llm/openrouter_test.go b/internal/llm/openrouter_test.go
index f8efe16..07d6e0f 100644
--- a/internal/llm/openrouter_test.go
+++ b/internal/llm/openrouter_test.go
@@ -8,6 +8,7 @@ import (
"net/http"
"net/http/httptest"
"os"
+ "strings"
"testing"
"codeberg.org/snonux/hexai/internal/logging"
@@ -102,6 +103,8 @@ func TestOpenRouter_Chat_MissingKey(t *testing.T) {
c := newOpenRouter("http://example", "anthropic/claude-test", "", f64p(0.2)).(openRouterClient)
if _, err := c.Chat(context.Background(), []Message{{Role: "user", Content: "ping"}}); err == nil {
t.Fatalf("expected error for missing api key")
+ } else if !strings.Contains(err.Error(), "OPENROUTER_API_KEY") || !strings.Contains(err.Error(), "HEXAI_OPENROUTER_API_KEY") {
+ t.Fatalf("expected actionable API key hint, got %q", err.Error())
}
}
diff --git a/internal/llm/provider.go b/internal/llm/provider.go
index 96646cf..afc126b 100644
--- a/internal/llm/provider.go
+++ b/internal/llm/provider.go
@@ -3,7 +3,8 @@ package llm
import (
"context"
- "errors"
+ "fmt"
+ "sort"
"strings"
"sync"
)
@@ -135,7 +136,7 @@ func NewFromConfig(cfg Config, openAIAPIKey, openRouterAPIKey, anthropicAPIKey s
factory, ok := lookupProviderFactory(provider)
if !ok {
- return nil, errors.New("unknown LLM provider: " + provider)
+ return nil, unknownProviderError(provider)
}
return factory(cfg, ProviderKeys{
@@ -163,3 +164,52 @@ func withDefaultTemperature(configured *float64, fallback float64) *float64 {
v := fallback
return &v
}
+
+func missingAPIKeyError(provider string, envVars ...string) error {
+ name := providerDisplayName(provider)
+ if len(envVars) == 0 {
+ return fmt.Errorf("missing %s API key", name)
+ }
+ return fmt.Errorf("missing %s API key for provider %s; set %s", name, normalizeProvider(provider), joinEnvVars(envVars))
+}
+
+func unknownProviderError(provider string) error {
+ return fmt.Errorf("unknown LLM provider %q; supported providers: %s", provider, strings.Join(supportedProviders(), ", "))
+}
+
+func providerDisplayName(provider string) string {
+ switch normalizeProvider(provider) {
+ case "openai":
+ return "OpenAI"
+ case "openrouter":
+ return "OpenRouter"
+ case "anthropic":
+ return "Anthropic"
+ default:
+ return provider
+ }
+}
+
+func joinEnvVars(envVars []string) string {
+ switch len(envVars) {
+ case 0:
+ return ""
+ case 1:
+ return envVars[0]
+ case 2:
+ return envVars[0] + " or " + envVars[1]
+ default:
+ return strings.Join(envVars[:len(envVars)-1], ", ") + ", or " + envVars[len(envVars)-1]
+ }
+}
+
+func supportedProviders() []string {
+ providerRegistryMu.RLock()
+ defer providerRegistryMu.RUnlock()
+ names := make([]string, 0, len(providerRegistry))
+ for name := range providerRegistry {
+ names = append(names, name)
+ }
+ sort.Strings(names)
+ return names
+}
diff --git a/internal/llm/provider_test.go b/internal/llm/provider_test.go
index 8ccba6e..14de7a6 100644
--- a/internal/llm/provider_test.go
+++ b/internal/llm/provider_test.go
@@ -1,6 +1,7 @@
package llm
import (
+ "strings"
"testing"
)
@@ -8,9 +9,13 @@ func TestNewFromConfig_DefaultsAndErrors(t *testing.T) {
// Unknown provider
if _, err := NewFromConfig(Config{Provider: "bogus"}, "", "", ""); err == nil {
t.Fatalf("expected error for unknown provider")
+ } else if !strings.Contains(err.Error(), "supported providers:") {
+ t.Fatalf("expected supported providers hint, got %q", err.Error())
}
// OpenAI missing key
if _, err := NewFromConfig(Config{Provider: "openai", OpenAIModel: "g"}, "", "", ""); err == nil {
t.Fatalf("expected key error")
+ } else if !strings.Contains(err.Error(), "OPENAI_API_KEY") || !strings.Contains(err.Error(), "HEXAI_OPENAI_API_KEY") {
+ t.Fatalf("expected actionable API key hint, got %q", err.Error())
}
}
diff --git a/internal/lsp/handlers_utils.go b/internal/lsp/handlers_utils.go
index 66e2ed1..e3c65a5 100644
--- a/internal/lsp/handlers_utils.go
+++ b/internal/lsp/handlers_utils.go
@@ -262,7 +262,14 @@ func (s *Server) chatWithStats(ctx context.Context, surface surfaceKind, spec re
// Perform request
client := s.clientFor(spec)
if client == nil {
- return "", fmt.Errorf("llm client unavailable")
+ 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)
}
modelUsed := spec.effectiveModel(client.DefaultModel())
txt, err := client.Chat(ctx, msgs, spec.options...)