Waratah AI services API (server contract)
Server-side HTTP contract for the AI services: an LLM egress proxy for browser clients that must never hold provider API keys, and a per-repository schema summary for seeding an AI agent’s context about the data. Three route groups, all disabled by default. When the feature is off, every route answers 501 with the standard JSON error body — that 501 is the frontend’s signal to hide the AI UI entirely.
Owning sources: waratah_api/src/ai/ (routes, config), and
waratah_compute/src/schema_summary.rs (the summary computation). The CLI
flag lives in waratah_bin/src/main.rs.
There is no CORS anywhere on these routes, same as the rest of the Waratah
surface. Proxy /waratah onto the UI origin.
Enabling
--with-ai on waratah start or waratah standalone (the flag sits on the
shared serve arguments, so both commands carry it), or [ai] enabled = true
in {config-dir}/ai.toml. The CLI flag wins over the file; when neither is
set the feature is disabled. Enabling requires the HTTP server — --with-ai
together with --no-http fails with a usage error before anything starts.
When the feature is enabled but the document declares no upstreams, a single
zen upstream is preconfigured (https://opencode.ai/zen/v1, BYOK) so a dev
console works out of the box.
ai.toml
[ai]
enabled = true
# Whole-request deadline for one proxied call (default 120).
request_timeout_secs = 120
# Connect deadline (default 10).
connect_timeout_secs = 10
# Request-body maximum on the proxy route, bytes (default 33554432 = 32 MiB).
max_request_body_bytes = 33554432
# One table per upstream; the table key is the upstream id (the URL path
# segment the proxy answers on). Declared upstreams are the COMPLETE set —
# the zen preconfiguration applies only when none are declared.
[ai.upstreams.zen]
base_url = "https://opencode.ai/zen/v1"
[ai.upstreams.my-provider]
base_url = "https://api.example.com/v1"
# Optional server-held key: an environment variable whose value REPLACES
# the client's Authorization header verbatim (the value is the complete
# header, scheme prefix included), read at request time.
api_key_env = "MY_PROVIDER_KEY"
A base_url must be an http(s) origin — host plus optional port and path
prefix; trailing / is trimmed, query and fragment are rejected. Unknown
keys are ignored so future knobs do not break older engines.
Discovery
GET /waratah/ai
{
"available": true,
"llmProxy": {
"upstreams": [
{ "id": "zen", "baseUrl": "https://opencode.ai/zen/v1",
"proxyPath": "/waratah/ai/upstreams/zen" }
]
},
"repoContext": true
}
Clients read proxyPath from here and never hard-code one. repoContext is
true whenever this answers 200: the ai-context endpoint is gated by the
same [ai] switch, so context is never promised on a disabled engine.
LLM egress proxy
GET|POST /waratah/ai/upstreams/{id}/*path
GET|POST /waratah/ai/upstreams/{id} (the base URL itself)
The proxy points the cluster-proxy machinery at allowlisted,
config-declared upstreams only — a caller never supplies an origin, just a
path under a configured id. Anything after the id is appended to the
upstream’s base_url (query strings preserved; percent-encoded segments
decode once, and a double-encoded segment cannot smuggle a traversal past
validation). TLS rides a rustls connector (hyper-rustls, ring, native
roots) — the workspace’s only TLS client.
Forwarded request headers are a fixed allowlist: Authorization,
Content-Type, Accept, User-Agent, OpenAI-Organization. Everything
else the client sent (cookies, origin, Accept-Encoding, …) is dropped.
When the upstream table sets api_key_env, the named environment variable’s
value replaces Authorization verbatim — the browser stays BYOK.
The response forwards the provider’s status line, Content-Type,
Content-Encoding, and body bytes untouched — the proxy prefix is
excluded from the server’s global gzip/brotli negotiation, so a provider’s
error payload or model list is never re-coded. A client disconnect aborts
the upstream call by drop semantics, as on the other proxies.
| Status | When |
|---|---|
501 |
Feature disabled (JSON error body). |
404 |
Unknown upstream id. |
405 |
Method other than GET or POST. |
400 |
Malformed proxy path or request. |
413 |
Request body over |
502 |
Connect or TLS failure — the upstream origin is unreachable. |
504 |
The whole-request deadline ( |
Timeouts after the headers cannot change the status line already on the
wire, so they end the body instead — and when the content type is
text/event-stream, the proxy first flushes one SSE comment line:
: waratah-proxy-truncated
Conformant SSE event parsers ignore comment lines, so an in-flight consumer
sees a clean end of stream; the Waratah console additionally scans for this
marker to label the cutoff in the UI. Each call logs exactly one
ai-proxy: line (upstream, method, path, status, duration, byte counts,
completed/truncated state) — secrets never appear in it.
Repository AI context
GET /waratah/repositories/{id}/ai-context
A schema summary for AI clients: classes with labels and super-classes,
properties with domain/range/kind, and optional per-class instance counts.
It describes the whole repository — the computation runs with
union-over-graphs semantics, so schema triples in named graphs are included,
and graphScope says "union" on the wire so the agent knows counts may
aggregate graphs it cannot see in a bare BGP.
Query parameters:
| Parameter | Meaning |
|---|---|
|
Maximum entries returned. Caller values only ever tighten; anything larger
is clamped down to the server maxima (200 classes, 400 properties — the same
numbers reported in the |
|
|
{
"repositoryId": "main",
"generatedAt": "2026-10-28T01:02:03Z",
"tripleCount": 26,
"graphScope": "union",
"prefixes": [ { "prefix": "rdfs", "namespace": "…" } ],
"classes": [
{ "iri": "http://ex/Person", "label": "Person", "comment": null,
"superClasses": ["http://ex/Agent", "http://ex/Thing"],
"instanceCount": 2 }
],
"properties": [
{ "iri": "http://ex/livesIn", "label": "lives in", "kind": "object",
"domain": "http://ex/Person", "range": "http://ex/City",
"subPropertyOf": [] }
],
"truncated": { "classes": false, "properties": false,
"instanceCounts": false },
"caps": { "classes": 200, "properties": 400 }
}
instanceCount keys are omitted entirely when counts were not computed
(never, or auto on a large store). truncated.classes /
truncated.properties are true when the server maxima clipped the list;
truncated.instanceCounts is true exactly when counts were computed and
the class list was clipped (counts then cover only the returned classes).
kind is object for owl:ObjectProperty, data for
owl:DatatypeProperty, and other otherwise — a property declared several
ways reports the strongest declaration.
Caching
The response carries the weak validator W/"q26"-style ETag derived from
the exact visible quad count: W/"q{count}" where the count spans the
default graph and every named graph. Sending it back in If-None-Match
answers 304 with the same ETag when the count is unchanged. The same
count is also what tripleCount reports — one count RPC serves both, so the
validator and the body can never disagree. Only a net-zero mutation (as many
inserts as deletes) can leave a stale 304; that is a known, benign
limitation, because the agent’s real queries always run against the live
store.
Error statuses
501 when the feature is off, 404 for an unknown repository, 400 for bad query parameters, and 500 with the JSON error body if the summary computation fails — the summary’s queries are fixed internal strings, so a failure there is a server fault, never a client one.
Computation notes
The summary is computed by a fixed set of internal SPARQL SELECTs through
the compute node’s own engine — the planner lowers the ?s a ?cls shapes
onto the TSG skip-scan RPCs and batches the facet lookups into broadcast
seek-unions, so no full-relation scan ever runs (see ARCH.md §9). Two
behaviors worth knowing as a client:
-
The class and property selection reads are bounded (
limit + 1rows, deliberately noORDER BYat the storage layer — the head scans emit in encoded-key order and sorting would materialize the whole head set). Storage-emission order picks which rows survive the bound; the returned list is then sorted on compute: classes by descending distinct-subject count then IRI, properties by IRI. -
superClasseswalksrdfs:subClassOfedges whose endpoints are IRIs only. Blank-node targets (OWL restriction axioms) are skipped — they are not class IRIs, and SPARQL query text cannot name a stored blank node, so the walk can never traverse one.