spicyapi문서
시작하기
본문

빠른 시작

키 발급부터 작업 생성과 결과 수집까지 전체 흐름을 안내합니다.

SpicyAPI는 이미지·영상 생성을 하나의 인증·작업·결과 프로토콜로 제공하며 텍스트 모델은 익숙한 OpenAI, Anthropic, Gemini 형식을 사용합니다. 모델을 바꾸어도 같은 호출 흐름을 유지하고 해당 모델의 inputSchema(허용 필드·유형·값)로 input을 구성하세요.

이 가이드는 https://api.spicyapi.ai/api/v1의 네이티브 작업 API를 사용합니다. 텍스트·영상 호환 엔드포인트는 https://api.spicyapi.ai/v1입니다. API 키는 같지만 요청·응답 형식은 다릅니다.

모델 선택부터 결과 조회까지 따라 하세요. 결제 전에 정확한 가격은 견적과 호환성, 채팅은 텍스트와 스트리밍을 참고하세요. 배포 전에 운영 환경 통합을 점검하고 단계가 실패하면 문제 해결을 이용하세요. 낯선 용어는 용어집에 있습니다.

코드 작성 전에 결과를 보고 싶으신가요?

spicyapi.ai/explore의 모델을 열어 Playground를 사용하세요. API 탭에는 같은 요청의 복사 가능한 명령이 있습니다. 코드 없이 터미널을 사용하려면 CLI, AI 도우미에 맡기려면 MCP 서버를 사용하세요.

공식 도구

HTTP API 직접 호출을 완전히 지원하며 아래에서 설명합니다. 호출 코드를 직접 작성하지 않으려면 MIT 라이선스의 npm 패키지 네 개가 있습니다. 각각 별도로 설치하며 전용 가이드가 있습니다.

Node.js 외 언어에는 공식 SDK 네 개가 더 있습니다. 모두 같은 흐름과 MIT 라이선스입니다. 현재 Go만 일반 명령으로 저장소에서 설치하며 다른 세 페이지에는 패키지 공개 전 사용할 설치 방법이 있습니다.

ComfyUI를 사용한다면 ComfyUI 노드로 모든 이미지·영상·오디오 모델을 캔버스에 놓고 실행 전에 가격을 확인할 수 있습니다.

시작 전

네 가지가 필요합니다. 하나를 생략하면 나중에 무엇이 빠졌는지 명확하지 않은 오류가 발생할 수 있습니다.

  1. 이메일 인증 계정. spicyapi.ai에서 가입하고 인증 메일의 링크를 누르세요. 인증 전에는 키의 모든 호출이 403입니다.
  2. 잔액. 생성 비용은 USD 잔액에서 지불하며 크레딧 단위는 없습니다. 새 계정 잔액은 0입니다. 콘솔 → 과금에서 충전하세요. 잔액 부족 시 생성은 비즈니스 코드 40201로 실패합니다. 계정 설정에서 가입·충전·키 생성을 설명합니다.
  3. 터미널. macOS는 Terminal, Linux는 셸, Windows는 PowerShell입니다. 다른 명령은 Windows PowerShell 탭으로 제공합니다. macOS / Linux 명령은 Windows의 WSL·Git Bash에서도 그대로 실행됩니다.
  4. 몇 센트의 비용. 3단계는 성공 시 과금되는 실제 이미지 작업 하나를 만듭니다. 2단계에서 가격을 보고 선택적 명령으로 정확한 금액을 먼저 무료 확인합니다.

계정 없이 모델과 가격 평가

모델과 가격을 보려면 계정·키가 필요하지 않습니다. 마케팅 사이트의 공개 카탈로그는 익명으로 읽을 수 있으며 다음 명령은 대체할 값 없이 실행됩니다.

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

응답 형식은 이 페이지 아래와 같으며 data.items 항목마다 다음을 담습니다.

  • model — 나중에 model로 보낼 정확한 값(slug도 같은 값).
  • callable — 지금 실행 가능한지 여부. true를 선택하세요. status는 운영 의도이므로 live라도 일시적으로 호출 불가일 수 있습니다.
  • modality와 tasks — 생성 결과의 분류.
  • price — 최저 구간. unit(per_image, per_second, per_request, per_1k_tokens)당 USD amount와 구간 수 tiers입니다. 텍스트는 price.perMillionTokens에서 입력·캐시 입력·출력의 백만 토큰당 가격을 읽으세요. amount만으로는 출력 요율뿐입니다.

