Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ coverage.*
.odek-artifacts/
.tmp-spincheck/
.env
.plans/.plans/

1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ go test -tags e2e -run 'TestE2E' -timeout 15m -v . # LIVE provider e2e (see be
| `chat.go` | `providerClient`: retry orchestration (buffered + streaming), error classification, learn-once consumption, SSE pump wiring, `httpError` parsing |
| `openai.go` / `gemini.go` / `anthropic.go` | Per-format request builders, response/stream mappers, model listing |
| `responses.go` | OpenAI Responses API (`/v1/responses`) for GPT-5.6+ tools+reasoning |
| `tts.go` | Text-to-speech: `Speak`/`SpeakRequest`/`SpeakResult` — OpenAI-compat `/audio/speech` + Gemini AUDIO modality, no transcoding |
| `sse.go` | SSE parser (abort-safe via `done` channel) + idle-watchdog pump |
| `retry.go` | Backoff/jitter/`Retry-After`/`retrySleep` (8 attempts, cap 30s) |
| `provider.go` | Built-in registry, quirks flags, config validation |
Expand Down
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,24 @@ case errors.Is(err, context.DeadlineExceeded): // wall-clock budget spent

API keys never appear in any error text. Provider error bodies are parsed per format (nested OpenAI envelope, Anthropic `error.type/message`, Gemini `error.status/message`) with a 512-byte raw-body fallback.

## Text-to-speech

`Speak` synthesizes speech via a provider's TTS endpoint. Supported wire formats: OpenAI-compatible (`POST {base}/audio/speech`, binary audio) and Gemini (`generateContent` with `AUDIO` response modality, base64 `inlineData`). Other formats return a `ConfigError`.

```go
res, err := sdk.Speak(ctx, "openai", "tts-1", llm.SpeakRequest{
Text: "Hello from go-llm-sdk",
Voice: "alloy", // required — no local default (never guessed)
Format: "mp3", // OpenAI-compat response_format (default "mp3")
Speed: 1.0, // optional, OpenAI-compat only
})
// res.Audio — raw audio bytes exactly as the provider returned them
// res.Model — the model that produced the audio
// res.MIMEType — the provider's Content-Type, or audio/mpeg when omitted
```

The SDK never transcodes: it returns the provider's bytes plus the MIME type the provider declared, falling back to the wire format's well-known default (`audio/mpeg` on the OpenAI path; Gemini's `inlineData.mimeType` is always present). A 2xx JSON error envelope from a gateway surfaces as a typed `*APIError` — JSON is never returned as audio. Requests carry the same retry ladder and error taxonomy as chat; empty `Text` or `Voice` fail fast with a `ConfigError`.

## Thread safety

`SDK` and `Provider` are safe for concurrent use. `ChatClient` is safe for concurrent `Call`/`CallStream`; `SetRequestTimeout` is race-safe (atomic swap) but should still be called before the first request so in-flight calls use one timeout. Learn-once state is shared per provider via atomics — monotonic, converging, race-free.
Expand Down
60 changes: 60 additions & 0 deletions e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -402,3 +402,63 @@ func TestE2EReasonerStreaming(t *testing.T) {
t.Errorf("finish = %q, want stop or length", res.FinishReason)
}
}

// ── TTS arms ─────────────────────────────────────────────────────────────

// e2eTTSModel resolves the TTS model for a provider id: <ID>_TTS_E2E_MODEL
// beats the default (kept separate from the chat-model override).
func e2eTTSModel(t *testing.T, id, def string) string {
t.Helper()
if v := strings.TrimSpace(os.Getenv(strings.ToUpper(id) + "_TTS_E2E_MODEL")); v != "" {
return v
}
return def
}

