# Workers AI — Observable Routing Architecture

An empirical definition of the Workers AI request-routing protocol, derived
from live probes against `POST /accounts/{id}/ai/run/{model}` (account
`0bb029b6...`, wrangler OAuth token). Every claim below was reproduced by a
probe before being written down. Raw results: `probe/results.jsonl`,
`probe/tasks*.ts`.

## 1. The entrypoint is a single, task-agnostic endpoint

One URL serves every model and every task family:

```
POST https://api.cloudflare.com/client/v4/accounts/{account_id}/ai/run/{model_name}
Authorization: Bearer <token>
Content-Type: application/json
```

The `{model_name}` is a ROUTE PATH, not a query value. It must be appended
verbatim (`@cf/moonshotai/kimi-k2.6`). Percent-encoding `/` → `%2F` breaks
matching and returns `400 No route for that URI`.

## 2. Routing is (a) name→canonical→effective, (b) task-family→input schema

### 2a. Name routing (Text Generation observed)

The platform resolves a requested name through three tiers:

1. **Canonical catalog name** — matches the current catalog entry, serves
   that model (sometimes under an effective version id).
2. **Alias / redirect** — an old or shorthand name is rewritten to a newer
   effective model id (still HTTP 200).
3. **Deprecated / unknown name** — `410` with an explicit deprecation error
   (deprecated), or `400 No route for that URI` (never existed / no alias).

Observed redirects (requested → effective `result.model`):

| Requested | Effective | Evidence |
|---|---|---|
| `@cf/meta/llama-3.1-8b-instruct` | `@cf/meta/llama-3.1-8b-fast-v2` | 200 |
| `@cf/meta/llama-3.2-3b-instruct` | `@cf/meta/llama-3.2-3b-instruct-v2` | 200 |
| `@cf/meta/llama-3.3-70b-instruct-fp8-fast` | `@cf/meta/llama-3.3-70b-instruct-sd` | 200 |
| `@cf/google/gemma-2b-it-lora` | `@cf/google/gemma-2b-v2` | 200 |
| `@cf/mistralai/mistral-small-3.1-24b-instruct` | `@cf/mistralai/mistral-small-3.1-24b-v2` | 200 |

Deprecated (HTTP 410):

- `@cf/meta/llama-3-8b-instruct` — "deprecated on 2026-05-30".

Unknown (HTTP 400 `No route for that URI`):

- `@cf/meta/llama-3.2-1b-instruct-awq`
- `@cf/mistral/mistral-7b-instruct-v0.2`
- `@cf/deepseek-ai/deepseek-r1-distill-qwen-32b-awq`
- `@cf/moonshotai/kimi-k2`
- `@cf/qwen/qwen3-30b-a3b`

### 2b. Task-family routing (input schema enforcement)

The same endpoint dispatches to a task-specific validator + backend based on
the model's registered task family. Wrong-shape input is rejected BEFORE the
model runs, with a schema error naming the missing/extra fields.

Observed task families and their input/output shapes:

| Task | Input | Output `result` shape |
|---|---|---|
| Text Generation | `{messages:[{role,content}]}` or `{prompt}` | `response` and/or `choices[]` |
| Text Embeddings | `{text}` | `{meta, data, response, shape, pooling}` |
| Translation | `{text, source_lang, target_lang}` | `{translated_text, usage}` |
| Text-to-Image | `{prompt, ...}` | `{image, usage}` |
| Image Classification | `{image:[bytes]}` | (rejected: image must be ≥4×4) |
| Image-to-Text | `{image:[bytes], prompt}` | (llava: 120s timeout on tiny input) |
| Text Classification | `{query, contexts}` | (reranker) |
| Text-to-Speech | `{text}` | audio bytes (binary body) |
| ASR | `{audio:[...]}` | `{text}` (whisper) |

## 3. The Text Generation result envelope has TWO families

Observation: `result` is NOT uniform. It has two mutually-exclusive shapes:

**Shape A — OpenAI-style `choices[]`** (newer / reasoning / vision models):
```json
{ "choices": [{ "finish_reason": "stop", "message": {
    "content": "OK", "reasoning_content": "...", "role": "assistant" } }],
  "model": "@cf/...", "object": "chat.completion", "usage": {...} }
```

**Shape B — legacy `response` string** (older / lora models):
```json
{ "response": "OK", "usage": {...} }
```

Some models return BOTH (`response+choices`) for backward compatibility.
Reasoning models put the chain-of-thought in `message.reasoning_content` and
the final answer in `message.content`. `finish_reason: length` means the
output budget was exhausted (for reasoning models, often before `content` was
written — never cap `max_tokens`).

Text-generation models and their observed shape family:

