AI assistant
Baselyra can talk to DeepSeek, or to anything that speaks the same /chat/completions shape. Two very different trust levels live behind /ai/v1: operator tooling that sees your real schema, and a plain relay any signed-in user may call. All of it is optional — without a key every route answers 503 and nothing else changes.
Enabling it
DEEPSEEK_API_KEY=sk-…
DEEPSEEK_BASE_URL=https://api.deepseek.com
DEEPSEEK_MODEL=deepseek-chat
DEEPSEEK_MAX_TOKENS=2048
curl -s "$URL/ai/v1/status"
{"enabled":true,"model":"deepseek-chat"}
The key stays on the server. It is never sent to a browser and never appears in a
response. DEEPSEEK_BASE_URL is any OpenAI-compatible /chat/completions
endpoint, so a local Ollama or vLLM behind an OpenAI shim works if you point it
there and set DEEPSEEK_MODEL to match.
The routes
| Route | Body | Who |
|---|---|---|
GET /ai/v1/status | — | Anyone |
POST /ai/v1/sql | {prompt} | A Studio token or the service key |
POST /ai/v1/explain | {sql} | A Studio token or the service key |
POST /ai/v1/ask | {question} | A Studio token or the service key |
POST /ai/v1/chat | {messages, system?, stream?} | Any signed-in application user |
Bodies are capped at 256 KiB, a prompt at 8000 characters, and a conversation at 50 messages.
POST /ai/v1/sql
Generates a statement against your real schema. It is not run. It comes back into the SQL editor for you to read and execute.
curl -s -X POST "$URL/ai/v1/sql" \
-H "authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"prompt":"top 10 authors by published article count in the last 30 days"}'
{"sql":"SELECT u.id, u.email, count(*) AS articles FROM public.articles a JOIN auth.users u ON u.id = a.author WHERE a.published_at > now() - interval '30 days' GROUP BY 1, 2 ORDER BY articles DESC LIMIT 10",
"explanation":"Counts articles published in the last month per author and returns the ten highest."}
The schema is rendered into the system prompt and the model is told to use only
names that exist, to qualify every table, and to emit one statement with no
trailing semicolon. A model that answers in prose instead of SQL gets its prose
back as explanation with an empty sql — a wrong-shaped answer is still an
answer, and showing it beats a 502.
POST /ai/v1/explain
curl -s -X POST "$URL/ai/v1/explain" \
-H "authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"sql":"delete from auth.sessions where expires_at < now()"}'
{"explanation":"This removes every row in auth.sessions whose expires_at is in the past — the refresh-token records for sign-ins that have already lapsed. It is destructive and cannot be undone, though the sessions it removes could no longer be refreshed anyway. It touches only auth.sessions."}
Two short paragraphs at most: what the statement returns or changes, which tables it touches, and anything expensive or destructive.
POST /ai/v1/ask
The only route that runs model-written SQL. It drafts a query, executes it, and answers the question from the rows.
curl -s -X POST "$URL/ai/v1/ask" \
-H "authorization: Bearer $ADMIN_TOKEN" -H 'content-type: application/json' \
-d '{"question":"how many users signed up last week?"}'
{"answer":"412 users signed up between 14 and 20 August.",
"sql":"SELECT count(*) FROM auth.users WHERE created_at >= date_trunc('week', now() - interval '1 week') AND created_at < date_trunc('week', now())",
"rows":[{"count":"412"}]}
If the model answers in prose rather than SQL — usually because the question was
not about the data — the prose comes back as answer and no query runs.
How the query is contained. Four independent guards, because one
hallucinated DROP TABLE is enough:
SET TRANSACTION READ ONLYEvery write and every DDL fails inside Postgres. Nothing relies on the server recognising a dangerous statement.
A local
statement_timeoutof 10 secondsA cartesian join cannot pin a core.
The statement is wrapped
select * from (<statement>) as baselyra_ask limit 200. That bounds memory and, as a bonus, turns a multi-statement reply into a syntax error rather than a second query.The transaction is rolled back either way
Nothing survives, even in principle.
POST /ai/v1/chat
A plain relay for your application's users, so an app built on Baselyra can offer its own assistant without standing up a second backend just to hold an API key. It requires a signed-in user token, not the anon key.
const { data, error } = await bl.ai.chat(
[{ role: 'user', content: 'Summarise this order for me: ' + JSON.stringify(order) }],
{ system: 'You are a helpful support assistant for Acme. Be brief.' },
);
if (error) return show(error.message);
console.log(data.content);
Streaming, over server-sent events:
curl -N -X POST "$URL/ai/v1/chat" \
-H "authorization: Bearer $USER_TOKEN" -H 'content-type: application/json' \
-d '{"messages":[{"role":"user","content":"hi"}],"stream":true}'
data: {"delta":"Hel"}
data: {"delta":"lo!"}
data: [DONE]
An error inside a stream arrives as a data: frame carrying the usual error
object, because the status line has already been sent:
data: {"error":{"code":"ai_upstream","message":"DeepSeek returned 500","details":null}}
Cost control
| Control | Value |
|---|---|
/ai/v1/chat rate limit | 20 requests per minute per user — keyed on the user id, not the IP, so a mobile carrier NAT does not share one budget across thousands of people |
DEEPSEEK_MAX_TOKENS | A hard ceiling per completion. A caller may ask for fewer, never more. |
| Client disconnect | Aborts the upstream call, so you stop paying for tokens the moment the user closes the tab |
| Upstream timeout | 60 seconds, with one retry, and only for 429 and 5xx |
There is no per-user or per-instance spend cap beyond these. If you expose
/ai/v1/chat to the public, watch your DeepSeek dashboard.
Privacy
Be clear-eyed about what leaves the machine.
| Route | Sent upstream |
|---|---|
/sql | Your schema — table, column and type names. No row data. |
/explain | The statement you pasted. No row data. |
/ask | Your schema, the question, and up to 200 result rows. |
/chat | Exactly the messages your app sends. |
Nothing is sent when DEEPSEEK_API_KEY is unset, which is the default. The AI
routes themselves are not audited; a query you then run in the SQL editor is, like
any other statement.
Errors
| Status | code | Meaning |
|---|---|---|
| 503 | ai_disabled | DEEPSEEK_API_KEY is not set |
| 400 | bad_request | A malformed body — a missing prompt, a message over 8000 characters, an unknown role |
| 401 | unauthorized | /chat without a signed-in user |
| 403 | forbidden | /sql, /explain or /ask without a Studio token or the service key |
| 429 | ai_rate_limited | DeepSeek's own rate limit, or Baselyra's per-user cap |
| 502 | ai_unauthorized | DeepSeek rejected the key |
| 502 | ai_upstream | DeepSeek returned an unexpected status |
| 504 | ai_timeout | No response within 60 seconds |
| 504 | ai_unreachable | A network failure reaching DeepSeek |
In the Studio
Studio → AI carries the ask-your-database panel and SQL generation; the SQL
editor offers "explain this" on the current statement. Every panel disappears when
/ai/v1/status reports enabled: false, rather than showing a control that would
return 503.
Failure modes
| What you see | Why | Fix |
|---|---|---|
503 ai_disabled | No key in the running process | Set DEEPSEEK_API_KEY and docker compose up -d |
| A stream that arrives all at once at the end | A proxy is buffering | proxy_buffering off on nginx. The app already sends X-Accel-Buffering: no, but an explicit proxy_buffering on in your config wins. |
/ask returns an answer and no sql | The model answered in prose | Ask a question about the data, or use /sql |
/ask errors with a SQLSTATE and a sql in details | The generated statement did not run — usually an invented column | Read the statement; it is in the error |
| Generated SQL names a table that does not exist | The schema in the prompt is a snapshot; a very new table may be missing | Re-run after the catalog refreshes |
/chat is 401 with a working anon key | The relay needs a user token, not the project key | Sign the user in first |