Contents
Thun Murex API
One endpoint for three cloud AI providers and four AI coding tools running on this machine. Send the same JSON to any of them, and every answer comes back in the same shape.
Overview
Thun Murex is a gateway running on thun-latitude-7280. Apps send it one kind of request, and it answers from any of seven providers: three cloud APIs, and four coding CLIs (command-line AI tools) running on the machine itself.
Requests and responses are JSON in the OpenAI chat format, so most OpenAI-style code works once you change the address and the key. The gateway can only be reached from devices on your Tailscale network.
| Provider | provider value | Kind | Default model |
|---|---|---|---|
| SEA-LION | sea-lion | Cloud API | aisingapore/Qwen-SEA-LION-v4.5-27B-IT |
| Cerebras | cerebras | Cloud API | qwen-3.8-27b |
| Groq | groq | Cloud API | openai/gpt-oss-120b |
| Claude Code | claude | Local CLI | The CLI’s own default |
| Codex | codex | Local CLI | The CLI’s own default |
| Gemini CLI | gemini | Local CLI | The CLI’s own default |
| Antigravity | antigravity | Local CLI | The CLI’s own default |
| ChatGPT (web) | chatgpt | Website | auto |
| Grok (web) | grok | Website | fast |
| MiMo (web) | mimo | Website | mimo-v2.6-pro |
Leave provider out and the request goes to SEA-LION. To see every model you can choose, call GET /api/models.
Authentication
Every request needs a key in the Authorization header:
Authorization: Bearer <your key>There are two kinds of key:
- Owner key (
APP_API_KEY): can call every endpoint and sign in to the console. Keep it to yourself. - Access tokens (
sk-ai-thun-…): created on the console’s API keys page, one per app or person, or by people with an account on their own account page. They can only send requests to providers and read their own balance at /api/me. Each one has limits, an expiry date and providers: its own, or for an account’s keys, the account’s plan, which they all share.
| Endpoint | Owner key | Access token |
|---|---|---|
POST /api/chat, /api/chat/stream | Yes | Yes |
POST /api/image, /api/transcribe, /api/speech | Yes | Yes |
GET /api/models, /api/speech, /api/me | Yes | Yes |
GET /api/usage, /api/quotas | Yes | — |
GET, PATCH /api/providers | Yes | — |
GET, POST, PATCH, DELETE /api/tokens | Yes | — |
GET, PATCH /api/users | Yes | — |
GET, POST, PATCH, DELETE /api/codes | Yes | — |
GET, POST, PATCH, DELETE /api/shop | Yes | — |
GET /api/local-agent/health, /api/local-agent/limits | Yes | — |
A missing or wrong key gets 401 with {"error": "Unauthorized"}. Keys are secrets: call the gateway from your own server or scripts, not from code that runs in someone’s web browser.
- Wrong keys lock you out for a while. After 10 wrong keys in 10 minutes from one address, every request from there gets
429for 15 minutes, with aRetry-Afterheader, even with the right key. - The console sends your owner key once, to sign in. The browser then keeps a sign-in cookie for 7 days, which the page’s scripts can’t read, never the key itself. A new
APP_API_KEYsigns everyone out.
Quickstart
1. Put the gateway’s address and your key in two variables. The examples on this page use them.
export AI_THUN_URL="https://your-ai-thun-host"
export AI_THUN_KEY="sk-ai-thun-…" # an access token, or your APP_API_KEY2. Send a message:
curl "$AI_THUN_URL/api/chat" \
-H "Authorization: Bearer $AI_THUN_KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "groq",
"messages": [{"role": "user", "content": "Write a haiku about Phnom Penh"}]
}'3. The answer is in choices[0].message.content:
{
"id": "chatcmpl-…",
"object": "chat.completion",
"model": "openai/gpt-oss-120b",
"choices": [
{
"index": 0,
"message": { "role": "assistant", "content": "River light at dawn…" },
"finish_reason": "stop"
}
],
"usage": { "prompt_tokens": 82, "completion_tokens": 141, "total_tokens": 223 }
}Chat
Sends a message or a whole conversation to any provider and returns the complete answer as one JSON response. For text that appears while it’s being written, use /api/chat/stream with the same body.
Request body
| Field | Type | Description |
|---|---|---|
provider | string | Which provider answers: sea-lion, cerebras, groq, claude, codex, gemini or antigravity. Default sea-lion. |
model | string | A model id from GET /api/models. Default: the provider’s default model. |
messages | array | The conversation so far: [{"role", "content"}] with roles system, user and assistant. Send this or prompt. |
prompt | string | One user message: a shorter way to send messages with a single message in it. |
session_id | string | Local CLIs: carry on an earlier conversation, using the session_id from its answer. See Conversations. |
workdir | string | Local CLIs: the folder the CLI works in, as an absolute path inside the folders the gateway allows. Default: the gateway’s default folder. |
no_tools | boolean | Local CLIs, claude and codex only: run the CLI with none of its tools (no files, shell or web), as a plain model. |
max_tokens, temperature, … | Cloud APIs: any other OpenAI field (top_p, stop, response_format, reasoning_effort, …) goes to the provider unchanged. Local CLIs ignore them. | |
chat_template_kwargs | object | SEA-LION: {"enable_thinking": false} turns its reasoning off, for much faster answers. |
Response
Cloud APIs answer in their own OpenAI format, passed on unchanged (see the Quickstart). Local CLI answers are converted to the same shape, with session_id and duration_ms added. usage is null for Codex and Gemini CLI, which don’t report tokens.
{
"id": "local-7352154e-6436-4025-b807-eea57954a1ff",
"object": "chat.completion",
"provider": "claude",
"model": "claude-sonnet-5-5",
"session_id": "7352154e-6436-4025-b807-eea57954a1ff",
"duration_ms": 8106,
"choices": [
{
"index": 0,
"finish_reason": "stop",
"message": { "role": "assistant", "content": "It's an AI gateway that…" }
}
],
"usage": { "prompt_tokens": 21480, "completion_tokens": 312, "total_tokens": 21792 }
}Streaming
Takes the same body as /api/chat, and sends the answer as Server-Sent Events while it’s being written. Every provider sends the same events:
| Event | Data | Meaning |
|---|---|---|
start | {"provider"} | The provider has started on the request. |
thinking | {"text"} | Reasoning, as it’s generated. Only from cloud models that think before answering (SEA-LION, gpt-oss). |
delta | {"text"} | The next piece of the answer. |
done | {"provider", "model", "result", "finish_reason", "usage", "duration_ms"} | The whole answer is in result. Local CLIs add session_id and leave out finish_reason. Always the last event. |
error | {"status", "detail", …} | The request failed after the stream started, with the same fields as an error response. Always the last event. |
event: start
data: {"provider":"groq"}
event: thinking
data: {"text":"Just list the numbers."}
event: delta
data: {"text":"1, 2, 3"}
event: delta
data: {"text":", 4, 5"}
event: done
data: {"provider":"groq","model":"openai/gpt-oss-120b","result":"1, 2, 3, 4, 5","finish_reason":"stop","usage":{"prompt_tokens":79,"completion_tokens":38,"total_tokens":117},"duration_ms":412}curl -N "$AI_THUN_URL/api/chat/stream" \
-H "Authorization: Bearer $AI_THUN_KEY" \
-H "Content-Type: application/json" \
-d '{"provider": "groq", "prompt": "Count from 1 to 5"}'- While nothing new comes for 20 seconds (a CLI at work), the stream sends a comment line,
: keep-alive, so nothing between you and the gateway closes it. Skip lines that start with:, as SSE readers do. - A problem found before the stream starts (a wrong key, an unknown provider, a paused provider, a provider’s own error such as
402) comes back as an ordinary JSON error response, not as a stream. - Closing the connection stops the work: a cloud request is cancelled, and a local CLI is stopped.
finish_reason: "length"meansmax_tokensran out. Thinking models can use it all on reasoning and leaveresultempty: raisemax_tokens, or for SEA-LION turn thinking off.
Models
Lists every provider’s models, with its default and whether it’s paused.
curl "$AI_THUN_URL/api/models" -H "Authorization: Bearer $AI_THUN_KEY"{
"providers": {
"groq": {
"kind": "cloud",
"default": "openai/gpt-oss-120b",
"models": [{ "id": "openai/gpt-oss-120b", "name": "openai/gpt-oss-120b" }, …],
"note": null,
"error": null,
"paused": false
},
"claude": {
"kind": "local",
"default": null,
"models": [{ "id": "sonnet", "name": "Claude Sonnet (latest)" }, …],
"note": "aliases for the latest model of each family …",
"error": null,
"paused": false
},
…
}
}| Provider field | Type | Description |
|---|---|---|
kind | string | cloud or local. |
default | string | null | The model used when a request names none. null: the CLI’s own default. |
models | array | Each model’s id (the value for model) and name. |
note | string | null | Extra advice about the list, from the CLI. |
error | string | null | Why the list couldn’t be fetched, if it couldn’t. |
paused | boolean | Paused providers turn requests away. See Pause & resume. |
Cloud lists come live from each provider, keep only chat models, and are cached for 10 minutes. Gemini CLI can’t list its models, so its list is empty and any model name is passed through. A paused cloud provider lists only its default model.
Image generation
Codex draws one image with its built-in image tool, using its ChatGPT sign-in (no API key). One image takes about 75 seconds.
| Field | Type | Description |
|---|---|---|
promptrequired | string | What to draw. |
images | array | Up to 5 pictures to edit or follow, in order: {"data_base64", "label"}. PNG, JPEG or WebP, at most 20 MB each. label says what each one is for, e.g. style reference. |
model | string | The Codex model that drives the image tool. |
workdir | string | As for chat. |
curl "$AI_THUN_URL/api/image" \
-H "Authorization: Bearer $AI_THUN_KEY" \
-H "Content-Type: application/json" \
-d '{"prompt": "A lotus flower, flat vector illustration"}' \
| jq -r .image_base64 | base64 -d > lotus.png{
"provider": "codex",
"model": null,
"image_base64": "iVBORw0KGgoAAAANSUhEUgAA…",
"mime_type": "image/png",
"message": "Created the lotus on a plain background.",
"session_id": "01a11183-…",
"duration_ms": 76000
}Speech to text
Turns audio into text with Groq’s Whisper, using the Groq keys. It takes the same form as OpenAI’s /audio/transcriptions, so OpenAI-compatible clients only need the address and key changed. Send multipart/form-data:
| Field | Type | Description |
|---|---|---|
filerequired | file | The audio. |
model | string | Default whisper-large-v3-turbo, the fastest. |
language | string | The spoken language, e.g. en. Optional, but it helps. |
prompt | string | Words or spellings to expect. |
response_format | string | json (default), verbose_json or text. |
temperature | number | As in OpenAI’s API. |
provider | string | groq (default) or mimo: MiMo Studio’s MiMo-V2.5-ASR, below. |
curl "$AI_THUN_URL/api/transcribe" \
-H "Authorization: Bearer $AI_THUN_KEY" \
-F file=@meeting.m4a \
-F language=en{ "text": " Let's start with the budget.", "x_groq": { "id": "req_…" } }With provider=mimo the file goes to MiMo Studio’s speech recognition page through chatgpt-web-gateway instead (noisy audio, several speakers, dialects; up to 10 MB and 90 seconds), and the answer is {"provider": "mimo", "model": "mimo-v2.5-asr", "text": "…"}. Its other fields are ignored, and pausing MiMo pauses it.
Groq allows 20 transcriptions a minute and 2,000 a day per key. A key that’s at its limit is skipped for the next one; if every key is, the answer is 429 with a Retry-After header. Pausing Groq pauses this endpoint too.
Text to speech
Speaks text through MiMo Studio’s speech pages, via chatgpt-web-gateway. It takes JSON like OpenAI’s /audio/speech and answers with the audio file (WAV, 24 kHz mono). GET /api/speech lists the models and voices.
| Field | Type | Description |
|---|---|---|
inputrequired | string | The text, up to 1,024 characters. |
model | string | mimo-v2.5-tts (default): one of its voices. mimo-v2.5-tts-voicedesign: a voice you describe. mimo-v2.5-tts-voiceclone: the voice of a recording. |
voice | string | For mimo-v2.5-tts: Bingtang (冰糖, default), Mia, Moli (茉莉), Chloe, Suda (苏打), Milo, Baihua (白桦) or Dean. |
instructions | string | The speaking style, or for voicedesign the voice to create. |
reference_audio_base64 | string | For voiceclone: a 5–30 s recording under 10 MB, base64-encoded. |
voice_consent | boolean | For voiceclone, required true: ticks MiMo’s consent to uploading the recording’s voiceprint. Only copy voices you have the right to use. |
response_format | string | json answers the audio base64-encoded, with its type and voice, instead of the file. |
curl "$AI_THUN_URL/api/speech" \
-H "Authorization: Bearer $AI_THUN_KEY" \
-H "Content-Type: application/json" \
-d '{"input": "Good evening!", "voice": "Dean", "instructions": "Calm, like a radio host."}' \
-o evening.wavThe file’s response carries X-Model, X-Voice (URL-encoded) and X-Duration-Ms headers. Each takes about 5–15 seconds, and speech, MiMo transcription and MiMo chat take turns on one MiMo tab. Every one is saved to the MiMo account’s history; pausing MiMo pauses this endpoint.
Your key
The access token you call with: its status (active, disabled, expired or limited), its limits, what it has used and has left this period, when the period starts over, and when the key expires. It answers even for a key that’s turned off, expired or used up, so you can see why requests are refused. With the owner key it answers {"owner": true}.
curl "$AI_THUN_URL/api/me" -H "Authorization: Bearer $AI_THUN_KEY"{
"name": "Dara",
"hint": "sk-ai-thun-3f9a…9c1e",
"status": "active",
"expiresAt": "2026-11-30T23:59:59.999Z",
"limits": { "period": "month", "tokens": 1000000, "requests": 1000, "perMinute": 20 },
"providers": ["groq", "cerebras"],
"used": { "requests": 212, "errors": 3, "tokens": 412880, "estimatedTokens": 0 },
"remaining": { "tokens": 587120, "requests": 791 },
"resetsAt": "2026-11-01T00:00:00.000Z",
"total": { "requests": 212, "errors": 3, "tokens": 412880, "estimatedTokens": 0 },
…
}periodisdayormonth(UTC), ortotalfor a prepaid amount that doesn’t start over.nullmeans no limit, andproviders: nullmeans every provider.- Failed requests (
errors) don’t count against the limit. Tokens are what the provider reported; where it doesn’t say (the web chats, Codex, Gemini CLI), they’re estimated from the prompt’s and answer’s length, about 4 characters a token, and counted inestimatedTokenstoo. Speech, transcription and images count as requests only.
Usage
One month’s usage across every provider: totals, every model with its requests and tokens (most tokens first), and a total for each day. month defaults to the current one. This is what the console’s Monthly usage panel shows.
curl "$AI_THUN_URL/api/usage?month=2026-10" -H "Authorization: Bearer $APP_API_KEY"{
"month": "2026-10",
"months": ["2026-10", "2026-09"],
"totals": {
"requests": 31,
"errors": 0,
"promptTokens": 122819,
"completionTokens": 12995,
"totalTokens": 135814
},
"models": [
{
"provider": "claude",
"model": "claude-haiku-5-5",
"requests": 9,
"errors": 0,
"promptTokens": 54753,
"completionTokens": 404,
"totalTokens": 55157,
"tokensReported": true,
"avgDurationMs": 3900,
"timed": 9,
"lastUsedAt": "2026-10-15T15:03:13.000Z"
},
…
],
"days": [
{ "day": "2026-10-01", "requests": 0, "totalTokens": 0 },
…
],
"generatedAt": "2026-10-16T02:30:00.000Z"
}- Days are UTC days.
monthslists the months that have usage. tokensReported: falsemeans none of that model’s requests said how many tokens they used (Codex, Gemini CLI, Whisper), so its zeros mean “unknown”.- Usage is saved on the server, so restarts and updates don’t reset it. It’s kept for about 13 months.
Quotas
Today’s usage in detail: every cloud API key with its last-minute and daily numbers next to the provider’s published limits, every local CLI and model used today, and the paused providers. This is what the console’s Analytics page shows.
curl "$AI_THUN_URL/api/quotas" -H "Authorization: Bearer $APP_API_KEY"{
"keys": [
{
"provider": "groq",
"keyName": "GROQ_API_KEY3",
"model": "openai/gpt-oss-120b",
"requestsLast60s": 1,
"tokensLast60s": 127,
"requestsToday": 12,
"tokensToday": 48210,
"limits": { "tokensPerDay": 500000 }
},
…
],
"local": [
{
"provider": "claude",
"model": "claude-sonnet-5-5",
"requestsToday": 4,
"errorsToday": 0,
"promptTokensToday": 86020,
"completionTokensToday": 1210,
"tokensToday": 87230,
"tokensReported": true,
"avgDurationMs": 8100,
"finishedToday": 4,
"lastUsedAt": "2026-10-16T02:11:40.000Z"
}
],
"paused": ["cerebras", "codex"],
"generatedAt": "2026-10-16T02:30:00.000Z"
}Keys are shown by the name of the setting that holds them, never by their value. The last-minute numbers start again from zero when the server restarts; the daily ones don’t.
Pause & resume
A paused provider turns every request away with 503, from the console and from access tokens alike, until you resume it. Its keys and CLI sign-in stay as they are. Pausing Groq also pauses speech to text, and pausing Codex pauses image generation. You can also do this on the console’s Manage page.
A provider that isn’t set up is held the same way, and can’t be resumed: a cloud API with no API key, a CLI that isn’t installed, or one the gateway says isn’t signed in. Resuming it answers 409 with the reason. Once it’s set up, it goes by your own pause switch again. If the gateway can’t be reached to ask, nothing is held.
Being set up isn’t being able to answer, so each provider is also tested with a real, tiny chat (“Reply with exactly: ok”) when the server starts: a few tokens on a cloud API, a small run on a CLI’s plan. The websites (ChatGPT, Grok, MiMo) aren’t tested at start, because they share one Chrome window and MiMo saves every chat to its history. A provider that fails is held like one that isn’t set up, with the reason, and tried again after 30 seconds, a minute and two minutes in case the gateway was only starting. A real request that fails because of the provider (a refused key, a server error) starts a test too. After that, a held provider waits for you to test it again. A provider at its rate or usage limit still counts as answering. Test chats aren’t counted as traffic and cost no one’s credit.
Only an answer is a verdict. If the gateway (or a cloud API) can’t be reached at all, the test says nothing about the provider: it isn’t held, and the Manage page says what couldn’t be reached. To keep a provider out of the automatic tests and out of the holds, for one that’s used with a model the test chat doesn’t use, list it in PROVIDER_CHECK_SKIP (for example antigravity,groq). Pressing Test on it still shows how the test went.
POST /api/providers tests one provider now, which is what the Manage page’s Test button does, and is the way to test a website. It answers 202 at once (checks[provider].testing stays true until it’s over; GET shows when), or with "wait": true after the test, which can take a minute or more. Resuming a provider you paused starts a test the same way. Not for a provider that isn’t set up: 409. Set PROVIDER_CHECKS=off to turn the test at start off.
curl -X POST "$AI_THUN_URL/api/providers" \
-H "Authorization: Bearer $APP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"provider": "groq", "wait": true}'| Field | Type | Description |
|---|---|---|
providerrequired | string | Which provider to change. |
pausedrequired | boolean | true to pause, false to resume. |
curl -X PATCH "$AI_THUN_URL/api/providers" \
-H "Authorization: Bearer $APP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"provider": "cerebras", "paused": true}'
# → {"paused": ["cerebras"]}These calls return the providers that turn requests away now, as {"paused": [...], "manual": [...], "setup": {...}, "checks": {...}}: paused has every one that does, manual the ones you paused. Each provider’s setup is {"status": "ready" | "no-key" | "not-installed" | "signed-out" | "failing" | "unknown",
"reason": "…" | null}, failing being a failed test chat. Each checks entry is {"status": "ok" | "failing" | "unverified",
"checkedAt", "source": "test" | "traffic", "reason", "limited", "testing"}.
Access tokens
Make, change and delete the keys other apps and people use instead of your owner key, and read what each one has used. Changes take effect on the next request. The console’s API keys page does the same. Only a hash of each key is stored, so a lost key can’t be shown again: give it a new secret instead.
| Field | Type | Description |
|---|---|---|
name | string | Who or what it’s for. |
note | string | Your own note (contact, plan, payment). The key’s holder never sees it. |
limits.period | string | month (default) or day, UTC, or total: a prepaid amount that doesn’t start over. |
limits.tokens | number | Tokens per period. null (default): no limit. |
limits.requests | number | Requests per period; failed ones don’t count. null: no limit. |
limits.perMinute | number | Requests in any 60 seconds. null: no limit. |
expiresAt | string | When it stops working. A date (2026-11-30) means the end of that day, UTC. null: never. |
providers | string[] | The providers it can use. null (default): all of them. |
agentTools | boolean | Default false: Claude Code and Codex answer without their tools, in the gateway’s own folder, and Gemini CLI, Antigravity and /api/image are refused. true lets the CLIs read files on the machine, in a workdir the caller picks. Only for keys you’d trust with that. |
disabled | boolean | PATCH: turn it off (true) or on again. |
addTokens, addRequests | number | PATCH: raise the limit by this much (a top-up). |
extendDays | number | PATCH: move the expiry this many days later, from now if it’s already past. |
rotate | boolean | PATCH: a new secret, sent back in token. The old one stops working; limits and usage stay. |
# Create one: 1M tokens and 1,000 requests a month, 20 a minute, until the end
# of Nov 30 (UTC), Groq and Cerebras only. The key is shown only in this response.
curl -X POST "$AI_THUN_URL/api/tokens" \
-H "Authorization: Bearer $APP_API_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "Dara", "note": "paid 5 USD on Oct 26",
"limits": {"period": "month", "tokens": 1000000, "requests": 1000, "perMinute": 20},
"expiresAt": "2026-11-30", "providers": ["groq", "cerebras"]}'
# → {"id": "9f2c41d07ab35e18", "token": "sk-ai-thun-3f9a…", "name": "Dara", "status": "active", …}
# List them, with each one's status and use this period
curl "$AI_THUN_URL/api/tokens" -H "Authorization: Bearer $APP_API_KEY"
# One key's use in a month, by day and model
curl "$AI_THUN_URL/api/tokens?id=9f2c41d07ab35e18&month=2026-10" -H "Authorization: Bearer $APP_API_KEY"
# Top it up, give it 30 more days, turn it off, or make it a new secret
curl -X PATCH "$AI_THUN_URL/api/tokens?id=9f2c41d07ab35e18" \
-H "Authorization: Bearer $APP_API_KEY" -H "Content-Type: application/json" \
-d '{"addTokens": 500000, "extendDays": 30}'
# -d '{"disabled": true}' or -d '{"rotate": true}' (the new key comes back in "token")
# Delete one
curl -X DELETE "$AI_THUN_URL/api/tokens?id=9f2c41d07ab35e18" \
-H "Authorization: Bearer $APP_API_KEY"
# → {"revoked": "9f2c41d07ab35e18"}Users
The people who signed up at /account (with Supabase), each with their account: its plan, how many keys it has, and what it has used. A new account is pending and its keys don’t work until you activate it. GET /api/users?id=…&month=2026-10 adds the user’s keys and that month’s use by day and model. The console’s Users page does the same.
| Field | Type | Description |
|---|---|---|
activate | boolean | true: the account’s keys can work. Send it with the plan. |
limits, expiresAt, providers, agentTools, disabled, note | The account’s plan, which all its keys share: the same fields as an access token’s (see Access tokens), and addTokens, addRequests, extendDays. | |
maxKeys | number | How many keys the user may make. Default 5. |
confirmEmail | boolean | true: confirm their email address, when Supabase’s email didn’t reach them. |
Redeem codes
Codes people redeem on their account page. A plan code activates an account with its plan, or replaces and renews it (its days count from the end of the current plan); a topup code adds to an active plan. Each account can use a code once. GET lists every code with its status, what it gives and who used it; PATCH takes disabled and note. The console’s Redeem codes page does the same.
| Field | Type | Description |
|---|---|---|
kindrequired | string | plan or topup. |
limits, providers, agentTools, maxKeys | A plan code’s plan, like an account’s (see Users). A prepaid (total) amount is that much more from redeeming. | |
days | number | A plan code: how many days the plan lasts. Empty: no end. |
addTokens, addRequests, addDays | number | A top-up code: what it adds. Only what the plan has a limit or end date for is added. |
count | number | How many codes to make, 1 to 500. Default 1. |
maxUses | number | How many accounts can use each code. Default 1. |
expiresAt | string | When the code stops working, like 2026-12-31. Default: never. |
code | string | Your own text for a single code, like WELCOME-2026. Default: a random one, like K7QM-2XHD-9WPB. |
note | string | Your own note. |
Shop
Prepaid token packs people buy on their account page with PayPal. GET returns the packs with their pricing, the market prices, every order and whether payments are set up. A pack’s price can be at most a tenth of its tokens at the market price of the cheapest provider it covers. PATCH /api/shop with {"market": {"groq": {"input": 0.15, "output": 0.6}}} changes market prices (US dollars per million tokens). The console’s Shop page does the same.
| Field | Type | Description |
|---|---|---|
namerequired | string | What buyers see it as. |
pricerequired | number | In US dollars, like 0.26. At most a tenth of the market value. |
tokensrequired | number | How many tokens it gives, prepaid. |
providers | string[] | The providers they work on. null: all of them (priced by the cheapest). |
days | number | How long they last. Empty: no end. |
perMinute, maxKeys, agentTools | As for an account’s plan (see Users). | |
description | string | A line buyers see under the name. |
active | boolean | false takes it off sale. |
CLI health
Which coding CLIs are installed on the machine, their versions, and what each one supports. Installed doesn’t mean signed in: each CLI needs its own sign-in on the machine.
{
"status": "ok",
"providers": {
"claude": {
"installed": true,
"path": "/home/thun/.nvm/versions/node/v24.22.0/bin/claude",
"version": "2.1.300 (Claude Code)",
"supports_resume": true,
"supports_images": false,
"supports_no_tools": true
},
…
}
}CLI plan limits
How much of each CLI subscription’s usage limits is left, and when each one is fully available again, as the CLI itself reports it. Checking uses none of it. Only CLIs that can say are filled in (Claude Code and Antigravity); the others have supported: false. The limits belong to the account, so use outside Thun Murex counts too. They refill in rolling windows (5h, weekly), not daily. Claude Code’s 5-hour session has no resets_at until it starts; Antigravity keeps separate limits for its Gemini models and for its Claude and GPT models. Answers are cached for a few minutes; a signed-out CLI comes back with error set.
{
"providers": {
"antigravity": {
"installed": true,
"supported": true,
"checked_at": "2026-10-24T10:24:36Z",
"error": null,
"limits": [
{
"id": "gemini-5h",
"window": "5h",
"remaining_fraction": 0.92,
"resets_at": "2026-10-24T14:08:32Z",
"name": "Five Hour Limit Remaining",
"group": "Gemini Models",
"models": "Gemini Flash, Gemini Pro"
},
…
]
},
"claude": {
"installed": true,
"supported": true,
"limits": [
{
"id": "five_hour",
"window": "5h",
"remaining_fraction": 0.93,
"resets_at": "2026-10-24T23:50:00+07:00",
"name": "Current session",
"group": null,
"models": null
},
{ "id": "seven_day", "window": "weekly", "remaining_fraction": 0.16, … }
],
…
},
"codex": { "installed": true, "supported": false, "limits": [], … },
…
}
}Conversations
Cloud APIs don’t remember anything between requests. To carry on a conversation, send all of it in messages every time, including the earlier answers:
curl "$AI_THUN_URL/api/chat" \
-H "Authorization: Bearer $AI_THUN_KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "sea-lion",
"messages": [
{"role": "system", "content": "Answer in one short sentence."},
{"role": "user", "content": "What is the capital of Cambodia?"},
{"role": "assistant", "content": "Phnom Penh is the capital of Cambodia."},
{"role": "user", "content": "How many people live there?"}
]
}'Local CLIs keep their own history. Every answer has a session_id; send it back with the next message, and the same workdir, to carry on. If you send messages to a CLI, only the last user message is used.
# The first answer includes a session_id …
curl "$AI_THUN_URL/api/chat" \
-H "Authorization: Bearer $AI_THUN_KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "claude",
"model": "sonnet",
"prompt": "What does this project do?",
"workdir": "/home/thun/Desktop/documents/Project/ai-thun"
}'
# … send it back, with the same workdir, to carry on the conversation
curl "$AI_THUN_URL/api/chat" \
-H "Authorization: Bearer $AI_THUN_KEY" \
-H "Content-Type: application/json" \
-d '{
"provider": "claude",
"session_id": "7352154e-6436-4025-b807-eea57954a1ff",
"prompt": "How is it deployed?",
"workdir": "/home/thun/Desktop/documents/Project/ai-thun"
}'Cloud providers
Each cloud provider has several API keys. Requests take turns across all of them, so together they handle many more requests than one key could. A key that answers 429 (rate limited) is skipped for the next one; if every key is, the request fails with 429, and its Retry-After header says how many seconds until the first key can be used again, when the provider said.
| Provider | Limits | Good to know |
|---|---|---|
| SEA-LION | 10 requests a minute per key | Thinks before answering, shown as thinking events. Turn it off with "chat_template_kwargs": {"enable_thinking": false}. |
| Cerebras | Different for each model | Very fast. The account needs billing set up, or it answers 402. |
| Groq | 500,000 tokens a day per key and model (our own limit) | When a key reaches it, the next key is used until the UTC day ends; when every key has, requests get 429 until then. gpt-oss models send thinking events. |
Local agent CLIs
claude, codex, gemini and antigravity are real AI coding tools running on the machine, started for each request by local-agent-gateway. They work in the workdir folder: by default they can read the files there, but not change them or run commands.
| CLI | Sessions | no_tools | Images | Reports tokens | Models |
|---|---|---|---|---|---|
| Claude Code | Yes | Yes | — | Yes | fable, opus, sonnet, haiku, or a full claude-… name |
| Codex | Yes | Yes | Yes | — | From GET /api/models |
| Gemini CLI | Yes | — | — | — | Any model name |
| Antigravity | Yes | — | — | Yes | From GET /api/models |
- Each CLI runs at most 2 requests at a time. Others wait up to 30 seconds, then get
429. - A request that takes longer than 5 minutes is stopped, with
504. - When a CLI reaches its plan’s usage limit (Claude Code’s 5-hour or weekly limit, Codex’s, Gemini’s or Antigravity’s quota), the answer is
429, withretry_afterwhen the CLI said when the limit resets. - Antigravity stops if it needs a permission nobody can grant while it runs on its own, such as running a command. The answer is
502, with the permissions indeniedand the conversation insession_id: allow them in agy’ssettings.json, then send thatsession_idto carry on. - Token counts are for that request alone, even in a long session, and input includes cached tokens, which are most of a Claude Code request.
- Each CLI has to be signed in on the machine before it can answer.
Errors
Errors come back with an HTTP error status and a JSON body. Read its error field; errors from a local CLI use detail instead, and a cloud provider’s own errors are passed on in the provider’s format.
{ "error": "Provider \"cerebras\" is paused. Resume it on the Manage page of the Thun Murex console." }| Status | Meaning |
|---|---|
400 | Something’s wrong with the request: invalid JSON, an unknown provider, no prompt or messages, or a bad workdir, session_id or model for a CLI. |
401 | The key is missing or wrong, or this key can’t call this endpoint. |
402 | The cloud provider wants payment (passed on from the provider). |
403 | The access token is turned off or expired, or can’t use that provider (or images); workdir is outside the folders the gateway allows; or a request reached the gateway’s public door without coming through its address. |
404 | No access token with that id. |
429 | Too many requests: the access token has used its tokens or requests for the period, or its requests a minute (/api/me says which), 10 wrong keys from your address in 10 minutes (for 15 minutes), every key of a cloud provider is rate limited or at its daily limit, a CLI reached its plan’s usage limit, a CLI already has 2 running, or every Groq key is at its transcription limit. Retry-After says how many seconds to wait, when that’s known. |
502 | The provider failed: it couldn’t be reached, or the CLI failed or isn’t signed in. |
503 | The provider is paused. See Pause & resume. |
504 | A CLI took longer than 5 minutes. |
Some errors carry more fields, so a program can recover on its own:
| Field | Type | Description |
|---|---|---|
retry_after | number | With 429: seconds until the limit resets, the same as the Retry-After header. Only there when the provider or CLI said. |
denied | array | Antigravity: the permissions it needed and couldn’t get, e.g. ["command"]. |
session_id | string | Antigravity, with denied: the conversation to carry on once the permissions are allowed. |
partial_response | string | A CLI’s text from before it stopped. |
stderr | string | The end of a CLI’s error output, with secrets hidden. |
HTTP/1.1 429 Too Many Requests
Retry-After: 7140
{
"detail": "claude exited with status 1: You've hit your limit · resets 3pm (Asia/Bangkok)",
"retry_after": 7140
}error event instead, with the same fields and the status in status; the HTTP status stays 200.VS Code & phone
The Thun Murex extension for VS Code (duchpanhathun.ai-thun) is a chat panel for this gateway: every provider and model, CLI sessions that carry on, teams of models that split up a task, approval of the CLI’s actions, and scheduled tasks and workflows.
- Set the gateway’s address in the
aiThun.baseUrlsetting, and the key with the command Thun Murex: Set API Key. VS Code keeps the key in its secret storage. - It uses the error fields above: a team stopped by a usage limit carries on by itself once
retry_afterhas passed, and an Antigravity run stopped for a permission picks up the samesession_id. - Phone app: run Thun Murex: Connect a Phone and scan the code. From the phone you can chat, approve the agent’s actions from a notification, change models, and run schedules and workflows. The phone only talks to VS Code, over Tailscale; VS Code sends the requests here with its own key.