summaryrefslogtreecommitdiff
path: root/internal/llm/openai.go
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 /internal/llm/openai.go
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
Diffstat (limited to 'internal/llm/openai.go')
-rw-r--r--internal/llm/openai.go281
1 files changed, 138 insertions, 143 deletions
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"
- "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
- chatLogger logging.ChatLogger
- defaultTemperature *float64
+ httpClient *http.Client
+ apiKey string
+ baseURL string
+ defaultModel string
+ chatLogger logging.ChatLogger
+ defaultTemperature *float64
}
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 {
@@ -54,43 +54,43 @@ 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"`
+ 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"`
}
// Constructor (kept among the first functions by convention)
// 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, defaultTemp *float64) 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"),
- defaultTemperature: defaultTemp,
- }
+ 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"),
+ defaultTemperature: defaultTemp,
+ }
}
func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) {
@@ -105,37 +105,8 @@ func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...Requ
o.Model = c.defaultModel
}
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 := oaChatRequest{Model: o.Model}
- req.Messages = make([]oaMessage, len(messages))
- for i, m := range messages {
- req.Messages[i] = oaMessage{Role: m.Role, Content: m.Content}
- }
- // Decide temperature: request option overrides config default.
- 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
- }
-
+ c.logStart(false, o, messages)
+ req := buildOAChatRequest(o, messages, c.defaultTemperature, false)
body, err := json.Marshal(req)
if err != nil {
c.logf("marshal error: %v", err)
@@ -143,33 +114,19 @@ func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...Requ
}
endpoint := c.baseURL + "/chat/completions"
logging.Logf("llm/openai ", "POST %s", endpoint)
- httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
- if err != nil {
- c.logf("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/openai ", "%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 oaChatResponse
- _ = json.NewDecoder(resp.Body).Decode(&apiErr)
- if apiErr.Error != nil && apiErr.Error.Message != "" {
- logging.Logf("llm/openai ", "%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("openai error: %s (status %d)", apiErr.Error.Message, resp.StatusCode)
- }
- logging.Logf("llm/openai ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase)
- return "", fmt.Errorf("openai http error: status %d", resp.StatusCode)
+ if err := handleOpenAINon2xx(resp, start); err != nil {
+ return "", err
}
- var out oaChatResponse
- if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
- logging.Logf("llm/openai ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
+ out, err := decodeOpenAIChat(resp, start)
+ if err != nil {
return "", err
}
if len(out.Choices) == 0 {
@@ -177,7 +134,6 @@ func (c openAIClient) Chat(ctx context.Context, messages []Message, opts ...Requ
return "", errors.New("openai: no choices returned")
}
content := out.Choices[0].Message.Content
- // Received context (green)
logging.Logf("llm/openai ", "success choice=0 finish=%s size=%d preview=%s%s%s duration=%s", out.Choices[0].FinishReason, len(content), logging.AnsiGreen, logging.PreviewForLog(content), logging.AnsiBase, time.Since(start))
return content, nil
}
@@ -187,7 +143,6 @@ func (c openAIClient) Name() string { return "openai" }
func (c openAIClient) DefaultModel() string { return c.defaultModel }
// Streaming support (optional)
-
func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error {
if c.apiKey == "" {
@@ -201,74 +156,118 @@ func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelt
o.Model = c.defaultModel
}
start := time.Now()
- logMessages := make([]struct {
- Role string
- Content string
- }, len(messages))
+ c.logStart(true, o, messages)
+ req := buildOAChatRequest(o, messages, c.defaultTemperature, true)
+ body, err := json.Marshal(req)
+ if err != nil {
+ c.logf("marshal error: %v", err)
+ return err
+ }
+ endpoint := c.baseURL + "/chat/completions"
+ logging.Logf("llm/openai ", "POST %s (stream)", endpoint)
+ resp, err := c.doJSONWithAccept(ctx, endpoint, body, map[string]string{
+ "Authorization": "Bearer " + c.apiKey,
+ }, "text/event-stream")
+ if err != nil {
+ logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
+ return err
+ }
+ defer resp.Body.Close()
+ if err := handleOpenAINon2xx(resp, start); err != nil {
+ return err
+ }
+
+ if err := parseOpenAIStream(resp, start, onDelta); err != nil {
+ return err
+ }
+ 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...) }
+
+// helpers extracted to keep methods small
+func (c openAIClient) logStart(stream bool, o Options, messages []Message) {
+ 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(true, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)
+ c.chatLogger.LogStart(stream, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)
+}
- req := oaChatRequest{Model: o.Model, Stream: true}
+func buildOAChatRequest(o Options, messages []Message, defaultTemp *float64, stream bool) oaChatRequest {
+ req := oaChatRequest{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}
}
- if o.Temperature != 0 {
- req.Temperature = &o.Temperature
- } else if c.defaultTemperature != nil {
- t := *c.defaultTemperature
- req.Temperature = &t
- }
+ 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
+}
- body, err := json.Marshal(req)
+func (c openAIClient) 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 {
- c.logf("marshal error: %v", err)
- return err
+ return nil, err
}
- endpoint := c.baseURL + "/chat/completions"
- logging.Logf("llm/openai ", "POST %s (stream)", endpoint)
- httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
- if err != nil {
- c.logf("new request error: %v", err)
- return err
+ req.Header.Set("Content-Type", "application/json")
+ for k, v := range headers {
+ req.Header.Set(k, v)
}
- httpReq.Header.Set("Content-Type", "application/json")
- httpReq.Header.Set("Authorization", "Bearer "+c.apiKey)
- // Streaming uses SSE-style data lines
- httpReq.Header.Set("Accept", "text/event-stream")
+ return c.httpClient.Do(req)
+}
- resp, err := c.httpClient.Do(httpReq)
+func (c openAIClient) doJSONWithAccept(ctx context.Context, url string, body []byte, headers map[string]string, accept string) (*http.Response, error) {
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
- logging.Logf("llm/openai ", "%shttp error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
- return err
+ return nil, err
}
- defer resp.Body.Close()
- if resp.StatusCode < 200 || resp.StatusCode >= 300 {
- // try to decode body to surface message
- var apiErr oaChatResponse
- _ = json.NewDecoder(resp.Body).Decode(&apiErr)
- if apiErr.Error != nil && apiErr.Error.Message != "" {
- logging.Logf("llm/openai ", "%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("openai error: %s (status %d)", apiErr.Error.Message, resp.StatusCode)
- }
- logging.Logf("llm/openai ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase)
- return fmt.Errorf("openai http error: status %d", resp.StatusCode)
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Accept", accept)
+ for k, v := range headers {
+ req.Header.Set(k, v)
+ }
+ return c.httpClient.Do(req)
+}
+
+func handleOpenAINon2xx(resp *http.Response, start time.Time) error {
+ if resp.StatusCode >= 200 && resp.StatusCode < 300 {
+ return nil
+ }
+ var apiErr oaChatResponse
+ _ = json.NewDecoder(resp.Body).Decode(&apiErr)
+ if apiErr.Error != nil && apiErr.Error.Message != "" {
+ logging.Logf("llm/openai ", "%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("openai error: %s (status %d)", apiErr.Error.Message, resp.StatusCode)
+ }
+ logging.Logf("llm/openai ", "%shttp non-2xx status=%d duration=%s%s", logging.AnsiRed, resp.StatusCode, time.Since(start), logging.AnsiBase)
+ return fmt.Errorf("openai http error: status %d", resp.StatusCode)
+}
+
+func decodeOpenAIChat(resp *http.Response, start time.Time) (oaChatResponse, error) {
+ var out oaChatResponse
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
+ logging.Logf("llm/openai ", "%sdecode error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
+ return oaChatResponse{}, err
}
+ return out, nil
+}
+func parseOpenAIStream(resp *http.Response, start time.Time, onDelta func(string)) error {
// Parse SSE: lines starting with "data: " containing JSON or [DONE]
scanner := bufio.NewScanner(resp.Body)
- // Increase buffer for long lines
const maxBuf = 1024 * 1024
buf := make([]byte, 0, 64*1024)
scanner.Buffer(buf, maxBuf)
@@ -283,7 +282,7 @@ func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelt
}
var chunk oaStreamChunk
if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
- continue // skip malformed lines
+ continue
}
if chunk.Error != nil && chunk.Error.Message != "" {
logging.Logf("llm/openai ", "%sstream error: %s%s", logging.AnsiRed, chunk.Error.Message, logging.AnsiBase)
@@ -299,9 +298,5 @@ func (c openAIClient) ChatStream(ctx context.Context, messages []Message, onDelt
logging.Logf("llm/openai ", "%sstream read error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
return err
}
- 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...) }