diff options
| author | Paul Buetow <paul@buetow.org> | 2026-03-19 09:05:29 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-03-19 09:05:29 +0200 |
| commit | 0918aad469ac2ff5513a7131661f1106e5ec851c (patch) | |
| tree | 90ecf1245c1d98ec5e3c8ead77978206f2e61155 /internal | |
| parent | 31394385e72dd3a317585838ed1696076043cc60 (diff) | |
Improve actionable error guidance
Diffstat (limited to 'internal')
| -rw-r--r-- | internal/hexaiaction/run.go | 9 | ||||
| -rw-r--r-- | internal/hexaiaction/run_more_test.go | 17 | ||||
| -rw-r--r-- | internal/llm/anthropic.go | 6 | ||||
| -rw-r--r-- | internal/llm/anthropic_test.go | 8 | ||||
| -rw-r--r-- | internal/llm/openai.go | 6 | ||||
| -rw-r--r-- | internal/llm/openai_test.go | 11 | ||||
| -rw-r--r-- | internal/llm/openrouter.go | 6 | ||||
| -rw-r--r-- | internal/llm/openrouter_test.go | 3 | ||||
| -rw-r--r-- | internal/llm/provider.go | 54 | ||||
| -rw-r--r-- | internal/llm/provider_test.go | 5 | ||||
| -rw-r--r-- | internal/lsp/handlers_utils.go | 9 |
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...) |
