OpenAI SDK compatibility
What POST /v1/chat/completions supports, ignores, and rejects from an OpenAI request, and how its streams, tool calls, and errors behave in the openai SDK.
POST /v1/chat/completions is the route the official openai npm package
calls when you set baseURL to https://api.cruxy.ai/v1. It runs your
messages on the tier you name and nothing else: no memory, no standing rules,
and no cruxy block, so a run is reproducible. This page goes field by field
through what it does with an OpenAI request. To move a client over first, see
Migrate from OpenAI.
Request parameters
| Parameter | Status | Behavior |
|---|---|---|
model | Supported | kavi, vaani, mira, or auto. Required. |
messages | Supported | Required. Each message has a role, one of system, user, assistant, or tool, and a string content. An assistant message can replay tool_calls, and a tool message needs tool_call_id. |
max_tokens | Supported, clamped | Unset, or above the tier's cap, is clamped to the cap: 8192 on kavi, 4096 on vaani, 2048 on mira. Thinking is generated inside it. |
temperature | Supported | Forwarded upstream unchanged; the gateway adds no bound of its own. Omit it for the model's default. |
top_p | Supported | Forwarded upstream unchanged. 0 is a real value, so sending it is not the same as omitting it. Omit it for the model's default. |
stream | Supported | false by default. |
stream_options | Supported | include_usage: true adds the usage frame to a stream. Ignored when stream is false. |
reasoning_effort | Supported | none, low, medium, or high. Omit it for the tier's default. |
tools | Supported | Functions the model may call: type: "function", with function.name, description, and a JSON Schema parameters. |
tool_choice | Supported | "none", "auto", "required", or {"type": "function", "function": {"name": "…"}} to force one function. |
response_format | Supported | {"type": "text"}, {"type": "json_object"}, or {"type": "json_schema", "json_schema": {"name", "schema", "strict"}}. |
n, stop, seed, presence_penalty, frequency_penalty, logit_bias, logprobs, user | Ignored | Accepted and not applied, so a request that sends them still runs. |
| Any other OpenAI field | Ignored | The route ignores the OpenAI fields it does not implement. That includes max_completion_tokens: cap the output with max_tokens. |
file_ids | Rejected | The file attachments of POST /api/v1/chat are refused here. |
messages takes the four roles above; OpenAI's developer role is not one of
them, so send your instructions as a system message.
Requests the route rejects
These requests fail with a 4xx and the code shown:
| Request | Status and code |
|---|---|
model other than kavi, vaani, mira, or auto | 400 invalid_request |
reasoning_effort other than none, low, medium, or high, OpenAI's minimal included | 400 invalid_request |
tool_choice names a function that is not in tools, or is "required" with no tools | 400 invalid_request |
A tool's parameters schema exceeds a bound on bytes, depth, or nodes | 400 invalid_tool_schema |
A response_format JSON schema is malformed, oversized, too deep, or uses an unsupported keyword | 400 invalid_schema |
| The input exceeds the tier's context window | 400 context_overflow |
| The server-side input moderation gate, when it is enabled, refuses the request's text | 400 moderation_rejected, not billed |
response_format on a tier that does not do constrained decoding | 422 structured_output_unsupported |
An Idempotency-Key reused with a different body | 409 idempotency_conflict |
One more 422 comes after the model call: structured_output_unsatisfied
means the output did not satisfy your json_schema. That request is billed,
and it may pass on a retry.
Response fields
With stream: false, the response is a standard chat.completion object:
| Field | Value |
|---|---|
id | The completion id, such as chatcmpl-8f3a1c2d. |
object | chat.completion. |
created | Unix seconds at which the completion was produced. |
model | The tier that served the request. With auto, the tier the router chose. |
choices[].message | The assistant message, with tool_calls when the model called tools. |
choices[].finish_reason | stop, tool_calls, length, or content_filter. |
choices[].logprobs | Always null. The key is there because OpenAI sends it. |
usage | prompt_tokens, completion_tokens, and total_tokens, plus prompt_tokens_details.cached_tokens when the prompt cache was involved. |
The response has no cruxy block, no request_id, and no cost.
Cruxy extensions in the SDK
Every Cruxy extension this route honors is either a typed SDK parameter or a header, so you never need an untyped body field:
reasoning_effortis OpenAI's own parameter, typed inchat.completions.create, with Cruxy's values: send onlynone,low,medium, orhigh, although the SDK's type allows more.kavithinks athighby default,vaaniatlow, andmiranot at all. See Reasoning effort.Idempotency-Keyis a request header; pass it in the SDK's per-requestheadersoption. The first success under a key is stored for 24 hours, and a retry with the same key and body replays it without calling the model or billing again. Only a success is stored, and only withstream: false. The SDK does not send this header for you. See Reliability.X-Request-Idis on every response, and it is the only request ID this route returns. The SDK exposes it as_request_idon a result and asrequestIDon an error. Send your own, up to 128 characters of[A-Za-z0-9_-], and the gateway reuses it. See Request IDs and metadata.
# -i prints the response headers, including X-Request-Id.
curl -i https://api.cruxy.ai/v1/chat/completions \
-H "Authorization: Bearer $CRUXY_API_KEY" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"model": "kavi",
"reasoning_effort": "none",
"max_tokens": 256,
"messages": [
{ "role": "user", "content": "In one sentence, what is a monsoon?" }
]
}'import { randomUUID } from 'node:crypto'
import OpenAI from 'openai'
const client = new OpenAI({
apiKey: process.env.CRUXY_API_KEY,
baseURL: 'https://api.cruxy.ai/v1',
})
const completion = await client.chat.completions.create(
{
model: 'kavi',
// Thinking off: the whole max_tokens budget goes to the answer.
reasoning_effort: 'none',
max_tokens: 256,
messages: [{ role: 'user', content: 'In one sentence, what is a monsoon?' }],
},
// The SDK resends the same headers when it retries, so a retry after a
// dropped connection replays a stored success instead of billing again.
{ headers: { 'Idempotency-Key': randomUUID() } },
)
console.log(completion.choices[0].message.content)
console.log(completion.model, completion._request_id)Memory and standing rules
This route does no memory recall, no memory capture, and no standing-rules
injection, and it takes no vault_id. There is nothing to pass through the
SDK to turn them on. To use them, call POST /api/v1/chat or
POST /api/v2/chat with fetch: the SDK's chat.completions methods call
/chat/completions under the base URL, so they cannot reach either route. See
Memory and Standing rules.
Tool calls
Tool calling keeps OpenAI's shapes. Declare tools, and when finish_reason
is tool_calls, run each call in message.tool_calls, append the assistant
message and one tool message per call, and call again.
A tool call's id is a string with no documented format. It may not start
with call_, even though examples show call_abc123. Copy it unchanged into
tool_call_id, and never parse, validate, or build one; see
Tool-call IDs may not use OpenAI's call_ format.
import OpenAI from 'openai'
const client = new OpenAI({
apiKey: process.env.CRUXY_API_KEY,
baseURL: 'https://api.cruxy.ai/v1',
})
const tools: OpenAI.ChatCompletionTool[] = [
{
type: 'function',
function: {
name: 'get_weather',
description: 'Get the current weather for a city',
parameters: {
type: 'object',
properties: { city: { type: 'string' } },
required: ['city'],
},
},
},
]
const messages: OpenAI.ChatCompletionMessageParam[] = [
{ role: 'user', content: 'Is it raining in Pune?' },
]
let completion = await client.chat.completions.create({ model: 'vaani', tools, messages })
while (completion.choices[0].finish_reason === 'tool_calls') {
const message = completion.choices[0].message
messages.push(message)
for (const call of message.tool_calls ?? []) {
if (call.type !== 'function') continue
const args = JSON.parse(call.function.arguments)
// Echo the id exactly as it arrived; do not assume a call_ prefix.
messages.push({
role: 'tool',
tool_call_id: call.id,
content: JSON.stringify({ city: args.city, raining: true }),
})
}
completion = await client.chat.completions.create({ model: 'vaani', tools, messages })
}
console.log(completion.choices[0].message.content)Streaming
With stream: true, the route speaks OpenAI's Server-Sent Events protocol, in
this order:
- A role primer: the first
chat.completion.chunk. - Content deltas in
choices[0].delta.content, or tool-call fragments inchoices[0].delta.tool_calls, which you assemble perindex. - A frame that carries
finish_reason. - With
stream_options: {"include_usage": true}only, a usage frame:choicesis an empty array andusageis complete. Checkchoicesbefore you index it. data: [DONE].
If the model fails after the stream has opened, the stream ends with a
data: {"error": {...}} frame instead of [DONE], so a cut-off answer never
looks finished. The SDK throws that frame as an APIError from the loop:
catch it and discard the partial text. The gateway retries a failing model
only while the stream opens, never after a byte has reached you, and a stream
may run for up to five minutes.
# -N turns off buffering so frames print as they arrive.
curl -N https://api.cruxy.ai/v1/chat/completions \
-H "Authorization: Bearer $CRUXY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "vaani",
"stream": true,
"stream_options": { "include_usage": true },
"messages": [
{ "role": "user", "content": "Write a haiku about monsoon rain." }
]
}'import OpenAI from 'openai'
const client = new OpenAI({
apiKey: process.env.CRUXY_API_KEY,
baseURL: 'https://api.cruxy.ai/v1',
})
const stream = await client.chat.completions.create({
model: 'vaani',
stream: true,
stream_options: { include_usage: true },
messages: [{ role: 'user', content: 'Write a haiku about monsoon rain.' }],
})
let text = ''
try {
for await (const chunk of stream) {
// The usage frame has an empty choices array.
if (chunk.choices.length > 0) text += chunk.choices[0].delta.content ?? ''
if (chunk.usage) console.log('usage:', chunk.usage)
}
} catch (err) {
// A failure after the stream opened arrives as a data: {"error": ...} frame,
// and the SDK throws it here. The partial text is not a finished answer.
throw new Error('stream failed before it finished', { cause: err })
}
console.log(text)A stream stores and replays nothing under Idempotency-Key. A retry with the
same key and body streams a new generation; when both attempts complete, the
request is billed once. Streaming covers the
stream of POST /api/v1/chat, which differs: it sends no error frame.
Errors
Errors on POST /v1/chat/completions and GET /v1/models use OpenAI's
envelope:
{
"error": {
"code": "unauthorized",
"message": "unauthorized",
"param": null,
"type": "authentication_error"
}
}codeis the stable Cruxy error code, the same vocabulary as the rest of the API. Branch on it.typeis a coarse category derived from the HTTP status:invalid_request_error,authentication_error,permission_error,not_found_error,rate_limit_error, orserver_error.paramis alwaysnull: no error on this route names the field at fault.
The /api/v1 routes use Cruxy's own envelope instead. The two differ in where
each value sits:
| Item | /v1/chat/completions and /v1/models | /api/v1 routes |
|---|---|---|
| Envelope | {"error": {"message", "type", "param", "code"}} | {"code", "error", "request_id", ...} |
| The code to branch on | error.code | code |
| The message | error.message | error |
| The request ID | The X-Request-Id header only | request_id in the body |
Detail fields such as reset_at, mira_available, or limit | Not carried | In the body |
Because this envelope has no place for the detail fields, read the headers
instead: every 429 carries Retry-After, and the X-RateLimit-* headers
describe the per-minute request window.
Errors and retries lists every code and
which ones to retry.
The SDK throws a non-2xx response as an APIError, with the status in
status, the Cruxy code in code, and X-Request-Id in requestID:
# OpenAI's "minimal" is not a Cruxy reasoning_effort, so this is a 400.
curl -i https://api.cruxy.ai/v1/chat/completions \
-H "Authorization: Bearer $CRUXY_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "kavi",
"reasoning_effort": "minimal",
"messages": [
{ "role": "user", "content": "Hello" }
]
}'import OpenAI from 'openai'
const client = new OpenAI({
apiKey: process.env.CRUXY_API_KEY,
baseURL: 'https://api.cruxy.ai/v1',
})
try {
await client.chat.completions.create({
model: 'kavi',
// OpenAI's "minimal" is not a Cruxy reasoning_effort, so this is a 400.
reasoning_effort: 'minimal',
messages: [{ role: 'user', content: 'Hello' }],
})
} catch (err) {
if (!(err instanceof OpenAI.APIError)) throw err
// 400 invalid_request, and the X-Request-Id to quote in a support request.
console.error(err.status, err.code, err.requestID)
}Retries in the SDK
The openai package retries a failed request twice by default: after a
connection error, and on 408, 409, 429, and any 5xx. It waits for
Retry-After when that is 60 seconds or less. Against Cruxy, three of those
retries are unlikely to help:
504upstream_timeout. The gateway made one attempt and does not retry it, and the same request usually takes as long again. Stream long turns, or lowermax_tokensorreasoning_effort, rather than rely on the retry.429spend and budget codes, such asbudget_exhausted. They clear only at their reset, and theirRetry-Aftercan be days away, so a retry sooner fails the same way. Branch oncodeand stop retrying them.409idempotency_conflict. The key was reused with a different body, and the SDK resends that same body. Send the new body under a new key.
A 504 or a 429 is not billed. To make the SDK's retries after a dropped
connection safe, send an Idempotency-Key, as the extensions sample does.
Known differences
These are the places where this route behaves unlike OpenAI's, collected in one list:
modeltakes a tier orauto, and the response'smodelis the tier that served, never an upstream model id.- Fields the route does not implement are ignored without an error, so a
stopsequence ornhas no effect. - Thinking spends
max_tokensand is not returned; see Kavi's thinking spends max_tokens and isn't returned. - A non-streaming call ends at the tier's deadline with
504upstream_timeout; see Non-streaming calls have a 90-second ceiling. - A tool call's
idmay not use thecall_format; see Tool-call IDs may not use OpenAI's call_ format. error.codeis a Cruxy code,paramis alwaysnull, and the detail fields of a Cruxy error do not travel.- The response carries no request ID in its body and no cost in its
usage. - No memory, standing rules, vaults, or file attachments.
Known limitations lists the limits that apply across the API, each with a workaround.
Next steps
- Migrate from OpenAI: change the base URL, the key, and
model. - Chat: the Cruxy-native route, with memory, standing rules, and the
cruxyblock. - Errors and retries: every error code, and which ones to retry.
- Known limitations: the limits to plan around.