spicyapiドキュメント
テキストとチャット
本文

チャットの実装例

複数ターンの会話、キャラクターロールプレイ、自分の画面へのストリーミング、ツール呼び出しを、Python・Node.js・curl の実行可能なプログラムで紹介します。

このページの各例は、プログラム全体です。環境変数を 2 つ設定し、コピーして実行できます。すべて公式の 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 は回答 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 から始まるよう、user/assistant の組で削除してください。

chat.py
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 ターン目も同じキャラクターを維持します。有効なカードは次の 6 点を示します。

要素書く内容例
人物設定行動を決める 2~3 の事実引退後に灯台を管理している元刑事
話し方文の長さ、語彙、癖、ユーザーの呼び方落ち着いた話し方。細部への気づきを口にし、ユーザーを「友よ」と呼ぶ
世界舞台と物語の中心になる 1~2 の事実沈んだ船の鐘が真夜中に鳴る、1920 年代の漁村
境界すること・しないこと、物語から出る方法舞台設定を守り、(OOC: ...) には短く役を離れて答える。物語は一般向けに保つ
語り人称、時制、会話の表記三人称・過去形。台詞は引用符で囲む
形式長さと各回答の終わり方最大 120 語。末尾に番号付きの選択肢を 2~3 個

2 つの工夫でロールプレイを進めやすくできます。最初の台詞を assistant として履歴に入れ、最初からキャラクターとして話し始めます。また、各回答を質問や選択肢で終え、次の行動を分かりやすくします。インタラクティブな物語では、番号付きの選択肢ならユーザーは数字 1 つで答えられます。

ファンタジー世界の飛行船の航海士です。回答は言葉が生成されるごとに届きます。

roleplay.py
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})

同じ 6 要素はどのジャンルにも使えます。SF 宇宙船のコンピューター、日々の習慣を支えるコンパニオン、テキストアドベンチャーの語り手も、カードを変えるだけです。

Another card: a daily companion
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.

長い物語ではカードを短く安定させ、履歴が増えたら古いターンを 2 つ目の system メッセージ内の短い「これまでの物語」に置き換えます。プロンプトを無制限に増やさないようにします。

自分の画面にストリーミングする

ストリーミングは書かれている途中の回答を表示し、チャット画面の反応を早く感じさせます。カタログのすべてのモデルに、次の 6 規則を使います。

  1. トークン数が必要なら使用量を指定します。 OpenAI の規則に従い、stream_options.include_usage がなければ usage を含むチャンクはありません。"include_usage": true を指定すると、[DONE] の直前に最終 usage と空の choices を持つチャンクが 1 つ追加され、ほかは usage: null です。
  2. choices[0] を読む前に choices を確認します。 最終使用量チャンク以外は選択肢が 1 つ以上あります。使用量を指定した時だけ必要な判定ですが、負担は小さく、どちらでもループを安全にできます。
  3. 推論は別です。 推論モデルは推論を delta.reasoning_content、回答を delta.content に送ります。前者は薄い表示、折りたたみ、非表示のいずれかにし、回答として扱うのは後者だけです。
  4. finish_reason: "length" は途中打ち切りです。 max_tokens に達しました。増やすか、reasoning_effort を下げてください。
  5. 途中でもエラーが届きます。 開始後の失敗は error オブジェクトを持つイベントとして届き、OpenAI ライブラリは例外を発生させます。この失敗リクエストは課金しません。接続が切れる前にモデルが回答を完了していた場合はエラーがなく、ストリームは通常終了し、ほかの回答と同様に課金します。詳細は 完了、エラー、切断を参照してください。
  6. 途中で止めても請求は止まりません。 接続を閉じてもモデルは止まらず、報告された使用分を課金します。代わりに max_tokens を下げてください(ストリームを途中で止めた場合の料金)。

6 規則に従うターミナル表示の例です。推論は薄く、回答は通常の色で表示し、最後にトークン数を示します。

stream.py
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]")

ツール呼び出し

ツールでは、モデルがサイコロ、注文照会、カレンダー取得などをプログラムへ依頼し、その結果を使って回答します。ループは常に同じです。

  1. JSON Schema で説明した関数一覧の tools をメッセージと一緒に送ります。
  2. 回答に tool_calls があれば、assistant メッセージを返されたまま追加します。自分で各処理を実行し、一致する tool_call_id を持つ tool メッセージを呼び出しごとに追加します。
  3. 全体を再送します。tool_calls のない回答が来るまで繰り返し、その回答を最終回答とします。

引数は信頼できない入力として解析・検証してください。クラッシュさせる代わりに、エラーをツール結果として返します。モデルが無限ループしないよう往復回数にも上限を設けます。現在、カタログの全テキストモデルは tools を受け付けますが、特定ツールを強制する前に tool_choice の値を確認してください。auto と none しか受け付けないモデルもあります。

tools.py
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 ではありません。

json_output.py
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"}} で返り、OpenAI ライブラリは通常の例外を発生させます。HTTP ステータスと error.code で分岐し、変更点を示す error.message は人に表示します。特定フィールドのエラーでは param が temperature や messages.1.content.0 のようなドット区切りのパスです。

ステータスerror.code原因対応
400unsupported_parameter項目(例:logprobs: is not a supported field)やメッセージ部分(テキスト専用モデルの image_url など)を受け付けない。temperature などのサンプリング項目は拒否せず、無視して X-Spicy-Ignored-Params に示す削除する。始める前にの方法でモデル項目を確認
400invalid_request必須項目の不足や不正な値。例:reasoning_effort: must be one of: "low", "high", "max"。またはチャットモデルではない(param は model)メッセージの値を変えるか、末尾が /chat のモデルを選ぶ
400content_rejectedモデルのコンテンツポリシーが拒否表現を変更。同じ入力の再送は再び拒否される
401invalid_api_keyキーが未指定、誤記、撤回済みSPICY_API_KEY を確認。キーは sk-spicy- で始まる
402insufficient_balance, spend_cap_exceeded残高または支出上限が今回の仮押さえ額に足りない入金、上限引き上げ、または max_tokens を下げる(後述)
404model_not_foundこの ID のモデルがない(param は model)。メッセージが ID 形式と、あれば呼べる最も近いチャットモデルを示すライブカタログから正確な ID をコピー
404not_foundモデルはあるが現在呼び出せないライブカタログから別モデルを選ぶ
409conflictIdempotency-Key を異なる本文で再使用した新しいメッセージには新しいキーを送る。テキストの冪等性キーを参照
429rate_limit_exceededリクエスト過多待って再試行。Retry-After があれば秒数を示す
503upstream_failed, model_unavailableモデルが失敗または一時的に利用できない。料金はかからない後で新しい Idempotency-Key で再試行

残高が十分に見えるのに 402 になる理由: テキストリクエストの開始前に、メッセージと max_tokens 全量の出力をモデルの価格で仮押さえします。省略時はモデルのデフォルト max_tokens を使います。支払うのは実際のトークン数だけで、終了時に未使用分を戻しますが、開始前には仮押さえ全額が利用可能残高に収まる必要があります。現実的な max_tokens にすれば仮押さえを減らせます。課金を参照してください。

呼び出し費用を調べる

受理されたすべてのリクエストはタスクとして実行し、ストリーミングかどうかを問わず X-Spicy-Task-Id ヘッダーに ID を返します。ヘッダーを読んでタスクを照会し、settled が true になったら cost が確定です。検証エラーなど受理前の拒否ではタスクを作らず、このヘッダーもありません。

task_id.py
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 からの移行 · モデルカタログとスキーマ · 課金 · エラー

このページの内容