UtteraUttera

Mettre Uttera dans votre code

Il n'y a pas de SDK. L'API est du HTTP simple et du multipart/form-data, vous pouvez donc lui parler depuis n'importe quel langage sans rien installer de nous. Ci-dessous, un client complet —téléversement, réessais, lecture de ce qui a été facturé— en six langages.

Les six exemples sont exécutés contre l'API en direct avant d'être publiés ici. Ce que vous lisez est le même fichier qui a tourné, non une reconstruction.

Téléchargez le tout : examples.zip — les mêmes fichiers que montre cette page, empaquetés à la demande. Il n'y a pas de seconde copie qui prenne du retard.

Si un agent le connecte pour vous

Il y a un document écrit pour être lu par une machine : uttera.ai/skill.md. Il contient la même chose que ce chapitre mais dans l'ordre dont un agent a besoin, et avec les avertissements dont il a besoin pour ne pas dépenser vos crédits par accident.

La phrase à lui donner est littéralement :

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

Cela fonctionne avec tout agent capable de lire une URL et d'exécuter curl.

Il ne peut pas créer de comptes, et c'est délibéré. Le document lui dit explicitement de ne pas essayer, et de vous demander la clé à la place. Une inscription menée par des agents serait une porte ouverte aux faux comptes et à l'abus du plan gratuit, qui est généreux parce que nous attendons des gens qu'ils l'utilisent.

Ce que vous devez savoir d'abord

Délais d'attente : la règle est deux heures

Le serveur garde la connexion ouverte jusqu'à 7200 secondes. Un client avec le délai par défaut —30 s dans beaucoup de bibliothèques— coupe des tâches qui se passaient parfaitement. C'est l'erreur la plus courante en intégrant.

Pour référence, mesuré : un enregistrement de 100 minutes est transcrit en 10 à 35 secondes selon le nœud qui le traite. La longue attente n'est pas pour le cas normal, elle est pour que le cas rare ne soit pas perdu.

Ce qu'il faut réessayer et ce qu'il ne faut pas

RéponseRéessayer ?Pourquoi
429Oui, en respectant Retry-AfterVous avez dépassé le quota par seconde, ou vous avez trop de requêtes en vol.
502 503 504Oui, avec un backoff croissantUn nœud a échoué. Notre système réessaie déjà une fois de lui-même avant de vous le rendre.
400 401 402 403 404 413 415 422NonLe problème est dans la requête ou dans le compte. Réessayer ne change rien et brûle du quota.

Les erreurs levées par la couche de bord arrivent toutes sous la même forme :

{"error": "quota_exceeded", "message": "Monthly credit balance depleted…"}
Il y a une exception, et elle vaut la peine d'être gérée. Quand l'erreur vient du moteur et non de la couche de bord —un 400 pour un fichier qui ne peut pas être décodé, ou un au-dessus de la taille maximale— la réponse porte detail au lieu d'error et message :
{"detail": "Maximum file size exceeded (parameter=audio_filesize_mb, value=112.9)"}
En lisant une erreur, regardez error et repliez-vous sur detail s'il n'y est pas.

En-têtes que porte chaque réponse