모델 ID를 붙이면 inputSchema, paramNotes와 실행 예시 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는 실제 모델이 아닌 자리표시자입니다. 항상 최신 카탈로그의 실제 ID를 복사하세요.

locale 기본값은 en입니다. ja, ko, de, fr, es, pt-BR, ru, it, pl을 보내면 이름·요약·매개변수 설명을 번역하여 받습니다. 다른 값은 400입니다. strict=1은 해당 언어 문구가 공개되지 않은 항목을 영어 대체 대신 제외하지만 보통 필요하지 않습니다. 이 영역은 계정이 아닌 출발지 주소별 별도 버킷으로 제한됩니다(호출 제한). 캐시도 있어 가격 변경이 표시되기까지 시간이 걸릴 수 있습니다.

tiers가 1보다 크면 표시 price는 선택한 설정이 아니라 최저 구간입니다. price.variant는 해당 설정을 보여 줍니다(예: 720p, 여러 필드가 결합되면 generate_audio=false;resolution=480p). 단일 기록의 inputSchema["x-pricing"].variantFields가 있으면 구간 선택 필드를 모두 나열합니다. pricingNote는 최신 견적이 기준임을 알리는 일반 안내이며 필드 목록이 아닙니다. 키를 얻은 뒤 정확한 요청 금액은 POST /api/v1/jobs/quote를 사용하세요.

평가는 공개 카탈로그, 호출은 인증 카탈로그

공개 카탈로그는 판매 모델과 표시 가격을 안내합니다. 자신의 키가 호출 가능한 모델과 자신의 계정 가격은 계정별이므로 답하지 못합니다. 키를 얻으면 2단계의 GET /api/v1/models로 전환하고 호출 가능 ID의 유일한 기준으로 사용하세요. 기억한 ID, 출시 사본이나 마케팅 페이지로 대체하면 안 됩니다.

다섯 단계

키 발급

API 키는 코드가 요청마다 보내는 비밀번호입니다. 계정을 식별하며 과금 기준이 됩니다.

  1. 콘솔 → API 키에서 키 생성을 누르세요.
  2. my-first-test 등 이름을 붙이고 고급은 그대로 두세요.
  3. sk-spicy- 키를 복사하거나 파일로 저장하세요. 한 번만 표시되며 잃어버리면 새로 만듭니다.
  4. 다음 단계에 사용할 터미널에서 환경 변수(각 명령에 전달하는 이름 있는 값)에 저장하여 명령마다 직접 입력하지 않도록 하세요.
export SPICY_API_KEY="sk-spicy-paste-your-key-here"

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

확인할 결과: sk-spicy-. 값은 터미널 창을 닫을 때까지 유지되므로 이후 단계도 같은 창에서 실행하세요.

키 생성이 없으면 현재 팀 역할에 생성 권한이 없습니다. 소유자에게 요청하거나 같은 페이지의 새 팀 시작을 사용하세요. 새 키는 직접 설정하지 않으면 지출 한도가 없습니다. 일 한도를 설정하면 설정 오류·유출 비용에 상한을 둘 수 있습니다. 인증 참조.

호출 가능한 모델 선택

인증된 카탈로그는 호출 ID, 현재 스키마와 예시의 유일한 기준입니다. 두 번 조회하세요. 가벼운 목록으로 호출 가능한 모델을 확인하고 단일 기록으로 선택 모델의 스키마를 받습니다. 조회는 무료입니다.

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"

긴 응답에서 필요한 부분을 줄이면 다음과 같습니다.

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

enabled와 available이 모두 true인 항목을 선택하여 가격을 확인하고 model을 복사해 보관하세요. jq가 있으면 curl … | jq -r '.data.items[] | select(.enabled and .available) | .model'로 호출 가능한 ID만 출력합니다.

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

