spicyapiDokumentacja
Pierwsze kroki
Treść główna

Szybki start

Uzyskaj klucz, utwórz zadanie i odbierz wynik. Cały proces od początku do końca.

SpicyAPI udostępnia generowanie obrazów i wideo przez jeden protokół uwierzytelniania, zadań i wyników, a modele tekstowe obsługuje w znanych ci formatach OpenAI, Anthropic i Gemini. Zachowaj ten sam przebieg wywołań przy zmianie modeli i buduj input na podstawie inputSchema danego modelu (listy akceptowanych pól, ich typów i dozwolonych wartości).

Ten poradnik używa natywnego API zadań pod https://api.spicyapi.ai/api/v1. Endpointy zgodności dla tekstu i wideo używają https://api.spicyapi.ai/v1. Oba przyjmują ten sam klucz API, ale mają różne formaty żądań i odpowiedzi.

Przejdź przez tę stronę, aby wybrać model i pobrać wynik. Aby poznać dokładną cenę przed zapłatą, zobacz Wyceny i zgodność. Dla czatu zobacz Tekst i strumieniowanie. Przed wdrożeniem integracji przejdź przez Integrację produkcyjną; gdy któryś krok się nie powiedzie, skorzystaj z Rozwiązywania problemów. Nieznane pojęcia wyjaśnia słowniczek.

Chcesz zobaczyć wynik przed napisaniem kodu?

Otwórz dowolny model na spicyapi.ai/explore i użyj zakładki Playground; zakładka API pokazuje to samo żądanie jako polecenie gotowe do skopiowania. Aby działać z terminala bez pisania kodu, użyj CLI lub powierz to asystentowi AI przez serwer MCP.

Oficjalne narzędzia

Bezpośrednie wywoływanie API HTTP jest w pełni obsługiwane i pokazuje je dalsza część strony. Jeśli wolisz nie pisać tych wywołań samodzielnie, dostępne są cztery pakiety npm na licencji MIT, każdy instalowany oddzielnie i z własnym poradnikiem:

Cztery dodatkowe oficjalne SDK obsługują języki spoza Node.js. Każde realizuje ten sam proces i jest na licencji MIT; obecnie tylko moduł Go instaluje się z repozytorium zwykłym poleceniem, a pozostałe trzy strony podają sposób instalacji stosowany do czasu publikacji pakietów:

Pracujesz w ComfyUI? Węzły ComfyUI umieszczają każdy model obrazów, wideo i audio na twoim obszarze roboczym jako węzeł, z ceną widoczną przed uruchomieniem.

Zanim zaczniesz

Potrzebujesz czterech rzeczy. Pominięcie jednej spowoduje później błąd, który nie wskaże, czego zabrakło.

  1. Konto ze zweryfikowanym adresem e-mail. Zarejestruj się na spicyapi.ai i kliknij link w e-mailu weryfikacyjnym. Do tego czasu każde wywołanie twoim kluczem zwraca 403.
  2. Saldo. Generowanie jest opłacane z salda w dolarach amerykańskich (nie ma kredytów), a nowe konto zaczyna od zera. Dodaj środki w Konsola → Rozliczenia. Bez wystarczającego salda tworzenie zadania kończy się kodem biznesowym 40201. Konfiguracja konta prowadzi przez rejestrację, doładowanie i tworzenie klucza.
  3. Terminal. macOS: aplikacja Terminal. Linux: dowolna powłoka. Windows: PowerShell. Gdy polecenia się różnią, każdy krok ma zakładkę Windows PowerShell. Jeśli wolisz, polecenia macOS / Linux działają też bez zmian w WSL lub Git Bash w Windows.
  4. Kilka centów. Krok 3 tworzy jedno rzeczywiste zadanie obrazu, rozliczane po powodzeniu. Krok 2 pokazuje cenę każdego modelu, a opcjonalne polecenie w tym kroku pozwala najpierw bezpłatnie poznać dokładną kwotę.

