## 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>
194 lines
4.9 KiB
Go
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)
|
|
}
|