ID의 슬래시는 경로에서 그대로 사용합니다. HTTP 클라이언트가 매개변수를 한 구간으로 요구할 때만 퍼센트 인코딩하세요. inputSchema가 input의 기준입니다. required는 필수 필드, properties는 설명, examples는 서버 검증 입력입니다. pricing 등 나머지는 모델 카탈로그와 스키마를 참고하세요.

스키마는 필요한 곳에서만 요청하세요. 목록에 includeSchema=1&includeExamples=1을 추가하면 모든 활성 모델의 전체 JSON Schema를 한 응답으로 받아 본문이 훨씬 커집니다. 카탈로그 조회는 작업 제출과 독립된 읽기 허용량을 사용합니다. 작업마다 다시 읽지도 마세요. 카탈로그 캐시를 참고하세요.

항목이 없으면 유료 작업 생성 전에 중단하세요. 빈 최신 카탈로그를 출시 사본, 마케팅 페이지나 기억한 ID로 대체하면 안 됩니다. 코드에서 선택하려면 코드로 모델 선택을 참고하세요.

선택 사항: 정확한 가격 먼저 확인. 견적은 무료이며 작업 생성·금액 선점이 없습니다. data.estimatedCost는 정확히 이 요청이 사용할 최대 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\"}}"

작업 생성

작업은 백그라운드 생성입니다. ID는 즉시 받고 결과는 나중에 받습니다. 이 단계는 비용이 발생합니다.

새 작업마다 새 멱등성 키를 붙입니다. 네트워크 오류 후 같은 요청을 두 번 보내도 작업 두 개가 생기지 않도록 하는 무작위 ID입니다. 별도 명령으로 만든 뒤 제출하세요.

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

3b가 시간 초과·연결 해제로 끝나면 3a를 실행하지 말고 3b만 반복하세요. 같은 키로 이미 시작한 작업을 받아 중복 결제를 막습니다. 다른 이미지는 3a부터 실행하세요. 409는 다른 프롬프트·모델에 키를 재사용했다는 뜻입니다. 멱등성 참조.

예시는 필드 하나를 보냅니다. 2단계 스키마에 다른 필수 필드가 있으면 추가하세요. input은 모델별 스키마가 정의하며 알 수 없는 필드 하나도 400입니다.

연결을 열어 두지 않고 즉시 작업 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"
}

macOS / Linux는 응답의 taskId를 복사해 저장하세요(PowerShell은 이미 저장했습니다).

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

202는 완료가 아니라 승인입니다

새 작업은 state: "queued"입니다. 같은 Idempotency-Key의 안전한 반복은 원래 작업의 현재 상태를 반환합니다. HTTP 202는 요청 승인이지 생성 완료가 아닙니다. 처음 승인 시 estimatedCost를 잔액에서 선점하며 결제하지 않습니다. 이 금액이 과금 상한이기도 합니다. 미사용액은 해제하고 나중에 상한을 넘는 금액을 추가 징수하지 않습니다. 실패는 보통 선점을 해제하지만 모델 페이지에 명시한 일부 콘텐츠 거절은 승인 견적만큼 과금합니다. settled와 cost를 확인하세요. deadlineAt은 작업이 expired로 끝나기 전의 최종 기한입니다. 응답의 성공 비즈니스 code는 200입니다. 승인 작업은 취소할 수 없습니다.

결과 수집

운영 요청에 callBackUrl을 넣으면 종료 상태에서 콜백을 보냅니다. webhook은 완료 시 호출하는 자체 서버의 HTTPS 주소로, 반복 조회를 줄입니다. 위 최소 요청은 공개 서버 없이 실행하도록 생략했습니다. 서명을 검증해야 합니다. Webhook 참조.

콜백이 없다면 작업을 조회하세요.

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

생성한 API 키를 쓰세요. 다른 키는 404입니다. data.state를 확인합니다.

data.state의미대응
queued 또는 running생성 중. 금액은 지출이 아닌 선점 상태수초 후 같은 명령 반복. 이미지는 대개 수초, 영상은 수분 가능. 상한은 deadlineAt
succeeded완료다음 단계
failed 또는 expired실패. 보통 선점 해제하나 모델 페이지에 명시한 일부 거절은 과금. settled, cost 확인안정 코드 data.errorCode와 사람이 읽는 data.errorMessage 확인, 입력 수정 후 3a부터 재시작

