Skip to main content

OpenThai 2.0 Legal ThaiLLM

FREE until 30 Sep 2026
Now: 0 IC — free with any iApp API key (registration free)After promo: 0.01 / 0.02 IC per 1K input/output tokens (same as Thanoy Legal AI)
v2.0 Active POST /v3/llm/openthai2p0-legal/chat/completions

OpenThai 2.0 Legal (iapp/openthai2.0-legal-thaillm-nemotron-3-nano-30b-a3b) is an open-weight Thai legal LLM built on NVIDIA Nemotron-3-Nano-30B-A3B by the OpenThai team (AIEAT / iApp Technology). The hosted API is RAG-connected out of the box: every request hybrid-searches a 39-law, 6,300-section Thai statute corpus (BM25 + Qwen3 embedding search + reranker) and grounds the answer in the current law text — returning the retrieved sections so you can display or audit them.

Try Demo

Try OpenThai 2.0 Legal — Live

FREE API · 1 MONTH

Real model, real answers. Free with your iApp API key until 30 Sep 2026.

📚
RAG connected — answers grounded in real statute text

This API retrieves from a 39-law, 6,300-section Thai statute corpus (hybrid BM25 + embedding search + reranker) and grounds every answer in the current law text. The sections it selects are shown below each answer. Switch to Closed-book mode (rag: false in the API) to compare with the bare model.

Full legal analysis in prose, citing มาตรา — grounded in auto-retrieved current statute text.

Try an example:
🧑‍💻 Developer view — the API call behind this demo

This curl command updates live as you change the question, mode and toggles above. Run it in your terminal with your own API key — it is exactly what this page sends.

Request (curl)
curl -s https://api.iapp.co.th/v3/llm/openthai2p0-legal/chat/completions \
  -H "Content-Type: application/json" \
  -H "apikey: YOUR_IAPP_API_KEY" \
  -d '{
  "model": "openthai2.0-legal",
  "rag": true,
  "rag_inject": "system",
  "messages": [
    {
      "role": "system",
      "content": "You are a Thai legal expert. Answer with legal analysis and cite the relevant มาตรา."
    },
    {
      "role": "user",
      "content": "จำเลยขีดฆ่าและฉีกเอกสารหลักฐานแห่งหนี้ แม้ยังอ่านได้ ถือเป็นความผิดสำเร็จหรือเพียงพยายามกระทำผิด"
    }
  ],
  "temperature": 0,
  "top_p": 1,
  "max_tokens": 4096,
  "chat_template_kwargs": {
    "enable_thinking": true
  },
  "stream": true,
  "stream_options": {
    "include_usage": true
  }
}'

Outputs are decision support, not legal advice. Verify every citation against the current law. Free with an iApp API key to 30 Sep 2026; standard pricing afterwards is 0.01/0.02 IC per 1K input/output tokens (same as Thanoy Legal AI).

