spicyapi문서
텍스트와 채팅
본문

텍스트와 스트리밍

OpenAI, Anthropic, Google Gemini의 공식 형식으로 텍스트 모델을 호출하고, 대화, 도구, 스트림, 비용을 올바르게 다루는 방법을 설명합니다.

텍스트 모델은 같은 HTTP 요청 안에서 바로 답합니다. 폴링할 작업이 따로 없습니다. 답변 전체가 완성될 때까지 기다릴 수도 있고, 스트리밍할 수도 있습니다. 스트리밍은 채팅 앱이 답장을 한 글자씩 써 내려가듯, 모델이 아직 쓰고 있는 동안 텍스트를 작은 조각으로 받는 방식입니다. 스트리밍된 조각은 서버 전송 이벤트(SSE) 형식으로 도착합니다. data:로 시작하는 평범한 텍스트 줄들이, 답변이 끝날 때까지 열려 있는 연결 하나를 통해 차례로 전송됩니다.

호출할 수 있는 텍스트 모델 고르기

이 페이지의 셸 예제는 macOS나 Linux 터미널을 기준으로 작성했고, 작은 명령줄 JSON 도구인 jq를 씁니다(macOS에서는 brew install jq, Debian이나 Ubuntu에서는 sudo apt install jq로 설치). Windows에서는 WSL 안에서 실행하거나, 대신 Python SDK 예제를 쓰세요.

먼저 API 키를 환경 변수에 넣으세요. 환경 변수는 터미널이 자신이 실행하는 명령에 넘겨주는, 이름이 붙은 값입니다. 이렇게 하면 키를 명령에 직접 붙여 넣을 필요가 없습니다.

export SPICY_API_KEY="sk-spicy-paste-your-key-here"

이 키로 지금 호출할 수 있는 텍스트 모델을 나열합니다.

curl -s "https://api.spicyapi.ai/api/v1/models?modality=text" \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  | jq -r '.data.items[] | select(.enabled and .available) | .model'

한 줄에 모델 ID가 하나씩 출력됩니다. 하나를 골라 SPICY_MODEL에 넣어 두세요. 아래의 모든 예제가 이 값을 읽습니다.

export SPICY_MODEL="MODEL_ID_FROM_CATALOG"   # 위 목록에서 고른 한 줄로 바꾸세요

MODEL_ID_FROM_CATALOG는 자리표시자일 뿐이니 절대 그대로 보내지 마세요. 도구, 이미지, 추론 설정, 구조화 출력을 쓰기 전에는 GET /api/v1/models/{model}에서 그 모델의 inputSchema를 읽으세요. 이런 기능은 스키마에 나와 있을 때만 작동합니다. 프로토콜이 호환된다고 해서 모든 모델이 모든 기능을 갖추는 것은 아닙니다.

같은 모델 ID를 아래 어느 프로토콜로든 쓸 수 있으며, 모델을 누가 만들었는지와는 관계가 없습니다. 다른 회사의 모델을 Anthropic 형식으로 호출하거나, Google이 만들지 않은 모델을 Gemini 형식으로 호출해도 됩니다.

지원하는 프로토콜

아래 엔드포인트는 모두 같은 API 키와 모델 카탈로그를 쓰고, 레이트 제한, 파라미터 검증, 과금도 똑같이 적용됩니다. 다른 것은 요청과 응답의 형식뿐입니다. 기존 코드나 SDK가 이미 쓰고 있는 형식을 고르세요.

프로토콜엔드포인트API 키 헤더
OpenAI Chat CompletionsPOST /v1/chat/completionsAuthorization: Bearer
OpenAI ResponsesPOST /v1/responsesAuthorization: Bearer
Anthropic MessagesPOST /v1/messagesx-api-key 또는 Authorization: Bearer
Google GeminiPOST /v1beta/models/{model}:generateContentx-goog-api-key 또는 Authorization: Bearer
Google Gemini (스트리밍)POST /v1beta/models/{model}:streamGenerateContentx-goog-api-key 또는 Authorization: Bearer
확정 가격으로 보내는 네이티브 스트림POST /api/v1/jobs/streamAuthorization: Bearer

/v1과 /v1beta에서는 응답과 오류가 각 프로토콜의 형식을 따르며, 네이티브 {code,msg,data,request_id} 외피가 붙지 않습니다. 그러니 body.data만 읽는 클라이언트로 파싱하지 마세요. Gemini 형식은 모델 ID를 URL 경로에 넣으며, ID 안의 슬래시는 그대로 두면 되고 인코딩할 필요가 없습니다.

키는 요청 헤더로만 보내세요. x-api-key와 x-goog-api-key는 /v1과 /v1beta에서만 작동하고, 네이티브 /api/v1은 Authorization: Bearer만 받습니다. 쿼리 파라미터 ?key=는 어떤 경우에도 받지 않습니다. 키가 한 번 URL에 들어가면 브라우저 기록, 프록시 서버, 접근 로그에 계속 남기 때문입니다. 공식 SDK는 모두 키를 헤더로 보내므로 영향이 없습니다.

공식 SDK 쓰기

