テキストとストリーミング
OpenAI、Anthropic、Google Gemini の公式の形式でテキストモデルを呼び出し、会話、ツール、ストリーム、費用を正しく扱う方法を説明します。
テキストモデルは、同じ HTTP リクエストの中で返答します。ポーリングするタスクはありません。返答全体ができあがるのを待つこともできますし、ストリーミングすることもできます。ストリーミングとは、チャットアプリが返信を少しずつ打ち出していくように、モデルがまだ書いている途中から、テキストを小さなかたまりで受け取ることです。ストリーミングされたかたまりは Server-Sent Events(SSE) として届きます。data: で始まるプレーンテキストの行が、返答が完成するまで開いたままの接続を通じて、1 行ずつ次々に送られてきます。
使えるテキストモデルを選ぶ
このページのシェルの例は 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 が 1 行に 1 つずつ表示されるはずです。そのうち 1 つをコピーして SPICY_MODEL に入れておいてください。以下の例はすべて、この変数を読み取ります。
export SPICY_MODEL="MODEL_ID_FROM_CATALOG" # 上の一覧のどれか 1 行に置き換えるMODEL_ID_FROM_CATALOG は仮の値にすぎないので、そのまま送らないでください。ツール、画像、推論の設定、構造化出力を使う前に、GET /api/v1/models/{model} でそのモデルの inputSchema を読んでください。これらの機能は、スキーマに載っている場合にだけ使えます。プロトコルに互換性があるからといって、どのモデルでもすべての機能が使えるわけではありません。
同じモデル ID は、モデルの開発元に関係なく、以下のどのプロトコルでも使えます。ほかの開発元のモデルを Anthropic 形式で呼び出すことも、Google 以外のモデルを Gemini 形式で呼び出すこともできます。
対応しているプロトコル
これらのエンドポイントはすべて、同じ API キーとモデルカタログを共有し、レート制限、パラメーターの検証、課金も共通です。違うのはリクエストと応答の形式だけです。既存のコードや SDK がすでに使っている形式を選んでください。
| プロトコル | エンドポイント | API キーのヘッダー |
|---|---|---|
| OpenAI Chat Completions | POST /v1/chat/completions | Authorization: Bearer |
| OpenAI Responses | POST /v1/responses | Authorization: Bearer |
| Anthropic Messages | POST /v1/messages | x-api-key または Authorization: Bearer |
| Google Gemini | POST /v1beta/models/{model}:generateContent | x-goog-api-key または Authorization: Bearer |
| Google Gemini(ストリーミング) | POST /v1beta/models/{model}:streamGenerateContent | x-goog-api-key または Authorization: Bearer |
| 価格を確定させるネイティブストリーム | POST /api/v1/jobs/stream | Authorization: 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 を含める必要があります。以下の 3 つの Python の例はどれも、環境変数から SPICY_API_KEY と、ライブカタログから選んだ SPICY_MODEL を読み取ります。
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)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)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 も受け付けないので、この経路では 1 回の呼び出しの金額に上限がありません。エージェントのループが暴走すると、そのままお金を使い続けます。
上限を設ける方法は 2 つあり、たいていの組み込みでは両方を使うのがよいでしょう。
- 1 回の呼び出しごと:ネイティブの
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 では、キーをクライアントオブジェクトに設定せず、呼び出しごとに渡してください。クライアント全体で 1 つのキーを使うと、最初のメッセージより後のメッセージはすべて、24 時間にわたって 409 で拒否されます。
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 はクライアントが読み取る環境変数の名前を指定するもので、キーそのものがファイルに書かれることはありません。
[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 Completions | Responses | Messages | Gemini |
|---|---|---|---|---|
messages | messages | instructions、input | system、messages | systemInstruction、contents |
max_tokens | max_tokens、max_completion_tokens | max_output_tokens | max_tokens | generationConfig.maxOutputTokens |
temperature | temperature | temperature | temperature | generationConfig.temperature |
top_p | top_p | top_p | top_p | generationConfig.topP |
tools | tools | tools(function 型) | tools(カスタムツール) | tools[].functionDeclarations |
tool_choice | tool_choice | tool_choice | tool_choice | toolConfig.functionCallingConfig |
response_format | response_format | text.format | — | generationConfig.responseMimeType と、responseSchema または responseJsonSchema |
reasoning_effort | reasoning_effort | reasoning.effort | — | generationConfig.thinkingConfig |
表にないフィールドも同じように正規のパラメーターに変換され、モデルによって検証されます。inputSchema にないものは 400 になります。たとえば、seed を inputSchema に載せているのは一部のモデルだけで、それ以外のモデルでは、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 分で待つのをやめます。次のような行が 1 つずつ届き、最後に 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 を付けないと、ストリーミングのリクエストは、少しずつ書き出される 1 つの JSON 配列を返します。公式 SDK は alt=sse を自動で付けます。
価格を確定させるネイティブストリーム
POST /api/v1/jobs/stream は、見積もりを受け付ける唯一のテキストエンドポイントです。そのため、価格が変わっていれば実行を断ることができます。リクエスト本文の形は createTask と同じで、model と、モデルの inputSchema に沿ったフィールドを持つ input を送ります(テキストモデルの場合は、messages と、上の表にあるその他の正規のパラメーターです)。
# 1. リクエストを 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 と違い、このエンドポイントは、すべてのモデルで同じイベント形式になることを約束していません。選んだモデルでパーサーをテストしてください。1 つの安定した形式が必要なら、/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 パーツを 1 つ、ファイルごとにパーツを 1 つ使います。
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 を取得する方法はメディアで説明しています。アップロードしたファイルは 1 日間保持されます。メディアがモデルに届く経路の違いから、次のルールがあります。
- 画像には、画像を表示するページではなく画像ファイルそのものを返す公開 HTTPS URL、アップロードしたファイル、またはインラインの上限の範囲内の
data:URI を使えます。JPEG と PNG は、画像を受け付けるすべてのモデルで使えます。 - 動画と音声はアップロードして送るのが最適です。 アップロードしたファイルをこちらで読み取り、リクエストに含めてモデルに渡すため、1 回のリクエストで送れるアップロード済みの動画と音声は合計 14 MB までです。動画は MP4 または WebM、音声は MP3 または WAV です。合計がそれを超えると
400が返り、課金はされません。公開された動画の URL はリンクのまま渡されるため、モデルのプロバイダーがダウンロードできる場合にのみ機能します。 - インライン音声(
input_audio)は OpenAI の形式に従い、リクエストボディの 2 MiB に収まる必要があるため、短いクリップ向きです。それより長い音声はアップロードしてaudio_urlで送ってください。
1 つのメッセージに複数のファイルを入れることもでき、その場合はファイルごとに別のパーツにします。過去のメッセージにメディアが含まれていても構いません。
ほかのプロトコルでも、画像はそれぞれの形式で送れ、どのプロトコルで送ってもモデルには同じ内容が届きます。動画と音声のパーツは、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 ソース |
| Gemini | inlineData(base64)または fileData.fileUri(HTTPS URL) |
費用について。 メディアは、そのモデルの通常の入力レートで入力トークンとして数えられます。ファイルごとの別料金はありません。画像は、モデルと画像のサイズによって、1 枚あたり数百から数千トークンが加わるのが一般的です。動画と音声は長さに応じて増えます。正確な数はレスポンスの usage ブロックに入っています。
事前に確保される金額。 リクエストを実行する前に上限額を確保し、精算時に差額を解放します。メディアが、アップロードした画像またはインラインで送信した画像だけのリクエストでは、上限額は 1 枚ごとに一定の許容分を加えたものになります。動画や音声を含むリクエスト、または公開 URL で指定した画像を含むリクエストでは、サイズを事前に知ることができないため、上限額はモデルの入力上限全体を基準に算出されます。そのため、確保される金額が最終的な請求額をかなり上回ることがあります。確保額を小さく抑えるには画像をアップロードしてください。確保額に足りる残高を保っておくか、POST /api/v1/jobs/quote で事前に見積もって確認することもできます。
Playground では。 メディアを受け付けるモデルを選ぶと、メッセージ欄に添付ボタンが表示されます。スクリーンショットを貼り付けたり、ファイルを欄にドロップしたりすることもできます。1 つのメッセージに添付できるのは最大 4 件で、そのうち動画と音声は合計 14 MB までです。テキストしか読めないモデルでは、ボタンは表示されません。
会話とツール呼び出し
Chat Completions
- ストリーミングしない場合:
choices[0].messageを読みます。 - ストリーミングする場合:
choices[].deltaを、ネットワークのパケット単位ではなく、SSE のイベント 1 つずつ処理します。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の 2 つのロールが交互に並びます。- モデルがツールを呼び出したいときは、
functionCallパートを返します。ツールを実行したら、結果をfunctionResponseパートで包み、呼び出しと同じnameを付けて、次のuserコンテンツで送り返します。
完了、エラー、切断
| プロトコル | 正常な完了 | 使用量 |
|---|---|---|
| Chat Completions | data: [DONE] | 最後のチャンクの usage |
| Responses | response.completed イベント | そのイベントの response.usage |
| Messages | message_delta のあとの message_stop | message_delta の usage |
| Gemini | finishReason が付いた最後のイベント。[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 ステータスコードは 4 つのプロトコルで共通です。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 秒ごとに改行が 1 つ、最後に 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 でストリーミングを有効にする例です。
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()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 --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.jsonNode.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 は、示しているフィールドに対応しているライブのモデルに置き換えてください。仮の値そのものを送ってはいけません。
{
"model": "MODEL_ID_FROM_CATALOG",
"input": "Explain a rainbow in one sentence.",
"stream": false
}{
"model": "MODEL_ID_FROM_CATALOG",
"max_tokens": 256,
"messages": [{"role": "user", "content": "Explain a rainbow in one sentence."}],
"stream": false
}続けて読む
見積もりとプロトコル互換 · 冪等性 · 課金 · 本番環境への組み込み · トラブルシューティングと復旧

