# Cruxy Learn: full corpus > OpenAI-compatible LLM gateway for the Cruxy model tiers (kavi, vaani, and mira), plus embeddings, rerank, and content moderation behind one base URL (https://api.cruxy.ai) and one Bearer key. Every documentation page, concatenated in navigation order. Each block below is fenced by a rule stating the page title, its canonical URL, and the markdown source it was rendered from. The per-page link index is at https://learn.getcruxy.com/llms.txt. ================================================================================ Page: Start here URL: https://learn.getcruxy.com/learn/en/start-here Markdown: https://learn.getcruxy.com/learn/en/start-here.md ================================================================================ # Start here A reading path through the Cruxy API in nine pages, from your first call to tool calling, retrieval, moderation, and running in production. Each step names one page and what you take from it. The pages build on each other, so read them in order the first time; after that, each one stands on its own as a reference. 1. **Make your first call** [Quickstart](/docs/en/get-started/quickstart) takes you from an empty terminal to your first model response: you get an API key with device login, then send a chat request to the `kavi` tier. 2. **Choose a tier** [Choosing a tier](/docs/en/build/choosing-a-tier) compares the tiers you pass as `model` (`kavi`, `vaani`, and `mira`) by profile, output cap, and how each one reasons by default, and shows how to read the live catalog from `GET /api/v1/models` instead of hardcoding it. 3. **Hold a conversation** [Chat](/docs/en/build/chat) documents `POST /api/v1/chat`, the OpenAI-compatible chat completion: the request options, a single JSON completion or a Server-Sent Events stream, the `cruxy` block, and usage. 4. **Let the model call your functions** [Tutorial: build a tool-using agent](/docs/en/build/tool-agent) builds on the same endpoint. You describe two functions, run the calls the model returns, send the results back, and loop on `finish_reason` until the model answers. 5. **Answer from your own documents** [Retrieval-augmented generation](/docs/en/resources/cookbook/rag) is a cookbook recipe that chains three endpoints: embeddings for a first-pass search, rerank to sharpen the shortlist, and chat to write the answer. 6. **Screen content** [Moderations](/docs/en/build/moderations) checks text against the content-safety categories with the `cruxy-guard` model and returns an overall `flagged` result. 7. **Handle failures** [Errors and retries](/docs/en/build/errors-and-retries) defines the error envelope, lists every error code, and says which failures to retry and how long to wait, so your client branches on the stable `code`, never on the wording of the message. 8. **Run it reliably** [Reliability](/docs/en/build/reliability) puts the production rules in one place: the per-tier deadlines, which failures to retry, `Idempotency-Key`, how to tell a finished stream from a failed one, and where to check platform status. 9. **Know the limits** [Known limitations](/docs/en/build/known-limitations) lists what the API does not do today, such as returning `kavi`'s thinking or running a non-streaming call past 90 seconds, and the workaround for each. ## Next steps [Cookbook](/docs/en/resources/cookbook) has more recipes that combine endpoints to finish a real job, and [API Reference](/docs/en/reference/api) lists every endpoint with its exact request and response shape. Moving code that already calls OpenAI? [Migrate from OpenAI](/docs/en/migrate/from-openai) changes the base URL, the key, and `model`, and [OpenAI SDK compatibility](/docs/en/migrate/openai-sdk-compatibility) says what carries over. ================================================================================ Page: One gateway for every model tier URL: https://learn.getcruxy.com/docs/en Markdown: https://learn.getcruxy.com/docs/en.md ================================================================================ # One gateway for every model tier An OpenAI-compatible LLM gateway for the Cruxy model tiers: chat, embeddings, and moderation behind one API key. ## Choose how you build Two ways into the same gateway: call the endpoints directly, or start from a pattern and copy what fits. - [Chat](/docs/en/build/chat) - [Embeddings](/docs/en/build/embeddings) - [Rerank](/docs/en/build/rerank) - [Moderations](/docs/en/build/moderations) - [Feedback](/docs/en/build/feedback) - [API Reference](/docs/en/reference/api) - [Cookbook](/docs/en/resources/cookbook) - [API guide for agents](/docs/en/resources/api-guide) - [Glossary](/docs/en/resources/glossary) - [Changelog](/docs/en/reference/changelog) ## From key to production Every integration walks the same path; each stage links into the guide that covers it. To read it as one ordered path, follow [Start here](/learn/en/start-here). - [Features overview](/docs/en/get-started/features) - [Quickstart](/docs/en/get-started/quickstart) - [Get your API key](/docs/en/get-started/api-keys) - [Authentication](/docs/en/get-started/authentication) - [Chat](/docs/en/build/chat) - [Streaming](/docs/en/build/streaming) - [Define tools](/docs/en/build/tools) - [Embeddings](/docs/en/build/embeddings) - [Rerank](/docs/en/build/rerank) - [Moderations](/docs/en/build/moderations) - [Models](/docs/en/reference/models) - [Errors and retries](/docs/en/build/errors-and-retries) - [Rate limits and budgets](/docs/en/build/rate-limits) - [Feedback](/docs/en/build/feedback) - [API Reference](/docs/en/reference/api) ## Pick a model You pass a tier id as `model` (or `auto` to let the router choose), and the gateway routes the call. The tier is the contract; the upstream model behind it is never exposed. [Choosing a tier](/docs/en/build/choosing-a-tier) compares them, and [Models](/docs/en/reference/models) documents every id and alias. - `kavi` (Deep): Deepest reasoning and the largest output budget; reach for it on the hardest tasks. - `vaani` (Balanced): Strong quality at lower latency and cost, a sensible default for everyday calls. - `mira` (Fast): Lowest latency and cost for short, high-volume calls; the budget-downgrade target. ## Start from a recipe Four working patterns that combine the endpoints to do a real job; copy the one shaped like yours. [Cookbook](/docs/en/resources/cookbook) indexes them all. ## Start building One key, one endpoint, three tiers behind it. ================================================================================ Page: Features overview URL: https://learn.getcruxy.com/docs/en/get-started/features Markdown: https://learn.getcruxy.com/docs/en/get-started/features.md ================================================================================ # Features overview What the Cruxy gateway gives you: OpenAI-compatible chat on three tiers, specialized model endpoints, and memory and rules you control. Cruxy is an OpenAI-compatible LLM gateway. You call one API at `https://api.cruxy.ai` with one key and name a model tier instead of a provider model. Cruxy-native fields are additive and namespaced under a `cruxy` key, so an OpenAI client keeps parsing the response and ignores what it does not know. ## Three tiers and auto You pass a tier id as the chat `model`. The gateway resolves the tier to an upstream model on the server; an upstream provider model is never accepted or returned, so the tier is the stable contract your code depends on. | Tier | Profile | Default reasoning | Reasoning control | | --- | --- | --- | --- | | `kavi` | Deep | `high` | On or off: any value except `none` turns thinking on. | | `vaani` | Balanced | `low` | Graded: `low`, `medium`, and `high` are different depths. | | `mira` | Fast | `none` | Graded: `low`, `medium`, and `high` are different depths. | The reasoning columns are the shipped defaults. `GET /api/v1/models` reports each tier's live `reasoning.default_effort` and `reasoning.graded`, along with its context window, output cap, and pricing; [Models](/docs/en/reference/models) shows how to read the catalog at runtime, and [Choosing a tier](/docs/en/build/choosing-a-tier) compares the tiers. A reasoning tier thinks inside the same `max_tokens` budget as its answer, and the thinking is not returned as content. A small `max_tokens` on `kavi` can be spent entirely on thinking, ending with `finish_reason: "length"` and empty content. Send `reasoning_effort: "none"` for a short, direct answer, or leave `max_tokens` unset to keep the tier's cap; see [Reasoning effort](/docs/en/build/reasoning-effort). Send `model: "auto"` to let the server-side router pick the tier from the request. It resolves to a concrete tier before billing and before the upstream call, and the tier that served the request comes back as `cruxy.routed_tier`. An `auto` request may be budget-downgraded to `mira`, so read `routed_tier` rather than assuming it; [Auto routing](/docs/en/build/auto-routing) covers the router, and [Rate limits and budgets](/docs/en/build/rate-limits) explains the budgets behind the downgrade. ## What you can call | Area | Endpoints | What it does | | --- | --- | --- | | [Chat](/docs/en/build/chat) | `POST /api/v1/chat` | OpenAI-compatible chat with Cruxy features: [Streaming](/docs/en/build/streaming), tool calling (see [Define tools](/docs/en/build/tools)), [Memory](/docs/en/build/memory), [Standing rules](/docs/en/build/rules), and attached files. | | Native chat | `POST /api/v2/chat` | The Cruxy-native wire: named SSE events that always end in a `finish` or `error` frame. | | OpenAI wire | `POST /v1/chat/completions`, `GET /v1/models` | The plain OpenAI protocol for OpenAI clients and evaluation harnesses, with none of the Cruxy-native features. | | [Models](/docs/en/reference/models) | `GET /api/v1/models` | The tier catalog. | | Token counting | `POST /api/v1/tokens/count` | The input tokens a chat request would bill, counted without inference and not billed. | | [Embeddings](/docs/en/build/embeddings) | `POST /api/v1/embeddings` | 1024-dimension vectors from `cruxy-embed`. | | [Rerank](/docs/en/build/rerank) | `POST /api/v1/rerank` | Documents ranked against a query by `cruxy-rerank`. | | [Moderations](/docs/en/build/moderations) | `POST /api/v1/moderations` | Content classification by `cruxy-guard`. | | [Feedback](/docs/en/build/feedback) | `POST /api/v1/feedback` | A signal about an earlier call, joined to it by `request_id`. | | Files | `/api/v1/files` | Uploads that a chat message attaches with `file_ids`; chat reads text, PDF, and Word (`.docx`) files. | | Conversations | `/api/v1/conversations` | Conversation history stored server-side, private to its author, with branches. | | Memory | `/api/v1/memories`, `/api/v1/vaults` | Stored facts, personal, organization-wide, or shared in a vault, that chat can recall. | | Rules | `/api/v1/rules`, `/api/v1/rules/resolved` | Standing instructions for chat, set for your organization or a vault. | | Prompts | `/api/v1/prompts` | Named prompts with immutable versions. | | Evals | `/api/v1/evals/suites` | Test suites run against a tier; every case is a billed completion. | | Account | `/api/v1/me`, `/api/v1/profile`, `/api/v1/limits` | Your identity, profile, and live limits. | | Organization | `/api/v1/org/members`, `/api/v1/workspaces`, `/api/v1/logs` | Members, invitations, workspaces with spend caps, and request-log metadata. | | Sign-in | `/api/v1/device/code`, `/api/v1/device/token` | Device login, which issues API keys; see [Get your API key](/docs/en/get-started/api-keys). | | Status | `GET /api/status`, `GET /api/health`, `GET /api/ready` | Public platform status and health probes. | Every `/api/v1` endpoint is also served under `/api/v2` with identical behavior, and this site documents the `/api/v1` path. Chat is the one exception: `POST /api/v2/chat` is a different wire from `POST /api/v1/chat`. [API Reference](/docs/en/reference/api) has every operation with its exact request and response shape. ## Memory and standing rules Two chat features add context beyond the messages you send. You control both per request, and their defaults depend on how the key was issued: - **On by default** for a signed-in credential: what a person gets by signing in to the Cruxy web app, desktop, the CLI, or mobile. A key minted by device login or OAuth login is a signed-in credential whichever client ran the flow, a third-party tool included. - **Off by default** for a developer API key or a headless key, which opts in per request. `GET /api/v1/limits` reports which kind a key is as `bucket`: `subscription` is a signed-in credential, and `apikey` and `headless` are not. A server, or anything that serves people other than the key's owner, should use a developer API key; see [Get your API key](/docs/en/get-started/api-keys#two-kinds-of-key). To get the same behavior from any key, set both on every request. ### Memory Memory has two independent sides, each set in the request's `memory` object: - **Recall** (`recall`) adds the stored memories most relevant to the turn as background context and reports them in `cruxy.memory_recall`: the scope and key of each, never the stored value. - **Capture** (`capture`) lets the gateway store a durable fact from the turn, such as a stated preference, and reports it in `cruxy.memory_capture`. No field means nothing was stored. Captures go to your personal memory, or to a vault you can write when the request names one, never to organization-wide memory. Content carrying an email address, phone number, or card number is dropped before any write. `true` needs a key whose allowed scopes include memory; otherwise the request fails with `403` `key_scope_forbidden`. `false` needs no scope. Pin `Cruxy-Version: 2026-09-26` on every request that relies on `false`, so a gateway that predates the field refuses the request instead of ignoring it. `GET /api/v1/memories` lists what is stored, and `DELETE /api/v1/memories/{id}` removes an entry. [Memory](/docs/en/build/memory) covers recall, capture, and the scope a key needs. ### Standing rules Standing rules are instructions your organization sets for chat, and a vault can have its own. They are injected into the turn as defaults that the user's own message overrides. A developer API key gets them only when the request sends `rules: true`, or when the key's allowed scopes name the rules family. Any key may send `rules: false` to decline them for a turn. When rules apply, the response carries `cruxy.rules_applied` with counts and sources, never the rule text. `GET /api/v1/rules/resolved` returns the exact rules a turn would get. Injected rule text counts as input tokens. [Standing rules](/docs/en/build/rules) covers opting in and what gets injected. This request recalls memory, stores nothing new, and declines standing rules: ```bash title="curl" curl https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -H "Cruxy-Version: 2026-09-26" \ -d '{ "model": "vaani", "stream": false, "memory": { "recall": true, "capture": false }, "rules": false, "messages": [ { "role": "user", "content": "Which time zone should my meeting use?" } ] }' ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', 'Cruxy-Version': '2026-09-26', }, body: JSON.stringify({ model: 'vaani', stream: false, memory: { recall: true, capture: false }, rules: false, messages: [{ role: 'user', content: 'Which time zone should my meeting use?' }], }), }) const completion = await res.json() console.log(completion.message.content) console.log(completion.cruxy.memory_recall) // undefined when nothing was recalled ``` ## Versioning The API is versioned by date. Send `Cruxy-Version: YYYY-MM-DD` to pin a version, or omit it for the server's current default; an unknown or malformed value is rejected with `400`. Every response, errors included, echoes the resolved version in its `Cruxy-Version` header. A breaking change, such as a change to streaming frame order, ships under a new dated version. Additive fields, including new `cruxy.*` fields, ship without one, so ignore JSON fields you do not recognize. [Changelog](/docs/en/reference/changelog) records what changed. ## Next steps - [Get your API key](/docs/en/get-started/api-keys): mint the key every call needs. - [Quickstart](/docs/en/get-started/quickstart): make your first chat call. - [Streaming](/docs/en/build/streaming): read a chat completion as it is written. - [Errors and retries](/docs/en/build/errors-and-retries): handle failures. ================================================================================ Page: Quickstart URL: https://learn.getcruxy.com/docs/en/get-started/quickstart Markdown: https://learn.getcruxy.com/docs/en/get-started/quickstart.md ================================================================================ # Quickstart Get an API key with device login, then make your first chat call. You'll go from an empty terminal to your first model response against the `kavi` tier. Every request in this guide goes to `https://api.cruxy.ai`. ## 1. Start device login Ask the gateway for a device code. This endpoint needs no authentication. ```bash curl -X POST https://api.cruxy.ai/api/v1/device/code ``` You get back a `user_code` to type, a URL to approve it, and the `device_code` your client polls with. ```json { "device_code": "cxy_dev_QK3f...redacted", "user_code": "WDJB-MJHT", "verification_uri": "https://cruxy.ai/device", "verification_uri_complete": "https://cruxy.ai/device?user_code=WDJB-MJHT", "expires_in": 600, "interval": 5 } ``` ## 2. Approve in the browser Open `verification_uri_complete` and approve the request. The `user_code` (`WDJB-MJHT`) is pre-filled; if you open the plain `verification_uri` instead, type it in by hand. You have `expires_in` seconds (600 = ten minutes) to finish. ## 3. Poll for the key Poll the token endpoint with your `device_code`, waiting `interval` seconds (5) between attempts. ```bash curl -X POST https://api.cruxy.ai/api/v1/device/token \ -H "Content-Type: application/json" \ -d '{ "device_code": "cxy_dev_QK3f...redacted" }' ``` > **Note:** Until someone approves, you get `400` with an `authorization_pending` > code. That is expected; keep polling. ```json { "code": "authorization_pending", "error": "authorization_pending", "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } ``` Once approved, the same request returns `200` with your key. > **Warning:** The key is shown exactly once; there is no way to retrieve it again. > Copy it now and store it before you move on. ```json { "access_token": "cxy_live_...redacted", "token_type": "bearer", "key_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "user_id": "6f9c1e2a-1b2c-4d5e-8f90-1a2b3c4d5e6f", "expires_at": "2026-10-20T12:00:00.000Z", "org": { "id": "8c1a5b3e-2d4f-4a6b-9c8d-0e1f2a3b4c5d", "name": "Acme Corp", "role": "member" } } ``` Export it into your shell. Never commit a key to source control. ```bash export CRUXY_API_KEY="cxy_live_..." ``` For the full flow (every polling error code, verifying the key, and key hygiene), see [Get your API key](/docs/en/get-started/api-keys). ## 4. Make your first call Send a prompt to the `kavi` tier. `stream: false`, which is also the default, asks for a single JSON response instead of a stream, so there is one block to read on a first call. ```bash title="curl" curl https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "kavi", "stream": false, "messages": [ { "role": "user", "content": "Hello" } ] }' ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'kavi', stream: false, messages: [{ role: 'user', content: 'Hello' }], }), }) const completion = await res.json() console.log(completion) ``` The response is an OpenAI-shaped chat completion. Alongside the standard fields, Cruxy adds a namespaced `cruxy` block carrying the `request_id` and the cost of the call; a vanilla OpenAI client ignores it. See [Chat](/docs/en/build/chat) for the streaming form and the full request options. ## Where to go next - [Chat](/docs/en/build/chat): Streaming SSE, tiers, temperature, and the cruxy block. - [Get your API key](/docs/en/get-started/api-keys): The device-login flow end to end, the key format, and key hygiene. - [API Reference](/docs/en/reference/api): Every endpoint, typed and interactive. ================================================================================ Page: Get your API key URL: https://learn.getcruxy.com/docs/en/get-started/api-keys Markdown: https://learn.getcruxy.com/docs/en/get-started/api-keys.md ================================================================================ # Get your API key Pick the right kind of key, mint a signed-in key with device login, verify it with GET /api/v1/me, and know when it expires. Every authenticated request sends a `cxy_live_` user key, and a user key is one of two kinds. A signed-in credential comes from device login and belongs in a person's own tool, such as a CLI or a desktop app. A developer API key is created in the Cruxy Console and belongs on a server. > **Warning:** Device login gives you a signed-in credential, with memory and standing rules on > by default. For a server, or anything that serves people other than you, use a > developer API key from the Cruxy Console. A signed-in key expires 90 days after > it is issued, and today you cannot revoke it yourself. ## Two kinds of key | | Signed-in credential | Developer API key | | --- | --- | --- | | How you get it | Device login, or native OAuth login, whichever client runs the flow. | Created in the Cruxy Console. | | Use it for | A person's own tool, such as a CLI or a desktop app. | A server, and anything that serves people other than the key's owner. | | Memory and rules | On by default. | Off unless a request opts in. | | Expires | At `expires_at`, 90 days after issue. Use does not renew it. | Never. | | Rotate or revoke | Not by its holder. | In the Console. | | Spend limits | A monthly spend cap. | The Console can limit it to chosen models and a monthly spend cap. | | Limits bucket | `subscription` | `apikey` | A signed-in credential on a server would recall its owner's memory into other people's turns, store what they say in that memory, and apply the owner's organization rules, unless every request sends `memory: {"recall": false, "capture": false}` and `rules: false`. A headless key, billed from prepaid credit, has memory and rules off by default, like a developer API key. To see which kind a key is, read `bucket` on `GET /api/v1/limits`; see [Rate limits and budgets](/docs/en/build/rate-limits#read-your-limits). ## Device login Cruxy issues signed-in credentials through an [RFC 8628][rfc] device-authorization flow: your client asks for a code, a person approves it in a browser, and your client polls until the key is minted. The browser and the client can be different machines. [rfc]: https://datatracker.ietf.org/doc/html/rfc8628 The two device-login endpoints take no key and no `Authorization` header: their job is to issue one. Both are rate-limited and return `429` with `rate_limited` if you call them too often. 1. **Request a device code** `POST /api/v1/device/code` with no body. ```bash title="curl" curl -X POST https://api.cruxy.ai/api/v1/device/code ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/device/code', { method: 'POST', }) if (!res.ok) throw new Error(`device code request failed: ${res.status}`) const device = await res.json() console.log(`Open ${device.verification_uri_complete} to approve.`) console.log(`Confirm this code on that page: ${device.user_code}`) ``` ```json { "device_code": "cxy_dev_QK3f...redacted", "user_code": "WDJB-MJHT", "verification_uri": "https://cruxy.ai/device", "verification_uri_complete": "https://cruxy.ai/device?user_code=WDJB-MJHT", "expires_in": 600, "interval": 5 } ``` | Field | Meaning | | --- | --- | | `device_code` | The code your client polls with. The person approving never needs it. | | `user_code` | The short code the person confirms in the browser. | | `verification_uri` | The approval page. The person types `user_code` there. | | `verification_uri_complete` | The approval page with `user_code` already filled in. | | `expires_in` | Seconds until `device_code` expires. | | `interval` | Seconds to wait between polls. | Read `verification_uri`, `expires_in`, and `interval` from each response rather than hardcoding the example values. 2. **Approve in the browser** Send the person to `verification_uri_complete`, or to `verification_uri` with the `user_code` to type. They approve the request before `expires_in` seconds pass. If they decline, polling returns `access_denied`. 3. **Poll for the key** `POST /api/v1/device/token` with the `device_code`, waiting `interval` seconds between attempts. Polling faster than the limiter allows returns `429` with `rate_limited`; wait the `Retry-After` seconds, then resume at `interval`. ```bash title="curl" # DEVICE_CODE holds the device_code from step 1. curl -X POST https://api.cruxy.ai/api/v1/device/token \ -H "Content-Type: application/json" \ -d "{\"device_code\": \"$DEVICE_CODE\"}" ``` ```ts title="TypeScript" // `device` is the response from step 1. const sleep = (seconds: number) => new Promise((r) => setTimeout(r, seconds * 1000)) async function pollForKey(device: { device_code: string; interval: number; expires_in: number }) { const deadline = Date.now() + device.expires_in * 1000 while (Date.now() < deadline) { await sleep(device.interval) const res = await fetch('https://api.cruxy.ai/api/v1/device/token', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ device_code: device.device_code }), }) if (res.ok) return res.json() if (res.status === 429) { // Polling faster than the limiter allows: honour Retry-After, then resume. await sleep(Number(res.headers.get('Retry-After')) || device.interval) continue } const { code, reason } = await res.json() if (code === 'authorization_pending') continue throw new Error(`device login stopped: ${code}${reason ? ` (${reason})` : ''}`) } throw new Error('device_code expired before approval; start a new device login') } const key = await pollForKey(device) // Store key.access_token in your secrets manager now. Log the id, never the key. console.log(`Minted key ${key.key_id} for ${key.org.name}, expires ${key.expires_at}`) ``` Until the flow resolves, the endpoint returns `400` with an OAuth-style body carrying `code`, `error`, and `request_id`: ```json { "code": "authorization_pending", "error": "authorization_pending", "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } ``` Only `authorization_pending` means keep polling: | `code` | Meaning | What to do | | --- | --- | --- | | `authorization_pending` | Not approved yet. | Wait `interval` seconds and poll again. | | `access_denied` | The person declined. | Stop. | | `expired_token` | The `device_code` expired. | Start a new device login. | | `invalid_grant` | The `device_code` is not valid. | Start a new device login. | A refusal can also carry `reason` and `error_description`. The one `reason` today is `org_membership_revoked`: the login was approved for an organization the account no longer belongs to. A new login succeeds, but it may bind to a different organization, so tell the person instead of retrying silently. Branch on `reason`; `error_description` is for people and is not meant to be parsed. On approval the endpoint returns `200` with the key: ```json { "access_token": "cxy_live_...redacted", "token_type": "bearer", "key_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "user_id": "6f9c1e2a-1b2c-4d5e-8f90-1a2b3c4d5e6f", "expires_at": "2026-10-20T12:00:00.000Z", "org": { "id": "8c1a5b3e-2d4f-4a6b-9c8d-0e1f2a3b4c5d", "name": "Acme Corp", "role": "member" } } ``` > **Warning:** The key is returned exactly once. Store it before you do anything else. ## What the token response tells you | Field | Meaning | | --- | --- | | `access_token` | Your API key. Send it as `Authorization: Bearer `. | | `token_type` | `bearer`. | | `key_id` | The key's id. Safe to log. | | `user_id` | The account the key belongs to. | | `expires_at` | When the key stops working, 90 days after issue. Record it with the key; see [Expiry and revocation](#expiry-and-revocation). | | `org` | The one organization this key is bound to: its `id`, `name`, and your `role` in it (`owner`, `admin`, or `member`). | A key is bound to exactly one organization, fixed when it is issued. That organization decides which shared memory, rules, vaults, and conversations the key can reach. A login is not always bound to the same organization as your previous one, so show `org.name` to the person after every login, and read `org.role` rather than inferring authority from anything else. ## Key format | Prefix | What it is | | --- | --- | | `cxy_live_` | A user key, either kind: what you send as a Bearer token. | | `cxy_svc_` | A service token, minted by an operator. It is not a user key, and an endpoint that expects one rejects the other with `401`. | Treat everything after the prefix as opaque. ## Verify the key `GET /api/v1/me` echoes the identity behind a key. Call it once after you store a new key to confirm it works end to end. ```bash title="curl" curl https://api.cruxy.ai/api/v1/me \ -H "Authorization: Bearer $CRUXY_API_KEY" ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/me', { headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}` }, }) if (!res.ok) throw new Error(`key check failed: ${res.status}`) const me = await res.json() console.log(`key ${me.api_key_id} belongs to user ${me.user_id}`) ``` ```json { "api_key_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "user_id": "6f9c1e2a-1b2c-4d5e-8f90-1a2b3c4d5e6f" } ``` A `200` means the key works. A `401` `unauthorized` means it is missing, malformed, unknown, expired, or revoked: the same answer for each. Check the `Authorization` header, then the key itself, then its `expires_at`. ## Expiry and revocation - **A signed-in credential stops working at `expires_at`,** 90 days after issue. The time is fixed when the key is issued: use does not extend it, and there is no refresh grant. Prompt for a new device login before it passes. - **A developer API key does not expire.** Rotate or revoke it in the Console. - **You cannot revoke a signed-in credential yourself today.** Its holder can neither rotate nor revoke it, so it ends only at `expires_at`, when its organization membership ends, or when Cruxy support revokes it. If one leaks, ask Cruxy support to revoke it. - **An expired, revoked, or unknown key is `401` `unauthorized`,** with `WWW-Authenticate: Bearer`. A key whose owner's account is suspended is `403` `account_suspended`. - **Revocation applies from the next request,** since nothing is cached. A stream already running is not cut. ## What a key is allowed to do A key can carry restrictions. A request outside them is refused: | Restriction | Refusal | | --- | --- | | Allowed scopes: the endpoint families the key may call, and whether it may use memory, rules, or files | `403` `key_scope_forbidden`, naming `required_scope` when a request field asked for a family the key lacks | | Model allowlist: the tiers the key may call | `403` `model_not_allowed` | | Read-only: the key may not perform billable operations | `403` `key_read_only` | | Monthly spend cap | `429` `key_spend_cap_exceeded` | A `403` never clears by waiting; re-scope the key or issue a new one. For a developer API key, the Console sets the model allowlist and the monthly spend cap. Memory recall, memory capture, and your organization's standing rules are on by default for a signed-in credential, which includes every key device login mints, and off by default for a developer API key. To get the same behavior from any key, set `memory` and `rules` on each chat request; see [Memory](/docs/en/build/memory) and [Standing rules](/docs/en/build/rules). ## Keep the key safe - **Treat it like a password.** Anyone who holds it can call the API as you. - **Keep it on the server.** Never ship a key to a browser. Put a server route between the browser and the gateway that adds the key; see [Streaming chat in the browser](/docs/en/resources/cookbook/streaming-ui). - **Read it from the environment.** Keep it in an environment variable or a secrets manager, never in source control. Every sample on this site reads `CRUXY_API_KEY`. - **Use a developer API key on a server.** A signed-in credential carries its owner's memory and rules into every request, expires, and cannot be revoked by its holder. - **Store it the moment it is issued.** The token endpoint returns it once; if you lose it, run device login again for a new key. - **Log the `key_id`, never the key.** `key_id` and `expires_at` identify a key and tell you when to replace it. - **Use one key per environment.** Separate keys for local, staging, and production keep each `key_id` meaningful in your logs. - **Issue the narrowest key a service needs.** A key whose allowed scopes omit memory can never read or write anyone's memory through chat. - **Send it only to `https://api.cruxy.ai`.** ## Next steps - [Authentication](/docs/en/get-started/authentication): send the key on every request. - [Quickstart](/docs/en/get-started/quickstart): make your first chat call. - [Errors and retries](/docs/en/build/errors-and-retries): handle `401`, `403`, and every other error code. ================================================================================ Page: Authentication URL: https://learn.getcruxy.com/docs/en/get-started/authentication Markdown: https://learn.getcruxy.com/docs/en/get-started/authentication.md ================================================================================ # Authentication Send your Cruxy API key as a Bearer token in the Authorization header on every authenticated request. Every authenticated endpoint reads one header: `Authorization: Bearer` followed by your `cxy_live_` key. To mint a key, confirm it works, and store it safely, see [Get your API key](/docs/en/get-started/api-keys). ## The Authorization header Send the key on every authenticated request: ```bash title="curl" curl https://api.cruxy.ai/api/v1/me \ -H "Authorization: Bearer $CRUXY_API_KEY" ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/me', { headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}` }, }) console.log(res.status, await res.json()) ``` The same key and header work on the OpenAI-compatible routes, `POST /v1/chat/completions` and `GET /v1/models`: point an OpenAI client at the base URL `https://api.cruxy.ai/v1` and give it your Cruxy key as its API key. [Migrate from OpenAI](/docs/en/migrate/from-openai) covers the rest of the switch. ## Endpoints that take no key These endpoints are public: | Endpoint | Why it is public | | --- | --- | | `POST /api/v1/device/code`, `POST /api/v1/device/token` | Device login issues keys. | | `POST /api/v1/auth/oauth/start`, `POST /api/v1/auth/oauth/token` | Native sign-in issues keys. | | `GET /api/status`, `GET /api/health`, `GET /api/ready` | Platform status and health probes. | ## When the key is rejected A `401` `unauthorized` means the key is missing, malformed, unknown, expired, or revoked: the same answer for each. A signed-in key stops working at its `expires_at`; see [Expiry and revocation](/docs/en/get-started/api-keys#expiry-and-revocation). Check the `Authorization` header, then [verify the key](/docs/en/get-started/api-keys#verify-the-key). A `403` means the key is valid but its authority does not cover the call: | `code` | Meaning | | --- | --- | | `key_scope_forbidden` | The key is not scoped for this endpoint family, or for the memory, rules, or files feature a request field asked for. | | `model_not_allowed` | The key's model allowlist excludes the requested tier. | | `key_read_only` | The key may not perform billable operations. | A `403` never clears by waiting: re-scope the key or issue a new one. [Errors and retries](/docs/en/build/errors-and-retries) lists every error code. ## Next steps - [Get your API key](/docs/en/get-started/api-keys): device login, key format, and key hygiene. - [Chat](/docs/en/build/chat): make an authenticated chat call. - [API Reference](/docs/en/reference/api): which endpoints require a key. ================================================================================ Page: Migrate from OpenAI URL: https://learn.getcruxy.com/docs/en/migrate/from-openai Markdown: https://learn.getcruxy.com/docs/en/migrate/from-openai.md ================================================================================ # Migrate from OpenAI Move an OpenAI client to Cruxy: change the base URL and the key, pass a tier as the model, and know what carries over and what differs. Cruxy serves OpenAI-compatible chat completions at `POST /v1/chat/completions` and an OpenAI-compatible model list at `GET /v1/models`. Both take OpenAI's request and return OpenAI's response, so code written for the official `openai` npm package moves over with three changes: the base URL, the API key, and `model`. ## Change three settings | Setting | Set it to | | --- | --- | | Base URL | `https://api.cruxy.ai/v1` | | API key | Your Cruxy API key, read from `CRUXY_API_KEY`. It travels as `Authorization: Bearer `, as on OpenAI. | | `model` | A Cruxy tier, `kavi`, `vaani`, or `mira`, or `auto` to let the router pick one. | The key needs the `chat` endpoint family, the same as for `POST /api/v1/chat`; a key without it is `403`. On a server, use a developer API key from the Cruxy Console: [Get your API key](/docs/en/get-started/api-keys) covers the kinds of key and how to create one. Authentication, rate limits, and billing work as they do on `POST /api/v1/chat`. ## Before and after The same request, first to OpenAI, then to Cruxy. Only the base URL, the key, and `model` change; the request body and the response you read stay the same. ```bash title="Before (curl)" # OPENAI_MODEL is the OpenAI model id you call today. curl https://api.openai.com/v1/chat/completions \ -H "Authorization: Bearer $OPENAI_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "'"$OPENAI_MODEL"'", "messages": [ { "role": "user", "content": "Hello" } ] }' ``` ```bash title="After (curl)" curl https://api.cruxy.ai/v1/chat/completions \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "kavi", "messages": [ { "role": "user", "content": "Hello" } ] }' ``` ```ts title="Before (TypeScript)" import OpenAI from 'openai' const client = new OpenAI({ apiKey: process.env.OPENAI_API_KEY }) const completion = await client.chat.completions.create({ // The OpenAI model id you call today. model: process.env.OPENAI_MODEL ?? '', messages: [{ role: 'user', content: 'Hello' }], }) console.log(completion.choices[0].message.content) ``` ```ts title="After (TypeScript)" 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', messages: [{ role: 'user', content: 'Hello' }], }) console.log(completion.choices[0].message.content) ``` ## Pass a tier as model `model` takes a tier or `auto`. Any other value, an OpenAI model id included, is `400` `invalid_request`. These docs map no OpenAI model to a tier: the model behind a tier is never exposed, and it can change while the tier stays the same. Choose a tier by its profile, then test it on your own prompts. | `model` | Profile | Default `reasoning_effort` | `max_tokens` cap | | --- | --- | --- | --- | | `kavi` | Deep | `high` | 8192 | | `vaani` | Balanced | `low` | 4096 | | `mira` | Fast | `none` | 2048 | | `auto` | The router picks a tier for each request. | Chosen per request | The cap of the tier that serves | The response's `model` is the tier that served the request. With `auto`, it is the tier the router chose, which a budget can also have downgraded, so read it rather than assume it. [Choosing a tier](/docs/en/build/choosing-a-tier) compares the tiers, and [Auto routing](/docs/en/build/auto-routing) covers what the router does. `GET /v1/models` returns OpenAI's list shape, `{"object": "list", "data": [...]}`, and its ids are the tiers. For each tier's context window, output cap, tool support, and reasoning defaults, read `GET /api/v1/models`; see [Models](/docs/en/reference/models). ## What carries over unchanged - **The request.** `messages` with the `system`, `user`, `assistant`, and `tool` roles, plus `temperature`, `top_p`, `max_tokens`, `stream`, `stream_options`, `tools`, `tool_choice`, `response_format`, and `reasoning_effort`. - **The response.** A `chat.completion` object with `id`, `object`, `created`, `model`, `choices[].message`, `choices[].finish_reason` (`stop`, `tool_calls`, `length`, or `content_filter`), and `usage` with `prompt_tokens`, `completion_tokens`, and `total_tokens`. - **Streaming.** `chat.completion.chunk` frames that end with `data: [DONE]`, and a usage frame only when you set `stream_options.include_usage`. - **Tool calling.** `tools` and `tool_choice` on the request, `tool_calls` on the assistant message, and a `tool` message with `tool_call_id` for each result. - **Errors.** OpenAI's envelope, `{"error": {"message", "type", "param", "code"}}`, which the SDK raises as an `APIError`. ## What differs - **`model` is a tier,** and the response's `model` names the tier that served. - **Fields Cruxy does not implement are ignored, not rejected:** `n`, `stop`, `seed`, `presence_penalty`, `frequency_penalty`, `logit_bias`, `logprobs`, `user`, and the rest. A request that sends them still runs, and they have no effect. - **`max_tokens` is clamped to the tier's cap, and thinking spends it.** `kavi` thinks by default, so a small `max_tokens` on `kavi` can end with `finish_reason: "length"` and empty content. - **`reasoning_effort` takes `none`, `low`, `medium`, or `high`.** Any other value, OpenAI's `minimal` included, is `400` `invalid_request`. - **A non-streaming call has a deadline:** 90 seconds on `kavi` and `vaani`, 45 on `mira`. Past it, the call is `504` `upstream_timeout`. - **An error's `code` is a Cruxy code,** and `param` is always `null`. - **The route carries none of the Cruxy-native chat features:** no memory, no standing rules, no vaults, no `file_ids`, and no `cruxy` block. For those, call `POST /api/v1/chat`; see [Chat](/docs/en/build/chat). - **The request ID is only in a header,** `X-Request-Id`, and the response's `usage` carries no cost. - **A tool call's `id` may not start with `call_`.** Treat it as opaque. [OpenAI SDK compatibility](/docs/en/migrate/openai-sdk-compatibility) covers each of these parameter by parameter, with streaming, errors, and how the SDK retries. ## Next steps - [OpenAI SDK compatibility](/docs/en/migrate/openai-sdk-compatibility): what the route supports, ignores, and rejects. - [Choosing a tier](/docs/en/build/choosing-a-tier): pick the tier to pass as `model`. - [Reasoning effort](/docs/en/build/reasoning-effort): keep thinking from spending your `max_tokens`. - [Known limitations](/docs/en/build/known-limitations): the limits to plan around. ================================================================================ Page: OpenAI SDK compatibility URL: https://learn.getcruxy.com/docs/en/migrate/openai-sdk-compatibility Markdown: https://learn.getcruxy.com/docs/en/migrate/openai-sdk-compatibility.md ================================================================================ # 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](/docs/en/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_effort`** is OpenAI's own parameter, typed in `chat.completions.create`, with Cruxy's values: send only `none`, `low`, `medium`, or `high`, although the SDK's type allows more. `kavi` thinks at `high` by default, `vaani` at `low`, and `mira` not at all. See [Reasoning effort](/docs/en/build/reasoning-effort). - **`Idempotency-Key`** is a request header; pass it in the SDK's per-request `headers` option. 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 with `stream: false`. The SDK does not send this header for you. See [Reliability](/docs/en/build/reliability). - **`X-Request-Id`** is on every response, and it is the only request ID this route returns. The SDK exposes it as `_request_id` on a result and as `requestID` on an error. Send your own, up to 128 characters of `[A-Za-z0-9_-]`, and the gateway reuses it. See [Request IDs and metadata](/docs/en/build/request-ids). ```bash title="curl" # -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?" } ] }' ``` ```ts title="TypeScript" 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](/docs/en/build/memory) and [Standing rules](/docs/en/build/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](/docs/en/build/known-limitations#tool-call-ids-may-not-use-openais-call_-format). ```ts 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: 1. A role primer: the first `chat.completion.chunk`. 2. Content deltas in `choices[0].delta.content`, or tool-call fragments in `choices[0].delta.tool_calls`, which you assemble per `index`. 3. A frame that carries `finish_reason`. 4. With `stream_options: {"include_usage": true}` only, a usage frame: `choices` is an empty array and `usage` is complete. Check `choices` before you index it. 5. `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. ```bash title="curl" # -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." } ] }' ``` ```ts title="TypeScript" 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](/docs/en/build/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: ```json { "error": { "code": "unauthorized", "message": "unauthorized", "param": null, "type": "authentication_error" } } ``` - **`code`** is the stable Cruxy error code, the same vocabulary as the rest of the API. Branch on it. - **`type`** is a coarse category derived from the HTTP status: `invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error`, `rate_limit_error`, or `server_error`. - **`param`** is always `null`: 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](/docs/en/build/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`: ```bash title="curl" # 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" } ] }' ``` ```ts title="TypeScript" 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: - **`504` `upstream_timeout`.** The gateway made one attempt and does not retry it, and the same request usually takes as long again. Stream long turns, or lower `max_tokens` or `reasoning_effort`, rather than rely on the retry. - **`429` spend and budget codes,** such as `budget_exhausted`. They clear only at their reset, and their `Retry-After` can be days away, so a retry sooner fails the same way. Branch on `code` and stop retrying them. - **`409` `idempotency_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: - `model` takes a tier or `auto`, and the response's `model` is the tier that served, never an upstream model id. - Fields the route does not implement are ignored without an error, so a `stop` sequence or `n` has no effect. - Thinking spends `max_tokens` and is not returned; see [Kavi's thinking spends max_tokens and isn't returned](/docs/en/build/known-limitations#kavis-thinking-spends-max_tokens-and-isnt-returned). - A non-streaming call ends at the tier's deadline with `504` `upstream_timeout`; see [Non-streaming calls have a 90-second ceiling](/docs/en/build/known-limitations#non-streaming-calls-have-a-90-second-ceiling). - A tool call's `id` may not use the `call_` format; see [Tool-call IDs may not use OpenAI's call\_ format](/docs/en/build/known-limitations#tool-call-ids-may-not-use-openais-call_-format). - `error.code` is a Cruxy code, `param` is always `null`, 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](/docs/en/build/known-limitations) lists the limits that apply across the API, each with a workaround. ## Next steps - [Migrate from OpenAI](/docs/en/migrate/from-openai): change the base URL, the key, and `model`. - [Chat](/docs/en/build/chat): the Cruxy-native route, with memory, standing rules, and the `cruxy` block. - [Errors and retries](/docs/en/build/errors-and-retries): every error code, and which ones to retry. - [Known limitations](/docs/en/build/known-limitations): the limits to plan around. ================================================================================ Page: Chat URL: https://learn.getcruxy.com/docs/en/build/chat Markdown: https://learn.getcruxy.com/docs/en/build/chat.md ================================================================================ # Chat OpenAI-compatible chat completions across the Cruxy tiers, streaming or not. `POST /api/v1/chat` runs an OpenAI-compatible chat completion. You pick a public tier, send a list of messages, and read back either a single JSON completion or a stream of Server-Sent Events. Here is the smallest complete call: ```bash title="curl" curl https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "kavi", "stream": false, "messages": [ { "role": "user", "content": "Hello" } ] }' ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'kavi', stream: false, messages: [{ role: 'user', content: 'Hello' }], }), }) const completion = await res.json() ``` ## Tiers, not provider models `model` is a **public tier**, one of: | Tier | Profile | | --- | --- | | `kavi` | Deep | | `vaani` | Balanced | | `mira` | Fast | The gateway resolves each tier to an upstream provider model on the server. You never send or receive an upstream provider id. Tiers are the stable contract, so your code keeps working when the backing model changes. [Choosing a tier](/docs/en/build/choosing-a-tier) compares them; to discover the catalog at runtime rather than hardcoding these strings, see [Models](/docs/en/reference/models). `model` also accepts `"auto"`, which hands tier selection to the gateway's router. It resolves to a concrete tier before billing and the upstream call, and `routed_tier` on the response (see [The cruxy block](#the-cruxy-block)) names the tier that served. [Auto routing](/docs/en/build/auto-routing) covers what the router guarantees. ## Request options Beyond `model` and `messages`, the request accepts: | Field | Type | Example | Purpose | | --- | --- | --- | --- | | `stream` | boolean | `true` | Stream SSE chunks. Optional and `false` by default, which returns one JSON object. | | `temperature` | number | `0.7` | Sampling temperature. | | `max_tokens` | integer | `1024` | Cap on tokens generated. | | `reasoning_effort` | string | `"medium"` | How much the model thinks before answering: `none`, `low`, `medium`, or `high` (see [Reasoning effort](/docs/en/build/reasoning-effort)). | | `tools` | array | | Functions the model may call (see [Define tools](/docs/en/build/tools)). | | `tool_choice` | string or object | `"auto"` | Whether and which tool the model calls. | | `memory` | object | `{ "recall": true }` | Memory recall and capture for this request (see [Memory](/docs/en/build/memory)). | | `rules` | boolean | `false` | Standing-rules injection for this request (see [Standing rules](/docs/en/build/rules)). | Each `messages` entry has a `role` and `content`. Assistant turns that call tools carry `tool_calls`, and your replies to those calls carry a `tool_call_id`; both are covered in [Handle tool calls](/docs/en/build/tool-calls). ## Streaming vs. not `stream` controls the response format. ### stream: false This is what you get when you omit `stream`: a single JSON object. Cruxy flattens it: the assistant reply is the top-level `message`, alongside `finish_reason`, `usage`, and the `cruxy` block. There is no OpenAI `choices[]` array. Read `message.content` directly, not `choices[0].message`. ```json { "id": "chatcmpl-8f3a1c2d", "model": "kavi", "message": { "role": "assistant", "content": "Namaste." }, "finish_reason": "stop", "usage": { "input_tokens": 12, "output_tokens": 48, "billable_input_tokens": 12, "cache_read_tokens": 0, "cache_write_tokens": 0, "cost": { "tier": "kavi", "amount": "", "currency": "" } }, "cruxy": { "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "routed_tier": "kavi", "reasoning_effort": "high" } } ``` `` and `` are placeholders: a real `amount` is a number. See [Usage and cost](/docs/en/build/usage-and-cost) for the full `cost` object. ### stream: true You get an OpenAI-style Server-Sent Events stream: a sequence of `chat.completion.chunk` events, each on a `data:` line, terminated by a final `data: [DONE]`. Unlike the non-streaming reply, a chunk uses the OpenAI `choices[]` shape: each token arrives in `choices[0].delta.content`. ```bash curl -N https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "kavi", "stream": true, "messages": [ { "role": "user", "content": "Write a haiku about monsoon rain." } ] }' ``` `data: [DONE]` is the only signal that the completion finished: a stream that ends without it failed partway. [Streaming](/docs/en/build/streaming) covers the frame order, the chunk and usage shapes, a complete TypeScript reader, and how to detect a failed stream. ## The cruxy block Cruxy-native fields are additive and namespaced under a `cruxy` key. They never alter the OpenAI-shaped fields, so a vanilla OpenAI client parses the response unchanged and ignores the extra key. On a chat response the block carries three fields for the turn: - `request_id`: a UUID for the call, e.g. `a1b2c3d4-e5f6-7890-abcd-ef1234567890`. Log it; it identifies the call in support requests and pairs with [Feedback](/docs/en/build/feedback). - `routed_tier`: the tier that actually served the request. This is how you learn which tier ran when you send `model: "auto"`. - `reasoning_effort`: the effort the call ran at, after any tier default is applied. When they apply, the block also reports what the turn recalled, captured, or injected: `memory_recall`, `memory_capture`, `rules_applied`, `files_omitted`, and `undeclared_tool_replay`. [Request IDs and metadata](/docs/en/build/request-ids#the-cruxy-block) lists every field, and how to quote `request_id` when you report a problem. Cost and token counts are not in this block; they live in `usage`. ## Usage Every chat response carries a `usage` object with the token accounting and the call's `cost`. The token fields depend on the response mode: non-streaming uses `input_tokens` and `output_tokens`; streaming uses `prompt_tokens`, `completion_tokens`, and `total_tokens`. Both also carry `billable_input_tokens`, `cache_read_tokens`, and `cache_write_tokens`. `usage.cost` breaks down the price of the call: | Field | Meaning | | --- | --- | | `tier` | The public tier that served the request. | | `amount` | Total cost of the call. | | `currency` | Currency of `amount`. | | `input_unit` | Input rate, per million tokens. | | `output_unit` | Output rate, per million tokens. | | `cache_read_unit` | `input_unit` × the cache discount, for cached reads. | Amounts and rates are specific to your account and shown in your Cruxy Console. [Usage and cost](/docs/en/build/usage-and-cost) covers which tokens are billed and how a stream reports usage. ## Errors Chat returns `400` for a malformed request, `401` `unauthorized` for a missing, expired, or revoked key, `429` when you hit a limit, such as `rate_limited` or `budget_exhausted`, and `5xx` when the model call fails. With `stream: false` the gateway makes one attempt, bounded by the tier's deadline; an attempt that runs past it is `504` `upstream_timeout`, which the gateway does not retry and does not bill (see [Timeouts](/docs/en/build/errors-and-retries#timeouts)). See [Errors](/docs/en/reference/errors) for the response shape, [Errors and retries](/docs/en/build/errors-and-retries) for every code and which ones to retry, and [Rate limits and budgets](/docs/en/build/rate-limits) for how to tell a rate limit from a budget. ## Next steps - [Streaming](/docs/en/build/streaming): read the SSE stream and detect a failed one. - [Define tools](/docs/en/build/tools): let the model call your functions. - [Reasoning effort](/docs/en/build/reasoning-effort): keep thinking from spending your `max_tokens`. - [Known limitations](/docs/en/build/known-limitations): the limits to plan around, such as the 90-second non-streaming ceiling. - [Streaming chat in the browser](/docs/en/resources/cookbook/streaming-ui): consume the SSE stream in a UI. - [Models](/docs/en/reference/models): discover tiers at runtime. - [Errors and retries](/docs/en/build/errors-and-retries): handle `400`, `401`, and `429`, and retry safely. ================================================================================ Page: Streaming URL: https://learn.getcruxy.com/docs/en/build/streaming Markdown: https://learn.getcruxy.com/docs/en/build/streaming.md ================================================================================ # Streaming Read chat completions as Server-Sent Events: the frame order, the chunk and usage shapes, and how to tell a finished stream from a failed one. `stream` is optional and `false` by default. With `stream: true`, `POST /api/v1/chat` answers with `text/event-stream`: OpenAI-style Server-Sent Events in which every event is one `chat.completion.chunk` JSON object on a `data:` line. The stream ends with the literal line `data: [DONE]`, and that line is the only signal that the completion finished. ```bash title="curl" curl -N https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "vaani", "stream": true, "messages": [ { "role": "user", "content": "Write a haiku about monsoon rain." } ] }' | tee stream.txt # The completion finished only if the stream ended with [DONE]. grep -q '^data: \[DONE\]' stream.txt || echo "stream failed before [DONE]" ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'vaani', stream: true, messages: [{ role: 'user', content: 'Write a haiku about monsoon rain.' }], }), }) if (!res.ok || !res.body) throw new Error(`chat failed: ${res.status} ${await res.text()}`) const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' let text = '' let finishReason: string | null = null let usage: Record | undefined let sawDone = false read: while (true) { const { value, done } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() ?? '' // keep a partial line for the next read for (const raw of lines) { const line = raw.trim() if (!line.startsWith('data:')) continue const data = line.slice('data:'.length).trim() if (data === '[DONE]') { sawDone = true break read } const chunk = JSON.parse(data) if (chunk.usage) usage = chunk.usage const choice = chunk.choices[0] // undefined on the usage frame if (!choice) continue if (choice.delta.content) text += choice.delta.content if (choice.finish_reason) finishReason = choice.finish_reason } } if (!sawDone) { // The stream ended early: a failed completion, not a short one. throw new Error('stream ended before [DONE]; discard the partial output') } console.log(text) console.log(finishReason, usage) ``` ## Frame order A successful stream sends these frames, in this order: | # | Frame | `choices` | What it carries | | --- | --- | --- | --- | | 1 | Role primer | One element; `delta` is `{"role": "assistant", "content": ""}` | The turn's `cruxy` block: `routed_tier`, `request_id`, and `reasoning_effort`, plus `memory_recall` and `rules_applied` when they apply. | | 2 | Content or tool-call deltas, zero or more | One element; `delta` has a `content` fragment or `tool_calls` fragments, never both | The answer, piece by piece. `finish_reason` is `null`. | | 3 | Finish | One element; `delta` is `{}` | `finish_reason`. No usage. | | 4 | Usage | `[]`, an empty array | The final `usage` object, plus `cruxy.memory_capture` when the turn stored memory. | | 5 | `data: [DONE]` | | Nothing. It is not JSON; do not parse it. | On the wire: ```text data: {"id":"chatcmpl-8f3a1c2d","object":"chat.completion.chunk","model":"vaani","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}],"cruxy":{"routed_tier":"vaani","request_id":"a1b2c3d4-e5f6-7890-abcd-ef1234567890","reasoning_effort":"low"}} data: {"id":"chatcmpl-8f3a1c2d","object":"chat.completion.chunk","model":"vaani","choices":[{"index":0,"delta":{"content":"Clouds lean"},"finish_reason":null}]} data: {"id":"chatcmpl-8f3a1c2d","object":"chat.completion.chunk","model":"vaani","choices":[{"index":0,"delta":{"content":" on the hills"},"finish_reason":null}]} data: {"id":"chatcmpl-8f3a1c2d","object":"chat.completion.chunk","model":"vaani","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]} data: {"id":"chatcmpl-8f3a1c2d","object":"chat.completion.chunk","model":"vaani","choices":[],"usage":{"prompt_tokens":12,"completion_tokens":48,"total_tokens":60,"cost":{"amount":,"currency":"","tier":"vaani",...}}} data: [DONE] ``` Changing the framing or this order is a breaking change that ships only under a new dated `Cruxy-Version`, so you can rely on it. New `cruxy.*` fields can appear without a version change, so ignore fields you do not recognize. ## The chunk | Field | Meaning | | --- | --- | | `id` | The completion id. | | `object` | `chat.completion.chunk`. | | `model` | The tier. | | `choices` | One element on every frame that carries output; an empty array on the usage frame. | | `choices[0].index` | `0`. | | `choices[0].delta` | The fragment: `role` on the primer, then `content` or `tool_calls`, then `{}` on the finish frame. | | `choices[0].finish_reason` | `null` until the finish frame, then `stop`, `tool_calls`, `length`, or `content_filter`. | | `usage` | Present only on the usage frame. | | `cruxy` | The Cruxy-native block; see [Chat](/docs/en/build/chat#the-cruxy-block). | > **Warning:** Check that `choices` is non-empty before you read `choices[0]`. The usage frame > has `choices: []` and arrives on every successful stream, so indexing it > unguarded crashes every time. The role primer's `content` is an empty string, not absent. Appending every `content` you see is safe; the primer adds nothing. ### Tool-call deltas When the model calls a tool, deltas carry `tool_calls` fragments instead of `content`. Every fragment has an `index` and a piece of `function.arguments`, a JSON string; fragments can also carry the call's `id`, `type`, and `function.name`. Assemble the fragments per `index`, concatenating the `arguments` pieces. A finish frame with `finish_reason: "tool_calls"` means the fragments are complete: run the calls and answer them as [Handle tool calls](/docs/en/build/tool-calls) describes. ## The usage frame The usage frame is the only frame with `usage`, and it is never merged into the finish frame before it, because an OpenAI-compatible client reads usage only from the frame whose `choices` is empty. | Field | Meaning | | --- | --- | | `prompt_tokens` | Input tokens. | | `completion_tokens` | Output tokens. | | `total_tokens` | The sum. | | `billable_input_tokens`, `cache_read_tokens`, `cache_write_tokens` | Omitted when zero; absence means zero. | | `prompt_tokens_details` | The prompt-cache breakdown, present only when the prompt cache was used. | | `cost` | An object, not a number: `amount`, `currency`, `tier`, and the per-million-token rates `input_unit`, `output_unit`, and `cache_read_unit`. Read `cost.amount` for a scalar. | A stream names its token fields the OpenAI way (`prompt_tokens`, `completion_tokens`); a non-streaming response uses `input_tokens` and `output_tokens`. [Usage and cost](/docs/en/build/usage-and-cost) covers both shapes, the `cost` object, and which tokens are billed. ## [DONE] is the only success signal Once the stream opens, the `200` is already sent. If the completion fails after that, no error frame follows: the gateway stops sending and closes the response without the finish frame, the usage frame, or `data: [DONE]`. > **Important:** A stream that ends before `data: [DONE]`, whether the body closes or a read > returns end-of-stream, is a failed, incomplete completion. Discard the partial > output or retry; never present it as a finished answer. The tokens already > delivered are real and are billed as a partial usage row. To detect it on the client: 1. Set a flag when you read `data: [DONE]`, as the sample above does with `sawDone`. 2. When the read loop ends, treat a missing flag as a failure. 3. Treat an exception from the read itself, such as a dropped connection, as a failure too. A stream that ends with `[DONE]` is complete, but the answer can still be cut short: `finish_reason: "length"` means the output reached `max_tokens` or the tier's cap. On `kavi`, which reasons by default, a small `max_tokens` can be spent entirely on thinking; see [Features overview](/docs/en/get-started/features#three-tiers-and-auto). The gateway retries a failing model only while the stream opens, never after a byte has reached you. A stream may run for up to five minutes. A stream stores and replays nothing under `Idempotency-Key`, since a stream cannot be replayed: a retry with the same key and body streams a new generation. When both attempts complete, the request is billed once. An attempt that did not complete is billed for the tokens it delivered, and the retry is billed as well. ## Other chat wires The rules above are for `POST /api/v1/chat`. The other two chat endpoints end their streams differently: - `POST /api/v2/chat` sends named events (`start`, `reasoning`, `delta`) and always ends with exactly one `finish` or `error` frame. It has no `[DONE]`; a stream that closes without `finish` or `error` failed. - `POST /v1/chat/completions` ends a stream that fails after it opens with a `data: {"error": {...}}` frame instead of `[DONE]`. It sends the usage frame only when the request sets `stream_options: {"include_usage": true}`. To render a stream in a browser without exposing your key, see [Streaming chat in the browser](/docs/en/resources/cookbook/streaming-ui). ## Next steps - [Chat](/docs/en/build/chat): the request options and the non-streaming response. - [Reliability](/docs/en/build/reliability): retry a stream that failed, and the deadlines it avoids. - [Handle tool calls](/docs/en/build/tool-calls): answer the tool calls a stream returns. - [Errors and retries](/docs/en/build/errors-and-retries): what to retry, and how. ================================================================================ Page: Embeddings URL: https://learn.getcruxy.com/docs/en/build/embeddings Markdown: https://learn.getcruxy.com/docs/en/build/embeddings.md ================================================================================ # Embeddings Turn text into vectors with the cruxy-embed model. `POST /api/v1/embeddings` takes one string or an array and returns a vector per input, ready to store and compare for search, clustering, or retrieval. It is OpenAI-compatible. Here is a single-string embedding: ```bash title="curl" curl https://api.cruxy.ai/api/v1/embeddings \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "cruxy-embed", "input": "The quick brown fox" }' ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/embeddings', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'cruxy-embed', input: 'The quick brown fox', }), }) const { data } = await res.json() const vector = data[0].embedding // number[] ``` ## The model `model` must be `"cruxy-embed"`. It is the only embedding model on the gateway. ## Single string or array `input` accepts either a single string or an array of strings. Pass an array to embed a batch in one call: ```bash curl https://api.cruxy.ai/api/v1/embeddings \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "cruxy-embed", "input": ["The quick brown fox", "jumps over the lazy dog"] }' ``` Each input maps to one entry in `data`, and the entry's `index` matches the position of its input. ## Response shape ```json { "object": "list", "model": "cruxy-embed", "data": [ { "object": "embedding", "index": 0, "embedding": [0.0023, -0.0117] } ], "usage": { "prompt_tokens": 8, "total_tokens": 8 }, "cruxy": { "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "cost": { "tier": "cruxy-embed" } } } ``` The `embedding` array is shown truncated; a real vector holds many more floats. The `cost` object carries the same fields documented under [Chat](/docs/en/build/chat#usage). | Field | Meaning | | --- | --- | | `object` | Always `"list"`. | | `data` | One `embedding` entry per input, each with its `index` and `embedding` vector. | | `usage` | `prompt_tokens` and `total_tokens` for the request. | | `cruxy` | Additive block with the `request_id` and `cost` (see [Chat](/docs/en/build/chat#the-cruxy-block)). | The `embedding` array holds the vector as floating-point numbers. Read a result at `data[i].embedding`, matching `data[i].index` to your input's position. ## Errors Embeddings returns `400` for a malformed request, `401` for a bad key, and `429` for `rate_limited` or `budget_exhausted`. See [Errors](/docs/en/reference/errors). ## Next steps - [Retrieval-augmented generation](/docs/en/resources/cookbook/rag): index a corpus and answer from it, end to end. - [Rerank](/docs/en/build/rerank): order retrieved candidates against a query. - [Chat](/docs/en/build/chat): completions across the tiers. - [Errors](/docs/en/reference/errors): the error shape. ================================================================================ Page: Rerank URL: https://learn.getcruxy.com/docs/en/build/rerank Markdown: https://learn.getcruxy.com/docs/en/build/rerank.md ================================================================================ # Rerank Order candidate documents against a query with the cruxy-rerank model. `POST /api/v1/rerank` scores each candidate document against your query and returns them best-first, so you can pick the strongest matches from a shortlist. Here is a working request: ```bash title="curl" curl https://api.cruxy.ai/api/v1/rerank \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "cruxy-rerank", "query": "What is the capital of France?", "documents": [ "Paris is the capital and largest city of France.", "Berlin is the capital of Germany.", "The Louvre is a museum in Paris.", "France is in Western Europe." ], "top_n": 3 }' ``` ```ts title="TypeScript" const documents = [ 'Paris is the capital and largest city of France.', 'Berlin is the capital of Germany.', 'The Louvre is a museum in Paris.', 'France is in Western Europe.', ] const res = await fetch('https://api.cruxy.ai/api/v1/rerank', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'cruxy-rerank', query: 'What is the capital of France?', documents, top_n: 3, }), }) const { results } = await res.json() const top = results[0] // highest relevance_score const topDocument = documents[top.index] ``` ## The model `model` must be `"cruxy-rerank"`. It is the only rerank model on the gateway. ## Not OpenAI-compatible Most of the gateway mirrors the OpenAI shape. This endpoint does not; it follows a Cohere-style rerank contract instead. There is no `object` envelope, and the results carry **indices into your input**, not the document text. Map each `index` back to your own `documents` array to recover the text, as the TypeScript example does above. ## Request fields | Field | Type | Example | Purpose | | --- | --- | --- | --- | | `model` | string | `"cruxy-rerank"` | Must be `"cruxy-rerank"`. | | `query` | string | `"What is the capital of France?"` | Scored against each document. | | `documents` | array | | Candidate strings to rank. | | `top_n` | integer | `3` | Cap on how many results come back. Omit to rank every document. | ## Ranking and top_n Every document is scored against `query`, and the response is sorted by `relevance_score` in **descending** order, the most relevant document first. `top_n` caps how many results return; with `top_n: 3` over four documents, only the top three come back. Omit `top_n` to get all of them, still ranked. ## Response shape ```json { "model": "cruxy-rerank", "results": [ { "index": 0, "relevance_score": 0.98 }, { "index": 2, "relevance_score": 0.71 }, { "index": 3, "relevance_score": 0.12 } ], "usage": { "prompt_tokens": 8, "total_tokens": 8 }, "cruxy": { "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "cost": { "tier": "cruxy-rerank" } } } ``` | Field | Meaning | | --- | --- | | `model` | Always `"cruxy-rerank"`. | | `results` | Ranked entries, `relevance_score` descending, capped to `top_n`. | | `index` | Position of the document in the request `documents` array. | | `relevance_score` | Relevance of that document to `query`; higher is more relevant. | | `usage` | `prompt_tokens` and `total_tokens` for the request. | | `cruxy` | Additive block with the `request_id` and `cost` (see [Chat](/docs/en/build/chat#the-cruxy-block)). | Each result's `index` points back into the `documents` you sent, so read the ranked text as `documents[results[i].index]`. A dropped document (one below the `top_n` cut) has no entry. ## Where it fits Rerank and [Embeddings](/docs/en/build/embeddings) are the two retrieval primitives on the gateway, and they work at different stages. A common pattern is to retrieve broadly with embeddings, then rerank the top candidates to settle their final order. | | Embeddings | Rerank | | --- | --- | --- | | Model id | `cruxy-embed` | `cruxy-rerank` | | What it does | Turns text into vectors you compare with cosine similarity. | Scores each candidate document against the query directly. | | Best for | A first-pass search over a large corpus. | Ordering a shortlist before you use it. | | Learn more | [Embeddings](/docs/en/build/embeddings) | [Retrieval-augmented generation](/docs/en/resources/cookbook/rag) | ## Errors Rerank returns `400` for a malformed request, `401` for a bad key, and `429` for `rate_limited` or `budget_exhausted`. See [Errors](/docs/en/reference/errors). ## Next steps - [Retrieval-augmented generation](/docs/en/resources/cookbook/rag): the embeddings-to-chat recipe this endpoint sits in. - [Embeddings](/docs/en/build/embeddings): the vectors you retrieve candidates with. - [Models](/docs/en/reference/models): discover tiers at runtime. - [Errors](/docs/en/reference/errors): the error shape. ================================================================================ Page: Moderations URL: https://learn.getcruxy.com/docs/en/build/moderations Markdown: https://learn.getcruxy.com/docs/en/build/moderations.md ================================================================================ # Moderations Classify text against content-safety categories with the cruxy-guard model. `POST /api/v1/moderations` checks text against the content-safety categories and returns whether each one matched, plus an overall `flagged`. It is OpenAI-compatible. Here is a single-string check: ```bash title="curl" curl https://api.cruxy.ai/api/v1/moderations \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "cruxy-guard", "input": "I want to learn how to bake bread." }' ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/moderations', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'cruxy-guard', input: 'I want to learn how to bake bread.', }), }) const { results } = await res.json() if (results[0].flagged) { // handle flagged content } ``` ## The model `model` must be `"cruxy-guard"`. It is the only moderation model on the gateway. ## Single string or array `input` accepts a single string or an array of strings. With an array, `results` holds one entry per input, in order. ## Response shape ```json { "id": "modr-a1b2c3d4-e5f6-7890-abcd-ef1234567890", "model": "cruxy-guard", "results": [ { "flagged": false, "categories": { "example_category": false }, "category_scores": { "example_category": 0.0 } } ] } ``` | Field | Meaning | | --- | --- | | `flagged` | `true` if any category matched. Check this first. | | `categories` | Map of category name to a boolean: whether that category matched. | | `category_scores` | Map of category name to a score. See the caveat below. | `categories` and `category_scores` share the same keys, one per moderation category. The keys shown above are illustrative; treat the category set as whatever the response returns rather than a fixed list. ## category_scores is binary The scores are **not** calibrated probabilities. The guard model emits labels, not likelihoods, so each value in `category_scores` is either `1.0` or `0.0`, the same information as the boolean in `categories`, expressed as a number. Do not threshold these scores or read them as confidence. To decide whether to act, use `flagged` (or the per-category booleans in `categories`). To run this check in front of a chat call, see the [Moderation gate](/docs/en/resources/cookbook/moderation-gate) recipe. ## Errors Moderations returns `400` for a malformed request, `401` for a bad key, and `429` for `rate_limited` or `budget_exhausted`. See [Errors](/docs/en/reference/errors). ## Next steps - [Moderation gate](/docs/en/resources/cookbook/moderation-gate): screen input before it reaches chat. - [Chat](/docs/en/build/chat): completions across the tiers. - [Errors](/docs/en/reference/errors): the error shape. ================================================================================ Page: Feedback URL: https://learn.getcruxy.com/docs/en/build/feedback Markdown: https://learn.getcruxy.com/docs/en/build/feedback.md ================================================================================ # Feedback Report the outcome of a prior call: accepts, rejects, thumbs, test results. `POST /api/v1/feedback` records how an earlier call turned out (an edit accepted or rejected, a thumbs rating, or a generated test's result), attached to that call by its `request_id`. Here is a working request: ```bash title="curl" curl https://api.cruxy.ai/api/v1/feedback \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "type": "thumbs", "consent": true, "payload": { "value": "up" } }' ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/feedback', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ request_id: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890', type: 'thumbs', consent: true, payload: { value: 'up' }, }), }) const { stored } = await res.json() // 202 Accepted; see below ``` ## Joining to a prior call `request_id` ties the feedback to the call it is about. It is the same UUID the gateway returns in the `cruxy.request_id` field of a chat, embeddings, rerank, or moderations response; see [the cruxy block](/docs/en/build/chat#the-cruxy-block). Log that id when you make a call, then send it back here when you learn the outcome. ## Event types `type` must be one of: | Value | | | --- | --- | | `edit_accepted` | | | `edit_rejected` | | | `thumbs` | | | `test_result` | | Each `type` fixes the shape of `payload`, and the gateway validates it, rejecting unknown keys, wrong types, missing required fields, and bad enum values with a `400`. `edit_accepted` and `edit_rejected` share one shape; `thumbs` and `test_result` each have their own. The [API Reference](/docs/en/reference/api) carries the exact fields for each; the request above sends a `thumbs` payload. ## Request fields | Field | Type | Example | Purpose | | --- | --- | --- | --- | | `request_id` | string | `"a1b2c3d4-…"` | The prior call this feedback is about. | | `type` | string | `"thumbs"` | One of the values above. | | `consent` | boolean | `true` | Whether this event may be stored. See [What gets stored](#what-gets-stored). | | `payload` | object | | Event data; its shape is fixed by `type` and validated (see [Event types](#event-types)). | ## What gets stored Feedback is privacy-gated, and the gate is strict. Two things decide whether an event is kept: - `consent`: the per-event boolean you send. - The account's opt-out: a setting outside this request. The outcomes are: - **Not consented, or the account has opted out**: the event is dropped entirely. No row is written and the payload is never stored. The response has `stored: false`. - **Consented, and not opted out**: a row is written, but its payload is redacted. The event is recorded; the payload you sent is not kept verbatim. The payload you send is never persisted as-is. A dropped event keeps nothing; a stored event keeps everything but the payload. Do not rely on reading the raw payload back later. ## The response The endpoint **always returns `202 Accepted`**, for a stored event and for a dropped one alike. A `202` means the event was accepted for processing, not that it was stored, so read `stored` to tell the two apart. ```json { "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "stored": true } ``` | Field | Meaning | | --- | --- | | `stored` | `true` if a row was written (payload redacted), `false` if the event was dropped. | | `id` | Identifier for the stored event. | ## Errors Feedback returns `400` for a malformed request and `401` for a bad key. A privacy drop is **not** an error; it still returns `202` with `stored: false`. See [Errors](/docs/en/reference/errors). ## Next steps - [Chat](/docs/en/build/chat#the-cruxy-block): where `request_id` comes from. - [Errors](/docs/en/reference/errors): the error shape. ================================================================================ Page: Choosing a tier URL: https://learn.getcruxy.com/docs/en/build/choosing-a-tier Markdown: https://learn.getcruxy.com/docs/en/build/choosing-a-tier.md ================================================================================ # Choosing a tier Compare kavi, vaani, and mira by profile, output cap, deadline, and reasoning default, then pick the tier that fits each call. You choose a tier on every chat request with the `model` field. A tier is a stable public id: the gateway resolves it to an upstream model on the server and never exposes which one, so the tier is the contract your code depends on. `GET /api/v1/models` returns the live catalog, so read it at runtime instead of hardcoding what this page shows: ```bash title="curl" curl https://api.cruxy.ai/api/v1/models \ -H "Authorization: Bearer $CRUXY_API_KEY" ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/models', { headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}` }, }) const { data } = await res.json() for (const tier of data) { console.log(tier.id, tier.label, tier.profile, tier.max_output, tier.reasoning) } ``` ## The three tiers Each tier has a display name (`label`) and a one-word `profile` that names its role. The profile is a qualitative descriptor, not a performance number. | Tier | Label | Profile | Output cap | Non-streaming deadline | | --- | --- | --- | --- | --- | | `kavi` | Kavi | Deep | 8192 tokens | 90 s | | `vaani` | Vaani | Balanced | 4096 tokens | 90 s | | `mira` | Mira | Fast | 2048 tokens | 45 s | - **Output cap** is the tier's `max_output`. A `max_tokens` you send above it, or no `max_tokens` at all, is clamped to the cap, so the gateway never generates more than the cap. - **Non-streaming deadline** bounds a `stream: false` call. The gateway makes one attempt; a call that runs past the deadline is cancelled and answered `504` `upstream_timeout`, and nothing is billed. A streaming call may run for up to five minutes. Every tier reads images today (`supports_vision` is `true` on each). Read each tier's `context_window`, `latency_target_ms`, `supports_tools`, and `pricing` from the catalog response. ## How each tier reasons A tier can think before it answers. Each catalog entry's `reasoning` object gives the effort a request gets when it sends no `reasoning_effort` (`default_effort`) and whether `low`, `medium`, and `high` are different depths on that tier (`graded`): | Tier | Profile | `default_effort` | `graded` | What that means | | --- | --- | --- | --- | --- | | `kavi` | Deep | `high` | `false` | Thinks unless you send `none`. The control is on or off, so `low` thinks as long as `high`. | | `vaani` | Balanced | `low` | `true` | Thinks lightly by default; `medium` and `high` think longer. | | `mira` | Fast | `none` | `true` | Answers without thinking unless you ask; `low`, `medium`, and `high` are graded. | These are the shipped defaults; the catalog reports the live ones. [Reasoning effort](/docs/en/build/reasoning-effort) covers every value and when to change it. > **Warning:** `kavi` thinks by default, and its thinking spends the same `max_tokens` budget > as the answer without coming back as content. A small `max_tokens` on `kavi` > can run out before any answer is written: the turn ends with > `finish_reason: "length"` and empty content. Send `reasoning_effort: "none"` > for a short, direct answer, or leave `max_tokens` unset to keep the tier's cap. ## Pick a tier for the job | | Kavi | Vaani | Mira | | --- | --- | --- | --- | | Model id | `kavi` | `vaani` | `mira` | | Reach for it when | The task needs multi-step reasoning or a long answer. It thinks by default and has the largest output cap. | Everyday calls. It thinks lightly by default, and its graded effort gives you more depth when a request needs it. | Short, direct calls. It answers without thinking unless you ask. | | Watch for | Thinking spends `max_tokens`. Leave `max_tokens` unset or large, or send `reasoning_effort: "none"` for a direct answer. | A 4096-token output cap. An answer that needs more ends with `finish_reason: "length"`. | The smallest output cap and a 45 s non-streaming deadline: stream a long turn. On a signed-in plan, `mira` has a monthly request cap of its own. | [Rate limits and budgets](/docs/en/build/rate-limits) shows how to read the `mira` request cap and the other budgets in `GET /api/v1/limits`, where it sits in the signed-in plan's `token_pool`. ## Pin a tier or let the router choose Name a tier when you need its behavior to hold on every call: the same output cap, deadline, and reasoning default. Send `model: "auto"` when your requests vary and you would rather the gateway pick per request; [Auto routing](/docs/en/build/auto-routing) covers what the router guarantees and how to read the tier it chose. A developer API key can carry a model allowlist. A request for a tier the allowlist excludes is `403` `model_not_allowed`; see [Get your API key](/docs/en/get-started/api-keys#what-a-key-is-allowed-to-do). ## Next steps - [Auto routing](/docs/en/build/auto-routing): let the gateway pick the tier per request. - [Reasoning effort](/docs/en/build/reasoning-effort): control how much a tier thinks. - [Models](/docs/en/reference/models): every field in the catalog response. - [Chat](/docs/en/build/chat): send your first request to a tier. ================================================================================ Page: Auto routing URL: https://learn.getcruxy.com/docs/en/build/auto-routing Markdown: https://learn.getcruxy.com/docs/en/build/auto-routing.md ================================================================================ # Auto routing Send model auto and the gateway picks a tier for each request before billing; read cruxy.routed_tier to see which tier served it. Send `model: "auto"` instead of a tier id and the gateway's server-side router picks the tier from the request content. It resolves `auto` to `kavi`, `vaani`, or `mira` before billing and before the upstream call, so `auto` never reaches a model, and every response names the tier that served it. ```bash title="curl" curl https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "auto", "stream": false, "messages": [ { "role": "user", "content": "Summarize the causes of the monsoon in three sentences." } ] }' ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'auto', stream: false, messages: [ { role: 'user', content: 'Summarize the causes of the monsoon in three sentences.' }, ], }), }) const completion = await res.json() console.log(completion.cruxy.routed_tier) // the tier that served the call console.log(completion.cruxy.reasoning_effort) // the effort it ran at console.log(completion.message.content) ``` ## Read the tier that served The routed tier comes back on every response, in a field that depends on the endpoint: | Endpoint | Where the routed tier is | | --- | --- | | `POST /api/v1/chat` | `cruxy.routed_tier`. On a stream it rides the role primer, the first frame, before any model output. | | `POST /api/v2/chat` | `routing.tier`, with `routing.mode` set to `explicit`, `auto`, or `auto_degraded`. On a stream it rides the `start` frame. | | `POST /v1/chat/completions` | The response's `model`. | `usage.cost.tier` also names the tier that served the request. The router can choose differently for two similar requests, so log `routed_tier` with each `request_id` and branch on the value you read, never on the tier you expected. ```json { "cruxy": { "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "routed_tier": "kavi", "reasoning_effort": "high" } } ``` ## What auto does for you - **Picks a healthy tier.** The router chooses a tier that is available before the call is sent. Once sent, a call never moves to another tier. When every tier is unavailable, the request is `503` `all_models_unavailable`; retry it with backoff. - **Chooses the reasoning effort.** With `reasoning_effort` omitted, `auto` chooses the effort per request and echoes it as `cruxy.reasoning_effort` (`routing.reasoning_effort` on the native wire). Send `reasoning_effort` to set it yourself. - **Routes images to a tier that reads them.** A turn whose messages attach an image goes to a tier with `supports_vision`. Every tier reads images today. - **Moves to `mira` instead of failing a budget.** When a budget would refuse the request and `mira` can serve it, the gateway moves an `auto` request to `mira` rather than answering `429` `budget_exhausted`. On the native wire, `routing.mode` is `auto_degraded`. [Rate limits and budgets](/docs/en/build/rate-limits) explains the budgets. ## Size max_tokens for any tier `max_tokens` is clamped to the cap of the tier the router picks: 8192 on `kavi`, 4096 on `vaani`, and 2048 on `mira`. If you set a small `max_tokens` on an `auto` request, also send `reasoning_effort`. Otherwise the router may pick `kavi`, whose default thinking can spend the whole budget and end the turn with `finish_reason: "length"` and empty content; see [Reasoning effort](/docs/en/build/reasoning-effort). ## When to name a tier instead - **You need the same behavior on every call:** one output cap, one non-streaming deadline, one reasoning default. - **You are measuring.** An evaluation or benchmark compares like with like, so pin the tier it measures. - **You count tokens first.** `POST /api/v1/tokens/count` rejects `auto` with `400`; count against the tier you plan to send. [Choosing a tier](/docs/en/build/choosing-a-tier) compares the three tiers. ## Next steps - [Choosing a tier](/docs/en/build/choosing-a-tier): what each tier is for. - [Reasoning effort](/docs/en/build/reasoning-effort): set the effort instead of letting `auto` choose. - [Streaming](/docs/en/build/streaming): read the routed tier from the role primer. - [Errors and retries](/docs/en/build/errors-and-retries): retry `503` `all_models_unavailable`. ================================================================================ Page: Reasoning effort URL: https://learn.getcruxy.com/docs/en/build/reasoning-effort Markdown: https://learn.getcruxy.com/docs/en/build/reasoning-effort.md ================================================================================ # Reasoning effort Set how much a tier thinks before it answers, from none to high, and keep hidden thinking from spending your whole max_tokens budget. `reasoning_effort` caps the hidden thinking a tier does before it answers. You send one of four values on a chat request, or omit it and the tier applies its default. The effort the call ran at comes back on the response as `cruxy.reasoning_effort`. This request asks `kavi` for a one-line answer, so it turns thinking off and keeps the budget small: ```bash title="curl" curl https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "kavi", "stream": false, "reasoning_effort": "none", "max_tokens": 256, "messages": [ { "role": "user", "content": "Is Mumbai north or south of Chennai? Answer in one sentence." } ] }' ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'kavi', stream: false, reasoning_effort: 'none', max_tokens: 256, messages: [ { role: 'user', content: 'Is Mumbai north or south of Chennai? Answer in one sentence.' }, ], }), }) const completion = await res.json() console.log(completion.cruxy.reasoning_effort) // "none" console.log(completion.message.content) ``` ## The four values | Value | Effect | | --- | --- | | `none` | Turns thinking off. The tier answers directly. | | `low`, `medium`, `high` | Turn thinking on. On a graded tier the three are different depths, from `low` to `high`; on an on-or-off tier each one turns thinking on. | Any other value is `400`. The field is accepted on `POST /api/v1/chat`, `POST /api/v2/chat`, and `POST /v1/chat/completions`. ## Defaults per tier Omit `reasoning_effort` and each tier applies its own default. `graded` says whether `low`, `medium`, and `high` are different depths on the tier. When it is `false` the control is on or off: `none` turns thinking off, every other value turns it on, `low` thinks as long as `high`, and only `max_tokens` bounds it. | Tier | Profile | Default | `graded` | | --- | --- | --- | --- | | `kavi` | Deep | `high` | `false`: on or off | | `vaani` | Balanced | `low` | `true` | | `mira` | Fast | `none` | `true` | With `model: "auto"`, the router chooses the effort per request unless you send one. These are the shipped defaults; `GET /api/v1/models` reports each tier's live `reasoning.default_effort` and `reasoning.graded`, read from the same configuration the chat endpoints apply. ## Thinking spends max_tokens A tier that thinks generates its thinking inside the same output budget as its answer. On `POST /api/v1/chat` and `POST /v1/chat/completions`, the thinking is not returned as content. So a small `max_tokens` on a tier that thinks can be used up before the answer starts. > **Warning:** `kavi` thinks by default (`high`), so `kavi` with a small `max_tokens` and no > `reasoning_effort` can return nothing: the turn ends with > `finish_reason: "length"` and empty content. For a short, direct answer, send > `reasoning_effort: "none"`. To > keep the thinking, leave `max_tokens` unset, which clamps it to the tier's cap, > or set it large. Check for the trap when you read a response, so a truncated turn never reaches your user as an empty answer: ```ts const { message, finish_reason } = completion if (finish_reason === 'length' && !message.content) { // The budget ran out before the answer began. Resend with // reasoning_effort: 'none', or with max_tokens unset. } ``` ## When to send none Send `none` when the answer needs no deliberation and you want it fast: - **Short, direct answers:** a classification, a yes or no, an extracted field, a one-line reply. - **A small `max_tokens`:** any time you cap the output tightly, so thinking cannot consume the cap. - **A turn that times out:** a `stream: false` call is bounded by the tier's deadline and answered `504` `upstream_timeout` past it. Lowering `reasoning_effort` or `max_tokens` shortens the turn; streaming lifts the limit to five minutes. Keep thinking on when the answer needs deliberation, and leave `max_tokens` unset there so the thinking and the answer share the tier's full cap. ## See the thinking The OpenAI-shaped wires do not return the thinking. The native wire, `POST /api/v2/chat`, does: a non-streaming response carries a top-level `reasoning` string, and a stream sends the thinking as `reasoning` events, separate from the answer's `delta` events. Either way it is present only when the tier reasoned. - It is not part of the answer, and it sits outside `message` on purpose. Do not send it back in the conversation. - There is no separate reasoning-token count. `usage.output_tokens` already includes the thinking, undivided. - The effort is echoed as `routing.reasoning_effort` on the native wire. ## Next steps - [Choosing a tier](/docs/en/build/choosing-a-tier): each tier's profile, output cap, and default. - [Auto routing](/docs/en/build/auto-routing): let the router choose the tier and the effort. - [Streaming](/docs/en/build/streaming): read the stream and its `finish_reason`. - [Errors and retries](/docs/en/build/errors-and-retries#timeouts): what to do about `504` `upstream_timeout`. ================================================================================ Page: Memory URL: https://learn.getcruxy.com/docs/en/build/memory Markdown: https://learn.getcruxy.com/docs/en/build/memory.md ================================================================================ # Memory Let chat recall stored facts and capture new ones. Memory is on by default for signed-in keys and off for developer keys until a request opts in. Memory gives chat two independent abilities, each set per request in the `memory` object. **Recall** reads the stored memories most relevant to the turn into it as background context. **Capture** lets the gateway store a durable fact from the turn for later ones. When a request sends no `memory` object, the key decides whether each one runs. ## Defaults depend on the key | Key | Recall and capture when `memory` is omitted | `bucket` in `GET /api/v1/limits` | | --- | --- | --- | | Signed-in credential: device login, OAuth login, and the Cruxy web app, desktop, CLI, and mobile | On | `subscription` | | Developer API key, created in the Cruxy Console | Off | `apikey` | | Headless key | Off | `headless` | A key minted by device login or OAuth login is a signed-in credential whichever client ran the flow, a third-party tool included. A developer key's memory is off because the key often serves people other than its owner: recall would put the owner's stored memory into their turns, and capture would store what they say. Use a developer API key for a server; see [Get your API key](/docs/en/get-started/api-keys#two-kinds-of-key). ## Opt in from a developer key Send `true` for each side you want. This request comes from an internal tool that only the key's owner uses, so it recalls the owner's memory and lets the turn store a new fact: ```bash title="curl" curl https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "vaani", "stream": false, "memory": { "recall": true, "capture": true }, "messages": [ { "role": "user", "content": "I work in IST. When should our weekly sync with the London team be?" } ] }' ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'vaani', stream: false, memory: { recall: true, capture: true }, messages: [ { role: 'user', content: 'I work in IST. When should our weekly sync with the London team be?', }, ], }), }) const completion = await res.json() console.log(completion.message.content) console.log(completion.cruxy.memory_recall) // what was recalled, or undefined console.log(completion.cruxy.memory_capture) // what was stored, or undefined ``` `true` needs a key whose allowed scopes include the `memory` family. An unscoped key has every family. A key without it gets `403` `key_scope_forbidden`, refused before anything is billed: | Error field | Value | | --- | --- | | `code` | `key_scope_forbidden` | | `required_scope` | `memory` | | `allowed_scopes` | The key's own families, for example `["chat"]` | A `403` never clears by waiting. A key whose allowed scopes omit `memory` can never use memory through chat, which makes it the key to issue for a service that must never see anyone's memory. > **Warning:** Never store secrets in memory, and never enable capture on anonymous or public > traffic. Memory has no expiry: a stored value stays, and recall can place it in > later turns, until someone deletes it. The capture filter drops content that > carries an email address, a phone number, or a card number; it does not > promise to catch a password, an API key, or a token. Capture on public traffic > stores what strangers say into the key owner's memory. ## What recall adds Recall draws on the same resolved view `GET /api/v1/memories` lists: your personal memory, your organization's, and every vault you can read whose `memory_scope` is `default`. - **`vault_id`** names the vault a chat is inside. Inside a `vault_only` vault, recall draws on that vault's memory alone, without personal or organization memory. A `vault_id` you cannot read is treated as if you sent none, never reported. - **`mentioned_vault_ids`** adds up to three vaults the turn references, such as an `@VersionGate` your picker resolved. Mentions share the turn's recall budget rather than enlarging it, and never make a vault writable. - **Billing.** The recalled block is billed input, so input tokens fall by its size when recall is off. The response reports what was recalled in `cruxy.memory_recall`: the scope and key of each memory, and for a vault memory its `vault_id` (and `vault_name` when it resolves). It never includes the stored value, and the field is absent when nothing was recalled. ```json { "cruxy": { "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "routed_tier": "vaani", "reasoning_effort": "low", "memory_recall": { "count": 1, "items": [{ "scope": "personal", "key": "timezone" }] } } } ``` ## What capture stores After the turn is answered, capture decides whether the exchange held a durable fact, such as a stated preference, a stable setting, or a constraint about your work, and stores it. - **Where it goes.** Your personal memory, or the vault the request's `vault_id` names when you can write to it. A vault you can only read demotes the capture to your personal memory. Capture never writes organization-wide memory, whatever your role. - **What it drops.** Content carrying an email address, a phone number, or a card number is dropped before any write. - **What it reports.** Every capture appears in `cruxy.memory_capture`, with the scope and key of each memory stored and never the value. No field means nothing was stored. - **What it never does.** Capture cannot fail or delay a turn. If extraction is unavailable, it stores nothing. On a stream, `memory_recall` rides the role primer and `memory_capture` rides the terminal usage frame, because what a turn stored is known only once the answer is complete. ## Turn memory off for one request Any key may send `false` for either side, and `false` needs no scope: ```json { "memory": { "recall": false, "capture": false } } ``` `recall: false` reads no memory and ignores `mentioned_vault_ids`. `capture: false` stores nothing. The two are independent, so `{ "capture": false }` alone keeps the turn personalized by what is already stored while adding nothing new. Pin `Cruxy-Version: 2026-09-26` on every request that relies on `false`. A gateway that predates the field would ignore it and capture anyway; with the pin, such a gateway refuses the request with `400` `unknown_version` instead. A server should use a developer API key. If a signed-in credential does serve other people, every request must send both `false` values, and `rules: false`, or the owner's memory and rules reach their turns. ## Review and remove what is stored - `GET /api/v1/memories` lists your effective memory, one entry per key, with the `id` of the row that won. - `DELETE /api/v1/memories/{id}` removes that entry. You can delete what you can write: your own memories, a vault's where your membership grants `can_write`, and organization memory if you are an owner or admin. - `PUT /api/v1/memories` stores a value by hand. A key or value carrying an email address, a phone number, or a card number is refused with `400` `pii_rejected`, and nothing is stored. [API Reference](/docs/en/reference/api) has every field of the memories endpoints. ## Next steps - [Standing rules](/docs/en/build/rules): the other per-request context, with the same default split. - [Get your API key](/docs/en/get-started/api-keys): the two kinds of key and their restrictions. - [Streaming](/docs/en/build/streaming): where the `cruxy` signals ride on a stream. - [Errors and retries](/docs/en/build/errors-and-retries): handle `403` `key_scope_forbidden`. ================================================================================ Page: Standing rules URL: https://learn.getcruxy.com/docs/en/build/rules Markdown: https://learn.getcruxy.com/docs/en/build/rules.md ================================================================================ # Standing rules Inject your organization's standing rules into chat as defaults. Rules are on for signed-in keys and off for developer keys unless you opt in. Standing rules are instructions your organization sets for chat, such as "Always answer in British English." A vault can have its own. When rules apply, the gateway injects them into the turn as defaults that the user's own message overrides, never as constraints that outrank the user. The `rules` field on a chat request turns injection on or off for that request. ## Defaults depend on the key | Key | Rules when `rules` is omitted | | --- | --- | | Signed-in credential: device login, OAuth login, and the Cruxy web app, desktop, CLI, and mobile | Injected | | Developer API key or headless key | Not injected, unless the key was issued with allowed scopes that name the `rules` family | An unscoped developer key has every family but has not opted in, so it gets no rules by default. A key's rules are its owner's organization's guidance, and a developer key often serves people other than its owner. ## Opt in A developer key opts in one of two ways: - **Per request:** send `rules: true`. - **Once, on the key:** issue the key with allowed scopes that name the `rules` family. Every request from it then gets rules unless it sends `rules: false`. ```bash title="curl" curl https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "vaani", "stream": false, "rules": true, "messages": [ { "role": "user", "content": "Draft a two-line status update for the launch." } ] }' ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'vaani', stream: false, rules: true, messages: [{ role: 'user', content: 'Draft a two-line status update for the launch.' }], }), }) const completion = await res.json() console.log(completion.message.content) console.log(completion.cruxy.rules_applied) // { count, sources }, or undefined ``` `rules: true` needs a key whose allowed scopes include `rules`. A key without it gets `403` `key_scope_forbidden` with `required_scope: "rules"`, refused before anything is billed, and such a key never gets rules. `rules: false` needs no scope: any key may send it to decline rules for one turn, since the user's own message already overrides them. > **Warning:** A model can repeat an injected rule. Anyone chatting through a key that gets > rules can ask the model to repeat them. Before you opt in a key > that serves other people, read `GET /api/v1/rules/resolved` and confirm > nothing in the result is meant to stay internal. ## What gets injected The switch covers the whole resolved ruleset: a turn gets exactly what `GET /api/v1/rules/resolved` returns for its context, or nothing. - **No `vault_id`:** the organization's rules. - **A `vault_id` for a vault that inherits** (the default): the organization's rules first, then the vault's own. - **A `vault_id` for a vault that does not inherit:** the vault's own rules only. A `vault_id` you cannot read is treated as none, so the turn runs with organization rules. Injected rule text is real input and counts in `usage.input_tokens`. Rules are separate from memory: the `memory` field never affects them. `POST /v1/chat/completions` never injects rules. Preview the rules a turn would get. Pass `vault_id` as a query parameter for a chat inside a vault: ```bash title="curl" curl https://api.cruxy.ai/api/v1/rules/resolved \ -H "Authorization: Bearer $CRUXY_API_KEY" ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/rules/resolved', { headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}` }, }) const resolved = await res.json() for (const rule of resolved.rules) { console.log(rule.source, rule.text) // "org" or "vault", then the instruction } ``` Each rule carries its `source`, `org` or `vault`, so an unexpected instruction traces back to the ruleset that contributed it. An empty `rules` array is a real answer: there is no default ruleset. Unlike chat, this endpoint answers `404` `vault_not_found` for a `vault_id` you cannot read, so a mistyped id is reported rather than resolved for the wrong context. ## Know when rules applied When at least one rule applied, the response carries `cruxy.rules_applied`: how many rules applied and from which rulesets, never the rule text. The field is absent when nothing was injected. On a stream it rides the role primer. ```json { "cruxy": { "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "routed_tier": "vaani", "reasoning_effort": "low", "rules_applied": { "count": 3, "sources": [{ "scope": "org", "count": 3 }] } } } ``` ## Edit the rules `GET /api/v1/rules` returns one stored ruleset, the organization's or, with `vault_id`, a vault's, and `PUT /api/v1/rules` replaces it whole. Its `permissions.can_write` tells you whether you may change it: the organization's ruleset is owner or admin only, and a vault's also admits a member the vault grants `can_write`. A refused write is `403` `rules_forbidden`. Rules are re-injected on every request in scope, so each ruleset is capped: | Cap | Limit | | --- | --- | | Rules per ruleset | 50 | | Characters per rule | 1000 | | Characters in total | 8000 | Exceeding a cap is `400` `rules_too_large`. A rule carrying an email address, a phone number, or a card number is refused with `400` `pii_rejected`, and nothing is stored. [API Reference](/docs/en/reference/api) has every field of the rules endpoints. ## Next steps - [Memory](/docs/en/build/memory): recall and capture, with the same default split. - [Get your API key](/docs/en/get-started/api-keys#what-a-key-is-allowed-to-do): allowed scopes and other key restrictions. - [Chat](/docs/en/build/chat): the rest of the request and the `cruxy` block. ================================================================================ Page: Define tools URL: https://learn.getcruxy.com/docs/en/build/tools Markdown: https://learn.getcruxy.com/docs/en/build/tools.md ================================================================================ # Define tools Describe your functions in the tools array with a JSON Schema for their arguments, and use tool_choice to control when the model calls one. Tool calling lets a chat model ask your code to run a function. You describe the functions on the request; when the model decides to use one, the turn ends with the call and its arguments, you run it, and you send the result back. This page covers the first part. [Handle tool calls](/docs/en/build/tool-calls) covers answering the calls, and [Tutorial: build a tool-using agent](/docs/en/build/tool-agent) runs the whole loop. ```bash title="curl" curl https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "kavi", "stream": false, "messages": [ { "role": "user", "content": "What is the weather in Paris?" } ], "tools": [ { "type": "function", "function": { "name": "get_weather", "description": "Get the current weather for a city", "parameters": { "type": "object", "properties": { "city": { "type": "string", "description": "City name, for example Paris" } }, "required": ["city"] } } } ], "tool_choice": "auto" }' ``` ```ts title="TypeScript" const tools = [ { type: 'function', function: { name: 'get_weather', description: 'Get the current weather for a city', parameters: { type: 'object', properties: { city: { type: 'string', description: 'City name, for example Paris' }, }, required: ['city'], }, }, }, ] const res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'kavi', stream: false, messages: [{ role: 'user', content: 'What is the weather in Paris?' }], tools, tool_choice: 'auto', }), }) const completion = await res.json() console.log(completion.finish_reason) // "tool_calls" when the model wants get_weather console.log(completion.message.tool_calls) ``` ## Describe a tool `tools` is an array. Each entry describes one function: | Field | Required | What it is | | --- | --- | --- | | `type` | Yes | `"function"`. | | `function.name` | Yes | The name the model calls the function by, and the name it returns in a tool call. | | `function.description` | No | What the function does, rendered into the prompt the model reads. | | `function.parameters` | No | A JSON Schema object describing the function's arguments, also rendered into the prompt. | Write the `description` and each parameter's `description` for the model: say what the function returns and when to use it, the way you would brief a colleague. ## Control tool use `tool_choice` controls whether the model may call your tools, and which: | `tool_choice` | Effect | | --- | --- | | Omitted | The provider default. | | `"none"` | The model calls no tool. | | `"auto"` | The model decides whether to call a tool. | | `"required"` | The model calls at least one tool. | | `{ "type": "function", "function": { "name": "get_weather" } }` | The model calls that function. This is the only form that makes a tool call deterministic. | A forced function must appear in `tools`, and `"required"` needs at least one tool. Either mistake is `400` `invalid_request`, answered before anything goes upstream. ## Schema bounds The gateway bounds each tool's `parameters` schema by size, depth, and node count. A schema past a bound is `400` `invalid_tool_schema`, which adds three fields: | Field | Meaning | | --- | --- | | `tool` | The function whose schema broke the bound. | | `bound` | Which bound: `bytes`, `depth`, or `nodes`. | | `limit` | That bound's value. | Retrying never helps, because the fix belongs in the tool definitions your client ships. To check them ahead of time, send the same `tools` to `POST /api/v1/tokens/count`: it applies the same bounds with the same `400` answer, is not billed, and returns the input tokens the request would bill, tools included. ## Tools are billed input The provider renders each function's name, description, and parameters into the prompt, so every request that sends `tools` is billed for them as input tokens. Count them with `POST /api/v1/tokens/count`, and keep descriptions as long as the model needs and no longer. ## Where tools work `tools` and `tool_choice` are accepted on `POST /api/v1/chat`, `POST /api/v2/chat`, and `POST /v1/chat/completions`. Each tier reports `supports_tools` in `GET /api/v1/models`; [Models](/docs/en/reference/models) shows how to read the catalog. ## Next steps - [Handle tool calls](/docs/en/build/tool-calls): run the calls the model returns and send the results back. - [Tutorial: build a tool-using agent](/docs/en/build/tool-agent): the full loop in one program. - [Chat](/docs/en/build/chat): the rest of the request and the response. ================================================================================ Page: Handle tool calls URL: https://learn.getcruxy.com/docs/en/build/tool-calls Markdown: https://learn.getcruxy.com/docs/en/build/tool-calls.md ================================================================================ # Handle tool calls Read the tool calls the model returns, run each function, send every result back by tool_call_id, and repeat until finish_reason is stop. When the model decides to call one of the tools you defined, the turn ends with `finish_reason: "tool_calls"` and the assistant message carries `tool_calls`. You run each function, then send a new request with the results; the model reads them and continues. [Define tools](/docs/en/build/tools) covers the request that offers the tools. ## Read the tool calls On `POST /api/v1/chat` with `stream: false`, the assistant reply is the top-level `message`. A turn that calls a tool looks like this, trimmed to the fields that matter here: ```json { "message": { "role": "assistant", "content": "", "tool_calls": [ { "id": "call_abc123", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" } } ] }, "finish_reason": "tool_calls" } ``` Each entry in `tool_calls` has three fields: | Field | What it is | | --- | --- | | `id` | The call's id. Your result for this call carries it back as `tool_call_id`. | | `type` | `"function"`. | | `function` | The `name` of the function to run, and its `arguments`. | `arguments` is a JSON-encoded string, not an object. Parse it before you dispatch: ```ts const args = JSON.parse(call.function.arguments) // { city: "Paris" } ``` ## Send the results back 1. **Append the assistant message whole** Push the `message` the gateway returned onto your conversation, `tool_calls` included. The model needs its own call in the history next to the result that answers it. 2. **Append one tool message per call** For every entry in `tool_calls`, run the function and append a message with `role: "tool"`, the call's `id` as `tool_call_id`, and the result as a string in `content`. 3. **Call chat again with the same tools** Send the whole conversation back with the same `tools`. The model reads the results and either answers or calls another tool. This request carries a finished tool exchange back to the model: ```bash title="curl" curl https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "kavi", "stream": false, "messages": [ { "role": "user", "content": "What is the weather in Paris?" }, { "role": "assistant", "content": "", "tool_calls": [ { "id": "call_abc123", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" } } ] }, { "role": "tool", "tool_call_id": "call_abc123", "content": "{\"city\":\"Paris\",\"tempC\":18,\"sky\":\"clear\"}" } ], "tools": [ { "type": "function", "function": { "name": "get_weather", "description": "Get the current weather for a city", "parameters": { "type": "object", "properties": { "city": { "type": "string" } }, "required": ["city"] } } } ] }' ``` ```ts title="TypeScript" const tools = [ { type: 'function', function: { name: 'get_weather', description: 'Get the current weather for a city', parameters: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'], }, }, }, ] // The assistant turn the gateway returned, kept whole. const assistantTurn = { role: 'assistant', content: '', tool_calls: [ { id: 'call_abc123', type: 'function', function: { name: 'get_weather', arguments: '{"city":"Paris"}' }, }, ], } const messages = [ { role: 'user', content: 'What is the weather in Paris?' }, assistantTurn, // Your result, tied back to the call by its id. { role: 'tool', tool_call_id: 'call_abc123', content: JSON.stringify({ city: 'Paris', tempC: 18, sky: 'clear' }), }, ] const res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'kavi', stream: false, messages, tools }), }) const completion = await res.json() console.log(completion.finish_reason) // "stop" once the model has its answer console.log(completion.message.content) ``` ## Loop on finish_reason `finish_reason` tells you what to do with each turn: | `finish_reason` | What it means | What you do | | --- | --- | --- | | `tool_calls` | The model wants the calls in `message.tool_calls`. | Run them, append the results, and call again. | | `stop` | The model finished its reply. | Read the answer in `message.content`. | | `length` | The output hit `max_tokens` or the tier's cap. | Raise `max_tokens` or lower `reasoning_effort`; see [Reasoning effort](/docs/en/build/reasoning-effort). | | `content_filter` | The upstream model stopped for safety. | Stop the loop and handle the turn as unanswered. | Keep calling while `finish_reason` is `tool_calls`, and stop when it returns `stop`. Put a ceiling on the number of rounds too, so a model that keeps calling tools cannot run your loop forever. ## Several calls in one turn `tool_calls` is an array, so one turn can ask for several calls. Run all of them and append one `tool` message for each before you call chat again. The `tool_call_id` on each result is what pairs it with its call. If a function fails, still answer its call: put a short description of the failure in that tool message's `content`, so every call in the turn has a result. ## Send the tools on every request Keep sending the same `tools` for as long as the conversation holds tool exchanges. When the history replays a tool call to a function that the request's `tools` does not declare, the gateway sends that exchange to the model as prose instead of as a tool call, and reports it in `cruxy.undeclared_tool_replay`: | Field | Meaning | | --- | --- | | `exchanges` | How many assistant messages were sent as prose. | | `tool_calls` | How many tool calls those messages carried. | The field is absent when nothing was rewritten, and nothing you store or send is changed: only what the model reads that turn. ## Tool calls on a stream With `stream: true`, the calls arrive in pieces. Deltas carry `tool_calls` fragments instead of `content`; each fragment has an `index` and a piece of `function.arguments`, and may carry the call's `id`, `type`, and `function.name`. Assemble the fragments per `index`, concatenating the `arguments` pieces. The finish frame with `finish_reason: "tool_calls"` means the calls are complete; answer them as above. [Tool-call deltas](/docs/en/build/streaming#tool-call-deltas) shows the frames. On the native wire, `POST /api/v2/chat`, the fragments ride `delta` events and the terminal `finish` event carries `finish_reason`. ## Next steps - [Tutorial: build a tool-using agent](/docs/en/build/tool-agent): this loop as one working program. - [Define tools](/docs/en/build/tools): the tool shape, `tool_choice`, and schema bounds. - [Streaming](/docs/en/build/streaming): the full frame order. ================================================================================ Page: Tutorial: build a tool-using agent URL: https://learn.getcruxy.com/docs/en/build/tool-agent Markdown: https://learn.getcruxy.com/docs/en/build/tool-agent.md ================================================================================ # Tutorial: build a tool-using agent Build a working TypeScript agent that offers two tools, runs the calls the model returns, and loops on finish_reason until it answers. In this tutorial you build a small agent: one TypeScript file that answers a travel question by calling two functions of yours. It uses `POST /api/v1/chat` with `stream: false` and the loop from [Handle tool calls](/docs/en/build/tool-calls). You finish with a program you can run, and a loop you can reuse with your own tools. ## Before you start - Node.js 22 or later, for the built-in `fetch`. - A developer API key in `CRUXY_API_KEY`; see [Get your API key](/docs/en/get-started/api-keys). - A way to run TypeScript directly. The commands here use `npx tsx`. ## Step 1: Watch one tool call Before writing the loop, send one request by hand and look at what comes back. Offer a `get_weather` tool and ask about the weather: ```bash title="curl" curl https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "kavi", "stream": false, "messages": [ { "role": "user", "content": "What is the weather in Paris?" } ], "tools": [ { "type": "function", "function": { "name": "get_weather", "description": "Get the current weather for a city", "parameters": { "type": "object", "properties": { "city": { "type": "string" } }, "required": ["city"] } } } ] }' ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'kavi', stream: false, messages: [{ role: 'user', content: 'What is the weather in Paris?' }], tools: [ { type: 'function', function: { name: 'get_weather', description: 'Get the current weather for a city', parameters: { type: 'object', properties: { city: { type: 'string' } }, required: ['city'], }, }, }, ], }), }) const { message, finish_reason } = await res.json() console.log(finish_reason) // "tool_calls" console.log(message.tool_calls) ``` When the model chooses the tool, `finish_reason` is `"tool_calls"` and `message.tool_calls` holds the call: an `id`, the function `name`, and its `arguments` as a JSON string. The model does not run anything. Your program runs the function and sends the result back, and the rest of this tutorial automates that. ## Step 2: Describe the tools Create `agent.mts`. Start with the types the loop passes around and the two tools the model may call: ```ts const BASE = 'https://api.cruxy.ai' interface ToolCall { id: string type: 'function' function: { name: string; arguments: string } } type Message = | { role: 'system' | 'user'; content: string } | { role: 'assistant'; content: string; tool_calls?: ToolCall[] } | { role: 'tool'; tool_call_id: string; content: string } interface Completion { message: { role: 'assistant'; content: string; tool_calls?: ToolCall[] } finish_reason: 'stop' | 'tool_calls' | 'length' | 'content_filter' } const tools = [ { type: 'function', function: { name: 'get_weather', description: 'Get the current weather for a city: temperature in Celsius and the sky.', parameters: { type: 'object', properties: { city: { type: 'string', description: 'City name, for example Paris' } }, required: ['city'], }, }, }, { type: 'function', function: { name: 'get_local_time', description: 'Get the current local time in a city.', parameters: { type: 'object', properties: { city: { type: 'string', description: 'City name, for example Mumbai' } }, required: ['city'], }, }, }, ] ``` [Define tools](/docs/en/build/tools) covers every field of a tool. ## Step 3: Write the handlers Each tool name maps to a function of yours. `runTool` parses the call's `arguments`, runs the matching handler, and always returns a string for the tool message, even when the call names an unknown tool or the handler throws: ```ts const TIME_ZONES: Record = { paris: 'Europe/Paris', mumbai: 'Asia/Kolkata', london: 'Europe/London', } const handlers: Record Promise> = { async get_weather({ city }) { // Replace with a call to your weather service. return { city, tempC: 18, sky: 'clear' } }, async get_local_time({ city }) { const timeZone = TIME_ZONES[city.toLowerCase()] if (!timeZone) return { city, error: 'unknown city' } return { city, time: new Date().toLocaleTimeString('en-GB', { timeZone }) } }, } async function runTool(call: ToolCall): Promise { const handler = handlers[call.function.name] if (!handler) return JSON.stringify({ error: `no tool named ${call.function.name}` }) try { const result = await handler(JSON.parse(call.function.arguments)) return JSON.stringify(result) } catch (err) { return JSON.stringify({ error: String(err) }) } } ``` ## Step 4: Call the model `chat` sends the conversation so far, with the tools, and returns the turn. It throws on an error response, naming the stable `code` and the `request_id`: ```ts async function chat(messages: Message[]): Promise { const res = await fetch(`${BASE}/api/v1/chat`, { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'kavi', stream: false, messages, tools }), }) if (!res.ok) { const { code, error, request_id } = await res.json() throw new Error(`${res.status} ${code}: ${error} (request ${request_id})`) } return res.json() } ``` The request leaves `max_tokens` unset. `kavi` thinks by default, and its thinking shares the `max_tokens` budget, so a small cap could end a turn with `finish_reason: "length"` before the model writes a call or an answer; see [Reasoning effort](/docs/en/build/reasoning-effort). ## Step 5: Run the loop The loop calls the model, keeps the assistant turn, and branches on `finish_reason`. On `tool_calls` it answers every call by `id` and goes around again; on `stop` the turn's `content` is the answer: ```ts async function runAgent(question: string, maxRounds = 8): Promise { const messages: Message[] = [{ role: 'user', content: question }] for (let round = 0; round < maxRounds; round++) { const { message, finish_reason } = await chat(messages) messages.push(message) // the assistant turn, tool_calls and all if (finish_reason === 'tool_calls') { for (const call of message.tool_calls ?? []) { messages.push({ role: 'tool', tool_call_id: call.id, content: await runTool(call) }) } continue } if (finish_reason === 'stop') return message.content throw new Error(`the turn ended with finish_reason "${finish_reason}"`) } throw new Error(`no answer after ${maxRounds} rounds`) } console.log( await runAgent( 'I fly from Mumbai to Paris tonight. What is the weather in Paris, and what time is it there now?', ), ) ``` ## Step 6: Run it With the four pieces in `agent.mts`, in order, run: ```bash npx tsx agent.mts ``` The program prints the model's answer. Along the way the model asks for `get_weather` and `get_local_time`, possibly both in one turn, and the loop answers each call before it asks again. ## Why the loop is shaped this way - **It branches on `finish_reason`.** `tool_calls` means the model is waiting on your results, and `stop` means it has answered. `length` and `content_filter` end the loop with an error, because neither carries a finished answer. - **It appends the assistant turn whole.** The model needs to see its own call next to the result that answers it, so the turn goes into the history, `tool_calls` included, before any result. - **It answers every call by `id`.** One turn can request several calls. Each result carries its call's `id` as `tool_call_id`, which is how the model pairs results with calls. - **It sends the same `tools` every round.** A replayed call to a tool the request does not declare reaches the model as prose, not as a tool call. - **It always returns a string from `runTool`.** A failed or unknown call still gets a result, so no call goes unanswered. - **It stops after `maxRounds`.** The bound is a backstop for a model that never settles; reaching it is worth logging, not presenting as an answer. ## Next steps - [Handle tool calls](/docs/en/build/tool-calls): the response shapes the loop reads, and tool calls on a stream. - [Define tools](/docs/en/build/tools): `tool_choice` and the schema bounds. - [Errors and retries](/docs/en/build/errors-and-retries): decide which errors `chat` should retry. ================================================================================ Page: Reliability URL: https://learn.getcruxy.com/docs/en/build/reliability Markdown: https://learn.getcruxy.com/docs/en/build/reliability.md ================================================================================ # Reliability Run Cruxy in production: per-tier deadlines, which errors to retry and how, Idempotency-Key, telling a finished stream from a failed one, and status. This page collects what the gateway guarantees when a call is slow or fails, and what it leaves to your client. Each section links to the page that covers its topic in full. The sample streams a long `kavi` turn, accepts the answer only once the stream finishes, and retries a failed attempt: ```bash title="curl" # One key per logical request: reuse it if you send this request again. IDEMPOTENCY_KEY=$(uuidgen) # A long kavi turn: stream it. --max-time sits above the five-minute limit # on a stream, so the gateway, not curl, decides when the turn has run long. curl -sN --max-time 330 https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -d '{ "model": "kavi", "stream": true, "messages": [ { "role": "user", "content": "Plan a three-day walking tour of Old Delhi." } ] }' > stream.txt # The completion finished only if the stream ended with [DONE]. grep -q '^data: \[DONE\]' stream.txt && echo "complete" || echo "incomplete: send it again" ``` ```ts title="TypeScript" // Your retry budget and timeout. Tune them for your workload; they are not API // limits. A stream may run for up to five minutes, so the timeout sits above it. const MAX_ATTEMPTS = 3 const BASE_DELAY_MS = 500 const TIMEOUT_MS = 330_000 // Transient failures the API marks retryable. const RETRYABLE = new Set([ 'rate_limited', 'credits_unavailable', 'headless_concurrency_limit', 'limits_unavailable', 'upstream_overloaded', 'model_unavailable', 'all_models_unavailable', 'server_shutting_down', 'internal_error', ]) const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) // Exponential backoff with full jitter. const backoff = (attempt: number) => Math.random() * BASE_DELAY_MS * 2 ** attempt type Attempt = { answer: string } | { retryInMs: number } async function streamOnce( body: Record, idempotencyKey: string, attempt: number, ): Promise { let res: Response try { res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey, }, body: JSON.stringify({ ...body, stream: true }), signal: AbortSignal.timeout(TIMEOUT_MS), }) } catch { return { retryInMs: backoff(attempt) } // no response at all: send it again } if (!res.ok) { const { code, error, request_id } = await res.json() if (!RETRYABLE.has(code)) throw new Error(`${res.status} ${code}: ${error} (request_id ${request_id})`) const retryAfter = Number(res.headers.get('Retry-After')) return { retryInMs: retryAfter > 0 ? retryAfter * 1000 : backoff(attempt) } } if (!res.body) return { retryInMs: backoff(attempt) } const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' let answer = '' try { while (true) { const { value, done } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() ?? '' // keep a partial line for the next read for (const raw of lines) { const line = raw.trim() if (!line.startsWith('data:')) continue const data = line.slice('data:'.length).trim() if (data === '[DONE]') return { answer } // the only success signal const choice = JSON.parse(data).choices[0] // undefined on the usage frame if (choice?.delta.content) answer += choice.delta.content } } } catch { // The connection dropped mid-stream: fall through and treat it as a failure. } // The stream ended before [DONE]: a failed completion. Discard the partial answer. return { retryInMs: backoff(attempt) } } async function chat(body: Record): Promise { // One key per logical request, reused on every retry of it: if two attempts // both complete, the request is billed once. const idempotencyKey = crypto.randomUUID() for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { const result = await streamOnce(body, idempotencyKey, attempt) if ('answer' in result) return result.answer if (attempt < MAX_ATTEMPTS) await sleep(result.retryInMs) } throw new Error(`no complete answer after ${MAX_ATTEMPTS} attempts`) } const answer = await chat({ model: 'kavi', messages: [{ role: 'user', content: 'Plan a three-day walking tour of Old Delhi.' }], }) console.log(answer) ``` ## Deadlines and timeouts With `stream: false`, the gateway makes one attempt at the model, bounded by the tier's deadline: | Tier | Deadline | | --- | --- | | `kavi` | 90 seconds | | `vaani` | 90 seconds | | `mira` | 45 seconds | An attempt that runs past its deadline is cancelled and answered `504` `upstream_timeout`. Nothing is billed, and the gateway does not retry it, since a turn that needed longer needs longer again. The same deadlines apply on all three chat wires: `POST /api/v1/chat`, `POST /api/v2/chat`, and `POST /v1/chat/completions`. - **Stream long turns.** A stream may run for up to five minutes. A long `kavi` turn, with reasoning on and a large output, belongs on a stream. - **Or ask for less.** A lower `max_tokens` or `reasoning_effort` shortens the turn; see [Reasoning effort](/docs/en/build/reasoning-effort). - **Set your client timeout above the deadline,** or above five minutes for a stream, so a slow model reaches you as a `504` with a `request_id` rather than as your own abort. ## What the gateway retries for you - **A failing model, inside the gateway.** An overloaded or failing model, or a dropped connection to it, is retried a bounded number of times, with backoff, before the gateway answers `503` `upstream_overloaded` or `model_unavailable`. On a stream, that happens only while the stream opens, never after a byte has reached you. - **Never a non-streaming timeout.** See [Deadlines and timeouts](#deadlines-and-timeouts). - **Never on another tier.** Once a call is sent, it stays on its tier. `model: "auto"` picks a healthy tier before the call, and an explicit tier that keeps failing is refused at once with `503` `model_unavailable` while it cools down. See [Auto routing](/docs/en/build/auto-routing). ## What to retry Branch on the error's `code`, never on its message: | Response | What to do | | --- | --- | | `429` `rate_limited`, `credits_unavailable`, `headless_concurrency_limit`, or `503` `limits_unavailable` | Wait the `Retry-After` seconds, then retry. | | `503` `upstream_overloaded`, `model_unavailable`, `all_models_unavailable`, `server_shutting_down`, or `500` `internal_error` | Back off, then retry. | | `504` `upstream_timeout` | Change the request first: stream it, or lower `max_tokens` or `reasoning_effort`. | | `429` `budget_exhausted`, `key_spend_cap_exceeded`, `workspace_spend_cap_exceeded`, `credits_exhausted` | Stop. Each clears only at its `reset_at`, or when the cap changes. | | No response, or a stream that ended before it finished | Send the request again, with the same `Idempotency-Key`. | | Any other code | Look it up in [Errors and retries](/docs/en/build/errors-and-retries#which-failures-to-retry). Most describe the request or the key, and fail the same way until you change them. | When you retry: - **Honour `Retry-After`.** Every `429` sends it, and so does a `503` `limits_unavailable`. - **Otherwise back off exponentially, with jitter,** so many clients that failed together do not retry together. - **Cap the attempts and the total wait,** so a persistent failure ends instead of hanging. - **Log the `request_id` of every failure,** retried or not; see [Request IDs and metadata](/docs/en/build/request-ids). ## Retry safely with Idempotency-Key Send an `Idempotency-Key` header, one per logical request, and reuse it on every retry of that request: - **A success is stored for 24 hours.** A later or concurrent call with the same key and the same body replays it byte for byte, without calling the model and without billing again. A concurrent duplicate waits for the first call to finish. - **Only a success is stored.** An error, a `429` or any `5xx` included, releases the key, so a retry with the same key and body runs the request again. That is the intended way to retry. - **A different body under the same key** is `409` `idempotency_conflict`. - **A response over 256 KiB** is served but not stored, so a retry runs again. It is still billed once. - **A key is at most 255 characters,** and is scoped to your API key and the endpoint. - **A stream stores and replays nothing.** A retry with the same key and body streams a new generation. When both attempts complete, the request is billed once; an attempt that did not complete is billed for the tokens it delivered, and the retry is billed as well. [Errors and retries](/docs/en/build/errors-and-retries#make-retries-idempotent) lists the endpoints that accept the header. ## Confirm a stream finished Once a stream opens, its `200` is already sent, so a failure after that point cannot change the status. How the stream tells you it failed depends on the wire: - **`POST /api/v1/chat`:** `data: [DONE]` is the only success signal. A failure after the stream opens sends no error frame; the stream stops early. Treat a stream that ends before `[DONE]` as a failed completion: discard the partial output or retry, and never show it as a finished answer. - **`POST /api/v2/chat`:** the stream always ends with exactly one `finish` or `error` frame. It has no `[DONE]`. - **`POST /v1/chat/completions`:** a failure after the stream opens ends it with a `data: {"error": {...}}` frame instead of `[DONE]`. The tokens a failed stream delivered are billed as a partial usage row. [Streaming](/docs/en/build/streaming#done-is-the-only-success-signal) shows how to detect each case. ## Check platform status [status.getcruxy.com](https://status.getcruxy.com) is the Cruxy status page. For the same picture in code, `GET /api/status` returns a status document. It takes no key. ```bash title="curl" curl https://api.cruxy.ai/api/status ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/status') // A 503 still carries the document, with status "unknown": no model component // has a fresh measurement, so the document cannot be trusted. const status = await res.json() console.log(`platform: ${status.status}`) // Iterate the array and read each row's kind; new components are appended. for (const component of status.components) { console.log(`${component.id} (${component.kind}): ${component.status}`) } ``` - **`status`** is the whole platform in one word: `healthy`, `degraded`, `down`, or `unknown`. - **`components`** has a row for the API, the database, each chat tier, and the `cruxy-embed`, `cruxy-rerank`, and `cruxy-guard` models, each with its own `status` and when it was last checked. A component that is measurably down is a `200` carrying `down`; the document is a `503` only when no model component has a fresh measurement. - **The verdicts come from active probes,** not from live traffic, and the response is never cached. `GET /api/health` reports only that the gateway's HTTP listener is up. It checks no model or database, so do not read it as platform status. ## Production checklist - **Use a developer API key on a server.** See [Get your API key](/docs/en/get-started/api-keys). - **Pin `Cruxy-Version`,** and ignore JSON fields you do not recognize. - **Set client timeouts above the tier deadline,** or above five minutes for a stream. - **Stream long `kavi` turns,** and size `max_tokens` for the thinking as well as the answer. - **Send an `Idempotency-Key`** on every request that takes one, and reuse it on that request's retries. - **Retry only what the table above says,** honouring `Retry-After`, with a cap on attempts. - **Check for `[DONE]`,** or a `finish` frame on v2, before you show a streamed answer. - **Log the `X-Request-Id`, the `request_id`, and the `Cruxy-Version`** of every call. - **Read [Known limitations](/docs/en/build/known-limitations)** before you launch. ## Next steps - [Errors and retries](/docs/en/build/errors-and-retries): every error code, and a retrying client. - [Streaming](/docs/en/build/streaming): the frames, and a complete stream reader. - [Rate limits and budgets](/docs/en/build/rate-limits): read your headroom before you hit a `429`. - [Known limitations](/docs/en/build/known-limitations): what the API does not do yet, and the workarounds. ================================================================================ Page: Errors and retries URL: https://learn.getcruxy.com/docs/en/build/errors-and-retries Markdown: https://learn.getcruxy.com/docs/en/build/errors-and-retries.md ================================================================================ # Errors and retries Read the error envelope, look up every error code the API defines, and decide which failures to retry and how long to wait. A failed request returns a non-2xx status and a JSON error body. Read the HTTP status first, then branch on the body's `code`: the code is stable, while the wording of the message can change. ## The error envelope ```json { "code": "invalid_request", "error": "invalid JSON body", "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } ``` | Field | Meaning | | --- | --- | | `code` | A stable, machine-readable error code. Branch on this. | | `error` | A human-readable message. Log it; do not parse it. | | `request_id` | The id of the failed call, equal to the response's `X-Request-Id` header. Log it with every failure; see [Request IDs and metadata](/docs/en/build/request-ids). | Some codes add fields next to these three. Each is optional, and present only on the codes listed: | Field | Sent on | | --- | --- | | `tier` | The tier a model failure or context check was about: `upstream_error`, `upstream_overloaded`, `upstream_timeout`, `model_unavailable`, and `server_shutting_down` on chat, and `context_overflow`. | | `reason` | Why: `rate` or `concurrency` on `rate_limited` from chat; why a document's text could not be read on `file_not_text`. | | `retry_after` | The `Retry-After` value in seconds: `rate_limited`, `limits_unavailable`, `credits_unavailable`, `headless_concurrency_limit`. | | `reset_at` | When the refusal clears (RFC 3339): `budget_exhausted`, `key_spend_cap_exceeded`, `workspace_spend_cap_exceeded`, `credits_exhausted`. `null` on `model_not_allowed`, since waiting never clears an allowlist. | | `model`, `state`, `window`, `mira_available` | A spend or budget refusal: `budget_exhausted`, `key_spend_cap_exceeded`, `workspace_spend_cap_exceeded`, `model_not_allowed`. `window` is `monthly` or `burst`; `mira_available` says whether the same request on `mira` would be served now. | | `balance`, `currency` | A headless key's remaining credit, on `credits_exhausted`. | | `dimension` | The check that faulted on `limits_unavailable`: `rate` or `budget`. | | `required_scope`, `allowed_scopes` | The family the key would need and the families it has, on `key_scope_forbidden`. | | `required_version` | The `Cruxy-Version` a request field needs pinned, on `invalid_request`. | | `hazard_codes` | The blocking classifier codes behind `moderation_rejected`. | | `tool`, `bound`, `limit` | The function and the cap it broke, on `invalid_tool_schema`. `bound` and `limit` also name the cap on `file_too_large`, and `limit` is the context window on `context_overflow`. | | `file_id`, `filename`, `content_type`, `tokens` | The refused file, on `file_not_found`, `file_not_text`, `file_too_large`, and `image_not_supported`. | | `vision_tiers` | The tiers that read images, heaviest first, on `image_not_supported`. | | `measured_bytes`, `limit_bytes` | The size and the cap on `input_not_countable`. | Ignore fields you do not recognize. Three surfaces use a different shape: - **`POST /v1/chat/completions`** uses the OpenAI envelope, `{"error": {"message", "type", "param", "code"}}`. Its `code` is the same Cruxy code listed here; `type` is a coarse category derived from the status (`invalid_request_error`, `authentication_error`, `permission_error`, `not_found_error`, `rate_limit_error`, or `server_error`); `param` is always `null`. - **Device login** answers a pending or refused poll with an OAuth-style body; see [Get your API key](/docs/en/get-started/api-keys#device-login). - **A `POST /api/v2/chat` stream** that fails after it opens ends with an `error` event carrying `code`, `error`, and `request_id`. ## Handle errors and retry safely This request sends an `Idempotency-Key`, so once it succeeds, running it again with the same key replays the stored response instead of calling the model and billing again. The gateway stores only a success, so a retry after an error runs the request again under the same key. The TypeScript version retries the codes the API marks retryable, waiting `Retry-After` when it is sent, and retries a request that got no response with the same key: ```bash title="curl" IDEMPOTENCY_KEY=$(uuidgen) # -i prints the status line and headers, including Cruxy-Version and the # X-RateLimit-* headers. Run it twice: once the first call succeeds, the # second replays it. curl -i https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: $IDEMPOTENCY_KEY" \ -d '{ "model": "vaani", "stream": false, "messages": [ { "role": "user", "content": "Hello" } ] }' ``` ```ts title="TypeScript" class CruxyError extends Error { constructor( readonly status: number, readonly code: string, readonly requestId: string, message: string, ) { super(`${status} ${code}: ${message} (request_id ${requestId})`) } } // Your retry budget and timeout. Tune them for your workload; they are not API limits. // TIMEOUT_MS sits above the longest tier deadline (90 s), so a slow model // reaches you as 504 upstream_timeout rather than as your own abort. const MAX_ATTEMPTS = 4 const BASE_DELAY_MS = 500 const TIMEOUT_MS = 120_000 // Transient failures the API marks retryable. upstream_timeout is left out: // the gateway already made its one attempt, and the same request usually // times out again. const RETRYABLE = new Set([ 'rate_limited', 'credits_unavailable', 'headless_concurrency_limit', 'limits_unavailable', 'upstream_overloaded', 'model_unavailable', 'all_models_unavailable', 'server_shutting_down', 'internal_error', ]) const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) // Exponential backoff with full jitter. const backoff = (attempt: number) => Math.random() * BASE_DELAY_MS * 2 ** attempt async function chat(body: Record) { // One key per logical request, reused on every retry of it. const idempotencyKey = crypto.randomUUID() for (let attempt = 1; ; attempt++) { let res: Response try { res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', 'Idempotency-Key': idempotencyKey, }, body: JSON.stringify({ ...body, stream: false }), signal: AbortSignal.timeout(TIMEOUT_MS), }) } catch (err) { // No response: the call may or may not have run. If it succeeded, the // same key replays its stored response rather than running and billing // it a second time; if it failed, the same key runs it again. if (attempt >= MAX_ATTEMPTS) throw err await sleep(backoff(attempt)) continue } if (res.ok) return res.json() const { code, error, request_id } = await res.json() // An error is never stored, so the same key runs the request again. if (RETRYABLE.has(code) && attempt < MAX_ATTEMPTS) { const retryAfter = Number(res.headers.get('Retry-After')) await sleep(retryAfter > 0 ? retryAfter * 1000 : backoff(attempt)) continue } throw new CruxyError(res.status, code, request_id, error) } } const completion = await chat({ model: 'vaani', messages: [{ role: 'user', content: 'Hello' }], }) console.log(completion.message.content) ``` ## Which failures to retry | Failure | Retry? | How | | --- | --- | --- | | `429` `rate_limited`, `credits_unavailable`, `headless_concurrency_limit` | Yes | Wait `Retry-After` seconds, then retry. | | `503` `upstream_overloaded`, `model_unavailable`, `all_models_unavailable`, `server_shutting_down`, `limits_unavailable` | Yes | Back off, honouring `Retry-After` where it is sent. The gateway already retried the model before it answered `upstream_overloaded` or `model_unavailable`. | | `500` `internal_error` | Yes | Back off. A write may have run, so retry with the same `Idempotency-Key` where the route takes one. | | `504` `upstream_timeout` | Only if changed | The same request usually times out again. Stream it, or lower `max_tokens` or `reasoning_effort`; see [Timeouts](#timeouts). | | `429` `budget_exhausted`, `key_spend_cap_exceeded`, `workspace_spend_cap_exceeded`, `credits_exhausted` | Not now | The allowance clears only at `reset_at`, or when the cap changes. `Retry-After` counts down to that moment. | | `422` `structured_output_unsatisfied` | Yes | It may pass on a retry. The attempt was billed; a retry with the same key and body is not billed again. | | `409` `idempotency_conflict` | Yes, adjusted | Resend the original body to replay the stored response, or send the new body under a new key. | | No response at all: a dropped connection or your own timeout | Yes | Retry with the same `Idempotency-Key`. | | A stream that ended before `data: [DONE]` | Yes | Send the request again; see [Streaming](/docs/en/build/streaming#done-is-the-only-success-signal). | | `502` `upstream_error` from a model call | No | The model provider rejected the request, and it fails the same way again. From native sign-in it is retryable. | | `503` `count_unavailable` | No | A fact of the deployment; it does not clear. | | Every other code | No | It describes the request or the credential. Fix the request, the key, or the target first. | [Rate limits and budgets](/docs/en/build/rate-limits) explains the difference between a rate limit and a budget, and how to read your remaining headroom. ### Timeouts With `stream: false`, the gateway makes one attempt at the model, bounded by the tier's deadline: | Tier | Deadline | | --- | --- | | `kavi` | 90 seconds | | `vaani` | 90 seconds | | `mira` | 45 seconds | An attempt that runs past the deadline is cancelled and answered `504` `upstream_timeout`, and nothing is billed. The gateway does not retry a timeout: a turn that needed longer needs longer again. Send `stream: true` instead, since a stream may run for up to five minutes, or ask for less with a lower `max_tokens` or `reasoning_effort`. [Reliability](/docs/en/build/reliability) collects the deadlines, retries, and stream checks in one place. Other upstream failures, such as an overloaded or failing model or a dropped connection, are retried inside the gateway a bounded number of times, with backoff, before it answers `503` `upstream_overloaded` or `model_unavailable`. On `stream: true` that happens only while the stream opens, never after a byte has reached you. A call never moves to another tier once it is sent: `model: "auto"` picks a healthy tier before the call, and an explicit tier that keeps failing is refused at once with `503` `model_unavailable` while it cools down. ### Back off - **Honour `Retry-After`.** Every `429` carries it, and so does a `503` `limits_unavailable`. On `rate_limited`, `X-RateLimit-Reset` gives the same moment as an absolute Unix time in seconds. On a budget or spend-cap refusal, `Retry-After` counts down to `reset_at` instead, while the `X-RateLimit-*` headers still describe the per-minute request window. - **Otherwise back off exponentially, with jitter.** Double the wait after each failed attempt and randomize it, so many clients that failed together do not retry together. - **Cap the attempts and the total wait.** A retry budget keeps a persistent failure from turning into a stuck request. - **Log `request_id` for every failure,** retried or not. ### Make retries idempotent `Idempotency-Key` makes a retry safe. The first call with a key is served, and when it succeeds its response is stored for 24 hours. A later or concurrent call with the same key and the same body replays that response byte for byte, without calling the model again and without billing again; a concurrent duplicate waits for the first call to finish. Only a success is stored. An error, a `429` or any `5xx` included, releases the key, so a retry with the same key and body runs the request again. Reusing the key is the intended way to retry a `429` or a `5xx`. - **Scope:** a key belongs to your API key and to the endpoint. On a conversation append, it belongs to the conversation. - **Conflicts:** the same key with a different body is `409` `idempotency_conflict`. - **Large responses:** a response over 256 KiB is served but not stored, so a retry runs again. It is still billed once. - **Length:** keys are at most 255 characters. These endpoints accept it: - `POST /api/v1/chat`, `POST /api/v2/chat`, and `POST /v1/chat/completions` - `POST /api/v1/embeddings`, `POST /api/v1/rerank`, and `POST /api/v1/moderations` - `POST /api/v1/conversations/{id}/messages`, which stores its `201` and replays it without appending a second message > **Note:** A `stream: true` chat stores and replays nothing, because a stream cannot be > replayed. A retry with the same key and body streams a new generation; when > both attempts complete, the request is billed once. An attempt that did not > complete is billed for the tokens it delivered, and the retry is billed as well. ## Error codes Every code the API defines, grouped by the status it is returned with. ### 400 Bad Request The request itself is wrong. Fix it; sending it again unchanged fails the same way. | `code` | Meaning | | --- | --- | | `invalid_request` | Malformed JSON, or a field that is missing or invalid. Carries `required_version` when the request uses a field that needs a newer `Cruxy-Version` pin. | | `context_overflow` | The input exceeds the tier's context window. | | `invalid_schema` | A `response_format` JSON schema is malformed, oversized, too deep, or uses an unsupported keyword. | | `invalid_tool_schema` | A tool's `parameters` schema exceeds a bound. Adds `tool`, `bound`, and `limit`. The fix belongs in your client, so a retry cannot help. | | `file_not_text` | A file named in `file_ids` cannot be read as text. Names the file; a document that could not be read also carries a `reason`. | | `file_too_large` | A file named in `file_ids` exceeds a size cap. Names the file, with `bound`, `limit`, and `tokens`. | | `image_not_supported` | A file named in `file_ids` on this turn is an image, and the tier serving the turn does not read images. Names the image and the tier, and lists the tiers that do in `vision_tiers`. | | `moderation_rejected` | The server-side input moderation gate refused the request's text. Carries `hazard_codes`. Not billed. | | `pii_rejected` | A memory or rule contains an email address, phone number, or card number. Rephrase without it and retry. | | `rules_too_large` | A ruleset exceeds a size cap. Names the cap. | | `input_not_countable` | `POST /api/v1/tokens/count` refuses to count this text and returns no count. | | `invalid_version` | The `Cruxy-Version` header is not a `YYYY-MM-DD` date. Answered on every route, before authentication. | | `unknown_version` | `Cruxy-Version` is a well-formed date this server does not support. The message lists the supported dates. Answered on every route, before authentication. | | `invalid_payload` | `POST /api/v1/feedback` only: the payload does not match the schema for its type. | | `provider_not_supported` | Native sign-in named a provider that is not supported. | | `redirect_uri_not_allowed` | Native sign-in named a redirect URI that is not allowlisted. | | `scope_unsupported` | Retired: never sent. It stays in the vocabulary so the string is never reused. | ### 401 Unauthorized | `code` | Meaning | | --- | --- | | `unauthorized` | The Bearer credential is missing, malformed, unknown, expired, or revoked, or its holder no longer belongs to the organization it names. Each case gets the same answer, with `WWW-Authenticate: Bearer`. It is the only `401` code; see [Get your API key](/docs/en/get-started/api-keys#expiry-and-revocation). | ### 403 Forbidden The credential is genuine, but it cannot make this call. Waiting never widens a credential, so retrying cannot succeed. | `code` | Meaning | | --- | --- | | `account_suspended` | The key's owner's account is suspended. | | `credential_quarantined` | The organization the key belongs to could not be established, so the key is refused rather than trusted. Signing in again does not clear it; contact support. | | `forbidden_bucket` | The credential's billing kind cannot be billed on this route. No key issued today can reach it. | | `key_scope_forbidden` | The key is not scoped for this endpoint family, or for the memory, rules, or files feature a request field asked for. Names `required_scope` in the second case. Re-scope or re-issue the key. | | `model_not_allowed` | The key's model allowlist excludes the requested tier. | | `key_read_only` | The key may not perform billable operations. | | `org_forbidden` | The operation needs an organization owner or admin. | | `workspace_forbidden` | Workspace management needs an organization owner or admin. | | `logs_forbidden` | Request logs need an organization owner or admin. | | `rules_forbidden` | You may not read or change this ruleset. | | `vault_forbidden` | You take part in the vault but lack the permission the operation needs. | | `conversation_forbidden` | The conversation is in a vault you may read but not write. | ### 404 Not Found The target does not exist, or you cannot see it. The API gives the same answer for an id that is missing and one you cannot see, so a `404` never confirms that an id exists. | `code` | What was not found | | --- | --- | | `user_not_found` | A user. | | `file_not_found` | A file of yours under this key's organization, including one named in `file_ids`. | | `request_not_found` | A `request_id` from a call this account made, on `POST /api/v1/feedback`. | | `memory_not_found` | A memory. | | `vault_not_found` | A vault you take part in. | | `vault_member_not_found` | A vault member. | | `org_member_not_found` | An organization member. | | `invitation_not_found` | An invitation addressed to you that is still open. | | `workspace_not_found` | A workspace in your organization. | | `log_not_found` | A request log in your organization. | | `conversation_not_found` | A conversation you can see. | | `message_not_found` | A message in the conversation. | | `prompt_not_found` | A prompt in your workspace. | | `prompt_version_not_found` | That version of the prompt. | | `eval_suite_not_found` | An eval suite in your workspace. | | `eval_run_not_found` | An eval run in your workspace. | | `eval_case_not_found` | A case in that eval suite. | ### 409 Conflict The request conflicts with existing state. Only `idempotency_conflict` has a retry path. | `code` | Meaning | | --- | --- | | `idempotency_conflict` | The `Idempotency-Key` was reused with a different body. Resend the original body to replay the stored response, or use a new key. | | `vault_name_exists` | A vault with this name already exists in your organization. | | `vault_external_id_conflict` | The `external_id` names a vault somebody else owns. | | `vault_member_exists` | The user is already a member of the vault. | | `org_member_exists` | The user is already a member of the organization. | | `org_member_active` | The member has not left the organization. | | `workspace_slug_exists` | A workspace with this slug already exists. | | `workspace_not_empty` | The workspace holds, or has held, a credential, so it cannot be deleted. | | `workspace_is_default` | The default workspace cannot be deleted. | | `prompt_slug_exists` | A prompt with this slug already exists. | | `eval_suite_slug_exists` | An eval suite with this slug already exists. | ### 413 Payload Too Large | `code` | Meaning | | --- | --- | | `payload_too_large` | The upload or message is over its size cap. | | `storage_quota_exceeded` | The upload would take your files past your total storage allowance. | ### 422 Unprocessable Content | `code` | Meaning | | --- | --- | | `structured_output_unsupported` | The tier that served the request does not support constrained decoding for `response_format`. | | `structured_output_unsatisfied` | The model's non-streaming output did not satisfy the requested JSON schema. The request is still billed. | ### 429 Too Many Requests Every `429` carries `Retry-After`, and nothing is billed. | `code` | Meaning | | --- | --- | | `rate_limited` | Too many requests: `reason` is `rate` (the per-minute request window) or `concurrency` (too many of your requests in flight at once). Retry after `Retry-After`. | | `budget_exhausted` | A signed-in plan's monthly or 12-hour burst pool is spent, or `mira`'s monthly request cap. `mira_available` says whether the same request on `mira` would be served now. Clears at `reset_at`. | | `key_spend_cap_exceeded` | The key's monthly spend cap is spent. Clears at `reset_at`. | | `workspace_spend_cap_exceeded` | The workspace's monthly spend cap is spent. Clears at `reset_at`. | | `credits_exhausted` | A headless key's credit pool is drained. Clears at `reset_at`. | | `credits_unavailable` | A headless key's credit balance could not be read. Retry after `Retry-After` (2 seconds). | | `headless_concurrency_limit` | Chat only: a headless key already has its maximum requests in flight. Retry after `Retry-After` (1 second). | ### 5xx Server errors On chat, every model failure adds `tier`, and none is billed. | `code` | Status | Meaning | | --- | --- | --- | | `internal_error` | `500` | An unexpected server fault, on any route. Retry with backoff. | | `upstream_error` | `502` | On a model call, the model provider rejected the request the gateway sent, and a retry fails the same way. On native sign-in, the identity provider could not be reached or answered unusably; retry. | | `upstream_overloaded` | `503` | The model is overloaded, and the gateway's own retries ran out. Retry with backoff. | | `model_unavailable` | `503` | The tier is temporarily unavailable: it kept failing and is cooling down, or it failed again on this request after the gateway's retries. Retry with backoff. | | `all_models_unavailable` | `503` | Chat only: `model: "auto"` was sent and every tier is unavailable. Retry with backoff. | | `server_shutting_down` | `503` | The gateway restarted, for a deploy, before it answered. Retry with backoff. | | `limits_unavailable` | `503` | A rate-limit or budget check could not be read, so the request was refused rather than served unmetered. Adds `dimension`. Retry after `Retry-After` (5 seconds). | | `count_unavailable` | `503` | `POST /api/v1/tokens/count` cannot count for this tier, because the count could disagree with billing. It does not clear on a retry. | | `upstream_timeout` | `504` | The model did not answer in time. With `stream: false` the gateway made one attempt and does not retry it; see [Timeouts](#timeouts). | ### Stream errors A stream that fails after it opens has already sent its `200`, so the code arrives in the stream, not the status: - `POST /api/v2/chat` ends with an `error` event whose `code` is one of `upstream_error`, `upstream_overloaded`, `upstream_timeout`, `model_unavailable`, `server_shutting_down`, or `internal_error`. - `POST /v1/chat/completions` ends with a `data: {"error": {...}}` frame. - `POST /api/v1/chat` sends no error frame: the stream ends without `data: [DONE]`; see [Streaming](/docs/en/build/streaming#done-is-the-only-success-signal). ### Codes in request logs `client_closed_request` (`499`) records that the caller disconnected before the gateway had an answer. Nobody receives it; it appears only in request logs. ## Next steps - [Reliability](/docs/en/build/reliability): deadlines, retries, idempotency, and platform status together. - [Request IDs and metadata](/docs/en/build/request-ids): find the `request_id` and quote it when you report a problem. - [Rate limits and budgets](/docs/en/build/rate-limits): read your headroom before you hit a `429`. - [Streaming](/docs/en/build/streaming): detect a stream that failed partway. - [Get your API key](/docs/en/get-started/api-keys): fix `401` and `403` at the key. ================================================================================ Page: Rate limits and budgets URL: https://learn.getcruxy.com/docs/en/build/rate-limits Markdown: https://learn.getcruxy.com/docs/en/build/rate-limits.md ================================================================================ # Rate limits and budgets How the gateway meters you: rate_limited versus budget_exhausted, the rate-limit headers, spend caps, and reading your live headroom. Two separate controls can refuse a request with `429`: a **rate limit** on how fast you send requests, and a **budget** on how much you spend. The body's `code` tells you which one you hit, and they need different handling. Your account's limits are shown in your Console, and `GET /api/v1/limits` returns them, with live headroom, to your code. ## rate_limited or budget_exhausted | | rate_limited | budget_exhausted | | --- | --- | --- | | What ran out | Request rate: too many requests in the current window. | Allowance: the plan's monthly cap, its 12-hour burst pool, or the monthly `mira` request cap is spent. | | What to do | Back off and retry after `Retry-After`. | Stop retrying. No immediate retry succeeds. | | Clears | When the rate window resets. | At `reset_at`, when the spent window resets. | | Headroom | `rate_limits.chat` on `GET /api/v1/limits` | `token_pool` on `GET /api/v1/limits` | Spend caps refuse with `429` too, under their own codes: a monthly cap on a key (`key_spend_cap_exceeded`), a monthly cap on a workspace (`workspace_spend_cap_exceeded`), and the credit pool (`credits_exhausted`). Like `budget_exhausted`, they mean an allowance is spent, and they clear only at `reset_at` or when the cap changes. Every `429` carries `Retry-After`; on these codes and on `budget_exhausted` it counts down to `reset_at`, and retrying sooner fails the same way. `POST /api/v1/moderations` never returns `budget_exhausted`: moderation is exempt from the subscription pool, so a spent allowance cannot switch it off. It can still return `rate_limited` or a spend-cap code. ## Rate-limit headers Metered responses carry the state of the bucket the route is metered on: | Header | Meaning | | --- | --- | | `X-RateLimit-Limit` | Requests allowed per window. | | `X-RateLimit-Remaining` | Requests left in the current window, never below `0`. | | `X-RateLimit-Reset` | When the current window ends, as an absolute Unix time in seconds, not a duration. | | `Retry-After` | On every `429`, the seconds to wait. On a budget or spend-cap refusal, the seconds until `reset_at`. | The `X-RateLimit-*` headers always describe the per-minute request window, so a budget refusal can still show requests remaining. The `X-RateLimit-*` headers are absent on a `401`, because authentication runs before the limiter, and on a `403` from the key check (`key_scope_forbidden`, `key_read_only`). They are also absent when the limiter cannot reach its own storage; that request is served unmetered rather than refused. ```bash title="curl" # -D - prints the response headers; look for X-RateLimit-* and, on a 429, Retry-After. curl -s -D - -o /dev/null https://api.cruxy.ai/api/v1/limits \ -H "Authorization: Bearer $CRUXY_API_KEY" ``` ```ts title="TypeScript" // These clear only at reset_at; every other 429 clears after Retry-After. const ALLOWANCE_SPENT = new Set([ 'budget_exhausted', 'key_spend_cap_exceeded', 'workspace_spend_cap_exceeded', 'credits_exhausted', ]) const res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'mira', stream: false, messages: [{ role: 'user', content: 'Hello' }], }), }) const remaining = res.headers.get('X-RateLimit-Remaining') const resetAt = res.headers.get('X-RateLimit-Reset') // Unix seconds if (res.status === 429) { const { code, reset_at, request_id } = await res.json() const waitSeconds = Number(res.headers.get('Retry-After')) if (ALLOWANCE_SPENT.has(code)) { // No retry succeeds before reset_at. console.log(`allowance spent: ${code}, resets at ${reset_at} (request_id ${request_id})`) } else { // rate_limited, or a transient headless check: wait Retry-After, then retry. console.log(`${code}; retry in ${waitSeconds}s (request_id ${request_id})`) } } else if (remaining !== null) { console.log(`${remaining} requests left until ${new Date(Number(resetAt) * 1000).toISOString()}`) } ``` Pace yourself before you reach `0`: when `X-RateLimit-Remaining` runs low, slow down until `X-RateLimit-Reset` rather than waiting for a `429`. ## Read your limits `GET /api/v1/limits` returns the limits that apply to your key, with live headroom. Every value comes from the same limiter and budget state that chat enforces, so `remaining` is exactly what your next request is checked against. The call costs nothing, writes nothing, and is rate-limited like any other route. ```bash title="curl" curl https://api.cruxy.ai/api/v1/limits \ -H "Authorization: Bearer $CRUXY_API_KEY" ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/limits', { headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}` }, }) if (!res.ok) throw new Error(`limits request failed: ${res.status}`) const limits = await res.json() const perKey = limits.rate_limits.chat.per_key console.log(`chat: ${perKey.remaining} of ${perKey.limit} left this window`) if (limits.token_pool) { console.log(`token pool: ${limits.token_pool.state}`) // healthy, low, or blocked } ``` Everything comes from the key you send; nothing is read from the request. Which blocks appear depends on your billing `bucket`: | Field | Meaning | | --- | --- | | `object` | `limits`. | | `bucket` | How this key is billed, and so which kind of key it is: `subscription` (a signed-in credential, with `token_pool`), `apikey` (a developer API key, billed per token), or `headless` (billed from prepaid `credits`). Memory and standing rules are on by default only for `subscription`. | | `metered` | `true` for the `apikey` bucket only, and omitted otherwise: no token pool applies, and every request is billed per token. A key or workspace spend cap still binds it. | | `tier` | Your account's subscription plan, which sizes `token_pool`. This is not a model tier. | | `rate_limits.chat.per_key` | The chat request-rate window for this key: `limit`, `remaining`, `resets_at`, and `window_seconds`. `resets_at` is `null` until a request opens the window. | | `rate_limits.chat.per_org` | The same window, for your whole organization. | | `rate_limits.chat.max_concurrent_requests` | How many chat requests your account may have in flight at once, across all three chat endpoints. One more is refused at once, not queued: `429` `rate_limited` with `reason: concurrency` and `Retry-After: 1`. | | `token_pool` | Subscription bucket only. The weighted-token budget behind `budget_exhausted`. | | `credits` | Headless bucket only. The dollar-credit pool: `granted`, `adjustment`, `used`, `remaining`, and `resets_at`. | | `key_spend_cap` | Present only when the key has a monthly spend cap: `cap`, `spent`, `remaining`, and `resets_at`, in USD. | | `workspace_spend_cap` | Present only when the key's workspace has a monthly spend cap, in the same shape. | `token_pool` has two windows, each with `cap`, `used`, `remaining`, `resets_at`, and a `state` of `healthy`, `low`, or `blocked`: - `monthly`: the calendar-month cap. - `burst`: the trailing 12-hour sliding cap. It also carries an overall `state`, `blocked_models`, the `unit` (`weighted_tokens`), and `mira`, a monthly request cap on the `mira` tier with its own `cap`, `used`, `remaining`, `resets_at`, and `state`. There is no tokens-per-minute limit and no per-model daily cap. The gateway enforces neither, so neither appears here. ## Budgets and auto A request with `model: "auto"` may be budget-downgraded to `mira`. Read `cruxy.routed_tier` on the response to see the tier that served it, rather than assuming the router's first choice; see [Auto routing](/docs/en/build/auto-routing). ## Next steps - [Errors and retries](/docs/en/build/errors-and-retries): every `429` code, and a retry loop that honours `Retry-After`. - [Features overview](/docs/en/get-started/features): the tiers and `auto`. - [Get your API key](/docs/en/get-started/api-keys): key restrictions, including spend caps. ================================================================================ Page: Usage and cost URL: https://learn.getcruxy.com/docs/en/build/usage-and-cost Markdown: https://learn.getcruxy.com/docs/en/build/usage-and-cost.md ================================================================================ # Usage and cost Read the usage object on each response: token counts, billable and cached tokens, the shape of the cost object, and how a stream reports usage. Every chat response reports what the call consumed in a `usage` object: its token counts and a `cost` object. This page documents the shape of both. Cost amounts and rates are specific to your account and shown in your Cruxy Console, so the examples here use placeholders where those numbers go. Read the real values from your own responses. ```bash title="curl" # usage, with its cost object, is near the end of the response body. curl https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "vaani", "messages": [ { "role": "user", "content": "Name three monsoon fruits." } ] }' ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'vaani', messages: [{ role: 'user', content: 'Name three monsoon fruits.' }], }), }) if (!res.ok) throw new Error(`chat failed: ${res.status} ${await res.text()}`) const { usage, cruxy } = await res.json() console.log(`request ${cruxy.request_id} on ${usage.cost.tier}`) console.log(`input ${usage.input_tokens} (billable ${usage.billable_input_tokens})`) console.log(`output ${usage.output_tokens}`) // cost is an object, not a number: amount is the one figure, in cost.currency. console.log(`cost ${usage.cost.amount} ${usage.cost.currency}`) ``` ## The usage object A non-streaming `POST /api/v1/chat` response carries `usage` next to `message`. `input_tokens`, `output_tokens`, `billable_input_tokens`, and `cost` are always present. | Field | Meaning | | --- | --- | | `input_tokens` | The call's input tokens. | | `output_tokens` | The tokens the model generated. | | `billable_input_tokens` | The input tokens the call is billed for. | | `cache_read_tokens` | Input tokens read from the prompt cache. | | `cache_write_tokens` | Input tokens written to the prompt cache. | | `prompt_tokens_details` | The same cache split in OpenAI's shape, `cached_tokens` and `cache_write_tokens`. Present only when the prompt cache was used. | | `cost` | What the call cost, as an object; see [The cost object](#the-cost-object). | The token fields have different names on different endpoints. Each set belongs to a wire contract that has already shipped, so read the one for the endpoint and mode you called: | Endpoint and mode | Token fields | Cost | | --- | --- | --- | | `POST /api/v1/chat`, `stream: false` | `input_tokens`, `output_tokens`, `billable_input_tokens`, `cache_read_tokens`, `cache_write_tokens` | `usage.cost` | | `POST /api/v1/chat`, `stream: true` | `prompt_tokens`, `completion_tokens`, `total_tokens`, plus the three Cruxy counts when they are not zero | `usage.cost`, on the usage frame | | `POST /api/v2/chat`, either mode | `input_tokens`, `output_tokens`, `billable_input_tokens`, `cache_read_tokens`, `cache_write_tokens` | `usage.cost` | | `POST /api/v1/embeddings` and `POST /api/v1/rerank` | `prompt_tokens`, `total_tokens` | `cruxy.cost` | | `POST /v1/chat/completions` | `prompt_tokens`, `completion_tokens`, `total_tokens`, and `prompt_tokens_details.cached_tokens` when the cache was used | Not in the response | | `POST /api/v1/moderations` | Not in the response | Not in the response | ## The cost object `cost` is an object, not a number. Read `cost.amount` when you want one figure. ```json { "tier": "vaani", "amount": "", "currency": "", "input_unit": "", "output_unit": "", "cache_read_unit": "" } ``` Each `<…>` is a placeholder. In a real response, `currency` is a string and the other placeholders are numbers. | Field | Meaning | | --- | --- | | `tier` | The tier that served the call. | | `amount` | The modeled cost of the call, in `currency`. | | `currency` | The currency of `amount`. | | `input_unit` | The input rate `amount` was computed from, per million tokens. | | `output_unit` | The output rate, per million tokens. | | `cache_read_unit` | The rate for input read from the prompt cache, per million tokens: the tier's own cache-read rate where it has one, otherwise `input_unit` times the cache discount. | Amounts and rates are specific to your account, and your Cruxy Console shows them. Take `cost.amount` from each response rather than computing it from token counts and a rate you copied, so your numbers match what the gateway records. ## Billable and cached tokens More goes into `input_tokens` than the messages you send. Each of these is billed as input: - **The tier's own prompt.** The gateway adds a per-tier system prompt and the tier's chat-template scaffolding. - **Standing rules,** when they are injected into the turn; see [Standing rules](/docs/en/build/rules). - **Recalled memory,** when recall runs. Sending `memory: {"recall": false}` lowers input by the size of that block; see [Memory](/docs/en/build/memory). - **Attached files,** on every request that sends them, including later turns that replay a message with `file_ids` in its history. - **Tools.** The `tools` array you send is real input; see [Define tools](/docs/en/build/tools). `billable_input_tokens` is the input the call is billed for, and `cache_read_tokens` and `cache_write_tokens` count prompt-cache activity. Input read from the cache is priced at `cost.cache_read_unit`. The API does not document how these counts relate to `input_tokens`, so read each one as reported rather than deriving one from another. Thinking is output. A reasoning tier writes its thinking inside the same `max_tokens` budget as the answer, `POST /api/v2/chat` reports that `output_tokens` includes the reasoning, and no response carries a separate reasoning-token count. See [Reasoning effort](/docs/en/build/reasoning-effort). To size a request before you send it, `POST /api/v1/tokens/count` returns the `input_tokens` a chat request would bill for the same tier, messages, and tools. It calls no model and is not billed. The count is exact for single-turn and user-only prompts on every tier, and never an undercount: where it can differ from the real call, it errs high. It takes a tier, not `"auto"`, and refuses `file_ids`. ## How a stream reports usage Usage arrives once, at the end of a successful stream: - **`POST /api/v1/chat`** sends exactly one usage frame: the frame whose `choices` is an empty array, after the finish frame and before `data: [DONE]`. It always carries `cost`. `billable_input_tokens`, `cache_read_tokens`, and `cache_write_tokens` are left out when they are zero, and `prompt_tokens_details` appears only when the cache was used, so a missing field means zero. See [Streaming](/docs/en/build/streaming#the-usage-frame). - **`POST /api/v2/chat`** carries `usage` on its terminal `finish` frame, in the same shape as a non-streaming v2 response. Its `error` frame can carry `usage` too. - **`POST /v1/chat/completions`** sends a usage frame only when the request sets `stream_options: {"include_usage": true}`, and that usage has no cost. A stream that fails partway sends no usage frame. The tokens it delivered were real, though, and are billed as a partial usage row. ## What is billed Not billed: - A non-streaming chat that runs past its tier's deadline: `504` `upstream_timeout`. - A model failure on chat: `502` `upstream_error`, and the `503` codes `upstream_overloaded`, `model_unavailable`, `all_models_unavailable`, `server_shutting_down`, and `limits_unavailable`. - A request the input moderation gate refuses: `400` `moderation_rejected`. - A request refused for its `Cruxy-Version`, for an attached file, or for the memory, rules, or files scope its key lacks: these are answered before anything is billed. - A replay of a stored success under the same `Idempotency-Key`. - `POST /api/v1/tokens/count`, and uploads to `POST /api/v1/files`. Billed: - `422` `structured_output_unsatisfied`. A retry with the same `Idempotency-Key` and body is not billed a second time. - A stream that ended early, for the tokens it delivered. - Every case in an eval run, as a chat completion. When a streamed request is retried under the same `Idempotency-Key`, it is billed once if both attempts complete. An attempt that did not complete is billed for the tokens it delivered, and the retry is billed as well; see [Errors and retries](/docs/en/build/errors-and-retries#make-retries-idempotent). ## Track spend - **Spend caps and credit.** `GET /api/v1/limits` reports the caps that bind your key: `key_spend_cap` and `workspace_spend_cap`, each with `cap`, `spent`, `remaining`, `resets_at`, and `unit`, and a headless key's `credits`. See [Rate limits and budgets](/docs/en/build/rate-limits#read-your-limits). - **Per-request tokens.** An organization owner or admin can read any call's token counts by its `request_id`; see [Look up a request](/docs/en/build/request-ids#look-up-a-request). - **Amounts.** Your Cruxy Console shows what your usage costs. ## Next steps - [Request IDs and metadata](/docs/en/build/request-ids): the `cruxy` block and the headers on each response. - [Streaming](/docs/en/build/streaming): the usage frame and the rest of the stream. - [Rate limits and budgets](/docs/en/build/rate-limits): spend caps, budgets, and live headroom. - [Reasoning effort](/docs/en/build/reasoning-effort): keep thinking from spending your `max_tokens`. ================================================================================ Page: Request IDs and metadata URL: https://learn.getcruxy.com/docs/en/build/request-ids Markdown: https://learn.getcruxy.com/docs/en/build/request-ids.md ================================================================================ # Request IDs and metadata Read the X-Request-Id header and each call's request_id, the Cruxy-Version header and the cruxy block, and quote a request when you report a problem. Every response the gateway sends carries an `X-Request-Id` header, and a call that succeeds also returns its own per-call `request_id` in the body. On an error, the two are the same value. On a success they differ, and the body's id is the one request logs and feedback take. Every response also names the API version that served it, in the `Cruxy-Version` header. ```bash title="curl" # -i prints the response headers above the body. X-Request-Id and # Cruxy-Version are headers; this call's own request_id is in the body, at # cruxy.request_id. curl -i https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "mira", "messages": [ { "role": "user", "content": "Hello" } ] }' ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'mira', messages: [{ role: 'user', content: 'Hello' }], }), }) // Every response, errors included, carries both headers. const correlationId = res.headers.get('X-Request-Id') const version = res.headers.get('Cruxy-Version') const body = await res.json() if (res.ok) { // A success returns its own per-call id. Request logs and feedback take it. console.log(`request ${body.cruxy.request_id} (X-Request-Id ${correlationId}), Cruxy-Version ${version}`) } else { // An error's request_id equals the X-Request-Id header. console.log(`${res.status} ${body.code}: request_id ${body.request_id}, Cruxy-Version ${version}`) } ``` ## The X-Request-Id header `X-Request-Id` is a correlation id, sent on every response of every route, whether the call succeeded or failed. - **You can set it.** Send `X-Request-Id` on the request, up to 128 characters of `[A-Za-z0-9_-]`, and the gateway reuses your value. Any other value, or none, gets a new UUID. - **On an error, it equals the body's `request_id`.** The JSON error envelope (`code`, `error`, `request_id`) carries the same value as the header. - **On a success, it is not the call's id.** A success returns a separate per-call id, `cruxy.request_id`, or `request_id` on `POST /api/v2/chat`. Feedback and request logs take that per-call id, not the header. - **On `POST /v1/chat/completions` and `GET /v1/models`,** OpenAI's response and error shapes carry no `request_id`, so the header is the only request id a response there carries. Quote it when you report a problem; see [Report a problem](#report-a-problem). ```bash title="curl" # Send your own correlation id. -D - prints the response headers, where # X-Request-Id echoes it; -o /dev/null drops the body. curl -s -o /dev/null -D - https://api.cruxy.ai/api/v1/models \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "X-Request-Id: order-1234-attempt-1" ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/models', { headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, // Up to 128 characters of [A-Za-z0-9_-]; anything else gets a new UUID. 'X-Request-Id': 'order-1234-attempt-1', }, }) console.log(res.headers.get('X-Request-Id')) // order-1234-attempt-1 ``` ## Where the request ID appears A success returns its per-call id in the body. Where it sits depends on the endpoint: | Response | Where the per-call id is | | --- | --- | | `POST /api/v1/chat`, `stream: false` | `cruxy.request_id`. | | `POST /api/v1/chat`, `stream: true` | `cruxy.request_id` on the first frame, the role primer. | | `POST /api/v2/chat` | A top-level `request_id` in the body, and on the stream's `start` and `error` frames. | | `POST /api/v1/embeddings` and `POST /api/v1/rerank` | `cruxy.request_id`. | | `POST /api/v1/moderations` | The top-level `id`, formatted `modr-`. Strip the `modr-` prefix for the same id the other endpoints return. | An error carries `request_id` in the JSON error envelope, next to `code` and `error`, and its value equals the response's `X-Request-Id` header. Device login and native sign-in return the same three fields while pending or refused. `POST /v1/chat/completions` and `GET /v1/models`, the plain OpenAI wire, have no `cruxy` block, and their OpenAI error shape has no `request_id` field, so `X-Request-Id` is the only id a response there carries. When you need a per-call id that request logs and feedback accept, use `POST /api/v1/chat`. Log the `X-Request-Id` of every response and the per-call `request_id` of every success. The per-call id is the one that ties a line in your logs to the gateway's record of the call. ## The Cruxy-Version header The API is versioned by date, and `Cruxy-Version` carries the date both ways: - **On the request,** `Cruxy-Version: YYYY-MM-DD` pins a version. Omit it to get the server's current default. - **On the response,** `Cruxy-Version` names the version that served the call. Every response carries it, errors included. A value that is not a `YYYY-MM-DD` date is `400` `invalid_version`. A well-formed date this server does not support is `400` `unknown_version`, and its message lists the supported dates. Both are answered before authentication and before anything is billed. A few request fields work only under a minimum version, and the [Changelog](/docs/en/reference/changelog) lists each one with its date. Sent without that pin, `file_ids` is refused with `400` `invalid_request`, and the error names the version you need in `required_version`. A breaking change ships under a new dated version. Ordinary additive fields, new `cruxy.*` fields among them, ship without one, so ignore JSON fields you do not recognize. ## Other response headers | Header | Sent on | What it carries | | --- | --- | --- | | `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `X-RateLimit-Reset` | Metered responses | The per-minute request window; see [Rate limits and budgets](/docs/en/build/rate-limits#rate-limit-headers). | | `Retry-After` | Every `429`, and a `503` `limits_unavailable` | The seconds to wait before you retry. | | `Cache-Control` | Every JSON response | `no-store`: no cache, shared or private, may store the response. | ## The cruxy block Cruxy-native fields are additive and namespaced under a `cruxy` key, so an OpenAI client parses the rest of the response unchanged. What the block holds depends on the endpoint. ### On chat On `POST /api/v1/chat`, the block carries `request_id`, `routed_tier`, and `reasoning_effort` for the turn. The rest appear only when they apply: | Field | What it tells you | | --- | --- | | `request_id` | The call's id. | | `routed_tier` | The tier that served the call. With `model: "auto"`, this is the tier the router picked. | | `reasoning_effort` | The effort the call ran at, after the tier's default is applied. | | `memory_recall` | Stored memories were recalled into the turn: the scope and key of each, never the stored value. See [Memory](/docs/en/build/memory). | | `memory_capture` | The turn stored memory: how many memories, and the scope and key of each. See [Memory](/docs/en/build/memory). | | `rules_applied` | Standing rules were injected: how many, and from which rulesets. See [Standing rules](/docs/en/build/rules). | | `files_omitted` | Files attached to earlier messages were left out of this turn, each with the reason. | | `undeclared_tool_replay` | Replayed tool exchanges called tools your `tools` array does not declare, so the model read them as prose. See [Handle tool calls](/docs/en/build/tool-calls). | A stream spreads the block across frames. The role primer carries `request_id`, `routed_tier`, and `reasoning_effort`, plus `memory_recall`, `rules_applied`, and `files_omitted` when they apply. The usage frame carries `memory_capture`, since what a turn stored is known only once the answer is complete. [Streaming](/docs/en/build/streaming#frame-order) shows the frames in order. Token counts and cost are not in the chat block: they are in `usage`, which [Usage and cost](/docs/en/build/usage-and-cost) covers. ### On other endpoints - **Embeddings and rerank:** `cruxy` carries `request_id` and `cost`. - **Moderations:** no top-level block. A flagged result carries its own `results[].cruxy.hazard_codes`, the guard taxonomy codes behind the flag, and an unflagged result has no `cruxy` key; see [Moderations](/docs/en/build/moderations). - **`POST /api/v2/chat`:** no `cruxy` wrapper. The same signals are top-level fields: `request_id`, `routing` (the tier, how it was routed, and the effort), `memory_recall`, `memory_capture`, `rules_applied`, `files_omitted`, and `undeclared_tool_replay`. - **`POST /v1/chat/completions`:** none. That wire carries no Cruxy-native fields. New `cruxy.*` fields ship without a new API version. Ignore a field you do not recognize rather than failing on it. ## Look up a request An organization owner or admin can read a call's metadata by its per-call id, the `cruxy.request_id` a success returned, with `GET /api/v1/logs/{request_id}`. ```bash title="curl" # REQUEST_ID holds the cruxy.request_id of an earlier call. curl https://api.cruxy.ai/api/v1/logs/$REQUEST_ID \ -H "Authorization: Bearer $CRUXY_API_KEY" ``` ```ts title="TypeScript" // REQUEST_ID holds the cruxy.request_id of an earlier call. const requestId = process.env.REQUEST_ID if (!requestId) throw new Error('set REQUEST_ID to the cruxy.request_id of an earlier call') const res = await fetch(`https://api.cruxy.ai/api/v1/logs/${requestId}`, { headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}` }, }) if (!res.ok) { const { code, request_id } = await res.json() throw new Error(`log lookup failed: ${res.status} ${code} (request_id ${request_id})`) } const log = await res.json() console.log(log.status, log.model, log.input_tokens, log.output_tokens, log.latency_ms) ``` ```json { "object": "request_log", "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", "status": "success", "model": "kavi", "input_tokens": 1024, "output_tokens": 256, "latency_ms": 842, "key_id": "6f9c1e2a-1b2c-4d5e-8f90-1a2b3c4d5e6f", "workspace_id": "2b7c9e4a-3d5f-4a6b-8c1d-9e0f1a2b3c4d", "created_at": "2026-07-18T12:34:56.000Z" } ``` - **Metadata only.** The model, token counts, latency, the lifecycle `status` (`success`, `error`, or `partial`), the key and workspace that made the call, and when. Prompt and completion content are never stored or returned. - **Owner or admin only.** Anyone else gets `403` `logs_forbidden`. - **One answer for a missing id.** An id from another organization, or one that names nothing, is `404` `log_not_found`, so the response never confirms that an id exists. - **The most recent row wins.** One id can map to more than one record, such as a reused `Idempotency-Key` or an error followed by a retried success; the most recent is returned. `GET /api/v1/logs` lists the same metadata for every key in your organization, newest first. ## Report a problem When you contact Cruxy support about a call, send enough to find it without a round of questions: - **The ids,** whole and exactly as the response returned them: the `X-Request-Id` header, and for a success its per-call `request_id` as well. If you retried, send the ids of every attempt you have. - **The `Cruxy-Version`** the response named. - **The method and path,** such as `POST /api/v1/chat`, and whether `stream` was on. - **The HTTP status and the `code`.** The `error` message alone is not enough; its wording can change. - **When it happened,** in UTC. - **For a stream,** whether it reached `data: [DONE]`, or a `finish` frame on `POST /api/v2/chat`. - **The `key_id`,** if the problem is with a key. Never send the key itself. To tell Cruxy how a call turned out, rather than report a fault, send [Feedback](/docs/en/build/feedback) joined to the same `request_id`. ## Next steps - [Usage and cost](/docs/en/build/usage-and-cost): the token counts and cost on each response. - [Errors and retries](/docs/en/build/errors-and-retries): the error envelope and every code. - [Feedback](/docs/en/build/feedback): attach an outcome to a `request_id`. - [Changelog](/docs/en/reference/changelog): the dated versions and what each one changed. ================================================================================ Page: Known limitations URL: https://learn.getcruxy.com/docs/en/build/known-limitations Markdown: https://learn.getcruxy.com/docs/en/build/known-limitations.md ================================================================================ # Known limitations What the Cruxy API does not do today, why each limit matters for your code, and the workaround to use until it changes. These are the limits you are most likely to hit when you build on the API. Each entry says what happens, why it matters, and what to do instead. When the API changes, the [Changelog](/docs/en/reference/changelog) records it by version. ## Kavi's thinking spends max_tokens and isn't returned - **What:** `kavi` reasons by default, at `reasoning_effort` `high`, and a reasoning tier writes its thinking inside the same `max_tokens` budget as its answer. `POST /api/v1/chat` and `POST /v1/chat/completions` do not return the thinking. `POST /api/v2/chat` returns it as a separate `reasoning` string, but no wire reports a reasoning-token count. - **Why it matters:** A small `max_tokens` on `kavi` can be spent entirely on thinking. The call ends with `finish_reason: "length"` and empty content, and on the v1 wire nothing in the response shows where the tokens went. - **Workaround:** For a short, direct answer, send `reasoning_effort: "none"`. To keep the reasoning, leave `max_tokens` unset, which gives the tier's cap (8192 on `kavi`). Treat `finish_reason: "length"` with empty content as a spent budget, not an empty answer. See [Reasoning effort](/docs/en/build/reasoning-effort). ```bash title="curl" # reasoning_effort "none" turns kavi's thinking off, so the whole # max_tokens budget goes to the answer. curl https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "kavi", "reasoning_effort": "none", "max_tokens": 256, "messages": [ { "role": "user", "content": "In one sentence, what is a monsoon?" } ] }' ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ 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?' }], }), }) if (!res.ok) throw new Error(`chat failed: ${res.status} ${await res.text()}`) const completion = await res.json() if (completion.finish_reason === 'length' && !completion.message.content) { // The budget ran out before any answer was written. throw new Error(`no answer within max_tokens (request_id ${completion.cruxy.request_id})`) } console.log(completion.message.content) ``` ## Non-streaming calls have a 90-second ceiling - **What:** With `stream: false`, the gateway makes one attempt, bounded by the tier's deadline: 90 seconds on `kavi` and `vaani`, and 45 seconds on `mira`. Past it, the call is cancelled and answered `504` `upstream_timeout`. It is not billed, and the gateway does not retry it. - **Why it matters:** A long `kavi` turn, with reasoning on and a large output, can need more than 90 seconds. The same request takes as long again on a retry, so retrying it unchanged times out every time. - **Workaround:** Stream long turns with `stream: true`: a stream may run for up to five minutes. Otherwise, ask for less with a lower `max_tokens` or `reasoning_effort`. See [Reliability](/docs/en/build/reliability#deadlines-and-timeouts). ## Signed-in keys expire in 90 days, and you can't revoke one - **What:** A key from device login or native OAuth login is a signed-in credential. It stops working at its `expires_at`, 90 days after issue by default. Use does not extend it, and there is no refresh grant. Its holder can neither rotate nor revoke it: it ends at `expires_at`, when its organization membership ends, or when Cruxy support revokes it. - **Why it matters:** A signed-in key that leaks stays valid until one of those happens. And a tool that stores one stops working the moment it expires, with `401` `unauthorized`, the same answer as a revoked key. - **Workaround:** On a server, use a developer API key, created in the Cruxy Console: it does not expire, and you rotate or revoke it there. In a person's own tool, store `expires_at` with the key and prompt for a new login before it passes. If a signed-in key leaks, ask Cruxy support to revoke it. See [Get your API key](/docs/en/get-started/api-keys#expiry-and-revocation). ## Tool-call IDs may not use OpenAI's call_ format - **What:** A tool call's `id` is a string with no documented format. It is not guaranteed to start with the `call_` prefix OpenAI uses, even though the examples in these docs show `call_abc123`. - **Why it matters:** Code that validates, parses, or generates ids on the `call_` pattern can reject a real call or fail to match its result. When the model makes several calls in one turn, `tool_call_id` is the only thing that says which result answers which call. - **Workaround:** Treat each `id` as opaque. Copy it unchanged into the `tool_call_id` of your result, key any lookup on the whole string, and never build an id yourself. See [Handle tool calls](/docs/en/build/tool-calls). ## No per-language list for Indic languages - **What:** Cruxy supports 20+ Indic languages, but it publishes no list of which ones, and the tier catalog at `GET /api/v1/models` has no language field. - **Why it matters:** Neither the docs nor the API can confirm that a specific language is supported, or how well each tier handles it. - **Workaround:** Before you launch in a language, test it on each tier you plan to use, with prompts from your own product. An eval suite under `/api/v1/evals/suites` runs the same cases against the tier you name; each case is billed as a chat completion. ## Next steps - [Reliability](/docs/en/build/reliability): deadlines, retries, and confirming a stream finished. - [Reasoning effort](/docs/en/build/reasoning-effort): control how much a tier thinks. - [Get your API key](/docs/en/get-started/api-keys): choose between a signed-in key and a developer API key. - [Handle tool calls](/docs/en/build/tool-calls): answer each call by its `tool_call_id`. - [OpenAI SDK compatibility](/docs/en/migrate/openai-sdk-compatibility): what `POST /v1/chat/completions` supports, ignores, and rejects. ================================================================================ Page: Errors URL: https://learn.getcruxy.com/docs/en/reference/errors Markdown: https://learn.getcruxy.com/docs/en/reference/errors.md ================================================================================ # Errors The error response shape and what 400, 401, and 429 mean. When a request fails, the gateway returns a non-2xx status and a JSON error body. Handle it by reading the HTTP status first, then the `code`. ```json { "code": "invalid_request", "error": "invalid JSON body", "request_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890" } ``` | Field | Meaning | | --- | --- | | `code` | A stable, machine-readable slug; branch on this. | | `error` | A human-readable message. Log it; don't parse it. | | `request_id` | The id of the failed call. Log it. | Check `code`, not the wording of `error`, in your control flow: the message can change, the code will not. [Errors and retries](/docs/en/build/errors-and-retries) lists every code the API defines and which failures to retry. ```ts const res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'kavi', messages: [] }), }) if (!res.ok) { const { code, error } = await res.json() console.error(`request failed (${res.status}): ${code} - ${error}`) } ``` ## Status codes The authenticated endpoints ([Chat](/docs/en/build/chat), [Embeddings](/docs/en/build/embeddings), [Rerank](/docs/en/build/rerank), and [Moderations](/docs/en/build/moderations)) share the same set of failures. ### 400 Bad Request The request was malformed: invalid JSON, a missing required field, or a value the endpoint rejects. Fix the request; retrying it unchanged fails the same way. ### 401 Unauthorized The code is always `unauthorized`: the Bearer key is missing, malformed, unknown, expired, or revoked, and each case gets the same answer. Check the `Authorization` header and the key itself; verify it with [`GET /api/v1/me`](/docs/en/get-started/api-keys#verify-the-key). ### 429 Too Many Requests A limit was hit. The `code` tells you which: | `code` | Meaning | | --- | --- | | `rate_limited` | Requests are arriving too fast. Slow down, then retry. | | `budget_exhausted` | The account's budget is spent. Retrying won't clear it until the budget resets or is raised. | The two are distinct: `rate_limited` clears on its own once you ease off, while `budget_exhausted` is a spend ceiling that a retry alone won't resolve. [Rate limits and budgets](/docs/en/build/rate-limits) covers both, and the headers that tell you how much headroom is left. ## Device-login errors The device-login endpoints use their own OAuth-style codes on `400` (`authorization_pending`, `access_denied`, `expired_token`, `invalid_grant`) rather than the generic error shape above. Those are covered in [Get your API key](/docs/en/get-started/api-keys#device-login). ## Next steps - [Errors and retries](/docs/en/build/errors-and-retries): every error code, and how to retry. - [Authentication](/docs/en/get-started/authentication): resolve `401`s. - [Chat](/docs/en/build/chat): where these errors surface most. - [Changelog](/docs/en/reference/changelog): when the API version changes. ================================================================================ Page: API Reference URL: https://learn.getcruxy.com/docs/en/reference/api Markdown: https://learn.getcruxy.com/docs/en/reference/api.md ================================================================================ # API Reference The complete Cruxy API: every endpoint, typed and interactive. ## GET /api/health: Liveness probe health. Returns 200 as soon as the HTTP listener is up, and cannot return anything else. `commit` is the git commit the running binary was built from (`unknown` for a build that recorded none). Liveness only — it checks no database, schema, or upstream, so it is NOT suitable as a status-page component. Use GET /api/ready for deep readiness. ## GET /api/ready: Readiness probe health. Deep readiness a deploy gates on. Returns 200 only when the database is reachable AND the schema is at or beyond this binary's embedded version; otherwise 503. Distinct from the liveness GET /api/health. ## GET /api/status: Public platform status health. Public, unauthenticated status document for every platform component — the API, the database, each chat tier (kavi, vaani, mira), and each auxiliary serving tier (cruxy-embed, cruxy-rerank, cruxy-guard) — built from active upstream probes rather than from live traffic. Every component carries checked_at and age_seconds; a component not measured recently enough reports "unknown" with a reason, and never a stale verdict. latency_target_ms is present only for the chat tiers (the same value GET /api/v1/models publishes) and is not a threshold. A model component is "degraded" when its median probe latency exceeds latency_degrade_above_ms, when its newest probe succeeded but took longer than latency_slow_above_ms, when its newest probe failed after a success, or, for a chat tier, when the circuit breaker is withholding traffic; it is "down" when its latest two probes both failed. The database is healthy or down. Returns 503 only when no model component has a fresh measurement (i.e. the document itself cannot be trusted); a component that is measurably down is a 200 carrying "down". Components is an array of self-describing rows and may grow — iterate it and read `kind` rather than indexing it. Never cached. ## POST /api/v1/auth/oauth/start: Start native OAuth login oauth-login. Begins a native in-app OAuth login ("Continue with Google/Microsoft/GitHub"). No auth, rate-limited. The CLIENT generates an RFC 7636 PKCE pair and sends the S256 code_challenge; the code_verifier must never be sent here. Open the returned authorization_url in the system browser (never an embedded webview), then POST the code that arrives on your redirect_uri to /api/v1/auth/oauth/token together with the verifier and this state. redirect_uri must be on the server's allowlist FOR THAT PROVIDER — the allowlist is per-provider, so a callback registered for one provider is rejected for another, and for Google it also selects which platform client (iOS or Android) the flow uses. The state is single-use and short-lived. ## POST /api/v1/auth/oauth/token: Complete native OAuth login oauth-login. Exchanges a provider authorization code for a Cruxy API key. Validates the single-use state and the RFC 7636 S256 PKCE verifier, exchanges the code with the provider server-side, resolves the existing Cruxy account from the provider's VERIFIED email (it never creates one: an email with no account is refused with account_required, and the person signs up at https://cruxy.ai/signup first), and mints a cxy_live_ key. The response is byte-identical to POST /api/v1/device/token, so one token-handling path serves both login methods - including `org`, the organization the credential is bound to and the tenant every subsequent request on this key acts in. A user in more than one organization cannot be signed in this way at all (`org_selection_required`): there is no step here where a human could choose. The key is returned exactly once. No cookie is set and no provider token is ever returned. ## POST /api/v1/chat: Chat completions chat. OpenAI-compatible chat completions. model is a public tier - kavi, vaani, or mira - or auto to let the server-side router pick the tier from the request. The canonical path for chat completions is this one, POST /api/v1/chat (native wire: POST /api/v2/chat); an unversioned POST /api/chat is the cruxy-app web BFF's own proxy route on its own origin, not a gateway endpoint - see the API description. stream is optional and false by default. With stream:true the response is OpenAI-style Server-Sent Events: a sequence of `chat.completion.chunk` events, each one object on a `data:` line. FRAME ORDER - a successful stream emits exactly these frames, in this order, and a client must tolerate all of them: (1) a ROLE PRIMER, always first and always before any model output, whose choices[0].delta is {"role":"assistant","content":""} - content is the empty string, not absent - and which carries the turn's cruxy.* block (routed_tier, request_id, reasoning_effort, plus memory_recall / rules_applied when they apply); (2) zero or more CONTENT or TOOL-CALL DELTAS, choices[0].delta carrying either a content fragment or tool_calls fragments (never both), each with finish_reason null; (3) exactly one FINISH_REASON frame, whose choices[0].delta is an EMPTY object {} and which sets finish_reason but carries NO usage; (4) exactly one terminal USAGE frame, which has `choices: []` - an EMPTY ARRAY, no choices[0] to index - and carries the final usage object (and cruxy.memory_capture when the turn stored memory); then (5) the literal line `data: [DONE]`, which is NOT JSON and must not be parsed as such. The usage frame is intentional and contractually stable: it is OpenAI's include_usage convention, which is exactly why frames 3 and 4 are separate and are never combined - a strict OpenAI-compatible client reads usage only from the choices-empty chunk. Note usage.cost is an OBJECT ({amount, currency, input_unit, output_unit, cache_read_unit, tier}), not a number - read usage.cost.amount for a scalar. cost is present on every usage frame, while the additive INTEGER Cruxy usage fields (billable_input_tokens, cache_read_tokens, cache_write_tokens) are omitted when zero and prompt_tokens_details appears only with prompt-cache activity, so their absence means zero rather than an error. Changing SSE framing or frame order is a breaking change requiring a new dated Cruxy-Version, so a client may rely on the order above; new additive cruxy.* fields, however, ship WITHOUT a version bump, so ignore unknown JSON fields. A failure AFTER the stream opens carries no error frame - the 200 is already committed, so the gateway simply stops, ending the response WITHOUT frames 3-5 and WITHOUT the `data: [DONE]` terminator. `data: [DONE]` is therefore the ONLY success signal: treat a stream that ends before it (body closed, or a read returning EOF) as a failed, incomplete completion, and discard or retry the partial output rather than presenting it as a finished answer - the tokens delivered were real and are billed as a partial usage row, but the completion did not finish. For an explicit terminal failure frame instead of truncation, use the native v2 wire (POST /api/v2/chat), whose stream always ends with a named finish or error frame and has no [DONE] sentinel. With stream:false or stream omitted the response is Cruxy-flattened: the assistant reply is the top-level message with a sibling finish_reason, not an OpenAI choices[] array. finish_reason is "stop", "tool_calls" (answer message.tool_calls and continue the turn), "length", or "content_filter". TIMEOUTS AND RETRIES. With stream:false the gateway makes ONE attempt at the model, bounded by the tier's deadline: kavi 90s, vaani 90s, mira 45s. An attempt that runs past it is cancelled and answered 504 upstream_timeout, and nothing is billed. The gateway does NOT retry a timeout, since a turn that needed longer needs longer again: send stream:true (a stream may run for up to five minutes), or ask for less with a lower max_tokens or reasoning_effort. Other upstream failures (an overloaded or failing model, a dropped connection) are retried inside the gateway a bounded number of times, with backoff, before it answers 503 upstream_overloaded or 503 model_unavailable; on stream:true that happens only while the stream is being opened, never after a byte has reached you. The gateway never moves a call to another tier once it is sent: model auto picks a healthy tier before the call, and an explicit tier that kept failing is refused at once with 503 model_unavailable while it cools down. REASONING SPENDS max_tokens: with reasoning_effort omitted each tier applies its default - kavi high (thinking on), vaani low, mira none; auto chooses per request - echoed as cruxy.reasoning_effort, and a reasoning tier generates its thinking inside the same max_tokens budget as the answer without returning it as content. A small max_tokens on kavi can therefore be spent entirely on thinking, ending with finish_reason "length" and empty content; send reasoning_effort:"none" for a short direct answer, or leave max_tokens unset (the tier's cap) to keep reasoning. Cruxy-native fields are additive and namespaced under cruxy.*: the block carries routed_tier, request_id, and reasoning_effort, while token counts and cost live in the usage object (cost at usage.cost). WHICH CREDENTIALS USE MEMORY: memory recall and automatic capture are ON by default for a credential a person gets by signing in (the Cruxy web app, desktop, the CLI's device login, mobile) and OFF by default for a developer API key or a headless key, which opts in per request with memory:{"recall":true} and/or memory:{"capture":true} (see PER-REQUEST MEMORY CONTROLS below). A key minted by POST /api/v1/device/token or POST /api/v1/auth/oauth/token is a signed-in credential whichever client ran the flow, a third-party tool included, so it has memory and rules on; a server must use a developer API key instead (see BearerAuth). GET /api/v1/limits reports the kind as bucket: subscription is signed in, apikey and headless are not. An API key often serves people other than its owner, and recall would put the owner's stored memory into their turns while capture would store what they say, so neither happens unless the request asks. A key whose allowed_scopes omit the memory family can never use memory through chat - asking with true is 403 key_scope_forbidden (required_scope memory), refused before anything is billed - which makes it the key to issue for a service that must never see anyone's memory. When recall runs, relevant stored memories are recalled server-side and added to the turn as background context, drawn from the same resolved view GET /api/v1/memories lists: the caller's personal memory, their organization's, and the vaults they can read. The optional vault_id names the vault a chat is happening inside - it does not gate vault memory generally (a readable `default` vault's memory is recalled either way), it selects the ISOLATED case: inside a `vault_only` vault recall draws on ONLY that vault's memory, excluding personal and organization memory, and outside it that vault contributes nothing. A vault_id the caller cannot read is treated as none and never reported, so the request cannot be used to test whether a vault id exists. What was recalled is reported additively as cruxy.memory_recall (scope - personal, org, or vault - and key per memory; never the stored value), and a vault-scope memory names the vault it came from (vault_id, plus vault_name when it resolves). A turn may also REFERENCE vaults it is not inside with mentioned_vault_ids - the ids behind an "@VersionGate", resolved by your own picker, never by the gateway reading names out of the message. At most 3 are used; ids you cannot read, repeats, and the active vault_id are dropped silently, so the response is identical to one that never sent them and the field cannot be used to probe whether a vault id exists. An accepted mention adds that vault's memory to this turn's recall - sharing the same recall budget rather than enlarging it, and keeping each vault's isolation intact - without making the vault writable. WHICH CREDENTIALS GET STANDING RULES: the same default as memory. Your organization's standing rules are injected by default for a credential a person gets by signing in, and NOT for a developer API key or a headless key, whose rules are its owner's organization's guidance and which a visitor to whatever the key serves could ask the model to repeat. Such a key opts in per request with rules:true, or once, on the key, by being issued allowed_scopes that NAME the rules family (an unscoped key has every family but has not opted in). Any credential may send rules:false to decline them for a turn, which needs no scope since the user's own message already overrides them. A key whose allowed_scopes omit rules never gets them, and rules:true on it is 403 key_scope_forbidden (required_scope rules), refused before anything is billed. The switch covers the whole resolved ruleset, the organization's and, with vault_id, the vault's, so a turn gets exactly what GET /api/v1/rules/resolved returns or nothing. When rules apply and the caller's organization has configured them, they are resolved server-side and injected as guidance for the turn - as DEFAULTS the user's own message overrides, never as constraints that outrank the user - and the response signals this additively as cruxy.rules_applied (how many rules applied and from which rulesets; never the rule text, which is readable at GET /api/v1/rules/resolved). vault_id selects which rules apply too: inside a vault the turn gets that vault's own ruleset merged with the organization's per the vault's inherit_org_rules switch - the same answer GET /api/v1/rules/resolved gives for that vault - and rules_applied names each contributing scope (org, vault). A vault_id the caller cannot read is treated as none here as well, so the turn simply runs with organization-scope rules. Injected rule text is real upstream input and is counted in usage.input_tokens like any other. When capture runs, after a turn is answered the gateway also decides whether the exchange contained a durable fact worth remembering - a stated preference, a stable setting, a constraint about your work - and stores it automatically for future turns. EVERY such capture is reported in the same response as cruxy.memory_capture (how many memories were stored, and the scope and key of each; never the stored value), so nothing enters memory without the response naming it: no field means nothing was stored. Captured memory is written to your own personal memory, or - when the chat names a vault_id you may write - to that vault, so a fact learned in a shared space is remembered there; a vault you may only read demotes the capture to your own memory. It is NEVER written to organization-wide memory, whatever your role. Content carrying an email address, phone number, or card number is dropped before any write, on the same terms as the manual write endpoint. Capture cannot fail or delay a turn: if extraction is unavailable it simply stores nothing. Everything stored is listed by GET /api/v1/memories and removable with DELETE /api/v1/memories/{id}. PER-REQUEST MEMORY CONTROLS: an API key opts in with memory:{"recall":true} (inject the stored memories most relevant to this turn) and/or memory:{"capture":true} (allow a durable fact from this turn to be stored); any credential may send memory:{"recall":false} to inject no stored memory into this turn (no memory is read, mentioned_vault_ids are not consulted, no cruxy.memory_recall is returned, and input tokens fall by the size of the block that would have been injected), and memory:{"capture":false} to store nothing from it (no extraction, no write, no cruxy.memory_capture). The two sides are independent: {"capture":false} alone keeps the turn personalised by what is already stored while storing nothing new. A missing memory object, a missing key, or null means the credential's default (on for a signed-in client, off for a developer or headless API key), and a literal true or false states your choice. false needs no key scope, since it only makes the gateway do less for your own request; true needs a key whose allowed_scopes include memory (an unscoped key has every family). Standing rules are not memory, and this field never affects them: they have their own switch, rules (see WHICH CREDENTIALS GET STANDING RULES above). Pin Cruxy-Version 2026-09-26 on every request that relies on memory:false: unknown fields are ignored rather than refused, so a gateway that predates this field would answer 200 and capture anyway, and a pinned request is instead refused by such a gateway with 400 unknown_version before recall, the upstream call, billing or capture run. This is the one exception to the rule above that a dated Cruxy-Version is only for breaking changes: a date may gate an additive request field whose silent absence on an older gateway would make the response misrepresent what it used, whether a privacy promise (as here) or content the user supplied. It is not precedent for ordinary additive fields, which still ship without a version bump. ATTACHED FILES: a user message may carry file_ids, the ids of files you uploaded with POST /api/v1/files under the same organization and user as this credential, and the gateway reads each one into the turn. The request must pin Cruxy-Version 2026-10-05 or later: a request naming file_ids without that pin is 400 invalid_request (carrying required_version), because a gateway that predates the field would ignore it and answer without the files, and the pin is what makes such a gateway refuse instead. UTF-8 text files are read (plain text, markdown, CSV, JSON, source code), and so are PDFs and Word documents (.docx), as the text read out of them once, when they were uploaded (POST /api/v1/files reports it as text_status): a document is framed, measured against the caps and billed by its text exactly as a text file is, and is never parsed on a turn. A file of another type, or whose bytes are not UTF-8 text, is 400 file_not_text, and so is a document whose text could not be read, with a reason field saying why: no_text (no text found, such as a scanned PDF), encrypted (password-protected), corrupt, unreadable (reading it went past the time or memory allowed for one document), legacy_doc (a Word 97-2003 .doc) or not_extracted (uploaded before documents were read; upload it again). A file over the per-file token cap, or one that takes the request past the per-turn cap, is 400 file_too_large, and so is a PDF of more than 500 pages (bound pages) or over 64 MiB (bound bytes), which is not read; for a document the message advises attaching a shorter excerpt. Both name the file (file_id, filename, and for file_too_large bound, limit and tokens). An id that is not yours, whether another user's, one filed under another of your organizations, missing or malformed, is 404 file_not_found, the same answer in every case. A key whose allowed_scopes omit files is 403 key_scope_forbidden (required_scope files). All of these are answered before anything is sent upstream or billed. Each file's text goes in as a user-role block immediately before the message that named it, framed as material for that message and never as instructions to follow; it never enters the system prompt, recall does not search with it, and automatic capture does not read it. A file named on several messages is sent once, before its first mention. Its tokens are input like any other and are billed on EVERY request that sends the file, wherever its block sits: a file id you keep in a conversation's history is billed on each turn, not only the first. They count toward the tier's context window: a file on an earlier message that does not fit beside the conversation, with room left for the reply's max_tokens and a margin of a tenth of the window, is left out, oldest first, and listed in files_omitted with reason context_window, and a turn that does not fit even with every earlier file left out is 400 context_overflow. POST /v1/chat/completions refuses file_ids, and POST /api/v1/tokens/count does not count them. The additive fields never remove or alter the OpenAI-shaped message, so a vanilla OpenAI client keeps parsing. ## GET /api/v1/conversations: List conversations conversations. Lists the CALLER'S OWN conversations, most recently active first. **Private to their author**: this never returns a conversation authored by anyone else, whatever the caller's organization role and whatever vaults they share. Keyset-paginated like the request-log listing: pass `?limit` (default 50, max 100) and the opaque `?cursor` from a previous response's `next_cursor`, which is null on the last page. Filter with `?vault_id` (only conversations held in that vault; a vault the caller cannot read simply matches nothing), `?archived` (false, the default, lists live conversations; true lists the archived shelf), and `?source` (chat, the default, cli, or agent: only conversations that surface wrote, so a chat client that never names a source never sees a Cruxy Code session). Each entry carries `permissions`, resolved from the same predicate the write routes enforce. Messages are not included - fetch a conversation by id for those. ## POST /api/v1/conversations: Create a conversation conversations. Creates a conversation owned by the calling user. **Conversations are private to their author** - no other member of the organization, including an owner or admin, can read, list, or search it. Optional `title` and `model` are presentation only. Optional `vault_id` holds the conversation inside a vault: it then requires `can_write` on that vault, and it is visible thereafter only while the author can still read that vault. A vault the caller participates in but cannot write is 403 vault_forbidden; one in another organization, one that does not exist, and one the caller does not participate in are all 404 vault_not_found. Omitting `vault_id` creates a loose conversation, which is the normal case. Optional `source` says which surface is writing it: `chat` (the default) or `cli` for a Cruxy Code session; `agent` is 400 invalid_request, because only the gateway writes agent transcripts. Optional `meta` is a JSON object of at most 4096 bytes once compacted, stored and returned verbatim; anything else is 400 invalid_request. The 201 body is read back from the committed transaction, so it agrees field-for-field with the next GET - including `retention_until`, the deadline after which the conversation and its messages are deleted (365 days from last activity by default). ## GET /api/v1/conversations/search: Search your own messages conversations. Full-text search over the CALLER'S OWN messages, most relevant first. **It can never see more than the listing can**: this route and GET /api/v1/conversations embed the identical visibility predicate, so a colleague's message - including an organization admin's view of a member's, and including in a vault they share - is not merely omitted from the results but never matched. `q` is parsed with `websearch_to_tsquery`, so quoted phrases, `or`, and a leading `-` to exclude all work, and no input is a syntax error. Each hit carries a `snippet`: an excerpt with matched terms delimited by the literal markers `[[hl]]` and `[[/hl]]`. **They are deliberately not HTML** - a snippet mixes raw user-authored text with the markers, so a client must escape the whole snippet first and substitute the markers afterwards; a client that does not want highlighting can simply strip them. Archived conversations are searched too, and so is every branch of a branched conversation: a message on a branch that is not active is still the caller's own text, so it is returned with `on_active_path` false rather than hidden. `on_active_path` is true for every message of a conversation that has never forked, and otherwise for the active leaf and its ancestors, the messages GET /api/v1/conversations/{id} reads. To open an off-path hit, POST its `message_id` to /api/v1/conversations/{id}/switch. `?source` (chat, the default, cli, or agent) searches only conversations that surface wrote, matching the listing's filter. `?limit` defaults to 20, max 50. ## DELETE /api/v1/conversations/{id}: Delete a conversation conversations. Deletes a conversation and every message in it. Irreversible and immediate - there is no trash, and archiving (PUT /api/v1/conversations/{id}/archive) is the reversible option. The deletion is recorded so it can be explained later (the same happens when a conversation reaches its retention deadline): the record is metadata only - the conversation id, its author, organization and vault, how many messages it held, when it was created, and the request's X-Request-Id - and never its title or any message text. Requires `permissions.can_write`; a conversation held in a vault the caller may read but not write is 403 conversation_forbidden, and one the caller cannot see at all is 404 conversation_not_found. **Attached chat attachments go with it**: every file a message of this conversation attached with purpose chat_attachment is deleted too, unless a message in another conversation still attaches it, in which case it stays until the last such message is gone. A file uploaded for any other purpose is never deleted by this route; it is only removed from this conversation's messages, and stays in the Files API until its owner deletes it or its expires_at passes. Each deleted file is recorded the same way the conversation is: metadata only, never its filename. A file no message attached is never touched. ## GET /api/v1/conversations/{id}: Get a conversation with its messages conversations. Returns one conversation and a page of its messages. **Private to its author**: a conversation authored by another member - including when the caller is an organization admin and a member of the same vault - is 404 conversation_not_found, indistinguishable from an id in another tenant and from one that never existed. A 403 would confirm the id is real. Messages are paginated independently of the conversation listing: `?message_limit` (default 100, max 200) and the opaque `?message_cursor` from `messages.next_cursor`, which is null on the last page. `?message_order` selects the direction: `asc` (the default, oldest first) or `desc`, which returns the NEWEST messages first - the tail of a long thread in one request, rather than paging forward through the whole of it. Ordering is ranking only and never changes which messages are visible. The page echoes the direction it was read in as `messages.order`. **A `message_cursor` may only be used with the `message_order` it was issued for**; the other direction is 400 invalid_request rather than a silently different page, because one anchor names two disjoint pages depending on direction. Each message carries `status` - `complete` (the model finished its answer), `incomplete` (the answer is not whole, however the stream ended: a dropped connection, a timeout, or a length cap, i.e. `finish_reason: "length"`, which ends the stream cleanly), or `cancelled` (a human stopped it) - so a partial reply is never rendered or replayed as a finished one. A conversation can branch, and the page is ONE PATH through it, read from `messages.leaf_id` back to the first message: the active branch on a first page. A `message_cursor` carries its page's leaf, so later pages stay on that branch even if the conversation is switched to another between requests. **`?message_branch` reads another branch without switching to it**: given a message id, the page is the branch through that message, from its deepest descendant by newest child, exactly the branch `POST /api/v1/conversations/{id}/switch` with that message would make active, while the conversation stays on its active branch for every device. It is a read, so it needs no write permission. Walking each message's `sibling_ids` this way, starting from the branch through `active_leaf_message_id` (the active leaf can have a child stored without moving it, which the plain read ends above), reads every branch of a conversation, which is how a client exports the whole tree. A `message_branch` that is not a message of this conversation is 404 message_not_found. It starts a read: later pages follow `messages.next_cursor`, which carries the branch, and sending it with `message_cursor` is 400 invalid_request. ## PATCH /api/v1/conversations/{id}: Rename or retag a conversation conversations. Changes a conversation's `title` and/or `model`. A title set here is a USER DECISION and outranks the automatic one: auto-titling only ever fills a `title` that is null, so a rename is permanent and no later message undoes it. Both are optional but at least one must be present; an omitted field is left unchanged, an explicit `null` clears it, and an empty patch is 400 invalid_request rather than a no-op 200 that would tell a client its request applied when it named nothing. `vault_id` is deliberately NOT patchable: moving a conversation between vaults changes who can see it, and that is a separate operation with its own authorization rather than a side effect of a rename. Requires `permissions.can_write` — false only when the conversation is held in a vault the caller may read but not write, which is 403 conversation_forbidden. A conversation the caller cannot see at all is 404 conversation_not_found. ## DELETE /api/v1/conversations/{id}/archive: Restore a conversation conversations. Clears a conversation's archive, returning it to the default listing. Idempotent: restoring a live conversation is 204. Requires `permissions.can_write`. ## PUT /api/v1/conversations/{id}/archive: Archive a conversation conversations. Archives the conversation: it leaves the default listing and appears only with `?archived=true`. The conversation and every message are untouched — archiving is not deletion, and archived conversations are still searched, because archiving means "stop showing me this", not "forget it". Idempotent: archiving an archived conversation is 204 and does not move the original archive instant. Archiving does NOT extend the retention deadline; shelving something is not activity. Requires `permissions.can_write`. ## POST /api/v1/conversations/{id}/messages: Append a message to a conversation conversations. Appends one message to a conversation. **Where it goes in the tree**: with no `parent_id` the message answers the current leaf (`active_leaf_message_id`, or the newest message while that is null), as every append always has; with one, it answers that message, which must belong to this conversation; with `root: true` it answers nothing, and is a new root beside the first message, which is how a client edits the first message. `root` and `parent_id` together are 400. Send `expected_leaf`, the leaf you believed was current, with any message that may arrive after the conversation has moved on (an edit, a regeneration, a streamed reply). If it still is current, or you omit it, the append moves the leaf to the new message; if not, the message is stored under its parent anyway, the leaf is left on the branch someone switched to, and `leaf_moved` is false. A stale `expected_leaf` never refuses an append. A `parent_id` or `expected_leaf` that is not a message of this conversation is 400. A conversation that has never branched keeps a null `active_leaf_message_id` for as long as every append answers its newest message. `role` is one of `system`, `user`, `assistant`, `tool`. `status` is `complete` (the default: the model finished its answer), `incomplete`, the answer is not whole however the stream ended (a dropped connection, a timeout, or a length cap: a reply that ended with `finish_reason: "length"` is `incomplete` even though its stream closed cleanly), or `cancelled`, a human stopping it deliberately; storing it is what keeps a partial reply from being rendered or replayed as a finished one. **An agentic turn must carry its tool fields**: `tool_calls` on the assistant message that requested them (same shape /chat returns), and `tool_call_id` on each tool result naming the call it answers. `content` may be empty on an assistant message that carries `tool_calls`, the calls are the message, and is required otherwise. `tool_calls` is valid only on role `assistant`, `tool_call_id` only on role `tool` and required there; anything else is 400. A message stored without them reads back blank and replays as a transcript /chat will not accept. The `sequence` is assigned SERVER-SIDE under a row lock, so concurrent appends cannot collide or reorder the transcript, and it is returned on the created message. **The first user message NAMES the conversation.** If `title` is still null it is derived from that message's content and set in the SAME transaction as the append, so the `conversation` echoed in this response already carries it, a title that landed later would land after a client had already rendered "Untitled chat". Derivation is mechanical, never model-generated: the first line, whitespace collapsed and control characters dropped, truncated to 80 characters on a word boundary with an ellipsis. It applies ONLY when `title` is null, a title you set at create or by PATCH is a decision and is never overwritten, and ONLY from a `user` message, since an assistant turn is the model's words rather than yours. Content carrying no letter, digit or symbol (`"..."`, `"???"`) leaves `title` null and the next user message that does carry words names the thread instead: a meaningless title looks like a name someone chose, where null correctly says the conversation has not been named. Appending advances the conversation's `updated_at` and re-stamps its retention deadline, so a conversation in use never ages out. **Send an `Idempotency-Key` header**: a retry with the same key and body replays the stored 201 rather than writing the turn twice, and the same key with a different body is 409 idempotency_conflict. **Attached files**: a `user` message may carry `file_ids`, files you uploaded with POST /api/v1/files, and the conversation keeps them: every read returns each message's `file_ids` in the order sent (an empty array when it attached none), which is what a client sends again as /chat's `file_ids` on later turns. The request must pin `Cruxy-Version: 2026-10-06` or later; without it, `file_ids` is 400 invalid_request with `required_version`, because a gateway that predates attachments would store the message without them and answer 201. At most 20 distinct files, a repeat attached once. Each must be a file of yours under this credential's organization: another user's, one filed under another of your organizations, one whose `expires_at` has passed, a missing id and a malformed one are all 404 file_not_found naming the id, and the append writes nothing. A key whose `allowed_scopes` omit `files` is 403 key_scope_forbidden. Deleting the conversation, or its retention deadline passing, deletes every attached file that no message in another conversation still attaches; a file attached elsewhere lives until its last attaching message goes. Deleting a file yourself removes it from the messages that listed it. Requires `permissions.can_write`. ## POST /api/v1/conversations/{id}/switch: Switch a conversation to another branch conversations. Moves the conversation onto the branch through `message_id`, the message a version switcher picked (for example the sibling shown as "1 / 2"). The server resolves the **deepest descendant** of that message, following the newest child at each level, and makes it the conversation's active leaf, so switching back to a branch restores it with every message written on it since. The response carries that leaf as `leaf_id`; the next `GET /api/v1/conversations/{id}` reads the branch ending there. **The switch is shared state**: every device reads the branch it names, so it requires `permissions.can_write`, and a conversation held in a vault the caller may read but not write is 403 conversation_forbidden. A `message_id` that is not a message of this conversation is 404 message_not_found, whether it belongs to another conversation, another tenant, or nothing. Switching is **not activity**: `updated_at` and `retention_until` do not move, so a switch never reorders the conversation listing. A conversation that has never branched stays unbranched: every message of it resolves to its newest message, and `active_leaf_message_id` stays null. Switching to the branch already active changes nothing and still succeeds. The switch takes the same per-conversation lock an append takes and reads the tree after it, so a switch racing an append to the same branch always sees the appended message if the append committed first. ## POST /api/v1/device/code: Start device login device-login. Begins an RFC 8628 device-authorization flow (no auth, rate-limited). Returns the client's device_code, the human-typed user_code, and where to approve. Poll POST /api/v1/device/token with the device_code until approved. ## POST /api/v1/device/token: Poll device login device-login. The CLI polls with its device_code. Returns 200 with a freshly minted API key once a human has approved the flow, or an OAuth-style error (400) while pending/denied/expired/invalid. The key is returned exactly once. It is a SIGNED-IN CREDENTIAL, whichever client ran the flow: in chat it gets the person's memory recall, automatic memory capture and their organization's standing rules by default, it expires at expires_at (90 days after issue by default) and use does not renew it, and its holder cannot rotate or revoke it. Keep it in the person's own tool. Do not deploy it in a server or in anything that serves other people: use a developer API key from the Cruxy Console there (see BearerAuth), or send memory:{"recall":false,"capture":false} and rules:false on every chat request. The 200 carries `org`: the organization the credential is bound to, with the signed-in user's role in it. Show it — a login binds to exactly one organization, and it is not always the one the previous login used. A 400 may additionally carry `reason` and `error_description`, both **additive** and both absent on most refusals; `error` and `code` are unchanged either way. The one `reason` today is `org_membership_revoked`: the authorization was approved for an organization the account no longer belongs to. Starting a new login will succeed, but it may bind to a **different** organization, so tell the user rather than retrying silently. ## POST /api/v1/embeddings: Create embeddings embeddings. OpenAI-compatible embeddings. model must be "cruxy-embed". input accepts a single string or an array of strings — at most 512 strings per request, with the whole body ≤ 64 KiB (either exceeded is a 400). Each embedding is a fixed 1024-dimensional dense vector (size your vector store to 1024). The additive cruxy.* block carries the request id and cost; the OpenAI-shaped fields are unchanged. ## GET /api/v1/evals/runs/{id}: Get an eval run evals. Returns a run with its status and a summary of results so far: total cases, how many are completed, and the pass/fail split. Poll this after starting a run. A run id in another workspace or organization, or one that names nothing, returns `404 eval_run_not_found`. ## GET /api/v1/evals/runs/{id}/results: List a run's results evals. Lists the per-case results of a run: the model's output, the pass/fail verdict, and the latency of each completion, oldest first. A run id in another workspace or organization, or one that names nothing, returns `404 eval_run_not_found`. ## GET /api/v1/evals/suites: List eval suites evals. Lists the eval suites in the caller's workspace, each with its case count. Ordered oldest first. ## POST /api/v1/evals/suites: Create an eval suite evals. Creates an eval suite in the caller's workspace — a named container for prompt/expected cases. The workspace is the one the credential names; there is no workspace or org id in the path or body. `name` is required (1..128 bytes). `slug` is the stable, URL-safe handle and is optional: when omitted it is derived from the name; a slug already used in the workspace is `409 eval_suite_slug_exists`. `prompt_id` optionally links a stored prompt in the SAME workspace (advisory metadata; a prompt_id outside the workspace is `400 invalid_request`). Returns the created suite. ## DELETE /api/v1/evals/suites/{id}: Delete an eval suite evals. Deletes a suite and all of its cases, runs, and results (they cascade). A suite id in another workspace or organization, or one that names nothing, returns `404 eval_suite_not_found`. ## GET /api/v1/evals/suites/{id}: Get an eval suite evals. Returns one suite of the caller's workspace. A suite id in another workspace or organization, or one that names nothing, returns `404 eval_suite_not_found`. ## GET /api/v1/evals/suites/{id}/cases: List a suite's cases evals. Lists every case of a suite, oldest first. A suite id in another workspace or organization, or one that names nothing, returns `404 eval_suite_not_found`. ## POST /api/v1/evals/suites/{id}/cases: Add a case to a suite evals. Appends a case to a suite. `input` is the prompt sent upstream verbatim (1..102400 bytes); `expected` is the reference answer (1..102400 bytes); `match_mode` is `exact` (output equals expected, trimmed) or `contains` (output contains expected). A suite id in another workspace or organization, or one that names nothing, returns `404 eval_suite_not_found`. ## DELETE /api/v1/evals/suites/{id}/cases/{case_id}: Delete a case evals. Deletes one case of a suite. A case that is not in the suite, or a suite id in another workspace or organization, returns `404 eval_case_not_found`. ## POST /api/v1/evals/suites/{id}/runs: Start an eval run evals. Enqueues an asynchronous run of a suite against `model` (one of `kavi`, `vaani`, `mira`) and returns immediately with the run in status `pending` — it does NOT block on execution. A background worker then runs each case through the standard chat path, so the run **costs money**: every case is a billed completion charged to the credential that created the run, exactly as an interactive chat call would be. Poll `GET /api/v1/evals/runs/{id}` for status and results. A suite id in another workspace or organization, or one that names nothing, returns `404 eval_suite_not_found`. ## POST /api/v1/feedback: Submit feedback feedback. Inbound flywheel signal joined to a prior call by request_id. PRIVACY: a non-consented or opted-out event is dropped entirely (stored=false, no row); a consented event is stored with its payload redacted. `request_id` must be the `cruxy.request_id` of a call THIS account made; an id belonging to another account, or one that names no call at all, is one indistinguishable `404 request_not_found`. The opt-out is a durable account-level setting managed in the Cruxy app, independent of this request's `consent` flag; either a `consent:false` event or an active account opt-out is dropped. There is no public endpoint to read or change the opt-out. ## GET /api/v1/files: List files files. List the authenticated user's files, newest first. Keyset-paginated: pass ?limit (default 20, max 100) and the opaque ?cursor from a previous response's next_cursor. Only the caller's own files are ever returned. Returns {data:[…], next_cursor} where next_cursor is null on the last page. ## POST /api/v1/files: Upload a file files. Upload a file as multipart/form-data. The file part is named "file"; an optional "purpose" text field labels it (short [A-Za-z0-9._-] token, default "general"). Content is stored opaquely under a generated id - never executed, never interpreted, never addressed by its original filename. Its media type is detected by the server from the content's first bytes, refined by the filename's extension only when the content already reads as UTF-8 text, and returned as content_type; the Content-Type on the multipart part is ignored. A JPEG, PNG or WebP image is recorded as image/jpeg, image/png or image/webp whatever its name, and chat shows it to a tier that reads images when a message names it in file_ids. A single file may be at most FILES_MAX_FILE_BYTES (default 512 MiB); exceeding it is 413. This endpoint is authenticated and per-key rate-limited but NOT billed. LIFETIME: an optional "expires_in" text field gives the file a lifetime in seconds, from 60 to 604800 (7 days), returned as expires_at. The moment it passes the file is gone to every read: it is not listed, its metadata and content are 404, it no longer counts against the quota, and chat cannot read it; the bytes are deleted from storage on the next sweep, within 15 minutes. A file uploaded from a temporary chat should always carry one. Setting it requires pinning Cruxy-Version 2026-10-07 or later, because a gateway that predates the field would keep the file forever and answer 201; without the pin, or with a value out of range, the upload is 400 invalid_request and nothing is stored. Without expires_in a file lives until you delete it, as before. UNATTACHED CHAT ATTACHMENTS: an upload with purpose "chat_attachment" exists to be attached to a conversation message (POST /api/v1/conversations/{id}/messages with file_ids). One that no message has attached 24 hours after upload is deleted, row and bytes, by the same sweep that enforces lifetimes, so a file picked in a chat and never sent does not live forever. An attached one is kept until the last message attaching it is deleted. No other purpose is ever deleted for being unattached. DOCUMENTS: a PDF, or a Word document (.docx, recognised from the zip's own content types and recorded as application/vnd.openxmlformats-officedocument.wordprocessingml.document), has its text read once, as part of the upload, so chat can read it like a text file (POST /api/v1/chat file_ids). The reading runs in a separate, bounded process (at most 15 seconds, 256 MiB, 500 pages and 64 MiB of document) and never fails the upload: the file is stored whatever it holds, and text_status reports what reading it found: extracted (text_tokens is its size, by the same characters/4 estimate chat's caps use), no_text (no text found, such as a scanned PDF), encrypted (it needs a password), corrupt (damaged, or a feature the reader does not support), too_long (over 1 MiB of text, which is not kept; text_tokens says how much there was), too_many_pages, too_big, failed (reading ran out of time or memory), or busy. Reading runs at most two documents at once; a document that arrives while both are busy waits in line for up to 30 seconds, behind at most 20 others, and only one that is still unread then is busy: nothing is wrong with the file, and the same file uploaded again is read. text_status and text_tokens are null for every other file. A legacy Word 97-2003 document is recognised from its bytes and recorded as application/msword, and is not read. A document's stored text counts toward FILES_MAX_USER_BYTES beside its bytes. The file belongs to the ORGANIZATION the credential names as well as to the user, and FILES_MAX_USER_BYTES (default 2 GiB) is metered per (organization, user) - so for a member of more than one organization each tenant has its own pool, and a file uploaded with one organization's credential is not visible to another's. ## DELETE /api/v1/files/{id}: Delete a file files. Delete one of the authenticated user's files and its content. A file belonging to another user, or to another of your own organizations, is 404 — the same answer as a missing or malformed id, since a distinguishable response would confirm the id exists. Idempotent from the client's view: a repeat delete of an already-deleted id is 404. ## GET /api/v1/files/{id}: Get file metadata files. Fetch metadata for one of the authenticated user's files, within the organization the credential names. A file belonging to another user — or to another of your own organizations — returns 404 (never 403, which would confirm the id exists), as does a missing or malformed id. ## GET /api/v1/files/{id}/content: Download file content files. Stream the raw bytes of one of the authenticated user's files. Always returned as application/octet-stream with Content-Disposition: attachment and X-Content-Type-Options: nosniff — content is never rendered inline. A file belonging to another user, or to another of your own organizations, is 404 — the same answer as a missing or malformed id, since a distinguishable response would confirm the id exists. ## GET /api/v1/invitations: List invitations addressed to me organization. Lists the live invitations addressed to the caller, across every organization — the pending offers to join somewhere. Settled and expired invitations are not listed. This route names no organization and takes none: it resolves through the caller's own account, so there is no parameter by which one person could read another's invitations. Each entry carries the inviting organization's name and the inviter's address, so the offer can be attributed before it is accepted. ## POST /api/v1/invitations/{invitation_id}/accept: Accept an invitation organization. Accepts an invitation addressed to the caller and joins that organization with the role the invitation offered. This is the only way a membership is created on a user credential's authority. An invitation that names somebody else, one that never existed, one that has expired, one already settled, and a malformed id are one `404 invitation_not_found` — there is deliberately no answer meaning "that invitation exists but is not yours". Joining an organization while already belonging to another means credential issuance can no longer resolve an organization implicitly; new credentials must name one (`org_selection_required`). Existing credentials are unaffected. ## POST /api/v1/invitations/{invitation_id}/decline: Decline an invitation organization. Declines an invitation addressed to the caller. Nothing is created and the invitation is settled, so it stops appearing in the inbox and stops being acceptable. Declining is exactly as private as accepting: an invitation that names somebody else, one that never existed, one that has expired, one already settled, and a malformed id are one `404 invitation_not_found`. The inviting organization sees the outcome on its own invitation list; it is not told anything else. ## GET /api/v1/limits: Get my limits and headroom limits. Returns the authenticated credential's tier, billing bucket, and current rate/budget limits with live remaining headroom — every value read from the same limiter and budget state the chat path enforces on, so `remaining` here is exactly what the next request is gated against. **Credential-derived and workspace-scoped**: tier (`users.plan`), bucket, the per-key spend cap, and the per-key request-rate all come from the authenticated key; the workspace spend cap is for the workspace the key binds to. None of it is read from the request. **`rate_limits.chat`** carries the per-key and per-org chat request-rate ceilings (requests per 60s window) and their remaining count; `resets_at` is null when no request has opened the current window. **`token_pool`** (subscription bucket only) is the weighted-token budget: the calendar-month cap and the trailing-12h sliding burst cap, each with used/remaining in weighted tokens, plus the Mira monthly request kill-switch. **`credits`** (headless bucket only) is the dollar-credit pool. **`key_spend_cap`** / **`workspace_spend_cap`** appear only when a monthly micro-USD ceiling is set, reported in USD. There is deliberately no tokens-per-minute limit and no per-model daily cap — Cruxy enforces neither, so neither is invented here. No write, no cost. The endpoint is itself rate-limited and emits the standard `X-RateLimit-*` headers for its own bucket. ## GET /api/v1/logs: List request logs organization. List request metadata for the caller's organization, newest first — one row per request the org's API keys made, drawn from the usage ledger. **Owner or admin only** (`logs_forbidden`): this is operator surface spanning every member's keys, not a per-user view. **Metadata only** — model, token counts, latency, lifecycle status (`success`/`error`/`partial`), the originating key and workspace, request id, and timestamp. Prompt and completion content are never stored and never returned. Keyset-paginated like the admin users list: pass `?limit` (default 50, max 100) and the opaque `?cursor` from a previous response's `next_cursor`. Filter with `?key_id`, `?workspace_id`, `?model`, `?status`, and an `?from`/`?to` RFC3339 time window (from inclusive, to exclusive). A `key_id` or `workspace_id` outside the caller's org simply matches nothing. Returns `{object:"list", data:[…], next_cursor}` where `next_cursor` is null on the last page. ## GET /api/v1/logs/{request_id}: Get request log organization. Fetch metadata for a single request by its `request_id` (the `cruxy.request_id` returned on chat/embeddings/rerank responses). **Owner or admin only** (`logs_forbidden`). **Metadata only** — never prompt or completion content. A request id in another organization, or one that names nothing, returns `404 log_not_found` — a 403 would confirm the id is real. A request id may map to more than one ledger row (a reused Idempotency-Key, or an error followed by a retried success); the most recent is returned. ## GET /api/v1/me: Current identity auth. Echoes the authenticated identity resolved from the Bearer key, proving the key pipeline works end to end. ## DELETE /api/v1/memories: Forget all my memory memory. Permanently deletes every `user`-scope memory belonging to the caller and reports how many rows were removed. This is a real delete, not a flag — the rows are gone and nothing resurrects them. It is scoped to the caller: an org-mate's memory, and every other tenant's, is untouched. **Shared memory is not included.** A vault's or an organization's contents belong to the container, not to whoever typed them, so removing one is a `DELETE /api/v1/memories/{id}` gated by `can_write` on that vault or by the organization owner/admin role — or, for a departed member's writes, `POST /api/v1/memories/departed/{user_id}`. `?scope=vault` and `?scope=org` are refused with `invalid_request` for that reason, rather than returning a misleading `deleted: 0`. ## GET /api/v1/memories: List memories memory. Returns the caller's effective memory — one entry per key, with the most specific scope winning (user beats vault beats org), merged across all three scopes. **Each resolved entry carries the `id` of the row that WON** — the row whose `value` is being shown, at the scope `resolved_from` names — and it is the id `DELETE /api/v1/memories/{id}` takes, so a review-and-forget surface can remove exactly what it is displaying without re-listing each scope's raw rows to work out which one supplied the value. Deleting it lets the next-most-specific scope surface on the following read. **The `id` is an address, not a permission**: delete authority is unchanged by its presence and is exactly write authority — your own `user` rows, a `vault` row where your membership grants `can_write` and is not held, an `org` row only if you are an owner or admin. A row you may read but not write returns **404** (not 403 — an id is never confirmed to a caller not entitled to act on it), so a client rendering a forget control should take the vault leg from that vault's own `permissions.can_write` (`resolved_from.vault_id` names it) and the org leg from `permissions.can_write` on `GET /api/v1/rules`, which any member may read and which reports the same organization-role predicate this delete gates on. The org leg covers organization-wide memory when the caller is still a member of the organization; the vault leg covers every vault whose membership grants the caller `can_read` without a hold, and when two readable vaults define the same key the most recently updated wins and `resolved_from.vault_id` reports which one. A vault whose `memory_scope` is `vault_only` is isolated and does not contribute to this general view — its memory surfaces only when resolving inside that vault. Precedence is resolved server-side; clients consume the result and never recompute it. Pass `?scope=user`, `?scope=org`, or `?scope=vault&vault_id=…`, to see the raw stored rows at one scope instead. ## PUT /api/v1/memories: Remember a value memory. Stores a memory under a key, replacing any existing value for that key at the same scope. Keys are case-insensitive, so "Timezone" and "timezone" are the same memory. **Content carrying obvious PII is refused, not redacted**: if the key or value contains an email address, a phone number, or a Luhn-valid card number, the request fails with `pii_rejected` and nothing is stored — memory has no expiry, so anything accepted is kept until it is explicitly deleted. The error names the category that matched and never echoes the matched text; rephrase without it and retry. The check is server-side and unconditional — no request field disables it, and it applies identically at every scope. `user` scope is private to the caller. `vault` scope requires a `vault_id` and a vault membership granting `can_write` — a held member, a non-member, and a vault in another organization all return 404. `org` scope writes memory shared with the whole organization and requires the **owner or admin** role; an ordinary member gets `org_forbidden`. The role is read from the credential's organization membership and can never be asserted in the request. ## POST /api/v1/memories/departed/{user_id}: Settle a departed member's shared memory memory. Decides what happens to the `org`- and `vault`-scope memory written by someone who has left the organization: `keep` retains every row and moves authorship to you, `purge` deletes them. Nothing happens automatically when a member is removed — their writes are retained and frozen (they lose access with their membership) until an owner or admin makes this call, so shared knowledge is never silently destroyed and never silently inherited. **Owner or admin only** (`org_forbidden`). The subject must actually have left: a current member is refused with `org_member_active`. A user who authored nothing in your organization returns `org_member_not_found` — as does a user id belonging to another tenant, and one that does not exist, so this route cannot be used to probe for accounts elsewhere. The subject's own private `user`-scope memory is never touched; it is theirs, and it is removed with their account. ## DELETE /api/v1/memories/{id}: Forget a memory memory. Deletes one memory by id. A caller may delete exactly what they may write: their own `user`-scope memories, `vault`-scope memories in a vault where their membership grants `can_write` and is not held, and `org`-scope memories if they are an organization owner or admin. Anything else — another tenant's id, another user's private memory, a vault they can only read, organization memory when they are an ordinary member — returns 404, identically to an id that does not exist: a 403 would confirm the id is real. ## GET /api/v1/models: List model tiers models. Returns the public tier catalog (OpenAI-discovery shape) so clients can discover tiers instead of hardcoding them. Never exposes upstream provider model ids. Each tier carries its display name (label: Kavi, Vaani, Mira) and a one-word profile beside it (kavi Deep, vaani Balanced, mira Fast), both stable display text. Each tier's reasoning object gives the reasoning_effort a chat request on that tier gets when it omits one (default_effort) and whether low, medium and high are different depths there (graded; false means on/off, so low thinks as long as high). It is read from the configuration the chat endpoints apply, so it is the live default rather than a description of one. ## POST /api/v1/moderations: Classify content (moderations) moderations. OpenAI-compatible moderations. model must be "cruxy-guard". input accepts a single string or an array of strings — at most 512 strings per request, with the whole body ≤ 64 KiB (either exceeded is a 400). NOTE: the guard model emits labels, not probabilities, so category_scores is binary (1.0/0.0), not calibrated. The response envelope mirrors OpenAI's moderations schema ({id, model, results}) and carries no top-level cruxy.* block: the per-call request_id rides the top-level id instead, formatted "modr-" — the same per-call id chat returns as cruxy.request_id. A FLAGGED result additionally carries an additive, OpenAI-inert cruxy block of its own — results[].cruxy.hazard_codes, the raw guard taxonomy codes (S-codes) behind the flag. It is present if and only if flagged is true, so an unflagged result is byte-identical to one from a gateway without the field, and absence must be read as "not flagged", never as "no codes". When present it is never null and may be EMPTY, meaning the guard flagged the input without naming a code. Four codes (S6 specialized advice, S7 privacy, S8 intellectual property, S13 elections) map to no OpenAI category, so a flagged result with every category false is explained by the codes rather than being indistinguishable from a bug. The OpenAI-shaped fields — flagged, categories, category_scores — are unchanged. ## GET /api/v1/org/invitations: List the organization's invitations organization. Lists the invitations the caller's organization has sent — live and settled — with each one's address, role and status. **Owner or admin only** (`org_forbidden`): this is management surface, the same authority that sends them. No invitee user id is reported, for any invitation, whether or not the address names an existing account. ## DELETE /api/v1/org/invitations/{invitation_id}: Revoke an invitation organization. Withdraws an invitation the caller's organization sent, before it is accepted. **Owner or admin only** (`org_forbidden`), resolved before existence — a caller who does not administer the organization is refused whether or not the id is real. An invitation in another organization, one that never existed, one already accepted, declined or revoked, and a malformed id are one `404 invitation_not_found`. ## GET /api/v1/org/members: List organization members organization. Lists the members of the caller's organization — the one their credential names; there is no way to address any other — with each member's role. **Owner or admin only** (`org_forbidden`): this is management surface, and an ordinary member is not entitled to enumerate the roster. ## POST /api/v1/org/members: Invite someone to the organization organization. Invites someone to the caller's organization, addressed by `email` or by `user_id` (exactly one). `role` defaults to `member` and may not be `owner` — ownership is granted with PATCH after the invitee joins. **Owner or admin only** (`org_forbidden`). This creates an INVITATION, not a membership: nobody joins an organization without accepting, and the membership row appears only when they call `POST /api/v1/invitations/{id}/accept`. The `202` response is deliberately identical whether or not the address names an existing account — the gateway never looks it up, so this endpoint cannot be used to discover whether someone has an account. Re-inviting an address that already has a live invitation returns that same invitation unchanged; one whose invitation has lapsed issues a fresh 14-day window. The only distinguishable outcome is `409 org_member_exists`, which tells the caller only what `GET /api/v1/org/members` already shows them about their own organization. ## DELETE /api/v1/org/members/{user_id}: Remove an organization member organization. Removes a member from the caller's organization. **Owner or admin only** (`org_forbidden`); a member whose role is `owner` cannot be removed by anyone. Removal ends access, it destroys nothing: the member's API keys bound to this organization are revoked in the same transaction, and their `org`- and `vault`-scope memory is retained and frozen — reachable by `POST /api/v1/memories/departed/{user_id}`, where an owner or admin decides `keep` or `purge`. Nothing is deleted or reassigned by the removal itself. A user id in another organization, or one that names nobody, returns `404 org_member_not_found`. ## PATCH /api/v1/org/members/{user_id}: Change an organization member's role organization. Changes a member's role. **Owner or admin only** (`org_forbidden`); granting `owner` additionally requires the caller to BE an owner, and the membership it creates is immediately immutable. A member whose role is `owner` cannot be changed by anyone — the org must always retain an owner, so ownership is never demotable. A user id in another organization, or one that names nobody, returns `404 org_member_not_found`. ## GET /api/v1/org/memberships: List the organizations I belong to organization. Lists the organizations the caller is a member of, with their role in each. It takes no organization and no user: the caller is the subject, so there is no parameter by which one person could read another's memberships. This is the list to show when credential issuance answers `org_selection_required`, and the list to read before leaving an organization with `DELETE /api/v1/org/memberships/{org_id}`. ## DELETE /api/v1/org/memberships/{org_id}: Leave an organization organization. Removes the CALLER'S OWN membership from the named organization. Any member may leave; no administrator's permission is involved, which is what makes an unwanted membership recoverable. The organization is named in the path rather than taken from the credential — the only route here that does — because leaving is by definition about an organization your current credential may not name. It can reach no membership but your own. Leaving revokes the caller's API keys bound to that organization, in the same transaction, exactly as removal by an administrator does. It destroys nothing else: memory the caller wrote at organization and vault scope is retained and frozen, for `POST /api/v1/memories/departed/{user_id}` to settle. An **owner** cannot leave (`org_forbidden`): owner rows are immutable so an organization always retains one, and this includes the personal organization every account owns. An organization the caller does not belong to, one that does not exist, and a malformed id are one `404 org_member_not_found`. ## GET /api/v1/profile: Get my profile profile. Returns the authenticated user's account profile: their id, email, `work_type`, whether they have `completed_onboarding`, UTC created/updated timestamps, and the `org` this credential is bound to. `work_type` is `null` until the user answers; otherwise it is one of the closed vocabulary (`engineering`, `data_science`, `product`, `design`, `marketing`, `sales`, `finance`, `operations`, `people_hr`, `legal`, `student`, `other`). The profile is always the caller's own — it is addressed by the credential's subject, never by an id in the path or body — so there is no id to enumerate and no other user's profile this route can return. **`org` is the organization THIS CREDENTIAL is bound to**, with your role in it — not "your organization". A user may belong to several (`GET /api/v1/org/memberships` lists them all); a key names exactly one, stamped at issuance, and it is the one whose shared memory, rules and conversations this credential sees. ## PATCH /api/v1/profile: Update my profile profile. Updates the authenticated user's profile and returns the stored row, in the same shape `GET /api/v1/profile` returns — including the `org` this credential is bound to, which no field here can change. Accepts ONLY `work_type` and `completed_onboarding`, both optional: an **absent** field is left unchanged (it is not reset to a default), so a client can complete onboarding without touching `work_type` and vice versa. `work_type`, when present, must be one of the closed vocabulary (`engineering`, `data_science`, `product`, `design`, `marketing`, `sales`, `finance`, `operations`, `people_hr`, `legal`, `student`, `other`); any other value is `400 invalid_request` and **nothing is written**. An `id` or `email` in the body is ignored — the profile is addressed by the credential's subject, so the write can neither be retargeted to another user nor change identity. ## GET /api/v1/prompts: List prompts prompts. Lists the prompts in the caller's workspace — the one their credential names; there is no way to address any other — each with its slug, latest version number, and total version count (no content). Ordered oldest first. ## POST /api/v1/prompts: Create a prompt prompts. Creates a prompt in the caller's workspace — a named container whose first immutable version holds the given `content`. The workspace is the one the credential names; there is no workspace or org id in the path or body. `name` is required (1..128 bytes). `slug` is the stable, URL-safe handle used to address the prompt and is optional: when omitted it is derived from the name (lowercased, non-alphanumerics collapsed to hyphens); supply it explicitly if the name has no usable characters. A slug already used in the workspace is `409 prompt_slug_exists`. `content` is required (1..102400 bytes) and is stored at rest and returned verbatim — it is deliberately-authored template text and is not PII-filtered. Returns the prompt with its version 1 embedded. ## DELETE /api/v1/prompts/{id}: Delete a prompt prompts. Deletes a prompt and all of its versions (the versions cascade). A prompt id in another workspace or organization, or one that names nothing, returns `404 prompt_not_found`. ## GET /api/v1/prompts/{id}: Get a prompt prompts. Returns one prompt of the caller's workspace with a single version embedded: the latest by default, or the exact version named by `?version=N`. A prompt id in another workspace or organization, or one that names nothing, returns `404 prompt_not_found`. A `?version=N` that the prompt does not have returns `404 prompt_version_not_found` (distinct, because the caller can already see the prompt). ## GET /api/v1/prompts/{id}/versions: List prompt versions prompts. Lists every version of a prompt, newest first, each with its number, content, author, and timestamp. A prompt id in another workspace or organization, or one that names nothing, returns `404 prompt_not_found`. ## POST /api/v1/prompts/{id}/versions: Add a prompt version prompts. Appends a new immutable version to a prompt and returns it. The new version's number is the previous highest plus one; existing versions are never modified. `content` is required (1..102400 bytes). A prompt id in another workspace or organization, or one that names nothing, returns `404 prompt_not_found`. ## POST /api/v1/rerank: Rerank documents rerank. Cohere-style reranking (OpenAI-INCOMPATIBLE). model must be "cruxy-rerank". Scores each document in documents against query and returns them ranked by relevance_score (descending), optionally capped to top_n. At most 512 documents per request, with the whole body ≤ 64 KiB (either exceeded is a 400) — pre-filter a larger candidate set before reranking. The additive cruxy.* block carries the request id and cost. ## DELETE /api/v1/rules: Delete organization or vault rules rules. Removes a ruleset entirely. **The `vault_id` query parameter selects the scope**: omit it to delete the organization's ruleset, or pass a vault id to delete that vault's - equivalent to `DELETE /api/v1/vaults/{id}/rules`. A `vault_id` that is malformed, names nothing, or belongs to another organization is `404 vault_not_found`; the deletion is never quietly applied to the organization's ruleset instead. **Owner or admin of the organization only**, at either scope; vault `can_write` does not grant it. The refusal is `rules_forbidden`. Idempotent: deleting a ruleset that does not exist succeeds, because the end state asked for already holds. Deleting the organization's ruleset leaves every vault's own rules untouched, as it does each vault's inheritance switch; deleting a vault's leaves the organization's untouched and does **not** reset that vault's `inherit_org_rules`. Afterwards the deleted scope contributes no rules to any chat - empty means empty, and no default ruleset takes its place. ## GET /api/v1/rules: Get organization or vault rules rules. Returns a stored ruleset - the ordered standing instructions the gateway prepends to turns in scope. **The `vault_id` query parameter selects the scope**: omit it for the organization's ruleset, or pass a vault id for that vault's own ruleset and its `inherit_org_rules` switch (equivalent to `GET /api/v1/vaults/{id}/rules`). This returns one stored ruleset, NOT the effective merge - for what actually applies to a chat, use `GET /api/v1/rules/resolved`. **Any member may read the organization's**: these instructions are added to the caller's own requests, so they are entitled to see them. A vault's is readable by a vault member granted `can_read` (and not held) and by any owner or admin of the organization; any other `vault_id`, including a malformed one and another tenant's, is `404 vault_not_found`. A scope that has never had rules written and one whose rules were deleted are the same answer, an empty `rules` array - there is no implicit default ruleset. The organization is always the one the credential names; there is no way to address another. **`permissions.can_write` says whether YOU may change this ruleset** - render the edit control from it instead of discovering the answer by attempting a write. It is resolved from the same authority that gates `PUT` and `DELETE` on the scope you selected: owner or admin of the organization, at either scope (vault `can_write` gates a vault's memory, not its rules), so `false` means those requests will be refused with `rules_forbidden` and `true` means they will be admitted. It is per-caller: two members of one organization legitimately see different values for the same ruleset. ## PUT /api/v1/rules: Replace organization or vault rules rules. Replaces a ruleset with the ordered list supplied. **The `vault_id` query parameter selects the scope**: omit it to replace the organization's ruleset, or pass a vault id to replace that vault's - the same scope selector `GET /api/v1/rules/resolved` takes, and the vault form is equivalent to `PUT /api/v1/vaults/{id}/rules`. A `vault_id` that is malformed, names nothing, or belongs to another organization is `404 vault_not_found`; a write is never quietly redirected to the organization's ruleset. **Authority depends on the scope selected.** Both are **owner or admin of the organization only**: the organization's ruleset is prepended to every colleague's every turn, and a vault's to every turn in that vault. Vault `can_write` gates the vault's memory, not its rules. The refusal is `rules_forbidden`, and for a vault the authority is resolved before the vault id is examined, so an unauthorized caller's denial never discloses whether the vault exists. This is a whole-set replace, not a merge: the array sent becomes the ruleset exactly, and sending `[]` clears it. `inherit_org_rules` may be sent only with a `vault_id`, and with one, `rules`, `inherit_org_rules`, or both may be sent; the two are applied in a single transaction. Each rule is one instruction; rules are trimmed of surrounding whitespace and an empty one is rejected. Three size caps apply and are enforced by the database, because rules are re-injected on every request in scope and so cost context and tokens on every call: at most 50 rules, at most 1000 characters per rule, and at most 8000 characters in total. Exceeding any of them is `400 rules_too_large`, naming which cap. **Content carrying obvious PII is refused, not redacted**: a rule containing an email address, a phone number, or a Luhn-valid card number fails with `pii_rejected` and nothing is stored - rules have no expiry and are transmitted on every request in scope. The error names the category and the rule's position, and never echoes the matched text. ## GET /api/v1/rules/resolved: Resolve the effective ruleset rules. Returns the rules that actually apply to a chat context, already merged - the single server-side answer, in a shape a client renders read-only. Pass `vault_id` for a chat inside a vault; omit it for a chat with none. The merge: with no vault, the organization's rules apply. Inside a vault that inherits (the default), the organization's rules apply first and then the vault's own. Inside a vault that does not inherit, only the vault's own rules apply. Every rule carries the `source` it came from (`org` or `vault`) so an unexpected instruction is traceable to the ruleset that contributed it. **Empty means empty** - every branch can legitimately return zero rules, and zero rules is a real answer, not a missing one; there is no implicit or default ruleset anywhere. Any member may call this. A `vault_id` the caller cannot read is `404 vault_not_found` and never a silent fallback to the organization's rules, so a mistyped id is reported rather than answered for the wrong context. ## GET /api/v1/service-token/self: Describe the calling service token auth. Returns the authority of the service token making the request — its service name, the endpoint families it may call, whether it is read-only, and when it expires. It describes ONLY the presented credential: the endpoint accepts no parameters, so there is no way to ask about another token, and it reveals nothing about any other token, user or organization. Intended as a preflight for long-running operator scripts that must not straddle their own credential's expiry. `allowed_scopes` is always the effective list and is never empty — a token issued without `--scopes` holds every family and lists them all. `expires_at` is null for a token that never expires, and is never in the past (an expired token cannot authenticate). To compute remaining lifetime without trusting the client clock, subtract the response's `Date` header from `expires_at`. ## POST /api/v1/tokens/count: Count input tokens tokens. Returns the number of input tokens POST /api/v1/chat would bill for the same model, messages, and tools, computed locally with no upstream inference call — so this endpoint is not billed and records no usage. model is an alias only (kavi, vaani, or mira); "auto" and any base-model id are rejected. messages use the same shape as /chat, and `tools` (the same shape /chat accepts) IS counted — a tool harness is real billed input, so omitting it from the body returns a count for a request you are not making. `tool_choice` does not affect the count and is not read. The count includes the server-injected per-tier system prompt and the tier's chat-template scaffolding — including the generation prompt's reasoning block, which follows the tier's DEFAULT reasoning mode (what /chat sends when the client does not override reasoning_effort) — so it matches /chat's usage.input_tokens (prompt_tokens) for the same input. It is EXACT for single-turn and user-only prompts on every tier, and for all shapes on vaani and mira. It is a conservative UPPER bound — never an undercount — in two cases where the tier's template renders the prompt more compactly upstream than can be reproduced offline: a kavi conversation with prior assistant turns (up to ~6 tokens per prior turn), and message content with leading or trailing whitespace on vaani or mira (up to ~3 tokens per such message). A /chat request that overrides reasoning_effort shifts prompt_tokens by 1–2 tokens (the reasoning-block delta), which this parameter-free count does not model. Authenticated and rate-limited per API key (its own bucket), emitting the same X-RateLimit-* and Cruxy-Version headers as the other endpoints. Idempotency-Key is not honored (the endpoint is a pure function with no side effects). Counting is REFUSED, with 400 input_not_countable and no count in the body, for text carrying a single unbroken run of more than 1024 bytes (a run of letters, whitespace or punctuation with no word, digit or punctuation boundary in it) or for more than 262144 bytes of text in one request: tokenizing is quadratic in the length of one such run, and returning a partial count would be the undercount this endpoint promises never to return. Ordinary prose, source code, JSON and CJK text are far inside both bounds. ## GET /api/v1/vaults: List vaults memory. Lists the vaults the caller can use — those where they hold a membership that is not held. Each entry reports the caller's own permissions — including `can_administer`, the structural authority to delete the vault or hold its members, which is resolved from the same predicate as on GET /api/v1/vaults/{id} and cannot disagree with it — and whether the caller has pinned it. Organization admins are not listed into vaults they are not members of: administering an organization does not grant access to its teams' shared memory. Two optional filters, both resolved server-side: `scope` (all | owned | shared — owned means the caller owns it, shared means they participate but do not own it) and `archived` (false, the default, lists only live vaults; true lists only archived ones). With no parameters the listing is all non-archived vaults. ## POST /api/v1/vaults: Create a vault memory. Creates a shared-memory vault inside the caller's organization. The caller becomes the owner and is added as a member with read, write, and share; the returned `permissions` therefore reports all three, and `can_administer` true, since the owner may delete the vault and hold its members. Vault names are unique within an organization. Optional `description`, `icon`, `color`, and `memory_scope` may be set; an invalid icon, color, or memory_scope is rejected with 400 invalid_request. `memory_scope` is enforced in memory resolution: `vault_only` isolates the vault's memory both ways — its rows are withheld when resolving outside it, and a chat inside it resolves only that vault's memory (organization and user scope excluded). `default` participates in the normal organization > vault > user merge. The create is IDEMPOTENT when the request carries `external_id`, your own system's identifier for the vault: a second create naming an `external_id` this organization has already seen returns the existing vault with **200** and `created: false`, and writes nothing — not the name, so a create is never a rename. A first create answers **201** with `created: true`. Send it whenever a retry is possible: a duplicate NAME is refused with 409 vault_name_exists, which carries no vault id, so a client whose request timed out cannot learn which vault it made — and if the vault has been renamed since, the retry does not collide at all and creates a second one holding a slice of the same team's memory. Reusing an `external_id` that names a vault somebody else owns is 409 vault_external_id_conflict. ## DELETE /api/v1/vaults/{id}: Delete a vault memory. Deletes a vault, its membership list, and all memory stored in it. Owner or organization admin only; a member — even one with write and share — gets 403 vault_forbidden. This is exactly what `permissions.can_administer` reports on the vault reads: the field and this gate are the same predicate, so a caller that saw `can_administer: false` will be refused here, and one that saw true will not be refused for want of authority. ## GET /api/v1/vaults/{id}: Get a vault memory. Returns one vault and the caller's own permissions in it. `permissions` carries `can_read`, `can_write` and `can_share` — the caller's grants over the vault's CONTENT — plus `can_administer`, which is structural authority over the CONTAINER: whether they may delete this vault and hold or restore its members. The four do not imply one another: a member with all three grants does not administer the vault, and an organization admin administers it while possibly holding no grants at all. Every one is resolved from the same predicate that gates the corresponding request, so a client may gate its controls on this block and on nothing else. A vault in another organization, a vault the caller is not a member of, and a vault where the caller is held all return 404 — a 403 would confirm the id is real. ## PATCH /api/v1/vaults/{id}: Update a vault's metadata memory. Changes a vault's `name`, `description`, `icon`, `color`, and/or `memory_scope`. Every field is optional but at least one must be present; an omitted field is left unchanged, while an explicit `null` clears a nullable presentation field (`description`, `icon`, `color`). `name` and `memory_scope` are non-null and can only be set to a value. The same closed sets as create apply — `icon` and `color` from their fixed vocabularies, `memory_scope` in `default | vault_only`, `name` 1..128 bytes, `description` at most 2000 — and an invalid value is 400 invalid_request with nothing written. Renaming to a name already taken in the organization is 409 vault_name_exists. Requires `can_write`: a participant without it gets 403 vault_forbidden, and a non-member, held, or cross-organization caller gets 404 vault_not_found. Returns the enriched vault shape, with `updated_at` advanced and the caller's own `permissions` — including `can_administer` — re-resolved. ## DELETE /api/v1/vaults/{id}/archive: Restore a vault memory. Clears a vault's archive, returning it to the default list and reporting `archived: false`. Idempotent: restoring a live vault is 204. Requires `can_write`: a participant without it gets 403 vault_forbidden, and a non-member, held, or cross-organization caller gets 404 vault_not_found. ## PUT /api/v1/vaults/{id}/archive: Archive a vault memory. Archives the vault: it is hidden from the default vault list and appears only with `?archived=true`, and GET reports `archived: true`. The container and all its memory are untouched — archiving is not deletion. Idempotent: archiving an archived vault is 204 and does not move the original archive instant. Requires `can_write`: a participant without it gets 403 vault_forbidden, and a non-member, held, or cross-organization caller gets 404 vault_not_found. ## GET /api/v1/vaults/{id}/members: List vault members memory. Lists the vault's members and each one's permissions, including any held members with their suspended permissions intact — the point of a reversible hold is that what it suspends stays visible to whoever may lift it. ## POST /api/v1/vaults/{id}/members: Add a vault member memory. Adds a member of the caller's organization to the vault with the given permissions. The vault owner and organization admins may grant anything; a member with `can_share` may grant only permissions they themselves hold — a member who cannot write cannot create a member who can. Permissions default to false, so omitting one grants nothing. A user id outside the caller's organization returns 404 user_not_found. ## DELETE /api/v1/vaults/{id}/members/{user_id}: Remove a vault member memory. Removes a member from the vault. Their access ends immediately — the next request reads the vault as if it did not exist. Owner, organization admin, or a member with `can_share`. The vault owner cannot be removed; delete the vault instead. ## PATCH /api/v1/vaults/{id}/members/{user_id}: Update a vault member's permissions memory. Changes a member's read/write/share permissions. Omitted fields keep their current value. The no-escalation rule is applied to the RESULTING permission set, not just the changed fields: a member with `can_share` but not `can_write` cannot edit a row that grants write, even if they are not the one who granted it. `held` is not accepted here — it is a separate authority with its own route. The vault owner's own membership cannot be modified by anyone. ## PUT /api/v1/vaults/{id}/members/{user_id}/held: Suspend or restore a vault member memory. Sets or clears a member's hold. While held, that member's read, write, and share are all inert regardless of their flags, and the vault reads to them as if it did not exist. The flags themselves are untouched, so clearing the hold restores exactly the permissions they had. Owner or organization admin only — suspending is the revocation of someone else's grant, not a grant, so `can_share` does not confer it. The owner cannot be held. This authority is reported as `permissions.can_administer` on the vault reads, from the same predicate that gates this route. ## DELETE /api/v1/vaults/{id}/pin: Unpin a vault memory. Removes the CALLING user's pin on the vault. Idempotent: unpinning a vault that was not pinned is 204. Requires only that the caller can read the vault; one they cannot read is 404 vault_not_found. Never affects another member's pin. ## PUT /api/v1/vaults/{id}/pin: Pin a vault memory. Pins the vault for the CALLING user — a personal bookmark that surfaces on the `pinned` field of GET and the vault list, and never visible to other members. Idempotent: pinning an already-pinned vault is 204, not a conflict. Requires only that the caller can read the vault (a bookmark is not an edit); a vault the caller cannot read — non-member, held, no `can_read`, or another organization — is 404 vault_not_found. ## DELETE /api/v1/vaults/{id}/rules: Delete a vault's rules rules. Removes a vault's own ruleset. **Requires owner or admin of the organization** (`rules_forbidden` otherwise), resolved before the vault id is examined. Vault `can_write` does not grant it. Idempotent: deleting a ruleset that does not exist succeeds. The vault's `inherit_org_rules` switch is deliberately **not** reset - clearing the rules a vault adds and changing whether it inherits the organization's are different intentions, and doing the second while asked for the first would silently start or stop applying the organization's rules to that vault. ## GET /api/v1/vaults/{id}/rules: Get a vault's rules rules. Returns a vault's own ruleset and its `inherit_org_rules` switch. Readable by any member of the vault granted `can_read` (and not held), and by any owner or admin of the organization (who may set it, and so can always read back what they set). A vault in another organization, one that does not exist, one the caller does not participate in, and one where the caller is held all return the same `404 vault_not_found`. `inherit_org_rules` is a property of the vault and is returned whether or not the vault has any rules of its own. **`permissions.can_write` says whether YOU may change this vault's rules** - render the edit control from it rather than inferring write access from a failed write. It is `true` for an owner or admin of the organization and `false` for everyone else; vault `can_write` gates the vault's memory, not its rules. It is resolved from the same predicate the `PUT` and `DELETE` use, so it cannot disagree with them. It is per-caller. ## PUT /api/v1/vaults/{id}/rules: Set a vault's rules and inheritance rules. Sets a vault's own ruleset and/or its `inherit_org_rules` switch. **Requires owner or admin of the organization** (`rules_forbidden` otherwise), for both halves of this request: a vault's rules and its inheritance switch. Vault `can_write` gates the vault's memory and does not grant it. Authority is resolved before the vault id is examined, so an unauthorized caller's denial never reveals whether the vault exists. Both fields are optional but at least one must be present, and **both are applied in a single transaction** - turning inheritance off and installing the vault's replacement rules is one change, and splitting it across two requests would leave a window in which chats in the vault run with no rules at all. `inherit_org_rules` true (the default for every vault) means the organization's rules apply **in addition to** this vault's, organization rules first; false narrows chats in this vault to its own rules, which may be none. It never makes the vault's own rules inert. The same caps and the same PII gate as the organization ruleset apply. ## GET /api/v1/workspaces: List workspaces organization. Lists the workspaces of the caller's organization — the one their credential names; there is no way to address any other — each with its slug, monthly spend cap (null when uncapped), and whether it is the reserved default. The default workspace is listed first. **Owner or admin only** (`workspace_forbidden`): this is management surface, not a directory. ## POST /api/v1/workspaces: Create a workspace organization. Creates a workspace inside the caller's organization — a team's isolated environment and spend budget under that org. **Owner or admin only** (`workspace_forbidden`). `name` is required (1..128 bytes). `slug` is the stable, URL-safe handle used to address the workspace and is optional: when omitted it is derived from the name (lowercased, non-alphanumerics collapsed to hyphens); supply it explicitly if the name has no usable characters. A slug already used in the organization is `409 workspace_slug_exists`, and the reserved `default` slug cannot be minted (`400 invalid_request`). `spend_cap_micros` is an optional monthly ceiling in micro-USD (positive; omit or null for uncapped) enforced by the chat/usage gates against the workspace's summed cost. ## DELETE /api/v1/workspaces/{id}: Delete a workspace organization. Deletes a workspace. **Owner or admin only** (`workspace_forbidden`). A workspace that holds — or has ever held — a credential is refused with `409 workspace_not_empty`: a credential's tenancy and its usage history must never be orphaned, so a workspace is deletable only while it never held a key. The reserved default workspace is never deletable (`409 workspace_is_default`). A workspace id in another organization, or one that names nothing, returns `404 workspace_not_found`. ## GET /api/v1/workspaces/{id}: Get a workspace organization. Returns one workspace of the caller's organization. **Owner or admin only** (`workspace_forbidden`). A workspace id in another organization, or one that names nothing, returns `404 workspace_not_found` — a 403 would confirm the id is real. ## PATCH /api/v1/workspaces/{id}: Rename or re-cap a workspace organization. Changes a workspace's `name` and/or its `spend_cap_micros`. Every field is optional but at least one must be present; an omitted field is left unchanged, an explicit `null` cap clears the cap (uncaps), and a positive cap sets it. The `slug` is a stable handle and is NOT changed here. Applies to any workspace including the default (only delete singles the default out). **Owner or admin only** (`workspace_forbidden`). A workspace id in another organization, or one that names nothing, returns `404 workspace_not_found`. Returns the updated workspace. ## POST /api/v2/chat: Chat completions (native v2) chat. Cruxy-native chat completions. model is a public tier - kavi, vaani, or mira - or auto to let the server-side router pick the tier from the request (accepted natively; no client-side shim). stream is optional and false by default. With stream:true the response is a text/event-stream of NAMED events: `start` (the served tier and how it was routed), zero or more `reasoning` (fragments of the model's thinking trace, only when the tier reasons - reasoning_effort above none; NOT answer content, no token count of its own, since usage output tokens already include it undivided; ignore it if you do not render thinking), one or more `delta` (each a text `content` fragment OR `tool_calls` fragments), then exactly one terminal frame - `finish` (finish_reason plus the final native usage) on success, or `error` (a stable, client-safe error code + message + request_id, from the same registry as the JSON error envelope) on a failure AFTER the stream opens. There is NO `[DONE]` sentinel and NO silent truncation: a v2 stream ALWAYS ends with a `finish` or an `error` frame. With stream:false or stream omitted the response is the native completion object - the assistant reply is the top-level message with a sibling finish_reason, a first-class routing object (tier, mode, reasoning_effort), a top-level request_id, usage carrying the native token counts and cost, and - only when the tier reasoned - a top-level reasoning string holding the thinking trace (kept out of message on purpose: do not send it back as conversation). TIMEOUTS AND RETRIES. With stream:false the gateway makes ONE attempt at the model, bounded by the tier's deadline: kavi 90s, vaani 90s, mira 45s. An attempt that runs past it is cancelled and answered 504 upstream_timeout, and nothing is billed. The gateway does NOT retry a timeout, since a turn that needed longer needs longer again: send stream:true (a stream may run for up to five minutes), or ask for less with a lower max_tokens or reasoning_effort. Other upstream failures (an overloaded or failing model, a dropped connection) are retried inside the gateway a bounded number of times, with backoff, before it answers 503 upstream_overloaded or 503 model_unavailable; on stream:true that happens only while the stream is being opened, never after a byte has reached you. The gateway never moves a call to another tier once it is sent: model auto picks a healthy tier before the call, and an explicit tier that kept failing is refused at once with 503 model_unavailable while it cools down. REASONING SPENDS max_tokens: with reasoning_effort omitted each tier applies its default - kavi high (thinking on), vaani low, mira none; auto chooses per request - reported as routing.reasoning_effort, and the thinking is generated inside the same max_tokens budget as the answer. A small max_tokens on kavi can therefore be spent entirely on thinking, ending with finish_reason "length" and an empty message; send reasoning_effort:"none" for a short direct answer, or leave max_tokens unset (the tier's cap) to keep reasoning - no OpenAI choices[] array and no cruxy.* wrapper. WHICH CREDENTIALS USE MEMORY: memory recall and automatic capture are ON by default for a credential a person gets by signing in (the Cruxy web app, desktop, the CLI's device login, mobile) and OFF by default for a developer API key or a headless key, which opts in per request with memory:{"recall":true} and/or memory:{"capture":true} (see PER-REQUEST MEMORY CONTROLS below). A key minted by POST /api/v1/device/token or POST /api/v1/auth/oauth/token is a signed-in credential whichever client ran the flow, a third-party tool included, so it has memory and rules on; a server must use a developer API key instead (see BearerAuth). GET /api/v1/limits reports the kind as bucket: subscription is signed in, apikey and headless are not. An API key often serves people other than its owner, and recall would put the owner's stored memory into their turns while capture would store what they say, so neither happens unless the request asks. A key whose allowed_scopes omit the memory family can never use memory through chat - asking with true is 403 key_scope_forbidden (required_scope memory), refused before anything is billed - which makes it the key to issue for a service that must never see anyone's memory. When recall runs, relevant stored memories are recalled server-side and added to the turn as background context, drawn from the same resolved view GET /api/v1/memories lists: personal memory, the organization's, and readable vaults'. The optional vault_id names the vault a chat is happening inside and selects the ISOLATED case - inside a `vault_only` vault recall draws on ONLY that vault's memory, excluding personal and organization memory. A vault_id the caller cannot read is treated as none and never reported. What was recalled rides the `start` frame and the non-streaming body as a top-level memory_recall (scope - personal, org, or vault - and key per memory; never the stored value), with a vault-scope memory naming its source vault (vault_id, and vault_name when it resolves). mentioned_vault_ids lets a turn reference vaults it is not inside (at most 3, ids resolved by the client's picker); unreadable ids, repeats, and the active vault are dropped silently, and an accepted mention adds that vault's memory to the same recall budget without changing isolation or making the vault writable. WHICH CREDENTIALS GET STANDING RULES: the same default as memory. Your organization's standing rules are injected by default for a credential a person gets by signing in, and NOT for a developer API key or a headless key, whose rules are its owner's organization's guidance and which a visitor to whatever the key serves could ask the model to repeat. Such a key opts in per request with rules:true, or once, on the key, by being issued allowed_scopes that NAME the rules family (an unscoped key has every family but has not opted in). Any credential may send rules:false to decline them for a turn, which needs no scope since the user's own message already overrides them. A key whose allowed_scopes omit rules never gets them, and rules:true on it is 403 key_scope_forbidden (required_scope rules), refused before anything is billed. The switch covers the whole resolved ruleset, the organization's and, with vault_id, the vault's, so a turn gets exactly what GET /api/v1/rules/resolved returns or nothing. When rules apply and the caller's organization has configured them they are resolved server-side and injected as guidance for the turn - DEFAULTS the user's own message overrides - and both the `start` frame and the non-streaming body carry an additive top-level rules_applied signal (counts and sources, never rule text). vault_id selects which rules apply too: inside a vault the turn gets that vault's own ruleset merged with the organization's per the vault's inherit_org_rules switch - the same answer GET /api/v1/rules/resolved gives for that vault - and rules_applied names each contributing scope (org, vault). A vault_id the caller cannot read is treated as none here as well, so the turn simply runs with organization-scope rules. Injected rule text is counted in the usage input tokens like any other input. When capture runs, after a turn is answered the gateway may also capture a durable fact from the exchange into memory; every capture is reported additively as a top-level memory_capture - on the non-streaming body, and on the terminal `finish` frame when streaming, since what was captured is only knowable once the answer is complete. It names the scope and key of each stored memory and never the value, so nothing is stored that the response does not report. Capture writes personal memory, or the vault named by vault_id when you may write it (a read-only vault demotes to personal), never organization-wide memory; obvious personal data is dropped rather than stored; and a failed capture never affects the turn. PER-REQUEST MEMORY CONTROLS: an API key opts in with memory:{"recall":true} (inject the stored memories most relevant to this turn) and/or memory:{"capture":true} (allow a durable fact from this turn to be stored); any credential may send memory:{"recall":false} to inject no stored memory into this turn (no memory is read, mentioned_vault_ids are not consulted, no memory_recall is returned, and input tokens fall by the size of the block that would have been injected), and memory:{"capture":false} to store nothing from it (no extraction, no write, no memory_capture). The two sides are independent: {"capture":false} alone keeps the turn personalised by what is already stored while storing nothing new. A missing memory object, a missing key, or null means the credential's default (on for a signed-in client, off for a developer or headless API key), and a literal true or false states your choice. false needs no key scope, since it only makes the gateway do less for your own request; true needs a key whose allowed_scopes include memory (an unscoped key has every family). Standing rules are not memory, and this field never affects them: they have their own switch, rules (see WHICH CREDENTIALS GET STANDING RULES above). Pin Cruxy-Version 2026-09-26 on every request that relies on memory:false: unknown fields are ignored rather than refused, so a gateway that predates this field would answer 200 and capture anyway, and a pinned request is instead refused by such a gateway with 400 unknown_version before recall, the upstream call, billing or capture run. Dated versions are otherwise only for breaking changes; this date gates an additive field because its silent absence on an older gateway would make the response misrepresent what it used (here, a privacy promise; content the user supplied qualifies on the same terms), and it is not precedent for ordinary additive fields. ATTACHED FILES: a user message may carry file_ids, the ids of files you uploaded with POST /api/v1/files under the same organization and user as this credential, and the gateway reads each one into the turn. The request must pin Cruxy-Version 2026-10-05 or later: a request naming file_ids without that pin is 400 invalid_request (carrying required_version), because a gateway that predates the field would ignore it and answer without the files, and the pin is what makes such a gateway refuse instead. UTF-8 text files are read (plain text, markdown, CSV, JSON, source code), and so are PDFs and Word documents (.docx), as the text read out of them once, when they were uploaded (POST /api/v1/files reports it as text_status): a document is framed, measured against the caps and billed by its text exactly as a text file is, and is never parsed on a turn. A file of another type, or whose bytes are not UTF-8 text, is 400 file_not_text, and so is a document whose text could not be read, with a reason field saying why: no_text (no text found, such as a scanned PDF), encrypted (password-protected), corrupt, unreadable (reading it went past the time or memory allowed for one document), legacy_doc (a Word 97-2003 .doc) or not_extracted (uploaded before documents were read; upload it again). A file over the per-file token cap, or one that takes the request past the per-turn cap, is 400 file_too_large, and so is a PDF of more than 500 pages (bound pages) or over 64 MiB (bound bytes), which is not read; for a document the message advises attaching a shorter excerpt. Both name the file (file_id, filename, and for file_too_large bound, limit and tokens). An id that is not yours, whether another user's, one filed under another of your organizations, missing or malformed, is 404 file_not_found, the same answer in every case. A key whose allowed_scopes omit files is 403 key_scope_forbidden (required_scope files). All of these are answered before anything is sent upstream or billed. Each file's text goes in as a user-role block immediately before the message that named it, framed as material for that message and never as instructions to follow; it never enters the system prompt, recall does not search with it, and automatic capture does not read it. A file named on several messages is sent once, before its first mention. Its tokens are input like any other and are billed on EVERY request that sends the file, wherever its block sits: a file id you keep in a conversation's history is billed on each turn, not only the first. They count toward the tier's context window: a file on an earlier message that does not fit beside the conversation, with room left for the reply's max_tokens and a margin of a tenth of the window, is left out, oldest first, and listed in files_omitted with reason context_window, and a turn that does not fit even with every earlier file left out is 400 context_overflow. POST /v1/chat/completions refuses file_ids, and POST /api/v1/tokens/count does not count them. ## POST /v1/chat/completions: Chat completions (OpenAI-compatible) openai. OpenAI-compatible chat completions, request and response. Point any OpenAI client or evaluation harness at base URL https://api.cruxy.ai/v1 with your Cruxy API key as the bearer token and it works unmodified. `model` is a public Cruxy tier — kavi, vaani, or mira — or auto to let the server-side router pick; an upstream provider model is never accepted or returned. With stream:false the response is a standard chat.completion object: id, object, created, model, choices[0].message, and usage with prompt_tokens / completion_tokens / total_tokens. With stream:true it is OpenAI's SSE protocol: chat.completion.chunk frames (a role primer, content or tool_call deltas, then a frame carrying finish_reason), terminated by the literal `data: [DONE]`. The terminal choices:[] usage frame is emitted only if you send stream_options:{"include_usage":true}, exactly as OpenAI gates it. If the upstream fails AFTER the stream has opened, the stream ends with a `data: {"error":{...}}` frame instead of `[DONE]`, so a truncated answer is never mistaken for a finished one. Errors use OpenAI's envelope, {"error":{message,type,param,code}}, where `code` is the stable Cruxy error code. `temperature`, `top_p`, `max_tokens` and `reasoning_effort` are honoured (max_tokens is clamped to the tier's own output cap; reasoning_effort is one of none, low, medium, high). As on OpenAI's reasoning models, thinking is generated inside the max_tokens budget and is not returned as content, and kavi reasons by default (reasoning_effort omitted means kavi high, vaani low, mira none): a small max_tokens on kavi can be spent entirely on thinking and return finish_reason "length" with empty content. Send reasoning_effort:"none" for a short direct answer, or leave max_tokens unset. TIMEOUTS AND RETRIES. With stream:false the gateway makes ONE attempt at the model, bounded by the tier's deadline: kavi 90s, vaani 90s, mira 45s. An attempt that runs past it is cancelled and answered 504 upstream_timeout, and nothing is billed. The gateway does NOT retry a timeout, since a turn that needed longer needs longer again: send stream:true (a stream may run for up to five minutes), or ask for less with a lower max_tokens or reasoning_effort. Other upstream failures (an overloaded or failing model, a dropped connection) are retried inside the gateway a bounded number of times, with backoff, before it answers 503 upstream_overloaded or 503 model_unavailable; on stream:true that happens only while the stream is being opened, never after a byte has reached you. The gateway never moves a call to another tier once it is sent: model auto picks a healthy tier before the call, and an explicit tier that kept failing is refused at once with 503 model_unavailable while it cools down. Fields this gateway does not implement — n, stop, seed, presence_penalty, frequency_penalty, logit_bias, logprobs, user and the rest — are IGNORED rather than rejected, so a harness that sends them still runs. This endpoint deliberately carries none of the Cruxy-native chat features: no cruxy.* block, no vault_id, no memory recall, no automatic memory capture, and no injection of your organization's standing rules — a run here is your messages and the tier, nothing else, which is what makes it reproducible as a benchmark. For those features use POST /api/v1/chat (OpenAI-shaped SSE, Cruxy-flattened body) or POST /api/v2/chat (the Cruxy-native wire). Authentication, rate limits, the endpoint-scope check and billing are identical on all three: this route requires the `chat` endpoint family and its usage lands on the same ledger. ## GET /v1/models: List models (OpenAI-compatible) openai. OpenAI-compatible model listing, for clients and evaluation harnesses pointed at this gateway as an OpenAI endpoint (base URL https://api.cruxy.ai/v1). Returns {"object":"list","data":[{id, object, created, owned_by}]} and nothing else — the ids are the public Cruxy tiers (kavi, vaani, mira) and never an upstream provider model. For the richer catalog — context window, max output, tool support, per-tier pricing, latency targets and reasoning defaults — use GET /api/v1/models, which lists the same tiers with Cruxy's own discovery metadata. Errors on this path use OpenAI's envelope ({"error":{message,type,param,code}}); the /api/v1 surface keeps the Cruxy envelope. ================================================================================ Page: Cookbook URL: https://learn.getcruxy.com/docs/en/resources/cookbook Markdown: https://learn.getcruxy.com/docs/en/resources/cookbook.md ================================================================================ # Cookbook Task-shaped recipes that combine Cruxy endpoints to do a real job: retrieval, a tool-calling loop, a streaming UI, and a moderation gate. The guides document each endpoint on its own: what `POST /api/v1/rerank` returns, how `stream` changes the chat response. The cookbook is the other half: recipes that wire endpoints together to finish a real job. Each one runs end to end against `https://api.cruxy.ai` and reads from `CRUXY_API_KEY` in the environment. - [Retrieval-augmented generation](/docs/en/resources/cookbook/rag): Embed a corpus, rank it by cosine, reorder the shortlist with rerank, then ground a chat answer in the winners. - [Tutorial: build a tool-using agent](/docs/en/build/tool-agent): Run the full round-trip: send tools, execute the calls the model returns, feed results back by id, loop until it stops. - [Streaming chat in the browser](/docs/en/resources/cookbook/streaming-ui): Proxy the SSE stream past your key, render deltas without jank, and detect a stream that breaks after the 200. - [Moderation gate](/docs/en/resources/cookbook/moderation-gate): Screen input with cruxy-guard before it reaches chat, and branch on flagged, never on the uncalibrated scores. ## What counts as a recipe Every recipe here combines at least two endpoints, or solves a problem a single-endpoint guide cannot. Retrieval chains embeddings, rerank, and chat; the moderation gate puts one endpoint in front of another; the tool loop and the streaming UI each turn one endpoint into a working control flow the reference shape only hints at. When you need the exact fields instead, the guides, starting with [Chat](/docs/en/build/chat), document each endpoint once. The tool loop lives with the tool-calling pages in the Docs tab, next to [Define tools](/docs/en/build/tools) and [Handle tool calls](/docs/en/build/tool-calls). ================================================================================ Page: Retrieval-augmented generation URL: https://learn.getcruxy.com/docs/en/resources/cookbook/rag Markdown: https://learn.getcruxy.com/docs/en/resources/cookbook/rag.md ================================================================================ # Retrieval-augmented generation Embed a corpus, rank it by cosine, reorder the shortlist with rerank, then ground a chat answer in the winning passages. Retrieval-augmented generation answers a question from your own documents: find the passages that bear on the question, then hand them to a chat model as context. This recipe chains three endpoints: [Embeddings](/docs/en/build/embeddings) for a first-pass search, [Rerank](/docs/en/build/rerank) to sharpen the shortlist, and [Chat](/docs/en/build/chat) to write the answer. ## 1. Index the corpus Split your source material into chunks and embed them once. Store each vector beside the text it came from; you embed the corpus at index time, not per query. ```ts const BASE = 'https://api.cruxy.ai' const auth = { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', } async function embed(input: string[]): Promise { const res = await fetch(`${BASE}/api/v1/embeddings`, { method: 'POST', headers: auth, body: JSON.stringify({ model: 'cruxy-embed', input }), }) const { data } = await res.json() // One entry per input; data[i].index is the input's position. Sort by it // so the returned vectors line up with `input`, whatever order they arrive. return data.sort((a, b) => a.index - b.index).map((d) => d.embedding) } const chunks = [ 'The gateway resolves each public tier to an upstream model on the server.', 'Rerank scores every document against the query and returns them best-first.', 'Embeddings turn text into a vector you can compare with cosine similarity.', 'A bearer key authenticates every request against api.cruxy.ai.', // ...the rest of your corpus, one string per chunk ] const vectors = await embed(chunks) // persist these alongside `chunks` ``` ## 2. Retrieve by cosine Embed the query with the same model, then score it against every stored vector. Cosine similarity ranks the whole corpus cheaply; keep the top handful as candidates. ```ts function cosine(a: number[], b: number[]): number { let dot = 0 let na = 0 let nb = 0 for (let i = 0; i < a.length; i++) { dot += a[i] * b[i] na += a[i] * a[i] nb += b[i] * b[i] } return dot / (Math.sqrt(na) * Math.sqrt(nb)) } const query = 'How does the gateway decide which model runs?' const [queryVec] = await embed([query]) const CANDIDATES = 20 const shortlist = chunks .map((text, i) => ({ text, score: cosine(queryVec, vectors[i]) })) .sort((a, b) => b.score - a.score) .slice(0, CANDIDATES) ``` ## 3. Rerank the shortlist Cosine over embeddings is fast but coarse. Send the shortlist to rerank, which scores each candidate against the query directly and returns them ordered by relevance. Cap the result to the few passages you will actually pass on. ```ts const documents = shortlist.map((c) => c.text) const res = await fetch(`${BASE}/api/v1/rerank`, { method: 'POST', headers: auth, body: JSON.stringify({ model: 'cruxy-rerank', query, documents, top_n: 5 }), }) const { results } = await res.json() // Each result's `index` points into `documents` (the shortlist you sent), // NOT the original `chunks`. Map through `documents` to recover the text. const context = results.map((r) => documents[r.index]) ``` ## 4. Generate the answer Give the reranked passages to chat as context and constrain the model to answer from them. `stream: false` returns the reply as the top-level `message`. ```ts const res = await fetch(`${BASE}/api/v1/chat`, { method: 'POST', headers: auth, body: JSON.stringify({ model: 'kavi', stream: false, messages: [ { role: 'system', content: 'Answer only from the provided context. If the answer is not in it, say you do not know.', }, { role: 'user', content: `Context:\n${context.join('\n\n')}\n\nQuestion: ${query}`, }, ], }), }) const { message } = await res.json() console.log(message.content) ``` ## Why rerank after vector search, not instead of it The two retrieval endpoints trade off differently, so the pipeline uses both. Embeddings compress a passage into a single vector, which makes cosine search cheap enough to run over a whole corpus, but that compression blurs fine distinctions, so the top cosine hit is not always the most relevant passage. Rerank scores each document against the query directly, which is far more discriminating, but it reads every candidate on every call, so running it over the full corpus does not scale. Chaining them keeps each in its lane: embeddings recall a broad shortlist cheaply, rerank orders that shortlist precisely. Rerank comes *after* vector search, never *instead of* it, because it ranks a candidate set you hand it; it does not index your corpus or retrieve from one. ## Mapping indices back Both retrieval endpoints return positions, not text, and they point into different arrays. An embedding's `data[i].index` is the position in the `input` you sent to `/embeddings`. A rerank result's `index` is the position in the `documents` you sent to `/rerank`: here, the shortlist, not the original corpus. Map each rerank `index` through `documents`; mapping it through `chunks` returns the wrong passage whenever the shortlist reordered the corpus, which is almost always. The [Rerank](/docs/en/build/rerank) guide flags this same trap for the single-endpoint case. ## Next steps - [Embeddings](/docs/en/build/embeddings): the vectors and the batch input shape. - [Rerank](/docs/en/build/rerank): the score ordering and `top_n`. - [Chat](/docs/en/build/chat): request options and the flattened response. ================================================================================ Page: Streaming chat in the browser URL: https://learn.getcruxy.com/docs/en/resources/cookbook/streaming-ui Markdown: https://learn.getcruxy.com/docs/en/resources/cookbook/streaming-ui.md ================================================================================ # Streaming chat in the browser Consume the OpenAI-style SSE stream in a browser, render deltas without jank, and recover when the connection breaks mid-response. A streaming chat UI reads tokens as the model writes them. This recipe proxies [Chat](/docs/en/build/chat) with `stream: true` through your own server (so the key never reaches the browser), then consumes the Server-Sent Events on the client, painting each delta as it arrives. ## 1. Proxy the stream past your key A browser must not hold `CRUXY_API_KEY`. Put a thin server route in front: it adds the bearer key, requests the stream from the gateway, and pipes the SSE body straight through to the client unchanged. ```ts // server route, e.g. POST /api/chat export async function POST(req: Request) { const { prompt } = await req.json() const upstream = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'kavi', stream: true, messages: [{ role: 'user', content: prompt }], }), }) // Forward the raw event stream; the browser reads the same chunks the // gateway emitted, terminated by `data: [DONE]`. return new Response(upstream.body, { headers: { 'Content-Type': 'text/event-stream' }, }) } ``` ## 2. Consume the SSE in the browser Read the response body as a stream, split it on the blank line between events, and pull each token from `choices[0].delta.content`. Tokens land in a buffer that a single `requestAnimationFrame` flushes to the DOM, so a fast stream repaints once per frame instead of once per token. ```ts async function streamInto(el: HTMLElement, prompt: string) { const res = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt }), }) if (!res.ok || !res.body) throw new Error(`chat failed: ${res.status}`) const reader = res.body.getReader() const decoder = new TextDecoder() let buffer = '' let pending = '' let frame = 0 let done = false // Batch DOM writes: deltas accumulate in `pending`, one rAF flush paints. const flush = () => { if (pending) { el.textContent += pending pending = '' } frame = 0 } const schedule = () => { if (!frame) frame = requestAnimationFrame(flush) } try { while (true) { const { value, done: streamDone } = await reader.read() if (streamDone) break buffer += decoder.decode(value, { stream: true }) const events = buffer.split('\n\n') buffer = events.pop() ?? '' // keep the trailing partial event for (const event of events) { const line = event.replace(/^data: /, '').trim() if (!line) continue if (line === '[DONE]') { done = true continue } const chunk = JSON.parse(line) const delta = chunk.choices?.[0]?.delta?.content if (delta) { pending += delta schedule() } } } } catch { // The stream broke after the 200 arrived; see "handling mid-stream // failure" below. Surface what streamed so far, then let the caller retry. el.dataset.error = 'stream interrupted' } finally { if (frame) cancelAnimationFrame(frame) flush() // paint whatever landed before the stream ended } // A clean HTTP close is not the same as a complete answer: only `[DONE]` // marks completion. No sentinel means the response was cut short. if (!done) el.dataset.incomplete = 'true' } ``` ## The streaming shape differs from non-streaming A streaming chunk is not shaped like the non-streaming reply. With `stream: true`, each `chat.completion.chunk` carries an OpenAI `choices[]` array and the token lives at `choices[0].delta.content`. With `stream: false`, the gateway flattens the reply to a top-level `message` and there is no `choices[]` at all. Code written against the flattened `message.content` will not find a token in a stream chunk, and vice versa; [Chat](/docs/en/build/chat#stream-true) documents both shapes side by side. The first chunk is a role primer whose `delta.content` is an empty string; the `if (delta)` guard skips it. The final chunk before `[DONE]` carries a `usage` object and an empty `choices` array, which the optional chaining on `choices?.[0]` skips; read `usage` there if you want token counts and cost for the call. [Streaming](/docs/en/build/streaming) documents every frame in order. ## Rendering without jank The reader hands you tokens faster than a browser needs to repaint. Writing to the DOM on every delta forces a layout on each one and the text visibly stutters. Accumulating deltas in a buffer and flushing once per `requestAnimationFrame` collapses a burst of tokens into a single repaint, so the text advances smoothly however fast the stream runs. ## Handling mid-stream failure The response status resolves before any token streams. Once the gateway has sent `200`, a later failure (a dropped connection, an upstream cut) cannot change that status; it surfaces as `reader.read()` throwing or as the stream ending. Two signals cover it: the `try/catch` catches a thrown read, and the `[DONE]` sentinel confirms completion. A stream that ends without `[DONE]` delivered a partial answer even though nothing threw, so this recipe marks it incomplete. Treat either signal as a cue to keep the partial text and offer a retry. ## Next steps - [Streaming](/docs/en/build/streaming): the frame order and how to detect a failed stream. - [Chat](/docs/en/build/chat): the full request options and both response shapes. - [Tutorial: build a tool-using agent](/docs/en/build/tool-agent): drive multi-turn tool use. - [Errors](/docs/en/reference/errors): the `400`, `401`, and `429` bodies. ================================================================================ Page: Moderation gate URL: https://learn.getcruxy.com/docs/en/resources/cookbook/moderation-gate Markdown: https://learn.getcruxy.com/docs/en/resources/cookbook/moderation-gate.md ================================================================================ # Moderation gate Screen user input with cruxy-guard before it reaches chat, and act on the flagged boolean, not the uncalibrated category scores. A moderation gate runs [Moderations](/docs/en/build/moderations) in front of [Chat](/docs/en/build/chat): classify the input first, and only call the model if it comes back clean. This recipe wires the two together and shows the one decision the guard's response forces you to get right. ## The gate Classify the prompt with `cruxy-guard`. If the single result is `flagged`, refuse before spending a chat call; otherwise pass the prompt through to chat. ```ts const BASE = 'https://api.cruxy.ai' const auth = { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', } async function screen(text: string): Promise<{ flagged: boolean; requestId: string }> { const res = await fetch(`${BASE}/api/v1/moderations`, { method: 'POST', headers: auth, body: JSON.stringify({ model: 'cruxy-guard', input: text }), }) const body = await res.json() return { flagged: body.results[0].flagged, // Moderations carries no cruxy block; the request id is the top-level // `id`, prefixed "modr-". Strip the prefix to get the bare request_id. requestId: body.id.replace(/^modr-/, ''), } } async function guardedChat(prompt: string): Promise { const gate = await screen(prompt) if (gate.flagged) { console.warn(`blocked input, request_id=${gate.requestId}`) return 'This request was blocked by the content filter.' } const res = await fetch(`${BASE}/api/v1/chat`, { method: 'POST', headers: auth, body: JSON.stringify({ model: 'kavi', stream: false, messages: [{ role: 'user', content: prompt }], }), }) const { message } = await res.json() return message.content } ``` ## Branch on flagged, never on the scores The guard returns `category_scores` alongside the `categories` booleans, and it is tempting to threshold them. Do not: ```ts // WRONG: category_scores is not a calibrated probability. if (result.category_scores.violence > 0.7) block() ``` The guard model emits labels, not likelihoods, so every value in `category_scores` is exactly `1.0` or `0.0`, the same bit as the matching boolean in `categories`, written as a number. A threshold like `0.7` buys nothing: nothing ever lands between the two values, and reading a score as confidence reads a precision that is not there. Decide on `flagged` for an any-category gate, or on a specific `categories[name]` boolean when you only want to block one kind of content. The [Moderations](/docs/en/build/moderations#category_scores-is-binary) guide carries the same caveat for the endpoint on its own. ## Screening a batch or the output `input` also accepts an array, and `results` returns one entry per input in order; screen every message in a conversation with one call rather than one call each. The gate reads left to right, so you can run the same `screen` over the model's reply before you show it, and refuse on the way out as well as on the way in. ## Next steps - [Moderations](/docs/en/build/moderations): the categories, the response, and the binary-scores caveat. - [Chat](/docs/en/build/chat): the endpoint the gate protects. - [Errors](/docs/en/reference/errors): the `400`, `401`, and `429` bodies. ================================================================================ Page: Resources URL: https://learn.getcruxy.com/docs/en/resources Markdown: https://learn.getcruxy.com/docs/en/resources.md ================================================================================ # Resources Reference material for building on Cruxy: the tier catalog, the changelog, a glossary of terms, and the API guide for agents. The Resources tab collects the material you reach for while building: the tier catalog, what changed and when, the definitions behind the guides, and a one-page reference for models and agents. Open the glossary when a term is unfamiliar and the changelog when a version moves. The cookbook and the API reference each have a tab of their own; the cards below link to both. - [Models](/docs/en/reference/models): The public tier catalog, read at runtime with GET /api/v1/models. - [Changelog](/docs/en/reference/changelog): What changed in the API and its docs, newest first, dated by API version. - [Glossary](/docs/en/resources/glossary): Definitions for tiers, the cruxy block, request_id, device login, and the 429 codes. - [API guide for agents](/docs/en/resources/api-guide): Auth, tiers, chat, tools, embeddings, rerank, moderations, and errors on one dense page. - [Cookbook](/docs/en/resources/cookbook): Task-shaped recipes that combine endpoints: retrieval, a tool loop, a streaming UI, and a moderation gate. - [API Reference](/docs/en/reference/api): Every endpoint, typed and interactive. ## For models and agents The whole documentation corpus is published as plain text for ingestion. - [/llms.txt](/llms.txt): The link index: one entry per page, each pointing at its raw markdown. - [/llms-full.txt](/llms-full.txt): Every page's markdown concatenated into one file, in navigation order. ================================================================================ Page: Models URL: https://learn.getcruxy.com/docs/en/reference/models Markdown: https://learn.getcruxy.com/docs/en/reference/models.md ================================================================================ # Models Discover the public tier catalog at runtime with GET /api/v1/models. `GET /api/v1/models` returns the public tier catalog (the tiers you can pass as `model`, with their capabilities), so you read it at runtime instead of hardcoding tier strings. ```bash title="curl" curl https://api.cruxy.ai/api/v1/models \ -H "Authorization: Bearer $CRUXY_API_KEY" ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/models', { headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}` }, }) const { data } = await res.json() for (const model of data) { console.log(model.id, model.context_window, model.supports_tools) } ``` ## Tiers, never provider models The catalog lists Cruxy's **public tiers** (`kavi`, `vaani`, and `mira`) in the OpenAI discovery shape. Each entry's `id` is the tier string you pass as `model` to [Chat](/docs/en/build/chat). The response never exposes the upstream provider model behind a tier, so a tier is a stable id you can depend on even when the backing model changes. [Choosing a tier](/docs/en/build/choosing-a-tier) compares the tiers and when to use each. | | Kavi | Vaani | Mira | | --- | --- | --- | --- | | Model id | `kavi` | `vaani` | `mira` | | What it is | The most capable tier. | The balanced tier. | The fastest tier. | | Best for | The hardest tasks: deep reasoning and the largest output budget. | Everyday calls: strong quality at lower latency and cost. | Short, high-volume calls where latency and cost matter most. | ## Response shape The envelope is a list of `model` objects: ```json { "object": "list", "data": [ { "object": "model", "id": "kavi", "label": "..." } ] } ``` Each entry describes one tier: | Field | Meaning | | --- | --- | | `id` | The tier string to pass as `model`, e.g. `kavi`. | | `label` | Human-readable name for the tier. | | `object` | Always `"model"`. | | `context_window` | Maximum input size, in tokens. | | `max_output` | Maximum tokens the tier will generate. | | `latency_target_ms` | The tier's latency target, in milliseconds. | | `supports_tools` | Whether the tier supports tool calling; see [Define tools](/docs/en/build/tools). | | `pricing` | `currency`, `input_unit`, and `output_unit`, the per-unit prices. | Read the concrete values from the live response rather than assuming them; they can change as tiers evolve. ## Errors `GET /api/v1/models` returns `401` if the key is missing or invalid. See [Errors](/docs/en/reference/errors). ## Next steps - [Chat](/docs/en/build/chat): use a tier id as `model`. - [Choosing a tier](/docs/en/build/choosing-a-tier): pick the tier for each call. - [Define tools](/docs/en/build/tools): for tiers where `supports_tools` is true. - [Changelog](/docs/en/reference/changelog): when the catalog's metadata changed. ================================================================================ Page: Changelog URL: https://learn.getcruxy.com/docs/en/reference/changelog Markdown: https://learn.getcruxy.com/docs/en/reference/changelog.md ================================================================================ # Changelog What changed in the Cruxy API and its docs, newest first: each dated release by its API version, plus the additive changes that need no pin. API version: 2026-10-07 The Cruxy API is date-versioned: `info.version` in the spec is a date, and every response echoes the version that served it in the `Cruxy-Version` header, so you can always confirm which version served a call. Send `Cruxy-Version: YYYY-MM-DD` to pin one; see [Request IDs and metadata](/docs/en/build/request-ids#the-cruxy-version-header). A new dated version ships for a breaking change, such as a change to streaming frame order. A date can also gate a new request field whose silent absence on an older gateway would make a response misrepresent what it used, and the four releases since 2026-06-13 each do that. Ordinary additive fields ship without a new version, so the spec dates neither them nor the endpoints added alongside them; [Additive changes since 2026-06-13](#additive-changes-since-2026-06-13) lists both. Entries use the [Keep a Changelog](https://keepachangelog.com) categories (Added, Changed, Fixed, and Deprecated), and a release lists only the categories it touched. ## 2026-10-07 The current version. ### Added - A lifetime for uploaded files. `POST /api/v1/files` takes an optional `expires_in`, from 60 to 604800 seconds (7 days), and returns the moment it ends as `expires_at`. Once it passes, the file is gone to every read, and its bytes are deleted within 15 minutes. Setting `expires_in` requires `Cruxy-Version: 2026-10-07` or later; without the pin the upload is `400` `invalid_request` and nothing is stored. A file uploaded without it lives until you delete it, as before. ## 2026-10-06 ### Added - Files on stored conversation messages. `POST /api/v1/conversations/{id}/messages` takes `file_ids` on a user message, at most 20 distinct files, and every read of the conversation returns them. It requires `Cruxy-Version: 2026-10-06` or later; without the pin, `file_ids` is `400` `invalid_request` with `required_version`. The date is one day later than chat's on purpose: a gateway that knows only 2026-10-05 reads `file_ids` in chat but would drop them from an append. ## 2026-10-05 ### Added - Files in a chat turn. A user message on `POST /api/v1/chat` or `POST /api/v2/chat` can carry `file_ids`, naming files you uploaded with `POST /api/v1/files`, and the gateway reads each one into the turn: UTF-8 text, PDF and Word (`.docx`) documents as the text read from them at upload, and JPEG, PNG, and WebP images on a tier that reads images. It requires `Cruxy-Version: 2026-10-05` or later; without the pin, `file_ids` is `400` `invalid_request` with `required_version`. A file's tokens are billed on every request that sends it, and `POST /v1/chat/completions` refuses `file_ids`. - `cruxy.files_omitted`, a top-level `files_omitted` on `POST /api/v2/chat`, lists the files on earlier messages that a turn left out, and why. - Three `400` codes for a file that cannot be read into a turn: `file_not_text`, `file_too_large`, and `image_not_supported`, which names the tiers that read images in `vision_tiers`. See [Errors and retries](/docs/en/build/errors-and-retries#400-bad-request). ## 2026-09-26 ### Added - Per-request memory controls on chat. `memory: {"recall": …, "capture": …}` on `POST /api/v1/chat` and `POST /api/v2/chat` turns automatic recall and capture on or off for one request; left out, each side follows the credential's default. Pin `Cruxy-Version: 2026-09-26` before relying on `false`: a gateway that predates the field ignores it and captures anyway, while a pinned request is refused by such a gateway instead. See [Memory](/docs/en/build/memory). ## Additive changes since 2026-06-13 These need no version pin, and the spec does not date them. Each is in the 2026-10-07 spec and was not in the 2026-06-13 spec. ### Added - The plain OpenAI wire: `POST /v1/chat/completions` and `GET /v1/models`, for OpenAI clients and evaluation harnesses. It carries none of the Cruxy-native chat features. - Conversations under `/api/v1/conversations`: history stored on the server, with search, archiving, and branches. - Standing rules: `GET`, `PUT`, and `DELETE /api/v1/rules`, `GET /api/v1/rules/resolved`, and a vault's own rules at `/api/v1/vaults/{id}/rules`, with the chat `rules` switch and `cruxy.rules_applied`. See [Standing rules](/docs/en/build/rules). - Chat request fields `tool_choice`, `vault_id`, and `mentioned_vault_ids`, and the response signals `cruxy.memory_recall`, `cruxy.memory_capture`, and `cruxy.undeclared_tool_replay`. See [Request IDs and metadata](/docs/en/build/request-ids#the-cruxy-block). - Eval suites, cases, and runs under `/api/v1/evals`. - Invitations and memberships: `/api/v1/invitations`, `/api/v1/org/invitations`, and `/api/v1/org/memberships`. - Native sign-in: `POST /api/v1/auth/oauth/start` and `POST /api/v1/auth/oauth/token`. A key minted this way is a signed-in credential, as from device login. - The `X-Request-Id` header, declared on every response: a correlation id that you can set on the request. See [Request IDs and metadata](/docs/en/build/request-ids#the-x-request-id-header). - A public status document at `GET /api/status`. See [Reliability](/docs/en/build/reliability#check-platform-status). - `GET /api/v1/service-token/self`, which describes the calling service token. - Tier catalog fields on `GET /api/v1/models`: `created`, `owned_by`, `profile`, `reasoning` (`default_effort` and `graded`), and `supports_vision`. - `results[].cruxy.hazard_codes` on a flagged moderation result. - `org` on the device-login token response: the organization the new key is bound to. - `content_type` on an uploaded file, plus `text_status` and `text_tokens` for a PDF or Word document, whose text is read once at upload. - Error codes: `client_closed_request`, `conversation_forbidden`, `conversation_not_found`, `eval_case_not_found`, `eval_run_not_found`, `eval_suite_not_found`, `eval_suite_slug_exists`, `input_not_countable`, `invalid_tool_schema`, `invitation_not_found`, `key_read_only`, `key_scope_forbidden`, `key_spend_cap_exceeded`, `limits_unavailable`, `message_not_found`, `model_not_allowed`, `moderation_rejected`, `provider_not_supported`, `redirect_uri_not_allowed`, `request_not_found`, `rules_forbidden`, `rules_too_large`, `server_shutting_down`, and `vault_external_id_conflict`. See [Errors and retries](/docs/en/build/errors-and-retries#error-codes). ### Changed - The production hostname is `https://api.cruxy.ai`. `https://api.cruxy.in` is the legacy hostname for the same service, and is still supported. - The spec documents each endpoint once, under `/api/v1`. Every public endpoint is also served under `/api/v2` by the same handler, with the same authentication, rate limit, and bodies. Chat is the exception: `POST /api/v2/chat` is the separate Cruxy-native wire. ### Deprecated - `scope_unsupported` is retired. It is never sent, and it stays in the error vocabulary so the string is never reused. ## 2026-06-13 This release types the surfaces you build against and documents the limits and failure modes the gateway already enforced. ### Added - Model catalog metadata on [Models](/docs/en/reference/models) (`GET /api/v1/models`): each tier advertises its `context_window`, `max_output`, `latency_target_ms`, and `pricing`, so you read a tier's limits at runtime instead of hardcoding them. Pricing is provisional; read it from the live response, never from a copy. - The full `finish_reason` enum on [Chat](/docs/en/build/chat) (`stop`, `tool_calls`, `length`, and `content_filter`) for both streaming and non-streaming responses, with `tool_calls` as the signal to answer `message.tool_calls` and continue the turn. - Per-request batch limits on the specialized endpoints: [Embeddings](/docs/en/build/embeddings), [Rerank](/docs/en/build/rerank), and [Moderations](/docs/en/build/moderations) accept at most 512 inputs, with the whole body capped at 64 KiB; exceeding either ceiling is a `400`. Batch a larger corpus across multiple requests. - Per-type feedback payloads on [Feedback](/docs/en/build/feedback): `type` fixes the shape of `payload`, and `thumbs`, `edit_accepted`/`edit_rejected`, and `test_result` each carry their own schema, so the reference documents the exact fields for each event. - Mid-stream failure behavior for streaming [Chat](/docs/en/build/chat): a response that ends without the `data: [DONE]` terminator is a failed, incomplete completion. The `200` is already committed, so no error frame follows; treat a stream that stops before `[DONE]` as a failure and retry it. ### Changed - The non-streaming chat response is now typed and flattened. [Chat](/docs/en/build/chat) with `stream: false` returns a `ChatCompletionResponse`: the assistant reply is the top-level `message` with a sibling `finish_reason`, not an OpenAI `choices[]` array; read `message.content` directly. Streaming responses are typed as `ChatCompletionChunk`. This replaces the previously untyped `200` body. - Feedback validation is stricter. [Feedback](/docs/en/build/feedback) rejects unknown payload keys, wrong types, and bad enum values with a `400`, and a stored event's payload is consent-gated and redacted rather than persisted verbatim. ## Next steps - [API Reference](/docs/en/reference/api): every endpoint, typed and interactive. - [Models](/docs/en/reference/models): the live tier catalog and its metadata. - [Errors](/docs/en/reference/errors): the `400`/`401`/`429` shape these changes reference. ================================================================================ Page: Glossary URL: https://learn.getcruxy.com/docs/en/resources/glossary Markdown: https://learn.getcruxy.com/docs/en/resources/glossary.md ================================================================================ # Glossary Definitions for the Cruxy vocabulary: tiers, the cruxy block, request_id, device login, and the 429 codes. Terms you meet across the guides and the API reference, defined once. Each entry links to where the term is used in full. ## Models and tiers ### Tier A public model you address by a stable name rather than by the provider model behind it. You pass a tier as `model`, and the gateway resolves it to an upstream provider model on the server; you never send or receive that upstream id. Because the name is stable, your code keeps working when the backing model changes. See [Models](/docs/en/reference/models). ### kavi, vaani, mira The three public chat tiers. You pass one as `model` to [Chat](/docs/en/build/chat). To read the live catalog rather than hardcoding the strings, call [Models](/docs/en/reference/models). ### cruxy-embed The embeddings model. You pass it as `model` to [Embeddings](/docs/en/build/embeddings) to turn text into vectors. ### cruxy-rerank The rerank model, and the only one the gateway offers for reranking. You pass it as `model` to [Rerank](/docs/en/build/rerank), which orders candidate documents against a query. ### cruxy-guard The moderation model, and the only one for moderation. You pass it as `model` to [Moderations](/docs/en/build/moderations), which classifies text against content-safety categories. ## Requests and responses ### OpenAI-compatible Requests and responses keep the OpenAI shape, so an existing OpenAI client needs little more than a new base URL and key; see [Migrate from OpenAI](/docs/en/migrate/from-openai). [Chat](/docs/en/build/chat), [Embeddings](/docs/en/build/embeddings), and [Moderations](/docs/en/build/moderations) follow it; [Rerank](/docs/en/build/rerank) does not; it uses a Cohere-style contract whose results carry indices into your input. ### The cruxy block An additive, namespaced `cruxy` key on responses. It never alters the OpenAI-shaped fields, so a vanilla OpenAI client parses the response unchanged and ignores the extra key. Its contents vary by endpoint: on chat it carries `request_id`, `routed_tier`, and `reasoning_effort` (cost and tokens live in `usage`); on embeddings and rerank it carries `request_id` and a `cost` object. Moderations has no top-level block, though a flagged result carries its own `cruxy.hazard_codes`. See [the cruxy block](/docs/en/build/request-ids#the-cruxy-block). ### request_id A UUID that identifies a single call, returned in the response's `cruxy.request_id`. You log it to identify the call in support requests, and you send it back to [Feedback](/docs/en/build/feedback) to attach an outcome to that call. [Request IDs and metadata](/docs/en/build/request-ids) shows where it appears on each endpoint. ### Rerank Scoring a set of candidate documents against a query and returning them best-first, so you order a shortlist by relevance. Each result carries an `index` into the documents you sent, not the text itself. See [Rerank](/docs/en/build/rerank). ### flagged The overall boolean in a [Moderations](/docs/en/build/moderations) result: `true` when any category matched. Check it first. The accompanying `category_scores` are binary (`1.0` or `0.0`), not calibrated probabilities, so you do not threshold them. ## Authentication ### Device login An [RFC 8628](https://datatracker.ietf.org/doc/html/rfc8628) device-authorization flow that mints a key: your client requests a code, a person approves it in a browser, and your client polls until a key is issued. The browser and the client can be different machines. See [Get your API key](/docs/en/get-started/api-keys). ### user_code and device_code The two codes in device login. `device_code` is the secret your client polls with and never shows the user; `user_code` is the short code the person confirms in the browser. See [Get your API key](/docs/en/get-started/api-keys). ### Bearer key A `cxy_live_` API key you send in the `Authorization: Bearer` header on every authenticated request. Device login returns it exactly once, so you store it on issue and treat it like a password. See [Authentication](/docs/en/get-started/authentication). ## Limits and budgets ### rate_limited The `429` `code` that means requests are arriving too fast. It clears on its own once you ease off, then retry. See [Rate limits and budgets](/docs/en/build/rate-limits). ### budget_exhausted The `429` `code` that means the account's budget is spent. A retry alone will not clear it until the budget resets or is raised, distinct from `rate_limited`, which eases on its own. See [Rate limits and budgets](/docs/en/build/rate-limits). ================================================================================ Page: API guide for agents URL: https://learn.getcruxy.com/docs/en/resources/api-guide Markdown: https://learn.getcruxy.com/docs/en/resources/api-guide.md ================================================================================ # API guide for agents A dense, single-page reference for calling the Cruxy API correctly: auth, tiers, chat, tool calling, embeddings, rerank, moderations, and errors. One page with everything a model needs to call the API correctly, and nothing else. The base URL is `https://api.cruxy.ai`. Every path below is under it. For the exhaustive per-field spec, see the [API Reference](/docs/en/reference/api); for the whole corpus as text, fetch `/llms-full.txt`. ## Auth Every endpoint except device login needs a Bearer key in the `Authorization` header. Keys carry the `cxy_live_` prefix. A missing or invalid key is `401`. ```bash curl https://api.cruxy.ai/api/v1/me \ -H "Authorization: Bearer $CRUXY_API_KEY" ``` `GET /api/v1/me` echoes `{ api_key_id, user_id }`; use it to confirm a key works. ### Device login Get a key with an RFC 8628 device flow: no key required, since its job is to mint one. 1. `POST /api/v1/device/code` (no body, no auth) returns `{ device_code, user_code, verification_uri, verification_uri_complete, expires_in, interval }`. 2. Send the user to `verification_uri_complete` to approve before `expires_in` seconds elapse. 3. `POST /api/v1/device/token` with `{ device_code }`, no more often than every `interval` seconds. While unresolved it returns `400` with a `code`: only `authorization_pending` means keep polling; `access_denied`, `expired_token`, and `invalid_grant` are terminal. On approval it returns `200` with `{ access_token, token_type, key_id, user_id }`. `access_token` is the `cxy_live_` key, returned once; persist it immediately. ## Tiers `model` is a public tier: `kavi`, `vaani`, or `mira`. It also accepts `auto`, which the server-side router resolves to a tier per request, before billing and the upstream call; an `auto` request may be budget-downgraded to `mira`, so read `cruxy.routed_tier` on the response for the tier that served the call (see [Auto routing](/docs/en/build/auto-routing)). Never send an upstream provider id; tiers are the stable contract. Discover the catalog at runtime instead of hardcoding: ```bash curl https://api.cruxy.ai/api/v1/models \ -H "Authorization: Bearer $CRUXY_API_KEY" ``` `GET /api/v1/models` returns `{ object: "list", data: [...] }`, each entry `{ id, label, profile, context_window, max_output, latency_target_ms, reasoning, supports_tools, supports_vision, pricing }`. Read `context_window`, `max_output`, and `supports_tools` from the response rather than assuming them. [Choosing a tier](/docs/en/build/choosing-a-tier) compares the tiers. ## Chat `POST /api/v1/chat`. The request is OpenAI-shaped: ```bash title="curl" curl https://api.cruxy.ai/api/v1/chat \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "kavi", "stream": false, "messages": [{ "role": "user", "content": "Hello" }] }' ``` ```ts title="TypeScript" const res = await fetch('https://api.cruxy.ai/api/v1/chat', { method: 'POST', headers: { Authorization: `Bearer ${process.env.CRUXY_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ model: 'kavi', stream: false, messages: [{ role: 'user', content: 'Hello' }], }), }) const completion = await res.json() ``` Request fields beyond `model` and `messages`: | Field | Type | Notes | | --- | --- | --- | | `messages` | array | Each `{ role, content }`; `role` is `system`, `user`, `assistant`, or `tool`. | | `stream` | boolean | `true` for SSE, `false` for one JSON object. Optional; `false` by default. | | `temperature` | number | 0–2, forwarded upstream unchanged. Omit for the model default. | | `max_tokens` | integer | Upper bound on generated tokens. Clamped to the tier cap: `kavi` 8192, `vaani` 4096, `mira` 2048. | | `reasoning_effort` | string | `none`, `low`, `medium`, or `high`; `none` disables reasoning. Default per tier: `kavi` high, `vaani` low, `mira` none. Thinking spends `max_tokens`, so a small `max_tokens` on `kavi` can end with `finish_reason: "length"` and empty content; send `none` for short answers. See [Reasoning effort](/docs/en/build/reasoning-effort). | | `tools` | array | Function tools; see below. | | `tool_choice` | string or object | `none`, `auto`, `required`, or `{ type: "function", function: { name } }`. | | `memory` | object | `{ recall, capture }`, each a boolean. Off by default for a developer key; `true` needs the `memory` scope. See [Memory](/docs/en/build/memory). | | `rules` | boolean | Standing-rules injection. Off by default for a developer key; `true` needs the `rules` scope. See [Standing rules](/docs/en/build/rules). | ### Response shape The non-streaming and streaming responses differ in shape; read the one that matches your `stream` value. `stream: false` returns a single object with the reply at the top level (not an OpenAI `choices[]` array): ```json { "id": "chatcmpl-8f3a1c2d", "model": "kavi", "message": { "role": "assistant", "content": "Hi." }, "finish_reason": "stop", "usage": { "input_tokens": 12, "output_tokens": 48, "billable_input_tokens": 12, "cache_read_tokens": 0, "cache_write_tokens": 0, "cost": { "tier": "kavi", "amount": "", "currency": "" } }, "cruxy": { "request_id": "…", "routed_tier": "kavi", "reasoning_effort": "high" } } ``` `` and `` are placeholders; a real `amount` is a number. Amounts are specific to your account and shown in your Cruxy Console. `stream: true` is an OpenAI-style Server-Sent Events stream: each event is a `chat.completion.chunk` on a `data:` line, terminated by `data: [DONE]`. Chunks use the `choices[].delta` shape: ```text data: {"object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Hi"}}]} data: [DONE] ``` ```ts const reader = res.body!.getReader() const decoder = new TextDecoder() let buffer = '' while (true) { const { value, done } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const events = buffer.split('\n\n') buffer = events.pop() ?? '' for (const event of events) { const line = event.replace(/^data: /, '').trim() if (line === '[DONE]') break const chunk = JSON.parse(line) // choices[0].delta.content is the token } } ``` The final chunk carries `usage` as `{ prompt_tokens, completion_tokens, total_tokens, cost, … }`. ### Tool calling Pass `tools`, each `{ type: "function", function: { name, description, parameters } }` where `parameters` is a JSON Schema object. When the model calls one, the turn ends with `finish_reason: "tool_calls"` and the assistant message carries `tool_calls`: ```json { "id": "call_abc123", "type": "function", "function": { "name": "get_weather", "arguments": "{\"city\":\"Paris\"}" } } ``` `arguments` is a JSON-encoded string; parse it. Run the function, then continue the conversation with the assistant message that made the call, kept whole, followed by one tool result per call, tied back by `tool_call_id`, and send the same `tools` again: ```ts messages.push( message, // the assistant turn as returned, tool_calls included { role: 'tool', tool_call_id: 'call_abc123', content: JSON.stringify(result) }, ) ``` Repeat while `finish_reason` is `tool_calls`; the turn with `finish_reason: "stop"` carries the answer. [Handle tool calls](/docs/en/build/tool-calls) has the full loop. ## Embeddings `POST /api/v1/embeddings` with `model: "cruxy-embed"`. `input` is a string or an array of strings; one embedding is returned per input, in order. ```bash curl https://api.cruxy.ai/api/v1/embeddings \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "cruxy-embed", "input": ["hello", "world"] }' ``` Returns `{ object: "list", model, data: [{ object: "embedding", index, embedding: [...] }], usage: { prompt_tokens, total_tokens }, cruxy }`. ## Rerank `POST /api/v1/rerank` with `model: "cruxy-rerank"`, a `query`, and a `documents` array of candidate strings. Optional `top_n` caps how many results come back. ```bash curl https://api.cruxy.ai/api/v1/rerank \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "cruxy-rerank", "query": "What is the capital of France?", "documents": ["Paris is the capital of France.", "Berlin is in Germany."], "top_n": 3 }' ``` Returns `{ model, results: [{ index, relevance_score }], usage, cruxy }`, ranked by `relevance_score` descending. Each result's `index` points back into the `documents` you sent. ## Moderations `POST /api/v1/moderations` with `model: "cruxy-guard"`. `input` is a string or an array; one result is returned per input. ```bash curl https://api.cruxy.ai/api/v1/moderations \ -H "Authorization: Bearer $CRUXY_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "model": "cruxy-guard", "input": "text to classify" }' ``` Returns `{ id, model, results: [{ flagged, categories, category_scores }] }`. `flagged` is the overall boolean; `categories` is a per-label boolean map over `harassment`, `hate`, `self-harm`, `sexual`, and `violence` (with their `/`-suffixed subtypes). `category_scores` mirrors it as numbers, but the guard model emits labels, so those are binary `1.0`/`0.0`, not calibrated probabilities. This endpoint returns no `cruxy` block and no `usage`. ## Errors Every failure is `{ code, error }`. Branch on `code`: it is stable; `error` is human text that may change. | Status | `code` | Meaning | | --- | --- | --- | | 400 | `invalid_request` | Malformed request. Fix it; a retry fails the same way. | | 400 | `context_overflow` | Chat input exceeded the tier's context window. | | 401 | `unauthorized` | Bearer key missing, malformed, unknown, expired, or revoked. | | 429 | `rate_limited` | Requests too fast. Back off, then retry. | | 429 | `budget_exhausted` | Spend cap hit. A retry won't clear it until the window resets. | The device-login endpoints use OAuth-style `400` codes (`authorization_pending`, `access_denied`, `expired_token`, `invalid_grant`) instead of this shape. ## The cruxy block Cruxy-native fields are additive and namespaced under a `cruxy` key, so a vanilla OpenAI client ignores them and keeps parsing. What the block carries differs by endpoint: - Chat: `{ request_id, routed_tier, reasoning_effort }`. Token counts and `cost` live in the sibling `usage` object, not here. - Embeddings and rerank: `{ request_id, cost }`. - Moderations: no top-level block. The request id is the response's `id`, formatted `modr-`, and a flagged result carries `results[].cruxy.hazard_codes`. `request_id` is a UUID for the call. Log it; it identifies the call in support requests. See [Request IDs and metadata](/docs/en/build/request-ids).