spicyapiDocumentation
Pour commencer
Contenu principal

Démarrage rapide

Obtenez une clé, créez une tâche et récupérez le résultat : le parcours complet, de bout en bout.

SpicyAPI regroupe la génération d’images et de vidéos derrière un seul protocole d’authentification, de tâches et de résultats et sert les modèles de texte aux formats OpenAI, Anthropic et Gemini que vous utilisez déjà. Gardez le même déroulement d’appels en changeant de modèle et construisez input depuis son inputSchema, la liste des champs acceptés, de leurs types et valeurs autorisées.

Ce guide utilise l’API native de tâches à https://api.spicyapi.ai/api/v1. Les points de terminaison compatibles de texte et de vidéo utilisent https://api.spicyapi.ai/v1. Les deux acceptent la même clé API, mais leurs formats de requête et de réponse diffèrent.

Suivez cette page pour choisir un modèle et récupérer un résultat. Pour connaître un montant exact avant paiement, voir Devis et compatibilité. Pour le chat, voir Texte et streaming. Avant de déployer l’intégration, suivez Intégration en production ; utilisez Dépannage lorsqu’une étape échoue. Les termes inconnus sont expliqués dans le glossaire.

Vous voulez voir un résultat avant d’écrire du code ?

Ouvrez un modèle sur spicyapi.ai/explore et utilisez son onglet Playground ; l’onglet API présente la même requête sous forme de commande copiable. Pour travailler en terminal sans écrire de code, utilisez la CLI ou laissez un assistant IA le faire avec le serveur MCP.

Outils officiels

L’appel HTTP direct est entièrement pris en charge et présenté dans le reste de cette page. Si vous préférez ne pas écrire les appels vous-même, quatre packages npm sous licence MIT s’installent séparément et disposent de leurs propres guides :

Quatre autres SDK officiels couvrent les langages hors de Node.js. Chacun implémente le même workflow sous licence MIT. Actuellement, seul le module Go s’installe depuis son dépôt par la commande habituelle ; les trois autres pages indiquent l’installation à utiliser en attendant la publication des packages :

Vous travaillez dans ComfyUI ? Les nœuds ComfyUI placent chaque modèle d’image, de vidéo et d’audio sur votre canevas, avec son tarif affiché avant l’exécution.

Avant de commencer

Il vous faut quatre éléments. En omettre un provoque plus tard une erreur qui ne dit pas lequel manque.

  1. Un compte avec adresse e-mail vérifiée. Inscrivez-vous sur spicyapi.ai et ouvrez le lien du mail de vérification. Avant cela, chaque appel de votre clé renvoie 403.
  2. Un solde. Les générations sont payées depuis un solde en dollars américains, sans crédits, et un nouveau compte commence à zéro. Ajoutez des fonds dans Console → Facturation. Sans solde suffisant, la création échoue avec le code métier 40201. Configuration du compte explique l’inscription, l’ajout de fonds et la création d’une clé.
  3. Un terminal. macOS : l’application Terminal. Linux : n’importe quel shell. Windows : PowerShell. Lorsque les commandes diffèrent, chaque étape comporte un onglet Windows PowerShell. Les commandes macOS / Linux fonctionnent aussi sans modification dans WSL ou Git Bash sous Windows.
  4. Quelques centimes. L’étape 3 crée une véritable tâche d’image facturée si elle réussit. L’étape 2 montre les tarifs et une commande facultative permet de connaître gratuitement le montant exact avant de commencer.

Évaluer d’abord : modèles et tarifs sans compte

Aucune clé ni aucun compte n’est nécessaire pour voir l’offre et ses tarifs. Le catalogue public du site marketing est lisible anonymement, et cette commande ne nécessite aucune substitution :

Public catalogue — no key, no account
curl "https://api.spicyapi.ai/console/v1/catalog/models?locale=en"

