summaryrefslogtreecommitdiff
path: root/internal/llm
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-17 00:06:00 +0300
committerPaul Buetow <paul@buetow.org>2025-08-17 00:06:00 +0300
commitdc383b4faef881f3bb22816f42c53a79236a4152 (patch)
tree7c6a48487fc1d51fed72ea5d15618d133132cdaa /internal/llm
parent6a1d48036105e92193aef11a15a77a569eeb1562 (diff)
lsp/config: make completion trigger characters configurable
- Add trigger_characters to JSON config and ServerOptions - Store on server and advertise in initialize - Update README and example config - Preserve previous defaults when unset
Diffstat (limited to 'internal/llm')
-rw-r--r--internal/llm/ollama.go188
-rw-r--r--internal/llm/provider.go62
2 files changed, 134 insertions, 116 deletions
diff --git a/internal/llm/ollama.go b/internal/llm/ollama.go
index db3e06b..e8b75c9 100644
--- a/internal/llm/ollama.go
+++ b/internal/llm/ollama.go
@@ -1,115 +1,133 @@
package llm
import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "strings"
- "time"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
- "hexai/internal/logging"
+ "hexai/internal/logging"
)
// ollamaClient implements Client against a local Ollama server.
type ollamaClient struct {
- httpClient *http.Client
- baseURL string
- defaultModel string
+ httpClient *http.Client
+ baseURL string
+ defaultModel string
}
func newOllama(baseURL, model string) Client {
- if strings.TrimSpace(baseURL) == "" {
- baseURL = "http://localhost:11434"
- }
- if strings.TrimSpace(model) == "" {
- model = "qwen2.5-coder:latest"
- }
- return &ollamaClient{
- httpClient: &http.Client{Timeout: 30 * time.Second},
- baseURL: strings.TrimRight(baseURL, "/"),
- defaultModel: model,
- }
+ if strings.TrimSpace(baseURL) == "" {
+ baseURL = "http://localhost:11434"
+ }
+ if strings.TrimSpace(model) == "" {
+ model = "qwen2.5-coder:latest"
+ }
+ return &ollamaClient{
+ httpClient: &http.Client{Timeout: 30 * time.Second},
+ baseURL: strings.TrimRight(baseURL, "/"),
+ defaultModel: model,
+ }
}
type ollamaChatRequest struct {
- Model string `json:"model"`
- Messages []oaMessage `json:"messages"`
- Stream bool `json:"stream"`
- Options any `json:"options,omitempty"`
+ Model string `json:"model"`
+ Messages []oaMessage `json:"messages"`
+ Stream bool `json:"stream"`
+ Options any `json:"options,omitempty"`
}
type ollamaChatResponse struct {
- Message struct {
- Role string `json:"role"`
- Content string `json:"content"`
- } `json:"message"`
- Done bool `json:"done"`
- Error string `json:"error,omitempty"`
+ Message struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"message"`
+ Done bool `json:"done"`
+ Error string `json:"error,omitempty"`
}
func (c *ollamaClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) {
- o := Options{Model: c.defaultModel}
- for _, opt := range opts { opt(&o) }
- if o.Model == "" { o.Model = c.defaultModel }
+ o := Options{Model: c.defaultModel}
+ for _, opt := range opts {
+ opt(&o)
+ }
+ if o.Model == "" {
+ o.Model = c.defaultModel
+ }
- start := time.Now()
- logging.Logf("llm/ollama ", "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 {
- logging.Logf("llm/ollama ", "msg[%d] role=%s size=%d preview=%s%s%s", i, m.Role, len(m.Content), logging.AnsiCyan, logging.PreviewForLog(m.Content), logging.AnsiBase)
- }
+ start := time.Now()
+ logging.Logf("llm/ollama ", "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 {
+ logging.Logf("llm/ollama ", "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 := ollamaChatRequest{Model: o.Model, Stream: false}
- req.Messages = make([]oaMessage, len(messages))
- for i, m := range messages { req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content} }
+ req := ollamaChatRequest{Model: o.Model, Stream: false}
+ req.Messages = make([]oaMessage, len(messages))
+ for i, m := range messages {
+ req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content}
+ }
- // Build options map only if any option is set
- optsMap := map[string]any{}
- if o.Temperature != 0 { optsMap["temperature"] = o.Temperature }
- if o.MaxTokens > 0 { optsMap["num_predict"] = o.MaxTokens }
- if len(o.Stop) > 0 { optsMap["stop"] = o.Stop }
- if len(optsMap) > 0 { req.Options = optsMap }
+ // Build options map only if any option is set
+ optsMap := map[string]any{}
+ if o.Temperature != 0 {
+ optsMap["temperature"] = o.Temperature
+ }
+ if o.MaxTokens > 0 {
+ optsMap["num_predict"] = o.MaxTokens
+ }
+ if len(o.Stop) > 0 {
+ optsMap["stop"] = o.Stop
+ }
+ if len(optsMap) > 0 {
+ req.Options = optsMap
+ }
- body, err := json.Marshal(req)
- if err != nil { return "", err }
+ body, err := json.Marshal(req)
+ if err != nil {
+ return "", err
+ }
- endpoint := c.baseURL + "/api/chat"
- logging.Logf("llm/ollama ", "POST %s", endpoint)
- httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
- if err != nil { return "", err }
- httpReq.Header.Set("Content-Type", "application/json")
+ endpoint := c.baseURL + "/api/chat"
+ logging.Logf("llm/ollama ", "POST %s", endpoint)
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ httpReq.Header.Set("Content-Type", "application/json")
- resp, err := c.httpClient.Do(httpReq)
- if err != nil {
- logging.Logf("llm/ollama ", "%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 ollamaChatResponse
- _ = json.NewDecoder(resp.Body).Decode(&apiErr)
- if strings.TrimSpace(apiErr.Error) != "" {
- logging.Logf("llm/ollama ", "%sapi error status=%d msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error, time.Since(start), logging.AnsiBase)
- return "", fmt.Errorf("ollama error: %s (status %d)", apiErr.Error, resp.StatusCode)
- }
- logging.Logf("llm/ollama ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase)
- return "", fmt.Errorf("ollama http error: status %d", resp.StatusCode)
- }
+ resp, err := c.httpClient.Do(httpReq)
+ if err != nil {
+ logging.Logf("llm/ollama ", "%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 ollamaChatResponse
+ _ = json.NewDecoder(resp.Body).Decode(&apiErr)
+ if strings.TrimSpace(apiErr.Error) != "" {
+ logging.Logf("llm/ollama ", "%sapi error status=%d msg=%s duration=%s%s", logging.AnsiRed, resp.StatusCode, apiErr.Error, time.Since(start), logging.AnsiBase)
+ return "", fmt.Errorf("ollama error: %s (status %d)", apiErr.Error, resp.StatusCode)
+ }
+ logging.Logf("llm/ollama ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase)
+ return "", fmt.Errorf("ollama http error: status %d", resp.StatusCode)
+ }
- var out ollamaChatResponse
- if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
- logging.Logf("llm/ollama ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
- return "", err
- }
- if strings.TrimSpace(out.Message.Content) == "" {
- logging.Logf("llm/ollama ", "%sempty content returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase)
- return "", errors.New("ollama: empty content")
- }
- content := out.Message.Content
- logging.Logf("llm/ollama ", "success size=%d preview=%s%s%s duration=%s", len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start))
- return content, nil
+ var out ollamaChatResponse
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ logging.Logf("llm/ollama ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
+ return "", err
+ }
+ if strings.TrimSpace(out.Message.Content) == "" {
+ logging.Logf("llm/ollama ", "%sempty content returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase)
+ return "", errors.New("ollama: empty content")
+ }
+ content := out.Message.Content
+ logging.Logf("llm/ollama ", "success size=%d preview=%s%s%s duration=%s", len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start))
+ return content, nil
}
// Provider metadata
diff --git a/internal/llm/provider.go b/internal/llm/provider.go
index c7367ed..6c6cf04 100644
--- a/internal/llm/provider.go
+++ b/internal/llm/provider.go
@@ -1,9 +1,9 @@
package llm
import (
- "context"
- "errors"
- "strings"
+ "context"
+ "errors"
+ "strings"
)
// Message represents a chat-style prompt message.
@@ -15,12 +15,12 @@ type Message struct {
// Client is a minimal LLM provider interface.
// Future providers (Ollama, etc.) should implement this.
type Client interface {
- // Chat sends chat messages and returns the assistant text.
- Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error)
- // Name returns the provider's short name (e.g., "openai", "ollama").
- Name() string
- // DefaultModel returns the configured default model name.
- DefaultModel() string
+ // Chat sends chat messages and returns the assistant text.
+ Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error)
+ // Name returns the provider's short name (e.g., "openai", "ollama").
+ Name() string
+ // DefaultModel returns the configured default model name.
+ DefaultModel() string
}
// Options for a request. Providers may ignore unsupported fields.
@@ -43,32 +43,32 @@ func WithStop(stop ...string) RequestOption {
// 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
+ Provider string
+ // OpenAI options
+ OpenAIBaseURL string
+ OpenAIModel string
+ // Ollama options
+ OllamaBaseURL string
+ OllamaModel string
}
// 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"
- }
- 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)
- }
+ p := strings.ToLower(strings.TrimSpace(cfg.Provider))
+ if p == "" {
+ p = "openai"
+ }
+ 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)
+ }
}