summaryrefslogtreecommitdiff
path: root/internal/llm
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-16 23:16:54 +0300
committerPaul Buetow <paul@buetow.org>2025-08-16 23:16:54 +0300
commit765eda955eb811d08d867ff4d3914fc6d60c22dd (patch)
treefdc87da6af9d86dbda2ea9ab08244e93fd167188 /internal/llm
parent1b01e35c34b953cbf51298f4650dc3215c382a4f (diff)
refactor(config): drop env-based config (except OPENAI_API_KEY)
- Switch to config-file-only; only OPENAI_API_KEY read from env.\n- llm: replace env autodetect with Config + NewFromConfig; add newOpenAI/newOllama.\n- lsp: NewServer now accepts injected llm.Client.\n- cli: remove env overrides; extend appConfig with provider-specific fields; build client from config + OPENAI_API_KEY.\n- docs: update README (config-only, defaults to OpenAI, minimal example); simplify flags table.\n- add config.json.example.\n- prompts: enforce ;text; (no spaces) and add ;;text; to remove entire line; tests added.
Diffstat (limited to 'internal/llm')
-rw-r--r--internal/llm/ollama.go17
-rw-r--r--internal/llm/openai.go84
-rw-r--r--internal/llm/provider.go55
3 files changed, 74 insertions, 82 deletions
diff --git a/internal/llm/ollama.go b/internal/llm/ollama.go
index 495b5c2..db3e06b 100644
--- a/internal/llm/ollama.go
+++ b/internal/llm/ollama.go
@@ -7,7 +7,6 @@ import (
"errors"
"fmt"
"net/http"
- "os"
"strings"
"time"
@@ -21,22 +20,16 @@ type ollamaClient struct {
defaultModel string
}
-func newOllamaFromEnv() Client {
- // Prefer OLLAMA_BASE_URL, fall back to OLLAMA_HOST, then default.
- base := strings.TrimSpace(os.Getenv("OLLAMA_BASE_URL"))
- if base == "" {
- base = strings.TrimSpace(os.Getenv("OLLAMA_HOST"))
+func newOllama(baseURL, model string) Client {
+ if strings.TrimSpace(baseURL) == "" {
+ baseURL = "http://localhost:11434"
}
- if base == "" {
- base = "http://localhost:11434"
- }
- model := strings.TrimSpace(os.Getenv("OLLAMA_MODEL"))
- if model == "" {
+ if strings.TrimSpace(model) == "" {
model = "qwen2.5-coder:latest"
}
return &ollamaClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
- baseURL: strings.TrimRight(base, "/"),
+ baseURL: strings.TrimRight(baseURL, "/"),
defaultModel: model,
}
}
diff --git a/internal/llm/openai.go b/internal/llm/openai.go
index dbcee4d..03e894a 100644
--- a/internal/llm/openai.go
+++ b/internal/llm/openai.go
@@ -7,7 +7,7 @@ import (
"errors"
"fmt"
"net/http"
- "os"
+ "strings"
"time"
"hexai/internal/logging"
@@ -15,27 +15,27 @@ import (
// openAIClient implements Client against OpenAI's Chat Completions API.
type openAIClient struct {
- httpClient *http.Client
- apiKey string
- baseURL string
- defaultModel string
+ httpClient *http.Client
+ apiKey string
+ baseURL string
+ defaultModel string
}
// Colors and base styling are provided by logging.go
-func newOpenAIFromEnv(apiKey string) Client {
- base := os.Getenv("OPENAI_BASE_URL")
- if base == "" {
- base = "https://api.openai.com/v1"
+// newOpenAI constructs an OpenAI client using explicit configuration values.
+// The apiKey may be empty; calls will fail until a valid key is supplied.
+func newOpenAI(baseURL, model, apiKey string) Client {
+ if strings.TrimSpace(baseURL) == "" {
+ baseURL = "https://api.openai.com/v1"
}
- model := os.Getenv("OPENAI_MODEL")
- if model == "" {
- model = "gpt-4o-mini"
+ if strings.TrimSpace(model) == "" {
+ model = "gpt-4.1"
}
return &openAIClient{
httpClient: &http.Client{Timeout: 30 * time.Second},
apiKey: apiKey,
- baseURL: base,
+ baseURL: baseURL,
defaultModel: model,
}
}
@@ -82,10 +82,10 @@ func (c *openAIClient) Chat(ctx context.Context, messages []Message, opts ...Req
o.Model = c.defaultModel
}
start := time.Now()
- logging.Logf("llm/openai ", "chat start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", o.Model, o.Temperature, o.MaxTokens, len(o.Stop), len(messages))
+ logging.Logf("llm/openai ", "chat start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", o.Model, o.Temperature, o.MaxTokens, len(o.Stop), len(messages))
for i, m := range messages {
- // Sending context (cyan)
- logging.Logf("llm/openai ", "msg[%d] role=%s size=%d preview=%s%s%s", i, m.Role, len(m.Content), logging.AnsiCyan, logging.PreviewForLog(m.Content), logging.AnsiBase)
+ // Sending context (cyan)
+ logging.Logf("llm/openai ", "msg[%d] role=%s size=%d preview=%s%s%s", i, m.Role, len(m.Content), logging.AnsiCyan, logging.PreviewForLog(m.Content), logging.AnsiBase)
}
req := oaChatRequest{Model: o.Model}
req.Messages = make([]oaMessage, len(messages))
@@ -108,7 +108,7 @@ func (c *openAIClient) Chat(ctx context.Context, messages []Message, opts ...Req
return "", err
}
endpoint := c.baseURL + "/chat/completions"
- logging.Logf("llm/openai ", "POST %s", endpoint)
+ logging.Logf("llm/openai ", "POST %s", endpoint)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
if err != nil {
c.logf("new request error: %v", err)
@@ -118,34 +118,34 @@ func (c *openAIClient) Chat(ctx context.Context, messages []Message, opts ...Req
httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
resp, err := c.httpClient.Do(httpReq)
- if err != nil {
- logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
- return "", err
- }
+ if err != nil {
+ logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
+ return "", err
+ }
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
var apiErr oaChatResponse
_ = json.NewDecoder(resp.Body).Decode(&apiErr)
- if apiErr.Error != nil && apiErr.Error.Message != "" {
- logging.Logf("llm/openai ", "%sapi error status=%d type=%s msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error.Type, apiErr.Error.Message, time.Since(start), logging.AnsiBase)
- return "", fmt.Errorf("openai error: %s (status %d)", apiErr.Error.Message, resp.StatusCode)
- }
- logging.Logf("llm/openai ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase)
- return "", fmt.Errorf("openai http error: status %d", resp.StatusCode)
- }
+ if apiErr.Error != nil && apiErr.Error.Message != "" {
+ logging.Logf("llm/openai ", "%sapi error status=%d type=%s msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error.Type, apiErr.Error.Message, time.Since(start), logging.AnsiBase)
+ return "", fmt.Errorf("openai error: %s (status %d)", apiErr.Error.Message, resp.StatusCode)
+ }
+ logging.Logf("llm/openai ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase)
+ return "", fmt.Errorf("openai http error: status %d", resp.StatusCode)
+ }
var out oaChatResponse
- if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
- logging.Logf("llm/openai ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
- return "", err
- }
- if len(out.Choices) == 0 {
- logging.Logf("llm/openai ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase)
- return "", errors.New("openai: no choices returned")
- }
- content := out.Choices[0].Message.Content
- // Received context (green)
- logging.Logf("llm/openai ", "success choice=0 finish=%s size=%d preview=%s%s%s duration=%s", out.Choices[0].FinishReason, len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start))
- return content, nil
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ logging.Logf("llm/openai ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
+ return "", err
+ }
+ if len(out.Choices) == 0 {
+ logging.Logf("llm/openai ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase)
+ return "", errors.New("openai: no choices returned")
+ }
+ content := out.Choices[0].Message.Content
+ // Received context (green)
+ logging.Logf("llm/openai ", "success choice=0 finish=%s size=%d preview=%s%s%s duration=%s", out.Choices[0].FinishReason, len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start))
+ return content, nil
}
// small helper to keep return type consistent
@@ -161,5 +161,5 @@ func trimPreview(s string, n int) string {
}
// Provider metadata
-func (c *openAIClient) Name() string { return "openai" }
-func (c *openAIClient) DefaultModel() string { return c.defaultModel }
+func (c *openAIClient) Name() string { return "openai" }
+func (c *openAIClient) DefaultModel() string { return c.defaultModel }
diff --git a/internal/llm/provider.go b/internal/llm/provider.go
index f7dad31..c7367ed 100644
--- a/internal/llm/provider.go
+++ b/internal/llm/provider.go
@@ -3,7 +3,6 @@ package llm
import (
"context"
"errors"
- "os"
"strings"
)
@@ -42,34 +41,34 @@ func WithStop(stop ...string) RequestOption {
return func(o *Options) { o.Stop = append([]string{}, stop...) }
}
-// NewDefault returns the default provider using environment configuration.
-// Selection order:
-// 1) HEXAI_LLM_PROVIDER=openai|ollama
-// 2) If OPENAI_API_KEY is set -> OpenAI
-// 3) If any OLLAMA_* vars are set -> Ollama
-func NewDefault() (Client, error) {
- // Explicit provider selection
- if p := strings.ToLower(strings.TrimSpace(os.Getenv("HEXAI_LLM_PROVIDER"))); p != "" {
- switch p {
- case "openai":
- apiKey := os.Getenv("OPENAI_API_KEY")
- if apiKey == "" {
- return nil, errors.New("OPENAI_API_KEY is not set")
- }
- return newOpenAIFromEnv(apiKey), nil
- case "ollama":
- return newOllamaFromEnv(), nil
- default:
- return nil, errors.New("unknown HEXAI_LLM_PROVIDER: " + p)
- }
- }
+// Config defines provider configuration read from the Hexai config file.
+type Config struct {
+ Provider string
+ // OpenAI options
+ OpenAIBaseURL string
+ OpenAIModel string
+ // Ollama options
+ OllamaBaseURL string
+ OllamaModel string
+}
- // Auto-detect
- if apiKey := os.Getenv("OPENAI_API_KEY"); apiKey != "" {
- return newOpenAIFromEnv(apiKey), nil
+// NewFromConfig creates an LLM client using only the supplied configuration.
+// The OpenAI API key is supplied separately and may be read from the environment
+// by the caller; other environment-based configuration is not used.
+func NewFromConfig(cfg Config, openAIAPIKey string) (Client, error) {
+ p := strings.ToLower(strings.TrimSpace(cfg.Provider))
+ if p == "" {
+ p = "openai"
}
- if os.Getenv("OLLAMA_BASE_URL") != "" || os.Getenv("OLLAMA_HOST") != "" || os.Getenv("OLLAMA_MODEL") != "" {
- return newOllamaFromEnv(), nil
+ switch p {
+ case "openai":
+ if strings.TrimSpace(openAIAPIKey) == "" {
+ return nil, errors.New("missing OPENAI_API_KEY for provider openai")
+ }
+ return newOpenAI(cfg.OpenAIBaseURL, cfg.OpenAIModel, openAIAPIKey), nil
+ case "ollama":
+ return newOllama(cfg.OllamaBaseURL, cfg.OllamaModel), nil
+ default:
+ return nil, errors.New("unknown LLM provider: " + p)
}
- return nil, errors.New("no LLM provider configured (set OPENAI_API_KEY or HEXAI_LLM_PROVIDER/OLLAMA_*)")
}