| Model | Shape | Reasoning | Vision |
|---|---|---|---|
| `@cf/deepseek-ai/deepseek-v4-{flash,pro}` | choices | yes | no |
| `@cf/moonshotai/kimi-k2.6` | choices | yes | yes |
| `@cf/moonshotai/kimi-k2.7-code` | choices | yes | yes |
| `@cf/qwen/qwen3.8-27b` | choices | no | yes |
| `@cf/qwen/qwen3-30b-a3b-fp8` | response+choices | yes | no |
| `@cf/meta/llama-4-scout-17b-16e-instruct` | response+choices | no | yes |
| `@cf/meta/llama-3.2-3b-instruct` | response+choices | no | no |
| `@cf/mistralai/mistral-small-3.1-24b-instruct` | response+choices | no | yes |
| `@cf/google/gemma-4-26b-a4b-it` | choices | no | no |
| `@cf/zai-org/glm-{4.7-flash,5.2}` | choices | yes | no |
| `@cf/nvidia/nemotron-3-120b-a12b` | choices | no | no |
| `@cf/aisingapore/gemma-sea-lion-v4-27b-it` | choices | no | no |
| `@cf/deepseek-ai/deepseek-r1-distill-qwen-32b` | response (think tags) | no | no |
| `@cf/qwen/qwen2.5-coder-32b-instruct` | response | no | no |
| `@cf/qwen/qwq-32b` | response | no | no |
| `@cf/meta/llama-3.1-8b-instruct-fp8` | response | no | no |
| `@cf/meta/llama-guard-3-8b` | response | no | no |
| `@cf/google/gemma-7b-it-lora` | response | no | no |
| `@cf/meta-llama/llama-2-7b-chat-hf-lora` | response | no | no |
| `@cf/mistral/mistral-7b-instruct-v0.2-lora` | response+choices | no | no |

## 4. Vision routing is per-model, not a separate endpoint

Vision-capable Text Generation models accept OpenAI multimodal content in
the SAME `/ai/run/{model}` call:

```json
{ "messages": [{ "role": "user", "content": [
    { "type": "text", "text": "..." },
    { "type": "image_url", "image_url": { "url": "data:image/png;base64,..." } } ] }] }
```

Text-only models REJECT the array content with a schema error (`Type
mismatch of '/messages/0/content'`), proving the routing decision is made by
the task-family validator before the model runs — NOT by the model.

Vision-capable model observed results (all accepted the 1×1 image):

- `@cf/moonshotai/kimi-k2.6` — 200, `choices`, reasoning, answers color.
- `@cf/moonshotai/kimi-k2.7-code` — 200, `choices`, reasoning.
- `@cf/meta/llama-4-scout-17b-16e-instruct` — 200, `response+choices`.
- `@cf/mistralai/mistral-small-3.1-24b-instruct` → effective `...-v2` — 200.
- `@cf/qwen/qwen3.8-27b` — 200, `choices`, slow (13 s).
- `@cf/meta/llama-3.2-11b-vision-instruct` — 403 **gated**: requires
  submitting the prompt `'agree'` to accept a community license first.

Text-only controls (rejected image content):

- `@cf/meta/llama-3.1-8b-instruct-fp8` — 400 schema error.
- `@cf/meta/llama-3.2-3b-instruct` — 400 schema error.

## 5. Gating and errors are part of the protocol

- **Model agreements** (HTTP 403): some models (e.g. llama-3.2-11b-vision)
  require an opt-in prompt before first use. The error body is a stable
  `AiError` with an instruction + a UUID correlation id.
- **Deprecation** (HTTP 410): carries the deprecation date + a catalog link.
- **Unknown** (HTTP 400): `No route for that URI`.
- **Validation** (HTTP 400): `AiError: Bad input` + a JSON-schema-style
  description of what was wrong (`required properties at '/' are ...`).
- **Model runtime** (HTTP 4xx/5xx): `AiError: Tensor error ...`, `Request
  timeout` (408), `Invalid or incomplete input for the model`.

## 6. The observable routing model (summary)

```
request(model, body)
  -> name router: alias? → rewrite; deprecated? → 410; unknown? → 400
  -> task-family dispatch (from model's registered task type)
       Text Generation  -> OpenAI messages OR legacy prompt
       Embeddings       -> {text}
       Translation      -> {text, source_lang, target_lang}
       Text-to-Image    -> {prompt}
       Text-to-Speech   -> {text}
       ASR              -> {audio}
       Image-to-Text    -> {image, prompt}
       Image/Text Class -> family-specific
  -> gate check (license agreement) → 403 if not accepted
  -> model inference
  -> result envelope: task-family-specific (choices[]/response/data/image/...)
```

**Key invariants to rely on:**

1. The effective model id is in `result.model` (not the requested name).
2. Text Generation output is `choices[].message.content` (modern) and/or
   `response` (legacy) — always check both.
3. Reasoning models separate `reasoning_content` from `content`; only
   `content` is the answer.
4. Vision capability is a validator-level fact: text-only models reject
   array content with a schema error before inference.
5. Never cap `max_tokens` for reasoning/vision models or you get empty
   `content` with `finish_reason: length`.