// TestE2ETTSOpenAI probes the OpenAI /audio/speech path against the live
// OpenAI endpoint (built-in registry, key via OPENAI_API_KEY). Asserts only
// SDK guarantees: the call succeeds and non-empty audio with a non-empty
// MIME type comes back. The audio's decodability is never asserted.
func TestE2ETTSOpenAI(t *testing.T) {
const keyEnv = "OPENAI_API_KEY"
key := e2eEnvKey(t, keyEnv)
sdk := New(WithProvider("openai", WithAPIKey(key)))
res, err := sdk.Speak(t.Context(), "openai", e2eTTSModel(t, "openai", "tts-1"), SpeakRequest{
Text: "go-llm-sdk text to speech probe.",
Voice: "alloy",
})
if err != nil {
t.Fatalf("Speak: %v", err)
}
if len(res.Audio) == 0 {
t.Errorf("Speak returned empty audio")
}
if res.MIMEType == "" {
t.Errorf("Speak returned empty MIMEType")
}
t.Logf("audio=%d bytes mime=%q model=%q", len(res.Audio), res.MIMEType, res.Model)
}

// TestE2ETTSGemini probes the Gemini AUDIO-modality TTS path (built-in
// registry, key via GEMINI_API_KEY). Same soft contract: non-empty audio
// bytes + non-empty MIME type; no format assumptions beyond what the SDK
// already guarantees.
func TestE2ETTSGemini(t *testing.T) {
const keyEnv = "GEMINI_API_KEY"
key := e2eEnvKey(t, keyEnv)
sdk := New(WithProvider("gemini", WithAPIKey(key)))
res, err := sdk.Speak(t.Context(), "gemini", e2eTTSModel(t, "gemini", "gemini-2.5-flash-preview-tts"), SpeakRequest{
Text: "go-llm-sdk text to speech probe.",
Voice: "Kore",
})
if err != nil {
t.Fatalf("Speak: %v", err)
}
if len(res.Audio) == 0 {
t.Errorf("Speak returned empty audio")
}
if res.MIMEType == "" {
t.Errorf("Speak returned empty MIMEType")
}
t.Logf("audio=%d bytes mime=%q model=%q", len(res.Audio), res.MIMEType, res.Model)
}
295 changes: 295 additions & 0 deletions tts.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,295 @@
package llm

import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
)

// ── TTS (text-to-speech) ─────────────────────────────────────────────────
//
// Speak turns text into audio bytes. Two wire formats are supported:
// - OpenAI-compatible: POST {base}/audio/speech, binary audio response
// - Gemini: generateContent with AUDIO response modality, base64 inlineData
//
// Any other format (anthropic, …) is a ConfigError. The SDK never
// transcodes: it returns the provider's bytes plus the MIME type the
// provider declared (or a well-known default when the provider omits it).
// API keys never appear in errors — the invariants of chat apply unchanged.

// SpeakRequest describes one text-to-speech conversion.
type SpeakRequest struct {
Text string // required, non-empty
Voice string // provider voice id (no fallback tables — invariant #5)
Format string // container for OpenAI-compat providers ("mp3" default)
Speed float64 // optional playback speed, OpenAI-compat only (0 = omit)
}

// SpeakResult carries the synthesized audio.
type SpeakResult struct {
Audio []byte // raw audio bytes as the provider returned them
Model string // model that produced the audio
MIMEType string // provider-declared (or well-known default) MIME type
}

// Speak synthesizes speech with the named provider and model. Buffered
// only — no streaming in v1 — with the same retry ladder as chat.
func (s *SDK) Speak(ctx context.Context, providerID, model string, req SpeakRequest) (*SpeakResult, error) {
if strings.TrimSpace(req.Text) == "" {
return nil, &ConfigError{Msg: "speak request requires non-empty Text"}
}
if strings.TrimSpace(model) == "" {
return nil, &ConfigError{Msg: "speak request requires a model"}
}
if strings.TrimSpace(req.Voice) == "" {
return nil, &ConfigError{Msg: "speak request requires a Voice (both supported wire formats require one server-side)"}
}
p, err := s.Provider(providerID)
if err != nil {
return nil, err
}
if !p.Authenticated() {
return nil, &ConfigError{Msg: providerID + " has no API key (set " + strings.ToUpper(providerID) + "_API_KEY or use WithAPIKey)"}
}
if p.invalid {
return nil, &ConfigError{Msg: providerID + " has an invalid configuration"}
}
pc := newProviderClient(p.cfg, newBufferedHTTP(s.rt, s.timeout), nil)
return pc.speak(ctx, model, req)
}

