# The LLM interface, explained
> **Status:** Partial · 2026-11-04
> **Evidence:** read-only reads of osionos at groot's `develop` pin `1c1d5d84` (the AI code is
> identical to osionos `develop` `d374c21f`, per `git diff --stat` on `services/`, `docs/ai` and
> `src/features/{ai,voice,recs}`). Each cited `file:line` was opened at that pin. #275 was read
> at its merge commit `1e757c3a`. No container was run and no question was asked for this page
> **To check:** the nginx buffering and timeouts (no test covers them); the provider-error
> patterns against live providers (`upstreamError.ts:14-19`); the groot pin bump that brings in
> #275
> **Sources:** groot #155 · osionos #270, #275 · osionos `docs/adr/0007-upstream-error-codes.md`,
> `0008-quota-aware-failover.md` · [ai-stall-diagnosis-2026-10-29](ai-stall-diagnosis-2026-10-29.md)
> · [runbooks/osionos-ai](../runbooks/osionos-ai.md)
The README claims the **Complete LLM system interface** major (row 15) for text, streaming,
error handling and rate limiting. This page follows one question from the keyboard to the model
and back, so a reviewer can open each step and check that claim. Image generation exists, but it
is off by default and is not part of the claim.
Paths are in osionos (`apps/osionos/app`) unless prefixed `G` (groot). Paths under
`services/osionos-ai/` drop that prefix after the first hop that enters the service (hop 5).
---
## One question, start to finish
```mermaid
sequenceDiagram
autonumber
actor U as User
participant C as Chat UI
ChatComposer, useChatStore
participant P as Inference port
aiInferencePort, runStream
participant N as nginx :4000
/api/ai/
participant S as osionos-ai :4300
routes/runs.ts
participant R as Runner
runner.ts + Redis
participant L as Provider
gateway/resolve.ts
U->>C: Enter
C->>P: runTurn(prompt)
P->>N: POST /api/ai/runs (runId)
N->>S: proxy, buffering off
S->>R: start the run
R->>R: checkRunRateLimit (20/min per user)
alt over the limit
R-->>S: refused 429 rate_limited
S-->>P: 429 + Retry-After
P-->>C: countdown, then retry offered
else allowed
S-->>P: 200 text/event-stream
R->>L: stream call (failover chain)
loop each token
L-->>R: part
R-->>S: frame
S-->>P: id / event / data
P-->>C: append text
end
Note over S,P: ": ping" every 15 s keeps the line open
opt connection drops
P->>N: GET events, Last-Event-ID
N->>S: resume from the last id
end
S-->>P: done, or error with a stable code
P-->>C: final message or error notice
end
```
## The twelve hops
| # | Hop | file:line |
|---|---|---|
| 1 | Send (Enter or the button) | `src/widgets/chat-shell/ui/ChatComposer.tsx:204`, `:305`; slash expansion `src/widgets/chat-shell/model/useSlashPalette.ts:159-169`; `ChatShell.tsx:169-179` |
| 2 | Chat store `send` and `runTurn` | `src/features/chat/model/useChatStore.ts:373`, `:204`, `:409`; error patch `:222-232`; stop `:434` |
| 3 | Inference port | `src/features/ai/model/aiInferencePort.ts:111-157` (runId `:113`, abort → cancel `:121`) |
| 3b | Stream client and resume | `src/features/ai/model/runStream.ts:77-135`: 3 resumes after 500, 1000 and 2000 ms (`:34`, `:124`); dedup + `Last-Event-ID` (`:89-95`); 410 → `run_expired` (`:115`); a 4xx is never resumed (`:109`) |
| 3c | Transport | `src/features/ai/api/aiClient.ts:118` (`/api/ai`), `:188-207`, `:330` start, `:350` resume, `:358` cancel |
| 4 | nginx | `G infrastructure/tls/nginx.conf:178-200`: upstream `osionos-ai:4300` `:179`, `proxy_buffering off` `:195`, `proxy_read_timeout 300s` `:197`. Public limit: `G infrastructure/tls/public.d/ai-public.conf:4` + `public.d/http-limits.conf:19` (2 r/s, burst 10) |
| 5 | Route table | `services/osionos-ai/src/routes/table.ts:148` (POST /runs), `:151` (events), `:157` (cancel), `:141` (/images) |
| 6 | `handleRuns` | `routes/runs.ts:178-293`: refusal + `Retry-After` `:276-280`; SSE opens `:283`; ping every 15 s `:118`; resume endpoint `:295-324` |
| 7 | Runner and per-user limit | `runs/runner.ts:205`; `checkRunRateLimit` `:260-264`; `orderByRest` `:388`; `reserveTarget` `:397` |
| 8 | Failover loop | `runs/upstream.ts:241`, `:248` (`maxRetries` 0 when a chain exists), `:274-289` move on, catch `:362-390`; `chain.ts:26`, `cooldown.ts:99,172`, `moveOn.ts:32-66`, `attempt.ts:49` (3 attempts) |
| 9 | Stream pump | `runs/streamPump.ts:232-252`; timeouts 30 s idle and 120 s total (`:25`, `:27`) |
| 10 | Provider | `gateway/resolve.ts:124-140` (anthropic, google, openai-compatible), `:176`; `gateway/platform.ts:28-32`; `connections/providers.ts:13,16` |
| 11 | SSE framing | `http.ts:108-129` (`x-accel-buffering: no`, id/event/data, `: ping`) |
| 12 | Frame dispatch → UI error | `src/features/chat/model/runEventDispatch.ts:204-214`; `ChatThread.tsx:278-285`; `src/features/chat/model/runErrorNotice.ts:42-59`; `src/features/chat/model/runErrors.ts:79-124` |
**Why the hops are shaped this way.** The browser never talks to a provider (hops 4–10 run on
the server), so a provider key never reaches the client. The answer streams over SSE instead
of one long response, and nginx must not buffer it (hop 4, hop 11). A dropped connection resumes
from the last event id instead of starting the question again (hop 3b). And every failure ends
as a stable code the UI can name (hop 12).
---
## Subject bullet → code → tests → CI
The CI jobs are in osionos `.github/workflows/ci.yml`. "AI service" is the job
`AI service (tsc + suite)` (`:314`), which runs `services/osionos-ai/scripts/ci.sh`. "Canvas" is
the `Canvas unit tests` step of `quality (root)` (`:285`).
| Subject bullet | Main files | Tests that prove it | CI job | What is not proven |
|---|---|---|---|---|
| Generate text | hops 1–12 | `tests/canvas/ai-chat-store.test.ts`, `ai-run-dispatch.test.ts`; `services/osionos-ai/tests/runs.test.ts`, `complete.test.ts` | Canvas; AI service | — |
| Streaming | `runStream.ts`, `aiInferencePort.ts`, `routes/runs.ts:283-324`, `http.ts:108-129` | `tests/canvas/ai-run-stream.test.ts`; `runs.test.ts`; `failover.test.ts` | Canvas; AI service | nginx buffering and timeouts; the ping and the idle/total timeouts on the happy path |
| Error handling | `runs/upstreamError.ts`, `upstream.ts:362-390`, `failover.ts`, `routes/complete.ts`; client `runErrors.ts:79-124` | `tests/upstreamError.test.ts`, `upstreamErrorRuns.test.ts`, `upstreamErrorLeak.test.ts`; `tests/canvas/ai-run-dispatch.test.ts` | AI service; Canvas | the UI shows one sentence for all the new codes (`runErrors.ts:72`, `:87-90`); the patterns were not checked against live providers |
| Rate limiting | `src/rateLimit.ts:62-84` (fixed 60 s window, Redis `INCR` + `EXPIRE NX`), `OSIONOS_AI_RUNS_PER_MINUTE` default 20 (`config.ts:48`), used at `runner.ts:260`; nginx `public_ai`; UI countdown `runErrorNotice.ts` | `tests/runRateLimit.test.ts`, `approvalRateLimit.test.ts`; `tests/canvas/ai-run-stream.test.ts` | AI service; Canvas | no direct unit test of `rateLimit.ts`; a fixed window lets up to twice the limit through at a boundary (its own caveat, `rateLimit.ts:6-9`) |
| Generate images (**not claimed**) | `routes/images.ts:141-282`, `images/engine.ts`, `promptGate.ts`, `outputCheck.ts`; client `imageRun.ts`, `AiImageJobView.tsx` | `tests/images.test.ts`, `imagesOff`, `imagesOutputCheck`, `imagesPromptGate`; `tests/canvas/ai-image-run.test.ts` | AI service; Canvas | off by default (404 unless `imagesEnabled`, `images.ts:144`); CI uses a mock engine only |
**No groot workflow builds `--profile ai`.** The tests above run in osionos CI against the
service alone. Nothing in groot CI starts the composed stack with the AI profile.
---
## What #270 and #275 added
**osionos #270: stable codes for provider failures** (merged 2026-11-03, on develop as
`553f2afc`, **in** groot's pin). Before it, every provider failure reached the browser as
`upstream_error`, so a 429, a daily cap and an oversized prompt looked the same.
- `runs/upstreamError.ts` maps a failure to `rate_limited`, `quota_exhausted`,
`provider_unavailable`, `context_too_long` or `upstream_error`. The error frame carries the
code, and `ai_runs.error` records `code[:status[:type]]`.
- Provider bodies and prompts are kept out of the service logs (`upstreamErrorLeak.test.ts`).
- The client reads the new codes (`runErrors.ts:88-90`), but still shows them as one sentence.
- Decision record: `docs/adr/0007-upstream-error-codes.md`.
**osionos #275: failover that knows the free quota is shared** (merged 2026-11-04 by LESdylan,
on develop as `1e757c3a`, **not yet in groot's pin `1c1d5d84`**). Every public user spends one
OpenRouter key on `:free` routes, and the SDK retried each step twice, so one question could
spend 15 calls.
- The SDK never retries (`maxRetries: 0`). The chain is the retry, and a run with one route
retries once on a 5xx or a network failure.
- `runs/quota.ts`: one Redis key rests every free route for everyone, for up to 300 s. While it
rests, a run whose routes are all free is refused with 429 and `Retry-After`.
- A run makes at most 7 provider calls (14 in research mode).
- Tests: `tests/quota.test.ts`, `tests/quotaFailover.test.ts`. Decision record:
`docs/adr/0008-quota-aware-failover.md`.
- It does not touch `rateLimit.ts`, the per-user limit.
---
See also: [README Modules](../../README.md#modules) · [02-modules](02-modules.md) ·
[ai-stall-diagnosis-2026-10-29](ai-stall-diagnosis-2026-10-29.md) ·
[runbooks/osionos-ai](../runbooks/osionos-ai.md)