L’enveloppe est celle décrite en bas de page. Dans data.items, chaque entrée contient :

  • model : la valeur exacte à envoyer ensuite comme model ; slug porte la même valeur ;
  • callable : indique si le modèle peut s’exécuter maintenant. Choisissez les entrées à true : status est notre intention d’exploitation, et un modèle live peut être temporairement non appelable ;
  • modality et tasks : type de sortie ;
  • price : palier le moins cher, avec amount en USD par unit (per_image, per_second, per_request ou per_1k_tokens), et tiers, le nombre de paliers. Pour le texte, lisez price.perMillionTokens, avec des tarifs distincts par million de tokens d’entrée, d’entrée en cache et de sortie ; amount seul ne donne que le tarif de sortie.

Ajoutez un ID de modèle pour sa fiche complète, avec inputSchema, paramNotes et les examples détaillés :

One model, in full — replace MODEL_ID_FROM_CATALOG with a model value from the list above
curl "https://api.spicyapi.ai/console/v1/catalog/models/MODEL_ID_FROM_CATALOG?locale=en"

MODEL_ID_FROM_CATALOG est une valeur à remplacer dans cette documentation, pas un modèle réel : copiez toujours un véritable ID du catalogue actuel.

locale vaut en par défaut ; fournissez ja, ko, de, fr, es, pt-BR, ru, it ou pl pour les noms, résumés et notes de paramètres traduits. Toute autre valeur renvoie 400. strict=1 retire les entrées dont le texte n’est pas publié dans cette langue au lieu de revenir à l’anglais ; ce réglage n’est généralement pas nécessaire. Cette API est limitée par adresse source, pas par compte, avec son propre quota ; voir Limites de débit. Elle est aussi mise en cache : un changement de tarif peut donc mettre un certain temps à apparaître.

Si tiers est supérieur à 1, le price affiché est le palier le moins cher, pas le prix de vos réglages. price.variant indique les réglages de ce palier, par exemple 720p ou generate_audio=false;resolution=480p pour plusieurs champs combinés. Le inputSchema["x-pricing"].variantFields de la fiche d’un modèle, lorsqu’il existe, liste tous les champs sélectionnant un palier. pricingNote rappelle généralement que l’estimation actuelle fait autorité, sans lister les champs. Pour le montant exact, utilisez POST /api/v1/jobs/quote une fois votre clé obtenue.

Catalogue public pour évaluer, catalogue authentifié pour appeler

Le catalogue public répond à « que proposez-vous et à quel tarif affiché ? ». Il ne répond pas à « qu’est-ce que ma clé peut appeler ? » ni « combien mon compte sera-t-il facturé ? », car cela dépend du compte. Avec une clé, passez à GET /api/v1/models à l’étape 2 et utilisez-le comme seule source d’IDs appelables. Un ID mémorisé, un instantané de lancement ou une page marketing n’est pas un repli.

Cinq étapes

Obtenir une clé

Une clé API est le mot de passe envoyé par votre code à chaque requête. Elle identifie votre compte et permet sa facturation.

  1. Ouvrez Console → Clés API et cliquez sur Créer une clé.
  2. Donnez-lui un nom, par exemple my-first-test. Laissez les réglages Avancé inchangés.
  3. Copiez la clé commençant par sk-spicy- ou cliquez sur Enregistrer dans un fichier. Elle est affichée une seule fois ; si vous la perdez, créez-en une nouvelle.
  4. Dans le terminal utilisé pour la suite, stockez-la dans une variable d’environnement, une valeur nommée transmise par le terminal aux commandes, pour ne jamais avoir à la saisir dans une commande :
export SPICY_API_KEY="sk-spicy-paste-your-key-here"

# Check: this should print sk-spicy-
echo "${SPICY_API_KEY:0:9}"

Résultat attendu : sk-spicy-. La valeur reste définie jusqu’à la fermeture de cette fenêtre ; exécutez donc la suite dans la même fenêtre.

