채팅 구현 예시
Python, Node.js와 curl로 실행하는 다중 턴 대화, 캐릭터 롤플레잉, 자체 UI 스트리밍과 도구 호출의 완전한 프로그램입니다.
각 예시는 완전한 프로그램입니다. 환경 변수 두 개를 설정한 뒤 복사하여 실행하세요. 공식 OpenAI 라이브러리로 POST https://api.spicyapi.ai/v1/chat/completions를 호출하므로 같은 코드가 카탈로그의 모든 텍스트 모델에 적용됩니다. 프로토콜은 텍스트와 스트리밍에서 설명하며 여기서는 그 위에 일반적인 기능을 구현합니다.
시작 전
1. API 키를 환경 변수에 저장하여 코드에 붙여 넣지 않도록 하세요.
export SPICY_API_KEY="sk-spicy-paste-your-key-here"2. 최신 카탈로그에서 텍스트 모델을 선택하고 ID를 SPICY_MODEL에 저장하세요. 모든 예시가 이 값을 읽습니다. 현재 키로 호출 가능한 텍스트 모델을 아래 명령으로 조회합니다(jq 필요).
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'
export SPICY_MODEL="MODEL_ID_FROM_CATALOG" # replace with one line from the list above텍스트 ID 형식은 publisher/model/chat입니다. MODEL_ID_FROM_CATALOG는 자리표시자일 뿐 그대로 보내면 안 됩니다.
3. 모델이 받는 입력을 확인하세요. 모델마다 temperature 지원 여부와 reasoning_effort 허용값이 다르며 이미지 입력은 일부 모델만 지원합니다. temperature나 frequency_penalty 같은 미지원 샘플링 필드는 무시되며 응답의 X-Spicy-Ignored-Params 헤더에 표시됩니다. 일반적인 클라이언트 기본값도 처리합니다. 미지원 stop은 무시하며 지원하지 않는 n: 1은 답변 하나를 뜻합니다. 필요한 경우 인식된 추론 단계를 가장 가까운 지원 단계로 조정합니다. X-Spicy-Adjusted-Params 헤더는 조정된 설정을 보여 줍니다. tools나 response_format 같은 미지원 기능 필드는 여전히 400을 반환합니다. 다음 명령은 필드, reasoning_effort 값, 모델이 받는 메시지 내용 유형을 출력합니다.
curl -s "https://api.spicyapi.ai/api/v1/models/$SPICY_MODEL" \
-H "Authorization: Bearer $SPICY_API_KEY" \
| jq '.data.inputSchema.properties | {
fields: keys,
reasoning_effort: .reasoning_effort.enum,
content_parts: [.messages.items.properties.content.items.oneOf[]?.properties.type.const]
}'모든 텍스트 모델을 한눈에 보려면 GET /v1/models?modality=text로 supported_parameters, context_length, max_output_tokens를 읽으세요. 필드명만 나열하므로 허용값은 위 명령으로 확인합니다.
4. OpenAI 라이브러리를 설치하세요. Python은 pip install openai, Node.js 18 이상은 npm install openai입니다. Node.js 예시는 최상위 await를 사용하므로 .mjs로 저장하고 node file.mjs로 실행하세요.
추론에 필요한 여유를 두세요
많은 텍스트 모델은 답변 전 추론하며 그 토큰도 max_tokens에 포함됩니다. 예시는 max_tokens를 생략하여 모델 기본값을 사용합니다. 설정할 때는 수천 토큰 등 충분한 값을 두세요. 너무 작으면 추론만으로 소진되어 max_tokens를 올리거나 reasoning_effort를 낮추라는 오류로 실패할 수 있습니다. 이런 실패는 과금하지 않습니다.
다중 턴 대화
API는 호출 간 기억을 유지하지 않습니다. 이용자가 메시지 목록을 보관하고 매 턴 전체를 다시 보내야 합니다. system부터 시작하여 user, assistant를 번갈아 넣고 답변 뒤에 assistant 메시지로 추가하세요.
다시 보내는 메시지는 매번 입력으로 과금되므로 길어지면 오래된 턴을 줄이세요. system은 유지하고 user/assistant 쌍으로 제거하여 대화 기록이 user로 시작하도록 합니다.
import os
from openai import OpenAI
client = OpenAI(api_key=os.environ["SPICY_API_KEY"], base_url="https://api.spicyapi.ai/v1")
MODEL = os.environ["SPICY_MODEL"]
KEEP = 20 # messages kept after the system prompt; an even number keeps user/assistant pairs together
history = [{"role": "system", "content": "You are a patient cooking coach. Keep every answer under 80 words."}]
while True:
try:
user_text = input("you> ").strip()
except EOFError: # Ctrl-D
break
if user_text in ("", "quit", "exit"):
break
history.append({"role": "user", "content": user_text})
reply = client.chat.completions.create(model=MODEL, messages=history)
answer = reply.choices[0].message.content or ""
print(f"coach> {answer}\n")
history.append({"role": "assistant", "content": answer})
# The API remembers nothing between calls: resend the conversation every turn,
# and trim it so the prompt (and what you pay for it) stops growing.
history = history[:1] + history[1:][-KEEP:]모델은 앞의 모든 내용을 사용하여 마지막 메시지에 답합니다. 나중에 언급한 재료를 물으면 기억합니다. 추론 모델은 content 옆에 reasoning_content도 반환하지만 일반 대화에서는 다시 보낼 필요가 없습니다.
캐릭터 카드 롤플레잉
캐릭터는 잘 작성한 system 메시지입니다. API가 기억하지 않으므로 카드를 매번 보내며 이것이 50번째 턴에서도 일관성을 유지하는 방법입니다. 좋은 카드는 여섯 질문에 답합니다.
| 요소 | 작성 내용 | 예시 |
|---|---|---|
| 정체성 | 행동을 결정하는 사실 두세 가지 | 은퇴한 탐정이며 지금은 등대를 관리함 |
| 말투 | 문장 길이, 어휘, 습관, 이용자를 부르는 방식 | 차분하고 작은 관찰을 말하며 이용자를 ‘친구’라고 부름 |
| 세계 | 배경과 이야기의 핵심 사실 한두 가지 | 한밤중 침몰선 종이 울리는 1920년대 어촌 |
| 경계 | 할 일·하지 않을 일과 이야기 밖으로 나가는 방법 | 배경 유지, (OOC: ...)에는 짧게 캐릭터 밖 답변, 일반 관객에게 적합한 이야기 |
| 서술 | 시점·시제와 대사 표시 | 3인칭 과거형, 대사는 따옴표 |
| 형식 | 답변 길이와 끝맺음 | 최대 120단어, 번호 선택지 두세 개로 끝냄 |
두 습관이 몰입을 돕습니다. 첫 대사를 assistant 메시지로 기록하여 처음부터 캐릭터의 말이 보이게 하세요. 매 답변 끝에는 질문이나 선택지를 넣어 다음 행동을 안내하세요. 인터랙티브 픽션에서는 번호 선택지로 숫자 하나만 입력할 수 있습니다.
판타지 비행선 항해사입니다. 답변이 단어별로 스트리밍됩니다.
import os
from openai import OpenAI
client = OpenAI(api_key=os.environ["SPICY_API_KEY"], base_url="https://api.spicyapi.ai/v1")
MODEL = os.environ["SPICY_MODEL"]
CARD = {
"name": "Captain Ilsa Varn",
"identity": "Navigator of the airship Kestrel. Forty years old, calm under pressure, dry sense of humour, fiercely loyal to her crew.",
"voice": "Short, vivid sentences full of wind and weather. Calls the user 'deckhand'. No modern slang.",
"world": "Islands drift on warm updrafts above an endless sea of cloud. Storms here are alive and hungry, and maps go out of date within a week.",
"boundaries": (
"Stay in character and inside this world. If the user asks about the real world, react as Ilsa would: puzzled but polite. "
"If the user writes (OOC: ...), step out of the story for one short, plain answer, then return. "
"Keep the story suitable for a general audience."
),
"narration": "Describe the scene in the second person ('You feel the deck tilt...'). Put Ilsa's spoken lines in quotation marks.",
"format": "At most 120 words. End every reply with a question or a choice, so the user always knows how to continue.",
}
system_prompt = "\n".join(
[f"You are {CARD['name']}, a character in an interactive story."]
+ [f"{key.capitalize()}: {value}" for key, value in CARD.items() if key != "name"]
)
history = [
{"role": "system", "content": system_prompt},
{"role": "assistant", "content": "\"Welcome aboard, deckhand. Storm wall to the north, a lost island to the east. Which way do we steer?\""},
]
print(history[-1]["content"])
while True:
try:
user_text = input("\nyou> ").strip()
except EOFError: # Ctrl-D
break
if user_text in ("", "quit", "exit"):
break
history.append({"role": "user", "content": user_text})
answer = ""
stream = client.chat.completions.create(model=MODEL, messages=history, stream=True)
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
piece = chunk.choices[0].delta.content
answer += piece
print(piece, end="", flush=True)
print()
history.append({"role": "assistant", "content": answer})여섯 요소는 어느 장르에나 적용됩니다. SF 우주선 컴퓨터, 일상 루틴을 돕는 동반자나 텍스트 모험 서술자는 카드만 바꾸면 됩니다.
You are Juniper, a friendly running coach inside a fitness app.
Identity: a former trail runner, upbeat and practical, never judgemental.
Voice: warm and brief; uses the user's name when they give it; celebrates small wins.
World: the user's own week. Remember goals and aches they mention earlier in this conversation.
Boundaries: give general fitness encouragement only; for pain, injury or medical questions, suggest seeing a professional. Stay in character; answer (OOC: ...) plainly.
Narration: first person, present tense, talking directly to the user.
Format: at most 80 words; end with one concrete suggestion for today.긴 이야기에서는 카드를 짧고 안정적으로 유지하세요. 기록이 늘면 프롬프트를 무한히 늘리지 말고 오래된 턴을 두 번째 system 메시지의 짧은 ‘지금까지의 이야기’로 대체하세요.
자체 UI로 스트리밍
스트리밍은 작성 중인 답변을 보여 주어 채팅의 반응성을 높입니다. 다음 여섯 규칙이 모든 카탈로그 모델에 적용됩니다.
- 토큰 수가 필요하면 사용량을 요청하세요. OpenAI 규칙에 따라
stream_options.include_usage가 없으면usage가 없습니다."include_usage": true이면[DONE]직전에 최종usage와 빈choices의 추가 청크가 오며 나머지는usage: null입니다. choices[0]전에choices를 확인하세요. 마지막 사용량 청크 외에는 선택이 하나 이상 있으므로 사용량 요청 때만 필요하지만, 어느 경우든 루프를 안전하게 합니다.- 추론은 별도입니다. 추론은
delta.reasoning_content, 답변은delta.content로 옵니다. 전자는 흐리게·접어서 표시하거나 숨기세요. 실제 답은 후자입니다. finish_reason: "length"는 잘림입니다. 모델이max_tokens에 도달했습니다. 값을 올리거나reasoning_effort를 낮추세요.- 스트리밍 중 오류가 올 수 있습니다. 시작 후 실패하면
error객체 이벤트로 도착하고 OpenAI 라이브러리는 예외를 발생시킵니다. 실패 요청은 과금되지 않습니다. 모델이 이미 답을 마친 뒤 연결이 끊기면 오류 없이 정상 종료하고 일반 답변처럼 과금됩니다. 완료·오류·연결 해제 참조. - 일찍 연결을 닫아도 과금은 중지되지 않습니다. 모델은 계속 실행하며 보고된 사용량을 과금합니다. 대신
max_tokens를 낮추세요(스트림 조기 종료 비용).
여섯 규칙을 따르는 터미널 렌더러입니다. 추론은 흐리게, 답은 선명하게, 마지막에는 토큰 수를 표시합니다.
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": "Write a four-line poem about a cat who lives in a lighthouse."}],
stream=True,
stream_options={"include_usage": True},
)
finish_reason = None
usage = None
thinking_shown = False
for chunk in stream:
# With include_usage, only the last chunk before [DONE] carries usage; it has no choices.
if chunk.usage:
usage = chunk.usage
if not chunk.choices:
continue
choice = chunk.choices[0]
thinking = getattr(choice.delta, "reasoning_content", None) # reasoning models only
if thinking:
thinking_shown = True
print(f"\033[2m{thinking}\033[0m", end="", flush=True) # dimmed
if choice.delta.content:
if thinking_shown:
print("\n")
thinking_shown = False
print(choice.delta.content, end="", flush=True)
if choice.finish_reason:
finish_reason = choice.finish_reason
print()
if finish_reason == "length":
print("[stopped at max_tokens: raise it, or lower reasoning_effort]")
if usage:
print(f"[{usage.prompt_tokens} prompt tokens, {usage.completion_tokens} completion tokens]")도구 호출
도구는 모델이 프로그램에 주사위 굴리기, 주문 조회나 일정 읽기를 요청하고 결과로 답하게 합니다. 루프는 같습니다.
- JSON Schema로 설명한 함수 목록
tools와 메시지를 보냅니다. - 답에
tool_calls가 있으면 assistant 메시지를 받은 그대로 추가하고 각 호출을 직접 실행합니다. 호출마다 대응tool_call_id의tool메시지를 추가합니다. - 전체를 다시 보냅니다.
tool_calls가 없는 답변이 올 때까지 반복하며 그것이 최종 답입니다.
인수는 신뢰하지 말고 파싱·검증하세요. 잘못되면 프로그램을 중단하지 말고 오류를 도구 결과로 보내세요. 혼동한 모델이 무한 반복하지 않도록 라운드 수를 제한합니다. 현재 모든 텍스트 모델은 tools를 받습니다. 다만 특정 도구 강제 전에 tool_choice를 확인하세요. 일부는 auto, none만 받습니다.
import json
import os
import random
from openai import OpenAI
client = OpenAI(api_key=os.environ["SPICY_API_KEY"], base_url="https://api.spicyapi.ai/v1")
MODEL = os.environ["SPICY_MODEL"]
def roll_dice(count, sides):
rolls = [random.randint(1, sides) for _ in range(count)]
return {"rolls": rolls, "total": sum(rolls)}
LOCAL_TOOLS = {"roll_dice": roll_dice}
TOOLS = [{
"type": "function",
"function": {
"name": "roll_dice",
"description": "Roll dice for a tabletop game and return every roll and the total.",
"parameters": {
"type": "object",
"properties": {
"count": {"type": "integer", "minimum": 1, "maximum": 10, "description": "How many dice to roll"},
"sides": {"type": "integer", "enum": [4, 6, 8, 10, 12, 20], "description": "Sides on each die"},
},
"required": ["count", "sides"],
},
},
}]
messages = [
{"role": "system", "content": "You are the game master of a fantasy adventure. Whenever the rules call for a roll, use roll_dice; never invent a result. A roll of 12 or more on a d20 succeeds."},
{"role": "user", "content": "I try to pick the lock on the old tower door. Roll a d20 for me."},
]
for _ in range(5): # a hard stop, so a confused model cannot loop forever
reply = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS)
message = reply.choices[0].message
# Send the assistant turn back exactly as it came, tool calls included.
messages.append(message.model_dump(exclude_none=True))
if not message.tool_calls:
print(message.content)
break
for call in message.tool_calls:
tool = LOCAL_TOOLS.get(call.function.name)
try:
result = tool(**json.loads(call.function.arguments or "{}")) if tool else {"error": "unknown tool"}
except (TypeError, ValueError) as error: # the model sent arguments the tool cannot use
result = {"error": str(error)}
print(f"[tool] {call.function.name}({call.function.arguments}) -> {result}")
messages.append({"role": "tool", "tool_call_id": call.id, "content": json.dumps(result)})첫 답은 finish_reason: "tool_calls"와 content가 null로 끝나며 최종 답은 "stop"입니다. 추론 모델은 assistant 턴에 reasoning_content도 담을 수 있습니다. 예시처럼 메시지 그대로 추가하여 다시 보낼 때 유지하세요. 스트리밍 도구 호출은 조각으로 오므로 대화와 도구 호출에 따라 index로 합칩니다.
JSON 출력
프로그램이 설명문 대신 데이터를 필요로 하면 inputSchema에 명시된 모델의 response_format으로 JSON을 요청하고 필드를 프롬프트에 설명하세요. max_tokens로 잘린 답은 유효 JSON이 아니므로 항상 방어적으로 파싱합니다.
import json
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": "system", "content": "Reply with a JSON object only, with the keys title (string), genre (string) and hook (one sentence)."},
{"role": "user", "content": "Invent a cosy mystery novel set in a mountain bakery."},
],
response_format={"type": "json_object"},
)
try:
book = json.loads(reply.choices[0].message.content)
print(book["title"], "-", book["hook"])
except (json.JSONDecodeError, KeyError, TypeError):
print("Not the JSON we asked for:", reply.choices[0].message.content)response_format을 명시하지 않은 모델에서는 필드를 생략하고 시스템 프롬프트만 사용합니다. 그 경우 위 try 블록이 더욱 중요합니다.
오류 대응
OpenAI 형식 {"error": {"message", "type", "param", "code"}}으로 오류를 반환하여 기존 라이브러리 예외가 발생합니다. HTTP 상태와 error.code로 분기하고 수정할 내용을 담은 error.message를 사람에게 보여 주세요. 필드 오류의 param은 temperature, messages.1.content.0 같은 점 경로입니다.
| 상태 | error.code | 상황 | 대응 |
|---|---|---|---|
| 400 | unsupported_parameter | 미지원 필드(예: logprobs: is not a supported field) 또는 메시지 부품(텍스트 전용 모델의 image_url 등). temperature 같은 샘플링 필드는 거절 대신 무시하여 X-Spicy-Ignored-Params에 표시 | 제거하고 시작 전에 따라 필드 확인 |
| 400 | invalid_request | 누락·미허용 값(예: reasoning_effort: must be one of: "low", "high", "max") 또는 채팅 모델 아님(param은 model) | 메시지의 값을 수정하거나 /chat ID 모델 선택 |
| 400 | content_rejected | 모델 콘텐츠 정책이 거절 | 프롬프트 수정. 같은 입력은 다시 거절됨 |
| 401 | invalid_api_key | 키 누락·오타·해지 | SPICY_API_KEY 확인. 접두사는 sk-spicy- |
| 402 | insufficient_balance, spend_cap_exceeded | 잔액·지출 한도가 선점액을 충당하지 못함 | 충전·한도 상향·max_tokens 축소(아래 참조) |
| 404 | model_not_found | 해당 ID 없음(param은 model). 메시지에 ID 형식과 가능한 경우 가장 가까운 호출 가능 모델 표시 | 최신 카탈로그의 정확한 ID 복사 |
| 404 | not_found | 모델은 있지만 현재 호출 불가 | 최신 카탈로그의 다른 모델 선택 |
| 409 | conflict | 다른 본문에 Idempotency-Key 재사용 | 새 메시지마다 새 키. 텍스트 멱등성 키 참조 |
| 429 | rate_limit_exceeded | 요청 과다 | 대기 후 재시도. Retry-After가 있으면 초 단위 대기 시간 |
| 503 | upstream_failed, model_unavailable | 모델 실패·일시 사용 불가. 과금 없음 | 나중에 새 Idempotency-Key로 재시도 |
잔액이 충분해 보여도 402가 발생하는 이유: 실행 전 메시지와 모델 가격으로 계산한 전체 max_tokens 출력(생략 시 기본 max_tokens)의 금액을 선점합니다. 실제 사용 토큰만 결제하고 종료 시 나머지는 해제하지만 시작 전에 전체 선점액이 사용 가능 잔액 안에 들어야 합니다. 현실적인 max_tokens를 설정하면 줄어듭니다. 과금 참조.
실제 호출 비용 확인
승인된 요청은 모두 작업으로 실행되며 스트리밍 여부와 관계없이 응답 X-Spicy-Task-Id가 식별합니다. 헤더로 작업을 조회하세요. settled가 true이면 cost가 확정입니다. 검증 오류처럼 승인 전에 거절되면 작업도 헤더도 없습니다.
import os
from openai import OpenAI
client = OpenAI(api_key=os.environ["SPICY_API_KEY"], base_url="https://api.spicyapi.ai/v1")
raw = client.chat.completions.with_raw_response.create(
model=os.environ["SPICY_MODEL"],
messages=[{"role": "user", "content": "Say hello in one sentence."}],
)
reply = raw.parse() # the usual ChatCompletion object
print(reply.choices[0].message.content)
print("task:", raw.headers.get("x-spicy-task-id"))스트림도 첫 청크 전에 응답 헤더가 있습니다. Python의 with_raw_response, Node.js의 .withResponse()는 stream: true에서도 동일합니다. GET /api/v1/jobs의 항목은 생성 호출의 requestId, 즉 X-Request-Id 응답 헤더도 포함합니다.
이어서 읽기
텍스트와 스트리밍 · OpenAI·OpenRouter에서 이전 · 모델 카탈로그와 스키마 · 과금 · 오류

