diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-18 09:28:48 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-18 09:28:48 +0300 |
| commit | 96ace6c7019a914e21b25fa94ddfc4ee9239c2fb (patch) | |
| tree | 30550bcab30c91e917a4d8b3feccda829a364437 | |
| parent | 6d29ac7e4b2604b5c7df50f33f8ef2357709faf2 (diff) | |
refactor(lsp,llm,hexailsp,appconfig): split long funcs; add tests
- Extract helpers to keep funcs <=50 lines; no behavior changes
- Add tests for prompt removal, code actions, and LLM request builders
- Table-drive TestInParamList; run gofmt
| -rw-r--r-- | internal/appconfig/config.go | 228 | ||||
| -rw-r--r-- | internal/hexailsp/run.go | 94 | ||||
| -rw-r--r-- | internal/llm/copilot.go | 166 | ||||
| -rw-r--r-- | internal/llm/copilot_test.go | 15 | ||||
| -rw-r--r-- | internal/llm/ollama.go | 192 | ||||
| -rw-r--r-- | internal/llm/ollama_test.go | 18 | ||||
| -rw-r--r-- | internal/llm/openai.go | 281 | ||||
| -rw-r--r-- | internal/llm/openai_test.go | 44 | ||||
| -rw-r--r-- | internal/lsp/codeaction_test.go | 63 | ||||
| -rw-r--r-- | internal/lsp/handlers.go | 339 | ||||
| -rw-r--r-- | internal/lsp/handlers_helpers_test.go | 52 | ||||
| -rw-r--r-- | internal/lsp/handlers_test.go | 30 |
12 files changed, 864 insertions, 658 deletions
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index 4fa3441..3067dd1 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -13,57 +13,57 @@ import ( // App holds user-configurable settings read from ~/.config/hexai/config.json. type App struct { - MaxTokens int `json:"max_tokens"` + MaxTokens int `json:"max_tokens"` ContextMode string `json:"context_mode"` ContextWindowLines int `json:"context_window_lines"` MaxContextTokens int `json:"max_context_tokens"` - LogPreviewLimit int `json:"log_preview_limit"` - // Single knob for LSP requests; if set, overrides hardcoded temps in LSP. - CodingTemperature *float64 `json:"coding_temperature"` + LogPreviewLimit int `json:"log_preview_limit"` + // Single knob for LSP requests; if set, overrides hardcoded temps in LSP. + CodingTemperature *float64 `json:"coding_temperature"` TriggerCharacters []string `json:"trigger_characters"` Provider string `json:"provider"` - // Provider-specific options - OpenAIBaseURL string `json:"openai_base_url"` - OpenAIModel string `json:"openai_model"` - // Default temperature for OpenAI requests (nil means use provider default) - OpenAITemperature *float64 `json:"openai_temperature"` - OllamaBaseURL string `json:"ollama_base_url"` - OllamaModel string `json:"ollama_model"` - // Default temperature for Ollama requests (nil means use provider default) - OllamaTemperature *float64 `json:"ollama_temperature"` - CopilotBaseURL string `json:"copilot_base_url"` - CopilotModel string `json:"copilot_model"` - // Default temperature for Copilot requests (nil means use provider default) - CopilotTemperature *float64 `json:"copilot_temperature"` + // Provider-specific options + OpenAIBaseURL string `json:"openai_base_url"` + OpenAIModel string `json:"openai_model"` + // Default temperature for OpenAI requests (nil means use provider default) + OpenAITemperature *float64 `json:"openai_temperature"` + OllamaBaseURL string `json:"ollama_base_url"` + OllamaModel string `json:"ollama_model"` + // Default temperature for Ollama requests (nil means use provider default) + OllamaTemperature *float64 `json:"ollama_temperature"` + CopilotBaseURL string `json:"copilot_base_url"` + CopilotModel string `json:"copilot_model"` + // Default temperature for Copilot requests (nil means use provider default) + CopilotTemperature *float64 `json:"copilot_temperature"` } // Constructor: defaults for App (kept first among functions) func newDefaultConfig() App { - // Coding-friendly default temperature across providers - // Users can override per provider in config.json (including 0.0). - t := 0.2 - return App{ - MaxTokens: 4000, - ContextMode: "always-full", - ContextWindowLines: 120, - MaxContextTokens: 4000, - LogPreviewLimit: 100, - CodingTemperature: &t, - OpenAITemperature: &t, - OllamaTemperature: &t, - CopilotTemperature: &t, - } + // Coding-friendly default temperature across providers + // Users can override per provider in config.json (including 0.0). + t := 0.2 + return App{ + MaxTokens: 4000, + ContextMode: "always-full", + ContextWindowLines: 120, + MaxContextTokens: 4000, + LogPreviewLimit: 100, + CodingTemperature: &t, + OpenAITemperature: &t, + OllamaTemperature: &t, + CopilotTemperature: &t, + } } // Load reads configuration from a file and merges with defaults. // It respects the XDG Base Directory Specification. func Load(logger *log.Logger) App { - cfg := newDefaultConfig() - if logger == nil { - return cfg // Return defaults if no logger is provided (e.g. in tests) - } + cfg := newDefaultConfig() + if logger == nil { + return cfg // Return defaults if no logger is provided (e.g. in tests) + } configPath, err := getConfigPath() if err != nil { @@ -76,91 +76,101 @@ func Load(logger *log.Logger) App { return cfg } - cfg.mergeWith(fileCfg) - return cfg + cfg.mergeWith(fileCfg) + return cfg } // Private helpers func loadFromFile(path string, logger *log.Logger) (*App, error) { - f, err := os.Open(path) - if err != nil { - if !os.IsNotExist(err) && logger != nil { - logger.Printf("cannot open config file %s: %v", path, err) - } - return nil, err - } - defer f.Close() + f, err := os.Open(path) + if err != nil { + if !os.IsNotExist(err) && logger != nil { + logger.Printf("cannot open config file %s: %v", path, err) + } + return nil, err + } + defer f.Close() - dec := json.NewDecoder(f) - var fileCfg App - if err := dec.Decode(&fileCfg); err != nil { - if logger != nil { - logger.Printf("invalid config file %s: %v", path, err) - } - return nil, err - } - return &fileCfg, nil + dec := json.NewDecoder(f) + var fileCfg App + if err := dec.Decode(&fileCfg); err != nil { + if logger != nil { + logger.Printf("invalid config file %s: %v", path, err) + } + return nil, err + } + return &fileCfg, nil } func (a *App) mergeWith(other *App) { - if other.MaxTokens > 0 { - a.MaxTokens = other.MaxTokens - } - if strings.TrimSpace(other.ContextMode) != "" { - a.ContextMode = other.ContextMode - } - if other.ContextWindowLines > 0 { - a.ContextWindowLines = other.ContextWindowLines - } - if other.MaxContextTokens > 0 { - a.MaxContextTokens = other.MaxContextTokens - } - if other.LogPreviewLimit >= 0 { - a.LogPreviewLimit = other.LogPreviewLimit - } - if other.CodingTemperature != nil { // allow explicit 0.0 - a.CodingTemperature = other.CodingTemperature - } - if len(other.TriggerCharacters) > 0 { - a.TriggerCharacters = slices.Clone(other.TriggerCharacters) - } - if strings.TrimSpace(other.Provider) != "" { - a.Provider = other.Provider - } - if strings.TrimSpace(other.OpenAIBaseURL) != "" { - a.OpenAIBaseURL = other.OpenAIBaseURL - } - if strings.TrimSpace(other.OpenAIModel) != "" { - a.OpenAIModel = other.OpenAIModel - } - if other.OpenAITemperature != nil { // allow explicit 0.0 - a.OpenAITemperature = other.OpenAITemperature - } - if strings.TrimSpace(other.OllamaBaseURL) != "" { - a.OllamaBaseURL = other.OllamaBaseURL - } - if strings.TrimSpace(other.OllamaModel) != "" { - a.OllamaModel = other.OllamaModel - } - if other.OllamaTemperature != nil { // allow explicit 0.0 - a.OllamaTemperature = other.OllamaTemperature - } - if strings.TrimSpace(other.CopilotBaseURL) != "" { - a.CopilotBaseURL = other.CopilotBaseURL - } - if strings.TrimSpace(other.CopilotModel) != "" { - a.CopilotModel = other.CopilotModel - } - if other.CopilotTemperature != nil { // allow explicit 0.0 - a.CopilotTemperature = other.CopilotTemperature - } + a.mergeBasics(other) + a.mergeProviderFields(other) +} + +// mergeBasics merges general (non-provider) fields. +func (a *App) mergeBasics(other *App) { + if other.MaxTokens > 0 { + a.MaxTokens = other.MaxTokens + } + if s := strings.TrimSpace(other.ContextMode); s != "" { + a.ContextMode = s + } + if other.ContextWindowLines > 0 { + a.ContextWindowLines = other.ContextWindowLines + } + if other.MaxContextTokens > 0 { + a.MaxContextTokens = other.MaxContextTokens + } + if other.LogPreviewLimit >= 0 { + a.LogPreviewLimit = other.LogPreviewLimit + } + if other.CodingTemperature != nil { // allow explicit 0.0 + a.CodingTemperature = other.CodingTemperature + } + if len(other.TriggerCharacters) > 0 { + a.TriggerCharacters = slices.Clone(other.TriggerCharacters) + } + if s := strings.TrimSpace(other.Provider); s != "" { + a.Provider = s + } +} + +// mergeProviderFields merges per-provider configuration. +func (a *App) mergeProviderFields(other *App) { + if s := strings.TrimSpace(other.OpenAIBaseURL); s != "" { + a.OpenAIBaseURL = s + } + if s := strings.TrimSpace(other.OpenAIModel); s != "" { + a.OpenAIModel = s + } + if other.OpenAITemperature != nil { // allow explicit 0.0 + a.OpenAITemperature = other.OpenAITemperature + } + if s := strings.TrimSpace(other.OllamaBaseURL); s != "" { + a.OllamaBaseURL = s + } + if s := strings.TrimSpace(other.OllamaModel); s != "" { + a.OllamaModel = s + } + if other.OllamaTemperature != nil { // allow explicit 0.0 + a.OllamaTemperature = other.OllamaTemperature + } + if s := strings.TrimSpace(other.CopilotBaseURL); s != "" { + a.CopilotBaseURL = s + } + if s := strings.TrimSpace(other.CopilotModel); s != "" { + a.CopilotModel = s + } + if other.CopilotTemperature != nil { // allow explicit 0.0 + a.CopilotTemperature = other.CopilotTemperature + } } func getConfigPath() (string, error) { - var configPath string - if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { - configPath = filepath.Join(xdgConfigHome, "hexai", "config.json") - } else { + var configPath string + if xdgConfigHome := os.Getenv("XDG_CONFIG_HOME"); xdgConfigHome != "" { + configPath = filepath.Join(xdgConfigHome, "hexai", "config.json") + } else { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("cannot find user home directory: %v", err) diff --git a/internal/hexailsp/run.go b/internal/hexailsp/run.go index 1beb93a..64607e3 100644 --- a/internal/hexailsp/run.go +++ b/internal/hexailsp/run.go @@ -40,56 +40,72 @@ func Run(logPath string, stdin io.Reader, stdout io.Writer, stderr io.Writer) er // RunWithFactory is the testable entrypoint. When client is nil, it is built from cfg+env. // When factory is nil, lsp.NewServer is used. func RunWithFactory(logPath string, stdin io.Reader, stdout io.Writer, logger *log.Logger, cfg appconfig.App, client llm.Client, factory ServerFactory) error { - // Normalize and apply logging config + normalizeLoggingConfig(&cfg) + client = buildClientIfNil(cfg, client) + factory = ensureFactory(factory) + + opts := makeServerOptions(cfg, strings.TrimSpace(logPath) != "", client) + server := factory(stdin, stdout, logger, opts) + if err := server.Run(); err != nil { + logger.Fatalf("server error: %v", err) + } + return nil +} + +// --- helpers to keep RunWithFactory small --- + +func normalizeLoggingConfig(cfg *appconfig.App) { cfg.ContextMode = strings.ToLower(strings.TrimSpace(cfg.ContextMode)) if cfg.LogPreviewLimit >= 0 { logging.SetLogPreviewLimit(cfg.LogPreviewLimit) } +} - // Build LLM client if not provided - if client == nil { - llmCfg := llm.Config{ - Provider: cfg.Provider, - OpenAIBaseURL: cfg.OpenAIBaseURL, - OpenAIModel: cfg.OpenAIModel, - OpenAITemperature: cfg.OpenAITemperature, - OllamaBaseURL: cfg.OllamaBaseURL, - OllamaModel: cfg.OllamaModel, - OllamaTemperature: cfg.OllamaTemperature, - CopilotBaseURL: cfg.CopilotBaseURL, - CopilotModel: cfg.CopilotModel, - CopilotTemperature: cfg.CopilotTemperature, - } - oaKey := os.Getenv("OPENAI_API_KEY") - cpKey := os.Getenv("COPILOT_API_KEY") - if c, err := llm.NewFromConfig(llmCfg, oaKey, cpKey); err != nil { - logging.Logf("lsp ", "llm disabled: %v", err) - } else { - client = c - logging.Logf("lsp ", "llm enabled provider=%s model=%s", c.Name(), c.DefaultModel()) - } +func buildClientIfNil(cfg appconfig.App, client llm.Client) llm.Client { + if client != nil { + return client } - - if factory == nil { - factory = func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.ServerOptions) ServerRunner { - return lsp.NewServer(r, w, logger, opts) - } + llmCfg := llm.Config{ + Provider: cfg.Provider, + OpenAIBaseURL: cfg.OpenAIBaseURL, + OpenAIModel: cfg.OpenAIModel, + OpenAITemperature: cfg.OpenAITemperature, + OllamaBaseURL: cfg.OllamaBaseURL, + OllamaModel: cfg.OllamaModel, + OllamaTemperature: cfg.OllamaTemperature, + CopilotBaseURL: cfg.CopilotBaseURL, + CopilotModel: cfg.CopilotModel, + CopilotTemperature: cfg.CopilotTemperature, + } + oaKey := os.Getenv("OPENAI_API_KEY") + cpKey := os.Getenv("COPILOT_API_KEY") + if c, err := llm.NewFromConfig(llmCfg, oaKey, cpKey); err != nil { + logging.Logf("lsp ", "llm disabled: %v", err) + return nil + } else { + logging.Logf("lsp ", "llm enabled provider=%s model=%s", c.Name(), c.DefaultModel()) + return c } +} - server := factory(stdin, stdout, logger, lsp.ServerOptions{ - LogContext: strings.TrimSpace(logPath) != "", - MaxTokens: cfg.MaxTokens, - ContextMode: cfg.ContextMode, - WindowLines: cfg.ContextWindowLines, - MaxContextTokens: cfg.MaxContextTokens, +func ensureFactory(factory ServerFactory) ServerFactory { + if factory != nil { + return factory + } + return func(r io.Reader, w io.Writer, logger *log.Logger, opts lsp.ServerOptions) ServerRunner { + return lsp.NewServer(r, w, logger, opts) + } +} +func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) lsp.ServerOptions { + return lsp.ServerOptions{ + LogContext: logContext, + MaxTokens: cfg.MaxTokens, + ContextMode: cfg.ContextMode, + WindowLines: cfg.ContextWindowLines, + MaxContextTokens: cfg.MaxContextTokens, CodingTemperature: cfg.CodingTemperature, - Client: client, TriggerCharacters: cfg.TriggerCharacters, - }) - if err := server.Run(); err != nil { - logger.Fatalf("server error: %v", err) } - return nil } diff --git a/internal/llm/copilot.go b/internal/llm/copilot.go index 47ce11e..67cffc9 100644 --- a/internal/llm/copilot.go +++ b/internal/llm/copilot.go @@ -17,20 +17,20 @@ import ( // copilotClient implements Client against GitHub Copilot's Chat Completions API. type copilotClient struct { - httpClient *http.Client - apiKey string - baseURL string - defaultModel string - chatLogger logging.ChatLogger - defaultTemperature *float64 + httpClient *http.Client + apiKey string + baseURL string + defaultModel string + chatLogger logging.ChatLogger + defaultTemperature *float64 } type copilotChatRequest struct { - Model string `json:"model"` - Messages []copilotMessage `json:"messages"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens *int `json:"max_tokens,omitempty"` - Stop []string `json:"stop,omitempty"` + Model string `json:"model"` + Messages []copilotMessage `json:"messages"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + Stop []string `json:"stop,omitempty"` } type copilotMessage struct { @@ -57,20 +57,20 @@ type copilotChatResponse struct { // Constructor (kept among the first functions by convention) func newCopilot(baseURL, model, apiKey string, defaultTemp *float64) Client { - if strings.TrimSpace(baseURL) == "" { - baseURL = "https://api.githubcopilot.com" - } - if strings.TrimSpace(model) == "" { - model = "gpt-4.1" - } - return copilotClient{ - httpClient: &http.Client{Timeout: 30 * time.Second}, - apiKey: apiKey, - baseURL: strings.TrimRight(baseURL, "/"), - defaultModel: model, - chatLogger: logging.NewChatLogger("copilot"), - defaultTemperature: defaultTemp, - } + if strings.TrimSpace(baseURL) == "" { + baseURL = "https://api.githubcopilot.com" + } + if strings.TrimSpace(model) == "" { + model = "gpt-4.1" + } + return copilotClient{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + apiKey: apiKey, + baseURL: strings.TrimRight(baseURL, "/"), + defaultModel: model, + chatLogger: logging.NewChatLogger("copilot"), + defaultTemperature: defaultTemp, + } } func (c copilotClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) { @@ -84,38 +84,14 @@ func (c copilotClient) Chat(ctx context.Context, messages []Message, opts ...Req if o.Model == "" { o.Model = c.defaultModel } - start := time.Now() - logMessages := make([]struct { - Role string - Content string - }, len(messages)) + logMessages := make([]struct{ Role, Content string }, len(messages)) for i, m := range messages { - logMessages[i] = struct { - Role string - Content string - }{Role: m.Role, Content: m.Content} + logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} } c.chatLogger.LogStart(false, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages) - req := copilotChatRequest{Model: o.Model} - req.Messages = make([]copilotMessage, len(messages)) - for i, m := range messages { - req.Messages[i] = copilotMessage{Role: m.Role, Content: m.Content} - } - if o.Temperature != 0 { - req.Temperature = &o.Temperature - } else if c.defaultTemperature != nil { - t := *c.defaultTemperature - req.Temperature = &t - } - if o.MaxTokens > 0 { - req.MaxTokens = &o.MaxTokens - } - if len(o.Stop) > 0 { - req.Stop = o.Stop - } - + req := buildCopilotChatRequest(o, messages, c.defaultTemperature) body, err := json.Marshal(req) if err != nil { logging.Logf("llm/copilot ", "marshal error: %v", err) @@ -124,34 +100,19 @@ func (c copilotClient) Chat(ctx context.Context, messages []Message, opts ...Req endpoint := c.baseURL + "/chat/completions" logging.Logf("llm/copilot ", "POST %s", endpoint) - httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - logging.Logf("llm/copilot ", "new request error: %v", err) - return "", err - } - httpReq.Header.Set("Content-Type", "application/json") - httpReq.Header.Set("Authorization", "Bearer "+c.apiKey) - - resp, err := c.httpClient.Do(httpReq) + resp, err := c.doJSON(ctx, endpoint, body, map[string]string{ + "Authorization": "Bearer " + c.apiKey, + }) if err != nil { logging.Logf("llm/copilot ", "%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 copilotChatResponse - _ = json.NewDecoder(resp.Body).Decode(&apiErr) - if apiErr.Error != nil && strings.TrimSpace(apiErr.Error.Message) != "" { - logging.Logf("llm/copilot ", "%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("copilot error: %s (status %d)", apiErr.Error.Message, resp.StatusCode) - } - logging.Logf("llm/copilot ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) - return "", fmt.Errorf("copilot http error: status %d", resp.StatusCode) + if err := handleCopilotNon2xx(resp, start); err != nil { + return "", err } - - var out copilotChatResponse - if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { - logging.Logf("llm/copilot ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + out, err := decodeCopilotChat(resp, start) + if err != nil { return "", err } if len(out.Choices) == 0 { @@ -166,3 +127,60 @@ func (c copilotClient) Chat(ctx context.Context, messages []Message, opts ...Req // Provider metadata func (c copilotClient) Name() string { return "copilot" } func (c copilotClient) DefaultModel() string { return c.defaultModel } + +// helpers +func buildCopilotChatRequest(o Options, messages []Message, defaultTemp *float64) copilotChatRequest { + req := copilotChatRequest{Model: o.Model} + req.Messages = make([]copilotMessage, len(messages)) + for i, m := range messages { + req.Messages[i] = copilotMessage{Role: m.Role, Content: m.Content} + } + if o.Temperature != 0 { + req.Temperature = &o.Temperature + } else if defaultTemp != nil { + t := *defaultTemp + req.Temperature = &t + } + if o.MaxTokens > 0 { + req.MaxTokens = &o.MaxTokens + } + if len(o.Stop) > 0 { + req.Stop = o.Stop + } + return req +} + +func (c copilotClient) doJSON(ctx context.Context, url string, body []byte, headers map[string]string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + for k, v := range headers { + req.Header.Set(k, v) + } + return c.httpClient.Do(req) +} + +func handleCopilotNon2xx(resp *http.Response, start time.Time) error { + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + var apiErr copilotChatResponse + _ = json.NewDecoder(resp.Body).Decode(&apiErr) + if apiErr.Error != nil && strings.TrimSpace(apiErr.Error.Message) != "" { + logging.Logf("llm/copilot ", "%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("copilot error: %s (status %d)", apiErr.Error.Message, resp.StatusCode) + } + logging.Logf("llm/copilot ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase) + return fmt.Errorf("copilot http error: status %d", resp.StatusCode) +} + +func decodeCopilotChat(resp *http.Response, start time.Time) (copilotChatResponse, error) { + var out copilotChatResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + logging.Logf("llm/copilot ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase) + return copilotChatResponse{}, err + } + return out, nil +} diff --git a/internal/llm/copilot_test.go b/internal/llm/copilot_test.go new file mode 100644 index 0000000..5492713 --- /dev/null +++ b/internal/llm/copilot_test.go @@ -0,0 +1,15 @@ +package llm + +import "testing" + +func TestBuildCopilotChatRequest_FieldsAndDefaults(t *testing.T) { + o := Options{Model: "gpt-x", Temperature: 0, MaxTokens: 123, Stop: []string{"X"}} + msgs := []Message{{Role: "user", Content: "q"}} + req := buildCopilotChatRequest(o, msgs, f64p(0.5)) + if req.Model != "gpt-x" { t.Fatalf("model mismatch: %q", req.Model) } + if req.Temperature == nil || *req.Temperature != 0.5 { t.Fatalf("default temp not applied") } + if req.MaxTokens == nil || *req.MaxTokens != 123 { t.Fatalf("max_tokens not applied") } + if len(req.Stop) != 1 || req.Stop[0] != "X" { t.Fatalf("stop not applied") } + if len(req.Messages) != 1 || req.Messages[0].Content != "q" { t.Fatalf("messages not copied") } +} + diff --git a/internal/llm/ollama.go b/internal/llm/ollama.go index 20dfe2a..50e9837 100644 --- a/internal/llm/ollama.go +++ b/internal/llm/ollama.go @@ -1,5 +1,4 @@ // Summary: Ollama client against a local server; supports chat responses and streaming via /api/chat. -// Not yet reviewed by a human package llm import ( @@ -18,11 +17,11 @@ import ( // ollamaClient implements Client against a local Ollama server. type ollamaClient struct { - httpClient *http.Client - baseURL string - defaultModel string - chatLogger logging.ChatLogger - defaultTemperature *float64 + httpClient *http.Client + baseURL string + defaultModel string + chatLogger logging.ChatLogger + defaultTemperature *float64 } type ollamaChatRequest struct { @@ -49,13 +48,13 @@ func newOllama(baseURL, model string, defaultTemp *float64) Client { if strings.TrimSpace(model) == "" { model = "qwen3-coder:30b-a3b-q4_K_M`" } - return ollamaClient{ - httpClient: &http.Client{Timeout: 30 * time.Second}, - baseURL: strings.TrimRight(baseURL, "/"), - defaultModel: model, - chatLogger: logging.NewChatLogger("ollama"), - defaultTemperature: defaultTemp, - } + return ollamaClient{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + baseURL: strings.TrimRight(baseURL, "/"), + defaultModel: model, + chatLogger: logging.NewChatLogger("ollama"), + defaultTemperature: defaultTemp, + } } // TODO: This function is too long and should be refactored for readability and maintainability. @@ -69,41 +68,8 @@ func (c ollamaClient) Chat(ctx context.Context, messages []Message, opts ...Requ } start := time.Now() - logMessages := make([]struct { - Role string - Content string - }, len(messages)) - for i, m := range messages { - logMessages[i] = struct { - Role string - Content string - }{Role: m.Role, Content: m.Content} - } - c.chatLogger.LogStart(false, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages) - - 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 - } else if c.defaultTemperature != nil { - optsMap["temperature"] = *c.defaultTemperature - } - 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 - } - + c.logStart(false, o, messages) + req := buildOllamaRequest(o, messages, c.defaultTemperature, false) body, err := json.Marshal(req) if err != nil { return "", err @@ -111,27 +77,14 @@ func (c ollamaClient) Chat(ctx context.Context, messages []Message, opts ...Requ 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) + resp, err := c.doJSON(ctx, endpoint, body) 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) + if err := handleOllamaNon2xx(resp, start); err != nil { + return "", err } var out ollamaChatResponse @@ -163,40 +116,8 @@ func (c ollamaClient) ChatStream(ctx context.Context, messages []Message, onDelt } start := time.Now() - logMessages := make([]struct { - Role string - Content string - }, len(messages)) - for i, m := range messages { - logMessages[i] = struct { - Role string - Content string - }{Role: m.Role, Content: m.Content} - } - c.chatLogger.LogStart(true, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages) - - req := ollamaChatRequest{Model: o.Model, Stream: true} - req.Messages = make([]oaMessage, len(messages)) - for i, m := range messages { - req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content} - } - // Build options map - optsMap := map[string]any{} - if o.Temperature != 0 { - optsMap["temperature"] = o.Temperature - } else if c.defaultTemperature != nil { - optsMap["temperature"] = *c.defaultTemperature - } - 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 - } - + c.logStart(true, o, messages) + req := buildOllamaRequest(o, messages, c.defaultTemperature, true) body, err := json.Marshal(req) if err != nil { return err @@ -204,27 +125,14 @@ func (c ollamaClient) ChatStream(ctx context.Context, messages []Message, onDelt endpoint := c.baseURL + "/api/chat" logging.Logf("llm/ollama ", "POST %s (stream)", 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) + resp, err := c.doJSON(ctx, endpoint, body) 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) + if err := handleOllamaNon2xx(resp, start); err != nil { + return err } dec := json.NewDecoder(resp.Body) @@ -251,3 +159,59 @@ func (c ollamaClient) ChatStream(ctx context.Context, messages []Message, onDelt logging.Logf("llm/ollama ", "stream end duration=%s", time.Since(start)) return nil } + +// helpers to keep methods small +func (c ollamaClient) logStart(stream bool, o Options, messages []Message) { + logMessages := make([]struct{ Role, Content string }, len(messages)) + for i, m := range messages { + logMessages[i] = struct{ Role, Content string }{m.Role, m.Content} + } + c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages) +} + +func buildOllamaRequest(o Options, messages []Message, defaultTemp *float64, stream bool) ollamaChatRequest { + req := ollamaChatRequest{Model: o.Model, Stream: stream} + req.Messages = make([]oaMessage, len(messages)) + for i, m := range messages { + req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content} + } + optsMap := map[string]any{} + if o.Temperature != 0 { + optsMap["temperature"] = o.Temperature + } else if defaultTemp != nil { + optsMap["temperature"] = *defaultTemp + } + 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 + } + return req +} + +func (c ollamaClient) doJSON(ctx context.Context, url string, body []byte) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + return c.httpClient.Do(req) +} + +func handleOllamaNon2xx(resp *http.Response, start time.Time) error { + if resp.StatusCode >= 200 && resp.StatusCode < 300 { + return nil + } + 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) +} diff --git a/internal/llm/ollama_test.go b/internal/llm/ollama_test.go new file mode 100644 index 0000000..4ad6fdf --- /dev/null +++ b/internal/llm/ollama_test.go @@ -0,0 +1,18 @@ +package llm + +import "testing" + +func TestBuildOllamaRequest_OptionsAndStream(t *testing.T) { + o := Options{Model: "codemodel", Temperature: 0, MaxTokens: 256, Stop: []string{"STOP"}} + msgs := []Message{{Role: "user", Content: "hello"}} + req := buildOllamaRequest(o, msgs, f64p(0.2), false) + if req.Model != "codemodel" || req.Stream { t.Fatalf("model/stream mismatch: %+v", req) } + if req.Options == nil { t.Fatalf("expected options map") } + if req.Options.(map[string]any)["temperature"].(float64) != 0.2 { t.Fatalf("default temp not applied") } + if req.Options.(map[string]any)["num_predict"].(int) != 256 { t.Fatalf("num_predict not applied") } + if req.Options.(map[string]any)["stop"].([]string)[0] != "STOP" { t.Fatalf("stop not applied") } + + req2 := buildOllamaRequest(o, msgs, f64p(0.2), true) + if !req2.Stream { t.Fatalf("expected stream=true") } +} + diff --git a/internal/llm/openai.go b/internal/llm/openai.go index 5348def..69c0cfc 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -13,26 +13,26 @@ import ( "strings" "time" |
