diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-16 23:16:54 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-16 23:16:54 +0300 |
| commit | 765eda955eb811d08d867ff4d3914fc6d60c22dd (patch) | |
| tree | fdc87da6af9d86dbda2ea9ab08244e93fd167188 /internal/llm | |
| parent | 1b01e35c34b953cbf51298f4650dc3215c382a4f (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.go | 17 | ||||
| -rw-r--r-- | internal/llm/openai.go | 84 | ||||
| -rw-r--r-- | internal/llm/provider.go | 55 |
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_*)") } |
