pi-freeflow 1.39.0 → 1.40.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.40.0
4
+
5
+ ### Minor Changes
6
+
7
+ - Streaming turns now survive a relay rate limit: a 429 error frame that arrives before any tool call is held, the relay is cooled, and the same turn resumes once on a sibling relay. Pre-stream 429s still roll before any client bytes, and tool-call turns keep the authentic verdict to avoid duplicate execution.
8
+
9
+ ### Patch Changes
10
+
11
+ - Reconcile the remaining free-model specs with the highest published twin values, keeping lane hard caps authoritative:
12
+
13
+ - Cat Jam now advertises its 1,048,576-token window: the endpoint refuses a 1.05M prompt with `context_length_exceeded` (max 1,048,576), and a 900,337-token needle probe recalled correctly.
14
+ - Nemotron 3 Ultra (both lanes) now declares the 1,000,000-token lane cap; larger prompts are refused.
15
+ - Nemotron 3.5 and Super now carry the highest published 235,929 output ceiling below the window.
16
+ - Thinking selectors now expose only the depths published by the free or paid twin's datasheet; unlisted rungs stay locked off. The 3.6/3.8 Qwens add `minimal`, the other OVH reasoning rows expose `medium`, Inkling Small adds `max`, and Exo follows its paid twin's `minimal..xhigh` union.
17
+ - OVH output ceilings follow the highest published twin value below the window: 235,929 for the 397B and 3.8 Qwens, 65,536 for the 9B, 3.6 and both gpt-oss models, and 32,768 for Mistral and Llama.
18
+ - Remove Space Bunny: the free endpoint answers `410 ModelDeprecated` and points at `minimax-m3.1-flash-free`.
19
+ - Declare only text and image inputs. The host catalog has no video or audio input type, so upstream video/audio/PDF support (MiMo, GLM 5.3 Flash, Nemotron Super VL, Nano Omni) is documented in the README instead of being advertised in the picker.
20
+ - Rate-limit rotation is now silent: a relay that answers 429 is cooled and deprioritized without a per-relay warning toast or per-relay WARN line while the pool still has a candidate. A pool-wide 429 burst alert still warns when many relays rate-limit in one minute. The rate-limit message appears only when no relay or direct fallback served the turn, including the existing 403-salvage path.
21
+ - Model specs refreshed against the latest upstream catalogs. Free and paid variants of the same model now share the highest published context window, output ceiling, and input modalities, so a picker entry no longer understates what the model accepts.
22
+ - Drop two Kilo models that stopped answering, so the picker only offers what is actually served: `nvidia/nemotron-3-super-120b-a12b:free` answers `404 Function ... Not found for account ...` from its Nvidia provider, and `thinkingmachines/inkling-small:free` answers `404 The requested model is currently unavailable`. Both verdicts reproduce from two independent egress IPs while the sibling Nvidia ids answer `200` with the same request shape. The catalog is 32 models; both ids stay on the retired list so a stale cache cannot bring them back.
23
+
3
24
  ## 1.39.0
4
25
 
5
26
  ### Minor Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-freeflow
2
2
 
3
- > 35 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
3
+ > 32 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
4
4
 
5
5
  Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handles thinking, normalization, and provider behavior.
6
6
 
@@ -16,7 +16,7 @@ Thin by design: a model list, a relay proxy, and a log. The host (`pi-ai`) handl
16
16
 
17
17
  | Feature | What it does | Cost |
18
18
  | :--- | :--- | :--- |
19
- | **35 free models** | 12 from OpenCode Zen, 13 from KiloCode Gateway, 2 from Cline, 8 from OVHcloud AI Endpoints, context windows up to 1M. Full list below. | **$0** |
19
+ | **32 free models** | 11 from OpenCode Zen, 11 from KiloCode Gateway, 2 from Cline, 8 from OVHcloud AI Endpoints, context windows up to 1M. Full list below. | **$0** |
20
20
  | **Relay pool** | Route requests through your own relays: a self-hosted egress pool (many egress IPs on one small host) or Cloudflare Workers / Deno Deploy / Vercel Edge workers. Traffic spreads across the pool, or stays on one sticky relay. A relay that rate-limits, times out, or drops the connection cools down while healthy ones take its traffic. | **$0** beyond your host or platforms' free tiers |
21
21
  | **Automatic fallback** | When every relay is cooling down, requests go direct to upstream instead of failing. | **$0** |
22
22
  | **Short model names** | Most models have a slash-free, colon-free alias, plus an optional `:effort` suffix for thinking depth. You type `freeflow/<name>`; the two Cline models have no alias and must be typed in full. | **$0** |
@@ -54,7 +54,7 @@ omp
54
54
  **OMP, one shot:**
55
55
  ```bash
56
56
  omp -p --model freeflow/deepseek-v4.1-flash-free "build me a SaaS"
57
- omp -p --model freeflow/space-bunny:high "solve this bug" # alias + thinking level
57
+ omp -p --model freeflow/longcat:high "solve this bug" # alias + thinking level
58
58
  ```
59
59
 
60
60
  **Pi, interactive:**
@@ -65,7 +65,7 @@ pi
65
65
 
66
66
  **Pi, one shot:**
67
67
  ```bash
68
- pi -p --model freeflow/space-bunny:high "solve this bug"
68
+ pi -p --model freeflow/longcat:high "solve this bug"
69
69
  ```
70
70
 
71
71
  Model IDs accept a full canonical ID, a short alias (see the tables below),
@@ -213,51 +213,51 @@ The same command set works identically in OMP and Pi:
213
213
 
214
214
  ---
215
215
 
216
- ### 35 models, one command
216
+ ### 32 models, one command
217
217
 
218
218
  ```bash
219
219
  /model → freeflow → pick
220
220
  ```
221
221
 