Si Créer une clé est absent, votre rôle dans l’équipe actuelle ne permet pas de créer de clés : demandez au propriétaire ou utilisez Créer votre propre équipe sur la même page. Les nouvelles clés n’ont aucun plafond de dépenses sauf si vous en définissez un. Un plafond quotidien dans la console limite les conséquences d’une mauvaise configuration ou d’une fuite. Voir Authentification.

Choisir un modèle appelable

Le catalogue authentifié est la seule source d’IDs appelables, de schémas actuels et d’exemples. Procédez en deux appels : une liste légère indique ce que vous pouvez appeler, puis une fiche donne le schéma du modèle choisi. La lecture est gratuite.

1. What can this key call?
curl "https://api.spicyapi.ai/api/v1/models?modality=image&task=text-to-image" \
  -H "Authorization: Bearer $SPICY_API_KEY"

La réponse est longue. Voici sa partie utile, abrégée :

{
  "code": 200,
  "msg": "success",
  "data": {
    "total": 1,
    "items": [
      {
        "model": "MODEL_ID_FROM_CATALOG",
        "displayName": "…",
        "modality": "image",
        "tasks": ["text-to-image"],
        "enabled": true,
        "available": true,
        "startingPrice": { "variant": "", "unit": "per_image", "price": "0.008", "currency": "USD" }
      }
    ]
  },
  "request_id": "req_01k3m8x9q2z4v7n5p6r8s0t1w2"
}

Choisissez une entrée dont enabled et available sont tous deux true, notez son tarif, copiez sa valeur model et conservez-la pour la suite. Avec jq, curl … | jq -r '.data.items[] | select(.enabled and .available) | .model' n’affiche que les IDs appelables.

# Replace MODEL_ID_FROM_CATALOG with the model value you copied
export SPICY_MODEL="MODEL_ID_FROM_CATALOG"
2. What fields does that one model take?
curl "https://api.spicyapi.ai/api/v1/models/${SPICY_MODEL}" \
  -H "Authorization: Bearer $SPICY_API_KEY"

Les barres obliques de l’ID restent telles quelles dans le chemin ; encodez-les seulement si votre client HTTP exige qu’un paramètre de chemin soit un segment unique. L’inputSchema de la fiche fait autorité pour input : required liste les champs à envoyer et properties décrit chacun. examples contient les entrées déjà validées par le serveur. Pour lire le reste de la fiche, dont pricing, voir Catalogue des modèles et schémas.

Demandez les schémas uniquement où ils sont nécessaires. Ajouter includeSchema=1&includeExamples=1 à la liste renvoie en une réponse le JSON Schema complet de tous les modèles activés : le corps est beaucoup plus volumineux et consomme le quota de lecture du compte, indépendamment de la création de tâches. Ne relisez pas non plus le catalogue avant chaque tâche ; voir Mise en cache du catalogue.

Si aucune entrée ne correspond, arrêtez-vous avant une tâche payante. Un instantané, une page marketing ou un ID mémorisé ne remplace pas un catalogue actuel vide. Pour une sélection par code plutôt que manuelle, voir Sélectionner le modèle par code.

Facultatif : connaître d’abord le montant exact. Un devis est gratuit, ne crée rien et ne réserve rien. data.estimatedCost est le coût maximal de cette requête exacte, en USD :

Optional quote (macOS / Linux)
curl -X POST https://api.spicyapi.ai/api/v1/jobs/quote \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  --data "{\"model\":\"${SPICY_MODEL}\",\"input\":{\"prompt\":\"A cinematic night portrait\"}}"

Créer une tâche

Une tâche est un travail de génération en arrière-plan : vous recevez son ID immédiatement, puis le résultat plus tard. Cette étape coûte de l’argent.

Chaque nouvelle tâche reçoit une nouvelle clé d’idempotence, un ID aléatoire empêchant que deux envois identiques, par exemple après une erreur réseau, créent deux tâches. Générez-la dans une commande séparée, puis envoyez la tâche :