Getting Started

  1. Prerequisites

    • A free iApp API key — registerAPI KeysCreate New API Key
    • A Thai legal question
  2. Endpoint

    Base URLhttps://api.iapp.co.th/v3/llm/openthai2p0-legal
    EndpointPOST /chat/completions (OpenAI-compatible)
    Modelopenthai2.0-legal
    Authapikey: <key> header or Authorization: Bearer <key> (OpenAI SDK works as-is)
    Rate limit30 requests/minute per IP (free tier)
  3. RAG parameters (this API's extras on top of the OpenAI schema)

    FieldDefaultMeaning
    ragtrueRetrieve Thai law sections server-side and ground the answer. false = bare model (closed-book).
    rag_top_k8 (max 20)Number of retrieved sections injected into the prompt (6 when rag_inject: "system").
    rag_inject"user""user" = the trained Provided context scaffold, best for JSON citation answers. "system" = advisory reference in the system prompt, best for essay/long-form analysis.
    dekafalseAlso retrieve real Supreme Court precedents (คำพิพากษาศาลฎีกา, 133k decisions) and inject them as analogous rulings. Recommended for long-form analysis; off for short citation answers.
    unanswerable_mode"replace"What to return when the answer cannot be grounded: "replace" = a standard refusal instead of the answer, "append" = the answer plus a warning, "flag" = the answer untouched, verdict fields only. See Guardrails.
    web"auto"Web search for current cases and events: "auto" = only when the question names a real case/person or uses news wording, true = always, false = never.
    guardtruePre-answer checks (non-existent laws/sections/cases, fiction, off-topic). false disables them (not recommended).

    Every RAG response carries a top-level retrieved_documents array — {law, section, text, score} for each section the answer was grounded in. When streaming, it rides on the first SSE chunk.

How to get API Key?

Please visit API Key Management page to view your existing API key or request a new one.

Code Examples

cURL — RAG on (default)

curl -X POST 'https://api.iapp.co.th/v3/llm/openthai2p0-legal/chat/completions' \
-H 'apikey: YOUR_API_KEY' \
-H 'Content-Type: application/json' \
-d '{
"model": "openthai2.0-legal",
"messages": [
{"role": "user", "content": "ลักทรัพย์ในเวลากลางคืน ผิดมาตราใด"}
],
"max_tokens": 1024
}'

Response (truncated)

{
"id": "chatcmpl-...",
"model": "openthai2.0-legal-thaillm-nemotron-3-nano-30b-a3b",
"choices": [
{"message": {"role": "assistant", "content": "ลักทรัพย์ในเวลากลางคืน เป็นการกระทำความผิดตามมาตรา ๓๓๕ (๑) แห่งประมวลกฎหมายอาญา ..."}}
],
"usage": {"prompt_tokens": 2874, "completion_tokens": 17},
"rag": true,
"retrieved_documents": [
{"law": "ประมวลกฎหมายอาญา", "section": "335", "text": "ผู้ใดลักทรัพย์ (๑) ในเวลากลางคืน ...", "score": 0.9989},
{"law": "ประมวลกฎหมายอาญา", "section": "334", "text": "ผู้ใดเอาทรัพย์ของผู้อื่น ...", "score": 0.9936}
]
}

Python — OpenAI SDK (works as-is, Bearer auth)

from openai import OpenAI

client = OpenAI(
base_url="https://api.iapp.co.th/v3/llm/openthai2p0-legal",
api_key="YOUR_API_KEY",
)

r = client.chat.completions.create(
model="openthai2.0-legal",
messages=[{"role": "user", "content": "ลักทรัพย์ในเวลากลางคืน ผิดมาตราใด"}],
max_tokens=1024,
# extra_body={"rag": False} # bare model (closed-book)
# extra_body={"rag_inject": "system"} # essay / long-form analysis
)
print(r.choices[0].message.content)
print(r.model_extra.get("retrieved_documents")) # the law sections the answer used

Streaming (SSE) — retrieved sections arrive on the first chunk

stream = client.chat.completions.create(
model="openthai2.0-legal",
messages=[{"role": "user", "content": "อธิบายความผิดฐานลักทรัพย์โดยละเอียด"}],
max_tokens=2048,
stream=True,
)
for chunk in stream:
docs = chunk.model_extra.get("retrieved_documents")
if docs:
print("Grounded in:", [(d["law"], d["section"]) for d in docs])
if chunk.choices and chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)

Since 16 Sep 2026 the answer streams token by token in every unanswerable_mode. Before that, replace mode held the whole answer back until the citation check had run, so it arrived as one block. Now the draft streams live and, in the rare case the verdict replaces or prunes it (about 1–3 % of answers), the stream continues with a separator line and the replacement text; the final chunk carries content_modified: "replaced" (or "pruned") and replacement_content, so a UI can swap the bubble to the replacement instead of showing both. Send "hold_until_verdict": true to get the old behaviour (answer withheld until checked).

