325 lines
12 KiB
Markdown
325 lines
12 KiB
Markdown
# API — LLM surfaces
|
|||
|
|
|
||
|
|
Two protocol dialects over the same models and the same slot controller.
|
||
|
|
|
||
|
|
| Prefix | Dialect | Endpoint | Client |
|
||
|
|
|---|---|---|---|
|
||
|
|
| `/v1` | OpenAI-compatible | `POST /v1/chat/completions` | pi, OpenAI SDKs |
|
||
|
|
| `/llm` | Anthropic Messages | `POST /llm/v1/messages` | riotpiao frontend (first-party) |
|
||
|
|
|
||
|
|
Status marks below:
|
||
|
|
**[LIVE]** verified against the running cluster on 2026-08-19.
|
||
|
|
**[SPEC]** the contract this gateway must implement; not built yet.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Models
|
||
|
|
|
||
|
|
| `model` value | Upstream | Engine | Context | Notes |
|
||
|
|
|---|---|---|---|---|
|
||
|
|
| `reasoning` | `reasoning-predictor.llm-serving:80` | vLLM, DeepSeek-R1-Distill-Qwen-32B | 16384 | emits `reasoning_content`; 8 sequence slots total |
|
||
|
|
| `ornith:35b` | `ornith-predictor.llm-serving:80` | Ollama | 131072 | reliable tool calling |
|
||
|
|
| `qwen2.5:3b-instruct` | `ornith-predictor.llm-serving:80` | Ollama | 32768 | same pods as ornith |
|
||
|
|
| `nomic-ai/nomic-embed-text-v2-moe` | `embeddings-predictor.llm-serving:80` | TEI | — | embeddings only |
|
||
|
|
| `BAAI/bge-reranker-base` | `reranker-predictor.llm-serving:80` | TEI | — | rerank only |
|
||
|
|
|
||
|
|
`reasoning` runs 2 replicas x `--max-num-seqs=4`. Those **8 slots are the scarcest resource in the cluster** and are shared across both dialects.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Authentication [SPEC]
|
||
|
|
|
||
|
|
Ships behind a flag, default off. The model API is unauthenticated today.
|
||
|
|
|
||
|
|
```
|
||
|
|
Authorization: Bearer <authentik-jwt>
|
||
|
|
```
|
||
|
|
|
||
|
|
### Decided — Bearer on both surfaces
|
||
|
|
|
||
|
|
`Authorization: Bearer <jwt>` is the only accepted credential, on `/v1` and `/llm` alike. One auth path, consistent with G5, validated against Authentik via JWKS.
|
||
|
|
|
||
|
|
**Known divergence from Anthropic:** the real Anthropic API authenticates with `x-api-key` and requires `anthropic-version: 2023-06-01`. A stock Anthropic SDK pointed at `/llm` will send `x-api-key` and get a 401.
|
||
|
|
|
||
|
|
This is accepted, not overlooked. The `/llm` client is the first-party riotpiao frontend, which sends whatever we tell it to. If a real Anthropic SDK ever needs to reach this gateway, accepting `x-api-key` as a second credential source is an additive change — a small branch in one middleware, not a redesign.
|
||
|
|
|
||
|
|
`anthropic-version` is accepted and ignored if present, and never required.
|
||
|
|
|
||
|
|
The 401 for an `x-api-key`-only request must name the problem — say that Bearer is required — rather than returning a bare 401. The Kong retirement was caused by exactly this failure mode: a gateway that rejected the header clients actually send, without saying why.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## OpenAI dialect — `POST /v1/chat/completions`
|
||
|
|
|
||
|
|
### Request [SPEC]
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"model": "reasoning",
|
||
|
|
"messages": [{"role": "user", "content": "Why is wave 4 empty?"}],
|
||
|
|
"max_tokens": 2000,
|
||
|
|
"temperature": 0.7,
|
||
|
|
"stream": false
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
`model` is required and selects the upstream. The body is forwarded byte-identical — the gateway reads `model`, it does not rewrite it.
|
||
|
|
|
||
|
|
### Response, non-streaming [LIVE]
|
||
|
|
|
||
|
|
Captured verbatim from `reasoning` on 2026-08-19, abridged:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"id": "chatcmpl-f17bd2fe22e4276d24e9438e40e89cea",
|
||
|
|
"object": "chat.completion",
|
||
|
|
"created": 1787172340,
|
||
|
|
"model": "reasoning",
|
||
|
|
"choices": [{
|
||
|
|
"index": 0,
|
||
|
|
"message": {
|
||
|
|
"role": "assistant",
|
||
|
|
"content": "\n\nTo find the current weather in Toronto...",
|
||
|
|
"reasoning_content": "Okay, so I need to figure out...",
|
||
|
|
"tool_calls": []
|
||
|
|
},
|
||
|
|
"finish_reason": "length"
|
||
|
|
}],
|
||
|
|
"usage": {"prompt_tokens": 16, "completion_tokens": 300, "total_tokens": 316}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
`reasoning_content` is a **sibling of** `content`, not nested in it. This is a vLLM extension produced by `--reasoning-parser=deepseek_r1`; it is not part of the OpenAI spec. Pass it through untouched.
|
||
|
|
|
||
|
|
### The two engines disagree on the field name [LIVE]
|
||
|
|
|
||
|
|
Verified 2026-08-19 by calling both:
|
||
|
|
|
||
|
|
| Upstream | Engine | Reasoning field |
|
||
|
|
|---|---|---|
|
||
|
|
| `reasoning-predictor` | vLLM | `reasoning_content` |
|
||
|
|
| `ornith-predictor` | Ollama | `reasoning` |
|
||
|
|
|
||
|
|
Neither is in the OpenAI spec, so neither is wrong — they are two vendor extensions that
|
||
|
|
happen to mean the same thing. The gateway must recognise **both** when mapping to the
|
||
|
|
Anthropic `thinking` block, or `ornith:35b` responses will silently lose their reasoning
|
||
|
|
on the `/llm` surface.
|
||
|
|
|
||
|
|
Do not normalise them on the `/v1` surface. That surface passes bodies through
|
||
|
|
untouched, and a client asking for `ornith:35b` should get exactly what Ollama sent.
|
||
|
|
Normalisation belongs in the canonical request model (task 2.9), which is the layer that
|
||
|
|
exists to absorb precisely this kind of upstream difference.
|
||
|
|
|
||
|
|
### Response, streaming [SPEC]
|
||
|
|
|
||
|
|
```
|
||
|
|
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"reasoning_content":"Okay"},"finish_reason":null}]}
|
||
|
|
|
||
|
|
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Wave"},"finish_reason":null}]}
|
||
|
|
|
||
|
|
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
||
|
|
|
||
|
|
data: [DONE]
|
||
|
|
```
|
||
|
|
|
||
|
|
Data-only frames, no `event:` lines. Terminated by the literal `data: [DONE]`.
|
||
|
|
|
||
|
|
### Legacy aliases [LIVE, being retired]
|
||
|
|
|
||
|
|
`POST /v1/{reasoning,ornith,qwen}/chat/completions` force `model` to the corresponding value regardless of the body. They exist only because Kong could not dispatch on the body. Removed once callers migrate.
|
||
|
|
|
||
|
|
### `GET /v1/models` [SPEC]
|
||
|
|
|
||
|
|
```json
|
||
|
|
{"object":"list","data":[{"id":"reasoning","object":"model","owned_by":"homelab","created":0}]}
|
||
|
|
```
|
||
|
|
|
||
|
|
Derived from the registry, never hardcoded.
|
||
|
|
|
||
|
|
### Errors [SPEC]
|
||
|
|
|
||
|
|
RFC 9457 `application/problem+json`:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"type": "https://riotpiao.com/errors/unknown-model",
|
||
|
|
"title": "Unknown model",
|
||
|
|
"status": 400,
|
||
|
|
"detail": "\"gpt-4\" is not available",
|
||
|
|
"validModels": ["reasoning", "ornith:35b", "qwen2.5:3b-instruct"]
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Anthropic dialect — `POST /llm/v1/messages` [SPEC]
|
||
|
|
|
||
|
|
Path note: the Anthropic SDK appends `/v1/messages` to its base URL, so a base URL of `https://api.riotpiao.com/llm` produces exactly this path.
|
||
|
|
|
||
|
|
### Request
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"model": "reasoning",
|
||
|
|
"max_tokens": 2000,
|
||
|
|
"system": "You are a cluster assistant.",
|
||
|
|
"messages": [
|
||
|
|
{"role": "user", "content": "Why is wave 4 empty?"}
|
||
|
|
],
|
||
|
|
"stream": true
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Differences from the OpenAI dialect that the translator must handle:
|
||
|
|
|
||
|
|
| Concern | OpenAI | Anthropic |
|
||
|
|
|---|---|---|
|
||
|
|
| system prompt | `messages[0].role = "system"` | top-level `system` field |
|
||
|
|
| `max_tokens` | optional | **required** |
|
||
|
|
| content | string | string *or* block array |
|
||
|
|
| roles | system/user/assistant/tool | user/assistant only |
|
||
|
|
| stop | `stop` | `stop_sequences` |
|
||
|
|
|
||
|
|
`max_tokens` being required is a real divergence — the gateway must either reject its absence with a clear error or apply a documented default. Pick one and state it; do not silently default.
|
||
|
|
|
||
|
|
### Response, non-streaming
|
||
|
|
|
||
|
|
```json
|
||
|
|
{
|
||
|
|
"id": "msg_01ABC",
|
||
|
|
"type": "message",
|
||
|
|
"role": "assistant",
|
||
|
|
"model": "reasoning",
|
||
|
|
"content": [
|
||
|
|
{"type": "thinking", "thinking": "Waves are sort keys, not a sequence..."},
|
||
|
|
{"type": "text", "text": "Wave 4 is empty. Waves are sort keys..."}
|
||
|
|
],
|
||
|
|
"stop_reason": "end_turn",
|
||
|
|
"stop_sequence": null,
|
||
|
|
"usage": {"input_tokens": 16, "output_tokens": 300}
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
Field mapping from the upstream OpenAI response:
|
||
|
|
|
||
|
|
| Upstream | Anthropic |
|
||
|
|
|---|---|
|
||
|
|
| `choices[0].message.reasoning_content` | `content[]` block `{"type":"thinking","thinking":...}` |
|
||
|
|
| `choices[0].message.content` | `content[]` block `{"type":"text","text":...}` |
|
||
|
|
| `finish_reason: "stop"` | `stop_reason: "end_turn"` |
|
||
|
|
| `finish_reason: "length"` | `stop_reason: "max_tokens"` |
|
||
|
|
| `usage.prompt_tokens` | `usage.input_tokens` |
|
||
|
|
| `usage.completion_tokens` | `usage.output_tokens` |
|
||
|
|
|
||
|
|
The thinking block precedes the text block.
|
||
|
|
|
||
|
|
### Response, streaming
|
||
|
|
|
||
|
|
Anthropic SSE uses **named events with content-block indices**, unlike OpenAI's flat frames. Verified event sequence:
|
||
|
|
|
||
|
|
```
|
||
|
|
event: message_start
|
||
|
|
data: {"type":"message_start","message":{"id":"msg_01ABC","type":"message","role":"assistant","model":"reasoning","content":[],"usage":{"input_tokens":16,"output_tokens":0}}}
|
||
|
|
|
||
|
|
event: content_block_start
|
||
|
|
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}
|
||
|
|
|
||
|
|
event: content_block_delta
|
||
|
|
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Waves are sort keys"}}
|
||
|
|
|
||
|
|
event: content_block_stop
|
||
|
|
data: {"type":"content_block_stop","index":0}
|
||
|
|
|
||
|
|
event: content_block_start
|
||
|
|
data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}
|
||
|
|
|
||
|
|
event: content_block_delta
|
||
|
|
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Wave 4 is empty."}}
|
||
|
|
|
||
|
|
event: content_block_stop
|
||
|
|
data: {"type":"content_block_stop","index":1}
|
||
|
|
|
||
|
|
event: message_delta
|
||
|
|
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":300}}
|
||
|
|
|
||
|
|
event: message_stop
|
||
|
|
data: {"type":"message_stop"}
|
||
|
|
```
|
||
|
|
|
||
|
|
Three details that are easy to get wrong:
|
||
|
|
|
||
|
|
- In `message_delta`, `usage` is a **sibling of** `delta`, not inside it.
|
||
|
|
- The delta field name matches the delta type: `thinking_delta` carries `.thinking`, `text_delta` carries `.text`.
|
||
|
|
- Block index 0 is thinking, index 1 is text. **You only learn reasoning has ended when `content` first appears in an upstream chunk**, so the thinking block must be closed before the text block opens. If a response has no `reasoning_content` at all, the text block is index 0 and no thinking block is emitted.
|
||
|
|
|
||
|
|
### Queue position — non-standard extension
|
||
|
|
|
||
|
|
Anthropic's event set has no way to say "you are queued", because the stream implicitly begins after a slot is acquired. With only 8 slots, queueing is normal here.
|
||
|
|
|
||
|
|
Emitted **before** `message_start`:
|
||
|
|
|
||
|
|
```
|
||
|
|
event: queue
|
||
|
|
data: {"type":"queue","position":3}
|
||
|
|
```
|
||
|
|
|
||
|
|
This is deliberately outside the Anthropic spec. It is safe only because the client is first-party; a strict Anthropic client would ignore the unknown event and show nothing while queued.
|
||
|
|
|
||
|
|
### Errors
|
||
|
|
|
||
|
|
Anthropic error shape, **not** RFC 9457 — the same rejection renders differently depending on which surface received it:
|
||
|
|
|
||
|
|
```json
|
||
|
|
{"type":"error","error":{"type":"invalid_request_error","message":"Unknown model \"gpt-4\". Available: reasoning, ornith:35b, qwen2.5:3b-instruct"}}
|
||
|
|
```
|
||
|
|
|
||
|
|
| Condition | HTTP | `error.type` |
|
||
|
|
|---|---|---|
|
||
|
|
| unknown or missing model | 400 | `invalid_request_error` |
|
||
|
|
| `max_tokens` absent (if required) | 400 | `invalid_request_error` |
|
||
|
|
| malformed JSON | 400 | `invalid_request_error` |
|
||
|
|
| unsupported feature requested | 400 | `invalid_request_error` |
|
||
|
|
| not authenticated | 401 | `authentication_error` |
|
||
|
|
| budget exhausted or queue full | 429 | `rate_limit_error` |
|
||
|
|
| upstream failure | 502 | `api_error` |
|
||
|
|
|
||
|
|
### Deliberately not implemented
|
||
|
|
|
||
|
|
Each returns 400 naming the unsupported feature — never a silent partial implementation:
|
||
|
|
|
||
|
|
tool use and `tool_result` turns, image content blocks, prompt-caching headers, the batch API, multi-block user content, `thinking.budget_tokens` configuration.
|
||
|
|
|
||
|
|
The target client is the riotpiao frontend. Widening scope is a code change with a test, not an accident.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Shared behaviour, both dialects
|
||
|
|
|
||
|
|
**One slot controller, keyed by upstream.** A `/v1` request and a `/llm` request contend for the same 8 `reasoning` slots and the same queue, in arrival order. Per-dialect semaphores would each believe they were within budget while together exceeding the physical limit.
|
||
|
|
|
||
|
|
**Streaming is unbuffered** and a client disconnect cancels the upstream immediately. An orphaned generation holds a slot until it completes on its own, which for a 32B model on a Volta GPU can run to minutes.
|
||
|
|
|
||
|
|
**Timeouts** [LIVE]: chat routes are connect 10s / read 1h / write 1h. The hour is deliberate — a 32B model on this hardware routinely exceeds 60s. Any shorter application cap is enforced in gateway logic, never by shortening the proxy timeout.
|
||
|
|
|
||
|
|
**Tool calling** [LIVE]: `reasoning` honours an explicit `tool_choice` but returns `tool_calls: []` under `tool_choice: "auto"` — it reasons about the tool in prose instead. `ornith:35b` returns `finish_reason: "tool_calls"` correctly under `auto`. This is a model property; the gateway does not compensate for it.
|
||
|
|
|
||
|
|
---
|
||
|
|
|
||
|
|
## Examples
|
||
|
|
|
||
|
|
```bash
|
||
|
|
# OpenAI dialect
|
||
|
|
curl -s https://api.riotpiao.com/v1/chat/completions \
|
||
|
|
-H 'content-type: application/json' \
|
||
|
|
-d '{"model":"reasoning","messages":[{"role":"user","content":"Why is wave 4 empty?"}],"max_tokens":500}'
|
||
|
|
|
||
|
|
# Anthropic dialect, streaming
|
||
|
|
curl -N -s https://api.riotpiao.com/llm/v1/messages \
|
||
|
|
-H 'content-type: application/json' \
|
||
|
|
-d '{"model":"reasoning","max_tokens":500,"stream":true,
|
||
|
|
"messages":[{"role":"user","content":"Why is wave 4 empty?"}]}'
|
||
|
|
|
||
|
|
# model list
|
||
|
|
curl -s https://api.riotpiao.com/v1/models
|
||
|
|
```
|