UtteraUttera

Uttera in Ihren Code einbauen

Es gibt kein SDK. Die API ist einfaches HTTP und multipart/form-data, sodass Sie aus jeder Sprache mit ihr sprechen können, ohne etwas von uns zu installieren. Unten steht ein vollständiger Client —Upload, Wiederholungen, Auslesen des Berechneten— in sechs Sprachen.

Alle sechs Beispiele werden gegen die Live-API ausgeführt, bevor sie hier veröffentlicht werden. Was Sie lesen, ist dieselbe Datei, die lief, keine Rekonstruktion.

Alles herunterladen: examples.zip — dieselben Dateien, die diese Seite zeigt, auf Anforderung gepackt. Es gibt keine zweite Kopie, die veralten könnte.

Wenn ein Agent es für Sie anbindet

Es gibt ein Dokument, das geschrieben wurde, um von einer Maschine gelesen zu werden: uttera.ai/skill.md. Es enthält dasselbe wie dieses Kapitel, aber in der Reihenfolge, die ein Agent braucht, und mit den Warnungen, die er braucht, damit er Ihre Credits nicht versehentlich ausgibt.

Der Satz, den man ihm gibt, lautet wörtlich:

read https://uttera.ai/skill.md and follow the instructions

Es funktioniert mit jedem Agenten, der eine URL lesen und curl ausführen kann.

Es kann keine Konten anlegen, und das ist Absicht. Das Dokument sagt ihm ausdrücklich, es nicht zu versuchen und Sie stattdessen um den Schlüssel zu bitten. Eine von Agenten durchgeführte Anmeldung wäre eine offene Tür für Fake-Konten und für den Missbrauch des kostenlosen Tarifs, der großzügig ist, weil wir erwarten, dass die Leute ihn nutzen.

Was Sie zuerst wissen sollten

Timeouts: Die Regel sind zwei Stunden

Der Server hält die Verbindung bis zu 7200 Sekunden offen. Ein Client mit dem Standard-Timeout —30 s in vielen Bibliotheken— bricht Aufträge ab, die einwandfrei liefen. Es ist der häufigste Fehler bei der Integration.

Zur Orientierung, gemessen: Eine 100-minütige Aufnahme wird in 10 bis 35 Sekunden transkribiert, je nachdem, welcher Knoten sie bearbeitet. Die lange Wartezeit ist nicht für den Normalfall, sie ist dafür, dass der seltene Fall nicht verloren geht.

Was wiederholen und was nicht

AntwortWiederholen?Warum
429Ja, unter Beachtung von Retry-AfterSie haben das Kontingent pro Sekunde überschritten, oder Sie haben zu viele Anfragen in Bearbeitung.
502 503 504Ja, mit wachsendem BackoffEin Knoten ist ausgefallen. Unser System wiederholt es bereits einmal von selbst, bevor es Ihnen zurückgegeben wird.
400 401 402 403 404 413 415 422NeinDas Problem liegt in der Anfrage oder im Konto. Wiederholen ändert nichts und verbrennt Kontingent.

Fehler, die von der Randschicht ausgelöst werden, kommen alle in derselben Form:

{"error": "quota_exceeded", "message": "Monthly credit balance depleted…"}
Es gibt eine Ausnahme, und sie lohnt sich zu behandeln. Wenn der Fehler von der Engine und nicht von der Randschicht kommt —ein 400 für eine Datei, die nicht decodiert werden kann, oder eine über der Maximalgröße— trägt die Antwort detail statt error und message:
{"detail": "Maximum file size exceeded (parameter=audio_filesize_mb, value=112.9)"}
Wenn Sie einen Fehler lesen, schauen Sie auf error und weichen Sie auf detail aus, wenn es nicht da ist.

Header, die jede Antwort trägt

