v0.2.0 — five more providers: Mistral, DeepSeek, Kimi (Moonshot), OpenRouter, Cerebras
OpenAI-compatible adapter factory with per-provider body translation, live model listings
(Mistral capabilities, Kimi /models/{id}, OpenRouter /models/user), audited catalogs and
pricing, reasoning controls per provider, json_object fallback, real-API matrix 13x9 green.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
121 changed files +13,057 −89
modified
.env.example
+5 −0
@@ -27,6 +27,11 @@ OPENAI_API_KEY= | ||
| 27 | 27 | ANTHROPIC_API_KEY= |
| 28 | 28 | GOOGLE_GEMINI_API_KEY= |
| 29 | 29 | XAI_API_KEY= |
| 30 | +MISTRAL_API_KEY= | |
| 31 | +DEEPSEEK_API_KEY= | |
| 32 | +KIMI_API_KEY= | |
| 33 | +OPENROUTER_API_KEY= | |
| 34 | +CEREBRAS_API_KEY= | |
| 30 | 35 | |
| 31 | 36 | # --- Admin --------------------------------------------------------------- |
| 32 | 37 | # Comma-separated emails with access to /admin. |
modified
CLAUDE.md
+7 −2
@@ -1,7 +1,7 @@ | ||
| 1 | 1 | # PolyLLM — repository guide |
| 2 | 2 | |
| 3 | −PolyLLM (www.polyllm.io) is a BYOK multi-provider AI workspace: OpenAI, Anthropic, Gemini, xAI behind one normalized | |
| 4 | −adapter layer, real streaming, capability-driven configuration, Arena comparisons, usage/cost tracking. | |
| 3 | +PolyLLM (www.polyllm.io) is a BYOK multi-provider AI workspace: OpenAI, Anthropic, Gemini, xAI, Mistral, DeepSeek, Kimi, | |
| 4 | +OpenRouter and Cerebras behind one normalized adapter layer (`createOpenAICompatAdapter` for the OpenAI-shaped ones), real streaming, capability-driven configuration, Arena comparisons, usage/cost tracking. | |
| 5 | 5 | The original product brief lives in `docs/SPEC-original.md`; the UI conventions in `docs/UI-BRIEF.md`. |
| 6 | 6 | |
| 7 | 7 | ## Non-negotiables |
@@ -36,3 +36,8 @@ Manifest with secrets: `M1M32:~/dispatch/apps/polyllm.json` (local gitignored co | ||
| 36 | 36 | thought signatures must be replayed on function-call parts. |
| 37 | 37 | - xAI: bad keys return HTTP **400** (`Incorrect API key`), `frequency/presence_penalty` always 400, web search only via `/v1/responses`. |
| 38 | 38 | - OpenAI: Responses API has no `seed`/`stop`; sampling params only when `reasoning.effort === "none"` on reasoning models. |
| 39 | +- Mistral: strict 422 on unknown keys (`random_seed`, `max_tokens`, no `developer` role); reasoning is binary `reasoning_effort` none|high and streams as `thinking` content-array chunks; `magistral-*` are aliases. | |
| 40 | +- DeepSeek: thinking ON by default (`thinking:{type}` + `reasoning_effort` low|high|max); only `json_object`; `max_tokens` caps reasoning + answer; images silently dropped except on the vision model. | |
| 41 | +- Kimi: temperature must be 1 / top_p 0.95 (400 otherwise); `/v1/models/{id}` gives capabilities; `reasoning_content` replayed; json_schema not enforced (json_object + prompt). | |
| 42 | +- OpenRouter: unsupported params are silently dropped — gate on `supported_parameters`; `usage.cost` is exact; `:batch` ids 404 on chat; finish chunk arrives twice. | |
| 43 | +- Cerebras: unknown params → 400 (whitelist); reasoning via `reasoning_effort` only; `tools`+`response_format` → 400 on gpt-oss; strict schema needs `additionalProperties:false`. | |
modified
README.md
+3 −3
@@ -2,7 +2,7 @@ | ||
| 2 | 2 | |
| 3 | 3 | **One interface. Every model. Bring your own keys.** — https://www.polyllm.io |
| 4 | 4 | |
| 5 | −PolyLLM is a universal control center for AI models: connect your own OpenAI, Anthropic, Google Gemini and xAI API keys, | |
| 5 | +PolyLLM is a universal control center for AI models: connect your own OpenAI, Anthropic, Google Gemini, xAI, Mistral, DeepSeek, Kimi (Moonshot), OpenRouter and Cerebras API keys, | |
| 6 | 6 | discover the models your keys can reach, configure each one with only the parameters it supports, chat with real |
| 7 | 7 | token-by-token streaming, compare up to four models side by side in the Arena, and track tokens, latency and estimated |
| 8 | 8 | cost. Keys are encrypted at rest (AES-256-GCM, HKDF-derived data key, AAD-bound to the user) and only decrypted on the |
@@ -11,13 +11,13 @@ server immediately before a provider request. | ||
| 11 | 11 | ## Stack |
| 12 | 12 | Next.js 16 (App Router, Route Handlers with SSE streaming) · React 19 · TypeScript · Tailwind v4 · PostgreSQL 17 + Drizzle · |
| 13 | 13 | Better Auth 1.7 (Argon2id, email verification, sessions, reset, change email, delete account) · Resend · Zod · |
| 14 | −official provider SDKs (`openai`, `@anthropic-ai/sdk`, `@google/genai`; xAI through the OpenAI SDK at `api.x.ai`). | |
| 14 | +official provider SDKs (`openai`, `@anthropic-ai/sdk`, `@google/genai`); xAI, Mistral, DeepSeek, Kimi, OpenRouter and Cerebras through the OpenAI SDK pointed at their endpoints with per-provider body translation (`providers/shared/openai-compat`). | |
| 15 | 15 | |
| 16 | 16 | ## Layout |
| 17 | 17 | ``` |
| 18 | 18 | src/app routes: (marketing) landing/legal · (auth) login/signup/verify/reset · app/* workspace · admin/* · share/[id] · api/* |
| 19 | 19 | src/lib/ai/core provider-neutral contract: PolyModel, UnifiedChatRequest, UnifiedStreamEvent, errors, retry, pricing, normalization |
| 20 | −src/lib/ai/providers adapters (openai, anthropic, gemini, xai) + catalogs — provider quirks live ONLY here | |
| 20 | +src/lib/ai/providers adapters (openai, anthropic, gemini, xai, mistral, deepseek, kimi, openrouter, cerebras) + catalogs — provider quirks live ONLY here | |
| 21 | 21 | src/lib/ai/registry model registry: live listing → catalog merge → Postgres; scheduled + manual sync |
| 22 | 22 | src/lib/chat turn orchestration (edit/regenerate/continue/branch), built-in tools loop, SSE |
| 23 | 23 | src/lib/providers encrypted key storage & validation |
modified
docs/provider-research.md
+11 −5
@@ -1,6 +1,6 @@ | ||
| 1 | 1 | # Provider research — index |
| 2 | 2 | |
| 3 | −**Last documentation audit: 2026-09-08** | |
| 3 | +**Last documentation audit: 2026-09-08** (all nine providers) | |
| 4 | 4 | |
| 5 | 5 | PolyLLM never relies on remembered API shapes. Before each adapter was written, the CURRENT official |
| 6 | 6 | documentation was fetched and every claim that matters for the adapter was **probed against the real API** |
@@ -12,15 +12,21 @@ scripts that produced the evidence. | ||
| 12 | 12 | | Provider | Report | Model data | SDK used | Primary API | Probe scripts | |
| 13 | 13 | | --- | --- | --- | --- | --- | --- | |
| 14 | 14 | | OpenAI | [openai.md](provider-research/openai.md) | [openai.models.json](provider-research/openai.models.json) (84) | `openai` 7.10 | Responses API (`store:false`, encrypted reasoning replay) | `research/openai/` | |
| 15 | −| Anthropic | [anthropic.md](provider-research/anthropic.md) | Models API (live capabilities) + `src/lib/ai/providers/anthropic/catalog.ts` (pricing) | `@anthropic-ai/sdk` 0.124 | Messages API (adaptive thinking, `output_config`) | `scripts/provider-matrix.ts` | | |
| 15 | +| Anthropic | [anthropic.md](provider-research/anthropic.md) | Models API (live capabilities) + `providers/anthropic/catalog.ts` (pricing) | `@anthropic-ai/sdk` 0.124 | Messages API (adaptive thinking, `output_config`) | `scripts/provider-matrix.ts` | | |
| 16 | 16 | | Google Gemini | [gemini.md](provider-research/gemini.md) | [gemini.models.json](provider-research/gemini.models.json) (19) | `@google/genai` 2.21 | `generateContentStream` (thinkingLevel/budget per family, thought signatures) | `research/gemini/` | |
| 17 | −| xAI | [xai.md](provider-research/xai.md) | [xai.models.json](provider-research/xai.models.json) (7) | `openai` 7.10 with `baseURL https://api.x.ai/v1` | Chat Completions (+ `/v1/responses` for server-side web search) | `research/xai/` | | |
| 17 | +| xAI | [xai.md](provider-research/xai.md) | [xai.models.json](provider-research/xai.models.json) (7) | `openai` 7.10 @ `api.x.ai/v1` | Chat Completions (+ `/v1/responses` for server-side web search) | `research/xai/` | | |
| 18 | +| Mistral AI | [mistral.md](provider-research/mistral.md) | [mistral.models.json](provider-research/mistral.models.json) (10) + live `/v1/models` capabilities | `openai` 7.10 @ `api.mistral.ai/v1` (bodies translated: `random_seed`, `max_tokens`, `reasoning_effort` none/high, `document_url`) | Chat Completions | `research/mistral/` | | |
| 19 | +| DeepSeek | [deepseek.md](provider-research/deepseek.md) | [deepseek.models.json](provider-research/deepseek.models.json) (3) | `openai` 7.10 @ `api.deepseek.com/v1` | Chat Completions (`thinking` + `reasoning_effort` low/high/max, `json_object` only) | `research/deepseek/` | | |
| 20 | +| Kimi (Moonshot AI) | [kimi.md](provider-research/kimi.md) | [kimi.models.json](provider-research/kimi.models.json) (4) + live `/v1/models/{id}` | `openai` 7.10 @ `api.moonshot.ai/v1` | Chat Completions (`thinking`, `reasoning_effort` low/high/max, temperature fixed at 1) | `research/kimi/` | | |
| 21 | +| OpenRouter | [openrouter.md](provider-research/openrouter.md) | [openrouter.models.json](provider-research/openrouter.models.json) (29 representative) — registry built live from `/api/v1/models/user` (~400) | `openai` 7.10 @ `openrouter.ai/api/v1` | Chat Completions (`reasoning{effort|max_tokens}`, `usage.cost`, `plugins:[web]`) | `research/openrouter/` | | |
| 22 | +| Cerebras | [cerebras.md](provider-research/cerebras.md) | [cerebras.models.json](provider-research/cerebras.models.json) (3) | `openai` 7.10 @ `api.cerebras.ai/v1` | Chat Completions (strict validation, `reasoning_effort`, `delta.reasoning`) | `research/cerebras/` | | |
| 18 | 23 | |
| 19 | 24 | The verified behaviour is encoded in code, not prose: |
| 20 | 25 | |
| 21 | 26 | - `src/lib/ai/providers/<provider>/catalog*.ts` — capability sheets, parameter support, pricing, lifecycle. |
| 22 | −- `src/lib/ai/providers/<provider>/index.ts` — request translation that only sends what the model accepts. | |
| 23 | −- `docs/provider-test-matrix.md` — generated by `pnpm providers:matrix` from live requests. | |
| 27 | +- `src/lib/ai/providers/<provider>/index.ts` — request translation that only sends what the model accepts; | |
| 28 | + the OpenAI-compatible providers share `providers/shared/openai-compat/{factory,chat-completions}.ts`. | |
| 29 | +- `docs/provider-test-matrix.md` — generated by `pnpm providers:matrix` from live requests (13 capabilities × 9 providers). | |
| 24 | 30 | |
| 25 | 31 | ## Re-audit procedure (before every release) |
| 26 | 32 | |
added
docs/provider-research/cerebras.md
+271 −0
@@ -0,0 +1,271 @@ | ||
| 1 | +# Cerebras Inference — provider research for PolyLLM | |
| 2 | + | |
| 3 | +Last documentation audit: **2026-09-08** | |
| 4 | +Probes executed 2026-09-08 with a real **Developer-tier (pay-as-you-go)** key against `https://api.cerebras.ai/v1` (scripts in `research/cerebras/`, raw outputs in `research/cerebras/out/`). Everything marked **(probed)** was observed live; everything marked **(docs)** comes from the pages listed at the end. | |
| 5 | + | |
| 6 | +> Heads-up: `GET /v1/models` for this key returns **3 models**: `gpt-oss-120b`, `qwen-3.8-27b`, `gemma-4-31b`. The changelog and deprecation page say **`gemma-4-31b` was removed from public endpoints on 2026-09-03** ("remains on dedicated endpoints"), its model page is 404 and it has no public price — yet it is still listed and **served normally for this key (all probes 200)**. Treat it as *grandfathered / may disappear without notice*; do not make it a default. | |
| 7 | + | |
| 8 | +--- | |
| 9 | + | |
| 10 | +## 1. Base URL, auth, headers | |
| 11 | + | |
| 12 | +| Item | Value | | |
| 13 | +|---|---| | |
| 14 | +| REST base URL | `https://api.cerebras.ai/v1` (docs + probed). Behind Cloudflare (`cf-ray`, `server: cloudflare`). | | |
| 15 | +| Auth | `Authorization: Bearer <CEREBRAS_API_KEY>` (keys start with `csk-`). | | |
| 16 | +| Content type | `application/json`. Malformed JSON → **400** `{"message":": Invalid JSON: key must be a string at line 1 column 2","type":"invalid_request_error","param":"validation_error","code":"wrong_api_format"}` **(probed)**. Docs: payload optimisation supports msgpack + gzip (not needed). | | |
| 17 | +| API version header | `X-Cerebras-Version-Patch: 2` (docs). **v2 is the default since 2026-07-22**; sending `1` → **400** `API version 1 is not supported by this model (minimum supported version 2).` **(probed)**. Don't send the header. | | |
| 18 | +| Response headers **(probed, inference calls only, not on `/models`)** | `x-request-id`, and **per-model, per-window** rate limits: `x-ratelimit-limit-requests-{minute,hour,day}`, `x-ratelimit-limit-tokens-{minute,hour,day}`, `x-ratelimit-remaining-requests-{minute,hour,day}`, `x-ratelimit-remaining-tokens-{minute,hour,day}`. 429 adds `retry-after: 60`. Observed limits for this Developer key: gpt-oss-120b **1000 RPM / 500 000 TPM** (60k RPH, 1.44M RPD, 30M TPH, 720M TPD); gemma-4-31b **500 RPM / 250 000 TPM**; qwen-3.8-27b **450 RPM / 150 000 TPM** (docs say 300 RPM / 150K uncached TPM — headers show the *uncached* bucket as `limit` and the *total* bucket in `remaining`, e.g. `remaining-tokens-hour 89 999 979` > `limit-tokens-hour 30 000 000`). | | |
| 19 | +| Key introspection | None. Cheapest "validate key" call is `GET /v1/models` (200 vs **401**) **(probed)**. | | |
| 20 | + | |
| 21 | +## 2. SDK recommendation (TypeScript / Node) | |
| 22 | + | |
| 23 | +- Official TS SDK **`@cerebras/cerebras_cloud_sdk@1.91.0`** (Stainless-generated, Node ≥ 18, TS ≥ 4.5). Options `apiKey`, `baseURL`, `timeout` (default 1 min), `maxRetries` (default 2; retries 408/429/≥500 + connection errors), `warmTCPConnection` (opens a TCP connection at construction — set `false` in a server). Errors: `APIError` subclasses `BadRequestError` 400, `AuthenticationError` 401, `PermissionDeniedError` 403, `NotFoundError` 404, `RateLimitError` 429, `InternalServerError` ≥500, `APIConnectionError`. **(probed: invalid key → `AuthenticationError`, `.status 401`, `.message "401 Wrong API Key"`)**. Its types know `reasoning`, `time_info`, `reasoning_effort`, `reasoning_format`, `clear_thinking`. | |
| 24 | +- **OpenAI SDK also works unchanged** (`openai@7.10.0` probed) with `baseURL: "https://api.cerebras.ai/v1"`: streaming, `delta.reasoning`, final usage chunk, tools, `response_format` all fine. Cerebras-only params (`reasoning_format`, `clear_thinking`) must go through `extra_body`/a cast (docs). | |
| 25 | +- **Recommendation for PolyLLM:** reuse the OpenAI-compatible chat-completions path (same as xAI) with `baseURL` swapped and a Cerebras translation layer; `maxRetries: 0` (we do our own backoff), `timeout` 120 s is plenty (fastest provider we have: 1 200–2 600 tok/s). No need to add the Cerebras SDK unless we want its typed `time_info`. | |
| 26 | +- Vercel AI SDK: `@ai-sdk/cerebras` exists (docs integration page) — not needed. | |
| 27 | + | |
| 28 | +## 3. Endpoints | |
| 29 | + | |
| 30 | +| Endpoint | Status | Notes | | |
| 31 | +|---|---|---| | |
| 32 | +| `POST /v1/chat/completions` | **the** inference endpoint | OpenAI-compatible; SSE streaming; `time_info` extra. **(probed)** | | |
| 33 | +| `POST /v1/completions` | legacy, works | `{model, prompt, max_tokens}` → `choices[0].text` **(probed 200)**. Docs page exists. Not for the adapter. | | |
| 34 | +| `GET /v1/models`, `GET /v1/models/{id}` | active | Minimal `{id, object:"model", created: 0, owned_by:"Cerebras"}`. **No context/pricing/capabilities.** Unknown id → 404 `model_not_found`. **(probed)** | | |
| 35 | +| `POST /v1/responses` | **404** **(probed)** | No Responses API. | | |
| 36 | +| `POST /v1/embeddings` | **404** **(probed)** | No embeddings. | | |
| 37 | +| Batch (`/v1/batches`), Files (`/v1/files`), Metrics | active (docs) | Batch = async JSONL jobs; out of scope. | | |
| 38 | +| Dedicated-endpoint management API | active (docs) | Enterprise only. | | |
| 39 | + | |
| 40 | +## 4. Chat Completions request/response (probed shapes) | |
| 41 | + | |
| 42 | +Request body fields (docs API ref + probes): `model`, `messages`, `max_completion_tokens` (alias `max_tokens`; **sending both → 400** `Setting "max_tokens" and "max_completion_tokens" at the same time is not supported.`), `min_completion_tokens`/`min_tokens`, `temperature` **0–2** (2.5 → 400 `temperature: Input should be less than or equal to 2`), `top_p` 0–1, `stop` (≤4), `seed`, `stream`, `stream_options` (accepted; **usage is sent anyway**, see §5), `response_format` (`text|json_object|json_schema`), `tools`, `tool_choice` (`none|auto|required|{type:"function",function:{name}}`), `parallel_tool_calls` (default true), `reasoning_effort` (`none|low|medium|high`, model-dependent), `reasoning_format` (`parsed|raw|hidden|none`), `clear_thinking` (qwen only), `logprobs`/`top_logprobs` (0–20), `n` (**only 1**: `n: 2` → 400 `"n" > 1 is not currently supported`), `frequency_penalty`/`presence_penalty` (−2..2, all models since 2026-03-31), `logit_bias`, `user`, `service_tier` (`default|priority|auto|flex`, dedicated only — **silently accepted on shared**, no `service_tier` echoed **(probed)**), `prompt_cache_key` (≤1024 chars), `prediction` (dedicated only). | |
| 43 | + | |
| 44 | +**Strict schema validation (v2): unknown top-level params are REJECTED**, e.g. `top_k` → 400 `top_k: property 'top_k' is unsupported`, `foo_bar` → 400 `foo_bar: property 'foo_bar' is unsupported` **(probed)**. Never forward unknown/experimental keys. | |
| 45 | + | |
| 46 | +Roles: `system`, `user`, `assistant`, `tool`. **`developer` role: OK on gpt-oss-120b and gemma-4-31b, 400 on qwen-3.8-27b** (`Failed to apply chat template to messages due to error: Unexpected message role.`) **(probed)** → always send `system`. Content parts: `{type:"text"}` (all roles), `{type:"image_url", image_url:{url}}` (user only, data URI only — §10). | |
| 47 | + | |
| 48 | +Non-streaming response **(probed)**: | |
| 49 | + | |
| 50 | +```json | |
| 51 | +{ | |
| 52 | + "id": "chatcmpl-61794ede-…", "object": "chat.completion", "created": 1788849167, "model": "gpt-oss-120b", | |
| 53 | + "system_fingerprint": "fp_b546658c8e93d2e57ef2", | |
| 54 | + "choices": [{ "index": 0, "finish_reason": "stop", | |
| 55 | + "message": { "role": "assistant", "content": "2 + 2 equals 4.", | |
| 56 | + "reasoning": "User asks: \"What is 2+2? …\" Just answer: …" } }], | |
| 57 | + "usage": { "prompt_tokens": 80, "completion_tokens": 51, "total_tokens": 131, | |
| 58 | + "prompt_tokens_details": { "cached_tokens": 0 }, | |
| 59 | + "completion_tokens_details": { "reasoning_tokens": 32, | |
| 60 | + "accepted_prediction_tokens": 0, "rejected_prediction_tokens": 0 } }, | |
| 61 | + "time_info": { "queue_time": 0.004089397, "prompt_time": 0.00175769, | |
| 62 | + "completion_time": 0.019383306, "total_time": 0.026791095733642648, "created": 1788849167.0522857 } | |
| 63 | +} | |
| 64 | +``` | |
| 65 | + | |
| 66 | +- **Reasoning is inside `completion_tokens`** (51 = 19 visible + 32 reasoning); `total_tokens = prompt + completion`. Reasoning also counts toward `max_completion_tokens` (docs + probed: `max_completion_tokens: 20` on qwen → `finish_reason: "length"`, `reasoning` = 20 tokens, **no `content` key at all**). The adapter must treat a missing `content` as empty string, and the UI needs a "budget consumed by reasoning" hint. | |
| 67 | +- The `reasoning` field is **absent** (not `null`) when the model did not reason (gemma default, `reasoning_effort: "none"`). | |
| 68 | +- `time_info` (seconds): use `completion_tokens / completion_time` for tokens/s. Observed: gemma ≈ **1 216 tok/s**, qwen ≈ **1 393**, gpt-oss ≈ **2 631** (high effort, 785 tokens: 1 472). `queue_time` occasionally 0.2–0.3 s on qwen. | |
| 69 | +- `prompt_tokens_details.image_tokens` present on gemma/qwen (0 without images); gpt-oss has none. gpt-oss adds `accepted/rejected_prediction_tokens`. | |
| 70 | +- `finish_reason`: `stop`, `length`, `tool_calls` **(probed)**; docs also `content_filter`. | |
| 71 | +- No hidden system prompt to speak of: a one-line prompt = 20–26 prompt tokens on gemma, ~60 on qwen, ~75–80 on gpt-oss (harmony preamble). | |
| 72 | +- `max_completion_tokens` far above the documented cap (60 000, even 999 999) is **accepted silently** **(probed)** — the API does not error, it just caps output; the docs' 40k "max output" is a soft figure. Omit or clamp client-side. | |
| 73 | + | |
| 74 | +## 5. Streaming protocol **(probed)** | |
| 75 | + | |
| 76 | +SSE, `Content-Type: text/event-stream`, no `event:` field, `data: {json}` lines, terminated by `data: [DONE]`. | |
| 77 | + | |
| 78 | +```json | |
| 79 | +data: {"id":"chatcmpl-…","object":"chat.completion.chunk","created":1788849167,"model":"gpt-oss-120b","system_fingerprint":"fp_…", | |
| 80 | + "choices":[{"index":0,"delta":{"role":"assistant"}}]} | |
| 81 | +data: {"…","choices":[{"index":0,"delta":{"reasoning":"The"}}]} | |
| 82 | +data: {"…","choices":[{"index":0,"delta":{"reasoning":" user asks: \"Say hello"}}]} | |
| 83 | +… | |
| 84 | +data: {"…","choices":[{"index":0,"delta":{"content":"Bonjour"}}]} | |
| 85 | +data: {"…","choices":[{"index":0,"delta":{},"finish_reason":"stop"}], | |
| 86 | + "usage":{"prompt_tokens":77,"completion_tokens":152,"total_tokens":229,"prompt_tokens_details":{"cached_tokens":0}, | |
| 87 | + "completion_tokens_details":{"reasoning_tokens":136,…}}, | |
| 88 | + "time_info":{"queue_time":0.0033,"prompt_time":0.0017,"completion_time":0.1104,"total_time":0.1175,"created":1788849167.17}} | |
| 89 | +data: [DONE] | |
| 90 | +``` | |
| 91 | + | |
| 92 | +- Delta keys observed: `role`, `reasoning`, `content`, `tool_calls`. **Reasoning streams as `delta.reasoning` (plain text)** — not `reasoning_content`, no `<think>` tags in `content` (unless `reasoning_format: "raw"`, §8). | |
| 93 | +- **`usage` + `time_info` ride on the SAME chunk as `finish_reason`** (no separate `choices: []` chunk) and are present **with or without `stream_options.include_usage`** **(probed on all 3)**. Standard OpenAI accumulation code works; just read `chunk.usage` whenever it is defined. | |
| 94 | +- Chunks are big (whole phrases per delta) because generation is so fast — 5–25 events per short answer; TTFB 107–221 ms. | |
| 95 | +- `json_object` + `stream: true`: docs say "not compatible… stream must be false", but it **works on all 3 models** **(probed, valid JSON streamed)**. Keep the docs rule in mind if it starts failing. | |
| 96 | + | |
| 97 | +## 6. Tool / function calling **(probed on all 3 models)** | |
| 98 | + | |
| 99 | +- OpenAI nested format `{type:"function", function:{name, description, parameters, strict?}}`; `tool_choice` `auto|required|none|{type:"function",function:{name}}` all OK; `parallel_tool_calls: false` OK; `strict: true` OK (requires `additionalProperties:false` on every object; qwen forbids `pattern/minLength/maxLength` in strict tool schemas — docs). | |
| 100 | +- Round trip (`assistant.tool_calls` + `{role:"tool", tool_call_id, content}`) → 200 with a grounded answer on all 3. Tool-call ids are **9-hex-char strings** (`"c1ded86e6"`), not `call_…`. | |
| 101 | +- **Streaming shape differs per model**: | |
| 102 | + - gemma-4-31b, qwen-3.8-27b: **one chunk** with the complete call `{"tool_calls":[{"index":0,"id":"7f46ce3e9","type":"function","function":{"name":"get_weather","arguments":"{\"city\": \"Montreal\"}"}}]}`. | |
| 103 | + - gpt-oss-120b: **true deltas** — chunk 1 `{id, type, index, function:{name, arguments:""}}`, then `{index, type, function:{arguments:"{\"city\":\""}}`, `{… arguments:"Montreal\"}"}`. Standard index-keyed accumulation handles both. | |
| 104 | + - then `finish_reason: "tool_calls"` (usage on that chunk). | |
| 105 | +- **`tools` + `response_format` together: 400 on gpt-oss-120b** `"tools" is incompatible with "response_format"` (`param: "tools"`); accepted on gemma/qwen (the model called the tool). Adapter: drop `response_format` when tools are present for gpt-oss, or refuse the combination. | |
| 106 | +- gpt-oss-120b docs warning: "may call tools that aren't directly specified due to its training" (hallucinated tool names) — validate the name before dispatch. | |
| 107 | +- v2 validation is strict on multi-turn tool history (docs): no orphan `tool` messages, every `tool_call_id` must match, unique ids. | |
| 108 | + | |
| 109 | +## 7. Structured output **(probed on all 3 models)** | |
| 110 | + | |
| 111 | +- `response_format: {type:"json_schema", json_schema:{name, schema, strict:true}}` → valid JSON matching the schema on all 3 (non-stream and stream, gpt-oss stream verified in probe 07). `{type:"json_object"}` works on all 3 (also streamed). | |
| 112 | +- **`strict: true` without `additionalProperties:false` → 400** `{"message":"'additionalProperties' is required to be supplied and set to false.","type":"invalid_request_error","param":"response_format","code":"wrong_api_format"}` **(probed all 3)** — the adapter must inject `additionalProperties:false` recursively (same requirement as OpenAI strict mode). | |
| 113 | +- `pattern` (documented as prohibited) was **accepted** on all 3 — apparently ignored (output happened to match). Don't rely on it. | |
| 114 | +- Docs limits (strict): schema text ≤ 5 000 chars, depth ≤ 10, ≤ 500 properties, ≤ 500 enum values total; supported: primitive types, `enum`, `const`, `anyOf`, nested objects/arrays, `required`, `$ref/$defs`, tuple `items`, number bounds, `multipleOf`, annotations. Prohibited: recursion, external `$ref`, `oneOf`, `allOf`, `not`, `if/then/else`, `patternProperties`, `unevaluatedProperties`, `pattern`, `format`, `minItems/maxItems`. Output keys follow schema order. | |
| 115 | +- gpt-oss: `min_tokens`/`min_completion_tokens` → 400 `Structured outputs is not currently supported with min_tokens` **even without response_format** **(probed)** — never send `min_tokens` to gpt-oss. | |
| 116 | + | |
| 117 | +## 8. Reasoning controls **(probed matrix)** | |
| 118 | + | |
| 119 | +| Model | Default | `reasoning_effort` accepted | Rejected | `reasoning_format` | `reasoning` field | | |
| 120 | +|---|---|---|---|---|---| | |
| 121 | +| gpt-oss-120b | `medium`, always on | `low`, `medium`, `high` | **`none` → 400** `Failed to apply chat template to messages due to error: Unsupported reasoning effort: none. Supported values are 'low', 'medium', and 'high'.`; `minimal` → 400 `reasoning_effort: Input should be 'none', 'low', 'medium' or 'high'` | `parsed` (default), `raw` (reasoning **prepended to `content` with no delimiter**: `"Need answer \"Yes\". One word.Yes"`), `hidden` (no `reasoning` key, `reasoning_tokens` still billed) | yes (`message.reasoning`, `delta.reasoning`) | | |
| 122 | +| qwen-3.8-27b | `high`, on | `none`, `low`, `medium`, `high` | `minimal` → 400 | `parsed` (default), `raw` (**`reasoning\n</think>\n\ncontent`** — closing tag only, no opening `<think>`), **`hidden` → 400** `qwen-3.8-27b does not support 'hidden' reasoning format.` | yes; absent when `none`. `content` starts with `"\n\n"` after reasoning — trim. | | |
| 123 | +| gemma-4-31b | **off** (`none`) | `none`, `low`, `medium`, `high` (docs: low/medium/high all equivalent — probed reasoning_tokens 34–40 regardless) | `minimal` → 400 | `parsed` only; **`raw`/`hidden` → 400** `Gemma-4 does not support 'raw' reasoning format.` | yes when enabled (`delta.reasoning` streamed **(probed)**); absent otherwise | | |
| 124 | + | |
| 125 | +- **`disable_reasoning`** (deprecated 2026-07-21): still **accepted on gemma/qwen** (behaves like `none`), **400 on gpt-oss** (same "Unsupported reasoning effort: none" message) **(probed)**. Use `reasoning_effort: "none"` only. | |
| 126 | +- **`clear_thinking: true`**: OK on qwen only; gemma/gpt-oss → 400 `"clear_thinking" is not supported for this model`. When replaying history, the adapter should **not** send prior `reasoning` back (qwen keeps it unless `clear_thinking`; others ignore). | |
| 127 | +- Effort has little effect on trivial prompts (qwen 22→31→29 reasoning tokens for low/medium/high; gpt-oss 18/33/37); gpt-oss `high` on "count the r's" → **758 reasoning tokens**, 785 completion in 0.53 s. | |
| 128 | +- No thinking budget parameter. Reasoning tokens are billed at the output rate and consume `max_completion_tokens` → for reasoning-on models set `max_completion_tokens` ≥ 1 000 or expect empty `content` with `finish_reason: "length"`. | |
| 129 | +- `logprobs: true` with reasoning → `choices[0].logprobs` (content tokens) **and `choices[0].reasoning_logprobs`** (reasoning tokens, same `{content:[{token, logprob, bytes, top_logprobs}]}` shape) **(probed)**. | |
| 130 | + | |
| 131 | +## 9. Sampling & other parameters — support matrix **(probed, chat completions, HTTP status)** | |
| 132 | + | |
| 133 | +| Param | gemma-4-31b | qwen-3.8-27b | gpt-oss-120b | | |
| 134 | +|---|---|---|---| | |
| 135 | +| `temperature` 0–2 | ✓ | ✓ | ✓ (>2 → 400 everywhere) | | |
| 136 | +| `top_p` | ✓ | ✓ | ✓ | | |
| 137 | +| `top_k` | **400 unsupported property** | **400** | **400** | | |
| 138 | +| `max_completion_tokens` / `max_tokens` (not both) | ✓ | ✓ | ✓ | | |
| 139 | +| `min_completion_tokens` | ✓ | ✓ | **400** (`Structured outputs is not currently supported with min_tokens`) | | |
| 140 | +| `stop` | ✓ (`stop` honoured, `finish_reason: stop`) | ✓ | ✓ | | |
| 141 | +| `seed` | ✓ (**deterministic**: same 6-digit "random" number twice, same `system_fingerprint`) | ✓ | ✓ | | |
| 142 | +| `frequency_penalty` / `presence_penalty` | ✓ | ✓ | ✓ | | |
| 143 | +| `logit_bias` | ✓ | ✓ | ✓ | | |
| 144 | +| `logprobs` + `top_logprobs` | ✓ | ✓ (+`reasoning_logprobs`) | ✓ (+`reasoning_logprobs`) | | |
| 145 | +| `n: 2` | **400** | **400** | **400** | | |
| 146 | +| `reasoning_effort` | none/low/medium/high | none/low/medium/high | low/medium/high (**no none**) | | |
| 147 | +| `reasoning_format` | parsed | parsed, raw | parsed, raw, hidden | | |
| 148 | +| `clear_thinking` | **400** | ✓ | **400** | | |
| 149 | +| `disable_reasoning` (deprecated) | ✓ (=none) | ✓ (=none) | **400** | | |
| 150 | +| `response_format` json_object / json_schema | ✓ / ✓ | ✓ / ✓ | ✓ / ✓ | | |
| 151 | +| `tools` + `response_format` | ✓ | ✓ | **400** | | |
| 152 | +| `developer` role | ✓ | **400** | ✓ | | |
| 153 | +| `system` role | ✓ | ✓ | ✓ | | |
| 154 | +| `service_tier: "priority"` | accepted, ignored | accepted, ignored | accepted, ignored | | |
| 155 | +| `prompt_cache_key`, `user` | ✓ | ✓ | ✓ | | |
| 156 | +| unknown param (`foo_bar`) | **400** | **400** | **400** | | |
| 157 | +| `image_url` content | ✓ | ✓ | **400** | | |
| 158 | + | |
| 159 | +Error body for every 400: `{"message": "...", "type": "invalid_request_error", "param": "<field or validation_error>", "code": "wrong_api_format", "id": ""}`. **Adapter rules: strip any non-whitelisted key; never send `top_k`, `n>1`, `min_tokens` (gpt-oss), `clear_thinking` (non-qwen), `developer` role (map to `system`), `reasoning_effort: "none"` to gpt-oss, `response_format` with tools on gpt-oss, `reasoning_format` other than `parsed`.** | |
| 160 | + | |
| 161 | +## 10. Modalities, context, output limits | |
| 162 | + | |
| 163 | +- **Input**: text on all; **images on gemma-4-31b and qwen-3.8-27b** (docs + **probed OK**: 32×32 PNG → gemma `"Red"`, `image_tokens: 256`; qwen `image_tokens: 64`). **gpt-oss-120b: 400** `{"message":"Content type 'image_url' is not supported by selected model. Only 'text' content type can be used.","type":"invalid_request_error","param":"prompt","code":"wrong_api_format"}`. **Output**: text only. No audio, no image generation, no files, no web search, no embeddings. | |
| 164 | +- **Images**: base64 data URI only (`data:image/png;base64,…` / JPEG); **HTTPS URL → 400** `{"message":"Remote image URLs are not supported; send images as data URIs (data:image/png;base64,...)","type":"invalid_request_error","param":"messages","code":"invalid_multimodal_input"}` **(probed)** → PolyLLM must fetch/inline remote images itself. `detail` is documented unsupported but **`detail: "high"` was silently accepted** **(probed)** — strip it anyway. Limits (docs): ≤ 15 000 px per side, ≤ 10 MiB request payload (→ 413 `image_too_large`), **2 images/request on Free Trial, 10 on Developer/Enterprise**, user messages only. Token formula: qwen 32×32 px/token (max 2 304), gemma 48×48 (max 280 — probe shows a 32×32 image is padded to 256 tokens). Image tokens are inside `prompt_tokens` and repeated in `prompt_tokens_details.image_tokens`. | |
| 165 | +- **Context windows (docs, Free → Paid)**: gpt-oss-120b **65k → 131k**; qwen-3.8-27b **64k → 128k** (65 536 → 131 072); gemma-4-31b: no page — **probed limit 131 072**. Live errors with a ~186k-token prompt: gpt-oss `Please reduce the length of the messages or completion. Current length is 186745 while limit is 131000` (`code: "context_length_exceeded"`, `param: "messages"`, 400); qwen/gemma same text with `limit is 131072`. A ~93k-token prompt succeeded on all 3 (this key is paid tier; free keys will be refused above ~65k). "Current length" = prompt + `max_completion_tokens`. | |
| 166 | +- **Max output (docs)**: gpt-oss-120b 32k free / **40k paid**; qwen-3.8-27b 32 768 free / **40 960 paid**; gemma unknown (null). Not enforced as a 400 (§4). | |
| 167 | +- Free Trial vs Developer for this key: headers show Developer-tier limits; a 150K-TPM qwen bucket was exhausted by one 93k-token request + retry → 429 `{"message":"Tokens per minute limit exceeded - too many tokens processed.","type":"too_many_tokens_error","param":"quota","code":"token_quota_exceeded"}` with `retry-after: 60` **(probed)**. Large-context use on qwen is effectively rate-limited to ~1 request/min. | |
| 168 | + | |
| 169 | +## 11. Server-side tools, search, citations | |
| 170 | + | |
| 171 | +None. Cerebras has no web search, code interpreter, file search or MCP tool; only client-side function calling. No citations/annotations. | |
| 172 | + | |
| 173 | +## 12. Model listing & pricing units | |
| 174 | + | |
| 175 | +`GET /v1/models` gives ids only. Pricing is **not in the API**; the public pricing page (`cerebras.ai/pricing`) only states tiers (Free Trial $5 credits / 30 days, Developer from $10, Enterprise). Per-model prices come from the model doc pages **(docs)**: | |
| 176 | + | |
| 177 | +| Model | ctx free / paid | max out free / paid | speed (docs / probed) | in / out ($/M) | cached | | |
| 178 | +|---|---|---|---|---|---| | |
| 179 | +| gpt-oss-120b | 65k / 131k (probed 131 000) | 32k / 40k | ~3000 / 2 631 tok/s | **0.35 / 0.75** | no discount | | |
| 180 | +| qwen-3.8-27b | 64k / 128k (probed 131 072) | 32 768 / 40 960 | ~1500 / 1 393 | **0.99 / 1.49** | no discount | | |
| 181 | +| gemma-4-31b | — / probed 131 072 | — | — / 1 216 | **unknown (removed from public price list 2026-09-03)** | — | | |
| 182 | + | |
| 183 | +Prompt caching gives **no price discount** ("billed at the standard input token rate") — it only reduces latency and the *uncached* TPM bucket. Reasoning tokens billed as output. Batch API and service tiers have no differentiated pricing during preview (docs). | |
| 184 | + | |
| 185 | +## 13. Prompt caching & provider-side state | |
| 186 | + | |
| 187 | +- **Automatic** on all models, 128-token blocks (prefixes < 128 tokens may not be cached), TTL guaranteed 5 min (up to 1 h), reported in `usage.prompt_tokens_details.cached_tokens`; optional `prompt_cache_key` (≤ 1024 chars) as a routing hint — docs warn *against* sharing one key across many users. **(probed: two identical 404-token prompts back-to-back both reported `cached_tokens: 0`** — cache warm-up is not instantaneous; don't promise hits.) No price change either way, so caching is purely a rate-limit/latency optimisation for PolyLLM. | |
| 188 | +- **Stateless**: no stored conversations, no `previous_response_id`, no ZDR flag. Docs policy page covers retention. | |
| 189 | + | |
| 190 | +## 14. Errors, rate limits, retries | |
| 191 | + | |
| 192 | +Error body: OpenAI-style flat object `{"message","type","param","code"}` (+ `"id": ""` on some). The OpenAI SDK maps it to `APIError` with `e.error` = that object. | |
| 193 | + | |
| 194 | +| Case **(probed)** | HTTP | body | | |
| 195 | +|---|---|---| | |
| 196 | +| Invalid key / no Authorization header | **401** | `{"message":"Wrong API Key","type":"invalid_request_error","param":"api_key","code":"wrong_api_key"}` | | |
| 197 | +| Unknown or retired model (`llama-3.3-70b`, `grok-99`) | **404** | `{"message":"Model does not exist or you do not have access to it.","type":"not_found_error","param":"model","code":"model_not_found"}` | | |
| 198 | +| Validation (unknown param, range, `n`, `top_k`, both max_*) | 400 | `type: invalid_request_error`, `code: wrong_api_format`, `param: validation_error` or the field | | |
| 199 | +| Missing `messages` | 400 | `messages: Field required` | | |
| 200 | +| Bad JSON / wrong type | 400 | `: Invalid JSON: …` / `messages: Input should be a valid array` | | |
| 201 | +| Context too long | 400 | `code: context_length_exceeded`, `param: messages` | | |
| 202 | +| Image on text model | 400 | `code: wrong_api_format`, `param: prompt` | | |
| 203 | +| Remote image URL | 400 | `code: invalid_multimodal_input`, `param: messages` | | |
| 204 | +| TPM bucket exhausted | **429** | `{"message":"Tokens per minute limit exceeded - too many tokens processed.","type":"too_many_tokens_error","param":"quota","code":"token_quota_exceeded"}` + `retry-after: 60` | | |
| 205 | +| Unsupported API version header | 400 | `code: wrong_api_format`, `param: api_version` | | |
| 206 | + | |
| 207 | +Docs also list 402 PaymentRequired (credits exhausted), 403, 413 ContentTooLarge (`image_too_large`), 422 (legacy; validation now 400 since 2026-04-24), 500, 503. Rate limits (docs): Free Trial 5 RPM / 30K uncached TPM / 90K total TPM / 1M TPH / 1M TPD per model; Developer: gpt-oss 1K RPM / 1M uncached / 3M total TPM, qwen 300 RPM / 150K / 450K, no hourly/daily caps; dual-bucket (uncached vs total = 3× uncached), token-bucket refill; 429 tells which bucket. Status page: https://status.cerebras.ai (docs). | |
| 208 | + | |
| 209 | +**Retry/timeout recommendation for the adapter:** retry 429/5xx with backoff honouring `retry-after` (60 s observed — for a chat UI show "rate limited, retry in 60 s" rather than blocking); never retry 400/401/404; 401 → *invalid credentials* state (clean, unlike xAI); 404 `model_not_found` → refresh the model list (Cerebras retires models often — 15 retirements since 2025). Short timeouts are fine (whole answers in < 1 s) but keep ≥ 60 s for 40k-token outputs and queueing. | |
| 210 | + | |
| 211 | +## 15. Lifecycle / aliases | |
| 212 | + | |
| 213 | +- **No aliases** (no `-latest`); ids are stable slugs. Retired ids return 404 immediately (no transparent redirect). | |
| 214 | +- Retirements (docs deprecation page): `gemma-4-31b` public 2026-09-03 (→ qwen-3.8-27b), `zai-glm-4.7` 2026-08-17, `llama3.1-8b` + `qwen-3-235b-a22b-instruct-2507` 2026-05-27, `qwen-3-32b` + `llama-3.3-70b` 2026-02-16, `zai-glm-4.6` 2026-01-20, `qwen-3-235b-a22b-thinking-2507` 2025-11-14, `qwen-3-coder-480b` 2025-11-05, llama-4 scout/maverick Oct–Nov 2025, `deepseek-r1-distill-llama-70b` 2025-08-12. Parameter deprecation: `disable_reasoning` 2026-07-21. | |
| 215 | +- `kimi-k2.7-code` appears in capability docs but is "customer trials only" (not in `/v1/models` for this key). | |
| 216 | +- Because `/v1/models` is the only truth and it lags the docs (gemma), the registry sync should **filter to the intersection** of `/v1/models` and our catalog, and show gemma with a "legacy / dedicated-only" badge if present. | |
| 217 | + | |
| 218 | +## 16. Exact streaming code that worked | |
| 219 | + | |
| 220 | +OpenAI SDK (`openai@7.10.0`) against Cerebras — reasoning + usage: | |
| 221 | + | |
| 222 | +```ts | |
| 223 | +import OpenAI from "openai"; | |
| 224 | +const client = new OpenAI({ apiKey: process.env.CEREBRAS_API_KEY, baseURL: "https://api.cerebras.ai/v1", timeout: 120_000, maxRetries: 0 }); | |
| 225 | + | |
| 226 | +const stream = await client.chat.completions.create({ | |
| 227 | + model: "gpt-oss-120b", | |
| 228 | + messages: [{ role: "user", content: "Say hi." }], | |
| 229 | + stream: true, // stream_options not needed: usage rides on the finish chunk | |
| 230 | + max_completion_tokens: 1000, // includes reasoning tokens! | |
| 231 | + reasoning_effort: "low", // gpt-oss: low|medium|high ; qwen/gemma: none|low|medium|high | |
| 232 | +} as any); | |
| 233 | + | |
| 234 | +for await (const chunk of stream as any) { | |
| 235 | + const d = chunk.choices?.[0]?.delta ?? {}; | |
| 236 | + if (d.reasoning) onThinking(d.reasoning); // Cerebras field name is `reasoning` | |
| 237 | + if (d.content) onText(d.content); | |
| 238 | + if (d.tool_calls) onToolCallDeltas(d.tool_calls); // gemma/qwen: complete in 1 chunk; gpt-oss: real deltas | |
| 239 | + if (chunk.choices?.[0]?.finish_reason) onFinish(chunk.choices[0].finish_reason); | |
| 240 | + if (chunk.usage) onUsage(chunk.usage, chunk.time_info); // same chunk as finish_reason | |
| 241 | +} | |
| 242 | +``` | |
| 243 | + | |
| 244 | +Raw fetch SSE (`research/cerebras/lib.ts` → `rawSSE`): POST JSON, read `res.body`, split on `\n\n`, parse `data:` lines, stop at `[DONE]` — identical parser to xAI. | |
| 245 | + | |
| 246 | +## 17. Probe results table | |
| 247 | + | |
| 248 | +| # | Probe | Model(s) | Result | | |
| 249 | +|---|---|---|---| | |
| 250 | +| 00 | `GET /models`, `/models/{id}`, unknown id, headers on a tiny call | — | 3 models (gpt-oss-120b, gemma-4-31b, qwen-3.8-27b), `created: 0`; unknown → 404; per-model `x-ratelimit-*-{minute,hour,day}` headers; Developer-tier limits | | |
| 251 | +| 01a | tiny chat completion `max_completion_tokens: 200` | 3 models | 200 in 124–399 ms; `message.reasoning` on qwen/gpt-oss, absent on gemma; `usage.completion_tokens_details.reasoning_tokens`; `time_info` → 1 216 / 1 393 / 2 631 tok/s | | |
| 252 | +| 01b | streaming ± `stream_options.include_usage` | 3 models | `delta.role` → `delta.reasoning`* → `delta.content`; `usage` + `time_info` on the `finish_reason` chunk in both cases; `[DONE]`; no `<think>` tags | | |
| 253 | +| 02 | param matrix (44 variants) | 3 models | see §9; unknown params & `top_k` 400; `n>1` 400; both max_* 400; `temperature > 2` 400; `reasoning_effort` per §8; `developer` 400 on qwen; `tools`+`response_format` 400 on gpt-oss; `min_tokens` 400 on gpt-oss; `service_tier` ignored | | |
| 254 | +| 03 | function call round trip, streaming | 3 models | gemma/qwen: single complete tool_call chunk; gpt-oss: name chunk + 2 argument deltas; 9-hex ids; `finish_reason: tool_calls`; round 2 answers correctly on all | | |
| 255 | +| 04 | `json_schema` strict (non-stream + stream), forbidden `pattern`, missing `additionalProperties` | 3 models | valid JSON on all; `pattern` silently accepted; missing `additionalProperties:false` → 400 `'additionalProperties' is required to be supplied and set to false.` | | |
| 256 | +| 05 | vision 32×32 PNG data URL; HTTPS URL; `detail` | 3 models | gemma 200 (`image_tokens 256`), qwen 200 (`64`), gpt-oss 400 "Content type 'image_url' is not supported"; HTTPS URL 400 `invalid_multimodal_input`; `detail` ignored | | |
| 257 | +| 06 | invalid key / no auth / unknown model / bad body / no messages / huge max / `/completions` / `/embeddings` / `/responses` / version header / SDK errors | — | 401 `wrong_api_key` / 401 / 404 `model_not_found` / 400 / 400 / 200 (cap ignored) / 200 / 404 / 404 / 400 (v1 unsupported) / `AuthenticationError` | | |
| 258 | +| 07 | gpt-oss json_schema stream, `reasoning_format: raw`, gemma reasoning stream, ~93k & ~186k-token prompts, `max_completion_tokens: 60000`, gpt-oss `high`, qwen cap 20 | mixed | valid; raw = reasoning glued to content (gpt-oss) / `…\n</think>\n\n…` (qwen) / 400 (gemma); gemma streams `delta.reasoning`; 93k OK on all, 186k → 400 `context_length_exceeded` limit 131000 (gpt-oss) / 131072 (qwen, gemma), qwen first hit 429 TPM; 60000 accepted; high → 758 reasoning tokens, 1 472 tok/s; cap 20 → `length`, no `content` | | |
| 259 | +| 08 | `json_object`+stream, qwen ctx retry, `logprobs` on reasoning models, `stop`, `seed` ×2, cache ×2, OpenAI SDK stream | mixed | json_object streams fine on all 3 (docs say no); qwen limit 131072; `logprobs` + `reasoning_logprobs`; `stop` honoured; seed deterministic; `cached_tokens` 0 on immediate repeat; OpenAI SDK sees `delta.reasoning` + usage | | |
| 260 | + | |
| 261 | +\* gemma only when `reasoning_effort` ≠ none. | |
| 262 | + | |
| 263 | +## Documentation pages used (all fetched 2026-09-08) | |
| 264 | + | |
| 265 | +- https://inference-docs.cerebras.ai/introduction · https://inference-docs.cerebras.ai/llms.txt (index used to find the real paths) · https://inference-docs.cerebras.ai/quickstart | |
| 266 | +- https://inference-docs.cerebras.ai/api-reference/chat-completions (+ `.md`) · https://inference-docs.cerebras.ai/api-reference/models · https://inference-docs.cerebras.ai/api-reference/models/retrieve-model.md · https://inference-docs.cerebras.ai/api-reference/versions.md | |
| 267 | +- https://inference-docs.cerebras.ai/models/overview · https://inference-docs.cerebras.ai/models/openai-oss · https://inference-docs.cerebras.ai/models/qwen-3.8-27b · https://inference-docs.cerebras.ai/models/choose-a-model.md · https://inference-docs.cerebras.ai/dedicated/overview.md | |
| 268 | +- https://inference-docs.cerebras.ai/capabilities/streaming · …/capabilities/tool-use · …/capabilities/structured-outputs · …/capabilities/reasoning · …/capabilities/image-inputs.md · …/capabilities/prompt-caching · …/capabilities/service-tiers.md | |
| 269 | +- https://inference-docs.cerebras.ai/support/rate-limits · https://inference-docs.cerebras.ai/support/error.md · https://inference-docs.cerebras.ai/support/change-log.md · https://inference-docs.cerebras.ai/support/deprecation.md | |
| 270 | +- https://inference-docs.cerebras.ai/resources/openai · https://www.cerebras.ai/pricing (tiers only, no per-model table; `/support/pricing` 307-redirects here) · https://registry.npmjs.org/@cerebras/cerebras_cloud_sdk/latest (1.91.0) · https://github.com/Cerebras/cerebras-cloud-sdk-node (README) | |
| 271 | +- 404 at audit time: `/support/error-codes`, `/support/error-handling`, `/resources/changelog`, `/models/deprecations`, `/models/gemma-4-31b` (+ `.md`), `/capabilities/vision`, `/capabilities/prompt-caching.md` was fine but `/support/pricing` is a redirect. | |
added
docs/provider-research/cerebras.models.json
+140 −0
@@ -0,0 +1,140 @@ | ||
| 1 | +[ | |
| 2 | + { | |
| 3 | + "id": "gpt-oss-120b", | |
| 4 | + "displayName": "GPT OSS 120B", | |
| 5 | + "family": "gpt-oss", | |
| 6 | + "contextTokens": 131000, | |
| 7 | + "maxOutputTokens": 40000, | |
| 8 | + "capabilities": { | |
| 9 | + "text": true, | |
| 10 | + "vision": false, | |
| 11 | + "audioInput": false, | |
| 12 | + "audioOutput": false, | |
| 13 | + "imageGeneration": false, | |
| 14 | + "video": false, | |
| 15 | + "reasoning": true, | |
| 16 | + "tools": true, | |
| 17 | + "structuredOutput": true, | |
| 18 | + "streaming": true, | |
| 19 | + "files": false, | |
| 20 | + "webSearch": false | |
| 21 | + }, | |
| 22 | + "parameters": { | |
| 23 | + "temperature": true, | |
| 24 | + "topP": true, | |
| 25 | + "topK": false, | |
| 26 | + "maxTokens": true, | |
| 27 | + "reasoningEffort": ["low", "medium", "high"], | |
| 28 | + "thinkingBudget": false, | |
| 29 | + "stop": true, | |
| 30 | + "seed": true, | |
| 31 | + "frequencyPenalty": true, | |
| 32 | + "presencePenalty": true | |
| 33 | + }, | |
| 34 | + "pricing": { | |
| 35 | + "inputPerMillion": 0.35, | |
| 36 | + "cachedInputPerMillion": 0.35, | |
| 37 | + "outputPerMillion": 0.75, | |
| 38 | + "longContextThresholdTokens": null, | |
| 39 | + "longContextInputPerMillion": null, | |
| 40 | + "longContextCachedInputPerMillion": null, | |
| 41 | + "longContextOutputPerMillion": null, | |
| 42 | + "imageInputPerMillionTokens": null | |
| 43 | + }, | |
| 44 | + "status": "active", | |
| 45 | + "aliases": [], | |
| 46 | + "notes": "OpenAI open-weight 120B MoE served at ~2600-3000 tok/s (probed 2631). Always-reasoning: reasoning_effort default medium, accepts low|medium|high; 'none' and deprecated disable_reasoning -> 400 'Unsupported reasoning effort: none'. Reasoning returned as message.reasoning / delta.reasoning (plain text); reasoning_format parsed|raw|hidden all accepted (raw glues reasoning onto content without delimiter). Reasoning tokens are INSIDE completion_tokens and consume max_completion_tokens -> keep >= 1000 or content may be missing with finish_reason 'length'. tools + response_format together -> 400 '\"tools\" is incompatible with \"response_format\"'. min_tokens -> 400. Streams tool-call arguments as real deltas (name first, then argument fragments). Only 'text' content parts (image_url -> 400). developer role accepted. Context: 65k free / 131k paid (probed limit 131000 = prompt + max_completion_tokens). Max output 32k free / 40k paid (docs; not enforced with a 400). Prompt caching automatic, no price discount. Developer-tier limits observed: 1000 RPM / 500K TPM (docs: 1M uncached / 3M total TPM). Cheapest and fastest Cerebras model -> good default." | |
| 47 | + }, | |
| 48 | + { | |
| 49 | + "id": "qwen-3.8-27b", | |
| 50 | + "displayName": "Qwen 3.8 27B", | |
| 51 | + "family": "qwen-3.8", | |
| 52 | + "contextTokens": 131072, | |
| 53 | + "maxOutputTokens": 40960, | |
| 54 | + "capabilities": { | |
| 55 | + "text": true, | |
| 56 | + "vision": true, | |
| 57 | + "audioInput": false, | |
| 58 | + "audioOutput": false, | |
| 59 | + "imageGeneration": false, | |
| 60 | + "video": false, | |
| 61 | + "reasoning": true, | |
| 62 | + "tools": true, | |
| 63 | + "structuredOutput": true, | |
| 64 | + "streaming": true, | |
| 65 | + "files": false, | |
| 66 | + "webSearch": false | |
| 67 | + }, | |
| 68 | + "parameters": { | |
| 69 | + "temperature": true, | |
| 70 | + "topP": true, | |
| 71 | + "topK": false, | |
| 72 | + "maxTokens": true, | |
| 73 | + "reasoningEffort": ["none", "low", "medium", "high"], | |
| 74 | + "thinkingBudget": false, | |
| 75 | + "stop": true, | |
| 76 | + "seed": true, | |
| 77 | + "frequencyPenalty": true, | |
| 78 | + "presencePenalty": true | |
| 79 | + }, | |
| 80 | + "pricing": { | |
| 81 | + "inputPerMillion": 0.99, | |
| 82 | + "cachedInputPerMillion": 0.99, | |
| 83 | + "outputPerMillion": 1.49, | |
| 84 | + "longContextThresholdTokens": null, | |
| 85 | + "longContextInputPerMillion": null, | |
| 86 | + "longContextCachedInputPerMillion": null, | |
| 87 | + "longContextOutputPerMillion": null, | |
| 88 | + "imageInputPerMillionTokens": 0.99 | |
| 89 | + }, | |
| 90 | + "status": "active", | |
| 91 | + "aliases": [], | |
| 92 | + "notes": "Added 2026-09-03; dense 27B multimodal model, ~1400-1500 tok/s (probed 1393). Reasoning ON by default at 'high'; reasoning_effort none|low|medium|high (none = no reasoning field, fastest); 'minimal' -> 400. reasoning_format parsed (default) or raw (content becomes 'reasoning\\n</think>\\n\\nanswer' - closing tag only); 'hidden' -> 400. Content after reasoning starts with '\\n\\n' -> trim. clear_thinking (qwen only) drops historical reasoning from the prompt. Reasoning tokens inside completion_tokens and count toward max_completion_tokens. Vision: base64 PNG/JPEG data URIs only (remote https URL -> 400 invalid_multimodal_input), user messages only, 2 images/request on Free Trial, 10 on Developer/Enterprise, 10 MiB payload, 32x32 px per image token (max 2304), image tokens billed at the input rate and reported in prompt_tokens_details.image_tokens; image_url.detail ignored. 'developer' role -> 400 'Unexpected message role' (use system). tools + response_format accepted. Tool calls arrive complete in a single stream chunk. Strict tool schemas may not use pattern/minLength/maxLength (docs). Context 64k free / 128k paid (probed limit 131072); max output 32768 free / 40960 paid (docs). Developer limits observed 450 RPM / 150K uncached TPM (docs 300 RPM) -> a single ~93k-token request exhausts the minute bucket (429 token_quota_exceeded, retry-after 60)." | |
| 93 | + }, | |
| 94 | + { | |
| 95 | + "id": "gemma-4-31b", | |
| 96 | + "displayName": "Gemma 4 31B", | |
| 97 | + "family": "gemma-4", | |
| 98 | + "contextTokens": 131072, | |
| 99 | + "maxOutputTokens": null, | |
| 100 | + "capabilities": { | |
| 101 | + "text": true, | |
| 102 | + "vision": true, | |
| 103 | + "audioInput": false, | |
| 104 | + "audioOutput": false, | |
| 105 | + "imageGeneration": false, | |
| 106 | + "video": false, | |
| 107 | + "reasoning": true, | |
| 108 | + "tools": true, | |
| 109 | + "structuredOutput": true, | |
| 110 | + "streaming": true, | |
| 111 | + "files": false, | |
| 112 | + "webSearch": false | |
| 113 | + }, | |
| 114 | + "parameters": { | |
| 115 | + "temperature": true, | |
| 116 | + "topP": true, | |
| 117 | + "topK": false, | |
| 118 | + "maxTokens": true, | |
| 119 | + "reasoningEffort": ["none", "low", "medium", "high"], | |
| 120 | + "thinkingBudget": false, | |
| 121 | + "stop": true, | |
| 122 | + "seed": true, | |
| 123 | + "frequencyPenalty": true, | |
| 124 | + "presencePenalty": true | |
| 125 | + }, | |
| 126 | + "pricing": { | |
| 127 | + "inputPerMillion": null, | |
| 128 | + "cachedInputPerMillion": null, | |
| 129 | + "outputPerMillion": null, | |
| 130 | + "longContextThresholdTokens": null, | |
| 131 | + "longContextInputPerMillion": null, | |
| 132 | + "longContextCachedInputPerMillion": null, | |
| 133 | + "longContextOutputPerMillion": null, | |
| 134 | + "imageInputPerMillionTokens": null | |
| 135 | + }, | |
| 136 | + "status": "deprecated", | |
| 137 | + "aliases": [], | |
| 138 | + "notes": "Officially REMOVED from Cerebras public endpoints on 2026-09-03 (changelog + deprecation page; replacement qwen-3.8-27b; still offered on Dedicated Endpoints). Nevertheless still listed by GET /v1/models for this Developer key and fully working in every probe (~1216 tok/s) -> treat as grandfathered/legacy: show only if present in /v1/models, badge it, never default to it. Pricing unknown (model page 404, not on pricing page). Reasoning OFF by default (no reasoning field); reasoning_effort low|medium|high enable it (docs: all three equivalent; probed 34-40 reasoning tokens regardless), streamed as delta.reasoning; reasoning_format raw/hidden -> 400 'Gemma-4 does not support ... reasoning format'; clear_thinking -> 400. Vision: base64 data URIs only, 48x48 px per token (max 280; a 32x32 PNG cost 256 image tokens), 2 images free / 10 paid. Tool calls arrive complete in one stream chunk; tools + response_format accepted. developer role accepted. seed is deterministic (identical output + system_fingerprint). Probed context limit 131072 (prompt + max_completion_tokens). Developer limits observed 500 RPM / 250K TPM." | |
| 139 | + } | |
| 140 | +] | |
added
docs/provider-research/deepseek.md
+271 −0
@@ -0,0 +1,271 @@ | ||
| 1 | +# DeepSeek (V4 API) — provider research for PolyLLM | |
| 2 | + | |
| 3 | +Last documentation audit: **2026-09-08** | |
| 4 | +Probes executed 2026-09-08 with a real key against `https://api.deepseek.com` (scripts in `research/deepseek/`, raw outputs in `research/deepseek/out/`, ~180 requests, total spend < $0.10). Everything marked **(probed)** was observed live; everything marked **(docs)** comes from the pages listed at the end. | |
| 5 | + | |
| 6 | +> Heads-up: several URLs given in the brief redirect to the landing page — the guides moved: `guides/reasoning_model` → **`guides/thinking_mode`**, `guides/function_calling` → **`guides/tool_calls`**, `news/` → **`/updates`** (+ `news/newsYYMMDD`), and `quick_start/pricing` only renders with a **trailing slash**. There is a new `guides/vision` page, a Files API and a Responses API. The FAQ is an external SPA (`static.deepseek.com/faq`) that cannot be fetched server-side. | |
| 7 | + | |
| 8 | +--- | |
| 9 | + | |
| 10 | +## 1. Base URL, auth, headers | |
| 11 | + | |
| 12 | +| Item | Value | | |
| 13 | +|---|---| | |
| 14 | +| REST base URL | `https://api.deepseek.com` (docs). **`https://api.deepseek.com/v1` works identically** for `/models`, `/user/balance`, `/chat/completions` **(probed)** — the `v1` has nothing to do with the model version. Trailing slash also accepted **(probed)**. | | |
| 15 | +| Beta base URL | `https://api.deepseek.com/beta` — required for chat **prefix completion**, FIM `/completions`, `strict` tools. Normal chat completions also work on `/beta` **(probed)**. | | |
| 16 | +| Anthropic-compatible base URL | `https://api.deepseek.com/anthropic` (`/anthropic/v1/messages`, `x-api-key` header) **(probed 200)** — see §11 | | |
| 17 | +| Auth | `Authorization: Bearer <DEEPSEEK_API_KEY>` | | |
| 18 | +| Content type | `application/json`. Malformed JSON → **400** with an OpenAI-shaped error (serde message, e.g. `Failed to deserialize the JSON body into the target type: messages: invalid type: string "nope", expected a sequence at line 1 column 46`); a syntactically broken body → 400 **text/plain** `Failed to parse the request body as JSON: …` **(probed)** | | |
| 19 | +| Response headers **(probed)** | Only `x-ds-trace-id` (request id), `server: elb`, `via: … cloudfront.net`. **No rate-limit headers**, no `retry-after` observed. | | |
| 20 | +| Key introspection | `GET /user/balance` → `{"is_available":true,"balance_infos":[{"currency":"USD","total_balance":"49.94","granted_balance":"0.00","topped_up_balance":"49.94"}]}` **(probed)**. `GET /models` also validates the key (401 on bad key). Both are free → good for a "validate key" button. | | |
| 21 | + | |
| 22 | +## 2. SDK recommendation (TypeScript / Node) | |
| 23 | + | |
| 24 | +- DeepSeek has **no SDK of its own**; the official docs use the **OpenAI SDK** (`npm install openai`) with `baseURL: "https://api.deepseek.com"` and the **Anthropic SDK** with `ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic`. | |
| 25 | +- The docs' Node sample passes `thinking: {type:"enabled"}` and `reasoning_effort: "high"` directly in `chat.completions.create()` (the TS SDK lets extra fields through; in Python they need `extra_body`). | |
| 26 | +- **Recommendation for PolyLLM:** OpenAI SDK **`openai@7.10.0`** (probed) with `baseURL: https://api.deepseek.com`, `maxRetries: 0` (we handle retries), long `timeout` (thinking at `max` on hard prompts + the documented 10-minute queue → ≥ 10 min). The OpenAI SDK already tolerates the SSE `: keep-alive` comment lines DeepSeek sends under load (docs); a hand-written parser must skip lines starting with `:`. `delta.reasoning_content` is not in the SDK types → cast. | |
| 27 | + | |
| 28 | +## 3. Endpoints | |
| 29 | + | |
| 30 | +| Endpoint | Status | Notes | | |
| 31 | +|---|---|---| | |
| 32 | +| `POST /chat/completions` | **primary** | OpenAI-compatible, thinking mode via `thinking` / `reasoning_effort`, `reasoning_content` in message and deltas. **(probed)** | | |
| 33 | +| `GET /models` | active | `{object:"list", data:[{id, object:"model", owned_by:"deepseek"}]}` — 3 models, no metadata. **(probed)** | | |
| 34 | +| `GET /user/balance` | active | See §1. **(probed)** | | |
| 35 | +| `POST /responses` | active (Aug 2026) | OpenAI Responses API, **stateless** (`store:false` always, `previous_response_id` unsupported), `reasoning.effort` `none|low|high|max`, reasoning item `{type:"reasoning", content:[{type:"reasoning_text"}]}`, events `response.reasoning_text.delta` etc. Server-side `web_search` tool + `apply_patch`. **(probed: streaming + effort none)** | | |
| 36 | +| `POST /beta/chat/completions` with `prefix: true` | beta | Chat prefix completion; outside `/beta` → 400 `prefix is only available when using beta api (set base_url="https://api.deepseek.com/beta")` **(probed)** | | |
| 37 | +| `POST /beta/completions` | beta | FIM (`prompt` + `suffix`, `max_tokens` ≤ 4K docs); docs say pro only / non-thinking only, but **flash answered too** **(probed)**; vision-exp unsupported (docs). | | |
| 38 | +| `POST/GET/DELETE /files` | active (free) | Images only (JPEG/PNG/GIF/WebP ≤ 64 MiB, 25 GiB / 10 000 files per user, optional `expires_after` 1 h–30 d); referenced as `{type:"file", file_id}` content parts for the vision model. `GET /files` → `{object:"list", data:[], has_more:false}` **(probed)** | | |
| 39 | +| `/anthropic/v1/messages` | active | Anthropic Messages format; see §11. | | |
| 40 | +| Batch API, embeddings, image generation, audio | **do not exist** | Text (+ image input on one model) only. | | |
| 41 | + | |
| 42 | +## 4. Chat Completions request/response (probed shapes) | |
| 43 | + | |
| 44 | +Request body fields (docs API ref + probes): `model`, `messages`, `max_tokens` (integer, **valid range `[1, 393216]`** = 384K **(probed)**; **caps reasoning + visible output together**, see §8; default not documented), `temperature` 0–2 (default 1), `top_p` (0, 1] (default 1), `stop` (string | array, **≤ 16**), `stream`, `stream_options.include_usage`, `response_format` `{type:"text"|"json_object"}`, `tools` (≤ 128), `tool_choice` (`none|auto|required|{type:"function",function:{name}}`), `logprobs`, `top_logprobs` 0–20, `thinking` `{type:"enabled"|"disabled"|"adaptive"(undocumented), reasoning_effort?}`, `reasoning_effort` (`low|high|max`; `medium`/`xhigh` mapped to `high`; **`none` and `minimal` also accepted (undocumented)**), `user_id` (`[a-zA-Z0-9\-_]`, ≤ 512, used for rate-limit/KV-cache/safety isolation), deprecated & ignored: `frequency_penalty`, `presence_penalty`. | |
| 45 | + | |
| 46 | +Roles: `system`, `user`, `assistant`, `tool` (+ an undocumented internal `latest_reminder`). **`developer` role → 400** `messages[0].role: unknown variant `developer`, expected one of `system`, `user`, `assistant`, `tool`, `latest_reminder`` **(probed)** — map developer → system. `name` on messages accepted. Unknown top-level params (`foo_bar`, `user`, `seed`, `parallel_tool_calls`, `max_completion_tokens`) are **silently ignored** **(probed: `max_completion_tokens: 50` did not cap output — always send `max_tokens`)**. | |
| 47 | + | |
| 48 | +Non-streaming response **(probed, deepseek-v4-pro)**: | |
| 49 | + | |
| 50 | +```json | |
| 51 | +{ | |
| 52 | + "id": "f09673ec-…", "object": "chat.completion", "created": 1788849178, "model": "deepseek-v4-pro", | |
| 53 | + "choices": [{ "index": 0, "finish_reason": "stop", "logprobs": null, | |
| 54 | + "message": { "role": "assistant", "content": "2+2 equals 4.", | |
| 55 | + "reasoning_content": "We need answer user asks simple. … final." } }], | |
| 56 | + "usage": { "prompt_tokens": 96, "completion_tokens": 46, "total_tokens": 142, | |
| 57 | + "prompt_tokens_details": { "cached_tokens": 0 }, | |
| 58 | + "completion_tokens_details": { "reasoning_tokens": 38 }, | |
| 59 | + "prompt_cache_hit_tokens": 0, "prompt_cache_miss_tokens": 96 }, | |
| 60 | + "system_fingerprint": "a307abda487cd1b463329ccb945ce396" | |
| 61 | +} | |
| 62 | +``` | |
| 63 | + | |
| 64 | +- **`completion_tokens` INCLUDES reasoning tokens** (46 = 38 reasoning + 8 visible); `total_tokens = prompt + completion`. Bill all `completion_tokens` at the output price. | |
| 65 | +- Cache: both the OpenAI-style `prompt_tokens_details.cached_tokens` and DeepSeek's `prompt_cache_hit_tokens` / `prompt_cache_miss_tokens` are present (`hit + miss = prompt_tokens`). `completion_tokens_details` is **absent** when thinking is disabled **(probed)**. | |
| 66 | +- `finish_reason`: `stop`, `length` **(probed)**, `tool_calls` **(probed)**, `content_filter`, `insufficient_system_resource` (docs: "request interrupted due to insufficient resource of the inference system" — treat as retryable). | |
| 67 | +- Hidden system prompt is tiny: a 6-token user message costs `prompt_tokens: 6` **(probed)** — no hidden overhead (unlike xAI). | |
| 68 | +- `system_fingerprint` is stable per model per day (`a26a79…` flash, `a307ab…` pro). | |
| 69 | + | |
| 70 | +## 5. Streaming protocol **(probed)** | |
| 71 | + | |
| 72 | +SSE `text/event-stream; charset=utf-8`, `data: {json}` lines, terminated by `data: [DONE]`. Under load the server emits `: keep-alive` comment lines (streaming) or empty lines (non-streaming) (docs; not observed on short requests). Connections that have not started inference after **10 minutes** are closed (docs). | |
| 73 | + | |
| 74 | +Chunk sequence in thinking mode (deepseek-v4-flash): | |
| 75 | + | |
| 76 | +```json | |
| 77 | +data: {"id":"cb67…","object":"chat.completion.chunk","created":1788849177,"model":"deepseek-v4-flash","system_fingerprint":"a26a…", | |
| 78 | + "choices":[{"index":0,"delta":{"role":"assistant","content":null,"reasoning_content":""},"logprobs":null,"finish_reason":null}],"usage":null} | |
| 79 | +data: {…"choices":[{"index":0,"delta":{"content":null,"reasoning_content":"We"},…}],"usage":null} | |
| 80 | +… | |
| 81 | +data: {…"choices":[{"index":0,"delta":{"content":"Bon","reasoning_content":null},…}],"usage":null} | |
| 82 | +… | |
| 83 | +data: {…"choices":[{"index":0,"delta":{"content":"","reasoning_content":null},"logprobs":null,"finish_reason":"stop"}], | |
| 84 | + "usage":{"prompt_tokens":93,"completion_tokens":48,"total_tokens":141,"prompt_tokens_details":{"cached_tokens":0}, | |
| 85 | + "completion_tokens_details":{"reasoning_tokens":44},"prompt_cache_hit_tokens":0,"prompt_cache_miss_tokens":93}} | |
| 86 | +data: [DONE] | |
| 87 | +``` | |
| 88 | + | |
| 89 | +- In thinking mode **both keys are always present** in every delta, one of them `null` (`content: null` while reasoning, `reasoning_content: null` while answering). In non-thinking mode (`thinking.disabled` / `reasoning_effort: "none"`) deltas only have `content` (first delta `{"role":"assistant","content":""}`) **(probed)**. Check `if (d.reasoning_content)` / `if (d.content)` — never `!== undefined`. | |
| 90 | +- **Usage arrives on the `finish_reason` chunk itself, not in a separate `choices: []` chunk, and it is sent even WITHOUT `stream_options.include_usage`** **(probed both ways)**. `include_usage: true` is accepted and changes nothing. Adapter: read `chunk.usage` whenever non-null, don't require an empty-choices chunk. | |
| 91 | +- Reasoning deltas start within ~300 ms — good liveness signal; a 5-word answer took 1.5 s (flash) / 2.6 s (pro) end to end incl. reasoning. | |
| 92 | + | |
| 93 | +## 6. Tool / function calling **(probed on all 3 models, thinking on and off)** | |
| 94 | + | |
| 95 | +- OpenAI nested format `{type:"function", function:{name, description, parameters}}`; `tool_choice` `auto` (default) / `required` / `none` / `{type:"function",function:{name}}` all behave as expected **(probed)**; ≤ 128 tools (docs). `parallel_tool_calls` is ignored but **parallel calls happen natively**: "Montreal and Quebec City" → two `tool_calls` (`index` 0 and 1) in one message **(probed)**. Tool-call ids look like `call_00_<24 chars>`, `call_01_…` (the `_NN_` is the index). | |
| 96 | +- Round trip `assistant.tool_calls` + `{role:"tool", tool_call_id, content}` works in both modes on all 3 models **(probed)**. | |
| 97 | +- **Streaming: standard OpenAI incremental deltas** (unlike xAI): first chunk `{"index":0,"id":"call_00_…","type":"function","function":{"name":"get_weather","arguments":""}}`, then ~10 chunks `{"index":0,"function":{"arguments":"{"}}` … `{"arguments":"}"}`, then `finish_reason: "tool_calls"` with usage **(probed)**. Standard accumulation by `index` required. | |
| 98 | +- In thinking mode the tool-call message also carries `reasoning_content` (and possibly `content` text before the call) — docs' "interleaved thinking": the model may reason → call → reason → call … inside one user turn. | |
| 99 | +- **`strict: true` (beta base URL)**: works **(probed 200)**. Requires every property `required` + `additionalProperties: false`; supported schema: object/string/number/integer/boolean/array/enum/anyOf/$ref+$defs; `pattern`, `format` (email/hostname/ipv4/ipv6/uuid), numeric bounds/`multipleOf`; **not** `minLength`/`maxLength`/`minItems`/`maxItems` (docs). Non-conforming schema → error. | |
| 100 | +- **`reasoning_content` replay rule (docs vs probes)**: docs say that when `tools` is present, *"the reasoning_content of all previous turns should be passed back… even for turns where the model did not perform a tool call. If your code does not correctly pass back reasoning_content, the API will return a 400 error."* **Probed on V4: NO 400** — stripping `reasoning_content` from the tool-call message, from the final answer, from all assistant messages, or sending `""`/`null`, all returned 200 and a sensible answer (`research/deepseek/out/09-…json`). The 400 is evidently a V3.2-era rule that V4 no longer enforces, but **follow the docs anyway**: store `reasoning_content` with every assistant message and send it back verbatim whenever the request has `tools` — it is free (cached prefix) and keeps chain-of-thought continuity across sub-turns. Without `tools`, replayed `reasoning_content` is accepted and *ignored* (docs + probed: bogus reasoning did not change the answer; cache hit identical). | |
| 101 | + | |
| 102 | +## 7. Structured output **(probed)** | |
| 103 | + | |
| 104 | +- `response_format: {type:"json_object"}` works on all 3 models, thinking on **and** off → valid JSON **(probed)**. The prompt **must contain the word "json"** or → 400 `Prompt must contain the word 'json' in some form to use 'response_format' of type 'json_object'.` **(probed)**. Docs: also give a format example, set `max_tokens` high enough, and "the API may occasionally return empty content" (not observed). | |
| 105 | +- **`json_schema` is NOT available**: 400 `This response_format type is unavailable now` on all models **(probed)**. Workaround for schema-constrained output: `strict` function calling on `/beta` (§6) with `tool_choice` forcing the function. | |
| 106 | +- Responses API: `text.format` supported (docs, "format only"). | |
| 107 | + | |
| 108 | +## 8. Reasoning controls **(probed matrix)** | |
| 109 | + | |
| 110 | +All three models are **hybrid**: thinking **enabled by default at effort `high`** (docs + probed: `reasoning_content` present with no flags). Same behaviour on flash, pro and vision-exp. | |
| 111 | + | |
| 112 | +| Control | Effect **(probed)** | | |
| 113 | +|---|---| | |
| 114 | +| `thinking: {type:"disabled"}` | no `reasoning_content`, no `completion_tokens_details`; fastest | | |
| 115 | +| `thinking: {type:"enabled"}` | default | | |
| 116 | +| `thinking: {type:"adaptive"}` | **accepted (undocumented; leaked from the Anthropic-format vocabulary)**, behaves like `enabled` (11 reasoning tokens on "pong", 90 on a riddle — same as enabled). Error text reveals the enum: `thinking.type: unknown variant `auto`, expected one of `adaptive`, `enabled`, `disabled`` | | |
| 117 | +| `thinking: {type:"enabled", reasoning_effort:"low"}` (nested) | accepted | | |
| 118 | +| `reasoning_effort: "low" \| "high" \| "max"` (top-level) | accepted; docs mapping `medium→high`, `xhigh→high` | | |
| 119 | +| `reasoning_effort: "none"` | **disables thinking** (equivalent to `thinking.disabled`) — undocumented on chat completions, documented for Responses `reasoning.effort` | | |
| 120 | +| `reasoning_effort: "minimal"` | accepted (undocumented; presumably → low) | | |
| 121 | +| `reasoning_effort: "low"` + `thinking.disabled` | thinking stays disabled (disabled wins) | | |
| 122 | + | |
| 123 | +- Reasoning-token counts on trivial prompts are tiny (8–40 tokens) and effort levels are not monotonic on easy prompts (the model decides); on a riddle: low 367 / high 97 / max 184 reasoning tokens at ~2–3 s — treat `reasoning_effort` as a hint, not a budget. **No thinking-budget parameter.** | |
| 124 | +- **`max_tokens` caps reasoning + answer together**: `max_tokens: 20` in thinking mode → `finish_reason: "length"`, `reasoning_tokens: 20`, **`content: ""`** **(probed)**; same with a 32×32 image at `max_tokens: 200` (all 200 spent on reasoning). Adapter: in thinking mode never send a small `max_tokens`; default to several thousand (range max 393 216) and surface "answer truncated during reasoning" when `content` is empty and `finish_reason === "length"`. | |
| 125 | +- Thinking mode **ignores** `temperature`, `top_p`, `presence_penalty`, `frequency_penalty` silently (docs: "will not trigger an error but will also have no effect"; probed: accepted, still range-validated → 400 outside range). | |
| 126 | +- Responses API `reasoning.effort` accepts `none|low|high|max` **(probed none/low)**; `reasoning.summary` / `encrypted_content` unsupported (docs) — the API nevertheless returns an `encrypted_content` string that is just an id. | |
| 127 | +- `reasoning_content` in the *last* assistant message is also the CoT prefix input for **chat prefix completion** on `/beta` (API ref). | |
| 128 | + | |
| 129 | +## 9. Sampling & other parameters — support matrix **(probed, chat completions, identical on the 3 models)** | |
| 130 | + | |
| 131 | +| Param | Result | Exact error | | |
| 132 | +|---|---|---| | |
| 133 | +| `temperature` | ✓ 0–2 (ignored in thinking mode) | `2.5`/`-1` → 400 `Invalid temperature value, the valid range of temperature is [0, 2]` | | |
| 134 | +| `top_p` | ✓ (0, 1] (ignored in thinking mode) | `1.5` → 400 `Invalid top_p value, the valid range of top_p is (0, 1.0]` | | |
| 135 | +| `max_tokens` | ✓ | `400000` → 400 `Invalid max_tokens value, the valid range of max_tokens is [1, 393216]` | | |
| 136 | +| `max_completion_tokens` | **silently ignored** (no cap applied) | — | | |
| 137 | +| `stop` (≤ 16) | ✓ applies to visible content (thinking mode too) | 17 items → 400 `Stop string array too long: 17` | | |
| 138 | +| `frequency_penalty` / `presence_penalty` | accepted, **no effect** (deprecated) | — | | |
| 139 | +| `logprobs` + `top_logprobs` 0–20 | ✓ (`choices[0].logprobs.content[]` with `token`, `logprob`, `bytes`, `top_logprobs[]`) | `25` → 400 `Invalid top_logprobs value, the valid range of top_logprobs is [0, 20].` | | |
| 140 | +| `n` | **only 1** | `n: 2` → 400 `Invalid n value (currently only n = 1 is supported)` | | |
| 141 | +| `seed` | silently ignored | — | | |
| 142 | +| `response_format` `text` / `json_object` | ✓ | `json_schema` → 400 `This response_format type is unavailable now` | | |
| 143 | +| `tools` / `tool_choice` | ✓ | — | | |
| 144 | +| `parallel_tool_calls` | ignored (parallel calls always possible) | — | | |
| 145 | +| `thinking`, `reasoning_effort` | see §8 | `thinking.type: "auto"` → 400 serde enum error | | |
| 146 | +| `user_id` | ✓ (isolation) ; `user` ignored | — | | |
| 147 | +| `developer` role | **400** | see §4 | | |
| 148 | +| unknown params | silently ignored | — | | |
| 149 | + | |
| 150 | +**Adapter rule:** send `max_tokens` (never `max_completion_tokens`), map `developer`→`system`, drop `n>1`/`seed`/penalties, offer `temperature`/`top_p` only when thinking is off, expose `reasoning_effort` as `none|low|high|max` (translate `none` → `thinking:{type:"disabled"}` for clarity), expose `json_object` only (no `json_schema`). | |
| 151 | + | |
| 152 | +## 10. Modalities, context, output limits | |
| 153 | + | |
| 154 | +- **Input**: text on all 3; **images only on `deepseek-v4-flash-vision-exp`**. **Output**: text only. No audio, no image generation, no files other than images. | |
| 155 | +- **Images on non-vision models are NOT rejected**: `deepseek-v4-flash` / `deepseek-v4-pro` return 200 and **silently replace the image with a placeholder** (+5 prompt tokens; pro said *"No. I received text only, with a placeholder indicating an unsupported image."*, flash said the image "wasn't successfully attached", pro once **hallucinated** a description) **(probed)** — contradicts the vision guide ("400 This model does not support image"). PolyLLM must block image attachments client-side via `capabilities.vision` — the API will not tell the user. | |
| 156 | +- Vision model **(probed)**: 32×32 PNG data URL → 200, correct description ("4x4 checkerboard … red and royal blue"); **2×2 accepted** (no minimum); `detail: low|high|original|auto` accepted (`low` = downscale to 512², `high`=`original`=`auto` keep original — docs); image in a **system** message → 400 `Image in system message is unsupported`; garbage bytes → 400 `.messages[0].image[0]: You have uploaded an unsupported image. Please make sure your image is valid and has one of the following formats: webp, png, jpeg, and gif.`; images in assistant messages also rejected (docs). Tools + JSON + thinking all work on the vision model **(probed)**. | |
| 157 | +- Image tokens **(probed)**: a 32×32 image cost **~111 prompt tokens** (131 vs 20 text-only; 103 with `detail: low`) — small images are **upscaled to ~384×384** (docs) so there is a floor of ~100 tokens; ceiling **384 tokens per image** (images downscaled to ~800×800 area). Billed at the text input price. Limits (docs): JPEG/PNG/GIF/WebP (sniffed, not by MIME), ≤ 32 MiB inline / 64 MiB via Files API, request body ≤ 48 MiB, ≤ 600 images per request, side ≤ 8192 px (4096 px when ≥ 15 images), URL ≤ 8192 chars, download ≤ 60 s. | |
| 158 | +- **Context window: 1M tokens on all 3 models** (docs pricing table). **Max output: 384K (`max_tokens` ≤ 393 216)**, no documented default — always send `max_tokens`. | |
| 159 | +- Files API: images only, `{type:"file", file_id}` or `{type:"file", file_data:"data:image/…;base64,…", filename}` content parts (mutually exclusive), `detail` ignored for `file_id`. | |
| 160 | + | |
| 161 | +## 11. Anthropic-compatible & Responses surfaces (secondary) | |
| 162 | + | |
| 163 | +- `POST /anthropic/v1/messages` with `x-api-key` + `anthropic-version` → 200 **(probed)**; response is a real Messages object with `content: [{type:"thinking", thinking:"…", signature:"<message id>"}, {type:"text", text}]`, `usage: {input_tokens, cache_creation_input_tokens, cache_read_input_tokens, output_tokens, service_tier:"standard"}`. `thinking.budget_tokens` is **ignored** (docs; probed: accepted), `output_config.effort` `low|high|max` controls effort; `reasoning.effort: none` disables. **Claude model names are mapped**: `claude-sonnet-4-5` → `deepseek-v4-flash` (response `model` field says so) **(probed)**; Opus names → `deepseek-v4-pro`, everything else → flash (docs). Unsupported: `top_k`, `service_tier`, `container`, `mcp_servers`, document/search-result blocks, MCP/code-execution blocks (docs). Useful only if PolyLLM ever reuses its Anthropic adapter; not needed for the OpenAI-style adapter. | |
| 164 | +- `POST /responses` **(probed)**: stateless (`store: false` echoed, `previous_response_id: null`), `reasoning.effort` `none|low|high|max`, output items `reasoning` (`content:[{type:"reasoning_text", text}]`, `summary: []`) + `message` (`output_text`, extra `phase: "final_answer"`), events `response.created`, `response.in_progress`, `response.output_item.added/done`, `response.content_part.added/done`, `response.reasoning_text.delta/done`, `response.output_text.delta/done`, `response.function_call_arguments.delta/done`, `response.completed|incomplete|failed`. `max_output_tokens: 100` on a reasoning request → `status: "incomplete"`, `incomplete_details.reason: "max_output_tokens"` (reasoning ate the budget). Server-side `web_search` tool exists here only (≤ 10 rounds, docs) — the only search option DeepSeek offers; no pricing published. | |
| 165 | + | |
| 166 | +## 12. Model listing & pricing **(docs pricing page, fetched 2026-09-08; USD per 1M tokens)** | |
| 167 | + | |
| 168 | +`GET /models` returns only ids — **no context/pricing metadata**; hardcode from the table. | |
| 169 | + | |
| 170 | +| Model | Version (docs) | ctx | max out | cache-hit in (peak / off-peak) | cache-miss in (peak / off-peak) | out (peak / off-peak) | concurrency | | |
| 171 | +|---|---|---|---|---|---|---|---| | |
| 172 | +| deepseek-v4-flash | DeepSeek-V4-Flash-0731 | 1M | 384K | 0.014 / 0.007 | 0.44 / 0.22 | 1.32 / 0.66 | 2500 | | |
| 173 | +| deepseek-v4-pro | DeepSeek-V4-Pro-0813 | 1M | 384K | 0.044 / 0.022 | 1.32 / 0.66 | 3.96 / 1.98 | 500 | | |
| 174 | +| deepseek-v4-flash-vision-exp | DeepSeek-V4-Flash-Vision-Exp | 1M | 384K | 0.014 / 0.007 | 0.44 / 0.22 | 1.32 / 0.66 | 2500 | | |
| 175 | + | |
| 176 | +- **Peak hours: 01:00–04:00 and 06:00–10:00 UTC, Monday–Friday; all other hours are off-peak at 50 %** (effective 2026-08-16 16:00 UTC). Cost display must pick the tariff from the request's UTC timestamp + weekday. Cache-hit input is **1/32 of cache-miss** (≈ 97 % discount). | |
| 177 | +- Reasoning tokens are part of `completion_tokens` → output price. Image tokens → input price (hit/miss applies). | |
| 178 | +- Billing currency per account (`/user/balance` → USD here); granted balance consumed first (docs). 402 when out of balance. | |
| 179 | +- Same architecture/tokenizer for flash and vision-exp ("matches DeepSeek-V4-Flash on text capabilities"). | |
| 180 | + | |
| 181 | +## 13. Prompt caching & provider-side state | |
| 182 | + | |
| 183 | +- **Automatic for everyone, no code change, no minimum documented**; cache units are created after each user input, after each model output, and at fixed token intervals for long content; a hit requires **exact match of whole cache units** (partial overlaps don't hit — DeepSeek Sparse Attention constraint). Cache built in seconds, evicted "within a few hours to a few days" when unused; best effort (docs). | |
| 184 | +- **(probed)**: turn 2 of a conversation reported `prompt_cache_hit_tokens: 256` / `miss 63` for a 319-token prompt; a tool-loop step 2 hit 256 then 384 — hits come in **multiples of 64 tokens** (256, 384). Cache hits are reported both as `prompt_cache_hit_tokens` and `prompt_tokens_details.cached_tokens`. Replaying `reasoning_content` did not change hit counts. | |
| 185 | +- `user_id` isolates KV cache per end user (privacy) — for a multi-user BYOK app set `user_id` to a hashed PolyLLM user id (no PII, `[a-zA-Z0-9\-_]`, ≤ 512). | |
| 186 | +- **No server-side state at all**: `/chat/completions` and `/responses` are stateless; nothing to delete. Files API stores uploaded images (set `expires_after`). | |
| 187 | + | |
| 188 | +## 14. Errors, rate limits, retries | |
| 189 | + | |
| 190 | +Error body: OpenAI-shaped `{"error":{"message","type","param":null,"code"}}` (`type` `authentication_error` | `invalid_request_error`; `code` always `invalid_request_error` so far). Some 400s are served with `content-type: application/octet-stream` — parse the body as JSON regardless of the header **(probed)**. | |
| 191 | + | |
| 192 | +| Case **(probed)** | HTTP | body | | |
| 193 | +|---|---|---| | |
| 194 | +| Invalid key | **401** | `{"error":{"message":"Authentication Fails, Your api key: ****0000 is invalid","type":"authentication_error","param":null,"code":"invalid_request_error"}}` (same on `/models`) | | |
| 195 | +| No Authorization header | 401 | text/plain `Authentication Fails (governor)` | | |
| 196 | +| Unknown model | 400 | `The supported API model names are deepseek-v4-pro, deepseek-v4-flash, and deepseek-v4-flash-vision-exp, but you passed deepseek-v99.` | | |
| 197 | +| Empty `messages` | 400 | `Empty input messages` | | |
| 198 | +| Missing `model` | 400 | `Failed to deserialize the JSON body into the target type: missing field `model` …` | | |
| 199 | +| Bad enum (role, thinking.type) | 400 | serde `unknown variant …, expected one of …` | | |
| 200 | +| Out-of-range param | 400 | see §9 | | |
| 201 | +| Malformed JSON | 400 | text/plain `Failed to parse the request body as JSON: …` | | |
| 202 | +| Insufficient balance | 402 (docs) | — | | |
| 203 | +| Invalid parameters | 422 (docs) | — | | |
| 204 | +| Concurrency limit | 429 (docs) | 2500 (flash, vision) / 500 (pro) concurrent requests per account, per `user_id` when quota was expanded | | |
| 205 | +| Server error / overloaded | 500 / 503 (docs) | retry after a short delay | | |
| 206 | + | |
| 207 | +**Retry/timeout recommendation for the adapter:** retry 429/500/503 (and `finish_reason: "insufficient_system_resource"`) with jittered exponential backoff; never retry 400/401/402/422; do not retry mid-stream; read timeout ≥ 10 min (server may queue up to 10 min before inference and sends keep-alive comments meanwhile). 401 maps cleanly to *invalid credentials*; 402 → *out of balance* (show link to `platform.deepseek.com`). | |
| 208 | + | |
| 209 | +## 15. Lifecycle / aliases | |
| 210 | + | |
| 211 | +- **`deepseek-chat` and `deepseek-reasoner` were announced as retired 2026-07-24 15:59 UTC** (V4 launch note) and are no longer listed by `/models` or the docs, **but they still resolve** **(probed 2026-09-08)**: `deepseek-chat` → `deepseek-v4-flash` non-thinking, `deepseek-reasoner` → `deepseek-v4-flash` thinking (response `model: "deepseek-v4-flash"`). Do not list them; if a user types one, show the redirect and expect it to break any day. | |
| 212 | +- Model names are **stable rolling aliases**: `deepseek-v4-flash` = V4-Flash-0731 (GA 2026-07-31), `deepseek-v4-pro` = V4-Pro-0813 (GA 2026-08-13), `deepseek-v4-flash-vision-exp` released 2026-08-21 as **experimental** (may change/disappear). No dated snapshot ids are exposed. | |
| 213 | +- Timeline (`/updates`): V4 preview 2026-04-24 (1M ctx, dual modes, OpenAI + Anthropic APIs) → Flash GA 07-31 (Responses API) → Pro GA 08-13 (3 effort levels, peak/off-peak pricing from 08-16) → Vision-Exp + Files API 08-21. DeepSeek Harness (agent framework) in developer preview. | |
| 214 | +- `latest_reminder` role and `thinking.type: "adaptive"` exist in the schema but are undocumented — do not rely on them. | |
| 215 | + | |
| 216 | +## 16. Exact streaming code that worked | |
| 217 | + | |
| 218 | +OpenAI SDK (`openai@7.10.0`) — chat completions with reasoning + tools + usage: | |
| 219 | + | |
| 220 | +```ts | |
| 221 | +import OpenAI from "openai"; | |
| 222 | +const client = new OpenAI({ apiKey: process.env.DEEPSEEK_API_KEY, baseURL: "https://api.deepseek.com", timeout: 660_000, maxRetries: 0 }); | |
| 223 | + | |
| 224 | +const stream = await client.chat.completions.create({ | |
| 225 | + model: "deepseek-v4-flash", | |
| 226 | + messages, // assistant msgs keep `reasoning_content` when `tools` is sent | |
| 227 | + tools, | |
| 228 | + stream: true, | |
| 229 | + max_tokens: 8192, // caps reasoning + answer together; never small in thinking mode | |
| 230 | + ...(thinking ? { reasoning_effort: "high" } : { thinking: { type: "disabled" }, temperature: 0.7 }), | |
| 231 | + user_id: hashedUserId, // KV-cache / rate-limit isolation | |
| 232 | +} as any); | |
| 233 | + | |
| 234 | +const calls: Record<number, { id?: string; name?: string; args: string }> = {}; | |
| 235 | +for await (const chunk of stream) { | |
| 236 | + const c = chunk.choices[0]; const d = (c?.delta ?? {}) as any; | |
| 237 | + if (d.reasoning_content) onThinking(d.reasoning_content); // null while answering | |
| 238 | + if (d.content) onText(d.content); // null while thinking | |
| 239 | + for (const tc of d.tool_calls ?? []) { const s = (calls[tc.index] ??= { args: "" }); if (tc.id) s.id = tc.id; if (tc.function?.name) s.name = tc.function.name; if (tc.function?.arguments) s.args += tc.function.arguments; } | |
| 240 | + if (c?.finish_reason) onFinish(c.finish_reason); // "stop" | "length" | "tool_calls" | … | |
| 241 | + if (chunk.usage) onUsage(chunk.usage); // on the finish chunk, even without include_usage | |
| 242 | +} | |
| 243 | +``` | |
| 244 | + | |
| 245 | +Raw fetch SSE (used in `research/deepseek/lib.ts` → `rawSSE`): POST JSON, read `res.body` with `TextDecoder`, split on `\n\n`, **skip lines starting with `:`** (keep-alive), take `data:` lines, `JSON.parse`, stop at `[DONE]`. | |
| 246 | + | |
| 247 | +## 17. Probe results table | |
| 248 | + | |
| 249 | +| # | Probe | Model(s) | Result | | |
| 250 | +|---|---|---|---| | |
| 251 | +| 00 | `GET /models`, `/v1/models`, `/user/balance`, `/v1/user/balance`, `POST /v1/chat/completions`, `/beta/chat/completions`, `GET /files` | — | all 200; 3 models; `/v1` prefix and `/beta` accepted; balance USD; only `x-ds-trace-id` header | | |
| 252 | +| 01a | tiny chat completion, default flags, `max_tokens: 200` | 3 models | 200, `reasoning_content` present on all (thinking default), 16–38 reasoning tokens for "2+2", 1.1–2.1 s | | |
| 253 | +| 01b | streaming + `include_usage` | 3 models | delta keys `role`/`content`/`reasoning_content` (both keys present, one null); usage on the `finish_reason` chunk; `[DONE]`; no keep-alive comments seen | | |
| 254 | +| 02 | param matrix (43 variants × 3 models) | 3 models | identical on all models — see §9; ranges `[0,2]`, `(0,1]`, `[1,393216]`, stop ≤ 16, top_logprobs ≤ 20, `n=1` only, `json_schema` unavailable, `developer` 400, `thinking.type` enum `adaptive|enabled|disabled`, `reasoning_effort none/minimal` accepted | | |
| 255 | +| 03 | streamed function call round trip, thinking on (with/without reasoning replay) and off; `tool_choice` ×4; parallel; `strict` on `/beta` | 3 models | incremental `tool_calls` deltas (11 chunks), `finish_reason: tool_calls`; round 2 OK in all 9 combos (**no 400 without reasoning_content**); required/none/specific/auto OK; 2 parallel calls; strict 200 | | |
| 256 | +| 04 | `json_object`, thinking on/off; prompt without "json" | 3 models | valid JSON ×6; 400 `Prompt must contain the word 'json'…` | | |
| 257 | +| 05 | vision: 32×32 PNG on 3 models; 2×2; `detail` low/original; image in system; thinking+image; garbage bytes | 3 models | flash/pro **200 with image silently dropped** (+5 tokens); vision-exp correct (~111 image tokens, 103 at low); 2×2 OK; system image 400; thinking+image at `max_tokens 200` → empty content (all reasoning); bad bytes 400 | | |
| 258 | +| 06 | invalid key / no auth / unknown model / retired slugs / malformed body / empty messages / bad role | — | 401 / 401 text / 400 / **200 served by v4-flash** / 400 / 400 / 400 | | |
| 259 | +| 07 | `reasoning_effort` low/high/max at `max_tokens 2000`; `max_tokens 20` truncation; multi-turn with / without / bogus `reasoning_content` (no tools); `reasoning_content` while thinking disabled | flash | 367/97/184 reasoning tokens; `length` with `content: ""`; all 3 replays 200, same answer, 256 cached tokens; accepted | | |
| 260 | +| 08 | Anthropic `/anthropic/v1/messages` (+ `claude-sonnet-4-5` alias); `/responses` stream + `effort: none`; `/beta` prefix (+ without beta); `/beta` FIM flash + pro | flash/pro | 200 thinking blocks, alias → flash; 9 event types, `incomplete` on small budget, none → 0 reasoning; prefix OK / 400 without beta; FIM OK on both | | |
| 261 | +| 09 | tool loop turn 1 (call → result → answer) then turn 2 stripping `reasoning_content` in 6 ways; no `tools` on turn 2; thinking disabled on turn 2 | flash | **all 200** — the documented 400 is not enforced on V4 | | |
| 262 | +| 10 | `thinking.adaptive` easy/hard; stream w/o `include_usage`; `effort none` stream; logprobs shape; image on pro; trailing slash | flash/pro | adaptive ≈ enabled; usage still present; deltas without `reasoning_content` key; logprobs `content[].{token,logprob,bytes,top_logprobs}`; pro: "placeholder indicating an unsupported image"; 200 | | |
| 263 | + | |
| 264 | +## Documentation pages used (all fetched 2026-09-08) | |
| 265 | + | |
| 266 | +- https://api-docs.deepseek.com/ (Your First API Call) · https://api-docs.deepseek.com/quick_start/pricing/ (trailing slash required) · https://api-docs.deepseek.com/quick_start/rate_limit · https://api-docs.deepseek.com/quick_start/error_codes · https://api-docs.deepseek.com/quick_start/token_usage | |
| 267 | +- https://api-docs.deepseek.com/api/deepseek-api · https://api-docs.deepseek.com/api/create-chat-completion · https://api-docs.deepseek.com/api/list-models · https://api-docs.deepseek.com/api/get-user-balance (also in sitemap: create-completion, create-response, create-file, list-files, retrieve-file, delete-file) | |
| 268 | +- https://api-docs.deepseek.com/guides/thinking_mode (replaces `guides/reasoning_model`) · https://api-docs.deepseek.com/guides/tool_calls (replaces `guides/function_calling`) · https://api-docs.deepseek.com/guides/json_mode · https://api-docs.deepseek.com/guides/multi_round_chat · https://api-docs.deepseek.com/guides/kv_cache · https://api-docs.deepseek.com/guides/anthropic_api · https://api-docs.deepseek.com/guides/fim_completion · https://api-docs.deepseek.com/guides/chat_prefix_completion · https://api-docs.deepseek.com/guides/vision · https://api-docs.deepseek.com/guides/responses_api · https://api-docs.deepseek.com/guides/files_api | |
| 269 | +- https://api-docs.deepseek.com/updates (changelog; `/news/` redirects) · https://api-docs.deepseek.com/news/news260424 (V4 launch, deprecation of deepseek-chat/reasoner) · https://api-docs.deepseek.com/news/news260813 (V4-Pro GA, effort levels, peak/off-peak) · https://api-docs.deepseek.com/news/news260821 (Vision-Exp + Files API) | |
| 270 | +- https://api-docs.deepseek.com/api_samples/chat_nodejs · …/thinking_mode_api_example_streaming · …/thinking_mode_api_example_tool_call · …/thinking_mode_api_example_tool_call_output · https://api-docs.deepseek.com/sitemap.xml | |
| 271 | +- Redirected/unfetchable at audit time: `guides/reasoning_model`, `guides/function_calling`, `news/` (→ landing page); `/faq` → external SPA `static.deepseek.com/faq` (no server-rendered content). | |
added
docs/provider-research/deepseek.models.json
+143 −0
@@ -0,0 +1,143 @@ | ||
| 1 | +[ | |
| 2 | + { | |
| 3 | + "id": "deepseek-v4-flash", | |
| 4 | + "displayName": "DeepSeek V4 Flash", | |
| 5 | + "family": "deepseek-v4", | |
| 6 | + "contextTokens": 1000000, | |
| 7 | + "maxOutputTokens": 393216, | |
| 8 | + "capabilities": { | |
| 9 | + "text": true, | |
| 10 | + "vision": false, | |
| 11 | + "audioInput": false, | |
| 12 | + "audioOutput": false, | |
| 13 | + "imageGeneration": false, | |
| 14 | + "video": false, | |
| 15 | + "reasoning": true, | |
| 16 | + "tools": true, | |
| 17 | + "structuredOutput": "json_object-only", | |
| 18 | + "streaming": true, | |
| 19 | + "files": false, | |
| 20 | + "webSearch": "responses-api-only" | |
| 21 | + }, | |
| 22 | + "parameters": { | |
| 23 | + "temperature": "non-thinking-only", | |
| 24 | + "topP": "non-thinking-only", | |
| 25 | + "topK": false, | |
| 26 | + "maxTokens": true, | |
| 27 | + "reasoningEffort": ["none", "low", "high", "max"], | |
| 28 | + "thinkingBudget": false, | |
| 29 | + "stop": true, | |
| 30 | + "seed": false, | |
| 31 | + "frequencyPenalty": false, | |
| 32 | + "presencePenalty": false | |
| 33 | + }, | |
| 34 | + "pricing": { | |
| 35 | + "inputPerMillion": 0.44, | |
| 36 | + "cachedInputPerMillion": 0.014, | |
| 37 | + "outputPerMillion": 1.32, | |
| 38 | + "offPeakInputPerMillion": 0.22, | |
| 39 | + "offPeakCachedInputPerMillion": 0.007, | |
| 40 | + "offPeakOutputPerMillion": 0.66, | |
| 41 | + "peakHoursUtc": "Mon-Fri 01:00-04:00 and 06:00-10:00 UTC; all other hours off-peak (50%)", | |
| 42 | + "longContextThresholdTokens": null, | |
| 43 | + "imageInputPerMillionTokens": null | |
| 44 | + }, | |
| 45 | + "status": "active", | |
| 46 | + "aliases": ["deepseek-chat", "deepseek-reasoner"], | |
| 47 | + "notes": "Version DeepSeek-V4-Flash-0731 (GA 2026-07-31), 284B total / 13B active MoE. Hybrid model: thinking ENABLED by default at effort 'high'; disable with thinking:{type:'disabled'} (or reasoning_effort:'none', undocumented). reasoning_effort low|high|max (medium/xhigh map to high). reasoning_content returned in message and streamed as delta.reasoning_content (both content/reasoning_content keys present in every delta, one null). max_tokens range [1,393216] and it caps reasoning+answer together (small caps -> finish_reason 'length' with empty content). completion_tokens includes reasoning tokens. In thinking mode temperature/top_p/penalties are silently ignored. Usage arrives on the finish_reason chunk even without include_usage. response_format json_schema -> 400 'unavailable now'; json_object requires the word 'json' in the prompt. n=1 only; developer role rejected (map to system). Images are silently DROPPED (placeholder, no 400) -> block client-side. When tools are sent, replay reasoning_content on assistant messages (docs require it; V4 did not 400 without it). Aliases deepseek-chat (non-thinking) / deepseek-reasoner (thinking) were retired 2026-07-24 per docs but still resolve here as of 2026-09-08 - do not list. Concurrency limit 2500. Cache hits in 64-token units, cache-hit input = 1/32 of miss price." | |
| 48 | + }, | |
| 49 | + { | |
| 50 | + "id": "deepseek-v4-pro", | |
| 51 | + "displayName": "DeepSeek V4 Pro", | |
| 52 | + "family": "deepseek-v4", | |
| 53 | + "contextTokens": 1000000, | |
| 54 | + "maxOutputTokens": 393216, | |
| 55 | + "capabilities": { | |
| 56 | + "text": true, | |
| 57 | + "vision": false, | |
| 58 | + "audioInput": false, | |
| 59 | + "audioOutput": false, | |
| 60 | + "imageGeneration": false, | |
| 61 | + "video": false, | |
| 62 | + "reasoning": true, | |
| 63 | + "tools": true, | |
| 64 | + "structuredOutput": "json_object-only", | |
| 65 | + "streaming": true, | |
| 66 | + "files": false, | |
| 67 | + "webSearch": "responses-api-only" | |
| 68 | + }, | |
| 69 | + "parameters": { | |
| 70 | + "temperature": "non-thinking-only", | |
| 71 | + "topP": "non-thinking-only", | |
| 72 | + "topK": false, | |
| 73 | + "maxTokens": true, | |
| 74 | + "reasoningEffort": ["none", "low", "high", "max"], | |
| 75 | + "thinkingBudget": false, | |
| 76 | + "stop": true, | |
| 77 | + "seed": false, | |
| 78 | + "frequencyPenalty": false, | |
| 79 | + "presencePenalty": false | |
| 80 | + }, | |
| 81 | + "pricing": { | |
| 82 | + "inputPerMillion": 1.32, | |
| 83 | + "cachedInputPerMillion": 0.044, | |
| 84 | + "outputPerMillion": 3.96, | |
| 85 | + "offPeakInputPerMillion": 0.66, | |
| 86 | + "offPeakCachedInputPerMillion": 0.022, | |
| 87 | + "offPeakOutputPerMillion": 1.98, | |
| 88 | + "peakHoursUtc": "Mon-Fri 01:00-04:00 and 06:00-10:00 UTC; all other hours off-peak (50%)", | |
| 89 | + "longContextThresholdTokens": null, | |
| 90 | + "imageInputPerMillionTokens": null | |
| 91 | + }, | |
| 92 | + "status": "active", | |
| 93 | + "aliases": [], | |
| 94 | + "notes": "Flagship, version DeepSeek-V4-Pro-0813 (GA 2026-08-13), 1.6T total / 49B active MoE. Same API behaviour and parameter matrix as deepseek-v4-flash (probed identical): thinking default on at 'high', effort low|high|max, reasoning_content in message + deltas, max_tokens [1,393216] covering reasoning+answer, json_object only, n=1, developer role 400, images silently dropped. 3x the price of Flash; lower concurrency limit (500). Slightly slower TTFT (~2.1 s vs 1.1 s for a trivial prompt). Only model listed for FIM in the docs (but Flash answered FIM too). Anthropic-format endpoint maps Claude Opus names to this model. Peak/off-peak pricing since 2026-08-16." | |
| 95 | + }, | |
| 96 | + { | |
| 97 | + "id": "deepseek-v4-flash-vision-exp", | |
| 98 | + "displayName": "DeepSeek V4 Flash Vision (experimental)", | |
| 99 | + "family": "deepseek-v4", | |
| 100 | + "contextTokens": 1000000, | |
| 101 | + "maxOutputTokens": 393216, | |
| 102 | + "capabilities": { | |
| 103 | + "text": true, | |
| 104 | + "vision": true, | |
| 105 | + "audioInput": false, | |
| 106 | + "audioOutput": false, | |
| 107 | + "imageGeneration": false, | |
| 108 | + "video": false, | |
| 109 | + "reasoning": true, | |
| 110 | + "tools": true, | |
| 111 | + "structuredOutput": "json_object-only", | |
| 112 | + "streaming": true, | |
| 113 | + "files": "images-via-files-api-only", | |
| 114 | + "webSearch": "responses-api-only" | |
| 115 | + }, | |
| 116 | + "parameters": { | |
| 117 | + "temperature": "non-thinking-only", | |
| 118 | + "topP": "non-thinking-only", | |
| 119 | + "topK": false, | |
| 120 | + "maxTokens": true, | |
| 121 | + "reasoningEffort": ["none", "low", "high", "max"], | |
| 122 | + "thinkingBudget": false, | |
| 123 | + "stop": true, | |
| 124 | + "seed": false, | |
| 125 | + "frequencyPenalty": false, | |
| 126 | + "presencePenalty": false | |
| 127 | + }, | |
| 128 | + "pricing": { | |
| 129 | + "inputPerMillion": 0.44, | |
| 130 | + "cachedInputPerMillion": 0.014, | |
| 131 | + "outputPerMillion": 1.32, | |
| 132 | + "offPeakInputPerMillion": 0.22, | |
| 133 | + "offPeakCachedInputPerMillion": 0.007, | |
| 134 | + "offPeakOutputPerMillion": 0.66, | |
| 135 | + "peakHoursUtc": "Mon-Fri 01:00-04:00 and 06:00-10:00 UTC; all other hours off-peak (50%)", | |
| 136 | + "longContextThresholdTokens": null, | |
| 137 | + "imageInputPerMillionTokens": 0.44 | |
| 138 | + }, | |
| 139 | + "status": "experimental", | |
| 140 | + "aliases": [], | |
| 141 | + "notes": "Released 2026-08-21, experimental (may change or be withdrawn). Text capabilities = V4-Flash, same price. The ONLY model that accepts images: {type:'image_url', image_url:{url, detail?}} with data URLs or http(s) URLs, or {type:'file', file_id|file_data} via the free Files API. JPEG/PNG/GIF/WebP sniffed from bytes; <=32 MiB inline (64 MiB via Files API), body <=48 MiB, <=600 images, side <=8192 px. Images only in user messages (system -> 400 'Image in system message is unsupported'). Token cost per image: upscaled to ~384x384 minimum / downscaled to ~800x800 area -> ~100-384 tokens per image billed as input (probed: 32x32 PNG = ~111 tokens, 103 with detail:'low'). detail low|high|original|auto (high=original=auto). Thinking + tools + json_object all work with images (probed). Thinking mode with a small max_tokens can spend the whole budget on reasoning -> empty content. Concurrency limit 2500. FIM not supported (docs)." | |
| 142 | + } | |
| 143 | +] | |
added
docs/provider-research/mistral.md
+291 −0
@@ -0,0 +1,291 @@ | ||
| 1 | +# Mistral AI (La Plateforme) — provider research for PolyLLM | |
| 2 | + | |
| 3 | +Last documentation audit: **2026-09-08** | |
| 4 | +Probes executed 2026-09-08 with a real key against `https://api.mistral.ai/v1` (scripts in `research/mistral/*.ts`, raw outputs in `research/mistral/out/*.json`). Everything marked **(probed)** was observed live; everything marked **(docs)** comes from the pages listed at the end. | |
| 5 | + | |
| 6 | +> Heads-up 1: **the docs site was restructured** (May 2026, "Vibe/Studio/Models/Admin"). Most URLs in the brief (`/getting-started/models/models_overview/` still works, but `/capabilities/structured-output/*`, `/capabilities/document/`, `/deployment/laplateforme/tier/`, `/models/deprecation`) are **404**. Current paths: `/studio/conversations/*`, `/studio-api/conversations/advanced/*`, `/inference/*`, `/models/<card>`, `/admin/*`, `/api/endpoint/*`. Several FAQ answers (vision limits, structured-output model list, predicted-output caveats) are collapsed accordions that WebFetch cannot expand — marked **(docs, FAQ not extractable)**. | |
| 7 | +> | |
| 8 | +> Heads-up 2: **Magistral no longer exists as a model.** `magistral-medium-latest` / `magistral-small-latest` are plain aliases of `mistral-medium-2604` (Medium 3.5) and `mistral-small-2603` (Small 4), which are *hybrid* models: reasoning is **off by default** and only enabled by `reasoning_effort: "high"`. `prompt_mode: "reasoning"` is dead (400 everywhere). All `magistral-*-25xx` dated ids return 400. | |
| 9 | + | |
| 10 | +--- | |
| 11 | + | |
| 12 | +## 1. Base URL, auth, headers | |
| 13 | + | |
| 14 | +| Item | Value | | |
| 15 | +|---|---| | |
| 16 | +| REST base URL | `https://api.mistral.ai/v1` (docs also mention regional inference: EU/US endpoints via SDK `server: "eu" \| "us"` at **+10 % price**; default global is fine for BYOK) | | |
| 17 | +| Auth | `Authorization: Bearer <MISTRAL_API_KEY>`; no header → **401** `{"detail":"Invalid API Key"}` **(probed)** | | |
| 18 | +| Content type | `application/json`; wrong types → **422** pydantic body (`{"detail":[{"type":"list_type","loc":["body","messages"],"msg":"Input should be a valid list",...}]}`) **(probed)** | | |
| 19 | +| Gateway | Kong behind Cloudflare: response headers `x-kong-request-id` (use as request id), `x-kong-proxy-latency`, `x-kong-upstream-latency`, `x-envoy-upstream-service-time`, `cf-ray` **(probed)** | | |
| 20 | +| Rate-limit headers **(probed, per model)** | `x-ratelimit-limit-req-minute`, `x-ratelimit-remaining-req-minute`, `x-ratelimit-limit-tokens-minute`, `x-ratelimit-remaining-tokens-minute`, `x-ratelimit-tokens-query-cost` (tokens this request consumed from the TPM budget — **cached tokens are not counted**: 3 276 → 28 on a cache hit). Present on inference calls only (not on `/models`, not on SSE). Limits differ **per model** (see §14). | | |
| 21 | +| Key introspection | None (`GET /v1/usage`, `/v1/tokenize` → 404 **(probed)**). Cheapest "validate key" call is `GET /v1/models` (401 on a bad key **(probed)**). | | |
| 22 | + | |
| 23 | +## 2. SDK recommendation (TypeScript / Node) | |
| 24 | + | |
| 25 | +- Official SDK: **`@mistralai/mistralai` 2.6.4** (npm, checked 2026-09-08; v2 is **ESM-only**). `client.chat.complete(...)` / `client.chat.stream(...)` (async iterable of `{data: chunk}`), `client.fim.complete`, `client.models.list`, `client.beta.conversations.*`, `client.ocr.process`, `client.files.*`. Params are **camelCase** (`maxTokens`, `randomSeed`, `reasoningEffort`, `promptCacheKey`) and responses are camelCased too (`finishReason`, `usage.promptTokens`) **except** `usage.prompt_tokens_details` which stays snake_case **(probed)** — a mapping trap. Errors: `SDKError` with `statusCode`, `body` (`401 API error occurred: Status 401. Body: {"detail":"Invalid API Key"}`) **(probed)**; retry via `retryConfig: {strategy: "backoff" | "none"}`. | |
| 26 | +- **OpenAI SDK (`openai@7.10.0`) with `baseURL: "https://api.mistral.ai/v1"` works** for non-streaming, streaming (incl. the array-typed `delta.content` thinking chunks, which the OpenAI types do not model — cast to `any`), and errors map to `AuthenticationError` 401 **(probed)**. It is **not documented as an official compatibility mode** on docs.mistral.ai (the migration guide says "switch the client"); third-party guides call it wire-compatible. Constraints: Mistral **rejects unknown fields with 422** (`seed`, `max_completion_tokens`, `developer` role, any extra key) — see §9 — so the adapter must translate rather than pass OpenAI bodies through. | |
| 27 | +- **Recommendation for PolyLLM:** keep the current `fetch`-based SSE client (or OpenAI SDK) — the wire format is OpenAI-shaped and the deltas below are simple; use `random_seed`, `max_tokens`, `system` role only. If you want typed responses for `thinking` chunks and the Conversations API, the official SDK is the only typed option. Vercel `@ai-sdk/mistral` exists (not evaluated). | |
| 28 | + | |
| 29 | +## 3. Endpoints | |
| 30 | + | |
| 31 | +| Endpoint | Status | Notes | | |
| 32 | +|---|---|---| | |
| 33 | +| `POST /v1/chat/completions` | **primary** | OpenAI-shaped; SSE streaming; tools; `response_format`; `reasoning_effort`; `document_url` / `image_url` / (docs) `input_audio` content parts; `prediction`; `prompt_cache_key`; `service_tier`; `guardrails`. **(probed)** | | |
| 34 | +| `POST /v1/fim/completions` | active | Fill-in-the-middle, **Codestral only** (`FIM is not enabled for this model` elsewhere) **(probed)** | | |
| 35 | +| `GET /v1/models`, `GET /v1/models/{id}` | active | Rich objects (§12). 50 entries incl. aliases as separate rows. **(probed)** | | |
| 36 | +| `POST /v1/conversations` (+`/{id}`, `/{id}/restart`, `#stream`, `GET /{id}/history`, `/messages`, `DELETE`) | **beta, working** | Agents & Conversations API — the only place for server-side tools (`web_search`, `web_search_premium`, `code_interpreter`, `image_generation`, `document_library`, MCP connectors). `store:false` honoured. **(probed)** | | |
| 37 | +| `POST /v1/agents` | beta | Persistent agent definitions (not needed for PolyLLM). (docs) | | |
| 38 | +| `POST /v1/ocr` | active | `mistral-ocr-latest` (= 4.1), $4 / 1 000 pages; not a chat model (400 `Invalid model` on chat) **(probed)** | | |
| 39 | +| `POST /v1/audio/transcriptions`, `/audio/speech`, `/audio/voices`, realtime WS | active | Voxtral Mini Transcribe 2 ($0.003/min), Voxtral TTS ($0.016 / 1k chars); out of scope. (docs) | | |
| 40 | +| `POST /v1/embeddings` | active | `mistral-embed`, `codestral-embed` (8 192 ctx). | | |
| 41 | +| `POST /v1/moderations`, `/v1/chat/moderations` | active | `mistral-moderation-2603`, free. (docs) | | |
| 42 | +| `POST /v1/files`, `/v1/batch/jobs`, fine-tuning, libraries, workflows, observability, admin API | active | Out of scope. Batch = −50 %. (docs) | | |
| 43 | +| Token counting | **none** | No tokenize endpoint (404 on 3 guesses) **(probed)**. Use `mistral-common` tokenizer offline or estimate. | | |
| 44 | + | |
| 45 | +## 4. Chat Completions request/response (probed shapes) | |
| 46 | + | |
| 47 | +Request fields (API ref + probes): `model`, `messages` (roles **`system`, `user`, `assistant`, `tool`** — `developer` → 422 `union_tag_invalid`), `temperature` **0–1.5**, `top_p` 0–1, `max_tokens` (any int accepted; docs: "prompt + max_tokens cannot exceed context"; 10 M and 200 k > context were accepted and simply ignored **(probed)**), `n` (works on all but Large 3; "input tokens billed once"), `stop` (string or array, excluded from the output text **(probed)**), `random_seed`, `frequency_penalty` / `presence_penalty` **[-2, 2]**, `stream`, `stream_options` (accepted, redundant — usage is always in the last chunk), `safe_prompt`, `parallel_tool_calls`, `tools`, `tool_choice` (`auto | none | any | required | {type:"function",function:{name}}` — all four string values + object probed OK), `response_format` (`text | json_object | json_schema`), `reasoning_effort` (enum `none | minimal | low | medium | high | xhigh | max`, model-gated §8), `prompt_mode` (`"reasoning"` — **dead**, 400 on every model), `prompt_cache_key`, `prediction {type:"content", content}`, `service_tier` (`auto | standard_only`), `guardrails`, `metadata`, assistant **`prefix: true`** (forces the reply to start with that text — probed: `"Sure thing: **OK**! 😊"`). | |
| 48 | + | |
| 49 | +**Strict validation:** any unknown top-level key → **422** `{"object":"error","message":{"detail":[{"type":"extra_forbidden","loc":["body","foo_bar"],"msg":"Extra inputs are not permitted"}]},"type":"invalid_request_error"}` **(probed)**. So `seed`, `max_completion_tokens`, `top_k`(→400 `top_k sampling is not enabled for this model`), `logprobs` (→400 `Logprobs are not enabled for this model`) must never be sent. | |
| 50 | + | |
| 51 | +Non-streaming response **(probed)**: | |
| 52 | + | |
| 53 | +```json | |
| 54 | +{ "id": "c2a8…", "object": "chat.completion", "created": 1788849048, "model": "mistral-large-latest", | |
| 55 | + "choices": [{ "index": 0, "finish_reason": "stop", | |
| 56 | + "message": { "role": "assistant", "tool_calls": null, "content": "2 + 2 equals 4." } }], | |
| 57 | + "usage": { "prompt_tokens": 16, "completion_tokens": 9, "total_tokens": 25, | |
| 58 | + "prompt_tokens_details": { "cached_tokens": 0 }, "service_tier": "standard" } } | |
| 59 | +``` | |
| 60 | + | |
| 61 | +- `message.tool_calls` is always present (`null` when none). `message.content` is a **string** normally and an **array of chunks** when reasoning is on (§8). Small 4 / Medium 3.5 sometimes return **both** `content` text and `tool_calls` in one message (`"I'll get the current weather for Paris for you."` + call) **(probed)**. | |
| 62 | +- `finish_reason`: `stop`, `length` (probed with `max_tokens: 5`, and when truncated *inside* thinking), `tool_calls`. No `content_filter` observed. | |
| 63 | +- `usage`: no separate reasoning-token field — **thinking tokens are inside `completion_tokens`**. `service_tier` echoed (`standard`); GLM omits it. Docs mention `prompt_audio_seconds` for Voxtral. | |
| 64 | +- Hidden system prompt: **Small 4 / Medium 3.5 add ~12 prompt tokens** (`Say OK.` = 6 tokens on Large/Ministral/Codestral/GLM, 18 on Small/Medium). With `safe_prompt: true` Small's count dropped to 6 (the injected safety prompt is not counted and seems to replace the hidden one) **(probed)**. | |
| 65 | +- `model` in the response echoes the alias you sent (`mistral-large-latest`), except retired slugs which echo the redirect target (§15). | |
| 66 | + | |
| 67 | +## 5. Streaming protocol **(probed)** | |
| 68 | + | |
| 69 | +SSE, `Content-Type: text/event-stream; charset=utf-8`, **no `event:` field** on chat completions, `data: {json}` lines, terminated by `data: [DONE]`. Every data chunk after the first carries a random-length **`"p": "abcdefghijklmnopq…"` padding field** (ignore it). | |
| 70 | + | |
| 71 | +``` | |
| 72 | +data: {"id":"…","object":"chat.completion.chunk","created":…,"model":"mistral-medium-latest","choices":[{"index":0,"delta":{"role":"assistant","content":""},"finish_reason":null}]} | |
| 73 | +data: {…,"choices":[{"index":0,"delta":{"content":"Bon"},"finish_reason":null}],"p":"abcdefghijklmnopqrstu"} | |
| 74 | +data: {…,"choices":[{"index":0,"delta":{"content":"jour !"},"finish_reason":"stop"}],"usage":{"prompt_tokens":25,"completion_tokens":4,"total_tokens":29,"prompt_tokens_details":{"cached_tokens":0},"service_tier":"standard"},"p":"abc"} | |
| 75 | +data: [DONE] | |
| 76 | +``` | |
| 77 | + | |
| 78 | +- Delta keys: `role`, `content`, `tool_calls`. **Usage arrives in the same chunk as `finish_reason`** (no extra `choices: []` chunk, `stream_options.include_usage` not needed). The last content delta can be non-empty in that chunk. | |
| 79 | +- Reasoning stream (Medium 3.5 / Small 4 / GLM with `reasoning_effort` ≥ high): `delta.content` **changes type** during the stream — sequence observed `"string"` (role chunk, `""`) → `[{type:"thinking", thinking:[{type:"text", text:"Let"}]}]` × N → **transition chunk** `[{type:"thinking", thinking:[{type:"text",text:"."}], closed:true}, {type:"text", text:"No."}]` → `"string"` deltas for the rest of the answer → final chunk with `finish_reason` + `usage`. When the answer is cut during thinking, the closing chunk has only the `closed:true` thinking part and `finish_reason: "length"` **(probed)**. Adapter: treat each `thinking[].text` as a *thinking* delta, `{type:"text"}` parts and plain strings as *text* deltas. | |
| 80 | +- Tool calls: for Mistral models the whole call arrives in **one chunk** with complete `arguments` (§6). **GLM 5.2 streams arguments across several chunks** and adds `delta.index` and `logprobs: null` — use standard OpenAI accumulation by `tool_calls[].index` to cover both. | |
| 81 | +- Conversations API streams *do* use `event:` names (§11). Docs: streaming connections time out after 10 min of inactivity. | |
| 82 | + | |
| 83 | +## 6. Tool / function calling **(probed on 7 models + GLM)** | |
| 84 | + | |
| 85 | +- OpenAI nested format `{type:"function", function:{name, description, parameters}}`; max **128 tools** per request (docs, known limitations); `parallel_tool_calls` default true; `tool_choice` `auto | none | any | required | {type:"function",function:{name}}`. | |
| 86 | +- Streamed shape (Mistral models): single delta `{"tool_calls":[{"id":"m82GbeK4G","type":"function","function":{"name":"get_weather","arguments":"{\"city\": \"Montreal\"}"},"index":0}]}` then `finish_reason: "tool_calls"` + usage. **Ids are 9 alphanumeric chars** (`m82GbeK4G`, `DxeWRMxcs`…). GLM ids: `chatcmpl-tool-ba59f9d9d6ffc20b`, arguments chunked. | |
| 87 | +- Round trip: `{role:"assistant", content:"", tool_calls:[…]}` then `{role:"tool", tool_call_id, name, content}` → 200 on all models. A **long OpenAI-style id (`call_abc123def456ghi789`) in the replayed history was accepted** (200) — the historical 9-char constraint is no longer enforced on input **(probed)**, but keep ids verbatim anyway. | |
| 88 | +- No `strict` flag on tools (docs). Citations from tool results: model can emit `{type:"reference", reference_ids:[…]}` content chunks when the tool result is a reference dictionary (docs, chat completions). | |
| 89 | + | |
| 90 | +## 7. Structured output **(probed on 7 models + GLM)** | |
| 91 | + | |
| 92 | +- `response_format: {type:"json_schema", json_schema:{name, schema, strict:true, description?}}` → valid JSON matching the schema on **all** models incl. Codestral, Ministral 8B, GLM. `minimum`/`maximum` keywords accepted (200). Output is the JSON string in `content` (pretty-printed by some models — parse, don't display raw). | |
| 93 | +- `{type:"json_object"}` works **even without "JSON" in the prompt** (probed on Small 4), though docs insist you must instruct the model to output JSON. | |
| 94 | +- No documented list of unsupported schema keywords **(docs, FAQ not extractable)**; SDK helper `client.chat.parse()` (Python) / Zod helpers in TS docs not surfaced. Docs: "Custom structured outputs are more reliable and are recommended". | |
| 95 | + | |
| 96 | +## 8. Reasoning controls **(probed matrix)** | |
| 97 | + | |
| 98 | +| Model | `reasoning` flag in `/models` | Accepted `reasoning_effort` | Default | Shape | | |
| 99 | +|---|---|---|---|---| | |
| 100 | +| mistral-medium-2604 (Medium 3.5, `magistral-medium-latest`) | true | **`high`, `none`** only; others → 400 code 3051 `reasoning_effort X is not supported for this model, supported values: [<ReasoningEffort.high: 'high'>, <ReasoningEffort.none: 'none'>]` | **none** (no thinking unless asked) | content chunks | | |
| 101 | +| mistral-small-2603 (Small 4, `magistral-small-latest`) | true | **`high`, `none`** only (same error, slightly different wording `reasoning_effort='low' is not supported …`) | none | content chunks | | |
| 102 | +| zai-glm-5-2 | true | **all 7 values** (`none`, `minimal` → plain string; `low`…`max` → thinking) | none | content chunks | | |
| 103 | +| mistral-large-2512, ministral-*, codestral, voxtral-small | false | any value → 400 `reasoning_effort is not enabled for this model` | — | string | | |
| 104 | +| labs-leanstral-1-5 | true | not probed (403 Labs) | — | — | | |
| 105 | +| any model, bad value | — | 422 enum error listing `'none', 'minimal', 'low', 'medium', 'high', 'xhigh' or 'max'` | | | | |
| 106 | + | |
| 107 | +Non-stream reasoning message **(probed)**: | |
| 108 | + | |
| 109 | +```json | |
| 110 | +"content": [ | |
| 111 | + {"type":"thinking","thinking":[{"type":"text","text":"Let me think about this. A prime number is …"}],"closed":true}, | |
| 112 | + {"type":"text","text":"No, 221 is not a prime number (13 × 17)."} | |
| 113 | +] | |
| 114 | +``` | |
| 115 | + | |
| 116 | +- No thinking budget, no summary control, no encrypted reasoning. Reasoning tokens are billed as output and counted in `completion_tokens` (Medium 3.5 spent 345 tokens on "is 221 prime"; `max_tokens` caps thinking + answer together → set a generous cap or you get `finish_reason: "length"` with only a thinking chunk). | |
| 117 | +- **Multi-turn**: docs say to replay the full assistant message including the `thinking` chunk; replaying it verbatim worked **(probed)**. Replaying an assistant message whose content is an empty string → 400 code 3240 `Assistant message must have either content or tool_calls, but not none.` (happens if you strip thinking from a truncated answer). Safe rule: replay the whole content array as received; when the model returned `tool_calls` with `content: ""` that is fine. | |
| 118 | +- `prompt_mode: "reasoning"` (old Magistral switch) → 400 `Reasoning prompt mode is not enabled for this model` on every model — drop it. | |
| 119 | + | |
| 120 | +## 9. Sampling & other parameters — support matrix **(probed, chat completions)** | |
| 121 | + | |
| 122 | +| Param | Large 3 | Medium 3.5 | Small 4 | Ministral 8B | Codestral | GLM 5.2 | | |
| 123 | +|---|---|---|---|---|---|---| | |
| 124 | +| `temperature` 0–1.5 (2.0 → 422 `less_than_equal … 1.5`) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | |
| 125 | +| `top_p` (1.5 → 422) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | |
| 126 | +| `top_k` | 400 | 400 | 400 | 400 | 400 | — | | |
| 127 | +| `max_tokens` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | |
| 128 | +| `max_completion_tokens`, `seed`, `developer` role, unknown keys | 422 | 422 | 422 | 422 | 422 | — | | |
| 129 | +| `random_seed` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | |
| 130 | +| `stop` (string / array) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | |
| 131 | +| `presence_penalty` / `frequency_penalty` ∈ [-2,2] (outside → 422) | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | |
| 132 | +| `n: 2` | **400** `Max allowed: 1` | ✓ | ✓ | ✓ | ✓ | ✓ | | |
| 133 | +| `logprobs` | 400 | 400 | 400 | 400 | 400 | — | | |
| 134 | +| `safe_prompt` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | |
| 135 | +| `reasoning_effort` | 400 | high/none | high/none | 400 | 400 | all | | |
| 136 | +| `prompt_mode: "reasoning"` | 400 | 400 | 400 | 400 | 400 | — | | |
| 137 | +| `response_format` json_object / json_schema | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | |
| 138 | +| `tool_choice` any/required/none/object, `parallel_tool_calls` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | |
| 139 | +| `prediction`, `prompt_cache_key`, `service_tier`, `metadata`, `stream_options`, assistant `prefix` | ✓ | ✓ | ✓ | ✓ | ✓ | ✓ | | |
| 140 | +| `image_url` | ✓ | ✓ | ✓ | ✓ | 400 | 400 | | |
| 141 | +| `document_url` (PDF) | (docs ✓) | (docs ✓) | ✓ | ✓ | ✓ | (docs ✓) | | |
| 142 | + | |
| 143 | +**Adapter rules:** clamp `temperature` to **[0, 1.5]**; map `seed` → `random_seed`; map `maxTokens` → `max_tokens` (never `max_completion_tokens`); map `developer` → `system`; never send `top_k`, `logprobs`, `stream_options`(harmless but useless), `prompt_mode`; send `reasoning_effort` only when `model.capabilities.reasoning` and only `high`/`none` for Mistral hybrids (all values for GLM); send `n` > 1 never to Large 3. | |
| 144 | + | |
| 145 | +## 10. Modalities, context, output limits | |
| 146 | + | |
| 147 | +- **Input**: text everywhere; **images** on Large 3, Medium 3.5, Small 4, Ministral 3/8/14B (and Leanstral per listing) — `capabilities.vision` in `/models` is authoritative (probed 4/4 vision models OK, Codestral & GLM → 400 code 3051 `Image input is not enabled for this model`). **Audio** input only on `voxtral-small-*` (`input_audio` part, docs). **Output**: text only (image generation exists only as a Conversations-API tool, $100 / 1k images). | |
| 148 | +- **Image format**: `{type:"image_url", image_url:"<https url or data:image/png;base64,…>"}` — string form (docs) **and** OpenAI object form `{url, detail}` both accepted **(probed)**. Accepted formats (from the error text): **JPEG, PNG, WEBP, GIF, MPO, HEIF, AVIF, BMP, TIFF**; invalid data → 400 code **3310** `invalid_request_file`. No minimum size (2×2 PNG accepted). Token cost: 32×32 ≈ 15 tokens, 512×512 ≈ 390 tokens on Small 4 (≈ 28×28 px per token); billed at the text input price (no separate image price). Limits (docs, known-limitations page): **20 MB per image**, resolution > 10 000×10 000 rejected (older FAQ: 8 images / request, 10 MB) — FAQ answers not extractable, treat 8 images as the safe cap. | |
| 149 | +- **Documents**: `{type:"document_url", document_url:"https://…pdf" | "data:application/pdf;base64,…", document_name?}` → server-side OCR then QnA; probed OK on Small 4, Ministral 8B **and Codestral**; usage shows only ~50 prompt tokens for a 1-page PDF (OCR cost is not in `usage` — pricing of Document QnA unclear, docs say OCR $4/1k pages). `{type:"file", file_id}` also valid (bogus id → 400 3310 `File … could not be found or may have expired`). Limits **(docs, FAQ not extractable)**. | |
| 150 | +- **Context**: from `/models.max_context_length` — 262 144 (Large 3, Medium 3.5, Small 4, Ministral 8B/14B, Leanstral), 256 000 (Codestral), 131 072 (Ministral 3B), 1 048 576 (GLM 5.2), 32 768 (Voxtral Small). Exceeding it → 400 (docs). **Max output**: not published per model except GLM (128k, model card); `max_tokens` above context is accepted silently **(probed)** → registry `maxOutputTokens: null`, cap in UI at context − prompt. | |
| 151 | + | |
| 152 | +## 11. Server-side tools, Conversations API, citations **(probed)** | |
| 153 | + | |
| 154 | +- On `/v1/chat/completions`: `tools:[{type:"web_search"}]` → **400** code **1800** `{"type":"invalid_tools","message":"WebSearchTool connector is not supported"}`. Docs: `web_search`, `web_search_premium`, `code_interpreter` are Conversations/Agents-API only; `image_generation` is claimed to work on chat completions (not probed, $0.10/image). | |
| 155 | +- `POST /v1/conversations` `{model, inputs:"…", tools:[{type:"web_search"}], store:false, completion_args:{max_tokens, temperature, response_format, …}}` → 200 **(probed)**: | |
| 156 | + | |
| 157 | +```json | |
| 158 | +{ "object":"conversation.response", "conversation_id":"conv_01a0…", | |
| 159 | + "outputs":[ | |
| 160 | + {"object":"entry","type":"tool.execution","name":"web_search","arguments":"{\"query\": \"…\"}","info":{"result":"{\"D5PmwtGh\": {\"url\":…,\"title\":…,\"snippets\":[…]}}"},"id":"tool_exec_…","created_at":…,"completed_at":…}, | |
| 161 | + {"object":"entry","type":"message.output","role":"assistant","id":"msg_…","content":[ | |
| 162 | + {"type":"text","text":"The latest stable Node.js version is **26.8.1**…"}, | |
| 163 | + {"type":"tool_reference","tool":"web_search","title":"…","url":"https://versionlog.com/nodejs/","favicon":"https://imgs.search.brave.com/…","description":"…"}, | |
| 164 | + {"type":"text","text":"."}]}], | |
| 165 | + "usage":{"prompt_tokens":779,"completion_tokens":66,"total_tokens":6582,"connector_tokens":5737,"connectors":{"web_search":1}}, | |
| 166 | + "guardrails":null } | |
| 167 | +``` | |
| 168 | + | |
| 169 | + **Citations = inline `tool_reference` chunks** (title, url, favicon, description) placed where the model cites; the raw search results sit in `tool.execution.info.result` (Brave-backed). **Billing trap**: `connector_tokens` (5 737 here, the search results fed to the model) are billed as input tokens on top of the $30 / 1k `web_search` calls. | |
| 170 | +- Streaming (`stream:true`): SSE **with `event:` names** — `conversation.response.started` `{conversation_id}`, `message.output.delta` `{output_index, id, content_index, role, content:"Hi"}` (content is a plain string per delta; tool_reference deltas presumably arrive as chunk objects — not observed), `conversation.response.done` `{usage}`; docs also list `tool.execution.started/done`, `function.call.delta`, `agent.handoff.*`, `conversation.response.error`. | |
| 171 | +- Client function tools, `handoff_execution`, `instructions`, `previous`-style continuation (`POST /v1/conversations/{id}`), `restart`, history GET/DELETE exist (docs). `store` defaults to **true** — send `store:false` for BYOK privacy. | |
| 172 | +- Pricing (docs): web search $30 / 1k calls, premium news $50 / 1k, code interpreter $30 / 1k, image generation $100 / 1k, document library $0.01 / call + OCR/indexing. | |
| 173 | + | |
| 174 | +## 12. Model listing & pricing **(probed + docs)** | |
| 175 | + | |
| 176 | +`GET /v1/models` → `{object:"list", data:[…]}` with **50 rows (aliases are separate rows)**. Fields: `id, object:"model", created (= request time, useless), owned_by ("mistralai"), name (canonical, e.g. "mistral-large-2512"), description, max_context_length, aliases[], deprecation (null everywhere today), deprecation_replacement_model, default_model_temperature (0.3 most; **1** for Medium 3.5 & Leanstral; 0.2 Voxtral; null GLM), billing_model_name (canonical billing id — use it to dedupe), type ("base"), capabilities {completion_chat, function_calling, reasoning, completion_fim, fine_tuning, vision, ocr, classification, moderation, audio, audio_transcription, audio_transcription_realtime, audio_speech, unified_resources}`. **No pricing, no max output** in the API. `capabilities.audio` is `false` even for voxtral-small (looks wrong vs docs). Docs' `/models` schema (`root`, `archived`, `job`) is outdated. | |
| 177 | + | |
| 178 | +Dedupe by `billing_model_name` → **10 chat-capable models** (the 10 entries in `mistral.models.json`): | |
| 179 | + | |
| 180 | +| billing id | display | ctx | in / cached / out $/M (docs pricing page) | notes | | |
| 181 | +|---|---|---|---|---| | |
| 182 | +| mistral-medium-3-5 (`mistral-medium-2604`) | Mistral Medium 3.5 | 256k | 1.50 / 0.15 / 7.50 | hybrid reasoning; 9 aliases incl. `magistral-medium-latest` | | |
| 183 | +| mistral-small-2603 | Mistral Small 4 | 256k | 0.15 / 0.015 / 0.60 | hybrid reasoning; `magistral-small-latest` | | |
| 184 | +| mistral-large-2512 | Mistral Large 3 | 256k | 0.50 / 0.05 / 1.50 | no reasoning, n≤1 | | |
| 185 | +| ministral-14b-2512 / 8b / 3b | Ministral 3 | 256k / 256k / 128k | 0.20/0.02/0.20 · 0.15/0.015/0.15 · 0.10/0.01/0.10 | vision, tools | | |
| 186 | +| codestral-2508 | Codestral | 256k (card says 128k) | 0.30 / 0.03 / 0.90 | FIM, no vision | | |
| 187 | +| zai-glm-5-2 | Z.ai GLM 5.2 | 1M | 1.40 / 0.14 / 4.40 | third-party preview | | |
| 188 | +| voxtral-small-2507 | Voxtral Small | 32k | 0.10 / ? / 0.40 | audio in | | |
| 189 | +| labs-leanstral-1-5 | Leanstral 1.5 | 256k | free | 403 unless org opts in; retiring 2026-09-30 | | |
| 190 | + | |
| 191 | +Cached input = **10 %** of input everywhere. Batch −50 %. Priority tier ×1.75. Regional +10 %. Non-chat: OCR 4.1 $4 / 1k pages, Voxtral Mini Transcribe 2 $0.003/min, TTS $0.016 / 1k chars, embeddings $0.10–0.15, moderation free. | |
| 192 | + | |
| 193 | +## 13. Prompt caching & provider-side state | |
| 194 | + | |
| 195 | +- **Explicit, key-based**: send the same `prompt_cache_key` (session/conversation id) on requests sharing a prefix; 64-token blocks (`cached_tokens` is a multiple of 64, prompts < 64 tokens never hit); billed at 10 %; reported in `usage.prompt_tokens_details.cached_tokens`; no TTL documented (docs). **Probed**: identical 3 273-token prefix — call 1 `cached_tokens: 0`, call 2 (same key) `cached_tokens: 3248`, call 3 (no key) `0`; the rate-limit header `x-ratelimit-tokens-query-cost` went 3 276 → **28**, so cache hits also spare TPM. **Adapter: always send `prompt_cache_key = conversationId`.** | |
| 196 | +- Chat completions are stateless. The Conversations API stores by default (`store:true`) — pass `store:false`. | |
| 197 | + | |
| 198 | +## 14. Errors, rate limits, retries | |
| 199 | + | |
| 200 | +Two error envelopes **(probed)**: | |
| 201 | +1. Business errors: `{"object":"error","message":"…","type":"<type>","param":null,"code":"<numeric string>","raw_status_code":400}`. | |
| 202 | +2. Validation errors (422) and auth: `{"detail": …}` (pydantic list, or `"Invalid API Key"`); some 422s are wrapped in envelope 1 with `message.detail[]`. | |
| 203 | + | |
| 204 | +| Case **(probed)** | HTTP | type / code | message | | |
| 205 | +|---|---|---|---| | |
| 206 | +| Invalid or missing key (any endpoint) | **401** | — | `{"detail":"Invalid API Key"}` | | |
| 207 | +| Unknown / retired / non-chat model | 400 | `invalid_model` / `1500` | `Invalid model: mistral-99-ultra` | | |
| 208 | +| Unsupported feature for model | 400 | `invalid_request_invalid_args` / `3051` | `reasoning_effort is not enabled for this model`, `top_k sampling is not enabled…`, `Image input is not enabled…`, `FIM is not enabled…`, `Logprobs are not enabled…`, `Invalid value 2 for parameter \`n\`. Max allowed: 1.` | | |
| 209 | +| Bad range / unknown field / bad enum / bad role | 422 | `invalid_request_error` / null | pydantic `detail[]` (`less_than_equal`, `extra_forbidden`, `enum`, `union_tag_invalid`) | | |
| 210 | +| Empty `messages` | 400 | `invalid_request_message_order` / `3230` | `Conversation must have at least one message` | | |
| 211 | +| Assistant message without content/tool_calls | 400 | `invalid_request_assistant_message` / `3240` | | | |
| 212 | +| Bad image / missing file | 400 | `invalid_request_file` / `3310` | lists allowed formats | | |
| 213 | +| Server tool on chat completions | 400 | `invalid_tools` / `1800` | `WebSearchTool connector is not supported` | | |
| 214 | +| Labs model not enabled | **403** | — | `Model labs-leanstral-1-5 is a Labs model. To use Labs models, an admin must enable them…` | | |
| 215 | +| Rate limit | 429 (docs) | | `Too Many Requests`; no `retry-after` observed (none triggered) | | |
| 216 | + | |
| 217 | +Rate limits (docs + headers): scoped **per workspace, per model**, expressed as requests/min + tokens/min headers (docs talk RPS + tokens/month). Tiers by cumulative spend: Free → Tier 1 (pay-as-you-go on) → Tier 2 (> $20) → Tier 3 (> $100) → Tier 4 (> $500) → custom. Observed on this key (req/min · tokens/min): Large 3 **75 · 1M** (!), Medium 3.5 3000 · 2M, Small 4 2000 · 2M, Ministral 8B 3800 · 2.5M, 14B 500 · 3.8M, 3B 15000 · 5M, Codestral 2500 · 2.5M, GLM 3000 · 4M, Voxtral Small 1440 · 500k. Users see theirs at admin.mistral.ai/plateforme/limits. | |
| 218 | + | |
| 219 | +**Retry/timeout recommendation:** retry 429 / 5xx with jittered exponential backoff (respect `retry-after` if present), never retry 400/401/403/422; surface `x-kong-request-id` in error toasts; read timeout ≥ 5 min for `reasoning_effort: "high"` (thinking deltas arrive quickly as liveness). Map 401 `Invalid API Key` → *invalid credentials*; map 403 Labs → *model not enabled for this workspace*. | |
| 220 | + | |
| 221 | +## 15. Lifecycle / aliases / deprecations | |
| 222 | + | |
| 223 | +- Policy (docs `/inference/model-lifecycle`): notice before retirement = **6 months GA**, **1 month** Labs / Public Preview / third-party; retired ids "fail with 404" (actually **400 `invalid_model`** **(probed)**). `-latest` aliases **move automatically** to the next GA model ("silent updates in model behaviour and pricing") → pin dated ids in the registry, show `-latest` as aliases. | |
| 224 | +- `deprecation` / `deprecation_replacement_model` are `null` for every current model **(probed)**; the docs table (models overview) lists retired ones: `mistral-medium-2508` (ret. 2026-08-31), `mistral-small-2506` (2026-07-31), `magistral-medium-2509` / `magistral-small-2509` (2026-07-31), `devstral-2512` (2026-07-31), `mistral-large-2411`, `pixtral-large-2411`, `devstral-*-2507` (2026-05-31), `open-mistral-nemo-2407` (2026-07-31), `labs-leanstral-2603` (2026-06-30), `mistral-moderation-2411`, `voxtral-mini-2507`, older 2024/25 models. | |
| 225 | +- **Silent redirects (probed)**: `mistral-small-2506` → served as `mistral-small-latest`; `mistral-medium-2508` / `-2505` → `mistral-medium-3-5`; `pixtral-12b-2409` → `ministral-14b-latest`; `open-mistral-7b`, `ministral-8b-2410`, `open-mistral-nemo` → Ministral 8B; `codestral-2501` → `codestral-latest`; `mistral-small-2501`, `mistral-saba-latest` → Small 4; `mistral-large-2407` → Large 3. **Hard 400**: `magistral-*-25xx`, `mistral-large-2411`, `pixtral-large-latest`, `devstral-medium-2507`. Don't list any of these; if a user types one, show the redirect from `response.model`. | |
| 226 | +- Alias sprawl worth knowing: `mistral-medium`, `mistral-medium-3`, `mistral-medium-3.5`, `mistral-vibe-cli-latest`, `mistral-vibe-cli-with-tools` (Vibe CLI product aliases) → Medium 3.5; `mistral-vibe-cli-fast` → Small 4; `mistral-code-latest`, `mistral-code-fim-latest` → Codestral; `glm-5-2` ↔ `zai-glm-5-2`. | |
| 227 | +- Changelog 2026: Medium 3.5 GA 2026-04-28; Small 4 (2603); GLM 5.2 preview 2026-08-06; OCR 4.1 GA Aug 2026; Leanstral 1.5 June 2026 (retire 2026-09-30); docs moved to `/vibe/*`, `/studio/*` May 2026. | |
| 228 | + | |
| 229 | +## 16. Exact streaming code that worked | |
| 230 | + | |
| 231 | +Raw fetch SSE (`research/mistral/lib.ts → rawSSE`) and the OpenAI SDK both worked; the reasoning-aware accumulation used in `08-sdks-cache.ts`: | |
| 232 | + | |
| 233 | +```ts | |
| 234 | +import OpenAI from "openai"; | |
| 235 | +const client = new OpenAI({ apiKey: process.env.MISTRAL_API_KEY, baseURL: "https://api.mistral.ai/v1", timeout: 300_000, maxRetries: 0 }); | |
| 236 | + | |
| 237 | +const stream = await client.chat.completions.create({ | |
| 238 | + model: "mistral-medium-2604", | |
| 239 | + messages: [{ role: "system", content: "Be concise." }, { role: "user", content: "Is 17 prime? One sentence." }], | |
| 240 | + stream: true, | |
| 241 | + max_tokens: 400, // covers thinking + answer | |
| 242 | + random_seed: 42, // NOT `seed` (422) | |
| 243 | + prompt_cache_key: conversationId, // enables prefix caching | |
| 244 | + reasoning_effort: "high", // only "high" | "none" on Mistral hybrids; omit on Large/Ministral/Codestral | |
| 245 | +} as any); | |
| 246 | + | |
| 247 | +for await (const chunk of stream as any) { | |
| 248 | + const choice = chunk.choices?.[0]; | |
| 249 | + const c = choice?.delta?.content; | |
| 250 | + if (typeof c === "string") { if (c) onText(c); } | |
| 251 | + else if (Array.isArray(c)) { | |
| 252 | + for (const part of c) { | |
| 253 | + if (part.type === "thinking") for (const t of part.thinking ?? []) if (t.text) onThinking(t.text); | |
| 254 | + else if (part.type === "text" && part.text) onText(part.text); | |
| 255 | + } | |
| 256 | + } | |
| 257 | + if (choice?.delta?.tool_calls) accumulateByIndex(choice.delta.tool_calls); // 1 chunk on Mistral, many on GLM | |
| 258 | + if (choice?.finish_reason) onFinish(choice.finish_reason); | |
| 259 | + if (chunk.usage) onUsage(chunk.usage); // same chunk as finish_reason; usage.prompt_tokens_details.cached_tokens | |
| 260 | +} | |
| 261 | +``` | |
| 262 | + | |
| 263 | +Official SDK equivalent: `for await (const ev of await mistral.chat.stream({ model, messages, maxTokens, reasoningEffort: "high", promptCacheKey })) { const d = ev.data.choices[0].delta; … ev.data.choices[0].finishReason; ev.data.usage?.promptTokens }` (camelCase, except `prompt_tokens_details`). | |
| 264 | + | |
| 265 | +## 17. Probe results table | |
| 266 | + | |
| 267 | +| # | Probe | Model(s) | Result | | |
| 268 | +|---|---|---|---| | |
| 269 | +| 00 | `GET /models`, `/models/mistral-large-latest` | — | 200; 50 rows; fields incl. `capabilities.reasoning`, `billing_model_name`, `default_model_temperature`, `deprecation` (all null); no pricing/max output | | |
| 270 | +| 01 | tiny chat + raw SSE stream | 7 models | all 200; string content; no thinking by default on `magistral-*`; usage in final chunk; `p` padding field; per-model rate-limit headers | | |
| 271 | +| 02 | param matrix (45 variants) | 7 models | §9: temperature ≤ 1.5, penalties ∈ [-2,2], unknown keys 422, `top_k`/`logprobs`/`prompt_mode` 400, `n` 400 on Large only, `reasoning_effort` high/none on hybrids, `developer` 422, `prefix` works | | |
| 272 | +| 03 | tools streamed + round trip; long tool_call_id | 7 models | single-chunk tool call, 9-char ids, `finish_reason: tool_calls`, round 2 OK; long id accepted | | |
| 273 | +| 04 | `json_schema` strict; min/max keywords; `json_object` w/o "JSON" | 7 models | valid JSON on all; extras accepted | | |
| 274 | +| 05 | vision 32 px / 2 px / 512 px / object form / invalid | 4 vision models + Codestral + GLM | OK on vision models (~15 / ~390 tokens); Codestral & GLM 400 3051; invalid → 400 3310 with format list | | |
| 275 | +| 06 | invalid key, no auth, unknown model, 17 retired slugs, malformed body, empty messages, huge max_tokens, temp 3, top_p 1.5, embed/OCR on chat | — | 401 `Invalid API Key`; 400 1500; redirects vs hard 400 per §15; 422 pydantic; 400 3230; 200 (ignored); 422; 422; 400 1500 | | |
| 276 | +| 07 | `document_url` base64 + public URL, `file` type | Small 4, Ministral 8B, Codestral | 200 and correct answer ("pamplemousse") on all incl. Codestral; ~50 prompt tokens; bogus file id → 400 3310 | | |
| 277 | +| 08 | OpenAI SDK non-stream/stream/bad key; Mistral SDK complete/stream/bad key; caching ×3 | Small 4, Medium 3.5 | all OK; `AuthenticationError` 401 / `SDKError` 401; cached_tokens 0 → 3248 → 0, TPM cost 3276 → 28 | | |
| 278 | +| 09 | reasoning high non-stream + stream + replay + text-only replay; `prompt_mode`; truncation; Labs/3B/14B/Voxtral; FIM; `web_search` on chat; Conversations (web_search, stream); tokenize/usage | Medium 3.5, Small 4, GLM, others | shapes §8/§5; replay OK; empty-content replay 400 3240; prompt_mode 400; Labs 403; FIM Codestral-only; chat web_search 400 1800; Conversations 200 with `tool_reference` + `connector_tokens`; no tokenize/usage endpoints | | |
| 279 | +| 10–11 | GLM param matrix + stream + tools; `max` on Medium; hidden prompt sizes; stop exclusion; max_tokens > ctx | GLM, Medium, Small, Ministral 3B | GLM accepts all efforts (none/minimal → string), multi-chunk tool args, `chatcmpl-tool-` ids; Medium `max` 400; Small/Medium +12 hidden tokens; stop excluded; over-context max_tokens accepted | | |
| 280 | + | |
| 281 | +## Documentation pages used (all fetched 2026-09-08) | |
| 282 | + | |
| 283 | +- https://docs.mistral.ai/ · https://docs.mistral.ai/sitemap.xml · https://docs.mistral.ai/llms.txt (stale `/docs/*.md` links, all 404) | |
| 284 | +- Models: https://docs.mistral.ai/getting-started/models/models_overview/ (deprecation table) · https://docs.mistral.ai/models · https://docs.mistral.ai/models/mistral-medium-3-5-26-04 · https://docs.mistral.ai/models/mistral-large-3-25-12 · https://docs.mistral.ai/models/codestral-25-08 · https://docs.mistral.ai/models/zai-glm-5-2 · https://docs.mistral.ai/inference/model-lifecycle · https://docs.mistral.ai/inference/model-selection-guide · https://docs.mistral.ai/inference/labs | |
| 285 | +- API reference: https://docs.mistral.ai/api/ · https://docs.mistral.ai/api/endpoint/chat · https://docs.mistral.ai/api/endpoint/models · https://docs.mistral.ai/api/endpoint/fim · https://docs.mistral.ai/api/endpoint/beta/conversations | |
| 286 | +- Capabilities: https://docs.mistral.ai/capabilities/reasoning/ · https://docs.mistral.ai/studio/conversations/reasoning · https://docs.mistral.ai/capabilities/function_calling/ · https://docs.mistral.ai/studio/conversations/function-calling · https://docs.mistral.ai/capabilities/vision/ · https://docs.mistral.ai/studio/conversations/vision · https://docs.mistral.ai/studio-api/conversations/vision · https://docs.mistral.ai/studio/conversations/structured-output (+ `/custom`) · https://docs.mistral.ai/studio/conversations/citations · https://docs.mistral.ai/studio/conversations/chat-completion · https://docs.mistral.ai/studio-api/conversations/advanced/prompt-caching · https://docs.mistral.ai/studio-api/conversations/advanced/predicted-outputs · https://docs.mistral.ai/capabilities/document_ai/ · https://docs.mistral.ai/studio/document-processing/document_qna · https://docs.mistral.ai/capabilities/audio/ · https://docs.mistral.ai/studio/audio/overview | |
| 287 | +- Agents: https://docs.mistral.ai/studio/agents/introduction · https://docs.mistral.ai/studio/agents/agents-api · https://docs.mistral.ai/studio/agents/agent-tools | |
| 288 | +- Safety: https://docs.mistral.ai/studio/safety-moderation | |
| 289 | +- Pricing / limits: https://mistral.ai/pricing · https://mistral.ai/pricing/api · https://docs.mistral.ai/inference/pricing · https://docs.mistral.ai/inference/priority-tier · https://docs.mistral.ai/inference/sampling · https://docs.mistral.ai/admin/user-management-finops/tier · https://docs.mistral.ai/admin/workspaces/usage-limits · https://docs.mistral.ai/admin/billing-usage/usage-limits · https://docs.mistral.ai/resources/known-limitations · https://help.mistral.ai/en/articles/698531-why-am-i-hitting-api-rate-limits-and-how-do-i-increase-them | |
| 290 | +- Misc: https://docs.mistral.ai/getting-started/changelog/ · https://docs.mistral.ai/resources/sdks · https://docs.mistral.ai/resources/migration-guides · https://github.com/mistralai/client-ts | |
| 291 | +- 404 at audit time: `/capabilities/structured-output/custom/`, `/capabilities/structured-output/`, `/capabilities/document/`, `/deployment/laplateforme/tier/`, `/deployment/laplateforme/`, `/models/deprecation`, `/models/mistral-small-4-26-03`, `/models/mistral-small-26-03`, `/studio/conversations/prompt-caching`, `/studio-api/conversations/advanced/prefix`, every `/docs/*.md` from llms.txt. | |
added
docs/provider-research/mistral.models.json
+421 −0
@@ -0,0 +1,421 @@ | ||
| 1 | +[ | |
| 2 | + { | |
| 3 | + "id": "mistral-medium-2604", | |
| 4 | + "displayName": "Mistral Medium 3.5", | |
| 5 | + "family": "mistral-medium", | |
| 6 | + "contextTokens": 262144, | |
| 7 | + "maxOutputTokens": null, | |
| 8 | + "capabilities": { | |
| 9 | + "text": true, | |
| 10 | + "vision": true, | |
| 11 | + "audioInput": false, | |
| 12 | + "audioOutput": false, | |
| 13 | + "imageGeneration": false, | |
| 14 | + "video": false, | |
| 15 | + "reasoning": true, | |
| 16 | + "tools": true, | |
| 17 | + "structuredOutput": true, | |
| 18 | + "streaming": true, | |
| 19 | + "files": true, | |
| 20 | + "webSearch": "conversations-api-only" | |
| 21 | + }, | |
| 22 | + "parameters": { | |
| 23 | + "temperature": true, | |
| 24 | + "topP": true, | |
| 25 | + "topK": false, | |
| 26 | + "maxTokens": true, | |
| 27 | + "reasoningEffort": ["none", "high"], | |
| 28 | + "thinkingBudget": false, | |
| 29 | + "stop": true, | |
| 30 | + "seed": "random_seed", | |
| 31 | + "frequencyPenalty": true, | |
| 32 | + "presencePenalty": true | |
| 33 | + }, | |
| 34 | + "pricing": { | |
| 35 | + "inputPerMillion": 1.5, | |
| 36 | + "cachedInputPerMillion": 0.15, | |
| 37 | + "outputPerMillion": 7.5 | |
| 38 | + }, | |
| 39 | + "status": "active", | |
| 40 | + "aliases": [ | |
| 41 | + "mistral-medium-latest", | |
| 42 | + "mistral-medium", | |
| 43 | + "mistral-medium-3-5", | |
| 44 | + "mistral-medium-3.5", | |
| 45 | + "mistral-medium-3", | |
| 46 | + "mistral-vibe-cli-latest", | |
| 47 | + "mistral-vibe-cli-with-tools", | |
| 48 | + "magistral-medium-latest" | |
| 49 | + ], | |
| 50 | + "notes": "Frontier hybrid-reasoning model (GA 2026-04-28, model card id mistral-medium-3-5-26-04, billing_model_name mistral-medium-3-5, default_model_temperature 1). Reasoning is OFF unless reasoning_effort:'high' is sent (probed: default returns a plain string, no thinking). Only 'none' and 'high' accepted; minimal/low/medium/xhigh/max -> 400 code 3051 'reasoning_effort X is not supported for this model, supported values: [high, none]'. Thinking comes back as content chunks [{type:'thinking', thinking:[{type:'text', text}], closed:true}, {type:'text', text}]; reasoning tokens are counted inside completion_tokens (no separate field). magistral-medium-latest is now just an alias of this model (native Magistral retired 2026-07-31). n=2 accepted. Hidden system prompt ~12 tokens. Rate limit observed on this key: 3000 req/min, 2M tokens/min. Document QnA (document_url) supported per model card." | |
| 51 | + }, | |
| 52 | + { | |
| 53 | + "id": "mistral-small-2603", | |
| 54 | + "displayName": "Mistral Small 4", | |
| 55 | + "family": "mistral-small", | |
| 56 | + "contextTokens": 262144, | |
| 57 | + "maxOutputTokens": null, | |
| 58 | + "capabilities": { | |
| 59 | + "text": true, | |
| 60 | + "vision": true, | |
| 61 | + "audioInput": false, | |
| 62 | + "audioOutput": false, | |
| 63 | + "imageGeneration": false, | |
| 64 | + "video": false, | |
| 65 | + "reasoning": true, | |
| 66 | + "tools": true, | |
| 67 | + "structuredOutput": true, | |
| 68 | + "streaming": true, | |
| 69 | + "files": true, | |
| 70 | + "webSearch": "conversations-api-only" | |
| 71 | + }, | |
| 72 | + "parameters": { | |
| 73 | + "temperature": true, | |
| 74 | + "topP": true, | |
| 75 | + "topK": false, | |
| 76 | + "maxTokens": true, | |
| 77 | + "reasoningEffort": ["none", "high"], | |
| 78 | + "thinkingBudget": false, | |
| 79 | + "stop": true, | |
| 80 | + "seed": "random_seed", | |
| 81 | + "frequencyPenalty": true, | |
| 82 | + "presencePenalty": true | |
| 83 | + }, | |
| 84 | + "pricing": { | |
| 85 | + "inputPerMillion": 0.15, | |
| 86 | + "cachedInputPerMillion": 0.015, | |
| 87 | + "outputPerMillion": 0.6 | |
| 88 | + }, | |
| 89 | + "status": "active", | |
| 90 | + "aliases": ["mistral-small-latest", "mistral-vibe-cli-fast", "magistral-small-latest"], | |
| 91 | + "notes": "Best value hybrid-reasoning model (Apache 2.0, 'Mistral Small 4', default_model_temperature 0.3). Same reasoning contract as Medium 3.5: reasoning off by default, reasoning_effort 'high'|'none' only, thinking returned as content chunks. magistral-small-latest aliases here. Vision probed OK (32px PNG ~15 image tokens, 512x512 ~390 tokens). document_url PDF (base64 data URL and public URL) probed OK. json_schema strict + json_object probed OK. Tool calls arrive in ONE stream chunk with full arguments; 9-char alphanumeric ids. Rate limit observed: 2000 req/min, 2M tokens/min. prompt_mode:'reasoning' (legacy Magistral) -> 400 'Reasoning prompt mode is not enabled for this model'." | |
| 92 | + }, | |
| 93 | + { | |
| 94 | + "id": "mistral-large-2512", | |
| 95 | + "displayName": "Mistral Large 3", | |
| 96 | + "family": "mistral-large", | |
| 97 | + "contextTokens": 262144, | |
| 98 | + "maxOutputTokens": null, | |
| 99 | + "capabilities": { | |
| 100 | + "text": true, | |
| 101 | + "vision": true, | |
| 102 | + "audioInput": false, | |
| 103 | + "audioOutput": false, | |
| 104 | + "imageGeneration": false, | |
| 105 | + "video": false, | |
| 106 | + "reasoning": false, | |
| 107 | + "tools": true, | |
| 108 | + "structuredOutput": true, | |
| 109 | + "streaming": true, | |
| 110 | + "files": true, | |
| 111 | + "webSearch": "conversations-api-only" | |
| 112 | + }, | |
| 113 | + "parameters": { | |
| 114 | + "temperature": true, | |
| 115 | + "topP": true, | |
| 116 | + "topK": false, | |
| 117 | + "maxTokens": true, | |
| 118 | + "reasoningEffort": false, | |
| 119 | + "thinkingBudget": false, | |
| 120 | + "stop": true, | |
| 121 | + "seed": "random_seed", | |
| 122 | + "frequencyPenalty": true, | |
| 123 | + "presencePenalty": true | |
| 124 | + }, | |
| 125 | + "pricing": { | |
| 126 | + "inputPerMillion": 0.5, | |
| 127 | + "cachedInputPerMillion": 0.05, | |
| 128 | + "outputPerMillion": 1.5 | |
| 129 | + }, | |
| 130 | + "status": "active", | |
| 131 | + "aliases": ["mistral-large-latest"], | |
| 132 | + "notes": "Open-weight MoE (675B total / 41B active, Apache 2.0, released 2025-12-02, default_model_temperature 0.3). NOT a reasoning model: any reasoning_effort -> 400 'reasoning_effort is not enabled for this model'. The ONLY model that rejects n>1 (400 'Invalid value 2 for parameter `n`. Max allowed: 1.'). Cheaper than Medium 3.5 (0.5/1.5 vs 1.5/7.5). No hidden system prompt (prompt_tokens = raw). Fine-tunable. Rate limit observed on this key: only 75 req/min / 1M tokens/min (much lower than the other models). Retired slugs mistral-large-2407 resolve here (response.model = mistral-large-latest); mistral-large-2411 and pixtral-large-* return 400 invalid_model." | |
| 133 | + }, | |
| 134 | + { | |
| 135 | + "id": "ministral-14b-2512", | |
| 136 | + "displayName": "Ministral 3 14B", | |
| 137 | + "family": "ministral-3", | |
| 138 | + "contextTokens": 262144, | |
| 139 | + "maxOutputTokens": null, | |
| 140 | + "capabilities": { | |
| 141 | + "text": true, | |
| 142 | + "vision": true, | |
| 143 | + "audioInput": false, | |
| 144 | + "audioOutput": false, | |
| 145 | + "imageGeneration": false, | |
| 146 | + "video": false, | |
| 147 | + "reasoning": false, | |
| 148 | + "tools": true, | |
| 149 | + "structuredOutput": true, | |
| 150 | + "streaming": true, | |
| 151 | + "files": null, | |
| 152 | + "webSearch": "conversations-api-only" | |
| 153 | + }, | |
| 154 | + "parameters": { | |
| 155 | + "temperature": true, | |
| 156 | + "topP": true, | |
| 157 | + "topK": false, | |
| 158 | + "maxTokens": true, | |
| 159 | + "reasoningEffort": false, | |
| 160 | + "thinkingBudget": false, | |
| 161 | + "stop": true, | |
| 162 | + "seed": "random_seed", | |
| 163 | + "frequencyPenalty": true, | |
| 164 | + "presencePenalty": true | |
| 165 | + }, | |
| 166 | + "pricing": { | |
| 167 | + "inputPerMillion": 0.2, | |
| 168 | + "cachedInputPerMillion": 0.02, | |
| 169 | + "outputPerMillion": 0.2 | |
| 170 | + }, | |
| 171 | + "status": "active", | |
| 172 | + "aliases": ["ministral-14b-latest"], | |
| 173 | + "notes": "Edge model family v25.12 (Apache 2.0), text + vision, function calling, fine-tunable. Only a tiny chat probe was run on this size (200 OK); parameters assumed identical to ministral-8b (same family, same listing capabilities). Retired pixtral-12b-2409 is transparently served by ministral-14b-latest. Rate limit observed: 500 req/min, 3.8M tokens/min." | |
| 174 | + }, | |
| 175 | + { | |
| 176 | + "id": "ministral-8b-2512", | |
| 177 | + "displayName": "Ministral 3 8B", | |
| 178 | + "family": "ministral-3", | |
| 179 | + "contextTokens": 262144, | |
| 180 | + "maxOutputTokens": null, | |
| 181 | + "capabilities": { | |
| 182 | + "text": true, | |
| 183 | + "vision": true, | |
| 184 | + "audioInput": false, | |
| 185 | + "audioOutput": false, | |
| 186 | + "imageGeneration": false, | |
| 187 | + "video": false, | |
| 188 | + "reasoning": false, | |
| 189 | + "tools": true, | |
| 190 | + "structuredOutput": true, | |
| 191 | + "streaming": true, | |
| 192 | + "files": true, | |
| 193 | + "webSearch": "conversations-api-only" | |
| 194 | + }, | |
| 195 | + "parameters": { | |
| 196 | + "temperature": true, | |
| 197 | + "topP": true, | |
| 198 | + "topK": false, | |
| 199 | + "maxTokens": true, | |
| 200 | + "reasoningEffort": false, | |
| 201 | + "thinkingBudget": false, | |
| 202 | + "stop": true, | |
| 203 | + "seed": "random_seed", | |
| 204 | + "frequencyPenalty": true, | |
| 205 | + "presencePenalty": true | |
| 206 | + }, | |
| 207 | + "pricing": { | |
| 208 | + "inputPerMillion": 0.15, | |
| 209 | + "cachedInputPerMillion": 0.015, | |
| 210 | + "outputPerMillion": 0.15 | |
| 211 | + }, | |
| 212 | + "status": "active", | |
| 213 | + "aliases": ["ministral-8b-latest"], | |
| 214 | + "notes": "Fully probed: chat, streaming, tools (single-chunk tool call), json_schema strict, vision (32px PNG OK), document_url PDF OK, n=2 OK. reasoning_effort -> 400 'not enabled for this model'. Retired open-mistral-7b / ministral-8b-2410 / open-mistral-nemo slugs are served by this model. Rate limit observed: 3800 req/min, 2.5M tokens/min." | |
| 215 | + }, | |
| 216 | + { | |
| 217 | + "id": "ministral-3b-2512", | |
| 218 | + "displayName": "Ministral 3 3B", | |
| 219 | + "family": "ministral-3", | |
| 220 | + "contextTokens": 131072, | |
| 221 | + "maxOutputTokens": null, | |
| 222 | + "capabilities": { | |
| 223 | + "text": true, | |
| 224 | + "vision": true, | |
| 225 | + "audioInput": false, | |
| 226 | + "audioOutput": false, | |
| 227 | + "imageGeneration": false, | |
| 228 | + "video": false, | |
| 229 | + "reasoning": false, | |
| 230 | + "tools": true, | |
| 231 | + "structuredOutput": true, | |
| 232 | + "streaming": true, | |
| 233 | + "files": null, | |
| 234 | + "webSearch": "conversations-api-only" | |
| 235 | + }, | |
| 236 | + "parameters": { | |
| 237 | + "temperature": true, | |
| 238 | + "topP": true, | |
| 239 | + "topK": false, | |
| 240 | + "maxTokens": true, | |
| 241 | + "reasoningEffort": false, | |
| 242 | + "thinkingBudget": false, | |
| 243 | + "stop": true, | |
| 244 | + "seed": "random_seed", | |
| 245 | + "frequencyPenalty": true, | |
| 246 | + "presencePenalty": true | |
| 247 | + }, | |
| 248 | + "pricing": { | |
| 249 | + "inputPerMillion": 0.1, | |
| 250 | + "cachedInputPerMillion": 0.01, | |
| 251 | + "outputPerMillion": 0.1 | |
| 252 | + }, | |
| 253 | + "status": "active", | |
| 254 | + "aliases": ["ministral-3b-latest"], | |
| 255 | + "notes": "Smallest/cheapest (a.k.a. 'Tinystral'). 128k context (the only current chat model below 256k). Tiny chat probe OK; max_tokens 200000 (> context) accepted without error (generation just stops naturally). Parameters assumed identical to ministral-8b. Highest rate limit observed: 15000 req/min, 5M tokens/min." | |
| 256 | + }, | |
| 257 | + { | |
| 258 | + "id": "codestral-2508", | |
| 259 | + "displayName": "Codestral 25.08", | |
| 260 | + "family": "codestral", | |
| 261 | + "contextTokens": 256000, | |
| 262 | + "maxOutputTokens": null, | |
| 263 | + "capabilities": { | |
| 264 | + "text": true, | |
| 265 | + "vision": false, | |
| 266 | + "audioInput": false, | |
| 267 | + "audioOutput": false, | |
| 268 | + "imageGeneration": false, | |
| 269 | + "video": false, | |
| 270 | + "reasoning": false, | |
| 271 | + "tools": true, | |
| 272 | + "structuredOutput": true, | |
| 273 | + "streaming": true, | |
| 274 | + "files": true, | |
| 275 | + "webSearch": false | |
| 276 | + }, | |
| 277 | + "parameters": { | |
| 278 | + "temperature": true, | |
| 279 | + "topP": true, | |
| 280 | + "topK": false, | |
| 281 | + "maxTokens": true, | |
| 282 | + "reasoningEffort": false, | |
| 283 | + "thinkingBudget": false, | |
| 284 | + "stop": true, | |
| 285 | + "seed": "random_seed", | |
| 286 | + "frequencyPenalty": true, | |
| 287 | + "presencePenalty": true | |
| 288 | + }, | |
| 289 | + "pricing": { | |
| 290 | + "inputPerMillion": 0.3, | |
| 291 | + "cachedInputPerMillion": 0.03, | |
| 292 | + "outputPerMillion": 0.9 | |
| 293 | + }, | |
| 294 | + "status": "active", | |
| 295 | + "aliases": ["codestral-latest", "mistral-code-latest", "mistral-code-fim-latest"], | |
| 296 | + "notes": "Coding model (Premier, released 2025-07-30). The only model with completion_fim=true -> POST /v1/fim/completions {prompt, suffix} probed OK (FIM on any other model -> 400 'FIM is not enabled for this model'). Chat, streaming, tools, json_schema all probed OK. Image input -> 400 code 3051 'Image input is not enabled for this model'. Surprisingly document_url (base64 PDF) worked (server-side OCR). API max_context_length 256000 vs model card '128k' -> prefer the API value. mistral-code-latest / mistral-code-fim-latest are aliases (Mistral Code IDE product). Rate limit observed: 2500 req/min, 2.5M tokens/min." | |
| 297 | + }, | |
| 298 | + { | |
| 299 | + "id": "zai-glm-5-2", | |
| 300 | + "displayName": "Z.ai GLM 5.2 (third-party, preview)", | |
| 301 | + "family": "glm", | |
| 302 | + "contextTokens": 1048576, | |
| 303 | + "maxOutputTokens": 128000, | |
| 304 | + "capabilities": { | |
| 305 | + "text": true, | |
| 306 | + "vision": false, | |
| 307 | + "audioInput": false, | |
| 308 | + "audioOutput": false, | |
| 309 | + "imageGeneration": false, | |
| 310 | + "video": false, | |
| 311 | + "reasoning": true, | |
| 312 | + "tools": true, | |
| 313 | + "structuredOutput": true, | |
| 314 | + "streaming": true, | |
| 315 | + "files": null, | |
| 316 | + "webSearch": "conversations-api-only" | |
| 317 | + }, | |
| 318 | + "parameters": { | |
| 319 | + "temperature": true, | |
| 320 | + "topP": true, | |
| 321 | + "topK": false, | |
| 322 | + "maxTokens": true, | |
| 323 | + "reasoningEffort": ["none", "minimal", "low", "medium", "high", "xhigh", "max"], | |
| 324 | + "thinkingBudget": false, | |
| 325 | + "stop": true, | |
| 326 | + "seed": "random_seed", | |
| 327 | + "frequencyPenalty": true, | |
| 328 | + "presencePenalty": true | |
| 329 | + }, | |
| 330 | + "pricing": { | |
| 331 | + "inputPerMillion": 1.4, | |
| 332 | + "cachedInputPerMillion": 0.14, | |
| 333 | + "outputPerMillion": 4.4 | |
| 334 | + }, | |
| 335 | + "status": "preview", | |
| 336 | + "aliases": ["glm-5-2"], | |
| 337 | + "notes": "Third-party model hosted by Mistral 'without Mistral modifications' (Public Preview since 2026-08-06; third-party lifecycle = 1-month deprecation notice). 1M context, 128k max output (docs). Reasoning off by default; EVERY reasoning_effort value is accepted (probed none/minimal/low/medium/high/xhigh/max); 'none' and 'minimal' return a plain string (no thinking), low..max return the same thinking-chunk content shape as the Mistral hybrids. Differences from Mistral models (probed): tool-call ids look like 'chatcmpl-tool-<16 hex>' and arguments are streamed across SEVERAL chunks (standard OpenAI accumulation required); stream chunks carry delta.index and logprobs:null; usage lacks service_tier; default_model_temperature null. Image input -> 400 'Image input is not enabled'. n=2 accepted. Rate limit observed: 3000 req/min, 4M tokens/min. Data leaves the Mistral model family - flag it in the UI as third-party." | |
| 338 | + }, | |
| 339 | + { | |
| 340 | + "id": "voxtral-small-2507", | |
| 341 | + "displayName": "Voxtral Small 24B", | |
| 342 | + "family": "voxtral", | |
| 343 | + "contextTokens": 32768, | |
| 344 | + "maxOutputTokens": null, | |
| 345 | + "capabilities": { | |
| 346 | + "text": true, | |
| 347 | + "vision": false, | |
| 348 | + "audioInput": true, | |
| 349 | + "audioOutput": false, | |
| 350 | + "imageGeneration": false, | |
| 351 | + "video": false, | |
| 352 | + "reasoning": false, | |
| 353 | + "tools": true, | |
| 354 | + "structuredOutput": null, | |
| 355 | + "streaming": true, | |
| 356 | + "files": null, | |
| 357 | + "webSearch": false | |
| 358 | + }, | |
| 359 | + "parameters": { | |
| 360 | + "temperature": true, | |
| 361 | + "topP": true, | |
| 362 | + "topK": false, | |
| 363 | + "maxTokens": true, | |
| 364 | + "reasoningEffort": false, | |
| 365 | + "thinkingBudget": false, | |
| 366 | + "stop": true, | |
| 367 | + "seed": "random_seed", | |
| 368 | + "frequencyPenalty": true, | |
| 369 | + "presencePenalty": true | |
| 370 | + }, | |
| 371 | + "pricing": { | |
| 372 | + "inputPerMillion": 0.1, | |
| 373 | + "cachedInputPerMillion": null, | |
| 374 | + "outputPerMillion": 0.4 | |
| 375 | + }, | |
| 376 | + "status": "active", | |
| 377 | + "aliases": ["voxtral-small-latest"], | |
| 378 | + "notes": "Audio-understanding chat model (July 2025, Apache 2.0, default_model_temperature 0.2): accepts input_audio content parts in chat completions (docs; not probed - no audio sample) plus text. Only 32k context. Text-only chat probe OK. Listing capabilities show completion_chat + function_calling but audio:false (listing flag looks wrong vs docs). Docs pricing 0.1 in / 0.4 out per M tokens; audio priced per token via prompt_audio_seconds in usage (docs). Lowest rate limit observed: 1440 req/min, 500k tokens/min. Treat as a niche model, not a default chat choice." | |
| 379 | + }, | |
| 380 | + { | |
| 381 | + "id": "labs-leanstral-1-5", | |
| 382 | + "displayName": "Leanstral 1.5 (Labs)", | |
| 383 | + "family": "labs", | |
| 384 | + "contextTokens": 262144, | |
| 385 | + "maxOutputTokens": null, | |
| 386 | + "capabilities": { | |
| 387 | + "text": true, | |
| 388 | + "vision": true, | |
| 389 | + "audioInput": false, | |
| 390 | + "audioOutput": false, | |
| 391 | + "imageGeneration": false, | |
| 392 | + "video": false, | |
| 393 | + "reasoning": true, | |
| 394 | + "tools": true, | |
| 395 | + "structuredOutput": null, | |
| 396 | + "streaming": null, | |
| 397 | + "files": null, | |
| 398 | + "webSearch": false | |
| 399 | + }, | |
| 400 | + "parameters": { | |
| 401 | + "temperature": null, | |
| 402 | + "topP": null, | |
| 403 | + "topK": false, | |
| 404 | + "maxTokens": true, | |
| 405 | + "reasoningEffort": null, | |
| 406 | + "thinkingBudget": false, | |
| 407 | + "stop": null, | |
| 408 | + "seed": null, | |
| 409 | + "frequencyPenalty": null, | |
| 410 | + "presencePenalty": null | |
| 411 | + }, | |
| 412 | + "pricing": { | |
| 413 | + "inputPerMillion": 0, | |
| 414 | + "cachedInputPerMillion": 0, | |
| 415 | + "outputPerMillion": 0 | |
| 416 | + }, | |
| 417 | + "status": "labs-retiring-2026-09-30", | |
| 418 | + "aliases": ["labs-leanstral-1-5-1"], | |
| 419 | + "notes": "Experimental Lean 4 proof-engineering model derived from Mistral Small 4 (free while in Labs; retirement announced for 2026-09-30, 1-month Labs notice policy). Requests return 403 'Model labs-leanstral-1-5 is a Labs model. To use Labs models, an admin must enable them in your organization settings at https://admin.mistral.ai/plateforme/privacy.' unless the org opts in -> nothing else could be probed. Docs: 'We do not recommend using these models in production environments.' Do NOT list in PolyLLM by default; at most show it behind an 'experimental' flag with the 403 message mapped to a clear error." | |
| 420 | + } | |
| 421 | +] | |
added
docs/provider-research/openrouter.md
+347 −0
Large diff (347 lines) is not loaded by default to keep the page fast.Load diff
added
docs/provider-research/openrouter.models.json
+2522 −0
Large diff (2,522 lines) is not loaded by default to keep the page fast.Load diff
modified
docs/provider-test-matrix.md
+26 −26
Large diff (59 lines) is not loaded by default to keep the page fast.Load diff
modified
e2e/full-flow.spec.ts
+9 −2
Large diff (34 lines) is not loaded by default to keep the page fast.Load diff
modified
qa/.e2e-session.json
+4 −4
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added
research/cerebras/00-models.ts
+12 −0
Large diff (12 lines) is not loaded by default to keep the page fast.Load diff
added
research/cerebras/01-chat-stream.ts
+75 −0
Large diff (75 lines) is not loaded by default to keep the page fast.Load diff
added
research/cerebras/02-params.ts
+135 −0
Large diff (135 lines) is not loaded by default to keep the page fast.Load diff
added
research/cerebras/03-tools-stream.ts
+51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added
research/cerebras/04-structured.ts
+61 −0
Large diff (61 lines) is not loaded by default to keep the page fast.Load diff
added
research/cerebras/05-vision.ts
+69 −0
Large diff (69 lines) is not loaded by default to keep the page fast.Load diff
added
research/cerebras/06-errors.ts
+57 −0
Large diff (57 lines) is not loaded by default to keep the page fast.Load diff
added
research/cerebras/07-context-reasoning.ts
+95 −0
Large diff (95 lines) is not loaded by default to keep the page fast.Load diff
added
research/cerebras/08-misc.ts
+72 −0
Large diff (72 lines) is not loaded by default to keep the page fast.Load diff
added
research/cerebras/lib.ts
+116 −0
Large diff (116 lines) is not loaded by default to keep the page fast.Load diff
added
research/cerebras/package.json
+25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added
research/cerebras/pnpm-lock.yaml
+822 −0
Large diff (822 lines) is not loaded by default to keep the page fast.Load diff
added
research/deepseek/00-models.ts
+19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
added
research/deepseek/01-chat-stream.ts
+38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added
research/deepseek/02-params.ts
+66 −0
Large diff (66 lines) is not loaded by default to keep the page fast.Load diff
added
research/deepseek/03-tools-stream.ts
+60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added
research/deepseek/04-json.ts
+19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
added
research/deepseek/05-vision.ts
+26 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added
research/deepseek/06-errors.ts
+18 −0
Large diff (18 lines) is not loaded by default to keep the page fast.Load diff
added
research/deepseek/07-reasoning.ts
+48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added
research/deepseek/08-other-apis.ts
+44 −0
Large diff (44 lines) is not loaded by default to keep the page fast.Load diff
added
research/deepseek/09-reasoning-replay-tools.ts
+42 −0
Large diff (42 lines) is not loaded by default to keep the page fast.Load diff
added
research/deepseek/10-misc.ts
+52 −0
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
added
research/deepseek/lib.ts
+99 −0
Large diff (99 lines) is not loaded by default to keep the page fast.Load diff
added
research/deepseek/package.json
+25 −0
Large diff (26 lines) is not loaded by default to keep the page fast.Load diff
added
research/deepseek/pnpm-lock.yaml
+532 −0
Large diff (532 lines) is not loaded by default to keep the page fast.Load diff
added
research/kimi/00-models.ts
+24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added
research/kimi/01-chat-stream.ts
+48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added
research/kimi/02-params.ts
+80 −0
Large diff (80 lines) is not loaded by default to keep the page fast.Load diff
added
research/kimi/03-tools-stream.ts
+60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added
research/kimi/04-json-vision.ts
+39 −0
Large diff (39 lines) is not loaded by default to keep the page fast.Load diff
added
research/kimi/05-errors-limits.ts
+60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added
research/kimi/06-websearch.ts
+45 −0
Large diff (45 lines) is not loaded by default to keep the page fast.Load diff
added
research/kimi/06b-websearch-variants.ts
+36 −0
Large diff (36 lines) is not loaded by default to keep the page fast.Load diff
added
research/kimi/07-websearch-alt.ts
+60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added
research/kimi/08-misc.ts
+92 −0
Large diff (92 lines) is not loaded by default to keep the page fast.Load diff
added
research/kimi/lib.ts
+139 −0
Large diff (139 lines) is not loaded by default to keep the page fast.Load diff
added
research/kimi/package.json
+24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added
research/kimi/pnpm-lock.yaml
+532 −0
Large diff (532 lines) is not loaded by default to keep the page fast.Load diff
added
research/mistral/00-models.ts
+33 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
added
research/mistral/01-chat-stream.ts
+60 −0
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
added
research/mistral/02-params.ts
+110 −0
Large diff (110 lines) is not loaded by default to keep the page fast.Load diff
added
research/mistral/03-tools-stream.ts
+67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added
research/mistral/04-structured.ts
+48 −0
Large diff (48 lines) is not loaded by default to keep the page fast.Load diff
added
research/mistral/05-vision.ts
+38 −0
Large diff (38 lines) is not loaded by default to keep the page fast.Load diff
added
research/mistral/06-errors.ts
+51 −0
Large diff (51 lines) is not loaded by default to keep the page fast.Load diff
added
research/mistral/07-document.ts
+49 −0
Large diff (49 lines) is not loaded by default to keep the page fast.Load diff
added
research/mistral/08-sdks-cache.ts
+119 −0
Large diff (119 lines) is not loaded by default to keep the page fast.Load diff
added
research/mistral/09-reasoning-misc.ts
+114 −0
Large diff (114 lines) is not loaded by default to keep the page fast.Load diff
added
research/mistral/10-glm-followups.ts
+73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added
research/mistral/11-glm-minimal.ts
+6 −0
Large diff (6 lines) is not loaded by default to keep the page fast.Load diff
added
research/mistral/lib.ts
+168 −0
Large diff (168 lines) is not loaded by default to keep the page fast.Load diff
added
research/mistral/package.json
+25 −0
Large diff (25 lines) is not loaded by default to keep the page fast.Load diff
added
research/mistral/pnpm-lock.yaml
+599 −0
Large diff (599 lines) is not loaded by default to keep the page fast.Load diff
added
research/openrouter/00-models.ts
+169 −0
Large diff (169 lines) is not loaded by default to keep the page fast.Load diff
added
research/openrouter/01-chat-stream.ts
+87 −0
Large diff (87 lines) is not loaded by default to keep the page fast.Load diff
added
research/openrouter/02-params.ts
+101 −0
Large diff (101 lines) is not loaded by default to keep the page fast.Load diff
added
research/openrouter/03-tools-stream.ts
+71 −0
Large diff (71 lines) is not loaded by default to keep the page fast.Load diff
added
research/openrouter/04-structured.ts
+65 −0
Large diff (65 lines) is not loaded by default to keep the page fast.Load diff
added
research/openrouter/05-vision.ts
+78 −0
Large diff (78 lines) is not loaded by default to keep the page fast.Load diff
added
research/openrouter/06-errors.ts
+59 −0
Large diff (59 lines) is not loaded by default to keep the page fast.Load diff
added
research/openrouter/07-websearch.ts
+73 −0
Large diff (73 lines) is not loaded by default to keep the page fast.Load diff
added
research/openrouter/08-misc.ts
+105 −0
Large diff (105 lines) is not loaded by default to keep the page fast.Load diff
added
research/openrouter/09-reasoning-roundtrip.ts
+115 −0
Large diff (115 lines) is not loaded by default to keep the page fast.Load diff
added
research/openrouter/gen-models-json.ts
+146 −0
Large diff (146 lines) is not loaded by default to keep the page fast.Load diff
added
research/openrouter/lib.ts
+114 −0
Large diff (114 lines) is not loaded by default to keep the page fast.Load diff
added
research/openrouter/models.ts
+31 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added
research/openrouter/package.json
+24 −0
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
added
research/openrouter/pnpm-lock.yaml
+532 −0
Large diff (532 lines) is not loaded by default to keep the page fast.Load diff
modified
scripts/provider-matrix.ts
+11 −9
Large diff (52 lines) is not loaded by default to keep the page fast.Load diff
modified
scripts/sync-models.ts
+2 −1
Large diff (14 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/(auth)/signup/page.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/(marketing)/page.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/api/admin/providers/route.ts
+3 −2
Large diff (23 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/globals.css
+15 −0
Large diff (33 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/layout.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
src/app/share/[id]/page.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/brand/provider-icon.tsx
+50 −4
Large diff (84 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/chat/chat-view.tsx
+2 −1
Large diff (15 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/chat/empty-state.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/chat/model-selector.tsx
+2 −5
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/marketing/features.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/marketing/footer.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/marketing/hero.tsx
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
src/components/marketing/mock-data.ts
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
modified
src/lib/ai/core/normalize.ts
+2 −1
Large diff (13 lines) is not loaded by default to keep the page fast.Load diff
modified
src/lib/ai/core/types.ts
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added
src/lib/ai/providers/cerebras/catalog.ts
+58 −0
Large diff (58 lines) is not loaded by default to keep the page fast.Load diff
added
src/lib/ai/providers/cerebras/index.ts
+67 −0
Large diff (67 lines) is not loaded by default to keep the page fast.Load diff
added
src/lib/ai/providers/deepseek/catalog.ts
+21 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
added
src/lib/ai/providers/deepseek/index.ts
+72 −0
Large diff (72 lines) is not loaded by default to keep the page fast.Load diff
added
src/lib/ai/providers/env-keys.ts
+19 −0
Large diff (19 lines) is not loaded by default to keep the page fast.Load diff
modified
src/lib/ai/providers/index.ts
+15 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
added
src/lib/ai/providers/kimi/catalog.ts
+4 −0
Large diff (4 lines) is not loaded by default to keep the page fast.Load diff
added
src/lib/ai/providers/kimi/index.ts
+141 −0
Large diff (141 lines) is not loaded by default to keep the page fast.Load diff
added
src/lib/ai/providers/mistral/catalog.ts
+56 −0
Large diff (56 lines) is not loaded by default to keep the page fast.Load diff
added
src/lib/ai/providers/mistral/index.ts
+165 −0
Large diff (165 lines) is not loaded by default to keep the page fast.Load diff
added
src/lib/ai/providers/openrouter/index.ts
+202 −0
Large diff (202 lines) is not loaded by default to keep the page fast.Load diff
modified
src/lib/ai/providers/shared/openai-compat/chat-completions.ts
+35 −5
Large diff (94 lines) is not loaded by default to keep the page fast.Load diff
added
src/lib/ai/providers/shared/openai-compat/factory.ts
+195 −0
Large diff (195 lines) is not loaded by default to keep the page fast.Load diff
modified
src/lib/ai/registry/catalog.ts
+9 −0
Large diff (21 lines) is not loaded by default to keep the page fast.Load diff
modified
src/lib/ai/registry/index.ts
+2 −7
Large diff (24 lines) is not loaded by default to keep the page fast.Load diff
modified
src/lib/chat/service.ts
+19 −0
Large diff (31 lines) is not loaded by default to keep the page fast.Load diff
modified
src/lib/client/providers.ts
+51 −1
Large diff (60 lines) is not loaded by default to keep the page fast.Load diff
modified
src/lib/email/templates.ts
+1 −1
Large diff (8 lines) is not loaded by default to keep the page fast.Load diff
added
tests/unit/compat-providers.test.ts
+110 −0
Large diff (111 lines) is not loaded by default to keep the page fast.Load diff