Text and streaming
Call text models in the official OpenAI, Anthropic or Google Gemini format, and handle conversations, tools, streams and costs correctly.
Select an available text model
Read the authenticated /api/v1/models?modality=text&includeSchema=1 catalog and require enabled and available. Tools, multimodal messages, reasoning fields and structured output must also be supported by that model’s inputSchema. Protocol compatibility does not give every model every capability.
The same model ID works with any of the protocols below, regardless of who built the model: you can call another vendor's model in Anthropic format, or a non-Google model in Gemini format.
Supported protocols
All four text protocols share the same API key and model catalog, along with the same rate limits, parameter validation and billing; only the request and response formats differ. Use whichever one your existing code or SDK already speaks.
| Protocol | Endpoint | API key header |
|---|---|---|
| OpenAI Chat Completions | POST /v1/chat/completions | Authorization: Bearer |
| OpenAI Responses | POST /v1/responses | Authorization: Bearer |
| Anthropic Messages | POST /v1/messages | x-api-key or Authorization: Bearer |
| Google Gemini | POST /v1beta/models/{model}:generateContent | x-goog-api-key or Authorization: Bearer |
| Google Gemini (streaming) | POST /v1beta/models/{model}:streamGenerateContent | x-goog-api-key or Authorization: Bearer |
| Native stream | POST /api/v1/jobs/stream | Authorization: Bearer |
On /v1 and /v1beta, responses and errors follow their own protocols and carry no native {code,msg,data,request_id} envelope, so do not parse them with a client that only reads body.data. The Gemini format puts the model ID in the URL path; slashes in the ID stay as they are and need no encoding.
Send the key in a request header only. x-api-key and x-goog-api-key work only on /v1 and /v1beta; the native /api/v1 accepts only Authorization: Bearer. A ?key= query parameter is never accepted: once a key lands in a URL, it lingers in browser history, proxy servers and access logs. The official SDKs all send the key in a header, so they are unaffected.
Use an official SDK
Change only the base URL and the key; everything else works the way each SDK normally does.
| SDK | Base URL |
|---|---|
OpenAI (openai) | https://api.spicyapi.ai/v1 |
Anthropic (anthropic) | https://api.spicyapi.ai |
Google GenAI (google-genai) | https://api.spicyapi.ai |
The Anthropic and Google SDKs append /v1 or /v1beta themselves, so their base URL has no version path; the OpenAI SDK's base URL must include /v1. All three Python examples below read SPICY_API_KEY from the environment, along with a SPICY_MODEL chosen from the live catalog.
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)To keep network retries from charging twice, generate one Idempotency-Key per user action, persist it, and send it through the SDK's custom-header option (extra_headers in the OpenAI and Anthropic SDKs).
Parameter mapping across protocols
Protocol-level fields are first converted to the platform's canonical parameter names, then validated against the selected model's inputSchema. A parameter the model does not support returns 400; it is never silently dropped while the request is billed as usual. The table below shows the mapping only, and "—" means the protocol has no equivalent field. Whether a given model accepts a parameter, and which values it allows, is defined by that model's inputSchema.
| Canonical parameter | 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 type) | tools (custom tools) | tools[].functionDeclarations |
tool_choice | tool_choice | tool_choice | tool_choice | toolConfig.functionCallingConfig |
response_format | response_format | text.format | — | generationConfig.responseMimeType plus responseSchema or responseJsonSchema |
reasoning_effort | reasoning_effort | reasoning.effort | — | generationConfig.thinkingConfig |
Fields not listed in the table are likewise converted to canonical parameters and validated by the model; anything missing from its inputSchema returns 400. For example, if a model does not accept stop, then Chat's stop, Messages' stop_sequences and Gemini's generationConfig.stopSequences all return 400. No text model currently lists seed in its inputSchema, so Chat's seed and Gemini's generationConfig.seed return 400 as well.
A few exceptions:
- Fields that only mean something to the protocol's own platform are ignored and do not affect generation:
user,metadataandstorein Chat;user,metadata,storeandincludein Responses;metadatain Messages. - Responses does not support
previous_response_id; send the full history ininput. - Gemini's
safetySettingsis accepted but has no effect.cachedContentand server-side tools such asgoogleSearchandcodeExecutionare not supported and return 400. Pass images asinlineData(base64) orfileData.fileUri(an https URL); whether a model accepts images is defined by its inputSchema.
Send a Chat stream
On your server, set SPICY_API_KEY, a SPICY_MODEL obtained from the live catalog, and a persisted SPICY_IDEMPOTENCY_KEY for this business action. The example needs jq. Replace the sample prompt as needed, and validate model parameters against the current schema before sending.
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: $SPICY_IDEMPOTENCY_KEY" \
--data-binary @chat-request.jsonSend a Gemini request
Use the same environment variables as in the previous section. The model ID goes in the URL, so the request body carries no model field. Wrap the URL in double quotes and write the variable as ${SPICY_MODEL}, so the shell does not read the colon that follows it as a variable modifier.
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: $SPICY_IDEMPOTENCY_KEY" \
--data-binary @gemini-request.jsonIf you do not need streaming, replace :streamGenerateContent?alt=sse with :generateContent and read the answer from candidates[0].content.parts[].text; usage is in usageMetadata. Without alt=sse, a streaming request returns a single JSON array written out incrementally; the official SDKs add alt=sse for you.
Conversations and tool calls
For non-streaming Chat, read choices[0].message; for a stream, process choices[].delta by SSE event, not by TCP chunk. Assemble tool_calls by index before validating and executing complete arguments. Preserve the assistant message and return results with the matching tool_call_id. Responses requires the full history in input and rejects previous_response_id; return function results as function_call_output with the matching call_id. Messages uses text, tool_use and tool_result content blocks rather than the Chat message shape.
Gemini contents alternate between two roles, user and model. When the model wants to call a tool, it returns a functionCall part; after running the tool, wrap the result in a functionResponse part and send it back in the next user content, using the same name as the call.
Completion, errors and disconnects
| Protocol | Normal completion | Usage |
|---|---|---|
| Chat Completions | data: [DONE] | usage in the last chunk |
| Responses | response.completed event | response.usage in that event |
| Messages | message_stop after message_delta | usage in message_delta |
| Gemini | Last event with finishReason; there is no [DONE] | usageMetadata |
Check the HTTP status and Content-Type before reading a stream; errors come back as each protocol's own JSON:
| Protocol | Error body |
|---|---|
| Chat Completions, Responses | {"error": {"message", "type", "param", "code"}} |
| Messages | {"type": "error", "error": {"type", "message"}} |
| Gemini | {"error": {"code", "message", "status"}} |
HTTP status codes are the same across all four protocols. Gemini's status uses Google's status names: 400 is INVALID_ARGUMENT, 401 is UNAUTHENTICATED, 402 is FAILED_PRECONDITION, 403 is PERMISSION_DENIED, 404 is NOT_FOUND, 429 is RESOURCE_EXHAUSTED and 503 is UNAVAILABLE. Branch your code on the status code and error type, never on the message text.
If something fails after streaming begins, Chat and Gemini send an event carrying an error object, Responses sends an error event and Messages sends event: error. Your client must handle these error events, as well as timeouts and premature EOF. Disable response buffering in your proxy. Closing a browser or firing an AbortSignal only stops reception; it does not cancel the task or promise a refund. A client disconnect with trustworthy usage is settled against work already performed; absent usage does not make a closed connection a free completion.
Quotes and billing
For explicit cost confirmation, use native jobs/quote → jobs/stream with the same model/input plus quoteId and expectedCost. /v1 and /v1beta do not accept those native quote credentials; they apply the price at acceptance. Use task records and billing history for final charges, not an end-of-text marker. reasoning_tokens is already included in completion_tokens: do not add it twice.
Other request shapes
These examples describe protocol envelopes only. Replace MODEL_ID_FROM_CATALOG with a live model that supports the fields shown; never send the placeholder itself.
{
"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
}Continue reading
Quotes and protocol compatibility · Billing · Production integration · Troubleshooting and recovery

