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 +21 -0
- package/README.md +36 -36
- package/package.json +1 -1
- package/src/catalog.ts +13 -1
- package/src/health.ts +3 -0
- package/src/models.ts +152 -133
- package/src/proxy.ts +26 -9
- package/src/relay.ts +41 -4
- package/src/stream-pipe.ts +141 -31
- package/src/types.ts +1 -1
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
|
-
>
|
|
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
|
-
| **
|
|
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/
|
|
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/
|
|
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
|
-
###
|
|
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
|
|
223
|
-
|
|
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 | **
|
|
230
|
-
| `mimo-v2.6-flash-free` | Xiaomi MiMo | **1M** (1.048.576) | **
|
|
231
|
-
| `nemotron-3.5-lightning-free` | NVIDIA | **1M** (1.000.000) | **
|
|
232
|
-
| `nemotron-3.5-super-vl-free` | NVIDIA | **1M** (1.
|
|
233
|
-
| `nemotron-3-ultra-free` | NVIDIA | **1M** (1.000.000) | **
|
|
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
|
-
| `
|
|
236
|
-
| `
|
|
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) | `
|
|
240
|
-
| `deepseek-v4.1-flash-free` | DeepSeek | **1M** (1.
|
|
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 (
|
|
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 | **
|
|
249
|
-
| `nemotron-3-nano-omni` (`nvidia/...:free`) | NVIDIA | **
|
|
250
|
-
| `nemotron-3-ultra-550b` (`nvidia/...:free`) | NVIDIA | **1M** (1.000.000) | **
|
|
251
|
-
| `nvidia/nemotron-3.5-lightning:free` | NVIDIA | **1M** (1.000.000) | **
|
|
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 | **
|
|
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 | **
|
|
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).
|
|
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) | **
|
|
286
|
-
| `gpt-oss-20b` (`ovh-free/gpt-oss-20b`) | OpenAI | **128K** (131.072) | **
|
|
287
|
-
| `qwen3.5-397b` (`ovh-free/Qwen3.5-397B-A17B`) | Alibaba Qwen | **256K** (262.144) | **
|
|
288
|
-
| `qwen3.5-9b` (`ovh-free/Qwen3.5-9B`) | Alibaba Qwen | **256K** (262.144) | **
|
|
289
|
-
| `qwen3.6-27b` (`ovh-free/Qwen3.6-27B`) | Alibaba Qwen | **256K** (262.144) | **
|
|
290
|
-
| `qwen3.8-27b` (`ovh-free/Qwen3.8-27B`) | Alibaba Qwen | **256K** (262.144) | **
|
|
291
|
-
| `mistral-small-3.2` (`ovh-free/Mistral-Small-3.2-24B-Instruct-2506`) | Mistral AI | **128K** (131.072) | **
|
|
292
|
-
| `llama-3.3-70b` (`ovh-free/Meta-Llama-3_3-70B-Instruct`) | Meta | **128K** (131.072) | **
|
|
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 #
|
|
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.
|
|
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
|
|
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
|
|
5
|
-
* -
|
|
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
|
-
* -
|
|
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
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
//
|
|
27
|
-
contextWindow:
|
|
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"
|
|
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:
|
|
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
|
-
|
|
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
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
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:
|
|
120
|
-
maxTokens:
|
|
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
|
-
|
|
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:
|
|
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
|
-
//
|
|
187
|
-
//
|
|
188
|
-
//
|
|
189
|
-
//
|
|
190
|
-
// the
|
|
191
|
-
// 450K/700K-word recall probes timed out upstream and stayed
|
|
192
|
-
// maxTokens
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
//
|
|
198
|
-
// (/v1/responses answers 400
|
|
199
|
-
// so `api` stays unset. Vision
|
|
200
|
-
//
|
|
201
|
-
//
|
|
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
|
-
|
|
206
|
-
|
|
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:
|
|
212
|
+
minimal: null,
|
|
211
213
|
low: "low",
|
|
212
|
-
medium:
|
|
214
|
+
medium: null,
|
|
213
215
|
high: "high",
|
|
214
|
-
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:
|
|
269
|
-
low:
|
|
270
|
-
medium:
|
|
273
|
+
minimal: "minimal",
|
|
274
|
+
low: "low",
|
|
275
|
+
medium: "medium",
|
|
271
276
|
high: "high",
|
|
272
|
-
xhigh:
|
|
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
|
|
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
|
|
284
|
-
// recalled through prompt_tokens=1,030,800, and
|
|
285
|
-
// oversized request away with an empty-bodied 400
|
|
286
|
-
//
|
|
287
|
-
//
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
// 384,
|
|
291
|
-
//
|
|
292
|
-
|
|
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
|
-
//
|
|
299
|
-
//
|
|
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:
|
|
311
|
+
minimal: null,
|
|
303
312
|
low: "low",
|
|
304
|
-
medium:
|
|
313
|
+
medium: null,
|
|
305
314
|
high: "high",
|
|
306
|
-
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 (
|
|
337
|
-
reasoning: true,
|
|
338
|
-
|
|
339
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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:
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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"
|
|
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"
|
|
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:
|
|
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 (
|
|
559
|
-
*
|
|
560
|
-
*
|
|
561
|
-
*
|
|
562
|
-
*
|
|
563
|
-
*
|
|
564
|
-
*
|
|
565
|
-
*
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
648
|
+
maxTokens: 235_929,
|
|
627
649
|
input: ["text", "image"],
|
|
628
|
-
thinkingLevelMap:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
1812
|
-
//
|
|
1813
|
-
// finds nothing usable, the pipe
|
|
1814
|
-
// on socket-error paths; bounded
|
|
1815
|
-
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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");
|
package/src/stream-pipe.ts
CHANGED
|
@@ -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
|
|
828
|
-
*
|
|
829
|
-
*
|
|
830
|
-
*
|
|
831
|
-
*
|
|
832
|
-
*
|
|
833
|
-
*
|
|
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
|
|
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
|
|
965
|
-
*
|
|
966
|
-
*
|
|
967
|
-
*
|
|
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
|
|
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
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
1097
|
-
if (isTransientModelFailureFrame(scanned) &&
|
|
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"
|
|
19
|
+
export type ModelInputType = "text" | "image";
|
|
20
20
|
|
|
21
21
|
export interface ModelDef {
|
|
22
22
|
id: string;
|