베이스 URL과 키만 바꾸세요. 나머지는 각 SDK를 평소 쓰던 방식 그대로 쓰면 됩니다.

SDK베이스 URL
OpenAI (openai)https://api.spicyapi.ai/v1
Anthropic (anthropic)https://api.spicyapi.ai
Google GenAI (google-genai)https://api.spicyapi.ai

Anthropic과 Google SDK는 /v1이나 /v1beta를 스스로 붙이므로 베이스 URL에 버전 경로를 넣지 않습니다. 반면 OpenAI SDK의 베이스 URL에는 /v1이 들어가야 합니다. 아래 Python 예제 세 개는 모두 환경 변수에서 SPICY_API_KEY와, 실시간 카탈로그에서 고른 SPICY_MODEL을 읽습니다.

OpenAI
import os
from openai import OpenAI

client = OpenAI(api_key=os.environ["SPICY_API_KEY"], base_url="https://api.spicyapi.ai/v1")
reply = client.chat.completions.create(
    model=os.environ["SPICY_MODEL"],
    messages=[{"role": "user", "content": "Explain a rainbow in one sentence."}],
)
print(reply.choices[0].message.content)
Anthropic
import os
import anthropic

client = anthropic.Anthropic(api_key=os.environ["SPICY_API_KEY"], base_url="https://api.spicyapi.ai")
message = client.messages.create(
    model=os.environ["SPICY_MODEL"],
    max_tokens=256,
    messages=[{"role": "user", "content": "Explain a rainbow in one sentence."}],
)
print(message.content[0].text)
Google GenAI
import os
from google import genai
from google.genai import types

client = genai.Client(
    api_key=os.environ["SPICY_API_KEY"],
    http_options=types.HttpOptions(base_url="https://api.spicyapi.ai"),
)
response = client.models.generate_content(
    model=os.environ["SPICY_MODEL"],
    contents="Explain a rainbow in one sentence.",
)
print(response.text)

호환 레이어는 접수 시점의 가격으로 과금하며 견적을 받지 않습니다

/v1과 /v1beta는 요청이 접수된 시점에 적용되는 가격으로 과금합니다. quoteId나 expectedCost를 받지 않으므로, 이 경로에는 호출 한 번에 대한 금액 상한이 없습니다. 에이전트 루프가 폭주하면 돈을 계속 씁니다.

상한을 두는 방법은 두 가지이며, 대부분의 연동에는 둘 다 필요합니다.

  • 호출 한 번 단위로: 네이티브 POST /api/v1/jobs/quote로 견적을 받은 다음, 돌려받은 quoteId와 expectedCost를 담아 POST /api/v1/jobs/stream으로 보냅니다. 그사이 가격이 바뀌었다면 동의한 금액보다 많이 과금하는 대신 40901로 접수가 실패합니다. 확정 가격으로 보내는 네이티브 스트림을 보세요.
  • 이 키가 쓰는 전체 금액: 키에 일·월·누적 지출 한도를 거세요. 이 경로를 포함한 모든 경로에 적용되며, 한도를 넘으면 더 큰 청구 대신 40202가 반환됩니다. 인증을 보세요.

텍스트 엔드포인트의 멱등 키

Idempotency-Key 헤더는 네트워크 재시도 때문에 두 번 과금되는 일을 막아 줍니다. 하지만 텍스트 엔드포인트에서는 잃어버린 답변을 되찾게 해 주지는 않습니다. 답변은 스트리밍으로 전달될 뿐 저장되지 않기 때문입니다.

  • 이미 완료된 요청의 키를 같은 본문으로 다시 보내면 400(This Idempotency-Key was already used for a completed request, and its answer cannot be replayed. Use a new Idempotency-Key to send a new request)이 반환되고, 추가로 과금되지는 않습니다. 작업 기록에도 텍스트는 들어 있지 않습니다.
  • 그 요청이 실패했다면, 같은 키와 같은 본문으로 다시 보냈을 때 당시와 같은 오류가 반환되고 과금되지 않습니다.
  • 다른 본문으로 다시 보내면 409가 반환됩니다.

그러니 새 메시지를 보낼 때마다 새 키를 쓰세요(예: 셸에서는 $(uuidgen), Python에서는 uuid.uuid4()). 키를 다시 쓰는 것은 네트워크 오류 뒤에 완전히 똑같은 요청을 다시 보낼 때뿐이며, 그때도 그 요청의 답변은 되찾을 수 없다는 점을 감수해야 합니다. 답변을 다시 받고 싶다면 새 키로 보내고 그 비용을 내세요.

SDK에서는 키를 클라이언트 객체에 붙이지 말고 호출할 때마다 넘기세요. 클라이언트 전체에 키 하나를 붙이면 첫 메시지 이후의 모든 메시지가 24시간 동안 409로 거부됩니다.

호출마다 Idempotency-Key 붙이기
import os, uuid