3a. A new key for each new image (run once per image)
SPICY_IDEMPOTENCY_KEY="$(uuidgen)"
# Linux without uuidgen: SPICY_IDEMPOTENCY_KEY="$(cat /proc/sys/kernel/random/uuid)"
3b. Send the task (safe to run again after a network error)
curl -X POST https://api.spicyapi.ai/api/v1/jobs/createTask \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $SPICY_IDEMPOTENCY_KEY" \
  --data "{\"model\":\"${SPICY_MODEL}\",\"input\":{\"prompt\":\"A cinematic night portrait\"}}"

Si 3b expire ou que la connexion se coupe, répétez 3b sans exécuter 3a : la même clé renvoie la tâche déjà commencée au lieu de payer deux fois. Pour une autre image, exécutez d’abord 3a. Un 409 signifie que vous avez réutilisé une clé avec un prompt ou modèle différent. Voir Idempotence.

Un seul champ est envoyé ici. Si le schéma lu à l’étape 2 en impose d’autres, ajoutez-les : l’input autorisé dépend du schéma du modèle, et un champ inconnu suffit à provoquer un 400.

La connexion ne reste jamais ouverte. Vous recevez immédiatement un ID :

HTTP/1.1 202 Accepted
{
  "code": 200,
  "msg": "success",
  "data": {
    "taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
    "state": "queued",
    "estimatedCost": "0.008",
    "deadlineAt": "2026-08-28T09:22:04Z"
  },
  "request_id": "req_01k3m8x9q2z4v7n5p6r8s0t1w2"
}

Sous macOS / Linux, copiez le taskId de la réponse et conservez-le ; PowerShell l’a déjà fait :

export SPICY_TASK_ID="paste-the-taskId-here"

202 signifie accepté, pas terminé

Une nouvelle tâche a state: "queued" ; une répétition sûre avec le même Idempotency-Key renvoie la tâche originale dans son état actuel. HTTP 202 signifie que la requête est acceptée, pas que la génération est terminée. À la première acceptation, les fonds sont réservés, pas facturés : estimatedCost est mis de côté sur le solde. Cette réservation constitue aussi le plafond de facturation de la tâche : les fonds inutilisés sont libérés et aucun montant supérieur n’est prélevé ensuite. Les tâches échouées libèrent normalement la réservation ; certains refus de contenu annoncés sur la page du modèle sont facturés au devis accepté. Vérifiez settled et cost. deadlineAt est l’échéance maximale avant que la tâche ne se termine en expired. Le code métier de réussite de l’enveloppe reste 200. Une tâche acceptée ne peut pas être annulée.

Récupérer le résultat

Si la requête de production comprend une callBackUrl, nous vous rappelons quand la tâche atteint un état terminal : un callback, ou webhook, est une adresse HTTPS de votre serveur appelée à la fin de la tâche pour éviter les interrogations répétées. La requête minimale précédente l’omet pour fonctionner sans point d’accès public. Vérifiez obligatoirement les signatures des callbacks ; voir Webhooks.

Sans adresse de callback, interrogez la tâche :

curl "https://api.spicyapi.ai/api/v1/jobs/recordInfo?taskId=${SPICY_TASK_ID}" \
  -H "Authorization: Bearer $SPICY_API_KEY"

Utilisez la même clé API que pour la création ; une autre reçoit 404. Examinez data.state :

data.stateSignificationAction
queued ou runningGénération en cours. Les fonds sont réservés, pas dépensésAttendez quelques secondes puis répétez la commande. Une image prend généralement des secondes, une vidéo des minutes. deadlineAt est la borne supérieure
succeededTerminéPassez à l’étape suivante
failed ou expiredÉchec. La réservation est normalement libérée ; certains refus de contenu explicitement annoncés sur la page sont facturés. Vérifiez settled et costLisez data.errorCode stable et data.errorMessage destiné à la lecture, corrigez l’entrée et reprenez à 3a

