Migrate from OpenAI or OpenRouter
Point existing OpenAI-library code at SpicyAPI. What changes (base URL, key, model ID), which request fields behave differently, and what to remove when you come from OpenRouter.
If your code already calls a chat model through the official OpenAI libraries, or through any tool with an "OpenAI-compatible" setting, moving it to SpicyAPI is three changes: the base URL, the API key and the model ID. This page lists those changes, then everything that behaves differently, so a migration does not end in a surprise 400. For complete example programs, see Chat recipes.
The three changes
| Setting | Value on SpicyAPI |
|---|---|
| Base URL | https://api.spicyapi.ai/v1 |
| API key | A SpicyAPI key (starts with sk-spicy-), sent as Authorization: Bearer |
| Model | An exact text model ID from the live catalog, for example in SPICY_MODEL (see Model IDs) |
import os
from openai import OpenAI
# Before: client = OpenAI() (reads OPENAI_API_KEY, calls api.openai.com)
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": "Say hello in one sentence."}],
)
print(reply.choices[0].message.content)The OpenAI libraries also read OPENAI_BASE_URL and OPENAI_API_KEY from the environment. Setting those two lets existing code run unchanged, but it also redirects every other OpenAI call in the same process, so prefer passing the values explicitly as above.
Model IDs
Text model IDs have the form publisher/model/chat. Copy them from the live catalog rather than translating a name you know from elsewhere:
GET /api/v1/models?modality=textlists only text models, with their prices, and adds each model'sinputSchemawithincludeSchema=1. See Model catalog and schemas.GET /v1/modelsis the OpenAI-format list that tools read. Without a filter it includes image, video and audio models, which cannot be called on/v1/chat/completions; add?modality=textfor text models only. Besides the four OpenAI fields, each item hasmodalityandtask, and chat models also havecontext_length,max_output_tokensandsupported_parameters(the request fields that model accepts). The OpenAI libraries ignore the extra fields.
A model ID that does not exist returns 404 with code: "model_not_found" and param: "model"; the message shows the ID shape and, when there is one, the closest callable chat model. Calling an image or video model on a chat endpoint returns 400 saying it is not a chat model. IDs from other services often look similar but are not interchangeable: publisher names are spelled differently, suffixes such as :free do not exist here, and text models end in /chat.
Request fields
Each model publishes the fields it accepts in its inputSchema (and, by name, in supported_parameters on /v1/models).
- Sampling fields the model does not list are ignored.
temperature,top_p,top_k,min_p,top_a,frequency_penalty,presence_penaltyandrepetition_penaltyonly change how random or repetitive a reply is, and many clients send them by default, so a model that does not take them simply generates without them. The same goes forn: 1, an emptystop,stopon a model without stop sequences, andreasoning_efforton a model without reasoning levels. The response tells you what was ignored in theX-Spicy-Ignored-Paramsheader, for examplefrequency_penalty, presence_penalty. - Values outside what the model takes are changed to the nearest accepted value. A sampling value outside the model's range is clamped to the bound, a
stoplist longer than the model allows keeps its first entries, and areasoning_effortlevel the model does not offer becomes the nearest one it does (the lower one on a tie). The response lists each change asname=valuein theX-Spicy-Adjusted-Paramsheader, for examplereasoning_effort=low, temperature=1. - Any other field the model does not list is rejected with
400,code: "unsupported_parameter",paramset to the field, and a message that names it, for examplelogprobs: is not a supported field. These fields change what you get back, so you never pay for a reply generated without one you thought you had sent.
| Field | On SpicyAPI |
|---|---|
messages | Same shape. Roles system, developer, user, assistant and tool. Text content works everywhere; image_url parts only on models whose schema lists them |
max_tokens, max_completion_tokens | Both accepted, same meaning. Reasoning tokens count toward it. Each model has its own default and ceiling |
stream, stream_options | Supported, with OpenAI's usage rules: a stream carries usage only when include_usage is true (see Responses and streams) |
tools, tool_choice | Supported on every text model. Some models accept only auto and none for tool_choice, so forcing a named tool returns 400 there |
parallel_tool_calls | Only on models that list it |
reasoning_effort | On most models, but the allowed values differ by model: some add none, minimal, xhigh or max, and some have no medium. A value the model does not offer is changed to the nearest one it does, so read the model's enum if the exact level matters |
temperature, top_p, top_k, frequency_penalty, presence_penalty | Used on models that list them, clamped to the model's range, ignored on the others (named in X-Spicy-Ignored-Params). Several models take none of the sampling fields |
seed | Only on models that list it |
stop | Used on models that list it, trimmed to the model's maximum count; ignored on models without stop sequences and when empty |
response_format | json_object on models that list the field; not available on the others |
user, metadata, store | Accepted and ignored |
n | 1 is ignored; any other value is rejected with 400 on models that do not list it |
logprobs, top_logprobs, logit_bias, and any other field the model does not list | Rejected with 400 |
To see a model's fields and their allowed values before you send anything, run the command in Chat recipes: before you start.
Responses and streams
A non-streamed reply is a normal chat.completion object, so reply.choices[0].message.content and reply.usage work as before. The differences are small:
reasoning_content. Models that think before answering return that text inmessage.reasoning_content(ordelta.reasoning_contentwhen streaming), next tocontent. It is already counted inusage.completion_tokens.- The ID is the request ID.
idhas the formchatcmpl-req_..., and thex-request-idresponse header carries the samereq_...value. Quote it when you contact support. A second header,X-Spicy-Task-Id, names the task that holds the charge (see Cost and usage). - No
system_fingerprintand nologprobs.
Streams use the same chat.completion.chunk events and end with data: [DONE], and follow OpenAI's rules for usage:
- Usage only on request. Without
stream_options.include_usage, no chunk carriesusageand every chunk has a non-emptychoiceslist. With"include_usage": true, one extra chunk with an emptychoiceslist and the finalusagearrives just before[DONE], and every other chunk hasusage: null. Code written against OpenAI's streaming examples works unchanged; checkingchoicesbefore readingchoices[0]keeps it safe on that last chunk. - One
finish_reasonper choice. Each choice ends with exactly one chunk that carries itsfinish_reason, whichever model you call. - Keep-alive lines. While a model is still thinking, the stream carries
: keep-alivecomment lines so the connection stays open. The OpenAI libraries skip them; a hand-written parser must too.
For long replies or high reasoning_effort, stream. A non-streamed request that runs past about 30 seconds is kept open with whitespace ahead of the JSON body and the X-Spicy-Keepalive: 1 header, so the OpenAI libraries still parse it; but if it fails after that point, the error object arrives in the body of a 200 response, which the libraries do not raise as an error. See Timeouts and streaming.
Errors
Errors use the OpenAI shape, {"error": {"message", "type", "param", "code"}}, with the usual HTTP status codes, so the OpenAI libraries raise their usual exceptions. param names the field the error is about, as a dot path such as temperature or messages.1.content.0, and code separates errors that share a status: unsupported_parameter (a field or message part the model does not accept), invalid_request (a missing field or a value the model does not accept), content_rejected (a content-policy refusal, also 400) and model_not_found (404). One difference from OpenAI: 402 is used when your balance or a spend cap does not cover the request. The full table, with what to do about each code, is in Chat recipes: when something goes wrong.
The OpenAI libraries retry 429 and 5xx responses on their own. That is safe here: a request that fails is not charged. If you send an Idempotency-Key, pass it per call and read Idempotency keys on text endpoints first.
Cost and usage
Replies report token counts in usage, not a price. Prices are per million tokens for input, cached input and output, listed on each model's page and in the catalog. To find what a call actually cost, read the X-Spicy-Task-Id response header (job_…), present on every accepted request, streamed or not, and look the task up with GET /api/v1/jobs/recordInfo?taskId=<job_…>: cost is final once settled is true. Items in GET /api/v1/jobs also carry the requestId of the call that created them (the X-Request-Id header), and Console → Logs shows the same tasks. Reading the header from the OpenAI libraries is shown in Chat recipes: find what a call cost; see also Quotes and billing.
Before a request runs, an amount is held from your balance that covers your messages plus the full max_tokens of output (the model's default when you leave it out). You pay only for what is used, but the hold has to fit first, so a small balance combined with a large max_tokens returns 402. Set a realistic max_tokens, or set spend caps on the key; see Billing.
Endpoints you will not find here
Text generation is available as OpenAI Chat Completions, OpenAI Responses, Anthropic Messages and Google Gemini; the details are in Text and streaming. These OpenAI endpoints do not exist and return 404: /v1/embeddings, the legacy /v1/completions, /v1/images/*, /v1/audio/*, /v1/moderations, /v1/files, /v1/batches, /v1/assistants and fine-tuning. Image, video and audio models are called through the task API instead, and videos also through /v1/videos.
Coming from OpenRouter
OpenRouter also speaks the OpenAI format, so the three changes above apply: replace the base URL https://openrouter.ai/api/v1 with https://api.spicyapi.ai/v1, and swap the key and the model IDs. Then remove the fields that exist only on OpenRouter; here they return 400:
| OpenRouter field | What to do |
|---|---|
models (a fallback list) and route | Remove. Send one model ID; SpicyAPI chooses how to serve it |
provider | Remove. There is no per-request provider selection |
reasoning (an object such as {"effort": "high"}) | Translated to the top-level reasoning_effort (enabled: false becomes none where the model has it); other keys inside it are ignored. Sending reasoning_effort directly is clearer |
usage ({"include": true}) | Remove. Non-streamed replies always include usage; for streams send stream_options: {"include_usage": true} |
transforms, plugins | Remove |
The HTTP-Referer and X-Title headers are harmless and ignored.
Checklist
- Base URL
https://api.spicyapi.ai/v1and a SpicyAPI key. - Model ID copied from the live catalog, ending in
/chat. - Request fields checked against the model's
inputSchema; unsupported non-sampling fields removed, and the response headersX-Spicy-Ignored-ParamsandX-Spicy-Adjusted-Paramschecked once to see what was changed. - If you send
stream_options.include_usage, stream loops skip the final chunk whosechoiceslist is empty. max_tokensset to a realistic value, or left to the model's default, with spend caps on the key.- Long or high-effort requests use
stream: true.
Continue reading
Chat recipes · Text and streaming · Model catalog and schemas · Authentication · Billing