Najpierw oceń ofertę: modele i ceny bez konta

Nie potrzebujesz klucza ani konta, aby zobaczyć dostępną ofertę i ceny. Publiczny katalog używany przez stronę marketingową można odczytać anonimowo, a w tym poleceniu niczego nie trzeba zastępować:

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

Struktura odpowiedzi jest taka sama jak opisana na końcu tej strony. Każda pozycja w data.items zawiera:

  • model — dokładną wartość, którą później wyślesz jako model (slug zawiera tę samą wartość);
  • callable — czy model może działać w tej chwili. Wybieraj pozycje z true: status określa nasz zamierzony stan operacyjny, a model live nadal może przejściowo nie nadawać się do wywołania;
  • modality i tasks — rodzaj generowanego wyniku;
  • price — najtańszy poziom: amount w USD na unit (per_image, per_second, per_request lub per_1k_tokens), a także tiers, czyli liczba poziomów ceny. Dla modeli tekstowych odczytuj price.perMillionTokens, które podaje osobne ceny za milion tokenów wejściowych, wejściowych z pamięci podręcznej i wyjściowych; samo amount jest tylko stawką wyjściową.

Dodaj identyfikator modelu, aby uzyskać jego pełny rekord, w tym inputSchema, paramNotes i opracowane examples:

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 jest symbolem zastępczym używanym w całej dokumentacji, a nie prawdziwym modelem: zawsze kopiuj rzeczywisty identyfikator z katalogu na żywo.

locale domyślnie ma wartość en; przekaż ja, ko, de, fr, es, pt-BR, ru, it lub pl, aby uzyskać przetłumaczone nazwy, opisy i notatki parametrów. Każda inna wartość zwraca 400. strict=1 usuwa pozycje, których tekst nie jest opublikowany w tym języku, zamiast wyświetlać angielski; zwykle nie jest potrzebne. Ten obszar ma limit według adresu źródłowego, a nie konta, i własną pulę — zobacz Limity żądań. Jest też buforowany, więc zmiana ceny może pojawić się tu z opóźnieniem.

Gdy tiers jest większe od 1, podane price oznacza najtańszy poziom, a nie cenę twoich ustawień. price.variant pokazuje ustawienia tego najtańszego poziomu (na przykład 720p lub generate_audio=false;resolution=480p, gdy łączy się kilka pól), a inputSchema["x-pricing"].variantFields w rekordzie pojedynczego modelu, jeśli jest obecne, wymienia wszystkie pola wybierające poziom ceny. pricingNote rekordu jest ogólnym przypomnieniem, że wiążąca jest aktualna wycena; nie wymienia pól. Aby poznać dokładną kwotę jednego żądania, użyj POST /api/v1/jobs/quote, gdy już masz klucz.

Publiczny katalog do oceny oferty, uwierzytelniony katalog do wywołań

Publiczny katalog odpowiada na pytanie „co jest w sprzedaży i jaka jest podana cena”. Nie odpowie na „co może wywołać mój klucz” ani „ile zapłaci moje konto”, ponieważ oba zależą od konta. Po uzyskaniu klucza przejdź na GET /api/v1/models w kroku 2 i traktuj go jako jedyne źródło identyfikatorów dostępnych do wywołania — zapamiętany identyfikator, migawka premiery ani strona marketingowa nie są źródłem zastępczym.

Pięć kroków

Uzyskaj klucz

Klucz API jest hasłem, które twój kod wysyła z każdym żądaniem. Identyfikuje konto i umożliwia jego rozliczanie.

  1. Otwórz Konsola → Klucze API i kliknij Utwórz klucz.
  2. Nadaj nazwę, na przykład my-first-test. Pozostaw wszystkie ustawienia Zaawansowane bez zmian.
  3. Skopiuj klucz zaczynający się od sk-spicy- lub kliknij Zapisz jako plik. Jest pokazywany dokładnie raz; jeśli go stracisz, utwórz nowy.
  4. W terminalu używanym do kolejnych kroków zapisz go w zmiennej środowiskowej (nazwanej wartości przekazywanej przez terminal do każdego uruchamianego polecenia), aby nigdy nie wpisywać klucza bezpośrednio w poleceniu:
