LEM/pkg/lem/client.go
Claude 70dd18c065
refactor: move Go library to pkg/lem, thin main.go
All scoring/influx/export/expand logic moves to pkg/lem as an
importable package. main.go is now a thin CLI dispatcher.

This lets new commands import the shared library directly —
ready for converting Python scripts to Go subcommands.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-15 16:30:09 +00:00

152 lines
3.7 KiB
Go

package lem
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"time"
)
// Message is a single chat message.
type Message struct {
Role string `json:"role"`
Content string `json:"content"`
}
// ChatRequest is the request body for /v1/chat/completions.
type ChatRequest struct {
Model string `json:"model"`
Messages []Message `json:"messages"`
Temperature float64 `json:"temperature"`
MaxTokens int `json:"max_tokens,omitempty"`
}
// Choice is a single completion choice.
type Choice struct {
Message Message `json:"message"`
}
// ChatResponse is the response from /v1/chat/completions.
type ChatResponse struct {
Choices []Choice `json:"choices"`
}
// retryableError marks errors that should be retried.
type retryableError struct {
err error
}
func (e *retryableError) Error() string { return e.err.Error() }
func (e *retryableError) Unwrap() error { return e.err }
// Client talks to an OpenAI-compatible API.
type Client struct {
baseURL string
model string
MaxTokens int
httpClient *http.Client
}
// NewClient creates a Client for the given base URL and model.
func NewClient(baseURL, model string) *Client {
return &Client{
baseURL: baseURL,
model: model,
httpClient: &http.Client{
Timeout: 300 * time.Second,
},
}
}
// Chat sends a prompt and returns the assistant's reply.
// Uses the default temperature of 0.1.
func (c *Client) Chat(prompt string) (string, error) {
return c.ChatWithTemp(prompt, 0.1)
}
// ChatWithTemp sends a prompt with a specific temperature and returns
// the assistant's reply. Retries up to 3 times with exponential backoff
// on transient failures (HTTP 5xx or network errors).
func (c *Client) ChatWithTemp(prompt string, temp float64) (string, error) {
req := ChatRequest{
Model: c.model,
Messages: []Message{
{Role: "user", Content: prompt},
},
Temperature: temp,
MaxTokens: c.MaxTokens,
}
body, err := json.Marshal(req)
if err != nil {
return "", fmt.Errorf("marshal request: %w", err)
}
maxAttempts := 3
var lastErr error
for attempt := range maxAttempts {
if attempt > 0 {
// Exponential backoff: 100ms, 200ms
backoff := time.Duration(100<<uint(attempt-1)) * time.Millisecond
time.Sleep(backoff)
}
result, err := c.doRequest(body)
if err == nil {
return result, nil
}
lastErr = err
// Only retry on transient (retryable) errors.
var re *retryableError
if !errors.As(err, &re) {
return "", err
}
}
return "", fmt.Errorf("exhausted %d retries: %w", maxAttempts, lastErr)
}
// doRequest sends a single HTTP request and parses the response.
func (c *Client) doRequest(body []byte) (string, error) {
url := c.baseURL + "/v1/chat/completions"
httpReq, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return "", &retryableError{fmt.Errorf("http request: %w", err)}
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", &retryableError{fmt.Errorf("read response: %w", err)}
}
if resp.StatusCode >= 500 {
return "", &retryableError{fmt.Errorf("server error %d: %s", resp.StatusCode, string(respBody))}
}
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(respBody))
}
var chatResp ChatResponse
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return "", fmt.Errorf("unmarshal response: %w", err)
}
if len(chatResp.Choices) == 0 {
return "", fmt.Errorf("no choices in response")
}
return chatResp.Choices[0].Message.Content, nil
}