En-têteQuand il apparaîtCe qu'il dit
X-Request-IdtoujoursIdentifie la requête. C'est la première chose que nous vous demanderons si quelque chose se passe mal.
X-Audio-Durationaudio en entrée ou généréSecondes facturées. Ce n'est pas dans le corps, seulement ici.
X-Detected-LanguagetranscriptionLa langue dans laquelle le moteur a travaillé, en code à deux lettres. Si vous n'avez pas envoyé language, c'est celle qu'il a détectée. Si vous l'avez envoyée, il vous rend la vôtre — ce n'est pas un second avis sur votre exactitude.
X-RateLimit-Service
X-RateLimit-Limit-Second
X-RateLimit-Remaining-Second
toujoursQuota par seconde pour le service utilisé, et ce qu'il en reste.
X-Credits-Limit-Monthly
X-Credits-Used-Monthly
X-Credits-Remaining-Monthly
X-Credits-Reset-Monthly
seulement si votre plan a un quotaÉtat des crédits de ce cycle.
X-Credits-Overageune fois le quota dépassétrue : vous êtes toujours servi, mais en dépassement facturable.
X-Concurrency-Limit
X-Concurrency-Active
X-Concurrency-Slots
seulement si votre plan a un plafondRequêtes simultanées permises, en vol, et combien celle-ci en prend.
X-CacheparoleHIT · MISS · BYPASS · ADHOC · DISABLED. Un hit coûte un dixième ; BYPASS confirme que rien n'a été stocké.
X-Watermarkaudio généréLe schéma dont les échantillons sont marqués, audioseal-1. Il est toujours là et aucun paramètre ne le retire. De l'audio de nous sans cet en-tête est une faute de notre côté — ce que dit la marque, et ce qu'elle ne dit pas.
X-Audio-Sha256
Content-Digest
audio généréSHA-256 des octets qui vous sont livrés : en hexa, et le même digest à nouveau sous la forme RFC 9530. Vérifiez-le et vous savez que l'audio est arrivé entier. Une réponse en cache porte le même, parce que les octets sont les mêmes.
Retry-After429 et 402Secondes que vous devez attendre. Laissez-le décider, non votre propre compteur.
Un en-tête qui décrit une limite n'apparaît que s'il y a une limite. Sur enterprise, avec un quota et une concurrence sans plafond, vous ne verrez aucun des en-têtes de crédits ou de concurrence. Ce n'est pas une faute : il n'y a rien à compter.

Un endpoint met en mouvement plusieurs services

/v1/translate et /v1/summarize enchaînent plusieurs moteurs, et chaque étape prend un slot de concurrence. Sur un plan à 3 requêtes simultanées, deux résumés à la fois peuvent vous donner 429 too_many_concurrent_requests même si vous n'avez lancé que deux requêtes. Regardez X-Concurrency-Slots pour savoir combien de slots chacune dépense.

Normalisez l'audio avant de l'envoyer

Le moteur rééchantillonne tout à 16 kHz mono de toute façon. Si vous téléversez un WAV stéréo 48 kHz, vous payez de la bande passante et du temps de téléversement pour de l'information qui va être jetée, et vous atteignez le plafond de taille plus tôt.

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

Deux heures d'audio ainsi pèsent environ 58 Mo, bien en dessous de la limite. Le même enregistrement en WAV stéréo 48 kHz ferait 1,3 Go.

Bash

Pour les tâches ponctuelles, le cron et les pipelines. Il n'a besoin que de 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

Il n'a besoin que de requests. Il inclut la requête de ce qui vous a été facturé, demandée séparément parce que la facturation est calculée après qu'on vous a répondu.

"""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

Aucune dépendance : fetch, FormData et Blob sont intégrés à Node depuis la version 20. Le fichier est un module ES, donc nommez-le .mjs ou mettez "type": "module" dans votre package.json.

Ne mettez jamais la clé dans du code qui tourne dans le navigateur. N'importe qui peut ouvrir les outils de développement et repartir avec, et avec votre quota de crédits. Depuis une page web, vous appelez votre backend, et c'est votre backend qui nous parle. Cet exemple est pour le serveur.
// 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

Le même client avec des types pour les réponses et les erreurs, ce qui est ce qui apporte vraiment de la valeur ici : code est stable et vous pouvez brancher dessus, message est fait pour être lu par un humain.

Il s'exécute avec node uttera.mts, sans étape de compilation. En échange, vous devez rester dans le sous-ensemble effaçable du langage : pas d'enum, pas de namespace et pas de propriétés de paramètre (constructor(private x: T)), parce que celles-ci émettent du code et pas seulement des types. Avec erasableSyntaxOnly dans votre tsconfig.json, le compilateur vous avertit si vous en sortez.
{
  "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

Aucune dépendance : tout est de la bibliothèque standard. Attention à un détail qui mord : le corps multipart est consommé quand il est envoyé, il doit donc être reconstruit à chaque réessai plutôt que de réutiliser le même lecteur.

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

Avec reqwest en mode bloquant, qui pour un client d'API se lit bien mieux que l'asynchrone. Il utilise rustls au lieu de native-tls pour ne pas dépendre de l'OpenSSL du système. Compilez-le avec une toolchain à jour (rustup update) : les dépendances de reqwest ne cessent de relever la version de Rust qu'elles exigent, et sur un vieux compilateur la compilation échoue à l'intérieur de l'une d'elles, non dans ce 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);
        }
    }
}