Lahjaلهجة
Arabic mode for AI apps

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.

Base URLhttps://api.lahja.dev/v1

Quickstart

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 idVariety“Now” in it
…:arabic-gulfGulf (Saudi, Emirati)الحين
…:arabic-egyEgyptianدلوقتي
…:arabic-levLevantineهلأ
…:arabic-magMoroccan Darija, Arabic scriptدابا
…:arabic-msaModern Standard Arabicالآن
…:arabicDetect from the last user messagevaries

… 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

LimitValueWhen you hit it
Trial allowance1M tokensPrompt plus completion tokens. Then 429 insufficient_quota.
Request rate60/minPer key. 429 rate_limit_error with Retry-After.
Context8,192 tokensPrompt plus completion. Longer requests fail.
New keys3/dayPer 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
  }
}
StatustypeMeaning
400invalid_request_errorBody is not JSON.
401authentication_errorMissing, wrong or revoked key.
404invalid_request_errorUnknown model id.
429insufficient_quotaTrial allowance used up. Do not retry.
429rate_limit_errorOver 60 requests a minute. Wait Retry-After seconds.
503service_unavailableModel 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.