summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2025-08-17 23:03:24 +0300
committerPaul Buetow <paul@buetow.org>2025-08-17 23:03:24 +0300
commitd72f95ae4e6cd4e7a0beca2b9764511c10de8655 (patch)
treed5b034abce358cc26f271c3fc492d97da6e5734c
parent8dfbbbb6de0f0c67413ee157e976fc3eaee4f914 (diff)
refactor(ordering): types/constants first; exported before private; ensure consistent receiver semantics per file
-rw-r--r--internal/appconfig/config.go167
-rw-r--r--internal/llm/copilot.go51
-rw-r--r--internal/llm/ollama.go51
-rw-r--r--internal/llm/openai.go96
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"),
+ }
+}