HeaderWann er erscheintWas er sagt
X-Request-IdimmerIdentifiziert die Anfrage. Es ist das Erste, wonach wir Sie fragen, wenn etwas schiefgeht.
X-Audio-DurationAudio-Eingabe oder erzeugtAbgerechnete Sekunden. Es steht nicht im Body, nur hier.
X-Detected-LanguageTranskriptionDie Sprache, in der die Engine gearbeitet hat, als Zwei-Buchstaben-Code. Wenn Sie language nicht gesendet haben, ist es die, die sie erkannt hat. Wenn Sie es gesendet haben, gibt sie Ihnen Ihre zurück — es ist keine zweite Meinung dazu, ob Sie richtig lagen.
X-RateLimit-Service
X-RateLimit-Limit-Second
X-RateLimit-Remaining-Second
immerKontingent pro Sekunde für den genutzten Dienst und wie viel davon übrig ist.
X-Credits-Limit-Monthly
X-Credits-Used-Monthly
X-Credits-Remaining-Monthly
X-Credits-Reset-Monthly
nur wenn Ihr Tarif ein Kontingent hatStand der Credits dieses Zyklus.
X-Credits-Overagesobald das Kontingent überschritten isttrue: Sie werden weiterhin bedient, aber in abrechenbaren Mehrverbrauch.
X-Concurrency-Limit
X-Concurrency-Active
X-Concurrency-Slots
nur wenn Ihr Tarif ein Limit hatErlaubte gleichzeitige Anfragen, in Bearbeitung, und wie viele diese belegt.
X-CacheSpracheHIT · MISS · BYPASS · ADHOC · DISABLED. Ein Treffer kostet ein Zehntel; BYPASS bestätigt, dass nichts gespeichert wurde.
X-Watermarkerzeugtes AudioDas Schema, mit dem die Samples markiert sind, audioseal-1. Es ist immer da, und kein Parameter entfernt es. Audio von uns ohne diesen Header ist ein Fehler auf unserer Seite — was die Marke sagt und was nicht.
X-Audio-Sha256
Content-Digest
erzeugtes AudioSHA-256 der Ihnen übergebenen Bytes: in Hex, und derselbe Digest noch einmal in der RFC 9530-Form. Prüfen Sie es, und Sie wissen, dass das Audio vollständig angekommen ist. Eine gecachte Antwort trägt denselben, weil die Bytes dieselben sind.
Retry-After429 und 402Sekunden, die Sie warten müssen. Lassen Sie ihn entscheiden, nicht Ihren eigenen Zähler.
Ein Header, der ein Limit beschreibt, erscheint nur, wenn es ein Limit gibt. Bei enterprise, mit unbegrenztem Kontingent und unbegrenzter Gleichzeitigkeit, sehen Sie keinen der Credit- oder Gleichzeitigkeits-Header. Das ist kein Fehler: Es gibt nichts zu zählen.

Ein Endpunkt bewegt mehrere Dienste

/v1/translate und /v1/summarize verketten mehrere Engines, und jede Stufe belegt einen Gleichzeitigkeits-Slot. Bei einem Tarif mit 3 gleichzeitigen Anfragen können zwei Zusammenfassungen auf einmal Ihnen 429 too_many_concurrent_requests geben, obwohl Sie nur zwei Anfragen gestartet haben. Schauen Sie auf X-Concurrency-Slots, um zu wissen, wie viele Slots jede verbraucht.

Normalisieren Sie das Audio, bevor Sie es senden

Die Engine tastet ohnehin alles auf 16 kHz Mono um. Wenn Sie ein 48-kHz-Stereo-WAV hochladen, zahlen Sie Bandbreite und Upload-Zeit für Informationen, die verworfen werden, und Sie erreichen das Größenlimit früher.

ffmpeg -i original.wav -ar 16000 -ac 1 -b:a 64k ready.mp3

Zwei Stunden Audio wie dieses wiegen etwa 58 MB, deutlich unter dem Limit. Dieselbe Aufnahme als 48-kHz-Stereo-WAV wäre 1,3 GB.

Bash

Für einmalige Aufgaben, cron und Pipelines. Es braucht nur curl.

#!/usr/bin/env bash
# Transcribe a recording, with retries and a read of what was billed.
set -euo pipefail

: "${UTTERA_API_KEY:?export your key: export UTTERA_API_KEY=sk-echo-...}"
API=https://api.uttera.ai
FILE=${1:?usage: ./transcribe.sh recording.mp3}

# The rule is two hours: the server holds the connection for up to 7200 s, so
# the client must not give up sooner or you will cut off a job that was fine.
MAX_WAIT=7200

attempt() {
  curl -sS --max-time "$MAX_WAIT" \
       -D /tmp/uttera.hdr -o /tmp/uttera.out -w '%{http_code}' \
       -H "Authorization: Bearer $UTTERA_API_KEY" \
       -F "file=@${FILE}" \
       -F "model=whisper-1" \
       -F "response_format=text" \
       "$API/v1/audio/transcriptions"
}

