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 max_request_body_bytes (checked while streaming, not just via Content-Length).

502

Connect or TLS failure — the upstream origin is unreachable.

504

The whole-request deadline (request_timeout_secs) expired before response headers arrived.

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

limit=<classes>,<properties>

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 caps field). Whitespace around each half is tolerated.

instanceCounts=<mode>

auto (default) computes per-class counts only when tripleCount is at most 2,000,000; always computes them regardless; never omits them.

{
  "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 + 1 rows, deliberately no ORDER BY at 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.

  • superClasses walks rdfs:subClassOf edges 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.