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.
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.
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éponse | Réessayer ? | Pourquoi |
|---|---|---|
429 | Oui, en respectant Retry-After | Vous avez dépassé le quota par seconde, ou vous avez trop de requêtes en vol. |
502 503 504 | Oui, avec un backoff croissant | Un 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 422 | Non | Le 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…"}
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ête | Quand il apparaît | Ce qu'il dit |
|---|---|---|
X-Request-Id | toujours | Identifie la requête. C'est la première chose que nous vous demanderons si quelque chose se passe mal. |
X-Audio-Duration | audio en entrée ou généré | Secondes facturées. Ce n'est pas dans le corps, seulement ici. |
X-Detected-Language | transcription | La 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-ServiceX-RateLimit-Limit-SecondX-RateLimit-Remaining-Second | toujours | Quota par seconde pour le service utilisé, et ce qu'il en reste. |
X-Credits-Limit-MonthlyX-Credits-Used-MonthlyX-Credits-Remaining-MonthlyX-Credits-Reset-Monthly | seulement si votre plan a un quota | État des crédits de ce cycle. |
X-Credits-Overage | une fois le quota dépassé | true : vous êtes toujours servi, mais en dépassement facturable. |
X-Concurrency-LimitX-Concurrency-ActiveX-Concurrency-Slots | seulement si votre plan a un plafond | Requêtes simultanées permises, en vol, et combien celle-ci en prend. |
X-Cache | parole | HIT · MISS · BYPASS · ADHOC · DISABLED. Un hit coûte un dixième ; BYPASS confirme que rien n'a été stocké. |
X-Watermark | audio 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-Sha256Content-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-After | 429 et 402 | Secondes que vous devez attendre. Laissez-le décider, non votre propre compteur. |
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.
// 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.
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);
}
}
}