Handling a replaced answer in a live stream

The stream is ordinary OpenAI-style SSE. The draft arrives as delta.content chunks; when the verdict replaces or prunes it, one more content chunk with \n\n---\n + the replacement follows, then the finish_reason: "stop" chunk, then a final chunk with choices: [] that carries usage, the verdict fields and replacement_content. Nothing arrives after data: [DONE].

data: {"choices":[{"delta":{"content":"ไม่"}}], ...}                      ← draft streams token by token
data: {"choices":[{"delta":{"content":"พบ"}}], ...}
...
data: {"choices":[{"delta":{"content":"\n\n---\nขออภัย ระบบไม่พบตัวบทกฎหมาย … แล้วถามใหม่ให้ชัดเจนขึ้น"}}]} ← only when replaced/pruned
data: {"choices":[{"delta":{"content":""},"finish_reason":"stop"}]}
data: {"choices":[], "usage":{...}, "answerable":false, "answerable_reason":"no_relevant_law_found",
"content_modified":"replaced", "replacement_content":"ขออภัย ระบบไม่พบตัวบทกฎหมาย … แล้วถามใหม่ให้ชัดเจนขึ้น", ...}
data: [DONE]

content_modified is null for a normal answer (nothing to do), "replaced" (show replacement_content instead of the draft) or "pruned" (the draft with ungrounded lines removed: replacement_content is the pruned text plus a notice). Swap the bubble either way:

# Python (openai SDK): render the draft live, swap it if the final chunk says so
draft, replacement = "", None
with client.chat.completions.create(model="openthai2.0-legal", messages=msgs, max_tokens=2048, stream=True,
extra_body={"rag_inject": "system", "deka": True, "web": "auto"}) as stream:
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
draft += chunk.choices[0].delta.content
ui.set_answer(draft) # live rendering
extra = chunk.model_extra or {}
if extra.get("replacement_content") is not None:
replacement = extra["replacement_content"] # final chunk: verdict replaced/pruned the draft
final_text = replacement if replacement is not None else draft
ui.set_answer(final_text)
// JavaScript (fetch + SSE): same logic in a browser or Node client
let draft = "", replacement = null;
const res = await fetch(url, { method: "POST", headers, body: JSON.stringify({ ...body, stream: true }) });
const reader = res.body.getReader(), dec = new TextDecoder(); let buf = "";
while (true) {
const { value, done } = await reader.read(); if (done) break;
buf += dec.decode(value, { stream: true });
let i; while ((i = buf.indexOf("\n\n")) >= 0) {
const line = buf.slice(0, i).trim(); buf = buf.slice(i + 2);
if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
const o = JSON.parse(line.slice(6));
for (const c of o.choices || []) if (c.delta?.content) { draft += c.delta.content; ui.setAnswer(draft); }
if (o.replacement_content !== undefined && o.replacement_content !== null) replacement = o.replacement_content;
}
}
ui.setAnswer(replacement ?? draft);

If the UI cannot swap, send "hold_until_verdict": true in the request body: the answer is then withheld until the verdict and arrives as one content chunk (the pre-16 Sep behaviour), with no replacement_content.

JSON citation contract (the trained RAG mode)

For machine-readable answers, use the system prompt the model was RL-trained on — it cites only sections present in the retrieved context:

SYSTEM = (
"You are OpenThaiGPT-Legal, an expert assistant on Thai law. You are given a legal "
"question and the exact statutory sections needed to answer it. Reason step by step in "
"English, then give the final answer in Thai. Cite ONLY sections present in the provided "
"context, using each section's exact law_name and bare section number (e.g. 132, 77/1). "
'Output the final answer as JSON: {"answer": "<Thai answer>", '
'"citations": [{"law": "<law_name>", "section": "<bare id>"}]}.'
)