for try in 1 2 3 4 5; do
  code=$(attempt) || code=000

  # 2xx: done.
  if [ "$code" -ge 200 ] && [ "$code" -lt 300 ]; then
    cat /tmp/uttera.out
    echo
    echo "--- seconds billed: $(grep -i '^x-audio-duration:' /tmp/uttera.hdr | tr -d '\r' | cut -d' ' -f2)"
    echo "--- request id:     $(grep -i '^x-request-id:'     /tmp/uttera.hdr | tr -d '\r' | cut -d' ' -f2)"
    exit 0
  fi

  # 4xx (except 429): the problem is yours. Retrying will not fix it.
  if [ "$code" -ge 400 ] && [ "$code" -lt 500 ] && [ "$code" != 429 ]; then
    echo "error $code, not retrying:" >&2
    cat /tmp/uttera.out >&2
    exit 1
  fi

  # 429 and 5xx: temporary. If Retry-After comes back, let it decide.
  wait=$(grep -i '^retry-after:' /tmp/uttera.hdr | tr -d '\r' | cut -d' ' -f2 || true)
  [ -n "${wait:-}" ] || wait=$(( 2 ** try ))
  echo "error $code, attempt $try, retrying in ${wait}s" >&2
  sleep "$wait"
done

echo "retries exhausted" >&2
exit 1

Python

Es braucht nur requests. Es enthält die Abfrage dessen, was Ihnen berechnet wurde, die separat erfolgt, weil die Berechnung erst nach der Antwort erfolgt.

"""Minimal Uttera client. Only needs `requests`."""
import os
import time

import requests

API = "https://api.uttera.ai"
# The rule is two hours: the server holds on for up to 7200 s. The first number
# is the connect timeout, the second the read timeout between response bytes.
TIMEOUT = (10, 7200)
# 429 asks you to wait; 502/503/504 are a node that failed and is already
# retried once on the server. Other 4xx are yours: retrying will not fix them.
RETRYABLE = {429, 502, 503, 504}


class UtteraError(RuntimeError):
    def __init__(self, status, body, request_id=None):
        self.status = status
        self.code = (body or {}).get("error")
        self.request_id = request_id
        super().__init__(f"{status} {self.code}: {(body or {}).get('message')}")


class Uttera:
    def __init__(self, key=None, api=API, attempts=4):
        self.key = key or os.environ["UTTERA_API_KEY"]
        self.api = api
        self.attempts = attempts
        self.session = requests.Session()

    def _call(self, method, path, **kw):
        headers = {"Authorization": f"Bearer {self.key}"}
        headers.update(kw.pop("headers", {}))
        for attempt in range(1, self.attempts + 1):
            r = self.session.request(method, self.api + path,
                                     headers=headers, timeout=TIMEOUT, **kw)
            if r.ok:
                return r
            if r.status_code not in RETRYABLE or attempt == self.attempts:
                try:
                    body = r.json()
                except ValueError:
                    body = {"message": r.text[:200]}
                raise UtteraError(r.status_code, body, r.headers.get("X-Request-Id"))
            # If the server says how long to wait, it decides.
            wait = float(r.headers.get("Retry-After", 2 ** attempt))
            time.sleep(wait)

    def transcribe(self, audio_path, **fields):
        fields.setdefault("model", "whisper-1")
        with open(audio_path, "rb") as f:
            r = self._call("POST", "/v1/audio/transcriptions",
                           files={"file": (os.path.basename(audio_path), f)},
                           data=fields)
        return r

    def speak(self, text, voice="alloy", fmt="mp3"):
        return self._call("POST", "/v1/audio/speech",
                          json={"model": "tts-1", "voice": voice,
                                "input": text, "response_format": fmt})

    def last_charge(self, endpoint=None):
        params = {"endpoint": endpoint} if endpoint else None
        return self._call("GET", "/v1/usage/last", params=params).json()


if __name__ == "__main__":
    import sys

    u = Uttera()
    r = u.transcribe(sys.argv[1], response_format="text")
    print(r.text.strip())
    # The seconds you are billed for come in the header, not in the body.
    print("seconds billed:", r.headers.get("X-Audio-Duration"))
    print("request id:    ", r.headers.get("X-Request-Id"))
    # The charge is computed AFTER you are answered, so it is queried apart.
    print("charged:", u.last_charge()["credits"], "credits")

