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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ go test -tags e2e -run 'TestE2E' -timeout 15m -v . # LIVE provider e2e (see be
| `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 |
| `stt.go` | Speech-to-text: `Transcribe`/`TranscribeRequest`/`TranscribeResult` — OpenAI-compat `/audio/transcriptions` (multipart), 25MB input cap |
| `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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,25 @@ res, err := sdk.Speak(ctx, "openai", "tts-1", llm.SpeakRequest{

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`.

## Speech-to-text

`Transcribe` converts audio bytes to text via the provider's transcription endpoint. v1 supports the OpenAI-compatible wire format (`POST {base}/audio/transcriptions`, multipart/form-data); other formats return a `ConfigError`. The SDK never touches the filesystem — callers own the audio bytes.

```go
res, err := sdk.Transcribe(ctx, "openai", "whisper-1", llm.TranscribeRequest{
Audio: audio, // required, non-empty, ≤25MB
Filename: "probe.mp3", // file part name (default "audio.wav")
MIMEType: "audio/mpeg", // audio part content type (default application/octet-stream)
Language: "en", // optional ISO-639-1 hint
Prompt: "context words", // optional conditioning text
})
// res.Text — recognized text
// res.Model — model that produced the transcription
// res.Language, res.DurationSec — provider-reported, zero when omitted
```

Requests carry the same retry ladder and error taxonomy as chat. Oversized audio (>25MB) and empty `Audio`/`Model` fail fast with a `ConfigError` before any network I/O. A 2xx body that is not JSON surfaces as a typed `*APIError`. Fields the provider does not report stay zero — the SDK never guesses.

## 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
34 changes: 34 additions & 0 deletions e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,3 +462,37 @@ func TestE2ETTSGemini(t *testing.T) {
}
t.Logf("audio=%d bytes mime=%q model=%q", len(res.Audio), res.MIMEType, res.Model)
}

// TestE2ETranscribeOpenAI probes the OpenAI /audio/transcriptions path
// against the live endpoint (built-in registry, key via OPENAI_API_KEY).
// Self-contained: audio is synthesized first via Speak, then transcribed
// (model override via OPENAI_STT_E2E_MODEL, default whisper-1). Asserts
// only SDK guarantees: the call succeeds and non-empty text comes back.
// Transcript accuracy is never asserted.
func TestE2ETranscribeOpenAI(t *testing.T) {
const keyEnv = "OPENAI_API_KEY"
key := e2eEnvKey(t, keyEnv)
sdk := New(WithProvider("openai", WithAPIKey(key)))
spoken, err := sdk.Speak(t.Context(), "openai", e2eTTSModel(t, "openai", "tts-1"), SpeakRequest{
Text: "go-llm-sdk speech to text probe.",
Voice: "alloy",
})
if err != nil {
t.Fatalf("Speak: %v", err)
}
sttModel := "whisper-1"
if v := strings.TrimSpace(os.Getenv("OPENAI_STT_E2E_MODEL")); v != "" {
sttModel = v
}
res, err := sdk.Transcribe(t.Context(), "openai", sttModel, TranscribeRequest{
Audio: spoken.Audio,
Filename: "probe.mp3",
})
if err != nil {
t.Fatalf("Transcribe: %v", err)
}
if res.Text == "" {
t.Errorf("Transcribe returned empty text")
}
t.Logf("text=%q duration=%.1fs model=%q", res.Text, res.DurationSec, res.Model)
}
270 changes: 270 additions & 0 deletions stt.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,270 @@
package llm

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

// ── STT (speech-to-text) ─────────────────────────────────────────────────
//
// Transcribe turns audio bytes into text. v1 supports the
// OpenAI-compatible wire format: POST {base}/audio/transcriptions,
// multipart/form-data, JSON response. Any other format (gemini,
// anthropic, …) is a ConfigError. The SDK never touches the
// filesystem: callers own the audio bytes. The chat invariants apply
// unchanged — canonical errors, keys never in error text, retry
// ladder identical to the buffered chat path.

// maxTranscribeAudioBytes bounds the audio payload accepted for one
// transcription request (matches OpenAI's documented 25MB upload limit).
// Oversized audio is rejected with a ConfigError before any network I/O.
const maxTranscribeAudioBytes = 25 << 20

// TranscribeRequest describes one speech-to-text conversion.
type TranscribeRequest struct {
Audio []byte // required, non-empty: raw audio bytes (caller-owned)
Filename string // file part name for the audio (default "audio.wav")
MIMEType string // optional content type for the audio part
Language string // optional ISO-639-1 hint, OpenAI-compat only
Prompt string // optional conditioning text, OpenAI-compat only
Format string // optional response_format (provider default when empty)
}

// TranscribeResult carries the recognized text. Fields the provider
// omits stay zero — unknown data stays unknown (invariant #5).
type TranscribeResult struct {
Text string
Model string
Language string
DurationSec float64
}

// Transcribe converts speech to text with the named provider and
// model. Buffered only, with the same retry ladder as chat.
func (s *SDK) Transcribe(ctx context.Context, providerID, model string, req TranscribeRequest) (*TranscribeResult, error) {
if len(req.Audio) == 0 {
return nil, &ConfigError{Msg: "transcribe request requires non-empty Audio"}
}
if len(req.Audio) > maxTranscribeAudioBytes {
return nil, &ConfigError{Msg: "transcribe audio exceeds 25MB limit"}
}
if strings.TrimSpace(model) == "" {
return nil, &ConfigError{Msg: "transcribe request requires a model"}
}
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.transcribe(ctx, model, req)
}

// escapeQuotes escapes quotes and backslashes in a multipart filename,
// mirroring mime/multipart's internal escape.
func escapeQuotes(s string) string {
r := strings.NewReplacer("\\", "\\\\", `"`, `\"`)
return r.Replace(s)
}

// buildTranscribeRequest serializes the multipart body and target URL.
func (pc *providerClient) buildTranscribeRequest(model string, req TranscribeRequest) ([]byte, string, error) {
if pc.cfg.Format != FormatOpenAI {
return nil, "", &ConfigError{Msg: "provider format " + string(pc.cfg.Format) + " does not support transcription"}
}
filename := req.Filename
if filename == "" {
filename = "audio.wav"
}
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
mime := req.MIMEType
if mime == "" {
mime = "application/octet-stream"
}
hdr := textproto.MIMEHeader{}
hdr.Set("Content-Disposition", fmt.Sprintf(`form-data; name="file"; filename="%s"`, escapeQuotes(filename)))
hdr.Set("Content-Type", mime)
fh, err := mw.CreatePart(hdr)
if err != nil {
return nil, "", fmt.Errorf("llm: build transcription request: %w", err)
}
if _, err := fh.Write(req.Audio); err != nil {
return nil, "", fmt.Errorf("llm: build transcription request: %w", err)
}
if err := mw.WriteField("model", model); err != nil {
return nil, "", fmt.Errorf("llm: build transcription request: %w", err)
}
if req.Language != "" {
if err := mw.WriteField("language", req.Language); err != nil {
return nil, "", fmt.Errorf("llm: build transcription request: %w", err)
}
}
if req.Prompt != "" {
if err := mw.WriteField("prompt", req.Prompt); err != nil {
return nil, "", fmt.Errorf("llm: build transcription request: %w", err)
}
}
if req.Format != "" {
if err := mw.WriteField("response_format", req.Format); err != nil {
return nil, "", fmt.Errorf("llm: build transcription request: %w", err)
}
}
if err := mw.Close(); err != nil {
return nil, "", fmt.Errorf("llm: build transcription request: %w", err)
}
return buf.Bytes(), pc.base + "/audio/transcriptions", nil
}

// transcribe runs the STT request against one provider with retry
// semantics identical to the buffered chat path.
func (pc *providerClient) transcribe(ctx context.Context, model string, req TranscribeRequest) (*TranscribeResult, error) {
body, url, err := pc.buildTranscribeRequest(model, req)
if err != nil {
return nil, err
}
ctype := "multipart/form-data; boundary=" + multipartBodyBoundary(body)

var (
lastErr error
rateErr *APIError
rateRA time.Duration
)
for attempt := 0; attempt <= maxRetries; attempt++ {
if err := ctx.Err(); err != nil {
return nil, err
}
data, ra, err := pc.postMultipart(ctx, url, body, ctype)
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)
}
res, perr := parseTranscribeResponse(data)
if perr != nil {
// 2xx with an undecodable body is a provider protocol
// failure — surface it through the typed error taxonomy
// at the actual HTTP status (never a plain fmt.Errorf).
return nil, &APIError{
Provider: pc.cfg.ID,
Status: http.StatusOK,
Message: perr.Error(),
}
}
res.Model = model
return res, nil
}
return nil, lastErr
}