r = client.chat.completions.create(
model="openthai2.0-legal",
messages=[
{"role": "system", "content": SYSTEM},
{"role": "user", "content": "หมิ่นประมาทโดยการโฆษณา มีความผิดตามมาตราใด"},
],
temperature=0.0, max_tokens=1024,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
# -> {"answer": "...มาตรา 328", "citations": [{"law": "ประมวลกฎหมายอาญา", "section": "328"}]}

Added 11 September 2026 — live on the hosted API, no client change needed.

The API no longer answers questions it cannot ground. Three layers run around the model:

  1. Premise guard (before the model) — the question is checked against the corpus, the Supreme Court database and a small classifier. If it names a law, section or case number that does not exist, a fictional premise, or is not a legal question at all, the API returns a standard Thai refusal immediately (no tokens are generated).
  2. Citation check (after the model) — every มาตรา in the answer is matched against the retrieved sections. A section the model cites from memory is looked up in the corpus and the citing sentence is verified against the real statute text by the reranker; if it matches, it counts as grounded, otherwise that part of the answer is removed. An answer with no grounded citation left is replaced by the refusal.
  3. Web search for current cases — when the question is about a specific real case or person (คดีอดีตหลวงพ่อโชติ ผิดอะไร) or uses news wording, the API searches the web, reads the top news articles, extracts the charges actually reported, retrieves the statutes for those charges and answers from them. A named case that no non-social news source reports is refused (case_not_found).

Response fields

FieldMeaning
answerable / answerable_reasonfalse when the answer was refused or rewritten. Reasons: no_relevant_law_found, ungrounded_citations, model_declined, empty_answer, unknown_law, unknown_section, unknown_case, case_not_found, fictional_premise, not_legal_question.
content_modifiednull, "replaced" (refusal returned instead of the answer), "pruned" (ungrounded parts removed), "appended" (warning added). The original text is in original_content.
citations{total, grounded, ungrounded: [{law, section}], lookup: [{law, section, score, ok}]}lookup lists sections the model cited from memory and how they scored against the statute text.
retrieval_confidenceBest reranker score among the retrieved sections (0–1).
guard{label: LEGAL / CASE / FICTION / OTHER, findings, deka_missing, deka_found, refused, reason}.
web{used, trigger, query, provider, results: [{title, url, snippet, published}], offences, case_evidence, cached} — show results as sources when used is true.

When streaming, these fields ride on the last SSE chunk (the one carrying usage); retrieved_documents still arrives on the first chunk.

Example — a case that does not exist

POST /chat/completions
{"model": "openthai2.0-legal", "messages": [{"role": "user", "content": "คดีหม่ำเท่งโหน่ง ผิดมาตราอะไร"}]}

{
"choices": [{"message": {"role": "assistant",
"content": "ขออภัย ไม่พบข่าวหรือข้อมูลคดีจากแหล่งข่าวที่น่าเชื่อถือเกี่ยวกับ \"หม่ำเท่งโหน่ง\" จึงไม่สามารถวิเคราะห์ทางกฎหมายได้ ..."}}],
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
"answerable": false, "answerable_reason": "case_not_found", "content_modified": "replaced",
"guard": {"label": "CASE", "refused": true, "reason": "case_not_found"},
"web": {"used": false, "trigger": "guard_refused"}
}

Example — a real current case

{"model": "openthai2.0-legal", "messages": [{"role": "user", "content": "คดีอดีตหลวงพ่อโชติ ผิดอะไร"}], "deka": true}

{
"choices": [{"message": {"content": "คดีอดีตหลวงพ่อโชติ ผิดฐานเป็นเจ้าพนักงานเบียดบังทรัพย์และปฏิบัติหน้าที่โดยมิชอบ ตามประมวลกฎหมายอาญา มาตรา 147 และมาตรา 157 และฐานฟอกเงินตามพระราชบัญญัติป้องกันและปราบปรามการฟอกเงิน พ.ศ. 2542 มาตรา 5 และมาตรา 60 ..."}}],
"answerable": true, "answerable_reason": "ok",
"citations": {"total": 4, "grounded": 4, "ungrounded": []},
"guard": {"label": "CASE", "refused": false},
"web": {"used": true, "trigger": "named_case", "provider": "google",
"offences": ["เบียดบังทรัพย์", "ฟอกเงิน", "ปฏิบัติหน้าที่มิชอบ"],
"case_evidence": {"entity": "อดีตหลวงพ่อโชติ", "news_sources": ["thestandard.co", "www.thairath.co.th"], "found": true},
"results": [{"title": "กองปราบฯ คุมตัว 'สมีโชติ' คดีฟอกเงิน-เบียดบังทรัพย์ ...", "url": "https://thestandard.co/...", "published": "2026-09-11T01:22:57+00:00"}]}
}

Notes

  • Greetings and "who are you" messages (สวัสดีครับ, คุณคือใคร, แนะนำตัว, hello, thanks) get a fixed Thai self-introduction of OpenThai 2.0 Legal instead of the not-a-legal-question refusal (since 16 Sep 2026; guard.kind = greeting / identity / thanks, no engine call). A greeting followed by a legal question is answered as a legal question.
  • Legal terms of art that the statute text does not contain verbatim (คดีอุทลุม, ครอบครองปรปักษ์, ทางจำเป็น, นิติกรรมอำพราง, ฟ้องซ้ำ / ฟ้องซ้อน, ประกันตัว, เช็คเด้ง, เลิกจ้างไม่เป็นธรรม, อุ้มหาย …) are resolved by a glossary (since 17 Sep 2026): the defining sections are pinned into the context, retrieval and precedent search use the statute's own wording, and guard.glossary lists the terms found. "คดี" + a case-type word (คดีอุทลุม, คดีอนาถา, คดีมโนสาเร่ …) is a legal question, not a named case.
  • Questions about current cases take 1–3 s longer (search + reading 2–3 articles). Search results and page bodies are cached for one hour, so repeated questions about the same case are not slower.
  • Exam-style hypotheticals ("นายแดงลักทรัพย์นายดำ …") are not treated as real cases and are answered from the statutes as before.
  • Measured on a 107-item guardrail set (non-existent laws/sections/cases, fiction, off-topic, typos, current cases, real NitiBench questions): 105/107 correct behaviour with thinking on and off; before this update 75/105.

Features & Capabilities

Core Features

  • Server-side RAG built in — hybrid BM25 + embedding retrieval with reranking over the current text of 39 core Thai laws (ประมวลกฎหมายอาญา, ป.พ.พ., วิ.แพ่ง, วิ.อาญา, ประมวลรัษฎากร and 34 more) plus 2,026 further acts, decrees and ministerial regulations from the Council of State's consolidated set — 54,215 sections in force; post-2560 penalty amounts verified.
  • Verifiable citations — exact law name + มาตรา, either in prose or as a fixed JSON contract; retrieved_documents lets you audit every answer.
  • Reasoning mode — add "chat_template_kwargs": {"enable_thinking": true} for step-by-step legal reasoning, returned separately in message.reasoning_content (delta.reasoning_content when streaming) with an automatic reasoning budget.
  • Open weights — the same model is downloadable and self-hostable on a single 24 GB GPU (NVFP4).

Use Cases

  • Legal chatbots and research tools — grounded answers with sections your users can verify.
  • Drafting and review assistants — the JSON citation contract drops straight into automation.
  • Legal-tech products — display the retrieved_documents panel to show why the model answered as it did.

Production chat (recommended, 14 Sep 2026):

{"temperature": 0.6, "top_p": 0.95, "repetition_penalty": 1.05, "min_p": 0.05, "max_tokens": 2048,
"rag_inject": "system", "deka": true, "deka_top_k": 2, "web": "auto", "unanswerable_mode": "replace"}

Measured on a loop-prone question set (70 questions + 8 prompts × 8 seeds per setting): the previous default (temperature 0.7, top_p 0.9, no penalty) produced repetition loops that ran to the 4,096-token cap in about 1–2 % of answers; this setting produced none in 134 runs with the same grounding (99 % of cited sections found in the retrieved text) and normal answer length.

  • If the client sends no repetition_penalty / presence_penalty / frequency_penalty, the service applies repetition_penalty 1.05.
  • Loop guard: the service detects a degenerated repetition loop (the same block repeated three times) and cuts it — in streaming it stops the engine at that point, in non-streaming it trims the text; the response carries content_modified: "loop_cut".
  • max_tokens 2048 is enough for a full legal opinion (average answer ≈ 500–650 tokens) and halves the worst-case latency.
Tasktemperaturethinkingrag_inject
Citation answering (JSON)0.0offuser (default)
Legal essay / analysis0.0onsystem
Conversational / production chat0.6 (+ repetition_penalty 1.05)offsystem

Multi-turn conversations

Send the whole conversation in messages (the assistant turns included). The model reads the history, and since 14 Sep the RAG layer does too: a short follow-up ("แล้วถ้าทำงานมา 3 ปีล่ะ", "การเล่นหมายเลข 5 ถึง 15 ในบัญชี ข. คือ") is searched together with the previous question (guard.retrieval_query shows the combined query), the guard classifies it in context, and a follow-up-shaped message in a legal conversation is never refused as "not a legal question". A self-contained off-topic message ("สูตรทำต้มยำกุ้ง") is still refused.

Context length

Since 16 Sep 2026 the reserved engine accepts prompts up to 262,144 tokens (the backup engine 131,072). Thai text runs about 1.3–1.8 characters per token, so a full 256K prompt is roughly 350–450K Thai characters.

  • Automatic truncation. A prompt longer than the engine window is not rejected: the service counts tokens with the engine's own tokenizer and cuts the middle of the longest user message so that prompt + max_tokens fits, leaving a Thai marker where text was removed. The response then carries truncation: {prompt_tokens_before, prompt_tokens_after, removed_chars, limit} (in the non-streaming body and on the first streaming chunk). Short prompts have no truncation field.
  • Accuracy is not flat across the window. In a needle test with real statute text the model retrieved both planted facts at 73K and 92K prompt tokens, one of two at 115–140K, and none from 165K upward. Keep documents that must be answered precisely under about 90K tokens (≈150K Thai characters); split longer material or ask per section.
  • Cost. A 250K-token prompt takes about 30–35 s to process and slows other requests on the same engine while it runs; short requests are otherwise unaffected by the larger window.
  • Request size. Request bodies up to 64 MB are accepted (since 16 Sep 2026); a full 256K-token Thai prompt is about 1.3 MB. Bodies above 500 KB travel a separate relay path to the same engine, so expect a few hundred milliseconds of extra latency on those.

Health check and status

Keyless endpoints on the same host (no credits, no API key):

curl -s https://api.iapp.co.th/v3/llm/openthai2p0-legal/health/h100     # 200 while the reserved H100 stack is healthy, else 503
curl -s https://api.iapp.co.th/v3/llm/openthai2p0-legal/health # 200 while the API can answer at all, 503 when both backends are down

/health returns status: "ok" (H100 serving), "degraded" (backup only) or "down", plus a h100 and backup object each with healthy, probe (last probe result and latency), load (live RAG slots and engine queue) and checked_at. /health/h100, /health/rag (embedding + reranker engines + full-corpus RAG index on the H100) and /health/backup return that single object with the HTTP status matching its health, so an uptime checker only needs the status code. A status page with 90-day uptime bars and recent incidents, in the same format as status.iapp.co.th, is at api.iapp.co.th/v3/llm/openthai2p0-legal/status (JSON at …/status/api).

Decision support, not legal advice

Outputs must be verified against the current law by a qualified professional before being relied upon. Retrieval quality drives results — the retrieved_documents array exists precisely so every answer can be audited.