작업이 실패해도 응답 code는 200입니다. code는 조회 성공 여부를 의미하며 작업 결과는 항상 data.state입니다. 단계별 응답을 줄인 예시입니다.

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

settled가 false이면 cost는 최종 과금이 아닌 선점액입니다.

결과물 다운로드

성공 결과의 data.output.assets[0].url로 직접 다운로드하세요. API 키를 붙이지 마세요. 링크는 약 20분(expiresAt) 유효합니다. 만료되면 조회마다 새 링크를 받으므로 4단계를 반복하세요. 파일은 최대 14일 후 삭제되니 필요한 결과는 자체 사본을 보관하세요.

# 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

jq가 있으면 복사·붙여 넣기를 생략할 수 있습니다.

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

확인할 결과: 터미널의 현재 폴더에 result.png가 있습니다. 비용은 콘솔 → 로그 또는 settled가 true일 때 cost로 확인합니다.

텍스트 모델: 같은 응답에서 답 받기

텍스트 모델은 같은 HTTP 응답에서 답하므로 폴링·다운로드가 없습니다. X-Spicy-Task-Id 헤더로 과금 작업을 나중에 확인할 수 있습니다. 2단계에서 modality=text로 조회하여 하나를 SPICY_MODEL로 설정한 뒤 실행하세요.

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

답변은 choices[0].message.content입니다. 사용 토큰으로 과금하며 {code, msg, data}가 아닌 OpenAI 응답 형식입니다. 텍스트와 스트리밍 참조.

코드로 모델 선택

위 다섯 단계는 사람이 선택합니다. 배포한 통합은 제출 직전에 직접 선택해야 합니다. 지난주 가능했던 모델이 오늘 중지될 수 있고 가격도 계정별입니다. 같은 호출에서 스키마와 서버 검증 예시를 요청하고 enabled와 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"

결과가 없으면 기억한 ID로 대체하지 말고 중단하세요. 다음 세 프로그램은 jq 없이 같은 선택을 수행합니다.

전체 흐름 실행

세 프로그램 모두 카탈로그 조회, 작업 생성, 간격을 늘리는 폴링과 다운로드 URL 출력을 수행합니다. 운영 환경은 webhook을 사용하세요. 폴링은 기본 계획이 아닌 보조 수단입니다.

실행하면 실제 유료 작업을 만듭니다

각 프로그램은 카탈로그의 첫 호출 가능한 텍스트→이미지 모델(최저가 모델은 아님)을 고르고 검증 예시 하나를 제출합니다. 가격을 먼저 확인하거나 원하는 정확한 모델로 선택을 바꾸세요.

언어저장 이름먼저 설치실행
Node.js 20 이상generate.mjs없음node generate.mjs
Python 3.9 이상generate.pypip install requestspython3 generate.py(Windows: py generate.py)
Go 1.21 이상빈 폴더의 main.gogo mod init examplego run .

모두 1단계의 SPICY_API_KEY를 읽고 완료 시 URL을 출력합니다. 이미지에 맞춰 최대 10분 기다립니다. 영상은 작업의 deadlineAt까지 기다리세요.

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

응답 형식

네이티브 작업 API의 JSON 응답 형식입니다. /v1 호환과 SSE는 각 프로토콜 형식을 사용합니다.

{ "code": 200, "msg": "success", "data": { }, "request_id": "req_…" }
  • code는 HTTP 상태가 아닌 비즈니스 코드입니다. 200은 성공입니다. HTTP 상태도 맞게 설정되므로 둘 중 하나로 분기하되 하나만 선택하세요. 세부 코드에서 다릅니다(잔액 부족은 code: 40201, HTTP 402). 섞으면 결국 놓치는 경우가 생깁니다.
  • 실패 시 data는 null이 아니라 생략됩니다.
  • request_id는 성공·실패 모두 포함되고 X-Request-Id 헤더에도 있습니다. 문제 신고에 포함하세요.

다음 단계

이 페이지에서