// buildSpeakRequest dispatches format-specific serialization. The third
// return is the well-known MIME type used when the provider omits
// Content-Type ("" for formats with no such default).
func (pc *providerClient) buildSpeakRequest(model string, req SpeakRequest) ([]byte, string, string, error) {
switch pc.cfg.Format {
case FormatGemini:
return buildGeminiSpeakRequest(pc.base, model, req)
case FormatOpenAI:
format := req.Format
if format == "" {
format = "mp3"
}
body := map[string]any{
"model": model,
"input": req.Text,
"voice": req.Voice,
"response_format": format,
}
if req.Speed != 0 {
body["speed"] = req.Speed
}
b, err := json.Marshal(body)
return b, pc.base + "/audio/speech", "audio/mpeg", err
default:
return nil, "", "", &ConfigError{Msg: "provider format " + string(pc.cfg.Format) + " does not support speech"}
}
}

// speak runs the TTS request against one provider with retry semantics
// identical to the buffered chat path (binary body, no SSE).
func (pc *providerClient) speak(ctx context.Context, model string, req SpeakRequest) (*SpeakResult, error) {
body, url, mimeHint, err := pc.buildSpeakRequest(model, req)
if err != nil {
return nil, err
}
gemini := pc.cfg.Format == FormatGemini

var (
lastErr error
rateErr *APIError
rateRA time.Duration
)
for attempt := 0; attempt <= maxRetries; attempt++ {
if err := ctx.Err(); err != nil {
return nil, err
}
data, ctype, ra, err := pc.postAudio(ctx, url, body)
if err != nil {
var apiErr *APIError
if errors.As(err, &apiErr) {
switch {
case apiErr.Status == http.StatusTooManyRequests && billingExhausted(apiErr):
return nil, apiErr
case apiErr.Status == http.StatusTooManyRequests:
rateErr, rateRA, lastErr = apiErr, ra, apiErr
if attempt < maxRetries {
if !retrySleep(ctx, retryDelay(ra, attempt)) {
return nil, &RateLimitError{APIError: *rateErr, Attempts: attempt + 1, RetryAfter: rateRA}
}
continue
}
case apiErr.Retryable && attempt < maxRetries:
lastErr = apiErr
if !retrySleep(ctx, retryDelay(ra, attempt)) {
return nil, ctx.Err()
}
continue
}
if rateErr != nil && !apiErr.Retryable && apiErr.Status != http.StatusTooManyRequests {
return nil, apiErr
}
if rateErr != nil {
return nil, &RateLimitError{APIError: *rateErr, Attempts: attempt + 1, RetryAfter: rateRA}
}
return nil, apiErr
}
// Transport error — retryable.
lastErr = err
if attempt < maxRetries {
if !retrySleep(ctx, retryDelay(0, attempt)) {
return nil, ctx.Err()
}
continue
}
return nil, fmt.Errorf("llm: retry exhausted (%d attempts): %w", maxRetries+1, err)
}
if gemini {
audio, mime, perr := parseGeminiSpeakResponse(data)
if perr != nil {
// 2xx with no audio parts is a provider protocol
// failure — surface it through the typed error
// taxonomy (APIError at the actual HTTP status).
return nil, &APIError{
Provider: pc.cfg.ID,
Status: http.StatusOK,
Message: perr.Error(),
}
}
data, ctype = audio, mime
} else if strings.HasPrefix(strings.TrimSpace(ctype), "application/json") {
// OpenAI-compatible gateways sometimes answer 2xx with a
// JSON error envelope. Never hand JSON bytes back as
// audio: parse the envelope through the shared httpError
// path.
return nil, pc.httpError(http.StatusOK, data)
}
return &SpeakResult{
Audio: data,
Model: model,
MIMEType: resolveAudioMIME(ctype, mimeHint),
}, nil
}
return nil, lastErr
}

// resolveAudioMIME prefers the provider's Content-Type; when absent it
// falls back to the wire format's well-known default (no guessing beyond
// that default — invariant #5).
func resolveAudioMIME(ctype, fallback string) string {
ctype = strings.TrimSpace(ctype)
if ctype != "" {
return ctype
}
return fallback
}

