summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-18 09:28:48 +0300
committerPaul Buetow <paul@buetow.org>2025-08-18 09:28:48 +0300
commit96ace6c7019a914e21b25fa94ddfc4ee9239c2fb (patch)
tree30550bcab30c91e917a4d8b3feccda829a364437
parent6d29ac7e4b2604b5c7df50f33f8ef2357709faf2 (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.go228
-rw-r--r--internal/hexailsp/run.go94
-rw-r--r--internal/llm/copilot.go166
-rw-r--r--internal/llm/copilot_test.go15
-rw-r--r--internal/llm/ollama.go192
-rw-r--r--internal/llm/ollama_test.go18
-rw-r--r--internal/llm/openai.go281
-rw-r--r--internal/llm/openai_test.go44
-rw-r--r--internal/lsp/codeaction_test.go63
-rw-r--r--internal/lsp/handlers.go339
-rw-r--r--internal/lsp/handlers_helpers_test.go52
-rw-r--r--internal/lsp/handlers_test.go30
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"