JavaScript · Node

Keine Abhängigkeiten: fetch, FormData und Blob sind ab Version 20 in Node eingebaut. Die Datei ist ein ES-Modul, also benennen Sie sie entweder .mjs oder setzen Sie "type": "module" in Ihre package.json.

Legen Sie den Schlüssel nie in Code, der im Browser läuft. Jeder kann die Entwicklerwerkzeuge öffnen und damit weggehen, und mit Ihrem Credit-Kontingent. Von einer Webseite rufen Sie Ihr Backend auf, und Ihr Backend ist es, das mit uns spricht. Dieses Beispiel ist für den Server.
// Minimal Uttera client. No dependencies: fetch, FormData and Blob are built
// into Node from version 20.
import { openAsBlob } from "node:fs";
import { basename } from "node:path";

const API = "https://api.uttera.ai";
// 429 asks you to wait; 5xx is a node that failed. Other 4xx are yours:
// retrying will not fix them.
const RETRYABLE = new Set([429, 502, 503, 504]);

export class UtteraError extends Error {
  constructor(status, body, requestId) {
    super(`${status} ${body?.error}: ${body?.message}`);
    this.name = "UtteraError";
    this.status = status;
    this.code = body?.error;
    this.requestId = requestId;
  }
}

const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));

export class Uttera {
  constructor(key = process.env.UTTERA_API_KEY, { api = API, attempts = 4 } = {}) {
    if (!key) throw new Error("UTTERA_API_KEY is missing");
    this.key = key;
    this.api = api;
    this.attempts = attempts;
  }

  async #call(path, options = {}, rebuild = null) {
    for (let attempt = 1; attempt <= this.attempts; attempt++) {
      // The body is consumed when sent: if we have to retry, it is rebuilt.
      const body = rebuild ? await rebuild() : options.body;
      // The rule is two hours: the server holds on for up to 7200 s. Without
      // this, fetch waits forever; with 30 s you would cut off healthy jobs.
      const res = await fetch(this.api + path, {
        ...options,
        body,
        headers: { Authorization: `Bearer ${this.key}`, ...options.headers },
        signal: AbortSignal.timeout(7200_000),
      });

      if (res.ok) return res;
      if (!RETRYABLE.has(res.status) || attempt === this.attempts) {
        let detail = null;
        try { detail = await res.json(); } catch { detail = { message: await res.text() }; }
        throw new UtteraError(res.status, detail, res.headers.get("x-request-id"));
      }
      // If the server says how long to wait, it decides.
      await sleep(Number(res.headers.get("retry-after")) || 2 ** attempt);
    }
  }

  async transcribe(path, fields = {}) {
    const rebuild = async () => {
      const fd = new FormData();
      fd.append("file", await openAsBlob(path), basename(path));
      fd.append("model", fields.model ?? "whisper-1");
      for (const [k, v] of Object.entries(fields)) if (k !== "model") fd.append(k, v);
      return fd;
    };
    return this.#call("/v1/audio/transcriptions", { method: "POST" }, rebuild);
  }

  async speak(text, voice = "alloy", fmt = "mp3") {
    return this.#call("/v1/audio/speech", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ model: "tts-1", voice, input: text, response_format: fmt }),
    });
  }

  async lastCharge(endpoint) {
    const q = endpoint ? `?endpoint=${encodeURIComponent(endpoint)}` : "";
    return (await this.#call(`/v1/usage/last${q}`)).json();
  }
}

// ── usage ──────────────────────────────────────────────────────────────────
const u = new Uttera();
const res = await u.transcribe(process.argv[2], { response_format: "text" });
console.log((await res.text()).trim());
// The seconds you are billed for come in the header, not in the body.
console.log("seconds billed:", res.headers.get("x-audio-duration"));
console.log("request id:    ", res.headers.get("x-request-id"));
// The charge is computed AFTER you are answered, so it is queried apart.
console.log("charged:", (await u.lastCharge()).credits, "credits");

TypeScript

Derselbe Client mit Typen für die Antworten und die Fehler, was hier wirklich Wert schafft: code ist stabil, und Sie können darauf verzweigen, message ist zum Lesen für einen Menschen.

