Images et vidéo asynchrones : respond-async, interrogation et webhooks

· Hyperouter · 4 min de lecture

Une vidéo prend du temps, et une image aussi parfois. Hyperouter vous donne trois moyens d’en attendre une sans connexion maintenue longtemps : envoyer Prefer: respond-async pour une image, interroger l’enregistrement, ou laisser un webhook vous prévenir quand la tâche se termine. Ce guide les passe en revue.

Quels modèles répondent comment

Chaque modèle de GET /v1/models liste ses response_modes :

Un modèle qui ne liste que async exige l’en-tête Prefer ; sans lui, la réponse est 400 avec le code async_required. La vidéo est toujours asynchrone via l’API Videos, avec ou sans l’en-tête.

Images : Prefer: respond-async

Sur un modèle qui liste async, ajoutez l’en-tête. L’id de modèle ci-dessous est un exemple : consultez les response_modes du vôtre. C’est la RFC 7240 : l’en-tête peut être répété et porter d’autres préférences, par exemple Prefer: wait=10, respond-async.

curl -i https://api.hyperouter.app/v1/images/generations \
  -H "Authorization: Bearer $HYPEROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Prefer: respond-async" \
  -d '{"model": "x-ai/grok-imagine-image", "prompt": "A lighthouse at dusk", "n": 1}'

Vous recevez aussitôt 202, avec l’en-tête de réponse Preference-Applied: respond-async :

{ "id": "0194f2c4-7a1e-7b3c-9d02-5f6a1c0e8b21", "status": "queued" }

Un modèle qui répond sync et auquel vous n’envoyez pas l’en-tête attend l’image jusqu’à 90 secondes. Si l’image n’est pas prête d’ici là, la réponse est 503 avec le code timeout, l’en-tête x-should-retry: false, et l’id de la tâche dans error.generation_id. La tâche continue, et vous n’êtes facturé que si elle réussit. Interrogez cet id comme ci-dessous.

Interroger une image

GET /v1/generations/{id} renvoie l’enregistrement : status vaut queued, submitting, running, done ou failed. Pour une image, outputs contient [{"url", "expires_at"}], des liens signés valables 24 heures. Les résultats sont disponibles pendant 30 jours.

import os
import time

import requests

headers = {"Authorization": f"Bearer {os.environ['HYPEROUTER_API_KEY']}"}

def wait_for_image(generation_id: str, every: float = 3.0, limit: float = 600.0) -> dict:
    deadline = time.monotonic() + limit
    while time.monotonic() < deadline:
        r = requests.get(
            f"https://api.hyperouter.app/v1/generations/{generation_id}",
            headers=headers,
            timeout=30,
        )
        r.raise_for_status()
        rec = r.json()["data"]
        if rec["status"] in ("done", "failed"):
            return rec
        time.sleep(every)
    raise TimeoutError(generation_id)

Sur failed, l’error de l’enregistrement contient un code et un message. Une tâche échouée ne coûte rien. Lire un résultat ne compte pas dans vos limites de débit.

Vidéos : toujours une tâche

Un appel à POST /v1/videos répond aussitôt 200 avec l’objet Video au statut queued. Prefer n’y change rien.

curl https://api.hyperouter.app/v1/videos \
  -H "Authorization: Bearer $HYPEROUTER_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"model": "x-ai/grok-imagine-video", "prompt": "A lighthouse at dusk", "seconds": 8}'

Interrogez GET /v1/videos/{id} jusqu’à ce que status vaille completed (il peut aussi valoir queued, in_progress ou failed). progress va de 0 à 100. Téléchargez ensuite le fichier :

async function waitForVideo(id: string): Promise<void> {
  const headers = { Authorization: `Bearer ${process.env.HYPEROUTER_API_KEY}` };
  for (;;) {
    const res = await fetch(`https://api.hyperouter.app/v1/videos/${id}`, { headers });
    const video = await res.json();
    if (video.status === "completed") break;
    if (video.status === "failed") throw new Error(video.error?.code ?? "failed");
    await new Promise((r) => setTimeout(r, 5000));
  }
  const file = await fetch(`https://api.hyperouter.app/v1/videos/${id}/content`, { headers });
  // file is video/mp4; ?variant=thumbnail returns the thumbnail
}

La vidéo est conservée jusqu’à son expires_at, 30 jours après sa création. Ensuite elle répond 404 not_found : téléchargez donc ce que vous voulez garder. Le montant est bloqué d’avance et libéré en totalité si la tâche échoue.

Des webhooks plutôt que l’interrogation

Ajoutez un endpoint dans le tableau de bord et une requête lui parvient lorsqu’une tâche se termine :

ÉvénementEnvoyé quand
video.completed, video.failedUne tâche vidéo se termine.
image.completed, image.failedUne tâche d’image que vous avez envoyée avec Prefer: respond-async se termine.

Le payload porte un id et rien d’autre du résultat : récupérez donc l’objet avec votre clé API. data.id est l’id que prennent GET /v1/videos/{id} et GET /v1/generations/{id}. Chaque livraison est signée selon le schéma Standard Webhooks, de sorte que les OpenAI SDK la vérifient. Vérifiez le corps brut avant de l’analyser :

const event = client.webhooks.unwrap(rawBody, req.headers, process.env.HYPEROUTER_WEBHOOK_SECRET);
if (event.type === "video.completed") {
  const video = await client.videos.retrieve(event.data.id);
}

Répondez par un 2xx en moins de 10 secondes. Tout le reste est retenté, jusqu’à huit fois de plus, et webhook-id est identique à chaque nouvelle tentative : utilisez-le pour dédupliquer. La référence des webhooks donne les règles des endpoints, les en-têtes et le calendrier complet des nouvelles tentatives.

Un endpoint dont toutes les livraisons échouent pendant 3 jours est désactivé, et le tableau de bord en indique la raison. Tant que vous ne l’avez pas réactivé, interrogez.

Et ensuite