export SPICY_API_KEY="sk-spicy-paste-your-key-here"

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

Co powinno się pojawić: sk-spicy-. Wartość istnieje do zamknięcia tego okna terminala, więc każdy następny krok wykonuj w tym samym oknie.

Jeśli nie ma przycisku Utwórz klucz, twoja rola w aktualnym zespole nie pozwala tworzyć kluczy: poproś właściciela zespołu lub użyj Załóż własny zespół na tej samej stronie. Nowe klucze nie mają limitu wydatków, dopóki go nie ustawisz; dzienny limit w konsoli ogranicza skutki błędnej konfiguracji lub wycieku. Zobacz Uwierzytelnianie.

Wybierz model dostępny do wywołania

Uwierzytelniony katalog jest jedynym źródłem identyfikatorów dostępnych do wywołania, aktualnych schematów i przykładów. Pobierz go w dwóch wywołaniach: lekka lista wskazuje, co możesz wywołać, a jeden rekord daje schemat wybranego modelu. Odczyt katalogu jest bezpłatny.

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"

Odpowiedź jest długa. Potrzebny fragment wygląda tak (w skrócie):

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

Wybierz pozycję, której enabled i available mają wartość true, sprawdź cenę, skopiuj model i zachowaj tę wartość do końca tej strony. Z zainstalowanym jq polecenie curl … | jq -r '.data.items[] | select(.enabled and .available) | .model' wypisuje tylko identyfikatory dostępne do wywołania.

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

Ukośniki w identyfikatorze przechodzą przez ścieżkę bez zmian; koduj je procentowo tylko wtedy, gdy klient HTTP wymaga traktowania parametru ścieżki jako pojedynczego segmentu. inputSchema rekordu jest wiążącym źródłem dla input: required wymienia pola, które musisz wysłać, a properties opisuje każde z nich. examples zawiera dane wejściowe już zwalidowane przez serwer. Odczyt pozostałych pól rekordu, w tym pricing, opisuje Katalog modeli i schematy.

Pobieraj schematy tylko tam, gdzie ich potrzebujesz. Dodanie includeSchema=1&includeExamples=1 do wywołania listy zwraca pełny JSON Schema każdego włączonego modelu w jednej odpowiedzi, co daje znacznie większą treść. Odczyty katalogu korzystają z osobnego limitu odczytów konta i nie zużywają limitu tworzenia zadań. Nie odczytuj też katalogu przed każdym zadaniem; zobacz Buforowanie katalogu.

Jeśli żadna pozycja nie pasuje, zatrzymaj się przed utworzeniem płatnego zadania. Migawka premiery, strona marketingowa ani zapamiętany identyfikator modelu nie zastępują pustego katalogu na żywo. Aby wybrać model z kodu zamiast ręcznie, zobacz Wybór modelu z kodu.

Opcjonalnie: najpierw sprawdź dokładną cenę. Wycena jest bezpłatna, niczego nie tworzy i niczego nie rezerwuje. data.estimatedCost wskazuje maksymalny koszt tego dokładnego żądania w 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\"}}"

Utwórz zadanie

Zadanie to generowanie wykonywane w tle: od razu otrzymujesz identyfikator, a wynik później. Ten krok jest płatny.

Każde nowe zadanie otrzymuje nowy klucz idempotencji: losowy identyfikator, który dołączasz, aby dwukrotne wysłanie tego samego żądania, na przykład po błędzie sieci, nigdy nie utworzyło dwóch zadań. Wygeneruj go osobnym poleceniem, a następnie wyślij zadanie:

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