# OpenAI SDK (위 OpenAI 예제의 client). Anthropic SDK도
# client.messages.create()에서 같은 extra_headers 인수를 받습니다.
reply = client.chat.completions.create(
    model=os.environ["SPICY_MODEL"],
    messages=[{"role": "user", "content": "Explain a rainbow in one sentence."}],
    extra_headers={"Idempotency-Key": str(uuid.uuid4())},
)

# Google GenAI SDK (위 Google GenAI 예제의 client와 types):
# 호출마다 넘기는 config 안의 http_options
response = client.models.generate_content(
    model=os.environ["SPICY_MODEL"],
    contents="Explain a rainbow in one sentence.",
    config=types.GenerateContentConfig(
        http_options=types.HttpOptions(headers={"Idempotency-Key": str(uuid.uuid4())}),
    ),
)

OpenAI와 Anthropic SDK는 시간이 초과되면 같은 헤더로 스스로 재시도합니다. 그 재시도가 저희가 이미 접수한 요청에 닿으면, SDK는 위의 메시지와 함께 400 bad-request 오류를 냅니다. 첫 시도는 처리되어 과금되었고, 그 답변은 사라졌다는 뜻입니다.

Grok 명령줄 클라이언트에서 SpicyAPI 모델 쓰기

이 설정은 xAI의 명령줄 클라이언트가 요청을 SpicyAPI로 보내게 합니다. 설정한 다음부터 이 클라이언트는 xAI 자체 엔드포인트가 아니라 SpicyAPI 카탈로그에 있는 모델과 주고받습니다. 카탈로그에 공개된 Grok 계열도 여기에 포함되며, 다른 모델과 똑같이 SpicyAPI의 모델 ID로 호출하면 됩니다. 이 클라이언트는 사용자 지정 모델을 OpenAI 호환 엔드포인트 어디로든 연결할 수 있습니다. 아래 항목을 ~/.grok/config.toml에 추가하세요. env_key에는 클라이언트가 읽을 환경 변수의 이름을 적으므로, 키 자체는 파일에 남지 않습니다.

~/.grok/config.toml
[model.spicyapi]
model = "MODEL_ID_FROM_CATALOG"
base_url = "https://api.spicyapi.ai/v1"
name = "SpicyAPI"
env_key = "SPICY_API_KEY"

[models]
default = "spicyapi"

MODEL_ID_FROM_CATALOG를 실시간 카탈로그에 있는 정확한 텍스트 모델 ID(<publisher>/<model>/<task> 형태)로 바꾸고, 자리표시자를 그대로 남겨 두지 마세요. 그런 다음 grok -m spicyapi로 시작하거나, 세션 안에서 /model spicyapi로 전환하세요. 이 클라이언트는 Chat Completions 형식을 쓰므로 아래에서 설명하는 파라미터 대응, 스트림 종료 방식, 오류 형태가 그대로 적용됩니다.

프로토콜 간 파라미터 대응

프로토콜 수준의 필드는 먼저 플랫폼 공통 파라미터 이름으로 바뀐 다음, 선택한 모델의 inputSchema로 검증됩니다. 모델이 지원하지 않는 파라미터는 400을 반환합니다. 파라미터가 조용히 버려진 채 요청이 평소대로 과금되는 일은 없습니다. 아래 표는 대응 관계만 보여 주며, "—"는 그 프로토콜에 대응하는 필드가 없다는 뜻입니다. 특정 모델이 어떤 파라미터를 받고 어떤 값을 허용하는지는 그 모델의 inputSchema가 정합니다.

공통 파라미터Chat CompletionsResponsesMessagesGemini
messagesmessagesinstructions, inputsystem, messagessystemInstruction, contents
max_tokensmax_tokens, max_completion_tokensmax_output_tokensmax_tokensgenerationConfig.maxOutputTokens
temperaturetemperaturetemperaturetemperaturegenerationConfig.temperature
top_ptop_ptop_ptop_pgenerationConfig.topP
toolstoolstools (function 타입)tools (사용자 정의 도구)tools[].functionDeclarations
tool_choicetool_choicetool_choicetool_choicetoolConfig.functionCallingConfig
response_formatresponse_formattext.format—generationConfig.responseMimeType, 그리고 responseSchema 또는 responseJsonSchema
reasoning_effortreasoning_effortreasoning.effort—generationConfig.thinkingConfig

표에 없는 필드도 똑같이 공통 파라미터로 바뀐 뒤 모델이 검증하며, inputSchema에 없는 것은 모두 400을 반환합니다. 예를 들어 inputSchema에 seed를 두는 모델은 일부뿐이며, 나머지 모델에서는 Chat의 seed와 Gemini의 generationConfig.seed가 400을 반환합니다. 샘플링 설정과 stop 시퀀스는 예외입니다. 모델이 받지 않으면 무시되고(X-Spicy-Ignored-Params 응답 헤더에 이름이 나옵니다), 모델이 받는 범위를 벗어난 값은 가장 가까운 허용 값으로 바뀝니다(X-Spicy-Adjusted-Params에 이름이 나옵니다). 여기에는 temperature, top_p, 각종 페널티, Chat의 stop, Messages의 stop_sequences, Gemini의 generationConfig.stopSequences, 그리고 reasoning_effort 단계가 해당합니다. 다른 모델의 필드를 그대로 가정하지 말고, 선택한 모델의 스키마를 읽으세요.

