One endpoint, five kinds of Arabic.
Lahja speaks the OpenAI chat API. Point your existing client at our base URL, add a dialect to the model name, and the model replies in that dialect.
https://api.lahja.dev/v1Quickstart
Get a key from the form on the home page. Send it as Authorization: Bearer <key>. There is one model, google/gemma-4-31b-it. We run it ourselves on a rented datacenter GPU with 4-bit QAT weights.
curl https://api.lahja.dev/v1/chat/completions \
-H "Authorization: Bearer $LAHJA_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"model": "google/gemma-4-31b-it:arabic-gulf",
"messages": [{"role": "user", "content": "وين أقرب صيدلية؟"}]
}'
import os
from openai import OpenAI
client = OpenAI(
base_url="https://api.lahja.dev/v1",
api_key=os.environ["LAHJA_API_KEY"],
)
r = client.chat.completions.create(
model="google/gemma-4-31b-it:arabic-gulf",
messages=[{"role": "user", "content": "وين أقرب صيدلية؟"}],
)
print(r.choices[0].message.content)
print(r.model) # google/gemma-4-31b-it:arabic-gulf
// Node 18+. Run this on your server: the key must not reach a browser.
const r = await fetch("https://api.lahja.dev/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.LAHJA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
model: "google/gemma-4-31b-it:arabic-gulf",
messages: [{ role: "user", content: "وين أقرب صيدلية؟" }],
}),
});
const j = await r.json();
console.log(j.choices[0].message.content);
// Or with the openai package:
// import OpenAI from "openai";
// const client = new OpenAI({ baseURL: "https://api.lahja.dev/v1", apiKey: process.env.LAHJA_API_KEY });
GET /v1/models lists the base model and every Arabic-mode id. The API accepts browser calls only from lahja.dev, so call it from your backend.
Arabic mode
Add a suffix to the model name to fix the dialect. Lahja appends a short style sheet for that dialect to your system message, or adds one if you sent none. Your other messages pass through unchanged.
| Model id | Variety | “Now” in it |
|---|---|---|
…:arabic-gulf | Gulf (Saudi, Emirati) | الحين |
…:arabic-egy | Egyptian | دلوقتي |
…:arabic-lev | Levantine | هلأ |
…:arabic-mag | Moroccan Darija, Arabic script | دابا |
…:arabic-msa | Modern Standard Arabic | الآن |
…:arabic | Detect from the last user message | varies |
… stands for google/gemma-4-31b-it. The response's model field reports the dialect we used, for example google/gemma-4-31b-it:arabic-gulf.
Tell us the dialect when you know it. :arabic asks the model to classify the last user message first. That classification is less accurate than a fixed dialect, and short messages are the hardest. If the message has no Arabic letters, auto mode adds no style sheet and model comes back without a suffix.
Header instead of suffix
Send X-Arabic-Dialect with gulf, egy, lev, mag, msa or auto. The header works with the plain model name and overrides any suffix. Use it when your client fixes the model string and you pick the dialect per user.
curl https://api.lahja.dev/v1/chat/completions \
-H "Authorization: Bearer $LAHJA_API_KEY" \
-H "Content-Type: application/json" \
-H "X-Arabic-Dialect: lev" \
-d '{"model": "google/gemma-4-31b-it",
"messages": [{"role": "user", "content": "شو بتنصحني إطبخ اليوم؟"}]}'
Streaming
Set "stream": true to get server-sent events in the OpenAI chunk format, ending with data: [DONE]. Token counts arrive in a final chunk only if you ask for them with stream_options.include_usage.
curl -N https://api.lahja.dev/v1/chat/completions \
-H "Authorization: Bearer $LAHJA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "google/gemma-4-31b-it:arabic-egy",
"stream": true,
"stream_options": {"include_usage": true},
"messages": [{"role": "user", "content": "إزاي أرجّع الأوردر؟"}]}'
stream = client.chat.completions.create(
model="google/gemma-4-31b-it:arabic-egy",
messages=[{"role": "user", "content": "إزاي أرجّع الأوردر؟"}],
stream=True,
stream_options={"include_usage": True},
)
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)
if chunk.usage: # last chunk, empty choices
print("\n", chunk.usage.prompt_tokens, chunk.usage.completion_tokens)
import OpenAI from "openai";
const client = new OpenAI({ baseURL: "https://api.lahja.dev/v1", apiKey: process.env.LAHJA_API_KEY });
const stream = await client.chat.completions.create({
model: "google/gemma-4-31b-it:arabic-egy",
messages: [{ role: "user", content: "إزاي أرجّع الأوردر؟" }],
stream: true,
stream_options: { include_usage: true },
});
for await (const chunk of stream) {
process.stdout.write(chunk.choices[0]?.delta?.content ?? "");
if (chunk.usage) console.log("\n", chunk.usage);
}
Cold starts
The GPU starts on demand. About 20 minutes after the last request, we shut it down. The first call after a quiet spell gets 503 with Retry-After: 120 and the message “The model is warming up. Retry in a few minutes (a cold start takes about 5–6).” That call also starts the GPU, so keep retrying and a later attempt answers. Measured cold start: about 6 minutes.
The OpenAI SDKs only wait out a Retry-After of 60 seconds or less. Turn their retries off and wait yourself:
# curl 7.66+ waits for Retry-After between attempts.
curl --retry 5 --retry-max-time 600 \
https://api.lahja.dev/v1/chat/completions \
-H "Authorization: Bearer $LAHJA_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "google/gemma-4-31b-it:arabic-gulf",
"messages": [{"role": "user", "content": "هلا"}]}'
import os, time
from openai import OpenAI, APIStatusError
client = OpenAI(base_url="https://api.lahja.dev/v1",
api_key=os.environ["LAHJA_API_KEY"], max_retries=0)
def chat(messages, model="google/gemma-4-31b-it:arabic-gulf", tries=6):
for attempt in range(tries):
try:
return client.chat.completions.create(model=model, messages=messages)
except APIStatusError as e:
retryable = e.status_code == 503 or (
e.status_code == 429 and e.type == "rate_limit_error")
if not retryable or attempt == tries - 1:
raise
time.sleep(int(e.response.headers.get("retry-after", "10")))
const sleep = (s) => new Promise((r) => setTimeout(r, s * 1000));
async function chat(body, tries = 6) {
for (let i = 0; ; i++) {
const r = await fetch("https://api.lahja.dev/v1/chat/completions", {
method: "POST",
headers: {
"Authorization": `Bearer ${process.env.LAHJA_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (r.ok) return r.json();
const { error } = await r.json().catch(() => ({}));
const retryable = r.status === 503 ||
(r.status === 429 && error?.type === "rate_limit_error");
if (!retryable || i === tries - 1) throw new Error(error?.message ?? r.statusText);
await sleep(Number(r.headers.get("retry-after") ?? 10));
}
}
Check GET https://api.lahja.dev/ready to see the state without spending a request. It returns 200 when the GPU is up and 503 otherwise.
Limits
| Limit | Value | When you hit it |
|---|---|---|
| Trial allowance | 1M tokens | Prompt plus completion tokens. Then 429 insufficient_quota. |
| Request rate | 60/min | Per key. 429 rate_limit_error with Retry-After. |
| Context | 8,192 tokens | Prompt plus completion. Longer requests fail. |
| New keys | 3/day | Per network. 429 on the signup form. |
We check the allowance before each request, so the request that crosses 1,000,000 tokens still completes.
Errors
Errors use the OpenAI shape. code holds the HTTP status as a number.
{
"error": {
"message": "Invalid or missing API key",
"type": "authentication_error",
"code": 401
}
}
| Status | type | Meaning |
|---|---|---|
| 400 | invalid_request_error | Body is not JSON. |
| 401 | authentication_error | Missing, wrong or revoked key. |
| 404 | invalid_request_error | Unknown model id. |
| 429 | insufficient_quota | Trial allowance used up. Do not retry. |
| 429 | rate_limit_error | Over 60 requests a minute. Wait Retry-After seconds. |
| 503 | service_unavailable | Model warming up (Retry-After: 120) or briefly unreachable (Retry-After: 30). Retry. |
If the model server itself rejects a request, for example one over 8,192 tokens, you get its status code and error body as it sent them.
Keys
Create a key with the form on the home page. Keys start with lj-. We show the key once and keep only a hash of it, so we cannot show it again. Store it in a secret manager or an environment variable. If you lose it, email bassam@bassame.com and we will revoke it and issue a new one.