SPB Git forge

spb/polyllm

Public
15commits 1branches 0releases
2.2 MBsize
maindefault branch
13 days agolast push
TypeScript 97.4% SQL 1% JavaScript 0.9% CSS 0.6%

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>
Simon-Pierre Boucher committed 16 days ago (Sep 8, 2026) parent 55d2d4b

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
@@ -0,0 +1,347 @@
1 +# OpenRouter — provider research for PolyLLM
2 +
3 +Last documentation audit: **2026-09-08**
4 +Probes executed 2026-09-08 with a real paid key against `https://openrouter.ai/api/v1` (scripts in `research/openrouter/`, raw outputs in `research/openrouter/out/`, full model dump in `research/openrouter/out/models.json`, 428 models). Everything marked **(probed)** was observed live; everything marked **(docs)** comes from the pages listed at the end. Total probe spend ≈ $0.07.
5 +
6 +> Heads-up: several `openrouter.ai/docs/...` URLs given in the brief are **404** now (`/docs/api-reference/list-available-models`, `/docs/api-reference/get-current-api-key`, `/docs/features/tool-calling`, `/docs/api-reference/responses/*`, `/docs/sdks/typescript`). The documentation was reorganised under `/docs/api/api-reference/...`, `/docs/guides/...`, `/docs/api_reference/...` and `/docs/client-sdks/...`; the index is `https://openrouter.ai/docs/llms.txt` (every page also exists as `.md`). See "Documentation pages used".
7 +
8 +---
9 +
10 +## 1. Base URL, auth, headers
11 +
12 +| Item | Value |
13 +|---|---|
14 +| REST base URL | `https://openrouter.ai/api/v1` |
15 +| Auth | `Authorization: Bearer <OPENROUTER_API_KEY>` (keys look like `sk-or-v1-…`) |
16 +| Attribution headers (optional, docs) | `HTTP-Referer: <site url>` (leaderboard/rankings) and `X-OpenRouter-Title: <app name>`. The older `X-Title` is still shown on the OpenAI-SDK page and both are accepted **(probed: 200 with either)**. The `/generation` record stored `origin: "https://www.polyllm.io/"` from our `HTTP-Referer` **(probed)**. Also `X-OpenRouter-Categories` (docs). |
17 +| Response headers **(probed)** | Only Cloudflare basics (`cf-ray`, `server: cloudflare`, `content-type`, `x-content-type-options`). **No `x-ratelimit-*` headers on 200s**; docs say `X-RateLimit-Limit`, `X-RateLimit-Remaining`, `Retry-After` appear on **429/503**. No request-id header — use the body `id` (`gen-…`). |
18 +| Key introspection | `GET /api/v1/key``data.{label, usage, usage_daily, usage_weekly, usage_monthly, limit, limit_remaining, limit_reset, is_free_tier, is_management_key, is_provisioning_key(deprecated), byok_usage*, include_byok_in_limit, expires_at, creator_user_id, rate_limit(deprecated: {requests:-1, interval:"10s", note})}` **(probed, 200 with a normal inference key)**. Ideal "validate key" call (no tokens). Invalid key → **401** `{"error":{"message":"User not found.","code":401}}`. |
19 +| Credits | `GET /api/v1/credits``{"data":{"total_credits":1050,"total_usage":778.849026603}}` **(probed 200 with our normal key)** although the docs say "Management key required" — don't depend on it for user keys; treat 403 as "not available". |
20 +| Session stickiness | body `session_id` (≤256 chars) or header `x-session-id` (docs) — groups multi-turn requests on the same provider and is used by `openrouter/auto` to keep a conversation on the same model. |
21 +
22 +## 2. SDK recommendation (TypeScript / Node)
23 +
24 +- OpenRouter now ships its **own SDKs**: `@openrouter/sdk` (TypeScript, `openRouter.chat.send({model, messages, stream})`) and `@openrouter/agent`; the quickstart says "For all code samples and guidance use OpenRouter SDKs by default (`@openrouter/sdk` for TypeScript…)". The OpenAI SDK with `baseURL: "https://openrouter.ai/api/v1"` + `defaultHeaders` is still a documented, fully supported path ("drop-in replacement", community page `guides/community/openai-sdk`), with OpenRouter-only fields passed through `extra_body`/untyped properties. `@openrouter/ai-sdk-provider` exists for Vercel AI SDK users (exposes generation ids and cost via provider metadata).
25 +- **Recommendation for PolyLLM:** keep the OpenAI SDK **`openai@7.10.0`** (probed) or a raw `fetch` SSE parser — PolyLLM already has both for xAI/OpenAI and the wire format is identical. Everything OpenRouter-specific (`reasoning`, `provider`, `plugins`, `usage.cost`, `reasoning_details`, `annotations`, `provider` field) is untyped in the OpenAI SDK, so cast (`as any`) or use raw fetch. The OpenAI SDK's SSE parser already skips the `: OPENROUTER PROCESSING` comment lines. Only adopt `@openrouter/sdk` if we want typed access to those extras.
26 +- The `models` array / `route` fallback fields of the old docs are now "deprecated — use provider.sort.partition" (API reference).
27 +
28 +## 3. Endpoints
29 +
30 +| Endpoint | Status | Notes |
31 +|---|---|---|
32 +| `POST /api/v1/chat/completions` | **primary** | OpenAI-compatible + OpenRouter extras. **(probed)** |
33 +| `POST /api/v1/responses` | active, **stateless** | Drop-in for OpenAI Responses API but `store: true` and `previous_response_id` are **rejected with 400** (`"Invalid input: expected false"` / `"expected null, received string"`) **(probed)**. Response echoes `store: false`. Streaming event names: `response.created`, `response.in_progress`, `response.output_item.added`, `response.content_part.added`, `response.output_text.delta`, `response.output_text.done`, `response.content_part.done`, `response.output_item.done`, `response.completed`, `[DONE]` **(probed)**. Usage: `input_tokens`, `output_tokens`, `*_details`, `cost`, `cost_details.upstream_inference_input_cost/output_cost`. Not needed for PolyLLM — chat completions covers everything, and reasoning/tools work there. |
34 +| `GET /api/v1/models` | active | Full catalogue with pricing/params (§12). Paginated (`limit` default 500, max 1000, `links.next`, `total_count`); with no params the full list is returned (`data: 428, links.next: null`) **(probed)**. Many filters: `supported_parameters=tools`, `output_modalities`, `input_modalities`, `category`, `sort`, `q`, `zdr=true`, `min_price`… (docs). **Works without auth (200 with a bad key)** **(probed)**. |
35 +| `GET /api/v1/models/user` | active | Same shape filtered by the account's provider/model preferences (425 vs 428: hides `meta/muse-spark-*-contributor`, `sakana/sakana-namazu`) **(probed)**. Use this one when the user's key is set so ignored providers disappear. |
36 +| `GET /api/v1/models/count` | active | `{"data":{"count":428}}` **(probed)** |
37 +| `GET /api/v1/models/{author}/{slug}/endpoints` | active | Per-provider endpoints: `name, provider_name, tag, context_length, max_completion_tokens, max_prompt_tokens, pricing (incl. discount), supported_parameters, supports_tool_choice, status, quantization, uptime_last_30m/5m/1d, supports_implicit_caching, latency_last_30m, throughput_last_30m, perf_last_30m_by_workload` **(probed)**. `links.details` in the listing points here. |
38 +| `GET /api/v1/endpoints/zdr` | active | 845 zero-data-retention endpoints **(probed)** |
39 +| `GET /api/v1/providers` | active | 106 providers with `slug, privacy_policy_url, terms_of_service_url, status_page_url, headquarters` **(probed)** |
40 +| `GET /api/v1/generation?id=gen-…` | active | Async stats for one generation: `native_tokens_prompt/completion/reasoning/cached`, `usage` (USD), `latency`, `generation_time`, `streamed`, `cancelled`, `finish_reason`, `origin`, `provider`**(probed: 404 "Generation … not found" 1.5 s after the call, 200 after ~4 min — poll with backoff or just use `usage.cost` from the response).** |
41 +| `GET /api/v1/key`, `GET /api/v1/credits` | active | §1 |
42 +| `POST /api/beta/batches` | beta | `:batch` model variants are **only** usable here: chat completions → **404** `"This model is only available through the Batch API. Use the /api/beta/batches endpoint instead."` **(probed)**. |
43 +| `POST /api/v1/images/generations`, `/api/v1/images/models`, audio/TTS/STT, embeddings | active | Out of scope; image generation also works through chat completions with `modalities: ["image","text"]` (docs). |
44 +
45 +## 4. Chat Completions request/response (probed shapes)
46 +
47 +Request body (docs API ref + probes): `model`, `messages` (roles `system|developer|user|assistant|tool`; `developer` accepted on all 5 probed models), `stream`, `max_tokens` (docs mark it deprecated in favour of `max_completion_tokens`, but **only 60/428 models list `max_completion_tokens` in `supported_parameters` while 417 list `max_tokens` → send `max_tokens`**), `temperature` 0–2, `top_p`, `top_k`, `min_p`, `top_a`, `frequency_penalty`/`presence_penalty` −2..2, `repetition_penalty`, `seed`, `stop` (≤4), `logit_bias`, `logprobs`/`top_logprobs` (0–20), `n`, `response_format` (`text|json_object|json_schema|grammar|python`), `tools`, `tool_choice` (`none|auto|required|{type:"function",function:{name}}`), `parallel_tool_calls`, `reasoning` (§8), `reasoning_effort` (shorthand), `include_reasoning` (legacy), `verbosity` (`low|medium|high`), `usage: {include: true}` (**deprecated, no effect — usage is always returned**, docs + probed), `stream_options: {include_usage: true}` (same), `provider` (§11), `plugins` (§10), `models`/`route` (deprecated), `modalities`, `image_config`, `web_search_options`, `prediction`, `session_id`, `user`, `metadata` (≤16 pairs), `trace`, `service_tier` (`auto|default|fast|flex|priority|scale`), `prompt_cache_key`, `prompt_cache_options`, `cache_control` (message-part level, §13), `debug: {echo_upstream_body: true}` (stream only, dev only).
48 +
49 +Unknown top-level params (`foo_bar`) are **silently ignored** **(probed)**. Unsupported-but-known params are **silently dropped** unless `provider.require_parameters: true` (§9).
50 +
51 +Non-streaming response **(probed, x-ai/grok-4.20 with `reasoning.effort: low`)**:
52 +
53 +```json
54 +{
55 + "id": "gen-1788849315-…", "object": "chat.completion", "created": 1788849315,
56 + "model": "x-ai/grok-4.20", "provider": "xAI", "system_fingerprint": null, "service_tier": "default",
57 + "choices": [{ "index": 0, "logprobs": null, "finish_reason": "stop", "native_finish_reason": "completed",
58 + "message": { "role": "assistant", "content": "2 + 2 equals 4.", "refusal": null,
59 + "reasoning": "The question is: \"What is 2+2? …\"\n",
60 + "reasoning_details": [
61 + { "type": "reasoning.summary", "summary": "The question is: …", "format": "xai-responses-v1", "index": 0 },
62 + { "type": "reasoning.encrypted", "data": "Tux7gDG5T2…", "format": "xai-responses-v1", "index": 1 } ] } }],
63 + "usage": { "prompt_tokens": 199, "completion_tokens": 156, "total_tokens": 355,
64 + "cost": 0.00050435, "is_byok": false,
65 + "prompt_tokens_details": { "cached_tokens": 128, "cache_write_tokens": 0, "audio_tokens": 0, "video_tokens": 0 },
66 + "cost_details": { "upstream_inference_cost": 0.00050435, "upstream_inference_prompt_cost": 0.00011435, "upstream_inference_completions_cost": 0.00039 },
67 + "completion_tokens_details": { "reasoning_tokens": 148, "image_tokens": 0, "audio_tokens": 0 } }
68 +}
69 +```
70 +
71 +- **`provider`** (top level) = the upstream that actually served the call (`OpenAI`, `Amazon Bedrock`, `Google`, `xAI`, `Novita`, `GMICloud`, `Parasail`, `StreamLake`, `AtlasCloud`, `Alibaba`, `Venice`, `Azure`, `DigitalOcean`, `NextBit`…). It **changes from call to call** for open models (DeepSeek hit 9 different providers in one probe run) → show it in the usage panel; it explains quality/format differences.
72 +- **`model`** = the resolved slug (aliases/`~latest`/`openrouter/auto` resolve here: `~openai/gpt-mini-latest``openai/gpt-5.4-mini`, `openrouter/auto``deepseek/deepseek-v4-flash-0731` **(probed)**). Unknown *variants* are ignored: `openai/gpt-5.4-nano:nope` → 200 served as `openai/gpt-5.4-nano` **(probed)**.
73 +- **`usage.cost`** = USD (credits) charged, always present (BYOK: `is_byok: true`, `cost` = OpenRouter fee only and `cost_details.upstream_inference_cost` = provider cost). Plugin fees are included in `cost` but not in `upstream_inference_cost` (web plugin: cost 0.007709 vs upstream 0.000709 → **$0.007 Exa search fee**) **(probed)**. **Use `usage.cost` directly for the cost display** — no client-side price math needed for OpenRouter.
74 +- **`completion_tokens` INCLUDES `reasoning_tokens`** (DeepSeek: completion 24 = 8 visible + 16 reasoning; xAI: 156 = 8 + 148) **(probed)** — unlike xAI direct. And **`max_tokens` caps reasoning + visible text together on most providers**: DeepSeek with `max_tokens: 40` and `reasoning.exclude: true` returned `content: null`, `finish_reason: "length"`, 40 reasoning tokens; gpt-5.4-nano `:online` with `max_tokens: 200` spent all 200 on reasoning → `content: null` **(probed)**. **Adapter rule: for reasoning-capable models never send a small `max_tokens`; default ≥ 2048 (or `max_tokens = reasoning.max_tokens + visible budget`).**
75 +- `finish_reason` normalised to `stop | length | tool_calls | content_filter | error` (+ `function_call` in the schema); raw value in `native_finish_reason` (`completed`, `end_turn`, `STOP`, `stop`, `length`…) **(probed)**.
76 +- `message.reasoning` may be `null` while `reasoning_details` is present (Gemini returns only an encrypted signature, no text) **(probed)**.
77 +- Hidden prompt overhead: xAI models carry ≈ 196 prompt tokens (128 cached) on an empty request, DeepSeek ≈ 90 via some providers, others ~7–20 **(probed)**.
78 +
79 +## 5. Streaming protocol **(probed)**
80 +
81 +SSE, `data: {json}` lines, terminated by `data: [DONE]`, **plus comment lines `: OPENROUTER PROCESSING`** as keep-alives (1 on OpenAI/Anthropic, 8 on xAI while it was thinking, 0 on Gemini/DeepSeek). SSE comments must be skipped before `JSON.parse` (OpenAI SDK / eventsource-parser do it; a naive `data:` splitter that also feeds other lines would crash).
82 +
83 +```json
84 +data: {"id":"gen-…","object":"chat.completion.chunk","created":1788849322,"model":"x-ai/grok-4.20","provider":"xAI",
85 + "choices":[{"index":0,"delta":{"content":"","role":"assistant","reasoning":"The",
86 + "reasoning_details":[{"type":"reasoning.summary","summary":"The","format":"xai-responses-v1","index":0}]},
87 + "finish_reason":null,"native_finish_reason":null}]}
88 +
89 +data: {"…","choices":[{"index":0,"delta":{"content":"Bonjour"},"finish_reason":null,"native_finish_reason":null}]}
90 +data: {"…","choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":"stop","native_finish_reason":"completed"}]}
91 +data: {"…","service_tier":"default","choices":[{"index":0,"delta":{"content":"","role":"assistant"},"finish_reason":"stop","native_finish_reason":"completed"}],
92 + "usage":{"prompt_tokens":196,"completion_tokens":380,"total_tokens":576,"cost":0.0010606,"is_byok":false,
93 + "prompt_tokens_details":{"cached_tokens":128,},"cost_details":{},"completion_tokens_details":{"reasoning_tokens":378,}}}
94 +data: [DONE]
95 +```
96 +
97 +- Delta keys observed: `role`, `content`, `reasoning` (plain text), `reasoning_details[]` (fragments), `tool_calls`, `annotations` (web search). Every chunk carries `provider` and `model`.
98 +- **The finish chunk is sent twice**: once without `usage`, then a final chunk with `usage` **that still has `choices:[{delta:{content:"",role:"assistant"},finish_reason:"stop"}]`** (docs: deliberate deviation from OpenAI's empty `choices` "to prevent client crashes"). Do not double-count `finish_reason`; take usage from whichever chunk has `usage`. Usage is included **without any flag** **(probed)**.
99 +- **Reasoning streams as `delta.reasoning` text** for DeepSeek (raw thoughts), xAI (summary), Anthropic (thinking text when enabled via `reasoning.max_tokens`); Gemini streams only `reasoning_details` with an encrypted `signature` and no text; OpenAI gpt-5.4-nano streamed nothing (0 reasoning tokens on trivial prompts at `low`).
100 +- **Mid-stream errors** (docs): HTTP 200 already sent, then an SSE event `{"error":{"code":"server_error","message":"…","metadata":{"error_type","provider_code"}},"choices":[{"index":0,"delta":{"content":""},"finish_reason":"error"}]}` and the stream ends. Handle `finish_reason: "error"` + top-level `error`.
101 +- Cancellation: aborting the connection stops billing on OpenAI, Anthropic, Fireworks, Together, Cohere and ~20 others; **not** on Groq, Google, Mistral, HuggingFace (docs).
102 +
103 +## 6. Tool / function calling **(probed on all 5 models)**
104 +
105 +- OpenAI nested format; `tool_choice` `auto|none|required|{type:"function",function:{name}}`; `parallel_tool_calls` (default true). Only 9/428 models list `parallel_tool_calls` in `supported_parameters` — sending it elsewhere is silently dropped **(probed OK on all 5)**.
106 +- Streaming: tool calls arrive as **fragmented deltas** (OpenAI 8 chunks, DeepSeek 5, Anthropic 4, xAI 3, Gemini 2) → standard accumulation by `index`, then `finish_reason: "tool_calls"`. Ids are provider-native: `call_…` (OpenAI/Gemini/DeepSeek), `toolu_bdrk_…` (Anthropic via Bedrock), `call-<uuid>-0` (xAI).
107 +- Round 2 with `assistant.tool_calls` + `{role:"tool", tool_call_id, content}` worked on all 5 (`finish_reason: stop`).
108 +- **Reasoning + tools:** with Anthropic thinking (`reasoning.max_tokens: 1024`) and Gemini, `reasoning_details` came alongside the tool call (Anthropic: `reasoning.text` fragments + final `signature`, `format: "anthropic-claude-v1"`; Gemini: one `reasoning.encrypted` whose `id` equals the tool-call id, `format: "google-gemini-v1"`). Docs require passing `reasoning_details` back **unmodified** on the assistant message ("the entire sequence of consecutive reasoning blocks must match"). We merged the streamed fragments by `(type,index)` and passed them back → 200; omitting them also returned 200 on both **(probed)** — OpenRouter tolerates it, but pass them back anyway for cache and thought continuity (mandatory for OpenAI Responses-backed models per docs). Store `reasoning_details` in PolyLLM's message model.
109 +- Find tool-capable models: `supported_parameters` contains `tools` (361/428) / `tool_choice` (353). Endpoint-level `supports_tool_choice` in `/endpoints`.
110 +- Docs also mention server tools (`openrouter:web_search`, `stop_server_tools_when`) and `:exacto` (quality-first provider sort for tool reliability).
111 +
112 +## 7. Structured output **(probed)**
113 +
114 +- `response_format: {type:"json_schema", json_schema:{name, strict:true, schema}}` with `provider.require_parameters: true` → valid JSON on gpt-5.4-nano, claude-haiku-4.5, gemini-3.5-flash-lite, grok-4.20; DeepSeek returned `content: null` (`finish_reason: length`, 200 reasoning tokens ate `max_tokens: 200`) — the usual reasoning-budget trap, not a schema failure. Without `require_parameters`, all 5 produced schema-conformant JSON in the params probe. Streaming json_schema works (partial JSON deltas, valid when concatenated) **(probed on gpt-5.4-nano)**.
115 +- `{type:"json_object"}` works on all 5, but Anthropic wrapped it in a ```json fence **(probed)** → strip fences when parsing json_object output.
116 +- Capability flags: `supported_parameters` has `response_format` (368) and `structured_outputs` (345; = strict schema support). 52 models have `tools` but not `structured_outputs`. Docs: "the same model may be served by multiple providers, and only some… support structured outputs" → send `provider: {require_parameters: true}` whenever `response_format.type === "json_schema"`; docs say `tools`, `response_format` and `verbosity` are already "soft preferences" in routing.
117 +- Extra formats exist in the schema (`grammar` GBNF, `python`) and a `response-healing` plugin for non-streaming JSON repair (docs).
118 +
119 +## 8. Reasoning controls
120 +
121 +Request: `reasoning: { effort?: "max"|"xhigh"|"high"|"medium"|"low"|"minimal"|"none", max_tokens?: number, exclude?: boolean, enabled?: boolean, summary?: "auto"|"concise"|"detailed", context?: "all_turns"|"current_turn"|"auto", mode?: "standard"|"pro" }` (docs). `reasoning_effort: "low"` shorthand also works **(probed)**. Effort→budget conversion for `max_tokens`-only providers: max/xhigh ≈95 %, high 80 %, medium 50 %, low 20 %, minimal 10 % of `max_tokens` (docs).
122 +
123 +Listing field `reasoning` (present on 302/428 models; absent = non-reasoning): `{ mandatory: boolean, default_enabled?: boolean, supported_efforts?: string[], default_effort?: string, supports_max_tokens?: boolean }`. Observed shapes: `{mandatory}` only (107), `+default_enabled` (34), `+supported_efforts+default_effort` (94/57), `+supports_max_tokens` (4/6).
124 +
125 +| Model **(probed)** | listing `reasoning` | observed |
126 +|---|---|---|
127 +| openai/gpt-5.4-nano | `{mandatory:false, default_enabled:false, supported_efforts:[xhigh,high,medium,low,none], default_effort:medium}` | off by default (0 reasoning tokens); `effort: low` accepted (still 0 on trivial prompt); `none` OK; `max_tokens`/`exclude`/`enabled:false` accepted (no-ops) |
128 +| anthropic/claude-haiku-4.5 | `{mandatory:false}` | **`effort` does nothing (0 tokens); only `reasoning.max_tokens: 1024` turns thinking on (43–65 tokens)**`reasoning` text + `reasoning_details[{type:"reasoning.text", text, signature, format:"anthropic-claude-v1"}]`. Matches the PolyLLM note "Haiku 4.5 needs budget_tokens". `enabled:false` fine. |
129 +| google/gemini-3.5-flash-lite | `{mandatory:true, default_enabled:true, supported_efforts:[high,medium,low,minimal], default_effort:minimal}` | **`effort: none` and `enabled: false` → 400 `"Reasoning is mandatory for this endpoint and cannot be disabled."`** (`metadata.provider_name: null` = OpenRouter-side validation). `reasoning` text never returned; `reasoning_details[{type:"reasoning.text", signature, format:"google-gemini-v1"}]` (signature only) |
130 +| deepseek/deepseek-v4-flash | `{mandatory:false, supported_efforts:[xhigh,high], default_effort:high}` | reasoning **on by default** (15–40 tokens); `effort: none` and `enabled:false` → 0 tokens (works although `none` isn't listed); raw thoughts in `reasoning` + `reasoning_details[{type:"reasoning.text", text, format:"unknown"}]`; `exclude:true` hides the text but still bills/consumes `max_tokens` |
131 +| x-ai/grok-4.20 | `{mandatory:false, default_enabled:false}` | off by default; `effort: low` / `max_tokens` / `reasoning_effort` turn it on (90–165 tokens) → `reasoning.summary` + `reasoning.encrypted` (`format: xai-responses-v1`) |
132 +
133 +**Adapter rules:** (1) `reasoning` absent → no reasoning UI. (2) `mandatory: true` → never send `effort: none` / `enabled: false`; offer only `supported_efforts`. (3) `mandatory: false` + `supported_efforts` → toggle + effort picker (`none` only if listed, else use `enabled: false` to turn off). (4) `mandatory: false` and **no** `supported_efforts` (Anthropic/Qwen/…): toggle maps to `reasoning.max_tokens` (budget slider, min 1024 for Anthropic per docs) or `reasoning.enabled: true`. (5) `default_enabled` decides the initial toggle state (absent → `mandatory`). (6) `supports_max_tokens: true` → expose a budget slider; docs say Anthropic (1024–128k) and Gemini (`thinkingBudget`) accept `max_tokens` even when the flag is absent. (7) Always keep `reasoning_details` and replay them.
134 +
135 +## 9. Sampling & other parameters — support matrix **(probed, chat completions; L = listed in `supported_parameters`, ✓ = 200 by default, 404 = rejected only with `provider.require_parameters: true`)**
136 +
137 +| Param | gpt-5.4-nano | claude-haiku-4.5 | gemini-3.5-flash-lite | deepseek-v4-flash | grok-4.20 |
138 +|---|---|---|---|---|---|
139 +| `temperature` | ✓ dropped (not L) | ✓ L | ✓ L | ✓ L | ✓ L |
140 +| `top_p` | ✓ dropped | ✓ L | ✓ L | ✓ L | ✓ L |
141 +| `top_k` | ✓ dropped / **404** rp | ✓ L | ✓ dropped / **404** rp | ✓ L | ✓ dropped / **404** rp |
142 +| `min_p`, `top_a` | dropped | dropped | dropped | ✓ L | dropped |
143 +| `seed` | ✓ L | dropped / **404** rp | ✓ L | ✓ L | ✓ L |
144 +| `stop` | dropped / **404** rp | ✓ L | ✓ L | ✓ L | dropped / **404** rp |
145 +| `frequency_penalty` / `presence_penalty` | dropped / **404** rp | dropped / **404** rp | dropped / **404** rp | ✓ L | dropped / **404** rp |
146 +| `repetition_penalty` | dropped / 404 rp | dropped / 404 rp | dropped / 404 rp | ✓ L | dropped / 404 rp |
147 +| `logprobs` + `top_logprobs` | dropped / 404 rp | dropped / 404 rp | dropped / 404 rp | ✓ L | ✓ L (`logprobs: null` returned) |
148 +| `max_tokens` / `max_completion_tokens` | ✓ / ✓ | ✓ / ✓ | ✓ / ✓ (not L) | ✓ / ✓ | ✓ / ✓ (not L) |
149 +| `n: 2` | 200, 1 choice | 200, 1 choice | 200, 1 choice | 200, 1 choice | 200, 1 choice |
150 +| `response_format` json_object / json_schema | ✓ / ✓ | ✓ (fenced) / ✓ | ✓ / ✓ | ✓ / ✓ (needs big max_tokens) | ✓ / ✓ |
151 +| `tools` / `tool_choice: required` / `parallel_tool_calls` | ✓ | ✓ | ✓ | ✓ | ✓ |
152 +| `developer` role | ✓ | ✓ | ✓ | ✓ | ✓ |
153 +| `verbosity: low` | 200 (not L) | 200 | 200 | 200 | 200 |
154 +| unknown `foo_bar` | ignored | ignored | ignored | ignored | ignored |
155 +
156 +Default behaviour (docs + probed): **parameters the routed endpoint does not support are silently dropped** ("providers that don't support specified parameters receive requests anyway but ignore unsupported fields"); OpenRouter also "omits absent sampling params upstream rather than substituting defaults". With `provider: {require_parameters: true}` the router filters endpoints and returns **404** `{"error":{"message":"No endpoints found that can handle the requested parameters. …","code":404,"metadata":{"routing_funnel":[{"step":"Initial Endpoints","endpoint_count":4},{"step":"Filter by Tier Endpoint Rows","endpoint_count":3}],"failed_routing_step":"Filter by Parameters"}}}` — there is **no per-parameter 400** like xAI. `n > 1` is accepted but only one choice is returned. Out-of-range values are validated by OpenRouter itself: `temperature: 7` → 400 `"Expected temperature to be at most 2, received 7"` (`metadata.provider_name: null`).
157 +
158 +**Adapter rule:** drive the settings UI from `supported_parameters` (+ `default_parameters` for initial values: 262 models carry `{temperature, top_p}` defaults such as `{1, 0.95}`, most have `null`s); `filterSettings()` must drop anything not listed; send `provider.require_parameters: true` only for `response_format`/`tools` (soft) — never for sampling params, otherwise multi-provider models lose endpoints for nothing.
159 +
160 +## 10. Modalities, context, output limits, files, images, web search
161 +
162 +- **Input modalities** (`architecture.input_modalities`, 428 models): text 428, image 261, file 162, video 78, audio 46. **Output**: text 413, text+image 11 (`google/gemini-3.1-flash-image*`, `google/gemini-3-pro-image*`, `google/gemini-2.5-flash-image`, `openai/gpt-5-image(-mini)`, `openai/gpt-5.4-image-2`, `openrouter/auto(-beta)`), text+audio 4 (`openai/gpt-audio(-mini)`, `google/lyria-3-*` music). **No model has a non-text output** in the chat listing (embeddings/rerank/speech live behind `output_modalities=` filters and other endpoints).
163 +- **Images** **(probed)**: `{type:"image_url", image_url:{url:"data:image/png;base64,…", detail:"low"}}` → OK on gpt-5.4-nano (21 prompt tokens), haiku (28), gemini (269), grok (202 incl. system prompt). Text-only model (DeepSeek) → **404** `"No endpoints found that support image input"` (`failed_routing_step: "Filter by Image Support"`) — the router does the modality check before any provider is hit. Invalid base64 → 400 `"Provider returned error"` with `metadata.raw` (provider message), `provider_name`, `provider_error_code`. Formats: JPEG/PNG/GIF/WebP; public URLs recommended for large files (docs).
164 +- **PDF / files** (docs): `{type:"file", file:{filename:"x.pdf", file_data:"data:application/pdf;base64,…" | "https://…"}}`; plugin `{id:"file-parser", pdf:{engine:"mistral-ocr"|"pdf-text"|"native"}}``mistral-ocr` $2 / 1000 pages (default when the model has no native file support), `pdf-text` (a.k.a. cloudflare-ai on the page) free, `native` = provider input tokens. Response carries `file_annotations` (parsed text + hash) to resend and avoid re-parsing (also in `error.metadata.file_annotations` on errors). Models with `"file"` in `input_modalities` take PDFs natively.
165 +- **Audio input** (docs): `{type:"input_audio", input_audio:{data:"<base64>", format:"wav"|"mp3"|…}}` (base64 only); `video_url` parts for video. Out of scope for v1.
166 +- **Context / output**: `context_length` (never null in the listing) and `top_provider.max_completion_tokens` (null on 6 meta/router models). `top_provider.context_length` can differ from `context_length` (e.g. `z-ai/glm-5.3-flash` 1 310 720 vs 1 048 576) → use `top_provider.context_length` when present (that's the endpoint that will serve you), fall back to `context_length`. Requesting `max_tokens` above the model limit is silently clamped (300 000 on a 128k-output model → 200) but prompt+max_tokens above context → 400 `"This endpoint's maximum context length is 400000 tokens. However, you requested about 1000000000 tokens (1 of text input, 999999999 in the output)…"` **(probed)**.
167 +- **Web search** **(probed)**: `model: "<id>:online"` (docs: "deprecated, migrate to the `openrouter:web_search` server tool", still works) or `plugins: [{id:"web", max_results: 3, search_prompt?, engine?: "native"|"exa"|"firecrawl"|"parallel"|"perplexity", mode?, include_domains?, exclude_domains?}]`. Results come as `message.annotations[]` (non-stream) / `delta.annotations[]` (stream, one chunk before the text) shaped `{type:"url_citation", url_citation:{url, title, content, start_index: 0, end_index: 0}}` (indices were 0 — don't rely on them; the model also inlines Markdown links because the default `search_prompt` asks for it). Cost: Exa **$0.007 per request** (10 results) + $0.001/extra result, added to `usage.cost`; `usage.server_tool_use_details.web_search_requests: 1` appears on the OpenAI-native path. The `:online` call on gpt-5.4-nano injected **8 572 prompt tokens** of results → budget ≥ 10k context and ≥ 1k `max_tokens`. Native provider search via `web_search_options: {search_context_size}` on 17 models (`pricing.web_search` per call, e.g. OpenAI $0.01, Gemini $0.014, xAI $0.005).
168 +- **Image generation** (docs): `modalities: ["image","text"]` on an image-output model → `message.images[]` (data URLs); `image_config` for aspect ratio/size; `pricing.image_output` per image (e.g. gemini-3.1-flash-image $0.00006 → **$0.06** per image? the string is USD per image unit as listed; gpt-5-image `0.00004`). Hide these from the plain chat picker until PolyLLM supports image output.
169 +
170 +## 11. Provider routing, variants, meta-models
171 +
172 +- `provider: { order?: string[], allow_fallbacks?: true, require_parameters?: false, data_collection?: "allow"|"deny", zdr?: boolean, only?: string[], ignore?: string[], quantizations?: string[], sort?: "price"|"throughput"|"latency" | {by, partition:"model"|"none"}, max_price?: {prompt, completion, …}, preferred_min_throughput?, preferred_max_latency?, enforce_distillable_text? }` (docs). Default = price-weighted load balancing among endpoints healthy in the last 30 s. For a BYOK privacy-minded app consider `data_collection: "deny"` (or `zdr: true`) as a user setting; note it removes cheap providers and can 404 (`No endpoints found`).
173 +- **Variants** (suffix after `:`): listing contains only `:batch` (69, Batch-API-only, **404 on chat completions**) and `:free` (16). Virtual suffixes you can append at request time: `:online` (web plugin), `:nitro` (sort by throughput + priority tier, billed at the serving tier), `:floor` (sort by price + flex tier), `:exacto` (quality-first provider sort, tool-calling models), `:thinking` (**deprecated**, use `reasoning`). Unknown suffixes are ignored (200) **(probed)**.
174 +- **`~` latest aliases**: 10 ids like `~openai/gpt-mini-latest`, `~anthropic/claude-fable-latest`, `~google/gemini-flash-latest`, `~x-ai/grok-latest` with `alias_target: {slug, name}`; the response `model` is the target **(probed)**. Show them as aliases of the target, not as separate models.
175 +- **Meta-models** `openrouter/auto`, `openrouter/auto-beta` (Auto Router: classifier + community spend ranking, `cost_tier`, `allowed_models`/`excluded_models`, session stickiness; billed at the chosen model's rate), `openrouter/free` (free router), `openrouter/fusion`, `openrouter/pareto-code`, `openrouter/bodybuilder` — all have `pricing.prompt: "-1"` (unknown) except `openrouter/free` (`"0"`), `top_provider` nulls, `tokenizer: "Router"`. `openrouter/auto` tiny probe → served by `deepseek/deepseek-v4-flash-0731` (AtlasCloud), cost $0.000066 **(probed)**.
176 +
177 +## 12. Model listing & pricing units **(probed against `GET /api/v1/models`, 428 entries)**
178 +
179 +Top-level fields: `id, canonical_slug, hugging_face_id (177 non-null), name, created, description, context_length, architecture{modality, input_modalities, output_modalities, tokenizer, instruct_type}, pricing{…}, top_provider{context_length, max_completion_tokens, is_moderated}, per_request_limits (null on all 428), supported_parameters, default_parameters, supported_voices (TTS only), knowledge_cutoff (183 non-null, `YYYY-MM-DD`), expiration_date (8 non-null), links{details}, benchmarks{artificial_analysis{intelligence_index, coding_index, agentic_index}, design_arena[]}, reasoning (302), alias_target (10)`.
180 +
181 +**Pricing unit: strings, USD per single token** (per image / per request / per search for the non-token keys). `openai/gpt-5.4-nano` `prompt: "0.0000002"`**$0.20 / M** ✓ (OpenAI list price), `completion: "0.00000125"` → $1.25/M ✓; `anthropic/claude-haiku-4.5` `"0.000001"`/`"0.000005"` → $1 / $5 per M ✓, `input_cache_read "0.0000001"` → $0.10/M (0.1×) ✓, `input_cache_write "0.00000125"` (1.25×), `input_cache_write_1h "0.000002"` (2×) ✓. Cross-check with the probes: haiku 20 prompt + 12 completion tokens → `cost 0.00008` = 20×1e-6 + 12×5e-6 ✓. **`per million = Number(str) * 1_000_000`.** `"-1"` = unknown (routers), `"0"` = free. Keys seen: `prompt, completion, input_cache_read, input_cache_write, input_cache_write_1h, image (31 models, per input image), image_output (per generated image), audio, audio_output, input_audio_cache, internal_reasoning (32 Google models: per reasoning token), web_search (160 models: per search call), request (none non-zero), discount (all 0), overrides[]`. **`overrides`** = tiered pricing: `[{min_prompt_tokens: 272000, prompt, completion, input_cache_read, input_cache_write}]` (OpenAI ≥272k → 2×; Gemini Pro / Grok ≥200k → 2×) — the long-context threshold PolyLLM's xAI catalog already models.
182 +
183 +Docs list more `pricing` keys (`image_token`, `discount` fraction, `overrides` with `utc_start/utc_end/utc_days`) and `tokenizer` enum (`Router, Media, Other, GPT, Claude, Gemini, Gemma, Grok, Cohere, Nova, Qwen, Yi, DeepSeek, Mistral, Llama2/3/4, PaLM, RWKV, Qwen3`), `instruct_type` (null on 388).
184 +
185 +`supported_parameters` enum and frequency: `max_tokens 417, response_format 368, tools 361, tool_choice 353, structured_outputs 345, temperature 343, top_p 325, seed 324, include_reasoning 301, reasoning 301, stop 299, frequency_penalty 232, presence_penalty 225, top_k 213, reasoning_effort 160, repetition_penalty 151, logprobs 147, top_logprobs 147, logit_bias 142, min_p 115, max_completion_tokens 60, verbosity 21, web_search_options 17, top_a 11, prediction 11, parallel_tool_calls 9`. Models with **no `max_tokens`**: 11 (`openai/gpt-5.2-codex`, `gpt-5.1-codex*`, `gpt-5.2-chat`, `gpt-3.5-turbo-0613`, sakana/*, routers) — still accept it in practice; treat as "no cap control".
186 +
187 +Free: 19 models with `pricing.prompt === "0"` (16 `:free` + `google/lyria-3-*` + `openrouter/free`). Expiration: 8 non-null (`nex-agi/nex-n2-*` **2026-09-08 = today, expired**, `dots-studio/dots-3-note-preview:free` 2026-09-30, `z-ai/glm-4.5*` 2026-12-31, and **`2098-12-31` placeholders** on z-ai/glm-5-turbo/5v-turbo/5.3-flash).
188 +
189 +## Mapping `/api/v1/models``PolyModel`
190 +
191 +Reference implementation: `research/openrouter/gen-models-json.ts` (generated `docs/provider-research/openrouter.models.json`).
192 +
193 +| PolyModel field | Source | Rule |
194 +|---|---|---|
195 +| `id` | `id` | keep the full slug incl. variant (`vendor/slug[:variant]`); `baseId = id.split(":")[0]`, `variant = after ":"` |
196 +| `displayName` | `name` | e.g. `"OpenAI: GPT-6 Astra"` (already vendor-prefixed; strip `"<Vendor>: "` if the UI groups by vendor) |
197 +| `family` / vendor | `id.split("/")[0]` | `openai, anthropic, google, x-ai, deepseek, …`; `openrouter/*` = meta |
198 +| `canonicalSlug` | `canonical_slug` | dated slug (`openai/gpt-6-astra-20260903`) — useful for dedup/alias detection; `~` aliases: use `alias_target.slug` |
199 +| `contextTokens` | `top_provider.context_length ?? context_length` | integers; routers have `top_provider` nulls → `context_length` |
200 +| `maxOutputTokens` | `top_provider.max_completion_tokens` | null on routers → treat as unknown (no cap UI) |
201 +| `pricing.inputPerMillion` | `Number(pricing.prompt) * 1e6` | `"-1"` → null/unknown; `"0"` → free |
202 +| `pricing.outputPerMillion` | `Number(pricing.completion) * 1e6` | same |
203 +| `pricing.cachedInputPerMillion` | `pricing.input_cache_read * 1e6` | present on OpenAI/Anthropic/Google/xAI/DeepSeek |
204 +| `pricing.cacheWritePerMillion` (+ `1h`) | `input_cache_write`, `input_cache_write_1h` | Anthropic/Gemini/Qwen only |
205 +| `pricing.internalReasoningPerMillion` | `internal_reasoning * 1e6` | Google only (reasoning tokens billed separately) |
206 +| `pricing.imageInputPerImage` | `pricing.image` | USD per image (some Google rows equal the token price — display "per image" only if > 0.0001 else treat as per-token) |
207 +| `pricing.imageOutputPerImage` | `pricing.image_output` | image-gen models |
208 +| `pricing.webSearchPerRequest` | `pricing.web_search` | native search fee; plugin fee is not in the listing ($0.007 Exa) |
209 +| `pricing.longContext*` | `pricing.overrides[0]` | `min_prompt_tokens` threshold + overridden prices |
210 +| `capabilities.text` | `output_modalities ∋ "text"` | all 428 today |
211 +| `capabilities.vision` | `input_modalities ∋ "image"` | router pre-checks; text-only → 404 |
212 +| `capabilities.files` | `input_modalities ∋ "file"` | native PDF; otherwise PDF only via `file-parser` plugin |
213 +| `capabilities.audioInput` / `video` | `∋ "audio"` / `∋ "video"` | |
214 +| `capabilities.imageGeneration` / `audioOutput` | `output_modalities ∋ "image"` / `∋ "audio"` | hide from chat picker (or separate section) |
215 +| `capabilities.tools` | `supported_parameters ∋ "tools"` | `tool_choice` separately |
216 +| `capabilities.structuredOutput` | `∋ "structured_outputs"` (strict json_schema) ; `∋ "response_format"` = json_object at least | send `provider.require_parameters: true` with json_schema |
217 +| `capabilities.reasoning` | `reasoning != null` | plus `reasoning.mandatory`, `default_enabled`, `supported_efforts`, `default_effort`, `supports_max_tokens` (§8) |
218 +| `capabilities.webSearch` | always available via `plugins`/`:online` | native search: `∋ "web_search_options"` |
219 +| `capabilities.streaming` | true for all | |
220 +| `parameters.temperature/topP/topK/minP/topA/seed/stop/frequencyPenalty/presencePenalty/repetitionPenalty/logprobs/logitBias/verbosity/parallelToolCalls` | `supported_parameters ∋ name` | drop anything not listed (`filterSettings`) |
221 +| `parameters.maxTokens` | `∋ "max_tokens" || ∋ "max_completion_tokens"` | always send `max_tokens` (broadest support) |
222 +| `parameters.reasoningEffort` | `reasoning.supported_efforts` | if `reasoning` present but no efforts → toggle only (`reasoning.enabled` / `reasoning.max_tokens`) |
223 +| `parameters.thinkingBudget` | `reasoning.supports_max_tokens` (or vendor ∈ {anthropic, google}) | `reasoning.max_tokens` |
224 +| `parameters.defaults` | `default_parameters` | non-null values seed the sliders |
225 +| `status` | derived | `expired` if `expiration_date <= today && < 2090`; `free` if `:free`/prompt `"0"`; `meta` for `openrouter/*`; `batch-only` for `:batch`; else `active` |
226 +| `knowledgeCutoff` | `knowledge_cutoff` | optional display |
227 +| `aliases` | ids whose `alias_target.slug === id` | `~vendor/x-latest` |
228 +| `moderated` | `top_provider.is_moderated` | 125 models (OpenAI/Anthropic first-party) — informational |
229 +| `benchmarks` | `benchmarks.artificial_analysis` | optional sort key |
230 +
231 +**Which models to hide (default chat picker):**
232 +1. `output_modalities` without `"text"` (none today, but filter anyway) and models whose output includes `"image"` or `"audio"` (image/audio generators: 15) unless PolyLLM supports those outputs — or show them in a separate "Image / Audio" group.
233 +2. Embeddings / rerank / speech / transcription models — they do not appear in the default `/models` (text output) listing; never fetch with `output_modalities=` filters other than text.
234 +3. `expiration_date` ≤ today (`nex-agi/nex-n2-*` today) — treat `2098-12-31` as "no expiration"; show a "retiring on …" badge when within 30 days.
235 +4. `:batch` variants (69) — chat completions returns 404; and `pricing.prompt === "-1"` routers except `openrouter/auto` (offer `openrouter/auto` explicitly as "Auto (OpenRouter)" with cost = unknown until the response `usage.cost`).
236 +5. `~` aliases — fold into the target as an alias badge rather than a duplicate row.
237 +6. Optional: `:free` models behind a "show free models" toggle (rate-limited 20 rpm / 50–1000 rpd, possible training on prompts) and `openrouter/free`.
238 +7. Prefer `GET /models/user` (account-filtered) when calling with the user's key; fall back to `/models` (works unauthenticated).
239 +
240 +Registry refresh: cache the listing per day (models appear/disappear daily; `total_count` and `created` help diff). Keep `supported_parameters` per model — it is the only way to know what to send.
241 +
242 +## 13. Prompt caching & provider-side state
243 +
244 +- **Automatic** for OpenAI (probed: second call with a 4.9k-token system prompt → `cached_tokens: 4864`, cost $0.000999 → $0.000124), DeepSeek, Grok (128 cached tokens on every call), Groq, Moonshot, Z.AI (docs). Read multipliers: OpenAI 0.25–0.5×, DeepSeek 0.1×, Grok 0.25×.
245 +- **Explicit breakpoints** for Anthropic / Gemini / Qwen: content-part `cache_control: {type:"ephemeral", ttl?: "5m"|"1h"}` **(probed on haiku: first call `cache_write_tokens: 5208` cost $0.006543, second call `cached_tokens: 5208` cost $0.000554 — 0.1× read, 1.25× write)**. Minimums 1 024–4 096 tokens depending on model (docs). Gemini 2.5+ also has implicit caching (`supports_implicit_caching` per endpoint).
246 +- Usage fields: `prompt_tokens_details.cached_tokens`, `cache_write_tokens`; `cost` reflects the discount; `/generation` shows `native_tokens_cached`.
247 +- **No server-side state**: Responses API rejects `store`/`previous_response_id`; nothing to clean up. OpenRouter itself logs metadata (and prompts only if the user enabled logging in their account settings); `provider.data_collection: "deny"` / `zdr: true` restrict upstream retention.
248 +
249 +## 14. Errors, rate limits, retries
250 +
251 +Error body: `{"error": {"code": <number>, "message": string, "metadata"?: {…}}, "user_id"?: string}``code` is the HTTP status (number, not a string); OpenAI SDK surfaces it as `e.status` / `e.error`.
252 +
253 +| Case **(probed)** | HTTP | body |
254 +|---|---|---|
255 +| Invalid key (chat, `/key`) | **401** | `{"error":{"message":"User not found.","code":401}}` |
256 +| No Authorization header | 401 | `{"error":{"message":"No cookie auth credentials found","code":401}}` |
257 +| Unknown model | **400** | `{"error":{"message":"openai/gpt-99-turbo is not a valid model ID","code":400},"user_id":"user_…"}` (streaming: same, before any SSE) |
258 +| Unknown variant suffix | 200 | ignored, base model served |
259 +| Missing/invalid `messages`, malformed JSON | 400 | `"Input required: specify \"prompt\" or \"messages\""` |
260 +| Out-of-range param | 400 | `"Expected temperature to be at most 2, received 7"`, `metadata.provider_name: null` |
261 +| `require_parameters` unsatisfiable | **404** | `"No endpoints found that can handle the requested parameters."`, `metadata.routing_funnel[]`, `failed_routing_step: "Filter by Parameters"` |
262 +| Image to text-only model | 404 | `"No endpoints found that support image input"`, `failed_routing_step: "Filter by Image Support"` |
263 +| `:batch` model on chat | 404 | `"This model is only available through the Batch API…"` |
264 +| Reasoning disabled on mandatory model | 400 | `"Reasoning is mandatory for this endpoint and cannot be disabled."` |
265 +| Provider-side validation (bad image bytes) | 400 | `"Provider returned error"`, `metadata.{raw (provider JSON as string), provider_name:"Azure", provider_error_code:"invalid_value", is_byok}` |
266 +| Prompt + max_tokens > context | 400 | `"This endpoint's maximum context length is 400000 tokens. However, you requested about …"` |
267 +| Responses API `store: true` | 400 | `{"error":{"code":"invalid_prompt","message":"Invalid Responses API request"},"metadata":{"raw":"[zod issues]"}}` (note: string code here) |
268 +| Insufficient credits / key limit | 402 (docs) | add credits or raise key limit; free models also 402 when balance negative |
269 +| Moderation / guardrail | 403 (docs) | `metadata.{reasons[], flagged_input (≤100 chars), provider_name, model_slug}` |
270 +| Rate limit | 429 (docs) | `Retry-After`, `X-RateLimit-Limit/Remaining`; mid-stream → SSE event with `finish_reason: "error"` |
271 +| Provider down / bad upstream response | 502 (docs) | OpenRouter may have already retried other providers silently |
272 +| No provider matches routing prefs | 503 (docs) | `Retry-After` |
273 +| Also documented | 408, 413, 422, 500, 524 (edge timeout), 529 (provider overloaded) | canonical `metadata.error_type` values: `context_length_exceeded, max_tokens_exceeded, authentication, permission_denied, payment_required, rate_limit_exceeded, provider_overloaded, provider_unavailable, invalid_request, invalid_prompt, not_found, content_policy_violation, refusal, invalid_image, image_too_large, image_too_small, unsupported_image_format, server, timeout, unmapped…` |
274 +
275 +Rate limits (docs): paid models have **no platform request caps** (only DDoS protection + upstream limits); `:free` models 20 req/min and 50 req/day (<$10 lifetime credits) or 1 000 req/day (≥$10). Negative balance → 402 everywhere. `GET /key` gives `limit`, `limit_remaining`, `is_free_tier` for a pre-flight check.
276 +
277 +**Retry/timeout recommendation for the adapter:** retry 429/502/503/524/529 with jittered backoff honouring `Retry-After`; never retry 400/401/402/403/404; map **401 "User not found."** to *invalid credentials*, **402** to *insufficient credits* (link to openrouter.ai/credits), **404 "No endpoints found…"** to a user-facing "this model/provider combination doesn't support <feature>" and automatically retry once **without** `provider.require_parameters` when it was set for soft preferences. Long read timeout (≥ 5 min) — `xhigh`/`max` efforts on frontier models can think for minutes; the `: OPENROUTER PROCESSING` comments (roughly every few seconds) are the liveness signal.
278 +
279 +## 15. Lifecycle / aliases
280 +
281 +- Vendor ids on OpenRouter differ from the vendors' own: `anthropic/claude-haiku-4.5` (dot, not `-4-5`), `x-ai/grok-4.20` (not `grok-4.20-0309-non-reasoning`; there is no non-reasoning slug — the same id switches modes via `reasoning`), `google/gemini-3.5-flash-lite`, `openai/gpt-5.4-nano`, `deepseek/deepseek-v4-flash` (+ dated `-0731`). Always take ids from the listing.
282 +- `canonical_slug` carries the dated snapshot (`openai/gpt-5.4-nano-20260317`); `/generation` reports `model` with that dated slug.
283 +- Retired/renamed slugs redirect silently (docs: `anthropic/claude-3-5-sonnet``claude-3.5-sonnet`); unknown slugs 400. Variant suffixes never 400.
284 +- `expiration_date` is the only deprecation signal in the API; poll daily.
285 +- `:thinking` and `:online` are deprecated suffixes (docs) — prefer `reasoning` and `plugins:[{id:"web"}]`.
286 +
287 +## 16. Exact streaming code that worked
288 +
289 +```ts
290 +import OpenAI from "openai";
291 +const client = new OpenAI({
292 + apiKey: process.env.OPENROUTER_API_KEY, baseURL: "https://openrouter.ai/api/v1",
293 + timeout: 600_000, maxRetries: 0,
294 + defaultHeaders: { "HTTP-Referer": "https://www.polyllm.io", "X-OpenRouter-Title": "PolyLLM" },
295 +});
296 +
297 +const stream = await client.chat.completions.create({
298 + model: "anthropic/claude-haiku-4.5",
299 + messages: [{ role: "user", content: "What's the weather in Montreal? Use the tool." }],
300 + tools, stream: true, max_tokens: 1500,
301 + reasoning: { max_tokens: 1024 }, // Anthropic: budget, not effort
302 + provider: { require_parameters: false }, // true only for json_schema
303 +} as any, { headers: { "x-session-id": sessionId } });
304 +
305 +const details: any[] = []; const calls: Record<number, any> = {};
306 +for await (const chunk of stream as any) {
307 + if (chunk.error) throw Object.assign(new Error(chunk.error.message), chunk.error); // mid-stream error, finish_reason "error"
308 + const c = chunk.choices?.[0]; const d = c?.delta ?? {};
309 + if (d.reasoning) onThinking(d.reasoning);
310 + if (d.reasoning_details) details.push(...d.reasoning_details); // merge by (type,index), replay on the assistant message
311 + if (d.content) onText(d.content);
312 + if (d.annotations) onCitations(d.annotations); // url_citation
313 + for (const tc of d.tool_calls ?? []) accumulate(calls, tc); // fragmented arguments
314 + if (c?.finish_reason) onFinish(c.finish_reason, c.native_finish_reason); // arrives twice (2nd with usage)
315 + if (chunk.usage) onUsage(chunk.usage, chunk.provider, chunk.model); // usage.cost = USD
316 +}
317 +```
318 +
319 +Raw fetch SSE (`research/openrouter/lib.ts``rawSSE`): split on `\n\n`, ignore lines starting with `:` (keep-alives), take `data:` lines, `JSON.parse`, stop at `[DONE]`.
320 +
321 +## 17. Probe results table
322 +
323 +| # | Probe | Model(s) | Result |
324 +|---|---|---|---|
325 +| 00 | `GET /models` (+ `?limit=1000`, `/models/user`, `/models/count`, `?supported_parameters=tools`), `/key`, `/credits` | — | 428 models, no pagination needed; user list 425; 345 tool models; `/key` and `/credits` 200 with a normal key |
326 +| 01a | tiny completion, `usage.include` | 5 models | all 200; `provider` field; `usage.cost` + `cost_details`; `completion_tokens` includes reasoning; DeepSeek/xAI reasoning on by default (xAI only because we sent effort low), Gemini signature-only `reasoning_details` |
327 +| 01b | streaming raw SSE | 5 models | `: OPENROUTER PROCESSING` comments (0–8); `delta.reasoning` + `delta.reasoning_details`; finish chunk twice, last one with `usage`; `[DONE]` |
328 +| 02 | 37 param variants × 5 models | 5 models | unsupported params silently dropped; 404 `No endpoints found…` with `require_parameters`; Gemini `effort: none` → 400 mandatory; Haiku effort no-op / `max_tokens` works; `n:2` → 1 choice; DeepSeek `max_tokens 40` eaten by reasoning |
329 +| 03 | streamed function call + round trip (OpenAI SDK) | 5 models | fragmented `tool_calls` deltas (2–8 chunks); `finish_reason: tool_calls`; round 2 OK on all |
330 +| 04 | json_schema strict + `require_parameters`, streamed once | 5 models | valid JSON on 4; DeepSeek `content: null` (`length`, reasoning consumed 200 tokens); streaming partial JSON valid |
331 +| 05 | 32×32 PNG data URL | 5 models | 4 vision models OK (21–269 prompt tokens); DeepSeek 404 `No endpoints found that support image input` |
332 +| 06 | invalid key / no auth / unknown model & variant / malformed / bad temp / bad image / overflow / truncation / `/generation` | — | 401 `User not found.`; 400 `not a valid model ID`; variant ignored; 400 with `metadata.raw` from Azure; `finish_reason: length`; `/generation` 404 right away, 200 later |
333 +| 07 | `:online` (gpt-5.4-nano), `plugins:[{id:"web",max_results:3}]` streamed (haiku), `openrouter/auto`, `~openai/gpt-mini-latest` | mixed | 8.5k search tokens + reasoning ate `max_tokens` (content null); haiku: 3 `url_citation` annotations in one delta, +$0.007; auto → deepseek-v4-flash-0731; alias → gpt-5.4-mini |
334 +| 08 | Responses API (basic, `store:true`, `previous_response_id`, stream), `/endpoints`, `/endpoints/zdr`, `/providers`, `X-Title` vs `X-OpenRouter-Title`, `:batch`, `cache_control` (Anthropic) + automatic caching (OpenAI), `reasoning.max_tokens` on Anthropic/Gemini | mixed | stateless 400s; 9 event types; 845 ZDR endpoints; 106 providers; both headers OK; `:batch` 404; cache write 5208 → read 5208 (0.1×); Anthropic reasoning.text + signature |
335 +| 09 | Anthropic/Gemini thinking + tools streamed, `reasoning_details` replayed vs omitted; stream without usage flag; `stream_options.include_usage`; `/generation` later; `/models` vs `/models/user` | haiku, gemini-lite, nano | both round-2 variants 200; usage always present; generation record 200 with native token counts; 3 models hidden by account prefs |
336 +
337 +## Documentation pages used (all fetched 2026-09-08)
338 +
339 +- https://openrouter.ai/docs/quickstart · https://openrouter.ai/docs/api-reference/overview · https://openrouter.ai/docs/api-reference/chat-completion · https://openrouter.ai/docs/llms.txt (index)
340 +- https://openrouter.ai/docs/api/api-reference/models/list-all-models-and-their-properties.md · https://openrouter.ai/docs/api/api-reference/api-keys/get-current-api-key.md · https://openrouter.ai/docs/api-reference/get-credits · https://openrouter.ai/docs/api_reference/parameters.md · https://openrouter.ai/docs/guides/overview/models.md
341 +- https://openrouter.ai/docs/api-reference/streaming · https://openrouter.ai/docs/api-reference/errors · https://openrouter.ai/docs/api-reference/limits · https://openrouter.ai/docs/use-cases/usage-accounting · https://openrouter.ai/docs/use-cases/reasoning-tokens
342 +- https://openrouter.ai/docs/guides/features/tool-calling.md · https://openrouter.ai/docs/features/structured-outputs · https://openrouter.ai/docs/features/images-and-pdfs · https://openrouter.ai/docs/guides/overview/multimodal/overview.md · https://openrouter.ai/docs/guides/overview/multimodal/pdfs.md · https://openrouter.ai/docs/guides/overview/multimodal/image-generation.md
343 +- https://openrouter.ai/docs/features/web-search · https://openrouter.ai/docs/features/prompt-caching · https://openrouter.ai/docs/features/provider-routing · https://openrouter.ai/docs/features/model-routing · https://openrouter.ai/docs/guides/features/zdr.md
344 +- https://openrouter.ai/docs/guides/routing/model-variants/{free,nitro,online,exacto,thinking}.md
345 +- https://openrouter.ai/docs/api_reference/responses/overview.md · https://openrouter.ai/docs/api_reference/responses/basic-usage.md
346 +- https://openrouter.ai/docs/client-sdks/typescript/overview.md (`@openrouter/sdk`) · https://openrouter.ai/docs/guides/community/openai-sdk.md · https://openrouter.ai/docs/guides/community/vercel-ai-sdk.md
347 +- 404 at audit time: `/docs/api-reference/list-available-models`, `/docs/api-reference/get-current-api-key`, `/docs/features/tool-calling`, `/docs/api-reference/models/list-models`, `/docs/api-reference/api-keys/get-current-key`, `/docs/api-reference/responses/create-responses`, `/docs/sdks/typescript`, `/docs/guides/routing/model-variants` (index page).
added docs/provider-research/openrouter.models.json +2522 −0
@@ -0,0 +1,2522 @@
1 +[
2 + {
3 + "id": "openai/gpt-6-astra",
4 + "displayName": "OpenAI: GPT-6 Astra",
5 + "family": "openai",
6 + "canonicalSlug": "openai/gpt-6-astra-20260903",
7 + "variant": null,
8 + "baseId": "openai/gpt-6-astra",
9 + "contextTokens": 1050000,
10 + "maxOutputTokens": 128000,
11 + "capabilities": {
12 + "text": true,
13 + "vision": true,
14 + "audioInput": false,
15 + "audioOutput": false,
16 + "imageGeneration": false,
17 + "video": false,
18 + "reasoning": true,
19 + "tools": true,
20 + "structuredOutput": true,
21 + "strictJsonSchema": true,
22 + "streaming": true,
23 + "files": true,
24 + "webSearch": "via-plugin-or-:online",
25 + "moderated": true
26 + },
27 + "parameters": {
28 + "temperature": false,
29 + "topP": false,
30 + "topK": false,
31 + "minP": false,
32 + "topA": false,
33 + "maxTokens": true,
34 + "reasoningEffort": [
35 + "max",
36 + "xhigh",
37 + "high",
38 + "medium",
39 + "low"
40 + ],
41 + "reasoningMandatory": true,
42 + "reasoningDefaultEnabled": true,
43 + "reasoningDefaultEffort": "medium",
44 + "thinkingBudget": false,
45 + "stop": false,
46 + "seed": true,
47 + "frequencyPenalty": false,
48 + "presencePenalty": false,
49 + "repetitionPenalty": false,
50 + "logprobs": false,
51 + "logitBias": false,
52 + "responseFormat": true,
53 + "toolChoice": true,
54 + "parallelToolCalls": false,
55 + "verbosity": false,
56 + "webSearchOptions": false,
57 + "defaults": null
58 + },
59 + "pricing": {
60 + "inputPerMillion": 10,
61 + "cachedInputPerMillion": 1,
62 + "cacheWritePerMillion": 12.5,
63 + "cacheWrite1hPerMillion": null,
64 + "outputPerMillion": 50,
65 + "internalReasoningPerMillion": null,
66 + "imageInputPerImage": null,
67 + "imageOutputPerImage": null,
68 + "audioInputPerMillion": null,
69 + "audioOutputPerMillion": null,
70 + "perRequest": null,
71 + "webSearchPerRequest": 0.01,
72 + "longContextThresholdTokens": 272000,
73 + "longContextInputPerMillion": 20,
74 + "longContextOutputPerMillion": 75,
75 + "longContextCachedInputPerMillion": 2,
76 + "free": false,
77 + "unknown": false
78 + },
79 + "status": "active",
80 + "expirationDate": null,
81 + "knowledgeCutoff": null,
82 + "aliases": [],
83 + "huggingFaceId": null,
84 + "created": 1788552838,
85 + "notes": "OpenAI flagship (Sep 2026). Reasoning mandatory (effort max…low, default medium). No temperature/top_p in supported_parameters → do not send sampling params. 1.05M context; pricing.overrides doubles the price above 272k prompt tokens."
86 + },
87 + {
88 + "id": "openai/gpt-5.5",
89 + "displayName": "OpenAI: GPT-5.5",
90 + "family": "openai",
91 + "canonicalSlug": "openai/gpt-5.5-20260423",
92 + "variant": null,
93 + "baseId": "openai/gpt-5.5",
94 + "contextTokens": 1050000,
95 + "maxOutputTokens": 128000,
96 + "capabilities": {
97 + "text": true,
98 + "vision": true,
99 + "audioInput": false,
100 + "audioOutput": false,
101 + "imageGeneration": false,
102 + "video": false,
103 + "reasoning": true,
104 + "tools": true,
105 + "structuredOutput": true,
106 + "strictJsonSchema": true,
107 + "streaming": true,
108 + "files": true,
109 + "webSearch": "via-plugin-or-:online",
110 + "moderated": true
111 + },
112 + "parameters": {
113 + "temperature": false,
114 + "topP": false,
115 + "topK": false,
116 + "minP": false,
117 + "topA": false,
118 + "maxTokens": true,
119 + "reasoningEffort": [
120 + "xhigh",
121 + "high",
122 + "medium",
123 + "low",
124 + "none"
125 + ],
126 + "reasoningMandatory": false,
127 + "reasoningDefaultEnabled": true,
128 + "reasoningDefaultEffort": "medium",
129 + "thinkingBudget": false,
130 + "stop": false,
131 + "seed": true,
132 + "frequencyPenalty": false,
133 + "presencePenalty": false,
134 + "repetitionPenalty": false,
135 + "logprobs": false,
136 + "logitBias": false,
137 + "responseFormat": true,
138 + "toolChoice": true,
139 + "parallelToolCalls": false,
140 + "verbosity": false,
141 + "webSearchOptions": false,
142 + "defaults": {
143 + "temperature": null,
144 + "top_p": null,
145 + "top_k": null,
146 + "frequency_penalty": null,
147 + "presence_penalty": null,
148 + "repetition_penalty": null
149 + }
150 + },
151 + "pricing": {
152 + "inputPerMillion": 5,
153 + "cachedInputPerMillion": 0.5,
154 + "cacheWritePerMillion": null,
155 + "cacheWrite1hPerMillion": null,
156 + "outputPerMillion": 30,
157 + "internalReasoningPerMillion": null,
158 + "imageInputPerImage": null,
159 + "imageOutputPerImage": null,
160 + "audioInputPerMillion": null,
161 + "audioOutputPerMillion": null,
162 + "perRequest": null,
163 + "webSearchPerRequest": 0.01,
164 + "longContextThresholdTokens": 272000,
165 + "longContextInputPerMillion": 10,
166 + "longContextOutputPerMillion": 45,
167 + "longContextCachedInputPerMillion": 1,
168 + "free": false,
169 + "unknown": false
170 + },
171 + "status": "active",
172 + "expirationDate": null,
173 + "knowledgeCutoff": "2025-12-01",
174 + "aliases": [],
175 + "huggingFaceId": "",
176 + "created": 1777051893,
177 + "notes": "Reasoning optional (default_enabled true); efforts xhigh…none. No sampling params exposed. Long-context override above 272k tokens."
178 + },
179 + {
180 + "id": "openai/gpt-5.4",
181 + "displayName": "OpenAI: GPT-5.4",
182 + "family": "openai",
183 + "canonicalSlug": "openai/gpt-5.4-20260305",
184 + "variant": null,
185 + "baseId": "openai/gpt-5.4",
186 + "contextTokens": 1050000,
187 + "maxOutputTokens": 128000,
188 + "capabilities": {
189 + "text": true,
190 + "vision": true,
191 + "audioInput": false,
192 + "audioOutput": false,
193 + "imageGeneration": false,
194 + "video": false,
195 + "reasoning": true,
196 + "tools": true,
197 + "structuredOutput": true,
198 + "strictJsonSchema": true,
199 + "streaming": true,
200 + "files": true,
201 + "webSearch": "via-plugin-or-:online",
202 + "moderated": true
203 + },
204 + "parameters": {
205 + "temperature": false,
206 + "topP": false,
207 + "topK": false,
208 + "minP": false,
209 + "topA": false,
210 + "maxTokens": true,
211 + "reasoningEffort": [
212 + "xhigh",
213 + "high",
214 + "medium",
215 + "low",
216 + "none"
217 + ],
218 + "reasoningMandatory": false,
219 + "reasoningDefaultEnabled": false,
220 + "reasoningDefaultEffort": "medium",
221 + "thinkingBudget": false,
222 + "stop": false,
223 + "seed": true,
224 + "frequencyPenalty": false,
225 + "presencePenalty": false,
226 + "repetitionPenalty": false,
227 + "logprobs": false,
228 + "logitBias": false,
229 + "responseFormat": true,
230 + "toolChoice": true,
231 + "parallelToolCalls": false,
232 + "verbosity": false,
233 + "webSearchOptions": false,
234 + "defaults": {
235 + "temperature": null,
236 + "top_p": null,
237 + "top_k": null,
238 + "frequency_penalty": null,
239 + "presence_penalty": null,
240 + "repetition_penalty": null
241 + }
242 + },
243 + "pricing": {
244 + "inputPerMillion": 2.5,
245 + "cachedInputPerMillion": 0.25,
246 + "cacheWritePerMillion": null,
247 + "cacheWrite1hPerMillion": null,
248 + "outputPerMillion": 15,
249 + "internalReasoningPerMillion": null,
250 + "imageInputPerImage": null,
251 + "imageOutputPerImage": null,
252 + "audioInputPerMillion": null,
253 + "audioOutputPerMillion": null,
254 + "perRequest": null,
255 + "webSearchPerRequest": 0.01,
256 + "longContextThresholdTokens": 272000,
257 + "longContextInputPerMillion": 5,
258 + "longContextOutputPerMillion": 22.5,
259 + "longContextCachedInputPerMillion": 0.5,
260 + "free": false,
261 + "unknown": false
262 + },
263 + "status": "active",
264 + "expirationDate": null,
265 + "knowledgeCutoff": null,
266 + "aliases": [],
267 + "huggingFaceId": "",
268 + "created": 1772734352,
269 + "notes": "Reasoning optional, default OFF (default_enabled false) → behaves as a non-reasoning model unless `reasoning` is sent. Cheaper than 5.5."
270 + },
271 + {
272 + "id": "openai/gpt-5.4-mini",
273 + "displayName": "OpenAI: GPT-5.4 Mini",
274 + "family": "openai",
275 + "canonicalSlug": "openai/gpt-5.4-mini-20260317",
276 + "variant": null,
277 + "baseId": "openai/gpt-5.4-mini",
278 + "contextTokens": 400000,
279 + "maxOutputTokens": 128000,
280 + "capabilities": {
281 + "text": true,
282 + "vision": true,
283 + "audioInput": false,
284 + "audioOutput": false,
285 + "imageGeneration": false,
286 + "video": false,
287 + "reasoning": true,
288 + "tools": true,
289 + "structuredOutput": true,
290 + "strictJsonSchema": true,
291 + "streaming": true,
292 + "files": true,
293 + "webSearch": "via-plugin-or-:online",
294 + "moderated": true
295 + },
296 + "parameters": {
297 + "temperature": false,
298 + "topP": false,
299 + "topK": false,
300 + "minP": false,
301 + "topA": false,
302 + "maxTokens": true,
303 + "reasoningEffort": [
304 + "xhigh",
305 + "high",
306 + "medium",
307 + "low",
308 + "none"
309 + ],
310 + "reasoningMandatory": false,
311 + "reasoningDefaultEnabled": false,
312 + "reasoningDefaultEffort": "medium",
313 + "thinkingBudget": false,
314 + "stop": false,
315 + "seed": true,
316 + "frequencyPenalty": false,
317 + "presencePenalty": false,
318 + "repetitionPenalty": false,
319 + "logprobs": false,
320 + "logitBias": false,
321 + "responseFormat": true,
322 + "toolChoice": true,
323 + "parallelToolCalls": false,
324 + "verbosity": false,
325 + "webSearchOptions": false,
326 + "defaults": {
327 + "temperature": null,
328 + "top_p": null,
329 + "top_k": null,
330 + "frequency_penalty": null,
331 + "presence_penalty": null,
332 + "repetition_penalty": null
333 + }
334 + },
335 + "pricing": {
336 + "inputPerMillion": 0.75,
337 + "cachedInputPerMillion": 0.075,
338 + "cacheWritePerMillion": null,
339 + "cacheWrite1hPerMillion": null,
340 + "outputPerMillion": 4.5,
341 + "internalReasoningPerMillion": null,
342 + "imageInputPerImage": null,
343 + "imageOutputPerImage": null,
344 + "audioInputPerMillion": null,
345 + "audioOutputPerMillion": null,
346 + "perRequest": null,
347 + "webSearchPerRequest": 0.01,
348 + "longContextThresholdTokens": null,
349 + "longContextInputPerMillion": null,
350 + "longContextOutputPerMillion": null,
351 + "longContextCachedInputPerMillion": null,
352 + "free": false,
353 + "unknown": false
354 + },
355 + "status": "active",
356 + "expirationDate": null,
357 + "knowledgeCutoff": "2025-08-31",
358 + "aliases": [
359 + "~openai/gpt-mini-latest"
360 + ],
361 + "huggingFaceId": "",
362 + "created": 1773748178,
363 + "notes": "Target of the `~openai/gpt-mini-latest` alias (probed: alias call answered with model = openai/gpt-5.4-mini)."
364 + },
365 + {
366 + "id": "openai/gpt-5.4-nano",
367 + "displayName": "OpenAI: GPT-5.4 Nano",
368 + "family": "openai",
369 + "canonicalSlug": "openai/gpt-5.4-nano-20260317",
370 + "variant": null,
371 + "baseId": "openai/gpt-5.4-nano",
372 + "contextTokens": 400000,
373 + "maxOutputTokens": 128000,
374 + "capabilities": {
375 + "text": true,
376 + "vision": true,
377 + "audioInput": false,
378 + "audioOutput": false,
379 + "imageGeneration": false,
380 + "video": false,
381 + "reasoning": true,
382 + "tools": true,
383 + "structuredOutput": true,
384 + "strictJsonSchema": true,
385 + "streaming": true,
386 + "files": true,
387 + "webSearch": "via-plugin-or-:online",
388 + "moderated": true
389 + },
390 + "parameters": {
391 + "temperature": false,
392 + "topP": false,
393 + "topK": false,
394 + "minP": false,
395 + "topA": false,
396 + "maxTokens": true,
397 + "reasoningEffort": [
398 + "xhigh",
399 + "high",
400 + "medium",
401 + "low",
402 + "none"
403 + ],
404 + "reasoningMandatory": false,
405 + "reasoningDefaultEnabled": false,
406 + "reasoningDefaultEffort": "medium",
407 + "thinkingBudget": false,
408 + "stop": false,
409 + "seed": true,
410 + "frequencyPenalty": false,
411 + "presencePenalty": false,
412 + "repetitionPenalty": false,
413 + "logprobs": false,
414 + "logitBias": false,
415 + "responseFormat": true,
416 + "toolChoice": true,
417 + "parallelToolCalls": false,
418 + "verbosity": false,
419 + "webSearchOptions": false,
420 + "defaults": {
421 + "temperature": null,
422 + "top_p": null,
423 + "top_k": null,
424 + "frequency_penalty": null,
425 + "presence_penalty": null,
426 + "repetition_penalty": null
427 + }
428 + },
429 + "pricing": {
430 + "inputPerMillion": 0.2,
431 + "cachedInputPerMillion": 0.02,
432 + "cacheWritePerMillion": null,
433 + "cacheWrite1hPerMillion": null,
434 + "outputPerMillion": 1.25,
435 + "internalReasoningPerMillion": null,
436 + "imageInputPerImage": null,
437 + "imageOutputPerImage": null,
438 + "audioInputPerMillion": null,
439 + "audioOutputPerMillion": null,
440 + "perRequest": null,
441 + "webSearchPerRequest": 0.01,
442 + "longContextThresholdTokens": null,
443 + "longContextInputPerMillion": null,
444 + "longContextOutputPerMillion": null,
445 + "longContextCachedInputPerMillion": null,
446 + "free": false,
447 + "unknown": false
448 + },
449 + "status": "active",
450 + "expirationDate": null,
451 + "knowledgeCutoff": "2025-08-31",
452 + "aliases": [],
453 + "huggingFaceId": "",
454 + "created": 1773748187,
455 + "notes": "PROBED (all probes). Reasoning default OFF; `reasoning.effort` low/none accepted; sampling params (temperature, top_p, top_k, penalties, stop, logprobs) are SILENTLY DROPPED by default and return 404 'No endpoints found that can handle the requested parameters' with provider.require_parameters=true. Tools, json_schema strict, vision (32×32 PNG → 21 prompt tokens), streaming all OK. `:online` variant on this model consumed 8.5k prompt tokens of search results and 200 reasoning tokens before any content (finish length) → give web-search calls a large max_tokens."
456 + },
457 + {
458 + "id": "openai/gpt-oss-120b",
459 + "displayName": "OpenAI: gpt-oss-120b",
460 + "family": "openai",
461 + "canonicalSlug": "openai/gpt-oss-120b",
462 + "variant": null,
463 + "baseId": "openai/gpt-oss-120b",
464 + "contextTokens": 131072,
465 + "maxOutputTokens": 117964,
466 + "capabilities": {
467 + "text": true,
468 + "vision": false,
469 + "audioInput": false,
470 + "audioOutput": false,
471 + "imageGeneration": false,
472 + "video": false,
473 + "reasoning": true,
474 + "tools": true,
475 + "structuredOutput": true,
476 + "strictJsonSchema": true,
477 + "streaming": true,
478 + "files": false,
479 + "webSearch": "via-plugin-or-:online",
480 + "moderated": false
481 + },
482 + "parameters": {
483 + "temperature": true,
484 + "topP": true,
485 + "topK": true,
486 + "minP": true,
487 + "topA": true,
488 + "maxTokens": true,
489 + "reasoningEffort": [
490 + "high",
491 + "medium",
492 + "low"
493 + ],
494 + "reasoningMandatory": true,
495 + "reasoningDefaultEnabled": true,
496 + "reasoningDefaultEffort": "medium",
497 + "thinkingBudget": false,
498 + "stop": true,
499 + "seed": true,
500 + "frequencyPenalty": true,
501 + "presencePenalty": true,
502 + "repetitionPenalty": true,
503 + "logprobs": true,
504 + "logitBias": true,
505 + "responseFormat": true,
506 + "toolChoice": true,
507 + "parallelToolCalls": false,
508 + "verbosity": false,
509 + "webSearchOptions": false,
510 + "defaults": {
511 + "temperature": null,
512 + "top_p": null,
513 + "frequency_penalty": null
514 + }
515 + },
516 + "pricing": {
517 + "inputPerMillion": 0.037,
518 + "cachedInputPerMillion": null,
519 + "cacheWritePerMillion": null,
520 + "cacheWrite1hPerMillion": null,
521 + "outputPerMillion": 0.17,
522 + "internalReasoningPerMillion": null,
523 + "imageInputPerImage": null,
524 + "imageOutputPerImage": null,
525 + "audioInputPerMillion": null,
526 + "audioOutputPerMillion": null,
527 + "perRequest": null,
528 + "webSearchPerRequest": null,
529 + "longContextThresholdTokens": null,
530 + "longContextInputPerMillion": null,
531 + "longContextOutputPerMillion": null,
532 + "longContextCachedInputPerMillion": null,
533 + "free": false,
534 + "unknown": false
535 + },
536 + "status": "active",
537 + "expirationDate": null,
538 + "knowledgeCutoff": "2024-06-30",
539 + "aliases": [],
540 + "huggingFaceId": "openai/gpt-oss-120b",
541 + "created": 1754414231,
542 + "notes": "Open-weights OpenAI model served by many providers; reasoning mandatory with effort high/medium/low; response.provider varies per call."
543 + },
544 + {
545 + "id": "anthropic/claude-fable-5.1",
546 + "displayName": "Anthropic: Claude Fable 5.1",
547 + "family": "anthropic",
548 + "canonicalSlug": "anthropic/claude-fable-5.1-20260831",
549 + "variant": null,
550 + "baseId": "anthropic/claude-fable-5.1",
551 + "contextTokens": 1000000,
552 + "maxOutputTokens": 128000,
553 + "capabilities": {
554 + "text": true,
555 + "vision": true,
556 + "audioInput": false,
557 + "audioOutput": false,
558 + "imageGeneration": false,
559 + "video": false,
560 + "reasoning": true,
561 + "tools": true,
562 + "structuredOutput": true,
563 + "strictJsonSchema": true,
564 + "streaming": true,
565 + "files": true,
566 + "webSearch": "via-plugin-or-:online",
567 + "moderated": true
568 + },
569 + "parameters": {
570 + "temperature": false,
571 + "topP": false,
572 + "topK": false,
573 + "minP": false,
574 + "topA": false,
575 + "maxTokens": true,
576 + "reasoningEffort": [
577 + "max",
578 + "xhigh",
579 + "high",
580 + "medium",
581 + "low"
582 + ],
583 + "reasoningMandatory": true,
584 + "reasoningDefaultEnabled": true,
585 + "reasoningDefaultEffort": "high",
586 + "thinkingBudget": "reasoning.max_tokens (docs)",
587 + "stop": true,
588 + "seed": false,
589 + "frequencyPenalty": false,
590 + "presencePenalty": false,
591 + "repetitionPenalty": false,
592 + "logprobs": false,
593 + "logitBias": false,
594 + "responseFormat": true,
595 + "toolChoice": false,
596 + "parallelToolCalls": false,
597 + "verbosity": true,
598 + "webSearchOptions": false,
599 + "defaults": null
600 + },
601 + "pricing": {
602 + "inputPerMillion": 10,
603 + "cachedInputPerMillion": 0.25,
604 + "cacheWritePerMillion": 12.5,
605 + "cacheWrite1hPerMillion": 20,
606 + "outputPerMillion": 50,
607 + "internalReasoningPerMillion": null,
608 + "imageInputPerImage": null,
609 + "imageOutputPerImage": null,
610 + "audioInputPerMillion": null,
611 + "audioOutputPerMillion": null,
612 + "perRequest": null,
613 + "webSearchPerRequest": 0.01,
614 + "longContextThresholdTokens": null,
615 + "longContextInputPerMillion": null,
616 + "longContextOutputPerMillion": null,
617 + "longContextCachedInputPerMillion": null,
618 + "free": false,
619 + "unknown": false
620 + },
621 + "status": "active",
622 + "expirationDate": null,
623 + "knowledgeCutoff": null,
624 + "aliases": [
625 + "~anthropic/claude-fable-latest"
626 + ],
627 + "huggingFaceId": null,
628 + "created": 1788285838,
629 + "notes": "Anthropic flagship; reasoning mandatory, efforts max…low (default high); 1M context / 128k output. Target of `~anthropic/claude-fable-latest`. Prompt caching needs `cache_control` breakpoints (write 1.25×, read 0.1×)."
630 + },
631 + {
632 + "id": "anthropic/claude-opus-5",
633 + "displayName": "Claude Opus 5",
634 + "family": "anthropic",
635 + "canonicalSlug": "anthropic/claude-opus-5-20260723",
636 + "variant": null,
637 + "baseId": "anthropic/claude-opus-5",
638 + "contextTokens": 1000000,
639 + "maxOutputTokens": 128000,
640 + "capabilities": {
641 + "text": true,
642 + "vision": true,
643 + "audioInput": false,
644 + "audioOutput": false,
645 + "imageGeneration": false,
646 + "video": false,
647 + "reasoning": true,
648 + "tools": true,
649 + "structuredOutput": true,
650 + "strictJsonSchema": true,
651 + "streaming": true,
652 + "files": true,
653 + "webSearch": "via-plugin-or-:online",
654 + "moderated": true
655 + },
656 + "parameters": {
657 + "temperature": true,
658 + "topP": false,
659 + "topK": false,
660 + "minP": false,
661 + "topA": false,
662 + "maxTokens": true,
663 + "reasoningEffort": [
664 + "max",
665 + "xhigh",
666 + "high",
667 + "medium",
668 + "low"
669 + ],
670 + "reasoningMandatory": false,
671 + "reasoningDefaultEnabled": true,
672 + "reasoningDefaultEffort": "high",
673 + "thinkingBudget": "reasoning.max_tokens (docs)",
674 + "stop": true,
675 + "seed": false,
676 + "frequencyPenalty": false,
677 + "presencePenalty": false,
678 + "repetitionPenalty": false,
679 + "logprobs": false,
680 + "logitBias": false,
681 + "responseFormat": true,
682 + "toolChoice": true,
683 + "parallelToolCalls": false,
684 + "verbosity": true,
685 + "webSearchOptions": false,
686 + "defaults": null
687 + },
688 + "pricing": {
689 + "inputPerMillion": 5,
690 + "cachedInputPerMillion": 0.5,
691 + "cacheWritePerMillion": 6.25,
692 + "cacheWrite1hPerMillion": 10,
693 + "outputPerMillion": 25,
694 + "internalReasoningPerMillion": null,
695 + "imageInputPerImage": null,
696 + "imageOutputPerImage": null,
697 + "audioInputPerMillion": null,
698 + "audioOutputPerMillion": null,
699 + "perRequest": null,
700 + "webSearchPerRequest": 0.01,
701 + "longContextThresholdTokens": null,
702 + "longContextInputPerMillion": null,
703 + "longContextOutputPerMillion": null,
704 + "longContextCachedInputPerMillion": null,
705 + "free": false,
706 + "unknown": false
707 + },
708 + "status": "active",
709 + "expirationDate": null,
710 + "knowledgeCutoff": null,
711 + "aliases": [
712 + "~anthropic/claude-opus-latest"
713 + ],
714 + "huggingFaceId": null,
715 + "created": 1784912544,
716 + "notes": "Reasoning optional, default_enabled true."
717 + },
718 + {
719 + "id": "anthropic/claude-sonnet-5",
720 + "displayName": "Anthropic: Claude Sonnet 5",
721 + "family": "anthropic",
722 + "canonicalSlug": "anthropic/claude-sonnet-5-20260630",
723 + "variant": null,
724 + "baseId": "anthropic/claude-sonnet-5",
725 + "contextTokens": 1000000,
726 + "maxOutputTokens": 128000,
727 + "capabilities": {
728 + "text": true,
729 + "vision": true,
730 + "audioInput": false,
731 + "audioOutput": false,
732 + "imageGeneration": false,
733 + "video": false,
734 + "reasoning": true,
735 + "tools": true,
736 + "structuredOutput": true,
737 + "strictJsonSchema": true,
738 + "streaming": true,
739 + "files": true,
740 + "webSearch": "via-plugin-or-:online",
741 + "moderated": true
742 + },
743 + "parameters": {
744 + "temperature": false,
745 + "topP": false,
746 + "topK": false,
747 + "minP": false,
748 + "topA": false,
749 + "maxTokens": true,
750 + "reasoningEffort": [
751 + "max",
752 + "xhigh",
753 + "high",
754 + "medium",
755 + "low"
756 + ],
757 + "reasoningMandatory": false,
758 + "reasoningDefaultEnabled": true,
759 + "reasoningDefaultEffort": "high",
760 + "thinkingBudget": "reasoning.max_tokens (docs)",
761 + "stop": true,
762 + "seed": false,
763 + "frequencyPenalty": false,
764 + "presencePenalty": false,
765 + "repetitionPenalty": false,
766 + "logprobs": false,
767 + "logitBias": false,
768 + "responseFormat": true,
769 + "toolChoice": true,
770 + "parallelToolCalls": false,
771 + "verbosity": true,
772 + "webSearchOptions": false,
773 + "defaults": null
774 + },
775 + "pricing": {
776 + "inputPerMillion": 2,
777 + "cachedInputPerMillion": 0.2,
778 + "cacheWritePerMillion": 2.5,
779 + "cacheWrite1hPerMillion": 4,
780 + "outputPerMillion": 10,
781 + "internalReasoningPerMillion": null,
782 + "imageInputPerImage": null,
783 + "imageOutputPerImage": null,
784 + "audioInputPerMillion": null,
785 + "audioOutputPerMillion": null,
786 + "perRequest": null,
787 + "webSearchPerRequest": 0.01,
788 + "longContextThresholdTokens": null,
789 + "longContextInputPerMillion": null,
790 + "longContextOutputPerMillion": null,
791 + "longContextCachedInputPerMillion": null,
792 + "free": false,
793 + "unknown": false
794 + },
795 + "status": "active",
796 + "expirationDate": null,
797 + "knowledgeCutoff": null,
798 + "aliases": [
799 + "~anthropic/claude-sonnet-latest"
800 + ],
801 + "huggingFaceId": null,
802 + "created": 1782843083,
803 + "notes": "Reasoning optional, default_enabled true; supports top_k."
804 + },
805 + {
806 + "id": "anthropic/claude-haiku-4.5",
807 + "displayName": "Anthropic: Claude Haiku 4.5",
808 + "family": "anthropic",
809 + "canonicalSlug": "anthropic/claude-4.5-haiku-20251001",
810 + "variant": null,
811 + "baseId": "anthropic/claude-haiku-4.5",
812 + "contextTokens": 200000,
813 + "maxOutputTokens": 64000,
814 + "capabilities": {
815 + "text": true,
816 + "vision": true,
817 + "audioInput": false,
818 + "audioOutput": false,
819 + "imageGeneration": false,
820 + "video": false,
821 + "reasoning": true,
822 + "tools": true,
823 + "structuredOutput": true,
824 + "strictJsonSchema": true,
825 + "streaming": true,
826 + "files": true,
827 + "webSearch": "via-plugin-or-:online",
828 + "moderated": true
829 + },
830 + "parameters": {
831 + "temperature": true,
832 + "topP": true,
833 + "topK": true,
834 + "minP": false,
835 + "topA": false,
836 + "maxTokens": true,
837 + "reasoningEffort": "toggle-only (reasoning.enabled / reasoning.max_tokens)",
838 + "reasoningMandatory": false,
839 + "reasoningDefaultEnabled": false,
840 + "reasoningDefaultEffort": null,
841 + "thinkingBudget": "reasoning.max_tokens (docs)",
842 + "stop": true,
843 + "seed": false,
844 + "frequencyPenalty": false,
845 + "presencePenalty": false,
846 + "repetitionPenalty": false,
847 + "logprobs": false,
848 + "logitBias": false,
849 + "responseFormat": true,
850 + "toolChoice": true,
851 + "parallelToolCalls": false,
852 + "verbosity": false,
853 + "webSearchOptions": false,
854 + "defaults": {
855 + "temperature": null,
856 + "top_p": null,
857 + "top_k": null,
858 + "frequency_penalty": null,
859 + "presence_penalty": null,
860 + "repetition_penalty": null
861 + }
862 + },
863 + "pricing": {
864 + "inputPerMillion": 1,
865 + "cachedInputPerMillion": 0.1,
866 + "cacheWritePerMillion": 1.25,
867 + "cacheWrite1hPerMillion": 2,
868 + "outputPerMillion": 5,
869 + "internalReasoningPerMillion": null,
870 + "imageInputPerImage": null,
871 + "imageOutputPerImage": null,
872 + "audioInputPerMillion": null,
873 + "audioOutputPerMillion": null,
874 + "perRequest": null,
875 + "webSearchPerRequest": 0.01,
876 + "longContextThresholdTokens": null,
877 + "longContextInputPerMillion": null,
878 + "longContextOutputPerMillion": null,
879 + "longContextCachedInputPerMillion": null,
880 + "free": false,
881 + "unknown": false
882 + },
883 + "status": "active",
884 + "expirationDate": null,
885 + "knowledgeCutoff": null,
886 + "aliases": [
887 + "~anthropic/claude-haiku-latest"
888 + ],
889 + "huggingFaceId": "",
890 + "created": 1760547638,
891 + "notes": "PROBED (all probes). reasoning = {mandatory:false} only (no supported_efforts): `reasoning.effort` does NOT enable thinking (0 reasoning tokens) — only `reasoning.max_tokens` does (probed 43–65 reasoning tokens; reasoning_details type reasoning.text with format anthropic-claude-v1 + signature, streamed as fragments). seed/repetition_penalty/logprobs not supported (404 with require_parameters). Served via Amazon Bedrock / Azure / Anthropic; provider chosen per request. cache_control ephemeral probed: 5208 cache_write_tokens then 5208 cached_tokens, cost 0.0065 → 0.00055."
892 + },
893 + {
894 + "id": "google/gemini-3.8-flash",
895 + "displayName": "Google: Gemini 3.8 Flash",
896 + "family": "google",
897 + "canonicalSlug": "google/gemini-3.8-flash-20260902",
898 + "variant": null,
899 + "baseId": "google/gemini-3.8-flash",
900 + "contextTokens": 1048576,
901 + "maxOutputTokens": 65536,
902 + "capabilities": {
903 + "text": true,
904 + "vision": true,
905 + "audioInput": true,
906 + "audioOutput": false,
907 + "imageGeneration": false,
908 + "video": true,
909 + "reasoning": true,
910 + "tools": true,
911 + "structuredOutput": true,
912 + "strictJsonSchema": true,
913 + "streaming": true,
914 + "files": true,
915 + "webSearch": "via-plugin-or-:online",
916 + "moderated": false
917 + },
918 + "parameters": {
919 + "temperature": true,
920 + "topP": true,
921 + "topK": false,
922 + "minP": false,
923 + "topA": false,
924 + "maxTokens": true,
925 + "reasoningEffort": [
926 + "high",
927 + "medium",
928 + "low"
929 + ],
930 + "reasoningMandatory": true,
931 + "reasoningDefaultEnabled": true,
932 + "reasoningDefaultEffort": "medium",
933 + "thinkingBudget": "reasoning.max_tokens (docs)",
934 + "stop": true,
935 + "seed": true,
936 + "frequencyPenalty": false,
937 + "presencePenalty": false,
938 + "repetitionPenalty": false,
939 + "logprobs": false,
940 + "logitBias": false,
941 + "responseFormat": true,
942 + "toolChoice": true,
943 + "parallelToolCalls": false,
944 + "verbosity": false,
945 + "webSearchOptions": false,
946 + "defaults": null
947 + },
948 + "pricing": {
949 + "inputPerMillion": 0.75,
950 + "cachedInputPerMillion": 0.075,
951 + "cacheWritePerMillion": 0.041667,
952 + "cacheWrite1hPerMillion": null,
953 + "outputPerMillion": 3.75,
954 + "internalReasoningPerMillion": 3.75,
955 + "imageInputPerImage": 7.5e-7,
956 + "imageOutputPerImage": null,
957 + "audioInputPerMillion": 0.75,
958 + "audioOutputPerMillion": null,
959 + "perRequest": null,
960 + "webSearchPerRequest": 0.014,
961 + "longContextThresholdTokens": null,
962 + "longContextInputPerMillion": null,
963 + "longContextOutputPerMillion": null,
964 + "longContextCachedInputPerMillion": null,
965 + "free": false,
966 + "unknown": false
967 + },
968 + "status": "active",
969 + "expirationDate": null,
970 + "knowledgeCutoff": null,
971 + "aliases": [
972 + "~google/gemini-flash-latest"
973 + ],
974 + "huggingFaceId": null,
975 + "created": 1788362056,
976 + "notes": "Target of `~google/gemini-flash-latest`. Reasoning mandatory (high/medium/low). Inputs text+image+video+file+audio."
977 + },
978 + {
979 + "id": "google/gemini-3.1-pro-preview",
980 + "displayName": "Google: Gemini 3.1 Pro Preview",
981 + "family": "google",
982 + "canonicalSlug": "google/gemini-3.1-pro-preview-20260219",
983 + "variant": null,
984 + "baseId": "google/gemini-3.1-pro-preview",
985 + "contextTokens": 1048576,
986 + "maxOutputTokens": 65536,
987 + "capabilities": {
988 + "text": true,
989 + "vision": true,
990 + "audioInput": true,
991 + "audioOutput": false,
992 + "imageGeneration": false,
993 + "video": true,
994 + "reasoning": true,
995 + "tools": true,
996 + "structuredOutput": true,
997 + "strictJsonSchema": true,
998 + "streaming": true,
999 + "files": true,
1000 + "webSearch": "via-plugin-or-:online",
1001 + "moderated": false
1002 + },
1003 + "parameters": {
1004 + "temperature": true,
1005 + "topP": true,
1006 + "topK": false,
1007 + "minP": false,
1008 + "topA": false,
1009 + "maxTokens": true,
1010 + "reasoningEffort": [
1011 + "high",
1012 + "medium",
1013 + "low"
1014 + ],
1015 + "reasoningMandatory": true,
1016 + "reasoningDefaultEnabled": true,
1017 + "reasoningDefaultEffort": "medium",
1018 + "thinkingBudget": "reasoning.max_tokens (docs)",
1019 + "stop": true,
1020 + "seed": true,
1021 + "frequencyPenalty": false,
1022 + "presencePenalty": false,
1023 + "repetitionPenalty": false,
1024 + "logprobs": false,
1025 + "logitBias": false,
1026 + "responseFormat": true,
1027 + "toolChoice": true,
1028 + "parallelToolCalls": false,
1029 + "verbosity": false,
1030 + "webSearchOptions": false,
1031 + "defaults": {
1032 + "temperature": null,
1033 + "top_p": null,
1034 + "top_k": null,
1035 + "frequency_penalty": null,
1036 + "presence_penalty": null,
1037 + "repetition_penalty": null
1038 + }
1039 + },
1040 + "pricing": {
1041 + "inputPerMillion": 2,
1042 + "cachedInputPerMillion": 0.2,
1043 + "cacheWritePerMillion": 0.375,
1044 + "cacheWrite1hPerMillion": null,
1045 + "outputPerMillion": 12,
1046 + "internalReasoningPerMillion": 12,
1047 + "imageInputPerImage": 0.000002,
1048 + "imageOutputPerImage": null,
1049 + "audioInputPerMillion": 2,
1050 + "audioOutputPerMillion": null,
1051 + "perRequest": null,
1052 + "webSearchPerRequest": 0.014,
1053 + "longContextThresholdTokens": 200000,
1054 + "longContextInputPerMillion": 4,
1055 + "longContextOutputPerMillion": 18,
1056 + "longContextCachedInputPerMillion": 0.4,
1057 + "free": false,
1058 + "unknown": false
1059 + },
1060 + "status": "active",
1061 + "expirationDate": null,
1062 + "knowledgeCutoff": null,
1063 + "aliases": [
1064 + "~google/gemini-pro-latest"
1065 + ],
1066 + "huggingFaceId": "",
1067 + "created": 1771509627,
1068 + "notes": "Target of `~google/gemini-pro-latest`. Reasoning mandatory. Long-context override above 200k prompt tokens (2× input). Separate `internal_reasoning` price."
1069 + },
1070 + {
1071 + "id": "google/gemini-3.5-flash-lite",
1072 + "displayName": "Google: Gemini 3.5 Flash Lite",
1073 + "family": "google",
1074 + "canonicalSlug": "google/gemini-3.5-flash-lite-20260721",
1075 + "variant": null,
1076 + "baseId": "google/gemini-3.5-flash-lite",
1077 + "contextTokens": 1048576,
1078 + "maxOutputTokens": 65536,
1079 + "capabilities": {
1080 + "text": true,
1081 + "vision": true,
1082 + "audioInput": true,
1083 + "audioOutput": false,
1084 + "imageGeneration": false,
1085 + "video": true,
1086 + "reasoning": true,
1087 + "tools": true,
1088 + "structuredOutput": true,
1089 + "strictJsonSchema": true,
1090 + "streaming": true,
1091 + "files": true,
1092 + "webSearch": "via-plugin-or-:online",
1093 + "moderated": false
1094 + },
1095 + "parameters": {
1096 + "temperature": true,
1097 + "topP": true,
1098 + "topK": false,
1099 + "minP": false,
1100 + "topA": false,
1101 + "maxTokens": true,
1102 + "reasoningEffort": [
1103 + "high",
1104 + "medium",
1105 + "low",
1106 + "minimal"
1107 + ],
1108 + "reasoningMandatory": true,
1109 + "reasoningDefaultEnabled": true,
1110 + "reasoningDefaultEffort": "minimal",
1111 + "thinkingBudget": "reasoning.max_tokens (docs)",
1112 + "stop": true,
1113 + "seed": true,
1114 + "frequencyPenalty": false,
1115 + "presencePenalty": false,
1116 + "repetitionPenalty": false,
1117 + "logprobs": false,
1118 + "logitBias": false,
1119 + "responseFormat": true,
1120 + "toolChoice": true,
1121 + "parallelToolCalls": false,
1122 + "verbosity": false,
1123 + "webSearchOptions": false,
1124 + "defaults": {
1125 + "temperature": null,
1126 + "top_p": null,
1127 + "top_k": null,
1128 + "frequency_penalty": null,
1129 + "presence_penalty": null,
1130 + "repetition_penalty": null
1131 + }
1132 + },
1133 + "pricing": {
1134 + "inputPerMillion": 0.3,
1135 + "cachedInputPerMillion": 0.03,
1136 + "cacheWritePerMillion": 0.083333,
1137 + "cacheWrite1hPerMillion": null,
1138 + "outputPerMillion": 2.5,
1139 + "internalReasoningPerMillion": 2.5,
1140 + "imageInputPerImage": 3e-7,
1141 + "imageOutputPerImage": null,
1142 + "audioInputPerMillion": 0.3,
1143 + "audioOutputPerMillion": null,
1144 + "perRequest": null,
1145 + "webSearchPerRequest": 0.014,
1146 + "longContextThresholdTokens": null,
1147 + "longContextInputPerMillion": null,
1148 + "longContextOutputPerMillion": null,
1149 + "longContextCachedInputPerMillion": null,
1150 + "free": false,
1151 + "unknown": false
1152 + },
1153 + "status": "active",
1154 + "expirationDate": null,
1155 + "knowledgeCutoff": null,
1156 + "aliases": [],
1157 + "huggingFaceId": null,
1158 + "created": 1784646726,
1159 + "notes": "PROBED (all probes). Reasoning mandatory (default_effort minimal): `reasoning.effort: none` / `enabled: false` → 400 'Reasoning is mandatory for this endpoint and cannot be disabled.' Reasoning text is NOT returned (reasoning: null) — only reasoning_details [{type:'reasoning.text', signature, format:'google-gemini-v1'}] (encrypted thought signature) or reasoning.encrypted attached to tool calls (id = tool call id). Vision OK (269 prompt tokens for a 32×32 PNG). Accepts audio/video/file input."
1160 + },
1161 + {
1162 + "id": "google/gemini-3.1-flash-image",
1163 + "displayName": "Google: Nano Banana 2 (Gemini 3.1 Flash Image)",
1164 + "family": "google",
1165 + "canonicalSlug": "google/gemini-3.1-flash-image-20260528",
1166 + "variant": null,
1167 + "baseId": "google/gemini-3.1-flash-image",
1168 + "contextTokens": 131072,
1169 + "maxOutputTokens": 32768,
1170 + "capabilities": {
1171 + "text": true,
1172 + "vision": true,
1173 + "audioInput": false,
1174 + "audioOutput": false,
1175 + "imageGeneration": true,
1176 + "video": false,
1177 + "reasoning": true,
1178 + "tools": false,
1179 + "structuredOutput": true,
1180 + "strictJsonSchema": true,
1181 + "streaming": true,
1182 + "files": false,
1183 + "webSearch": "via-plugin-or-:online",
1184 + "moderated": false
1185 + },
1186 + "parameters": {
1187 + "temperature": true,
1188 + "topP": true,
1189 + "topK": false,
1190 + "minP": false,
1191 + "topA": false,
1192 + "maxTokens": true,
1193 + "reasoningEffort": [
1194 + "high",
1195 + "minimal"
1196 + ],
1197 + "reasoningMandatory": false,
1198 + "reasoningDefaultEnabled": true,
1199 + "reasoningDefaultEffort": "minimal",
1200 + "thinkingBudget": "reasoning.max_tokens (docs)",
1201 + "stop": false,
1202 + "seed": true,
1203 + "frequencyPenalty": false,
1204 + "presencePenalty": false,
1205 + "repetitionPenalty": false,
1206 + "logprobs": false,
1207 + "logitBias": false,
1208 + "responseFormat": true,
1209 + "toolChoice": false,
1210 + "parallelToolCalls": false,
1211 + "verbosity": false,
1212 + "webSearchOptions": false,
1213 + "defaults": null
1214 + },
1215 + "pricing": {
1216 + "inputPerMillion": 0.5,
1217 + "cachedInputPerMillion": null,
1218 + "cacheWritePerMillion": null,
1219 + "cacheWrite1hPerMillion": null,
1220 + "outputPerMillion": 3,
1221 + "internalReasoningPerMillion": null,
1222 + "imageInputPerImage": null,
1223 + "imageOutputPerImage": 0.00006,
1224 + "audioInputPerMillion": null,
1225 + "audioOutputPerMillion": null,
1226 + "perRequest": null,
1227 + "webSearchPerRequest": 0.014,
1228 + "longContextThresholdTokens": null,
1229 + "longContextInputPerMillion": null,
1230 + "longContextOutputPerMillion": null,
1231 + "longContextCachedInputPerMillion": null,
1232 + "free": false,
1233 + "unknown": false
1234 + },
1235 + "status": "active",
1236 + "expirationDate": null,
1237 + "knowledgeCutoff": null,
1238 + "aliases": [],
1239 + "huggingFaceId": null,
1240 + "created": 1781754065,
1241 + "notes": "Image OUTPUT model (output_modalities text+image): request `modalities: ['image','text']`, images come back in message.images[]; pricing.image_output per image. Hide from the plain chat picker or flag as image generation."
1242 + },
1243 + {
1244 + "id": "x-ai/grok-4.6",
1245 + "displayName": "SpaceXAI: Grok 4.6",
1246 + "family": "x-ai",
1247 + "canonicalSlug": "x-ai/grok-4.6-20260810",
1248 + "variant": null,
1249 + "baseId": "x-ai/grok-4.6",
1250 + "contextTokens": 500000,
1251 + "maxOutputTokens": 450000,
1252 + "capabilities": {
1253 + "text": true,
1254 + "vision": true,
1255 + "audioInput": false,
1256 + "audioOutput": false,
1257 + "imageGeneration": false,
1258 + "video": false,
1259 + "reasoning": true,
1260 + "tools": true,
1261 + "structuredOutput": true,
1262 + "strictJsonSchema": true,
1263 + "streaming": true,
1264 + "files": true,
1265 + "webSearch": "via-plugin-or-:online",
1266 + "moderated": false
1267 + },
1268 + "parameters": {
1269 + "temperature": true,
1270 + "topP": true,
1271 + "topK": true,
1272 + "minP": false,
1273 + "topA": false,
1274 + "maxTokens": true,
1275 + "reasoningEffort": [
1276 + "xhigh",
1277 + "high",
1278 + "medium",
1279 + "low"
1280 + ],
1281 + "reasoningMandatory": true,
1282 + "reasoningDefaultEnabled": true,
1283 + "reasoningDefaultEffort": "high",
1284 + "thinkingBudget": false,
1285 + "stop": true,
1286 + "seed": true,
1287 + "frequencyPenalty": false,
1288 + "presencePenalty": false,
1289 + "repetitionPenalty": false,
1290 + "logprobs": true,
1291 + "logitBias": false,
1292 + "responseFormat": true,
1293 + "toolChoice": true,
1294 + "parallelToolCalls": false,
1295 + "verbosity": false,
1296 + "webSearchOptions": false,
1297 + "defaults": null
1298 + },
1299 + "pricing": {
1300 + "inputPerMillion": 2,
1301 + "cachedInputPerMillion": 0.5,
1302 + "cacheWritePerMillion": null,
1303 + "cacheWrite1hPerMillion": null,
1304 + "outputPerMillion": 6,
1305 + "internalReasoningPerMillion": null,
1306 + "imageInputPerImage": null,
1307 + "imageOutputPerImage": null,
1308 + "audioInputPerMillion": null,
1309 + "audioOutputPerMillion": null,
1310 + "perRequest": null,
1311 + "webSearchPerRequest": 0.005,
1312 + "longContextThresholdTokens": 200000,
1313 + "longContextInputPerMillion": 4,
1314 + "longContextOutputPerMillion": 12,
1315 + "longContextCachedInputPerMillion": 1,
1316 + "free": false,
1317 + "unknown": false
1318 + },
1319 + "status": "active",
1320 + "expirationDate": null,
1321 + "knowledgeCutoff": null,
1322 + "aliases": [
1323 + "~x-ai/grok-latest"
1324 + ],
1325 + "huggingFaceId": null,
1326 + "created": 1786548957,
1327 + "notes": "xAI flagship via OpenRouter (target of `~x-ai/grok-latest`). Reasoning mandatory, efforts xhigh…low. supported_parameters include stop and top_k here (xAI direct rejects stop). Override above 200k prompt tokens (2×)."
1328 + },
1329 + {
1330 + "id": "x-ai/grok-4.3",
1331 + "displayName": "SpaceXAI: Grok 4.3",
1332 + "family": "x-ai",
1333 + "canonicalSlug": "x-ai/grok-4.3-20260430",
1334 + "variant": null,
1335 + "baseId": "x-ai/grok-4.3",
1336 + "contextTokens": 1000000,
1337 + "maxOutputTokens": 900000,
1338 + "capabilities": {
1339 + "text": true,
1340 + "vision": true,
1341 + "audioInput": false,
1342 + "audioOutput": false,
1343 + "imageGeneration": false,
1344 + "video": false,
1345 + "reasoning": true,
1346 + "tools": true,
1347 + "structuredOutput": true,
1348 + "strictJsonSchema": true,
1349 + "streaming": true,
1350 + "files": true,
1351 + "webSearch": "via-plugin-or-:online",
1352 + "moderated": false
1353 + },
1354 + "parameters": {
1355 + "temperature": true,
1356 + "topP": true,
1357 + "topK": false,
1358 + "minP": false,
1359 + "topA": false,
1360 + "maxTokens": true,
1361 + "reasoningEffort": [
1362 + "high",
1363 + "medium",
1364 + "low",
1365 + "none"
1366 + ],
1367 + "reasoningMandatory": false,
1368 + "reasoningDefaultEnabled": true,
1369 + "reasoningDefaultEffort": "low",
1370 + "thinkingBudget": false,
1371 + "stop": false,
1372 + "seed": true,
1373 + "frequencyPenalty": false,
1374 + "presencePenalty": false,
1375 + "repetitionPenalty": false,
1376 + "logprobs": true,
1377 + "logitBias": false,
1378 + "responseFormat": true,
1379 + "toolChoice": true,
1380 + "parallelToolCalls": false,
1381 + "verbosity": false,
1382 + "webSearchOptions": false,
1383 + "defaults": {
1384 + "temperature": null,
1385 + "top_p": null,
1386 + "top_k": null,
1387 + "frequency_penalty": null,
1388 + "presence_penalty": null,
1389 + "repetition_penalty": null
1390 + }
1391 + },
1392 + "pricing": {
1393 + "inputPerMillion": 1.25,
1394 + "cachedInputPerMillion": 0.2,
1395 + "cacheWritePerMillion": null,
1396 + "cacheWrite1hPerMillion": null,
1397 + "outputPerMillion": 2.5,
1398 + "internalReasoningPerMillion": null,
1399 + "imageInputPerImage": null,
1400 + "imageOutputPerImage": null,
1401 + "audioInputPerMillion": null,
1402 + "audioOutputPerMillion": null,
1403 + "perRequest": null,
1404 + "webSearchPerRequest": 0.005,
1405 + "longContextThresholdTokens": 200000,
1406 + "longContextInputPerMillion": 2.5,
1407 + "longContextOutputPerMillion": 5,
1408 + "longContextCachedInputPerMillion": 0.4,
1409 + "free": false,
1410 + "unknown": false
1411 + },
1412 + "status": "active",
1413 + "expirationDate": null,
1414 + "knowledgeCutoff": null,
1415 + "aliases": [],
1416 + "huggingFaceId": null,
1417 + "created": 1777591821,
1418 + "notes": "Reasoning optional (efforts high/medium/low/none)."
1419 + },
1420 + {
1421 + "id": "x-ai/grok-4.20",
1422 + "displayName": "SpaceXAI: Grok 4.20",
1423 + "family": "x-ai",
1424 + "canonicalSlug": "x-ai/grok-4.20-20260309",
1425 + "variant": null,
1426 + "baseId": "x-ai/grok-4.20",
1427 + "contextTokens": 2000000,
1428 + "maxOutputTokens": 1800000,
1429 + "capabilities": {
1430 + "text": true,
1431 + "vision": true,
1432 + "audioInput": false,
1433 + "audioOutput": false,
1434 + "imageGeneration": false,
1435 + "video": false,
1436 + "reasoning": true,
1437 + "tools": true,
1438 + "structuredOutput": true,
1439 + "strictJsonSchema": true,
1440 + "streaming": true,
1441 + "files": true,
1442 + "webSearch": "via-plugin-or-:online",
1443 + "moderated": false
1444 + },
1445 + "parameters": {
1446 + "temperature": true,
1447 + "topP": true,
1448 + "topK": false,
1449 + "minP": false,
1450 + "topA": false,
1451 + "maxTokens": true,
1452 + "reasoningEffort": "toggle-only (reasoning.enabled / reasoning.max_tokens)",
1453 + "reasoningMandatory": false,
1454 + "reasoningDefaultEnabled": false,
1455 + "reasoningDefaultEffort": null,
1456 + "thinkingBudget": false,
1457 + "stop": false,
1458 + "seed": true,
1459 + "frequencyPenalty": false,
1460 + "presencePenalty": false,
1461 + "repetitionPenalty": false,
1462 + "logprobs": true,
1463 + "logitBias": false,
1464 + "responseFormat": true,
1465 + "toolChoice": true,
1466 + "parallelToolCalls": false,
1467 + "verbosity": false,
1468 + "webSearchOptions": false,
1469 + "defaults": {
1470 + "temperature": null,
1471 + "top_p": null,
1472 + "top_k": null,
1473 + "frequency_penalty": null,
1474 + "presence_penalty": null,
1475 + "repetition_penalty": null
1476 + }
1477 + },
1478 + "pricing": {
1479 + "inputPerMillion": 1.25,
1480 + "cachedInputPerMillion": 0.2,
1481 + "cacheWritePerMillion": null,
1482 + "cacheWrite1hPerMillion": null,
1483 + "outputPerMillion": 2.5,
1484 + "internalReasoningPerMillion": null,
1485 + "imageInputPerImage": null,
1486 + "imageOutputPerImage": null,
1487 + "audioInputPerMillion": null,
1488 + "audioOutputPerMillion": null,
1489 + "perRequest": null,
1490 + "webSearchPerRequest": 0.005,
1491 + "longContextThresholdTokens": 200000,
1492 + "longContextInputPerMillion": 2.5,
1493 + "longContextOutputPerMillion": 5,
1494 + "longContextCachedInputPerMillion": 0.4,
1495 + "free": false,
1496 + "unknown": false
1497 + },
1498 + "status": "active",
1499 + "expirationDate": null,
1500 + "knowledgeCutoff": "2025-09-01",
1501 + "aliases": [],
1502 + "huggingFaceId": "",
1503 + "created": 1774979019,
1504 + "notes": "PROBED (all probes). reasoning = {mandatory:false, default_enabled:false} → non-reasoning by default; `reasoning.effort: low` turns thinking on (~90–165 reasoning tokens) and returns reasoning_details [reasoning.summary (format xai-responses-v1) + reasoning.encrypted]. Emits many ': OPENROUTER PROCESSING' keep-alive comments while thinking. top_k/penalties/stop silently dropped (404 with require_parameters). Hidden xAI system prompt ≈ 196 prompt tokens, 128 cached on every call. 2M context."
1505 + },
1506 + {
1507 + "id": "deepseek/deepseek-v4-flash",
1508 + "displayName": "DeepSeek: DeepSeek V4 Flash 0423",
1509 + "family": "deepseek",
1510 + "canonicalSlug": "deepseek/deepseek-v4-flash-20260423",
1511 + "variant": null,
1512 + "baseId": "deepseek/deepseek-v4-flash",
1513 + "contextTokens": 1048576,
1514 + "maxOutputTokens": 384000,
1515 + "capabilities": {
1516 + "text": true,
1517 + "vision": false,
1518 + "audioInput": false,
1519 + "audioOutput": false,
1520 + "imageGeneration": false,
1521 + "video": false,
1522 + "reasoning": true,
1523 + "tools": true,
1524 + "structuredOutput": true,
1525 + "strictJsonSchema": true,
1526 + "streaming": true,
1527 + "files": false,
1528 + "webSearch": "via-plugin-or-:online",
1529 + "moderated": false
1530 + },
1531 + "parameters": {
1532 + "temperature": true,
1533 + "topP": true,
1534 + "topK": true,
1535 + "minP": true,
1536 + "topA": true,
1537 + "maxTokens": true,
1538 + "reasoningEffort": [
1539 + "xhigh",
1540 + "high"
1541 + ],
1542 + "reasoningMandatory": false,
1543 + "reasoningDefaultEnabled": false,
1544 + "reasoningDefaultEffort": "high",
1545 + "thinkingBudget": false,
1546 + "stop": true,
1547 + "seed": true,
1548 + "frequencyPenalty": true,
1549 + "presencePenalty": true,
1550 + "repetitionPenalty": true,
1551 + "logprobs": true,
1552 + "logitBias": true,
1553 + "responseFormat": true,
1554 + "toolChoice": true,
1555 + "parallelToolCalls": false,
1556 + "verbosity": false,
1557 + "webSearchOptions": false,
1558 + "defaults": null
1559 + },
1560 + "pricing": {
1561 + "inputPerMillion": 0.088606,
1562 + "cachedInputPerMillion": 0.017721,
1563 + "cacheWritePerMillion": null,
1564 + "cacheWrite1hPerMillion": null,
1565 + "outputPerMillion": 0.177212,
1566 + "internalReasoningPerMillion": null,
1567 + "imageInputPerImage": null,
1568 + "imageOutputPerImage": null,
1569 + "audioInputPerMillion": null,
1570 + "audioOutputPerMillion": null,
1571 + "perRequest": null,
1572 + "webSearchPerRequest": null,
1573 + "longContextThresholdTokens": null,
1574 + "longContextInputPerMillion": null,
1575 + "longContextOutputPerMillion": null,
1576 + "longContextCachedInputPerMillion": null,
1577 + "free": false,
1578 + "unknown": false
1579 + },
1580 + "status": "active",
1581 + "expirationDate": null,
1582 + "knowledgeCutoff": null,
1583 + "aliases": [],
1584 + "huggingFaceId": "deepseek-ai/DeepSeek-V4-Flash",
1585 + "created": 1777000666,
1586 + "notes": "PROBED (all probes). Text-only (vision → 404 'No endpoints found that support image input'). Served by many providers (Novita, GMICloud, Parasail, StreamLake, AtlasCloud, Alibaba, Venice, Azure, DigitalOcean… — response.provider changes per call, output quality/format varies). Reasoning on by default; `effort: none` / `enabled: false` disable it. IMPORTANT: reasoning tokens count against max_tokens — with max_tokens 40 + exclude:true the answer came back null (finish length); strict json_schema with require_parameters returned null content on one provider. Give ≥ 1–2k max_tokens. Widest supported_parameters list (top_k, min_p, top_a, repetition_penalty, logit_bias, logprobs)."
1587 + },
1588 + {
1589 + "id": "deepseek/deepseek-v4-pro",
1590 + "displayName": "DeepSeek: DeepSeek V4 Pro 0423",
1591 + "family": "deepseek",
1592 + "canonicalSlug": "deepseek/deepseek-v4-pro-20260423",
1593 + "variant": null,
1594 + "baseId": "deepseek/deepseek-v4-pro",
1595 + "contextTokens": 1048576,
1596 + "maxOutputTokens": 384000,
1597 + "capabilities": {
1598 + "text": true,
1599 + "vision": false,
1600 + "audioInput": false,
1601 + "audioOutput": false,
1602 + "imageGeneration": false,
1603 + "video": false,
1604 + "reasoning": true,
1605 + "tools": true,
1606 + "structuredOutput": true,
1607 + "strictJsonSchema": true,
1608 + "streaming": true,
1609 + "files": false,
1610 + "webSearch": "via-plugin-or-:online",
1611 + "moderated": false
1612 + },
1613 + "parameters": {
1614 + "temperature": true,
1615 + "topP": true,
1616 + "topK": true,
1617 + "minP": true,
1618 + "topA": false,
1619 + "maxTokens": true,
1620 + "reasoningEffort": [
1621 + "xhigh",
1622 + "high"
1623 + ],
1624 + "reasoningMandatory": false,
1625 + "reasoningDefaultEnabled": false,
1626 + "reasoningDefaultEffort": "high",
1627 + "thinkingBudget": false,
1628 + "stop": true,
1629 + "seed": true,
1630 + "frequencyPenalty": true,
1631 + "presencePenalty": true,
1632 + "repetitionPenalty": true,
1633 + "logprobs": true,
1634 + "logitBias": true,
1635 + "responseFormat": true,
1636 + "toolChoice": true,
1637 + "parallelToolCalls": false,
1638 + "verbosity": false,
1639 + "webSearchOptions": false,
1640 + "defaults": {
1641 + "temperature": 1,
1642 + "top_p": 1
1643 + }
1644 + },
1645 + "pricing": {
1646 + "inputPerMillion": 0.95526,
1647 + "cachedInputPerMillion": 0.079605,
1648 + "cacheWritePerMillion": null,
1649 + "cacheWrite1hPerMillion": null,
1650 + "outputPerMillion": 1.91052,
1651 + "internalReasoningPerMillion": null,
1652 + "imageInputPerImage": null,
1653 + "imageOutputPerImage": null,
1654 + "audioInputPerMillion": null,
1655 + "audioOutputPerMillion": null,
1656 + "perRequest": null,
1657 + "webSearchPerRequest": null,
1658 + "longContextThresholdTokens": null,
1659 + "longContextInputPerMillion": null,
1660 + "longContextOutputPerMillion": null,
1661 + "longContextCachedInputPerMillion": null,
1662 + "free": false,
1663 + "unknown": false
1664 + },
1665 + "status": "active",
1666 + "expirationDate": null,
1667 + "knowledgeCutoff": null,
1668 + "aliases": [],
1669 + "huggingFaceId": "deepseek-ai/DeepSeek-V4-Pro",
1670 + "created": 1777000679,
1671 + "notes": "Reasoning optional (xhigh/high)."
1672 + },
1673 + {
1674 + "id": "moonshotai/kimi-k3",
1675 + "displayName": "MoonshotAI: Kimi K3",
1676 + "family": "moonshotai",
1677 + "canonicalSlug": "moonshotai/kimi-k3-20260715",
1678 + "variant": null,
1679 + "baseId": "moonshotai/kimi-k3",
1680 + "contextTokens": 1048576,
1681 + "maxOutputTokens": 943718,
1682 + "capabilities": {
1683 + "text": true,
1684 + "vision": true,
1685 + "audioInput": false,
1686 + "audioOutput": false,
1687 + "imageGeneration": false,
1688 + "video": true,
1689 + "reasoning": true,
1690 + "tools": true,
1691 + "structuredOutput": true,
1692 + "strictJsonSchema": true,
1693 + "streaming": true,
1694 + "files": false,
1695 + "webSearch": "via-plugin-or-:online",
1696 + "moderated": false
1697 + },
1698 + "parameters": {
1699 + "temperature": true,
1700 + "topP": true,
1701 + "topK": true,
1702 + "minP": true,
1703 + "topA": false,
1704 + "maxTokens": true,
1705 + "reasoningEffort": [
1706 + "max",
1707 + "high",
1708 + "low"
1709 + ],
1710 + "reasoningMandatory": false,
1711 + "reasoningDefaultEnabled": true,
1712 + "reasoningDefaultEffort": "max",
1713 + "thinkingBudget": false,
1714 + "stop": true,
1715 + "seed": true,
1716 + "frequencyPenalty": true,
1717 + "presencePenalty": true,
1718 + "repetitionPenalty": true,
1719 + "logprobs": true,
1720 + "logitBias": true,
1721 + "responseFormat": true,
1722 + "toolChoice": true,
1723 + "parallelToolCalls": false,
1724 + "verbosity": false,
1725 + "webSearchOptions": false,
1726 + "defaults": {
1727 + "temperature": null,
1728 + "top_p": 0.95,
1729 + "top_k": null,
1730 + "frequency_penalty": null,
1731 + "presence_penalty": null,
1732 + "repetition_penalty": null
1733 + }
1734 + },
1735 + "pricing": {
1736 + "inputPerMillion": 3,
1737 + "cachedInputPerMillion": 0.3,
1738 + "cacheWritePerMillion": null,
1739 + "cacheWrite1hPerMillion": null,
1740 + "outputPerMillion": 15,
1741 + "internalReasoningPerMillion": null,
1742 + "imageInputPerImage": null,
1743 + "imageOutputPerImage": null,
1744 + "audioInputPerMillion": null,
1745 + "audioOutputPerMillion": null,
1746 + "perRequest": null,
1747 + "webSearchPerRequest": null,
1748 + "longContextThresholdTokens": null,
1749 + "longContextInputPerMillion": null,
1750 + "longContextOutputPerMillion": null,
1751 + "longContextCachedInputPerMillion": null,
1752 + "free": false,
1753 + "unknown": false
1754 + },
1755 + "status": "active",
1756 + "expirationDate": null,
1757 + "knowledgeCutoff": null,
1758 + "aliases": [
1759 + "~moonshotai/kimi-latest"
1760 + ],
1761 + "huggingFaceId": "moonshotai/Kimi-K3",
1762 + "created": 1784215858,
1763 + "notes": "Target of `~moonshotai/kimi-latest`; 1M context, text+image+video input; reasoning optional with efforts."
1764 + },
1765 + {
1766 + "id": "qwen/qwen3.8-max-0902",
1767 + "displayName": "Qwen: Qwen3.8 Max (0902)",
1768 + "family": "qwen",
1769 + "canonicalSlug": "qwen/qwen3.8-max-20260902",
1770 + "variant": null,
1771 + "baseId": "qwen/qwen3.8-max-0902",
1772 + "contextTokens": 1000000,
1773 + "maxOutputTokens": 131072,
1774 + "capabilities": {
1775 + "text": true,
1776 + "vision": true,
1777 + "audioInput": false,
1778 + "audioOutput": false,
1779 + "imageGeneration": false,
1780 + "video": true,
1781 + "reasoning": true,
1782 + "tools": true,
1783 + "structuredOutput": true,
1784 + "strictJsonSchema": true,
1785 + "streaming": true,
1786 + "files": false,
1787 + "webSearch": "via-plugin-or-:online",
1788 + "moderated": false
1789 + },
1790 + "parameters": {
1791 + "temperature": true,
1792 + "topP": true,
1793 + "topK": true,
1794 + "minP": false,
1795 + "topA": false,
1796 + "maxTokens": true,
1797 + "reasoningEffort": [
1798 + "xhigh",
1799 + "high",
1800 + "medium",
1801 + "low",
1802 + "minimal"
1803 + ],
1804 + "reasoningMandatory": true,
1805 + "reasoningDefaultEnabled": true,
1806 + "reasoningDefaultEffort": "xhigh",
1807 + "thinkingBudget": false,
1808 + "stop": true,
1809 + "seed": true,
1810 + "frequencyPenalty": true,
1811 + "presencePenalty": true,
1812 + "repetitionPenalty": false,
1813 + "logprobs": true,
1814 + "logitBias": false,
1815 + "responseFormat": true,
1816 + "toolChoice": true,
1817 + "parallelToolCalls": false,
1818 + "verbosity": false,
1819 + "webSearchOptions": false,
1820 + "defaults": null
1821 + },
1822 + "pricing": {
1823 + "inputPerMillion": 2,
1824 + "cachedInputPerMillion": 0.25,
1825 + "cacheWritePerMillion": 2.5,
1826 + "cacheWrite1hPerMillion": null,
1827 + "outputPerMillion": 6,
1828 + "internalReasoningPerMillion": null,
1829 + "imageInputPerImage": null,
1830 + "imageOutputPerImage": null,
1831 + "audioInputPerMillion": null,
1832 + "audioOutputPerMillion": null,
1833 + "perRequest": null,
1834 + "webSearchPerRequest": null,
1835 + "longContextThresholdTokens": null,
1836 + "longContextInputPerMillion": null,
1837 + "longContextOutputPerMillion": null,
1838 + "longContextCachedInputPerMillion": null,
1839 + "free": false,
1840 + "unknown": false
1841 + },
1842 + "status": "active",
1843 + "expirationDate": null,
1844 + "knowledgeCutoff": null,
1845 + "aliases": [],
1846 + "huggingFaceId": null,
1847 + "created": 1788469704,
1848 + "notes": "Reasoning mandatory (default xhigh). text+image+video."
1849 + },
1850 + {
1851 + "id": "z-ai/glm-5.3-flash",
1852 + "displayName": "Z.ai: GLM 5.3 Flash",
1853 + "family": "z-ai",
1854 + "canonicalSlug": "z-ai/glm-5.3-flash-20260826",
1855 + "variant": null,
1856 + "baseId": "z-ai/glm-5.3-flash",
1857 + "contextTokens": 1310720,
1858 + "maxOutputTokens": 131072,
1859 + "capabilities": {
1860 + "text": true,
1861 + "vision": true,
1862 + "audioInput": false,
1863 + "audioOutput": false,
1864 + "imageGeneration": false,
1865 + "video": true,
1866 + "reasoning": true,
1867 + "tools": true,
1868 + "structuredOutput": true,
1869 + "strictJsonSchema": true,
1870 + "streaming": true,
1871 + "files": false,
1872 + "webSearch": "via-plugin-or-:online",
1873 + "moderated": false
1874 + },
1875 + "parameters": {
1876 + "temperature": true,
1877 + "topP": true,
1878 + "topK": true,
1879 + "minP": true,
1880 + "topA": false,
1881 + "maxTokens": true,
1882 + "reasoningEffort": [
1883 + "max",
1884 + "high",
1885 + "low"
1886 + ],
1887 + "reasoningMandatory": true,
1888 + "reasoningDefaultEnabled": true,
1889 + "reasoningDefaultEffort": "max",
1890 + "thinkingBudget": false,
1891 + "stop": true,
1892 + "seed": true,
1893 + "frequencyPenalty": true,
1894 + "presencePenalty": true,
1895 + "repetitionPenalty": true,
1896 + "logprobs": true,
1897 + "logitBias": true,
1898 + "responseFormat": true,
1899 + "toolChoice": true,
1900 + "parallelToolCalls": false,
1901 + "verbosity": false,
1902 + "webSearchOptions": false,
1903 + "defaults": {
1904 + "temperature": 1,
1905 + "top_p": 0.95
1906 + }
1907 + },
1908 + "pricing": {
1909 + "inputPerMillion": 0.075,
1910 + "cachedInputPerMillion": 0.015,
1911 + "cacheWritePerMillion": null,
1912 + "cacheWrite1hPerMillion": null,
1913 + "outputPerMillion": 0.25,
1914 + "internalReasoningPerMillion": null,
1915 + "imageInputPerImage": null,
1916 + "imageOutputPerImage": null,
1917 + "audioInputPerMillion": null,
1918 + "audioOutputPerMillion": null,
1919 + "perRequest": null,
1920 + "webSearchPerRequest": null,
1921 + "longContextThresholdTokens": null,
1922 + "longContextInputPerMillion": null,
1923 + "longContextOutputPerMillion": null,
1924 + "longContextCachedInputPerMillion": null,
1925 + "free": false,
1926 + "unknown": false
1927 + },
1928 + "status": "active",
1929 + "expirationDate": null,
1930 + "knowledgeCutoff": null,
1931 + "aliases": [
1932 + "~z-ai/glm-flash-latest"
1933 + ],
1934 + "huggingFaceId": "zai-org/GLM-5.3-Flash",
1935 + "created": 1787752741,
1936 + "notes": "Very cheap; reasoning mandatory; expiration_date '2098-12-31' (placeholder — treat dates ≥ 2090 as 'none'). Target of `~z-ai/glm-flash-latest`. default_parameters {temperature:1, top_p:0.95}."
1937 + },
1938 + {
1939 + "id": "mistralai/mistral-medium-3-5",
1940 + "displayName": "Mistral: Mistral Medium 3.5",
1941 + "family": "mistralai",
1942 + "canonicalSlug": "mistralai/mistral-medium-3.5-20260430",
1943 + "variant": null,
1944 + "baseId": "mistralai/mistral-medium-3-5",
1945 + "contextTokens": 262144,
1946 + "maxOutputTokens": 209715,
1947 + "capabilities": {
1948 + "text": true,
1949 + "vision": true,
1950 + "audioInput": false,
1951 + "audioOutput": false,
1952 + "imageGeneration": false,
1953 + "video": false,
1954 + "reasoning": true,
1955 + "tools": true,
1956 + "structuredOutput": true,
1957 + "strictJsonSchema": true,
1958 + "streaming": true,
1959 + "files": true,
1960 + "webSearch": "via-plugin-or-:online",
1961 + "moderated": false
1962 + },
1963 + "parameters": {
1964 + "temperature": true,
1965 + "topP": true,
1966 + "topK": false,
1967 + "minP": false,
1968 + "topA": false,
1969 + "maxTokens": true,
1970 + "reasoningEffort": [
1971 + "high",
1972 + "none"
1973 + ],
1974 + "reasoningMandatory": false,
1975 + "reasoningDefaultEnabled": false,
1976 + "reasoningDefaultEffort": "high",
1977 + "thinkingBudget": false,
1978 + "stop": true,
1979 + "seed": true,
1980 + "frequencyPenalty": true,
1981 + "presencePenalty": true,
1982 + "repetitionPenalty": false,
1983 + "logprobs": false,
1984 + "logitBias": false,
1985 + "responseFormat": true,
1986 + "toolChoice": true,
1987 + "parallelToolCalls": false,
1988 + "verbosity": false,
1989 + "webSearchOptions": false,
1990 + "defaults": {
1991 + "temperature": null,
1992 + "top_p": null,
1993 + "top_k": null,
1994 + "frequency_penalty": null,
1995 + "presence_penalty": null,
1996 + "repetition_penalty": null
1997 + }
1998 + },
1999 + "pricing": {
2000 + "inputPerMillion": 1.5,
2001 + "cachedInputPerMillion": null,
2002 + "cacheWritePerMillion": null,
2003 + "cacheWrite1hPerMillion": null,
2004 + "outputPerMillion": 7.5,
2005 + "internalReasoningPerMillion": null,
2006 + "imageInputPerImage": null,
2007 + "imageOutputPerImage": null,
2008 + "audioInputPerMillion": null,
2009 + "audioOutputPerMillion": null,
2010 + "perRequest": null,
2011 + "webSearchPerRequest": null,
2012 + "longContextThresholdTokens": null,
2013 + "longContextInputPerMillion": null,
2014 + "longContextOutputPerMillion": null,
2015 + "longContextCachedInputPerMillion": null,
2016 + "free": false,
2017 + "unknown": false
2018 + },
2019 + "status": "active",
2020 + "expirationDate": null,
2021 + "knowledgeCutoff": null,
2022 + "aliases": [],
2023 + "huggingFaceId": null,
2024 + "created": 1777570439,
2025 + "notes": "Reasoning optional with efforts ['high','none'] — a model whose only effort levels are on/off."
2026 + },
2027 + {
2028 + "id": "meta-llama/llama-4-maverick",
2029 + "displayName": "Meta: Llama 4 Maverick",
2030 + "family": "meta-llama",
2031 + "canonicalSlug": "meta-llama/llama-4-maverick-17b-128e-instruct",
2032 + "variant": null,
2033 + "baseId": "meta-llama/llama-4-maverick",
2034 + "contextTokens": 1048576,
2035 + "maxOutputTokens": 115200,
2036 + "capabilities": {
2037 + "text": true,
2038 + "vision": true,
2039 + "audioInput": false,
2040 + "audioOutput": false,
2041 + "imageGeneration": false,
2042 + "video": false,
2043 + "reasoning": false,
2044 + "tools": true,
2045 + "structuredOutput": true,
2046 + "strictJsonSchema": true,
2047 + "streaming": true,
2048 + "files": false,
2049 + "webSearch": "via-plugin-or-:online",
2050 + "moderated": false
2051 + },
2052 + "parameters": {
2053 + "temperature": true,
2054 + "topP": true,
2055 + "topK": true,
2056 + "minP": true,
2057 + "topA": false,
2058 + "maxTokens": true,
2059 + "reasoningEffort": false,
2060 + "reasoningMandatory": false,
2061 + "reasoningDefaultEnabled": false,
2062 + "reasoningDefaultEffort": null,
2063 + "thinkingBudget": false,
2064 + "stop": true,
2065 + "seed": true,
2066 + "frequencyPenalty": true,
2067 + "presencePenalty": true,
2068 + "repetitionPenalty": true,
2069 + "logprobs": true,
2070 + "logitBias": true,
2071 + "responseFormat": true,
2072 + "toolChoice": true,
2073 + "parallelToolCalls": false,
2074 + "verbosity": false,
2075 + "webSearchOptions": false,
2076 + "defaults": null
2077 + },
2078 + "pricing": {
2079 + "inputPerMillion": 0.2,
2080 + "cachedInputPerMillion": null,
2081 + "cacheWritePerMillion": null,
2082 + "cacheWrite1hPerMillion": null,
2083 + "outputPerMillion": 0.696,
2084 + "internalReasoningPerMillion": null,
2085 + "imageInputPerImage": null,
2086 + "imageOutputPerImage": null,
2087 + "audioInputPerMillion": null,
2088 + "audioOutputPerMillion": null,
2089 + "perRequest": null,
2090 + "webSearchPerRequest": null,
2091 + "longContextThresholdTokens": null,
2092 + "longContextInputPerMillion": null,
2093 + "longContextOutputPerMillion": null,
2094 + "longContextCachedInputPerMillion": null,
2095 + "free": false,
2096 + "unknown": false
2097 + },
2098 + "status": "active",
2099 + "expirationDate": null,
2100 + "knowledgeCutoff": "2024-08-31",
2101 + "aliases": [],
2102 + "huggingFaceId": "meta-llama/Llama-4-Maverick-17B-128E-Instruct",
2103 + "created": 1743881822,
2104 + "notes": "No `reasoning` field at all → non-reasoning. Many third-party providers; instruct_type null."
2105 + },
2106 + {
2107 + "id": "perplexity/sonar-pro",
2108 + "displayName": "Perplexity: Sonar Pro",
2109 + "family": "perplexity",
2110 + "canonicalSlug": "perplexity/sonar-pro",
2111 + "variant": null,
2112 + "baseId": "perplexity/sonar-pro",
2113 + "contextTokens": 200000,
2114 + "maxOutputTokens": 8000,
2115 + "capabilities": {
2116 + "text": true,
2117 + "vision": true,
2118 + "audioInput": false,
2119 + "audioOutput": false,
2120 + "imageGeneration": false,
2121 + "video": false,
2122 + "reasoning": false,
2123 + "tools": false,
2124 + "structuredOutput": false,
2125 + "strictJsonSchema": false,
2126 + "streaming": true,
2127 + "files": false,
2128 + "webSearch": "via-plugin-or-:online",
2129 + "moderated": false
2130 + },
2131 + "parameters": {
2132 + "temperature": true,
2133 + "topP": true,
2134 + "topK": true,
2135 + "minP": false,
2136 + "topA": false,
2137 + "maxTokens": true,
2138 + "reasoningEffort": false,
2139 + "reasoningMandatory": false,
2140 + "reasoningDefaultEnabled": false,
2141 + "reasoningDefaultEffort": null,
2142 + "thinkingBudget": false,
2143 + "stop": false,
2144 + "seed": false,
2145 + "frequencyPenalty": true,
2146 + "presencePenalty": true,
2147 + "repetitionPenalty": false,
2148 + "logprobs": false,
2149 + "logitBias": false,
2150 + "responseFormat": false,
2151 + "toolChoice": false,
2152 + "parallelToolCalls": false,
2153 + "verbosity": false,
2154 + "webSearchOptions": true,
2155 + "defaults": null
2156 + },
2157 + "pricing": {
2158 + "inputPerMillion": 3,
2159 + "cachedInputPerMillion": null,
2160 + "cacheWritePerMillion": null,
2161 + "cacheWrite1hPerMillion": null,
2162 + "outputPerMillion": 15,
2163 + "internalReasoningPerMillion": null,
2164 + "imageInputPerImage": null,
2165 + "imageOutputPerImage": null,
2166 + "audioInputPerMillion": null,
2167 + "audioOutputPerMillion": null,
2168 + "perRequest": null,
2169 + "webSearchPerRequest": 0.005,
2170 + "longContextThresholdTokens": null,
2171 + "longContextInputPerMillion": null,
2172 + "longContextOutputPerMillion": null,
2173 + "longContextCachedInputPerMillion": null,
2174 + "free": false,
2175 + "unknown": false
2176 + },
2177 + "status": "active",
2178 + "expirationDate": null,
2179 + "knowledgeCutoff": null,
2180 + "aliases": [],
2181 + "huggingFaceId": "",
2182 + "created": 1741312423,
2183 + "notes": "Built-in native web search (answers include citations); max_completion_tokens 8000; no reasoning field."
2184 + },
2185 + {
2186 + "id": "minimax/minimax-m3",
2187 + "displayName": "MiniMax: MiniMax M3",
2188 + "family": "minimax",
2189 + "canonicalSlug": "minimax/minimax-m3-20260531",
2190 + "variant": null,
2191 + "baseId": "minimax/minimax-m3",
2192 + "contextTokens": 1048576,
2193 + "maxOutputTokens": 512000,
2194 + "capabilities": {
2195 + "text": true,
2196 + "vision": true,
2197 + "audioInput": false,
2198 + "audioOutput": false,
2199 + "imageGeneration": false,
2200 + "video": true,
2201 + "reasoning": true,
2202 + "tools": true,
2203 + "structuredOutput": true,
2204 + "strictJsonSchema": true,
2205 + "streaming": true,
2206 + "files": false,
2207 + "webSearch": "via-plugin-or-:online",
2208 + "moderated": false
2209 + },
2210 + "parameters": {
2211 + "temperature": true,
2212 + "topP": true,
2213 + "topK": true,
2214 + "minP": true,
2215 + "topA": false,
2216 + "maxTokens": true,
2217 + "reasoningEffort": "toggle-only (reasoning.enabled / reasoning.max_tokens)",
2218 + "reasoningMandatory": false,
2219 + "reasoningDefaultEnabled": false,
2220 + "reasoningDefaultEffort": null,
2221 + "thinkingBudget": false,
2222 + "stop": true,
2223 + "seed": true,
2224 + "frequencyPenalty": true,
2225 + "presencePenalty": true,
2226 + "repetitionPenalty": true,
2227 + "logprobs": true,
2228 + "logitBias": true,
2229 + "responseFormat": true,
2230 + "toolChoice": true,
2231 + "parallelToolCalls": false,
2232 + "verbosity": false,
2233 + "webSearchOptions": false,
2234 + "defaults": {
2235 + "temperature": 1,
2236 + "top_p": 0.95,
2237 + "top_k": null,
2238 + "frequency_penalty": null,
2239 + "presence_penalty": null,
2240 + "repetition_penalty": null
2241 + }
2242 + },
2243 + "pricing": {
2244 + "inputPerMillion": 0.3,
2245 + "cachedInputPerMillion": 0.06,
2246 + "cacheWritePerMillion": null,
2247 + "cacheWrite1hPerMillion": null,
2248 + "outputPerMillion": 1.2,
2249 + "internalReasoningPerMillion": null,
2250 + "imageInputPerImage": null,
2251 + "imageOutputPerImage": null,
2252 + "audioInputPerMillion": null,
2253 + "audioOutputPerMillion": null,
2254 + "perRequest": null,
2255 + "webSearchPerRequest": null,
2256 + "longContextThresholdTokens": null,
2257 + "longContextInputPerMillion": null,
2258 + "longContextOutputPerMillion": null,
2259 + "longContextCachedInputPerMillion": null,
2260 + "free": false,
2261 + "unknown": false
2262 + },
2263 + "status": "active",
2264 + "expirationDate": null,
2265 + "knowledgeCutoff": null,
2266 + "aliases": [],
2267 + "huggingFaceId": "MiniMaxAI/Minimax-M3",
2268 + "created": 1780245374,
2269 + "notes": "Cheap 1M-context reasoning model (mandatory false, no efforts). text+image+video."
2270 + },
2271 + {
2272 + "id": "nvidia/nemotron-3-super-120b-a12b:free",
2273 + "displayName": "NVIDIA: Nemotron 3 Super (free)",
2274 + "family": "nvidia",
2275 + "canonicalSlug": "nvidia/nemotron-3-super-120b-a12b-20230311",
2276 + "variant": "free",
2277 + "baseId": "nvidia/nemotron-3-super-120b-a12b",
2278 + "contextTokens": 262144,
2279 + "maxOutputTokens": 235929,
2280 + "capabilities": {
2281 + "text": true,
2282 + "vision": false,
2283 + "audioInput": false,
2284 + "audioOutput": false,
2285 + "imageGeneration": false,
2286 + "video": false,
2287 + "reasoning": true,
2288 + "tools": true,
2289 + "structuredOutput": true,
2290 + "strictJsonSchema": true,
2291 + "streaming": true,
2292 + "files": false,
2293 + "webSearch": "via-plugin-or-:online",
2294 + "moderated": false
2295 + },
2296 + "parameters": {
2297 + "temperature": true,
2298 + "topP": true,
2299 + "topK": false,
2300 + "minP": false,
2301 + "topA": false,
2302 + "maxTokens": true,
2303 + "reasoningEffort": [
2304 + "medium",
2305 + "low"
2306 + ],
2307 + "reasoningMandatory": false,
2308 + "reasoningDefaultEnabled": true,
2309 + "reasoningDefaultEffort": "medium",
2310 + "thinkingBudget": true,
2311 + "stop": false,
2312 + "seed": true,
2313 + "frequencyPenalty": false,
2314 + "presencePenalty": false,
2315 + "repetitionPenalty": false,
2316 + "logprobs": false,
2317 + "logitBias": false,
2318 + "responseFormat": true,
2319 + "toolChoice": true,
2320 + "parallelToolCalls": false,
2321 + "verbosity": false,
2322 + "webSearchOptions": false,
2323 + "defaults": {
2324 + "temperature": 1,
2325 + "top_p": 0.95,
2326 + "top_k": null,
2327 + "frequency_penalty": null,
2328 + "presence_penalty": null,
2329 + "repetition_penalty": null
2330 + }
2331 + },
2332 + "pricing": {
2333 + "inputPerMillion": 0,
2334 + "cachedInputPerMillion": null,
2335 + "cacheWritePerMillion": null,
2336 + "cacheWrite1hPerMillion": null,
2337 + "outputPerMillion": 0,
2338 + "internalReasoningPerMillion": null,
2339 + "imageInputPerImage": null,
2340 + "imageOutputPerImage": null,
2341 + "audioInputPerMillion": null,
2342 + "audioOutputPerMillion": null,
2343 + "perRequest": null,
2344 + "webSearchPerRequest": null,
2345 + "longContextThresholdTokens": null,
2346 + "longContextInputPerMillion": null,
2347 + "longContextOutputPerMillion": null,
2348 + "longContextCachedInputPerMillion": null,
2349 + "free": true,
2350 + "unknown": false
2351 + },
2352 + "status": "free",
2353 + "expirationDate": null,
2354 + "knowledgeCutoff": null,
2355 + "aliases": [],
2356 + "huggingFaceId": "nvidia/NVIDIA-Nemotron-3-Super-120B-A12B-FP8",
2357 + "created": 1773245239,
2358 + "notes": "FREE variant (pricing.prompt '0'): 20 req/min and 50 req/day (<$10 purchased) or 1000/day (≥$10) — docs/limits. Providers may train on free-tier prompts unless data_collection: 'deny'. Show as free but expect 429s."
2359 + },
2360 + {
2361 + "id": "openai/gpt-audio-mini",
2362 + "displayName": "OpenAI: GPT Audio Mini",
2363 + "family": "openai",
2364 + "canonicalSlug": "openai/gpt-audio-mini",
2365 + "variant": null,
2366 + "baseId": "openai/gpt-audio-mini",
2367 + "contextTokens": 128000,
2368 + "maxOutputTokens": 16384,
2369 + "capabilities": {
2370 + "text": true,
2371 + "vision": false,
2372 + "audioInput": true,
2373 + "audioOutput": true,
2374 + "imageGeneration": false,
2375 + "video": false,
2376 + "reasoning": false,
2377 + "tools": true,
2378 + "structuredOutput": true,
2379 + "strictJsonSchema": true,
2380 + "streaming": true,
2381 + "files": false,
2382 + "webSearch": "via-plugin-or-:online",
2383 + "moderated": true
2384 + },
2385 + "parameters": {
2386 + "temperature": true,
2387 + "topP": true,
2388 + "topK": false,
2389 + "minP": false,
2390 + "topA": false,
2391 + "maxTokens": true,
2392 + "reasoningEffort": false,
2393 + "reasoningMandatory": false,
2394 + "reasoningDefaultEnabled": false,
2395 + "reasoningDefaultEffort": null,
2396 + "thinkingBudget": false,
2397 + "stop": true,
2398 + "seed": true,
2399 + "frequencyPenalty": true,
2400 + "presencePenalty": true,
2401 + "repetitionPenalty": false,
2402 + "logprobs": true,
2403 + "logitBias": true,
2404 + "responseFormat": true,
2405 + "toolChoice": true,
2406 + "parallelToolCalls": false,
2407 + "verbosity": false,
2408 + "webSearchOptions": false,
2409 + "defaults": {
2410 + "temperature": null,
2411 + "top_p": null,
2412 + "frequency_penalty": null
2413 + }
2414 + },
2415 + "pricing": {
2416 + "inputPerMillion": 0.6,
2417 + "cachedInputPerMillion": null,
2418 + "cacheWritePerMillion": null,
2419 + "cacheWrite1hPerMillion": null,
2420 + "outputPerMillion": 2.4,
2421 + "internalReasoningPerMillion": null,
2422 + "imageInputPerImage": null,
2423 + "imageOutputPerImage": null,
2424 + "audioInputPerMillion": 0.6,
2425 + "audioOutputPerMillion": 2.4,
2426 + "perRequest": null,
2427 + "webSearchPerRequest": null,
2428 + "longContextThresholdTokens": null,
2429 + "longContextInputPerMillion": null,
2430 + "longContextOutputPerMillion": null,
2431 + "longContextCachedInputPerMillion": null,
2432 + "free": false,
2433 + "unknown": false
2434 + },
2435 + "status": "active",
2436 + "expirationDate": null,
2437 + "knowledgeCutoff": null,
2438 + "aliases": [],
2439 + "huggingFaceId": "",
2440 + "created": 1768859419,
2441 + "notes": "Audio in/out model (output_modalities text+audio; pricing.audio / audio_output per token). Needs `modalities: ['text','audio']` + audio config — out of scope for the first adapter; hide or flag."
2442 + },
2443 + {
2444 + "id": "openrouter/auto",
2445 + "displayName": "Auto Router",
2446 + "family": "openrouter",
2447 + "canonicalSlug": "openrouter/auto",
2448 + "variant": null,
2449 + "baseId": "openrouter/auto",
2450 + "contextTokens": 2000000,
2451 + "maxOutputTokens": null,
2452 + "capabilities": {
2453 + "text": true,
2454 + "vision": true,
2455 + "audioInput": true,
2456 + "audioOutput": false,
2457 + "imageGeneration": true,
2458 + "video": true,
2459 + "reasoning": false,
2460 + "tools": true,
2461 + "structuredOutput": true,
2462 + "strictJsonSchema": true,
2463 + "streaming": true,
2464 + "files": true,
2465 + "webSearch": "via-plugin-or-:online",
2466 + "moderated": false
2467 + },
2468 + "parameters": {
2469 + "temperature": true,
2470 + "topP": true,
2471 + "topK": true,
2472 + "minP": true,
2473 + "topA": true,
2474 + "maxTokens": true,
2475 + "reasoningEffort": false,
2476 + "reasoningMandatory": false,
2477 + "reasoningDefaultEnabled": false,
2478 + "reasoningDefaultEffort": null,
2479 + "thinkingBudget": false,
2480 + "stop": true,
2481 + "seed": true,
2482 + "frequencyPenalty": true,
2483 + "presencePenalty": true,
2484 + "repetitionPenalty": true,
2485 + "logprobs": true,
2486 + "logitBias": true,
2487 + "responseFormat": true,
2488 + "toolChoice": true,
2489 + "parallelToolCalls": false,
2490 + "verbosity": false,
2491 + "webSearchOptions": true,
2492 + "defaults": null
2493 + },
2494 + "pricing": {
2495 + "inputPerMillion": null,
2496 + "cachedInputPerMillion": null,
2497 + "cacheWritePerMillion": null,
2498 + "cacheWrite1hPerMillion": null,
2499 + "outputPerMillion": null,
2500 + "internalReasoningPerMillion": null,
2501 + "imageInputPerImage": null,
2502 + "imageOutputPerImage": null,
2503 + "audioInputPerMillion": null,
2504 + "audioOutputPerMillion": null,
2505 + "perRequest": null,
2506 + "webSearchPerRequest": null,
2507 + "longContextThresholdTokens": null,
2508 + "longContextInputPerMillion": null,
2509 + "longContextOutputPerMillion": null,
2510 + "longContextCachedInputPerMillion": null,
2511 + "free": false,
2512 + "unknown": true
2513 + },
2514 + "status": "meta",
2515 + "expirationDate": null,
2516 + "knowledgeCutoff": null,
2517 + "aliases": [],
2518 + "huggingFaceId": null,
2519 + "created": 1699401600,
2520 + "notes": "Meta-model: pricing '-1' (unknown up front; billed at the routed model's rate, no fee). response.model tells the chosen model (probed: routed a 'say hi' prompt to deepseek/deepseek-v4-flash-0731). context_length 2M and output_modalities text+image are the union of candidates. supported_parameters is the union too — keep sampling params off unless the user opts in. `:online` is defined as openrouter/auto + web plugin."
2521 + }
2522 +]
modified docs/provider-test-matrix.md +26 −26
@@ -2,33 +2,33 @@
2 2
3 3 Generated by `pnpm providers:matrix` against the REAL provider APIs on 2026-09-08. A cell is ✅ only when the live request succeeded and the assertion passed. “—” = not run (no key).
4 4
5 Test models: openai: gpt-5.4-mini / reasoning gpt-5.4-mini; anthropic: claude-haiku-4-5 / reasoning claude-sonnet-5; gemini: gemini-3.5-flash-lite / reasoning gemini-3.5-flash-lite; xai: grok-4.20-0309-non-reasoning / reasoning grok-4.3.
5 +Test models: openai: gpt-5.4-mini / reasoning gpt-5.4-mini; anthropic: claude-haiku-4-5 / reasoning claude-sonnet-5; gemini: gemini-3.5-flash-lite / reasoning gemini-3.5-flash-lite; xai: grok-4.20-0309-non-reasoning / reasoning grok-4.3; mistral: mistral-small-latest / reasoning magistral-medium-latest; deepseek: deepseek-v4-flash / reasoning deepseek-v4-flash; kimi: kimi-k2.6 / reasoning kimi-k3; openrouter: openai/gpt-5.4-nano / reasoning openai/gpt-5.4-nano; cerebras: gemma-4-31b / reasoning gpt-oss-120b.
6 6
7 | Capability | OpenAI | Anthropic | Gemini | xAI |
8 | --- | :-: | :-: | :-: | :-: |
9 | Auth | ✅ | ✅ | ✅ | ✅ |
10 | Model list | ✅ | ✅ | ✅ | ✅ |
11 | Text | ✅ | ✅ | ✅ | ✅ |
12 | Streaming | ✅ | ✅ | ✅ | ✅ |
13 | System | ✅ | ✅ | ✅ | ✅ |
14 | Vision | ✅ | ✅ | ✅ | ✅ |
15 | Tools | ✅ | ✅ | ✅ | ✅ |
16 | Structured output | ✅ | ✅ | ✅ | ✅ |
17 | Reasoning | ✅ | ✅ | ✅ | ✅ |
18 | Token usage | ✅ | ✅ | ✅ | ✅ |
19 | Invalid key | ✅ | ✅ | ✅ | ✅ |
20 | Error normalization | ✅ | ✅ | ✅ | ✅ |
21 | Long response | ✅ | ✅ | ✅ | ✅ |
7 +| Capability | openai | anthropic | gemini | xai | mistral | deepseek | kimi | openrouter | cerebras |
8 +| --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
9 +| Auth | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
10 +| Model list | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
11 +| Text | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
12 +| Streaming | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
13 +| System | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
14 +| Vision | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
15 +| Tools | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
16 +| Structured output | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
17 +| Reasoning | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
18 +| Token usage | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
19 +| Invalid key | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
20 +| Error normalization | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
21 +| Long response | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
22 22
23 23 ## Notes
24 24
25 - gemini: models: 18 (gemini-2.5-flash, gemini-2.5-pro, gemma-4-26b-a4b-it, gemma-4-31b-it, gemini-flash-latest…)
26 - gemini: text(gemini-3.5-flash-lite): "OK" deltas=1 usage={"inputTokens":6,"outputTokens":1,"totalTokens":7} finish=stop
27 - gemini: system: "Arr, hello there, me hearty!"
28 - gemini: vision: "Red"
29 - gemini: tools: [{"n":"calculator","a":{"expression":"1234 * 5678"}}] finish=tool-calls
30 - gemini: structured: { "city": "Ottawa", "country": "Canada" }
31 - gemini: reasoning(gemini-3.5-flash-lite): text="16" reasoningChars=2115 reasoningTokens=3907
32 - gemini: invalid key → INVALID_API_KEY (400)
33 - gemini: unknown model → MODEL_NOT_FOUND: { "error": { "code": 404, "message": "models/definitely-not-a-model-xyz is not f
34 - gemini: long: 471 words, 25 deltas
25 +- openrouter: models: 323 (openai/gpt-6-astra, openai/gpt-6-astra-pro, inclusionai/ling-3.0-flash-sante:free, qwen/qwen3.8-max-0902, meta/muse-spark-1.3…)
26 +- openrouter: text(openai/gpt-5.4-nano): "OK" deltas=1 usage={"inputTokens":11,"outputTokens":5,"cachedInputTokens":0,"reasoningTokens":0,"totalTokens":16} finish=stop
27 +- openrouter: system: "Arr! Hello there, matey, cheers!"
28 +- openrouter: vision: "Red"
29 +- openrouter: tools: [{"n":"calculator","a":{"expression":"1234*5678"}}] finish=tool-calls
30 +- openrouter: structured: {"city":"Ottawa","country":"Canada"}
31 +- openrouter: reasoning(openai/gpt-5.4-nano): text="16" reasoningChars=838 reasoningTokens=565
32 +- openrouter: invalid key → INVALID_API_KEY (401)
33 +- openrouter: unknown model → INVALID_PARAMETER: definitely-not-a-model-xyz is not a valid model ID
34 +- openrouter: long: 468 words, 541 deltas
modified e2e/full-flow.spec.ts +9 −2
@@ -54,12 +54,17 @@ test.describe.serial("PolyLLM full flow", () => {
54 54 await login(page, email, password);
55 55 await saveSession(page);
56 56 await page.goto("/app/settings/providers");
57 const names: Record<string, RegExp> = { anthropic: /^Anthropic$/, openai: /^OpenAI$/, xai: /^xAI$/, gemini: /^Google Gemini$/ };
57 + const names: Record<string, RegExp> = { anthropic: /^Anthropic$/, openai: /^OpenAI$/, xai: /^xAI$/, gemini: /^Google Gemini$/, mistral: /^Mistral AI$/, deepseek: /^DeepSeek$/, kimi: /^Kimi/, openrouter: /^OpenRouter$/, cerebras: /^Cerebras$/ };
58 58 for (const [provider, envName] of [
59 59 ["anthropic", "ANTHROPIC_API_KEY"],
60 60 ["openai", "OPENAI_API_KEY"],
61 61 ["xai", "XAI_API_KEY"],
62 62 ["gemini", "GOOGLE_GEMINI_API_KEY"],
63 + ["mistral", "MISTRAL_API_KEY"],
64 + ["deepseek", "DEEPSEEK_API_KEY"],
65 + ["kimi", "KIMI_API_KEY"],
66 + ["openrouter", "OPENROUTER_API_KEY"],
67 + ["cerebras", "CEREBRAS_API_KEY"],
63 68 ] as const) {
64 69 const key = readEnv(envName);
65 70 test.skip(!key, `${envName} missing`);
@@ -73,7 +78,7 @@ test.describe.serial("PolyLLM full flow", () => {
73 78 // never in the DOM
74 79 const html = await page.content();
75 80 expect(html).not.toContain(readEnv("ANTHROPIC_API_KEY")!.slice(10, 30));
76 await expect.poll(() => sql(`select count(*) from provider_connections pc join users u on u.id=pc.user_id where u.email='${email}' and pc.status='valid'`)).toBe("4");
81 + await expect.poll(() => sql(`select count(*) from provider_connections pc join users u on u.id=pc.user_id where u.email='${email}' and pc.status='valid'`)).toBe("9");
77 82 // keys are encrypted at rest
78 83 const stored = sql(`select encrypted_key from provider_connections pc join users u on u.id=pc.user_id where u.email='${email}' and provider='anthropic'`);
79 84 expect(stored.startsWith("v1.")).toBe(true);
@@ -118,6 +123,8 @@ test.describe.serial("PolyLLM full flow", () => {
118 123 await restoreSession(page);
119 124 await page.goto("/app/chat");
120 125 await page.getByRole("button", { name: /select model/i }).click();
126 + // Pin the native OpenAI catalog (OpenRouter also lists openai/* models with different parameter sheets).
127 + await page.getByRole("dialog").getByRole("button", { name: /^OpenAI$/ }).click();
121 128 await page.getByLabel(/search models/i).fill("gpt-5.5");
122 129 await page.getByRole("option").first().click();
123 130 await page.getByRole("button", { name: /model configuration/i }).click();
modified qa/.e2e-session.json +4 −4
@@ -2,20 +2,20 @@
2 2 "cookies": [
3 3 {
4 4 "name": "polyllm.session_token",
5 "value": "OW8aTEihDFQdZszuaP7ZOTlBJzCGcL4X.fh4lrQahmOboChg8zxS9lYyIExLxJNlvkbeFUyKcBp8%3D",
5 + "value": "phK8cA3C9MPwvAPdhy3XoVvFCa1IBoBP.VlG8WlzEnbIlUawskRzIaIhpdLak%2BdYQTP4w0UJjAn8%3D",
6 6 "domain": "localhost",
7 7 "path": "/",
8 "expires": 1791439031.995432,
8 + "expires": 1791442457.664571,
9 9 "httpOnly": true,
10 10 "secure": false,
11 11 "sameSite": "Lax"
12 12 },
13 13 {
14 14 "name": "polyllm.session_data",
15 "value": "eyJzZXNzaW9uIjp7InNlc3Npb24iOnsiZXhwaXJlc0F0IjoiMjAyNi0xMC0wOFQwNTo1NzoxMS45OTFaIiwidG9rZW4iOiJPVzhhVEVpaERGUWRac3p1YVA3Wk9UbEJKekNHY0w0WCIsImNyZWF0ZWRBdCI6IjIwMjYtMDktMDhUMDU6NTc6MTEuOTkxWiIsInVwZGF0ZWRBdCI6IjIwMjYtMDktMDhUMDU6NTc6MTEuOTkxWiIsImlwQWRkcmVzcyI6IjAwMDA6MDAwMDowMDAwOjAwMDA6MDAwMDowMDAwOjAwMDA6MDAwMCIsInVzZXJBZ2VudCI6Ik1vemlsbGEvNS4wIChXaW5kb3dzIE5UIDEwLjA7IFdpbjY0OyB4NjQpIEFwcGxlV2ViS2l0LzUzNy4zNiAoS0hUTUwsIGxpa2UgR2Vja28pIENocm9tZS8xNTMuMC44MDEwLjEyIFNhZmFyaS81MzcuMzYiLCJ1c2VySWQiOiJESjRuTjNieUttVkJCZG5DcEw2Z0tYbU52Vk5jU0s2NiIsImlkIjoiUkhCaDRtdzU3YnN3NEZObEwxOEczR2pQNGhrb2MyV3MifSwidXNlciI6eyJuYW1lIjoiRTJFIFRlc3RlciIsImVtYWlsIjoiZTJlKzE3ODg4NDcwMjkzNjJAcG9seWxsbS50ZXN0IiwiZW1haWxWZXJpZmllZCI6dHJ1ZSwiaW1hZ2UiOm51bGwsImNyZWF0ZWRBdCI6IjIwMjYtMDktMDhUMDU6NTc6MTAuMzI5WiIsInVwZGF0ZWRBdCI6IjIwMjYtMDktMDhUMDU6NTc6MTEuMDAwWiIsInJvbGUiOiJ1c2VyIiwib25ib2FyZGluZ0NvbXBsZXRlZEF0IjpudWxsLCJpZCI6IkRKNG5OM2J5S21WQkJkbkNwTDZnS1htTnZWTmNTSzY2In0sInVwZGF0ZWRBdCI6MTc4ODg0NzAzMTk5NCwidmVyc2lvbiI6IjEifSwiZXhwaXJlc0F0IjoxNzg4ODQ3MzMxOTk0LCJzaWduYXR1cmUiOiJ2R2t0b3pvODhRU05YaHNHcjFRUThKQ3VpdVhDaWQ1bUVPdlpRSHNqTUdBIn0",
15 + "value": "eyJzZXNzaW9uIjp7InNlc3Npb24iOnsiZXhwaXJlc0F0IjoiMjAyNi0xMC0wOFQwNjo1NDoxNy42NjFaIiwidG9rZW4iOiJwaEs4Y0EzQzlNUHd2QVBkaHkzWG9WdkZDYTFJQm9CUCIsImNyZWF0ZWRBdCI6IjIwMjYtMDktMDhUMDY6NTQ6MTcuNjYxWiIsInVwZGF0ZWRBdCI6IjIwMjYtMDktMDhUMDY6NTQ6MTcuNjYxWiIsImlwQWRkcmVzcyI6IjAwMDA6MDAwMDowMDAwOjAwMDA6MDAwMDowMDAwOjAwMDA6MDAwMCIsInVzZXJBZ2VudCI6Ik1vemlsbGEvNS4wIChXaW5kb3dzIE5UIDEwLjA7IFdpbjY0OyB4NjQpIEFwcGxlV2ViS2l0LzUzNy4zNiAoS0hUTUwsIGxpa2UgR2Vja28pIENocm9tZS8xNTMuMC44MDEwLjEyIFNhZmFyaS81MzcuMzYiLCJ1c2VySWQiOiJMRlNiN21LWkZJanJVdFpGZzVPU2RFVmt4TFg2VWVvUCIsImlkIjoibzh5TjA4VnpYeXFTZHkwTjJMNU5HSlN1U2poajgyNWsifSwidXNlciI6eyJuYW1lIjoiRTJFIFRlc3RlciIsImVtYWlsIjoiZTJlKzE3ODg4NTA0NTUwNDBAcG9seWxsbS50ZXN0IiwiZW1haWxWZXJpZmllZCI6dHJ1ZSwiaW1hZ2UiOm51bGwsImNyZWF0ZWRBdCI6IjIwMjYtMDktMDhUMDY6NTQ6MTUuOTk5WiIsInVwZGF0ZWRBdCI6IjIwMjYtMDktMDhUMDY6NTQ6MTYuNzE2WiIsInJvbGUiOiJ1c2VyIiwib25ib2FyZGluZ0NvbXBsZXRlZEF0IjpudWxsLCJpZCI6IkxGU2I3bUtaRklqclV0WkZnNU9TZEVWa3hMWDZVZW9QIn0sInVwZGF0ZWRBdCI6MTc4ODg1MDQ1NzY2MywidmVyc2lvbiI6IjEifSwiZXhwaXJlc0F0IjoxNzg4ODUwNzU3NjYzLCJzaWduYXR1cmUiOiJNT2ZCUV95NnljMmlZZzh4T2ZhSzhkdlF5RjFGVTlqQ2xFVHlNeDRucnNVIn0",
16 16 "domain": "localhost",
17 17 "path": "/",
18 "expires": 1788847331.99546,
18 + "expires": 1788850757.664602,
19 19 "httpOnly": true,
20 20 "secure": false,
21 21 "sameSite": "Lax"
added research/cerebras/00-models.ts +12 −0
@@ -0,0 +1,12 @@
1 +import { raw, save, MODELS, chat } from "./lib.ts";
2 +
3 +const out: Record<string, unknown> = {};
4 +out.list = await raw("/models");
5 +for (const m of MODELS) out[`retrieve:${m}`] = await raw(`/models/${m}`);
6 +out["retrieve:unknown"] = await raw(`/models/does-not-exist`);
7 +// one tiny call to see rate-limit headers on inference
8 +out.tinyForHeaders = await chat({ model: "gpt-oss-120b", messages: [{ role: "user", content: "Say hi" }], max_completion_tokens: 20 });
9 +save("00-models.json", out);
10 +console.log(JSON.stringify(out.list, null, 1));
11 +console.log("headers on inference:", (out.tinyForHeaders as any).headers);
12 +console.log("status", (out.tinyForHeaders as any).status);
added research/cerebras/01-chat-stream.ts +75 −0
@@ -0,0 +1,75 @@
1 +import { chat, rawSSE, save, MODELS, short } from "./lib.ts";
2 +
3 +const out: Record<string, unknown> = {};
4 +for (const model of MODELS) {
5 + // (a) tiny non-streaming
6 + const r = await chat({
7 + model,
8 + messages: [{ role: "user", content: "What is 2+2? Answer in one short sentence." }],
9 + max_completion_tokens: 200,
10 + });
11 + const b: any = r.body;
12 + const usage = b?.usage;
13 + const tps = usage && b?.time_info ? Math.round(usage.completion_tokens / b.time_info.completion_time) : null;
14 + out[`${model}:nonstream`] = { ...r, tokensPerSecond: tps };
15 + console.log(model, "nonstream", r.status, r.ms + "ms", "tps=", tps, "\n message keys:", b?.choices?.[0]?.message && Object.keys(b.choices[0].message), "\n content:", short(b?.choices?.[0]?.message?.content, 200), "\n reasoning:", short(b?.choices?.[0]?.message?.reasoning, 120), "\n usage:", JSON.stringify(usage), "\n time_info:", JSON.stringify(b?.time_info), "\n top keys:", b && Object.keys(b));
16 +
17 + // (b) streaming with include_usage
18 + const s = await rawSSE("/chat/completions", {
19 + model,
20 + messages: [{ role: "user", content: "Say hello in French, 5 words max." }],
21 + max_completion_tokens: 200,
22 + stream: true,
23 + stream_options: { include_usage: true },
24 + });
25 + const deltaKeys = new Set<string>();
26 + const finishes: string[] = [];
27 + let content = "";
28 + let reasoning = "";
29 + const withUsage: number[] = [];
30 + const withTimeInfo: number[] = [];
31 + const otherKeys = new Set<string>();
32 + s.events.forEach((e, i) => {
33 + if (e.data === "[DONE]") return;
34 + const d = e.data;
35 + Object.keys(d).forEach((k) => otherKeys.add(k));
36 + if (d.usage) withUsage.push(i);
37 + if (d.time_info) withTimeInfo.push(i);
38 + const c = d.choices?.[0];
39 + if (c?.delta) Object.keys(c.delta).forEach((k) => deltaKeys.add(k));
40 + if (c?.delta?.content) content += c.delta.content;
41 + if (c?.delta?.reasoning) reasoning += c.delta.reasoning;
42 + if (c?.delta?.reasoning_content) reasoning += "[RC]" + c.delta.reasoning_content;
43 + if (c?.finish_reason) finishes.push(`${i}:${c.finish_reason}`);
44 + });
45 + out[`${model}:stream`] = {
46 + status: s.status,
47 + headers: s.headers,
48 + ttfbMs: s.ttfbMs,
49 + totalMs: s.totalMs,
50 + nEvents: s.events.length,
51 + deltaKeys: [...deltaKeys],
52 + topLevelKeys: [...otherKeys],
53 + finishes,
54 + withUsage,
55 + withTimeInfo,
56 + content,
57 + reasoning: short(reasoning, 400),
58 + hasThinkTag: /<think>/.test(content),
59 + first3: s.events.slice(0, 3),
60 + last3: s.events.slice(-3),
61 + error: s.error,
62 + };
63 + console.log(model, "stream", s.status, "events", s.events.length, "ttfb", s.ttfbMs, "deltaKeys", [...deltaKeys], "finishes", finishes, "usageAt", withUsage, "timeInfoAt", withTimeInfo, "\n content:", short(content, 150), "\n reasoning:", short(reasoning, 150), "\n last3:", short(s.events.slice(-3), 900));
64 + // (b2) streaming WITHOUT stream_options: is usage still present?
65 + const s2 = await rawSSE("/chat/completions", {
66 + model,
67 + messages: [{ role: "user", content: "Say hi." }],
68 + max_completion_tokens: 30,
69 + stream: true,
70 + });
71 + const usageIdx = s2.events.map((e, i) => (e.data !== "[DONE]" && e.data?.usage ? i : -1)).filter((i) => i >= 0);
72 + out[`${model}:stream-no-options`] = { status: s2.status, nEvents: s2.events.length, usageIdx, last2: s2.events.slice(-2) };
73 + console.log(model, "stream w/o stream_options: usage at", usageIdx, "of", s2.events.length);
74 +}
75 +save("01-chat-stream.json", out);
added research/cerebras/02-params.ts +135 −0
@@ -0,0 +1,135 @@
1 +import { chat, save, MODELS, short } from "./lib.ts";
2 +
3 +const base = (model: string) => ({
4 + model,
5 + messages: [{ role: "user", content: "Reply with the single word: pong" }],
6 + max_completion_tokens: 40,
7 +});
8 +
9 +const tool = {
10 + type: "function",
11 + function: {
12 + name: "get_weather",
13 + description: "Get weather for a city",
14 + parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"], additionalProperties: false },
15 + },
16 +};
17 +
18 +const variants: Record<string, (m: string) => Record<string, unknown>> = {
19 + temperature_0_5: (m) => ({ ...base(m), temperature: 0.5 }),
20 + temperature_2: (m) => ({ ...base(m), temperature: 2 }),
21 + temperature_2_5: (m) => ({ ...base(m), temperature: 2.5 }),
22 + top_p: (m) => ({ ...base(m), top_p: 0.9 }),
23 + top_k: (m) => ({ ...base(m), top_k: 40 }),
24 + max_tokens: (m) => ({ model: m, messages: base(m).messages, max_tokens: 40 }),
25 + both_max: (m) => ({ ...base(m), max_tokens: 40 }),
26 + min_tokens: (m) => ({ ...base(m), min_completion_tokens: 5 }),
27 + stop: (m) => ({ ...base(m), stop: ["\n", "END"] }),
28 + seed: (m) => ({ ...base(m), seed: 42 }),
29 + frequency_penalty: (m) => ({ ...base(m), frequency_penalty: 0.5 }),
30 + presence_penalty: (m) => ({ ...base(m), presence_penalty: 0.5 }),
31 + logit_bias: (m) => ({ ...base(m), logit_bias: { "1234": 5 } }),
32 + logprobs: (m) => ({ ...base(m), logprobs: true, top_logprobs: 2 }),
33 + n_2: (m) => ({ ...base(m), n: 2 }),
34 + reasoning_effort_none: (m) => ({ ...base(m), reasoning_effort: "none" }),
35 + reasoning_effort_low: (m) => ({ ...base(m), reasoning_effort: "low" }),
36 + reasoning_effort_medium: (m) => ({ ...base(m), reasoning_effort: "medium" }),
37 + reasoning_effort_high: (m) => ({ ...base(m), reasoning_effort: "high" }),
38 + reasoning_effort_minimal: (m) => ({ ...base(m), reasoning_effort: "minimal" }),
39 + disable_reasoning: (m) => ({ ...base(m), disable_reasoning: true }),
40 + reasoning_format_parsed: (m) => ({ ...base(m), reasoning_format: "parsed" }),
41 + reasoning_format_raw: (m) => ({ ...base(m), reasoning_format: "raw" }),
42 + reasoning_format_hidden: (m) => ({ ...base(m), reasoning_format: "hidden" }),
43 + clear_thinking: (m) => ({ ...base(m), clear_thinking: true }),
44 + json_object: (m) => ({
45 + model: m,
46 + messages: [{ role: "user", content: "Return a JSON object with key answer = 4." }],
47 + max_completion_tokens: 60,
48 + response_format: { type: "json_object" },
49 + }),
50 + json_object_stream: (m) => ({
51 + model: m,
52 + messages: [{ role: "user", content: "Return a JSON object with key answer = 4." }],
53 + max_completion_tokens: 60,
54 + response_format: { type: "json_object" },
55 + stream: true,
56 + }),
57 + json_schema_strict: (m) => ({
58 + model: m,
59 + messages: [{ role: "user", content: "Give the sum of 2 and 2." }],
60 + max_completion_tokens: 60,
61 + response_format: {
62 + type: "json_schema",
63 + json_schema: {
64 + name: "sum",
65 + strict: true,
66 + schema: { type: "object", properties: { answer: { type: "integer" } }, required: ["answer"], additionalProperties: false },
67 + },
68 + },
69 + }),
70 + tools_auto: (m) => ({ ...base(m), messages: [{ role: "user", content: "Weather in Montreal?" }], max_completion_tokens: 100, tools: [tool], tool_choice: "auto" }),
71 + tools_required: (m) => ({ ...base(m), messages: [{ role: "user", content: "Weather in Montreal?" }], max_completion_tokens: 100, tools: [tool], tool_choice: "required" }),
72 + tools_named: (m) => ({ ...base(m), messages: [{ role: "user", content: "Weather in Montreal?" }], max_completion_tokens: 100, tools: [tool], tool_choice: { type: "function", function: { name: "get_weather" } } }),
73 + tools_parallel_false: (m) => ({ ...base(m), messages: [{ role: "user", content: "Weather in Montreal and Paris?" }], max_completion_tokens: 100, tools: [tool], parallel_tool_calls: false }),
74 + tools_strict: (m) => ({ ...base(m), messages: [{ role: "user", content: "Weather in Montreal?" }], max_completion_tokens: 100, tools: [{ ...tool, function: { ...tool.function, strict: true } }] }),
75 + tools_plus_json_schema: (m) => ({
76 + ...base(m),
77 + messages: [{ role: "user", content: "Weather in Montreal?" }],
78 + max_completion_tokens: 100,
79 + tools: [tool],
80 + response_format: { type: "json_schema", json_schema: { name: "x", strict: true, schema: { type: "object", properties: { a: { type: "string" } }, required: ["a"], additionalProperties: false } } },
81 + }),
82 + developer_role: (m) => ({ ...base(m), messages: [{ role: "developer", content: "Be terse." }, ...base(m).messages] }),
83 + system_role: (m) => ({ ...base(m), messages: [{ role: "system", content: "Be terse." }, ...base(m).messages] }),
84 + unknown_param: (m) => ({ ...base(m), foo_bar: 1 }),
85 + user_field: (m) => ({ ...base(m), user: "probe-user" }),
86 + service_tier_priority: (m) => ({ ...base(m), service_tier: "priority" }),
87 + prompt_cache_key: (m) => ({ ...base(m), prompt_cache_key: "polyllm-probe" }),
88 + logprobs_alone: (m) => ({ ...base(m), logprobs: true }),
89 + max_completion_tokens_5: (m) => ({ ...base(m), messages: [{ role: "user", content: "Write 3 sentences about Montreal." }], max_completion_tokens: 5 }),
90 +};
91 +
92 +const out: Record<string, Record<string, unknown>> = {};
93 +for (const model of MODELS) {
94 + out[model] = {};
95 + for (const [name, mk] of Object.entries(variants)) {
96 + const payload = mk(model);
97 + let r: any;
98 + if (payload.stream) {
99 + // streaming variant: just record status + first event/body
100 + const res = await fetch("https://api.cerebras.ai/v1/chat/completions", {
101 + method: "POST",
102 + headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.CEREBRAS_API_KEY}` },
103 + body: JSON.stringify(payload),
104 + });
105 + const text = await res.text();
106 + let body: unknown = text;
107 + try {
108 + body = JSON.parse(text);
109 + } catch {}
110 + r = { status: res.status, body: typeof body === "string" ? short(body, 500) : body };
111 + } else {
112 + r = await chat(payload);
113 + }
114 + const b: any = r.body;
115 + const msg = b?.choices?.[0]?.message;
116 + const summary = {
117 + status: r.status,
118 + finish: b?.choices?.[0]?.finish_reason,
119 + content: short(msg?.content, 160),
120 + reasoning: msg?.reasoning !== undefined ? short(msg.reasoning, 100) : undefined,
121 + reasoningLen: typeof msg?.reasoning === "string" ? msg.reasoning.length : undefined,
122 + tool_calls: msg?.tool_calls ? short(msg.tool_calls, 200) : undefined,
123 + usage: b?.usage,
124 + logprobs: b?.choices?.[0]?.logprobs ? short(b.choices[0].logprobs, 200) : undefined,
125 + nChoices: b?.choices?.length,
126 + error: r.status !== 200 ? b : undefined,
127 + system_fingerprint: b?.system_fingerprint,
128 + service_tier: b?.service_tier,
129 + service_tier_used: b?.service_tier_used,
130 + };
131 + out[model][name] = summary;
132 + console.log(`${model} | ${name} | ${r.status} | ${summary.finish ?? ""} | ${short(summary.error ?? summary.content, 200)}${summary.reasoningLen !== undefined ? ` | reasoningLen=${summary.reasoningLen} rt=${b?.usage?.completion_tokens_details?.reasoning_tokens}` : ""}`);
133 + }
134 +}
135 +save("02-params.json", out);
added research/cerebras/03-tools-stream.ts +51 −0
@@ -0,0 +1,51 @@
1 +import { chat, rawSSE, save, MODELS, short } from "./lib.ts";
2 +
3 +const tools = [
4 + {
5 + type: "function",
6 + function: {
7 + name: "get_weather",
8 + description: "Get the current weather for a city",
9 + parameters: { type: "object", properties: { city: { type: "string", description: "City name" } }, required: ["city"], additionalProperties: false },
10 + },
11 + },
12 +];
13 +
14 +const out: Record<string, unknown> = {};
15 +for (const model of MODELS) {
16 + const messages: any[] = [{ role: "user", content: "What's the weather in Montreal right now? Use the tool." }];
17 + const s = await rawSSE("/chat/completions", { model, messages, tools, tool_choice: "auto", max_completion_tokens: 200, stream: true, stream_options: { include_usage: true } });
18 + const tcEvents = s.events.filter((e) => e.data !== "[DONE]" && e.data?.choices?.[0]?.delta?.tool_calls);
19 + const finishes = s.events.filter((e) => e.data !== "[DONE]" && e.data?.choices?.[0]?.finish_reason).map((e) => e.data.choices[0].finish_reason);
20 + // accumulate
21 + const acc: Record<number, { id?: string; name?: string; args: string; type?: string }> = {};
22 + for (const e of tcEvents) {
23 + for (const tc of e.data.choices[0].delta.tool_calls) {
24 + const i = tc.index ?? 0;
25 + acc[i] ??= { args: "" };
26 + if (tc.id) acc[i].id = tc.id;
27 + if (tc.type) acc[i].type = tc.type;
28 + if (tc.function?.name) acc[i].name = tc.function.name;
29 + if (tc.function?.arguments) acc[i].args += tc.function.arguments;
30 + }
31 + }
32 + console.log(model, "stream tools:", s.status, "events", s.events.length, "toolCallChunks", tcEvents.length, "finishes", finishes, "\n accumulated:", JSON.stringify(acc), "\n tc deltas:", short(tcEvents.map((e) => e.data.choices[0].delta), 800), s.error ? "\n ERROR " + short(s.error) : "");
33 + out[`${model}:stream`] = { status: s.status, nEvents: s.events.length, toolCallChunkCount: tcEvents.length, finishes, accumulated: acc, toolCallDeltas: tcEvents.map((e) => e.data.choices[0].delta), usageEvent: s.events.find((e) => e.data !== "[DONE]" && e.data?.usage)?.data, error: s.error };
34 +
35 + // round trip (non-stream round 2)
36 + const first = Object.values(acc)[0];
37 + if (first?.id) {
38 + messages.push({ role: "assistant", content: null, tool_calls: [{ id: first.id, type: "function", function: { name: first.name, arguments: first.args } }] });
39 + messages.push({ role: "tool", tool_call_id: first.id, content: JSON.stringify({ city: "Montreal", temp_c: 21, sky: "sunny" }) });
40 + const r2 = await chat({ model, messages, tools, max_completion_tokens: 150 });
41 + const b: any = r2.body;
42 + out[`${model}:round2`] = { status: r2.status, body: b };
43 + console.log(model, "round2:", r2.status, short(b?.choices?.[0]?.message?.content ?? b, 200), "finish", b?.choices?.[0]?.finish_reason);
44 + } else {
45 + // non-stream tool call for comparison
46 + const r1 = await chat({ model, messages, tools, tool_choice: "required", max_completion_tokens: 200 });
47 + out[`${model}:nonstream-required`] = r1;
48 + console.log(model, "nonstream required:", r1.status, short((r1.body as any)?.choices?.[0]?.message ?? r1.body, 400));
49 + }
50 +}
51 +save("03-tools-stream.json", out);
added research/cerebras/04-structured.ts +61 −0
@@ -0,0 +1,61 @@
1 +import { chat, rawSSE, save, MODELS, short } from "./lib.ts";
2 +
3 +const schema = {
4 + type: "object",
5 + properties: {
6 + city: { type: "string" },
7 + country: { type: "string" },
8 + population_millions: { type: "number" },
9 + landmarks: { type: "array", items: { type: "string" } },
10 + },
11 + required: ["city", "country", "population_millions", "landmarks"],
12 + additionalProperties: false,
13 +};
14 +
15 +const out: Record<string, unknown> = {};
16 +for (const model of MODELS) {
17 + const payload = {
18 + model,
19 + messages: [{ role: "user", content: "Describe Montreal. Two landmarks max." }],
20 + max_completion_tokens: 200,
21 + response_format: { type: "json_schema", json_schema: { name: "city_info", strict: true, schema } },
22 + };
23 + const r = await chat(payload);
24 + const b: any = r.body;
25 + let parsed: unknown = null;
26 + let valid = false;
27 + try {
28 + parsed = JSON.parse(b?.choices?.[0]?.message?.content ?? "");
29 + valid = typeof (parsed as any).city === "string" && Array.isArray((parsed as any).landmarks);
30 + } catch {}
31 + out[`${model}:json_schema`] = { status: r.status, valid, parsed, finish: b?.choices?.[0]?.finish_reason, usage: b?.usage, reasoning: short(b?.choices?.[0]?.message?.reasoning, 100), error: r.status !== 200 ? b : undefined };
32 + console.log(model, "json_schema strict:", r.status, "valid", valid, short(parsed ?? b, 250));
33 +
34 + // streamed json_schema
35 + const s = await rawSSE("/chat/completions", { ...payload, stream: true });
36 + let content = "";
37 + for (const e of s.events) if (e.data !== "[DONE]" && e.data?.choices?.[0]?.delta?.content) content += e.data.choices[0].delta.content;
38 + let sValid = false;
39 + try {
40 + sValid = typeof JSON.parse(content).city === "string";
41 + } catch {}
42 + out[`${model}:json_schema_stream`] = { status: s.status, valid: sValid, content: short(content, 300), error: s.error };
43 + console.log(model, "json_schema stream:", s.status, "valid", sValid);
44 +
45 + // schema with a forbidden keyword (pattern) to capture the error shape
46 + const r3 = await chat({
47 + ...payload,
48 + response_format: { type: "json_schema", json_schema: { name: "x", strict: true, schema: { type: "object", properties: { code: { type: "string", pattern: "^[A-Z]{3}$" } }, required: ["code"], additionalProperties: false } } },
49 + });
50 + out[`${model}:json_schema_pattern`] = { status: r3.status, body: r3.status !== 200 ? r3.body : short((r3.body as any)?.choices?.[0]?.message?.content, 100) };
51 + console.log(model, "json_schema with pattern:", r3.status, short(r3.body, 300));
52 +
53 + // strict without additionalProperties:false
54 + const r4 = await chat({
55 + ...payload,
56 + response_format: { type: "json_schema", json_schema: { name: "x", strict: true, schema: { type: "object", properties: { a: { type: "string" } }, required: ["a"] } } },
57 + });
58 + out[`${model}:json_schema_no_addprops`] = { status: r4.status, body: r4.status !== 200 ? r4.body : short((r4.body as any)?.choices?.[0]?.message?.content, 100) };
59 + console.log(model, "strict w/o additionalProperties:false:", r4.status, short(r4.body, 300));
60 +}
61 +save("04-structured.json", out);
added research/cerebras/05-vision.ts +69 −0
@@ -0,0 +1,69 @@
1 +import { chat, save, MODELS, short } from "./lib.ts";
2 +import { deflateSync } from "node:zlib";
3 +
4 +// Build a 32x32 solid red PNG (no deps)
5 +function crc32(buf: Buffer) {
6 + let c,
7 + crc = 0xffffffff;
8 + for (let n = 0; n < buf.length; n++) {
9 + c = (crc ^ buf[n]) & 0xff;
10 + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
11 + crc = (crc >>> 8) ^ c;
12 + }
13 + return (crc ^ 0xffffffff) >>> 0;
14 +}
15 +function chunk(type: string, data: Buffer) {
16 + const len = Buffer.alloc(4);
17 + len.writeUInt32BE(data.length);
18 + const td = Buffer.concat([Buffer.from(type), data]);
19 + const crc = Buffer.alloc(4);
20 + crc.writeUInt32BE(crc32(td));
21 + return Buffer.concat([len, td, crc]);
22 +}
23 +function png(w: number, h: number) {
24 + const ihdr = Buffer.alloc(13);
25 + ihdr.writeUInt32BE(w, 0);
26 + ihdr.writeUInt32BE(h, 4);
27 + ihdr[8] = 8; // bit depth
28 + ihdr[9] = 2; // RGB
29 + const rows: Buffer[] = [];
30 + for (let y = 0; y < h; y++) {
31 + const row = Buffer.alloc(1 + w * 3);
32 + for (let x = 0; x < w; x++) {
33 + row[1 + x * 3] = 220;
34 + row[2 + x * 3] = 30;
35 + row[3 + x * 3] = 30;
36 + }
37 + rows.push(row);
38 + }
39 + return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", deflateSync(Buffer.concat(rows))), chunk("IEND", Buffer.alloc(0))]);
40 +}
41 +
42 +const dataUrl = `data:image/png;base64,${png(32, 32).toString("base64")}`;
43 +const out: Record<string, unknown> = {};
44 +for (const model of MODELS) {
45 + const r = await chat({
46 + model,
47 + messages: [{ role: "user", content: [{ type: "text", text: "What colour is this image? One word." }, { type: "image_url", image_url: { url: dataUrl } }] }],
48 + max_completion_tokens: 60,
49 + });
50 + const b: any = r.body;
51 + out[`${model}:png32`] = { status: r.status, content: short(b?.choices?.[0]?.message?.content, 100), usage: b?.usage, error: r.status !== 200 ? b : undefined };
52 + console.log(model, "vision 32x32 png:", r.status, short(r.status === 200 ? { content: b?.choices?.[0]?.message?.content, usage: b?.usage } : b, 400));
53 +}
54 +// external URL + detail on the vision-capable model
55 +const rUrl = await chat({
56 + model: "qwen-3.8-27b",
57 + messages: [{ role: "user", content: [{ type: "text", text: "Describe." }, { type: "image_url", image_url: { url: "https://upload.wikimedia.org/wikipedia/commons/4/47/PNG_transparency_demonstration_1.png", detail: "low" } }] }],
58 + max_completion_tokens: 40,
59 +});
60 +out["qwen:https-url"] = rUrl;
61 +console.log("qwen https url:", rUrl.status, short(rUrl.body, 300));
62 +const rDetail = await chat({
63 + model: "qwen-3.8-27b",
64 + messages: [{ role: "user", content: [{ type: "text", text: "Colour? One word." }, { type: "image_url", image_url: { url: dataUrl, detail: "high" } }] }],
65 + max_completion_tokens: 40,
66 +});
67 +out["qwen:detail-high"] = { status: rDetail.status, body: rDetail.status === 200 ? short((rDetail.body as any)?.choices?.[0]?.message?.content, 100) : rDetail.body };
68 +console.log("qwen detail=high:", rDetail.status, short(rDetail.body, 300));
69 +save("05-vision.json", out);
added research/cerebras/06-errors.ts +57 −0
@@ -0,0 +1,57 @@
1 +import { chat, raw, save, short, BASE } from "./lib.ts";
2 +
3 +const out: Record<string, unknown> = {};
4 +const msgs = [{ role: "user", content: "hi" }];
5 +out.invalidKey = await chat({ model: "gpt-oss-120b", messages: msgs, max_completion_tokens: 5 }, "csk-invalid-key-0000000000000000");
6 +console.log("invalid key:", (out.invalidKey as any).status, short((out.invalidKey as any).body, 400), (out.invalidKey as any).headers["content-type"]);
7 +
8 +const noAuth = await fetch(`${BASE}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "gpt-oss-120b", messages: msgs }) });
9 +out.noAuth = { status: noAuth.status, body: await noAuth.text() };
10 +console.log("no auth:", short(out.noAuth, 400));
11 +
12 +out.unknownModel = await chat({ model: "llama-3.3-70b", messages: msgs, max_completion_tokens: 5 });
13 +console.log("unknown/retired model:", (out.unknownModel as any).status, short((out.unknownModel as any).body, 400));
14 +
15 +out.unknownModel2 = await chat({ model: "grok-99", messages: msgs, max_completion_tokens: 5 });
16 +console.log("unknown model 2:", (out.unknownModel2 as any).status, short((out.unknownModel2 as any).body, 400));
17 +
18 +out.malformed = await raw("/chat/completions", { method: "POST", body: '{"model":"gpt-oss-120b","messages":"nope"}' });
19 +console.log("malformed:", (out.malformed as any).status, short((out.malformed as any).body, 400));
20 +
21 +out.badJson = await raw("/chat/completions", { method: "POST", body: "{not json" });
22 +console.log("bad json:", (out.badJson as any).status, short((out.badJson as any).body, 400));
23 +
24 +out.noMessages = await chat({ model: "gpt-oss-120b" });
25 +console.log("no messages:", (out.noMessages as any).status, short((out.noMessages as any).body, 400));
26 +
27 +out.hugeMax = await chat({ model: "gpt-oss-120b", messages: msgs, max_completion_tokens: 999999 });
28 +console.log("max_completion_tokens 999999:", (out.hugeMax as any).status, short((out.hugeMax as any).body, 400));
29 +
30 +out.legacyCompletions = await raw("/completions", { method: "POST", body: JSON.stringify({ model: "gpt-oss-120b", prompt: "Say hi", max_tokens: 5 }) });
31 +console.log("/completions:", (out.legacyCompletions as any).status, short((out.legacyCompletions as any).body, 300));
32 +
33 +out.embeddings = await raw("/embeddings", { method: "POST", body: JSON.stringify({ model: "gpt-oss-120b", input: "hi" }) });
34 +console.log("/embeddings:", (out.embeddings as any).status, short((out.embeddings as any).body, 300));
35 +
36 +out.responses = await raw("/responses", { method: "POST", body: JSON.stringify({ model: "gpt-oss-120b", input: "hi" }) });
37 +console.log("/responses:", (out.responses as any).status, short((out.responses as any).body, 300));
38 +
39 +// Version header pinning
40 +out.versionHeaderV1 = await raw("/chat/completions", { method: "POST", headers: { "X-Cerebras-Version-Patch": "1" }, body: JSON.stringify({ model: "gpt-oss-120b", messages: msgs, max_completion_tokens: 5 }) });
41 +console.log("X-Cerebras-Version-Patch:1 ->", (out.versionHeaderV1 as any).status, short((out.versionHeaderV1 as any).body, 200));
42 +
43 +// SDK error class check
44 +import Cerebras from "@cerebras/cerebras_cloud_sdk";
45 +try {
46 + const bad = new Cerebras({ apiKey: "csk-invalid-key-0000000000000000", maxRetries: 0, warmTCPConnection: false });
47 + await bad.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "hi" }] });
48 +} catch (e: any) {
49 + out.sdkError = { name: e?.constructor?.name, status: e?.status, message: e?.message, error: e?.error };
50 + console.log("SDK error:", e?.constructor?.name, e?.status, short(e?.message, 200));
51 +}
52 +// SDK happy path (types: usage/time_info)
53 +import { cerebras } from "./lib.ts";
54 +const ok = await cerebras.chat.completions.create({ model: "qwen-3.8-27b", messages: [{ role: "user", content: "Say hi" }], max_completion_tokens: 30, reasoning_effort: "none" } as any);
55 +out.sdkOk = ok;
56 +console.log("SDK ok keys:", Object.keys(ok as any), "time_info", JSON.stringify((ok as any).time_info));
57 +save("06-errors.json", out);
added research/cerebras/07-context-reasoning.ts +95 −0
@@ -0,0 +1,95 @@
1 +import { chat, rawSSE, save, MODELS, short } from "./lib.ts";
2 +
3 +const out: Record<string, unknown> = {};
4 +
5 +// (1) gpt-oss json_schema streamed: inspect chunks (why invalid in 04?)
6 +{
7 + const s = await rawSSE("/chat/completions", {
8 + model: "gpt-oss-120b",
9 + messages: [{ role: "user", content: "Give the sum of 2 and 2." }],
10 + max_completion_tokens: 200,
11 + stream: true,
12 + response_format: { type: "json_schema", json_schema: { name: "sum", strict: true, schema: { type: "object", properties: { answer: { type: "integer" } }, required: ["answer"], additionalProperties: false } } },
13 + });
14 + let content = "";
15 + let reasoning = "";
16 + for (const e of s.events) {
17 + if (e.data === "[DONE]") continue;
18 + const d = e.data.choices?.[0]?.delta ?? {};
19 + if (d.content) content += d.content;
20 + if (d.reasoning) reasoning += d.reasoning;
21 + }
22 + const fin = s.events.find((e) => e.data !== "[DONE]" && e.data?.choices?.[0]?.finish_reason)?.data?.choices?.[0]?.finish_reason;
23 + out.gptOssJsonSchemaStream = { status: s.status, content, reasoning: short(reasoning, 300), finish: fin, usage: s.events.find((e) => e.data !== "[DONE]" && e.data?.usage)?.data?.usage };
24 + console.log("gpt-oss json_schema stream:", s.status, "finish", fin, "content:", JSON.stringify(content), "reasoning:", short(reasoning, 150));
25 +}
26 +
27 +// (2) reasoning_format raw on gpt-oss and qwen: how are <think>/analysis tags embedded? (non-stream, low effort)
28 +for (const model of ["gpt-oss-120b", "qwen-3.8-27b", "gemma-4-31b"]) {
29 + const r = await chat({ model, messages: [{ role: "user", content: "Is 7 prime? One word." }], max_completion_tokens: 300, reasoning_format: "raw", reasoning_effort: "low" });
30 + const b: any = r.body;
31 + out[`${model}:raw`] = { status: r.status, message: b?.choices?.[0]?.message, usage: b?.usage, error: r.status !== 200 ? b : undefined };
32 + console.log(model, "reasoning_format raw:", r.status, short(b?.choices?.[0]?.message ?? b, 500));
33 +}
34 +
35 +// (3) gemma with reasoning enabled: is `reasoning` returned & streamed?
36 +{
37 + const s = await rawSSE("/chat/completions", { model: "gemma-4-31b", messages: [{ role: "user", content: "Is 7 prime? One word." }], max_completion_tokens: 300, stream: true, reasoning_effort: "medium" });
38 + const keys = new Set<string>();
39 + let reasoning = "";
40 + let content = "";
41 + for (const e of s.events) {
42 + if (e.data === "[DONE]") continue;
43 + const d = e.data.choices?.[0]?.delta ?? {};
44 + Object.keys(d).forEach((k) => keys.add(k));
45 + if (d.reasoning) reasoning += d.reasoning;
46 + if (d.content) content += d.content;
47 + }
48 + out.gemmaReasoningStream = { status: s.status, deltaKeys: [...keys], reasoning: short(reasoning, 300), content, usage: s.events.find((e) => e.data !== "[DONE]" && e.data?.usage)?.data?.usage, error: s.error };
49 + console.log("gemma reasoning stream:", s.status, [...keys], "reasoning:", short(reasoning, 150), "content:", content, "usage:", JSON.stringify((out.gemmaReasoningStream as any).usage));
50 +}
51 +
52 +// (4) Context window: ~70k-token prompt (paid: 128k/131k; free: 64k/65k) then ~140k (over limit) to capture error text.
53 +function filler(tokensApprox: number) {
54 + // "lorem ipsum " ~ 3 tokens per 12 chars → build ~4 chars/token filler of distinct words to avoid weirdness
55 + const words = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet", "kilo", "lima"];
56 + const parts: string[] = [];
57 + for (let i = 0; i < tokensApprox; i++) parts.push(words[i % words.length]);
58 + return parts.join(" ");
59 +}
60 +for (const model of MODELS) {
61 + for (const target of [70_000, 140_000]) {
62 + const r = await chat({
63 + model,
64 + messages: [{ role: "user", content: `Reply with the single word OK. Ignore this filler:\n${filler(target)}` }],
65 + max_completion_tokens: 10,
66 + reasoning_effort: model === "gpt-oss-120b" ? "low" : "none",
67 + });
68 + const b: any = r.body;
69 + out[`${model}:ctx${target}`] = { status: r.status, promptTokens: b?.usage?.prompt_tokens, finish: b?.choices?.[0]?.finish_reason, error: r.status !== 200 ? b : undefined, headers: r.headers, ms: r.ms };
70 + console.log(model, `ctx~${target}:`, r.status, "prompt_tokens", b?.usage?.prompt_tokens, r.ms + "ms", r.status !== 200 ? short(b, 400) : "");
71 + }
72 +}
73 +
74 +// (5) max_completion_tokens above the per-model cap → error text?
75 +for (const model of MODELS) {
76 + const r = await chat({ model, messages: [{ role: "user", content: "hi" }], max_completion_tokens: 60_000, reasoning_effort: model === "gpt-oss-120b" ? "low" : "none" });
77 + out[`${model}:maxout60k`] = { status: r.status, body: r.status !== 200 ? r.body : short((r.body as any)?.choices?.[0]?.message?.content, 80) };
78 + console.log(model, "max_completion_tokens 60000:", r.status, short(r.body, 300));
79 +}
80 +
81 +// (6) reasoning-heavy probe: gpt-oss high effort, see reasoning_tokens vs completion_tokens & finish on cap
82 +{
83 + const r = await chat({ model: "gpt-oss-120b", messages: [{ role: "user", content: "How many r's in 'strawberry'? Think carefully." }], max_completion_tokens: 1000, reasoning_effort: "high" });
84 + const b: any = r.body;
85 + out.gptOssHigh = { status: r.status, usage: b?.usage, time_info: b?.time_info, finish: b?.choices?.[0]?.finish_reason, content: short(b?.choices?.[0]?.message?.content, 200), reasoningLen: b?.choices?.[0]?.message?.reasoning?.length };
86 + console.log("gpt-oss high:", r.status, JSON.stringify(b?.usage), "finish", b?.choices?.[0]?.finish_reason, "tps", Math.round(b?.usage?.completion_tokens / b?.time_info?.completion_time));
87 +}
88 +// (7) reasoning cap: qwen with tiny max_completion_tokens while reasoning on → finish_reason length, content empty?
89 +{
90 + const r = await chat({ model: "qwen-3.8-27b", messages: [{ role: "user", content: "Explain gravity." }], max_completion_tokens: 20 });
91 + const b: any = r.body;
92 + out.qwenCap20 = { status: r.status, message: b?.choices?.[0]?.message, finish: b?.choices?.[0]?.finish_reason, usage: b?.usage };
93 + console.log("qwen cap 20:", r.status, "finish", b?.choices?.[0]?.finish_reason, JSON.stringify(b?.choices?.[0]?.message), JSON.stringify(b?.usage));
94 +}
95 +save("07-context-reasoning.json", out);
added research/cerebras/08-misc.ts +72 −0
@@ -0,0 +1,72 @@
1 +import { chat, rawSSE, save, MODELS, short } from "./lib.ts";
2 +
3 +const out: Record<string, unknown> = {};
4 +// (1) json_object + stream (docs: incompatible)
5 +for (const model of MODELS) {
6 + const s = await rawSSE("/chat/completions", {
7 + model,
8 + messages: [{ role: "user", content: "Return a JSON object with key answer = 4." }],
9 + max_completion_tokens: 150,
10 + response_format: { type: "json_object" },
11 + stream: true,
12 + reasoning_effort: model === "gpt-oss-120b" ? "low" : "none",
13 + });
14 + let content = "";
15 + for (const e of s.events) if (e.data !== "[DONE]" && e.data?.choices?.[0]?.delta?.content) content += e.data.choices[0].delta.content;
16 + out[`${model}:json_object_stream`] = { status: s.status, error: s.error, nEvents: s.events.length, content };
17 + console.log(model, "json_object+stream:", s.status, s.error ? short(s.error, 300) : `events=${s.events.length} content=${JSON.stringify(content)}`);
18 +}
19 +// (2) qwen over-context (retry after TPM window)
20 +{
21 + const words = ["alpha", "bravo", "charlie", "delta", "echo", "foxtrot", "golf", "hotel", "india", "juliet", "kilo", "lima"];
22 + const parts: string[] = [];
23 + for (let i = 0; i < 140_000; i++) parts.push(words[i % words.length]);
24 + const r = await chat({ model: "qwen-3.8-27b", messages: [{ role: "user", content: `Reply OK.\n${parts.join(" ")}` }], max_completion_tokens: 10, reasoning_effort: "none" });
25 + out["qwen:ctx140k"] = { status: r.status, body: r.body, headers: r.headers };
26 + console.log("qwen ctx140k:", r.status, short(r.body, 300), r.status === 429 ? JSON.stringify(r.headers) : "");
27 +}
28 +// (3) logprobs + reasoning_logprobs on reasoning models
29 +for (const model of ["qwen-3.8-27b", "gpt-oss-120b"]) {
30 + const r = await chat({ model, messages: [{ role: "user", content: "Reply with the single word: pong" }], max_completion_tokens: 150, logprobs: true, top_logprobs: 2, reasoning_effort: "low" });
31 + const b: any = r.body;
32 + const ch = b?.choices?.[0];
33 + out[`${model}:logprobs`] = { status: r.status, choiceKeys: ch && Object.keys(ch), logprobs: short(ch?.logprobs, 300), reasoning_logprobs: short(ch?.reasoning_logprobs, 300), finish: ch?.finish_reason };
34 + console.log(model, "logprobs:", r.status, "choice keys", ch && Object.keys(ch), "\n logprobs:", short(ch?.logprobs, 200), "\n reasoning_logprobs:", short(ch?.reasoning_logprobs, 200));
35 +}
36 +// (4) qwen with `stop` and reasoning none to see stop works on content
37 +{
38 + const r = await chat({ model: "qwen-3.8-27b", messages: [{ role: "user", content: "Count from 1 to 10 separated by commas." }], max_completion_tokens: 60, stop: [", 5"], reasoning_effort: "none" });
39 + const b: any = r.body;
40 + out["qwen:stop"] = { status: r.status, content: b?.choices?.[0]?.message?.content, finish: b?.choices?.[0]?.finish_reason };
41 + console.log("qwen stop:", r.status, JSON.stringify(b?.choices?.[0]?.message?.content), b?.choices?.[0]?.finish_reason);
42 +}
43 +// (5) seed determinism (gemma, 2 calls)
44 +{
45 + const mk = () => chat({ model: "gemma-4-31b", messages: [{ role: "user", content: "Give me a random 6-digit number, digits only." }], max_completion_tokens: 20, seed: 7, temperature: 1 });
46 + const a: any = (await mk()).body, b: any = (await mk()).body;
47 + out["gemma:seed"] = { a: a?.choices?.[0]?.message?.content, b: b?.choices?.[0]?.message?.content, fp: [a?.system_fingerprint, b?.system_fingerprint] };
48 + console.log("gemma seed=7 twice:", JSON.stringify(out["gemma:seed"]));
49 +}
50 +// (6) prompt caching: repeat same long-ish prefix twice
51 +{
52 + const prefix = "You are a helpful assistant. " + "Context paragraph about Montreal geography and history. ".repeat(40);
53 + const mk = () => chat({ model: "gpt-oss-120b", messages: [{ role: "system", content: prefix }, { role: "user", content: "Reply OK." }], max_completion_tokens: 20, reasoning_effort: "low" });
54 + const a: any = (await mk()).body, b: any = (await mk()).body;
55 + out["gptoss:cache"] = { first: a?.usage, second: b?.usage };
56 + console.log("cache: first cached", a?.usage?.prompt_tokens_details?.cached_tokens, "second cached", b?.usage?.prompt_tokens_details?.cached_tokens, "of", b?.usage?.prompt_tokens);
57 +}
58 +// (7) OpenAI SDK compatibility (openai@7) streaming with reasoning field
59 +{
60 + const { openai } = await import("./lib.ts");
61 + const stream = await openai.chat.completions.create({ model: "gpt-oss-120b", messages: [{ role: "user", content: "Say hi." }], stream: true, max_completion_tokens: 100, reasoning_effort: "low" } as any);
62 + let reasoning = "", content = "", usage: unknown;
63 + for await (const chunk of stream as any) {
64 + const d = chunk.choices?.[0]?.delta ?? {};
65 + if (d.reasoning) reasoning += d.reasoning;
66 + if (d.content) content += d.content;
67 + if (chunk.usage) usage = chunk.usage;
68 + }
69 + out["openai-sdk-stream"] = { reasoning: short(reasoning, 100), content, usage };
70 + console.log("openai sdk stream ok: reasoning?", reasoning.length > 0, "content", JSON.stringify(content), "usage?", !!usage);
71 +}
72 +save("08-misc.json", out);
added research/cerebras/lib.ts +116 −0
@@ -0,0 +1,116 @@
1 +// Shared helpers for Cerebras probes. Key comes from process.env.CEREBRAS_API_KEY (never logged).
2 +import { mkdirSync, writeFileSync } from "node:fs";
3 +import OpenAI from "openai";
4 +import Cerebras from "@cerebras/cerebras_cloud_sdk";
5 +
6 +export const BASE = "https://api.cerebras.ai/v1";
7 +export const KEY = process.env.CEREBRAS_API_KEY ?? "";
8 +if (!KEY) {
9 + console.error("CEREBRAS_API_KEY missing (run: set -a; . ./.env; set +a)");
10 + process.exit(1);
11 +}
12 +
13 +export const OUT = new URL("./out/", import.meta.url).pathname;
14 +mkdirSync(OUT, { recursive: true });
15 +
16 +export const openai = new OpenAI({ apiKey: KEY, baseURL: BASE, timeout: 120_000, maxRetries: 0 });
17 +export const cerebras = new Cerebras({ apiKey: KEY, timeout: 120_000, maxRetries: 0, warmTCPConnection: false });
18 +
19 +export const MODELS = ["gemma-4-31b", "qwen-3.8-27b", "gpt-oss-120b"];
20 +
21 +export function save(name: string, data: unknown) {
22 + writeFileSync(`${OUT}${name}`, JSON.stringify(data, null, 2));
23 + console.log(`saved out/${name}`);
24 +}
25 +
26 +export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
27 +
28 +export function pickHeaders(h: Headers) {
29 + const headers: Record<string, string> = {};
30 + h.forEach((v, k) => {
31 + if (/ratelimit|request-id|x-request|retry-after|content-type|server|date|cf-ray|x-cerebras|via|cache/i.test(k)) headers[k] = v;
32 + });
33 + return headers;
34 +}
35 +
36 +/** Raw request; returns status, selected headers, and parsed body (never echoes the key). */
37 +export async function raw(path: string, init: RequestInit & { key?: string } = {}) {
38 + const { key, ...rest } = init;
39 + const t0 = Date.now();
40 + const res = await fetch(`${BASE}${path}`, {
41 + ...rest,
42 + headers: {
43 + "Content-Type": "application/json",
44 + Authorization: `Bearer ${key ?? KEY}`,
45 + ...(rest.headers ?? {}),
46 + },
47 + });
48 + const text = await res.text();
49 + let body: unknown = text;
50 + try {
51 + body = JSON.parse(text);
52 + } catch {}
53 + return { status: res.status, ms: Date.now() - t0, headers: pickHeaders(res.headers), body };
54 +}
55 +
56 +export async function chat(payload: Record<string, unknown>, key?: string) {
57 + return raw("/chat/completions", { method: "POST", body: JSON.stringify(payload), key });
58 +}
59 +
60 +/** Raw SSE POST; returns list of parsed events (data lines). */
61 +export async function rawSSE(path: string, payload: unknown, key?: string) {
62 + const t0 = Date.now();
63 + const res = await fetch(`${BASE}${path}`, {
64 + method: "POST",
65 + headers: { "Content-Type": "application/json", Authorization: `Bearer ${key ?? KEY}` },
66 + body: JSON.stringify(payload),
67 + });
68 + const headers = pickHeaders(res.headers);
69 + if (!res.ok || !res.body) {
70 + const text = await res.text();
71 + let body: unknown = text;
72 + try {
73 + body = JSON.parse(text);
74 + } catch {}
75 + return { status: res.status, headers, error: body, events: [] as any[], ttfbMs: Date.now() - t0, totalMs: Date.now() - t0 };
76 + }
77 + const reader = res.body.getReader();
78 + const dec = new TextDecoder();
79 + let buf = "";
80 + let ttfbMs = -1;
81 + const events: { event?: string; data: any; raw?: string; t: number }[] = [];
82 + for (;;) {
83 + const { value, done } = await reader.read();
84 + if (done) break;
85 + if (ttfbMs < 0) ttfbMs = Date.now() - t0;
86 + buf += dec.decode(value, { stream: true });
87 + let idx;
88 + while ((idx = buf.indexOf("\n\n")) >= 0) {
89 + const block = buf.slice(0, idx);
90 + buf = buf.slice(idx + 2);
91 + let ev: string | undefined;
92 + const datas: string[] = [];
93 + for (const line of block.split("\n")) {
94 + if (line.startsWith("event:")) ev = line.slice(6).trim();
95 + else if (line.startsWith("data:")) datas.push(line.slice(5).trim());
96 + }
97 + if (!datas.length) continue;
98 + const d = datas.join("\n");
99 + if (d === "[DONE]") {
100 + events.push({ event: ev, data: "[DONE]", t: Date.now() - t0 });
101 + continue;
102 + }
103 + try {
104 + events.push({ event: ev, data: JSON.parse(d), t: Date.now() - t0 });
105 + } catch {
106 + events.push({ event: ev, data: null, raw: d, t: Date.now() - t0 });
107 + }
108 + }
109 + }
110 + return { status: res.status, headers, events, ttfbMs, totalMs: Date.now() - t0 };
111 +}
112 +
113 +export function short(s: unknown, n = 300) {
114 + const t = typeof s === "string" ? s : JSON.stringify(s) ?? String(s);
115 + return t.length > n ? t.slice(0, n) + "…" : t;
116 +}
added research/cerebras/package.json +25 −0
@@ -0,0 +1,25 @@
1 +{
2 + "name": "cerebras",
3 + "version": "1.0.0",
4 + "description": "",
5 + "main": "index.js",
6 + "scripts": {
7 + "test": "echo \"Error: no test specified\" && exit 1"
8 + },
9 + "keywords": [],
10 + "author": "",
11 + "license": "ISC",
12 + "devEngines": {
13 + "packageManager": {
14 + "name": "pnpm",
15 + "version": "^11.1.2",
16 + "onFail": "download"
17 + }
18 + },
19 + "type": "module",
20 + "dependencies": {
21 + "@cerebras/cerebras_cloud_sdk": "^1.91.0",
22 + "openai": "^7.10.0",
23 + "tsx": "^4.23.13"
24 + }
25 +}
added research/cerebras/pnpm-lock.yaml +822 −0
@@ -0,0 +1,822 @@
1 +---
2 +lockfileVersion: '9.0'
3 +
4 +importers:
5 +
6 + .:
7 + configDependencies: {}
8 + packageManagerDependencies:
9 + '@pnpm/exe':
10 + specifier: ^11.1.2
11 + version: 11.26.0
12 + pnpm:
13 + specifier: ^11.1.2
14 + version: 11.26.0
15 +
16 +packages:
17 +
18 + '@pnpm/exe@11.26.0':
19 + resolution: {integrity: sha512-eeiNi7WeXulOO1BzDAU9HIk+N3dokG+xwKPUupNQoZT5auL3rh2pFW9DqdIEnCC2xtt/hZd6UGtKyb6OMpqndw==}
20 + hasBin: true
21 +
22 + '@pnpm/linux-arm64@11.26.0':
23 + resolution: {integrity: sha512-M0IDuD4hbXxpLBsj1/I9pODcxu/gxTDtbyQmsVGYu1TZwCCpf6YU1x3lzq8aCGjrysmXYQAtCiY8GBjdw4UGFg==}
24 + cpu: [arm64]
25 + os: [linux]
26 +
27 + '@pnpm/linux-x64@11.26.0':
28 + resolution: {integrity: sha512-nUuNRsFCGVje3FHtOaWxIQl2EP4NBktKr+PpLN7uhuWnJCCMN/F9RKjNJAM+dUPyvAZs8VDvS0kA8J55kyybyg==}
29 + cpu: [x64]
30 + os: [linux]
31 +
32 + '@pnpm/linuxstatic-arm64@11.26.0':
33 + resolution: {integrity: sha512-+dXROkvdWjskrQtjwJAFuJI7Q2uTkghBfLsd0vqRmorX3fPuinhoRjJJ/UPtWXzJqwcByJBmdJRW+q2964m5qQ==}
34 + cpu: [arm64]
35 + os: [linux]
36 + libc: [musl]
37 +
38 + '@pnpm/linuxstatic-x64@11.26.0':
39 + resolution: {integrity: sha512-0eXE6spzIBdCbYhJZQ0u/sIOD0v30sqk2PrzoixzsNMc7S/lhd7EkBMVJ6cCPiCL2TXvOsrJC2SpWPBK769A9A==}
40 + cpu: [x64]
41 + os: [linux]
42 + libc: [musl]
43 +
44 + '@pnpm/macos-arm64@11.26.0':
45 + resolution: {integrity: sha512-Za3kKV89Zj0SFzQf6zEUTUIy13xH8UiNyb7aILDRjPiTvTaUSCh3q+nvljISfGH1XcopXvo+p3K8Q2nrKYWAug==}
46 + cpu: [arm64]
47 + os: [darwin]
48 +
49 + '@pnpm/win-arm64@11.26.0':
50 + resolution: {integrity: sha512-5akeLtbqbFDdJtCV4qgLmDBV3R+XNs6E7h7Xb4ZBzS3rLRp8e/y/ZdgrZaGF5Kjo45g0BLAxdGUREHbU2/lGUw==}
51 + cpu: [arm64]
52 + os: [win32]
53 +
54 + '@pnpm/win-x64@11.26.0':
55 + resolution: {integrity: sha512-f15SfIo7nppxBII+STy6jpd1z3LgGTZh6skfuxKA/xHmkzopnQJQXr4hBnl1rJLJAnmuOhN8BssaC4LiXmS78w==}
56 + cpu: [x64]
57 + os: [win32]
58 +
59 + '@reflink/reflink-darwin-arm64@0.1.19':
60 + resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==}
61 + engines: {node: '>= 10'}
62 + cpu: [arm64]
63 + os: [darwin]
64 +
65 + '@reflink/reflink-darwin-x64@0.1.19':
66 + resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==}
67 + engines: {node: '>= 10'}
68 + cpu: [x64]
69 + os: [darwin]
70 +
71 + '@reflink/reflink-linux-arm64-gnu@0.1.19':
72 + resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==}
73 + engines: {node: '>= 10'}
74 + cpu: [arm64]
75 + os: [linux]
76 + libc: [glibc]
77 +
78 + '@reflink/reflink-linux-arm64-musl@0.1.19':
79 + resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==}
80 + engines: {node: '>= 10'}
81 + cpu: [arm64]
82 + os: [linux]
83 + libc: [musl]
84 +
85 + '@reflink/reflink-linux-x64-gnu@0.1.19':
86 + resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==}
87 + engines: {node: '>= 10'}
88 + cpu: [x64]
89 + os: [linux]
90 + libc: [glibc]
91 +
92 + '@reflink/reflink-linux-x64-musl@0.1.19':
93 + resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==}
94 + engines: {node: '>= 10'}
95 + cpu: [x64]
96 + os: [linux]
97 + libc: [musl]
98 +
99 + '@reflink/reflink-win32-arm64-msvc@0.1.19':
100 + resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==}
101 + engines: {node: '>= 10'}
102 + cpu: [arm64]
103 + os: [win32]
104 +
105 + '@reflink/reflink-win32-x64-msvc@0.1.19':
106 + resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==}
107 + engines: {node: '>= 10'}
108 + cpu: [x64]
109 + os: [win32]
110 +
111 + '@reflink/reflink@0.1.19':
112 + resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==}
113 + engines: {node: '>= 10'}
114 +
115 + detect-libc@2.1.2:
116 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
117 + engines: {node: '>=8'}
118 +
119 + pnpm@11.26.0:
120 + resolution: {integrity: sha512-/A4r+JC5+YNhHxq2jAY3vOkUOQZTaZ+DwaeLAF7SXyyB53kgyP2O7i7PC1iyjNywCoTZf2m898VrLzRHECOGZA==}
121 + engines: {node: '>=22.13'}
122 + hasBin: true
123 +
124 +snapshots:
125 +
126 + '@pnpm/exe@11.26.0':
127 + dependencies:
128 + '@reflink/reflink': 0.1.19
129 + detect-libc: 2.1.2
130 + optionalDependencies:
131 + '@pnpm/linux-arm64': 11.26.0
132 + '@pnpm/linux-x64': 11.26.0
133 + '@pnpm/linuxstatic-arm64': 11.26.0
134 + '@pnpm/linuxstatic-x64': 11.26.0
135 + '@pnpm/macos-arm64': 11.26.0
136 + '@pnpm/win-arm64': 11.26.0
137 + '@pnpm/win-x64': 11.26.0
138 +
139 + '@pnpm/linux-arm64@11.26.0':
140 + optional: true
141 +
142 + '@pnpm/linux-x64@11.26.0':
143 + optional: true
144 +
145 + '@pnpm/linuxstatic-arm64@11.26.0':
146 + optional: true
147 +
148 + '@pnpm/linuxstatic-x64@11.26.0':
149 + optional: true
150 +
151 + '@pnpm/macos-arm64@11.26.0':
152 + optional: true
153 +
154 + '@pnpm/win-arm64@11.26.0':
155 + optional: true
156 +
157 + '@pnpm/win-x64@11.26.0':
158 + optional: true
159 +
160 + '@reflink/reflink-darwin-arm64@0.1.19':
161 + optional: true
162 +
163 + '@reflink/reflink-darwin-x64@0.1.19':
164 + optional: true
165 +
166 + '@reflink/reflink-linux-arm64-gnu@0.1.19':
167 + optional: true
168 +
169 + '@reflink/reflink-linux-arm64-musl@0.1.19':
170 + optional: true
171 +
172 + '@reflink/reflink-linux-x64-gnu@0.1.19':
173 + optional: true
174 +
175 + '@reflink/reflink-linux-x64-musl@0.1.19':
176 + optional: true
177 +
178 + '@reflink/reflink-win32-arm64-msvc@0.1.19':
179 + optional: true
180 +
181 + '@reflink/reflink-win32-x64-msvc@0.1.19':
182 + optional: true
183 +
184 + '@reflink/reflink@0.1.19':
185 + optionalDependencies:
186 + '@reflink/reflink-darwin-arm64': 0.1.19
187 + '@reflink/reflink-darwin-x64': 0.1.19
188 + '@reflink/reflink-linux-arm64-gnu': 0.1.19
189 + '@reflink/reflink-linux-arm64-musl': 0.1.19
190 + '@reflink/reflink-linux-x64-gnu': 0.1.19
191 + '@reflink/reflink-linux-x64-musl': 0.1.19
192 + '@reflink/reflink-win32-arm64-msvc': 0.1.19
193 + '@reflink/reflink-win32-x64-msvc': 0.1.19
194 +
195 + detect-libc@2.1.2: {}
196 +
197 + pnpm@11.26.0: {}
198 +
199 +---
200 +lockfileVersion: '9.0'
201 +
202 +settings:
203 + autoInstallPeers: true
204 + excludeLinksFromLockfile: false
205 +
206 +importers:
207 +
208 + .:
209 + dependencies:
210 + '@cerebras/cerebras_cloud_sdk':
211 + specifier: ^1.91.0
212 + version: 1.91.0
213 + openai:
214 + specifier: ^7.10.0
215 + version: 7.10.0
216 + tsx:
217 + specifier: ^4.23.13
218 + version: 4.23.13
219 +
220 +packages:
221 +
222 + '@cerebras/cerebras_cloud_sdk@1.91.0':
223 + resolution: {integrity: sha512-M1CzFeMX13/mat+/YMWZiY45UakJRkQwoNRCfK0xlFoSWIWxR3WfBq4sN2apqH34JeaGCCj4Sk4ORVZcG1H9Rg==}
224 +
225 + '@esbuild/aix-ppc64@0.28.2':
226 + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
227 + engines: {node: '>=18'}
228 + cpu: [ppc64]
229 + os: [aix]
230 +
231 + '@esbuild/android-arm64@0.28.2':
232 + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
233 + engines: {node: '>=18'}
234 + cpu: [arm64]
235 + os: [android]
236 +
237 + '@esbuild/android-arm@0.28.2':
238 + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
239 + engines: {node: '>=18'}
240 + cpu: [arm]
241 + os: [android]
242 +
243 + '@esbuild/android-x64@0.28.2':
244 + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
245 + engines: {node: '>=18'}
246 + cpu: [x64]
247 + os: [android]
248 +
249 + '@esbuild/darwin-arm64@0.28.2':
250 + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
251 + engines: {node: '>=18'}
252 + cpu: [arm64]
253 + os: [darwin]
254 +
255 + '@esbuild/darwin-x64@0.28.2':
256 + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
257 + engines: {node: '>=18'}
258 + cpu: [x64]
259 + os: [darwin]
260 +
261 + '@esbuild/freebsd-arm64@0.28.2':
262 + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
263 + engines: {node: '>=18'}
264 + cpu: [arm64]
265 + os: [freebsd]
266 +
267 + '@esbuild/freebsd-x64@0.28.2':
268 + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
269 + engines: {node: '>=18'}
270 + cpu: [x64]
271 + os: [freebsd]
272 +
273 + '@esbuild/linux-arm64@0.28.2':
274 + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
275 + engines: {node: '>=18'}
276 + cpu: [arm64]
277 + os: [linux]
278 +
279 + '@esbuild/linux-arm@0.28.2':
280 + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
281 + engines: {node: '>=18'}
282 + cpu: [arm]
283 + os: [linux]
284 +
285 + '@esbuild/linux-ia32@0.28.2':
286 + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
287 + engines: {node: '>=18'}
288 + cpu: [ia32]
289 + os: [linux]
290 +
291 + '@esbuild/linux-loong64@0.28.2':
292 + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
293 + engines: {node: '>=18'}
294 + cpu: [loong64]
295 + os: [linux]
296 +
297 + '@esbuild/linux-mips64el@0.28.2':
298 + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
299 + engines: {node: '>=18'}
300 + cpu: [mips64el]
301 + os: [linux]
302 +
303 + '@esbuild/linux-ppc64@0.28.2':
304 + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
305 + engines: {node: '>=18'}
306 + cpu: [ppc64]
307 + os: [linux]
308 +
309 + '@esbuild/linux-riscv64@0.28.2':
310 + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
311 + engines: {node: '>=18'}
312 + cpu: [riscv64]
313 + os: [linux]
314 +
315 + '@esbuild/linux-s390x@0.28.2':
316 + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
317 + engines: {node: '>=18'}
318 + cpu: [s390x]
319 + os: [linux]
320 +
321 + '@esbuild/linux-x64@0.28.2':
322 + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
323 + engines: {node: '>=18'}
324 + cpu: [x64]
325 + os: [linux]
326 +
327 + '@esbuild/netbsd-arm64@0.28.2':
328 + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
329 + engines: {node: '>=18'}
330 + cpu: [arm64]
331 + os: [netbsd]
332 +
333 + '@esbuild/netbsd-x64@0.28.2':
334 + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
335 + engines: {node: '>=18'}
336 + cpu: [x64]
337 + os: [netbsd]
338 +
339 + '@esbuild/openbsd-arm64@0.28.2':
340 + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
341 + engines: {node: '>=18'}
342 + cpu: [arm64]
343 + os: [openbsd]
344 +
345 + '@esbuild/openbsd-x64@0.28.2':
346 + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
347 + engines: {node: '>=18'}
348 + cpu: [x64]
349 + os: [openbsd]
350 +
351 + '@esbuild/openharmony-arm64@0.28.2':
352 + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
353 + engines: {node: '>=18'}
354 + cpu: [arm64]
355 + os: [openharmony]
356 +
357 + '@esbuild/sunos-x64@0.28.2':
358 + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
359 + engines: {node: '>=18'}
360 + cpu: [x64]
361 + os: [sunos]
362 +
363 + '@esbuild/win32-arm64@0.28.2':
364 + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
365 + engines: {node: '>=18'}
366 + cpu: [arm64]
367 + os: [win32]
368 +
369 + '@esbuild/win32-ia32@0.28.2':
370 + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
371 + engines: {node: '>=18'}
372 + cpu: [ia32]
373 + os: [win32]
374 +
375 + '@esbuild/win32-x64@0.28.2':
376 + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
377 + engines: {node: '>=18'}
378 + cpu: [x64]
379 + os: [win32]
380 +
381 + '@types/node-fetch@2.6.13':
382 + resolution: {integrity: sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==}
383 +
384 + '@types/node@18.19.130':
385 + resolution: {integrity: sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==}
386 +
387 + abort-controller@3.0.0:
388 + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
389 + engines: {node: '>=6.5'}
390 +
391 + agentkeepalive@4.6.0:
392 + resolution: {integrity: sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==}
393 + engines: {node: '>= 8.0.0'}
394 +
395 + asynckit@0.4.0:
396 + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==}
397 +
398 + call-bind-apply-helpers@1.0.2:
399 + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
400 + engines: {node: '>= 0.4'}
401 +
402 + combined-stream@1.0.8:
403 + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
404 + engines: {node: '>= 0.8'}
405 +
406 + delayed-stream@1.0.0:
407 + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
408 + engines: {node: '>=0.4.0'}
409 +
410 + dunder-proto@1.0.1:
411 + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
412 + engines: {node: '>= 0.4'}
413 +
414 + es-define-property@1.0.1:
415 + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
416 + engines: {node: '>= 0.4'}
417 +
418 + es-errors@1.3.0:
419 + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
420 + engines: {node: '>= 0.4'}
421 +
422 + es-object-atoms@1.1.2:
423 + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
424 + engines: {node: '>= 0.4'}
425 +
426 + es-set-tostringtag@2.1.0:
427 + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==}
428 + engines: {node: '>= 0.4'}
429 +
430 + esbuild@0.28.2:
431 + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
432 + engines: {node: '>=18'}
433 + hasBin: true
434 +
435 + event-target-shim@5.0.1:
436 + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==}
437 + engines: {node: '>=6'}
438 +
439 + form-data-encoder@1.7.2:
440 + resolution: {integrity: sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==}
441 +
442 + form-data@4.0.6:
443 + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==}
444 + engines: {node: '>= 6'}
445 +
446 + formdata-node@4.4.1:
447 + resolution: {integrity: sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==}
448 + engines: {node: '>= 12.20'}
449 +
450 + fsevents@2.3.3:
451 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
452 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
453 + os: [darwin]
454 +
455 + function-bind@1.1.2:
456 + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
457 +
458 + get-intrinsic@1.3.0:
459 + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
460 + engines: {node: '>= 0.4'}
461 +
462 + get-proto@1.0.1:
463 + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
464 + engines: {node: '>= 0.4'}
465 +
466 + gopd@1.2.0:
467 + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
468 + engines: {node: '>= 0.4'}
469 +
470 + has-symbols@1.1.0:
471 + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
472 + engines: {node: '>= 0.4'}
473 +
474 + has-tostringtag@1.0.2:
475 + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==}
476 + engines: {node: '>= 0.4'}
477 +
478 + hasown@2.0.4:
479 + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==}
480 + engines: {node: '>= 0.4'}
481 +
482 + humanize-ms@1.2.1:
483 + resolution: {integrity: sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==}
484 +
485 + math-intrinsics@1.1.0:
486 + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
487 + engines: {node: '>= 0.4'}
488 +
489 + mime-db@1.52.0:
490 + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
491 + engines: {node: '>= 0.6'}
492 +
493 + mime-types@2.1.35:
494 + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
495 + engines: {node: '>= 0.6'}
496 +
497 + ms@2.1.3:
498 + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
499 +
500 + node-domexception@1.0.0:
501 + resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
502 + engines: {node: '>=10.5.0'}
503 + deprecated: Use your platform's native DOMException instead
504 +
505 + node-fetch@2.7.0:
506 + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==}
507 + engines: {node: 4.x || >=6.0.0}
508 + peerDependencies:
509 + encoding: ^0.1.0
510 + peerDependenciesMeta:
511 + encoding:
512 + optional: true
513 +
514 + openai@7.10.0:
515 + resolution: {integrity: sha512-sn9t2Kls7O52PwuF9BUTYNu4Gk/r0lXJyrgaNht4TNRlZFb3dJIGO0RciSgjARGCBRtWjySubAQFJttlzUvGQQ==}
516 + engines: {node: '>=22.0.0'}
517 + peerDependencies:
518 + '@aws-sdk/credential-provider-node': '>=3.972.0 <4'
519 + '@smithy/hash-node': '>=4.3.0 <5'
520 + '@smithy/signature-v4': '>=5.4.0 <6'
521 + undici: '>=5 <9'
522 + ws: ^8.21.0
523 + zod: ^3.25 || ^4.0
524 + peerDependenciesMeta:
525 + '@aws-sdk/credential-provider-node':
526 + optional: true
527 + '@smithy/hash-node':
528 + optional: true
529 + '@smithy/signature-v4':
530 + optional: true
531 + undici:
532 + optional: true
533 + ws:
534 + optional: true
535 + zod:
536 + optional: true
537 +
538 + tr46@0.0.3:
539 + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==}
540 +
541 + tsx@4.23.13:
542 + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
543 + engines: {node: '>=18.0.0'}
544 + hasBin: true
545 +
546 + undici-types@5.26.5:
547 + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==}
548 +
549 + web-streams-polyfill@4.0.0-beta.3:
550 + resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
551 + engines: {node: '>= 14'}
552 +
553 + webidl-conversions@3.0.1:
554 + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
555 +
556 + whatwg-url@5.0.0:
557 + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
558 +
559 +snapshots:
560 +
561 + '@cerebras/cerebras_cloud_sdk@1.91.0':
562 + dependencies:
563 + '@types/node': 18.19.130
564 + '@types/node-fetch': 2.6.13
565 + abort-controller: 3.0.0
566 + agentkeepalive: 4.6.0
567 + form-data-encoder: 1.7.2
568 + formdata-node: 4.4.1
569 + node-fetch: 2.7.0
570 + transitivePeerDependencies:
571 + - encoding
572 +
573 + '@esbuild/aix-ppc64@0.28.2':
574 + optional: true
575 +
576 + '@esbuild/android-arm64@0.28.2':
577 + optional: true
578 +
579 + '@esbuild/android-arm@0.28.2':
580 + optional: true
581 +
582 + '@esbuild/android-x64@0.28.2':
583 + optional: true
584 +
585 + '@esbuild/darwin-arm64@0.28.2':
586 + optional: true
587 +
588 + '@esbuild/darwin-x64@0.28.2':
589 + optional: true
590 +
591 + '@esbuild/freebsd-arm64@0.28.2':
592 + optional: true
593 +
594 + '@esbuild/freebsd-x64@0.28.2':
595 + optional: true
596 +
597 + '@esbuild/linux-arm64@0.28.2':
598 + optional: true
599 +
600 + '@esbuild/linux-arm@0.28.2':
601 + optional: true
602 +
603 + '@esbuild/linux-ia32@0.28.2':
604 + optional: true
605 +
606 + '@esbuild/linux-loong64@0.28.2':
607 + optional: true
608 +
609 + '@esbuild/linux-mips64el@0.28.2':
610 + optional: true
611 +
612 + '@esbuild/linux-ppc64@0.28.2':
613 + optional: true
614 +
615 + '@esbuild/linux-riscv64@0.28.2':
616 + optional: true
617 +
618 + '@esbuild/linux-s390x@0.28.2':
619 + optional: true
620 +
621 + '@esbuild/linux-x64@0.28.2':
622 + optional: true
623 +
624 + '@esbuild/netbsd-arm64@0.28.2':
625 + optional: true
626 +
627 + '@esbuild/netbsd-x64@0.28.2':
628 + optional: true
629 +
630 + '@esbuild/openbsd-arm64@0.28.2':
631 + optional: true
632 +
633 + '@esbuild/openbsd-x64@0.28.2':
634 + optional: true
635 +
636 + '@esbuild/openharmony-arm64@0.28.2':
637 + optional: true
638 +
639 + '@esbuild/sunos-x64@0.28.2':
640 + optional: true
641 +
642 + '@esbuild/win32-arm64@0.28.2':
643 + optional: true
644 +
645 + '@esbuild/win32-ia32@0.28.2':
646 + optional: true
647 +
648 + '@esbuild/win32-x64@0.28.2':
649 + optional: true
650 +
651 + '@types/node-fetch@2.6.13':
652 + dependencies:
653 + '@types/node': 18.19.130
654 + form-data: 4.0.6
655 +
656 + '@types/node@18.19.130':
657 + dependencies:
658 + undici-types: 5.26.5
659 +
660 + abort-controller@3.0.0:
661 + dependencies:
662 + event-target-shim: 5.0.1
663 +
664 + agentkeepalive@4.6.0:
665 + dependencies:
666 + humanize-ms: 1.2.1
667 +
668 + asynckit@0.4.0: {}
669 +
670 + call-bind-apply-helpers@1.0.2:
671 + dependencies:
672 + es-errors: 1.3.0
673 + function-bind: 1.1.2
674 +
675 + combined-stream@1.0.8:
676 + dependencies:
677 + delayed-stream: 1.0.0
678 +
679 + delayed-stream@1.0.0: {}
680 +
681 + dunder-proto@1.0.1:
682 + dependencies:
683 + call-bind-apply-helpers: 1.0.2
684 + es-errors: 1.3.0
685 + gopd: 1.2.0
686 +
687 + es-define-property@1.0.1: {}
688 +
689 + es-errors@1.3.0: {}
690 +
691 + es-object-atoms@1.1.2:
692 + dependencies:
693 + es-errors: 1.3.0
694 +
695 + es-set-tostringtag@2.1.0:
696 + dependencies:
697 + es-errors: 1.3.0
698 + get-intrinsic: 1.3.0
699 + has-tostringtag: 1.0.2
700 + hasown: 2.0.4
701 +
702 + esbuild@0.28.2:
703 + optionalDependencies:
704 + '@esbuild/aix-ppc64': 0.28.2
705 + '@esbuild/android-arm': 0.28.2
706 + '@esbuild/android-arm64': 0.28.2
707 + '@esbuild/android-x64': 0.28.2
708 + '@esbuild/darwin-arm64': 0.28.2
709 + '@esbuild/darwin-x64': 0.28.2
710 + '@esbuild/freebsd-arm64': 0.28.2
711 + '@esbuild/freebsd-x64': 0.28.2
712 + '@esbuild/linux-arm': 0.28.2
713 + '@esbuild/linux-arm64': 0.28.2
714 + '@esbuild/linux-ia32': 0.28.2
715 + '@esbuild/linux-loong64': 0.28.2
716 + '@esbuild/linux-mips64el': 0.28.2
717 + '@esbuild/linux-ppc64': 0.28.2
718 + '@esbuild/linux-riscv64': 0.28.2
719 + '@esbuild/linux-s390x': 0.28.2
720 + '@esbuild/linux-x64': 0.28.2
721 + '@esbuild/netbsd-arm64': 0.28.2
722 + '@esbuild/netbsd-x64': 0.28.2
723 + '@esbuild/openbsd-arm64': 0.28.2
724 + '@esbuild/openbsd-x64': 0.28.2
725 + '@esbuild/openharmony-arm64': 0.28.2
726 + '@esbuild/sunos-x64': 0.28.2
727 + '@esbuild/win32-arm64': 0.28.2
728 + '@esbuild/win32-ia32': 0.28.2
729 + '@esbuild/win32-x64': 0.28.2
730 +
731 + event-target-shim@5.0.1: {}
732 +
733 + form-data-encoder@1.7.2: {}
734 +
735 + form-data@4.0.6:
736 + dependencies:
737 + asynckit: 0.4.0
738 + combined-stream: 1.0.8
739 + es-set-tostringtag: 2.1.0
740 + hasown: 2.0.4
741 + mime-types: 2.1.35
742 +
743 + formdata-node@4.4.1:
744 + dependencies:
745 + node-domexception: 1.0.0
746 + web-streams-polyfill: 4.0.0-beta.3
747 +
748 + fsevents@2.3.3:
749 + optional: true
750 +
751 + function-bind@1.1.2: {}
752 +
753 + get-intrinsic@1.3.0:
754 + dependencies:
755 + call-bind-apply-helpers: 1.0.2
756 + es-define-property: 1.0.1
757 + es-errors: 1.3.0
758 + es-object-atoms: 1.1.2
759 + function-bind: 1.1.2
760 + get-proto: 1.0.1
761 + gopd: 1.2.0
762 + has-symbols: 1.1.0
763 + hasown: 2.0.4
764 + math-intrinsics: 1.1.0
765 +
766 + get-proto@1.0.1:
767 + dependencies:
768 + dunder-proto: 1.0.1
769 + es-object-atoms: 1.1.2
770 +
771 + gopd@1.2.0: {}
772 +
773 + has-symbols@1.1.0: {}
774 +
775 + has-tostringtag@1.0.2:
776 + dependencies:
777 + has-symbols: 1.1.0
778 +
779 + hasown@2.0.4:
780 + dependencies:
781 + function-bind: 1.1.2
782 +
783 + humanize-ms@1.2.1:
784 + dependencies:
785 + ms: 2.1.3
786 +
787 + math-intrinsics@1.1.0: {}
788 +
789 + mime-db@1.52.0: {}
790 +
791 + mime-types@2.1.35:
792 + dependencies:
793 + mime-db: 1.52.0
794 +
795 + ms@2.1.3: {}
796 +
797 + node-domexception@1.0.0: {}
798 +
799 + node-fetch@2.7.0:
800 + dependencies:
801 + whatwg-url: 5.0.0
802 +
803 + openai@7.10.0: {}
804 +
805 + tr46@0.0.3: {}
806 +
807 + tsx@4.23.13:
808 + dependencies:
809 + esbuild: 0.28.2
810 + optionalDependencies:
811 + fsevents: 2.3.3
812 +
813 + undici-types@5.26.5: {}
814 +
815 + web-streams-polyfill@4.0.0-beta.3: {}
816 +
817 + webidl-conversions@3.0.1: {}
818 +
819 + whatwg-url@5.0.0:
820 + dependencies:
821 + tr46: 0.0.3
822 + webidl-conversions: 3.0.1
added research/deepseek/00-models.ts +19 −0
@@ -0,0 +1,19 @@
1 +// Endpoint discovery: /models with and without /v1, /user/balance, HEAD-ish checks.
2 +import { raw, save } from "./lib.ts";
3 +
4 +const out: Record<string, unknown> = {};
5 +out.models = await raw("/models");
6 +out.models_v1 = await raw("/v1/models");
7 +out.balance = await raw("/user/balance");
8 +out.balance_v1 = await raw("/v1/user/balance");
9 +out.chat_v1 = await raw("/v1/chat/completions", {
10 + method: "POST",
11 + body: JSON.stringify({ model: "deepseek-v4-flash", messages: [{ role: "user", content: "Say OK" }], max_tokens: 5, thinking: { type: "disabled" } }),
12 +});
13 +out.chat_beta = await raw("/beta/chat/completions", {
14 + method: "POST",
15 + body: JSON.stringify({ model: "deepseek-v4-flash", messages: [{ role: "user", content: "Say OK" }], max_tokens: 5, thinking: { type: "disabled" } }),
16 +});
17 +out.files = await raw("/files");
18 +save("00-models.json", out);
19 +for (const [k, v] of Object.entries(out)) console.log(k, (v as any).status, JSON.stringify((v as any).body).slice(0, 400), JSON.stringify((v as any).headers));
added research/deepseek/01-chat-stream.ts +38 −0
@@ -0,0 +1,38 @@
1 +// (a) tiny non-streaming completion (default thinking), (b) streaming with include_usage, per model.
2 +import { client, MODELS, rawSSE, save, errInfo } from "./lib.ts";
3 +
4 +const out: Record<string, unknown> = {};
5 +for (const model of MODELS) {
6 + const t0 = Date.now();
7 + try {
8 + const r = await client.chat.completions.create({
9 + model,
10 + messages: [{ role: "user", content: "What is 2+2? Answer in one short sentence." }],
11 + max_tokens: 200,
12 + });
13 + out[`${model}:nonstream`] = { ms: Date.now() - t0, response: r };
14 + console.log(model, "nonstream", Date.now() - t0, "ms", JSON.stringify(r.usage), "finish", r.choices[0].finish_reason, "reasoning?", !!(r.choices[0].message as any).reasoning_content);
15 + } catch (e) {
16 + out[`${model}:nonstream`] = errInfo(e);
17 + console.log(model, "nonstream ERR", JSON.stringify(errInfo(e)));
18 + }
19 +
20 + const t1 = Date.now();
21 + const s = await rawSSE("/chat/completions", {
22 + model,
23 + messages: [{ role: "user", content: "Say hello in French, 5 words max." }],
24 + max_tokens: 200,
25 + stream: true,
26 + stream_options: { include_usage: true },
27 + });
28 + const deltaKeys = new Set<string>();
29 + let firstReasoningMs: number | null = null, firstContentMs: number | null = null;
30 + for (const ev of s.events) {
31 + if (ev === "[DONE]") continue;
32 + const d = ev.choices?.[0]?.delta;
33 + if (d) for (const k of Object.keys(d)) { deltaKeys.add(k); if (d.reasoning_content && firstReasoningMs === null) firstReasoningMs = 0; if (d.content && firstContentMs === null) firstContentMs = 0; }
34 + }
35 + out[`${model}:stream`] = { ms: Date.now() - t1, status: s.status, headers: s.headers, comments: s.comments, nEvents: s.events.length, deltaKeys: [...deltaKeys], first3: s.events.slice(0, 3), last4: s.events.slice(-4) };
36 + console.log(model, "stream", s.status, Date.now() - t1, "ms", s.events.length, "events; delta keys", [...deltaKeys], "comments", s.comments.length);
37 +}
38 +save("01-chat-stream.json", out);
added research/deepseek/02-params.ts +66 −0
@@ -0,0 +1,66 @@
1 +// (c) parameter acceptance matrix, one request per (model, variant). Exact error strings captured.
2 +import { raw, MODELS, save } from "./lib.ts";
3 +
4 +const base = (model: string) => ({ model, messages: [{ role: "user", content: "Reply with the single word: pong" }], max_tokens: 100 });
5 +const variants: Record<string, Record<string, unknown>> = {
6 + "temperature=0.2": { temperature: 0.2 },
7 + "temperature=2": { temperature: 2 },
8 + "temperature=2.5": { temperature: 2.5 },
9 + "temperature=-1": { temperature: -1 },
10 + "top_p=0.5": { top_p: 0.5 },
11 + "top_p=1.5": { top_p: 1.5 },
12 + "max_tokens=1": { max_tokens: 1 },
13 + "max_tokens=384000": { max_tokens: 384000 },
14 + "max_tokens=400000": { max_tokens: 400000 },
15 + "max_tokens=1000000": { max_tokens: 1000000 },
16 + "max_completion_tokens=50": { max_completion_tokens: 50, max_tokens: undefined },
17 + "stop=[pong]": { stop: ["pong", "Pong"] },
18 + "stop=17items": { stop: Array.from({ length: 17 }, (_, i) => `zz${i}`) },
19 + "frequency_penalty=1": { frequency_penalty: 1 },
20 + "presence_penalty=1": { presence_penalty: 1 },
21 + "logprobs+top_logprobs=3": { logprobs: true, top_logprobs: 3 },
22 + "top_logprobs=25": { logprobs: true, top_logprobs: 25 },
23 + "thinking.disabled": { thinking: { type: "disabled" } },
24 + "thinking.enabled": { thinking: { type: "enabled" } },
25 + "thinking.bogus": { thinking: { type: "auto" } },
26 + "thinking.enabled+nested_effort=low": { thinking: { type: "enabled", reasoning_effort: "low" } },
27 + "reasoning_effort=low": { reasoning_effort: "low" },
28 + "reasoning_effort=medium": { reasoning_effort: "medium" },
29 + "reasoning_effort=high": { reasoning_effort: "high" },
30 + "reasoning_effort=xhigh": { reasoning_effort: "xhigh" },
31 + "reasoning_effort=max": { reasoning_effort: "max" },
32 + "reasoning_effort=none": { reasoning_effort: "none" },
33 + "reasoning_effort=minimal": { reasoning_effort: "minimal" },
34 + "reasoning_effort=low+thinking.disabled": { reasoning_effort: "low", thinking: { type: "disabled" } },
35 + "thinking.disabled+temperature=1.5+top_p=0.9": { thinking: { type: "disabled" }, temperature: 1.5, top_p: 0.9 },
36 + "response_format=json_object": { response_format: { type: "json_object" }, messages: [{ role: "user", content: "Return a json object {\"word\":\"pong\"}" }] },
37 + "response_format=json_schema": { response_format: { type: "json_schema", json_schema: { name: "w", schema: { type: "object", properties: { word: { type: "string" } }, required: ["word"], additionalProperties: false }, strict: true } }, messages: [{ role: "user", content: "Return json {\"word\":\"pong\"}" }] },
38 + "response_format=text": { response_format: { type: "text" } },
39 + "n=2": { n: 2 },
40 + "seed=42": { seed: 42 },
41 + "parallel_tool_calls=false": { parallel_tool_calls: false, tools: [{ type: "function", function: { name: "noop", description: "does nothing", parameters: { type: "object", properties: {} } } }], tool_choice: "none" },
42 + "tool_choice=none": { tools: [{ type: "function", function: { name: "noop", description: "does nothing", parameters: { type: "object", properties: {} } } }], tool_choice: "none" },
43 + "unknown_param": { foo_bar: 1 },
44 + "developer_role": { messages: [{ role: "developer", content: "Be terse." }, { role: "user", content: "Reply with the single word: pong" }] },
45 + "user_id": { user_id: "polyllm-probe-1" },
46 + "user": { user: "polyllm-probe-1" },
47 + "name_on_user": { messages: [{ role: "user", content: "Reply with the single word: pong", name: "simon" }] },
48 + "reasoning_effort_in_stream_nonstream": { stream: false, reasoning_effort: "low" },
49 +};
50 +
51 +const out: Record<string, unknown> = {};
52 +for (const model of MODELS) {
53 + for (const [name, extra] of Object.entries(variants)) {
54 + const body: Record<string, unknown> = { ...base(model), ...extra };
55 + for (const k of Object.keys(body)) if (body[k] === undefined) delete body[k];
56 + const t0 = Date.now();
57 + const r = await raw("/chat/completions", { method: "POST", body: JSON.stringify(body) });
58 + const b: any = r.body;
59 + const summary = r.status === 200
60 + ? { ok: true, ms: Date.now() - t0, finish: b.choices?.[0]?.finish_reason, nChoices: b.choices?.length, content: (b.choices?.[0]?.message?.content ?? "").slice(0, 60), reasoning_len: b.choices?.[0]?.message?.reasoning_content?.length ?? null, logprobs: b.choices?.[0]?.logprobs ? "present" : null, usage: b.usage, system_fingerprint: b.system_fingerprint }
61 + : { ok: false, status: r.status, body: b };
62 + out[`${model}|${name}`] = summary;
63 + console.log(model, name, "→", r.status, r.status === 200 ? `finish=${summary.finish} reasoning_tokens=${b.usage?.completion_tokens_details?.reasoning_tokens} rl=${summary.reasoning_len} "${summary.content}"` : JSON.stringify(b).slice(0, 300));
64 + }
65 +}
66 +save("02-params.json", out);
added research/deepseek/03-tools-stream.ts +60 −0
@@ -0,0 +1,60 @@
1 +// (d) streamed function-call round trip in thinking and non-thinking mode; reasoning_content replay rules; tool_choice variants.
2 +import { raw, rawSSE, MODELS, save } from "./lib.ts";
3 +
4 +const tools = [{ type: "function", function: { name: "get_weather", description: "Get the current weather for a city", parameters: { type: "object", properties: { city: { type: "string", description: "City name" } }, required: ["city"] } } }];
5 +const out: Record<string, unknown> = {};
6 +
7 +async function roundTrip(model: string, thinking: "enabled" | "disabled", replayReasoning: boolean) {
8 + const key = `${model}|thinking=${thinking}|replay=${replayReasoning}`;
9 + const messages: any[] = [{ role: "user", content: "What's the weather in Montreal right now? Use the tool." }];
10 + const s = await rawSSE("/chat/completions", { model, messages, tools, stream: true, stream_options: { include_usage: true }, max_tokens: 200, thinking: { type: thinking } });
11 + if (s.status !== 200) { out[key] = { step1: s }; console.log(key, "step1 ERR", s.status, JSON.stringify(s.error)); return; }
12 + // accumulate
13 + let reasoning = "", content = "";
14 + const calls: Record<number, { id?: string; name?: string; args: string; type?: string }> = {};
15 + const toolChunks: any[] = [];
16 + let finish: string | null = null; let usage: any = null;
17 + for (const ev of s.events) {
18 + if (ev === "[DONE]") continue;
19 + if (ev.usage) usage = ev.usage;
20 + const c = ev.choices?.[0]; if (!c) continue;
21 + if (c.finish_reason) finish = c.finish_reason;
22 + const d = c.delta ?? {};
23 + if (d.reasoning_content) reasoning += d.reasoning_content;
24 + if (d.content) content += d.content;
25 + if (d.tool_calls) { toolChunks.push(d.tool_calls); for (const tc of d.tool_calls) { const slot = (calls[tc.index] ??= { args: "" }); if (tc.id) slot.id = tc.id; if (tc.type) slot.type = tc.type; if (tc.function?.name) slot.name = tc.function.name; if (tc.function?.arguments) slot.args += tc.function.arguments; } }
26 + }
27 + const tcs = Object.values(calls).map((c) => ({ id: c.id, type: c.type ?? "function", function: { name: c.name, arguments: c.args } }));
28 + const assistant: any = { role: "assistant", content: content || null, tool_calls: tcs };
29 + if (replayReasoning && reasoning) assistant.reasoning_content = reasoning;
30 + messages.push(assistant);
31 + for (const tc of tcs) messages.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify({ city: "Montreal", temp_c: 21, sky: "sunny" }) });
32 + const r2 = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model, messages, tools, max_tokens: 200, thinking: { type: thinking } }) });
33 + const b2: any = r2.body;
34 + out[key] = { step1: { status: s.status, finish, usage, reasoning_len: reasoning.length, content, toolCalls: tcs, toolChunkCount: toolChunks.length, toolChunksFirst3: toolChunks.slice(0, 3), toolChunksLast: toolChunks.slice(-1), comments: s.comments }, step2: { status: r2.status, body: r2.status === 200 ? { finish: b2.choices?.[0]?.finish_reason, content: b2.choices?.[0]?.message?.content, reasoning_len: b2.choices?.[0]?.message?.reasoning_content?.length ?? null, usage: b2.usage } : b2 } };
35 + console.log(key, "step1 finish", finish, "calls", JSON.stringify(tcs), "chunks", toolChunks.length, "reasoning", reasoning.length, "| step2", r2.status, r2.status === 200 ? `"${(b2.choices?.[0]?.message?.content ?? "").slice(0, 80)}"` : JSON.stringify(b2).slice(0, 300));
36 +}
37 +
38 +for (const model of MODELS) {
39 + await roundTrip(model, "enabled", true);
40 + await roundTrip(model, "enabled", false); // docs: should 400
41 + await roundTrip(model, "disabled", false);
42 +}
43 +
44 +// tool_choice variants on flash (non-thinking to keep it cheap)
45 +const m = "deepseek-v4-flash";
46 +for (const [name, tool_choice] of Object.entries({ required: "required", none: "none", specific: { type: "function", function: { name: "get_weather" } }, auto: "auto" })) {
47 + const r = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: m, messages: [{ role: "user", content: "Hi there, how are you?" }], tools, tool_choice, max_tokens: 100, thinking: { type: "disabled" } }) });
48 + const b: any = r.body;
49 + out[`tool_choice=${name}`] = { status: r.status, finish: b.choices?.[0]?.finish_reason, tool_calls: b.choices?.[0]?.message?.tool_calls, content: (b.choices?.[0]?.message?.content ?? "").slice(0, 80), error: r.status !== 200 ? b : undefined };
50 + console.log("tool_choice", name, r.status, b.choices?.[0]?.finish_reason, JSON.stringify(b.choices?.[0]?.message?.tool_calls ?? b).slice(0, 200));
51 +}
52 +// parallel tool calls: two cities in one prompt
53 +const rp = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: m, messages: [{ role: "user", content: "Weather in Montreal and in Quebec City? Use the tool for both." }], tools, max_tokens: 200, thinking: { type: "disabled" } }) });
54 +out.parallel = { status: rp.status, tool_calls: (rp.body as any).choices?.[0]?.message?.tool_calls, finish: (rp.body as any).choices?.[0]?.finish_reason };
55 +console.log("parallel", rp.status, JSON.stringify(out.parallel).slice(0, 400));
56 +// strict beta
57 +const rs = await raw("/chat/completions", { method: "POST", base: "https://api.deepseek.com/beta", body: JSON.stringify({ model: m, messages: [{ role: "user", content: "Weather in Montreal? Use the tool." }], tools: [{ type: "function", function: { ...tools[0].function, strict: true, parameters: { ...tools[0].function.parameters, additionalProperties: false } } }], max_tokens: 100, thinking: { type: "disabled" } }) });
58 +out.strict_beta = { status: rs.status, tool_calls: (rs.body as any).choices?.[0]?.message?.tool_calls, error: rs.status !== 200 ? rs.body : undefined };
59 +console.log("strict beta", rs.status, JSON.stringify(out.strict_beta).slice(0, 300));
60 +save("03-tools-stream.json", out);
added research/deepseek/04-json.ts +19 −0
@@ -0,0 +1,19 @@
1 +// (e) JSON mode with thinking on and off, per model; plus json_object without the word "json" in the prompt.
2 +import { raw, MODELS, save } from "./lib.ts";
3 +
4 +const out: Record<string, unknown> = {};
5 +for (const model of MODELS) {
6 + for (const thinking of ["enabled", "disabled"] as const) {
7 + const r = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model, thinking: { type: thinking }, response_format: { type: "json_object" }, max_tokens: 200, messages: [
8 + { role: "system", content: "Extract the person. Output json only, format: {\"name\": string, \"age\": number}" },
9 + { role: "user", content: "Marie is 31 years old." } ] }) });
10 + const b: any = r.body;
11 + let parsed: unknown = null; try { parsed = JSON.parse(b.choices?.[0]?.message?.content ?? ""); } catch {}
12 + out[`${model}|thinking=${thinking}`] = { status: r.status, content: b.choices?.[0]?.message?.content, parsed, reasoning_len: b.choices?.[0]?.message?.reasoning_content?.length ?? null, finish: b.choices?.[0]?.finish_reason, usage: b.usage, error: r.status !== 200 ? b : undefined };
13 + console.log(model, thinking, r.status, JSON.stringify(b.choices?.[0]?.message?.content ?? b).slice(0, 200), "valid", parsed !== null);
14 + }
15 +}
16 +const r = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: "deepseek-v4-flash", thinking: { type: "disabled" }, response_format: { type: "json_object" }, max_tokens: 100, messages: [{ role: "user", content: "Marie is 31 years old. Give her name and age." }] }) });
17 +out["no-json-word"] = { status: r.status, body: r.body };
18 +console.log("no json word in prompt →", r.status, JSON.stringify(r.body).slice(0, 300));
19 +save("04-json.json", out);
added research/deepseek/05-vision.ts +26 −0
@@ -0,0 +1,26 @@
1 +// (f) vision: 32x32 PNG data URL on all 3 models (expect 400 on non-vision); tiny 2x2; detail:low; image in system message.
2 +import { raw, MODELS, save, pngDataUrl } from "./lib.ts";
3 +
4 +const out: Record<string, unknown> = {};
5 +const img32 = pngDataUrl(32, 32);
6 +for (const model of MODELS) {
7 + const r = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model, max_tokens: 200, thinking: { type: "disabled" }, messages: [{ role: "user", content: [{ type: "text", text: "Describe this image in one sentence (colors, pattern)." }, { type: "image_url", image_url: { url: img32 } }] }] }) });
8 + const b: any = r.body;
9 + out[`${model}|32x32`] = { status: r.status, content: b.choices?.[0]?.message?.content, usage: b.usage, error: r.status !== 200 ? b : undefined };
10 + console.log(model, "32x32 →", r.status, JSON.stringify(b.choices?.[0]?.message?.content ?? b).slice(0, 200), JSON.stringify(b.usage ?? ""));
11 +}
12 +const v = "deepseek-v4-flash-vision-exp";
13 +for (const [name, body] of Object.entries({
14 + "2x2": { messages: [{ role: "user", content: [{ type: "text", text: "What color?" }, { type: "image_url", image_url: { url: pngDataUrl(2, 2) } }] }] },
15 + "detail=low": { messages: [{ role: "user", content: [{ type: "text", text: "What color?" }, { type: "image_url", image_url: { url: img32, detail: "low" } }] }] },
16 + "detail=original": { messages: [{ role: "user", content: [{ type: "text", text: "What color?" }, { type: "image_url", image_url: { url: img32, detail: "original" } }] }] },
17 + "image_in_system": { messages: [{ role: "system", content: [{ type: "text", text: "You see:" }, { type: "image_url", image_url: { url: img32 } }] }, { role: "user", content: "What color?" }] },
18 + "thinking+image": { thinking: { type: "enabled" }, reasoning_effort: "low", messages: [{ role: "user", content: [{ type: "text", text: "Dominant colors? one sentence." }, { type: "image_url", image_url: { url: img32 } }] }] },
19 + "bad_image_bytes": { messages: [{ role: "user", content: [{ type: "text", text: "What color?" }, { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }] }] },
20 +})) {
21 + const r = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: v, max_tokens: 200, thinking: { type: "disabled" }, ...body }) });
22 + const b: any = r.body;
23 + out[`${v}|${name}`] = { status: r.status, content: b.choices?.[0]?.message?.content, reasoning_len: b.choices?.[0]?.message?.reasoning_content?.length ?? null, usage: b.usage, error: r.status !== 200 ? b : undefined };
24 + console.log(v, name, "→", r.status, JSON.stringify(b.choices?.[0]?.message?.content ?? b).slice(0, 160), JSON.stringify(b.usage ?? ""));
25 +}
26 +save("05-vision.json", out);
added research/deepseek/06-errors.ts +18 −0
@@ -0,0 +1,18 @@
1 +// (g)(h) invalid key, no auth, unknown model, retired slugs, malformed body, wrong content-type.
2 +import { raw, save } from "./lib.ts";
3 +
4 +const msg = { messages: [{ role: "user", content: "hi" }], max_tokens: 5 };
5 +const out: Record<string, unknown> = {};
6 +out.invalid_key = await raw("/chat/completions", { method: "POST", key: "sk-invalid-0000000000", body: JSON.stringify({ model: "deepseek-v4-flash", ...msg }) });
7 +out.no_auth = await (async () => { const res = await fetch("https://api.deepseek.com/chat/completions", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "deepseek-v4-flash", ...msg }) }); const t = await res.text(); let b: unknown = t; try { b = JSON.parse(t); } catch {} return { status: res.status, body: b }; })();
8 +out.invalid_key_models = await raw("/models", { key: "sk-invalid-0000000000" });
9 +out.unknown_model = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: "deepseek-v99", ...msg }) });
10 +out.retired_deepseek_chat = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: "deepseek-chat", ...msg }) });
11 +out.retired_deepseek_reasoner = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: "deepseek-reasoner", ...msg }) });
12 +out.malformed_json = await raw("/chat/completions", { method: "POST", body: '{"model":"deepseek-v4-flash","messages":"nope"}' });
13 +out.broken_json = await raw("/chat/completions", { method: "POST", body: '{"model":' });
14 +out.empty_messages = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: "deepseek-v4-flash", messages: [] }) });
15 +out.missing_model = await raw("/chat/completions", { method: "POST", body: JSON.stringify(msg) });
16 +out.bad_role = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: "deepseek-v4-flash", messages: [{ role: "robot", content: "hi" }], max_tokens: 5 }) });
17 +save("06-errors.json", out);
18 +for (const [k, v] of Object.entries(out)) console.log(k, (v as any).status, JSON.stringify((v as any).body).slice(0, 300), JSON.stringify((v as any).headers ?? {}));
added research/deepseek/07-reasoning.ts +48 −0
@@ -0,0 +1,48 @@
1 +// (i)(j) reasoning: effort levels vs reasoning_tokens (max_tokens 2000), truncation semantics (does max_tokens include reasoning?),
2 +// multi-turn replay of reasoning_content without tools (ignored?), cache hit on repeated prefix.
3 +import { raw, save } from "./lib.ts";
4 +
5 +const out: Record<string, unknown> = {};
6 +const m = "deepseek-v4-flash";
7 +const q = "A bat and a ball cost $1.10 in total. The bat costs $1.00 more than the ball. How much does the ball cost? Answer with the number only.";
8 +
9 +for (const effort of ["low", "high", "max"]) {
10 + const t0 = Date.now();
11 + const r = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: m, messages: [{ role: "user", content: q }], max_tokens: 2000, reasoning_effort: effort }) });
12 + const b: any = r.body;
13 + out[`effort=${effort}`] = { status: r.status, ms: Date.now() - t0, finish: b.choices?.[0]?.finish_reason, content: b.choices?.[0]?.message?.content, reasoning_len: b.choices?.[0]?.message?.reasoning_content?.length, usage: b.usage };
14 + console.log("effort", effort, r.status, Date.now() - t0, "ms", JSON.stringify(b.usage), "→", b.choices?.[0]?.message?.content);
15 +}
16 +
17 +// truncation: max_tokens 20 in thinking mode — does the cap include reasoning tokens?
18 +const rt = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: m, messages: [{ role: "user", content: q }], max_tokens: 20, reasoning_effort: "high" }) });
19 +const bt: any = rt.body;
20 +out["max_tokens=20 thinking"] = { status: rt.status, finish: bt.choices?.[0]?.finish_reason, content: bt.choices?.[0]?.message?.content, reasoning_len: bt.choices?.[0]?.message?.reasoning_content?.length, usage: bt.usage };
21 +console.log("max_tokens=20 thinking →", rt.status, bt.choices?.[0]?.finish_reason, JSON.stringify(bt.usage), "reasoning_len", bt.choices?.[0]?.message?.reasoning_content?.length, "content", JSON.stringify(bt.choices?.[0]?.message?.content));
22 +const rt2 = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: m, messages: [{ role: "user", content: "Write a 300 word essay on rivers." }], max_tokens: 20, thinking: { type: "disabled" } }) });
23 +const bt2: any = rt2.body;
24 +out["max_tokens=20 non-thinking"] = { status: rt2.status, finish: bt2.choices?.[0]?.finish_reason, usage: bt2.usage };
25 +console.log("max_tokens=20 non-thinking →", rt2.status, bt2.choices?.[0]?.finish_reason, JSON.stringify(bt2.usage));
26 +
27 +// multi-turn: replay reasoning_content without tools (docs: ignored) + measure cache hit; then bogus reasoning_content
28 +const sys = { role: "system", content: "You are a terse assistant. " + "Context filler sentence for caching purposes. ".repeat(40) };
29 +const r1 = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: m, messages: [sys, { role: "user", content: "Remember the word 'pamplemousse'. Reply OK." }], max_tokens: 300, reasoning_effort: "low" }) });
30 +const b1: any = r1.body;
31 +const a1 = b1.choices?.[0]?.message;
32 +console.log("turn1", r1.status, JSON.stringify(b1.usage), "reasoning_len", a1?.reasoning_content?.length);
33 +const turn2 = (assistant: any) => raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: m, messages: [sys, { role: "user", content: "Remember the word 'pamplemousse'. Reply OK." }, assistant, { role: "user", content: "Which word did I ask you to remember?" }], max_tokens: 300, reasoning_effort: "low" }) });
34 +const r2a = await turn2({ role: "assistant", content: a1?.content, reasoning_content: a1?.reasoning_content });
35 +const r2b = await turn2({ role: "assistant", content: a1?.content });
36 +const r2c = await turn2({ role: "assistant", content: a1?.content, reasoning_content: "BOGUS reasoning that says the word was 'banana'." });
37 +for (const [k, r] of Object.entries({ with_reasoning: r2a, without_reasoning: r2b, bogus_reasoning: r2c })) {
38 + const b: any = r.body;
39 + out[`turn2:${k}`] = { status: r.status, content: b.choices?.[0]?.message?.content, usage: b.usage, error: r.status !== 200 ? b : undefined };
40 + console.log("turn2", k, r.status, JSON.stringify(b.usage), "→", JSON.stringify(b.choices?.[0]?.message?.content ?? b).slice(0, 200));
41 +}
42 +out.turn1 = { status: r1.status, usage: b1.usage, content: a1?.content, reasoning_len: a1?.reasoning_content?.length };
43 +
44 +// assistant message with reasoning_content but thinking disabled → accepted?
45 +const r3 = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: m, thinking: { type: "disabled" }, messages: [{ role: "user", content: "Say A" }, { role: "assistant", content: "A", reasoning_content: "thinking..." }, { role: "user", content: "Say B" }], max_tokens: 20 }) });
46 +out["reasoning_content_with_thinking_disabled"] = { status: r3.status, body: r3.body };
47 +console.log("reasoning_content with thinking disabled →", r3.status, JSON.stringify(r3.body).slice(0, 200));
48 +save("07-reasoning.json", out);
added research/deepseek/08-other-apis.ts +44 −0
@@ -0,0 +1,44 @@
1 +// Secondary surfaces: Anthropic-compatible /anthropic/v1/messages, /responses (streaming), /beta prefix completion, /beta FIM.
2 +import { raw, rawSSE, save, KEY } from "./lib.ts";
3 +
4 +const out: Record<string, unknown> = {};
5 +// Anthropic format
6 +{
7 + const res = await fetch("https://api.deepseek.com/anthropic/v1/messages", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": KEY, "anthropic-version": "2023-06-01" }, body: JSON.stringify({ model: "deepseek-v4-flash", max_tokens: 100, thinking: { type: "enabled", budget_tokens: 1024 }, messages: [{ role: "user", content: "Say hi in 3 words." }] }) });
8 + const t = await res.text(); let b: unknown = t; try { b = JSON.parse(t); } catch {}
9 + out.anthropic_messages = { status: res.status, body: b };
10 + console.log("anthropic /v1/messages →", res.status, JSON.stringify(b).slice(0, 500));
11 + const res2 = await fetch("https://api.deepseek.com/anthropic/v1/messages", { method: "POST", headers: { "Content-Type": "application/json", "x-api-key": KEY, "anthropic-version": "2023-06-01" }, body: JSON.stringify({ model: "claude-sonnet-4-5", max_tokens: 50, messages: [{ role: "user", content: "Which model are you? 5 words." }] }) });
12 + const t2 = await res2.text(); let b2: unknown = t2; try { b2 = JSON.parse(t2); } catch {}
13 + out.anthropic_claude_alias = { status: res2.status, body: b2 };
14 + console.log("anthropic claude alias →", res2.status, JSON.stringify(b2).slice(0, 300));
15 +}
16 +// Responses API streaming
17 +{
18 + const s = await rawSSE("/responses", { model: "deepseek-v4-flash", input: "Say hello in 3 words.", max_output_tokens: 100, reasoning: { effort: "low" }, stream: true });
19 + const types = s.events.filter((e) => e !== "[DONE]").map((e: any) => e.type);
20 + out.responses_stream = { status: s.status, error: (s as any).error, eventTypes: [...new Set(types)], count: s.events.length, last: s.events.slice(-2) };
21 + console.log("responses stream →", s.status, [...new Set(types)].join(","), JSON.stringify(s.events.slice(-2)).slice(0, 600));
22 + const r = await raw("/responses", { method: "POST", body: JSON.stringify({ model: "deepseek-v4-flash", input: "Say hello in 3 words.", max_output_tokens: 100, reasoning: { effort: "none" } }) });
23 + out.responses_nonstream_effort_none = { status: r.status, body: r.body };
24 + console.log("responses effort=none →", r.status, JSON.stringify(r.body).slice(0, 500));
25 +}
26 +// Beta: chat prefix completion
27 +{
28 + const r = await raw("/chat/completions", { method: "POST", base: "https://api.deepseek.com/beta", body: JSON.stringify({ model: "deepseek-v4-flash", thinking: { type: "disabled" }, max_tokens: 60, stop: ["```"], messages: [{ role: "user", content: "Write a python one-liner that prints hello" }, { role: "assistant", content: "```python\n", prefix: true }] }) });
29 + out.beta_prefix = { status: r.status, body: r.body };
30 + console.log("beta prefix →", r.status, JSON.stringify((r.body as any).choices?.[0]?.message ?? r.body).slice(0, 300));
31 + const r2 = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: "deepseek-v4-flash", thinking: { type: "disabled" }, max_tokens: 60, messages: [{ role: "user", content: "Write a python one-liner that prints hello" }, { role: "assistant", content: "```python\n", prefix: true }] }) });
32 + out.prefix_without_beta = { status: r2.status, body: r2.body };
33 + console.log("prefix without /beta →", r2.status, JSON.stringify(r2.body).slice(0, 300));
34 +}
35 +// Beta FIM
36 +{
37 + const r = await raw("/completions", { method: "POST", base: "https://api.deepseek.com/beta", body: JSON.stringify({ model: "deepseek-v4-flash", prompt: "def fib(a):", suffix: " return fib(a-1) + fib(a-2)", max_tokens: 40 }) });
38 + out.beta_fim_flash = { status: r.status, body: r.body };
39 + console.log("beta FIM flash →", r.status, JSON.stringify(r.body).slice(0, 300));
40 + const r2 = await raw("/completions", { method: "POST", base: "https://api.deepseek.com/beta", body: JSON.stringify({ model: "deepseek-v4-pro", prompt: "def fib(a):", suffix: " return fib(a-1) + fib(a-2)", max_tokens: 40 }) });
41 + out.beta_fim_pro = { status: r2.status, body: r2.body };
42 + console.log("beta FIM pro →", r2.status, JSON.stringify(r2.body).slice(0, 300));
43 +}
44 +save("08-other-apis.json", out);
added research/deepseek/09-reasoning-replay-tools.ts +42 −0
@@ -0,0 +1,42 @@
1 +// When exactly does omitting reasoning_content with tools trigger the documented 400?
2 +// Scenario: turn1 = tool call -> tool result -> final answer; turn2 = new user question. Variants strip reasoning_content at different places.
3 +import { raw, save } from "./lib.ts";
4 +
5 +const m = "deepseek-v4-flash";
6 +const tools = [{ type: "function", function: { name: "get_weather", description: "Get the current weather for a city", parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] } } }];
7 +const call = (messages: any[], extra: Record<string, unknown> = {}) => raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: m, messages, tools, max_tokens: 300, reasoning_effort: "low", ...extra }) });
8 +const out: Record<string, unknown> = {};
9 +
10 +// Build a genuine turn 1
11 +const msgs: any[] = [{ role: "user", content: "Weather in Montreal? Use the tool." }];
12 +const r1 = await call(msgs); const a1 = (r1.body as any).choices[0].message; msgs.push(a1);
13 +for (const tc of a1.tool_calls ?? []) msgs.push({ role: "tool", tool_call_id: tc.id, content: "21C sunny" });
14 +const r2 = await call(msgs); const a2 = (r2.body as any).choices[0].message; msgs.push(a2);
15 +out.turn1 = { a1: { reasoning_len: a1.reasoning_content?.length, tool_calls: a1.tool_calls?.length, content: a1.content }, a2: { reasoning_len: a2.reasoning_content?.length, content: a2.content, finish: (r2.body as any).choices[0].finish_reason, usage: (r2.body as any).usage } };
16 +console.log("turn1 built:", JSON.stringify(out.turn1));
17 +
18 +const strip = (arr: any[], which: (i: number, msg: any) => boolean) => arr.map((msg, i) => (msg.role === "assistant" && which(i, msg) ? Object.fromEntries(Object.entries(msg).filter(([k]) => k !== "reasoning_content")) : msg));
19 +const q2 = { role: "user", content: "And in Quebec City? Use the tool." };
20 +const variants: Record<string, any[]> = {
21 + "full_replay": [...msgs, q2],
22 + "strip_all": [...strip(msgs, () => true), q2],
23 + "strip_toolcall_msg_only": [...strip(msgs, (_, msg) => !!msg.tool_calls), q2],
24 + "strip_final_answer_only": [...strip(msgs, (_, msg) => !msg.tool_calls), q2],
25 + "empty_string_reasoning": [...msgs.map((msg) => (msg.role === "assistant" ? { ...msg, reasoning_content: "" } : msg)), q2],
26 + "null_reasoning": [...msgs.map((msg) => (msg.role === "assistant" ? { ...msg, reasoning_content: null } : msg)), q2],
27 +};
28 +for (const [name, messages] of Object.entries(variants)) {
29 + const r = await call(messages);
30 + const b: any = r.body;
31 + out[name] = { status: r.status, finish: b.choices?.[0]?.finish_reason, tool_calls: b.choices?.[0]?.message?.tool_calls?.length, reasoning_len: b.choices?.[0]?.message?.reasoning_content?.length, usage: b.usage, error: r.status !== 200 ? b : undefined };
32 + console.log(name, "→", r.status, r.status === 200 ? `finish=${b.choices?.[0]?.finish_reason} cache_hit=${b.usage?.prompt_cache_hit_tokens}` : JSON.stringify(b).slice(0, 300));
33 +}
34 +// same conversation, tools param removed on turn 2, full replay (reasoning ignored?)
35 +const r3 = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: m, messages: [...msgs, { role: "user", content: "Summarize what you told me in 5 words." }], max_tokens: 300, reasoning_effort: "low" }) });
36 +out.no_tools_param_after_tool_turn = { status: r3.status, body: r3.status === 200 ? { content: (r3.body as any).choices[0].message.content, usage: (r3.body as any).usage } : r3.body };
37 +console.log("no tools param after tool turn →", r3.status, JSON.stringify(out.no_tools_param_after_tool_turn).slice(0, 300));
38 +// thinking disabled on turn 2 with reasoning_content present in history + tools
39 +const r4 = await call([...msgs, q2], { thinking: { type: "disabled" }, reasoning_effort: undefined });
40 +out.thinking_disabled_turn2_with_reasoning_history = { status: r4.status, finish: (r4.body as any).choices?.[0]?.finish_reason, error: r4.status !== 200 ? r4.body : undefined };
41 +console.log("thinking disabled turn2 w/ reasoning history →", r4.status, JSON.stringify(r4.body).slice(0, 200));
42 +save("09-reasoning-replay-tools.json", out);
added research/deepseek/10-misc.ts +52 −0
@@ -0,0 +1,52 @@
1 +// Misc: undocumented thinking.type=adaptive, stream without include_usage, non-thinking stream delta shape, logprobs shape, image silently dropped on pro.
2 +import { raw, rawSSE, save, pngDataUrl } from "./lib.ts";
3 +
4 +const out: Record<string, unknown> = {};
5 +const m = "deepseek-v4-flash";
6 +const hard = "A bat and a ball cost $1.10 in total. The bat costs $1.00 more than the ball. How much does the ball cost? Number only.";
7 +for (const [name, body] of Object.entries({
8 + "adaptive_easy": { thinking: { type: "adaptive" }, messages: [{ role: "user", content: "Reply with the single word: pong" }] },
9 + "adaptive_hard": { thinking: { type: "adaptive" }, messages: [{ role: "user", content: hard }] },
10 + "adaptive+effort_low": { thinking: { type: "adaptive" }, reasoning_effort: "low", messages: [{ role: "user", content: hard }] },
11 + "enabled_easy": { thinking: { type: "enabled" }, messages: [{ role: "user", content: "Reply with the single word: pong" }] },
12 +})) {
13 + const r = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: m, max_tokens: 1500, ...body }) });
14 + const b: any = r.body;
15 + out[name] = { status: r.status, finish: b.choices?.[0]?.finish_reason, content: b.choices?.[0]?.message?.content, reasoning_len: b.choices?.[0]?.message?.reasoning_content?.length ?? null, usage: b.usage, error: r.status !== 200 ? b : undefined };
16 + console.log(name, r.status, JSON.stringify(b.usage?.completion_tokens_details), "rl", b.choices?.[0]?.message?.reasoning_content?.length ?? null, "→", JSON.stringify(b.choices?.[0]?.message?.content ?? b).slice(0, 120));
17 +}
18 +// stream WITHOUT include_usage
19 +{
20 + const s = await rawSSE("/chat/completions", { model: m, messages: [{ role: "user", content: "Say hi." }], max_tokens: 100, stream: true, thinking: { type: "disabled" } });
21 + const last = s.events.slice(-3);
22 + out.stream_no_include_usage = { status: s.status, last, deltaKeysFirst: Object.keys((s.events[0] as any)?.choices?.[0]?.delta ?? {}) };
23 + console.log("stream no include_usage: usage on finish chunk?", JSON.stringify((last[0] as any)?.usage ?? (last[1] as any)?.usage), "first delta", JSON.stringify((s.events[0] as any)?.choices?.[0]?.delta));
24 +}
25 +// stream thinking disabled + include_usage: do deltas still carry reasoning_content key?
26 +{
27 + const s = await rawSSE("/chat/completions", { model: m, messages: [{ role: "user", content: "Say hi." }], max_tokens: 100, stream: true, stream_options: { include_usage: true }, reasoning_effort: "none" });
28 + out.stream_effort_none = { status: s.status, first2: s.events.slice(0, 2), last3: s.events.slice(-3) };
29 + console.log("stream effort=none first delta", JSON.stringify((s.events[0] as any)?.choices?.[0]?.delta), "last", JSON.stringify(s.events.slice(-2)).slice(0, 500));
30 +}
31 +// logprobs shape
32 +{
33 + const r = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: m, messages: [{ role: "user", content: "Say hi." }], max_tokens: 5, thinking: { type: "disabled" }, logprobs: true, top_logprobs: 2 }) });
34 + out.logprobs = { status: r.status, logprobs: (r.body as any).choices?.[0]?.logprobs };
35 + console.log("logprobs", JSON.stringify((r.body as any).choices?.[0]?.logprobs).slice(0, 400));
36 +}
37 +// image on pro: is it dropped? ask a question only answerable from the image
38 +{
39 + const r = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: "deepseek-v4-pro", thinking: { type: "disabled" }, max_tokens: 60, messages: [{ role: "user", content: [{ type: "text", text: "Is there an image attached to this message? Answer yes or no and say what you received." }, { type: "image_url", image_url: { url: pngDataUrl(32, 32) } }] }] }) });
40 + out.pro_image_dropped = { status: r.status, content: (r.body as any).choices?.[0]?.message?.content, usage: (r.body as any).usage };
41 + console.log("pro image →", r.status, JSON.stringify(out.pro_image_dropped).slice(0, 400));
42 + const r2 = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: "deepseek-v4-pro", thinking: { type: "disabled" }, max_tokens: 5, messages: [{ role: "user", content: [{ type: "text", text: "Is there an image attached to this message? Answer yes or no and say what you received." }] }] }) });
43 + out.pro_text_only_tokens = (r2.body as any).usage;
44 + console.log("pro same text w/o image prompt_tokens", (r2.body as any).usage?.prompt_tokens, "vs with image", (r.body as any).usage?.prompt_tokens);
45 +}
46 +// /v1 streaming + trailing slash variants
47 +{
48 + const r = await raw("/v1/chat/completions/", { method: "POST", body: JSON.stringify({ model: m, messages: [{ role: "user", content: "hi" }], max_tokens: 2, thinking: { type: "disabled" } }) });
49 + out.trailing_slash = { status: r.status, body: r.status === 200 ? "ok" : r.body };
50 + console.log("trailing slash →", r.status);
51 +}
52 +save("10-misc.json", out);
added research/deepseek/lib.ts +99 −0
@@ -0,0 +1,99 @@
1 +// Shared helpers for DeepSeek probes. Key from process.env.DEEPSEEK_API_KEY (never logged).
2 +import { mkdirSync, writeFileSync } from "node:fs";
3 +import * as zlib from "node:zlib";
4 +import OpenAI from "openai";
5 +
6 +export const BASE = "https://api.deepseek.com";
7 +export const KEY = process.env.DEEPSEEK_API_KEY ?? "";
8 +if (!KEY) {
9 + console.error("DEEPSEEK_API_KEY missing (run: set -a; . ./.env; set +a)");
10 + process.exit(1);
11 +}
12 +export const OUT = new URL("./out/", import.meta.url).pathname;
13 +mkdirSync(OUT, { recursive: true });
14 +
15 +export const client = new OpenAI({ apiKey: KEY, baseURL: BASE, timeout: 300_000, maxRetries: 0 });
16 +export const MODELS = ["deepseek-v4-flash", "deepseek-v4-pro", "deepseek-v4-flash-vision-exp"];
17 +
18 +export function save(name: string, data: unknown) {
19 + writeFileSync(`${OUT}${name}`, JSON.stringify(data, null, 2));
20 + console.log(`saved out/${name}`);
21 +}
22 +
23 +export function pickHeaders(h: Headers) {
24 + const out: Record<string, string> = {};
25 + h.forEach((v, k) => {
26 + if (/ratelimit|request-id|x-request|retry-after|content-type|server|date|cf-ray|x-ds|x-deepseek|via|alt-svc/i.test(k)) out[k] = v;
27 + });
28 + return out;
29 +}
30 +
31 +/** Raw request; returns status, selected headers, parsed body. */
32 +export async function raw(path: string, init: RequestInit & { key?: string; base?: string } = {}) {
33 + const { key, base, ...rest } = init;
34 + const res = await fetch(`${base ?? BASE}${path}`, {
35 + ...rest,
36 + headers: { "Content-Type": "application/json", Authorization: `Bearer ${key ?? KEY}`, ...(rest.headers ?? {}) },
37 + });
38 + const text = await res.text();
39 + let body: unknown = text;
40 + try { body = JSON.parse(text); } catch {}
41 + return { status: res.status, headers: pickHeaders(res.headers), body };
42 +}
43 +
44 +/** Raw SSE POST; returns parsed data events + raw comment lines (keep-alive). */
45 +export async function rawSSE(path: string, payload: unknown, key?: string) {
46 + const res = await fetch(`${BASE}${path}`, {
47 + method: "POST",
48 + headers: { "Content-Type": "application/json", Authorization: `Bearer ${key ?? KEY}` },
49 + body: JSON.stringify(payload),
50 + });
51 + const headers = pickHeaders(res.headers);
52 + if (!res.ok || !res.body) {
53 + const text = await res.text();
54 + let body: unknown = text;
55 + try { body = JSON.parse(text); } catch {}
56 + return { status: res.status, headers, error: body, events: [] as any[], comments: [] as string[] };
57 + }
58 + const reader = res.body.getReader();
59 + const dec = new TextDecoder();
60 + let buf = "";
61 + const events: any[] = [];
62 + const comments: string[] = [];
63 + for (;;) {
64 + const { value, done } = await reader.read();
65 + if (done) break;
66 + buf += dec.decode(value, { stream: true });
67 + let idx;
68 + while ((idx = buf.indexOf("\n\n")) >= 0) {
69 + const block = buf.slice(0, idx);
70 + buf = buf.slice(idx + 2);
71 + for (const line of block.split("\n")) {
72 + if (line.startsWith(":")) comments.push(line);
73 + else if (line.startsWith("data:")) {
74 + const d = line.slice(5).trim();
75 + if (d === "[DONE]") events.push("[DONE]");
76 + else { try { events.push(JSON.parse(d)); } catch { events.push({ unparsed: d }); } }
77 + } else if (line.trim()) comments.push("??" + line);
78 + }
79 + }
80 + }
81 + return { status: res.status, headers, events, comments };
82 +}
83 +
84 +export function errInfo(e: any) {
85 + return { status: e?.status, message: e?.message, error: e?.error ?? e?.response?.data ?? null };
86 +}
87 +
88 +/** 32x32 RGB PNG (red/blue checker) as data URL, generated with zlib. */
89 +export function pngDataUrl(w = 32, h = 32) {
90 + const crcTable = (() => { const t = new Uint32Array(256); for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; t[n] = c >>> 0; } return t; })();
91 + const crc = (buf: Buffer) => { let c = 0xffffffff; for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ 0xffffffff) >>> 0; };
92 + const chunk = (type: string, data: Buffer) => { const len = Buffer.alloc(4); len.writeUInt32BE(data.length); const td = Buffer.concat([Buffer.from(type), data]); const c = Buffer.alloc(4); c.writeUInt32BE(crc(td)); return Buffer.concat([len, td, c]); };
93 + const ihdr = Buffer.alloc(13); ihdr.writeUInt32BE(w, 0); ihdr.writeUInt32BE(h, 4); ihdr[8] = 8; ihdr[9] = 2; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
94 + const rows: Buffer[] = [];
95 + for (let y = 0; y < h; y++) { const row = Buffer.alloc(1 + w * 3); row[0] = 0; for (let x = 0; x < w; x++) { const red = ((x >> 3) + (y >> 3)) % 2 === 0; row[1 + x * 3] = red ? 220 : 30; row[2 + x * 3] = 30; row[3 + x * 3] = red ? 30 : 220; } rows.push(row); }
96 + const idat = zlib.deflateSync(Buffer.concat(rows));
97 + const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", Buffer.alloc(0))]);
98 + return `data:image/png;base64,${png.toString("base64")}`;
99 +}
added research/deepseek/package.json +25 −0
@@ -0,0 +1,25 @@
1 +{
2 + "name": "deepseek",
3 + "version": "1.0.0",
4 + "description": "",
5 + "main": "index.js",
6 + "scripts": {
7 + "test": "echo \"Error: no test specified\" && exit 1"
8 + },
9 + "keywords": [],
10 + "author": "",
11 + "license": "ISC",
12 + "devEngines": {
13 + "packageManager": {
14 + "name": "pnpm",
15 + "version": "^11.1.2",
16 + "onFail": "download"
17 + }
18 + },
19 + "type": "module",
20 + "private": true,
21 + "dependencies": {
22 + "openai": "^7.10.0",
23 + "tsx": "^4.23.13"
24 + }
25 +}
\ No newline at end of file
added research/deepseek/pnpm-lock.yaml +532 −0
@@ -0,0 +1,532 @@
1 +---
2 +lockfileVersion: '9.0'
3 +
4 +importers:
5 +
6 + .:
7 + configDependencies: {}
8 + packageManagerDependencies:
9 + '@pnpm/exe':
10 + specifier: ^11.1.2
11 + version: 11.26.0
12 + pnpm:
13 + specifier: ^11.1.2
14 + version: 11.26.0
15 +
16 +packages:
17 +
18 + '@pnpm/exe@11.26.0':
19 + resolution: {integrity: sha512-eeiNi7WeXulOO1BzDAU9HIk+N3dokG+xwKPUupNQoZT5auL3rh2pFW9DqdIEnCC2xtt/hZd6UGtKyb6OMpqndw==}
20 + hasBin: true
21 +
22 + '@pnpm/linux-arm64@11.26.0':
23 + resolution: {integrity: sha512-M0IDuD4hbXxpLBsj1/I9pODcxu/gxTDtbyQmsVGYu1TZwCCpf6YU1x3lzq8aCGjrysmXYQAtCiY8GBjdw4UGFg==}
24 + cpu: [arm64]
25 + os: [linux]
26 +
27 + '@pnpm/linux-x64@11.26.0':
28 + resolution: {integrity: sha512-nUuNRsFCGVje3FHtOaWxIQl2EP4NBktKr+PpLN7uhuWnJCCMN/F9RKjNJAM+dUPyvAZs8VDvS0kA8J55kyybyg==}
29 + cpu: [x64]
30 + os: [linux]
31 +
32 + '@pnpm/linuxstatic-arm64@11.26.0':
33 + resolution: {integrity: sha512-+dXROkvdWjskrQtjwJAFuJI7Q2uTkghBfLsd0vqRmorX3fPuinhoRjJJ/UPtWXzJqwcByJBmdJRW+q2964m5qQ==}
34 + cpu: [arm64]
35 + os: [linux]
36 + libc: [musl]
37 +
38 + '@pnpm/linuxstatic-x64@11.26.0':
39 + resolution: {integrity: sha512-0eXE6spzIBdCbYhJZQ0u/sIOD0v30sqk2PrzoixzsNMc7S/lhd7EkBMVJ6cCPiCL2TXvOsrJC2SpWPBK769A9A==}
40 + cpu: [x64]
41 + os: [linux]
42 + libc: [musl]
43 +
44 + '@pnpm/macos-arm64@11.26.0':
45 + resolution: {integrity: sha512-Za3kKV89Zj0SFzQf6zEUTUIy13xH8UiNyb7aILDRjPiTvTaUSCh3q+nvljISfGH1XcopXvo+p3K8Q2nrKYWAug==}
46 + cpu: [arm64]
47 + os: [darwin]
48 +
49 + '@pnpm/win-arm64@11.26.0':
50 + resolution: {integrity: sha512-5akeLtbqbFDdJtCV4qgLmDBV3R+XNs6E7h7Xb4ZBzS3rLRp8e/y/ZdgrZaGF5Kjo45g0BLAxdGUREHbU2/lGUw==}
51 + cpu: [arm64]
52 + os: [win32]
53 +
54 + '@pnpm/win-x64@11.26.0':
55 + resolution: {integrity: sha512-f15SfIo7nppxBII+STy6jpd1z3LgGTZh6skfuxKA/xHmkzopnQJQXr4hBnl1rJLJAnmuOhN8BssaC4LiXmS78w==}
56 + cpu: [x64]
57 + os: [win32]
58 +
59 + '@reflink/reflink-darwin-arm64@0.1.19':
60 + resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==}
61 + engines: {node: '>= 10'}
62 + cpu: [arm64]
63 + os: [darwin]
64 +
65 + '@reflink/reflink-darwin-x64@0.1.19':
66 + resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==}
67 + engines: {node: '>= 10'}
68 + cpu: [x64]
69 + os: [darwin]
70 +
71 + '@reflink/reflink-linux-arm64-gnu@0.1.19':
72 + resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==}
73 + engines: {node: '>= 10'}
74 + cpu: [arm64]
75 + os: [linux]
76 + libc: [glibc]
77 +
78 + '@reflink/reflink-linux-arm64-musl@0.1.19':
79 + resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==}
80 + engines: {node: '>= 10'}
81 + cpu: [arm64]
82 + os: [linux]
83 + libc: [musl]
84 +
85 + '@reflink/reflink-linux-x64-gnu@0.1.19':
86 + resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==}
87 + engines: {node: '>= 10'}
88 + cpu: [x64]
89 + os: [linux]
90 + libc: [glibc]
91 +
92 + '@reflink/reflink-linux-x64-musl@0.1.19':
93 + resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==}
94 + engines: {node: '>= 10'}
95 + cpu: [x64]
96 + os: [linux]
97 + libc: [musl]
98 +
99 + '@reflink/reflink-win32-arm64-msvc@0.1.19':
100 + resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==}
101 + engines: {node: '>= 10'}
102 + cpu: [arm64]
103 + os: [win32]
104 +
105 + '@reflink/reflink-win32-x64-msvc@0.1.19':
106 + resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==}
107 + engines: {node: '>= 10'}
108 + cpu: [x64]
109 + os: [win32]
110 +
111 + '@reflink/reflink@0.1.19':
112 + resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==}
113 + engines: {node: '>= 10'}
114 +
115 + detect-libc@2.1.2:
116 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
117 + engines: {node: '>=8'}
118 +
119 + pnpm@11.26.0:
120 + resolution: {integrity: sha512-/A4r+JC5+YNhHxq2jAY3vOkUOQZTaZ+DwaeLAF7SXyyB53kgyP2O7i7PC1iyjNywCoTZf2m898VrLzRHECOGZA==}
121 + engines: {node: '>=22.13'}
122 + hasBin: true
123 +
124 +snapshots:
125 +
126 + '@pnpm/exe@11.26.0':
127 + dependencies:
128 + '@reflink/reflink': 0.1.19
129 + detect-libc: 2.1.2
130 + optionalDependencies:
131 + '@pnpm/linux-arm64': 11.26.0
132 + '@pnpm/linux-x64': 11.26.0
133 + '@pnpm/linuxstatic-arm64': 11.26.0
134 + '@pnpm/linuxstatic-x64': 11.26.0
135 + '@pnpm/macos-arm64': 11.26.0
136 + '@pnpm/win-arm64': 11.26.0
137 + '@pnpm/win-x64': 11.26.0
138 +
139 + '@pnpm/linux-arm64@11.26.0':
140 + optional: true
141 +
142 + '@pnpm/linux-x64@11.26.0':
143 + optional: true
144 +
145 + '@pnpm/linuxstatic-arm64@11.26.0':
146 + optional: true
147 +
148 + '@pnpm/linuxstatic-x64@11.26.0':
149 + optional: true
150 +
151 + '@pnpm/macos-arm64@11.26.0':
152 + optional: true
153 +
154 + '@pnpm/win-arm64@11.26.0':
155 + optional: true
156 +
157 + '@pnpm/win-x64@11.26.0':
158 + optional: true
159 +
160 + '@reflink/reflink-darwin-arm64@0.1.19':
161 + optional: true
162 +
163 + '@reflink/reflink-darwin-x64@0.1.19':
164 + optional: true
165 +
166 + '@reflink/reflink-linux-arm64-gnu@0.1.19':
167 + optional: true
168 +
169 + '@reflink/reflink-linux-arm64-musl@0.1.19':
170 + optional: true
171 +
172 + '@reflink/reflink-linux-x64-gnu@0.1.19':
173 + optional: true
174 +
175 + '@reflink/reflink-linux-x64-musl@0.1.19':
176 + optional: true
177 +
178 + '@reflink/reflink-win32-arm64-msvc@0.1.19':
179 + optional: true
180 +
181 + '@reflink/reflink-win32-x64-msvc@0.1.19':
182 + optional: true
183 +
184 + '@reflink/reflink@0.1.19':
185 + optionalDependencies:
186 + '@reflink/reflink-darwin-arm64': 0.1.19
187 + '@reflink/reflink-darwin-x64': 0.1.19
188 + '@reflink/reflink-linux-arm64-gnu': 0.1.19
189 + '@reflink/reflink-linux-arm64-musl': 0.1.19
190 + '@reflink/reflink-linux-x64-gnu': 0.1.19
191 + '@reflink/reflink-linux-x64-musl': 0.1.19
192 + '@reflink/reflink-win32-arm64-msvc': 0.1.19
193 + '@reflink/reflink-win32-x64-msvc': 0.1.19
194 +
195 + detect-libc@2.1.2: {}
196 +
197 + pnpm@11.26.0: {}
198 +
199 +---
200 +lockfileVersion: '9.0'
201 +
202 +settings:
203 + autoInstallPeers: true
204 + excludeLinksFromLockfile: false
205 +
206 +importers:
207 +
208 + .:
209 + dependencies:
210 + openai:
211 + specifier: ^7.10.0
212 + version: 7.10.0
213 + tsx:
214 + specifier: ^4.23.13
215 + version: 4.23.13
216 +
217 +packages:
218 +
219 + '@esbuild/aix-ppc64@0.28.2':
220 + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
221 + engines: {node: '>=18'}
222 + cpu: [ppc64]
223 + os: [aix]
224 +
225 + '@esbuild/android-arm64@0.28.2':
226 + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
227 + engines: {node: '>=18'}
228 + cpu: [arm64]
229 + os: [android]
230 +
231 + '@esbuild/android-arm@0.28.2':
232 + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
233 + engines: {node: '>=18'}
234 + cpu: [arm]
235 + os: [android]
236 +
237 + '@esbuild/android-x64@0.28.2':
238 + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
239 + engines: {node: '>=18'}
240 + cpu: [x64]
241 + os: [android]
242 +
243 + '@esbuild/darwin-arm64@0.28.2':
244 + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
245 + engines: {node: '>=18'}
246 + cpu: [arm64]
247 + os: [darwin]
248 +
249 + '@esbuild/darwin-x64@0.28.2':
250 + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
251 + engines: {node: '>=18'}
252 + cpu: [x64]
253 + os: [darwin]
254 +
255 + '@esbuild/freebsd-arm64@0.28.2':
256 + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
257 + engines: {node: '>=18'}
258 + cpu: [arm64]
259 + os: [freebsd]
260 +
261 + '@esbuild/freebsd-x64@0.28.2':
262 + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
263 + engines: {node: '>=18'}
264 + cpu: [x64]
265 + os: [freebsd]
266 +
267 + '@esbuild/linux-arm64@0.28.2':
268 + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
269 + engines: {node: '>=18'}
270 + cpu: [arm64]
271 + os: [linux]
272 +
273 + '@esbuild/linux-arm@0.28.2':
274 + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
275 + engines: {node: '>=18'}
276 + cpu: [arm]
277 + os: [linux]
278 +
279 + '@esbuild/linux-ia32@0.28.2':
280 + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
281 + engines: {node: '>=18'}
282 + cpu: [ia32]
283 + os: [linux]
284 +
285 + '@esbuild/linux-loong64@0.28.2':
286 + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
287 + engines: {node: '>=18'}
288 + cpu: [loong64]
289 + os: [linux]
290 +
291 + '@esbuild/linux-mips64el@0.28.2':
292 + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
293 + engines: {node: '>=18'}
294 + cpu: [mips64el]
295 + os: [linux]
296 +
297 + '@esbuild/linux-ppc64@0.28.2':
298 + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
299 + engines: {node: '>=18'}
300 + cpu: [ppc64]
301 + os: [linux]
302 +
303 + '@esbuild/linux-riscv64@0.28.2':
304 + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
305 + engines: {node: '>=18'}
306 + cpu: [riscv64]
307 + os: [linux]
308 +
309 + '@esbuild/linux-s390x@0.28.2':
310 + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
311 + engines: {node: '>=18'}
312 + cpu: [s390x]
313 + os: [linux]
314 +
315 + '@esbuild/linux-x64@0.28.2':
316 + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
317 + engines: {node: '>=18'}
318 + cpu: [x64]
319 + os: [linux]
320 +
321 + '@esbuild/netbsd-arm64@0.28.2':
322 + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
323 + engines: {node: '>=18'}
324 + cpu: [arm64]
325 + os: [netbsd]
326 +
327 + '@esbuild/netbsd-x64@0.28.2':
328 + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
329 + engines: {node: '>=18'}
330 + cpu: [x64]
331 + os: [netbsd]
332 +
333 + '@esbuild/openbsd-arm64@0.28.2':
334 + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
335 + engines: {node: '>=18'}
336 + cpu: [arm64]
337 + os: [openbsd]
338 +
339 + '@esbuild/openbsd-x64@0.28.2':
340 + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
341 + engines: {node: '>=18'}
342 + cpu: [x64]
343 + os: [openbsd]
344 +
345 + '@esbuild/openharmony-arm64@0.28.2':
346 + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
347 + engines: {node: '>=18'}
348 + cpu: [arm64]
349 + os: [openharmony]
350 +
351 + '@esbuild/sunos-x64@0.28.2':
352 + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
353 + engines: {node: '>=18'}
354 + cpu: [x64]
355 + os: [sunos]
356 +
357 + '@esbuild/win32-arm64@0.28.2':
358 + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
359 + engines: {node: '>=18'}
360 + cpu: [arm64]
361 + os: [win32]
362 +
363 + '@esbuild/win32-ia32@0.28.2':
364 + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
365 + engines: {node: '>=18'}
366 + cpu: [ia32]
367 + os: [win32]
368 +
369 + '@esbuild/win32-x64@0.28.2':
370 + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
371 + engines: {node: '>=18'}
372 + cpu: [x64]
373 + os: [win32]
374 +
375 + esbuild@0.28.2:
376 + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
377 + engines: {node: '>=18'}
378 + hasBin: true
379 +
380 + fsevents@2.3.3:
381 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
382 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
383 + os: [darwin]
384 +
385 + openai@7.10.0:
386 + resolution: {integrity: sha512-sn9t2Kls7O52PwuF9BUTYNu4Gk/r0lXJyrgaNht4TNRlZFb3dJIGO0RciSgjARGCBRtWjySubAQFJttlzUvGQQ==}
387 + engines: {node: '>=22.0.0'}
388 + peerDependencies:
389 + '@aws-sdk/credential-provider-node': '>=3.972.0 <4'
390 + '@smithy/hash-node': '>=4.3.0 <5'
391 + '@smithy/signature-v4': '>=5.4.0 <6'
392 + undici: '>=5 <9'
393 + ws: ^8.21.0
394 + zod: ^3.25 || ^4.0
395 + peerDependenciesMeta:
396 + '@aws-sdk/credential-provider-node':
397 + optional: true
398 + '@smithy/hash-node':
399 + optional: true
400 + '@smithy/signature-v4':
401 + optional: true
402 + undici:
403 + optional: true
404 + ws:
405 + optional: true
406 + zod:
407 + optional: true
408 +
409 + tsx@4.23.13:
410 + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
411 + engines: {node: '>=18.0.0'}
412 + hasBin: true
413 +
414 +snapshots:
415 +
416 + '@esbuild/aix-ppc64@0.28.2':
417 + optional: true
418 +
419 + '@esbuild/android-arm64@0.28.2':
420 + optional: true
421 +
422 + '@esbuild/android-arm@0.28.2':
423 + optional: true
424 +
425 + '@esbuild/android-x64@0.28.2':
426 + optional: true
427 +
428 + '@esbuild/darwin-arm64@0.28.2':
429 + optional: true
430 +
431 + '@esbuild/darwin-x64@0.28.2':
432 + optional: true
433 +
434 + '@esbuild/freebsd-arm64@0.28.2':
435 + optional: true
436 +
437 + '@esbuild/freebsd-x64@0.28.2':
438 + optional: true
439 +
440 + '@esbuild/linux-arm64@0.28.2':
441 + optional: true
442 +
443 + '@esbuild/linux-arm@0.28.2':
444 + optional: true
445 +
446 + '@esbuild/linux-ia32@0.28.2':
447 + optional: true
448 +
449 + '@esbuild/linux-loong64@0.28.2':
450 + optional: true
451 +
452 + '@esbuild/linux-mips64el@0.28.2':
453 + optional: true
454 +
455 + '@esbuild/linux-ppc64@0.28.2':
456 + optional: true
457 +
458 + '@esbuild/linux-riscv64@0.28.2':
459 + optional: true
460 +
461 + '@esbuild/linux-s390x@0.28.2':
462 + optional: true
463 +
464 + '@esbuild/linux-x64@0.28.2':
465 + optional: true
466 +
467 + '@esbuild/netbsd-arm64@0.28.2':
468 + optional: true
469 +
470 + '@esbuild/netbsd-x64@0.28.2':
471 + optional: true
472 +
473 + '@esbuild/openbsd-arm64@0.28.2':
474 + optional: true
475 +
476 + '@esbuild/openbsd-x64@0.28.2':
477 + optional: true
478 +
479 + '@esbuild/openharmony-arm64@0.28.2':
480 + optional: true
481 +
482 + '@esbuild/sunos-x64@0.28.2':
483 + optional: true
484 +
485 + '@esbuild/win32-arm64@0.28.2':
486 + optional: true
487 +
488 + '@esbuild/win32-ia32@0.28.2':
489 + optional: true
490 +
491 + '@esbuild/win32-x64@0.28.2':
492 + optional: true
493 +
494 + esbuild@0.28.2:
495 + optionalDependencies:
496 + '@esbuild/aix-ppc64': 0.28.2
497 + '@esbuild/android-arm': 0.28.2
498 + '@esbuild/android-arm64': 0.28.2
499 + '@esbuild/android-x64': 0.28.2
500 + '@esbuild/darwin-arm64': 0.28.2
501 + '@esbuild/darwin-x64': 0.28.2
502 + '@esbuild/freebsd-arm64': 0.28.2
503 + '@esbuild/freebsd-x64': 0.28.2
504 + '@esbuild/linux-arm': 0.28.2
505 + '@esbuild/linux-arm64': 0.28.2
506 + '@esbuild/linux-ia32': 0.28.2
507 + '@esbuild/linux-loong64': 0.28.2
508 + '@esbuild/linux-mips64el': 0.28.2
509 + '@esbuild/linux-ppc64': 0.28.2
510 + '@esbuild/linux-riscv64': 0.28.2
511 + '@esbuild/linux-s390x': 0.28.2
512 + '@esbuild/linux-x64': 0.28.2
513 + '@esbuild/netbsd-arm64': 0.28.2
514 + '@esbuild/netbsd-x64': 0.28.2
515 + '@esbuild/openbsd-arm64': 0.28.2
516 + '@esbuild/openbsd-x64': 0.28.2
517 + '@esbuild/openharmony-arm64': 0.28.2
518 + '@esbuild/sunos-x64': 0.28.2
519 + '@esbuild/win32-arm64': 0.28.2
520 + '@esbuild/win32-ia32': 0.28.2
521 + '@esbuild/win32-x64': 0.28.2
522 +
523 + fsevents@2.3.3:
524 + optional: true
525 +
526 + openai@7.10.0: {}
527 +
528 + tsx@4.23.13:
529 + dependencies:
530 + esbuild: 0.28.2
531 + optionalDependencies:
532 + fsevents: 2.3.3
added research/kimi/00-models.ts +24 −0
@@ -0,0 +1,24 @@
1 +import { rawGet, save, KEY } from "./lib.ts";
2 +
3 +const models = await rawGet("/models");
4 +save("00-models", models);
5 +console.log("GET /models", models.status, models.headers);
6 +for (const m of (models.body as any).data ?? []) console.log(JSON.stringify(m));
7 +
8 +for (const id of ["kimi-k3", "kimi-k2.6"]) {
9 + const r = await rawGet(`/models/${id}`);
10 + save(`00-model-${id}`, r);
11 + console.log(`GET /models/${id}`, r.status, JSON.stringify(r.body).slice(0, 400));
12 +}
13 +
14 +const bal = await rawGet("/users/me/balance");
15 +save("00-balance", bal);
16 +console.log("GET /users/me/balance", bal.status, JSON.stringify(bal.body));
17 +
18 +const est = await fetch("https://api.moonshot.ai/v1/tokenizers/estimate-token-count", {
19 + method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${KEY}` },
20 + body: JSON.stringify({ model: "kimi-k3", messages: [{ role: "user", content: "Hello, how many tokens is this?" }] }),
21 +});
22 +const estBody = await est.text();
23 +save("00-estimate", { status: est.status, body: estBody });
24 +console.log("POST /tokenizers/estimate-token-count", est.status, estBody.slice(0, 300));
added research/kimi/01-chat-stream.ts +48 −0
@@ -0,0 +1,48 @@
1 +import { MODELS, rawPost, rawSSE, save, short } from "./lib.ts";
2 +
3 +const results: Record<string, unknown> = {};
4 +for (const model of MODELS) {
5 + const t0 = Date.now();
6 + // (a) tiny non-streaming
7 + const a = await rawPost("/chat/completions", {
8 + model, messages: [{ role: "user", content: "What is 2+2? Answer in one short sentence." }], max_tokens: 200,
9 + });
10 + const ms = Date.now() - t0;
11 + const msg = (a.body as any)?.choices?.[0]?.message;
12 + console.log(`\n[${model}] non-stream ${a.status} ${ms}ms finish=${(a.body as any)?.choices?.[0]?.finish_reason}`);
13 + console.log(" headers:", JSON.stringify(a.headers));
14 + console.log(" content:", short(msg?.content, 200));
15 + console.log(" reasoning_content:", short(msg?.reasoning_content, 200));
16 + console.log(" usage:", JSON.stringify((a.body as any)?.usage));
17 + console.log(" top-level keys:", Object.keys((a.body as any) ?? {}), "message keys:", Object.keys(msg ?? {}));
18 + if (a.status !== 200) console.log(" body:", short(a.body, 600));
19 +
20 + // (b) streaming WITHOUT stream_options
21 + const s1 = await rawSSE("/chat/completions", {
22 + model, messages: [{ role: "user", content: "Say hello in French, 5 words max." }], max_tokens: 200,
23 + });
24 + // (b') streaming WITH include_usage
25 + const s2 = await rawSSE("/chat/completions", {
26 + model, messages: [{ role: "user", content: "Say hello in French, 5 words max." }], max_tokens: 200,
27 + stream_options: { include_usage: true },
28 + });
29 + const summarize = (s: typeof s1) => {
30 + const deltaKeys = new Set<string>();
31 + let usageChunks = 0; let finish: string | null = null; let emptyChoicesChunks = 0;
32 + const eventLines = s.rawLines.filter((l) => l.startsWith("event:"));
33 + for (const c of s.chunks) {
34 + if (c === "[DONE]") continue;
35 + if (c.usage) usageChunks++;
36 + if (Array.isArray(c.choices) && c.choices.length === 0) emptyChoicesChunks++;
37 + for (const ch of c.choices ?? []) { for (const k of Object.keys(ch.delta ?? {})) deltaKeys.add(k); if (ch.finish_reason) finish = ch.finish_reason; }
38 + }
39 + return { status: s.status, nChunks: s.chunks.length, deltaKeys: [...deltaKeys], usageChunks, emptyChoicesChunks, finish, eventLines: eventLines.length, hasDone: s.chunks.at(-1) === "[DONE]", error: s.error, headers: s.headers };
40 + };
41 + const sum1 = summarize(s1), sum2 = summarize(s2);
42 + console.log(" stream (no include_usage):", JSON.stringify(sum1));
43 + console.log(" stream (include_usage): ", JSON.stringify(sum2));
44 + console.log(" first 3 chunks:", short(s2.chunks.slice(0, 3), 900));
45 + console.log(" last 3 chunks:", short(s2.chunks.slice(-3), 900));
46 + results[model] = { nonStream: a, stream: { noUsage: { summary: sum1, chunks: s1.chunks }, withUsage: { summary: sum2, chunks: s2.chunks } } };
47 +}
48 +save("01-chat-stream", results);
added research/kimi/02-params.ts +80 −0
@@ -0,0 +1,80 @@
1 +import { MODELS, rawPost, save, short, sleep, WEATHER_TOOL } from "./lib.ts";
2 +
3 +const base = (model: string) => ({ model, messages: [{ role: "user", content: "Reply with the single word: ok" }], max_tokens: 60 });
4 +
5 +const variants: Array<[string, Record<string, unknown>]> = [
6 + ["temperature=0", { temperature: 0 }],
7 + ["temperature=0.5", { temperature: 0.5 }],
8 + ["temperature=1", { temperature: 1 }],
9 + ["temperature=1.5", { temperature: 1.5 }],
10 + ["temperature=2", { temperature: 2 }],
11 + ["top_p=0.5", { top_p: 0.5 }],
12 + ["top_p=0.95", { top_p: 0.95 }],
13 + ["max_completion_tokens=60 (no max_tokens)", { max_tokens: undefined, max_completion_tokens: 60 }],
14 + ["stop=[\\n]", { stop: ["\n"] }],
15 + ["stop=6 items", { stop: ["a", "b", "c", "d", "e", "f"] }],
16 + ["frequency_penalty=0.5", { frequency_penalty: 0.5 }],
17 + ["frequency_penalty=0", { frequency_penalty: 0 }],
18 + ["presence_penalty=0.5", { presence_penalty: 0.5 }],
19 + ["presence_penalty=0", { presence_penalty: 0 }],
20 + ["n=2", { n: 2 }],
21 + ["n=1", { n: 1 }],
22 + ["seed=42", { seed: 42 }],
23 + ["logprobs=true,top_logprobs=2", { logprobs: true, top_logprobs: 2 }],
24 + ["thinking=enabled", { thinking: { type: "enabled" } }],
25 + ["thinking=disabled", { thinking: { type: "disabled" } }],
26 + ["thinking=enabled,keep=all", { thinking: { type: "enabled", keep: "all" } }],
27 + ["reasoning_effort=low", { reasoning_effort: "low" }],
28 + ["reasoning_effort=high", { reasoning_effort: "high" }],
29 + ["reasoning_effort=max", { reasoning_effort: "max" }],
30 + ["reasoning_effort=medium", { reasoning_effort: "medium" }],
31 + ["reasoning_effort=none", { reasoning_effort: "none" }],
32 + ["response_format=json_object", { response_format: { type: "json_object" }, messages: [{ role: "user", content: "Return a JSON object with key ok set to true." }] }],
33 + ["response_format=json_schema strict", { response_format: { type: "json_schema", json_schema: { name: "ok", strict: true, schema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"], additionalProperties: false } } }, messages: [{ role: "user", content: "Return ok=true as JSON." }] }],
34 + ["tools+tool_choice=auto", { tools: [WEATHER_TOOL], tool_choice: "auto" }],
35 + ["tools+tool_choice=none", { tools: [WEATHER_TOOL], tool_choice: "none" }],
36 + ["tools+tool_choice=required", { tools: [WEATHER_TOOL], tool_choice: "required", messages: [{ role: "user", content: "Weather in Montreal?" }] }],
37 + ["tools+tool_choice=function", { tools: [WEATHER_TOOL], tool_choice: { type: "function", function: { name: "get_weather" } }, messages: [{ role: "user", content: "Weather in Montreal?" }] }],
38 + ["tools+parallel_tool_calls=false", { tools: [WEATHER_TOOL], parallel_tool_calls: false }],
39 + ["tools+parallel_tool_calls=true", { tools: [WEATHER_TOOL], parallel_tool_calls: true }],
40 + ["unknown param foo_bar", { foo_bar: 1 }],
41 + ["developer role", { messages: [{ role: "developer", content: "Be terse." }, { role: "user", content: "Reply with the single word: ok" }] }],
42 + ["max_tokens=1 (truncation)", { max_tokens: 1 }],
43 +];
44 +
45 +import { existsSync, readFileSync } from "node:fs";
46 +const prevPath = new URL("./out/02-params.json", import.meta.url);
47 +const prev: Record<string, any[]> = existsSync(prevPath) ? JSON.parse(readFileSync(prevPath, "utf8")) : {};
48 +const onlyModels = process.argv.slice(2);
49 +const all: Record<string, unknown> = { ...prev };
50 +for (const model of MODELS) {
51 + if (onlyModels.length && !onlyModels.includes(model)) continue;
52 + const prevRows: any[] = prev[model] ?? [];
53 + const rows: any[] = [];
54 + for (const [label, patch] of variants) {
55 + const old = prevRows.find((r) => r.label === label);
56 + if (old && old.status === 200 || old && old.status === 400) { rows.push(old); continue; } // keep definitive results, redo 429s
57 + await sleep(700);
58 + const body: Record<string, unknown> = { ...base(model), ...patch };
59 + for (const k of Object.keys(body)) if (body[k] === undefined) delete body[k];
60 + let r: any;
61 + try { r = await rawPost("/chat/completions", body); } catch (e: any) { r = { status: "EXC", body: String(e) }; }
62 + const ch = r.body?.choices?.[0];
63 + const row = {
64 + label, status: r.status,
65 + finish: ch?.finish_reason ?? null,
66 + content: short(ch?.message?.content, 80),
67 + hasReasoning: typeof ch?.message?.reasoning_content === "string" && ch.message.reasoning_content.length > 0,
68 + toolCalls: ch?.message?.tool_calls?.length ?? 0,
69 + usage: r.body?.usage ?? null,
70 + error: r.status === 200 ? null : r.body?.error ?? r.body,
71 + nChoices: r.body?.choices?.length ?? null,
72 + logprobs: ch?.logprobs ? "present" : null,
73 + };
74 + rows.push(row);
75 + console.log(`[${model}] ${label.padEnd(42)} ${r.status} finish=${row.finish} reasoning=${row.hasReasoning} tc=${row.toolCalls} ${r.status !== 200 ? short(row.error, 220) : short(row.content, 40)}`);
76 + }
77 + all[model] = rows;
78 + save("02-params", all);
79 +}
80 +save("02-params", all);
added research/kimi/03-tools-stream.ts +60 −0
@@ -0,0 +1,60 @@
1 +import { MODELS, rawPost, rawSSE, save, short, WEATHER_TOOL } from "./lib.ts";
2 +
3 +const out: Record<string, unknown> = {};
4 +await Promise.all(MODELS.map(async (model) => {
5 + const messages: any[] = [{ role: "user", content: "What is the weather in Montreal right now? Use the tool." }];
6 + const s = await rawSSE("/chat/completions", { model, messages, tools: [WEATHER_TOOL], tool_choice: "auto", max_tokens: 4000, stream_options: { include_usage: true } });
7 + // accumulate
8 + const acc: any = { role: "assistant", content: "", reasoning_content: "", tool_calls: [] as any[] };
9 + const toolChunks: any[] = [];
10 + let finish: string | null = null; let usage: any = null;
11 + for (const c of s.chunks) {
12 + if (c === "[DONE]") continue;
13 + if (c.usage) usage = c.usage;
14 + for (const ch of c.choices ?? []) {
15 + const d = ch.delta ?? {};
16 + if (d.content) acc.content += d.content;
17 + if (d.reasoning_content) acc.reasoning_content += d.reasoning_content;
18 + if (d.tool_calls) { toolChunks.push(d.tool_calls); for (const tc of d.tool_calls) { const i = tc.index ?? 0; acc.tool_calls[i] ??= { id: "", type: "function", function: { name: "", arguments: "" } }; if (tc.id) acc.tool_calls[i].id = tc.id; if (tc.type) acc.tool_calls[i].type = tc.type; if (tc.function?.name) acc.tool_calls[i].function.name += tc.function.name; if (tc.function?.arguments) acc.tool_calls[i].function.arguments += tc.function.arguments; } }
19 + if (ch.finish_reason) finish = ch.finish_reason;
20 + }
21 + }
22 + console.log(`\n[${model}] step1 stream status=${s.status} finish=${finish} nToolChunks=${toolChunks.length} reasoningLen=${acc.reasoning_content.length} content=${short(acc.content, 60)}`);
23 + console.log(" tool_calls delta chunks (first 3):", short(toolChunks.slice(0, 3), 700));
24 + console.log(" accumulated:", short(acc.tool_calls, 300), "usage:", JSON.stringify(usage));
25 + if (s.error) console.log(" error:", short(s.error, 400));
26 +
27 + const results: any = { step1: { status: s.status, finish, toolChunks, acc, usage, error: s.error } };
28 + if (acc.tool_calls.length) {
29 + const toolMsgs = acc.tool_calls.map((tc: any) => ({ role: "tool", tool_call_id: tc.id, name: tc.function.name, content: JSON.stringify({ city: "Montreal", temp_c: 21, condition: "sunny" }) }));
30 + // (A) full replay incl. reasoning_content
31 + const withR = await rawPost("/chat/completions", { model, messages: [...messages, { role: "assistant", content: acc.content || null, reasoning_content: acc.reasoning_content, tool_calls: acc.tool_calls }, ...toolMsgs], tools: [WEATHER_TOOL], max_tokens: 4000 });
32 + console.log(` step2 WITH reasoning_content: ${withR.status} finish=${(withR.body as any)?.choices?.[0]?.finish_reason} ${short((withR.body as any)?.choices?.[0]?.message?.content ?? withR.body, 120)}`);
33 + // (B) replay WITHOUT reasoning_content
34 + const noR = await rawPost("/chat/completions", { model, messages: [...messages, { role: "assistant", content: acc.content || null, tool_calls: acc.tool_calls }, ...toolMsgs], tools: [WEATHER_TOOL], max_tokens: 4000 });
35 + console.log(` step2 WITHOUT reasoning_content: ${noR.status} finish=${(noR.body as any)?.choices?.[0]?.finish_reason} ${short((noR.body as any)?.choices?.[0]?.message?.content ?? noR.body, 200)}`);
36 + // (C) replay with EMPTY reasoning_content string
37 + const emptyR = await rawPost("/chat/completions", { model, messages: [...messages, { role: "assistant", content: acc.content || null, reasoning_content: "", tool_calls: acc.tool_calls }, ...toolMsgs], tools: [WEATHER_TOOL], max_tokens: 4000 });
38 + console.log(` step2 EMPTY reasoning_content: ${emptyR.status} finish=${(emptyR.body as any)?.choices?.[0]?.finish_reason} ${short((emptyR.body as any)?.choices?.[0]?.message?.content ?? emptyR.body, 200)}`);
39 + results.step2 = { withReasoning: withR, withoutReasoning: noR, emptyReasoning: emptyR };
40 + results.step2WithUsage = (withR.body as any)?.usage;
41 + console.log(" step2 usage (with):", JSON.stringify((withR.body as any)?.usage));
42 + }
43 + out[model] = results;
44 +}));
45 +
46 +// k2.6 thinking disabled: tool round trip without reasoning at all
47 +{
48 + const model = "kimi-k2.6";
49 + const messages: any[] = [{ role: "user", content: "What is the weather in Montreal right now? Use the tool." }];
50 + const r1 = await rawPost("/chat/completions", { model, messages, tools: [WEATHER_TOOL], thinking: { type: "disabled" }, max_tokens: 500 });
51 + const m = (r1.body as any)?.choices?.[0]?.message;
52 + console.log(`\n[k2.6 thinking=disabled] step1 ${r1.status} finish=${(r1.body as any)?.choices?.[0]?.finish_reason} keys=${Object.keys(m ?? {})} tc=${m?.tool_calls?.length}`);
53 + let r2: any = null;
54 + if (m?.tool_calls?.length) {
55 + r2 = await rawPost("/chat/completions", { model, messages: [...messages, m, ...m.tool_calls.map((tc: any) => ({ role: "tool", tool_call_id: tc.id, name: tc.function.name, content: "{\"temp_c\":21}" }))], tools: [WEATHER_TOOL], thinking: { type: "disabled" }, max_tokens: 500 });
56 + console.log(` step2 ${r2.status} ${short((r2.body as any)?.choices?.[0]?.message?.content ?? r2.body, 150)}`);
57 + }
58 + out["kimi-k2.6/thinking-disabled"] = { r1, r2 };
59 +}
60 +save("03-tools-stream", out);
added research/kimi/04-json-vision.ts +39 −0
@@ -0,0 +1,39 @@
1 +import { MODELS, rawPost, save, short, pngDataUrl } from "./lib.ts";
2 +
3 +const out: Record<string, unknown> = {};
4 +await Promise.all(MODELS.map(async (model) => {
5 + const res: any = {};
6 + // (e) JSON mode with a schema described in prompt; streaming off; thinking model → check content parses
7 + const j = await rawPost("/chat/completions", { model, messages: [{ role: "system", content: "Output JSON only." }, { role: "user", content: "Give me a JSON object with fields city (string) and population (integer) for Montreal." }], response_format: { type: "json_object" }, max_tokens: 3000 });
8 + const jc = (j.body as any)?.choices?.[0]?.message?.content;
9 + let parsed: any = null; try { parsed = JSON.parse(jc); } catch {}
10 + console.log(`[${model}] json_object ${j.status} finish=${(j.body as any)?.choices?.[0]?.finish_reason} parses=${parsed !== null} ${short(jc, 120)} usage=${JSON.stringify((j.body as any)?.usage)}`);
11 + res.jsonObject = { status: j.status, content: jc, parses: parsed !== null, usage: (j.body as any)?.usage, error: j.status === 200 ? null : j.body };
12 +
13 + // json_schema strict with nested + enum
14 + const schema = { type: "object", properties: { city: { type: "string" }, population: { type: "integer" }, tags: { type: "array", items: { type: "string" } }, size: { type: "string", enum: ["small", "medium", "large"] } }, required: ["city", "population", "tags", "size"], additionalProperties: false };
15 + const js = await rawPost("/chat/completions", { model, messages: [{ role: "user", content: "Describe Montreal." }], response_format: { type: "json_schema", json_schema: { name: "city_info", strict: true, schema } }, max_tokens: 3000 });
16 + const jsc = (js.body as any)?.choices?.[0]?.message?.content;
17 + let parsed2: any = null; try { parsed2 = JSON.parse(jsc); } catch {}
18 + console.log(`[${model}] json_schema ${js.status} finish=${(js.body as any)?.choices?.[0]?.finish_reason} parses=${parsed2 !== null} keysOk=${parsed2 && Object.keys(parsed2).sort().join() === "city,population,size,tags"} ${short(jsc, 120)}`);
19 + res.jsonSchema = { status: js.status, content: jsc, parses: parsed2 !== null, error: js.status === 200 ? null : js.body };
20 +
21 + // (f) vision: 32x32 red PNG; object-form image_url (OpenAI style)
22 + const v = await rawPost("/chat/completions", { model, messages: [{ role: "user", content: [{ type: "text", text: "What colour is this image? One word." }, { type: "image_url", image_url: { url: pngDataUrl(32) } }] }], max_tokens: 2000 });
23 + console.log(`[${model}] vision 32x32 (image_url object) ${v.status} ${short((v.body as any)?.choices?.[0]?.message?.content ?? (v.body as any)?.error, 150)} usage=${JSON.stringify((v.body as any)?.usage)}`);
24 + res.vision32 = { status: v.status, content: (v.body as any)?.choices?.[0]?.message?.content, usage: (v.body as any)?.usage, error: v.status === 200 ? null : v.body };
25 + // image_url as plain string (docs show string form)
26 + const v2 = await rawPost("/chat/completions", { model, messages: [{ role: "user", content: [{ type: "text", text: "What colour is this image? One word." }, { type: "image_url", image_url: pngDataUrl(32, [20, 40, 220, 255]) }] }], max_tokens: 2000 });
27 + console.log(`[${model}] vision 32x32 (image_url string) ${v2.status} ${short((v2.body as any)?.choices?.[0]?.message?.content ?? (v2.body as any)?.error, 150)}`);
28 + res.vision32String = { status: v2.status, content: (v2.body as any)?.choices?.[0]?.message?.content, error: v2.status === 200 ? null : v2.body };
29 + // tiny 2x2
30 + const v3 = await rawPost("/chat/completions", { model, messages: [{ role: "user", content: [{ type: "text", text: "Colour? One word." }, { type: "image_url", image_url: { url: pngDataUrl(2) } }] }], max_tokens: 2000 });
31 + console.log(`[${model}] vision 2x2 ${v3.status} ${short((v3.body as any)?.choices?.[0]?.message?.content ?? (v3.body as any)?.error, 150)} usage=${JSON.stringify((v3.body as any)?.usage)}`);
32 + res.vision2 = { status: v3.status, content: (v3.body as any)?.choices?.[0]?.message?.content, usage: (v3.body as any)?.usage, error: v3.status === 200 ? null : v3.body };
33 + out[model] = res;
34 +}));
35 +// public URL image (docs say unsupported) — one model
36 +const u = await rawPost("/chat/completions", { model: "kimi-k2.6", messages: [{ role: "user", content: [{ type: "text", text: "Describe." }, { type: "image_url", image_url: { url: "https://upload.wikimedia.org/wikipedia/commons/thumb/4/47/PNG_transparency_demonstration_1.png/280px-PNG_transparency_demonstration_1.png" } }] }], max_tokens: 500, thinking: { type: "disabled" } });
37 +console.log(`[kimi-k2.6] vision public URL ${u.status} ${short((u.body as any)?.choices?.[0]?.message?.content ?? (u.body as any)?.error, 300)}`);
38 +out["publicUrl"] = u;
39 +save("04-json-vision", out);
added research/kimi/05-errors-limits.ts +60 −0
@@ -0,0 +1,60 @@
1 +import { rawPost, rawGet, save, short, BASE } from "./lib.ts";
2 +
3 +const out: Record<string, unknown> = {};
4 +const tiny = { model: "kimi-k2.6", messages: [{ role: "user", content: "hi" }], max_tokens: 5, thinking: { type: "disabled" } };
5 +
6 +// (g) invalid key
7 +const bad = await rawPost("/chat/completions", tiny, {}, "sk-invalid-key-000000");
8 +console.log("invalid key:", bad.status, JSON.stringify(bad.body), JSON.stringify(bad.headers));
9 +out.invalidKey = bad;
10 +const badModels = await rawGet("/models", "sk-invalid-key-000000");
11 +console.log("invalid key GET /models:", badModels.status, JSON.stringify(badModels.body));
12 +out.invalidKeyModels = badModels;
13 +// no auth header
14 +const noAuth = await fetch(`${BASE}/chat/completions`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(tiny) });
15 +console.log("no auth:", noAuth.status, (await noAuth.text()).slice(0, 300));
16 +// (h) unknown model
17 +const unk = await rawPost("/chat/completions", { ...tiny, model: "kimi-k99" });
18 +console.log("unknown model:", unk.status, JSON.stringify(unk.body));
19 +out.unknownModel = unk;
20 +// retired slugs
21 +for (const m of ["kimi-k2.5", "kimi-k2-thinking", "moonshot-v1-8k", "kimi-latest", "kimi-k2-0905-preview"]) {
22 + const r = await rawPost("/chat/completions", { model: m, messages: [{ role: "user", content: "hi" }], max_tokens: 5 });
23 + console.log(`retired ${m}:`, r.status, short(r.body, 200));
24 + out[`retired:${m}`] = r;
25 +}
26 +// malformed body
27 +const mal = await fetch(`${BASE}/chat/completions`, { method: "POST", headers: { "content-type": "application/json", authorization: `Bearer ${process.env.KIMI_API_KEY}` }, body: "{\"model\":\"kimi-k2.6\",\"messages\":\"nope\"}" });
28 +console.log("malformed messages:", mal.status, (await mal.text()).slice(0, 300));
29 +// missing messages
30 +const miss = await rawPost("/chat/completions", { model: "kimi-k2.6" });
31 +console.log("missing messages:", miss.status, JSON.stringify(miss.body));
32 +out.missingMessages = miss;
33 +
34 +// (j) max output limits: oversize max_tokens per model
35 +for (const [model, mt] of [["kimi-k2.6", 40000], ["kimi-k2.6", 300000], ["kimi-k2.7-code", 300000], ["kimi-k2.7-code-highspeed", 300000], ["kimi-k3", 1048576], ["kimi-k3", 1048577], ["kimi-k3", 2000000]] as const) {
36 + const r = await rawPost("/chat/completions", { model, messages: [{ role: "user", content: "Reply: ok" }], max_tokens: mt });
37 + console.log(`max_tokens=${mt} on ${model}:`, r.status, r.status === 200 ? `finish=${(r.body as any).choices[0].finish_reason} usage=${JSON.stringify((r.body as any).usage)}` : short(r.body, 300));
38 + out[`maxTokens:${model}:${mt}`] = { status: r.status, body: r.status === 200 ? { usage: (r.body as any).usage } : r.body };
39 +}
40 +// context overflow: ~300k tokens of text to k2.6 (262144 ctx)
41 +const big = "lorem ipsum dolor sit amet ".repeat(60000); // ~ 300k+ tokens
42 +const ov = await rawPost("/chat/completions", { model: "kimi-k2.6", messages: [{ role: "user", content: big + "\nReply ok." }], max_tokens: 5, thinking: { type: "disabled" } });
43 +console.log("context overflow k2.6:", ov.status, short(ov.body, 300));
44 +out.contextOverflow = ov;
45 +// tool_choice forced function with thinking enabled on k2.6 (docs: 400)
46 +const forced = await rawPost("/chat/completions", { model: "kimi-k2.6", messages: [{ role: "user", content: "Weather in Montreal?" }], tools: [{ type: "function", function: { name: "get_weather", parameters: { type: "object", properties: { city: { type: "string" } } } } }], tool_choice: { type: "function", function: { name: "get_weather" } }, thinking: { type: "disabled" }, max_tokens: 200 });
47 +console.log("k2.6 forced tool + thinking disabled:", forced.status, short((forced.body as any)?.choices?.[0]?.message?.tool_calls ?? forced.body, 200));
48 +out.forcedToolNoThinking = forced;
49 +// cache hit check: same >256-token prompt twice on k2.6 non-thinking
50 +const long = "You are a helpful assistant. " + "The quick brown fox jumps over the lazy dog. ".repeat(80);
51 +const c1 = await rawPost("/chat/completions", { model: "kimi-k2.6", messages: [{ role: "system", content: long }, { role: "user", content: "Reply: ok" }], max_tokens: 5, thinking: { type: "disabled" } });
52 +await new Promise((r) => setTimeout(r, 1500));
53 +const c2 = await rawPost("/chat/completions", { model: "kimi-k2.6", messages: [{ role: "system", content: long }, { role: "user", content: "Reply: ok" }], max_tokens: 5, thinking: { type: "disabled" } });
54 +console.log("cache probe usage #1:", JSON.stringify((c1.body as any)?.usage), "#2:", JSON.stringify((c2.body as any)?.usage));
55 +out.cache = { c1: (c1.body as any)?.usage, c2: (c2.body as any)?.usage, headers2: c2.headers };
56 +// partial mode
57 +const partial = await rawPost("/chat/completions", { model: "kimi-k2.6", messages: [{ role: "user", content: "Name three colours." }, { role: "assistant", content: "Sure! The three colours are:", partial: true }], max_tokens: 60, thinking: { type: "disabled" } });
58 +console.log("partial mode k2.6 non-thinking:", partial.status, short((partial.body as any)?.choices?.[0]?.message?.content ?? partial.body, 200));
59 +out.partial = partial;
60 +save("05-errors-limits", out);
added research/kimi/06-websearch.ts +45 −0
@@ -0,0 +1,45 @@
1 +import { rawPost, rawSSE, save, short } from "./lib.ts";
2 +
3 +const model = process.argv[2] ?? "kimi-k3";
4 +const tools = [{ type: "builtin_function", function: { name: "$web_search" } }];
5 +const messages: any[] = [{ role: "user", content: "What was the closing value of the S&P 500 yesterday? Search the web and cite the source URL." }];
6 +
7 +// step 1 streamed
8 +const s = await rawSSE("/chat/completions", { model, messages, tools, max_tokens: 6000, stream_options: { include_usage: true }, ...(model === "kimi-k3" ? { reasoning_effort: "low" } : {}) });
9 +const acc: any = { role: "assistant", content: "", reasoning_content: "", tool_calls: [] as any[] };
10 +let finish: string | null = null; let usage: any = null; const tcChunks: any[] = [];
11 +for (const c of s.chunks) {
12 + if (c === "[DONE]") continue;
13 + if (c.usage) usage = c.usage;
14 + for (const ch of c.choices ?? []) {
15 + const d = ch.delta ?? {};
16 + if (d.content) acc.content += d.content;
17 + if (d.reasoning_content) acc.reasoning_content += d.reasoning_content;
18 + if (d.tool_calls) { tcChunks.push(d.tool_calls); for (const tc of d.tool_calls) { const i = tc.index ?? 0; acc.tool_calls[i] ??= { id: "", type: "", function: { name: "", arguments: "" } }; if (tc.id) acc.tool_calls[i].id = tc.id; if (tc.type) acc.tool_calls[i].type = tc.type; if (tc.function?.name) acc.tool_calls[i].function.name += tc.function.name; if (tc.function?.arguments) acc.tool_calls[i].function.arguments += tc.function.arguments; } }
19 + if (ch.finish_reason) finish = ch.finish_reason;
20 + }
21 +}
22 +console.log(`[${model}] step1 ${s.status} finish=${finish} tcChunks=${tcChunks.length} usage=${JSON.stringify(usage)} error=${short(s.error, 300)}`);
23 +console.log(" first tool_calls chunk:", short(tcChunks[0], 500));
24 +console.log(" accumulated tool_calls:", short(acc.tool_calls, 800));
25 +console.log(" content:", short(acc.content, 200), "reasoning len:", acc.reasoning_content.length);
26 +const out: any = { step1: { status: s.status, finish, usage, tcChunks, acc, error: s.error } };
27 +
28 +if (acc.tool_calls.length) {
29 + // step 2: return arguments verbatim
30 + const toolMsgs = acc.tool_calls.map((tc: any) => ({ role: "tool", tool_call_id: tc.id, name: tc.function.name, content: tc.function.arguments }));
31 + const r2 = await rawPost("/chat/completions", { model, messages: [...messages, { role: "assistant", content: acc.content || null, reasoning_content: acc.reasoning_content, tool_calls: acc.tool_calls }, ...toolMsgs], tools, max_tokens: 6000, ...(model === "kimi-k3" ? { reasoning_effort: "low" } : {}) });
32 + const m = (r2.body as any)?.choices?.[0]?.message;
33 + console.log(` step2 ${r2.status} finish=${(r2.body as any)?.choices?.[0]?.finish_reason} keys=${Object.keys(m ?? {})} usage=${JSON.stringify((r2.body as any)?.usage)}`);
34 + console.log(" step2 content:", short(m?.content ?? r2.body, 1200));
35 + console.log(" step2 top-level keys:", Object.keys((r2.body as any) ?? {}));
36 + console.log(" step2 choice keys:", Object.keys((r2.body as any)?.choices?.[0] ?? {}));
37 + out.step2 = r2;
38 + // if it called search again, do a 3rd round
39 + if (m?.tool_calls?.length) {
40 + const r3 = await rawPost("/chat/completions", { model, messages: [...messages, { role: "assistant", content: acc.content || null, reasoning_content: acc.reasoning_content, tool_calls: acc.tool_calls }, ...toolMsgs, m, ...m.tool_calls.map((tc: any) => ({ role: "tool", tool_call_id: tc.id, name: tc.function.name, content: tc.function.arguments }))], tools, max_tokens: 6000, ...(model === "kimi-k3" ? { reasoning_effort: "low" } : {}) });
41 + console.log(` step3 ${r3.status} finish=${(r3.body as any)?.choices?.[0]?.finish_reason}`, short((r3.body as any)?.choices?.[0]?.message?.content ?? r3.body, 1200), JSON.stringify((r3.body as any)?.usage));
42 + out.step3 = r3;
43 + }
44 +}
45 +save(`06-websearch-${model}`, out);
added research/kimi/06b-websearch-variants.ts +36 −0
@@ -0,0 +1,36 @@
1 +import { rawPost, save, short } from "./lib.ts";
2 +
3 +const model = process.argv[2] ?? "kimi-k3";
4 +const tools = [{ type: "builtin_function", function: { name: "$web_search" } }];
5 +const messages: any[] = [{ role: "user", content: "What was the closing value of the S&P 500 yesterday? Search the web and cite the source URL." }];
6 +const extra = model === "kimi-k3" ? { reasoning_effort: "low" } : {};
7 +
8 +// canonical: non-streamed step 1, append message verbatim
9 +const r1 = await rawPost("/chat/completions", { model, messages, tools, max_tokens: 6000, ...extra });
10 +const m = (r1.body as any)?.choices?.[0]?.message;
11 +console.log(`[${model}] step1 non-stream ${r1.status} finish=${(r1.body as any)?.choices?.[0]?.finish_reason} keys=${Object.keys(m ?? {})} usage=${JSON.stringify((r1.body as any)?.usage)}`);
12 +console.log(" message:", short(m, 900));
13 +const out: any = { step1: r1 };
14 +if (m?.tool_calls?.length) {
15 + const toolMsgs = m.tool_calls.map((tc: any) => ({ role: "tool", tool_call_id: tc.id, name: tc.function.name, content: tc.function.arguments }));
16 + const variants: Array<[string, any]> = [
17 + ["verbatim message", m],
18 + ["no reasoning_content", { role: "assistant", content: m.content, tool_calls: m.tool_calls }],
19 + ["reasoning_content empty string", { role: "assistant", content: m.content, reasoning_content: "", tool_calls: m.tool_calls }],
20 + ["tool_calls type=function instead of builtin_function", { ...m, tool_calls: m.tool_calls.map((tc: any) => ({ ...tc, type: "function" })) }],
21 + ];
22 + for (const [label, asst] of variants) {
23 + const r2 = await rawPost("/chat/completions", { model, messages: [...messages, asst, ...toolMsgs], tools, max_tokens: 6000, ...extra });
24 + const mm = (r2.body as any)?.choices?.[0]?.message;
25 + console.log(` step2 [${label}] ${r2.status} finish=${(r2.body as any)?.choices?.[0]?.finish_reason} usage=${JSON.stringify((r2.body as any)?.usage)}`);
26 + console.log(" ->", short(mm?.content ?? mm?.tool_calls ?? r2.body, 700));
27 + if (mm) console.log(" message keys:", Object.keys(mm), "choice keys:", Object.keys((r2.body as any).choices[0]));
28 + out[`step2:${label}`] = r2;
29 + if (r2.status === 200 && !out.firstOk) out.firstOk = label;
30 + }
31 + // tool message without name field
32 + const noName = await rawPost("/chat/completions", { model, messages: [...messages, m, ...m.tool_calls.map((tc: any) => ({ role: "tool", tool_call_id: tc.id, content: tc.function.arguments }))], tools, max_tokens: 6000, ...extra });
33 + console.log(` step2 [tool msg without name] ${noName.status}`, short((noName.body as any)?.choices?.[0]?.message?.content ?? noName.body, 200));
34 + out["step2:noName"] = noName;
35 +}
36 +save(`06b-websearch-variants-${model}`, out);
added research/kimi/07-websearch-alt.ts +60 −0
@@ -0,0 +1,60 @@
1 +import { rawPost, rawGet, rawSSE, save, short } from "./lib.ts";
2 +
3 +const out: any = {};
4 +// A) Responses API web_search on kimi-k3 (streamed) — server-side tool
5 +const s = await rawSSE("/responses", { model: "kimi-k3", input: "What was the closing value of the S&P 500 yesterday? Cite the source URL.", tools: [{ type: "web_search" }], reasoning: { effort: "low" }, max_output_tokens: 4000 });
6 +const types = [...new Set(s.chunks.filter((c) => c !== "[DONE]").map((c: any) => c.type))];
7 +console.log("responses web_search:", s.status, "event types:", types, "error:", short(s.error, 400));
8 +const completed = s.chunks.find((c: any) => c?.type === "response.completed");
9 +if (completed) {
10 + const resp = (completed as any).response;
11 + console.log(" output item types:", resp.output?.map((o: any) => o.type), "usage:", JSON.stringify(resp.usage));
12 + const txt = resp.output?.find((o: any) => o.type === "message");
13 + console.log(" text:", short(txt?.content?.[0]?.text, 600));
14 + console.log(" annotations:", short(txt?.content?.[0]?.annotations, 800));
15 + const ws = resp.output?.find((o: any) => o.type === "web_search_call");
16 + console.log(" web_search_call item:", short(ws, 800));
17 + console.log(" response keys:", Object.keys(resp));
18 +}
19 +const evLines = s.rawLines.filter((l) => l.startsWith("event:")).slice(0, 5);
20 +console.log(" sample event: lines:", evLines);
21 +out.responsesWebSearch = { status: s.status, types, chunks: s.chunks, error: s.error, headers: s.headers };
22 +
23 +// B) Responses API plain (no tools) — check basic shape + reasoning
24 +const s2 = await rawPost("/responses", { model: "kimi-k3", input: "Say hi in 3 words.", reasoning: { effort: "low" }, max_output_tokens: 2000 });
25 +console.log("responses plain:", s2.status, short((s2.body as any)?.output ?? s2.body, 600), JSON.stringify((s2.body as any)?.usage));
26 +out.responsesPlain = s2;
27 +// Responses on k2.6 (docs: k3 only)
28 +const s3 = await rawPost("/responses", { model: "kimi-k2.6", input: "Say hi in 3 words.", max_output_tokens: 500 });
29 +console.log("responses k2.6:", s3.status, short(s3.body, 300));
30 +out.responsesK26 = s3;
31 +
32 +// C) $web_search builtin on kimi-k2.6 (thinking enabled) verbatim round trip
33 +{
34 + const model = "kimi-k2.6";
35 + const tools = [{ type: "builtin_function", function: { name: "$web_search" } }];
36 + const messages: any[] = [{ role: "user", content: "What was the closing value of the S&P 500 yesterday? Search the web and cite the source URL." }];
37 + const r1 = await rawPost("/chat/completions", { model, messages, tools, max_tokens: 6000 });
38 + const m = (r1.body as any)?.choices?.[0]?.message;
39 + console.log(`[k2.6] $web_search step1 ${r1.status} finish=${(r1.body as any)?.choices?.[0]?.finish_reason} usage=${JSON.stringify((r1.body as any)?.usage)} msg=${short(m ?? r1.body, 700)}`);
40 + out.k26step1 = r1;
41 + if (m?.tool_calls?.length) {
42 + const toolMsgs = m.tool_calls.map((tc: any) => ({ role: "tool", tool_call_id: tc.id, name: tc.function.name, content: tc.function.arguments }));
43 + const r2 = await rawPost("/chat/completions", { model, messages: [...messages, m, ...toolMsgs], tools, max_tokens: 6000 });
44 + console.log(`[k2.6] $web_search step2 verbatim ${r2.status} usage=${JSON.stringify((r2.body as any)?.usage)} ->`, short((r2.body as any)?.choices?.[0]?.message?.content ?? r2.body, 900));
45 + out.k26step2 = r2;
46 + }
47 +}
48 +// D) $web_search on k2.7-code (one shot)
49 +{
50 + const r = await rawPost("/chat/completions", { model: "kimi-k2.7-code", messages: [{ role: "user", content: "Search the web: who won the 2026 Stanley Cup?" }], tools: [{ type: "builtin_function", function: { name: "$web_search" } }], max_tokens: 4000 });
51 + console.log(`[k2.7-code] $web_search step1 ${r.status}`, short((r.body as any)?.choices?.[0]?.message ?? r.body, 500));
52 + out.k27step1 = r;
53 +}
54 +// E) formulas (official tools) discovery
55 +for (const p of ["/formulas", "/formulas/moonshot/web-search/tools", "/formulas/web-search/tools"]) {
56 + const r = await rawGet(p);
57 + console.log(`GET ${p}:`, r.status, short(r.body, 500));
58 + out[`formulas:${p}`] = r;
59 +}
60 +save("07-websearch-alt", out);
added research/kimi/08-misc.ts +92 −0
@@ -0,0 +1,92 @@
1 +import { client, rawPost, rawGet, save, short, last429, sleep } from "./lib.ts";
2 +import { readFileSync } from "node:fs";
3 +
4 +const out: any = {};
5 +const q = "What was the closing value of the S&P 500 on its most recent trading day? Search the web and cite the source URL.";
6 +const builtin = [{ type: "builtin_function", function: { name: "$web_search" } }];
7 +
8 +// (a) k3 $web_search verbatim, default effort
9 +{
10 + const messages: any[] = [{ role: "user", content: q }];
11 + const r1 = await rawPost("/chat/completions", { model: "kimi-k3", messages, tools: builtin, max_tokens: 8000 });
12 + const m = (r1.body as any)?.choices?.[0]?.message;
13 + console.log("[k3 default effort] step1", r1.status, short(m, 400));
14 + if (m?.tool_calls?.length) {
15 + const r2 = await rawPost("/chat/completions", { model: "kimi-k3", messages: [...messages, m, ...m.tool_calls.map((tc: any) => ({ role: "tool", tool_call_id: tc.id, name: tc.function.name, content: tc.function.arguments }))], tools: builtin, max_tokens: 8000 });
16 + console.log("[k3 default effort] step2 verbatim", r2.status, JSON.stringify((r2.body as any)?.usage), short((r2.body as any)?.choices?.[0]?.message?.content ?? r2.body, 500));
17 + out.k3builtin = { r1, r2 };
18 + }
19 +}
20 +// (b) k2.7-code verbatim step2 from saved step1
21 +{
22 + const prev = JSON.parse(readFileSync(new URL("./out/07-websearch-alt.json", import.meta.url), "utf8"));
23 + const m = prev.k27step1?.body?.choices?.[0]?.message;
24 + if (m?.tool_calls?.length) {
25 + const messages = [{ role: "user", content: "Search the web: who won the 2026 Stanley Cup?" }];
26 + const r2 = await rawPost("/chat/completions", { model: "kimi-k2.7-code", messages: [...messages, m, ...m.tool_calls.map((tc: any) => ({ role: "tool", tool_call_id: tc.id, name: tc.function.name, content: tc.function.arguments }))], tools: builtin, max_tokens: 8000 });
27 + console.log("[k2.7-code] $web_search step2 verbatim", r2.status, JSON.stringify((r2.body as any)?.usage), short((r2.body as any)?.choices?.[0]?.message?.content ?? r2.body, 500));
28 + out.k27builtinStep2 = r2;
29 + }
30 +}
31 +// (c) formulas: official web-search tool via fibers on k3
32 +{
33 + const decl = await rawGet("/formulas/moonshot/web-search:latest/tools");
34 + console.log("[formula] GET tools", decl.status, short(decl.body, 300));
35 + const tools = (decl.body as any)?.tools ?? [];
36 + const messages: any[] = [{ role: "user", content: q }];
37 + const r1 = await rawPost("/chat/completions", { model: "kimi-k3", messages, tools, reasoning_effort: "low", max_tokens: 8000 });
38 + const m = (r1.body as any)?.choices?.[0]?.message;
39 + console.log("[formula] step1", r1.status, JSON.stringify((r1.body as any)?.usage), short(m, 500));
40 + out.formula = { decl, r1 };
41 + if (m?.tool_calls?.length) {
42 + const tc = m.tool_calls[0];
43 + const fiber = await rawPost("/formulas/moonshot/web-search:latest/fibers", { name: tc.function.name, arguments: tc.function.arguments });
44 + const fb: any = fiber.body;
45 + console.log("[formula] fiber", fiber.status, "keys:", Object.keys(fb ?? {}), "status:", fb?.status, "context keys:", Object.keys(fb?.context ?? {}), "output:", short(fb?.context?.output, 300), "encrypted:", short(fb?.context?.encrypted_output, 120));
46 + out.formula.fiber = fiber;
47 + const content = fb?.context?.encrypted_output ?? fb?.context?.output ?? "";
48 + const r2 = await rawPost("/chat/completions", { model: "kimi-k3", messages: [...messages, m, { role: "tool", tool_call_id: tc.id, content }], tools, reasoning_effort: "low", max_tokens: 8000 });
49 + console.log("[formula] step2", r2.status, JSON.stringify((r2.body as any)?.usage), short((r2.body as any)?.choices?.[0]?.message?.content ?? r2.body, 600));
50 + out.formula.r2 = r2;
51 + }
52 +}
53 +// (d) k2.6 json_schema strict again: thinking on and off
54 +{
55 + const schema = { type: "object", properties: { city: { type: "string" }, population: { type: "integer" }, size: { type: "string", enum: ["small", "medium", "large"] } }, required: ["city", "population", "size"], additionalProperties: false };
56 + for (const thinking of [{ type: "enabled" }, { type: "disabled" }]) {
57 + const r = await rawPost("/chat/completions", { model: "kimi-k2.6", messages: [{ role: "user", content: "Describe Montreal." }], response_format: { type: "json_schema", json_schema: { name: "city_info", strict: true, schema } }, thinking, max_tokens: 3000 });
58 + const c = (r.body as any)?.choices?.[0]?.message?.content; let ok = false; try { JSON.parse(c); ok = true; } catch {}
59 + console.log(`[k2.6 json_schema thinking=${thinking.type}]`, r.status, "parses:", ok, short(c, 200), JSON.stringify((r.body as any)?.usage));
60 + out[`k26schema:${thinking.type}`] = r;
61 + }
62 +}
63 +// (e) highspeed logprobs single; 429 headers if any
64 +{
65 + await sleep(2000);
66 + const r = await rawPost("/chat/completions", { model: "kimi-k2.7-code-highspeed", messages: [{ role: "user", content: "Reply: ok" }], logprobs: true, top_logprobs: 2, max_tokens: 60 });
67 + console.log("[highspeed logprobs]", r.status, r.attempts, short((r.body as any)?.choices?.[0]?.logprobs ?? r.body, 300));
68 + out.hsLogprobs = r;
69 + console.log("last429 headers:", JSON.stringify(last429?.headers), short(last429?.body, 200));
70 + out.last429 = last429;
71 +}
72 +// (f) OpenAI SDK smoke test: streaming + thinking + tools on k2.6
73 +{
74 + const stream = await client.chat.completions.create({
75 + model: "kimi-k2.6",
76 + messages: [{ role: "user", content: "Weather in Montreal? Use the tool." }],
77 + tools: [{ type: "function", function: { name: "get_weather", parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] } } }],
78 + stream: true, stream_options: { include_usage: true }, max_tokens: 3000,
79 + ...( { thinking: { type: "enabled" } } as any ),
80 + });
81 + let reasoning = "", args = "", usage: any = null, finish: string | null = null, id = "";
82 + for await (const chunk of stream) {
83 + const d: any = chunk.choices[0]?.delta ?? {};
84 + if (d.reasoning_content) reasoning += d.reasoning_content;
85 + for (const tc of d.tool_calls ?? []) { if (tc.id) id = tc.id; args += tc.function?.arguments ?? ""; }
86 + if (chunk.choices[0]?.finish_reason) finish = chunk.choices[0].finish_reason;
87 + if (chunk.usage) usage = chunk.usage;
88 + }
89 + console.log("[SDK] finish:", finish, "reasoning chars:", reasoning.length, "tool id:", id, "args:", args, "usage:", JSON.stringify(usage));
90 + out.sdk = { finish, reasoningLen: reasoning.length, id, args, usage };
91 +}
92 +save("08-misc", out);
added research/kimi/lib.ts +139 −0
@@ -0,0 +1,139 @@
1 +import OpenAI from "openai";
2 +import { writeFileSync, mkdirSync } from "node:fs";
3 +import { deflateSync } from "node:zlib";
4 +
5 +export const BASE = "https://api.moonshot.ai/v1";
6 +export const KEY = process.env.KIMI_API_KEY ?? "";
7 +if (!KEY) throw new Error("KIMI_API_KEY missing (set -a; . ./.env; set +a)");
8 +
9 +export const MODELS = ["kimi-k2.6", "kimi-k2.7-code", "kimi-k2.7-code-highspeed", "kimi-k3"] as const;
10 +export type ModelId = (typeof MODELS)[number];
11 +
12 +export const client = new OpenAI({ apiKey: KEY, baseURL: BASE, timeout: 600_000, maxRetries: 0 });
13 +
14 +mkdirSync(new URL("./out/", import.meta.url), { recursive: true });
15 +export function save(name: string, data: unknown) {
16 + const p = new URL(`./out/${name}.json`, import.meta.url);
17 + writeFileSync(p, JSON.stringify(data, null, 2));
18 +}
19 +
20 +/** Raw POST returning status, headers (redacted) and parsed body (or text). */
21 +export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
22 +export let last429: { status: number; headers: Record<string, string>; body: unknown } | null = null;
23 +
24 +/** Retries 429 rate_limit/engine_overloaded up to 8× (3 s, 6 s, …) unless RETRY429=0. */
25 +export async function rawPost(path: string, body: unknown, extraHeaders: Record<string, string> = {}, key = KEY) {
26 + const maxTries = process.env.RETRY429 === "0" ? 1 : 8;
27 + let attempt = 0;
28 + for (;;) {
29 + attempt++;
30 + const res = await fetch(`${BASE}${path}`, {
31 + method: "POST",
32 + headers: { "content-type": "application/json", authorization: `Bearer ${key}`, ...extraHeaders },
33 + body: JSON.stringify(body),
34 + });
35 + const text = await res.text();
36 + let json: unknown = null;
37 + try { json = JSON.parse(text); } catch { /* text */ }
38 + const out = { status: res.status, headers: pickHeaders(res.headers), body: json ?? text, attempts: attempt };
39 + if (res.status === 429 && attempt < maxTries) {
40 + last429 = out;
41 + const t = (json as any)?.error?.type;
42 + if (t === "rate_limit_reached_error" || t === "engine_overloaded_error") { await sleep(3000 * attempt); continue; }
43 + }
44 + return out;
45 + }
46 +}
47 +
48 +export async function rawGet(path: string, key = KEY) {
49 + const res = await fetch(`${BASE}${path}`, { headers: { authorization: `Bearer ${key}` } });
50 + const text = await res.text();
51 + let json: unknown = null;
52 + try { json = JSON.parse(text); } catch { /* text */ }
53 + return { status: res.status, headers: pickHeaders(res.headers), body: json ?? text };
54 +}
55 +
56 +export function pickHeaders(h: Headers) {
57 + const out: Record<string, string> = {};
58 + h.forEach((v, k) => {
59 + if (/^(set-cookie|authorization)$/i.test(k)) return;
60 + if (/^(msh-|x-msh-|x-ratelimit|ratelimit|retry-after|content-type|cf-ray|server|date)/i.test(k)) out[k] = v;
61 + });
62 + return out;
63 +}
64 +
65 +/** Raw SSE POST: returns every parsed `data:` JSON chunk plus status/headers. */
66 +export async function rawSSE(path: string, body: unknown, extraHeaders: Record<string, string> = {}) {
67 + const res = await fetch(`${BASE}${path}`, {
68 + method: "POST",
69 + headers: { "content-type": "application/json", authorization: `Bearer ${KEY}`, ...extraHeaders },
70 + body: JSON.stringify({ ...(body as object), stream: true }),
71 + });
72 + const headers = pickHeaders(res.headers);
73 + if (!res.ok || !res.body) {
74 + const text = await res.text();
75 + let json: unknown = null; try { json = JSON.parse(text); } catch {}
76 + return { status: res.status, headers, chunks: [] as any[], error: json ?? text, rawLines: [] as string[] };
77 + }
78 + const reader = res.body.getReader();
79 + const dec = new TextDecoder();
80 + let buf = "";
81 + const chunks: any[] = [];
82 + const rawLines: string[] = [];
83 + let done = false;
84 + while (!done) {
85 + const r = await reader.read();
86 + if (r.done) break;
87 + buf += dec.decode(r.value, { stream: true });
88 + let idx;
89 + while ((idx = buf.indexOf("\n\n")) >= 0) {
90 + const evt = buf.slice(0, idx); buf = buf.slice(idx + 2);
91 + for (const line of evt.split("\n")) {
92 + rawLines.push(line);
93 + if (!line.startsWith("data:")) continue;
94 + const payload = line.slice(5).trim();
95 + if (payload === "[DONE]") { done = true; chunks.push("[DONE]"); continue; }
96 + try { chunks.push(JSON.parse(payload)); } catch { chunks.push({ unparsed: payload }); }
97 + }
98 + }
99 + }
100 + return { status: res.status, headers, chunks, rawLines, error: null };
101 +}
102 +
103 +/** Minimal valid PNG (RGBA), size x size, solid colour. */
104 +export function pngDataUrl(size = 32, rgba: [number, number, number, number] = [200, 30, 30, 255]) {
105 + const crcTable = new Int32Array(256);
106 + for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; crcTable[n] = c; }
107 + const crc32 = (buf: Buffer) => { let c = -1; for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8); return (c ^ -1) >>> 0; };
108 + const chunk = (type: string, data: Buffer) => {
109 + const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
110 + const td = Buffer.concat([Buffer.from(type, "ascii"), data]);
111 + const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(td));
112 + return Buffer.concat([len, td, crc]);
113 + };
114 + const ihdr = Buffer.alloc(13);
115 + ihdr.writeUInt32BE(size, 0); ihdr.writeUInt32BE(size, 4);
116 + ihdr[8] = 8; ihdr[9] = 6; ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
117 + const row = Buffer.alloc(1 + size * 4);
118 + for (let x = 0; x < size; x++) row.set(rgba, 1 + x * 4);
119 + const raw = Buffer.concat(Array.from({ length: size }, () => row));
120 + const png = Buffer.concat([
121 + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
122 + chunk("IHDR", ihdr), chunk("IDAT", deflateSync(raw)), chunk("IEND", Buffer.alloc(0)),
123 + ]);
124 + return `data:image/png;base64,${png.toString("base64")}`;
125 +}
126 +
127 +export function short(s: unknown, n = 300) {
128 + const t = typeof s === "string" ? s : (JSON.stringify(s) ?? String(s));
129 + return t.length > n ? t.slice(0, n) + `…(${t.length})` : t;
130 +}
131 +
132 +export const WEATHER_TOOL = {
133 + type: "function" as const,
134 + function: {
135 + name: "get_weather",
136 + description: "Get the current weather for a city",
137 + parameters: { type: "object", properties: { city: { type: "string", description: "City name" } }, required: ["city"] },
138 + },
139 +};
added research/kimi/package.json +24 −0
@@ -0,0 +1,24 @@
1 +{
2 + "name": "kimi",
3 + "version": "1.0.0",
4 + "description": "",
5 + "main": "index.js",
6 + "scripts": {
7 + "test": "echo \"Error: no test specified\" && exit 1"
8 + },
9 + "keywords": [],
10 + "author": "",
11 + "license": "ISC",
12 + "devEngines": {
13 + "packageManager": {
14 + "name": "pnpm",
15 + "version": "^11.1.2",
16 + "onFail": "download"
17 + }
18 + },
19 + "type": "module",
20 + "dependencies": {
21 + "openai": "^7.10.0",
22 + "tsx": "^4.23.13"
23 + }
24 +}
added research/kimi/pnpm-lock.yaml +532 −0
@@ -0,0 +1,532 @@
1 +---
2 +lockfileVersion: '9.0'
3 +
4 +importers:
5 +
6 + .:
7 + configDependencies: {}
8 + packageManagerDependencies:
9 + '@pnpm/exe':
10 + specifier: ^11.1.2
11 + version: 11.26.0
12 + pnpm:
13 + specifier: ^11.1.2
14 + version: 11.26.0
15 +
16 +packages:
17 +
18 + '@pnpm/exe@11.26.0':
19 + resolution: {integrity: sha512-eeiNi7WeXulOO1BzDAU9HIk+N3dokG+xwKPUupNQoZT5auL3rh2pFW9DqdIEnCC2xtt/hZd6UGtKyb6OMpqndw==}
20 + hasBin: true
21 +
22 + '@pnpm/linux-arm64@11.26.0':
23 + resolution: {integrity: sha512-M0IDuD4hbXxpLBsj1/I9pODcxu/gxTDtbyQmsVGYu1TZwCCpf6YU1x3lzq8aCGjrysmXYQAtCiY8GBjdw4UGFg==}
24 + cpu: [arm64]
25 + os: [linux]
26 +
27 + '@pnpm/linux-x64@11.26.0':
28 + resolution: {integrity: sha512-nUuNRsFCGVje3FHtOaWxIQl2EP4NBktKr+PpLN7uhuWnJCCMN/F9RKjNJAM+dUPyvAZs8VDvS0kA8J55kyybyg==}
29 + cpu: [x64]
30 + os: [linux]
31 +
32 + '@pnpm/linuxstatic-arm64@11.26.0':
33 + resolution: {integrity: sha512-+dXROkvdWjskrQtjwJAFuJI7Q2uTkghBfLsd0vqRmorX3fPuinhoRjJJ/UPtWXzJqwcByJBmdJRW+q2964m5qQ==}
34 + cpu: [arm64]
35 + os: [linux]
36 + libc: [musl]
37 +
38 + '@pnpm/linuxstatic-x64@11.26.0':
39 + resolution: {integrity: sha512-0eXE6spzIBdCbYhJZQ0u/sIOD0v30sqk2PrzoixzsNMc7S/lhd7EkBMVJ6cCPiCL2TXvOsrJC2SpWPBK769A9A==}
40 + cpu: [x64]
41 + os: [linux]
42 + libc: [musl]
43 +
44 + '@pnpm/macos-arm64@11.26.0':
45 + resolution: {integrity: sha512-Za3kKV89Zj0SFzQf6zEUTUIy13xH8UiNyb7aILDRjPiTvTaUSCh3q+nvljISfGH1XcopXvo+p3K8Q2nrKYWAug==}
46 + cpu: [arm64]
47 + os: [darwin]
48 +
49 + '@pnpm/win-arm64@11.26.0':
50 + resolution: {integrity: sha512-5akeLtbqbFDdJtCV4qgLmDBV3R+XNs6E7h7Xb4ZBzS3rLRp8e/y/ZdgrZaGF5Kjo45g0BLAxdGUREHbU2/lGUw==}
51 + cpu: [arm64]
52 + os: [win32]
53 +
54 + '@pnpm/win-x64@11.26.0':
55 + resolution: {integrity: sha512-f15SfIo7nppxBII+STy6jpd1z3LgGTZh6skfuxKA/xHmkzopnQJQXr4hBnl1rJLJAnmuOhN8BssaC4LiXmS78w==}
56 + cpu: [x64]
57 + os: [win32]
58 +
59 + '@reflink/reflink-darwin-arm64@0.1.19':
60 + resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==}
61 + engines: {node: '>= 10'}
62 + cpu: [arm64]
63 + os: [darwin]
64 +
65 + '@reflink/reflink-darwin-x64@0.1.19':
66 + resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==}
67 + engines: {node: '>= 10'}
68 + cpu: [x64]
69 + os: [darwin]
70 +
71 + '@reflink/reflink-linux-arm64-gnu@0.1.19':
72 + resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==}
73 + engines: {node: '>= 10'}
74 + cpu: [arm64]
75 + os: [linux]
76 + libc: [glibc]
77 +
78 + '@reflink/reflink-linux-arm64-musl@0.1.19':
79 + resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==}
80 + engines: {node: '>= 10'}
81 + cpu: [arm64]
82 + os: [linux]
83 + libc: [musl]
84 +
85 + '@reflink/reflink-linux-x64-gnu@0.1.19':
86 + resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==}
87 + engines: {node: '>= 10'}
88 + cpu: [x64]
89 + os: [linux]
90 + libc: [glibc]
91 +
92 + '@reflink/reflink-linux-x64-musl@0.1.19':
93 + resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==}
94 + engines: {node: '>= 10'}
95 + cpu: [x64]
96 + os: [linux]
97 + libc: [musl]
98 +
99 + '@reflink/reflink-win32-arm64-msvc@0.1.19':
100 + resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==}
101 + engines: {node: '>= 10'}
102 + cpu: [arm64]
103 + os: [win32]
104 +
105 + '@reflink/reflink-win32-x64-msvc@0.1.19':
106 + resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==}
107 + engines: {node: '>= 10'}
108 + cpu: [x64]
109 + os: [win32]
110 +
111 + '@reflink/reflink@0.1.19':
112 + resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==}
113 + engines: {node: '>= 10'}
114 +
115 + detect-libc@2.1.2:
116 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
117 + engines: {node: '>=8'}
118 +
119 + pnpm@11.26.0:
120 + resolution: {integrity: sha512-/A4r+JC5+YNhHxq2jAY3vOkUOQZTaZ+DwaeLAF7SXyyB53kgyP2O7i7PC1iyjNywCoTZf2m898VrLzRHECOGZA==}
121 + engines: {node: '>=22.13'}
122 + hasBin: true
123 +
124 +snapshots:
125 +
126 + '@pnpm/exe@11.26.0':
127 + dependencies:
128 + '@reflink/reflink': 0.1.19
129 + detect-libc: 2.1.2
130 + optionalDependencies:
131 + '@pnpm/linux-arm64': 11.26.0
132 + '@pnpm/linux-x64': 11.26.0
133 + '@pnpm/linuxstatic-arm64': 11.26.0
134 + '@pnpm/linuxstatic-x64': 11.26.0
135 + '@pnpm/macos-arm64': 11.26.0
136 + '@pnpm/win-arm64': 11.26.0
137 + '@pnpm/win-x64': 11.26.0
138 +
139 + '@pnpm/linux-arm64@11.26.0':
140 + optional: true
141 +
142 + '@pnpm/linux-x64@11.26.0':
143 + optional: true
144 +
145 + '@pnpm/linuxstatic-arm64@11.26.0':
146 + optional: true
147 +
148 + '@pnpm/linuxstatic-x64@11.26.0':
149 + optional: true
150 +
151 + '@pnpm/macos-arm64@11.26.0':
152 + optional: true
153 +
154 + '@pnpm/win-arm64@11.26.0':
155 + optional: true
156 +
157 + '@pnpm/win-x64@11.26.0':
158 + optional: true
159 +
160 + '@reflink/reflink-darwin-arm64@0.1.19':
161 + optional: true
162 +
163 + '@reflink/reflink-darwin-x64@0.1.19':
164 + optional: true
165 +
166 + '@reflink/reflink-linux-arm64-gnu@0.1.19':
167 + optional: true
168 +
169 + '@reflink/reflink-linux-arm64-musl@0.1.19':
170 + optional: true
171 +
172 + '@reflink/reflink-linux-x64-gnu@0.1.19':
173 + optional: true
174 +
175 + '@reflink/reflink-linux-x64-musl@0.1.19':
176 + optional: true
177 +
178 + '@reflink/reflink-win32-arm64-msvc@0.1.19':
179 + optional: true
180 +
181 + '@reflink/reflink-win32-x64-msvc@0.1.19':
182 + optional: true
183 +
184 + '@reflink/reflink@0.1.19':
185 + optionalDependencies:
186 + '@reflink/reflink-darwin-arm64': 0.1.19
187 + '@reflink/reflink-darwin-x64': 0.1.19
188 + '@reflink/reflink-linux-arm64-gnu': 0.1.19
189 + '@reflink/reflink-linux-arm64-musl': 0.1.19
190 + '@reflink/reflink-linux-x64-gnu': 0.1.19
191 + '@reflink/reflink-linux-x64-musl': 0.1.19
192 + '@reflink/reflink-win32-arm64-msvc': 0.1.19
193 + '@reflink/reflink-win32-x64-msvc': 0.1.19
194 +
195 + detect-libc@2.1.2: {}
196 +
197 + pnpm@11.26.0: {}
198 +
199 +---
200 +lockfileVersion: '9.0'
201 +
202 +settings:
203 + autoInstallPeers: true
204 + excludeLinksFromLockfile: false
205 +
206 +importers:
207 +
208 + .:
209 + dependencies:
210 + openai:
211 + specifier: ^7.10.0
212 + version: 7.10.0
213 + tsx:
214 + specifier: ^4.23.13
215 + version: 4.23.13
216 +
217 +packages:
218 +
219 + '@esbuild/aix-ppc64@0.28.2':
220 + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
221 + engines: {node: '>=18'}
222 + cpu: [ppc64]
223 + os: [aix]
224 +
225 + '@esbuild/android-arm64@0.28.2':
226 + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
227 + engines: {node: '>=18'}
228 + cpu: [arm64]
229 + os: [android]
230 +
231 + '@esbuild/android-arm@0.28.2':
232 + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
233 + engines: {node: '>=18'}
234 + cpu: [arm]
235 + os: [android]
236 +
237 + '@esbuild/android-x64@0.28.2':
238 + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
239 + engines: {node: '>=18'}
240 + cpu: [x64]
241 + os: [android]
242 +
243 + '@esbuild/darwin-arm64@0.28.2':
244 + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
245 + engines: {node: '>=18'}
246 + cpu: [arm64]
247 + os: [darwin]
248 +
249 + '@esbuild/darwin-x64@0.28.2':
250 + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
251 + engines: {node: '>=18'}
252 + cpu: [x64]
253 + os: [darwin]
254 +
255 + '@esbuild/freebsd-arm64@0.28.2':
256 + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
257 + engines: {node: '>=18'}
258 + cpu: [arm64]
259 + os: [freebsd]
260 +
261 + '@esbuild/freebsd-x64@0.28.2':
262 + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
263 + engines: {node: '>=18'}
264 + cpu: [x64]
265 + os: [freebsd]
266 +
267 + '@esbuild/linux-arm64@0.28.2':
268 + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
269 + engines: {node: '>=18'}
270 + cpu: [arm64]
271 + os: [linux]
272 +
273 + '@esbuild/linux-arm@0.28.2':
274 + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
275 + engines: {node: '>=18'}
276 + cpu: [arm]
277 + os: [linux]
278 +
279 + '@esbuild/linux-ia32@0.28.2':
280 + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
281 + engines: {node: '>=18'}
282 + cpu: [ia32]
283 + os: [linux]
284 +
285 + '@esbuild/linux-loong64@0.28.2':
286 + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
287 + engines: {node: '>=18'}
288 + cpu: [loong64]
289 + os: [linux]
290 +
291 + '@esbuild/linux-mips64el@0.28.2':
292 + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
293 + engines: {node: '>=18'}
294 + cpu: [mips64el]
295 + os: [linux]
296 +
297 + '@esbuild/linux-ppc64@0.28.2':
298 + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
299 + engines: {node: '>=18'}
300 + cpu: [ppc64]
301 + os: [linux]
302 +
303 + '@esbuild/linux-riscv64@0.28.2':
304 + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
305 + engines: {node: '>=18'}
306 + cpu: [riscv64]
307 + os: [linux]
308 +
309 + '@esbuild/linux-s390x@0.28.2':
310 + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
311 + engines: {node: '>=18'}
312 + cpu: [s390x]
313 + os: [linux]
314 +
315 + '@esbuild/linux-x64@0.28.2':
316 + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
317 + engines: {node: '>=18'}
318 + cpu: [x64]
319 + os: [linux]
320 +
321 + '@esbuild/netbsd-arm64@0.28.2':
322 + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
323 + engines: {node: '>=18'}
324 + cpu: [arm64]
325 + os: [netbsd]
326 +
327 + '@esbuild/netbsd-x64@0.28.2':
328 + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
329 + engines: {node: '>=18'}
330 + cpu: [x64]
331 + os: [netbsd]
332 +
333 + '@esbuild/openbsd-arm64@0.28.2':
334 + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
335 + engines: {node: '>=18'}
336 + cpu: [arm64]
337 + os: [openbsd]
338 +
339 + '@esbuild/openbsd-x64@0.28.2':
340 + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
341 + engines: {node: '>=18'}
342 + cpu: [x64]
343 + os: [openbsd]
344 +
345 + '@esbuild/openharmony-arm64@0.28.2':
346 + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
347 + engines: {node: '>=18'}
348 + cpu: [arm64]
349 + os: [openharmony]
350 +
351 + '@esbuild/sunos-x64@0.28.2':
352 + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
353 + engines: {node: '>=18'}
354 + cpu: [x64]
355 + os: [sunos]
356 +
357 + '@esbuild/win32-arm64@0.28.2':
358 + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
359 + engines: {node: '>=18'}
360 + cpu: [arm64]
361 + os: [win32]
362 +
363 + '@esbuild/win32-ia32@0.28.2':
364 + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
365 + engines: {node: '>=18'}
366 + cpu: [ia32]
367 + os: [win32]
368 +
369 + '@esbuild/win32-x64@0.28.2':
370 + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
371 + engines: {node: '>=18'}
372 + cpu: [x64]
373 + os: [win32]
374 +
375 + esbuild@0.28.2:
376 + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
377 + engines: {node: '>=18'}
378 + hasBin: true
379 +
380 + fsevents@2.3.3:
381 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
382 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
383 + os: [darwin]
384 +
385 + openai@7.10.0:
386 + resolution: {integrity: sha512-sn9t2Kls7O52PwuF9BUTYNu4Gk/r0lXJyrgaNht4TNRlZFb3dJIGO0RciSgjARGCBRtWjySubAQFJttlzUvGQQ==}
387 + engines: {node: '>=22.0.0'}
388 + peerDependencies:
389 + '@aws-sdk/credential-provider-node': '>=3.972.0 <4'
390 + '@smithy/hash-node': '>=4.3.0 <5'
391 + '@smithy/signature-v4': '>=5.4.0 <6'
392 + undici: '>=5 <9'
393 + ws: ^8.21.0
394 + zod: ^3.25 || ^4.0
395 + peerDependenciesMeta:
396 + '@aws-sdk/credential-provider-node':
397 + optional: true
398 + '@smithy/hash-node':
399 + optional: true
400 + '@smithy/signature-v4':
401 + optional: true
402 + undici:
403 + optional: true
404 + ws:
405 + optional: true
406 + zod:
407 + optional: true
408 +
409 + tsx@4.23.13:
410 + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
411 + engines: {node: '>=18.0.0'}
412 + hasBin: true
413 +
414 +snapshots:
415 +
416 + '@esbuild/aix-ppc64@0.28.2':
417 + optional: true
418 +
419 + '@esbuild/android-arm64@0.28.2':
420 + optional: true
421 +
422 + '@esbuild/android-arm@0.28.2':
423 + optional: true
424 +
425 + '@esbuild/android-x64@0.28.2':
426 + optional: true
427 +
428 + '@esbuild/darwin-arm64@0.28.2':
429 + optional: true
430 +
431 + '@esbuild/darwin-x64@0.28.2':
432 + optional: true
433 +
434 + '@esbuild/freebsd-arm64@0.28.2':
435 + optional: true
436 +
437 + '@esbuild/freebsd-x64@0.28.2':
438 + optional: true
439 +
440 + '@esbuild/linux-arm64@0.28.2':
441 + optional: true
442 +
443 + '@esbuild/linux-arm@0.28.2':
444 + optional: true
445 +
446 + '@esbuild/linux-ia32@0.28.2':
447 + optional: true
448 +
449 + '@esbuild/linux-loong64@0.28.2':
450 + optional: true
451 +
452 + '@esbuild/linux-mips64el@0.28.2':
453 + optional: true
454 +
455 + '@esbuild/linux-ppc64@0.28.2':
456 + optional: true
457 +
458 + '@esbuild/linux-riscv64@0.28.2':
459 + optional: true
460 +
461 + '@esbuild/linux-s390x@0.28.2':
462 + optional: true
463 +
464 + '@esbuild/linux-x64@0.28.2':
465 + optional: true
466 +
467 + '@esbuild/netbsd-arm64@0.28.2':
468 + optional: true
469 +
470 + '@esbuild/netbsd-x64@0.28.2':
471 + optional: true
472 +
473 + '@esbuild/openbsd-arm64@0.28.2':
474 + optional: true
475 +
476 + '@esbuild/openbsd-x64@0.28.2':
477 + optional: true
478 +
479 + '@esbuild/openharmony-arm64@0.28.2':
480 + optional: true
481 +
482 + '@esbuild/sunos-x64@0.28.2':
483 + optional: true
484 +
485 + '@esbuild/win32-arm64@0.28.2':
486 + optional: true
487 +
488 + '@esbuild/win32-ia32@0.28.2':
489 + optional: true
490 +
491 + '@esbuild/win32-x64@0.28.2':
492 + optional: true
493 +
494 + esbuild@0.28.2:
495 + optionalDependencies:
496 + '@esbuild/aix-ppc64': 0.28.2
497 + '@esbuild/android-arm': 0.28.2
498 + '@esbuild/android-arm64': 0.28.2
499 + '@esbuild/android-x64': 0.28.2
500 + '@esbuild/darwin-arm64': 0.28.2
501 + '@esbuild/darwin-x64': 0.28.2
502 + '@esbuild/freebsd-arm64': 0.28.2
503 + '@esbuild/freebsd-x64': 0.28.2
504 + '@esbuild/linux-arm': 0.28.2
505 + '@esbuild/linux-arm64': 0.28.2
506 + '@esbuild/linux-ia32': 0.28.2
507 + '@esbuild/linux-loong64': 0.28.2
508 + '@esbuild/linux-mips64el': 0.28.2
509 + '@esbuild/linux-ppc64': 0.28.2
510 + '@esbuild/linux-riscv64': 0.28.2
511 + '@esbuild/linux-s390x': 0.28.2
512 + '@esbuild/linux-x64': 0.28.2
513 + '@esbuild/netbsd-arm64': 0.28.2
514 + '@esbuild/netbsd-x64': 0.28.2
515 + '@esbuild/openbsd-arm64': 0.28.2
516 + '@esbuild/openbsd-x64': 0.28.2
517 + '@esbuild/openharmony-arm64': 0.28.2
518 + '@esbuild/sunos-x64': 0.28.2
519 + '@esbuild/win32-arm64': 0.28.2
520 + '@esbuild/win32-ia32': 0.28.2
521 + '@esbuild/win32-x64': 0.28.2
522 +
523 + fsevents@2.3.3:
524 + optional: true
525 +
526 + openai@7.10.0: {}
527 +
528 + tsx@4.23.13:
529 + dependencies:
530 + esbuild: 0.28.2
531 + optionalDependencies:
532 + fsevents: 2.3.3
added research/mistral/00-models.ts +33 −0
@@ -0,0 +1,33 @@
1 +import { raw, save, short } from "./lib.ts";
2 +
3 +const list = await raw("/models");
4 +save("00-models.json", list);
5 +console.log("status", list.status, "headers", list.headers);
6 +const data = (list.body as any)?.data ?? [];
7 +console.log("count", data.length);
8 +// compact table
9 +for (const m of data) {
10 + const c = m.capabilities ?? {};
11 + console.log(
12 + [
13 + m.id.padEnd(40),
14 + String(m.max_context_length ?? "").padStart(8),
15 + c.completion_chat ? "chat" : " ",
16 + c.completion_fim ? "fim" : " ",
17 + c.function_calling ? "fn" : " ",
18 + c.vision ? "vis" : " ",
19 + c.classification ? "cls" : " ",
20 + c.fine_tuning ? "ft" : " ",
21 + m.deprecation ? `DEPR ${m.deprecation}` : "",
22 + m.deprecation_replacement_model ? `-> ${m.deprecation_replacement_model}` : "",
23 + `aliases=${JSON.stringify(m.aliases ?? [])}`,
24 + ].join(" "),
25 + );
26 +}
27 +// full example object
28 +console.log("example object keys:", Object.keys(data[0] ?? {}));
29 +console.log("capabilities keys:", Object.keys(data[0]?.capabilities ?? {}));
30 +// retrieve one
31 +const one = await raw("/models/mistral-large-latest");
32 +save("00-model-large.json", one);
33 +console.log("GET /models/mistral-large-latest", one.status, short(one.body, 800));
added research/mistral/01-chat-stream.ts +60 −0
@@ -0,0 +1,60 @@
1 +import { CHAT_MODELS, post, rawSSE, save, short } from "./lib.ts";
2 +
3 +const results: Record<string, any> = {};
4 +for (const model of CHAT_MODELS) {
5 + const t0 = Date.now();
6 + const r = await post("/chat/completions", {
7 + model,
8 + messages: [{ role: "user", content: "What is 2+2? Answer in one short sentence." }],
9 + max_tokens: 200,
10 + });
11 + const ms = Date.now() - t0;
12 + const body: any = r.body;
13 + const msg = body?.choices?.[0]?.message;
14 + console.log(
15 + `\n=== ${model} non-stream ${r.status} ${ms}ms model=${body?.model} finish=${body?.choices?.[0]?.finish_reason}`,
16 + );
17 + console.log("headers", r.headers);
18 + console.log("message keys", msg ? Object.keys(msg) : body);
19 + console.log("content type", Array.isArray(msg?.content) ? "array:" + msg.content.map((c: any) => c.type).join(",") : typeof msg?.content);
20 + console.log("content", short(msg?.content, 600));
21 + console.log("usage", JSON.stringify(body?.usage));
22 + console.log("top-level keys", body ? Object.keys(body) : "");
23 +
24 + // streaming
25 + const t1 = Date.now();
26 + const s = await rawSSE("/chat/completions", {
27 + model,
28 + messages: [{ role: "user", content: "Say hello in French, 5 words max." }],
29 + max_tokens: 200,
30 + stream: true,
31 + });
32 + const ms2 = Date.now() - t1;
33 + const deltaKeys = new Set<string>();
34 + const contentShapes: string[] = [];
35 + let usageChunk: any = null;
36 + let finish: any = null;
37 + let text = "";
38 + for (const e of s.events) {
39 + if (e.data === "[DONE]") continue;
40 + const ch = e.data?.choices?.[0];
41 + if (ch?.delta) for (const k of Object.keys(ch.delta)) deltaKeys.add(k);
42 + if (ch?.delta?.content !== undefined) {
43 + const c = ch.delta.content;
44 + const shape = typeof c === "string" ? "string" : Array.isArray(c) ? "array:" + c.map((x: any) => x.type).join(",") : typeof c;
45 + if (contentShapes[contentShapes.length - 1] !== shape) contentShapes.push(shape);
46 + if (typeof c === "string") text += c;
47 + }
48 + if (ch?.finish_reason) finish = ch.finish_reason;
49 + if (e.data?.usage) usageChunk = { usage: e.data.usage, choicesLen: e.data.choices?.length, keys: Object.keys(e.data) };
50 + }
51 + console.log(`--- stream ${s.status} ${ms2}ms events=${s.events.length} headers=${JSON.stringify(s.headers)}`);
52 + console.log("first event", short(s.events[0]?.data, 500));
53 + console.log("second event", short(s.events[1]?.data, 500));
54 + console.log("last-2 events", short(s.events[s.events.length - 2]?.data, 500), "|", short(s.events[s.events.length - 1]?.data, 100));
55 + console.log("delta keys", [...deltaKeys], "content shapes", contentShapes, "finish", finish);
56 + console.log("usage chunk", JSON.stringify(usageChunk));
57 + console.log("text", short(text, 200));
58 + results[model] = { nonStream: r, stream: s };
59 +}
60 +save("01-chat-stream.json", results);
added research/mistral/02-params.ts +110 −0
@@ -0,0 +1,110 @@
1 +import { CHAT_MODELS, post, save, short } from "./lib.ts";
2 +
3 +const TOOL = {
4 + type: "function",
5 + function: {
6 + name: "get_weather",
7 + description: "Get weather for a city",
8 + parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
9 + },
10 +};
11 +
12 +const variants: Record<string, any> = {
13 + base: {},
14 + temperature_0: { temperature: 0 },
15 + temperature_1: { temperature: 1.0 },
16 + temperature_1_5: { temperature: 1.5 },
17 + temperature_2: { temperature: 2.0 },
18 + top_p_0_5: { top_p: 0.5 },
19 + top_k_5: { top_k: 5 },
20 + max_tokens_5: { max_tokens: 5 },
21 + max_completion_tokens_5: { max_completion_tokens: 5 },
22 + stop_array: { stop: ["."] },
23 + stop_string: { stop: "." },
24 + random_seed_42: { random_seed: 42 },
25 + seed_42: { seed: 42 },
26 + presence_penalty_1: { presence_penalty: 1.0 },
27 + presence_penalty_2_5: { presence_penalty: 2.5 },
28 + frequency_penalty_1: { frequency_penalty: 1.0 },
29 + frequency_penalty_neg_2_5: { frequency_penalty: -2.5 },
30 + n_2: { n: 2 },
31 + safe_prompt: { safe_prompt: true },
32 + prompt_mode_reasoning: { prompt_mode: "reasoning" },
33 + reasoning_effort_none: { reasoning_effort: "none" },
34 + reasoning_effort_minimal: { reasoning_effort: "minimal" },
35 + reasoning_effort_low: { reasoning_effort: "low" },
36 + reasoning_effort_medium: { reasoning_effort: "medium" },
37 + reasoning_effort_high: { reasoning_effort: "high" },
38 + reasoning_effort_xhigh: { reasoning_effort: "xhigh" },
39 + reasoning_effort_bogus: { reasoning_effort: "bogus" },
40 + response_format_json_object: {
41 + response_format: { type: "json_object" },
42 + messages: [{ role: "user", content: "Return a JSON object with key ok set to true." }],
43 + },
44 + response_format_json_schema: {
45 + response_format: {
46 + type: "json_schema",
47 + json_schema: {
48 + name: "ok",
49 + strict: true,
50 + schema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"], additionalProperties: false },
51 + },
52 + },
53 + },
54 + parallel_tool_calls_false: { tools: [TOOL], parallel_tool_calls: false },
55 + tool_choice_required: { tools: [TOOL], tool_choice: "required", messages: [{ role: "user", content: "Weather in Paris?" }] },
56 + tool_choice_any: { tools: [TOOL], tool_choice: "any", messages: [{ role: "user", content: "Weather in Paris?" }] },
57 + tool_choice_object: {
58 + tools: [TOOL],
59 + tool_choice: { type: "function", function: { name: "get_weather" } },
60 + messages: [{ role: "user", content: "Weather in Paris?" }],
61 + },
62 + logprobs: { logprobs: true },
63 + developer_role: { messages: [{ role: "developer", content: "Be terse." }, { role: "user", content: "Say OK." }] },
64 + system_role: { messages: [{ role: "system", content: "Be terse." }, { role: "user", content: "Say OK." }] },
65 + unknown_param: { foo_bar: 1 },
66 + prediction: { prediction: { type: "content", content: "OK" } },
67 + service_tier_auto: { service_tier: "auto" },
68 + service_tier_standard_only: { service_tier: "standard_only" },
69 + prompt_cache_key: { prompt_cache_key: "polyllm-probe" },
70 + stream_options_nonstream: { stream_options: { include_usage: true } },
71 + assistant_prefix: {
72 + messages: [
73 + { role: "user", content: "Say OK." },
74 + { role: "assistant", content: "Sure thing:", prefix: true },
75 + ],
76 + },
77 + metadata: { metadata: { app: "polyllm" } },
78 +};
79 +
80 +async function pool<T>(items: T[], n: number, fn: (t: T) => Promise<void>) {
81 + let i = 0;
82 + await Promise.all(
83 + Array.from({ length: n }, async () => {
84 + while (i < items.length) await fn(items[i++]);
85 + }),
86 + );
87 +}
88 +
89 +const results: Record<string, Record<string, any>> = {};
90 +for (const model of CHAT_MODELS) {
91 + results[model] = {};
92 + console.log(`\n=== ${model}`);
93 + await pool(Object.entries(variants), 5, async ([name, v]) => {
94 + const payload = { model, messages: [{ role: "user", content: "Say OK." }], max_tokens: 30, ...v };
95 + const r = await post("/chat/completions", payload);
96 + const body: any = r.body;
97 + let info = "";
98 + if (r.status === 200) {
99 + const ch = body.choices?.[0];
100 + const c = ch?.message?.content;
101 + const shape = Array.isArray(c) ? "array:" + c.map((x: any) => x.type).join(",") : typeof c;
102 + info = `finish=${ch?.finish_reason} choices=${body.choices?.length} content=${shape} ${short(c, 80)} tool_calls=${ch?.message?.tool_calls ? ch.message.tool_calls.length : 0} usage=${JSON.stringify(body.usage)}${ch?.logprobs ? " logprobs=" + short(ch.logprobs, 80) : ""}`;
103 + } else {
104 + info = short(body, 400);
105 + }
106 + results[model][name] = { status: r.status, body: r.status === 200 ? { finish: body.choices?.[0]?.finish_reason, content: body.choices?.[0]?.message?.content, usage: body.usage, choices: body.choices?.length } : body };
107 + console.log(`${name.padEnd(30)} ${r.status} ${info}`);
108 + });
109 +}
110 +save("02-params.json", results);
added research/mistral/03-tools-stream.ts +67 −0
@@ -0,0 +1,67 @@
1 +import { CHAT_MODELS, post, rawSSE, save, short } from "./lib.ts";
2 +
3 +const tools = [
4 + {
5 + type: "function",
6 + function: {
7 + name: "get_weather",
8 + description: "Get the current weather for a city",
9 + parameters: { type: "object", properties: { city: { type: "string", description: "City name" } }, required: ["city"] },
10 + },
11 + },
12 +];
13 +
14 +const results: Record<string, any> = {};
15 +for (const model of CHAT_MODELS) {
16 + console.log(`\n=== ${model}`);
17 + const messages: any[] = [{ role: "user", content: "What's the weather in Montreal right now? Use the tool." }];
18 + const s = await rawSSE("/chat/completions", { model, messages, tools, tool_choice: "auto", max_tokens: 200, stream: true });
19 + const toolDeltas: any[] = [];
20 + let finish: any;
21 + let usage: any;
22 + for (const e of s.events) {
23 + if (e.data === "[DONE]") continue;
24 + const ch = e.data?.choices?.[0];
25 + if (ch?.delta?.tool_calls) toolDeltas.push(ch.delta);
26 + if (ch?.finish_reason) finish = ch.finish_reason;
27 + if (e.data?.usage) usage = e.data.usage;
28 + }
29 + console.log(`stream ${s.status} events=${s.events.length} finish=${finish} toolDeltaChunks=${toolDeltas.length}`);
30 + console.log("tool deltas", short(toolDeltas, 900));
31 + console.log("usage", JSON.stringify(usage));
32 + // assemble
33 + const calls: Record<number, any> = {};
34 + for (const d of toolDeltas)
35 + for (const tc of d.tool_calls) {
36 + const idx = tc.index ?? 0;
37 + calls[idx] ??= { id: tc.id, type: tc.type, function: { name: "", arguments: "" } };
38 + if (tc.id) calls[idx].id = tc.id;
39 + if (tc.function?.name) calls[idx].function.name += tc.function.name;
40 + if (tc.function?.arguments) calls[idx].function.arguments += tc.function.arguments;
41 + }
42 + const toolCalls = Object.values(calls);
43 + console.log("assembled", JSON.stringify(toolCalls));
44 + let round2: any = null;
45 + if (toolCalls.length) {
46 + messages.push({ role: "assistant", content: "", tool_calls: toolCalls });
47 + for (const tc of toolCalls)
48 + messages.push({ role: "tool", tool_call_id: tc.id, name: tc.function.name, content: JSON.stringify({ temp_c: -3, sky: "snow" }) });
49 + round2 = await post("/chat/completions", { model, messages, tools, max_tokens: 100 });
50 + console.log(`round2 ${round2.status} ${short(round2.body?.choices?.[0]?.message?.content ?? round2.body, 200)}`);
51 + }
52 + results[model] = { stream: { status: s.status, events: s.events, toolDeltas }, assembled: toolCalls, round2 };
53 +}
54 +// tool_call_id format constraint: try a long OpenAI-style id in a tool message
55 +const bad = await post("/chat/completions", {
56 + model: "mistral-small-latest",
57 + max_tokens: 50,
58 + messages: [
59 + { role: "user", content: "Weather in Paris?" },
60 + { role: "assistant", content: "", tool_calls: [{ id: "call_abc123def456ghi789", type: "function", function: { name: "get_weather", arguments: '{"city":"Paris"}' } }] },
61 + { role: "tool", tool_call_id: "call_abc123def456ghi789", name: "get_weather", content: '{"temp_c": 12}' },
62 + ],
63 + tools,
64 +});
65 +console.log("\nlong tool_call_id round trip", bad.status, short(bad.body, 400));
66 +results.longToolCallId = bad;
67 +save("03-tools-stream.json", results);
added research/mistral/04-structured.ts +48 −0
@@ -0,0 +1,48 @@
1 +import { CHAT_MODELS, post, save, short } from "./lib.ts";
2 +
3 +const schema = {
4 + type: "object",
5 + properties: {
6 + city: { type: "string" },
7 + country: { type: "string" },
8 + population_millions: { type: "number" },
9 + landmarks: { type: "array", items: { type: "string" } },
10 + },
11 + required: ["city", "country", "population_millions", "landmarks"],
12 + additionalProperties: false,
13 +};
14 +const results: Record<string, any> = {};
15 +for (const model of CHAT_MODELS) {
16 + const r = await post("/chat/completions", {
17 + model,
18 + messages: [{ role: "user", content: "Give facts about Montreal." }],
19 + max_tokens: 200,
20 + response_format: { type: "json_schema", json_schema: { name: "city_facts", strict: true, schema, description: "City facts" } },
21 + });
22 + const body: any = r.body;
23 + let parsed: any = null;
24 + const c = body?.choices?.[0]?.message?.content;
25 + try {
26 + parsed = JSON.parse(typeof c === "string" ? c : c?.find((x: any) => x.type === "text")?.text);
27 + } catch {}
28 + console.log(`${model.padEnd(28)} ${r.status} finish=${body?.choices?.[0]?.finish_reason} valid=${!!parsed} keys=${parsed ? Object.keys(parsed) : ""} ${short(c ?? body, 160)}`);
29 + results[model] = r;
30 +}
31 +// strict:false + schema with unsupported keyword, and json_object without mentioning JSON
32 +const r2 = await post("/chat/completions", {
33 + model: "mistral-small-latest",
34 + messages: [{ role: "user", content: "Give facts about Montreal." }],
35 + max_tokens: 100,
36 + response_format: { type: "json_schema", json_schema: { name: "x", strict: true, schema: { type: "object", properties: { n: { type: "integer", minimum: 0, maximum: 10 } }, required: ["n"], additionalProperties: false } } },
37 +});
38 +console.log("min/max keyword", r2.status, short(r2.body, 300));
39 +const r3 = await post("/chat/completions", {
40 + model: "mistral-small-latest",
41 + messages: [{ role: "user", content: "Give facts about Montreal." }],
42 + max_tokens: 100,
43 + response_format: { type: "json_object" },
44 +});
45 +console.log("json_object without JSON in prompt", r3.status, short(r3.body?.choices?.[0]?.message?.content ?? r3.body, 300));
46 +results.minmax = r2;
47 +results.jsonObjectNoMention = r3;
48 +save("04-structured.json", results);
added research/mistral/05-vision.ts +38 −0
@@ -0,0 +1,38 @@
1 +import { pngDataUrl, post, save, short } from "./lib.ts";
2 +
3 +const img32 = pngDataUrl(32);
4 +const results: Record<string, any> = {};
5 +const ask = (model: string, url: string, asObject = false) =>
6 + post("/chat/completions", {
7 + model,
8 + max_tokens: 100,
9 + messages: [
10 + {
11 + role: "user",
12 + content: [
13 + { type: "text", text: "Describe this image in one sentence: colours and shapes." },
14 + { type: "image_url", image_url: asObject ? { url, detail: "high" } : url },
15 + ],
16 + },
17 + ],
18 + });
19 +
20 +for (const model of ["mistral-large-latest", "mistral-medium-latest", "mistral-small-latest", "ministral-8b-latest", "codestral-latest", "glm-5-2"]) {
21 + const r = await ask(model, img32);
22 + const b: any = r.body;
23 + console.log(`${model.padEnd(24)} 32px string-url ${r.status} usage=${JSON.stringify(b?.usage)} ${short(b?.choices?.[0]?.message?.content ?? b, 200)}`);
24 + results[model] = r;
25 +}
26 +const obj = await ask("mistral-small-latest", img32, true);
27 +console.log("small object-form {url,detail}", obj.status, JSON.stringify((obj.body as any)?.usage), short((obj.body as any)?.choices?.[0]?.message?.content ?? obj.body, 150));
28 +results.objectForm = obj;
29 +const tiny = await ask("mistral-small-latest", pngDataUrl(2));
30 +console.log("small 2x2", tiny.status, JSON.stringify((tiny.body as any)?.usage), short((tiny.body as any)?.choices?.[0]?.message?.content ?? tiny.body, 200));
31 +results.tiny = tiny;
32 +const big = await ask("mistral-small-latest", pngDataUrl(512));
33 +console.log("small 512x512", big.status, JSON.stringify((big.body as any)?.usage), short((big.body as any)?.choices?.[0]?.message?.content ?? big.body, 200));
34 +results.big = big;
35 +const bad = await ask("mistral-small-latest", "data:image/png;base64,AAAA");
36 +console.log("small invalid png", bad.status, short(bad.body, 300));
37 +results.bad = bad;
38 +save("05-vision.json", results);
added research/mistral/06-errors.ts +51 −0
@@ -0,0 +1,51 @@
1 +import { BASE, post, raw, save, short } from "./lib.ts";
2 +
3 +const results: Record<string, any> = {};
4 +const tiny = (model: string) => ({ model, messages: [{ role: "user", content: "Hi" }], max_tokens: 5 });
5 +
6 +results.invalidKey = await post("/chat/completions", tiny("mistral-small-latest"), "sk-invalid-key-0000");
7 +console.log("invalid key", results.invalidKey.status, JSON.stringify(results.invalidKey.headers), short(results.invalidKey.body, 400));
8 +
9 +const noAuth = await fetch(`${BASE}/chat/completions`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(tiny("mistral-small-latest")) });
10 +results.noAuth = { status: noAuth.status, body: await noAuth.text() };
11 +console.log("no auth", results.noAuth.status, short(results.noAuth.body, 300));
12 +
13 +results.invalidKeyModels = await raw("/models", { key: "sk-invalid-key-0000" });
14 +console.log("invalid key GET /models", results.invalidKeyModels.status, short(results.invalidKeyModels.body, 300));
15 +
16 +results.unknownModel = await post("/chat/completions", tiny("mistral-99-ultra"));
17 +console.log("unknown model", results.unknownModel.status, short(results.unknownModel.body, 400));
18 +
19 +for (const slug of ["mistral-small-2506", "mistral-medium-2508", "magistral-medium-2509", "magistral-small-2509", "pixtral-12b-2409", "open-mistral-7b", "mistral-large-2411", "devstral-medium-2507", "mistral-medium-2505", "ministral-8b-2410", "open-mistral-nemo", "pixtral-large-latest", "codestral-2501", "mistral-small-2501", "mistral-saba-latest", "magistral-medium-2507", "mistral-large-2407"]) {
20 + const r = await post("/chat/completions", tiny(slug));
21 + results[`retired_${slug}`] = r;
22 + console.log(`retired ${slug.padEnd(26)} ${r.status} model=${(r.body as any)?.model ?? ""} ${r.status !== 200 ? short(r.body, 200) : ""}`);
23 +}
24 +
25 +const malformed = await fetch(`${BASE}/chat/completions`, {
26 + method: "POST",
27 + headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.MISTRAL_API_KEY}` },
28 + body: '{"model":"mistral-small-latest","messages":"nope"}',
29 +});
30 +results.malformed = { status: malformed.status, body: await malformed.text() };
31 +console.log("messages as string", results.malformed.status, short(results.malformed.body, 400));
32 +
33 +results.emptyMessages = await post("/chat/completions", { model: "mistral-small-latest", messages: [] });
34 +console.log("empty messages", results.emptyMessages.status, short(results.emptyMessages.body, 300));
35 +
36 +results.maxTokensHuge = await post("/chat/completions", { model: "mistral-small-latest", messages: [{ role: "user", content: "Hi" }], max_tokens: 10_000_000 });
37 +console.log("max_tokens 10M", results.maxTokensHuge.status, short(results.maxTokensHuge.body, 300));
38 +
39 +results.badTemp = await post("/chat/completions", { model: "mistral-small-latest", messages: [{ role: "user", content: "Hi" }], max_tokens: 5, temperature: 3 });
40 +console.log("temperature 3", results.badTemp.status, short(results.badTemp.body, 300));
41 +
42 +results.badTopP = await post("/chat/completions", { model: "mistral-small-latest", messages: [{ role: "user", content: "Hi" }], max_tokens: 5, top_p: 1.5 });
43 +console.log("top_p 1.5", results.badTopP.status, short(results.badTopP.body, 300));
44 +
45 +results.embedOnChat = await post("/chat/completions", tiny("mistral-embed"));
46 +console.log("embed model on chat", results.embedOnChat.status, short(results.embedOnChat.body, 300));
47 +
48 +results.ocrOnChat = await post("/chat/completions", tiny("mistral-ocr-latest"));
49 +console.log("ocr model on chat", results.ocrOnChat.status, short(results.ocrOnChat.body, 300));
50 +
51 +save("06-errors.json", results);
added research/mistral/07-document.ts +49 −0
@@ -0,0 +1,49 @@
1 +import { post, save, short } from "./lib.ts";
2 +
3 +// Minimal one-page PDF with a secret word.
4 +function pdf(text: string) {
5 + const objs: string[] = [];
6 + objs.push("<< /Type /Catalog /Pages 2 0 R >>");
7 + objs.push("<< /Type /Pages /Kids [3 0 R] /Count 1 >>");
8 + objs.push("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 300 144] /Contents 4 0 R /Resources << /Font << /F1 5 0 R >> >> >>");
9 + const stream = `BT /F1 18 Tf 20 100 Td (${text}) Tj ET`;
10 + objs.push(`<< /Length ${stream.length} >>\nstream\n${stream}\nendstream`);
11 + objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>");
12 + let out = "%PDF-1.4\n";
13 + const offsets: number[] = [];
14 + objs.forEach((o, i) => {
15 + offsets.push(out.length);
16 + out += `${i + 1} 0 obj\n${o}\nendobj\n`;
17 + });
18 + const xref = out.length;
19 + out += `xref\n0 ${objs.length + 1}\n0000000000 65535 f \n`;
20 + for (const o of offsets) out += `${String(o).padStart(10, "0")} 00000 n \n`;
21 + out += `trailer\n<< /Size ${objs.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
22 + return Buffer.from(out, "latin1").toString("base64");
23 +}
24 +
25 +const b64 = pdf("The secret word is pamplemousse.");
26 +const results: Record<string, any> = {};
27 +const ask = (model: string, doc: any) =>
28 + post("/chat/completions", {
29 + model,
30 + max_tokens: 60,
31 + messages: [{ role: "user", content: [{ type: "text", text: "What is the secret word in this document? Answer with the word only." }, doc] }],
32 + });
33 +
34 +results.base64Small = await ask("mistral-small-latest", { type: "document_url", document_url: `data:application/pdf;base64,${b64}`, document_name: "secret.pdf" });
35 +console.log("small base64 pdf", results.base64Small.status, JSON.stringify(results.base64Small.body?.usage), short(results.base64Small.body?.choices?.[0]?.message?.content ?? results.base64Small.body, 300));
36 +
37 +results.urlSmall = await ask("mistral-small-latest", { type: "document_url", document_url: "https://www.w3.org/WAI/ER/tests/xhtml/testfiles/resources/pdf/dummy.pdf" });
38 +console.log("small public pdf url", results.urlSmall.status, JSON.stringify(results.urlSmall.body?.usage), short(results.urlSmall.body?.choices?.[0]?.message?.content ?? results.urlSmall.body, 300));
39 +
40 +results.base64Ministral = await ask("ministral-8b-latest", { type: "document_url", document_url: `data:application/pdf;base64,${b64}` });
41 +console.log("ministral-8b base64 pdf", results.base64Ministral.status, JSON.stringify(results.base64Ministral.body?.usage), short(results.base64Ministral.body?.choices?.[0]?.message?.content ?? results.base64Ministral.body, 300));
42 +
43 +results.base64Codestral = await ask("codestral-latest", { type: "document_url", document_url: `data:application/pdf;base64,${b64}` });
44 +console.log("codestral base64 pdf", results.base64Codestral.status, short(results.base64Codestral.body?.choices?.[0]?.message?.content ?? results.base64Codestral.body, 300));
45 +
46 +results.fileType = await ask("mistral-small-latest", { type: "file", file_id: "00000000-0000-0000-0000-000000000000" });
47 +console.log("file type bogus id", results.fileType.status, short(results.fileType.body, 300));
48 +
49 +save("07-document.json", results);
added research/mistral/08-sdks-cache.ts +119 −0
@@ -0,0 +1,119 @@
1 +import { mistral, openai, post, save, short } from "./lib.ts";
2 +
3 +const results: Record<string, any> = {};
4 +
5 +// (k) OpenAI SDK against api.mistral.ai/v1 — non-stream
6 +try {
7 + const r = await openai.chat.completions.create({
8 + model: "mistral-small-latest",
9 + messages: [{ role: "user", content: "Say OK." }],
10 + max_tokens: 10,
11 + });
12 + results.openaiNonStream = r;
13 + console.log("openai sdk non-stream OK", r.model, JSON.stringify(r.usage), short(r.choices[0].message.content, 80));
14 +} catch (e: any) {
15 + results.openaiNonStream = { error: e.status, body: e.error ?? e.message };
16 + console.log("openai sdk non-stream ERR", e.status, short(e.error ?? e.message, 300));
17 +}
18 +// OpenAI SDK stream with include_usage + reasoning_effort high on the hybrid model
19 +try {
20 + const stream = await openai.chat.completions.create({
21 + model: "magistral-medium-latest",
22 + messages: [{ role: "user", content: "Is 17 prime? One sentence." }],
23 + max_tokens: 200,
24 + stream: true,
25 + stream_options: { include_usage: true },
26 + // @ts-expect-error mistral-specific
27 + reasoning_effort: "high",
28 + } as any);
29 + const shapes: string[] = [];
30 + let usage: any, finish: any, n = 0, text = "", thinking = "";
31 + for await (const chunk of stream as any) {
32 + n++;
33 + const d = chunk.choices?.[0]?.delta;
34 + if (d?.content !== undefined) {
35 + const c = d.content;
36 + const shape = typeof c === "string" ? "string" : Array.isArray(c) ? "array:" + c.map((x: any) => x.type).join(",") : typeof c;
37 + if (shapes[shapes.length - 1] !== shape) shapes.push(shape);
38 + if (typeof c === "string") text += c;
39 + else if (Array.isArray(c))
40 + for (const part of c) {
41 + if (part.type === "thinking") for (const t of part.thinking ?? []) thinking += t.text ?? "";
42 + if (part.type === "text") text += part.text ?? "";
43 + }
44 + }
45 + if (chunk.choices?.[0]?.finish_reason) finish = chunk.choices[0].finish_reason;
46 + if (chunk.usage) usage = { usage: chunk.usage, choicesLen: chunk.choices?.length };
47 + }
48 + results.openaiStream = { chunks: n, shapes, usage, finish, text, thinkingLen: thinking.length };
49 + console.log("openai sdk stream OK", JSON.stringify(results.openaiStream));
50 +} catch (e: any) {
51 + results.openaiStream = { error: e.status, body: e.error ?? e.message };
52 + console.log("openai sdk stream ERR", e.status, short(e.error ?? e.message, 300));
53 +}
54 +// OpenAI SDK: invalid key error class
55 +try {
56 + const { default: OpenAI } = await import("openai");
57 + const bad = new OpenAI({ apiKey: "sk-bad", baseURL: "https://api.mistral.ai/v1", maxRetries: 0 });
58 + await bad.chat.completions.create({ model: "mistral-small-latest", messages: [{ role: "user", content: "x" }], max_tokens: 1 });
59 +} catch (e: any) {
60 + results.openaiBadKey = { name: e.constructor?.name, status: e.status, error: e.error, message: e.message };
61 + console.log("openai sdk bad key:", e.constructor?.name, e.status, short(e.error, 200), "|", short(e.message, 200));
62 +}
63 +
64 +// Mistral SDK: complete + stream
65 +try {
66 + const r = await mistral.chat.complete({ model: "mistral-small-latest", messages: [{ role: "user", content: "Say OK." }], maxTokens: 10 });
67 + results.mistralComplete = r;
68 + console.log("mistral sdk complete OK", r.model, JSON.stringify(r.usage), short(r.choices?.[0]?.message?.content, 80));
69 +} catch (e: any) {
70 + console.log("mistral sdk complete ERR", short(e.message, 300));
71 +}
72 +try {
73 + const s = await mistral.chat.stream({
74 + model: "magistral-small-latest",
75 + messages: [{ role: "user", content: "Is 17 prime? One sentence." }],
76 + maxTokens: 200,
77 + reasoningEffort: "high",
78 + } as any);
79 + const shapes: string[] = [];
80 + let n = 0, last: any;
81 + for await (const ev of s) {
82 + n++;
83 + last = ev;
84 + const c = ev.data?.choices?.[0]?.delta?.content;
85 + const shape = c === undefined ? "undefined" : typeof c === "string" ? "string" : Array.isArray(c) ? "array:" + c.map((x: any) => x.type).join(",") : typeof c;
86 + if (shapes[shapes.length - 1] !== shape) shapes.push(shape);
87 + if (n <= 2) console.log("mistral sdk stream event", n, short(ev, 400));
88 + }
89 + results.mistralStream = { events: n, shapes, last };
90 + console.log("mistral sdk stream OK events", n, shapes, "last", short(last, 400));
91 +} catch (e: any) {
92 + console.log("mistral sdk stream ERR", short(e.message, 300));
93 +}
94 +try {
95 + const { Mistral } = await import("@mistralai/mistralai");
96 + const bad = new Mistral({ apiKey: "sk-bad", retryConfig: { strategy: "none" } });
97 + await bad.chat.complete({ model: "mistral-small-latest", messages: [{ role: "user", content: "x" }], maxTokens: 1 });
98 +} catch (e: any) {
99 + results.mistralBadKey = { name: e.constructor?.name, statusCode: e.statusCode, message: e.message, body: e.body };
100 + console.log("mistral sdk bad key:", e.constructor?.name, e.statusCode, short(e.message, 200), short(e.body, 200));
101 +}
102 +
103 +// (j) prompt caching: same long prefix twice with prompt_cache_key
104 +const filler = Array.from({ length: 120 }, (_, i) => `Fact ${i}: The quick brown fox number ${i} jumps over the lazy dog while reciting prime ${i * 7 + 3}.`).join(" ");
105 +const cachePayload = (q: string) => ({
106 + model: "mistral-small-latest",
107 + max_tokens: 10,
108 + prompt_cache_key: "polyllm-cache-probe-1",
109 + messages: [{ role: "system", content: "You are a terse assistant. Context: " + filler }, { role: "user", content: q }],
110 +});
111 +const c1 = await post("/chat/completions", cachePayload("Say A."));
112 +const c2 = await post("/chat/completions", cachePayload("Say B."));
113 +const c3 = await post("/chat/completions", { ...cachePayload("Say C."), prompt_cache_key: undefined });
114 +console.log("cache #1", c1.status, JSON.stringify((c1.body as any)?.usage), "cost hdr", (c1.headers as any)["x-ratelimit-tokens-query-cost"]);
115 +console.log("cache #2", c2.status, JSON.stringify((c2.body as any)?.usage), "cost hdr", (c2.headers as any)["x-ratelimit-tokens-query-cost"]);
116 +console.log("cache #3 (no key)", c3.status, JSON.stringify((c3.body as any)?.usage), "cost hdr", (c3.headers as any)["x-ratelimit-tokens-query-cost"]);
117 +results.cache = { c1, c2, c3 };
118 +
119 +save("08-sdks-cache.json", results);
added research/mistral/09-reasoning-misc.ts +114 −0
@@ -0,0 +1,114 @@
1 +import { post, rawSSE, raw, save, short } from "./lib.ts";
2 +
3 +const results: Record<string, any> = {};
4 +const q = { role: "user", content: "Is 221 a prime number? Answer briefly." };
5 +
6 +// Reasoning non-stream full shape on both hybrid models + glm
7 +for (const model of ["magistral-medium-latest", "mistral-small-latest", "glm-5-2"]) {
8 + const r = await post("/chat/completions", { model, messages: [q], max_tokens: 400, reasoning_effort: "high" });
9 + const b: any = r.body;
10 + const msg = b?.choices?.[0]?.message;
11 + console.log(`\n=== ${model} reasoning_effort=high ${r.status} finish=${b?.choices?.[0]?.finish_reason} usage=${JSON.stringify(b?.usage)}`);
12 + console.log("content", short(msg?.content, 1200));
13 + results[`${model}_high`] = r;
14 +}
15 +
16 +// Streaming shape with reasoning on medium: record the sequence of delta.content shapes and the raw first thinking chunk
17 +const s = await rawSSE("/chat/completions", { model: "magistral-medium-latest", messages: [q], max_tokens: 400, reasoning_effort: "high", stream: true });
18 +const seq: string[] = [];
19 +let firstThink: any, transition: any, firstText: any;
20 +for (const e of s.events) {
21 + if (e.data === "[DONE]") continue;
22 + const c = e.data?.choices?.[0]?.delta?.content;
23 + const shape = c === undefined ? "undef" : typeof c === "string" ? "string" : Array.isArray(c) ? "array:" + c.map((x: any) => x.type).join(",") : typeof c;
24 + if (seq[seq.length - 1] !== shape) seq.push(shape);
25 + if (!firstThink && Array.isArray(c) && c.some((x: any) => x.type === "thinking")) firstThink = e.data;
26 + if (!transition && Array.isArray(c) && c.length > 1) transition = e.data;
27 + if (!firstText && typeof c === "string" && c.length && firstThink) firstText = e.data;
28 +}
29 +console.log("\nstream shapes seq", seq, "events", s.events.length);
30 +console.log("first thinking chunk", short(firstThink, 700));
31 +console.log("transition chunk", short(transition, 700));
32 +console.log("first text chunk after thinking", short(firstText, 400));
33 +console.log("last", short(s.events[s.events.length - 2]?.data, 500));
34 +results.streamReasoning = s;
35 +
36 +// Multi-turn replay including the thinking chunk
37 +const first: any = results["magistral-medium-latest_high"].body;
38 +if (first?.choices?.[0]?.message) {
39 + const replay = await post("/chat/completions", {
40 + model: "magistral-medium-latest",
41 + reasoning_effort: "high",
42 + max_tokens: 200,
43 + messages: [q, first.choices[0].message, { role: "user", content: "And 223?" }],
44 + });
45 + console.log("\nreplay with thinking chunk", replay.status, short((replay.body as any)?.choices?.[0]?.message?.content ?? replay.body, 300));
46 + results.replay = replay;
47 + // replay with thinking stripped to text only
48 + const textOnly = Array.isArray(first.choices[0].message.content)
49 + ? first.choices[0].message.content.filter((c: any) => c.type === "text").map((c: any) => c.text).join("")
50 + : first.choices[0].message.content;
51 + const replay2 = await post("/chat/completions", {
52 + model: "magistral-medium-latest",
53 + reasoning_effort: "high",
54 + max_tokens: 200,
55 + messages: [q, { role: "assistant", content: textOnly }, { role: "user", content: "And 223?" }],
56 + });
57 + console.log("replay text-only", replay2.status, short((replay2.body as any)?.choices?.[0]?.message?.content ?? replay2.body, 200));
58 + results.replayTextOnly = replay2;
59 +}
60 +
61 +// prompt_mode reasoning on small (legacy Magistral param) — shape
62 +const pm = await post("/chat/completions", { model: "mistral-small-latest", messages: [q], max_tokens: 300, prompt_mode: "reasoning" });
63 +console.log("\nprompt_mode reasoning (small)", pm.status, JSON.stringify((pm.body as any)?.usage), short((pm.body as any)?.choices?.[0]?.message?.content ?? pm.body, 500));
64 +results.promptMode = pm;
65 +
66 +// max_tokens truncation during thinking -> finish_reason?
67 +const trunc = await post("/chat/completions", { model: "magistral-medium-latest", messages: [q], max_tokens: 20, reasoning_effort: "high" });
68 +console.log("truncated during thinking", trunc.status, "finish", (trunc.body as any)?.choices?.[0]?.finish_reason, JSON.stringify((trunc.body as any)?.usage), short((trunc.body as any)?.choices?.[0]?.message?.content, 300));
69 +results.trunc = trunc;
70 +
71 +// Labs + third-party quick chat
72 +for (const model of ["labs-leanstral-1-5", "ministral-3b-latest", "ministral-14b-latest", "voxtral-small-latest"]) {
73 + const r = await post("/chat/completions", { model, messages: [{ role: "user", content: "Say OK." }], max_tokens: 20 });
74 + console.log(`\n${model} ${r.status} ${short((r.body as any)?.choices?.[0]?.message?.content ?? r.body, 200)} usage=${JSON.stringify((r.body as any)?.usage)} rl=${(r.headers as any)["x-ratelimit-limit-req-minute"]}/${(r.headers as any)["x-ratelimit-limit-tokens-minute"]}`);
75 + results[`tiny_${model}`] = r;
76 +}
77 +
78 +// FIM on codestral
79 +const fim = await post("/fim/completions", { model: "codestral-latest", prompt: "def fib(n):\n", suffix: "\n return fib(n-1) + fib(n-2)", max_tokens: 40 });
80 +console.log("\nFIM codestral", fim.status, short((fim.body as any)?.choices?.[0]?.message?.content ?? fim.body, 200));
81 +results.fim = fim;
82 +const fimSmall = await post("/fim/completions", { model: "mistral-small-latest", prompt: "def fib(n):\n", max_tokens: 10 });
83 +console.log("FIM on small", fimSmall.status, short(fimSmall.body, 200));
84 +results.fimSmall = fimSmall;
85 +
86 +// web_search tool on chat completions (docs list WebSearchTool in tools[])
87 +const ws = await post("/chat/completions", { model: "mistral-medium-latest", messages: [{ role: "user", content: "What is today's date and one headline from Le Devoir today? Cite." }], tools: [{ type: "web_search" }], max_tokens: 200 });
88 +console.log("\nweb_search on chat completions", ws.status, JSON.stringify((ws.body as any)?.usage), short(ws.body, 1200));
89 +results.webSearchChat = ws;
90 +
91 +// Conversations API: one call with web_search, store false
92 +const conv = await post("/conversations", {
93 + model: "mistral-medium-latest",
94 + inputs: "In one sentence, what is the latest stable Node.js version? Cite a source.",
95 + tools: [{ type: "web_search" }],
96 + store: false,
97 + completion_args: { max_tokens: 200 },
98 +});
99 +console.log("\nconversations web_search", conv.status, short(conv.body, 1500));
100 +results.conversation = conv;
101 +// Conversations stream, no tools
102 +const cs = await rawSSE("/conversations", { model: "mistral-small-latest", inputs: "Say hello in 3 words.", store: false, stream: true, completion_args: { max_tokens: 30 } });
103 +console.log("\nconversations stream", cs.status, "events", cs.events.length, "types", [...new Set(cs.events.map((e) => e.event ?? e.data?.type))]);
104 +console.log("first", short(cs.events[0], 400), "\nlast", short(cs.events[cs.events.length - 1], 500));
105 +results.conversationStream = cs;
106 +
107 +// Token counting / tokenize endpoints?
108 +for (const p of ["/tokenize", "/chat/tokenize", "/models/mistral-small-latest/tokenize"]) {
109 + const t = await post(p, { model: "mistral-small-latest", messages: [{ role: "user", content: "Hello" }] });
110 + console.log("tokenize", p, t.status, short(t.body, 150));
111 +}
112 +const usage = await raw("/usage");
113 +console.log("GET /usage", usage.status, short(usage.body, 150));
114 +save("09-reasoning-misc.json", results);
added research/mistral/10-glm-followups.ts +73 −0
@@ -0,0 +1,73 @@
1 +import { post, rawSSE, save, short } from "./lib.ts";
2 +
3 +const results: Record<string, any> = {};
4 +const base = (model: string, extra: any = {}) => ({ model, messages: [{ role: "user", content: "Say OK." }], max_tokens: 30, ...extra });
5 +
6 +// glm-5-2 param acceptance
7 +for (const [name, extra] of Object.entries({
8 + base: {},
9 + effort_none: { reasoning_effort: "none" },
10 + effort_low: { reasoning_effort: "low" },
11 + effort_medium: { reasoning_effort: "medium" },
12 + effort_xhigh: { reasoning_effort: "xhigh" },
13 + effort_max: { reasoning_effort: "max" },
14 + n_2: { n: 2 },
15 + temp_1_5: { temperature: 1.5 },
16 + stop: { stop: ["."] },
17 + seed: { random_seed: 1 },
18 + penalties: { presence_penalty: 1, frequency_penalty: 1 },
19 + json_schema: { response_format: { type: "json_schema", json_schema: { name: "ok", strict: true, schema: { type: "object", properties: { ok: { type: "boolean" } }, required: ["ok"], additionalProperties: false } } } },
20 + safe_prompt: { safe_prompt: true },
21 + prefix: { messages: [{ role: "user", content: "Say OK." }, { role: "assistant", content: "Sure:", prefix: true }] },
22 + system: { messages: [{ role: "system", content: "Be terse." }, { role: "user", content: "Say OK." }] },
23 +})) {
24 + const r = await post("/chat/completions", base("glm-5-2", extra));
25 + const b: any = r.body;
26 + const c = b?.choices?.[0]?.message?.content;
27 + console.log(`glm ${name.padEnd(14)} ${r.status} ${r.status === 200 ? `finish=${b.choices[0].finish_reason} shape=${Array.isArray(c) ? "arr(" + c.map((x: any) => x.type).join(",") + ")" : typeof c} ct=${b.usage?.completion_tokens} ${short(c, 100)}` : short(b, 250)}`);
28 + if (name === "base") console.log(" glm headers", JSON.stringify(r.headers));
29 + results[`glm_${name}`] = r;
30 +}
31 +// glm stream shape (default effort)
32 +const s = await rawSSE("/chat/completions", { model: "glm-5-2", messages: [{ role: "user", content: "Is 17 prime? One sentence." }], max_tokens: 300, stream: true });
33 +const seq: string[] = [];
34 +for (const e of s.events) {
35 + if (e.data === "[DONE]") continue;
36 + const c = e.data?.choices?.[0]?.delta?.content;
37 + const shape = c === undefined ? "undef" : typeof c === "string" ? "string" : Array.isArray(c) ? "array:" + c.map((x: any) => x.type).join(",") : typeof c;
38 + if (seq[seq.length - 1] !== shape) seq.push(shape);
39 +}
40 +console.log("glm stream", s.status, "events", s.events.length, "shapes", seq, "last", short(s.events[s.events.length - 2]?.data, 400));
41 +results.glmStream = s;
42 +
43 +// reasoning_effort "max" on medium; default effort behaviour check (is default none?)
44 +const mx = await post("/chat/completions", base("mistral-medium-latest", { reasoning_effort: "max" }));
45 +console.log("medium effort=max", mx.status, short(mx.body, 250));
46 +results.mediumMax = mx;
47 +
48 +// glm reasoning tool call
49 +const tools = [{ type: "function", function: { name: "get_weather", description: "Weather for a city", parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] } } }];
50 +const gt = await rawSSE("/chat/completions", { model: "glm-5-2", messages: [{ role: "user", content: "Weather in Montreal? Use the tool." }], tools, max_tokens: 300, stream: true });
51 +const td = gt.events.filter((e) => e.data?.choices?.[0]?.delta?.tool_calls).map((e) => e.data.choices[0].delta);
52 +const shapes2 = [...new Set(gt.events.filter((e) => e.data !== "[DONE]").map((e) => { const c = e.data?.choices?.[0]?.delta?.content; return c === undefined ? "undef" : typeof c === "string" ? "string" : Array.isArray(c) ? "array:" + c.map((x: any) => x.type).join(",") : typeof c; }))];
53 +console.log("glm tools stream", gt.status, "events", gt.events.length, "shapes", shapes2, "toolDeltas", short(td, 500), "finish", gt.events.map((e) => e.data?.choices?.[0]?.finish_reason).filter(Boolean));
54 +results.glmTools = gt;
55 +
56 +// hidden system prompt size: empty-ish prompt token counts per model (safe_prompt vs not) on small
57 +const a = await post("/chat/completions", base("mistral-small-latest"));
58 +const b2 = await post("/chat/completions", base("mistral-small-latest", { safe_prompt: true }));
59 +const c2 = await post("/chat/completions", base("mistral-large-latest"));
60 +const d2 = await post("/chat/completions", base("mistral-large-latest", { safe_prompt: true }));
61 +console.log("prompt_tokens small", (a.body as any).usage.prompt_tokens, "small+safe", (b2.body as any).usage.prompt_tokens, "large", (c2.body as any).usage.prompt_tokens, "large+safe", (d2.body as any).usage.prompt_tokens);
62 +
63 +// stop sequence in output? is stop string included/excluded
64 +const st = await post("/chat/completions", { model: "mistral-small-latest", messages: [{ role: "user", content: "Count: one, two, three, four, five." }], max_tokens: 40, stop: ["three"] });
65 +console.log("stop excluded?", JSON.stringify((st.body as any).choices[0].message.content), (st.body as any).choices[0].finish_reason);
66 +
67 +// max_tokens > context -> ?
68 +const mt = await post("/chat/completions", { model: "ministral-3b-latest", messages: [{ role: "user", content: "Hi" }], max_tokens: 200000 });
69 +console.log("max_tokens 200k on 128k model", mt.status, short(mt.body, 250));
70 +results.maxTokensOver = mt;
71 +
72 +// Rate-limit 429 body: not forced.
73 +save("10-glm-followups.json", results);
added research/mistral/11-glm-minimal.ts +6 −0
@@ -0,0 +1,6 @@
1 +import { post, short } from "./lib.ts";
2 +const r = await post("/chat/completions", { model: "glm-5-2", messages: [{ role: "user", content: "Say OK." }], max_tokens: 30, reasoning_effort: "minimal" });
3 +const b: any = r.body; const c = b?.choices?.[0]?.message?.content;
4 +console.log("glm minimal", r.status, r.status === 200 ? `shape=${Array.isArray(c) ? "arr(" + c.map((x: any) => x.type).join(",") + ")" : typeof c} ct=${b.usage?.completion_tokens}` : short(b, 200));
5 +const v = await post("/chat/completions", { model: "voxtral-small-latest", messages: [{ role: "user", content: "Say OK." }], max_tokens: 10, reasoning_effort: "high" });
6 +console.log("voxtral reasoning_effort", v.status, short(v.body, 150));
added research/mistral/lib.ts +168 −0
@@ -0,0 +1,168 @@
1 +// Shared helpers for Mistral probes. Key comes from process.env.MISTRAL_API_KEY (never logged).
2 +import { mkdirSync, writeFileSync } from "node:fs";
3 +import { deflateSync } from "node:zlib";
4 +import OpenAI from "openai";
5 +import { Mistral } from "@mistralai/mistralai";
6 +
7 +export const BASE = "https://api.mistral.ai/v1";
8 +export const KEY = process.env.MISTRAL_API_KEY ?? "";
9 +if (!KEY) {
10 + console.error("MISTRAL_API_KEY missing (run: set -a; . ./.env; set +a)");
11 + process.exit(1);
12 +}
13 +
14 +export const OUT = new URL("./out/", import.meta.url).pathname;
15 +mkdirSync(OUT, { recursive: true });
16 +
17 +export const openai = new OpenAI({ apiKey: KEY, baseURL: BASE, timeout: 120_000, maxRetries: 0 });
18 +export const mistral = new Mistral({ apiKey: KEY, retryConfig: { strategy: "none" } });
19 +
20 +export const CHAT_MODELS = [
21 + "mistral-large-latest",
22 + "mistral-medium-latest",
23 + "mistral-small-latest",
24 + "ministral-8b-latest",
25 + "magistral-medium-latest",
26 + "magistral-small-latest",
27 + "codestral-latest",
28 +];
29 +
30 +export function save(name: string, data: unknown) {
31 + writeFileSync(`${OUT}${name}`, JSON.stringify(data, null, 2));
32 + console.log(`saved out/${name}`);
33 +}
34 +
35 +const HDR = /ratelimit|request-id|x-request|retry-after|content-type|server|date|cf-ray|x-kong|x-envoy|x-mistral|x-ms/i;
36 +
37 +/** Raw request; returns status, selected headers, and parsed body (never echoes the key). */
38 +export async function raw(path: string, init: RequestInit & { key?: string } = {}) {
39 + const { key, ...rest } = init;
40 + const res = await fetch(`${BASE}${path}`, {
41 + ...rest,
42 + headers: {
43 + "Content-Type": "application/json",
44 + Accept: "application/json",
45 + Authorization: `Bearer ${key ?? KEY}`,
46 + ...(rest.headers ?? {}),
47 + },
48 + });
49 + const text = await res.text();
50 + let body: unknown = text;
51 + try {
52 + body = JSON.parse(text);
53 + } catch {}
54 + const headers: Record<string, string> = {};
55 + res.headers.forEach((v, k) => {
56 + if (HDR.test(k)) headers[k] = v;
57 + });
58 + return { status: res.status, headers, body };
59 +}
60 +
61 +export function post(path: string, payload: unknown, key?: string) {
62 + return raw(path, { method: "POST", body: JSON.stringify(payload), key });
63 +}
64 +
65 +/** Raw SSE POST; returns list of parsed events (data lines). */
66 +export async function rawSSE(path: string, payload: unknown, key?: string) {
67 + const res = await fetch(`${BASE}${path}`, {
68 + method: "POST",
69 + headers: { "Content-Type": "application/json", Accept: "text/event-stream", Authorization: `Bearer ${key ?? KEY}` },
70 + body: JSON.stringify(payload),
71 + });
72 + const headers: Record<string, string> = {};
73 + res.headers.forEach((v, k) => {
74 + if (HDR.test(k)) headers[k] = v;
75 + });
76 + if (!res.ok || !res.body) {
77 + const text = await res.text();
78 + let body: unknown = text;
79 + try {
80 + body = JSON.parse(text);
81 + } catch {}
82 + return { status: res.status, headers, error: body, events: [] as any[] };
83 + }
84 + const reader = res.body.getReader();
85 + const dec = new TextDecoder();
86 + let buf = "";
87 + const events: { event?: string; data: any; raw?: string }[] = [];
88 + for (;;) {
89 + const { value, done } = await reader.read();
90 + if (done) break;
91 + buf += dec.decode(value, { stream: true });
92 + let idx;
93 + while ((idx = buf.indexOf("\n\n")) >= 0) {
94 + const block = buf.slice(0, idx);
95 + buf = buf.slice(idx + 2);
96 + let ev: string | undefined;
97 + const datas: string[] = [];
98 + for (const line of block.split("\n")) {
99 + if (line.startsWith("event:")) ev = line.slice(6).trim();
100 + else if (line.startsWith("data:")) datas.push(line.slice(5).trim());
101 + }
102 + if (!datas.length) continue;
103 + const d = datas.join("\n");
104 + if (d === "[DONE]") {
105 + events.push({ event: ev, data: "[DONE]" });
106 + continue;
107 + }
108 + try {
109 + events.push({ event: ev, data: JSON.parse(d) });
110 + } catch {
111 + events.push({ event: ev, data: null, raw: d });
112 + }
113 + }
114 + }
115 + return { status: res.status, headers, events };
116 +}
117 +
118 +export function short(s: unknown, n = 300) {
119 + const t = typeof s === "string" ? s : JSON.stringify(s) ?? String(s);
120 + return t.length > n ? t.slice(0, n) + "…" : t;
121 +}
122 +
123 +/** Minimal valid PNG (RGB, solid colour with a diagonal), size x size, base64 data URL. */
124 +export function pngDataUrl(size = 32): string {
125 + const crcTable = new Int32Array(256);
126 + for (let n = 0; n < 256; n++) {
127 + let c = n;
128 + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
129 + crcTable[n] = c;
130 + }
131 + const crc32 = (buf: Buffer) => {
132 + let c = -1;
133 + for (const b of buf) c = crcTable[(c ^ b) & 0xff] ^ (c >>> 8);
134 + return (c ^ -1) >>> 0;
135 + };
136 + const chunk = (type: string, data: Buffer) => {
137 + const len = Buffer.alloc(4);
138 + len.writeUInt32BE(data.length);
139 + const td = Buffer.concat([Buffer.from(type, "ascii"), data]);
140 + const crc = Buffer.alloc(4);
141 + crc.writeUInt32BE(crc32(td));
142 + return Buffer.concat([len, td, crc]);
143 + };
144 + const ihdr = Buffer.alloc(13);
145 + ihdr.writeUInt32BE(size, 0);
146 + ihdr.writeUInt32BE(size, 4);
147 + ihdr[8] = 8; // bit depth
148 + ihdr[9] = 2; // RGB
149 + const rows: Buffer[] = [];
150 + for (let y = 0; y < size; y++) {
151 + const row = Buffer.alloc(1 + size * 3);
152 + for (let x = 0; x < size; x++) {
153 + const onDiag = Math.abs(x - y) < 2;
154 + row[1 + x * 3] = onDiag ? 255 : 30; // R
155 + row[2 + x * 3] = onDiag ? 255 : 60; // G
156 + row[3 + x * 3] = onDiag ? 255 : 200; // B
157 + }
158 + rows.push(row);
159 + }
160 + const idat = deflateSync(Buffer.concat(rows));
161 + const png = Buffer.concat([
162 + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
163 + chunk("IHDR", ihdr),
164 + chunk("IDAT", idat),
165 + chunk("IEND", Buffer.alloc(0)),
166 + ]);
167 + return `data:image/png;base64,${png.toString("base64")}`;
168 +}
added research/mistral/package.json +25 −0
@@ -0,0 +1,25 @@
1 +{
2 + "name": "mistral",
3 + "version": "1.0.0",
4 + "description": "",
5 + "main": "index.js",
6 + "scripts": {
7 + "test": "echo \"Error: no test specified\" && exit 1"
8 + },
9 + "keywords": [],
10 + "author": "",
11 + "license": "ISC",
12 + "devEngines": {
13 + "packageManager": {
14 + "name": "pnpm",
15 + "version": "^11.1.2",
16 + "onFail": "download"
17 + }
18 + },
19 + "type": "module",
20 + "dependencies": {
21 + "@mistralai/mistralai": "^2.6.4",
22 + "openai": "^7.10.0",
23 + "tsx": "^4.23.13"
24 + }
25 +}
added research/mistral/pnpm-lock.yaml +599 −0
@@ -0,0 +1,599 @@
1 +---
2 +lockfileVersion: '9.0'
3 +
4 +importers:
5 +
6 + .:
7 + configDependencies: {}
8 + packageManagerDependencies:
9 + '@pnpm/exe':
10 + specifier: ^11.1.2
11 + version: 11.26.0
12 + pnpm:
13 + specifier: ^11.1.2
14 + version: 11.26.0
15 +
16 +packages:
17 +
18 + '@pnpm/exe@11.26.0':
19 + resolution: {integrity: sha512-eeiNi7WeXulOO1BzDAU9HIk+N3dokG+xwKPUupNQoZT5auL3rh2pFW9DqdIEnCC2xtt/hZd6UGtKyb6OMpqndw==}
20 + hasBin: true
21 +
22 + '@pnpm/linux-arm64@11.26.0':
23 + resolution: {integrity: sha512-M0IDuD4hbXxpLBsj1/I9pODcxu/gxTDtbyQmsVGYu1TZwCCpf6YU1x3lzq8aCGjrysmXYQAtCiY8GBjdw4UGFg==}
24 + cpu: [arm64]
25 + os: [linux]
26 +
27 + '@pnpm/linux-x64@11.26.0':
28 + resolution: {integrity: sha512-nUuNRsFCGVje3FHtOaWxIQl2EP4NBktKr+PpLN7uhuWnJCCMN/F9RKjNJAM+dUPyvAZs8VDvS0kA8J55kyybyg==}
29 + cpu: [x64]
30 + os: [linux]
31 +
32 + '@pnpm/linuxstatic-arm64@11.26.0':
33 + resolution: {integrity: sha512-+dXROkvdWjskrQtjwJAFuJI7Q2uTkghBfLsd0vqRmorX3fPuinhoRjJJ/UPtWXzJqwcByJBmdJRW+q2964m5qQ==}
34 + cpu: [arm64]
35 + os: [linux]
36 + libc: [musl]
37 +
38 + '@pnpm/linuxstatic-x64@11.26.0':
39 + resolution: {integrity: sha512-0eXE6spzIBdCbYhJZQ0u/sIOD0v30sqk2PrzoixzsNMc7S/lhd7EkBMVJ6cCPiCL2TXvOsrJC2SpWPBK769A9A==}
40 + cpu: [x64]
41 + os: [linux]
42 + libc: [musl]
43 +
44 + '@pnpm/macos-arm64@11.26.0':
45 + resolution: {integrity: sha512-Za3kKV89Zj0SFzQf6zEUTUIy13xH8UiNyb7aILDRjPiTvTaUSCh3q+nvljISfGH1XcopXvo+p3K8Q2nrKYWAug==}
46 + cpu: [arm64]
47 + os: [darwin]
48 +
49 + '@pnpm/win-arm64@11.26.0':
50 + resolution: {integrity: sha512-5akeLtbqbFDdJtCV4qgLmDBV3R+XNs6E7h7Xb4ZBzS3rLRp8e/y/ZdgrZaGF5Kjo45g0BLAxdGUREHbU2/lGUw==}
51 + cpu: [arm64]
52 + os: [win32]
53 +
54 + '@pnpm/win-x64@11.26.0':
55 + resolution: {integrity: sha512-f15SfIo7nppxBII+STy6jpd1z3LgGTZh6skfuxKA/xHmkzopnQJQXr4hBnl1rJLJAnmuOhN8BssaC4LiXmS78w==}
56 + cpu: [x64]
57 + os: [win32]
58 +
59 + '@reflink/reflink-darwin-arm64@0.1.19':
60 + resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==}
61 + engines: {node: '>= 10'}
62 + cpu: [arm64]
63 + os: [darwin]
64 +
65 + '@reflink/reflink-darwin-x64@0.1.19':
66 + resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==}
67 + engines: {node: '>= 10'}
68 + cpu: [x64]
69 + os: [darwin]
70 +
71 + '@reflink/reflink-linux-arm64-gnu@0.1.19':
72 + resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==}
73 + engines: {node: '>= 10'}
74 + cpu: [arm64]
75 + os: [linux]
76 + libc: [glibc]
77 +
78 + '@reflink/reflink-linux-arm64-musl@0.1.19':
79 + resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==}
80 + engines: {node: '>= 10'}
81 + cpu: [arm64]
82 + os: [linux]
83 + libc: [musl]
84 +
85 + '@reflink/reflink-linux-x64-gnu@0.1.19':
86 + resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==}
87 + engines: {node: '>= 10'}
88 + cpu: [x64]
89 + os: [linux]
90 + libc: [glibc]
91 +
92 + '@reflink/reflink-linux-x64-musl@0.1.19':
93 + resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==}
94 + engines: {node: '>= 10'}
95 + cpu: [x64]
96 + os: [linux]
97 + libc: [musl]
98 +
99 + '@reflink/reflink-win32-arm64-msvc@0.1.19':
100 + resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==}
101 + engines: {node: '>= 10'}
102 + cpu: [arm64]
103 + os: [win32]
104 +
105 + '@reflink/reflink-win32-x64-msvc@0.1.19':
106 + resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==}
107 + engines: {node: '>= 10'}
108 + cpu: [x64]
109 + os: [win32]
110 +
111 + '@reflink/reflink@0.1.19':
112 + resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==}
113 + engines: {node: '>= 10'}
114 +
115 + detect-libc@2.1.2:
116 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
117 + engines: {node: '>=8'}
118 +
119 + pnpm@11.26.0:
120 + resolution: {integrity: sha512-/A4r+JC5+YNhHxq2jAY3vOkUOQZTaZ+DwaeLAF7SXyyB53kgyP2O7i7PC1iyjNywCoTZf2m898VrLzRHECOGZA==}
121 + engines: {node: '>=22.13'}
122 + hasBin: true
123 +
124 +snapshots:
125 +
126 + '@pnpm/exe@11.26.0':
127 + dependencies:
128 + '@reflink/reflink': 0.1.19
129 + detect-libc: 2.1.2
130 + optionalDependencies:
131 + '@pnpm/linux-arm64': 11.26.0
132 + '@pnpm/linux-x64': 11.26.0
133 + '@pnpm/linuxstatic-arm64': 11.26.0
134 + '@pnpm/linuxstatic-x64': 11.26.0
135 + '@pnpm/macos-arm64': 11.26.0
136 + '@pnpm/win-arm64': 11.26.0
137 + '@pnpm/win-x64': 11.26.0
138 +
139 + '@pnpm/linux-arm64@11.26.0':
140 + optional: true
141 +
142 + '@pnpm/linux-x64@11.26.0':
143 + optional: true
144 +
145 + '@pnpm/linuxstatic-arm64@11.26.0':
146 + optional: true
147 +
148 + '@pnpm/linuxstatic-x64@11.26.0':
149 + optional: true
150 +
151 + '@pnpm/macos-arm64@11.26.0':
152 + optional: true
153 +
154 + '@pnpm/win-arm64@11.26.0':
155 + optional: true
156 +
157 + '@pnpm/win-x64@11.26.0':
158 + optional: true
159 +
160 + '@reflink/reflink-darwin-arm64@0.1.19':
161 + optional: true
162 +
163 + '@reflink/reflink-darwin-x64@0.1.19':
164 + optional: true
165 +
166 + '@reflink/reflink-linux-arm64-gnu@0.1.19':
167 + optional: true
168 +
169 + '@reflink/reflink-linux-arm64-musl@0.1.19':
170 + optional: true
171 +
172 + '@reflink/reflink-linux-x64-gnu@0.1.19':
173 + optional: true
174 +
175 + '@reflink/reflink-linux-x64-musl@0.1.19':
176 + optional: true
177 +
178 + '@reflink/reflink-win32-arm64-msvc@0.1.19':
179 + optional: true
180 +
181 + '@reflink/reflink-win32-x64-msvc@0.1.19':
182 + optional: true
183 +
184 + '@reflink/reflink@0.1.19':
185 + optionalDependencies:
186 + '@reflink/reflink-darwin-arm64': 0.1.19
187 + '@reflink/reflink-darwin-x64': 0.1.19
188 + '@reflink/reflink-linux-arm64-gnu': 0.1.19
189 + '@reflink/reflink-linux-arm64-musl': 0.1.19
190 + '@reflink/reflink-linux-x64-gnu': 0.1.19
191 + '@reflink/reflink-linux-x64-musl': 0.1.19
192 + '@reflink/reflink-win32-arm64-msvc': 0.1.19
193 + '@reflink/reflink-win32-x64-msvc': 0.1.19
194 +
195 + detect-libc@2.1.2: {}
196 +
197 + pnpm@11.26.0: {}
198 +
199 +---
200 +lockfileVersion: '9.0'
201 +
202 +settings:
203 + autoInstallPeers: true
204 + excludeLinksFromLockfile: false
205 +
206 +importers:
207 +
208 + .:
209 + dependencies:
210 + '@mistralai/mistralai':
211 + specifier: ^2.6.4
212 + version: 2.6.4
213 + openai:
214 + specifier: ^7.10.0
215 + version: 7.10.0(ws@8.21.3)(zod@4.5.4)
216 + tsx:
217 + specifier: ^4.23.13
218 + version: 4.23.13
219 +
220 +packages:
221 +
222 + '@esbuild/aix-ppc64@0.28.2':
223 + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
224 + engines: {node: '>=18'}
225 + cpu: [ppc64]
226 + os: [aix]
227 +
228 + '@esbuild/android-arm64@0.28.2':
229 + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
230 + engines: {node: '>=18'}
231 + cpu: [arm64]
232 + os: [android]
233 +
234 + '@esbuild/android-arm@0.28.2':
235 + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
236 + engines: {node: '>=18'}
237 + cpu: [arm]
238 + os: [android]
239 +
240 + '@esbuild/android-x64@0.28.2':
241 + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
242 + engines: {node: '>=18'}
243 + cpu: [x64]
244 + os: [android]
245 +
246 + '@esbuild/darwin-arm64@0.28.2':
247 + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
248 + engines: {node: '>=18'}
249 + cpu: [arm64]
250 + os: [darwin]
251 +
252 + '@esbuild/darwin-x64@0.28.2':
253 + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
254 + engines: {node: '>=18'}
255 + cpu: [x64]
256 + os: [darwin]
257 +
258 + '@esbuild/freebsd-arm64@0.28.2':
259 + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
260 + engines: {node: '>=18'}
261 + cpu: [arm64]
262 + os: [freebsd]
263 +
264 + '@esbuild/freebsd-x64@0.28.2':
265 + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
266 + engines: {node: '>=18'}
267 + cpu: [x64]
268 + os: [freebsd]
269 +
270 + '@esbuild/linux-arm64@0.28.2':
271 + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
272 + engines: {node: '>=18'}
273 + cpu: [arm64]
274 + os: [linux]
275 +
276 + '@esbuild/linux-arm@0.28.2':
277 + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
278 + engines: {node: '>=18'}
279 + cpu: [arm]
280 + os: [linux]
281 +
282 + '@esbuild/linux-ia32@0.28.2':
283 + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
284 + engines: {node: '>=18'}
285 + cpu: [ia32]
286 + os: [linux]
287 +
288 + '@esbuild/linux-loong64@0.28.2':
289 + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
290 + engines: {node: '>=18'}
291 + cpu: [loong64]
292 + os: [linux]
293 +
294 + '@esbuild/linux-mips64el@0.28.2':
295 + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
296 + engines: {node: '>=18'}
297 + cpu: [mips64el]
298 + os: [linux]
299 +
300 + '@esbuild/linux-ppc64@0.28.2':
301 + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
302 + engines: {node: '>=18'}
303 + cpu: [ppc64]
304 + os: [linux]
305 +
306 + '@esbuild/linux-riscv64@0.28.2':
307 + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
308 + engines: {node: '>=18'}
309 + cpu: [riscv64]
310 + os: [linux]
311 +
312 + '@esbuild/linux-s390x@0.28.2':
313 + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
314 + engines: {node: '>=18'}
315 + cpu: [s390x]
316 + os: [linux]
317 +
318 + '@esbuild/linux-x64@0.28.2':
319 + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
320 + engines: {node: '>=18'}
321 + cpu: [x64]
322 + os: [linux]
323 +
324 + '@esbuild/netbsd-arm64@0.28.2':
325 + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
326 + engines: {node: '>=18'}
327 + cpu: [arm64]
328 + os: [netbsd]
329 +
330 + '@esbuild/netbsd-x64@0.28.2':
331 + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
332 + engines: {node: '>=18'}
333 + cpu: [x64]
334 + os: [netbsd]
335 +
336 + '@esbuild/openbsd-arm64@0.28.2':
337 + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
338 + engines: {node: '>=18'}
339 + cpu: [arm64]
340 + os: [openbsd]
341 +
342 + '@esbuild/openbsd-x64@0.28.2':
343 + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
344 + engines: {node: '>=18'}
345 + cpu: [x64]
346 + os: [openbsd]
347 +
348 + '@esbuild/openharmony-arm64@0.28.2':
349 + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
350 + engines: {node: '>=18'}
351 + cpu: [arm64]
352 + os: [openharmony]
353 +
354 + '@esbuild/sunos-x64@0.28.2':
355 + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
356 + engines: {node: '>=18'}
357 + cpu: [x64]
358 + os: [sunos]
359 +
360 + '@esbuild/win32-arm64@0.28.2':
361 + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
362 + engines: {node: '>=18'}
363 + cpu: [arm64]
364 + os: [win32]
365 +
366 + '@esbuild/win32-ia32@0.28.2':
367 + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
368 + engines: {node: '>=18'}
369 + cpu: [ia32]
370 + os: [win32]
371 +
372 + '@esbuild/win32-x64@0.28.2':
373 + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
374 + engines: {node: '>=18'}
375 + cpu: [x64]
376 + os: [win32]
377 +
378 + '@mistralai/mistralai@2.6.4':
379 + resolution: {integrity: sha512-PPt4GyJqs2hEsWrYCJZK5f0ORmT+L2MSm75LVGD7kBLf6ZKsoDpld/FRBQXr8xG6iFCBOFJFYzvGYhUb+UCkbw==}
380 + peerDependencies:
381 + '@opentelemetry/api': ^1.9.0
382 + '@opentelemetry/exporter-trace-otlp-http': ^0.220.0
383 + '@opentelemetry/resources': ^2.9.0
384 + '@opentelemetry/sdk-trace-base': ^2.9.0
385 + peerDependenciesMeta:
386 + '@opentelemetry/api':
387 + optional: true
388 + '@opentelemetry/exporter-trace-otlp-http':
389 + optional: true
390 + '@opentelemetry/resources':
391 + optional: true
392 + '@opentelemetry/sdk-trace-base':
393 + optional: true
394 +
395 + '@opentelemetry/semantic-conventions@1.43.0':
396 + resolution: {integrity: sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==}
397 + engines: {node: '>=14'}
398 +
399 + esbuild@0.28.2:
400 + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
401 + engines: {node: '>=18'}
402 + hasBin: true
403 +
404 + fsevents@2.3.3:
405 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
406 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
407 + os: [darwin]
408 +
409 + openai@7.10.0:
410 + resolution: {integrity: sha512-sn9t2Kls7O52PwuF9BUTYNu4Gk/r0lXJyrgaNht4TNRlZFb3dJIGO0RciSgjARGCBRtWjySubAQFJttlzUvGQQ==}
411 + engines: {node: '>=22.0.0'}
412 + peerDependencies:
413 + '@aws-sdk/credential-provider-node': '>=3.972.0 <4'
414 + '@smithy/hash-node': '>=4.3.0 <5'
415 + '@smithy/signature-v4': '>=5.4.0 <6'
416 + undici: '>=5 <9'
417 + ws: ^8.21.0
418 + zod: ^3.25 || ^4.0
419 + peerDependenciesMeta:
420 + '@aws-sdk/credential-provider-node':
421 + optional: true
422 + '@smithy/hash-node':
423 + optional: true
424 + '@smithy/signature-v4':
425 + optional: true
426 + undici:
427 + optional: true
428 + ws:
429 + optional: true
430 + zod:
431 + optional: true
432 +
433 + tsx@4.23.13:
434 + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
435 + engines: {node: '>=18.0.0'}
436 + hasBin: true
437 +
438 + ws@8.21.3:
439 + resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==}
440 + engines: {node: '>=10.0.0'}
441 + peerDependencies:
442 + bufferutil: ^4.0.1
443 + utf-8-validate: '>=5.0.2'
444 + peerDependenciesMeta:
445 + bufferutil:
446 + optional: true
447 + utf-8-validate:
448 + optional: true
449 +
450 + zod-to-json-schema@3.25.2:
451 + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
452 + peerDependencies:
453 + zod: ^3.25.28 || ^4
454 +
455 + zod@4.5.4:
456 + resolution: {integrity: sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA==}
457 +
458 +snapshots:
459 +
460 + '@esbuild/aix-ppc64@0.28.2':
461 + optional: true
462 +
463 + '@esbuild/android-arm64@0.28.2':
464 + optional: true
465 +
466 + '@esbuild/android-arm@0.28.2':
467 + optional: true
468 +
469 + '@esbuild/android-x64@0.28.2':
470 + optional: true
471 +
472 + '@esbuild/darwin-arm64@0.28.2':
473 + optional: true
474 +
475 + '@esbuild/darwin-x64@0.28.2':
476 + optional: true
477 +
478 + '@esbuild/freebsd-arm64@0.28.2':
479 + optional: true
480 +
481 + '@esbuild/freebsd-x64@0.28.2':
482 + optional: true
483 +
484 + '@esbuild/linux-arm64@0.28.2':
485 + optional: true
486 +
487 + '@esbuild/linux-arm@0.28.2':
488 + optional: true
489 +
490 + '@esbuild/linux-ia32@0.28.2':
491 + optional: true
492 +
493 + '@esbuild/linux-loong64@0.28.2':
494 + optional: true
495 +
496 + '@esbuild/linux-mips64el@0.28.2':
497 + optional: true
498 +
499 + '@esbuild/linux-ppc64@0.28.2':
500 + optional: true
501 +
502 + '@esbuild/linux-riscv64@0.28.2':
503 + optional: true
504 +
505 + '@esbuild/linux-s390x@0.28.2':
506 + optional: true
507 +
508 + '@esbuild/linux-x64@0.28.2':
509 + optional: true
510 +
511 + '@esbuild/netbsd-arm64@0.28.2':
512 + optional: true
513 +
514 + '@esbuild/netbsd-x64@0.28.2':
515 + optional: true
516 +
517 + '@esbuild/openbsd-arm64@0.28.2':
518 + optional: true
519 +
520 + '@esbuild/openbsd-x64@0.28.2':
521 + optional: true
522 +
523 + '@esbuild/openharmony-arm64@0.28.2':
524 + optional: true
525 +
526 + '@esbuild/sunos-x64@0.28.2':
527 + optional: true
528 +
529 + '@esbuild/win32-arm64@0.28.2':
530 + optional: true
531 +
532 + '@esbuild/win32-ia32@0.28.2':
533 + optional: true
534 +
535 + '@esbuild/win32-x64@0.28.2':
536 + optional: true
537 +
538 + '@mistralai/mistralai@2.6.4':
539 + dependencies:
540 + '@opentelemetry/semantic-conventions': 1.43.0
541 + ws: 8.21.3
542 + zod: 4.5.4
543 + zod-to-json-schema: 3.25.2(zod@4.5.4)
544 + transitivePeerDependencies:
545 + - bufferutil
546 + - utf-8-validate
547 +
548 + '@opentelemetry/semantic-conventions@1.43.0': {}
549 +
550 + esbuild@0.28.2:
551 + optionalDependencies:
552 + '@esbuild/aix-ppc64': 0.28.2
553 + '@esbuild/android-arm': 0.28.2
554 + '@esbuild/android-arm64': 0.28.2
555 + '@esbuild/android-x64': 0.28.2
556 + '@esbuild/darwin-arm64': 0.28.2
557 + '@esbuild/darwin-x64': 0.28.2
558 + '@esbuild/freebsd-arm64': 0.28.2
559 + '@esbuild/freebsd-x64': 0.28.2
560 + '@esbuild/linux-arm': 0.28.2
561 + '@esbuild/linux-arm64': 0.28.2
562 + '@esbuild/linux-ia32': 0.28.2
563 + '@esbuild/linux-loong64': 0.28.2
564 + '@esbuild/linux-mips64el': 0.28.2
565 + '@esbuild/linux-ppc64': 0.28.2
566 + '@esbuild/linux-riscv64': 0.28.2
567 + '@esbuild/linux-s390x': 0.28.2
568 + '@esbuild/linux-x64': 0.28.2
569 + '@esbuild/netbsd-arm64': 0.28.2
570 + '@esbuild/netbsd-x64': 0.28.2
571 + '@esbuild/openbsd-arm64': 0.28.2
572 + '@esbuild/openbsd-x64': 0.28.2
573 + '@esbuild/openharmony-arm64': 0.28.2
574 + '@esbuild/sunos-x64': 0.28.2
575 + '@esbuild/win32-arm64': 0.28.2
576 + '@esbuild/win32-ia32': 0.28.2
577 + '@esbuild/win32-x64': 0.28.2
578 +
579 + fsevents@2.3.3:
580 + optional: true
581 +
582 + openai@7.10.0(ws@8.21.3)(zod@4.5.4):
583 + optionalDependencies:
584 + ws: 8.21.3
585 + zod: 4.5.4
586 +
587 + tsx@4.23.13:
588 + dependencies:
589 + esbuild: 0.28.2
590 + optionalDependencies:
591 + fsevents: 2.3.3
592 +
593 + ws@8.21.3: {}
594 +
595 + zod-to-json-schema@3.25.2(zod@4.5.4):
596 + dependencies:
597 + zod: 4.5.4
598 +
599 + zod@4.5.4: {}
added research/openrouter/00-models.ts +169 −0
@@ -0,0 +1,169 @@
1 +// Probe 00: GET /models (full dump), /key, /credits, and a summary of the listing.
2 +import { raw, save, short } from "./lib.ts";
3 +
4 +// Default call (no params) — to see whether the response is paginated / how many models come back
5 +const first = await raw("/models");
6 +const fb: any = first.body;
7 +console.log("GET /models (no params)", first.status, `data=${fb?.data?.length} total_count=${fb?.total_count} links=${JSON.stringify(fb?.links)} keys=${Object.keys(fb ?? {})}`);
8 +save("models-default-page.meta.json", { status: first.status, headers: first.headers, count: fb?.data?.length, total_count: fb?.total_count, links: fb?.links, keys: Object.keys(fb ?? {}) });
9 +
10 +// Full dump: follow pagination with limit=1000
11 +const all: any[] = [];
12 +let next: string | null = "/models?limit=1000";
13 +let pages = 0;
14 +let lastPage: any = null;
15 +while (next) {
16 + const r = await raw(next.replace(/^https:\/\/openrouter\.ai\/api\/v1/, ""));
17 + const b: any = r.body;
18 + pages++;
19 + lastPage = b;
20 + all.push(...(b?.data ?? []));
21 + console.log(`page ${pages}: status=${r.status} data=${b?.data?.length} total_count=${b?.total_count} next=${b?.links?.next}`);
22 + next = b?.links?.next ?? null;
23 + if (pages > 10) break;
24 +}
25 +const models = { status: first.status, body: { ...lastPage, data: all } };
26 +save("models.json", models.body);
27 +
28 +const key = await raw("/key");
29 +console.log("GET /key", key.status, short(key.body, 1200));
30 +save("key.json", { status: key.status, headers: key.headers, body: key.body });
31 +
32 +const credits = await raw("/credits");
33 +console.log("GET /credits", credits.status, short(credits.body, 600));
34 +save("credits.json", { status: credits.status, headers: credits.headers, body: credits.body });
35 +
36 +// Extra listing endpoints worth knowing about
37 +for (const p of ["/models/user", "/models?supported_parameters=tools", "/models/count", "/auth/key"]) {
38 + const r = await raw(p);
39 + const b: any = r.body;
40 + console.log("GET", p, r.status, Array.isArray(b?.data) ? `data.length=${b.data.length}` : short(b, 200));
41 +}
42 +
43 +const data: any[] = (models.body as any)?.data ?? [];
44 +const count = data.length;
45 +const outMod: Record<string, number> = {};
46 +const inMod: Record<string, number> = {};
47 +const params = new Map<string, number>();
48 +const topKeys = new Set<string>();
49 +const pricingKeys = new Set<string>();
50 +const archKeys = new Set<string>();
51 +const tpKeys = new Set<string>();
52 +let free = 0;
53 +let freeSuffix = 0;
54 +let withExpiration = 0;
55 +let withReasoning = 0;
56 +let withDefaults = 0;
57 +const reasoningSamples: any[] = [];
58 +const defaultSamples: any[] = [];
59 +const expSamples: any[] = [];
60 +const prlSamples: any[] = [];
61 +const variants: Record<string, number> = {};
62 +const nullCtx: string[] = [];
63 +const nullMaxOut = { count: 0 };
64 +const tokenizers: Record<string, number> = {};
65 +const instructTypes: Record<string, number> = {};
66 +const modalityCombos: Record<string, number> = {};
67 +const nonTextOut: string[] = [];
68 +let perRequestLimitsNonNull = 0;
69 +let hfIds = 0;
70 +
71 +for (const m of data) {
72 + for (const k of Object.keys(m)) topKeys.add(k);
73 + for (const k of Object.keys(m.pricing ?? {})) pricingKeys.add(k);
74 + for (const k of Object.keys(m.architecture ?? {})) archKeys.add(k);
75 + for (const k of Object.keys(m.top_provider ?? {})) tpKeys.add(k);
76 + const om = (m.architecture?.output_modalities ?? []).slice().sort().join("+") || "(none)";
77 + outMod[om] = (outMod[om] ?? 0) + 1;
78 + const im = (m.architecture?.input_modalities ?? []).slice().sort().join("+") || "(none)";
79 + inMod[im] = (inMod[im] ?? 0) + 1;
80 + const mod = m.architecture?.modality ?? "(none)";
81 + modalityCombos[mod] = (modalityCombos[mod] ?? 0) + 1;
82 + if (m.architecture?.tokenizer) tokenizers[m.architecture.tokenizer] = (tokenizers[m.architecture.tokenizer] ?? 0) + 1;
83 + const it = m.architecture?.instruct_type ?? "(null)";
84 + instructTypes[it] = (instructTypes[it] ?? 0) + 1;
85 + for (const p of m.supported_parameters ?? []) params.set(p, (params.get(p) ?? 0) + 1);
86 + if (m.pricing?.prompt === "0") free++;
87 + if (/:free$/.test(m.id)) freeSuffix++;
88 + const v = m.id.includes(":") ? m.id.slice(m.id.indexOf(":")) : "(none)";
89 + variants[v] = (variants[v] ?? 0) + 1;
90 + if (m.expiration_date) {
91 + withExpiration++;
92 + if (expSamples.length < 8) expSamples.push({ id: m.id, expiration_date: m.expiration_date });
93 + }
94 + if (m.reasoning != null) {
95 + withReasoning++;
96 + if (reasoningSamples.length < 12) reasoningSamples.push({ id: m.id, reasoning: m.reasoning });
97 + }
98 + if (m.default_parameters != null && Object.keys(m.default_parameters).length) {
99 + withDefaults++;
100 + if (defaultSamples.length < 8) defaultSamples.push({ id: m.id, default_parameters: m.default_parameters });
101 + }
102 + if (m.per_request_limits != null) {
103 + perRequestLimitsNonNull++;
104 + if (prlSamples.length < 3) prlSamples.push({ id: m.id, per_request_limits: m.per_request_limits });
105 + }
106 + if (m.hugging_face_id) hfIds++;
107 + if (m.context_length == null) nullCtx.push(m.id);
108 + if (m.top_provider?.max_completion_tokens == null) nullMaxOut.count++;
109 + const outs: string[] = m.architecture?.output_modalities ?? [];
110 + if (!outs.includes("text")) nonTextOut.push(`${m.id} → ${outs.join(",")}`);
111 +}
112 +
113 +const reasoningShapes: Record<string, number> = {};
114 +for (const m of data) {
115 + const r = m.reasoning;
116 + const shape = r == null ? "null/absent" : JSON.stringify(Object.fromEntries(Object.entries(r).map(([k, v]) => [k, Array.isArray(v) ? "array" : typeof v])));
117 + reasoningShapes[shape] = (reasoningShapes[shape] ?? 0) + 1;
118 +}
119 +
120 +const summary = {
121 + count,
122 + topLevelKeys: [...topKeys],
123 + architectureKeys: [...archKeys],
124 + pricingKeys: [...pricingKeys],
125 + topProviderKeys: [...tpKeys],
126 + outputModalities: outMod,
127 + inputModalities: inMod,
128 + modalityStrings: modalityCombos,
129 + tokenizers,
130 + instructTypes,
131 + supportedParameters: Object.fromEntries([...params.entries()].sort((a, b) => b[1] - a[1])),
132 + freeByPromptZero: free,
133 + freeBySuffix: freeSuffix,
134 + variantSuffixes: variants,
135 + withExpirationDate: withExpiration,
136 + expirationSamples: expSamples,
137 + withReasoningField: withReasoning,
138 + reasoningFieldShapes: reasoningShapes,
139 + reasoningSamples,
140 + withDefaultParameters: withDefaults,
141 + defaultParameterSamples: defaultSamples,
142 + perRequestLimitsNonNull,
143 + perRequestLimitsSamples: prlSamples,
144 + huggingFaceIds: hfIds,
145 + nullContextLength: nullCtx,
146 + nullMaxCompletionTokens: nullMaxOut.count,
147 + nonTextOutput: nonTextOut,
148 + sample: data.find((m) => m.id === "anthropic/claude-haiku-4-5") ?? data[0],
149 +};
150 +save("models-summary.json", summary);
151 +console.log(JSON.stringify({ ...summary, sample: undefined, nonTextOutput: summary.nonTextOutput.length, nullContextLength: nullCtx.length }, null, 1).slice(0, 6000));
152 +
153 +// Candidate cheap models for the probes
154 +const want = [
155 + "openai/gpt-5.4-nano",
156 + "anthropic/claude-haiku-4-5",
157 + "google/gemini-3.5-flash-lite",
158 + "deepseek/deepseek-v4-flash",
159 + "x-ai/grok-4.20-0309-non-reasoning",
160 +];
161 +for (const w of want) {
162 + const m = data.find((x) => x.id === w);
163 + console.log(w, m ? `OK ctx=${m.context_length} in=${m.pricing.prompt} out=${m.pricing.completion} params=${(m.supported_parameters ?? []).join(",")}` : "MISSING");
164 +}
165 +const cheap = data
166 + .filter((m) => m.pricing?.prompt !== "0" && Number(m.pricing?.prompt) < 0.0000005 && (m.architecture?.output_modalities ?? []).includes("text"))
167 + .filter((m) => /^(openai|anthropic|google|deepseek|x-ai|mistralai|meta-llama|qwen|moonshotai|z-ai)\//.test(m.id))
168 + .map((m) => `${m.id} in=${m.pricing.prompt} out=${m.pricing.completion} ctx=${m.context_length} img=${(m.architecture.input_modalities ?? []).includes("image")}`);
169 +console.log("cheap candidates:\n" + cheap.join("\n"));
added research/openrouter/01-chat-stream.ts +87 −0
@@ -0,0 +1,87 @@
1 +// Probe 01: (a) tiny non-streaming completion with usage accounting; (b) streaming via raw SSE (comments, delta shapes, final usage).
2 +import { raw, rawSSE, save, short } from "./lib.ts";
3 +import { MODELS, byId } from "./models.ts";
4 +
5 +const results: any = {};
6 +for (const model of MODELS) {
7 + const reasoningCapable = !!byId.get(model)?.reasoning;
8 + const body: any = {
9 + model,
10 + messages: [{ role: "user", content: "What is 2+2? Answer in one short sentence." }],
11 + max_tokens: 200,
12 + usage: { include: true },
13 + };
14 + if (reasoningCapable) body.reasoning = { effort: "low" };
15 + const t0 = Date.now();
16 + const r = await raw("/chat/completions", { method: "POST", body: JSON.stringify(body) });
17 + const b: any = r.body;
18 + results[model] = { status: r.status, ms: Date.now() - t0, headers: r.headers, body: b };
19 + console.log(
20 + `\n[${model}] ${r.status} ${Date.now() - t0}ms provider=${b?.provider} model=${b?.model} finish=${b?.choices?.[0]?.finish_reason}/${b?.choices?.[0]?.native_finish_reason}`,
21 + );
22 + console.log(" top-level keys:", Object.keys(b ?? {}).join(","));
23 + console.log(" message keys:", Object.keys(b?.choices?.[0]?.message ?? {}).join(","));
24 + console.log(" content:", short(b?.choices?.[0]?.message?.content, 120));
25 + console.log(" reasoning:", short(b?.choices?.[0]?.message?.reasoning, 120), "details:", short(b?.choices?.[0]?.message?.reasoning_details, 300));
26 + console.log(" usage:", JSON.stringify(b?.usage));
27 + if (b?.error) console.log(" ERROR:", JSON.stringify(b.error));
28 +}
29 +save("01-chat.json", results);
30 +
31 +// (b) streaming
32 +const streams: any = {};
33 +for (const model of MODELS) {
34 + const reasoningCapable = !!byId.get(model)?.reasoning;
35 + const body: any = {
36 + model,
37 + messages: [{ role: "user", content: "Say hello in French, 5 words max." }],
38 + max_tokens: 150,
39 + stream: true,
40 + usage: { include: true },
41 + };
42 + if (reasoningCapable) body.reasoning = { effort: "low" };
43 + const t0 = Date.now();
44 + const s = await rawSSE("/chat/completions", body);
45 + const ev = s.events;
46 + const deltaKeys = new Set<string>();
47 + let text = "";
48 + let reasoning = "";
49 + let details: any[] = [];
50 + for (const e of ev) {
51 + if (e.data === "[DONE]") continue;
52 + const d = e.data?.choices?.[0]?.delta ?? {};
53 + for (const k of Object.keys(d)) deltaKeys.add(k);
54 + if (d.content) text += d.content;
55 + if (d.reasoning) reasoning += d.reasoning;
56 + if (d.reasoning_details) details.push(...d.reasoning_details);
57 + }
58 + const withUsage = ev.filter((e) => e.data?.usage);
59 + const finishChunks = ev.filter((e) => e.data?.choices?.[0]?.finish_reason);
60 + streams[model] = {
61 + status: s.status,
62 + ms: Date.now() - t0,
63 + headers: s.headers,
64 + comments: s.comments,
65 + count: ev.length,
66 + first: ev[0]?.data,
67 + second: ev[1]?.data,
68 + finishChunks: finishChunks.map((e) => e.data),
69 + usageChunk: withUsage.map((e) => e.data),
70 + last: ev[ev.length - 1]?.data,
71 + deltaKeys: [...deltaKeys],
72 + text,
73 + reasoning: reasoning.slice(0, 400),
74 + reasoningDetailsTypes: [...new Set(details.map((x) => x.type))],
75 + reasoningDetailsSample: details.slice(0, 2),
76 + error: (s as any).error,
77 + };
78 + console.log(
79 + `\n[stream ${model}] ${s.status} ${Date.now() - t0}ms events=${ev.length} comments=${JSON.stringify(s.comments)} deltaKeys=${[...deltaKeys]} usageChunks=${withUsage.length} finishChunks=${finishChunks.length}`,
80 + );
81 + console.log(" text:", short(text, 100), "| reasoning:", short(reasoning, 100), "| details types:", streams[model].reasoningDetailsTypes);
82 + console.log(" first:", short(ev[0]?.data, 400));
83 + console.log(" usage chunk:", short(withUsage[0]?.data, 700));
84 + console.log(" last:", short(ev[ev.length - 1]?.data, 200));
85 + if ((s as any).error) console.log(" ERROR", short((s as any).error));
86 +}
87 +save("01-stream.json", streams);
added research/openrouter/02-params.ts +101 −0
@@ -0,0 +1,101 @@
1 +// Probe 02: parameter matrix — what is accepted / dropped / rejected per model, with and without provider.require_parameters.
2 +import { raw, save, short } from "./lib.ts";
3 +import { MODELS, byId } from "./models.ts";
4 +
5 +const schema = {
6 + type: "object",
7 + properties: { answer: { type: "string" }, n: { type: "integer" } },
8 + required: ["answer", "n"],
9 + additionalProperties: false,
10 +};
11 +const tools = [
12 + {
13 + type: "function",
14 + function: {
15 + name: "get_weather",
16 + description: "Get weather for a city",
17 + parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
18 + },
19 + },
20 +];
21 +
22 +const variants: Record<string, any> = {
23 + baseline: {},
24 + temperature: { temperature: 0.2 },
25 + top_p: { top_p: 0.9 },
26 + top_k: { top_k: 40 },
27 + min_p: { min_p: 0.05 },
28 + top_a: { top_a: 0.1 },
29 + max_completion_tokens: { max_completion_tokens: 30, max_tokens: undefined },
30 + stop: { stop: ["\n"] },
31 + seed: { seed: 42 },
32 + frequency_penalty: { frequency_penalty: 0.5 },
33 + presence_penalty: { presence_penalty: 0.5 },
34 + repetition_penalty: { repetition_penalty: 1.1 },
35 + logprobs: { logprobs: true, top_logprobs: 2 },
36 + n2: { n: 2 },
37 + reasoning_effort_low: { reasoning: { effort: "low" } },
38 + reasoning_effort_none: { reasoning: { effort: "none" } },
39 + reasoning_max_tokens: { reasoning: { max_tokens: 1024 } },
40 + reasoning_exclude: { reasoning: { effort: "low", exclude: true } },
41 + reasoning_enabled_false: { reasoning: { enabled: false } },
42 + legacy_reasoning_effort: { reasoning_effort: "low" },
43 + json_object: { response_format: { type: "json_object" }, messages: [{ role: "user", content: 'Return JSON {"answer": "4"}' }] },
44 + json_schema: { response_format: { type: "json_schema", json_schema: { name: "a", strict: true, schema } } },
45 + tools_auto: { tools, tool_choice: "auto" },
46 + tools_required: { tools, tool_choice: "required", messages: [{ role: "user", content: "Weather in Montreal?" }] },
47 + parallel_tool_calls_false: { tools, parallel_tool_calls: false, messages: [{ role: "user", content: "Weather in Montreal?" }] },
48 + developer_role: { messages: [{ role: "developer", content: "Answer in uppercase." }, { role: "user", content: "2+2?" }] },
49 + unknown_param: { foo_bar: 1 },
50 + verbosity_low: { verbosity: "low" },
51 + // require_parameters variants: does OpenRouter refuse when the endpoint lacks the param?
52 + rp_top_k: { top_k: 40, provider: { require_parameters: true } },
53 + rp_repetition_penalty: { repetition_penalty: 1.1, provider: { require_parameters: true } },
54 + rp_frequency_penalty: { frequency_penalty: 0.5, provider: { require_parameters: true } },
55 + rp_stop: { stop: ["\n"], provider: { require_parameters: true } },
56 + rp_seed: { seed: 42, provider: { require_parameters: true } },
57 + rp_reasoning: { reasoning: { effort: "low" }, provider: { require_parameters: true } },
58 + rp_structured: { response_format: { type: "json_schema", json_schema: { name: "a", strict: true, schema } }, provider: { require_parameters: true } },
59 + rp_logprobs: { logprobs: true, top_logprobs: 2, provider: { require_parameters: true } },
60 +};
61 +
62 +const results: Record<string, Record<string, any>> = {};
63 +for (const model of MODELS) {
64 + results[model] = {};
65 + const supported: string[] = byId.get(model)?.supported_parameters ?? [];
66 + console.log(`\n=== ${model} (supported: ${supported.join(",")})`);
67 + for (const [name, extra] of Object.entries(variants)) {
68 + const body: any = {
69 + model,
70 + messages: [{ role: "user", content: "2+2? One short sentence." }],
71 + max_tokens: 40,
72 + usage: { include: true },
73 + ...extra,
74 + };
75 + if (extra.max_completion_tokens) delete body.max_tokens;
76 + const t0 = Date.now();
77 + const r = await raw("/chat/completions", { method: "POST", body: JSON.stringify(body) });
78 + const b: any = r.body;
79 + const ch = b?.choices?.[0];
80 + const summary = {
81 + status: r.status,
82 + ms: Date.now() - t0,
83 + provider: b?.provider,
84 + finish: ch?.finish_reason,
85 + native_finish: ch?.native_finish_reason,
86 + content: short(ch?.message?.content, 80),
87 + reasoning: ch?.message?.reasoning == null ? undefined : short(ch.message.reasoning, 60),
88 + reasoning_tokens: b?.usage?.completion_tokens_details?.reasoning_tokens,
89 + tool_calls: ch?.message?.tool_calls?.map((t: any) => `${t.function?.name}(${t.function?.arguments})`),
90 + choices: b?.choices?.length,
91 + logprobs: ch?.logprobs ? Object.keys(ch.logprobs) : undefined,
92 + cost: b?.usage?.cost,
93 + error: b?.error ?? (r.status >= 400 ? b : undefined),
94 + };
95 + results[model][name] = summary;
96 + console.log(
97 + ` ${name.padEnd(26)} ${r.status} ${String(summary.ms).padStart(5)}ms ${summary.provider ?? "-"} finish=${summary.finish ?? "-"} rt=${summary.reasoning_tokens ?? "-"} ${summary.tool_calls ? "tools=" + JSON.stringify(summary.tool_calls) : ""} ${summary.error ? "ERR=" + short(summary.error, 220) : short(summary.content, 60)}`,
98 + );
99 + }
100 +}
101 +save("02-params.json", results);
added research/openrouter/03-tools-stream.ts +71 −0
@@ -0,0 +1,71 @@
1 +// Probe 03: streamed function-call round trip on each model (OpenAI SDK).
2 +import { client, save, short } from "./lib.ts";
3 +import { MODELS, byId } from "./models.ts";
4 +
5 +const tools: any = [
6 + {
7 + type: "function",
8 + function: {
9 + name: "get_weather",
10 + description: "Get the current weather for a city",
11 + parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] },
12 + },
13 + },
14 +];
15 +
16 +const results: any = {};
17 +for (const model of MODELS) {
18 + const out: any = { chunks: [] as any[] };
19 + results[model] = out;
20 + try {
21 + const messages: any[] = [{ role: "user", content: "What's the weather in Montreal? Use the tool." }];
22 + const stream = await client.chat.completions.create({
23 + model,
24 + messages,
25 + tools,
26 + tool_choice: "auto",
27 + stream: true,
28 + max_tokens: 200,
29 + ...(byId.get(model)?.reasoning ? { reasoning: { effort: "low" } } : {}),
30 + } as any);
31 + const acc: Record<number, any> = {};
32 + let deltaWithTools = 0;
33 + let finish: string | undefined;
34 + let usage: any;
35 + let reasoningDetails: any[] = [];
36 + for await (const chunk of stream) {
37 + const c: any = chunk.choices?.[0];
38 + if (c?.delta?.tool_calls) {
39 + deltaWithTools++;
40 + if (out.chunks.length < 4) out.chunks.push(chunk);
41 + for (const tc of c.delta.tool_calls) {
42 + const i = tc.index ?? 0;
43 + acc[i] ??= { id: tc.id, type: "function", function: { name: "", arguments: "" } };
44 + if (tc.id) acc[i].id = tc.id;
45 + if (tc.function?.name) acc[i].function.name += tc.function.name;
46 + if (tc.function?.arguments) acc[i].function.arguments += tc.function.arguments;
47 + }
48 + }
49 + if (c?.delta?.reasoning_details) reasoningDetails.push(...c.delta.reasoning_details);
50 + if (c?.finish_reason) finish = c.finish_reason;
51 + if ((chunk as any).usage) usage = (chunk as any).usage;
52 + }
53 + const toolCalls = Object.values(acc);
54 + out.round1 = { finish, deltaWithTools, toolCalls, usage, reasoningDetailsTypes: [...new Set(reasoningDetails.map((d) => d.type))] };
55 + console.log(`\n[${model}] round1 finish=${finish} toolDeltaChunks=${deltaWithTools} calls=${JSON.stringify(toolCalls)} rt=${usage?.completion_tokens_details?.reasoning_tokens} cost=${usage?.cost}`);
56 + if (!toolCalls.length) continue;
57 + const assistantMsg: any = { role: "assistant", content: null, tool_calls: toolCalls };
58 + if (reasoningDetails.length) assistantMsg.reasoning_details = reasoningDetails; // docs: pass back unmodified
59 + messages.push(assistantMsg);
60 + for (const tc of toolCalls) {
61 + messages.push({ role: "tool", tool_call_id: tc.id, content: JSON.stringify({ city: "Montreal", temp_c: 21, sky: "sunny" }) });
62 + }
63 + const r2: any = await client.chat.completions.create({ model, messages, tools, max_tokens: 100, ...(byId.get(model)?.reasoning ? { reasoning: { effort: "low" } } : {}) } as any);
64 + out.round2 = { finish: r2.choices[0].finish_reason, content: r2.choices[0].message.content, usage: r2.usage, provider: r2.provider };
65 + console.log(` round2 finish=${r2.choices[0].finish_reason} provider=${r2.provider} content=${short(r2.choices[0].message.content, 120)}`);
66 + } catch (e: any) {
67 + out.error = { status: e.status, message: e.message, body: e.error };
68 + console.log(` ERROR ${e.status} ${short(e.error ?? e.message, 300)}`);
69 + }
70 +}
71 +save("03-tools.json", results);
added research/openrouter/04-structured.ts +65 −0
@@ -0,0 +1,65 @@
1 +// Probe 04: structured output (json_schema strict) — with and without provider.require_parameters, streaming once.
2 +import { raw, rawSSE, save, short } from "./lib.ts";
3 +import { MODELS, OPENAI, byId } from "./models.ts";
4 +
5 +const response_format = {
6 + type: "json_schema",
7 + json_schema: {
8 + name: "city_info",
9 + strict: true,
10 + schema: {
11 + type: "object",
12 + properties: {
13 + city: { type: "string" },
14 + country: { type: "string" },
15 + population_millions: { type: "number" },
16 + landmarks: { type: "array", items: { type: "string" } },
17 + },
18 + required: ["city", "country", "population_millions", "landmarks"],
19 + additionalProperties: false,
20 + },
21 + },
22 +};
23 +
24 +const results: any = {};
25 +for (const model of MODELS) {
26 + const body: any = {
27 + model,
28 + messages: [{ role: "user", content: "Give facts about Montreal." }],
29 + response_format,
30 + max_tokens: 200,
31 + provider: { require_parameters: true },
32 + usage: { include: true },
33 + ...(byId.get(model)?.reasoning ? { reasoning: { effort: "low" } } : {}),
34 + };
35 + const r = await raw("/chat/completions", { method: "POST", body: JSON.stringify(body) });
36 + const b: any = r.body;
37 + const content = b?.choices?.[0]?.message?.content;
38 + let parsed: any = null;
39 + let valid = false;
40 + try {
41 + parsed = JSON.parse(content);
42 + valid = typeof parsed.city === "string" && Array.isArray(parsed.landmarks) && typeof parsed.population_millions === "number";
43 + } catch {}
44 + results[model] = { status: r.status, provider: b?.provider, finish: b?.choices?.[0]?.finish_reason, content, valid, usage: b?.usage, error: b?.error };
45 + console.log(`[${model}] ${r.status} provider=${b?.provider} valid=${valid} ${short(content, 160)} ${b?.error ? "ERR=" + short(b.error, 300) : ""}`);
46 +}
47 +
48 +// streaming structured output
49 +const s = await rawSSE("/chat/completions", {
50 + model: OPENAI,
51 + messages: [{ role: "user", content: "Give facts about Montreal." }],
52 + response_format,
53 + max_tokens: 200,
54 + stream: true,
55 + reasoning: { effort: "low" },
56 +});
57 +let text = "";
58 +for (const e of s.events) if (e.data !== "[DONE]") text += e.data?.choices?.[0]?.delta?.content ?? "";
59 +let streamValid = false;
60 +try {
61 + streamValid = typeof JSON.parse(text).city === "string";
62 +} catch {}
63 +results.streamed = { model: OPENAI, status: s.status, events: s.events.length, text, valid: streamValid };
64 +console.log(`[stream ${OPENAI}] events=${s.events.length} valid=${streamValid} ${short(text, 160)}`);
65 +save("04-structured.json", results);
added research/openrouter/05-vision.ts +78 −0
@@ -0,0 +1,78 @@
1 +// Probe 05: vision with a 32x32 PNG data URL on every probe model (incl. the text-only one to see the error shape).
2 +import { deflateSync } from "node:zlib";
3 +import { raw, save, short } from "./lib.ts";
4 +import { MODELS, byId } from "./models.ts";
5 +
6 +function crc32(buf: Buffer) {
7 + let c: number;
8 + const table: number[] = [];
9 + for (let n = 0; n < 256; n++) {
10 + c = n;
11 + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
12 + table[n] = c >>> 0;
13 + }
14 + let crc = 0xffffffff;
15 + for (const b of buf) crc = table[(crc ^ b) & 0xff] ^ (crc >>> 8);
16 + return (crc ^ 0xffffffff) >>> 0;
17 +}
18 +function chunk(type: string, data: Buffer) {
19 + const len = Buffer.alloc(4);
20 + len.writeUInt32BE(data.length);
21 + const td = Buffer.concat([Buffer.from(type, "ascii"), data]);
22 + const crc = Buffer.alloc(4);
23 + crc.writeUInt32BE(crc32(td));
24 + return Buffer.concat([len, td, crc]);
25 +}
26 +/** Solid red square with a blue diagonal, size×size, RGB PNG. */
27 +function png(size: number) {
28 + const ihdr = Buffer.alloc(13);
29 + ihdr.writeUInt32BE(size, 0);
30 + ihdr.writeUInt32BE(size, 4);
31 + ihdr[8] = 8; // bit depth
32 + ihdr[9] = 2; // RGB
33 + const rows: Buffer[] = [];
34 + for (let y = 0; y < size; y++) {
35 + const row = Buffer.alloc(1 + size * 3);
36 + for (let x = 0; x < size; x++) {
37 + const diag = Math.abs(x - y) < 2;
38 + row[1 + x * 3] = diag ? 0 : 220;
39 + row[2 + x * 3] = 0;
40 + row[3 + x * 3] = diag ? 220 : 0;
41 + }
42 + rows.push(row);
43 + }
44 + const idat = deflateSync(Buffer.concat(rows));
45 + return Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), chunk("IHDR", ihdr), chunk("IDAT", idat), chunk("IEND", Buffer.alloc(0))]);
46 +}
47 +
48 +const dataUrl = `data:image/png;base64,${png(32).toString("base64")}`;
49 +const results: any = {};
50 +for (const model of MODELS) {
51 + const body: any = {
52 + model,
53 + messages: [
54 + {
55 + role: "user",
56 + content: [
57 + { type: "text", text: "Describe this image in one sentence: main colour and any shape." },
58 + { type: "image_url", image_url: { url: dataUrl, detail: "low" } },
59 + ],
60 + },
61 + ],
62 + max_tokens: 100,
63 + usage: { include: true },
64 + ...(byId.get(model)?.reasoning ? { reasoning: { effort: "low" } } : {}),
65 + };
66 + const r = await raw("/chat/completions", { method: "POST", body: JSON.stringify(body) });
67 + const b: any = r.body;
68 + results[model] = {
69 + status: r.status,
70 + supportsImage: (byId.get(model)?.architecture?.input_modalities ?? []).includes("image"),
71 + provider: b?.provider,
72 + content: b?.choices?.[0]?.message?.content,
73 + usage: b?.usage,
74 + error: b?.error,
75 + };
76 + console.log(`[${model}] ${r.status} img=${results[model].supportsImage} provider=${b?.provider} prompt_tokens=${b?.usage?.prompt_tokens} cost=${b?.usage?.cost} ${short(b?.choices?.[0]?.message?.content, 140)} ${b?.error ? "ERR=" + short(b.error, 300) : ""}`);
77 +}
78 +save("05-vision.json", results);
added research/openrouter/06-errors.ts +59 −0
@@ -0,0 +1,59 @@
1 +// Probe 06: error shapes — invalid key, no auth, unknown model, malformed body, bad image, streaming error, key endpoints with bad key.
2 +import { raw, rawSSE, save, short } from "./lib.ts";
3 +import { OPENAI, DEEPSEEK } from "./models.ts";
4 +
5 +const results: any = {};
6 +async function rec(name: string, p: Promise<any>) {
7 + const r = await p;
8 + results[name] = r;
9 + console.log(`\n[${name}] ${r.status} ${JSON.stringify(r.headers)}\n ${short(r.body ?? r.error ?? r.events, 500)}`);
10 +}
11 +
12 +const tiny = { model: OPENAI, messages: [{ role: "user", content: "hi" }], max_tokens: 5 };
13 +await rec("invalid_key_chat", raw("/chat/completions", { method: "POST", body: JSON.stringify(tiny), key: "sk-or-v1-0000000000000000000000000000000000000000000000000000000000000000" }));
14 +await rec("invalid_key_models", raw("/models", { key: "sk-or-v1-bad" }));
15 +await rec("invalid_key_key_endpoint", raw("/key", { key: "sk-or-v1-bad" }));
16 +await rec("no_auth_chat", raw("/chat/completions", { method: "POST", body: JSON.stringify(tiny), headers: { Authorization: "" } }));
17 +await rec("unknown_model", raw("/chat/completions", { method: "POST", body: JSON.stringify({ ...tiny, model: "openai/gpt-99-turbo" }) }));
18 +await rec("unknown_model_no_slash", raw("/chat/completions", { method: "POST", body: JSON.stringify({ ...tiny, model: "gpt-99" }) }));
19 +await rec("unknown_variant", raw("/chat/completions", { method: "POST", body: JSON.stringify({ ...tiny, model: `${OPENAI}:nope` }) }));
20 +await rec("malformed_messages", raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: OPENAI, messages: "nope" }) }));
21 +await rec("malformed_json", raw("/chat/completions", { method: "POST", body: "{not json" }));
22 +await rec("empty_messages", raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: OPENAI, messages: [] }) }));
23 +await rec("bad_temperature", raw("/chat/completions", { method: "POST", body: JSON.stringify({ ...tiny, temperature: 7 }) }));
24 +await rec(
25 + "image_on_text_only_model",
26 + raw("/chat/completions", {
27 + method: "POST",
28 + body: JSON.stringify({
29 + model: DEEPSEEK,
30 + max_tokens: 20,
31 + messages: [{ role: "user", content: [{ type: "text", text: "what is this" }, { type: "image_url", image_url: { url: "data:image/png;base64,iVBORw0KGgo=" } }] }],
32 + }),
33 + }),
34 +);
35 +await rec(
36 + "bad_image_data",
37 + raw("/chat/completions", {
38 + method: "POST",
39 + body: JSON.stringify({
40 + model: OPENAI,
41 + max_tokens: 20,
42 + messages: [{ role: "user", content: [{ type: "text", text: "what is this" }, { type: "image_url", image_url: { url: "data:image/png;base64,AAAA" } }] }],
43 + }),
44 + }),
45 +);
46 +await rec("stream_unknown_model", rawSSE("/chat/completions", { ...tiny, model: "openai/gpt-99-turbo", stream: true }));
47 +await rec("stream_invalid_key", rawSSE("/chat/completions", { ...tiny, stream: true }, "sk-or-v1-bad"));
48 +await rec("context_overflow", raw("/chat/completions", { method: "POST", body: JSON.stringify({ ...tiny, max_tokens: 999999999 }) }));
49 +// max_tokens above the model's max_completion_tokens (128000 for gpt-5.4-nano)
50 +await rec("max_tokens_above_limit", raw("/chat/completions", { method: "POST", body: JSON.stringify({ ...tiny, max_tokens: 300000 }) }));
51 +// finish_reason on truncation
52 +await rec("truncation", raw("/chat/completions", { method: "POST", body: JSON.stringify({ model: DEEPSEEK, messages: [{ role: "user", content: "Write 200 words about rivers." }], max_tokens: 10, reasoning: { effort: "none" } }) }));
53 +// generation endpoint with the id from the previous call
54 +const genId = results.truncation?.body?.id;
55 +if (genId) {
56 + await new Promise((r) => setTimeout(r, 1500));
57 + await rec("generation_lookup", raw(`/generation?id=${genId}`));
58 +}
59 +save("06-errors.json", results);
added research/openrouter/07-websearch.ts +73 −0
@@ -0,0 +1,73 @@
1 +// Probe 07: web search via :online suffix and via plugins web (max_results 3), streaming annotations; openrouter/auto tiny call.
2 +import { raw, rawSSE, save, short } from "./lib.ts";
3 +import { OPENAI, ANTHROPIC } from "./models.ts";
4 +
5 +const results: any = {};
6 +const q = "What is the current version number of Node.js LTS? One sentence with a source.";
7 +
8 +// (1) :online suffix, non-streaming
9 +{
10 + const r = await raw("/chat/completions", {
11 + method: "POST",
12 + body: JSON.stringify({ model: `${OPENAI}:online`, messages: [{ role: "user", content: q }], max_tokens: 200, usage: { include: true }, reasoning: { effort: "low" } }),
13 + });
14 + const b: any = r.body;
15 + const msg = b?.choices?.[0]?.message;
16 + results.online_suffix = { status: r.status, model: b?.model, provider: b?.provider, keys: Object.keys(msg ?? {}), annotations: msg?.annotations, content: msg?.content, usage: b?.usage, error: b?.error };
17 + console.log(`[online] ${r.status} model=${b?.model} provider=${b?.provider} msgKeys=${Object.keys(msg ?? {})} annotations=${msg?.annotations?.length} usage=${JSON.stringify(b?.usage)}`);
18 + console.log(" content:", short(msg?.content, 300));
19 + console.log(" annotation[0]:", short(msg?.annotations?.[0], 500));
20 + if (b?.error) console.log(" ERR", JSON.stringify(b.error));
21 +}
22 +
23 +// (2) plugins web with max_results 3, streaming, on Anthropic
24 +{
25 + const s = await rawSSE("/chat/completions", {
26 + model: ANTHROPIC,
27 + messages: [{ role: "user", content: q }],
28 + plugins: [{ id: "web", max_results: 3 }],
29 + max_tokens: 200,
30 + stream: true,
31 + usage: { include: true },
32 + });
33 + const annots: any[] = [];
34 + let text = "";
35 + const deltaKeys = new Set<string>();
36 + for (const e of s.events) {
37 + if (e.data === "[DONE]") continue;
38 + const d = e.data?.choices?.[0]?.delta ?? {};
39 + for (const k of Object.keys(d)) deltaKeys.add(k);
40 + if (d.content) text += d.content;
41 + if (d.annotations) annots.push(...d.annotations);
42 + }
43 + const usage = s.events.find((e) => e.data?.usage)?.data?.usage;
44 + results.plugin_stream = { status: s.status, events: s.events.length, comments: s.comments, deltaKeys: [...deltaKeys], annotations: annots, text, usage, error: (s as any).error, firstAnnotationChunk: s.events.find((e) => e.data?.choices?.[0]?.delta?.annotations)?.data };
45 + console.log(`\n[plugin stream ${ANTHROPIC}] ${s.status} events=${s.events.length} deltaKeys=${[...deltaKeys]} annotations=${annots.length} comments=${s.comments.length}`);
46 + console.log(" text:", short(text, 300));
47 + console.log(" annotation[0]:", short(annots[0], 500));
48 + console.log(" usage:", JSON.stringify(usage));
49 + if ((s as any).error) console.log(" ERR", short((s as any).error));
50 +}
51 +
52 +// (3) openrouter/auto tiny
53 +{
54 + const r = await raw("/chat/completions", {
55 + method: "POST",
56 + body: JSON.stringify({ model: "openrouter/auto", messages: [{ role: "user", content: "Say hi in 3 words." }], max_tokens: 20, usage: { include: true } }),
57 + });
58 + const b: any = r.body;
59 + results.auto = { status: r.status, model: b?.model, provider: b?.provider, usage: b?.usage, content: b?.choices?.[0]?.message?.content, error: b?.error };
60 + console.log(`\n[auto] ${r.status} model=${b?.model} provider=${b?.provider} usage=${JSON.stringify(b?.usage)} ${short(b?.choices?.[0]?.message?.content, 80)} ${b?.error ? JSON.stringify(b.error) : ""}`);
61 +}
62 +
63 +// (4) tilde latest alias
64 +{
65 + const r = await raw("/chat/completions", {
66 + method: "POST",
67 + body: JSON.stringify({ model: "~openai/gpt-mini-latest", messages: [{ role: "user", content: "Say hi in 3 words." }], max_tokens: 20, usage: { include: true }, reasoning: { effort: "low" } }),
68 + });
69 + const b: any = r.body;
70 + results.tilde = { status: r.status, model: b?.model, provider: b?.provider, usage: b?.usage, error: b?.error };
71 + console.log(`[tilde] ${r.status} model=${b?.model} provider=${b?.provider} usage=${JSON.stringify(b?.usage)} ${b?.error ? JSON.stringify(b.error) : ""}`);
72 +}
73 +save("07-websearch.json", results);
added research/openrouter/08-misc.ts +105 −0
@@ -0,0 +1,105 @@
1 +// Probe 08: Responses API (stateless check), endpoints listing, ZDR list, header variants, :batch behaviour, prompt caching on a repeated prompt, reasoning_details round trip on Anthropic.
2 +import { raw, rawSSE, save, short } from "./lib.ts";
3 +import { OPENAI, ANTHROPIC, GOOGLE, byId } from "./models.ts";
4 +
5 +const results: any = {};
6 +
7 +// Responses API
8 +{
9 + const r = await raw("/responses", { method: "POST", body: JSON.stringify({ model: OPENAI, input: "Say hi in 3 words.", max_output_tokens: 30, reasoning: { effort: "low" } }) });
10 + const b: any = r.body;
11 + results.responses_basic = { status: r.status, keys: Object.keys(b ?? {}), outputTypes: b?.output?.map((o: any) => o.type), usage: b?.usage, model: b?.model, provider: b?.provider, store: b?.store, error: b?.error };
12 + console.log(`[responses] ${r.status} keys=${Object.keys(b ?? {})} output=${b?.output?.map((o: any) => o.type)} usage=${JSON.stringify(b?.usage)} ${b?.error ? JSON.stringify(b.error) : ""}`);
13 + const r2 = await raw("/responses", { method: "POST", body: JSON.stringify({ model: OPENAI, input: "hi", max_output_tokens: 30, store: true }) });
14 + results.responses_store_true = { status: r2.status, body: r2.body };
15 + console.log(`[responses store:true] ${r2.status} ${short(r2.body, 300)}`);
16 + const r3 = await raw("/responses", { method: "POST", body: JSON.stringify({ model: ANTHROPIC, input: "hi", max_output_tokens: 30, previous_response_id: "resp_123" }) });
17 + results.responses_prev_id = { status: r3.status, body: r3.body };
18 + console.log(`[responses previous_response_id] ${r3.status} ${short(r3.body, 300)}`);
19 + const s = await rawSSE("/responses", { model: ANTHROPIC, input: "Say hi in 3 words.", max_output_tokens: 30, stream: true });
20 + results.responses_stream = { status: s.status, eventTypes: [...new Set(s.events.map((e) => e.data?.type ?? e.data))], eventNames: [...new Set(s.events.map((e) => e.event))], count: s.events.length, last: s.events[s.events.length - 2]?.data };
21 + console.log(`[responses stream] ${s.status} events=${s.events.length} types=${results.responses_stream.eventTypes} eventNames=${results.responses_stream.eventNames}`);
22 +}
23 +
24 +// Endpoints for a model + ZDR list
25 +{
26 + const slug = byId.get(ANTHROPIC)?.canonical_slug ?? ANTHROPIC;
27 + const r = await raw(`/models/${ANTHROPIC}/endpoints`);
28 + const b: any = r.body;
29 + results.endpoints = { status: r.status, keys: Object.keys(b?.data ?? {}), endpointKeys: Object.keys(b?.data?.endpoints?.[0] ?? {}), endpoints: b?.data?.endpoints?.map((e: any) => ({ name: e.name, provider_name: e.provider_name, tag: e.tag, context_length: e.context_length, max_completion_tokens: e.max_completion_tokens, pricing: e.pricing, supported_parameters: e.supported_parameters, status: e.status, quantization: e.quantization, uptime_last_30m: e.uptime_last_30m, supports_implicit_caching: e.supports_implicit_caching })) };
30 + console.log(`\n[endpoints ${ANTHROPIC}] ${r.status} keys=${results.endpoints.keys} endpointKeys=${results.endpoints.endpointKeys}`);
31 + console.log(" ", short(results.endpoints.endpoints, 800));
32 + const z = await raw("/endpoints/zdr");
33 + const zb: any = z.body;
34 + results.zdr = { status: z.status, count: zb?.data?.length, sample: zb?.data?.slice(0, 2), keys: Object.keys(zb ?? {}) };
35 + console.log(`[zdr] ${z.status} count=${zb?.data?.length} sample=${short(zb?.data?.[0], 300)}`);
36 + const p = await raw("/providers");
37 + const pb: any = p.body;
38 + results.providers = { status: p.status, count: pb?.data?.length, sample: pb?.data?.slice(0, 2) };
39 + console.log(`[providers] ${p.status} count=${pb?.data?.length} sample=${short(pb?.data?.[0], 300)}`);
40 +}
41 +
42 +// Header variants: X-Title vs X-OpenRouter-Title, both accepted?
43 +{
44 + for (const [name, headers] of Object.entries({ x_title: { "X-Title": "PolyLLM" }, x_openrouter_title: { "X-OpenRouter-Title": "PolyLLM" } })) {
45 + const r = await raw("/chat/completions", { method: "POST", headers, body: JSON.stringify({ model: OPENAI, messages: [{ role: "user", content: "hi" }], max_tokens: 5, reasoning: { effort: "none" } }) });
46 + results[`header_${name}`] = { status: r.status, id: (r.body as any)?.id };
47 + console.log(`[header ${name}] ${r.status}`);
48 + }
49 +}
50 +
51 +// :batch variant behaviour (what does chat/completions do with it?)
52 +{
53 + const ctrl = new AbortController();
54 + const t = setTimeout(() => ctrl.abort(), 60_000);
55 + const t0 = Date.now();
56 + try {
57 + const r = await raw("/chat/completions", { method: "POST", signal: ctrl.signal, body: JSON.stringify({ model: `${OPENAI}:batch`, messages: [{ role: "user", content: "hi" }], max_tokens: 5, usage: { include: true } }) });
58 + results.batch = { status: r.status, ms: Date.now() - t0, body: r.body };
59 + console.log(`\n[batch] ${r.status} ${Date.now() - t0}ms ${short(r.body, 500)}`);
60 + } catch (e: any) {
61 + results.batch = { error: String(e), ms: Date.now() - t0 };
62 + console.log(`\n[batch] aborted after ${Date.now() - t0}ms: ${e}`);
63 + } finally {
64 + clearTimeout(t);
65 + }
66 +}
67 +
68 +// Prompt caching: Anthropic with cache_control on a ~1200-token system prompt, twice
69 +{
70 + const big = "You are a helpful assistant. " + "The quick brown fox jumps over the lazy dog near the riverbank while the sun sets slowly. ".repeat(260);
71 + const body = {
72 + model: ANTHROPIC,
73 + messages: [
74 + { role: "system", content: [{ type: "text", text: big, cache_control: { type: "ephemeral" } }] },
75 + { role: "user", content: "Reply with the single word OK." },
76 + ],
77 + max_tokens: 5,
78 + usage: { include: true },
79 + };
80 + const a = await raw("/chat/completions", { method: "POST", body: JSON.stringify(body) });
81 + const bb = await raw("/chat/completions", { method: "POST", body: JSON.stringify(body) });
82 + results.cache = { first: (a.body as any)?.usage, second: (bb.body as any)?.usage, err: (a.body as any)?.error ?? (bb.body as any)?.error };
83 + console.log(`\n[cache anthropic] first=${JSON.stringify(results.cache.first)}\n second=${JSON.stringify(results.cache.second)} ${results.cache.err ? JSON.stringify(results.cache.err) : ""}`);
84 + // OpenAI automatic caching with the same big prompt
85 + const body2 = { model: OPENAI, messages: [{ role: "system", content: big }, { role: "user", content: "Reply with the single word OK." }], max_tokens: 5, usage: { include: true }, reasoning: { effort: "none" } };
86 + const c = await raw("/chat/completions", { method: "POST", body: JSON.stringify(body2) });
87 + const d = await raw("/chat/completions", { method: "POST", body: JSON.stringify(body2) });
88 + results.cache_openai = { first: (c.body as any)?.usage, second: (d.body as any)?.usage };
89 + console.log(`[cache openai] first=${JSON.stringify(results.cache_openai.first)}\n second=${JSON.stringify(results.cache_openai.second)}`);
90 +}
91 +
92 +// Reasoning on Anthropic (max_tokens budget) and Google (mandatory reasoning) — reasoning_details shapes, non-streaming
93 +{
94 + for (const model of [ANTHROPIC, GOOGLE]) {
95 + const r = await raw("/chat/completions", {
96 + method: "POST",
97 + body: JSON.stringify({ model, messages: [{ role: "user", content: "Is 17 prime? One sentence." }], max_tokens: 200, reasoning: { max_tokens: 1024 }, usage: { include: true } }),
98 + });
99 + const b: any = r.body;
100 + const m = b?.choices?.[0]?.message;
101 + results[`reasoning_${model}`] = { status: r.status, provider: b?.provider, reasoning: m?.reasoning, reasoning_details: m?.reasoning_details, usage: b?.usage, error: b?.error };
102 + console.log(`\n[reasoning ${model}] ${r.status} reasoning=${short(m?.reasoning, 100)} details=${short(m?.reasoning_details?.map((d: any) => ({ ...d, text: d.text?.slice(0, 40), data: d.data?.slice(0, 20), signature: d.signature?.slice(0, 20) })), 500)} usage=${JSON.stringify(b?.usage)} ${b?.error ? JSON.stringify(b.error) : ""}`);
103 + }
104 +}
105 +save("08-misc.json", results);
added research/openrouter/09-reasoning-roundtrip.ts +115 −0
@@ -0,0 +1,115 @@
1 +// Probe 09: Anthropic thinking (reasoning.max_tokens) + tools, streamed, then round 2 passing reasoning_details back (and once without);
2 +// stream without any usage flag; /generation lookup with a longer delay; /models vs /models/user diff.
3 +import { readFileSync } from "node:fs";
4 +import { raw, rawSSE, save, short, OUT } from "./lib.ts";
5 +import { ANTHROPIC, GOOGLE, OPENAI } from "./models.ts";
6 +
7 +const results: any = {};
8 +const tools = [
9 + { type: "function", function: { name: "get_weather", description: "Get the current weather for a city", parameters: { type: "object", properties: { city: { type: "string" } }, required: ["city"] } } },
10 +];
11 +
12 +async function streamCollect(body: any) {
13 + const s = await rawSSE("/chat/completions", { ...body, stream: true });
14 + const acc: Record<number, any> = {};
15 + const details: any[] = [];
16 + let reasoning = "";
17 + let content = "";
18 + let finish: string | undefined;
19 + let usage: any;
20 + const detailChunks: any[] = [];
21 + for (const e of s.events) {
22 + if (e.data === "[DONE]") continue;
23 + const c = e.data?.choices?.[0];
24 + const d = c?.delta ?? {};
25 + if (d.reasoning) reasoning += d.reasoning;
26 + if (d.content) content += d.content;
27 + if (d.reasoning_details) {
28 + details.push(...d.reasoning_details);
29 + if (detailChunks.length < 3) detailChunks.push(d.reasoning_details);
30 + }
31 + for (const tc of d.tool_calls ?? []) {
32 + const i = tc.index ?? 0;
33 + acc[i] ??= { id: tc.id, type: "function", function: { name: "", arguments: "" } };
34 + if (tc.id) acc[i].id = tc.id;
35 + if (tc.function?.name) acc[i].function.name += tc.function.name;
36 + if (tc.function?.arguments) acc[i].function.arguments += tc.function.arguments;
37 + }
38 + if (c?.finish_reason) finish = c.finish_reason;
39 + if (e.data?.usage) usage = e.data.usage;
40 + if (e.data?.error) results.streamError = e.data;
41 + }
42 + return { status: s.status, error: (s as any).error, toolCalls: Object.values(acc), details, detailChunks, reasoning, content, finish, usage, events: s.events.length };
43 +}
44 +
45 +for (const model of [ANTHROPIC, GOOGLE]) {
46 + const messages: any[] = [{ role: "user", content: "What's the weather in Montreal right now? Use the tool." }];
47 + const r1 = await streamCollect({ model, messages, tools, max_tokens: 1500, reasoning: { max_tokens: 1024 } });
48 + console.log(`\n[${model}] round1 ${r1.status} finish=${r1.finish} calls=${JSON.stringify(r1.toolCalls)} rt=${r1.usage?.completion_tokens_details?.reasoning_tokens} reasoning=${short(r1.reasoning, 80)}`);
49 + console.log(" detail types:", [...new Set(r1.details.map((d) => d.type))], "count:", r1.details.length);
50 + console.log(" first detail chunks:", short(r1.detailChunks.map((c: any[]) => c.map((d) => ({ ...d, text: d.text?.slice(0, 30), signature: d.signature?.slice(0, 16), data: d.data?.slice(0, 16) }))), 600));
51 + if (r1.error) console.log(" ERR", short(r1.error));
52 + results[`${model}_round1`] = r1;
53 + if (!r1.toolCalls.length) continue;
54 +
55 + // Merge reasoning_details by index/type (docs: pass back unmodified; streaming yields fragments)
56 + const merged: any[] = [];
57 + for (const d of r1.details) {
58 + const last = merged[merged.length - 1];
59 + if (last && last.type === d.type && last.index === d.index) {
60 + if (d.text) last.text = (last.text ?? "") + d.text;
61 + if (d.summary) last.summary = (last.summary ?? "") + d.summary;
62 + if (d.data) last.data = (last.data ?? "") + d.data;
63 + if (d.signature) last.signature = d.signature;
64 + if (d.id) last.id = d.id;
65 + } else merged.push({ ...d });
66 + }
67 + results[`${model}_merged_details`] = merged.map((d) => ({ ...d, text: d.text?.slice(0, 60), signature: d.signature?.slice(0, 24), data: d.data?.slice(0, 24) }));
68 + console.log(" merged details:", short(results[`${model}_merged_details`], 500));
69 +
70 + const toolMsgs = r1.toolCalls.map((tc: any) => ({ role: "tool", tool_call_id: tc.id, content: JSON.stringify({ city: "Montreal", temp_c: 21, sky: "sunny" }) }));
71 + // (a) with reasoning_details passed back
72 + const withDetails = [...messages, { role: "assistant", content: r1.content || null, tool_calls: r1.toolCalls, reasoning_details: merged }, ...toolMsgs];
73 + const a = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model, messages: withDetails, tools, max_tokens: 1500, reasoning: { max_tokens: 1024 } }) });
74 + const ab: any = a.body;
75 + results[`${model}_round2_with_details`] = { status: a.status, finish: ab?.choices?.[0]?.finish_reason, content: ab?.choices?.[0]?.message?.content, usage: ab?.usage, error: ab?.error };
76 + console.log(` round2 WITH details: ${a.status} ${short(ab?.choices?.[0]?.message?.content, 100)} ${ab?.error ? "ERR=" + short(ab.error, 300) : ""}`);
77 + // (b) without reasoning_details
78 + const without = [...messages, { role: "assistant", content: r1.content || null, tool_calls: r1.toolCalls }, ...toolMsgs];
79 + const b = await raw("/chat/completions", { method: "POST", body: JSON.stringify({ model, messages: without, tools, max_tokens: 1500, reasoning: { max_tokens: 1024 } }) });
80 + const bb: any = b.body;
81 + results[`${model}_round2_without_details`] = { status: b.status, finish: bb?.choices?.[0]?.finish_reason, content: bb?.choices?.[0]?.message?.content, usage: bb?.usage, error: bb?.error };
82 + console.log(` round2 WITHOUT details: ${b.status} ${short(bb?.choices?.[0]?.message?.content, 100)} ${bb?.error ? "ERR=" + short(bb.error, 300) : ""}`);
83 +}
84 +
85 +// Stream with no usage flag at all
86 +{
87 + const s = await rawSSE("/chat/completions", { model: OPENAI, messages: [{ role: "user", content: "hi" }], max_tokens: 5, stream: true, reasoning: { effort: "none" } });
88 + const usageChunk = s.events.find((e) => e.data?.usage)?.data;
89 + results.stream_no_usage_flag = { status: s.status, hasUsage: !!usageChunk, usage: usageChunk?.usage };
90 + console.log(`\n[stream without usage flag] ${s.status} usage present=${!!usageChunk} ${JSON.stringify(usageChunk?.usage)}`);
91 + // and with stream_options.include_usage (OpenAI SDK style)
92 + const s2 = await rawSSE("/chat/completions", { model: OPENAI, messages: [{ role: "user", content: "hi" }], max_tokens: 5, stream: true, stream_options: { include_usage: true }, reasoning: { effort: "none" } });
93 + results.stream_options_include_usage = { status: s2.status, hasUsage: s2.events.some((e) => e.data?.usage), error: (s2 as any).error };
94 + console.log(`[stream_options.include_usage] ${s2.status} usage present=${results.stream_options_include_usage.hasUsage} ${(s2 as any).error ? JSON.stringify((s2 as any).error) : ""}`);
95 +}
96 +
97 +// /generation lookup with a longer delay
98 +{
99 + const chat = JSON.parse(readFileSync(`${OUT}01-chat.json`, "utf8"));
100 + const id = chat[OPENAI]?.body?.id;
101 + const g = await raw(`/generation?id=${id}`);
102 + results.generation = { id, status: g.status, body: g.body };
103 + console.log(`\n[generation ${id}] ${g.status} ${short(g.body, 900)}`);
104 +}
105 +
106 +// /models vs /models/user
107 +{
108 + const all: any[] = JSON.parse(readFileSync(`${OUT}models.json`, "utf8")).data;
109 + const user = await raw("/models/user");
110 + const userIds = new Set(((user.body as any)?.data ?? []).map((m: any) => m.id));
111 + const missing = all.filter((m) => !userIds.has(m.id)).map((m) => m.id);
112 + results.models_user_diff = { allCount: all.length, userCount: userIds.size, missingFromUser: missing };
113 + console.log(`\n[/models/user] ${user.status} user=${userIds.size} all=${all.length} missing=${JSON.stringify(missing)}`);
114 +}
115 +save("09-reasoning-roundtrip.json", results);
added research/openrouter/gen-models-json.ts +146 −0
@@ -0,0 +1,146 @@
1 +// Generates docs/provider-research/openrouter.models.json (representative subset) from out/models.json,
2 +// applying the /api/v1/models → PolyModel mapping documented in docs/provider-research/openrouter.md.
3 +import { readFileSync, writeFileSync } from "node:fs";
4 +
5 +const OUT = new URL("./out/", import.meta.url).pathname;
6 +const listing: any[] = JSON.parse(readFileSync(`${OUT}models.json`, "utf8")).data;
7 +const byId = new Map(listing.map((m) => [m.id, m]));
8 +
9 +const PICK: Record<string, string> = {
10 + "openai/gpt-6-astra": "OpenAI flagship (Sep 2026). Reasoning mandatory (effort max…low, default medium). No temperature/top_p in supported_parameters → do not send sampling params. 1.05M context; pricing.overrides doubles the price above 272k prompt tokens.",
11 + "openai/gpt-5.5": "Reasoning optional (default_enabled true); efforts xhigh…none. No sampling params exposed. Long-context override above 272k tokens.",
12 + "openai/gpt-5.4": "Reasoning optional, default OFF (default_enabled false) → behaves as a non-reasoning model unless `reasoning` is sent. Cheaper than 5.5.",
13 + "openai/gpt-5.4-mini": "Target of the `~openai/gpt-mini-latest` alias (probed: alias call answered with model = openai/gpt-5.4-mini).",
14 + "openai/gpt-5.4-nano": "PROBED (all probes). Reasoning default OFF; `reasoning.effort` low/none accepted; sampling params (temperature, top_p, top_k, penalties, stop, logprobs) are SILENTLY DROPPED by default and return 404 'No endpoints found that can handle the requested parameters' with provider.require_parameters=true. Tools, json_schema strict, vision (32×32 PNG → 21 prompt tokens), streaming all OK. `:online` variant on this model consumed 8.5k prompt tokens of search results and 200 reasoning tokens before any content (finish length) → give web-search calls a large max_tokens.",
15 + "openai/gpt-oss-120b": "Open-weights OpenAI model served by many providers; reasoning mandatory with effort high/medium/low; response.provider varies per call.",
16 + "anthropic/claude-fable-5.1": "Anthropic flagship; reasoning mandatory, efforts max…low (default high); 1M context / 128k output. Target of `~anthropic/claude-fable-latest`. Prompt caching needs `cache_control` breakpoints (write 1.25×, read 0.1×).",
17 + "anthropic/claude-opus-5": "Reasoning optional, default_enabled true.",
18 + "anthropic/claude-sonnet-5": "Reasoning optional, default_enabled true; supports top_k.",
19 + "anthropic/claude-haiku-4.5": "PROBED (all probes). reasoning = {mandatory:false} only (no supported_efforts): `reasoning.effort` does NOT enable thinking (0 reasoning tokens) — only `reasoning.max_tokens` does (probed 43–65 reasoning tokens; reasoning_details type reasoning.text with format anthropic-claude-v1 + signature, streamed as fragments). seed/repetition_penalty/logprobs not supported (404 with require_parameters). Served via Amazon Bedrock / Azure / Anthropic; provider chosen per request. cache_control ephemeral probed: 5208 cache_write_tokens then 5208 cached_tokens, cost 0.0065 → 0.00055.",
20 + "google/gemini-3.8-flash": "Target of `~google/gemini-flash-latest`. Reasoning mandatory (high/medium/low). Inputs text+image+video+file+audio.",
21 + "google/gemini-3.1-pro-preview": "Target of `~google/gemini-pro-latest`. Reasoning mandatory. Long-context override above 200k prompt tokens (2× input). Separate `internal_reasoning` price.",
22 + "google/gemini-3.5-flash-lite": "PROBED (all probes). Reasoning mandatory (default_effort minimal): `reasoning.effort: none` / `enabled: false` → 400 'Reasoning is mandatory for this endpoint and cannot be disabled.' Reasoning text is NOT returned (reasoning: null) — only reasoning_details [{type:'reasoning.text', signature, format:'google-gemini-v1'}] (encrypted thought signature) or reasoning.encrypted attached to tool calls (id = tool call id). Vision OK (269 prompt tokens for a 32×32 PNG). Accepts audio/video/file input.",
23 + "google/gemini-3.1-flash-image": "Image OUTPUT model (output_modalities text+image): request `modalities: ['image','text']`, images come back in message.images[]; pricing.image_output per image. Hide from the plain chat picker or flag as image generation.",
24 + "x-ai/grok-4.6": "xAI flagship via OpenRouter (target of `~x-ai/grok-latest`). Reasoning mandatory, efforts xhigh…low. supported_parameters include stop and top_k here (xAI direct rejects stop). Override above 200k prompt tokens (2×).",
25 + "x-ai/grok-4.3": "Reasoning optional (efforts high/medium/low/none).",
26 + "x-ai/grok-4.20": "PROBED (all probes). reasoning = {mandatory:false, default_enabled:false} → non-reasoning by default; `reasoning.effort: low` turns thinking on (~90–165 reasoning tokens) and returns reasoning_details [reasoning.summary (format xai-responses-v1) + reasoning.encrypted]. Emits many ': OPENROUTER PROCESSING' keep-alive comments while thinking. top_k/penalties/stop silently dropped (404 with require_parameters). Hidden xAI system prompt ≈ 196 prompt tokens, 128 cached on every call. 2M context.",
27 + "deepseek/deepseek-v4-flash": "PROBED (all probes). Text-only (vision → 404 'No endpoints found that support image input'). Served by many providers (Novita, GMICloud, Parasail, StreamLake, AtlasCloud, Alibaba, Venice, Azure, DigitalOcean… — response.provider changes per call, output quality/format varies). Reasoning on by default; `effort: none` / `enabled: false` disable it. IMPORTANT: reasoning tokens count against max_tokens — with max_tokens 40 + exclude:true the answer came back null (finish length); strict json_schema with require_parameters returned null content on one provider. Give ≥ 1–2k max_tokens. Widest supported_parameters list (top_k, min_p, top_a, repetition_penalty, logit_bias, logprobs).",
28 + "deepseek/deepseek-v4-pro": "Reasoning optional (xhigh/high).",
29 + "moonshotai/kimi-k3": "Target of `~moonshotai/kimi-latest`; 1M context, text+image+video input; reasoning optional with efforts.",
30 + "qwen/qwen3.8-max-0902": "Reasoning mandatory (default xhigh). text+image+video.",
31 + "z-ai/glm-5.3-flash": "Very cheap; reasoning mandatory; expiration_date '2098-12-31' (placeholder — treat dates ≥ 2090 as 'none'). Target of `~z-ai/glm-flash-latest`. default_parameters {temperature:1, top_p:0.95}.",
32 + "mistralai/mistral-medium-3-5": "Reasoning optional with efforts ['high','none'] — a model whose only effort levels are on/off.",
33 + "meta-llama/llama-4-maverick": "No `reasoning` field at all → non-reasoning. Many third-party providers; instruct_type null.",
34 + "perplexity/sonar-pro": "Built-in native web search (answers include citations); max_completion_tokens 8000; no reasoning field.",
35 + "minimax/minimax-m3": "Cheap 1M-context reasoning model (mandatory false, no efforts). text+image+video.",
36 + "nvidia/nemotron-3-super-120b-a12b:free": "FREE variant (pricing.prompt '0'): 20 req/min and 50 req/day (<$10 purchased) or 1000/day (≥$10) — docs/limits. Providers may train on free-tier prompts unless data_collection: 'deny'. Show as free but expect 429s.",
37 + "openai/gpt-audio-mini": "Audio in/out model (output_modalities text+audio; pricing.audio / audio_output per token). Needs `modalities: ['text','audio']` + audio config — out of scope for the first adapter; hide or flag.",
38 + "openrouter/auto": "Meta-model: pricing '-1' (unknown up front; billed at the routed model's rate, no fee). response.model tells the chosen model (probed: routed a 'say hi' prompt to deepseek/deepseek-v4-flash-0731). context_length 2M and output_modalities text+image are the union of candidates. supported_parameters is the union too — keep sampling params off unless the user opts in. `:online` is defined as openrouter/auto + web plugin.",
39 +};
40 +
41 +const toPerMillion = (s: unknown) => (s == null || s === "-1" ? null : Math.round(Number(s) * 1e6 * 1e6) / 1e6);
42 +const num = (s: unknown) => (s == null ? null : Number(s));
43 +
44 +function map(m: any) {
45 + const sp: string[] = m.supported_parameters ?? [];
46 + const has = (p: string) => sp.includes(p);
47 + const inMods: string[] = m.architecture?.input_modalities ?? [];
48 + const outMods: string[] = m.architecture?.output_modalities ?? [];
49 + const r = m.reasoning ?? null;
50 + const reasoning = !!r; // field present → model can think
51 + const efforts: string[] | null = r?.supported_efforts ?? null;
52 + const author = m.id.split("/")[0];
53 + const base = m.id.split(":")[0];
54 + const variant = m.id.includes(":") ? m.id.slice(m.id.indexOf(":") + 1) : null;
55 + const expired = m.expiration_date && m.expiration_date < "2090-01-01" && m.expiration_date <= "2026-09-08";
56 + const override = m.pricing?.overrides?.[0];
57 + return {
58 + id: m.id,
59 + displayName: m.name,
60 + family: author,
61 + canonicalSlug: m.canonical_slug,
62 + variant,
63 + baseId: base,
64 + contextTokens: m.context_length ?? m.top_provider?.context_length ?? null,
65 + maxOutputTokens: m.top_provider?.max_completion_tokens ?? null,
66 + capabilities: {
67 + text: outMods.includes("text"),
68 + vision: inMods.includes("image"),
69 + audioInput: inMods.includes("audio"),
70 + audioOutput: outMods.includes("audio"),
71 + imageGeneration: outMods.includes("image"),
72 + video: inMods.includes("video"),
73 + reasoning,
74 + tools: has("tools"),
75 + structuredOutput: has("structured_outputs") || has("response_format"),
76 + strictJsonSchema: has("structured_outputs"),
77 + streaming: true,
78 + files: inMods.includes("file"),
79 + webSearch: "via-plugin-or-:online",
80 + moderated: m.top_provider?.is_moderated ?? false,
81 + },
82 + parameters: {
83 + temperature: has("temperature"),
84 + topP: has("top_p"),
85 + topK: has("top_k"),
86 + minP: has("min_p"),
87 + topA: has("top_a"),
88 + maxTokens: has("max_tokens") || has("max_completion_tokens"),
89 + reasoningEffort: reasoning ? (efforts ?? (r.mandatory ? false : "toggle-only (reasoning.enabled / reasoning.max_tokens)")) : false,
90 + reasoningMandatory: r?.mandatory ?? false,
91 + reasoningDefaultEnabled: r?.default_enabled ?? (r ? r.mandatory : false),
92 + reasoningDefaultEffort: r?.default_effort ?? null,
93 + thinkingBudget: r?.supports_max_tokens ?? (author === "anthropic" || author === "google" ? "reasoning.max_tokens (docs)" : false),
94 + stop: has("stop"),
95 + seed: has("seed"),
96 + frequencyPenalty: has("frequency_penalty"),
97 + presencePenalty: has("presence_penalty"),
98 + repetitionPenalty: has("repetition_penalty"),
99 + logprobs: has("logprobs"),
100 + logitBias: has("logit_bias"),
101 + responseFormat: has("response_format"),
102 + toolChoice: has("tool_choice"),
103 + parallelToolCalls: has("parallel_tool_calls"),
104 + verbosity: has("verbosity"),
105 + webSearchOptions: has("web_search_options"),
106 + defaults: m.default_parameters && Object.keys(m.default_parameters).length ? m.default_parameters : null,
107 + },
108 + pricing: {
109 + inputPerMillion: toPerMillion(m.pricing?.prompt),
110 + cachedInputPerMillion: toPerMillion(m.pricing?.input_cache_read),
111 + cacheWritePerMillion: toPerMillion(m.pricing?.input_cache_write),
112 + cacheWrite1hPerMillion: toPerMillion(m.pricing?.input_cache_write_1h),
113 + outputPerMillion: toPerMillion(m.pricing?.completion),
114 + internalReasoningPerMillion: toPerMillion(m.pricing?.internal_reasoning),
115 + imageInputPerImage: num(m.pricing?.image),
116 + imageOutputPerImage: num(m.pricing?.image_output),
117 + audioInputPerMillion: toPerMillion(m.pricing?.audio),
118 + audioOutputPerMillion: toPerMillion(m.pricing?.audio_output),
119 + perRequest: num(m.pricing?.request),
120 + webSearchPerRequest: num(m.pricing?.web_search),
121 + longContextThresholdTokens: override?.min_prompt_tokens ?? null,
122 + longContextInputPerMillion: toPerMillion(override?.prompt),
123 + longContextOutputPerMillion: toPerMillion(override?.completion),
124 + longContextCachedInputPerMillion: toPerMillion(override?.input_cache_read),
125 + free: m.pricing?.prompt === "0" && m.pricing?.completion === "0",
126 + unknown: m.pricing?.prompt === "-1",
127 + },
128 + status: expired ? "expired" : variant === "free" ? "free" : m.id.startsWith("openrouter/") ? "meta" : "active",
129 + expirationDate: m.expiration_date && m.expiration_date < "2090-01-01" ? m.expiration_date : null,
130 + knowledgeCutoff: m.knowledge_cutoff ?? null,
131 + aliases: listing.filter((x) => x.alias_target?.slug === m.id).map((x) => x.id),
132 + huggingFaceId: m.hugging_face_id ?? null,
133 + created: m.created,
134 + notes: PICK[m.id],
135 + };
136 +}
137 +
138 +const rows = Object.keys(PICK).map((id) => {
139 + const m = byId.get(id);
140 + if (!m) throw new Error(`missing in listing: ${id}`);
141 + return map(m);
142 +});
143 +const target = new URL("../../docs/provider-research/openrouter.models.json", import.meta.url).pathname;
144 +writeFileSync(target, JSON.stringify(rows, null, 2) + "\n");
145 +console.log(`wrote ${target} (${rows.length} models)`);
146 +for (const r of rows) console.log(`${r.id.padEnd(44)} ctx=${String(r.contextTokens).padStart(8)} out=${String(r.maxOutputTokens).padStart(7)} $${r.pricing.inputPerMillion}/${r.pricing.outputPerMillion} eff=${JSON.stringify(r.parameters.reasoningEffort)} vis=${r.capabilities.vision} tools=${r.capabilities.tools} so=${r.capabilities.strictJsonSchema} temp=${r.parameters.temperature}`);
added research/openrouter/lib.ts +114 −0
@@ -0,0 +1,114 @@
1 +// Shared helpers for OpenRouter probes. Key comes from process.env.OPENROUTER_API_KEY (never logged).
2 +import { mkdirSync, writeFileSync } from "node:fs";
3 +import OpenAI from "openai";
4 +
5 +export const BASE = "https://openrouter.ai/api/v1";
6 +export const KEY = process.env.OPENROUTER_API_KEY ?? "";
7 +if (!KEY) {
8 + console.error("OPENROUTER_API_KEY missing (run: set -a; . ./.env; set +a)");
9 + process.exit(1);
10 +}
11 +
12 +export const OUT = new URL("./out/", import.meta.url).pathname;
13 +mkdirSync(OUT, { recursive: true });
14 +
15 +export const APP_HEADERS = { "HTTP-Referer": "https://www.polyllm.io", "X-Title": "PolyLLM research" };
16 +
17 +export const client = new OpenAI({
18 + apiKey: KEY,
19 + baseURL: BASE,
20 + timeout: 120_000,
21 + maxRetries: 0,
22 + defaultHeaders: APP_HEADERS,
23 +});
24 +
25 +export function save(name: string, data: unknown) {
26 + writeFileSync(`${OUT}${name}`, JSON.stringify(data, null, 2));
27 + console.log(`saved out/${name}`);
28 +}
29 +
30 +const HEADER_RE = /ratelimit|request-id|x-request|retry-after|content-type|server|date|cf-ray|x-openrouter|x-or-/i;
31 +
32 +/** Raw request; returns status, selected headers, and parsed body (never echoes the key). */
33 +export async function raw(path: string, init: RequestInit & { key?: string } = {}) {
34 + const { key, ...rest } = init;
35 + const res = await fetch(`${BASE}${path}`, {
36 + ...rest,
37 + headers: {
38 + "Content-Type": "application/json",
39 + Authorization: `Bearer ${key ?? KEY}`,
40 + ...APP_HEADERS,
41 + ...(rest.headers ?? {}),
42 + },
43 + });
44 + const text = await res.text();
45 + let body: unknown = text;
46 + try {
47 + body = JSON.parse(text);
48 + } catch {}
49 + const headers: Record<string, string> = {};
50 + res.headers.forEach((v, k) => {
51 + if (HEADER_RE.test(k)) headers[k] = v;
52 + });
53 + return { status: res.status, headers, body };
54 +}
55 +
56 +/** Raw SSE POST; returns parsed data events plus every comment line (": OPENROUTER PROCESSING"). */
57 +export async function rawSSE(path: string, payload: unknown, key?: string) {
58 + const res = await fetch(`${BASE}${path}`, {
59 + method: "POST",
60 + headers: { "Content-Type": "application/json", Authorization: `Bearer ${key ?? KEY}`, ...APP_HEADERS },
61 + body: JSON.stringify(payload),
62 + });
63 + const headers: Record<string, string> = {};
64 + res.headers.forEach((v, k) => {
65 + if (HEADER_RE.test(k)) headers[k] = v;
66 + });
67 + if (!res.ok || !res.body) {
68 + const text = await res.text();
69 + let body: unknown = text;
70 + try {
71 + body = JSON.parse(text);
72 + } catch {}
73 + return { status: res.status, headers, error: body, events: [] as any[], comments: [] as string[] };
74 + }
75 + const reader = res.body.getReader();
76 + const dec = new TextDecoder();
77 + let buf = "";
78 + const events: { event?: string; data: any; raw?: string }[] = [];
79 + const comments: string[] = [];
80 + for (;;) {
81 + const { value, done } = await reader.read();
82 + if (done) break;
83 + buf += dec.decode(value, { stream: true });
84 + let idx;
85 + while ((idx = buf.indexOf("\n\n")) >= 0) {
86 + const block = buf.slice(0, idx);
87 + buf = buf.slice(idx + 2);
88 + let ev: string | undefined;
89 + const datas: string[] = [];
90 + for (const line of block.split("\n")) {
91 + if (line.startsWith(":")) comments.push(line);
92 + else if (line.startsWith("event:")) ev = line.slice(6).trim();
93 + else if (line.startsWith("data:")) datas.push(line.slice(5).trim());
94 + }
95 + if (!datas.length) continue;
96 + const d = datas.join("\n");
97 + if (d === "[DONE]") {
98 + events.push({ event: ev, data: "[DONE]" });
99 + continue;
100 + }
101 + try {
102 + events.push({ event: ev, data: JSON.parse(d) });
103 + } catch {
104 + events.push({ event: ev, data: null, raw: d });
105 + }
106 + }
107 + }
108 + return { status: res.status, headers, events, comments };
109 +}
110 +
111 +export function short(s: unknown, n = 300) {
112 + const t = typeof s === "string" ? s : JSON.stringify(s) ?? String(s);
113 + return t.length > n ? t.slice(0, n) + "…" : t;
114 +}
added research/openrouter/models.ts +31 −0
@@ -0,0 +1,31 @@
1 +// Picks the probe models from out/models.json (written by 00-models.ts).
2 +import { readFileSync } from "node:fs";
3 +import { OUT } from "./lib.ts";
4 +
5 +const listing: any[] = JSON.parse(readFileSync(`${OUT}models.json`, "utf8")).data;
6 +export const byId = new Map<string, any>(listing.map((m) => [m.id, m]));
7 +
8 +function pick(preferred: string, fallback: (m: any) => boolean) {
9 + if (byId.has(preferred)) return preferred;
10 + const alt = listing
11 + .filter(fallback)
12 + .filter((m) => !m.id.includes(":"))
13 + .sort((a, b) => Number(a.pricing.prompt) - Number(b.pricing.prompt))[0];
14 + if (!alt) throw new Error(`no model for ${preferred}`);
15 + console.log(`(${preferred} missing → using ${alt.id})`);
16 + return alt.id as string;
17 +}
18 +
19 +export const OPENAI = pick("openai/gpt-5.4-nano", (m) => m.id.startsWith("openai/gpt-5") && m.id.includes("nano"));
20 +export const ANTHROPIC = pick("anthropic/claude-haiku-4.5", (m) => m.id.startsWith("anthropic/claude-haiku"));
21 +export const GOOGLE = pick("google/gemini-3.5-flash-lite", (m) => m.id.startsWith("google/gemini-3") && m.id.includes("lite"));
22 +export const DEEPSEEK = pick("deepseek/deepseek-v4-flash", (m) => m.id.startsWith("deepseek/deepseek-v4"));
23 +export const XAI = pick("x-ai/grok-4.20-0309-non-reasoning", (m) => m.id === "x-ai/grok-4.20" || (m.id.startsWith("x-ai/grok-4") && m.reasoning?.default_enabled === false));
24 +
25 +export const MODELS = [OPENAI, ANTHROPIC, GOOGLE, DEEPSEEK, XAI];
26 +export const VISION_MODELS = MODELS.filter((id) => (byId.get(id)?.architecture?.input_modalities ?? []).includes("image"));
27 +console.log("probe models:", MODELS.join(", "));
28 +for (const id of MODELS) {
29 + const m = byId.get(id);
30 + console.log(` ${id}: in=${m.pricing.prompt} out=${m.pricing.completion} ctx=${m.context_length} maxout=${m.top_provider?.max_completion_tokens} mods=${m.architecture.input_modalities} reasoning=${JSON.stringify(m.reasoning)} params=${m.supported_parameters.join(",")}`);
31 +}
added research/openrouter/package.json +24 −0
@@ -0,0 +1,24 @@
1 +{
2 + "name": "openrouter",
3 + "version": "1.0.0",
4 + "description": "",
5 + "main": "index.js",
6 + "scripts": {
7 + "test": "echo \"Error: no test specified\" && exit 1"
8 + },
9 + "keywords": [],
10 + "author": "",
11 + "license": "ISC",
12 + "devEngines": {
13 + "packageManager": {
14 + "name": "pnpm",
15 + "version": "^11.1.2",
16 + "onFail": "download"
17 + }
18 + },
19 + "type": "module",
20 + "dependencies": {
21 + "openai": "^7.10.0",
22 + "tsx": "^4.23.13"
23 + }
24 +}
added research/openrouter/pnpm-lock.yaml +532 −0
@@ -0,0 +1,532 @@
1 +---
2 +lockfileVersion: '9.0'
3 +
4 +importers:
5 +
6 + .:
7 + configDependencies: {}
8 + packageManagerDependencies:
9 + '@pnpm/exe':
10 + specifier: ^11.1.2
11 + version: 11.26.0
12 + pnpm:
13 + specifier: ^11.1.2
14 + version: 11.26.0
15 +
16 +packages:
17 +
18 + '@pnpm/exe@11.26.0':
19 + resolution: {integrity: sha512-eeiNi7WeXulOO1BzDAU9HIk+N3dokG+xwKPUupNQoZT5auL3rh2pFW9DqdIEnCC2xtt/hZd6UGtKyb6OMpqndw==}
20 + hasBin: true
21 +
22 + '@pnpm/linux-arm64@11.26.0':
23 + resolution: {integrity: sha512-M0IDuD4hbXxpLBsj1/I9pODcxu/gxTDtbyQmsVGYu1TZwCCpf6YU1x3lzq8aCGjrysmXYQAtCiY8GBjdw4UGFg==}
24 + cpu: [arm64]
25 + os: [linux]
26 +
27 + '@pnpm/linux-x64@11.26.0':
28 + resolution: {integrity: sha512-nUuNRsFCGVje3FHtOaWxIQl2EP4NBktKr+PpLN7uhuWnJCCMN/F9RKjNJAM+dUPyvAZs8VDvS0kA8J55kyybyg==}
29 + cpu: [x64]
30 + os: [linux]
31 +
32 + '@pnpm/linuxstatic-arm64@11.26.0':
33 + resolution: {integrity: sha512-+dXROkvdWjskrQtjwJAFuJI7Q2uTkghBfLsd0vqRmorX3fPuinhoRjJJ/UPtWXzJqwcByJBmdJRW+q2964m5qQ==}
34 + cpu: [arm64]
35 + os: [linux]
36 + libc: [musl]
37 +
38 + '@pnpm/linuxstatic-x64@11.26.0':
39 + resolution: {integrity: sha512-0eXE6spzIBdCbYhJZQ0u/sIOD0v30sqk2PrzoixzsNMc7S/lhd7EkBMVJ6cCPiCL2TXvOsrJC2SpWPBK769A9A==}
40 + cpu: [x64]
41 + os: [linux]
42 + libc: [musl]
43 +
44 + '@pnpm/macos-arm64@11.26.0':
45 + resolution: {integrity: sha512-Za3kKV89Zj0SFzQf6zEUTUIy13xH8UiNyb7aILDRjPiTvTaUSCh3q+nvljISfGH1XcopXvo+p3K8Q2nrKYWAug==}
46 + cpu: [arm64]
47 + os: [darwin]
48 +
49 + '@pnpm/win-arm64@11.26.0':
50 + resolution: {integrity: sha512-5akeLtbqbFDdJtCV4qgLmDBV3R+XNs6E7h7Xb4ZBzS3rLRp8e/y/ZdgrZaGF5Kjo45g0BLAxdGUREHbU2/lGUw==}
51 + cpu: [arm64]
52 + os: [win32]
53 +
54 + '@pnpm/win-x64@11.26.0':
55 + resolution: {integrity: sha512-f15SfIo7nppxBII+STy6jpd1z3LgGTZh6skfuxKA/xHmkzopnQJQXr4hBnl1rJLJAnmuOhN8BssaC4LiXmS78w==}
56 + cpu: [x64]
57 + os: [win32]
58 +
59 + '@reflink/reflink-darwin-arm64@0.1.19':
60 + resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==}
61 + engines: {node: '>= 10'}
62 + cpu: [arm64]
63 + os: [darwin]
64 +
65 + '@reflink/reflink-darwin-x64@0.1.19':
66 + resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==}
67 + engines: {node: '>= 10'}
68 + cpu: [x64]
69 + os: [darwin]
70 +
71 + '@reflink/reflink-linux-arm64-gnu@0.1.19':
72 + resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==}
73 + engines: {node: '>= 10'}
74 + cpu: [arm64]
75 + os: [linux]
76 + libc: [glibc]
77 +
78 + '@reflink/reflink-linux-arm64-musl@0.1.19':
79 + resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==}
80 + engines: {node: '>= 10'}
81 + cpu: [arm64]
82 + os: [linux]
83 + libc: [musl]
84 +
85 + '@reflink/reflink-linux-x64-gnu@0.1.19':
86 + resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==}
87 + engines: {node: '>= 10'}
88 + cpu: [x64]
89 + os: [linux]
90 + libc: [glibc]
91 +
92 + '@reflink/reflink-linux-x64-musl@0.1.19':
93 + resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==}
94 + engines: {node: '>= 10'}
95 + cpu: [x64]
96 + os: [linux]
97 + libc: [musl]
98 +
99 + '@reflink/reflink-win32-arm64-msvc@0.1.19':
100 + resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==}
101 + engines: {node: '>= 10'}
102 + cpu: [arm64]
103 + os: [win32]
104 +
105 + '@reflink/reflink-win32-x64-msvc@0.1.19':
106 + resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==}
107 + engines: {node: '>= 10'}
108 + cpu: [x64]
109 + os: [win32]
110 +
111 + '@reflink/reflink@0.1.19':
112 + resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==}
113 + engines: {node: '>= 10'}
114 +
115 + detect-libc@2.1.2:
116 + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
117 + engines: {node: '>=8'}
118 +
119 + pnpm@11.26.0:
120 + resolution: {integrity: sha512-/A4r+JC5+YNhHxq2jAY3vOkUOQZTaZ+DwaeLAF7SXyyB53kgyP2O7i7PC1iyjNywCoTZf2m898VrLzRHECOGZA==}
121 + engines: {node: '>=22.13'}
122 + hasBin: true
123 +
124 +snapshots:
125 +
126 + '@pnpm/exe@11.26.0':
127 + dependencies:
128 + '@reflink/reflink': 0.1.19
129 + detect-libc: 2.1.2
130 + optionalDependencies:
131 + '@pnpm/linux-arm64': 11.26.0
132 + '@pnpm/linux-x64': 11.26.0
133 + '@pnpm/linuxstatic-arm64': 11.26.0
134 + '@pnpm/linuxstatic-x64': 11.26.0
135 + '@pnpm/macos-arm64': 11.26.0
136 + '@pnpm/win-arm64': 11.26.0
137 + '@pnpm/win-x64': 11.26.0
138 +
139 + '@pnpm/linux-arm64@11.26.0':
140 + optional: true
141 +
142 + '@pnpm/linux-x64@11.26.0':
143 + optional: true
144 +
145 + '@pnpm/linuxstatic-arm64@11.26.0':
146 + optional: true
147 +
148 + '@pnpm/linuxstatic-x64@11.26.0':
149 + optional: true
150 +
151 + '@pnpm/macos-arm64@11.26.0':
152 + optional: true
153 +
154 + '@pnpm/win-arm64@11.26.0':
155 + optional: true
156 +
157 + '@pnpm/win-x64@11.26.0':
158 + optional: true
159 +
160 + '@reflink/reflink-darwin-arm64@0.1.19':
161 + optional: true
162 +
163 + '@reflink/reflink-darwin-x64@0.1.19':
164 + optional: true
165 +
166 + '@reflink/reflink-linux-arm64-gnu@0.1.19':
167 + optional: true
168 +
169 + '@reflink/reflink-linux-arm64-musl@0.1.19':
170 + optional: true
171 +
172 + '@reflink/reflink-linux-x64-gnu@0.1.19':
173 + optional: true
174 +
175 + '@reflink/reflink-linux-x64-musl@0.1.19':
176 + optional: true
177 +
178 + '@reflink/reflink-win32-arm64-msvc@0.1.19':
179 + optional: true
180 +
181 + '@reflink/reflink-win32-x64-msvc@0.1.19':
182 + optional: true
183 +
184 + '@reflink/reflink@0.1.19':
185 + optionalDependencies:
186 + '@reflink/reflink-darwin-arm64': 0.1.19
187 + '@reflink/reflink-darwin-x64': 0.1.19
188 + '@reflink/reflink-linux-arm64-gnu': 0.1.19
189 + '@reflink/reflink-linux-arm64-musl': 0.1.19
190 + '@reflink/reflink-linux-x64-gnu': 0.1.19
191 + '@reflink/reflink-linux-x64-musl': 0.1.19
192 + '@reflink/reflink-win32-arm64-msvc': 0.1.19
193 + '@reflink/reflink-win32-x64-msvc': 0.1.19
194 +
195 + detect-libc@2.1.2: {}
196 +
197 + pnpm@11.26.0: {}
198 +
199 +---
200 +lockfileVersion: '9.0'
201 +
202 +settings:
203 + autoInstallPeers: true
204 + excludeLinksFromLockfile: false
205 +
206 +importers:
207 +
208 + .:
209 + dependencies:
210 + openai:
211 + specifier: ^7.10.0
212 + version: 7.10.0
213 + tsx:
214 + specifier: ^4.23.13
215 + version: 4.23.13
216 +
217 +packages:
218 +
219 + '@esbuild/aix-ppc64@0.28.2':
220 + resolution: {integrity: sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==}
221 + engines: {node: '>=18'}
222 + cpu: [ppc64]
223 + os: [aix]
224 +
225 + '@esbuild/android-arm64@0.28.2':
226 + resolution: {integrity: sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==}
227 + engines: {node: '>=18'}
228 + cpu: [arm64]
229 + os: [android]
230 +
231 + '@esbuild/android-arm@0.28.2':
232 + resolution: {integrity: sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==}
233 + engines: {node: '>=18'}
234 + cpu: [arm]
235 + os: [android]
236 +
237 + '@esbuild/android-x64@0.28.2':
238 + resolution: {integrity: sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==}
239 + engines: {node: '>=18'}
240 + cpu: [x64]
241 + os: [android]
242 +
243 + '@esbuild/darwin-arm64@0.28.2':
244 + resolution: {integrity: sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==}
245 + engines: {node: '>=18'}
246 + cpu: [arm64]
247 + os: [darwin]
248 +
249 + '@esbuild/darwin-x64@0.28.2':
250 + resolution: {integrity: sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==}
251 + engines: {node: '>=18'}
252 + cpu: [x64]
253 + os: [darwin]
254 +
255 + '@esbuild/freebsd-arm64@0.28.2':
256 + resolution: {integrity: sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==}
257 + engines: {node: '>=18'}
258 + cpu: [arm64]
259 + os: [freebsd]
260 +
261 + '@esbuild/freebsd-x64@0.28.2':
262 + resolution: {integrity: sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==}
263 + engines: {node: '>=18'}
264 + cpu: [x64]
265 + os: [freebsd]
266 +
267 + '@esbuild/linux-arm64@0.28.2':
268 + resolution: {integrity: sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==}
269 + engines: {node: '>=18'}
270 + cpu: [arm64]
271 + os: [linux]
272 +
273 + '@esbuild/linux-arm@0.28.2':
274 + resolution: {integrity: sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==}
275 + engines: {node: '>=18'}
276 + cpu: [arm]
277 + os: [linux]
278 +
279 + '@esbuild/linux-ia32@0.28.2':
280 + resolution: {integrity: sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==}
281 + engines: {node: '>=18'}
282 + cpu: [ia32]
283 + os: [linux]
284 +
285 + '@esbuild/linux-loong64@0.28.2':
286 + resolution: {integrity: sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==}
287 + engines: {node: '>=18'}
288 + cpu: [loong64]
289 + os: [linux]
290 +
291 + '@esbuild/linux-mips64el@0.28.2':
292 + resolution: {integrity: sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==}
293 + engines: {node: '>=18'}
294 + cpu: [mips64el]
295 + os: [linux]
296 +
297 + '@esbuild/linux-ppc64@0.28.2':
298 + resolution: {integrity: sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==}
299 + engines: {node: '>=18'}
300 + cpu: [ppc64]
301 + os: [linux]
302 +
303 + '@esbuild/linux-riscv64@0.28.2':
304 + resolution: {integrity: sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==}
305 + engines: {node: '>=18'}
306 + cpu: [riscv64]
307 + os: [linux]
308 +
309 + '@esbuild/linux-s390x@0.28.2':
310 + resolution: {integrity: sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==}
311 + engines: {node: '>=18'}
312 + cpu: [s390x]
313 + os: [linux]
314 +
315 + '@esbuild/linux-x64@0.28.2':
316 + resolution: {integrity: sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==}
317 + engines: {node: '>=18'}
318 + cpu: [x64]
319 + os: [linux]
320 +
321 + '@esbuild/netbsd-arm64@0.28.2':
322 + resolution: {integrity: sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==}
323 + engines: {node: '>=18'}
324 + cpu: [arm64]
325 + os: [netbsd]
326 +
327 + '@esbuild/netbsd-x64@0.28.2':
328 + resolution: {integrity: sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==}
329 + engines: {node: '>=18'}
330 + cpu: [x64]
331 + os: [netbsd]
332 +
333 + '@esbuild/openbsd-arm64@0.28.2':
334 + resolution: {integrity: sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==}
335 + engines: {node: '>=18'}
336 + cpu: [arm64]
337 + os: [openbsd]
338 +
339 + '@esbuild/openbsd-x64@0.28.2':
340 + resolution: {integrity: sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==}
341 + engines: {node: '>=18'}
342 + cpu: [x64]
343 + os: [openbsd]
344 +
345 + '@esbuild/openharmony-arm64@0.28.2':
346 + resolution: {integrity: sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==}
347 + engines: {node: '>=18'}
348 + cpu: [arm64]
349 + os: [openharmony]
350 +
351 + '@esbuild/sunos-x64@0.28.2':
352 + resolution: {integrity: sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==}
353 + engines: {node: '>=18'}
354 + cpu: [x64]
355 + os: [sunos]
356 +
357 + '@esbuild/win32-arm64@0.28.2':
358 + resolution: {integrity: sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==}
359 + engines: {node: '>=18'}
360 + cpu: [arm64]
361 + os: [win32]
362 +
363 + '@esbuild/win32-ia32@0.28.2':
364 + resolution: {integrity: sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==}
365 + engines: {node: '>=18'}
366 + cpu: [ia32]
367 + os: [win32]
368 +
369 + '@esbuild/win32-x64@0.28.2':
370 + resolution: {integrity: sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==}
371 + engines: {node: '>=18'}
372 + cpu: [x64]
373 + os: [win32]
374 +
375 + esbuild@0.28.2:
376 + resolution: {integrity: sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==}
377 + engines: {node: '>=18'}
378 + hasBin: true
379 +
380 + fsevents@2.3.3:
381 + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
382 + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
383 + os: [darwin]
384 +
385 + openai@7.10.0:
386 + resolution: {integrity: sha512-sn9t2Kls7O52PwuF9BUTYNu4Gk/r0lXJyrgaNht4TNRlZFb3dJIGO0RciSgjARGCBRtWjySubAQFJttlzUvGQQ==}
387 + engines: {node: '>=22.0.0'}
388 + peerDependencies:
389 + '@aws-sdk/credential-provider-node': '>=3.972.0 <4'
390 + '@smithy/hash-node': '>=4.3.0 <5'
391 + '@smithy/signature-v4': '>=5.4.0 <6'
392 + undici: '>=5 <9'
393 + ws: ^8.21.0
394 + zod: ^3.25 || ^4.0
395 + peerDependenciesMeta:
396 + '@aws-sdk/credential-provider-node':
397 + optional: true
398 + '@smithy/hash-node':
399 + optional: true
400 + '@smithy/signature-v4':
401 + optional: true
402 + undici:
403 + optional: true
404 + ws:
405 + optional: true
406 + zod:
407 + optional: true
408 +
409 + tsx@4.23.13:
410 + resolution: {integrity: sha512-BL5MGkRln6aDYhb0xbQlEAGw743BaZYWdbWtdJOBriYJboKgUUYCadFp2/FpBBZquBC/ezNBn7wMMPx7FDZUDw==}
411 + engines: {node: '>=18.0.0'}
412 + hasBin: true
413 +
414 +snapshots:
415 +
416 + '@esbuild/aix-ppc64@0.28.2':
417 + optional: true
418 +
419 + '@esbuild/android-arm64@0.28.2':
420 + optional: true
421 +
422 + '@esbuild/android-arm@0.28.2':
423 + optional: true
424 +
425 + '@esbuild/android-x64@0.28.2':
426 + optional: true
427 +
428 + '@esbuild/darwin-arm64@0.28.2':
429 + optional: true
430 +
431 + '@esbuild/darwin-x64@0.28.2':
432 + optional: true
433 +
434 + '@esbuild/freebsd-arm64@0.28.2':
435 + optional: true
436 +
437 + '@esbuild/freebsd-x64@0.28.2':
438 + optional: true
439 +
440 + '@esbuild/linux-arm64@0.28.2':
441 + optional: true
442 +
443 + '@esbuild/linux-arm@0.28.2':
444 + optional: true
445 +
446 + '@esbuild/linux-ia32@0.28.2':
447 + optional: true
448 +
449 + '@esbuild/linux-loong64@0.28.2':
450 + optional: true
451 +
452 + '@esbuild/linux-mips64el@0.28.2':
453 + optional: true
454 +
455 + '@esbuild/linux-ppc64@0.28.2':
456 + optional: true
457 +
458 + '@esbuild/linux-riscv64@0.28.2':
459 + optional: true
460 +
461 + '@esbuild/linux-s390x@0.28.2':
462 + optional: true
463 +
464 + '@esbuild/linux-x64@0.28.2':
465 + optional: true
466 +
467 + '@esbuild/netbsd-arm64@0.28.2':
468 + optional: true
469 +
470 + '@esbuild/netbsd-x64@0.28.2':
471 + optional: true
472 +
473 + '@esbuild/openbsd-arm64@0.28.2':
474 + optional: true
475 +
476 + '@esbuild/openbsd-x64@0.28.2':
477 + optional: true
478 +
479 + '@esbuild/openharmony-arm64@0.28.2':
480 + optional: true
481 +
482 + '@esbuild/sunos-x64@0.28.2':
483 + optional: true
484 +
485 + '@esbuild/win32-arm64@0.28.2':
486 + optional: true
487 +
488 + '@esbuild/win32-ia32@0.28.2':
489 + optional: true
490 +
491 + '@esbuild/win32-x64@0.28.2':
492 + optional: true
493 +
494 + esbuild@0.28.2:
495 + optionalDependencies:
496 + '@esbuild/aix-ppc64': 0.28.2
497 + '@esbuild/android-arm': 0.28.2
498 + '@esbuild/android-arm64': 0.28.2
499 + '@esbuild/android-x64': 0.28.2
500 + '@esbuild/darwin-arm64': 0.28.2
501 + '@esbuild/darwin-x64': 0.28.2
502 + '@esbuild/freebsd-arm64': 0.28.2
503 + '@esbuild/freebsd-x64': 0.28.2
504 + '@esbuild/linux-arm': 0.28.2
505 + '@esbuild/linux-arm64': 0.28.2
506 + '@esbuild/linux-ia32': 0.28.2
507 + '@esbuild/linux-loong64': 0.28.2
508 + '@esbuild/linux-mips64el': 0.28.2
509 + '@esbuild/linux-ppc64': 0.28.2
510 + '@esbuild/linux-riscv64': 0.28.2
511 + '@esbuild/linux-s390x': 0.28.2
512 + '@esbuild/linux-x64': 0.28.2
513 + '@esbuild/netbsd-arm64': 0.28.2
514 + '@esbuild/netbsd-x64': 0.28.2
515 + '@esbuild/openbsd-arm64': 0.28.2
516 + '@esbuild/openbsd-x64': 0.28.2
517 + '@esbuild/openharmony-arm64': 0.28.2
518 + '@esbuild/sunos-x64': 0.28.2
519 + '@esbuild/win32-arm64': 0.28.2
520 + '@esbuild/win32-ia32': 0.28.2
521 + '@esbuild/win32-x64': 0.28.2
522 +
523 + fsevents@2.3.3:
524 + optional: true
525 +
526 + openai@7.10.0: {}
527 +
528 + tsx@4.23.13:
529 + dependencies:
530 + esbuild: 0.28.2
531 + optionalDependencies:
532 + fsevents: 2.3.3
modified scripts/provider-matrix.ts +11 −9
@@ -10,14 +10,10 @@ import "./load-env";
10 10 import fs from "node:fs";
11 11 import zlib from "node:zlib";
12 12 import { getAdapter } from "@/lib/ai/providers";
13 +import { ownerKey } from "@/lib/ai/providers/env-keys";
13 14 import { PROVIDER_IDS, type PolyModel, type ProviderId, type UnifiedChatRequest, type UnifiedStreamEvent } from "@/lib/ai/core/types";
14 15
15 const KEYS: Record<ProviderId, string | undefined> = {
16 openai: process.env.OPENAI_API_KEY,
17 anthropic: process.env.ANTHROPIC_API_KEY,
18 gemini: process.env.GOOGLE_GEMINI_API_KEY,
19 xai: process.env.XAI_API_KEY,
20 };
16 +const KEYS = Object.fromEntries(PROVIDER_IDS.map((p) => [p, ownerKey(p)])) as Record<ProviderId, string | undefined>;
21 17
22 18 /** Cheap, current models per provider used for the matrix. */
23 19 const TEST_MODELS: Record<ProviderId, { text: string; reasoning: string; vision: string }> = {
@@ -25,6 +21,11 @@ const TEST_MODELS: Record<ProviderId, { text: string; reasoning: string; vision:
25 21 anthropic: { text: "claude-haiku-4-5", reasoning: "claude-sonnet-5", vision: "claude-haiku-4-5" },
26 22 gemini: { text: "gemini-3.5-flash-lite", reasoning: "gemini-3.5-flash-lite", vision: "gemini-3.5-flash-lite" },
27 23 xai: { text: "grok-4.20-0309-non-reasoning", reasoning: "grok-4.3", vision: "grok-4.20-0309-non-reasoning" },
24 + mistral: { text: "mistral-small-latest", reasoning: "magistral-medium-latest", vision: "mistral-small-latest" },
25 + deepseek: { text: "deepseek-v4-flash", reasoning: "deepseek-v4-flash", vision: "deepseek-v4-flash-vision-exp" },
26 + kimi: { text: "kimi-k2.6", reasoning: "kimi-k3", vision: "kimi-k2.6" },
27 + openrouter: { text: "openai/gpt-5.4-nano", reasoning: "openai/gpt-5.4-nano", vision: "openai/gpt-5.4-nano" },
28 + cerebras: { text: "gemma-4-31b", reasoning: "gpt-oss-120b", vision: "gemma-4-31b" },
28 29 };
29 30
30 31 // 2x2 PNG (red, green / blue, white) — 32x32 needed for xAI minimums, so we scale via a bigger canvas.
@@ -171,7 +172,7 @@ async function runProvider(p: ProviderId, results: Record<Row, Record<ProviderId
171 172 const r = await collect({ ...base(m.text), settings: { maxTokens: 2000, responseFormat: { type: "json_schema", schema: { type: "object", properties: { city: { type: "string" }, country: { type: "string" } }, required: ["city", "country"], additionalProperties: false }, schemaName: "city" } }, messages: [{ role: "user", content: [{ type: "text", text: "Give the capital of Canada as JSON." }] }] });
172 173 let ok = false;
173 174 try {
174 const j = JSON.parse(r.text);
175 + const j = JSON.parse(r.text.trim().replace(/^```(?:json)?\s*|\s*```$/g, ""));
175 176 ok = typeof j.city === "string" && typeof j.country === "string";
176 177 } catch {
177 178 ok = false;
@@ -192,7 +193,8 @@ async function runProvider(p: ProviderId, results: Record<Row, Record<ProviderId
192 193 note(`reasoning: ${(e as Error).message}`);
193 194 }
194 195 try {
195 const bad = await adapter.validateApiKey(p === "gemini" ? "AIzaSyInvalidKey0000000000000000000000" : p === "anthropic" ? "sk-ant-api03-invalid" : p === "xai" ? "xai-invalid" : "sk-invalid");
196 + const badKeys: Record<string, string> = { gemini: "AIzaSyInvalidKey0000000000000000000000", anthropic: "sk-ant-api03-invalid", xai: "xai-invalid", mistral: "invalidinvalidinvalidinvalid0000", openrouter: "sk-or-v1-invalid0000000000000000", cerebras: "csk-invalid00000000000000000000" };
197 + const bad = await adapter.validateApiKey(badKeys[p] ?? "sk-invalid-0000000000000000");
196 198 set("Invalid key", !bad.ok && bad.error?.code === "INVALID_API_KEY" ? "✅" : "❌");
197 199 note(`invalid key → ${bad.error?.code} (${bad.error?.status})`);
198 200 } catch (e) {
@@ -236,7 +238,7 @@ async function main() {
236 238 }
237 239 }
238 240 const date = new Date().toISOString().slice(0, 10);
239 const header = `| Capability | OpenAI | Anthropic | Gemini | xAI |\n| --- | :-: | :-: | :-: | :-: |`;
241 + const header = `| Capability | ${PROVIDER_IDS.map((p) => p).join(" | ")} |\n| --- | ${PROVIDER_IDS.map(() => ":-:").join(" | ")} |`;
240 242 const lines = ROWS.map((r) => `| ${r} | ${PROVIDER_IDS.map((p) => results[r][p]).join(" | ")} |`);
241 243 const md = `# Provider test matrix\n\nGenerated by \`pnpm providers:matrix\` against the REAL provider APIs on ${date}. A cell is ✅ only when the live request succeeded and the assertion passed. “—” = not run (no key).\n\nTest models: ${PROVIDER_IDS.map((p) => `${p}: ${TEST_MODELS[p].text} / reasoning ${TEST_MODELS[p].reasoning}`).join("; ")}.\n\n${header}\n${lines.join("\n")}\n\n## Notes\n\n${notes.map((n) => `- ${n.replace(/\n/g, " ")}`).join("\n")}\n`;
242 244 fs.mkdirSync("docs", { recursive: true });
modified scripts/sync-models.ts +2 −1
@@ -7,12 +7,13 @@ import "./load-env";
7 7 import { syncAllWithEnvKeys, syncProvider } from "@/lib/ai/registry";
8 8 import { closeDb } from "@/db";
9 9 import { isProviderId } from "@/lib/ai/core/types";
10 +import { ownerKey } from "@/lib/ai/providers/env-keys";
10 11
11 12 async function main() {
12 13 const target = process.argv[2];
13 14 if (target) {
14 15 if (!isProviderId(target)) throw new Error(`Unknown provider ${target}`);
15 const key = { openai: process.env.OPENAI_API_KEY, anthropic: process.env.ANTHROPIC_API_KEY, gemini: process.env.GOOGLE_GEMINI_API_KEY, xai: process.env.XAI_API_KEY }[target];
16 + const key = ownerKey(target);
16 17 if (!key) throw new Error(`No key for ${target} in the environment`);
17 18 console.table([await syncProvider(target, key, "cli")]);
18 19 } else {
modified src/app/(auth)/signup/page.tsx +1 −1
@@ -3,7 +3,7 @@ import { SignupForm } from "../_components/signup-form";
3 3
4 4 export const metadata: Metadata = {
5 5 title: "Create your account",
6 description: "Create a free PolyLLM account. Bring your own API keys for OpenAI, Anthropic, Gemini and xAI.",
6 + description: "Create a free PolyLLM account. Bring your own API keys for OpenAI, Anthropic, Google Gemini, xAI, Mistral, DeepSeek, Kimi, OpenRouter and Cerebras.",
7 7 alternates: { canonical: "/signup" },
8 8 };
9 9
modified src/app/(marketing)/page.tsx +1 −1
@@ -10,7 +10,7 @@ import { FinalCta } from "@/components/marketing/final-cta";
10 10 export const metadata: Metadata = {
11 11 title: { absolute: "PolyLLM — One interface. Every model." },
12 12 description:
13 "PolyLLM is a free, bring-your-own-keys workspace for OpenAI, Anthropic, Google Gemini and xAI models: real streaming chat, capability-driven configuration, side-by-side Arena and usage analytics. Your keys are encrypted and never leave the server.",
13 + "PolyLLM is a free, bring-your-own-keys workspace for OpenAI, Anthropic, Google Gemini, xAI, Mistral, DeepSeek, Kimi, OpenRouter and Cerebras models: real streaming chat, capability-driven configuration, side-by-side Arena and usage analytics. Your keys are encrypted and never leave the server.",
14 14 alternates: { canonical: "/" },
15 15 openGraph: {
16 16 title: "PolyLLM — One interface. Every model.",
modified src/app/api/admin/providers/route.ts +3 −2
@@ -6,6 +6,7 @@ import { isAdmin } from "@/lib/session";
6 6 import { registryStats, syncAllWithEnvKeys, syncProvider } from "@/lib/ai/registry";
7 7 import { getDb, providerConnections, usageRecords, users, models } from "@/db";
8 8 import { PROVIDER_IDS, isProviderId } from "@/lib/ai/core/types";
9 +import { ownerKey } from "@/lib/ai/providers/env-keys";
9 10
10 11 export const dynamic = "force-dynamic";
11 12
@@ -31,7 +32,7 @@ export const GET = withUser(async ({ user }) => {
31 32 const perProvider = await Promise.all(
32 33 PROVIDER_IDS.map(async (p) => {
33 34 const rows = await db.select({ key: models.key, displayName: models.displayName, status: models.status, hidden: models.hidden, lastSeenAt: models.lastSeenAt, source: models.source }).from(models).where(eq(models.provider, p)).orderBy(desc(models.sortWeight));
34 return { provider: p, envKeyConfigured: Boolean({ openai: process.env.OPENAI_API_KEY, anthropic: process.env.ANTHROPIC_API_KEY, gemini: process.env.GOOGLE_GEMINI_API_KEY, xai: process.env.XAI_API_KEY }[p]), models: rows.map((r) => ({ ...r, lastSeenAt: r.lastSeenAt.toISOString() })) };
35 + return { provider: p, envKeyConfigured: Boolean(ownerKey(p)), models: rows.map((r) => ({ ...r, lastSeenAt: r.lastSeenAt.toISOString() })) };
35 36 }),
36 37 );
37 38 return json({
@@ -50,7 +51,7 @@ export const POST = withUser(async ({ req, user }) => {
50 51 const body = await parseBody(req, z.object({ action: z.literal("sync"), provider: z.string().optional() }));
51 52 if (body.provider) {
52 53 if (!isProviderId(body.provider)) throw new ApiError(400, "Unknown provider");
53 const key = { openai: process.env.OPENAI_API_KEY, anthropic: process.env.ANTHROPIC_API_KEY, gemini: process.env.GOOGLE_GEMINI_API_KEY, xai: process.env.XAI_API_KEY }[body.provider];
54 + const key = ownerKey(body.provider);
54 55 if (!key) return NextResponse.json({ error: { code: "NO_ENV_KEY", message: "No owner key configured for this provider" } }, { status: 400 });
55 56 return json({ results: [await syncProvider(body.provider, key, "admin")] });
56 57 }
modified src/app/globals.css +15 −0
@@ -34,6 +34,11 @@
34 34 --color-anthropic: var(--p-anthropic);
35 35 --color-gemini: var(--p-gemini);
36 36 --color-xai: var(--p-xai);
37 + --color-mistral: var(--p-mistral);
38 + --color-deepseek: var(--p-deepseek);
39 + --color-kimi: var(--p-kimi);
40 + --color-openrouter: var(--p-openrouter);
41 + --color-cerebras: var(--p-cerebras);
37 42
38 43 --radius-xs: 4px;
39 44 --radius-sm: 6px;
@@ -92,6 +97,11 @@
92 97 --p-anthropic: #d97757;
93 98 --p-gemini: #3b82f6;
94 99 --p-xai: #303540;
100 + --p-mistral: #e8590c;
101 + --p-deepseek: #4d6bfe;
102 + --p-kimi: #0f766e;
103 + --p-openrouter: #6b46c1;
104 + --p-cerebras: #c2410c;
95 105
96 106 --selection: rgb(91 91 214 / 0.18);
97 107 }
@@ -125,6 +135,11 @@
125 135 --p-anthropic: #e69375;
126 136 --p-gemini: #6aa2ff;
127 137 --p-xai: #d5d8e0;
138 + --p-mistral: #ff8a3d;
139 + --p-deepseek: #7d93ff;
140 + --p-kimi: #2dd4bf;
141 + --p-openrouter: #a78bfa;
142 + --p-cerebras: #fb923c;
128 143
129 144 --selection: rgb(139 141 255 / 0.28);
130 145 }
modified src/app/layout.tsx +1 −1
@@ -11,7 +11,7 @@ export const metadata: Metadata = {
11 11 metadataBase: new URL(APP_URL),
12 12 title: { default: "PolyLLM — One interface. Every model.", template: "%s · PolyLLM" },
13 13 description:
14 "PolyLLM is a universal control center for AI models. Bring your own API keys for OpenAI, Anthropic, Gemini and xAI, discover the latest models, configure them precisely and chat through one premium interface.",
14 + "PolyLLM is a universal control center for AI models. Bring your own API keys for OpenAI, Anthropic, Google Gemini, xAI, Mistral, DeepSeek, Kimi, OpenRouter and Cerebras, discover the latest models, configure them precisely and chat through one premium interface.",
15 15 applicationName: "PolyLLM",
16 16 keywords: ["LLM", "AI chat", "BYOK", "OpenAI", "Anthropic", "Gemini", "xAI", "Grok", "Claude", "GPT", "model comparison", "arena"],
17 17 openGraph: {
modified src/app/share/[id]/page.tsx +1 −1
@@ -169,7 +169,7 @@ export default async function SharePage({ params }: { params: Promise<{ id: stri
169 169
170 170 <aside className="mt-14 rounded-2xl border border-border bg-bg-elevated p-6 text-center shadow-sm sm:p-8">
171 171 <p className="text-lg font-semibold tracking-tight">Continue this conversation with your own keys</p>
172 <p className="mx-auto mt-2 max-w-md text-[14px] leading-6 text-fg-muted">PolyLLM is a free workspace for OpenAI, Anthropic, Gemini and xAI models. Bring your API keys, chat, compare and track costs.</p>
172 + <p className="mx-auto mt-2 max-w-md text-[14px] leading-6 text-fg-muted">PolyLLM is a free workspace for OpenAI, Anthropic, Google Gemini, xAI, Mistral, DeepSeek, Kimi, OpenRouter and Cerebras models. Bring your API keys, chat, compare and track costs.</p>
173 173 <div className="mt-5 flex flex-col items-center justify-center gap-2 sm:flex-row">
174 174 <Button asChild size="lg" className="w-full sm:w-auto">
175 175 <Link href="/signup">
modified src/components/brand/provider-icon.tsx +50 −4
@@ -4,34 +4,80 @@ import { cn } from "@/lib/utils";
4 4 /** Monochrome provider glyphs drawn from scratch (no trademarked logos). */
5 5 export function ProviderIcon({ provider, className, size = 16 }: { provider: ProviderId | string | null | undefined; className?: string; size?: number }) {
6 6 const common = { width: size, height: size, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.8, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, "aria-hidden": true };
7 + const color = (p: string) => ({ color: `var(--p-${p})` });
7 8 switch (provider) {
8 9 case "openai":
9 10 return (
10 <svg {...common} className={cn("shrink-0", className)} style={{ color: "var(--p-openai)" }}>
11 + <svg {...common} className={cn("shrink-0", className)} style={color("openai")}>
11 12 <path d="M12 3.5 19 7.6v8.8L12 20.5 5 16.4V7.6z" />
12 13 <path d="M12 3.5v17M5 7.6l14 8.8M19 7.6 5 16.4" opacity="0.55" />
13 14 </svg>
14 15 );
15 16 case "anthropic":
16 17 return (
17 <svg {...common} className={cn("shrink-0", className)} style={{ color: "var(--p-anthropic)" }}>
18 + <svg {...common} className={cn("shrink-0", className)} style={color("anthropic")}>
18 19 <path d="M4 19 10.2 5h1.6L18 19h-2.6l-1.3-3.2H9.9L8.6 19z" />
19 20 <path d="M10.6 13.6h3l-1.5-3.8z" opacity="0.55" />
20 21 </svg>
21 22 );
22 23 case "gemini":
23 24 return (
24 <svg {...common} className={cn("shrink-0", className)} style={{ color: "var(--p-gemini)" }}>
25 + <svg {...common} className={cn("shrink-0", className)} style={color("gemini")}>
25 26 <path d="M12 3c.6 4.9 4.1 8.4 9 9-4.9.6-8.4 4.1-9 9-.6-4.9-4.1-8.4-9-9 4.9-.6 8.4-4.1 9-9z" />
26 27 </svg>
27 28 );
28 29 case "xai":
29 30 return (
30 <svg {...common} className={cn("shrink-0", className)} style={{ color: "var(--p-xai)" }}>
31 + <svg {...common} className={cn("shrink-0", className)} style={color("xai")}>
31 32 <path d="M5 5l14 14M19 5 5 19" />
32 33 <path d="M12 12l7-7" opacity="0.4" />
33 34 </svg>
34 35 );
36 + case "mistral":
37 + // stepped "M" — evokes Mistral's blocky mark without copying it
38 + return (
39 + <svg {...common} className={cn("shrink-0", className)} style={color("mistral")} strokeWidth={0} fill="currentColor">
40 + <rect x="3" y="5" width="3.6" height="14" rx="0.6" />
41 + <rect x="17.4" y="5" width="3.6" height="14" rx="0.6" />
42 + <rect x="6.6" y="8" width="3.6" height="4" rx="0.6" opacity="0.8" />
43 + <rect x="13.8" y="8" width="3.6" height="4" rx="0.6" opacity="0.8" />
44 + <rect x="10.2" y="11.5" width="3.6" height="4" rx="0.6" opacity="0.6" />
45 + </svg>
46 + );
47 + case "deepseek":
48 + // a whale-like wave arc
49 + return (
50 + <svg {...common} className={cn("shrink-0", className)} style={color("deepseek")}>
51 + <path d="M3 14c2.5-5 6.5-7 11-6 3.2.7 5.2 2.7 7 5.5-2.2-.4-4 .3-5.4 1.8-1.6-1.4-3.6-2-6-1.6-1.9.3-3.6 1.3-6.6.3z" />
52 + <path d="M17 11.2h.01" strokeWidth="2.6" />
53 + <path d="M5 17.5c1.6 1 3.4 1 5.2 0" opacity="0.6" />
54 + </svg>
55 + );
56 + case "kimi":
57 + // crescent + spark ("moon"shot)
58 + return (
59 + <svg {...common} className={cn("shrink-0", className)} style={color("kimi")}>
60 + <path d="M14.5 3.5a8.5 8.5 0 1 0 6 14.3A9 9 0 0 1 14.5 3.5z" />
61 + <path d="M6 5.5v3M4.5 7h3" opacity="0.7" />
62 + </svg>
63 + );
64 + case "openrouter":
65 + // routes converging into one node
66 + return (
67 + <svg {...common} className={cn("shrink-0", className)} style={color("openrouter")}>
68 + <path d="M3 7h5c3 0 3.5 5 6.5 5H21M3 17h5c3 0 3.5-5 6.5-5" />
69 + <path d="M18.5 9.5 21 12l-2.5 2.5" />
70 + </svg>
71 + );
72 + case "cerebras":
73 + // wafer / chip
74 + return (
75 + <svg {...common} className={cn("shrink-0", className)} style={color("cerebras")}>
76 + <rect x="6" y="6" width="12" height="12" rx="2" />
77 + <path d="M9 9h6v6H9z" opacity="0.6" />
78 + <path d="M9 3v3M15 3v3M9 18v3M15 18v3M3 9h3M3 15h3M18 9h3M18 15h3" />
79 + </svg>
80 + );
35 81 default:
36 82 return (
37 83 <svg {...common} className={cn("shrink-0 text-fg-subtle", className)}>
modified src/components/chat/chat-view.tsx +2 −1
@@ -4,6 +4,7 @@ import { useRouter, useSearchParams } from "next/navigation";
4 4 import { ArrowDown, Menu, Pin, Share2, Sparkles, Swords, Trash2 } from "lucide-react";
5 5 import Link from "next/link";
6 6 import { useApp, invalidateConversations } from "@/components/app/store";
7 +import { providerName } from "@/lib/client/providers";
7 8 import { api, streamEvents, ClientApiError } from "@/lib/client/api";
8 9 import type { ChatStreamEvent, ConversationDetail, PublicConversation, PublicMessage, PolyModel, ModelPreset, PromptPreset } from "@/lib/client/types";
9 10 import { MessageItem, type LiveState } from "./message";
@@ -423,7 +424,7 @@ export function ChatView({ conversationId, initial }: Props) {
423 424 <p className="mt-1.5 hidden text-center text-[11px] text-fg-subtle sm:block">
424 425 {model ? (
425 426 <>
426 Responses come straight from {model.provider === "openai" ? "OpenAI" : model.provider === "anthropic" ? "Anthropic" : model.provider === "gemini" ? "Google" : "xAI"} using your key · <Sparkles className="inline size-3" /> costs are estimates
427 + Responses come straight from {providerName(model.provider)} using your key · <Sparkles className="inline size-3" /> costs are estimates
427 428 </>
428 429 ) : (
429 430 "Choose a model to start"
modified src/components/chat/empty-state.tsx +1 −1
@@ -30,7 +30,7 @@ export function ChatEmptyState({ model, onPick }: { model?: PolyModel; onPick: (
30 30 <div className="mt-6 flex flex-col items-center gap-3 rounded-xl border border-border bg-bg-elevated p-5">
31 31 <KeyRound className="size-5 text-accent" />
32 32 <p className="text-sm font-medium">Connect your first provider</p>
33 <p className="max-w-sm text-[13px] text-fg-muted">Add an OpenAI, Anthropic, Gemini or xAI key. Keys are encrypted at rest and only decrypted server-side right before a request.</p>
33 + <p className="max-w-sm text-[13px] text-fg-muted">Add a key from OpenAI, Anthropic, Gemini, xAI, Mistral, DeepSeek, Kimi, OpenRouter or Cerebras. Keys are encrypted at rest and only decrypted server-side right before a request.</p>
34 34 <Button asChild variant="accent" size="sm">
35 35 <Link href="/app/settings/providers">Connect a provider</Link>
36 36 </Button>
modified src/components/chat/model-selector.tsx +2 −5
@@ -19,10 +19,7 @@ const CATEGORIES: { key: Category; label: string; icon?: React.ReactNode }[] = [
19 19 { key: "all", label: "All" },
20 20 { key: "favorites", label: "Favorites", icon: <Star className="size-3.5" /> },
21 21 { key: "recent", label: "Recent", icon: <Clock className="size-3.5" /> },
22 { key: "openai", label: "OpenAI", icon: <ProviderIcon provider="openai" size={14} /> },
23 { key: "anthropic", label: "Anthropic", icon: <ProviderIcon provider="anthropic" size={14} /> },
24 { key: "gemini", label: "Gemini", icon: <ProviderIcon provider="gemini" size={14} /> },
25 { key: "xai", label: "xAI", icon: <ProviderIcon provider="xai" size={14} /> },
22 + ...PROVIDER_ORDER.map((p) => ({ key: p as Category, label: PROVIDERS[p].shortName, icon: <ProviderIcon provider={p} size={14} /> })),
26 23 { key: "reasoning", label: "Reasoning", icon: <Brain className="size-3.5" /> },
27 24 { key: "fast", label: "Fast", icon: <Zap className="size-3.5" /> },
28 25 { key: "vision", label: "Vision", icon: <Eye className="size-3.5" /> },
@@ -169,7 +166,7 @@ export function ModelSelector({ value, onChange, className, size = "md", allowDi
169 166 <div className="flex h-full flex-col items-center justify-center gap-3 p-8 text-center">
170 167 <KeyRound className="size-6 text-fg-subtle" />
171 168 <p className="text-sm font-medium">No provider connected yet</p>
172 <p className="max-w-sm text-[13px] text-fg-muted">Add your OpenAI, Anthropic, Gemini or xAI API key to unlock their models. Keys are encrypted and never leave the server.</p>
169 + <p className="max-w-sm text-[13px] text-fg-muted">Add an API key from any supported provider (OpenAI, Anthropic, Gemini, xAI, Mistral, DeepSeek, Kimi, OpenRouter, Cerebras) to unlock their models. Keys are encrypted and never leave the server.</p>
173 170 <Button asChild variant="accent" size="sm">
174 171 <Link href="/app/settings/providers">Connect a provider</Link>
175 172 </Button>
modified src/components/marketing/features.tsx +1 −1
@@ -107,7 +107,7 @@ function ModelPickerVisual() {
107 107 ))}
108 108 </ul>
109 109 <div className="flex items-center justify-between border-t border-border px-3 py-2 text-[11px] text-fg-subtle">
110 <span>Synced 4 min ago · 4 providers</span>
110 + <span>Synced 4 min ago · 9 providers</span>
111 111 <span className="inline-flex items-center gap-1">
112 112 <kbd className="kbd">↵</kbd> select
113 113 </span>
modified src/components/marketing/footer.tsx +1 −1
@@ -67,7 +67,7 @@ export function MarketingFooter() {
67 67 <div className="mt-12 flex flex-col gap-3 border-t border-border pt-6 text-xs text-fg-subtle sm:flex-row sm:items-center sm:justify-between">
68 68 <p>© {new Date().getFullYear()} PolyLLM. All rights reserved.</p>
69 69 <p className="max-w-md leading-5">
70 OpenAI, Anthropic, Google Gemini and xAI are trademarks of their respective owners. PolyLLM is an independent product and is not affiliated with any provider.
70 + OpenAI, Anthropic, Google Gemini, xAI, Mistral AI, DeepSeek, Moonshot AI, OpenRouter and Cerebras are trademarks of their respective owners. PolyLLM is an independent product and is not affiliated with any provider.
71 71 </p>
72 72 </div>
73 73 </div>
modified src/components/marketing/hero.tsx +1 −1
@@ -31,7 +31,7 @@ export function Hero() {
31 31 </Reveal>
32 32 <Reveal delay={0.1}>
33 33 <p className="mx-auto mt-5 max-w-xl text-pretty text-base leading-7 text-fg-muted sm:text-lg sm:leading-8">
34 Your models. Your keys. One workspace. Chat with OpenAI, Anthropic, Gemini and xAI models, configure every parameter they actually support, and compare them side by side.
34 + Your models. Your keys. One workspace. Chat with OpenAI, Anthropic, Gemini, xAI, Mistral, DeepSeek, Kimi, Cerebras and hundreds of OpenRouter models, configure every parameter they actually support, and compare them side by side.
35 35 </p>
36 36 </Reveal>
37 37 <Reveal delay={0.15} className="mt-8 flex flex-col items-center justify-center gap-3 sm:flex-row">
modified src/components/marketing/mock-data.ts +1 −1
@@ -63,7 +63,7 @@ export const MOCK_MODELS: MockModel[] = [
63 63 },
64 64 ];
65 65
66 export const PROVIDER_LABEL: Record<ProviderId, string> = {
66 +export const PROVIDER_LABEL: Partial<Record<ProviderId, string>> = {
67 67 openai: "OpenAI",
68 68 anthropic: "Anthropic",
69 69 gemini: "Google Gemini",
modified src/lib/ai/core/normalize.ts +2 −1
@@ -63,11 +63,12 @@ export function snapEffort(effort: string, levels: string[]): UnifiedGenerationS
63 63 if (idx < 0) return levels[Math.floor(levels.length / 2)] as UnifiedGenerationSettings["reasoningEffort"];
64 64 const ordered = EFFORT_ORDER.filter((l) => levels.includes(l));
65 65 if (ordered.length === 0) return undefined;
66 + // Closest accepted level; on a tie prefer the HIGHER one so a reasoning request never silently becomes "none".
66 67 let best = ordered[0];
67 68 let bestDist = Infinity;
68 69 for (const l of ordered) {
69 70 const d = Math.abs(EFFORT_ORDER.indexOf(l) - idx);
70 if (d < bestDist) {
71 + if (d < bestDist || (d === bestDist && EFFORT_ORDER.indexOf(l) > EFFORT_ORDER.indexOf(best))) {
71 72 best = l;
72 73 bestDist = d;
73 74 }
modified src/lib/ai/core/types.ts +1 −1
@@ -3,7 +3,7 @@
3 3 * and every UI surface consumes. Nothing in here is provider-specific.
4 4 */
5 5
6 export const PROVIDER_IDS = ["openai", "anthropic", "gemini", "xai"] as const;
6 +export const PROVIDER_IDS = ["openai", "anthropic", "gemini", "xai", "mistral", "deepseek", "kimi", "openrouter", "cerebras"] as const;
7 7 export type ProviderId = (typeof PROVIDER_IDS)[number];
8 8
9 9 export function isProviderId(v: unknown): v is ProviderId {
added src/lib/ai/providers/cerebras/catalog.ts +58 −0
@@ -0,0 +1,58 @@
1 +import type { CatalogEntry } from "@/lib/ai/registry/catalog";
2 +import type { ModelCapabilities } from "@/lib/ai/core/types";
3 +
4 +/**
5 + * Cerebras documented augmentation — docs/provider-research/cerebras.md (audited 2026-09-08, ~170 probes).
6 + * Verified: strict validation (unknown params → 400), reasoning via `reasoning_effort` only
7 + * (gpt-oss: low|medium|high, `none` → 400; qwen: none|low|medium|high, default high; gemma: off by default),
8 + * reasoning tokens consume `max_completion_tokens`, `tools` + `response_format` → 400 on gpt-oss,
9 + * strict json_schema needs `additionalProperties:false`, images only on gemma/qwen (base64 data URIs).
10 + * Contexts are paid-tier (131k); free tier is ~64k. gemma-4-31b is officially retired (2026-09-03) but still served.
11 + */
12 +const caps = (vision: boolean, reasoning: boolean): ModelCapabilities => ({ text: true, vision, audioInput: false, audioOutput: false, imageGeneration: false, video: false, reasoning, tools: true, structuredOutput: true, streaming: true, files: false, webSearch: false });
13 +const params = (levels: string[]) => ({ temperature: true, topP: true, topK: false, maxTokens: true, stop: true, seed: true, frequencyPenalty: true, presencePenalty: true, reasoningEffort: true, reasoningEffortLevels: levels, thinkingBudget: false, temperatureRange: { min: 0, max: 2 } });
14 +
15 +export const CEREBRAS_CATALOG = new Map<string, CatalogEntry>([
16 + [
17 + "gpt-oss-120b",
18 + {
19 + displayName: "GPT-OSS 120B",
20 + family: "GPT-OSS",
21 + capabilities: caps(false, true),
22 + limits: { contextTokens: 131_000, maxOutputTokens: 40_000 },
23 + parameters: params(["low", "medium", "high"]),
24 + pricing: { inputPerMillion: 0.35, cachedInputPerMillion: 0.35, outputPerMillion: 0.75, source: "cerebras-pricing-page", asOf: "2026-09-08" },
25 + status: "active",
26 + sortWeight: 100,
27 + metadata: { reasoningKnob: "reasoning_effort", toolsWithResponseFormat: false, defaultReasoningEffort: "medium", minOutputForReasoning: 1000 },
28 + },
29 + ],
30 + [
31 + "qwen-3.8-27b",
32 + {
33 + displayName: "Qwen 3.8 27B",
34 + family: "Qwen",
35 + capabilities: caps(true, true),
36 + limits: { contextTokens: 131_072, maxOutputTokens: 40_960 },
37 + parameters: params(["none", "low", "medium", "high"]),
38 + pricing: { inputPerMillion: 0.99, cachedInputPerMillion: 0.99, outputPerMillion: 1.49, source: "cerebras-pricing-page", asOf: "2026-09-08" },
39 + status: "active",
40 + sortWeight: 90,
41 + metadata: { reasoningKnob: "reasoning_effort", defaultReasoningEffort: "high", minOutputForReasoning: 1000 },
42 + },
43 + ],
44 + [
45 + "gemma-4-31b",
46 + {
47 + displayName: "Gemma 4 31B",
48 + family: "Gemma",
49 + capabilities: caps(true, true),
50 + limits: { contextTokens: 131_072 },
51 + parameters: params(["none", "low", "medium", "high"]),
52 + pricing: null,
53 + status: "deprecated",
54 + sortWeight: 60,
55 + metadata: { reasoningKnob: "reasoning_effort", defaultReasoningEffort: "none", retired: "2026-09-03", note: "Removed from public endpoints on 2026-09-03 but still served for existing keys." },
56 + },
57 + ],
58 +]);
added src/lib/ai/providers/cerebras/index.ts +67 −0
@@ -0,0 +1,67 @@
1 +import type OpenAI from "openai";
2 +import { type PolyModel, modelKey } from "@/lib/ai/core/types";
3 +import { createOpenAICompatAdapter, listOpenAIModels } from "../shared/openai-compat/factory";
4 +import { CEREBRAS_CATALOG } from "./catalog";
5 +
6 +/**
7 + * Cerebras Inference — OpenAI-compatible at https://api.cerebras.ai/v1 (open models, very fast).
8 + * `GET /v1/models` exposes ids only; the catalog (docs/provider-research/cerebras.md) provides
9 + * limits, pricing and which reasoning knob each model accepts.
10 + */
11 +export function normalizeCerebrasModel(m: { id: string }): PolyModel | null {
12 + const cat = CEREBRAS_CATALOG.get(m.id);
13 + return {
14 + key: modelKey("cerebras", m.id),
15 + id: m.id,
16 + provider: "cerebras",
17 + displayName: cat?.displayName ?? prettify(m.id),
18 + family: cat?.family ?? (m.id.startsWith("gpt-oss") ? "GPT-OSS" : m.id.startsWith("qwen") ? "Qwen" : m.id.startsWith("gemma") ? "Gemma" : m.id.startsWith("llama") ? "Llama" : "Open models"),
19 + capabilities: cat?.capabilities ?? { text: true, vision: !m.id.startsWith("gpt-oss"), audioInput: false, audioOutput: false, imageGeneration: false, video: false, reasoning: true, tools: true, structuredOutput: true, streaming: true, files: false, webSearch: false },
20 + limits: cat?.limits ?? {},
21 + parameters: cat?.parameters ?? { temperature: true, topP: true, maxTokens: true, stop: true, seed: true, frequencyPenalty: true, presencePenalty: true, topK: false, reasoningEffort: true, reasoningEffortLevels: ["low", "medium", "high"], temperatureRange: { min: 0, max: 2 } },
22 + status: cat?.status ?? "active",
23 + pricing: cat?.pricing ?? null,
24 + metadata: { ...(cat?.metadata ?? {}), sortWeight: cat?.sortWeight ?? (m.id.startsWith("gpt-oss") ? 100 : m.id.startsWith("qwen") ? 90 : 80) },
25 + };
26 +}
27 +
28 +function prettify(id: string) {
29 + return id.replace(/gpt-oss/, "GPT-OSS").replace(/qwen/, "Qwen").replace(/gemma/, "Gemma").replace(/llama/, "Llama").replace(/-(\d+)b\b/, " $1B").replace(/-/g, " ");
30 +}
31 +
32 +async function list(client: OpenAI, _k: string, signal?: AbortSignal): Promise<PolyModel[]> {
33 + return (await listOpenAIModels(client, signal)).map(normalizeCerebrasModel).filter((m): m is PolyModel => Boolean(m));
34 +}
35 +
36 +export const cerebrasAdapter = createOpenAICompatAdapter({
37 + id: "cerebras",
38 + name: "Cerebras",
39 + baseURL: "https://api.cerebras.ai/v1",
40 + keyDocsUrl: "https://cloud.cerebras.ai/",
41 + keyPrefixHint: "csk-",
42 + listModels: list,
43 + messageOptions: { inlineFiles: true },
44 + tweakParams: (params, settings, req) => {
45 + const p = params as unknown as Record<string, unknown>;
46 + const meta = (req.modelInfo?.metadata ?? {}) as Record<string, unknown>;
47 + // Cerebras validates strictly: only `reasoning_effort` exists (no disable_reasoning / budget).
48 + if (req.modelInfo?.capabilities.reasoning && settings.reasoningEffort) {
49 + const levels = req.modelInfo.parameters.reasoningEffortLevels ?? ["low", "medium", "high"];
50 + const wanted = settings.reasoningEffort === "minimal" ? "low" : settings.reasoningEffort === "xhigh" || settings.reasoningEffort === "max" ? "high" : settings.reasoningEffort;
51 + if (levels.includes(wanted)) p.reasoning_effort = wanted;
52 + }
53 + // gpt-oss rejects tools + response_format together — tools win, JSON is requested in the prompt instead.
54 + if (meta.toolsWithResponseFormat === false && params.tools?.length && params.response_format) delete p.response_format;
55 + // Reasoning tokens consume the output cap; never leave a reasoning model with a tiny cap.
56 + if (req.modelInfo?.capabilities.reasoning && typeof params.max_completion_tokens === "number" && params.max_completion_tokens < Number(meta.minOutputForReasoning ?? 1000)) params.max_completion_tokens = Number(meta.minOutputForReasoning ?? 1000);
57 + },
58 + refineError: (status, code, message) => {
59 + if (status === 401) return "INVALID_API_KEY";
60 + if (status === 404) return "MODEL_NOT_FOUND";
61 + if (status === 429 || code === "token_quota_exceeded" || /quota/i.test(message)) return "RATE_LIMITED";
62 + if (code === "wrong_api_key" || code === "invalid_api_key") return "INVALID_API_KEY";
63 + if (code === "model_not_found") return "MODEL_NOT_FOUND";
64 + if (code === "context_length_exceeded") return "CONTEXT_TOO_LONG";
65 + return undefined;
66 + },
67 +});
added src/lib/ai/providers/deepseek/catalog.ts +21 −0
@@ -0,0 +1,21 @@
1 +import type { CatalogEntry } from "@/lib/ai/registry/catalog";
2 +import type { ModelCapabilities, ModelParameters } from "@/lib/ai/core/types";
3 +
4 +/**
5 + * DeepSeek documented augmentation — docs/provider-research/deepseek.md (audited 2026-09-08, ~180 probes).
6 + * Verified: all V4 models are hybrid thinkers, ON by default at effort high; controls are
7 + * `thinking:{type:"enabled"|"disabled"}` + top-level `reasoning_effort: low|high|max` (medium → high);
8 + * `max_tokens` caps reasoning + answer together (range 1–393216); `response_format` only `json_object`
9 + * (prompt must mention "json"); temperature/top_p ignored in thinking mode; seed/penalties no-ops;
10 + * only `deepseek-v4-flash-vision-exp` sees images (the others silently drop them).
11 + * Pricing = peak (off-peak is −50 % outside Mon–Fri 01–04 & 06–10 UTC); cache hit = 1/32 of miss.
12 + */
13 +const caps = (vision: boolean): ModelCapabilities => ({ text: true, vision, audioInput: false, audioOutput: false, imageGeneration: false, video: false, reasoning: true, tools: true, structuredOutput: true, streaming: true, files: false, webSearch: false });
14 +const params: ModelParameters = { temperature: true, topP: true, topK: false, maxTokens: true, stop: true, seed: false, frequencyPenalty: false, presencePenalty: false, reasoningEffort: true, reasoningEffortLevels: ["none", "low", "medium", "high", "max"], thinkingBudget: false, temperatureRange: { min: 0, max: 2 } };
15 +const meta = { thinkingParam: "thinking", reasoningEffortParam: true, jsonSchema: false, jsonWordRequired: true, defaultReasoningEffort: "high", minOutputForReasoning: 2000, samplingIgnoredWhenThinking: true };
16 +
17 +export const DEEPSEEK_CATALOG = new Map<string, CatalogEntry>([
18 + ["deepseek-v4-pro", { displayName: "DeepSeek V4 Pro", family: "DeepSeek V4", capabilities: caps(false), limits: { contextTokens: 1_000_000, maxOutputTokens: 393_216 }, parameters: params, pricing: { inputPerMillion: 1.32, cachedInputPerMillion: 0.044, outputPerMillion: 3.96, source: "deepseek-pricing-page (peak)", asOf: "2026-09-08" }, status: "active", sortWeight: 100, metadata: { ...meta, offPeakDiscount: 0.5, concurrency: 500 } }],
19 + ["deepseek-v4-flash", { displayName: "DeepSeek V4 Flash", family: "DeepSeek V4", capabilities: caps(false), limits: { contextTokens: 1_000_000, maxOutputTokens: 393_216 }, parameters: params, pricing: { inputPerMillion: 0.44, cachedInputPerMillion: 0.014, outputPerMillion: 1.32, source: "deepseek-pricing-page (peak)", asOf: "2026-09-08" }, status: "active", sortWeight: 95, metadata: { ...meta, offPeakDiscount: 0.5, concurrency: 2500 } }],
20 + ["deepseek-v4-flash-vision-exp", { displayName: "DeepSeek V4 Flash Vision (experimental)", family: "DeepSeek V4", capabilities: caps(true), limits: { contextTokens: 1_000_000, maxOutputTokens: 393_216 }, parameters: params, pricing: { inputPerMillion: 0.44, cachedInputPerMillion: 0.014, outputPerMillion: 1.32, source: "deepseek-pricing-page (peak)", asOf: "2026-09-08" }, status: "preview", sortWeight: 85, metadata: { ...meta, offPeakDiscount: 0.5, imageTokensMax: 384 } }],
21 +]);
added src/lib/ai/providers/deepseek/index.ts +72 −0
@@ -0,0 +1,72 @@
1 +import type OpenAI from "openai";
2 +import { type PolyModel, modelKey } from "@/lib/ai/core/types";
3 +import { createOpenAICompatAdapter, listOpenAIModels } from "../shared/openai-compat/factory";
4 +import { DEEPSEEK_CATALOG } from "./catalog";
5 +
6 +/**
7 + * DeepSeek — OpenAI-compatible at https://api.deepseek.com. `GET /models` exposes ids only, so the
8 + * catalog (docs/provider-research/deepseek.md) provides limits, pricing and thinking controls.
9 + * Reasoning arrives as `delta.reasoning_content`; cache hits as `prompt_cache_hit_tokens`.
10 + */
11 +export function normalizeDeepSeekModel(m: { id: string }): PolyModel | null {
12 + if (!m.id.startsWith("deepseek")) return null;
13 + const cat = DEEPSEEK_CATALOG.get(m.id);
14 + const vision = m.id.includes("vision");
15 + return {
16 + key: modelKey("deepseek", m.id),
17 + id: m.id,
18 + provider: "deepseek",
19 + displayName: cat?.displayName ?? prettify(m.id),
20 + family: cat?.family ?? "DeepSeek V4",
21 + capabilities: cat?.capabilities ?? { text: true, vision, audioInput: false, audioOutput: false, imageGeneration: false, video: false, reasoning: true, tools: true, structuredOutput: true, streaming: true, files: false, webSearch: false },
22 + limits: cat?.limits ?? {},
23 + parameters: cat?.parameters ?? { temperature: true, topP: true, maxTokens: true, stop: true, frequencyPenalty: true, presencePenalty: true, seed: false, topK: false, reasoningEffort: true, reasoningEffortLevels: ["none", "medium"], temperatureRange: { min: 0, max: 2 } },
24 + status: cat?.status ?? (m.id.includes("exp") ? "preview" : "active"),
25 + pricing: cat?.pricing ?? null,
26 + metadata: { ...(cat?.metadata ?? {}), sortWeight: cat?.sortWeight ?? (m.id.includes("pro") ? 100 : m.id.includes("vision") ? 80 : 90) },
27 + };
28 +}
29 +
30 +function prettify(id: string) {
31 + return id.replace(/^deepseek-/, "DeepSeek ").replace(/-/g, " ").replace(/\bv(\d)/i, "V$1").replace(/\b(\w)/g, (c) => c.toUpperCase()).replace(/Exp$/, "(experimental)");
32 +}
33 +
34 +async function list(client: OpenAI, _k: string, signal?: AbortSignal): Promise<PolyModel[]> {
35 + return (await listOpenAIModels(client, signal)).map(normalizeDeepSeekModel).filter((m): m is PolyModel => Boolean(m));
36 +}
37 +
38 +export const deepseekAdapter = createOpenAICompatAdapter({
39 + id: "deepseek",
40 + name: "DeepSeek",
41 + baseURL: "https://api.deepseek.com/v1",
42 + keyDocsUrl: "https://platform.deepseek.com/api_keys",
43 + keyPrefixHint: "sk-",
44 + listModels: list,
45 + useMaxTokens: true,
46 + messageOptions: { inlineFiles: true, replayReasoningContent: true },
47 + tweakParams: (params, settings, req) => {
48 + const p = params as unknown as Record<string, unknown>;
49 + const meta = (req.modelInfo?.metadata ?? {}) as Record<string, unknown>;
50 + if (req.modelInfo?.capabilities.reasoning) {
51 + const effort = settings.reasoningEffort;
52 + if (effort === "none") p.thinking = { type: "disabled" };
53 + else if (effort) {
54 + p.thinking = { type: "enabled" };
55 + // DeepSeek accepts low | high | max (medium/xhigh are treated as high).
56 + p.reasoning_effort = effort === "minimal" || effort === "low" ? "low" : effort === "max" ? "max" : "high";
57 + }
58 + // Reasoning consumes `max_tokens`; a tiny cap yields an empty answer with finish_reason "length".
59 + if (effort !== "none" && typeof params.max_tokens === "number" && params.max_tokens < Number(meta.minOutputForReasoning ?? 2000)) params.max_tokens = Number(meta.minOutputForReasoning ?? 2000);
60 + }
61 + delete p.seed; // silently ignored — never pretend determinism
62 + },
63 + refineError: (status, code, message) => {
64 + if (status === 401) return "INVALID_API_KEY";
65 + if (status === 402) return "INSUFFICIENT_CREDITS";
66 + if (status === 422) return "INVALID_PARAMETER";
67 + if (status === 503) return "PROVIDER_UNAVAILABLE";
68 + if (status === 400 && /supported API model names|model/i.test(message) && /model/i.test(message)) return "MODEL_NOT_FOUND";
69 + void code;
70 + return undefined;
71 + },
72 +});
added src/lib/ai/providers/env-keys.ts +19 −0
@@ -0,0 +1,19 @@
1 +import type { ProviderId } from "@/lib/ai/core/types";
2 +
3 +/** Environment variable holding the OWNER key for each provider (registry sync, admin diagnostics, tests only). */
4 +export const OWNER_KEY_ENV: Record<ProviderId, string> = {
5 + openai: "OPENAI_API_KEY",
6 + anthropic: "ANTHROPIC_API_KEY",
7 + gemini: "GOOGLE_GEMINI_API_KEY",
8 + xai: "XAI_API_KEY",
9 + mistral: "MISTRAL_API_KEY",
10 + deepseek: "DEEPSEEK_API_KEY",
11 + kimi: "KIMI_API_KEY",
12 + openrouter: "OPENROUTER_API_KEY",
13 + cerebras: "CEREBRAS_API_KEY",
14 +};
15 +
16 +export function ownerKey(provider: ProviderId): string | undefined {
17 + const v = process.env[OWNER_KEY_ENV[provider]];
18 + return v && v.trim() ? v.trim() : undefined;
19 +}
modified src/lib/ai/providers/index.ts +15 −0
@@ -3,12 +3,22 @@ import { anthropicAdapter } from "./anthropic";
3 3 import { openaiAdapter } from "./openai";
4 4 import { geminiAdapter } from "./gemini";
5 5 import { xaiAdapter } from "./xai";
6 +import { mistralAdapter } from "./mistral";
7 +import { deepseekAdapter } from "./deepseek";
8 +import { kimiAdapter } from "./kimi";
9 +import { openrouterAdapter } from "./openrouter";
10 +import { cerebrasAdapter } from "./cerebras";
6 11
7 12 const ADAPTERS: Record<ProviderId, AIProviderAdapter> = {
8 13 openai: openaiAdapter,
9 14 anthropic: anthropicAdapter,
10 15 gemini: geminiAdapter,
11 16 xai: xaiAdapter,
17 + mistral: mistralAdapter,
18 + deepseek: deepseekAdapter,
19 + kimi: kimiAdapter,
20 + openrouter: openrouterAdapter,
21 + cerebras: cerebrasAdapter,
12 22 };
13 23
14 24 export function getAdapter(provider: ProviderId): AIProviderAdapter {
@@ -59,4 +69,9 @@ export const PROVIDER_META: Record<ProviderId, { name: string; shortName: string
59 69 description: "Grok 4.x reasoning and non-reasoning models with live search.",
60 70 consoleUrl: "https://console.x.ai",
61 71 },
72 + mistral: { name: "Mistral AI", shortName: "Mistral", keyDocsUrl: "https://console.mistral.ai/api-keys", keyPrefixHint: "", color: "var(--p-mistral)", description: "Mistral Large / Medium / Small, Ministral, Magistral, Codestral.", consoleUrl: "https://console.mistral.ai" },
73 + deepseek: { name: "DeepSeek", shortName: "DeepSeek", keyDocsUrl: "https://platform.deepseek.com/api_keys", keyPrefixHint: "sk-", color: "var(--p-deepseek)", description: "DeepSeek V4 Flash / Pro with thinking mode.", consoleUrl: "https://platform.deepseek.com" },
74 + kimi: { name: "Kimi (Moonshot AI)", shortName: "Kimi", keyDocsUrl: "https://platform.moonshot.ai/console/api-keys", keyPrefixHint: "sk-", color: "var(--p-kimi)", description: "Kimi K2.6 / K2.7 Code / K3.", consoleUrl: "https://platform.moonshot.ai" },
75 + openrouter: { name: "OpenRouter", shortName: "OpenRouter", keyDocsUrl: "https://openrouter.ai/settings/keys", keyPrefixHint: "sk-or-v1-", color: "var(--p-openrouter)", description: "Hundreds of models behind one key.", consoleUrl: "https://openrouter.ai" },
76 + cerebras: { name: "Cerebras", shortName: "Cerebras", keyDocsUrl: "https://cloud.cerebras.ai/", keyPrefixHint: "csk-", color: "var(--p-cerebras)", description: "Ultra-fast inference for open models.", consoleUrl: "https://cloud.cerebras.ai" },
62 77 };
added src/lib/ai/providers/kimi/catalog.ts +4 −0
@@ -0,0 +1,4 @@
1 +import type { CatalogEntry } from "@/lib/ai/registry/catalog";
2 +
3 +/** Kimi documented augmentation — filled from docs/provider-research/kimi.md (2026-09-08). */
4 +export const KIMI_CATALOG = new Map<string, CatalogEntry>();
added src/lib/ai/providers/kimi/index.ts +141 −0
@@ -0,0 +1,141 @@
1 +import type OpenAI from "openai";
2 +import { type PolyModel, modelKey } from "@/lib/ai/core/types";
3 +import { createOpenAICompatAdapter } from "../shared/openai-compat/factory";
4 +import { KIMI_CATALOG } from "./catalog";
5 +
6 +/**
7 + * Kimi (Moonshot AI, international platform) — OpenAI-compatible at https://api.moonshot.ai/v1.
8 + * `GET /v1/models/{id}` exposes `supports_image_in`, `supports_video_in`, `supports_reasoning`,
9 + * `think_efforts.valid_efforts`, `supports_thinking_type` ("only" = cannot be disabled) and
10 + * `context_length` — used live; pricing comes from the catalog (docs/provider-research/kimi.md).
11 + * Verified quirks: temperature must be 1 and top_p 0.95 (400 otherwise), thinking streams as
12 + * `delta.reasoning_content` and must be replayed on assistant turns, `json_schema` is not enforced
13 + * (json_object + prompt instead).
14 + */
15 +interface KimiModel {
16 + id: string;
17 + created?: number;
18 + context_length?: number;
19 + supports_image_in?: boolean;
20 + supports_video_in?: boolean;
21 + supports_reasoning?: boolean;
22 + supports_dynamic_tools?: boolean;
23 + think_efforts?: { support?: boolean; valid_efforts?: string[]; default_effort?: string };
24 + reasoning_efforts?: { support?: boolean; valid_efforts?: string[]; default_effort?: string };
25 + supports_thinking_type?: string; // "only" | "optional" | …
26 +}
27 +
28 +export function normalizeKimiModel(m: KimiModel): PolyModel | null {
29 + if (!m.id.startsWith("kimi") && !m.id.startsWith("moonshot")) return null;
30 + const cat = KIMI_CATALOG.get(m.id);
31 + const reasoning = m.supports_reasoning ?? true;
32 + const efforts = m.reasoning_efforts?.valid_efforts ?? m.think_efforts?.valid_efforts ?? ["low", "high", "max"];
33 + const alwaysThinking = m.supports_thinking_type === "only";
34 + return {
35 + key: modelKey("kimi", m.id),
36 + id: m.id,
37 + provider: "kimi",
38 + displayName: cat?.displayName ?? prettify(m.id),
39 + family: cat?.family ?? (m.id.startsWith("kimi-k3") ? "Kimi K3" : "Kimi K2"),
40 + capabilities: {
41 + text: true,
42 + vision: m.supports_image_in ?? true,
43 + audioInput: false,
44 + audioOutput: false,
45 + imageGeneration: false,
46 + video: m.supports_video_in ?? false,
47 + reasoning,
48 + tools: true,
49 + structuredOutput: true,
50 + streaming: true,
51 + files: false,
52 + webSearch: false, // `$web_search` builtin exists but its round trip is not stable enough to expose yet
53 + ...(cat?.capabilityOverrides ?? {}),
54 + },
55 + limits: { contextTokens: m.context_length ?? cat?.limits?.contextTokens, maxOutputTokens: cat?.limits?.maxOutputTokens },
56 + parameters: {
57 + temperature: false, // only 1 accepted
58 + topP: false, // only 0.95 accepted
59 + topK: false,
60 + maxTokens: true,
61 + stop: true,
62 + seed: false,
63 + frequencyPenalty: true,
64 + presencePenalty: true,
65 + reasoningEffort: reasoning,
66 + reasoningEffortLevels: reasoning ? [...(alwaysThinking ? [] : ["none"]), ...efforts] : undefined,
67 + thinkingBudget: false,
68 + ...(cat?.parameters ?? {}),
69 + },
70 + status: cat?.status ?? "active",
71 + pricing: cat?.pricing ?? null,
72 + metadata: {
73 + ...(cat?.metadata ?? {}),
74 + jsonSchema: false,
75 + jsonWordRequired: true,
76 + alwaysThinking,
77 + defaultReasoningEffort: m.reasoning_efforts?.default_effort ?? m.think_efforts?.default_effort,
78 + dynamicTools: m.supports_dynamic_tools,
79 + createdAt: m.created ? new Date(m.created * 1000).toISOString() : undefined,
80 + sortWeight: cat?.sortWeight ?? (m.id.startsWith("kimi-k3") ? 100 : m.id.includes("2.7-code-highspeed") ? 88 : m.id.includes("2.7") ? 90 : 85),
81 + },
82 + };
83 +}
84 +
85 +function prettify(id: string) {
86 + return id.replace(/^kimi-/, "Kimi ").replace(/-code/, " Code").replace(/-highspeed/, " High-speed").replace(/\bk(\d)/, "K$1");
87 +}
88 +
89 +async function list(_client: OpenAI, apiKey: string, signal?: AbortSignal): Promise<PolyModel[]> {
90 + const headers = { Authorization: `Bearer ${apiKey}` };
91 + const res = await fetch("https://api.moonshot.ai/v1/models", { headers, signal });
92 + if (!res.ok) {
93 + const body = (await res.json().catch(() => ({}))) as { error?: { message?: string; type?: string } };
94 + throw Object.assign(new Error(body.error?.message ?? `HTTP ${res.status}`), { status: res.status, error: body, headers: res.headers });
95 + }
96 + const data = (await res.json()) as { data: { id: string }[] };
97 + const details = await Promise.all(
98 + data.data.map(async (m) => {
99 + const r = await fetch(`https://api.moonshot.ai/v1/models/${encodeURIComponent(m.id)}`, { headers, signal }).catch(() => null);
100 + return r && r.ok ? ((await r.json()) as KimiModel) : ({ id: m.id } as KimiModel);
101 + }),
102 + );
103 + return details.map(normalizeKimiModel).filter((m): m is PolyModel => Boolean(m));
104 +}
105 +
106 +export const kimiAdapter = createOpenAICompatAdapter({
107 + id: "kimi",
108 + name: "Kimi (Moonshot AI)",
109 + baseURL: "https://api.moonshot.ai/v1",
110 + keyDocsUrl: "https://platform.moonshot.ai/console/api-keys",
111 + keyPrefixHint: "sk-",
112 + listModels: list,
113 + useMaxTokens: true,
114 + messageOptions: { inlineFiles: true, replayReasoningContent: true },
115 + tweakParams: (params, settings, req) => {
116 + const p = params as unknown as Record<string, unknown>;
117 + const meta = (req.modelInfo?.metadata ?? {}) as Record<string, unknown>;
118 + delete p.temperature; // only the default (1) is accepted
119 + delete p.top_p; // only 0.95 is accepted
120 + delete p.seed;
121 + if (req.modelInfo?.capabilities.reasoning && settings.reasoningEffort) {
122 + const e = settings.reasoningEffort;
123 + if (e === "none" && meta.alwaysThinking !== true) p.thinking = { type: "disabled" };
124 + else {
125 + const levels = (req.modelInfo.parameters.reasoningEffortLevels ?? ["low", "high", "max"]).filter((l) => l !== "none");
126 + const wanted = e === "minimal" ? "low" : e === "medium" ? (levels.includes("medium") ? "medium" : "low") : e === "xhigh" ? (levels.includes("xhigh") ? "xhigh" : "high") : e === "none" ? levels[0] : e;
127 + p.thinking = { type: "enabled" };
128 + p.reasoning_effort = levels.includes(wanted) ? wanted : levels[levels.length - 1];
129 + }
130 + }
131 + },
132 + refineError: (status, code, message) => {
133 + if (status === 401 || code === "invalid_authentication_error") return "INVALID_API_KEY";
134 + if (status === 403 && /balance|quota|insufficient/i.test(message)) return "INSUFFICIENT_CREDITS";
135 + if (status === 404) return "MODEL_NOT_FOUND";
136 + if (code === "exceeded_current_quota_error" || code === "rate_limit_reached_error" || code === "engine_overloaded_error") return "RATE_LIMITED";
137 + if (code === "content_filter") return "CONTENT_REJECTED";
138 + if (/only 1 is allowed|only 0.95 is allowed/i.test(message)) return "INVALID_PARAMETER";
139 + return undefined;
140 + },
141 +});
added src/lib/ai/providers/mistral/catalog.ts +56 −0
@@ -0,0 +1,56 @@
1 +import type { CatalogEntry } from "@/lib/ai/registry/catalog";
2 +import type { ModelPricing } from "@/lib/ai/core/types";
3 +
4 +/**
5 + * Mistral documented augmentation. The listing already carries capabilities/limits/aliases; this
6 + * file adds pricing (mistral.ai/pricing, audited 2026-09-08 — see docs/provider-research/mistral.md)
7 + * and per-model quirks. Keys are billing model names (the `name` field) or ids.
8 + */
9 +const MISTRAL_PRICING_BASE: Record<string, ModelPricing> = {
10 + "mistral-medium-2604": { inputPerMillion: 1.5, outputPerMillion: 7.5, cachedInputPerMillion: 0.15 },
11 + "mistral-small-2603": { inputPerMillion: 0.15, outputPerMillion: 0.6, cachedInputPerMillion: 0.015 },
12 + "mistral-large-2512": { inputPerMillion: 0.5, outputPerMillion: 1.5, cachedInputPerMillion: 0.05 },
13 + "ministral-14b-2512": { inputPerMillion: 0.2, outputPerMillion: 0.2, cachedInputPerMillion: 0.02 },
14 + "ministral-8b-2512": { inputPerMillion: 0.15, outputPerMillion: 0.15, cachedInputPerMillion: 0.015 },
15 + "ministral-3b-2512": { inputPerMillion: 0.1, outputPerMillion: 0.1, cachedInputPerMillion: 0.01 },
16 + "codestral-2508": { inputPerMillion: 0.3, outputPerMillion: 0.9, cachedInputPerMillion: 0.03 },
17 + "zai-glm-5-2": { inputPerMillion: 1.4, outputPerMillion: 4.4, cachedInputPerMillion: 0.14 },
18 + "voxtral-small-2507": { inputPerMillion: 0.1, outputPerMillion: 0.4 },
19 + "labs-leanstral-1-5": { inputPerMillion: 0, outputPerMillion: 0, cachedInputPerMillion: 0 },
20 +};
21 +
22 +/** Aliases (`*-latest`, marketing names) bill as their base model. */
23 +export const MISTRAL_PRICING: Record<string, ModelPricing> = {
24 + ...MISTRAL_PRICING_BASE,
25 + "mistral-medium-latest": MISTRAL_PRICING_BASE["mistral-medium-2604"],
26 + "mistral-medium": MISTRAL_PRICING_BASE["mistral-medium-2604"],
27 + "mistral-medium-3-5": MISTRAL_PRICING_BASE["mistral-medium-2604"],
28 + "mistral-medium-3.5": MISTRAL_PRICING_BASE["mistral-medium-2604"],
29 + "mistral-medium-3": MISTRAL_PRICING_BASE["mistral-medium-2604"],
30 + "mistral-vibe-cli-latest": MISTRAL_PRICING_BASE["mistral-medium-2604"],
31 + "mistral-vibe-cli-with-tools": MISTRAL_PRICING_BASE["mistral-medium-2604"],
32 + "magistral-medium-latest": MISTRAL_PRICING_BASE["mistral-medium-2604"],
33 + "mistral-small-latest": MISTRAL_PRICING_BASE["mistral-small-2603"],
34 + "mistral-vibe-cli-fast": MISTRAL_PRICING_BASE["mistral-small-2603"],
35 + "magistral-small-latest": MISTRAL_PRICING_BASE["mistral-small-2603"],
36 + "mistral-large-latest": MISTRAL_PRICING_BASE["mistral-large-2512"],
37 + "ministral-14b-latest": MISTRAL_PRICING_BASE["ministral-14b-2512"],
38 + "ministral-8b-latest": MISTRAL_PRICING_BASE["ministral-8b-2512"],
39 + "ministral-3b-latest": MISTRAL_PRICING_BASE["ministral-3b-2512"],
40 + "codestral-latest": MISTRAL_PRICING_BASE["codestral-2508"],
41 + "mistral-code-latest": MISTRAL_PRICING_BASE["codestral-2508"],
42 + "mistral-code-fim-latest": MISTRAL_PRICING_BASE["codestral-2508"],
43 + "glm-5-2": MISTRAL_PRICING_BASE["zai-glm-5-2"],
44 + "voxtral-small-latest": MISTRAL_PRICING_BASE["voxtral-small-2507"],
45 + "labs-leanstral-1-5-1": MISTRAL_PRICING_BASE["labs-leanstral-1-5"],
46 +};
47 +
48 +export function mistralPricingFor(id: string, name?: string): ModelPricing | null {
49 + const hit = MISTRAL_PRICING[id] ?? (name ? MISTRAL_PRICING[name] : undefined) ?? MISTRAL_PRICING[id.replace(/-latest$/, "")];
50 + return hit ? { ...hit, source: "mistral-pricing-page", asOf: "2026-09-08" } : null;
51 +}
52 +
53 +export const MISTRAL_CATALOG = new Map<string, CatalogEntry>();
54 +
55 +/** Listed by /v1/models but not chat models (OCR, embeddings, moderation, audio, FIM-only). */
56 +export const MISTRAL_NON_CHAT = /embed|ocr|moderation|voxtral|transcribe|tts|fim/i;
added src/lib/ai/providers/mistral/index.ts +165 −0
@@ -0,0 +1,165 @@
1 +import type OpenAI from "openai";
2 +import { type PolyModel, modelKey } from "@/lib/ai/core/types";
3 +import { createOpenAICompatAdapter } from "../shared/openai-compat/factory";
4 +import { MISTRAL_CATALOG, mistralPricingFor, MISTRAL_NON_CHAT } from "./catalog";
5 +
6 +/**
7 + * Mistral AI (La Plateforme) — `https://api.mistral.ai/v1/chat/completions` is OpenAI-shaped.
8 + * `GET /v1/models` returns rich capability metadata (completion_chat, function_calling, vision,
9 + * reasoning, max_context_length, aliases, deprecation) which drives the registry.
10 + * Quirks (docs/provider-research/mistral.md): `random_seed` instead of `seed`, `max_tokens`,
11 + * tool_choice `any`, Magistral reasoning streamed as `thinking` content chunks, `safe_prompt`.
12 + */
13 +interface MistralModel {
14 + id: string;
15 + name?: string;
16 + description?: string;
17 + created?: number;
18 + max_context_length?: number;
19 + aliases?: string[];
20 + deprecation?: string | null;
21 + deprecation_replacement_model?: string | null;
22 + default_model_temperature?: number | null;
23 + type?: string;
24 + capabilities?: { completion_chat?: boolean; function_calling?: boolean; vision?: boolean; reasoning?: boolean; completion_fim?: boolean; ocr?: boolean; audio?: boolean };
25 +}
26 +
27 +export function normalizeMistralModel(m: MistralModel): PolyModel | null {
28 + const caps = m.capabilities ?? {};
29 + if (!caps.completion_chat) return null;
30 + if (MISTRAL_NON_CHAT.test(m.id)) return null;
31 + const cat = MISTRAL_CATALOG.get(m.id) ?? MISTRAL_CATALOG.get(m.name ?? "");
32 + const reasoning = caps.reasoning === true;
33 + const deprecated = Boolean(m.deprecation) && new Date(m.deprecation!).getTime() < Date.now();
34 + const isAlias = m.name && m.name !== m.id;
35 + return {
36 + key: modelKey("mistral", m.id),
37 + id: m.id,
38 + provider: "mistral",
39 + displayName: cat?.displayName ?? prettyMistral(m.id, m.name),
40 + family: cat?.family ?? familyOf(m.id),
41 + capabilities: {
42 + text: true,
43 + vision: caps.vision === true,
44 + audioInput: caps.audio === true,
45 + audioOutput: false,
46 + imageGeneration: false,
47 + video: false,
48 + reasoning,
49 + tools: caps.function_calling === true,
50 + structuredOutput: true,
51 + streaming: true,
52 + files: true, // PDF via `document_url` data URI works on every chat model (probed)
53 + webSearch: false,
54 + ...(cat?.capabilityOverrides ?? {}),
55 + },
56 + limits: { contextTokens: m.max_context_length ?? cat?.limits?.contextTokens, maxOutputTokens: cat?.limits?.maxOutputTokens },
57 + parameters: {
58 + temperature: true,
59 + topP: true,
60 + topK: false,
61 + maxTokens: true,
62 + stop: true,
63 + seed: true,
64 + frequencyPenalty: true,
65 + presencePenalty: true,
66 + reasoningEffort: reasoning,
67 + reasoningEffortLevels: reasoning ? (m.id.includes("glm") ? ["none", "minimal", "low", "medium", "high", "xhigh", "max"] : ["none", "high"]) : undefined,
68 + thinkingBudget: false,
69 + temperatureRange: { min: 0, max: 1.5 },
70 + ...(cat?.parameters ?? {}),
71 + },
72 + status: deprecated ? "deprecated" : m.deprecation ? "deprecated" : cat?.status ?? (m.id.startsWith("labs-") ? "preview" : "active"),
73 + pricing: mistralPricingFor(m.id, m.name),
74 + metadata: {
75 + ...(cat?.metadata ?? {}),
76 + aliases: m.aliases ?? [],
77 + resolvesTo: isAlias ? m.name : undefined,
78 + description: m.description,
79 + defaultTemperature: m.default_model_temperature,
80 + deprecation: m.deprecation,
81 + replacement: m.deprecation_replacement_model,
82 + createdAt: m.created ? new Date(m.created * 1000).toISOString() : undefined,
83 + sortWeight: cat?.sortWeight ?? sortWeightOf(m.id),
84 + },
85 + };
86 +}
87 +
88 +function familyOf(id: string): string {
89 + if (id.startsWith("magistral")) return "Magistral";
90 + if (id.startsWith("codestral") || id.startsWith("mistral-code")) return "Codestral";
91 + if (id.startsWith("ministral")) return "Ministral";
92 + if (id.startsWith("mistral-large")) return "Mistral Large";
93 + if (id.startsWith("mistral-medium")) return "Mistral Medium";
94 + if (id.startsWith("mistral-small")) return "Mistral Small";
95 + if (id.includes("glm")) return "GLM (hosted)";
96 + if (id.startsWith("labs-")) return "Labs";
97 + return "Mistral";
98 +}
99 +function sortWeightOf(id: string): number {
100 + if (id === "mistral-large-latest") return 100;
101 + if (id === "mistral-medium-latest") return 96;
102 + if (id === "magistral-medium-latest") return 94;
103 + if (id === "mistral-small-latest") return 90;
104 + if (id === "magistral-small-latest") return 88;
105 + if (id === "codestral-latest") return 84;
106 + if (id.startsWith("ministral-14b-latest")) return 80;
107 + if (id.startsWith("ministral-8b-latest")) return 78;
108 + if (id.startsWith("ministral-3b-latest")) return 76;
109 + if (id.endsWith("-latest")) return 60;
110 + return 30;
111 +}
112 +function prettyMistral(id: string, name?: string): string {
113 + const base = id.replace(/-latest$/, "");
114 + const words = base.split("-").map((w) => (/^\d/.test(w) ? w : w.charAt(0).toUpperCase() + w.slice(1)));
115 + const pretty = words.join(" ").replace(/\b(\d+)b\b/i, "$1B");
116 + return id.endsWith("-latest") ? `${pretty} (latest${name && name !== id ? ` → ${name}` : ""})` : pretty;
117 +}
118 +
119 +async function listMistral(client: OpenAI, apiKey: string, signal?: AbortSignal): Promise<PolyModel[]> {
120 + void client;
121 + const res = await fetch("https://api.mistral.ai/v1/models", { headers: { Authorization: `Bearer ${apiKey}` }, signal });
122 + if (!res.ok) {
123 + const body = (await res.json().catch(() => ({}))) as { message?: string; detail?: unknown };
124 + throw Object.assign(new Error(body.message ?? `HTTP ${res.status}`), { status: res.status, error: body, headers: res.headers });
125 + }
126 + const data = (await res.json()) as { data: MistralModel[] };
127 + return data.data.map(normalizeMistralModel).filter((m): m is PolyModel => Boolean(m));
128 +}
129 +
130 +export const mistralAdapter = createOpenAICompatAdapter({
131 + id: "mistral",
132 + name: "Mistral AI",
133 + baseURL: "https://api.mistral.ai/v1",
134 + keyDocsUrl: "https://console.mistral.ai/api-keys",
135 + listModels: listMistral,
136 + useMaxTokens: true,
137 + // PDFs go in natively as `document_url` data URIs (probed OK, even on Codestral).
138 + messageOptions: { inlineFiles: true, pdfPart: (data) => ({ type: "document_url", document_url: `data:application/pdf;base64,${data}` }) as unknown as import("openai/resources/chat/completions").ChatCompletionContentPart },
139 + tweakParams: (params, settings, req) => {
140 + const p = params as unknown as Record<string, unknown>;
141 + // Mistral validates bodies strictly (422 on unknown keys): rename `seed` → `random_seed`, never send `max_completion_tokens`.
142 + if (params.seed !== undefined) {
143 + p.random_seed = params.seed;
144 + delete p.seed;
145 + }
146 + // tool_choice "required" is spelled "any" on Mistral.
147 + if (params.tool_choice === "required") p.tool_choice = "any";
148 + // Hybrid reasoning (Mistral Medium 3.5 / Small 4 / Magistral aliases): off by default, `reasoning_effort: "high"`
149 + // turns it on and `"none"` keeps it off (docs/provider-research/mistral.md). Third-party GLM accepts the full ladder.
150 + if (req.modelInfo?.capabilities.reasoning && settings.reasoningEffort) {
151 + const levels = req.modelInfo.parameters.reasoningEffortLevels ?? ["none", "high"];
152 + const e = settings.reasoningEffort;
153 + p.reasoning_effort = levels.includes(e) ? e : e === "minimal" ? (levels.includes("low") ? "low" : "none") : levels.includes("high") ? "high" : levels[levels.length - 1];
154 + }
155 + delete p.stream_options; // Mistral returns usage on the final chunk without it
156 + },
157 + streamUsage: false,
158 + refineError: (status, code, message) => {
159 + if (status === 401) return "INVALID_API_KEY";
160 + if (status === 422 && /model|not found/i.test(message)) return "MODEL_NOT_FOUND";
161 + if (status === 422) return "INVALID_PARAMETER";
162 + if (status === 429 && /quota|capacity|tier/i.test(message)) return "RATE_LIMITED";
163 + return undefined;
164 + },
165 +});
added src/lib/ai/providers/openrouter/index.ts +202 −0
@@ -0,0 +1,202 @@
1 +import type OpenAI from "openai";
2 +import { type PolyModel, type ModelPricing, modelKey } from "@/lib/ai/core/types";
3 +import { createOpenAICompatAdapter } from "../shared/openai-compat/factory";
4 +
5 +/**
6 + * OpenRouter — OpenAI-compatible gateway to hundreds of models. The registry is built entirely
7 + * from `GET /api/v1/models`, whose metadata (pricing per token, context, modalities,
8 + * supported_parameters, reasoning) is authoritative for each underlying model.
9 + * Quirks (docs/provider-research/openrouter.md): `usage: {include:true}` → exact `usage.cost`,
10 + * `reasoning: {effort|max_tokens|exclude}`, streamed `delta.reasoning`, web search via
11 + * `plugins:[{id:"web"}]`, attribution headers, 402 = insufficient credits.
12 + */
13 +export interface OpenRouterModel {
14 + id: string;
15 + canonical_slug?: string;
16 + name: string;
17 + description?: string;
18 + created?: number;
19 + context_length?: number | null;
20 + architecture?: { modality?: string; input_modalities?: string[]; output_modalities?: string[]; tokenizer?: string };
21 + pricing?: { prompt?: string; completion?: string; request?: string; image?: string; web_search?: string; input_cache_read?: string; input_cache_write?: string; overrides?: { min_prompt_tokens?: number; prompt?: string; completion?: string; input_cache_read?: string }[] };
22 + top_provider?: { context_length?: number | null; max_completion_tokens?: number | null; is_moderated?: boolean };
23 + supported_parameters?: string[];
24 + default_parameters?: Record<string, unknown>;
25 + expiration_date?: string | null;
26 + knowledge_cutoff?: string | null;
27 + reasoning?: { mandatory?: boolean; default_enabled?: boolean; supported_efforts?: string[]; default_effort?: string } | null;
28 +}
29 +
30 +const perMillion = (v?: string | null): number | undefined => {
31 + if (v === undefined || v === null) return undefined;
32 + const n = Number(v);
33 + return Number.isFinite(n) ? Math.round(n * 1_000_000 * 1e6) / 1e6 : undefined;
34 +};
35 +
36 +export function normalizeOpenRouterModel(m: OpenRouterModel): PolyModel | null {
37 + const out = m.architecture?.output_modalities ?? ["text"];
38 + if (!out.includes("text") || out.includes("image") || out.includes("audio")) return null; // image/audio generators are not chat models
39 + if (m.expiration_date && new Date(m.expiration_date).getTime() < Date.now()) return null;
40 + if (/embedding|embed-|moderation|tts|whisper/i.test(m.id)) return null;
41 + if (m.id.endsWith(":batch")) return null; // Batch API only — 404 on chat completions
42 + if (m.id.startsWith("~")) return null; // `~vendor/x-latest` aliases fold into their targets
43 + if (m.pricing?.prompt === "-1" && m.id !== "openrouter/auto") return null; // routers without a price
44 + const sp = new Set(m.supported_parameters ?? []);
45 + const inputs = new Set(m.architecture?.input_modalities ?? ["text"]);
46 + const reasoningInfo = m.reasoning ?? null;
47 + const reasoning = Boolean(reasoningInfo) || sp.has("reasoning") || sp.has("include_reasoning");
48 + const efforts = reasoningInfo?.supported_efforts?.length ? [...reasoningInfo.supported_efforts].reverse() : sp.has("reasoning_effort") || sp.has("reasoning") ? ["low", "medium", "high"] : undefined;
49 + const free = m.pricing?.prompt === "0" && m.pricing?.completion === "0";
50 + const pricing: ModelPricing | null = m.pricing
51 + ? {
52 + inputPerMillion: perMillion(m.pricing.prompt),
53 + outputPerMillion: perMillion(m.pricing.completion),
54 + cachedInputPerMillion: perMillion(m.pricing.input_cache_read),
55 + longContext: m.pricing.overrides?.[0]?.min_prompt_tokens ? { thresholdTokens: m.pricing.overrides[0].min_prompt_tokens!, inputPerMillion: perMillion(m.pricing.overrides[0].prompt), outputPerMillion: perMillion(m.pricing.overrides[0].completion), cachedInputPerMillion: perMillion(m.pricing.overrides[0].input_cache_read) } : undefined,
56 + source: "openrouter:/api/v1/models",
57 + asOf: new Date().toISOString().slice(0, 10),
58 + }
59 + : null;
60 + const vendor = m.id.split("/")[0];
61 + return {
62 + key: modelKey("openrouter", m.id),
63 + id: m.id,
64 + provider: "openrouter",
65 + displayName: m.name,
66 + family: vendorName(vendor),
67 + capabilities: {
68 + text: true,
69 + vision: inputs.has("image"),
70 + audioInput: inputs.has("audio"),
71 + audioOutput: false,
72 + imageGeneration: false,
73 + video: inputs.has("video"),
74 + reasoning,
75 + tools: sp.has("tools"),
76 + structuredOutput: sp.has("structured_outputs") || sp.has("response_format"),
77 + streaming: true,
78 + files: inputs.has("file"),
79 + webSearch: true, // web plugin is available on every model (billed per request)
80 + },
81 + limits: { contextTokens: m.context_length ?? m.top_provider?.context_length ?? undefined, maxOutputTokens: m.top_provider?.max_completion_tokens ?? undefined },
82 + parameters: {
83 + temperature: sp.has("temperature"),
84 + topP: sp.has("top_p"),
85 + topK: sp.has("top_k"),
86 + maxTokens: sp.has("max_tokens") || sp.has("max_completion_tokens"),
87 + stop: sp.has("stop"),
88 + seed: sp.has("seed"),
89 + frequencyPenalty: sp.has("frequency_penalty"),
90 + presencePenalty: sp.has("presence_penalty"),
91 + verbosity: sp.has("verbosity"),
92 + reasoningEffort: Boolean(efforts?.length),
93 + reasoningEffortLevels: efforts ? [...(reasoningInfo?.mandatory ? [] : ["none"]), ...efforts] : undefined,
94 + thinkingBudget: reasoning && !reasoningInfo?.supported_efforts?.length,
95 + thinkingBudgetRange: reasoning && !reasoningInfo?.supported_efforts?.length ? { min: 1024, max: 32_000 } : undefined,
96 + temperatureRange: { min: 0, max: 2 },
97 + },
98 + status: m.id.endsWith(":free") || free ? "active" : /preview|beta|exp/i.test(m.id) ? "preview" : "active",
99 + pricing,
100 + metadata: {
101 + vendor,
102 + canonicalSlug: m.canonical_slug,
103 + description: m.description?.slice(0, 300),
104 + supportedParameters: m.supported_parameters,
105 + reasoningInfo,
106 + free,
107 + moderated: m.top_provider?.is_moderated,
108 + knowledgeCutoff: m.knowledge_cutoff,
109 + expirationDate: m.expiration_date,
110 + createdAt: m.created ? new Date(m.created * 1000).toISOString() : undefined,
111 + sortWeight: sortWeightOf(m, free),
112 + },
113 + };
114 +}
115 +
116 +function vendorName(v: string): string {
117 + const map: Record<string, string> = { openai: "OpenAI", anthropic: "Anthropic", google: "Google", "x-ai": "xAI", "meta-llama": "Meta", mistralai: "Mistral", deepseek: "DeepSeek", moonshotai: "Moonshot", qwen: "Qwen", cohere: "Cohere", perplexity: "Perplexity", nvidia: "NVIDIA", microsoft: "Microsoft", amazon: "Amazon", "z-ai": "Z.ai", minimax: "MiniMax", openrouter: "OpenRouter" };
118 + return map[v] ?? v.charAt(0).toUpperCase() + v.slice(1);
119 +}
120 +function sortWeightOf(m: OpenRouterModel, free: boolean): number {
121 + // newest first within the gateway, big vendors slightly boosted, free variants last
122 + const age = m.created ? Math.max(0, (Date.now() / 1000 - m.created) / 86_400) : 3650;
123 + // Aggregator entries rank below native provider catalogs (-30) so "gpt-5.5" resolves to OpenAI first.
124 + let w = Math.round(70 - Math.min(60, age / 10));
125 + if (/^(openai|anthropic|google|x-ai)\//.test(m.id)) w += 5;
126 + if (free) w -= 20;
127 + return w;
128 +}
129 +
130 +async function listOpenRouter(_client: OpenAI, apiKey: string, signal?: AbortSignal): Promise<PolyModel[]> {
131 + // `/models/user` honours the account's ignored-model settings; fall back to the public listing.
132 + let res = await fetch("https://openrouter.ai/api/v1/models/user", { headers: { Authorization: `Bearer ${apiKey}` }, signal });
133 + if (!res.ok && res.status !== 401 && res.status !== 402) res = await fetch("https://openrouter.ai/api/v1/models", { headers: { Authorization: `Bearer ${apiKey}` }, signal });
134 + if (!res.ok) {
135 + const body = (await res.json().catch(() => ({}))) as { error?: { message?: string; code?: number } };
136 + throw Object.assign(new Error(body.error?.message ?? `HTTP ${res.status}`), { status: res.status, error: body, headers: res.headers });
137 + }
138 + const data = (await res.json()) as { data: OpenRouterModel[] };
139 + return data.data.map(normalizeOpenRouterModel).filter((m): m is PolyModel => Boolean(m));
140 +}
141 +
142 +async function validateOpenRouter(_client: OpenAI, apiKey: string, signal?: AbortSignal) {
143 + // `/api/v1/key` is the cheapest authenticated call and confirms the key is live.
144 + const res = await fetch("https://openrouter.ai/api/v1/key", { headers: { Authorization: `Bearer ${apiKey}` }, signal });
145 + if (!res.ok) {
146 + const body = (await res.json().catch(() => ({}))) as { error?: { message?: string } };
147 + throw Object.assign(new Error(body.error?.message ?? `HTTP ${res.status}`), { status: res.status, error: body, headers: res.headers });
148 + }
149 + const models = await listOpenRouter(_client, apiKey, signal);
150 + return { modelsAvailable: models.length };
151 +}
152 +
153 +export const openrouterAdapter = createOpenAICompatAdapter({
154 + id: "openrouter",
155 + name: "OpenRouter",
156 + baseURL: "https://openrouter.ai/api/v1",
157 + keyDocsUrl: "https://openrouter.ai/settings/keys",
158 + keyPrefixHint: "sk-or-v1-",
159 + defaultHeaders: { "HTTP-Referer": "https://www.polyllm.io", "X-OpenRouter-Title": "PolyLLM", "X-Title": "PolyLLM" },
160 + listModels: listOpenRouter,
161 + validate: validateOpenRouter,
162 + messageOptions: { inlineFiles: true },
163 + useMaxTokens: true,
164 + tweakParams: (params, settings, req) => {
165 + const p = params as unknown as Record<string, unknown>;
166 + const sp = new Set((req.modelInfo?.metadata?.supportedParameters as string[] | undefined) ?? []);
167 + const info = (req.modelInfo?.metadata?.reasoningInfo ?? null) as { mandatory?: boolean; supported_efforts?: string[] } | null;
168 + if (req.modelInfo?.capabilities.reasoning) {
169 + const effort = settings.reasoningEffort;
170 + const reasoning: Record<string, unknown> = {};
171 + if (effort === "none" && !info?.mandatory) reasoning.enabled = false;
172 + else if (effort && effort !== "none") {
173 + if (info?.supported_efforts?.length) reasoning.effort = effort === "minimal" ? "low" : effort;
174 + else reasoning.max_tokens = effort === "low" || effort === "minimal" ? 2000 : effort === "medium" ? 8000 : 16000; // models without effort levels (e.g. Anthropic) need a budget
175 + } else if (settings.thinkingBudget) reasoning.max_tokens = settings.thinkingBudget;
176 + if (settings.includeReasoning === false) reasoning.exclude = true;
177 + if (Object.keys(reasoning).length) p.reasoning = reasoning;
178 + // reasoning tokens count against max_tokens → never leave a tiny cap
179 + if (typeof params.max_tokens === "number" && params.max_tokens < 2000 && effort !== "none") params.max_tokens = 2000;
180 + }
181 + if (settings.webSearch) p.plugins = [{ id: "web" }];
182 + // Ask OpenRouter to route only to endpoints that honour the schema when strict JSON is requested.
183 + if (params.response_format?.type === "json_schema") p.provider = { require_parameters: true };
184 + // Never forward knobs the underlying model does not list (OpenRouter may otherwise 400 or silently ignore).
185 + if (sp.size) {
186 + for (const [ours, theirs] of [["temperature", "temperature"], ["top_p", "top_p"], ["seed", "seed"], ["stop", "stop"], ["frequency_penalty", "frequency_penalty"], ["presence_penalty", "presence_penalty"]] as const) {
187 + if (p[ours] !== undefined && !sp.has(theirs)) delete p[ours];
188 + }
189 + if (p.response_format && !sp.has("response_format") && !sp.has("structured_outputs")) delete p.response_format;
190 + }
191 + },
192 + refineError: (status, code, message) => {
193 + if (status === 402) return "INSUFFICIENT_CREDITS";
194 + if (status === 401) return "INVALID_API_KEY";
195 + if (status === 403 && /moderat|flagged/i.test(message)) return "CONTENT_REJECTED";
196 + if (status === 404 && /model|no endpoints/i.test(message)) return "MODEL_NOT_FOUND";
197 + if (status === 408) return "REQUEST_TIMEOUT";
198 + if (status === 502 || status === 503) return "PROVIDER_UNAVAILABLE";
199 + void code;
200 + return undefined;
201 + },
202 +});
modified src/lib/ai/providers/shared/openai-compat/chat-completions.ts +35 −5
@@ -14,6 +14,10 @@ export interface ChatCompletionsBuildOptions {
14 14 systemRole?: "system" | "developer";
15 15 /** When the endpoint has no PDF support, text-like files are inlined; others are described. */
16 16 inlineFiles?: boolean;
17 + /** Replay stored reasoning as `reasoning_content` on assistant turns (required by Kimi/DeepSeek thinking models when tools are used). */
18 + replayReasoningContent?: boolean;
19 + /** Provider-specific content part for PDFs (e.g. Mistral `document_url`). When absent, PDFs are described. */
20 + pdfPart?: (data: string, name: string) => ChatCompletionContentPart;
17 21 }
18 22
19 23 export function toChatCompletionMessages(system: string | undefined, messages: UnifiedMessage[], opts: ChatCompletionsBuildOptions = {}): ChatCompletionMessageParam[] {
@@ -34,6 +38,10 @@ export function toChatCompletionMessages(system: string | undefined, messages: U
34 38 const text = m.content.map((p) => (p.type === "text" ? p.text : "")).join("");
35 39 const toolCalls = m.content.filter((p) => p.type === "tool-call");
36 40 const msg: ChatCompletionMessageParam = { role: "assistant", content: text || null };
41 + if (opts.replayReasoningContent) {
42 + const reasoning = m.content.filter((p) => p.type === "reasoning").map((p) => (p.type === "reasoning" ? p.text : "")).join("");
43 + if (reasoning) (msg as unknown as Record<string, unknown>).reasoning_content = reasoning;
44 + }
37 45 if (toolCalls.length) {
38 46 msg.tool_calls = toolCalls.map((p) => (p.type === "tool-call" ? { id: p.id, type: "function" as const, function: { name: p.name, arguments: p.argumentsText ?? JSON.stringify(p.arguments) } } : null)).filter(Boolean) as NonNullable<typeof msg.tool_calls>;
39 47 }
@@ -50,7 +58,8 @@ export function toChatCompletionMessages(system: string | undefined, messages: U
50 58 if (p.type === "text") parts.push({ type: "text", text: p.text });
51 59 else if (p.type === "image") parts.push({ type: "image_url", image_url: { url: `data:${p.mimeType};base64,${p.data}`, detail: "auto" } });
52 60 else if (p.type === "file") {
53 if (isTextLike(p.mimeType, p.name) || opts.inlineFiles !== false) parts.push({ type: "text", text: isTextLike(p.mimeType, p.name) ? inlineTextFile(p.name, p.data) : `[Attached file "${p.name}" (${p.mimeType}) — this model cannot read binary files of this type.]` });
61 + if (p.mimeType === "application/pdf" && opts.pdfPart) parts.push(opts.pdfPart(p.data, p.name));
62 + else if (isTextLike(p.mimeType, p.name) || opts.inlineFiles !== false) parts.push({ type: "text", text: isTextLike(p.mimeType, p.name) ? inlineTextFile(p.name, p.data) : `[Attached file "${p.name}" (${p.mimeType}) — this model cannot read binary files of this type.]` });
54 63 }
55 64 }
56 65 if (parts.length === 1 && parts[0].type === "text") out.push({ role: "user", content: parts[0].text });
@@ -91,17 +100,36 @@ export async function* normalizeChatCompletionStream(stream: AsyncIterable<ChatC
91 100 let finish: FinishReason = "other";
92 101 let started = false;
93 102 let sawUsage = false;
103 + let lastUpstream: string | undefined;
94 104 for await (const chunk of stream) {
95 105 if (!started) {
96 106 started = true;
97 107 yield { type: "start", id: chunk.id, model: chunk.model };
98 108 }
109 + const upstream = (chunk as { provider?: string }).provider;
110 + if (upstream && upstream !== lastUpstream) {
111 + lastUpstream = upstream;
112 + yield { type: "provider-data", data: { upstreamProvider: upstream } };
113 + }
99 114 const choice = chunk.choices?.[0];
100 115 if (choice) {
101 const delta = choice.delta as ChatCompletionChunk.Choice.Delta & { reasoning_content?: string | null; reasoning?: string | null };
116 + const annotations = (choice.delta as { annotations?: Array<{ type?: string; url_citation?: { url?: string; title?: string; content?: string } }> }).annotations;
117 + if (Array.isArray(annotations)) {
118 + for (const a of annotations) if (a.type === "url_citation" && a.url_citation?.url) yield { type: "citation", citation: { url: a.url_citation.url, title: a.url_citation.title, snippet: a.url_citation.content?.slice(0, 300), source: "web_search" } };
119 + }
120 + const delta = choice.delta as Omit<ChatCompletionChunk.Choice.Delta, "content"> & { content?: string | null | Array<{ type?: string; text?: string; thinking?: Array<{ type?: string; text?: string }> | string }>; reasoning_content?: string | null; reasoning?: string | null };
102 121 const reasoning = delta.reasoning_content ?? delta.reasoning;
103 122 if (reasoning) yield { type: "reasoning-delta", text: reasoning };
104 if (delta.content) yield { type: "text-delta", text: delta.content };
123 + if (typeof delta.content === "string" && delta.content) yield { type: "text-delta", text: delta.content };
124 + else if (Array.isArray(delta.content)) {
125 + // Mistral (Magistral) streams content as chunks: {type:"thinking", thinking:[{type:"text", text}]} | {type:"text", text}
126 + for (const c of delta.content) {
127 + if (c.type === "thinking") {
128 + const t = typeof c.thinking === "string" ? c.thinking : (c.thinking ?? []).map((x) => x.text ?? "").join("");
129 + if (t) yield { type: "reasoning-delta", text: t };
130 + } else if (c.text) yield { type: "text-delta", text: c.text };
131 + }
132 + }
105 133 if (delta.tool_calls) {
106 134 for (const tc of delta.tool_calls) {
107 135 const idx = tc.index ?? 0;
@@ -124,10 +152,11 @@ export async function* normalizeChatCompletionStream(stream: AsyncIterable<ChatC
124 152 }
125 153 if (choice.finish_reason) finish = mapChatFinish(choice.finish_reason);
126 154 }
127 const usage = chunk.usage as (ChatCompletionChunk["usage"] & { prompt_tokens_details?: { cached_tokens?: number | null } | null; completion_tokens_details?: { reasoning_tokens?: number | null } | null }) | null | undefined;
155 + const usage = chunk.usage as (ChatCompletionChunk["usage"] & { prompt_tokens_details?: { cached_tokens?: number | null } | null; completion_tokens_details?: { reasoning_tokens?: number | null } | null; prompt_cache_hit_tokens?: number | null; cost?: number | null; cached_tokens?: number | null }) | null | undefined;
128 156 if (usage) {
129 157 sawUsage = true;
130 158 const reasoningTokens = usage.completion_tokens_details?.reasoning_tokens ?? undefined;
159 + const cached = usage.prompt_tokens_details?.cached_tokens ?? usage.prompt_cache_hit_tokens ?? usage.cached_tokens ?? undefined;
131 160 // xAI: total_tokens = prompt + completion + reasoning; completion excludes reasoning. We bill reasoning as output.
132 161 const outputTokens = (usage.completion_tokens ?? 0) + (provider === "xai" ? reasoningTokens ?? 0 : 0);
133 162 yield {
@@ -135,13 +164,14 @@ export async function* normalizeChatCompletionStream(stream: AsyncIterable<ChatC
135 164 usage: {
136 165 inputTokens: usage.prompt_tokens ?? 0,
137 166 outputTokens,
138 cachedInputTokens: usage.prompt_tokens_details?.cached_tokens ?? undefined,
167 + cachedInputTokens: cached ?? undefined,
139 168 reasoningTokens,
140 169 totalTokens: usage.total_tokens ?? undefined,
141 170 },
142 171 };
143 172 const cost = (usage as { cost_in_usd_ticks?: number }).cost_in_usd_ticks;
144 173 if (typeof cost === "number") yield { type: "provider-data", data: { exactCostUsd: cost / 1e10 } };
174 + if (typeof usage.cost === "number") yield { type: "provider-data", data: { exactCostUsd: usage.cost } }; // OpenRouter usage accounting
145 175 }
146 176 }
147 177 for (const acc of tools.values()) {
added src/lib/ai/providers/shared/openai-compat/factory.ts +195 −0
@@ -0,0 +1,195 @@
1 +import OpenAI from "openai";
2 +import type { ChatCompletionCreateParamsStreaming, ChatCompletionTool } from "openai/resources/chat/completions";
3 +import {
4 + type AIProviderAdapter,
5 + type PolyModel,
6 + type ProviderId,
7 + type UnifiedChatRequest,
8 + type UnifiedChatResponse,
9 + type UnifiedStreamEvent,
10 + type ValidationResult,
11 + type TokenEstimate,
12 + type UnifiedGenerationSettings,
13 + PolyProviderError,
14 +} from "@/lib/ai/core/types";
15 +import { normalizeGenericError, refineByMessage, codeFromStatus, isRetryableStatus, parseRetryAfter } from "@/lib/ai/core/errors";
16 +import { filterSettings, heuristicTokens } from "@/lib/ai/core/normalize";
17 +import { collectStream } from "@/lib/ai/core/stream-utils";
18 +import { toChatCompletionMessages, normalizeChatCompletionStream, type ChatCompletionsBuildOptions } from "./chat-completions";
19 +import { log } from "@/lib/log";
20 +
21 +/**
22 + * Factory for OpenAI-compatible Chat Completions providers (DeepSeek, Kimi, Cerebras, OpenRouter, Mistral…).
23 + * Each provider supplies: base URL, a model lister, param quirks (names/limits) and an error refiner.
24 + * Everything verified per provider lives in its own `catalog.ts` and is fed through `modelInfo`.
25 + */
26 +export interface CompatConfig {
27 + id: ProviderId;
28 + name: string;
29 + baseURL: string;
30 + keyDocsUrl: string;
31 + keyPrefixHint?: string;
32 + defaultHeaders?: Record<string, string>;
33 + /** List and normalize models (uses the provider's own listing endpoint). */
34 + listModels: (client: OpenAI, apiKey: string, signal?: AbortSignal) => Promise<PolyModel[]>;
35 + /** Optional cheaper validation (defaults to listModels). */
36 + validate?: (client: OpenAI, apiKey: string, signal?: AbortSignal) => Promise<{ modelsAvailable?: number }>;
37 + messageOptions?: ChatCompletionsBuildOptions;
38 + /** Mutate the request params for provider quirks (reasoning knobs, renamed fields, unsupported fields). */
39 + tweakParams?: (params: ChatCompletionCreateParamsStreaming, settings: UnifiedGenerationSettings, req: UnifiedChatRequest) => void;
40 + /** Use `max_tokens` instead of `max_completion_tokens`. */
41 + useMaxTokens?: boolean;
42 + /** Whether `stream_options.include_usage` is accepted (default true). */
43 + streamUsage?: boolean;
44 + /** Provider-specific refinement of error codes from (status, providerCode, message). */
45 + refineError?: (status: number | undefined, providerCode: string | undefined, message: string) => PolyProviderError["code"] | undefined;
46 + /** How to read the error body: OpenAI shape `{error:{message,type,code}}` is the default. */
47 + timeoutMs?: number;
48 +}
49 +
50 +export function createOpenAICompatAdapter(cfg: CompatConfig): AIProviderAdapter {
51 + const timeout = cfg.timeoutMs ?? 10 * 60_000;
52 + const client = (apiKey: string, t = timeout) => new OpenAI({ apiKey, baseURL: cfg.baseURL, maxRetries: 1, timeout: t, defaultHeaders: cfg.defaultHeaders });
53 +
54 + function buildParams(req: UnifiedChatRequest): ChatCompletionCreateParamsStreaming {
55 + const { settings } = filterSettings(req.settings, req.modelInfo);
56 + const params: ChatCompletionCreateParamsStreaming = {
57 + model: req.model,
58 + messages: toChatCompletionMessages(req.system, req.messages, { systemRole: "system", inlineFiles: true, ...(cfg.messageOptions ?? {}) }),
59 + stream: true,
60 + ...(cfg.streamUsage === false ? {} : { stream_options: { include_usage: true } }),
61 + };
62 + if (settings.maxTokens !== undefined) {
63 + if (cfg.useMaxTokens) params.max_tokens = settings.maxTokens;
64 + else params.max_completion_tokens = settings.maxTokens;
65 + }
66 + if (settings.temperature !== undefined) params.temperature = settings.temperature;
67 + if (settings.topP !== undefined) params.top_p = settings.topP;
68 + if (settings.seed !== undefined) params.seed = settings.seed;
69 + if (settings.stop?.length) params.stop = settings.stop.slice(0, 4);
70 + if (settings.frequencyPenalty !== undefined) params.frequency_penalty = settings.frequencyPenalty;
71 + if (settings.presencePenalty !== undefined) params.presence_penalty = settings.presencePenalty;
72 + const meta = (req.modelInfo?.metadata ?? {}) as Record<string, unknown>;
73 + if (settings.responseFormat?.type === "json_schema" && settings.responseFormat.schema) {
74 + if (meta.jsonSchema === false) {
75 + // Provider only supports json_object: enforce the schema through the system prompt instead.
76 + params.response_format = { type: "json_object" };
77 + const instruction = `Respond with a single JSON object that strictly follows this JSON Schema (no markdown fences, no prose):\n${JSON.stringify(settings.responseFormat.schema)}`;
78 + const first = params.messages[0];
79 + if (first && first.role === "system" && typeof first.content === "string") first.content = `${first.content}\n\n${instruction}`;
80 + else params.messages.unshift({ role: "system", content: instruction });
81 + } else {
82 + const strict = settings.responseFormat.strict ?? true;
83 + params.response_format = { type: "json_schema", json_schema: { name: settings.responseFormat.schemaName ?? "response", schema: strict ? withNoAdditionalProps(settings.responseFormat.schema) : settings.responseFormat.schema, strict } };
84 + }
85 + } else if (settings.responseFormat?.type === "json") {
86 + params.response_format = { type: "json_object" };
87 + if (meta.jsonWordRequired && !JSON.stringify(params.messages).toLowerCase().includes("json")) {
88 + params.messages.unshift({ role: "system", content: "Respond with valid JSON." });
89 + }
90 + }
91 + if (req.tools?.length) {
92 + params.tools = req.tools.map<ChatCompletionTool>((t) => ({ type: "function", function: { name: t.name, description: t.description, parameters: t.parameters } }));
93 + const tc = settings.toolChoice;
94 + if (tc === "none" || tc === "required" || tc === "auto") params.tool_choice = tc;
95 + else if (tc && typeof tc === "object") params.tool_choice = { type: "function", function: { name: tc.name } };
96 + }
97 + cfg.tweakParams?.(params, settings, req);
98 + return params;
99 + }
100 +
101 + const adapter: AIProviderAdapter = {
102 + id: cfg.id,
103 + name: cfg.name,
104 + keyDocsUrl: cfg.keyDocsUrl,
105 + keyPrefixHint: cfg.keyPrefixHint,
106 +
107 + async validateApiKey(apiKey, signal): Promise<ValidationResult> {
108 + const t0 = Date.now();
109 + try {
110 + const c = client(apiKey, 20_000);
111 + const res = cfg.validate ? await cfg.validate(c, apiKey, signal) : { modelsAvailable: (await cfg.listModels(c, apiKey, signal)).length };
112 + return { ok: true, modelsAvailable: res.modelsAvailable, latencyMs: Date.now() - t0 };
113 + } catch (e) {
114 + return { ok: false, error: adapter.normalizeError(e).toJSON(), latencyMs: Date.now() - t0 };
115 + }
116 + },
117 +
118 + async listModels(apiKey, signal): Promise<PolyModel[]> {
119 + try {
120 + return await cfg.listModels(client(apiKey, 30_000), apiKey, signal);
121 + } catch (e) {
122 + throw adapter.normalizeError(e);
123 + }
124 + },
125 +
126 + async chat(req): Promise<UnifiedChatResponse> {
127 + return collectStream(cfg.id, req.model, adapter.streamChat(req));
128 + },
129 +
130 + async *streamChat(req: UnifiedChatRequest): AsyncIterable<UnifiedStreamEvent> {
131 + try {
132 + const params = buildParams(req);
133 + if (process.env.POLYLLM_DEBUG_PROVIDER === "1") log.debug(`${cfg.id} request`, { model: params.model, keys: Object.keys(params).filter((k) => k !== "messages") });
134 + const stream = await client(req.apiKey, req.timeoutMs).chat.completions.create(params, { signal: req.signal });
135 + yield* normalizeChatCompletionStream(stream, cfg.id);
136 + } catch (e) {
137 + yield { type: "error", error: adapter.normalizeError(e).toJSON() };
138 + }
139 + },
140 +
141 + async estimateTokens(req): Promise<TokenEstimate> {
142 + const text = req.messages.map((m) => m.content.map((p) => (p.type === "text" ? p.text : "")).join(" ")).join(" ") + (req.system ?? "");
143 + return { inputTokens: heuristicTokens(text), method: "heuristic" };
144 + },
145 +
146 + normalizeError(error: unknown): PolyProviderError {
147 + if (error instanceof PolyProviderError) return error;
148 + if (error instanceof OpenAI.APIError) {
149 + const body = error.error as { message?: string; type?: string; code?: string | number; error?: string | { message?: string; type?: string; code?: string } } | undefined;
150 + const nested = typeof body?.error === "object" ? body.error : undefined;
151 + const message = nested?.message ?? (typeof body?.error === "string" ? body.error : undefined) ?? body?.message ?? error.message ?? `${cfg.name} error`;
152 + const providerCode = (nested?.code ?? body?.code ?? error.code ?? nested?.type ?? body?.type) as string | undefined;
153 + let code = cfg.refineError?.(error.status, providerCode, message) ?? codeFromStatus(error.status);
154 + if (error.status === 401 || error.status === 403) code = cfg.refineError?.(error.status, providerCode, message) ?? "INVALID_API_KEY";
155 + if (code === "INVALID_PARAMETER" || code === "UNKNOWN_PROVIDER_ERROR" || error.status === 400) code = refineByMessage(code === "UNKNOWN_PROVIDER_ERROR" ? "INVALID_PARAMETER" : code, message);
156 + if (error.status === 402) code = "INSUFFICIENT_CREDITS";
157 + return new PolyProviderError({
158 + code,
159 + message: code === "INVALID_API_KEY" ? "Invalid API key" : String(message).slice(0, 600),
160 + provider: cfg.id,
161 + status: error.status,
162 + retryable: isRetryableStatus(error.status) && code !== "INSUFFICIENT_CREDITS" && code !== "INVALID_API_KEY",
163 + retryAfterMs: parseRetryAfter(error.headers ?? null),
164 + providerCode: providerCode !== undefined ? String(providerCode) : undefined,
165 + cause: error,
166 + });
167 + }
168 + return normalizeGenericError(cfg.id, error);
169 + },
170 + };
171 + return adapter;
172 +}
173 +
174 +/** Strict JSON-schema modes require `additionalProperties: false` on every object (Cerebras returns 400 otherwise). */
175 +export function withNoAdditionalProps(schema: Record<string, unknown>): Record<string, unknown> {
176 + const walk = (node: unknown): unknown => {
177 + if (Array.isArray(node)) return node.map(walk);
178 + if (!node || typeof node !== "object") return node;
179 + const o = { ...(node as Record<string, unknown>) };
180 + if (o.type === "object" || o.properties) {
181 + if (o.additionalProperties === undefined) o.additionalProperties = false;
182 + if (o.properties && typeof o.properties === "object") o.properties = Object.fromEntries(Object.entries(o.properties as Record<string, unknown>).map(([k, v]) => [k, walk(v)]));
183 + }
184 + for (const k of ["items", "anyOf", "oneOf", "allOf", "$defs", "definitions"]) if (o[k] !== undefined) o[k] = k === "$defs" || k === "definitions" ? Object.fromEntries(Object.entries(o[k] as Record<string, unknown>).map(([kk, v]) => [kk, walk(v)])) : walk(o[k]);
185 + return o;
186 + };
187 + return walk(schema) as Record<string, unknown>;
188 +}
189 +
190 +/** Utility for catalogs built on top of `/v1/models` listings. */
191 +export async function listOpenAIModels(client: OpenAI, signal?: AbortSignal): Promise<{ id: string; created?: number; owned_by?: string }[]> {
192 + const out: { id: string; created?: number; owned_by?: string }[] = [];
193 + for await (const m of client.models.list({ signal })) out.push({ id: m.id, created: m.created, owned_by: m.owned_by });
194 + return out;
195 +}
modified src/lib/ai/registry/catalog.ts +9 −0
@@ -2,6 +2,10 @@ import type { ModelCapabilities, ModelLimits, ModelParameters, ModelPricing, Mod
2 2 import { OPENAI_CATALOG } from "@/lib/ai/providers/openai/catalog";
3 3 import { GEMINI_CATALOG } from "@/lib/ai/providers/gemini/catalog";
4 4 import { XAI_CATALOG } from "@/lib/ai/providers/xai/catalog";
5 +import { MISTRAL_CATALOG } from "@/lib/ai/providers/mistral/catalog";
6 +import { DEEPSEEK_CATALOG } from "@/lib/ai/providers/deepseek/catalog";
7 +import { KIMI_CATALOG } from "@/lib/ai/providers/kimi/catalog";
8 +import { CEREBRAS_CATALOG } from "@/lib/ai/providers/cerebras/catalog";
5 9
6 10 /**
7 11 * Documented augmentation for models whose list endpoint lacks capability metadata.
@@ -27,6 +31,11 @@ const CATALOGS: Record<ProviderId, Map<string, CatalogEntry>> = {
27 31 anthropic: new Map(), // Anthropic's Models API returns full capability metadata; pricing lives in the adapter.
28 32 gemini: GEMINI_CATALOG,
29 33 xai: XAI_CATALOG,
34 + mistral: MISTRAL_CATALOG,
35 + deepseek: DEEPSEEK_CATALOG,
36 + kimi: KIMI_CATALOG,
37 + openrouter: new Map(), // fully live metadata from /api/v1/models
38 + cerebras: CEREBRAS_CATALOG,
30 39 };
31 40
32 41 export function getCatalog(provider: ProviderId): Map<string, CatalogEntry> {
modified src/lib/ai/registry/index.ts +2 −7
@@ -6,6 +6,7 @@ import { log } from "@/lib/log";
6 6 import { PROVIDER_IDS, type PolyModel, type ProviderId } from "@/lib/ai/core/types";
7 7 import { getAdapter } from "@/lib/ai/providers";
8 8 import { getCatalog } from "./catalog";
9 +import { ownerKey } from "@/lib/ai/providers/env-keys";
9 10
10 11 export function rowToModel(r: ModelRow): PolyModel {
11 12 return {
@@ -139,16 +140,10 @@ export async function syncProvider(provider: ProviderId, apiKey: string, trigger
139 140
140 141 /** Sync every provider for which an owner/dev key exists in the environment. */
141 142 export async function syncAllWithEnvKeys(triggeredBy = "schedule"): Promise<SyncResult[]> {
142 const keys: Partial<Record<ProviderId, string | undefined>> = {
143 openai: process.env.OPENAI_API_KEY,
144 anthropic: process.env.ANTHROPIC_API_KEY,
145 gemini: process.env.GOOGLE_GEMINI_API_KEY,
146 xai: process.env.XAI_API_KEY,
147 };
148 143 const results: SyncResult[] = [];
149 144 await Promise.all(
150 145 PROVIDER_IDS.map(async (p) => {
151 const k = keys[p];
146 + const k = ownerKey(p);
152 147 if (!k) return;
153 148 results.push(await syncProvider(p, k, triggeredBy));
154 149 }),
modified src/lib/chat/service.ts +19 −0
@@ -380,6 +380,19 @@ export async function runTurn(ctx: ChatContext, turn: PreparedTurn, emit: (ev: u
380 380 }
381 381
382 382 // --- finalize ---------------------------------------------------------------
383 + if (turn.settings.responseFormat && turn.settings.responseFormat.type !== "text") {
384 + const unfenced = stripJsonFence(fullText);
385 + if (unfenced !== fullText) {
386 + fullText = unfenced;
387 + for (let i = storedParts.length - 1; i >= 0; i--) {
388 + const part = storedParts[i];
389 + if (part.type === "text") {
390 + part.text = stripJsonFence(part.text);
391 + break;
392 + }
393 + }
394 + }
395 + }
383 396 const latencyMs = Date.now() - t0;
384 397 const cost = estimateCost(usageTotal, turn.model.pricing);
385 398 const exactCost = typeof providerData.exactCostUsd === "number" ? (providerData.exactCostUsd as number) : null;
@@ -449,6 +462,12 @@ export async function runTurn(ctx: ChatContext, turn: PreparedTurn, emit: (ev: u
449 462 return { message: saved, usage: usageTotal, costUsd, latencyMs, ttftMs: ttft, status, error, title };
450 463 }
451 464
465 +/** Some providers wrap JSON-mode answers in ```json fences; unwrap when the whole answer is one fenced block. */
466 +export function stripJsonFence(text: string): string {
467 + const m = text.trim().match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i);
468 + return m ? m[1] : text;
469 +}
470 +
452 471 export function deriveTitle(text: string): string | undefined {
453 472 const clean = textOf([{ type: "text", text }])
454 473 .replace(/[#*`>_~\[\]()]/g, "")
modified src/lib/client/providers.ts +51 −1
@@ -42,9 +42,59 @@ export const PROVIDERS: Record<ProviderId, { name: string; shortName: string; ke
42 42 consoleUrl: "https://console.x.ai",
43 43 keyHelp: "Create a key in the xAI Console → API Keys. Keys look like xai-….",
44 44 },
45 + mistral: {
46 + name: "Mistral AI",
47 + shortName: "Mistral",
48 + keyDocsUrl: "https://console.mistral.ai/api-keys",
49 + keyPrefixHint: "32 characters",
50 + colorVar: "var(--p-mistral)",
51 + description: "Mistral Large / Medium / Small, Ministral, Magistral reasoning, Codestral — La Plateforme.",
52 + consoleUrl: "https://console.mistral.ai",
53 + keyHelp: "Create a key in La Plateforme → API Keys (console.mistral.ai). Keys are 32 alphanumeric characters.",
54 + },
55 + deepseek: {
56 + name: "DeepSeek",
57 + shortName: "DeepSeek",
58 + keyDocsUrl: "https://platform.deepseek.com/api_keys",
59 + keyPrefixHint: "sk-…",
60 + colorVar: "var(--p-deepseek)",
61 + description: "DeepSeek V4 Flash / Pro — thinking mode, context caching, very low prices.",
62 + consoleUrl: "https://platform.deepseek.com",
63 + keyHelp: "Create a key at platform.deepseek.com → API keys. Keys look like sk-….",
64 + },
65 + kimi: {
66 + name: "Kimi (Moonshot AI)",
67 + shortName: "Kimi",
68 + keyDocsUrl: "https://platform.moonshot.ai/console/api-keys",
69 + keyPrefixHint: "sk-…",
70 + colorVar: "var(--p-kimi)",
71 + description: "Kimi K2.6 / K2.7 Code / K3 — long context, thinking, web search (api.moonshot.ai).",
72 + consoleUrl: "https://platform.moonshot.ai",
73 + keyHelp: "Create a key in the Moonshot AI international console (platform.moonshot.ai). Keys look like sk-….",
74 + },
75 + openrouter: {
76 + name: "OpenRouter",
77 + shortName: "OpenRouter",
78 + keyDocsUrl: "https://openrouter.ai/settings/keys",
79 + keyPrefixHint: "sk-or-v1-…",
80 + colorVar: "var(--p-openrouter)",
81 + description: "Hundreds of models from every vendor behind one key, with live pricing and routing.",
82 + consoleUrl: "https://openrouter.ai",
83 + keyHelp: "Create a key at openrouter.ai → Settings → Keys. Keys look like sk-or-v1-….",
84 + },
85 + cerebras: {
86 + name: "Cerebras",
87 + shortName: "Cerebras",
88 + keyDocsUrl: "https://cloud.cerebras.ai/",
89 + keyPrefixHint: "csk-…",
90 + colorVar: "var(--p-cerebras)",
91 + description: "Ultra-fast inference for open models (GPT-OSS, Qwen, Gemma) on wafer-scale hardware.",
92 + consoleUrl: "https://cloud.cerebras.ai",
93 + keyHelp: "Create a key in Cerebras Cloud → API Keys. Keys look like csk-….",
94 + },
45 95 };
46 96
47 export const PROVIDER_ORDER: ProviderId[] = ["openai", "anthropic", "gemini", "xai"];
97 +export const PROVIDER_ORDER: ProviderId[] = ["openai", "anthropic", "gemini", "xai", "mistral", "deepseek", "kimi", "openrouter", "cerebras"];
48 98
49 99 export function providerName(p: string | null | undefined): string {
50 100 return p && p in PROVIDERS ? PROVIDERS[p as ProviderId].shortName : p ?? "—";
modified src/lib/email/templates.ts +1 −1
@@ -91,7 +91,7 @@ export function welcomeEmail(siteUrl: string, name: string): EmailContent {
91 91 body:
92 92 p(hi) +
93 93 p("Your email is verified. PolyLLM is a universal control center for AI models: connect your own API keys, discover the latest models, configure them precisely, and chat with every provider through one interface.") +
94 p("<strong>Next step:</strong> connect at least one provider (OpenAI, Anthropic, Gemini or xAI). Your keys are encrypted at rest and only decrypted on the server immediately before a request."),
94 + p("<strong>Next step:</strong> connect at least one provider (OpenAI, Anthropic, Gemini, xAI, Mistral, DeepSeek, Kimi, OpenRouter or Cerebras). Your keys are encrypted at rest and only decrypted on the server immediately before a request."),
95 95 cta: { label: "Connect a provider", url: `${siteUrl}/app/settings/providers` },
96 96 }),
97 97 text: `${name ? `Hi ${name},` : "Hi,"}\n\nYour email is verified. Connect at least one provider to start chatting:\n${siteUrl}/app/settings/providers${textFooter(siteUrl)}`,
added tests/unit/compat-providers.test.ts +110 −0
@@ -0,0 +1,110 @@
1 +import { describe, it, expect } from "vitest";
2 +import { normalizeOpenRouterModel } from "@/lib/ai/providers/openrouter";
3 +import { normalizeMistralModel } from "@/lib/ai/providers/mistral";
4 +import { normalizeDeepSeekModel } from "@/lib/ai/providers/deepseek";
5 +import { normalizeKimiModel } from "@/lib/ai/providers/kimi";
6 +import { normalizeCerebrasModel } from "@/lib/ai/providers/cerebras";
7 +import { normalizeChatCompletionStream, toChatCompletionMessages } from "@/lib/ai/providers/shared/openai-compat/chat-completions";
8 +import { stripJsonFence } from "@/lib/chat/service";
9 +import type { ChatCompletionChunk } from "openai/resources/chat/completions";
10 +
11 +describe("OpenRouter model mapping", () => {
12 + it("converts per-token USD strings and supported_parameters into a PolyModel", () => {
13 + const m = normalizeOpenRouterModel({
14 + id: "openai/gpt-6-astra",
15 + name: "OpenAI: GPT-6 Astra",
16 + created: 1788552838,
17 + context_length: 1050000,
18 + architecture: { input_modalities: ["file", "image", "text"], output_modalities: ["text"] },
19 + pricing: { prompt: "0.00001", completion: "0.00005", input_cache_read: "0.000001", overrides: [{ min_prompt_tokens: 272000, prompt: "0.00002", completion: "0.000075" }] },
20 + top_provider: { context_length: 1050000, max_completion_tokens: 128000, is_moderated: true },
21 + supported_parameters: ["include_reasoning", "max_completion_tokens", "max_tokens", "reasoning", "reasoning_effort", "response_format", "seed", "structured_outputs", "tool_choice", "tools"],
22 + reasoning: { mandatory: true, default_enabled: true, supported_efforts: ["max", "xhigh", "high", "medium", "low"], default_effort: "medium" },
23 + })!;
24 + expect(m.key).toBe("openrouter/openai/gpt-6-astra");
25 + expect(m.pricing?.inputPerMillion).toBe(10);
26 + expect(m.pricing?.outputPerMillion).toBe(50);
27 + expect(m.pricing?.cachedInputPerMillion).toBe(1);
28 + expect(m.pricing?.longContext?.thresholdTokens).toBe(272000);
29 + expect(m.capabilities.vision).toBe(true);
30 + expect(m.capabilities.files).toBe(true);
31 + expect(m.capabilities.reasoning).toBe(true);
32 + expect(m.parameters.temperature).toBe(false); // not in supported_parameters
33 + expect(m.parameters.seed).toBe(true);
34 + expect(m.parameters.reasoningEffortLevels).toEqual(["low", "medium", "high", "xhigh", "max"]); // mandatory → no "none"
35 + expect(m.limits?.maxOutputTokens).toBe(128000);
36 + expect(m.family).toBe("OpenAI");
37 + });
38 + it("hides non-text and expired models", () => {
39 + expect(normalizeOpenRouterModel({ id: "x/img", name: "img", architecture: { output_modalities: ["image"] } })).toBeNull();
40 + expect(normalizeOpenRouterModel({ id: "x/old", name: "old", expiration_date: "2020-01-01", architecture: { output_modalities: ["text"] } })).toBeNull();
41 + });
42 +});
43 +
44 +describe("Mistral model mapping", () => {
45 + it("uses the listing capabilities and skips non-chat models", () => {
46 + const m = normalizeMistralModel({ id: "mistral-small-latest", name: "mistral-small-2603", max_context_length: 262144, aliases: ["mistral-small-2603"], capabilities: { completion_chat: true, function_calling: true, vision: true, reasoning: true } })!;
47 + expect(m.capabilities.vision).toBe(true);
48 + expect(m.capabilities.reasoning).toBe(true);
49 + expect(m.limits?.contextTokens).toBe(262144);
50 + expect(m.metadata?.resolvesTo).toBe("mistral-small-2603");
51 + expect(normalizeMistralModel({ id: "mistral-embed", capabilities: { completion_chat: false } })).toBeNull();
52 + expect(normalizeMistralModel({ id: "mistral-ocr-latest", capabilities: { completion_chat: false, ocr: true } })).toBeNull();
53 + });
54 +});
55 +
56 +describe("id-only listings", () => {
57 + it("normalizes DeepSeek / Kimi / Cerebras ids with sane defaults", () => {
58 + expect(normalizeDeepSeekModel({ id: "deepseek-v4-flash-vision-exp" })?.capabilities.vision).toBe(true);
59 + expect(normalizeDeepSeekModel({ id: "text-embedding" })).toBeNull();
60 + expect(normalizeKimiModel({ id: "kimi-k3" })?.family).toBe("Kimi K3");
61 + expect(normalizeKimiModel({ id: "kimi-k2.6" })?.parameters.temperature).toBe(false); // only temperature=1 is accepted
62 + expect(normalizeKimiModel({ id: "kimi-k3", supports_thinking_type: "only", reasoning_efforts: { valid_efforts: ["low", "high", "max"] } })?.parameters.reasoningEffortLevels).toEqual(["low", "high", "max"]);
63 + expect(normalizeCerebrasModel({ id: "gpt-oss-120b" })?.parameters.reasoningEffortLevels).toEqual(["low", "medium", "high"]);
64 + });
65 +});
66 +
67 +describe("chat-completions normalizer extras", () => {
68 + async function* fake(chunks: Partial<ChatCompletionChunk>[]): AsyncIterable<ChatCompletionChunk> {
69 + for (const c of chunks) yield { id: "c", object: "chat.completion.chunk", created: 0, model: "m", choices: [], ...c } as ChatCompletionChunk;
70 + }
71 + it("handles Mistral thinking chunks, DeepSeek cache hits and OpenRouter cost", async () => {
72 + const events = [];
73 + const stream = fake([
74 + { choices: [{ index: 0, delta: { content: [{ type: "thinking", thinking: [{ type: "text", text: "hmm " }] }, { type: "text", text: "Hi" }] as never }, finish_reason: null }] },
75 + { choices: [{ index: 0, delta: { reasoning_content: "more" } as never, finish_reason: "stop" }] },
76 + { choices: [], usage: { prompt_tokens: 100, completion_tokens: 10, total_tokens: 110, prompt_cache_hit_tokens: 60, cost: 0.0042 } as never },
77 + ]);
78 + for await (const ev of normalizeChatCompletionStream(stream, "openrouter")) events.push(ev);
79 + const reasoning = events.filter((e) => e.type === "reasoning-delta").map((e) => (e as { text: string }).text).join("");
80 + const text = events.filter((e) => e.type === "text-delta").map((e) => (e as { text: string }).text).join("");
81 + expect(reasoning).toBe("hmm more");
82 + expect(text).toBe("Hi");
83 + const usage = events.find((e) => e.type === "usage") as { usage: { cachedInputTokens?: number } };
84 + expect(usage.usage.cachedInputTokens).toBe(60);
85 + const cost = events.find((e) => e.type === "provider-data") as unknown as { data: { exactCostUsd: number } };
86 + expect(cost.data.exactCostUsd).toBe(0.0042);
87 + expect(events.at(-1)).toEqual({ type: "finish", reason: "stop" });
88 + });
89 + it("replays reasoning_content on assistant turns when asked", () => {
90 + const msgs = toChatCompletionMessages(undefined, [{ role: "assistant", content: [{ type: "reasoning", text: "think" }, { type: "text", text: "answer" }] }], { replayReasoningContent: true });
91 + expect((msgs[0] as unknown as { reasoning_content?: string }).reasoning_content).toBe("think");
92 + const plain = toChatCompletionMessages(undefined, [{ role: "assistant", content: [{ type: "reasoning", text: "think" }, { type: "text", text: "answer" }] }]);
93 + expect((plain[0] as unknown as { reasoning_content?: string }).reasoning_content).toBeUndefined();
94 + });
95 + it("strips markdown fences around JSON-mode answers", () => {
96 + expect(stripJsonFence('```json\n{"a":1}\n```')).toBe('{"a":1}');
97 + expect(stripJsonFence('{"a":1}')).toBe('{"a":1}');
98 + expect(stripJsonFence("text ```json x``` more")).toBe("text ```json x``` more");
99 + });
100 +});
101 +
102 +describe("strict schema helper", () => {
103 + it("adds additionalProperties:false recursively", async () => {
104 + const { withNoAdditionalProps } = await import("@/lib/ai/providers/shared/openai-compat/factory");
105 + const out = withNoAdditionalProps({ type: "object", properties: { a: { type: "object", properties: { b: { type: "string" } } }, list: { type: "array", items: { type: "object", properties: { c: { type: "number" } } } } }, required: ["a"] }) as { additionalProperties: boolean; properties: { a: { additionalProperties: boolean }; list: { items: { additionalProperties: boolean } } } };
106 + expect(out.additionalProperties).toBe(false);
107 + expect(out.properties.a.additionalProperties).toBe(false);
108 + expect(out.properties.list.items.additionalProperties).toBe(false);
109 + });
110 +});
111