Le code de l’enveloppe vaut 200 même si la tâche a échoué : code indique si la lecture de la tâche a réussi. Son résultat propre est toujours dans data.state. Voici les réponses abrégées aux différents stades :

{
  "code": 200,
  "msg": "success",
  "data": {
    "taskId": "job_01k3m8x9q2z4v7n5p6r8s0t1w3",
    "model": "MODEL_ID_FROM_CATALOG",
    "state": "running",
    "cost": "0.008",
    "settled": false,
    "createdAt": "2026-08-28T09:12:04Z",
    "deadlineAt": "2026-08-28T09:22:04Z"
  },
  "request_id": "req_01k3m8x9q2z4v7n5p6r8s0t1w4"
}

Tant que settled est false, cost est le montant réservé, pas le coût final.

Télécharger le fichier

Prenez data.output.assets[0].url du résultat réussi et téléchargez directement, sans clé API. Le lien fonctionne environ 20 minutes (expiresAt) ; s’il a expiré, répétez l’étape 4, car chaque lecture renvoie un nouveau lien. Les fichiers générés sont supprimés après au maximum 14 jours : gardez vos copies.

# Paste data.output.assets[0].url. Keep the double quotes: the URL contains & characters.
RESULT_URL="paste-the-url-here"
curl -L "$RESULT_URL" -o result.png   # use result.mp4 when mime is video/mp4

Avec jq, vous pouvez éviter le copier-coller :

RESULT_URL="$(curl -s "https://api.spicyapi.ai/api/v1/jobs/recordInfo?taskId=${SPICY_TASK_ID}" \
  -H "Authorization: Bearer $SPICY_API_KEY" | jq -r '.data.output.assets[0].url')"

Résultat attendu : un fichier result.png dans le dossier du terminal. Vérifiez le coût dans Console → Journaux, ou dans cost lorsque settled est true.

Modèles de texte : une requête, la réponse directement

Les modèles de texte répondent dans la même réponse HTTP, sans polling ni téléchargement. L’en-tête X-Spicy-Task-Id désigne la tâche portant la facture pour une vérification ultérieure. À l’étape 2, listez plutôt les modèles avec modality=text, exportez-en un dans SPICY_MODEL, puis :

curl https://api.spicyapi.ai/v1/chat/completions \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  --data "{\"model\":\"${SPICY_MODEL}\",\"messages\":[{\"role\":\"user\",\"content\":\"Say hello in one sentence.\"}]}"

La réponse figure dans choices[0].message.content. L’appel est facturé selon les tokens utilisés. Le point de terminaison suit le format OpenAI, pas l’enveloppe {code, msg, data}. Voir Texte et streaming.

Sélectionner le modèle par code

Les cinq étapes précédentes gardent une personne dans la boucle. Une intégration déployée choisit elle-même le modèle juste avant l’envoi : un modèle appelable la semaine dernière peut être suspendu aujourd’hui, et les tarifs dépendent du compte. Demandez le schéma et un exemple validé côté serveur dans le même appel, puis exigez enabled et available :

Select a live text-to-image model (requires jq)
CATALOG_JSON="$(curl --fail-with-body \
  "https://api.spicyapi.ai/api/v1/models?modality=image&task=text-to-image&includeSchema=1&includeExamples=1" \
  -H "Authorization: Bearer $SPICY_API_KEY")"

MODEL_ID="$(printf '%s' "$CATALOG_JSON" | jq -er \
  '[.data.items[] | select(.enabled == true and .available == true and (.examples | length > 0))][0].model')"
MODEL_INPUT="$(printf '%s' "$CATALOG_JSON" | jq -cer \
  '[.data.items[] | select(.enabled == true and .available == true and (.examples | length > 0))][0].examples[0].input')"

