stemedb/cmd/pitch-voiceover/pkg/elevenlabs/client.go
jordan 157dbbb9eb feat: Complete Aphoria Phase 8-9 + UAT suite (90/90 tests passing)
## Phase 8: Enterprise Extractor Improvements 
- 14 security extractors (TLS, JWT, SQL injection, XSS, etc.)
- 10 framework-specific extractors (Spring, Django, Rails, etc.)
- Config file security detection (YAML, TOML)

## Phase 9: Autonomous Extractor Generation 
- Shadow mode executor with TP/FP tracking
- Graduation pipeline with confidence thresholds
- Auto-rollback on regression detection
- Cross-project pattern syncing

## UAT Suite Complete (14 scripts, 90 tests)
- test-core-detection.sh (6 tests)
- test-declarative-extractors.sh (5 tests)
- test-domain-frameworks.sh (5 tests)
- test-domain-unreal.sh (3 tests)
- test-llm-extraction.sh (6 tests)
- test-eval-harness.sh (5 tests)
- test-cross-language.sh (3 tests)
- test-precommit-performance.sh (4 tests)
- test-output-formats.sh (8 tests)
- test-drift-detection.sh (6 tests)
- test-exit-codes.sh (12 tests)
+ 3 more scripts

## Other Changes
- Updated roadmap to mark Phase 8-9 complete
- Added .gitignore entries for build artifacts
- Updated pre-commit: 800 line limit, exclude tests/data/cmd

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 22:50:55 -07:00

194 lines
4.9 KiB
Go

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)
}