Jeśli 3b przekroczy czas oczekiwania lub połączenie się zerwie, uruchom 3b ponownie bez wykonywania 3a: ten sam klucz zwróci już rozpoczęte zadanie zamiast naliczyć drugą opłatę. Aby wygenerować inny obraz, najpierw wykonaj 3a. Odpowiedź 409 oznacza ponowne użycie klucza z innym promptem lub modelem. Więcej w Idempotencji.

To żądanie wysyła jedno pole. Jeśli schemat odczytany w kroku 2 oznacza inne pola jako wymagane, dodaj je — to schemat każdego modelu określa, co wolno umieścić w input, a jedno nieznane pole oznacza 400.

Połączenie nigdy nie pozostaje otwarte. Identyfikator zadania otrzymujesz od razu:

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

W macOS / Linux skopiuj taskId z odpowiedzi i zachowaj go (PowerShell zrobił to już za ciebie):

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

202 oznacza przyjęcie, a nie ukończenie

Nowo utworzone zadanie ma state: "queued"; bezpieczne powtórzenie z tym samym Idempotency-Key zwraca pierwotne zadanie w jego aktualnym stanie. HTTP 202 oznacza przyjęcie żądania, nie ukończenie generowania. Przy pierwszej akceptacji środki są blokowane, a nie pobierane: estimatedCost jest odkładane z twojego salda. Ta blokada jest też maksymalną opłatą za zadanie: niewykorzystane środki są zwalniane i później nie pobiera się kwoty ponad nią. Nieudane zadania zwykle zwalniają blokadę; niektóre odmowy związane z treścią, ujawnione na stronie modelu, są rozliczane według zaakceptowanej wyceny. Sprawdź settled i cost. deadlineAt określa najpóźniejszy czas zakończenia, po którym zadanie przechodzi do expired. Kod biznesowy powodzenia code w strukturze odpowiedzi pozostaje 200. Zaakceptowanego zadania nie można anulować.

Odbierz wynik

Jeśli żądanie produkcyjne zawiera callBackUrl, wywołamy twój serwer, gdy zadanie osiągnie stan końcowy: callback (webhook) to adres HTTPS na twoim serwerze wywoływany po zakończeniu zadania, dzięki czemu nie trzeba ciągle pytać o stan. Minimalne powyższe żądanie go pomija, aby mogło działać bez publicznego endpointu. Podpisy callbacków trzeba weryfikować — zobacz Webhooki.

Bez endpointu callback odpytaj zadanie:

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

Używaj tego samego klucza API, który utworzył zadanie: inny klucz otrzyma 404. Sprawdź data.state:

data.stateZnaczenieCo zrobić
queued lub runningGenerowanie nadal trwa. Środki są zablokowane, nie wydanePoczekaj kilka sekund i wykonaj to samo polecenie ponownie. Obrazy zwykle powstają w kilka sekund; wideo może trwać minuty. deadlineAt jest górną granicą
succeededGotowePrzejdź do następnego kroku
failed lub expiredNie powiodło się. Blokada jest zwykle zwalniana; niektóre odmowy związane z treścią, wyraźnie ujawnione na stronie modelu, są płatne. Sprawdź settled i costOdczytaj data.errorCode (stabilny) i data.errorMessage (dla człowieka), popraw dane wejściowe i zacznij od 3a

code struktury odpowiedzi wynosi 200 nawet przy nieudanym zadaniu: code mówi, czy powiódł się odczyt zadania. Własny wynik zadania zawsze znajduje się w data.state. Skrócony wygląd odpowiedzi na kolejnych etapach:

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

Gdy settled ma wartość false, cost jest kwotą zablokowaną, a nie ostateczną opłatą.

Pobierz plik wynikowy

Weź data.output.assets[0].url z udanego wyniku i pobierz go bezpośrednio. Nie dołączaj klucza API. Link działa około 20 minut (expiresAt); jeśli wygasł, wykonaj ponownie krok 4, ponieważ każdy odczyt zwraca nowy link. Wygenerowane pliki są usuwane po najwyżej 14 dniach, więc zachowaj własną kopię potrzebnych materiałów.