printf 'Selected %s\n' "$MODEL_ID"
Create the task from those two values (this costs money)
SPICY_IDEMPOTENCY_KEY="$(uuidgen)"   # new task, new key; reuse it only to resend this same request
TASK_PAYLOAD="$(jq -cn \
  --arg model "$MODEL_ID" \
  --argjson input "$MODEL_INPUT" \
  '{model: $model, input: $input}')"

curl -X POST https://api.spicyapi.ai/api/v1/jobs/createTask \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $SPICY_IDEMPOTENCY_KEY" \
  --data "$TASK_PAYLOAD"

Si la sélection est vide, arrêtez-vous plutôt que de reprendre un ID mémorisé. Les trois programmes ci-dessous font la même sélection sans jq.

Assembler le tout

Les trois programmes lisent le catalogue, créent une tâche, l’interrogent avec temporisation progressive et affichent un lien de téléchargement. En production, utilisez plutôt les webhooks : le polling est un repli, pas le plan principal.

Ces programmes créent une véritable tâche payante

Chaque programme choisit le premier modèle texte-vers-image appelable, pas le moins cher, puis envoie l’un de ses exemples validés. Vérifiez d’abord son tarif ou adaptez la sélection au modèle exact voulu.

LangageFichierInstallation préalableExécution
Node.js 20 ou ultérieurgenerate.mjsAucunenode generate.mjs
Python 3.9 ou ultérieurgenerate.pypip install requestspython3 generate.py (Windows : py generate.py)
Go 1.21 ou ultérieurmain.go dans un dossier vidego mod init examplego run .

Les trois lisent la clé depuis SPICY_API_KEY, étape 1, et affichent l’URL à la fin. Ils attendent au maximum dix minutes, adapté aux images ; pour la vidéo, attendez plutôt jusqu’à la deadlineAt de la tâche.

Node.js 20+
const BASE = 'https://api.spicyapi.ai/api/v1';
const KEY = process.env.SPICY_API_KEY;

const headers = {
  Authorization: `Bearer ${KEY}`,
  'Content-Type': 'application/json',
};
async function call(path, init) {
  const res = await fetch(`${BASE}${path}`, {
    ...init,
    headers: { ...headers, ...init?.headers },
    signal: AbortSignal.timeout(30_000),
  });
  const body = await res.json();
  // Branch on `code`, not on res.ok — the business code says far more
  if (body.code !== 200) {
    throw new Error(`${body.code} ${body.msg} (request_id=${body.request_id})`);
  }
  return body.data;
}

async function generate() {
  const catalog = await call(
    '/models?modality=image&task=text-to-image&includeSchema=1&includeExamples=1',
  );
  const selected = catalog.items.find(
    (item) => item.enabled && item.available && item.examples?.length,
  );
  if (!selected) throw new Error('No callable text-to-image model with a validated example');

  const idempotencyKey = crypto.randomUUID();
  const { taskId } = await call('/jobs/createTask', {
    method: 'POST',
    headers: { 'Idempotency-Key': idempotencyKey },
    body: JSON.stringify({
      model: selected.model,
      input: selected.examples[0].input,
    }),
  });

  // Start at 2s, multiply by 1.5, cap at 15s. The first check is almost
  // certainly still `queued`, so a shorter start just wastes a round trip.
  const deadline = Date.now() + 10 * 60_000;
  let wait = 2000;
  while (Date.now() < deadline) {
    await new Promise((r) => setTimeout(r, wait));
    wait = Math.min(wait * 1.5, 15000);

    const task = await call(`/jobs/recordInfo?taskId=${taskId}`);
    if (task.state === 'succeeded') {
      const asset = task.output?.assets?.find((item) => item.url);
      if (asset) return asset.url;
      if (task.output?.assets?.some((item) => item.pending)) continue;
      throw new Error('Result is unavailable');
    }
    // Anything other than queued / running is terminal. This also handles
    // historical canceled records and future terminal states safely.
    if (task.state !== 'queued' && task.state !== 'running') {
      throw new Error(`${task.state}: ${task.errorMessage ?? 'no detail'}`);
    }
  }
  throw new Error(`task ${taskId} timed out`);
}