Es läuft mit node uttera.mts, ohne Kompilierungsschritt. Im Gegenzug müssen Sie innerhalb der löschbaren Teilmenge der Sprache bleiben: kein enum, kein namespace und keine Parametereigenschaften (constructor(private x: T)), weil diese Code und nicht nur Typen erzeugen. Mit erasableSyntaxOnly in Ihrer tsconfig.json warnt Sie der Compiler, wenn Sie heraustreten.
{
  "type": "module",
  "name": "uttera-ejemplo",
  "version": "1.0.0",
  "private": true,
  "devDependencies": {
    "typescript": "^5",
    "@types/node": "^22"
  }
}
{
  "compilerOptions": {
    "target": "ES2023",
    "module": "nodenext",
    "moduleResolution": "nodenext",
    "lib": ["ES2023", "DOM"],
    "strict": true,
    "noEmit": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "types": ["node"]
  }
}
// Minimal Uttera client in TypeScript. No dependencies.
import { openAsBlob } from "node:fs";
import { basename } from "node:path";

const API = "https://api.uttera.ai";
const RETRYABLE = new Set([429, 502, 503, 504]);

/** Shape of EVERY API error. Always the same, wherever it is handled. */
export interface ErrorBody {
  error: string;
  message: string;
  /** Only on errors raised by the edge (404, 413, 502…). */
  status?: number;
  docs?: string;
}

/** What GET /v1/usage/last returns. The breakdown depends on the endpoint. */
export interface Charge {
  endpoint: string;
  service: string;
  credits: number;
  breakdown: Record<string, number>;
  ts: number;
  audio_seconds?: number;
  input_tokens?: number;
  output_tokens?: number;
}

export type ResponseFormat = "json" | "text" | "verbose_json" | "srt" | "vtt";

export interface TranscribeOptions {
  model?: string;
  language?: string;
  prompt?: string;
  response_format?: ResponseFormat;
  temperature?: number;
}

export class UtteraError extends Error {
  readonly status: number;
  readonly code: string;
  readonly requestId: string | null;

  constructor(status: number, body: Partial<ErrorBody>, requestId: string | null) {
    super(`${status} ${body.error}: ${body.message}`);
    this.name = "UtteraError";
    this.status = status;
    this.code = body.error ?? "unknown";
    this.requestId = requestId;
  }
}

const sleep = (s: number): Promise<void> =>
  new Promise((r) => setTimeout(r, s * 1000));

export class Uttera {
  // ⚠ Fields declared and assigned by hand, NOT parameter properties
  // (`constructor(private key: string)`). That form emits code, not just
  // types, and Node refuses to run it directly: with ordinary fields this file
  // runs as it is with `node uttera.mts`, no compilation step.
  private readonly key: string;
  private readonly attempts: number;
  private readonly api: string;

  constructor(
    key: string = process.env.UTTERA_API_KEY ?? "",
    attempts: number = 4,
    api: string = API,
  ) {
    if (!key) throw new Error("UTTERA_API_KEY is missing");
    this.key = key;
    this.attempts = attempts;
    this.api = api;
  }

  private async call(
    path: string,
    options: RequestInit = {},
    rebuild?: () => Promise<BodyInit>,
  ): Promise<Response> {
    for (let attempt = 1; attempt <= this.attempts; attempt++) {
      // The body is consumed when sent: if we have to retry, it is rebuilt.
      const body = rebuild ? await rebuild() : options.body;
      // The rule is two hours: the server holds on for up to 7200 s.
      const res = await fetch(this.api + path, {
        ...options,
        body,
        headers: { Authorization: `Bearer ${this.key}`, ...options.headers },
        signal: AbortSignal.timeout(7_200_000),
      });

      if (res.ok) return res;
      if (!RETRYABLE.has(res.status) || attempt === this.attempts) {
        const err = (await res.json().catch(() => ({}))) as Partial<ErrorBody>;
        throw new UtteraError(res.status, err, res.headers.get("x-request-id"));
      }
      // If the server says how long to wait, it decides.
      await sleep(Number(res.headers.get("retry-after")) || 2 ** attempt);
    }
    throw new Error("unreachable");
  }

  async transcribe(path: string, options: TranscribeOptions = {}): Promise<Response> {
    const rebuild = async (): Promise<FormData> => {
      const fd = new FormData();
      fd.append("file", await openAsBlob(path), basename(path));
      fd.append("model", options.model ?? "whisper-1");
      for (const [k, v] of Object.entries(options)) {
        if (k !== "model" && v !== undefined) fd.append(k, String(v));
      }
      return fd;
    };
    return this.call("/v1/audio/transcriptions", { method: "POST" }, rebuild);
  }

