persona-community-1/pkg/laozhang/client.go
jordan 4004f88f4a
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/manual/woodpecker Pipeline was successful
Initialize project from skeleton template
2026-02-23 10:20:59 +00:00

441 lines
12 KiB
Go

// Package laozhang provides a Go client for the LaoZhang API gateway.
//
// LaoZhang is an OpenAI-compatible API gateway that provides access to various
// AI models including chat completion, image generation (Nano Banana Pro), and
// video generation (Veo 3.1).
//
// Basic usage:
//
// client, err := laozhang.NewClient(laozhang.Config{
// APIKey: os.Getenv("LAOZHANG_API_KEY"),
// })
// if err != nil {
// log.Fatal(err)
// }
//
// // Generate an image
// resp, err := client.GenerateImage(ctx, laozhang.ImageRequest{
// Prompt: "A serene Japanese garden",
// })
//
// The client automatically handles retries for server errors (5xx) and rate
// limits (429) with exponential backoff. Use the sentinel errors (ErrRateLimit,
// ErrServerError, etc.) with errors.Is() for programmatic error handling.
package laozhang
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"strings"
"time"
"git.threesix.ai/jordan/persona-community-1/pkg/httpclient"
)
const (
defaultBaseURL = "https://api.laozhang.ai/v1"
defaultTimeout = 60 * time.Second
defaultVideoTimeout = 5 * time.Minute
defaultMaxRetries = 3
)
// Config holds configuration options for the LaoZhang client
type Config struct {
APIKey string // Required: API key for authentication
BaseURL string // Optional: defaults to https://api.laozhang.ai/v1
Timeout time.Duration // Optional: defaults to 60s
VideoTimeout time.Duration // Optional: defaults to 5m (video generation takes 2-5 minutes)
MaxRetries int // Optional: defaults to 3
Logger *slog.Logger // Optional: defaults to slog.Default()
}
// Client is the LaoZhang API client
type Client struct {
httpClient *httpclient.Client // Standard timeout (60s) for text/image
videoHTTPClient *httpclient.Client // Long timeout (5m) for video generation
config *Config
logger *slog.Logger
}
// NewClient creates a new LaoZhang API client
func NewClient(config Config) (*Client, error) {
if config.APIKey == "" {
return nil, fmt.Errorf("%w: API key is required", ErrInvalidConfig)
}
if config.BaseURL == "" {
config.BaseURL = defaultBaseURL
}
if config.Timeout == 0 {
config.Timeout = defaultTimeout
}
if config.VideoTimeout == 0 {
config.VideoTimeout = defaultVideoTimeout
}
if config.MaxRetries == 0 {
config.MaxRetries = defaultMaxRetries
}
if config.Logger == nil {
config.Logger = slog.Default()
}
// Validate base URL
if _, err := url.Parse(config.BaseURL); err != nil {
return nil, fmt.Errorf("%w: invalid base URL: %v", ErrInvalidConfig, err)
}
return &Client{
httpClient: httpclient.New(httpclient.Config{
Timeout: config.Timeout,
MaxRetries: config.MaxRetries,
Logger: config.Logger,
}),
videoHTTPClient: httpclient.New(httpclient.Config{
Timeout: config.VideoTimeout,
MaxRetries: config.MaxRetries,
Logger: config.Logger,
}),
config: &config,
logger: config.Logger,
}, nil
}
// Health checks the health of the LaoZhang API
func (c *Client) Health(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.config.BaseURL+"/models", nil)
if err != nil {
return fmt.Errorf("create health check request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("health check request failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
baseErr := classifyHTTPError(resp.StatusCode)
return NewAPIError(resp.StatusCode, string(body), "", "", baseErr)
}
return nil
}
// ChatCompletion sends a chat completion request to the LaoZhang API
func (c *Client) ChatCompletion(ctx context.Context, req ChatCompletionRequest) (*ChatCompletionResponse, error) {
if req.Model == "" {
return nil, fmt.Errorf("%w: model is required", ErrInvalidConfig)
}
if len(req.Messages) == 0 {
return nil, fmt.Errorf("%w: messages are required", ErrInvalidConfig)
}
respBody, err := c.doRequest(ctx, http.MethodPost, "/chat/completions", req)
if err != nil {
return nil, err
}
var chatResp ChatCompletionResponse
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, fmt.Errorf("unmarshal response: %w", err)
}
return &chatResp, nil
}
// ChatCompletionStream sends a streaming chat completion request.
// Returns chunks via the onChunk callback as tokens arrive from the API.
// The request is sent with stream=true and the response body is read as SSE.
func (c *Client) ChatCompletionStream(ctx context.Context, req ChatCompletionRequest, onChunk func(StreamChunk)) error {
if req.Model == "" {
return fmt.Errorf("%w: model is required", ErrInvalidConfig)
}
if len(req.Messages) == 0 {
return fmt.Errorf("%w: messages are required", ErrInvalidConfig)
}
req.Stream = true
jsonBody, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("marshal request: %w", err)
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.config.BaseURL+"/chat/completions", bytes.NewReader(jsonBody))
if err != nil {
return fmt.Errorf("create request: %w", err)
}
httpReq.Header.Set("Authorization", "Bearer "+c.config.APIKey)
httpReq.Header.Set("Content-Type", "application/json")
// Use a raw http.Client with timeout for streaming (httpClient.Do may buffer)
rawClient := &http.Client{Timeout: c.config.Timeout}
resp, err := rawClient.Do(httpReq)
if err != nil {
return fmt.Errorf("stream request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
baseErr := classifyHTTPError(resp.StatusCode)
return NewAPIError(resp.StatusCode, string(body), "", "", baseErr)
}
// Read SSE stream line by line
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
// Skip empty lines and comments
if line == "" || line[0] == ':' {
continue
}
// Parse SSE data lines
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
// [DONE] marks the end of the stream
if data == "[DONE]" {
onChunk(StreamChunk{Done: true})
return nil
}
var chunk streamResponse
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
c.logger.Debug("skipping unparseable stream chunk", "error", err)
continue
}
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
onChunk(StreamChunk{Text: chunk.Choices[0].Delta.Content})
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("read stream: %w", err)
}
// If we reach here without [DONE], send a final chunk
onChunk(StreamChunk{Done: true})
return nil
}
// StreamChunk represents a chunk from a streaming response.
type StreamChunk struct {
Text string
Done bool
}
// streamResponse is the JSON structure for streaming chat completion chunks.
type streamResponse struct {
Choices []streamChoice `json:"choices"`
}
type streamChoice struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
FinishReason *string `json:"finish_reason"`
}
// doRequest is a helper method for making HTTP requests
func (c *Client) doRequest(ctx context.Context, method, path string, bodyData interface{}) ([]byte, error) {
var reqBody io.Reader
if bodyData != nil {
jsonBody, err := json.Marshal(bodyData)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
reqBody = bytes.NewReader(jsonBody)
}
req, err := http.NewRequestWithContext(ctx, method, c.config.BaseURL+path, reqBody)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
if bodyData != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
// Success response
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return respBody, nil
}
// Parse error response
var errResp ErrorResponse
baseErr := classifyHTTPError(resp.StatusCode)
if err := json.Unmarshal(respBody, &errResp); err != nil {
// Failed to parse error response
return nil, NewAPIError(
resp.StatusCode,
string(respBody),
"",
"",
baseErr,
)
}
// Successfully parsed error response
return nil, NewAPIError(
resp.StatusCode,
errResp.Error.Message,
errResp.Error.Type,
errResp.Error.Code,
baseErr,
)
}
// doRequestVideo is like doRequest but uses the video HTTP client with a longer timeout.
// Video generation (Veo) takes 2-5 minutes, exceeding the standard 60s client timeout.
func (c *Client) doRequestVideo(ctx context.Context, method, path string, bodyData interface{}) ([]byte, error) {
var reqBody io.Reader
if bodyData != nil {
jsonBody, err := json.Marshal(bodyData)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
reqBody = bytes.NewReader(jsonBody)
}
req, err := http.NewRequestWithContext(ctx, method, c.config.BaseURL+path, reqBody)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
if bodyData != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.videoHTTPClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
// Success response
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return respBody, nil
}
// Parse error response
var errResp ErrorResponse
baseErr := classifyHTTPError(resp.StatusCode)
if err := json.Unmarshal(respBody, &errResp); err != nil {
return nil, NewAPIError(
resp.StatusCode,
string(respBody),
"",
"",
baseErr,
)
}
return nil, NewAPIError(
resp.StatusCode,
errResp.Error.Message,
errResp.Error.Type,
errResp.Error.Code,
baseErr,
)
}
// geminiBaseURL returns the base URL for Gemini API endpoints (without /v1 suffix)
func (c *Client) geminiBaseURL() string {
// Strip /v1 suffix if present to get the root URL for Gemini endpoints
baseURL := c.config.BaseURL
if len(baseURL) > 3 && baseURL[len(baseURL)-3:] == "/v1" {
return baseURL[:len(baseURL)-3]
}
return baseURL
}
// doRequestGemini is similar to doRequest but uses the Gemini base URL format
func (c *Client) doRequestGemini(ctx context.Context, method, path string, bodyData interface{}) ([]byte, error) {
var reqBody io.Reader
if bodyData != nil {
jsonBody, err := json.Marshal(bodyData)
if err != nil {
return nil, fmt.Errorf("marshal request: %w", err)
}
reqBody = bytes.NewReader(jsonBody)
}
// Use Gemini base URL (without /v1)
fullURL := c.geminiBaseURL() + path
req, err := http.NewRequestWithContext(ctx, method, fullURL, reqBody)
if err != nil {
return nil, fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
if bodyData != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("read response body: %w", err)
}
// Success response
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return respBody, nil
}
// Parse error response (Gemini format may differ)
baseErr := classifyHTTPError(resp.StatusCode)
return nil, NewAPIError(
resp.StatusCode,
string(respBody),
"",
"",
baseErr,
)
}