# 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

Z zainstalowanym jq możesz pominąć kopiowanie i wklejanie:

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

Co powinno się pojawić: plik result.png w folderze, w którym działa terminal. Sprawdź opłatę w Konsola → Logi lub w cost, gdy settled ma wartość true.

Modele tekstowe: jedno żądanie, odpowiedź bezpośrednio w odpowiedzi HTTP

Modele tekstowe odpowiadają w tej samej odpowiedzi HTTP: bez odpytywania i pobierania pliku. Nagłówek odpowiedzi X-Spicy-Task-Id wskazuje zadanie zawierające opłatę, jeśli chcesz sprawdzić ją później. W kroku 2 zamiast tego pobierz modele z modality=text, wyeksportuj jeden jako SPICY_MODEL, a następnie:

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.\"}]}"

Odpowiedź znajduje się w choices[0].message.content. Wywołanie jest rozliczane za zużyte tokeny. Endpoint używa formatu odpowiedzi OpenAI, a nie struktury {code, msg, data}. Zobacz Tekst i strumieniowanie.

Wybór modelu z kodu

Powyższe pięć kroków pozostawia człowieka w procesie. Wdrożona integracja wybiera model sama, bezpośrednio przed wysłaniem: model dostępny w zeszłym tygodniu może być dziś wstrzymany, a ceny zależą od konta. Pobierz schemat i przykład zwalidowany przez serwer w tym samym wywołaniu, a następnie wymagaj enabled i 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"

Jeśli wybór niczego nie zwróci, zatrzymaj się zamiast używać zapamiętanego identyfikatora. Trzy poniższe programy wykonują ten sam wybór bez jq.

Kompletny proces

Wszystkie trzy programy poniżej odczytują katalog, tworzą zadanie, odpytują je ze stopniowym wydłużaniem odstępu i wypisują adres pobierania. W produkcji zamiast tego używaj webhooków — odpytywanie jest rozwiązaniem zapasowym, nie głównym planem.

Uruchomienie tych programów tworzy rzeczywiste, płatne zadanie

Każdy program wybiera pierwszy model text-to-image dostępny do wywołania w twoim katalogu (nie najtańszy) i wysyła jeden z jego zwalidowanych przykładów. Najpierw sprawdź cenę tego modelu albo zmień wybór na dokładnie ten model, którego chcesz użyć.

JęzykZapisz jakoNajpierw zainstalujUruchom
Node.js 20 lub nowszygenerate.mjsnicnode generate.mjs
Python 3.9 lub nowszygenerate.pypip install requestspython3 generate.py (Windows: py generate.py)
Go 1.21 lub nowszymain.go w pustym folderzego mod init examplego run .

Wszystkie trzy odczytują klucz z SPICY_API_KEY (krok 1) i wypisują adres wyniku po zakończeniu zadania. Czekają najwyżej dziesięć minut, co pasuje do modeli obrazów; dla wideo zamiast tego czekaj do deadlineAt zadania.

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)
}

Wspólna struktura odpowiedzi

Odpowiedzi JSON natywnego API zadań używają poniższej struktury; endpointy zgodności /v1 i SSE używają struktur własnych protokołów:

{ "code": 200, "msg": "success", "data": { }, "request_id": "req_…" }
  • code jest kodem biznesowym, a nie statusem HTTP. 200 oznacza powodzenie. Status HTTP jest również ustawiony poprawnie, więc można opierać logikę na dowolnym z nich — ale wybierz jeden. Różnią się przy konkretnych kodach (niewystarczające saldo to code: 40201 z HTTP 402), a ich mieszanie ostatecznie prowadzi do pominięcia któregoś przypadku.
  • Przy błędzie data jest nieobecne — nie ma wartości null.
  • request_id jest obecne zarówno przy powodzeniu, jak i błędzie, oraz powtarzane w nagłówku odpowiedzi X-Request-Id. Podawaj je przy zgłaszaniu problemu.

Dalej

Na tej stronie