  async lastCharge(endpoint?: string): Promise<Charge> {
    const q = endpoint ? `?endpoint=${encodeURIComponent(endpoint)}` : "";
    return (await this.call(`/v1/usage/last${q}`)).json() as Promise<Charge>;
  }
}

// ── usage ──────────────────────────────────────────────────────────────────
const u = new Uttera();
try {
  const res = await u.transcribe(process.argv[2]!, { response_format: "text" });
  console.log((await res.text()).trim());
  console.log("seconds billed:", res.headers.get("x-audio-duration"));
  const charge = await u.lastCharge();
  console.log("charged:", charge.credits, "credits", charge.breakdown);
} catch (e) {
  if (e instanceof UtteraError) {
    // `code` is stable; `message` is for a human to read, not to branch on.
    console.error(`failed ${e.code} (request ${e.requestId})`);
    process.exit(1);
  }
  throw e;
}

Go

Keine Abhängigkeiten: Alles ist Standardbibliothek. Achten Sie auf ein Detail, das beißt: Der Multipart-Body wird beim Senden verbraucht, sodass er bei jeder Wiederholung neu aufgebaut werden muss, statt denselben Reader wiederzuverwenden.

module uttera

go 1.21
// Minimal Uttera client. No dependencies: standard library only.
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"io"
	"math"
	"mime/multipart"
	"net/http"
	"os"
	"path/filepath"
	"strconv"
	"time"
)

const api = "https://api.uttera.ai"

// The rule is two hours: the server holds on for up to 7200 s. A Client with
// no Timeout waits forever; one with 30 s cuts off healthy jobs.
var client = &http.Client{Timeout: 2 * time.Hour}

// 429 asks you to wait; 5xx is a node that failed. Other 4xx are yours.
func retryable(code int) bool {
	return code == 429 || code == 502 || code == 503 || code == 504
}

type APIError struct {
	Status    int    `json:"-"`
	Code      string `json:"error"`
	Message   string `json:"message"`
	RequestID string `json:"-"`
}

func (e *APIError) Error() string {
	return fmt.Sprintf("%d %s: %s (request %s)", e.Status, e.Code, e.Message, e.RequestID)
}

// multipart must be rebuilt on every attempt: the body is consumed when sent.
func audioBody(path string, fields map[string]string) (io.Reader, string, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, "", err
	}
	defer f.Close()

	var buf bytes.Buffer
	w := multipart.NewWriter(&buf)
	part, err := w.CreateFormFile("file", filepath.Base(path))
	if err != nil {
		return nil, "", err
	}
	if _, err := io.Copy(part, f); err != nil {
		return nil, "", err
	}
	for k, v := range fields {
		if err := w.WriteField(k, v); err != nil {
			return nil, "", err
		}
	}
	if err := w.Close(); err != nil {
		return nil, "", err
	}
	return &buf, w.FormDataContentType(), nil
}

func Transcribe(key, path string, fields map[string]string) (string, http.Header, error) {
	for attempt := 1; attempt <= 4; attempt++ {
		body, ctype, err := audioBody(path, fields)
		if err != nil {
			return "", nil, err
		}
		req, err := http.NewRequest("POST", api+"/v1/audio/transcriptions", body)
		if err != nil {
			return "", nil, err
		}
		req.Header.Set("Authorization", "Bearer "+key)
		req.Header.Set("Content-Type", ctype)

		res, err := client.Do(req)
		if err != nil {
			return "", nil, err
		}
		data, _ := io.ReadAll(res.Body)
		res.Body.Close()

		if res.StatusCode < 300 {
			return string(data), res.Header, nil
		}
		if !retryable(res.StatusCode) || attempt == 4 {
			e := &APIError{Status: res.StatusCode, RequestID: res.Header.Get("X-Request-Id")}
			if json.Unmarshal(data, e) != nil {
				e.Message = string(data)
			}
			return "", res.Header, e
		}
		// If the server says how long to wait, it decides.
		wait := math.Pow(2, float64(attempt))
		if ra, err := strconv.ParseFloat(res.Header.Get("Retry-After"), 64); err == nil {
			wait = ra
		}
		time.Sleep(time.Duration(wait * float64(time.Second)))
	}
	return "", nil, fmt.Errorf("retries exhausted")
}

