Request parameters
| Field | Type | Required | Notes |
|---|---|---|---|
userId | string | Yes | Non-empty, ≤ 128 chars. Keys rate limit, daily allowance and usage rows. Not authenticated. |
message | string | Yes | Non-empty, ≤ 12,000 characters. |
plan | string | Yes | normal or premium. Validated, then normalised to normal server-side — it cannot grant a paid tier. |
modelTier | string | No | basic | pro | ultra. Defaults to basic. |
subject | string | No | ≤ 100 chars. Scopes the tutor prompt. |
grade | string | No | ≤ 100 chars. The student's stage. |
conversationId | string | No | ≤ 128 chars. Groups turns of one chat. |
complexity | string | No | normal or complex. Legacy; prefer modelTier. |
history | array | No | Prior turns of the same chat: { role: "user" | "assistant", content: string }. Bounded and validated; never stored or logged. Excludes the current question. |
image | object | No | See Vision. |
Two quirks worth knowing
plan is required by the validator even though
the server immediately normalises it to normal. Omitting it is a
400, so send "plan": "normal" and select capability
with modelTier.
Unrecognised fields in the request body are ignored. There is no field that selects or influences how Sargon 1.5 executes your request.
Response structure
| Field | Type | Notes |
|---|---|---|
answer | string | The tutor's answer, in Arabic unless an English-book policy applies. |
model | string | The public model identifier. Always sargon-1.5. |
inputTokens | number | Optional — present when a token count is reported for the request. |
outputTokens | number | Optional — present when a token count is reported for the request. |
totalTokens | number | Optional. The figure used for daily allowance accounting. |
latencyMs | number | Server-side duration. |
requestId | string | UUID. Quote it in support requests. |
modelTier | string | Optional. The tier that served the request. |
proTrial | object | Optional. Remaining Pro trial quota, for pro requests. |
Token usage
Token counts come back on every successful response and, for streaming, on the
terminal done event. Each request is also metered server-side —
inputTokens, outputTokens, totalTokens,
latencyMs, success, and the request type
(text, vision, math_ocr,
premium_reasoning).
Usage rows never contain prompts, questions, answers, image data or credentials. Aggregation resets daily on the Asia/Baghdad calendar day.
{
"inputTokens": 412,
"outputTokens": 735,
"totalTokens": 1147
}
These counts are recorded, not yet priced
Token accounting is real and persisted. Converting it into a monetary amount is not implemented in the service — no cost field is computed, stored or returned. See Pricing.
Error responses
Every failure returns a stable code, an Arabic error message safe to show a student, and a requestId.
{
"error": "لقد أرسلت سؤالاً للتو. انتظر قليلاً قبل إرسال سؤال جديد.",
"code": "RATE_LIMITED",
"retryAfterSeconds": 27,
"requestId": "0f4c1b86-27a1-4f1e-9d0a-2b7c6e5a1d33"
}
| Code | HTTP | Cause |
|---|---|---|
INVALID_REQUEST | 400 | Validation failed: missing or malformed field, message too long, unsupported image type, image too large. |
CONTENT_BLOCKED | 422 | The request was refused on safety grounds. Rephrase. |
RATE_LIMITED | 429 | Per-caller cooldown not yet elapsed. Honour Retry-After. |
DAILY_LIMIT_REACHED | 429 | Daily token allowance exhausted. Resets 00:00 Asia/Baghdad. |
PRO_TRIAL_LIMIT_REACHED | 429 | Pro trial quota exhausted. Includes proTrial and retryAfterSeconds. |
PRO_UNAVAILABLE | 503 | Pro tier cannot be served right now. Fall back to basic. |
ULTRA_UNAVAILABLE | 503 | ultra is not released. Never downgraded silently. |
PROVIDER_UNAVAILABLE | 503 | The service could not produce an answer for this request. Retry with backoff. |
INTERNAL_ERROR | 500 | Unexpected server fault. Retry with backoff; quote the requestId. |
Rate limits
| Limit | Value | Scope | On breach |
|---|---|---|---|
| Request cooldown | 1 request / 30 s | Per userId | 429 RATE_LIMITED + Retry-After |
| Daily tokens | 100,000 (default) | Per userId, per Baghdad day | 429 DAILY_LIMIT_REACHED |
| Message length | 12,000 characters | Per request | 400 INVALID_REQUEST |
| Image size | 10 MiB decoded | Per request | 400 INVALID_REQUEST |
| Pro tier | Trial quota | Per userId | 429 PRO_TRIAL_LIMIT_REACHED |
The cooldown is enforced atomically and persists across restarts. It is
reserved once per accepted request, so an internal retry still
counts as a single request. The server is the single source of truth for the
pace — mirror Retry-After rather than hard-coding an interval, and
note that a cooldown storage fault denies the request rather
than letting it through.
Handling 429 correctly
async function askWithRetry(body: unknown, attempts = 3) {
for (let attempt = 1; attempt <= attempts; attempt++) {
const res = await fetch("https://api.oorr.ai/api/tutor", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (res.ok) return res.json();
const err = await res.json();
// Never retry a validation or safety failure — it will fail identically.
if (err.code === "INVALID_REQUEST" || err.code === "CONTENT_BLOCKED") {
throw new Error(`${err.code}: ${err.error}`);
}
// Obey the server's pace; fall back to the header, then a sane default.
const wait = err.retryAfterSeconds
?? Number(res.headers.get("Retry-After"))
?? 30;
if (attempt === attempts) throw new Error(`${err.code} after ${attempts} attempts`);
await new Promise((r) => setTimeout(r, wait * 1000));
}
}
import time
import requests
NO_RETRY = {"INVALID_REQUEST", "CONTENT_BLOCKED"}
def ask_with_retry(body, attempts=3):
for attempt in range(1, attempts + 1):
res = requests.post(
"https://api.oorr.ai/api/tutor", json=body, timeout=90
)
if res.ok:
return res.json()
err = res.json()
if err.get("code") in NO_RETRY:
raise RuntimeError(f"{err['code']}: {err['error']}")
wait = err.get("retryAfterSeconds") or int(
res.headers.get("Retry-After", 30)
)
if attempt == attempts:
raise RuntimeError(f"{err.get('code')} after {attempts} attempts")
time.sleep(wait)
oorr / api platform