summaryrefslogtreecommitdiff
path: root/internal/llm
diff options
context:
space:
mode:
Diffstat (limited to 'internal/llm')
-rw-r--r--internal/llm/copilot.go247
-rw-r--r--internal/llm/ollama.go222
-rw-r--r--internal/llm/openai.go282
3 files changed, 389 insertions, 362 deletions
diff --git a/internal/llm/copilot.go b/internal/llm/copilot.go
index 1e36bb7..7cc0278 100644
--- a/internal/llm/copilot.go
+++ b/internal/llm/copilot.go
@@ -3,153 +3,160 @@
package llm
import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "net/http"
- "strings"
- "time"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+ "strings"
+ "time"
- "hexai/internal/logging"
+ "hexai/internal/logging"
)
// 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,
- }
+ 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 {
- Role string `json:"role"`
- Content string `json:"content"`
+ Role string `json:"role"`
+ Content string `json:"content"`
}
type copilotChatResponse struct {
- Choices []struct {
- Index int `json:"index"`
- Message struct {
- Role string `json:"role"`
- Content string `json:"content"`
- } `json:"message"`
- 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 {
+ Index int `json:"index"`
+ Message struct {
+ Role string `json:"role"`
+ Content string `json:"content"`
+ } `json:"message"`
+ 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 *copilotClient) Chat(ctx context.Context, messages []Message, opts ...RequestOption) (string, error) {
- if strings.TrimSpace(c.apiKey) == "" {
- return nilStringErr("missing Copilot API key")
- }
- o := Options{Model: c.defaultModel}
- for _, opt := range opts {
- opt(&o)
- }
- if o.Model == "" {
- o.Model = c.defaultModel
- }
+ if strings.TrimSpace(c.apiKey) == "" {
+ return nilStringErr("missing Copilot API key")
+ }
+ o := Options{Model: c.defaultModel}
+ for _, opt := range opts {
+ opt(&o)
+ }
+ if o.Model == "" {
+ o.Model = c.defaultModel
+ }
- start := time.Now()
- logging.Logf("llm/copilot ", "chat start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", o.Model, o.Temperature, o.MaxTokens, len(o.Stop), len(messages))
- for i, m := range messages {
- logging.Logf("llm/copilot ", "msg[%d] role=%s size=%d preview=%s%s%s", i, m.Role, len(m.Content), logging.AnsiCyan, logging.PreviewForLog(m.Content), logging.AnsiBase)
- }
+ 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 := 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
- }
- if o.MaxTokens > 0 {
- req.MaxTokens = &o.MaxTokens
- }
- if len(o.Stop) > 0 {
- req.Stop = o.Stop
- }
+ 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
+ }
+ if o.MaxTokens > 0 {
+ req.MaxTokens = &o.MaxTokens
+ }
+ if len(o.Stop) > 0 {
+ req.Stop = o.Stop
+ }
- body, err := json.Marshal(req)
- if err != nil {
- logging.Logf("llm/copilot ", "marshal error: %v", err)
- return "", err
- }
+ body, err := json.Marshal(req)
+ if err != nil {
+ logging.Logf("llm/copilot ", "marshal error: %v", err)
+ return "", err
+ }
- 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)
- // Some Copilot deployments expect a version header; optional here.
- // httpReq.Header.Set("X-GitHub-Api-Version", "2023-12-07")
+ 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)
- 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)
- }
+ resp, err := c.httpClient.Do(httpReq)
+ 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)
+ }
- 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 "", err
- }
- if len(out.Choices) == 0 {
- logging.Logf("llm/copilot ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase)
- return "", errors.New("copilot: no choices returned")
- }
- content := out.Choices[0].Message.Content
- logging.Logf("llm/copilot ", "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
+ 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 "", err
+ }
+ if len(out.Choices) == 0 {
+ logging.Logf("llm/copilot ", "%sno choices returned duration=%s%s", logging.AnsiRed, time.Since(start), logging.AnsiBase)
+ return "", errors.New("copilot: no choices returned")
+ }
+ content := out.Choices[0].Message.Content
+ logging.Logf("llm/copilot ", "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
}
// Provider metadata
func (c *copilotClient) Name() string { return "copilot" }
-func (c *copilotClient) DefaultModel() string { return c.defaultModel }
+func (c *copilotClient) DefaultModel() string { return c.defaultModel } \ No newline at end of file
diff --git a/internal/llm/ollama.go b/internal/llm/ollama.go
index 774eaf1..49adcb2 100644
--- a/internal/llm/ollama.go
+++ b/internal/llm/ollama.go
@@ -3,15 +3,15 @@
package llm
import (
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
- "io"
- "net/http"
- "strings"
- "time"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "strings"
+ "time"
"hexai/internal/logging"
)
@@ -21,6 +21,7 @@ type ollamaClient struct {
httpClient *http.Client
baseURL string
defaultModel string
+ chatLogger *logging.ChatLogger
}
func newOllama(baseURL, model string) Client {
@@ -34,14 +35,15 @@ func newOllama(baseURL, model string) Client {
httpClient: &http.Client{Timeout: 30 * time.Second},
baseURL: strings.TrimRight(baseURL, "/"),
defaultModel: model,
+ chatLogger: logging.NewChatLogger("ollama"),
}
}
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 {
@@ -63,10 +65,17 @@ func (c *ollamaClient) Chat(ctx context.Context, messages []Message, opts ...Req
}
start := time.Now()
- logging.Logf("llm/ollama ", "chat start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", o.Model, o.Temperature, o.MaxTokens, len(o.Stop), len(messages))
+ logMessages := make([]struct {
+ Role string
+ Content string
+ }, len(messages))
for i, m := range messages {
- logging.Logf("llm/ollama ", "msg[%d] role=%s size=%d preview=%s%s%s", i, m.Role, len(m.Content), logging.AnsiCyan, logging.PreviewForLog(m.Content), logging.AnsiBase)
+ 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))
@@ -139,91 +148,98 @@ func (c *ollamaClient) DefaultModel() string { return c.defaultModel }
// Streaming support (optional)
func (c *ollamaClient) ChatStream(ctx context.Context, messages []Message, onDelta func(string), opts ...RequestOption) error {
- o := Options{Model: c.defaultModel}
- for _, opt := range opts {
- opt(&o)
- }
- if o.Model == "" {
- o.Model = c.defaultModel
- }
-
- start := time.Now()
- logging.Logf("llm/ollama ", "stream start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", o.Model, o.Temperature, o.MaxTokens, len(o.Stop), len(messages))
- for i, m := range messages {
- logging.Logf("llm/ollama ", "msg[%d] role=%s size=%d preview=%s%s%s", i, m.Role, len(m.Content), logging.AnsiCyan, logging.PreviewForLog(m.Content), logging.AnsiBase)
- }
-
- 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
- }
- 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
- }
-
- body, err := json.Marshal(req)
- if err != nil {
- return err
- }
-
- 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)
- 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)
- }
-
- dec := json.NewDecoder(resp.Body)
- for {
- var ev ollamaChatResponse
- if err := dec.Decode(&ev); err != nil {
- if errors.Is(err, io.EOF) {
- break
- }
- logging.Logf("llm/ollama ", "%sdecode stream error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
- return err
- }
- if strings.TrimSpace(ev.Error) != "" {
- logging.Logf("llm/ollama ", "%sstream event error: %s%s", logging.AnsiRed, ev.Error, logging.AnsiBase)
- return fmt.Errorf("ollama stream error: %s", ev.Error)
- }
- if s := ev.Message.Content; strings.TrimSpace(s) != "" {
- onDelta(s)
- }
- if ev.Done {
- break
- }
- }
- logging.Logf("llm/ollama ", "stream end duration=%s", time.Since(start))
- return nil
-}
+ o := Options{Model: c.defaultModel}
+ for _, opt := range opts {
+ opt(&o)
+ }
+ if o.Model == "" {
+ 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(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
+ }
+ 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
+ }
+
+ body, err := json.Marshal(req)
+ if err != nil {
+ return err
+ }
+
+ 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)
+ 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)
+ }
+
+ dec := json.NewDecoder(resp.Body)
+ for {
+ var ev ollamaChatResponse
+ if err := dec.Decode(&ev); err != nil {
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ logging.Logf("llm/ollama ", "%sdecode stream error after %s: %v%s", logging.AnsiRed, time.Since(start), err, logging.AnsiBase)
+ return err
+ }
+ if strings.TrimSpace(ev.Error) != "" {
+ logging.Logf("llm/ollama ", "%sstream event error: %s%s", logging.AnsiRed, ev.Error, logging.AnsiBase)
+ return fmt.Errorf("ollama stream error: %s", ev.Error)
+ }
+ if s := ev.Message.Content; strings.TrimSpace(s) != "" {
+ onDelta(s)
+ }
+ if ev.Done {
+ break
+ }
+ }
+ logging.Logf("llm/ollama ", "stream end duration=%s", time.Since(start))
+ return nil
+} \ No newline at end of file
diff --git a/internal/llm/openai.go b/internal/llm/openai.go
index 288622f..fe6705b 100644
--- a/internal/llm/openai.go
+++ b/internal/llm/openai.go
@@ -3,17 +3,17 @@
package llm
import (
- "bufio"
- "bytes"
- "context"
- "encoding/json"
- "errors"
- "fmt"
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
"net/http"
"strings"
- "time"
+ "time"
- "hexai/internal/logging"
+ "hexai/internal/logging"
)
// openAIClient implements Client against OpenAI's Chat Completions API.
@@ -22,10 +22,9 @@ type openAIClient struct {
apiKey string
baseURL string
defaultModel string
+ chatLogger *logging.ChatLogger
}
-// Colors and base styling are provided by logging.go
-
// 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 {
@@ -40,16 +39,17 @@ func newOpenAI(baseURL, model, apiKey string) Client {
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 {
@@ -86,11 +86,18 @@ func (c *openAIClient) Chat(ctx context.Context, messages []Message, opts ...Req
o.Model = c.defaultModel
}
start := time.Now()
- logging.Logf("llm/openai ", "chat start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", o.Model, o.Temperature, o.MaxTokens, len(o.Stop), len(messages))
+ logMessages := make([]struct {
+ Role string
+ Content string
+ }, len(messages))
for i, m := range messages {
- // Sending context (cyan)
- logging.Logf("llm/openai ", "msg[%d] role=%s size=%d preview=%s%s%s", i, m.Role, len(m.Content), logging.AnsiCyan, logging.PreviewForLog(m.Content), logging.AnsiBase)
+ 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 {
@@ -152,138 +159,135 @@ func (c *openAIClient) Chat(ctx context.Context, messages []Message, opts ...Req
return content, nil
}
-// small helper to keep return type consistent
-func nilStringErr(msg string) (string, error) { return "", errors.New(msg) }
-
func (c *openAIClient) logf(format string, args ...any) { logging.Logf("llm/openai ", format, args...) }
-func trimPreview(s string, n int) string {
- if n <= 0 || len(s) <= n {
- return s
- }
- return s[:n] + "…"
-}
-
// 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"`
+ 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 == "" {
- return errors.New("missing OpenAI API key")
- }
- o := Options{Model: c.defaultModel}
- for _, opt := range opts {
- opt(&o)
- }
- if o.Model == "" {
- o.Model = c.defaultModel
- }
- start := time.Now()
- logging.Logf("llm/openai ", "stream start model=%s temp=%.2f max_tokens=%d stop=%d messages=%d", o.Model, o.Temperature, o.MaxTokens, len(o.Stop), len(messages))
- for i, m := range messages {
- logging.Logf("llm/openai ", "msg[%d] role=%s size=%d preview=%s%s%s", i, m.Role, len(m.Content), logging.AnsiCyan, logging.PreviewForLog(m.Content), logging.AnsiBase)
- }
+ if c.apiKey == "" {
+ return errors.New("missing OpenAI API key")
+ }
+ o := Options{Model: c.defaultModel}
+ for _, opt := range opts {
+ opt(&o)
+ }
+ if o.Model == "" {
+ 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(true, o.Model, o.Temperature, o.MaxTokens, o.Stop, logMessages)
- req := oaChatRequest{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}
- }
- if o.Temperature != 0 {
- req.Temperature = &o.Temperature
- }
- if o.MaxTokens > 0 {
- req.MaxTokens = &o.MaxTokens
- }
- if len(o.Stop) > 0 {
- req.Stop = o.Stop
- }
+ req := oaChatRequest{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}
+ }
+ if o.Temperature != 0 {
+ req.Temperature = &o.Temperature
+ }
+ if o.MaxTokens > 0 {
+ req.MaxTokens = &o.MaxTokens
+ }
+ if len(o.Stop) > 0 {
+ req.Stop = o.Stop
+ }
- 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)
- 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)
- // Streaming uses SSE-style data lines
- httpReq.Header.Set("Accept", "text/event-stream")
+ 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)
+ 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)
+ // Streaming uses SSE-style data lines
+ httpReq.Header.Set("Accept", "text/event-stream")
- resp, err := c.httpClient.Do(httpReq)
- 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 {
- // 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)
- }
+ resp, err := c.httpClient.Do(httpReq)
+ 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 {
+ // 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)
+ }
- // 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)
- for scanner.Scan() {
- line := scanner.Text()
- if !strings.HasPrefix(line, "data: ") {
- continue
- }
- payload := strings.TrimPrefix(line, "data: ")
- if strings.TrimSpace(payload) == "[DONE]" {
- break
- }
- var chunk oaStreamChunk
- if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
- continue // skip malformed lines
- }
- if chunk.Error != nil && chunk.Error.Message != "" {
- logging.Logf("llm/openai ", "%sstream error: %s%s", logging.AnsiRed, chunk.Error.Message, logging.AnsiBase)
- return fmt.Errorf("openai stream error: %s", chunk.Error.Message)
- }
- for _, ch := range chunk.Choices {
- if ch.Delta.Content != "" {
- onDelta(ch.Delta.Content)
- }
- }
- }
- if err := scanner.Err(); err != nil {
- 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
-}
+ // 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)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if !strings.HasPrefix(line, "data: ") {
+ continue
+ }
+ payload := strings.TrimPrefix(line, "data: ")
+ if strings.TrimSpace(payload) == "[DONE]" {
+ break
+ }
+ var chunk oaStreamChunk
+ if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
+ continue // skip malformed lines
+ }
+ if chunk.Error != nil && chunk.Error.Message != "" {
+ logging.Logf("llm/openai ", "%sstream error: %s%s", logging.AnsiRed, chunk.Error.Message, logging.AnsiBase)
+ return fmt.Errorf("openai stream error: %s", chunk.Error.Message)
+ }
+ for _, ch := range chunk.Choices {
+ if ch.Delta.Content != "" {
+ onDelta(ch.Delta.Content)
+ }
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ 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
+} \ No newline at end of file