몇 가지 예외가 있습니다.

  • 그 프로토콜의 원래 플랫폼에서만 의미가 있는 필드는 무시되며 생성에 영향을 주지 않습니다. Chat의 user, metadata, store, Responses의 user, metadata, store, include, Messages의 metadata가 여기에 해당합니다.
  • Responses는 previous_response_id를 지원하지 않습니다. 전체 대화 기록을 input에 담아 보내세요.
  • Gemini의 safetySettings는 받기는 하지만 아무 효과가 없습니다. cachedContent, 그리고 googleSearch나 codeExecution 같은 서버 측 도구는 지원하지 않으며 400을 반환합니다. 이미지는 inlineData(base64)나 fileData.fileUri(https URL)로 넘기세요. 모델이 이미지를 받는지는 그 모델의 inputSchema가 정합니다.

Chat 스트림 보내기

위에서처럼 SPICY_API_KEY와 SPICY_MODEL을 설정했다면, 아래 명령은 요청을 파일에 쓴 다음 답변을 스트리밍으로 받습니다. 예시 프롬프트는 필요에 맞게 바꾸고, 보내기 전에 모델 파라미터를 현재 스키마와 대조해 확인하세요. 실행할 때마다 새 Idempotency-Key를 보냅니다. 실행 한 번 한 번이 새 메시지이기 때문입니다.

jq -n --arg model "$SPICY_MODEL" '{
  model: $model,
  messages: [{role: "user", content: "Explain a rainbow in one sentence."}],
  stream: true
}' > chat-request.json

curl --fail-with-body --no-buffer --max-time 120 \
  https://api.spicyapi.ai/v1/chat/completions \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @chat-request.json

--no-buffer는 조각이 도착하는 즉시 출력하게 하고, --max-time 120은 2분이 지나면 기다리기를 그만두게 합니다. 아래와 같은 줄이 하나씩 차례로 도착하고, 마지막에 data: [DONE]이 나와야 합니다. 답변은 여러 delta.content 조각에 나뉘어 있으니, 순서대로 이어 붙이면 전체 텍스트가 됩니다. [DONE] 바로 앞의 마지막 청크에 usage가 들어 있습니다.

data: {"id":"chatcmpl-req_01k...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"A rainbow"}}]}

data: {"id":"chatcmpl-req_01k...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":" forms when"}}]}

data: [DONE]

uuidgen은 macOS에 기본으로 들어 있고, Linux에서는 uuid-runtime 패키지에 들어 있습니다.

Gemini 요청 보내기

같은 SPICY_API_KEY와 SPICY_MODEL을 쓰고, 여기서도 새 Idempotency-Key를 붙입니다. Chat 예제와는 다른 요청이므로 그 키를 다시 쓰면 409가 반환됩니다. 모델 ID는 URL에 들어가므로 요청 본문에는 모델 필드가 없습니다. 셸이 변수 바로 뒤의 콜론을 변수 수식자로 읽지 않도록, URL을 큰따옴표로 감싸고 변수를 ${SPICY_MODEL}처럼 쓰세요.

jq -n '{
  systemInstruction: {parts: [{text: "Answer in one sentence."}]},
  contents: [{role: "user", parts: [{text: "Explain a rainbow."}]}],
  generationConfig: {temperature: 0.7, maxOutputTokens: 256}
}' > gemini-request.json

curl --fail-with-body --no-buffer --max-time 120 \
  "https://api.spicyapi.ai/v1beta/models/${SPICY_MODEL}:streamGenerateContent?alt=sse" \
  -H "x-goog-api-key: $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @gemini-request.json

스트리밍이 필요 없다면 :streamGenerateContent?alt=sse를 :generateContent로 바꾸고, 답변은 candidates[0].content.parts[].text에서 읽으세요. 사용량은 usageMetadata에 있습니다. alt=sse 없이 보낸 스트리밍 요청은 조금씩 이어서 쓰이는 JSON 배열 하나를 반환합니다. 공식 SDK는 alt=sse를 알아서 붙여 줍니다.

확정 가격으로 보내는 네이티브 스트림

POST /api/v1/jobs/stream은 견적을 받는 유일한 텍스트 엔드포인트라서, 가격이 바뀌었다면 실행을 거부할 수 있습니다. 요청 본문은 createTask와 같은 모양입니다. model, 그리고 모델의 inputSchema에 나온 필드로 채운 input으로 이루어집니다(텍스트 모델이라면 messages와 위 표의 다른 공통 파라미터).

# 1. 요청을 한 번만 작성합니다.
jq -n --arg model "$SPICY_MODEL" '{
  model: $model,
  input: {messages: [{role: "user", content: "Explain a rainbow in one sentence."}]}
}' > native-request.json

