diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-17 23:03:24 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-17 23:03:24 +0300 |
| commit | d72f95ae4e6cd4e7a0beca2b9764511c10de8655 (patch) | |
| tree | d5b034abce358cc26f271c3fc492d97da6e5734c | |
| parent | 8dfbbbb6de0f0c67413ee157e976fc3eaee4f914 (diff) | |
refactor(ordering): types/constants first; exported before private; ensure consistent receiver semantics per file
| -rw-r--r-- | internal/appconfig/config.go | 167 | ||||
| -rw-r--r-- | internal/llm/copilot.go | 51 | ||||
| -rw-r--r-- | internal/llm/ollama.go | 51 | ||||
| -rw-r--r-- | internal/llm/openai.go | 96 |
4 files changed, 186 insertions, 179 deletions
diff --git a/internal/appconfig/config.go b/internal/appconfig/config.go index c5166a0..1f7e9d8 100644 --- a/internal/appconfig/config.go +++ b/internal/appconfig/config.go @@ -31,86 +31,13 @@ type App struct { CopilotModel string `json:"copilot_model"` } -func newDefaultConfig() App { - return App{ - MaxTokens: 4000, - ContextMode: "always-full", - ContextWindowLines: 120, - MaxContextTokens: 4000, - LogPreviewLimit: 100, - } -} - -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() - - 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 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 strings.TrimSpace(other.OllamaBaseURL) != "" { - a.OllamaBaseURL = other.OllamaBaseURL - } - if strings.TrimSpace(other.OllamaModel) != "" { - a.OllamaModel = other.OllamaModel - } - if strings.TrimSpace(other.CopilotBaseURL) != "" { - a.CopilotBaseURL = other.CopilotBaseURL - } - if strings.TrimSpace(other.CopilotModel) != "" { - a.CopilotModel = other.CopilotModel - } -} - // 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 { @@ -123,15 +50,89 @@ func Load(logger *log.Logger) App { return cfg } - cfg.mergeWith(fileCfg) - return cfg + cfg.mergeWith(fileCfg) + return cfg +} + +// Private helpers +func newDefaultConfig() App { + return App{ + MaxTokens: 4000, + ContextMode: "always-full", + ContextWindowLines: 120, + MaxContextTokens: 4000, + LogPreviewLimit: 100, + } +} + +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() + + 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 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 strings.TrimSpace(other.OllamaBaseURL) != "" { + a.OllamaBaseURL = other.OllamaBaseURL + } + if strings.TrimSpace(other.OllamaModel) != "" { + a.OllamaModel = other.OllamaModel + } + if strings.TrimSpace(other.CopilotBaseURL) != "" { + a.CopilotBaseURL = other.CopilotBaseURL + } + if strings.TrimSpace(other.CopilotModel) != "" { + a.CopilotModel = other.CopilotModel + } } 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/llm/copilot.go b/internal/llm/copilot.go index cf24565..22b0ae4 100644 --- a/internal/llm/copilot.go +++ b/internal/llm/copilot.go @@ -17,35 +17,19 @@ import ( // copilotClient implements Client against GitHub Copilot's Chat Completions API. type copilotClient struct { - httpClient *http.Client - apiKey string - baseURL string - defaultModel string + httpClient *http.Client + apiKey string + baseURL string + defaultModel string chatLogger logging.ChatLogger } -func newCopilot(baseURL, model, apiKey string) 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"), - } -} - 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 { @@ -160,3 +144,20 @@ 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 } + +// Private constructor +func newCopilot(baseURL, model, apiKey string) 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"), + } +} diff --git a/internal/llm/ollama.go b/internal/llm/ollama.go index a53716b..a796d8c 100644 --- a/internal/llm/ollama.go +++ b/internal/llm/ollama.go @@ -18,32 +18,17 @@ import ( // ollamaClient implements Client against a local Ollama server. type ollamaClient struct { - httpClient *http.Client - baseURL string - defaultModel string - chatLogger logging.ChatLogger -} - -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, - chatLogger: logging.NewChatLogger("ollama"), - } + httpClient *http.Client + baseURL string + defaultModel string + chatLogger logging.ChatLogger } 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 { @@ -240,6 +225,22 @@ func (c ollamaClient) ChatStream(ctx context.Context, messages []Message, onDelt break } } - logging.Logf("llm/ollama ", "stream end duration=%s", time.Since(start)) - return nil + logging.Logf("llm/ollama ", "stream end duration=%s", time.Since(start)) + return nil +} + +// Private constructor +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, + chatLogger: logging.NewChatLogger("ollama"), + } } diff --git a/internal/llm/openai.go b/internal/llm/openai.go index 6b77144..ed5629e 100644 --- a/internal/llm/openai.go +++ b/internal/llm/openai.go @@ -13,43 +13,25 @@ import ( "strings" "time" - "hexai/internal/logging" + "hexai/internal/logging" ) // 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 chatLogger logging.ChatLogger } -// 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" - } - if strings.TrimSpace(model) == "" { - model = "gpt-4.1" - } - return openAIClient{ - httpClient: &http.Client{Timeout: 30 * time.Second}, - apiKey: apiKey, - baseURL: baseURL, - defaultModel: model, - chatLogger: logging.NewChatLogger("openai"), - } -} - type oaChatRequest struct { - Model string `json:"model"` - Messages []oaMessage `json:"messages"` - Temperature *float64 `json:"temperature,omitempty"` - MaxTokens *int `json:"max_tokens,omitempty"` - Stop []string `json:"stop,omitempty"` - Stream bool `json:"stream,omitempty"` + Model string `json:"model"` + Messages []oaMessage `json:"messages"` + Temperature *float64 `json:"temperature,omitempty"` + MaxTokens *int `json:"max_tokens,omitempty"` + Stop []string `json:"stop,omitempty"` + Stream bool `json:"stream,omitempty"` } type oaMessage struct { @@ -71,7 +53,23 @@ type oaChatResponse struct { Type string `json:"type"` Param any `json:"param"` Code any `json:"code"` - } `json:"error,omitempty"` + } `json:"error,omitempty"` +} + +// Streaming response chunk type (SSE) +type oaStreamChunk struct { + Choices []struct { + Delta struct { + Content string `json:"content"` + } `json:"delta"` + FinishReason string `json:"finish_reason"` + } `json:"choices"` + Error *struct { + Message string `json:"message"` + Type string `json:"type"` + Param any `json:"param"` + Code any `json:"code"` + } `json:"error,omitempty"` } func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) { @@ -159,27 +157,12 @@ func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...Requ return content, nil } -func (c openAIClient) logf(format string, args ...any) { logging.Logf("llm/openai ", format, args...) } - // Provider metadata func (c openAIClient) Name() string { return "openai" } func (c openAIClient) DefaultModel() string { return c.defaultModel } // Streaming support (optional) -type oaStreamChunk struct { - Choices []struct { - Delta struct { - Content string `json:"content"` - } `json:"delta"` - FinishReason string `json:"finish_reason"` - } `json:"choices"` - Error *struct { - Message string `json:"message"` - Type string `json:"type"` - Param any `json:"param"` - Code any `json:"code"` - } `json:"error,omitempty"` -} + func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error { if c.apiKey == "" { @@ -291,3 +274,24 @@ func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelt logging.Logf("llm/openai ", "stream end duration=%s", time.Since(start)) return nil } + +// Private helpers +func (c openAIClient) logf(format string, args ...any) { logging.Logf("llm/openai ", format, args...) } + +// 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" + } + if strings.TrimSpace(model) == "" { + model = "gpt-4.1" + } + return openAIClient{ + httpClient: &http.Client{Timeout: 30 * time.Second}, + apiKey: apiKey, + baseURL: baseURL, + defaultModel: model, + chatLogger: logging.NewChatLogger("openai"), + } +} |
