summaryrefslogtreecommitdiff
path: root/internal/llm
diff options
context:
space:
mode:
Diffstat (limited to 'internal/llm')
-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
8 files changed, 84 insertions, 15 deletions
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())
}
}