generate().then(console.log);
Python 3.9+
import os, time, uuid, requests

BASE = "https://api.spicyapi.ai/api/v1"
HEADERS = {
    "Authorization": f"Bearer {os.environ['SPICY_API_KEY']}",
    "Content-Type": "application/json",
}


def call(method: str, path: str, headers=None, **kwargs) -> dict:
    res = requests.request(
        method, f"{BASE}{path}", headers={**HEADERS, **(headers or {})}, timeout=30, **kwargs
    )
    body = res.json()
    # Branch on `code`, not on status_code
    if body["code"] != 200:
        raise RuntimeError(f'{body["code"]} {body["msg"]} (request_id={body.get("request_id")})')
    return body["data"]


def generate() -> str:
    catalog = call(
        "GET",
        "/models?modality=image&task=text-to-image&includeSchema=1&includeExamples=1",
    )
    selected = next(
        (
            item
            for item in catalog["items"]
            if item["enabled"] and item["available"] and item.get("examples")
        ),
        None,
    )
    if selected is None:
        raise RuntimeError("No callable text-to-image model with a validated example")

    idempotency_key = str(uuid.uuid4())
    task_id = call("POST", "/jobs/createTask", headers={"Idempotency-Key": idempotency_key}, json={
        "model": selected["model"],
        "input": selected["examples"][0]["input"],
    })["taskId"]

    # Start at 2s, multiply by 1.5, cap at 15s
    deadline = time.monotonic() + 600
    wait = 2.0
    while time.monotonic() < deadline:
        time.sleep(wait)
        wait = min(wait * 1.5, 15.0)

        task = call("GET", "/jobs/recordInfo", params={"taskId": task_id})
        if task["state"] == "succeeded":
            assets = task.get("output", {}).get("assets", [])
            ready = next((asset for asset in assets if asset.get("url")), None)
            if ready:
                return ready["url"]
            if any(asset.get("pending") for asset in assets):
                continue
            raise RuntimeError("Result is unavailable")
        # Anything other than queued / running is terminal.
        if task["state"] not in {"queued", "running"}:
            raise RuntimeError(f'{task["state"]}: {task.get("errorMessage", "no detail")}')
    raise TimeoutError(f"task {task_id} timed out")


print(generate())
Go 1.21+
package main

import (
	"bytes"
	"crypto/rand"
	"encoding/json"
	"encoding/hex"
	"fmt"
	"math"
	"net/http"
	"os"
	"time"
)

const base = "https://api.spicyapi.ai/api/v1"

// envelope is the shape every endpoint returns. Data stays raw so each
// call site can decode it into whatever it actually expects.
type envelope struct {
	Code      int             `json:"code"`
	Msg       string          `json:"msg"`
	Data      json.RawMessage `json:"data"`
	RequestID string          `json:"request_id"`
}

var client = &http.Client{Timeout: 30 * time.Second}

func newIdempotencyKey() string {
	b := make([]byte, 16)
	if _, err := rand.Read(b); err != nil {
		panic(err)
	}
	return "job-" + hex.EncodeToString(b)
}

func call(method, path string, body any, idempotencyKey string, out any) error {
	var rdr *bytes.Reader
	if body != nil {
		raw, err := json.Marshal(body)
		if err != nil {
			return err
		}
		rdr = bytes.NewReader(raw)
	} else {
		rdr = bytes.NewReader(nil)
	}

	req, err := http.NewRequest(method, base+path, rdr)
	if err != nil {
		return err
	}
	req.Header.Set("Authorization", "Bearer "+os.Getenv("SPICY_API_KEY"))
	req.Header.Set("Content-Type", "application/json")
	if idempotencyKey != "" {
		req.Header.Set("Idempotency-Key", idempotencyKey)
	}

	res, err := client.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()

	var env envelope
	if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
		return err
	}
	// Branch on Code, not on res.StatusCode
	if env.Code != 200 {
		return fmt.Errorf("%d %s (request_id=%s)", env.Code, env.Msg, env.RequestID)
	}
	return json.Unmarshal(env.Data, out)
}