222
- Every model specification is reconciled against [models.dev](https://models.dev/), [OpenRouter](https://openrouter.ai/), and OMP's built-in catalog, configured with the highest verified context, output ceiling, and reasoning capabilities across twin models.
223
- #### OpenCode Zen (12 models), Chat Completions API
222
+ Every model specification is reconciled against [models.dev](https://models.dev/), [OpenRouter](https://openrouter.ai/), and OMP's built-in catalog on 2026-10-20: a free and a non-free variant of the same model share the highest published context window, output ceiling, and input modalities across its twins. The catalog follows that published spec even where a free lane enforces a lower runtime cap; the authentic upstream error is surfaced above it. (Measured runtime caps: MiMo ~348K, GLM 5.3 Flash ~297K, Laguna S 262K.) Lane-published hard caps stay authoritative where the lane refuses above them: OVH's advertised context windows, the 1,000,000-token Nemotron 3 Ultra ceiling, and Dots3-Note's 393,216 served window (its only provider refused a 460,800 output budget on 2026-10-20).
223
+
224
+ The host catalog carries text and image inputs only. Upstream video and audio support (MiMo: audio + video; GLM 5.3 Flash: video + PDF; Nemotron Super VL and Nano Omni: video) is therefore not selectable from the picker and is not declared in `input`.
225
+
226
+ #### OpenCode Zen (11 models), Chat Completions API
224
227
 
225
228
  Good defaults for long coding sessions and agentic work.
226
229
 
227
230
  | Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
228
231
  | :--- | :--- | :--- | :--- | :--- | :--- |
229
- | `cat-jam-free` | Cat Jam | **256K** (262.144) | **131K** (131.072) | `low … max` | ❌ |
230
- | `mimo-v2.6-flash-free` | Xiaomi MiMo | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh`\* | ✅ |
231
- | `nemotron-3.5-lightning-free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `minimal … xhigh` | ❌ |
232
- | `nemotron-3.5-super-vl-free` | NVIDIA | **1M** (1.000.000) | **131K** (131.072) | `minimal … xhigh` | ✅ |
233
- | `nemotron-3-ultra-free` | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `minimal … xhigh` | ❌ |
232
+ | `cat-jam-free` | Cat Jam | **1M** (1.048.576) | **131K** (131.072) | `low … max` | ❌ |
233
+ | `mimo-v2.6-flash-free` | Xiaomi MiMo | **1M** (1.048.576) | **128K** (131.072) | `minimal … xhigh`\* | ✅ |
234
+ | `nemotron-3.5-lightning-free` | NVIDIA | **1M** (1.000.000) | **235K** (235.929) | `minimal … xhigh` | ❌ |
235
+ | `nemotron-3.5-super-vl-free` | NVIDIA | **1M** (1.048.576) | **235K** (235.929) | `minimal … xhigh` | ✅ |
236
+ | `nemotron-3-ultra-free` | NVIDIA | **1M** (1.000.000) | **182K** (182.520) | `minimal … xhigh` | ❌ |
234
237
  | `big-pickle` | Big Pickle | **200K** (200.000) | **32K** (32.000) | `high / max` | ❌ |
235
- | `space-bunny-free` | Stealth preview (lab undisclosed) | **1M** (1.048.576) | **512K** (524.288) | `low … max` | ✅ |
236
- | `longcat-2.5-preview-free` | Meituan LongCat | **1M** (1.000.000) | **131K** (131.072) | `minimal … max` | ✅ |
237
- | `glm-5.3-flash-free` | Zhipu AI | **1M** (1.000.000) | **32K** (32.000) | `minimal … max` | ✅ |
238
+ | `longcat-2.5-preview-free` | Meituan LongCat | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
239
+ | `glm-5.3-flash-free` | Zhipu AI | **1M** (1.048.576) | **131K** (131.072) | `low / high / max` | ✅ |
238
240
  | `beam-free` | Reflection AI | **1M** (1.048.576) | **131K** (131.072) | `low … max` | ❌ |
239
- | `exo-free` | Undisclosed | **1M** (1.048.576) | **131K** (131.072) | `high` | ✅ |
240
- | `deepseek-v4.1-flash-free` | DeepSeek | **1M** (1.000.000) | **384K** (384.000) | `minimal … max` | ✅ |
241
+ | `exo-free` | Undisclosed | **1M** (1.048.576) | **131K** (131.072) | `minimal … xhigh` | ✅ |
242
+ | `deepseek-v4.1-flash-free` | DeepSeek | **1M** (1.048.576) | **393K** (393.216) | `low / high / max` | ✅ |
241
243
 
242
- #### KiloCode Gateway (13 models), OpenRouter compatible
244
+ #### KiloCode Gateway (11 models), OpenRouter compatible
243
245
 
244
246
  Keyless access. Short aliases work for every row (the full ID is in parentheses).
245
247
 
246
248
  | Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
247
249
  | :--- | :--- | :--- | :--- | :--- | :--- |
248
- | `dots-3-note-preview` (`dots-studio/...:free`) | Dots Studio | **512K** (512.000) | **512K** (512.000) | `minimal…xhigh`\* | ✅ |
249
- | `nemotron-3-nano-omni` (`nvidia/...:free`) | NVIDIA | **256K** (256.000) | **131K** (131.072) | `minimal…xhigh`\* | ✅ |
250
- | `nemotron-3-ultra-550b` (`nvidia/...:free`) | NVIDIA | **1M** (1.000.000) | **128K** (128.000) | `minimal…xhigh`\* | ❌ |
251
- | `nvidia/nemotron-3.5-lightning:free` | NVIDIA | **1M** (1.000.000) | **262K** (262.144) | `minimal…xhigh`\* | ❌ |
252
- | `nemotron-3-super` (`nvidia/...:free`) | NVIDIA | **262K** (262.144) | **262K** (262.144) | `minimal…xhigh`\* | ❌ |
250
+ | `dots-3-note-preview` (`dots-studio/...:free`) | Dots Studio | **384K** (393.216) | **256K** (262.144) | `minimal…xhigh`\* | ✅ |
251
+ | `nemotron-3-nano-omni` (`nvidia/...:free`) | NVIDIA | **262K** (262.144) | **65K** (65.536) | `minimal…xhigh`\* | ✅ |
252
+ | `nemotron-3-ultra-550b` (`nvidia/...:free`) | NVIDIA | **1M** (1.000.000) | **182K** (182.520) | `minimal…xhigh`\* | ❌ |
253
+ | `nvidia/nemotron-3.5-lightning:free` | NVIDIA | **1M** (1.000.000) | **235K** (235.929) | `minimal…xhigh`\* | ❌ |
253
254
  | `north-mini-code` (`cohere/...:free`) | Cohere | **256K** (256.000) | **64K** (64.000) | `minimal…xhigh`\* | ❌ |
254
- | `laguna-s-2.1:free` (`poolside/...:free`) | Poolside | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
255
+ | `laguna-s-2.1:free` (`poolside/...:free`) | Poolside | **1M** (1.048.576) | **131K** (131.072) | `minimal…xhigh`\* | ❌ |
255
256
  | `laguna-xs-2.1:free` (`poolside/...:free`) | Poolside | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
256
- | `lfm-2.5` (`liquid/lfm-2.5-2.6b:free`) | Liquid AI | **65K** (65.536) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
257
+ | `lfm-2.5` (`liquid/lfm-2.5-2.6b:free`) | Liquid AI | **128K** (128.000) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
257
258
  | `kilo-auto` (`kilo-auto/free`) | Kilo Gateway Auto | **256K** (256.000) | **10K** (10.000) | `minimal…xhigh`\* | ❌ |
258
259
  | `openrouter` (`openrouter/free`) | OpenRouter Free | **200K** (200.000) | **65K** (65.536) | `minimal…xhigh`\* | ✅ |
259
260
  | `glyph-cluster` (`stealth/glyph-cluster`) | Stealth preview (lab undisclosed) | **256K** (256.000) | **256K** (256.000) | `minimal…xhigh`\* | ❌ |
260
- | `inkling-small` (`thinkingmachines/inkling-small:free`) | Thinking Machines | **1M** (1.048.576) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
261
261
 
262
262
  \* Levels are forwarded as-is through the OpenRouter-style nested `reasoning` parameter; effort mapping is decided by each model. MiMo collapses `minimal→low` and `xhigh→high` upstream, so its selector shows 5 labels but only 3 distinct effort values.
263
263
 
@@ -277,19 +277,19 @@ Each account can only be saved once: an extra login for an account you already h
277
277
 
278
278
  Anonymous access — no account, no key, nothing to sign in to. Direct only (never through the relay pool), because OVH has no per-IP gate for relays to route around. OVH rate-limits anonymous callers per IP, so a burst of parallel subagents can hit `429 API rate limit exceeded`; the model list itself never costs anything.
279
279
 
280
- Pick them with the slash-free short name (the full ID is in parentheses). Only `low` and `high` thinking levels are offered, because those are the two OVH confirmed.
280
+ Pick them with the slash-free short name (the full ID is in parentheses). The lane's rows publish `low`/`medium`/`high`; the 3.6 and 3.8 Qwens add `minimal`, and nothing on this lane offers `xhigh`/`max`.
281
281
  All eight talk the normal chat API. Hosts that prefer the responses API work too — the proxy maps that shape to chat and maps the answer back, so tool calls and streamed output behave exactly as they do on the other providers.
282
282
 
283
283
  | Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
284
284
  | :--- | :--- | :--- | :--- | :--- | :--- |
285
- | `gpt-oss-120b` (`ovh-free/gpt-oss-120b`) | OpenAI | **128K** (131.072) | **32K** (32.768) | `low/high` | ❌ |
286
- | `gpt-oss-20b` (`ovh-free/gpt-oss-20b`) | OpenAI | **128K** (131.072) | **32K** (32.768) | `low/high` | ❌ |
287
- | `qwen3.5-397b` (`ovh-free/Qwen3.5-397B-A17B`) | Alibaba Qwen | **256K** (262.144) | **64K** (65.536) | `low/high` | ✅ |
288
- | `qwen3.5-9b` (`ovh-free/Qwen3.5-9B`) | Alibaba Qwen | **256K** (262.144) | **64K** (65.536) | `low/high` | ✅ |
289
- | `qwen3.6-27b` (`ovh-free/Qwen3.6-27B`) | Alibaba Qwen | **256K** (262.144) | **64K** (65.536) | `low/high` | ✅ |
290
- | `qwen3.8-27b` (`ovh-free/Qwen3.8-27B`) | Alibaba Qwen | **256K** (262.144) | **32K** (32.768) | `low/high` | ✅ |
291
- | `mistral-small-3.2` (`ovh-free/Mistral-Small-3.2-24B-Instruct-2506`) | Mistral AI | **128K** (131.072) | **16K** (16.384) | ❌ | ✅ |
292
- | `llama-3.3-70b` (`ovh-free/Meta-Llama-3_3-70B-Instruct`) | Meta | **128K** (131.072) | **16K** (16.384) | ❌ | ❌ |
285
+ | `gpt-oss-120b` (`ovh-free/gpt-oss-120b`) | OpenAI | **128K** (131.072) | **65K** (65.536) | `low/medium/high` | ❌ |
286
+ | `gpt-oss-20b` (`ovh-free/gpt-oss-20b`) | OpenAI | **128K** (131.072) | **65K** (65.536) | `low/medium/high` | ❌ |
287
+ | `qwen3.5-397b` (`ovh-free/Qwen3.5-397B-A17B`) | Alibaba Qwen | **256K** (262.144) | **235K** (235.929) | `low/medium/high` | ✅ |
288
+ | `qwen3.5-9b` (`ovh-free/Qwen3.5-9B`) | Alibaba Qwen | **256K** (262.144) | **65K** (65.536) | `low/medium/high` | ✅ |
289
+ | `qwen3.6-27b` (`ovh-free/Qwen3.6-27B`) | Alibaba Qwen | **256K** (262.144) | **65K** (65.536) | `minimal … high` | ✅ |
290
+ | `qwen3.8-27b` (`ovh-free/Qwen3.8-27B`) | Alibaba Qwen | **256K** (262.144) | **235K** (235.929) | `minimal … high` | ✅ |
291
+ | `mistral-small-3.2` (`ovh-free/Mistral-Small-3.2-24B-Instruct-2506`) | Mistral AI | **128K** (131.072) | **32K** (32.768) | ❌ | ✅ |
292
+ | `llama-3.3-70b` (`ovh-free/Meta-Llama-3_3-70B-Instruct`) | Meta | **128K** (131.072) | **32K** (32.768) | ❌ | ❌ |
293
293
 
294
294
  ---
295
295
 
@@ -382,7 +382,7 @@ pnpm smoke # verifies extensions/index.ts loads without crashing
382
382
  #### Project structure
383
383
 
384
384
  ```
385
- ├── models.ts # 35-model catalog definitions
385
+ ├── models.ts # 32-model catalog definitions
386
386
  ├── index.ts # extension entry, lifecycle hooks
387
387
  ├── catalog.ts # model catalog cache (24h disk)
388
388
  ├── proxy.ts # local proxy server (127.0.0.1:28180)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.39.0",
4
+ "version": "1.40.0",
5
5
  "description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
6
6
  "main": "extensions/index.ts",
7
7
  "types": "src/index.ts",
package/src/catalog.ts CHANGED
@@ -101,6 +101,9 @@ export const DEAD_MODEL_IDS = new Set<string>([
101
101
  "inclusionai/ling-3.0-flash-fin:free",
102
102
  "mimo-v2.5-free",
103
103
  "stealth/space-bunny-alpha",
104
+ // space-bunny-free: deprecated on the Zen free tier (2026-10-20) — the
105
+ // endpoint answers 410 ModelDeprecated and points at minimax-m3.1-flash-free.
106
+ "space-bunny-free",
104
107
  "qwen/qwen3.8-27b:free",
105
108
  "nvidia/nemotron-3.5-content-safety:free",
106
109
  "fledge-alpha-free",
@@ -135,6 +138,15 @@ export const DEAD_MODEL_IDS = new Set<string>([
135
138
  // same request shape on the surviving Zen models answers 200. Retired from the
136
139
  // static catalog the same day; the Cline Muse Spark 1.3 entry is unaffected.
137
140
  "muse-spark-1.2-contributor-free",
141
+ // nvidia/nemotron-3-super-120b-a12b:free: dead route (2026-10-21) — Kilo answers
142
+ // 404 "Function '<uuid>': Not found for account ..." from its Nvidia provider on
143
+ // every attempt, from two independent egress IPs, while sibling Nvidia ids answer
144
+ // 200 with the same request shape.
145
+ "nvidia/nemotron-3-super-120b-a12b:free",
146
+ // thinkingmachines/inkling-small:free: gone upstream (2026-10-21) — Kilo answers
147
+ // HTTP 404 "The requested model is currently unavailable" (unavailable_model) on
148
+ // every attempt, from two independent egress IPs; it is no longer served.
149
+ "thinkingmachines/inkling-small:free",
138
150
  ]);
139
151
  /**
140
152
  * Free-tier allowlist for anything entering the picker via network or stale disk.
@@ -174,7 +186,7 @@ export function sanitizeCatalogModels(models: RegisteredModel[]): RegisteredMode
174
186
  }
175
187
  /**
176
188
  * In-memory cache of currently active/available free models.
177
- * Initialized with all 35 verified models for 0ms instant availability.
189
+ * Initialized with all 32 verified models for 0ms instant availability.
178
190
  */
179
191
  let aliveCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
180
192
  ...m,
package/src/health.ts CHANGED
@@ -47,6 +47,8 @@ interface HealthData {
47
47
  lastBytesAt: number;
48
48
  /** Mid-stream turns the upstream model itself failed (model fault, never a relay fault). */
49
49
  midStreamModelFaults: number;
50
+ /** Mid-stream turns held on a retriable rate-limit frame (whether or not the rescue served). */
51
+ midStreamRateLimitFaults: number;
50
52
  /** Mid-stream turns cut by the relay/transport (relay fault, rotated away from). */
51
53
  midStreamRelayTruncations: number;
52
54
  }
@@ -104,6 +106,7 @@ export function getHealthData(portOverride?: number, activeRequests = 0): Health
104
106
  sseDegraded: sse.degraded,
105
107
  lastBytesAt: getLastForwardedByteAt(),
106
108
  midStreamModelFaults: midStreamFaults.modelFaults,
109
+ midStreamRateLimitFaults: midStreamFaults.rateLimitFaults,
107
110
  midStreamRelayTruncations: midStreamFaults.relayTruncations,
108
111
  };
109
112
  }
package/src/models.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Static model definitions and upstream routing catalogs for pi-freeflow
3
3
  *
4
- * Defines the 35 verified free models (live-verified 2026-10-18):
5
- * - 12 OpenCode Zen models (all Chat Completions — the last Responses model,
4
+ * Defines the 32 verified free models (live-verified 2026-10-21):
5
+ * - 11 OpenCode Zen models (all Chat Completions — the last Responses model,
6
6
  * muse-spark-1.2-contributor-free, was deprecated upstream 2026-10-18)
7
- * - 13 KiloCode Keyless Gateway models (OpenRouter format)
7
+ * - 11 KiloCode Keyless Gateway models (OpenRouter format)
8
8
  * - 2 Cline direct-only models (per-user pool; one is Responses API)
9
9
  * - 8 OVHcloud AI Endpoints models (keyless, direct-only)
10
10
  */
@@ -20,11 +20,11 @@ export const OPENCODE_MODELS: ModelDef[] = [
20
20
  id: "cat-jam-free",
21
21
  name: "Cat Jam [OpenCode]",
22
22
  reasoning: true,
23
- // Context is 262_144, NOT the 1M an acceptance probe suggests: the gateway
24
- // answers 200 to oversized prompts and silently truncates. A needle planted
25
- // at the end of a 248,232-token prompt is recalled; at 250,272 it is not.
26
- // Cross-checked against the published spec, which agrees.
27
- contextWindow: 262_144,
23
+ // Confirmed 2026-10-20: the endpoint refuses a 1.05M-token prompt with
24
+ // "maximum context length is 1048576 tokens", and a 900,337-token needle
25
+ // probe recalled correctly. The earlier ~250K miss was recall, not
26
+ // truncation.
27
+ contextWindow: 1_048_576,
28
28
  maxTokens: 131_072,
29
29
  input: ["text"],
30
30
  // Effort ladder live-probed 2026-10-16: the gateway enumerates
@@ -46,9 +46,14 @@ export const OPENCODE_MODELS: ModelDef[] = [
46
46
  id: "mimo-v2.6-flash-free",
47
47
  name: "MiMo V2.6 Flash [OpenCode]",
48
48
  reasoning: true,
49
+ // Built-in catalog spec: 1,048,576 context / 131,072 output (the origin
50
+ // model and every paid twin agree). The free lane enforces a lower runtime
51
+ // cap in practice (measured ~348K; 349K is refused), but the catalog follows
52
+ // the model's built-in highest spec, so the authentic upstream error is what
53
+ // a caller sees above the lane's cap.
49
54
  contextWindow: 1_048_576,
50
55
  maxTokens: 131_072,
51
- input: ["text", "image", "video"],
56
+ input: ["text", "image"],
52
57
  thinkingLevelMap: {
53
58
  off: null,
54
59
  minimal: "low",
@@ -63,8 +68,11 @@ export const OPENCODE_MODELS: ModelDef[] = [
63
68
  id: "nemotron-3-ultra-free",
64
69
  name: "Nemotron 3 Ultra [OpenCode]",
65
70
  reasoning: true,
71
+ // The lane itself caps at 1,000,000 (a 1.29M-token request is refused with
72
+ // "maximum context length is 1000000 tokens"); the 1,048,576 figure belongs
73
+ // to a different provider row and is not served here.
66
74
  contextWindow: 1_000_000,
67
- maxTokens: 128_000,
75
+ maxTokens: 182_520,
68
76
  input: ["text"],
69
77
  thinkingLevelMap: {
70
78
  off: null,
@@ -81,7 +89,9 @@ export const OPENCODE_MODELS: ModelDef[] = [
81
89
  name: "Nemotron 3.5 Lightning [OpenCode]",
82
90
  reasoning: true,
83
91
  contextWindow: 1_000_000,
84
- maxTokens: 262_144,
92
+ // Highest published output below the window (OpenRouter/Kilo paid twins);
93
+ // the 262,144 figures repeat the context as the output cap.
94
+ maxTokens: 235_929,
85
95
  input: ["text"],
86
96
  thinkingLevelMap: {
87
97
  off: null,
@@ -100,11 +110,15 @@ export const OPENCODE_MODELS: ModelDef[] = [
100
110
  // merge registered it via the enrichModelDef heuristic, which guesses
101
111
  // reasoning:false and no effort map for this id (no magic substring) — that
102
112
  // is why the picker offered no thinking levels at all.
103
- // Context: models.dev's opencode twin and the NVIDIA family origin both
104
- // publish 1,048,576; declared 1,000,000 like its Zen siblings, and no
105
- // recall probe has raised it. maxTokens 131,072 per models.dev — the
106
- // gateway accepted 131,072 through 524,288 on probe, and acceptance is not
107
- // a cap, so the published figure stays the budget.
113
+ // Context: 1,048,576 — the value both the models.dev opencode twin and the
114
+ // NVIDIA family origin publish, adopted 2026-10-20 (this entry previously
115
+ // carried the round 1M). maxTokens 235,929 is the highest output ceiling
116
+ // published across the free and paid twins; the gateway also accepted
117
+ // 131,072 through 524,288 on probe, and acceptance is not a cap, so the
118
+ // published figure stays the budget.
119
+ // The upstream twin advertises video, but the host's ModelInputType has no
120
+ // video member, so only text and image are declared. Video remains
121
+ // documented in the README; it is not selectable in the picker.
108
122
  // Effort ladder probed level-by-level 2026-10-16: minimal/low/medium/high/
109
123
  // xhigh all answer 200 with reasoning (an unsupported value answers 400, so
110
124
  // the gateway validates per model); `max` also 200s but stays hidden
@@ -116,8 +130,8 @@ export const OPENCODE_MODELS: ModelDef[] = [
116
130
  id: "nemotron-3.5-super-vl-free",
117
131
  name: "Nemotron 3.5 Super VL [OpenCode]",
118
132
  reasoning: true,
119
- contextWindow: 1_000_000,
120
- maxTokens: 131_072,
133
+ contextWindow: 1_048_576,
134
+ maxTokens: 235_929,
121
135
  input: ["text", "image"],
122
136
  thinkingLevelMap: {
123
137
  off: null,
@@ -146,28 +160,13 @@ export const OPENCODE_MODELS: ModelDef[] = [
146
160
  max: "max",
147
161
  },
148
162
  },
149
- {
150
- id: "space-bunny-free",
151
- name: "Space Bunny Free [OpenCode]",
152
- reasoning: true,
153
- contextWindow: 1_048_576,
154
- maxTokens: 524_288,
155
- input: ["text", "image", "video"],
156
- thinkingLevelMap: {
157
- off: null,
158
- minimal: null,
159
- low: "low",
160
- medium: "medium",
161
- high: "high",
162
- xhigh: "xhigh",
163
- max: "max",
164
- },
165
- },
166
163
  {
167
164
  id: "longcat-2.5-preview-free",
168
165
  name: "LongCat 2.5 Preview [OpenCode]",
169
166
  reasoning: true,
170
- contextWindow: 1_000_000,
167
+ // Raised 2026-10-20 (spec reconciliation): 1,048,576 ctx per the published
168
+ // twin, replacing the round 1M this entry used to declare.
169
+ contextWindow: 1_048_576,
171
170
  maxTokens: 131_072,
172
171
  input: ["text", "image"],
173
172
  thinkingLevelMap: {
@@ -177,41 +176,44 @@ export const OPENCODE_MODELS: ModelDef[] = [
177
176
  medium: "medium",
178
177
  high: "high",
179
178
  xhigh: "xhigh",
180
- max: "max",
179
+ max: null,
181
180
  },
182
181
  },
183
182
  {
184
183
  // Added 2026-10-16 (issue #71 drift): Zen v1.19.2 added this free id;
185
184
  // served through the fingerprint path (chat 200 with tools + stream).
186
- // Not published on pi.dev yet, and models.dev's 200,000 context is
187
- // contradicted by probe: a marker planted at 90% depth (~250K words) is
188
- // recalled, and only above ~1M words does the gateway refuse with
189
- // context_length_exceeded. Declared 1,000,000 like its Zen siblings and
190
- // the Kilo twin (z-ai/glm-5.3-flash, 1,048,576 in this catalog); the
191
- // 450K/700K-word recall probes timed out upstream and stayed inconclusive.
192
- // maxTokens 32,000 per models.dev — the gateway accepted up to 131,072,
193
- // and acceptance is not a cap, so the published figure stays the budget.
194
- // Effort ladder probed level-by-level 2026-10-16: minimal/low/medium/high/
195
- // xhigh/max all answer 200 (an unsupported value answers 400
196
- // [invalid_value], so the gateway validates per model), and minimal vs max
197
- // visibly changes reasoning length. Chat is the only native shape
198
- // (/v1/responses answers 400 "Model does not support this protocol"),
199
- // so `api` stays unset. Vision verified live (a 1x1 PNG reads back as a
200
- // colour); video/pdf from the published entry stay unverified and are not
201
- // offered.
185
+ // Context is 1,048,576 — the published twin value, adopted 2026-10-20
186
+ // (models.dev's 200,000 figure for the free id is a stale under-report: a
187
+ // marker planted at 90% depth of a ~250K-word prompt is recalled, and only
188
+ // above ~1M words does the gateway refuse with context_length_exceeded).
189
+ // It now matches the non-free twin (z-ai/glm-5.3-flash, Cline lane,
190
+ // 1,048,576 in this catalog); the 450K/700K-word recall probes timed out upstream and stayed
191
+ // inconclusive. maxTokens 131,072 is the highest output published across the
192
+ // twins — models.dev's free entry claims only 32,000, while the gateway
193
+ // accepted up to 131,072; acceptance is not a cap, so the twin figure stays
194
+ // the budget.
195
+ // Effort ladder: the free and paid datasheets both publish low/high/max
196
+ // (models.dev opencode + OMP's committed row); the other rungs stay null.
197
+ // Chat is the only native shape (/v1/responses answers 400
198
+ // "Model does not support this protocol"), so `api` stays unset. Vision is
199
+ // published. Video and PDF are documented upstream capabilities, but the
200
+ // host carries no input type for them, so neither is declared.
202
201
  id: "glm-5.3-flash-free",
203
202
  name: "GLM-5.3 Flash [OpenCode]",
204
203
  reasoning: true,
205
- contextWindow: 1_000_000,
206
- maxTokens: 32_000,
204
+ // Built-in catalog spec: 1,048,576 context. The free lane's runtime cap is
205
+ // lower (296,517 accepted, 298,556 refused), but the catalog follows the
206
+ // model's built-in highest spec and surfaces the upstream error.
207
+ contextWindow: 1_048_576,
208
+ maxTokens: 131_072,
207
209
  input: ["text", "image"],
208
210
  thinkingLevelMap: {
209
211
  off: null,
210
- minimal: "minimal",
212
+ minimal: null,
211
213
  low: "low",
212
- medium: "medium",
214
+ medium: null,
213
215
  high: "high",
214
- xhigh: "xhigh",
216
+ xhigh: null,
215
217
  max: "max",
216
218
  },
217
219
  },
@@ -263,47 +265,54 @@ export const OPENCODE_MODELS: ModelDef[] = [
263
265
  contextWindow: 1_048_576,
264
266
  maxTokens: 131_072,
265
267
  input: ["text", "image"],
268
+ // The free datasheet lists only `high`, but the paid twin publishes
269
+ // minimal..xhigh, so the shared-model union exposes those rungs; `max` is
270
+ // not published by either and stays null.
266
271
  thinkingLevelMap: {
267
272
  off: null,
268
- minimal: null,
269
- low: null,
270
- medium: null,
273
+ minimal: "minimal",
274
+ low: "low",
275
+ medium: "medium",
271
276
  high: "high",
272
- xhigh: null,
277
+ xhigh: "xhigh",
273
278
  max: null,
274
279
  },
275
280
  },
276
281
  {
277
282
  // Added 2026-10-15: new free model on the OpenCode Zen lane. Live-verified by
278
283
  // probe, NOT copied from the published catalog — the entry for this exact
279
- // free id claims 200,000 context, which the gateway contradicts (see below).
284
+ // free id claimed 200,000 context, which the gateway contradicts (see below).
285
+ // The reconciled twin spec (2026-10-20) now publishes the full 1M, so the
286
+ // declared number is the 1,048,576 the twin states rather than the round 1M
287
+ // this entry used to carry.
280
288
  id: "deepseek-v4.1-flash-free",
281
289
  name: "DeepSeek V4.1 Flash [OpenCode]",
282
290
  reasoning: true,
283
- // 1,048,576 measured, not assumed: a marker planted mid-prompt is still
284
- // recalled through prompt_tokens=1,030,800, and the gateway turns an
285
- // oversized request away with an empty-bodied 400 somewhere above ~1.05M.
286
- // The published 200k belongs to the paid deepseek-v4-flash-free, a different
287
- // model with a confusingly similar name. Acceptance is not capability; here
288
- // the two agree, which is why this is the declared number.
289
- contextWindow: 1_000_000,
290
- // 384,000 output tokens per https://models.dev/models/deepseek/deepseek-v4.1-flash/
291
- // and live gateway boundary probe (gateway accepts up to 393,216 = 384KiB, rejects 393,217+ with 400).
292
- maxTokens: 384_000,
291
+ // 1,048,576, matching both the published twin and the probe: a marker
292
+ // planted mid-prompt is still recalled through prompt_tokens=1,030,800, and
293
+ // the gateway turns an oversized request away with an empty-bodied 400
294
+ // somewhere above ~1.05M. The stale 200k figure described a different,
295
+ // confusingly similar paid id; the reconciled twin now agrees with the probe.
296
+ contextWindow: 1_048_576,
297
+ // 393,216 output tokens: the live gateway boundary (it accepts 393,216 =
298
+ // 384 * 1024 and rejects 393,217+ with 400), which is also the highest
299
+ // figure published across the twins — models.dev rounds it down to 384,000,
300
+ // and the boundary probe is what the request budget is set to.
301
+ maxTokens: 393_216,
293
302
  // Chat Completions only — a Responses-shaped request is refused with
294
303
  // 400 ModelProtocolUnsupported. `api` left unset, which means chat.
295
304
  input: ["text", "image"],
296
305
  // Vision verified live (a 1x1 PNG reads back as "Pink"). Video is not
297
306
  // advertised for this model upstream, so it is not offered here.
298
- // Every effort level is accepted and visibly changes reasoning length, so
299
- // the whole ladder stays selectable; `off` is always null.
307
+ // Effort ladder: both datasheets publish exactly low/high/max; the other
308
+ // rungs stay null instead of exposing an unlisted value.
300
309
  thinkingLevelMap: {
301
310
  off: null,
302
- minimal: "minimal",
311
+ minimal: null,
303
312
  low: "low",
304
- medium: "medium",
313
+ medium: null,
305
314
  high: "high",
306
- xhigh: "xhigh",
315
+ xhigh: null,
307
316
  max: "max",
308
317
  },
309
318
  },
@@ -333,10 +342,15 @@ const KILO_REASONING_MAP: ThinkingLevelMap = {
333
342
  export const KILO_MODELS: ModelDef[] = [
334
343
  {
335
344
  id: "dots-studio/dots-3-note-preview:free",
336
- name: "Dots3-Note Preview (512K) [Kilo]",
337
- reasoning: true,
338
- contextWindow: 512_000,
339
- maxTokens: 512_000,
345
+ name: "Dots3-Note Preview (384K) [Kilo]",
346
+ reasoning: true,
347
+ // Kilo publishes 512,000 ctx / 460,800 out, but the only provider behind
348
+ // this free id (atlas-cloud) serves a 393,216-token total window. Re-probed
349
+ // 2026-10-20: with a 20-token prompt, max_tokens 393,195 answers 200 and
350
+ // 460,800 answers 400 "bad request". Declared values are the served window
351
+ // and an output budget that keeps prompt headroom under it.
352
+ contextWindow: 393_216,
353
+ maxTokens: 262_144,
340
354
  input: ["text", "image"],
341
355
  thinkingFormat: "openrouter",
342
356
  thinkingLevelMap: KILO_REASONING_MAP,
@@ -345,9 +359,12 @@ export const KILO_MODELS: ModelDef[] = [
345
359
  id: "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
346
360
  name: "Nemotron 3 Nano Omni [Kilo]",
347
361
  reasoning: true,
348
- contextWindow: 256_000,
349
- maxTokens: 131_072,
350
- input: ["text", "image", "video"],
362
+ // Reconciled 2026-10-20: 262,144 ctx and 65,536 output per the model's own
363
+ // published spec; the previous 256,000 / 131,072 pair was a rounded-down
364
+ // context with an output figure the spec does not carry.
365
+ contextWindow: 262_144,
366
+ maxTokens: 65_536,
367
+ input: ["text", "image"],
351
368
  thinkingFormat: "openrouter",
352
369
  thinkingLevelMap: KILO_REASONING_MAP,
353
370
  },
@@ -355,8 +372,10 @@ export const KILO_MODELS: ModelDef[] = [
355
372
  id: "nvidia/nemotron-3-ultra-550b-a55b:free",
356
373
  name: "Nemotron 3 Ultra 550B [Kilo]",
357
374
  reasoning: true,
375
+ // The gateway caps this id at 1,000,000 (a 1.11M-token request is refused
376
+ // with "maximum context length is 1000000 tokens").
358
377
  contextWindow: 1_000_000,
359
- maxTokens: 128_000,
378
+ maxTokens: 182_520,
360
379
  input: ["text"],
361
380
  thinkingFormat: "openrouter",
362
381
  thinkingLevelMap: KILO_REASONING_MAP,
@@ -366,17 +385,9 @@ export const KILO_MODELS: ModelDef[] = [
366
385
  name: "Nemotron 3.5 Lightning [Kilo]",
367
386
  reasoning: true,
368
387
  contextWindow: 1_000_000,
369
- maxTokens: 262_144,
370
- input: ["text"],
371
- thinkingFormat: "openrouter",
372
- thinkingLevelMap: KILO_REASONING_MAP,
373
- },
374
- {
375
- id: "nvidia/nemotron-3-super-120b-a12b:free",
376
- name: "Nemotron 3 Super 120B [Kilo]",
377
- reasoning: true,
378
- contextWindow: 262_144,
379
- maxTokens: 262_144,
388
+ // Highest published output below the window (OpenRouter/Kilo paid twins);
389
+ // the 262,144 figures repeat the context as the output cap.
390
+ maxTokens: 235_929,
380
391
  input: ["text"],
381
392
  thinkingFormat: "openrouter",
382
393
  thinkingLevelMap: KILO_REASONING_MAP,
@@ -395,8 +406,11 @@ export const KILO_MODELS: ModelDef[] = [
395
406
  id: "poolside/laguna-s-2.1:free",
396
407
  name: "Laguna S 2.1 [Kilo]",
397
408
  reasoning: true,
398
- contextWindow: 262_144,
399
- maxTokens: 32_768,
409
+ // Built-in catalog spec: 1,048,576 context. The free gateway's runtime cap
410
+ // is 262,144; the catalog follows the model's built-in highest spec and
411
+ // surfaces the upstream error above that cap.
412
+ contextWindow: 1_048_576,
413
+ maxTokens: 131_072,
400
414
  input: ["text"],
401
415
  thinkingFormat: "openrouter",
402
416
  thinkingLevelMap: KILO_REASONING_MAP,
@@ -415,7 +429,9 @@ export const KILO_MODELS: ModelDef[] = [
415
429
  id: "liquid/lfm-2.5-2.6b:free",
416
430
  name: "Liquid LFM 2.5 [Kilo]",
417
431
  reasoning: true,
418
- contextWindow: 65_536,
432
+ // Raised 2026-10-20 to the 128,000 ctx the published twin advertises; the
433
+ // 32,768 output ceiling is unchanged.
434
+ contextWindow: 128_000,
419
435
  maxTokens: 32_768,
420
436
  input: ["text"],
421
437
  thinkingFormat: "openrouter",
@@ -441,16 +457,6 @@ export const KILO_MODELS: ModelDef[] = [
441
457
  thinkingFormat: "openrouter",
442
458
  thinkingLevelMap: KILO_REASONING_MAP,
443
459
  },
444
- {
445
- id: "thinkingmachines/inkling-small:free",
446
- name: "Inkling Small [Kilo]",
447
- reasoning: true,
448
- contextWindow: 1_048_576,
449
- maxTokens: 262_144,
450
- input: ["text", "image"],
451
- thinkingFormat: "openrouter",
452
- thinkingLevelMap: KILO_REASONING_MAP,
453
- },
454
460
  {
455
461
  // Added 2026-10-09: live on Kilo free list (isFree:true, 0/0 pricing,
456
462
  // 256K ctx) + keyless chat 200.
@@ -496,7 +502,7 @@ export const CLINE_MODELS: ModelDef[] = [
496
502
  contextWindow: 1_048_576,
497
503
  maxTokens: 131_072,
498
504
  api: "openai-responses",
499
- input: ["text", "image", "video"],
505
+ input: ["text", "image"],
500
506
  thinkingLevelMap: {
501
507
  off: null,
502
508
  minimal: "minimal",
@@ -513,7 +519,7 @@ export const CLINE_MODELS: ModelDef[] = [
513
519
  reasoning: true,
514
520
  contextWindow: 1_048_576,
515
521
  maxTokens: 131_072,
516
- input: ["text", "image", "video"],
522
+ input: ["text", "image"],
517
523
  thinkingLevelMap: CLINE_FLASH_REASONING_MAP,
518
524
  },
519
525
  ];
@@ -530,7 +536,22 @@ const OVH_REASONING_MAP: ThinkingLevelMap = {
530
536
  off: null,
531
537
  minimal: null,
532
538
  low: "low",
533
- medium: null,
539
+ medium: "medium",
540
+ high: "high",
541
+ xhigh: null,
542
+ max: null,
543
+ };
544
+
545
+ /**
546
+ * Qwen3.6/Qwen3.8 on OVH publish one extra rung: models.dev's ovhcloud row
547
+ * lists none/minimal/low/medium/high, unlike the 3.5 pairs and gpt-oss which
548
+ * start at low. No xhigh/max on either.
549
+ */
550
+ const OVH_QWEN_REASONING_MAP: ThinkingLevelMap = {
551
+ off: null,
552
+ minimal: "minimal",
553
+ low: "low",
554
+ medium: "medium",
534
555
  high: "high",
535
556
  xhigh: null,
536
557
  max: null,
@@ -555,14 +576,15 @@ export const OVH_MODEL_PREFIX = "ovh-free/";
555
576
  * natively too (with store:false injected). Anthropic messages is not served
556
577
  * (`/v1/messages` answers 404 unknown_url upstream).
557
578
  *
558
- * Specs (measured live 2026-10-13 and cross-checked against every twin
559
- * provider carrying a spec): contextWindow is OVH's advertised value;
560
- * maxTokens is deliberately the conservative FLOOR across twin providers —
561
- * per-provider output caps for the same model range from 4K to 256K, and a
562
- * cap that is too low never breaks a request while one that is too high lets
563
- * a host ask for an output the provider refuses. OVH itself enforces only
564
- * `context - prompt` (verified by binary search), so these are safe request
565
- * budgets rather than a refusal boundary.
579
+ * Specs (datasheets only, reconciled 2026-10-20): contextWindow is OVH's own
580
+ * advertised value — the lane hard-caps there, so a twin advertising a larger
581
+ * window on another provider is not served here and must not be copied in.
582
+ * maxTokens is the highest output published by a twin below the window (the
583
+ * `/v1/models` max_completion_tokens field repeats the context, so that echo
584
+ * is discarded): 235,929 for the 397B and 3.8, 65,536 for the 9B, 3.6 and
585
+ * both gpt-oss models. Effort rungs come from models.dev's ovhcloud rows,
586
+ * not from wire probes: gpt-oss and the 3.5 Qwens list low/medium/high, the
587
+ * 3.6/3.8 add minimal.
566
588
  *
567
589
  * Vision was probed with a generated solid-colour PNG sent as a data URL,
568
590
  * twice with two different colours to defeat a memorised answer: the four
@@ -578,7 +600,7 @@ export const OVH_MODELS: ModelDef[] = [
578
600
  name: "GPT-OSS 120B [OVH]",
579
601
  reasoning: true,
580
602
  contextWindow: 131_072,
581
- maxTokens: 32_768,
603
+ maxTokens: 65_536,
582
604
  input: ["text"],
583
605
  thinkingLevelMap: OVH_REASONING_MAP,
584
606
  },
@@ -587,7 +609,7 @@ export const OVH_MODELS: ModelDef[] = [
587
609
  name: "GPT-OSS 20B [OVH]",
588
610
  reasoning: true,
589
611
  contextWindow: 131_072,
590
- maxTokens: 32_768,
612
+ maxTokens: 65_536,
591
613
  input: ["text"],
592
614
  thinkingLevelMap: OVH_REASONING_MAP,
593
615
  },
@@ -596,7 +618,7 @@ export const OVH_MODELS: ModelDef[] = [
596
618
  name: "Qwen3.5 397B A17B [OVH]",
597
619
  reasoning: true,
598
620
  contextWindow: 262_144,
599
- maxTokens: 65_536,
621
+ maxTokens: 235_929,
600
622
  input: ["text", "image"],
601
623
  thinkingLevelMap: OVH_REASONING_MAP,
602
624
  },
@@ -616,23 +638,23 @@ export const OVH_MODELS: ModelDef[] = [
616
638
  contextWindow: 262_144,
617
639
  maxTokens: 65_536,
618
640
  input: ["text", "image"],
619
- thinkingLevelMap: OVH_REASONING_MAP,
641
+ thinkingLevelMap: OVH_QWEN_REASONING_MAP,
620
642
  },
621
643
  {
622
644
  id: "ovh-free/Qwen3.8-27B",
623
645
  name: "Qwen3.8 27B [OVH]",
624
646
  reasoning: true,
625
647
  contextWindow: 262_144,
626
- maxTokens: 32_768,
648
+ maxTokens: 235_929,
627
649
  input: ["text", "image"],
628
- thinkingLevelMap: OVH_REASONING_MAP,
650
+ thinkingLevelMap: OVH_QWEN_REASONING_MAP,
629
651
  },
630
652
  {
631
653
  id: "ovh-free/Mistral-Small-3.2-24B-Instruct-2506",
632
654
  name: "Mistral Small 3.2 24B [OVH]",
633
655
  reasoning: false,
634
656
  contextWindow: 131_072,
635
- maxTokens: 16_384,
657
+ maxTokens: 32_768,
636
658
  input: ["text", "image"],
637
659
  },
638
660
  {
@@ -640,7 +662,7 @@ export const OVH_MODELS: ModelDef[] = [
640
662
  name: "Meta Llama 3.3 70B Instruct [OVH]",
641
663
  reasoning: false,
642
664
  contextWindow: 131_072,
643
- maxTokens: 16_384,
665
+ maxTokens: 32_768,
644
666
  input: ["text"],
645
667
  },
646
668
  ];
@@ -672,16 +694,13 @@ export const MODEL_ALIASES: Record<string, string> = {
672
694
  "nemotron-3-nano-omni": "nvidia/nemotron-3-nano-omni-30b-a3b-reasoning:free",
673
695
  "nemotron-3-ultra-550b": "nvidia/nemotron-3-ultra-550b-a55b:free",
674
696
  "nemotron-3.5-lightning": "nvidia/nemotron-3.5-lightning:free",
675
- "nemotron-3-super": "nvidia/nemotron-3-super-120b-a12b:free",
676
697
  "north-mini-code": "cohere/north-mini-code:free",
677
698
  "lfm-2.5": "liquid/lfm-2.5-2.6b:free",
678
699
  "glm-5.3-flash": "glm-5.3-flash-free",
679
700
  "exo": "exo-free",
680
701
  "glyph-cluster": "stealth/glyph-cluster",
681
- "inkling-small": "thinkingmachines/inkling-small:free",
682
702
  "mimo-v2.6-flash": "mimo-v2.6-flash-free",
683
703
  "nemotron-3.5-super-vl": "nemotron-3.5-super-vl-free",
684
- "space-bunny": "space-bunny-free",
685
704
  "longcat-2.5-preview": "longcat-2.5-preview-free",
686
705
  "longcat": "longcat-2.5-preview-free",
687
706
  "beam": "beam-free",
@@ -739,7 +758,7 @@ export const OVH_MODEL_IDS = new Set<string>([
739
758
  .map(([alias]) => alias),
740
759
  ]);
741
760
  /**
742
- * Combined list of all 35 static free models (canonical)
761
+ * Combined list of all 34 static free models (canonical)
743
762
  */
744
763
  export const ALL_MODELS: ModelDef[] = [...OPENCODE_MODELS, ...KILO_MODELS, ...CLINE_MODELS, ...OVH_MODELS];
745
764
 
package/src/proxy.ts CHANGED
@@ -1794,25 +1794,42 @@ export function startProxy(
1794
1794
  streamCloak,
1795
1795
  {
1796
1796
  upstreamContentType: response.headers.get("content-type"),
1797
- refire: async () => {
1797
+ refire: async (reason) => {
1798
1798
  if (midStreamRefires >= 1) return null;
1799
1799
  midStreamRefires += 1;
1800
1800
  const cutter = servedIssuer ?? "direct";
1801
+ const why =
1802
+ reason === "rate_limit"
1803
+ ? "rate-limited"
1804
+ : reason === "model"
1805
+ ? "model-failed"
1806
+ : "truncated";
1801
1807
  log(
1802
- "warn",
1803
- `responses stream truncated on ${cutter === "direct" ? "direct upstream" : `relay ${cutter}`} — re-firing once on next relay`,
1804
- { model: (parsedBody as Record<string, unknown> | null)?.model, path: req.url, relay: cutter },
1808
+ reason === "rate_limit" ? "info" : "warn",
1809
+ `responses stream ${why} on ${cutter === "direct" ? "direct upstream" : `relay ${cutter}`} — re-firing once on next relay`,
1810
+ { model: (parsedBody as Record<string, unknown> | null)?.model, path: req.url, relay: cutter, reason },
1805
1811
  reqId,
1806
1812
  );
1807
1813
  let retryRes: Response;
1808
1814
  // Mark the cutter BEFORE re-sending: candidate ordering partitions
1809
1815
  // healthy-first, so only a pre-marked (cooling) cutter guarantees
1810
1816
  // the re-fire rolls to a sibling instead of retrying the relay
1811
- // that just dropped us. A mark here is deserved either way — the
1812
- // relay demonstrably cut a live stream. (If the re-fire itself
1813
- // finds nothing usable, the pipe fallthrough may add its own mark
1814
- // on socket-error paths; bounded by the 4x escalation cap.)
1815
- if (cutter !== "direct") markRelayFailure(cutter, 0, "stream truncated mid-stream");
1817
+ // that just dropped us. A 429 cools for the rate-limit window; a
1818
+ // truncation/model fault keeps the existing relay-rotation mark.
1819
+ // (If the re-fire itself finds nothing usable, the pipe
1820
+ // fallthrough may add its own mark on socket-error paths; bounded
1821
+ // by the 4x escalation cap.)
1822
+ if (cutter !== "direct") {
1823
+ markRelayFailure(
1824
+ cutter,
1825
+ reason === "rate_limit" ? 429 : 0,
1826
+ reason === "rate_limit"
1827
+ ? "upstream rate-limited mid-stream"
1828
+ : reason === "model"
1829
+ ? "upstream model failed mid-stream"
1830
+ : "stream truncated mid-stream",
1831
+ );
1832
+ }
1816
1833
  try {
1817
1834
  retryRes = await sendViaRelay(bodyForUpstream);
1818
1835
  if (responsesRequest) {
package/src/relay.ts CHANGED
@@ -186,6 +186,8 @@ export async function relayFetch(
186
186
  const disabledRelays: string[] = [];
187
187
  /** Bare-500 roll-once cap: one extra attempt per relayFetch turn. */
188
188
  let sawBare500 = false;
189
+ /** True only while every candidate that produced a response answered 429. */
190
+ let rateLimitOnly = true;
189
191
  const u = new URL(url);
190
192
  const relayTarget = `${u.protocol}//${u.host}`;
191
193
  const relayPath = `${u.pathname}${u.search}`;
@@ -218,6 +220,7 @@ export async function relayFetch(
218
220
  for (let i = 0; i < candidates.length; i++) {
219
221
  if (hedgeSkipNext) {
220
222
  hedgeSkipNext = false;
223
+ rateLimitOnly = false;
221
224
  continue;
222
225
  }
223
226
  let targetUrl = candidates[i];
@@ -385,6 +388,7 @@ export async function relayFetch(
385
388
  // Not a relay health signal, so no failure marking — try the next
386
389
  // relay (a different host may accept it), else the direct fallback.
387
390
  if (res.status === 413) {
391
+ rateLimitOnly = false;
388
392
  lastResponse?.body?.cancel().catch(() => {});
389
393
  lastResponse = res;
390
394
  lastResponseRelay = targetUrl;
@@ -418,6 +422,7 @@ export async function relayFetch(
418
422
  !res.headers.get("content-type")?.includes("json"));
419
423
 
420
424
  if (isRelayEdge404) {
425
+ rateLimitOnly = false;
421
426
  markRelayFailure(targetUrl, 404, "Deployment or route not found on relay host");
422
427
  lastResponse?.body?.cancel().catch(() => {});
423
428
  lastResponse = res;
@@ -454,6 +459,7 @@ export async function relayFetch(
454
459
  disabledBody = null;
455
460
  }
456
461
  if (isRelayDeploymentDisabled(res, disabledBody)) {
462
+ rateLimitOnly = false;
457
463
  markRelayFailure(targetUrl, 402, "Deployment disabled (DEPLOYMENT_DISABLED) on relay host");
458
464
  disabledRelays.push(targetUrl);
459
465
  lastResponse?.body?.cancel().catch(() => {});
@@ -481,12 +487,29 @@ export async function relayFetch(
481
487
  lastResponse?.body?.cancel().catch(() => {});
482
488
  lastResponse = res;
483
489
  lastResponseRelay = targetUrl;
490
+ if (res.status === 429) {
491
+ // A 429 is a transient per-relay rate limit, not a terminal
492
+ // verdict: the request is already moving to the next candidate.
493
+ // Keep the per-relay rotation silent (no warning toast, no
494
+ // per-relay WARN line) so the host never flashes "rate limited"
495
+ // while a sibling relay serves. A pool-wide 429 burst alert may
496
+ // still fire from markRelayFailure; the final verdict is logged
497
+ // once when the pool and direct fallback cannot serve the turn.
498
+ log(
499
+ "info",
500
+ `relay ${targetUrl} rate-limited in ${elapsed}s — auto-rotating to next relay`,
501
+ { upstream: url, status: 429 },
502
+ rid,
503
+ );
504
+ continue;
505
+ }
484
506
  log(
485
507
  "warn",
486
508
  `relay ${targetUrl} returned HTTP ${res.status} in ${elapsed}s — rolling to next relay`,
487
509
  { upstream: url, status: res.status },
488
510
  rid,
489
511
  );
512
+ rateLimitOnly = false;
490
513
  const now = Date.now();
491
514
  if (now - lastRollNotify > ROLL_NOTIFY_MS) {
492
515
  lastRollNotify = now;
@@ -502,6 +525,7 @@ export async function relayFetch(
502
525
  // relay-fault signal (413 precedent: roll without health penalty), so
503
526
  // retry once on the next candidate instead of surfacing.
504
527
  if (res.status === 500) {
528
+ rateLimitOnly = false;
505
529
  if (!sawBare500) {
506
530
  sawBare500 = true;
507
531
  lastResponse?.body?.cancel().catch(() => {});
@@ -552,6 +576,7 @@ export async function relayFetch(
552
576
  // below instead of cooling a healthy relay. Non-401/403 client errors
553
577
  // keep their non-penalizing terminal path below too.
554
578
  if (res.status === 403 || (res.status === 401 && (await isRelayAuthRejection(res)))) {
579
+ rateLimitOnly = false;
555
580
  const authRejected = res.status === 401;
556
581
  markRelayFailure(
557
582
  targetUrl,
@@ -643,6 +668,7 @@ export async function relayFetch(
643
668
  }
644
669
  const elapsed = ((Date.now() - attemptStart) / 1000).toFixed(1);
645
670
  lastError = err;
671
+ rateLimitOnly = false;
646
672
  const errMsg =
647
673
  (err as Error)?.name === "AbortError"
648
674
  ? `relay header timeout (${Math.round(UPSTREAM_HEADER_TIMEOUT_MS / 1000)}s exceeded)`
@@ -661,10 +687,21 @@ export async function relayFetch(
661
687
  // Full fallback: attempt direct fetch to upstream
662
688
  const directStart = Date.now();
663
689
  try {
664
- log("warn", "relays bypassed/exhausted — attempting direct fetch to upstream", {
665
- upstream: url,
666
- sizeKB: bodySizeKB,
667
- }, rid);
690
+ // A pool that only produced 429s is still in the middle of a transient
691
+ // rate-limit episode; trying direct is recovery, not a warning-worthy
692
+ // failure. Other exhaustion causes keep their WARN.
693
+ const exhaustedByRateLimit = lastResponse?.status === 429 && rateLimitOnly;
694
+ log(
695
+ exhaustedByRateLimit ? "info" : "warn",
696
+ exhaustedByRateLimit
697
+ ? "all relays rate-limited — trying direct upstream"
698
+ : "relays bypassed/exhausted — attempting direct fetch to upstream",
699
+ {
700
+ upstream: url,
701
+ sizeKB: bodySizeKB,
702
+ },
703
+ rid,
704
+ );
668
705
 
669
706
  const directHeaders = new Headers(opts.headers);
670
707
  directHeaders.delete("x-relay-target");
@@ -80,6 +80,63 @@ const DETERMINISTIC_FAILURE_MARKERS = [
80
80
  "moderation",
81
81
  ];
82
82
 
83
+ /**
84
+ * A 429-shaped mid-stream error frame is a per-egress rate limit, not a
85
+ * terminal verdict: another relay IP may serve the same turn immediately.
86
+ * Kept separate from isTransientModelFailureFrame (which owns model flakes),
87
+ * and still vetoed by hard request/auth/quota verdicts so a billable or
88
+ * malformed request is never replayed as relay trouble.
89
+ */
90
+ const RATE_LIMIT_MARKERS = [
91
+ "rate_limit",
92
+ "rate limit",
93
+ "too many requests",
94
+ '"status":429',
95
+ '"code":429',
96
+ "shared free-tier ip quota",
97
+ ];
98
+ const RATE_LIMIT_HARD_MARKERS = [
99
+ "invalid_request",
100
+ "invalid_response",
101
+ "invalid_reasoning",
102
+ "previous_response",
103
+ "freetiererror",
104
+ "insufficient_quota",
105
+ "billing",
106
+ "authentication",
107
+ "invalid_api_key",
108
+ "permission",
109
+ "moderation",
110
+ ];
111
+
112
+ /**
113
+ * True when an SSE block carries an upstream-issued rate-limit error (HTTP
114
+ * 429 / rate_limit_exceeded / "too many requests"). Requires an error-shaped
115
+ * frame so ordinary model prose mentioning "rate limit" never triggers a
116
+ * replay; hard billing/auth/request verdicts always win.
117
+ */
118
+ export function isRetriableRateLimitFrame(s: string): boolean {
119
+ const lower = s.toLowerCase();
120
+ // A successful terminal echoes the full response object, whose `error` is
121
+ // null; it can contain the model's prose ("HTTP 429 means rate limit") and
122
+ // must never be replayed. Only a real error object/event may be held.
123
+ if (lower.includes('"type":"response.completed"') || lower.includes('"status":"completed"')) {
124
+ return false;
125
+ }
126
+ if (
127
+ !lower.includes('"error":{') &&
128
+ !lower.includes('"error":"') &&
129
+ !lower.includes('"status":"failed"') &&
130
+ !lower.includes("response.failed") &&
131
+ !lower.includes('"type":"error"')
132
+ ) {
133
+ return false;
134
+ }
135
+ if (RATE_LIMIT_HARD_MARKERS.some((m) => lower.includes(m))) return false;
136
+ return RATE_LIMIT_MARKERS.some((m) => lower.includes(m));
137
+ }
138
+
139
+
83
140
  /**
84
141
  * True when scanned SSE text carries an upstream-issued response.failed frame
85
142
  * for a TRANSIENT model fault (server_error class). Text-only turns holding
@@ -130,16 +187,18 @@ function endsOnFinishedSseLine(tail: string): boolean {
130
187
  // model-fault frames vs relay-fault truncations. Relay-health marks stay the
131
188
  // rotation mechanism; these counters attribute blame for status/logs.
132
189
  let midStreamModelFaults = 0;
190
+ let midStreamRateLimitFaults = 0;
133
191
  let midStreamRelayTruncations = 0;
134
192
 
135
193
  /** Current mid-stream fault split. */
136
- export function getMidStreamFaultStats(): { modelFaults: number; relayTruncations: number } {
137
- return { modelFaults: midStreamModelFaults, relayTruncations: midStreamRelayTruncations };
194
+ export function getMidStreamFaultStats(): { modelFaults: number; rateLimitFaults: number; relayTruncations: number } {
195
+ return { modelFaults: midStreamModelFaults, rateLimitFaults: midStreamRateLimitFaults, relayTruncations: midStreamRelayTruncations };
138
196
  }
139
197
 
140
198
  /** Test-only: reset the mid-stream fault split. */
141
199
  export function _resetMidStreamFaultStatsForTest(): void {
142
200
  midStreamModelFaults = 0;
201
+ midStreamRateLimitFaults = 0;
143
202
  midStreamRelayTruncations = 0;
144
203
  }
145
204
 
@@ -819,21 +878,24 @@ export function rewriteSseBlock(
819
878
  if (cloak.pathname.endsWith("/messages")) return rewriteMessagesBlock(block, state, cloak);
820
879
  return rewriteChatBlock(block, state, cloak);
821
880
  }
881
+ export type MidStreamRefireReason = "truncation" | "model" | "rate_limit";
822
882
  /**
823
883
  * Options for pipeUpstreamStream.
824
884
  */
825
885
  export interface StreamPipeOptions {
826
886
  /**
827
- * Mid-stream resume hook. Invoked at most once per turn, only when the
828
- * upstream truncates (clean FIN without a terminal marker, premature
829
- * socket close, or a genuine stream error) while the client is still
830
- * attached. Return a replacement upstream readable to continue THIS turn
831
- * on it — no synthetic terminal is injected and the client response stays
832
- * open. Return null (or throw) to keep the existing terminate behavior:
833
- * a synthetic response.incomplete/failed plus relay-health accounting.
887
+ * Mid-stream resume hook. Invoked at most once per turn when the upstream
888
+ * truncates (clean FIN without a terminal marker, premature socket close,
889
+ * or a genuine stream error), emits a transient model-failure frame, or
890
+ * emits a retriable rate-limit frame, while the client is still attached.
891
+ * Return a replacement upstream readable to continue THIS turn on it — no
892
+ * synthetic terminal is injected and the client response stays open.
893
+ * Return null (or throw) to keep the existing terminate behavior: a held
894
+ * failure frame is released, a synthetic response.incomplete/failed is
895
+ * injected when needed, and the response is ended exactly once.
834
896
  * Client aborts and clean terminal markers never reach this hook.
835
897
  */
836
- refire?: () => Promise<Readable | null>;
898
+ refire?: (reason?: MidStreamRefireReason) => Promise<Readable | null>;
837
899
  /**
838
900
  * Content-type the upstream sent for this response, passed through verbatim.
839
901
  * A value that is present and not an SSE type (text/event-stream) switches
@@ -955,31 +1017,46 @@ export function pipeUpstreamStream(
955
1017
  s.includes("[DONE]")
956
1018
  );
957
1019
  };
958
- // Held transient model-failure frame (raw chunk or rewritten block text):
1020
+ // Held transient failure frame (raw chunk or rewritten block text):
959
1021
  // withheld from the client while one silent resume is attempted. Dropped on
960
1022
  // success, forwarded verbatim (as the genuine upstream verdict) when the
961
1023
  // resume is vetoed, unavailable, or fails. Null outside a hold.
962
1024
  let heldFailureChunk: string | Buffer | null = null;
1025
+ let heldFailureReason: "model" | "rate_limit" = "model";
963
1026
  /**
964
- * Hold an upstream-issued transient model-failure frame for one silent
965
- * resume on a sibling relay. Returns true when held (caller must NOT
966
- * forward the frame); false keeps the existing terminate behavior. Tool
967
- * turns always veto: a regenerated turn would re-execute forwarded calls.
1027
+ * Hold an upstream-issued transient failure frame for one silent resume on
1028
+ * a sibling relay. Returns true when held (caller must NOT forward the
1029
+ * frame); false keeps the existing terminate behavior. Tool turns always
1030
+ * veto: a regenerated turn would re-execute forwarded calls.
968
1031
  */
969
- const holdModelFailureForRefire = (chunk: string | Buffer): boolean => {
1032
+ const holdFailureForRefire = (
1033
+ chunk: string | Buffer,
1034
+ reason: "model" | "rate_limit",
1035
+ ): boolean => {
970
1036
  if (refirePending || heldFailureChunk !== null || sawToolCall) return false;
971
1037
  if (clientAborted || refireUsed || !opts?.refire || res.writableEnded || res.destroyed) return false;
972
1038
  refireUsed = true;
973
1039
  refirePending = true;
974
1040
  heldFailureChunk = chunk;
975
- midStreamModelFaults += 1;
976
- log(
977
- "warn",
978
- "upstream model failed this turn mid-stream (model fault) — retrying once on the next relay",
979
- { totalChunks, totalBytes },
980
- rid,
981
- );
982
- void rescueHeldModelFailure();
1041
+ heldFailureReason = reason;
1042
+ if (reason === "rate_limit") {
1043
+ midStreamRateLimitFaults += 1;
1044
+ log(
1045
+ "info",
1046
+ "upstream rate-limited this turn mid-stream — retrying once on the next relay",
1047
+ { totalChunks, totalBytes },
1048
+ rid,
1049
+ );
1050
+ } else {
1051
+ midStreamModelFaults += 1;
1052
+ log(
1053
+ "warn",
1054
+ "upstream model failed this turn mid-stream (model fault) — retrying once on the next relay",
1055
+ { totalChunks, totalBytes },
1056
+ rid,
1057
+ );
1058
+ }
1059
+ void rescueHeldFailure(heldFailureReason);
983
1060
  return true;
984
1061
  };
985
1062
  /**
@@ -1008,16 +1085,16 @@ export function pipeUpstreamStream(
1008
1085
  } catch { }
1009
1086
  };
1010
1087
  /**
1011
- * Resume a held model-failure turn on the replacement stream from
1088
+ * Resume a held transient-failure turn on the replacement stream from
1012
1089
  * opts.refire (rotation marking lives there, shared with the truncation
1013
1090
  * path). Success drops the held frame; the stale cut stream's later
1014
1091
  * events are neutered by the generation check in watch().
1015
1092
  */
1016
- const rescueHeldModelFailure = async (): Promise<void> => {
1093
+ const rescueHeldFailure = async (reason: "model" | "rate_limit"): Promise<void> => {
1017
1094
  const refire = opts?.refire;
1018
1095
  let next: Readable | null = null;
1019
1096
  try {
1020
- next = refire ? await refire() : null;
1097
+ next = refire ? await refire(reason) : null;
1021
1098
  } catch {
1022
1099
  next = null;
1023
1100
  }
@@ -1027,6 +1104,17 @@ export function pipeUpstreamStream(
1027
1104
  next?.destroy();
1028
1105
  } catch { }
1029
1106
  releaseHeldFailure();
1107
+ // The cut stream's FIN/close may already have been consumed while the
1108
+ // rescue was in flight (both handlers return early on refirePending), so
1109
+ // nothing else will terminate this response. A held terminal frame sets
1110
+ // hasTerminalEvent in releaseHeldFailure; a non-terminal chat/error frame
1111
+ // still gets the synthetic terminal before the socket closes.
1112
+ if (!hasTerminalEvent) {
1113
+ ensureTerminalEvent(false, "mid-stream rescue failed", false, true);
1114
+ }
1115
+ try {
1116
+ if (!res.writableEnded) res.end();
1117
+ } catch { }
1030
1118
  return;
1031
1119
  }
1032
1120
  heldFailureChunk = null;
@@ -1040,7 +1128,12 @@ export function pipeUpstreamStream(
1040
1128
  try {
1041
1129
  if (!cut.destroyed) cut.destroy();
1042
1130
  } catch { }
1043
- log("debug", "mid-stream model-fault refire: continuing turn on replacement stream", { totalChunks, totalBytes }, rid);
1131
+ log(
1132
+ "debug",
1133
+ `mid-stream ${reason === "rate_limit" ? "rate-limit" : "model-fault"} refire: continuing turn on replacement stream`,
1134
+ { totalChunks, totalBytes },
1135
+ rid,
1136
+ );
1044
1137
  };
1045
1138
  /**
1046
1139
  * Write one payload to the client with the shared backpressure/flush
@@ -1083,6 +1176,9 @@ export function pipeUpstreamStream(
1083
1176
  * delivery guarantees.
1084
1177
  */
1085
1178
  const forwardText = (text: string): void => {
1179
+ // A hold/refire can start inside a multi-block chunk; the cut stream's
1180
+ // remaining blocks must not interleave with the replacement turn.
1181
+ if (refirePending) return;
1086
1182
  const buf = Buffer.from(text, "utf8");
1087
1183
  const scanBuf =
1088
1184
  terminalScanCarry.length > 0
@@ -1090,11 +1186,19 @@ export function pipeUpstreamStream(
1090
1186
  : buf;
1091
1187
  const scanned = scanBuf.toString("utf8");
1092
1188
  if (!sawToolCall && TOOL_CALL_MARKERS.some((m) => scanned.includes(m))) sawToolCall = true;
1189
+ // A 429-shaped frame is a per-egress rate limit: hold it for one silent
1190
+ // resume on a sibling relay before the client ever sees a rate-limit error.
1191
+ // Classify the CURRENT complete block, not the terminal scan carry: the
1192
+ // carry may splice a previous block's "error" key into this one.
1193
+ // Tool turns, a spent resume budget, or a hard verdict keep it terminal.
1194
+ if (!hasTerminalEvent && isRetriableRateLimitFrame(text) && holdFailureForRefire(text, "rate_limit")) {
1195
+ return;
1196
+ }
1093
1197
  if (!hasTerminalEvent && checkTerminalEvent(scanned)) {
1094
1198
  // Upstream-issued transient model failure on a text-only turn: hold the
1095
1199
  // frame for one silent resume instead of terminating (tool turns veto
1096
- // inside holdModelFailureForRefire and keep the terminal below).
1097
- if (isTransientModelFailureFrame(scanned) && holdModelFailureForRefire(text)) return;
1200
+ // inside holdFailureForRefire and keep the terminal below).
1201
+ if (isTransientModelFailureFrame(scanned) && holdFailureForRefire(text, "model")) return;
1098
1202
  hasTerminalEvent = true;
1099
1203
  }
1100
1204
  terminalScanCarry = Buffer.from(scanBuf.subarray(Math.max(0, scanBuf.length - 32)));
@@ -1166,7 +1270,7 @@ export function pipeUpstreamStream(
1166
1270
  refirePending = true;
1167
1271
  let next: Readable | null = null;
1168
1272
  try {
1169
- next = await opts.refire();
1273
+ next = await opts.refire("truncation");
1170
1274
  } catch {
1171
1275
  next = null;
1172
1276
  }
@@ -1394,6 +1498,9 @@ export function pipeUpstreamStream(
1394
1498
  const flushed = flushBufferedToolArgs(cloakState, cloak);
1395
1499
  if (flushed !== null) forwardText(flushed);
1396
1500
  }
1501
+ // A tail-framed failure frame may have started a hold during the flush
1502
+ // above; the rescue owns the turn now. Do not inject a terminal or end.
1503
+ if (refirePending || heldFailureChunk !== null) return;
1397
1504
  if (hasTerminalEvent) recordOnce(false);
1398
1505
  const elapsed = ((Date.now() - startAt) / 1000).toFixed(1);
1399
1506
  if (!hasTerminalEvent && totalChunks > 0) {
@@ -1447,6 +1554,9 @@ export function pipeUpstreamStream(
1447
1554
  const flushed = flushBufferedToolArgs(cloakState, cloak);
1448
1555
  if (flushed !== null) forwardText(flushed);
1449
1556
  }
1557
+ // A tail-framed failure frame may have started a hold during the flush
1558
+ // above; the rescue owns the turn now.
1559
+ if (refirePending || heldFailureChunk !== null) return;
1450
1560
  if (bodyGate !== "sse") {
1451
1561
  // Non-SSE passthrough: same relay-fault accounting as the SSE path's
1452
1562
  // mid-body close (a genuine upstream socket death, not a client
package/src/types.ts CHANGED
@@ -16,7 +16,7 @@ export type ThinkingLevel =
16
16
 
17
17
  export type ThinkingLevelMap = Partial<Record<ThinkingLevel, string | null>>;
18
18
 
19
- export type ModelInputType = "text" | "image" | "video";
19
+ export type ModelInputType = "text" | "image";
20
20
 
21
21
  export interface ModelDef {
22
22
  id: string;