func main() {
	key := os.Getenv("UTTERA_API_KEY")
	if key == "" || len(os.Args) < 2 {
		fmt.Fprintln(os.Stderr, "usage: UTTERA_API_KEY=sk-echo-... ./uttera recording.mp3")
		os.Exit(2)
	}
	text, hdr, err := Transcribe(key, os.Args[1], map[string]string{
		"model":           "whisper-1",
		"response_format": "text",
	})
	if err != nil {
		fmt.Fprintln(os.Stderr, err)
		os.Exit(1)
	}
	fmt.Println(text)
	// The seconds you are billed for come in the header, not in the body.
	fmt.Println("seconds billed:", hdr.Get("X-Audio-Duration"))
	fmt.Println("request id:    ", hdr.Get("X-Request-Id"))
}

Rust

Mit reqwest im blockierenden Modus, der sich für einen API-Client weit besser liest als der asynchrone. Es verwendet rustls statt native-tls, um nicht vom OpenSSL des Systems abzuhängen. Bauen Sie es mit einer aktuellen Toolchain (rustup update): Die Abhängigkeiten von reqwest heben ständig die Rust-Version an, die sie brauchen, und auf einem alten Compiler scheitert der Build in einer von ihnen, nicht in diesem Code.

[package]
name = "uttera"
version = "0.1.0"
edition = "2021"

[dependencies]
# rustls instead of native-tls: that way you do not need the system OpenSSL.
reqwest = { version = "0.12", default-features = false, features = [
    "blocking", "multipart", "json", "rustls-tls", "charset", "http2",
] }
serde_json = "1"
//! Minimal Uttera client.
use std::{env, thread, time::Duration};

const API: &str = "https://api.uttera.ai";

/// 429 asks you to wait; 5xx is a node that failed. Other 4xx are yours:
/// retrying will not fix them.
fn retryable(code: u16) -> bool {
    matches!(code, 429 | 502 | 503 | 504)
}

fn transcribe(key: &str, path: &str) -> Result<(String, reqwest::header::HeaderMap), String> {
    // The rule is two hours: the server holds on for up to 7200 s. A short
    // timeout cuts off healthy jobs.
    let client = reqwest::blocking::Client::builder()
        .timeout(Duration::from_secs(7200))
        .build()
        .map_err(|e| e.to_string())?;

    for attempt in 1..=4u32 {
        // The form is consumed when sent: it has to be rebuilt every time.
        let form = reqwest::blocking::multipart::Form::new()
            .file("file", path)
            .map_err(|e| e.to_string())?
            .text("model", "whisper-1")
            .text("response_format", "text");

        let res = client
            .post(format!("{API}/v1/audio/transcriptions"))
            .bearer_auth(key)
            .multipart(form)
            .send()
            .map_err(|e| e.to_string())?;

        let code = res.status().as_u16();
        let headers = res.headers().clone();
        let body = res.text().unwrap_or_default();

        if (200..300).contains(&code) {
            return Ok((body, headers));
        }
        if !retryable(code) || attempt == 4 {
            let detail = serde_json::from_str::<serde_json::Value>(&body)
                .ok()
                .and_then(|v| v.get("message").and_then(|m| m.as_str()).map(String::from))
                .unwrap_or(body);
            let id = headers
                .get("x-request-id")
                .and_then(|v| v.to_str().ok())
                .unwrap_or("-");
            return Err(format!("{code}: {detail} (request {id})"));
        }
        // If the server says how long to wait, it decides.
        let wait = headers
            .get("retry-after")
            .and_then(|v| v.to_str().ok())
            .and_then(|v| v.parse::<u64>().ok())
            .unwrap_or(1u64 << attempt);
        thread::sleep(Duration::from_secs(wait));
    }
    Err("retries exhausted".into())
}

fn main() {
    let key = env::var("UTTERA_API_KEY").expect("export UTTERA_API_KEY");
    let path = env::args().nth(1).expect("usage: uttera recording.mp3");

    match transcribe(&key, &path) {
        Ok((text, hdr)) => {
            println!("{}", text.trim());
            // The seconds you are billed for come in the header, not the body.
            let read = |n| hdr.get(n).and_then(|v| v.to_str().ok()).unwrap_or("-");
            println!("seconds billed: {}", read("x-audio-duration"));
            println!("request id:     {}", read("x-request-id"));
        }
        Err(e) => {
            eprintln!("{e}");
            std::process::exit(1);
        }
    }
}