func generate() (string, error) {
	var catalog struct {
		Items []struct {
			Model     string `json:"model"`
			Enabled   bool   `json:"enabled"`
			Available bool   `json:"available"`
			Examples  []struct {
				Input map[string]any `json:"input"`
			} `json:"examples"`
		} `json:"items"`
	}
	if err := call(http.MethodGet,
		"/models?modality=image&task=text-to-image&includeSchema=1&includeExamples=1",
		nil, "", &catalog); err != nil {
		return "", err
	}
	var modelID string
	var modelInput map[string]any
	for _, item := range catalog.Items {
		if item.Enabled && item.Available && len(item.Examples) > 0 {
			modelID, modelInput = item.Model, item.Examples[0].Input
			break
		}
	}
	if modelID == "" {
		return "", fmt.Errorf("no callable text-to-image model with a validated example")
	}

	var created struct {
		TaskID string `json:"taskId"`
	}
	err := call(http.MethodPost, "/jobs/createTask", map[string]any{
		"model": modelID,
		"input": modelInput,
	}, newIdempotencyKey(), &created)
	if err != nil {
		return "", err
	}

	// Start at 2s, multiply by 1.5, cap at 15s
	wait := 2 * time.Second
	deadline := time.Now().Add(10 * time.Minute)
	for time.Now().Before(deadline) {
		time.Sleep(wait)
		wait = time.Duration(math.Min(float64(wait)*1.5, float64(15*time.Second)))

		var task struct {
			State    string `json:"state"`
			ErrorMsg string `json:"errorMessage"`
			Output struct { Assets []struct { URL string `json:"url"`; Pending bool `json:"pending"` } `json:"assets"` } `json:"output"`
		}
		if err := call(http.MethodGet, "/jobs/recordInfo?taskId="+created.TaskID, nil, "", &task); err != nil {
			return "", err
		}

		if task.State == "succeeded" {
			pending := false
			for _, asset := range task.Output.Assets {
				if asset.URL != "" { return asset.URL, nil }
				pending = pending || asset.Pending
			}
			if pending { continue }
			return "", fmt.Errorf("result is unavailable")
		}
		// Terminal means "not one of the two in-flight states" — do not
		// enumerate the terminal states, that breaks when a new one appears
		if task.State != "queued" && task.State != "running" {
			return "", fmt.Errorf("%s: %s", task.State, task.ErrorMsg)
		}
	}
	return "", fmt.Errorf("task %s timed out", created.TaskID)
}

func main() {
	url, err := generate()
	if err != nil {
		panic(err)
	}
	fmt.Println(url)
}

L’enveloppe de réponse

Les réponses JSON natives de tâches utilisent cette enveloppe ; la compatibilité /v1 et les SSE utilisent leurs propres formats :

{ "code": 200, "msg": "success", "data": { }, "request_id": "req_…" }
  • code est un code métier, pas un statut HTTP. 200 signifie succès. Le statut HTTP est correct aussi : vous pouvez décider selon l’un ou l’autre, mais choisissez-en un seul. Ils divergent pour les codes spécifiques, par exemple un solde insuffisant donne code: 40201 avec HTTP 402, et les mélanger finit par faire manquer un cas.
  • En cas d’échec, data est absent, pas null.
  • request_id figure en cas de succès comme d’échec et dans l’en-tête X-Request-Id. Indiquez-le pour signaler un problème.

Suite

Sur cette page