package elevenlabs import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "log/slog" "net/http" "net/url" "strings" "time" "pitch-voiceover/pkg/httpkit" ) const ( defaultBaseURL = "https://api.elevenlabs.io/v1" defaultTimeout = 60 * time.Second defaultMaxRetries = 3 ) // Config holds configuration options for the ElevenLabs client. type Config struct { // APIKey is required for authentication with the ElevenLabs API. APIKey string // BaseURL is the API base URL (optional, defaults to https://api.elevenlabs.io/v1). BaseURL string // Timeout is the HTTP request timeout (optional, defaults to 60s). Timeout time.Duration // MaxRetries is the number of retry attempts (optional, defaults to 3). MaxRetries int // Logger is the structured logger (optional, defaults to slog.Default()). Logger *slog.Logger } // Client is the ElevenLabs API client. type Client struct { kit *httpkit.Client config *Config logger *slog.Logger } // NewClient creates a new ElevenLabs 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.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) } // ElevenLabs uses xi-api-key header, not Bearer token kit := httpkit.NewClient(httpkit.Config{ Timeout: config.Timeout, MaxRetries: config.MaxRetries, Logger: config.Logger, Headers: map[string]string{ "xi-api-key": config.APIKey, }, }) return &Client{ kit: kit, config: &config, logger: config.Logger, }, nil } // doRequest executes an HTTP request expecting a JSON response. func (c *Client) doRequest(ctx context.Context, method, path string, body any) ([]byte, error) { resp, err := c.kit.Do(ctx, httpkit.Request{ Method: method, URL: c.config.BaseURL + path, Body: body, }) if err != nil { return nil, c.enrichError(err) } return resp, nil } // doRequestRaw executes an HTTP request expecting a binary response (e.g., audio). // This bypasses httpkit's JSON handling for streaming audio responses. func (c *Client) doRequestRaw(ctx context.Context, method, path string, body any, format OutputFormat) ([]byte, error) { var bodyReader io.Reader if body != nil { data, err := json.Marshal(body) if err != nil { return nil, fmt.Errorf("marshal request body: %w", err) } bodyReader = bytes.NewReader(data) } reqURL := c.config.BaseURL + path if format != "" { reqURL += "?output_format=" + string(format) } req, err := http.NewRequestWithContext(ctx, method, reqURL, bodyReader) if err != nil { return nil, fmt.Errorf("create request: %w", err) } req.Header.Set("xi-api-key", c.config.APIKey) req.Header.Set("Content-Type", "application/json") req.Header.Set("Accept", "audio/mpeg") client := &http.Client{Timeout: c.config.Timeout} resp, err := client.Do(req) if err != nil { if errors.Is(err, context.DeadlineExceeded) { return nil, fmt.Errorf("%w: %v", ErrTimeout, err) } return nil, err } defer func() { _ = resp.Body.Close() }() respBody, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("read response body: %w", err) } if resp.StatusCode >= 200 && resp.StatusCode < 300 { return respBody, nil } // Parse error response return nil, c.parseErrorResponse(resp.StatusCode, respBody) } // enrichError parses provider-specific error details from httpkit.APIError. func (c *Client) enrichError(err error) error { var apiErr *httpkit.APIError if !errors.As(err, &apiErr) { return err } return c.parseErrorResponse(apiErr.StatusCode, []byte(apiErr.Body)) } // parseErrorResponse parses an ElevenLabs error response body. func (c *Client) parseErrorResponse(statusCode int, body []byte) error { var errResp ErrorResponse if jsonErr := json.Unmarshal(body, &errResp); jsonErr != nil { // Couldn't parse as JSON, return raw body return NewAPIError(statusCode, string(body), "", httpkit.ClassifyStatus(statusCode)) } // Map ElevenLabs-specific status codes to sentinel errors underlying := httpkit.ClassifyStatus(statusCode) status := errResp.Detail.Status message := errResp.Detail.Message // Check for quota exceeded if strings.Contains(strings.ToLower(message), "quota") || strings.Contains(strings.ToLower(status), "quota") { underlying = ErrQuotaExceeded } // Check for voice not found if statusCode == http.StatusNotFound && (strings.Contains(strings.ToLower(message), "voice") || strings.Contains(strings.ToLower(status), "voice_not_found")) { underlying = ErrVoiceNotFound } return NewAPIError(statusCode, message, status, underlying) }