diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-17 00:06:00 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-17 00:06:00 +0300 |
| commit | dc383b4faef881f3bb22816f42c53a79236a4152 (patch) | |
| tree | 7c6a48487fc1d51fed72ea5d15618d133132cdaa /internal/llm | |
| parent | 6a1d48036105e92193aef11a15a77a569eeb1562 (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.go | 188 | ||||
| -rw-r--r-- | internal/llm/provider.go | 62 |
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) + } } |