# 2. 견적을 받습니다. 아무것도 만들지 않고 과금도 없습니다.
QUOTE=$(curl -s https://api.spicyapi.ai/api/v1/jobs/quote \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  --data-binary @native-request.json)
echo "$QUOTE" | jq '{code, msg, estimatedCost: .data.estimatedCost, maxCharge: .data.maxCharge, expiresAt: .data.expiresAt}'

# 3. 5분 안에, 바꾸지 않은 요청에 quoteId와 expectedCost를 더해 스트리밍합니다.
jq --argjson quote "$QUOTE" \
  '. + {quoteId: $quote.data.quoteId, expectedCost: $quote.data.estimatedCost}' \
  native-request.json > native-confirmed.json

curl --fail-with-body --no-buffer --max-time 120 \
  https://api.spicyapi.ai/api/v1/jobs/stream \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @native-confirmed.json

2단계는 code: 200과 미국 달러 가격을 출력합니다. 다른 code가 나오면 견적이 실패한 것이며, 이유는 msg에 있습니다. 텍스트가 전송되기 전에 스트림 호출이 실패하면(예를 들어 가격이나 요청이 바뀌어 40901이 나오거나, 잔액이 부족해 40201이 나오는 경우) 일반적인 {code,msg,data,request_id} JSON을 받으며, 예약되는 금액은 없습니다. 스트리밍이 시작된 뒤에 실패하면 event: error와 data: {"code":"stream_interrupted"}로 스트림이 끝나고, 그 요청에는 아무것도 과금되지 않습니다. 이미지, 영상, 오디오 모델은 이 엔드포인트에서 503을 반환하니, 그런 모델에는 createTask를 쓰세요.

답변은 SSE data: 줄로 도착합니다. /v1/chat/completions와 달리 이 엔드포인트는 모든 모델에 똑같은 이벤트 형식을 약속하지 않으므로, 고른 모델로 파서를 테스트하세요. 하나의 안정적인 형식이 필요하다면 /v1 엔드포인트를 쓰고, 지출은 키의 한도로 제한하세요.

텍스트 모델에 이미지·동영상·오디오 보내기

텍스트 모델 중에는 글뿐 아니라 이미지도 읽을 수 있는 모델이 있고, 일부는 동영상과 오디오도 읽습니다. 미디어를 질문과 함께 보내면 답변은 여전히 텍스트로 돌아옵니다. 모델이 무엇을 받는지는 프로토콜이 보장하는 것이 아니라 모델의 스키마에 적혀 있습니다. 선택한 모델에서 확인하세요.

curl -s "https://api.spicyapi.ai/api/v1/models/$SPICY_MODEL" \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  | jq -c '[.data.inputSchema.properties.messages.items.properties.content.items.oneOf[]?.properties.type.const]'

이 목록에는 모델이 받는 콘텐츠 파트가 나열됩니다. ["text","image_url"]이면 텍스트와 이미지를 받습니다. 동영상과 오디오도 읽는 모델은 video_url, audio_url, input_audio도 함께 나열합니다. 빈 목록이면 텍스트만 받습니다. 목록에 없는 파트를 보내면 400이 반환되고 과금되지 않습니다.

미디어를 보내려면 메시지의 content를 문자열이 아닌 파트 목록으로 만듭니다. 질문에는 text 파트 하나를, 파일마다 파트 하나를 씁니다.

jq -n --arg model "$SPICY_MODEL" '{
  model: $model,
  stream: true,
  messages: [{
    role: "user",
    content: [
      {type: "text", text: "What is in this picture?"},
      {type: "image_url", image_url: {url: "https://example.com/photo.jpg"}}
    ]
  }]
}' > image-request.json

curl --fail-with-body --no-buffer --max-time 120 \
  https://api.spicyapi.ai/v1/chat/completions \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @image-request.json

미디어 종류마다 쓰는 파트가 다릅니다.