// postAudio sends one TTS request and reads the full binary body (capped).
// Unlike post it captures the response Content-Type and never assumes JSON.
func (pc *providerClient) postAudio(ctx context.Context, url string, body []byte) (data []byte, ctype string, ra time.Duration, err error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body))
if err != nil {
return nil, "", 0, &ConfigError{Msg: "build request: " + err.Error()}
}
req.Header.Set("Content-Type", "application/json")
pc.setAuthHeaders(req.Header)

resp, err := pc.buffered().Do(req)
if err != nil {
return nil, "", 0, err
}
defer func() { _ = resp.Body.Close() }()
ctype = resp.Header.Get("Content-Type")
data, err = io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1))
if err != nil {
return nil, "", 0, err
}
if len(data) > maxResponseSize {
return nil, "", 0, fmt.Errorf("llm: audio response exceeds %d bytes", maxResponseSize)
}
ra = parseRetryAfter(resp.Header.Get("Retry-After"), time.Now())
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, "", ra, pc.httpError(resp.StatusCode, data)
}
return data, ctype, ra, nil
}

// ── Gemini TTS wire types ────────────────────────────────────────────────

type geminiSpeakRequest struct {
Contents []gmContent `json:"contents"`
GenerationConfig geminiGenerationAudio `json:"generationConfig"`
}

type geminiGenerationAudio struct {
ResponseModalities []string `json:"responseModalities"`
SpeechConfig geminiSpeechCfg `json:"speechConfig"`
}

type geminiSpeechCfg struct {
VoiceConfig geminiVoiceCfg `json:"voiceConfig"`
}

type geminiVoiceCfg struct {
PrebuiltVoiceConfig geminiPrebuiltVoice `json:"prebuiltVoiceConfig"`
}

type geminiPrebuiltVoice struct {
VoiceName string `json:"voiceName"`
}

type geminiSpeakResponse struct {
Candidates []struct {
Content struct {
Parts []struct {
InlineData struct {
MimeType string `json:"mimeType"`
Data string `json:"data"`
} `json:"inlineData"`
} `json:"parts"`
} `json:"content"`
} `json:"candidates"`
}

// buildGeminiSpeakRequest serializes a native Gemini TTS request via
// generateContent: AUDIO response modality + prebuilt voice config. The
// response MIME comes from inlineData.mimeType, so there is no
// well-known fallback.
func buildGeminiSpeakRequest(base, model string, req SpeakRequest) ([]byte, string, string, error) {
greq := geminiSpeakRequest{
Contents: []gmContent{{Role: "user", Parts: []gmPart{{Text: req.Text}}}},
GenerationConfig: geminiGenerationAudio{
ResponseModalities: []string{"AUDIO"},
SpeechConfig: geminiSpeechCfg{VoiceConfig: geminiVoiceCfg{PrebuiltVoiceConfig: geminiPrebuiltVoice{VoiceName: req.Voice}}},
},
}
b, err := json.Marshal(greq)
return b, fmt.Sprintf("%s/v1beta/models/%s:generateContent", base, model), "", err
}

// parseGeminiSpeakResponse decodes a buffered Gemini TTS response.
func parseGeminiSpeakResponse(data []byte) (audio []byte, mime string, err error) {
var resp geminiSpeakResponse
if err := json.Unmarshal(data, &resp); err != nil {
return nil, "", fmt.Errorf("llm: decode gemini speech response: %w", err)
}
if len(resp.Candidates) == 0 || len(resp.Candidates[0].Content.Parts) == 0 {
return nil, "", fmt.Errorf("llm: gemini speech response contained no audio parts")
}
inline := resp.Candidates[0].Content.Parts[0].InlineData
if inline.Data == "" {
return nil, "", fmt.Errorf("llm: gemini speech response contained no audio data")
}
audio, err = base64.StdEncoding.DecodeString(inline.Data)
if err != nil {
return nil, "", fmt.Errorf("llm: decode gemini speech audio: %w", err)
}
return audio, inline.MimeType, nil
}
Loading
Loading