// multipartBodyBoundary extracts the boundary from a multipart writer's
// Content-Type header line.
func multipartBodyBoundary(body []byte) string {
// The boundary is the last line of the leading preamble; cheaper and
// stricter: parse it from the first line of the body.
line := body
if i := bytes.IndexByte(body, '\r'); i >= 0 {
line = body[:i]
} else if i := bytes.IndexByte(body, '\n'); i >= 0 {
line = body[:i]
}
return strings.TrimPrefix(string(line), "--")
}

type transcribeResponse struct {
Text string `json:"text"`
Language string `json:"language"`
Duration float64 `json:"duration"`
}

// parseTranscribeResponse decodes a buffered transcription response.
// Both plain json ({text}) and verbose_json ({text,language,duration})
// decode through the same struct — fields the provider omits stay zero.
func parseTranscribeResponse(data []byte) (*TranscribeResult, error) {
var resp transcribeResponse
if err := json.Unmarshal(data, &resp); err != nil {
return nil, fmt.Errorf("llm: decode transcription response: %w", err)
}
return &TranscribeResult{
Text: resp.Text,
Language: resp.Language,
DurationSec: resp.Duration,
}, nil
}

// postMultipart sends one transcription request and reads the full JSON
// body (capped). Mirrors postAudio but with the multipart content type.
func (pc *providerClient) postMultipart(ctx context.Context, url string, body []byte, ctype string) (data []byte, 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", ctype)
pc.setAuthHeaders(req.Header)

resp, err := pc.buffered().Do(req)
if err != nil {
return nil, 0, err
}
defer func() { _ = resp.Body.Close() }()
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: 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, ra, nil
}
Loading
Loading