파트형태파일을 전달하는 방법
이미지{"type": "image_url", "image_url": {"url": "…"}}공개 HTTPS URL, 업로드한 파일(spicy://f/…) 또는 작은 data: URI
동영상{"type": "video_url", "video_url": {"url": "…"}}업로드한 파일(spicy://f/…) 또는 공개 HTTPS URL
오디오 파일{"type": "audio_url", "audio_url": {"url": "spicy://f/…"}}업로드한 파일만 가능
인라인 오디오{"type": "input_audio", "input_audio": {"data": "<base64>", "format": "wav"}}요청에 담은 Base64, wav 또는 mp3

파일을 업로드해 spicy://f/… URI를 받는 방법은 미디어에서 설명합니다. 업로드한 파일은 하루 동안 보관됩니다. 미디어가 모델에 도달하는 경로가 종류마다 달라서 다음 규칙이 적용됩니다.

  • 이미지는 이미지를 보여 주는 페이지가 아니라 이미지 파일 자체를 반환하는 공개 HTTPS URL, 업로드한 파일, 또는 인라인 한도 안의 data: URI를 쓸 수 있습니다. JPEG와 PNG는 이미지를 받는 모든 모델에서 작동합니다.
  • 동영상과 오디오는 업로드해서 보내는 방식이 가장 좋습니다. 업로드한 파일을 저희가 읽어 요청에 담아 모델에 전달하므로, 한 요청에 담을 수 있는 업로드한 동영상과 오디오는 합쳐서 최대 14MB입니다. 동영상은 MP4 또는 WebM, 오디오는 MP3 또는 WAV입니다. 합계가 이를 넘으면 400이 반환되고 과금되지 않습니다. 공개 동영상 URL은 링크 그대로 전달되므로, 모델 제공업체가 내려받을 수 있을 때만 작동합니다.
  • 인라인 오디오(input_audio)는 OpenAI 형식을 따르며 요청 본문 2MiB 안에 들어와야 하므로 짧은 클립에 적합합니다. 더 긴 오디오는 업로드한 뒤 audio_url로 보내세요.

한 메시지에 파일을 여러 개 넣을 수 있으며, 이때 파일마다 별도의 파트로 보냅니다. 이전 대화 기록의 메시지에도 미디어가 들어 있어도 됩니다.

다른 프로토콜도 각자의 형식으로 이미지를 전달하며, 어느 쪽으로 보내든 모델에는 똑같이 도달합니다. 동영상과 오디오 파트는 Chat Completions와 네이티브 스트림으로 보냅니다.

프로토콜이미지 파트
Chat Completions{"type": "image_url", "image_url": {"url": "…"}}
Responses{"type": "input_image", "image_url": "…"}
Anthropic Messages{"type": "image", "source": {"type": "url", "url": "…"}} 또는 base64 소스
GeminiinlineData(base64) 또는 fileData.fileUri(HTTPS URL)

비용. 미디어는 해당 모델의 일반 입력 요율로 입력 토큰으로 계산되며, 파일당 별도 가격은 없습니다. 이미지는 모델과 이미지 크기에 따라 대개 수백에서 수천 개의 토큰이 추가되고, 동영상과 오디오는 길이에 따라 늘어납니다. 정확한 수는 응답의 usage 블록에 나옵니다.

사전에 확보되는 금액. 요청을 실행하기 전에 상한 금액을 확보해 두고, 정산할 때 차액을 풀어 줍니다. 미디어가 직접 업로드했거나 인라인으로 보낸 이미지뿐인 요청은 이미지마다 정해진 여유분을 더한 금액이 상한입니다. 동영상이나 오디오를 담은 요청, 또는 공개 URL로 지정한 이미지를 담은 요청은 크기를 미리 알 수 없으므로, 상한이 모델의 입력 한도 전체를 기준으로 산정됩니다. 그래서 확보된 금액이 최종 청구액보다 훨씬 클 수 있습니다. 확보 금액을 작게 유지하려면 이미지를 업로드하고, 확보 금액을 감당할 잔액을 유지하거나, POST /api/v1/jobs/quote로 먼저 견적을 내어 확인하세요.

Playground에서. 미디어를 받는 모델에서는 메시지 입력란에 첨부 버튼이 표시됩니다. 스크린샷을 붙여넣거나 파일을 입력란에 끌어다 놓을 수도 있으며, 메시지 하나에 최대 4개까지 첨부할 수 있고 그중 동영상과 오디오는 합쳐서 14MB까지입니다. 텍스트만 읽는 모델에는 버튼이 표시되지 않습니다.

대화와 도구 호출

Chat Completions

  • 스트리밍하지 않을 때: choices[0].message를 읽습니다.
  • 스트리밍할 때: choices[].delta를 네트워크 패킷 단위가 아니라 SSE 이벤트 하나 단위로 처리합니다. tool_calls는 index별로 조립하고, 호출은 arguments가 완성된 뒤에만 검증하고 실행하세요.
  • 어시스턴트 메시지를 대화 기록에 남기고, 각 결과는 해당하는 tool_call_id를 붙인 tool 메시지로 돌려보내세요.

Responses

  • 전체 대화 기록을 input에 담아 보내세요. previous_response_id는 거부됩니다.
  • 함수 결과는 해당하는 call_id를 붙인 function_call_output 항목으로 돌려보내세요.

Messages

  • Chat 메시지 형태 대신 text, tool_use, tool_result 콘텐츠 블록을 쓰세요.

Gemini

  • contents는 user와 model 두 역할이 번갈아 이어집니다.
  • 모델이 도구를 호출하려 하면 functionCall 파트를 반환합니다. 도구를 실행한 뒤 결과를 functionResponse 파트로 감싸고, 호출 때와 같은 name을 써서 다음 user 콘텐츠에 담아 돌려보내세요.

완료, 오류, 연결 끊김

프로토콜정상 완료사용량
Chat Completionsdata: [DONE]마지막 청크의 usage
Responsesresponse.completed 이벤트그 이벤트의 response.usage
Messagesmessage_delta 다음에 오는 message_stopmessage_delta의 usage
GeminifinishReason이 담긴 마지막 이벤트. [DONE]은 없습니다usageMetadata

스트림을 읽기 전에 HTTP 상태와 Content-Type을 확인하세요. 오류는 각 프로토콜 고유의 JSON으로 돌아옵니다.

프로토콜오류 본문
Chat Completions, Responses{"error": {"message", "type", "param", "code"}}
Messages{"type": "error", "error": {"type", "message"}}
Gemini{"error": {"code", "message", "status"}}

HTTP 상태 코드는 네 프로토콜 모두 같습니다. Gemini의 status에는 Google의 상태 이름이 쓰입니다. 400은 INVALID_ARGUMENT, 401은 UNAUTHENTICATED, 402는 FAILED_PRECONDITION, 403은 PERMISSION_DENIED, 404는 NOT_FOUND, 409는 ABORTED, 429는 RESOURCE_EXHAUSTED, 500은 INTERNAL, 503은 UNAVAILABLE, 504는 DEADLINE_EXCEEDED입니다. 코드는 메시지 문구가 아니라 상태 코드와 오류 유형으로 분기하세요.

스트리밍이 시작된 뒤에 문제가 생기면 Chat과 Gemini는 오류 객체가 담긴 이벤트를, Responses는 오류 이벤트를, Messages는 event: error를 보냅니다. 클라이언트는 이런 오류 이벤트뿐 아니라 시간 초과, 그리고 정상 완료 표시 없이 연결이 닫히는 경우도 처리해야 합니다. 스트림을 자체 프록시를 거쳐 전달한다면 그 프록시의 응답 버퍼링을 끄세요. 그러지 않으면 조각이 한꺼번에 몰려서 도착합니다.

스트림이 일찍 멈췄을 때 내는 비용

  • 내가 연결을 끊은 경우(탭을 닫거나, 요청을 중단하거나, 클라이언트 시간 초과에 걸린 경우): 연결을 끊어도 과금은 멈추지 않습니다. 모델이 보고한 사용량만큼 과금되며, 많은 모델에서 이는 받지 못한 텍스트까지 포함한 답변 전체입니다. 다만 요청이 접수될 때 예약된 금액을 넘지는 않습니다. 받지 못한 텍스트는 나중에 가져올 수 없습니다. 비용을 줄이려면 연결을 끊지 말고 max_tokens를 낮추세요.
  • 모델이나 저희 서비스가 스트리밍 도중 실패한 경우(오류 이벤트를 받았거나, 완료 표시 없이 스트림이 끝난 경우): 요청은 실패로 기록되고 아무것도 과금되지 않습니다.
  • 모델이 텍스트나 추론 내용을 이미 보낸 뒤 답변이 비어 있거나 거절된 것으로 판정된 경우: 이미 받은 내용은 모델이 보고한 사용량만큼 과금되며, 예약된 금액을 넘지 않고, 스트림은 정상적으로 끝납니다. 아무것도 받지 못했을 때만 전액 환불됩니다.
  • 요청이 서버 마감 시각을 넘긴 경우: 실패로 끝나고 아무것도 과금되지 않습니다.

타임아웃과 스트리밍

스트리밍을 쓰지 않으면 답변은 준비된 뒤 한 번에 전달됩니다. 긴 답변과 긴 추론에는 시간이 걸리며, Grok, GLM, DeepSeek Pro, Claude Opus 같은 모델은 특히 reasoning_effort가 높으면 무언가를 쓰기 시작하기 전에 몇 분 동안 생각하기도 합니다. API 앞단의 네트워크 엣지는 약 100초 동안 아무것도 도착하지 않은 연결을 닫으므로, 두 방식의 요청 모두 모델이 작업하는 동안 연결을 열어 둡니다.

  • 약 30초가 지나도 끝나지 않은 비스트리밍 요청은 응답을 먼저 시작합니다. 상태 200, Content-Type: application/json, 헤더 X-Spicy-Keepalive: 1이 먼저 도착하고, 이어서 15초마다 줄바꿈 하나가, 마지막에 JSON 본문이 옵니다. JSON은 값 앞의 공백을 허용하므로 표준 파서(json.loads, JSON.parse, response.json())와 공식 SDK는 본문을 그대로 읽습니다. 그보다 일찍 끝난 요청은 원래 상태 코드로 일반 응답을 받으며, 이 헤더는 붙지 않습니다.
  • 그 이후의 실패는 상태 코드가 아니라 본문으로 전달됩니다. 200이 이미 전송되었으므로 본문에는 위 표에 있는 각 프로토콜의 일반 오류 객체가 들어가며, 코드와 메시지도 원래 돌아왔을 것과 같습니다. Gemini 형식에서는 error.code에 원래의 HTTP 상태가 그대로 들어갑니다. X-Spicy-Keepalive가 있으면 답변을 읽기 전에 본문에 error 객체가 있는지 확인하십시오. 공식 SDK는 200을 오류로 처리하지 않습니다. 실패한 요청에는 요금이 청구되지 않습니다.
  • 스트리밍도 연결을 열어 둡니다. 모델이 아직 작업하는 동안 스트리밍 응답에는 킵얼라이브 줄(SSE에서는 : keep-alive 주석 줄, Gemini 형식에서는 빈 줄)이 흐르므로, 모델이 아무리 오래 생각해도 연결이 유휴 상태가 되지 않습니다. SSE 형식이 요구하는 대로 파서는 이 줄을 건너뛰어야 합니다. 공식 SDK는 이미 그렇게 합니다.
  • 오래 걸릴 수 있는 요청에는 가급적 stream: true를 쓰십시오. 답변이 작성되는 대로 볼 수 있고, 실패도 SDK가 이해하는 오류 이벤트로 도착합니다. Chat Completions, Responses, Messages는 stream: true를 받습니다. Gemini 형식에서는 :generateContent 대신 :streamGenerateContent?alt=sse를 호출합니다. 본문이 바뀌면 새 요청이므로 새 Idempotency-Key로 보내십시오. 이전 키를 다시 쓰면 409가 돌아옵니다.
  • 네트워크 엣지에서 끊긴 요청에는 요금이 청구되지 않습니다. 약 100초 동안 아무것도 도착하지 않은 채 연결이 닫히면, 그 요청은 콘솔 → 로그와 GET /api/v1/jobs에 실패로 표시되며, errorCode는 timeout이고 메시지에 무슨 일이 있었는지 적혀 있습니다. 같은 Idempotency-Key로 다시 보내면 그 메시지가 오류로 돌아옵니다.
  • 내 쪽의 시간 제한은 그대로 적용됩니다. HTTP 클라이언트나 직접 운영하는 프록시에 전체 시간 제한이 있으면 요청도 거기서 끝납니다. 킵얼라이브 줄을 포함해 무언가가 한 번이라도 도착한 뒤라면 이는 직접 연결을 끊은 것으로 처리됩니다(스트림이 일찍 멈췄을 때 내는 비용 참고). 공식 OpenAI SDK는 기본적으로 최대 10분까지 기다립니다. 이 페이지의 curl 예시는 2분 뒤에 포기하므로, 긴 추론에는 --max-time을 늘리십시오.

OpenAI SDK와 curl에서 스트리밍을 켜는 방법입니다.

OpenAI Python
import os
from openai import OpenAI

client = OpenAI(api_key=os.environ["SPICY_API_KEY"], base_url="https://api.spicyapi.ai/v1")
stream = client.chat.completions.create(
    model=os.environ["SPICY_MODEL"],
    messages=[{"role": "user", "content": "Explain a rainbow in one sentence."}],
    stream=True,
)
for chunk in stream:
    if chunk.choices and chunk.choices[0].delta.content:
        print(chunk.choices[0].delta.content, end="", flush=True)
print()
OpenAI Node.js
import OpenAI from "openai";

const client = new OpenAI({ apiKey: process.env.SPICY_API_KEY, baseURL: "https://api.spicyapi.ai/v1" });
const stream = await client.chat.completions.create({
  model: process.env.SPICY_MODEL,
  messages: [{ role: "user", content: "Explain a rainbow in one sentence." }],
  stream: true,
});
for await (const chunk of stream) {
  process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
}
process.stdout.write("\n");
curl
curl --fail-with-body --no-buffer --max-time 900 \
  https://api.spicyapi.ai/v1/chat/completions \
  -H "Authorization: Bearer $SPICY_API_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $(uuidgen)" \
  --data-binary @chat-request.json

Node.js 예시는 최상위 await를 쓰므로 ES 모듈로 실행하십시오(예: stream.mjs로 저장). curl 명령은 Chat 스트림 보내기에서 만든 chat-request.json을 보내며, 이 파일에는 이미 stream: true가 들어 있습니다. --max-time 900이면 답변에 최대 15분을 쓸 수 있습니다.

견적과 과금

비용을 미리 명시적으로 확인받으려면 견적과 함께 네이티브 스트림을 쓰세요. /v1과 /v1beta는 견적 정보를 받지 않고, 접수 시점의 가격을 적용합니다.

텍스트 호출은 작업 ID를 돌려주지 않으며, 텍스트 끝 표시가 최종 과금액을 증명하지도 않습니다. 호출 하나에 얼마가 들었는지 알려면 같은 API 키로 최근 작업 목록(GET /api/v1/jobs?model=<model>&from=<YYYY-MM-DD>)을 조회해 createdAt으로 맞춰 보거나, 콘솔 → 로그에서 확인하세요. cost는 settled가 true일 때만 최종 금액으로 보세요. 작업 기록은 비동기 작업, 과금은 과금을 보세요.

reasoning_tokens는 이미 completion_tokens에 포함되어 있으니 두 번 더하지 마세요.

그 밖의 요청 형태

아래 예제는 프로토콜의 요청 형식만 보여 줍니다. MODEL_ID_FROM_CATALOG를 예제에 나온 필드를 지원하는 실제 모델로 바꾸고, 자리표시자 자체는 보내지 마세요.

POST /v1/responses
{
  "model": "MODEL_ID_FROM_CATALOG",
  "input": "Explain a rainbow in one sentence.",
  "stream": false
}
POST /v1/messages
{
  "model": "MODEL_ID_FROM_CATALOG",
  "max_tokens": 256,
  "messages": [{"role": "user", "content": "Explain a rainbow in one sentence."}],
  "stream": false
}

더 읽어 보기

견적과 호환성 · 멱등성 · 과금 · 프로덕션 연동 · 문제 해결과 복구

이 페이지에서