pi-freeflow 1.41.0 → 1.42.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,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.42.0
4
+
5
+ ### Minor Changes
6
+
7
+ - b247ea6: Enable relay pool routing for OVHcloud AI Endpoints to bypass per-IP rate limits during burst and subagent requests. Retire paid `z-ai/glm-5.3-flash` and update the Cline lane to true free-tier models (`cline/nemotron-3.5-super-vl`). Implement dynamic Cline version fetching from Open VSX with disk caching, and background keep-alive auto-probing so saved Cline account tokens never expire.
8
+
9
+ ### Patch Changes
10
+
11
+ - 2b1f434: Roll Cline pool to the next slot when OpenRouter free models return in-stream 429 errors within HTTP 200 SSE streams.
12
+ - 92532c3: Fix DeepSeek follow-up requests in Pi being rejected when earlier assistant messages have no reasoning content.
13
+ - a6d9834: Retire unserved `cat-jam-free` (persistent upstream 404), correct `mimo-v2.6-flash-free` input modality to text-only (upstream rejects image input), and strip `max_output_tokens` on the OVH lane so translated Responses requests no longer trigger upstream 400 extra arguments errors. Model catalog count updated from 35 to 34 across documentation and tests.
14
+ - debb162: Report client version 1.19.4 to OpenCode, matching the current upstream release. The session-ID format is unchanged: upstream refactored its generator in the same release, and identifiers are still `ses_` followed by 12 hexadecimal timestamp characters and 14 random characters.
15
+ - b5083bd: Stop OVH Qwen models from failing with `400 feature 'extra arguments: {"chat_template_kwargs": …}' is not currently supported`.
16
+
17
+ The host classifies the loopback proxy as a local OpenAI-compatible backend before model compat overrides land, so `Qwen3.x` ids were treated as template-dialect Qwen and every OVH request carried `chat_template_kwargs` (`enable_thinking`, `preserve_thinking`). OVH's OpenAI-compatible surface rejects those fields. The OVH lane now pins `thinkingFormat: "openai"` and clears `qwenPreserveThinking`, and `handleOvhRequest` strips any Qwen template controls that still reach the proxy.
18
+ - 92a818a: Normalize OVH reasoning stream chunks from `delta.reasoning` to standard `delta.reasoning_content` so OMP and OpenAI-compatible clients capture thinking tokens without reporting empty turns. Defensively strip `reasoning_effort` on non-reasoning OVH models (`Meta-Llama-3_3-70B-Instruct` and `Mistral-Small-3.2-24B-Instruct-2506`) to prevent upstream HTTP 400 unsupported feature errors.
19
+ - debb162: Remove MiniMax M3.1 Flash from the free catalog: both free endpoints are gone. OpenCode Zen answers HTTP 410 with a model-level deprecation pointing at `deepseek-v4.1-flash-free`, and the KiloCode free id answers HTTP 404 "does not exist" while the gateway listing keeps only a paid id. Both leave the picker and both stay dead-listed, so a stale cache cannot bring them back. The `minimax-m3.1-flash` short name is removed rather than pointing at a paid model. The catalog is 35 models: 11 from OpenCode Zen, 15 from KiloCode Gateway, 1 from Cline, 8 from OVHcloud AI Endpoints.
20
+
3
21
  ## 1.41.0
4
22
 
5
23
  ### Minor Changes
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # pi-freeflow
2
2
 
3
- > 37 free models with up to 1M context. No API keys to manage. Add your own relays to spread requests across more IPs.
3
+ > 34 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
- | **37 free models** | 12 from OpenCode Zen, 16 from KiloCode Gateway, 1 from Cline, 8 from OVHcloud AI Endpoints, context windows up to 1M. Full list below. | **$0** |
19
+ | **34 free models** | 10 from OpenCode Zen, 15 from KiloCode Gateway, 1 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 Cline model has no alias and must be typed in full. | **$0** |
@@ -214,24 +214,23 @@ The same command set works identically in OMP and Pi:
214
214
 
215
215
  ---
216
216
 
217
- ### 37 models, one command
217
+ ### 34 models, one command
218
218
 
219
219
  ```bash
220
220
  /model → freeflow → pick
221
221
  ```
222
222
 
223
- Every model specification is reconciled against [models.dev](https://models.dev/), [OpenRouter](https://openrouter.ai/), and OMP's built-in catalog on 2026-10-22: 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. Where the lane itself refuses above a published figure, the served boundary wins (MiniMax M3.1 Flash: published 512,000, served 524,288). (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
+ Every model specification is reconciled against [models.dev](https://models.dev/), [OpenRouter](https://openrouter.ai/), and OMP's built-in catalog on 2026-10-22: 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. Where the lane itself refuses above a published figure, the served boundary wins (DeepSeek V4.1 Flash: published 384,000, served 393,216). (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). These tables mirror `src/models.ts`, and the scheduled health check retires a model only after a live model-level 404/410 — so a row that no longer answers is a bug worth filing.
224
224
 
225
225
  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`.
226
226
 
227
- #### OpenCode Zen (12 models), Chat Completions API
227
+ #### OpenCode Zen (10 models), Chat Completions API
228
228
 
229
229
  Good defaults for long coding sessions and agentic work.
230
230
 
231
231
  | Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
232
232
  | :--- | :--- | :--- | :--- | :--- | :--- |
233
- | `cat-jam-free` | Cat Jam | **1M** (1.048.576) | **131K** (131.072) | `low … max` | ❌ |
234
- | `mimo-v2.6-flash-free` | Xiaomi MiMo | **1M** (1.048.576) | **128K** (131.072) | `minimal … xhigh`\* | ✅ |
233
+ | `mimo-v2.6-flash-free` | Xiaomi MiMo | **1M** (1.048.576) | **128K** (131.072) | `minimal … xhigh`\* | ❌ |
235
234
  | `nemotron-3.5-lightning-free` | NVIDIA | **1M** (1.000.000) | **235K** (235.929) | `minimal … xhigh` | ❌ |
236
235
  | `nemotron-3.5-super-vl-free` | NVIDIA | **1M** (1.048.576) | **235K** (235.929) | `minimal … xhigh` | ✅ |
237
236
  | `nemotron-3-ultra-free` | NVIDIA | **1M** (1.000.000) | **182K** (182.520) | `minimal … xhigh` | ❌ |
@@ -240,10 +239,9 @@ Good defaults for long coding sessions and agentic work.
240
239
  | `glm-5.3-flash-free` | Zhipu AI | **1M** (1.048.576) | **131K** (131.072) | `low / high / max` | ✅ |
241
240
  | `beam-free` | Reflection AI | **1M** (1.048.576) | **131K** (131.072) | `low … max` | ❌ |
242
241
  | `deepseek-v4.1-flash-free` | DeepSeek | **1M** (1.048.576) | **393K** (393.216) | `low / high / max` | ✅ |
243
- | `minimax-m3.1-flash-free` | MiniMax | **1M** (1.000.000) | **512K** (524.288) | `low … max` | ✅ |
244
242
  | `flint-40b-free` | Recursive | **262K** (262.144) | **65K** (65.536) | `low … max` | ❌ |
245
243
 
246
- #### KiloCode Gateway (16 models), OpenRouter compatible
244
+ #### KiloCode Gateway (15 models), OpenRouter compatible
247
245
 
248
246
  Keyless access. Most rows have a short alias (shown with the full ID in parentheses); a row that shows only the full ID has no short form.
249
247
 
@@ -260,7 +258,6 @@ Keyless access. Most rows have a short alias (shown with the full ID in parenthe
260
258
  | `kilo-auto` (`kilo-auto/free`) | Kilo Gateway Auto | **256K** (256.000) | **10K** (10.000) | `minimal…xhigh`\* | ❌ |
261
259
  | `openrouter` (`openrouter/free`) | OpenRouter Free | **200K** (200.000) | **65K** (65.536) | `minimal…xhigh`\* | ✅ |
262
260
  | `glyph-cluster` (`stealth/glyph-cluster`) | Stealth preview (lab undisclosed) | **256K** (256.000) | **256K** (256.000) | `minimal…xhigh`\* | ❌ |
263
- | `minimax/minimax-m3.1-flash:free` | MiniMax | **1M** (1.000.000) | **512K** (524.288) | `low…max`\* | ✅ |
264
261
  | `nvidia/nemotron-3.5-super-vl:free` | NVIDIA | **1M** (1.000.000) | **131K** (131.072) | `minimal…xhigh`\* | ✅ |
265
262
  | `beam-kilo` (`reflection/beam-free`) | Reflection AI | **1M** (1.048.576) | **131K** (131.072) | `low…max`\* | ❌ |
266
263
  | `flint-kilo` (`recursive/flint-v1.0:free`) | Recursive | **262K** (262.144) | **65K** (65.536) | `minimal…xhigh`\* | ❌ |
@@ -278,10 +275,11 @@ Each account can only be saved once: an extra login for an account you already h
278
275
 
279
276
  | Model ID | Creator / Lab | Context | Max Output | Thinking | Vision |
280
277
  | :--- | :--- | :--- | :--- | :--- | :--- |
281
- | `z-ai/glm-5.3-flash` | Zhipu AI | **1M** (1.048.576) | **131K** (131.072) | `low/high/max` | ✅ |
278
+ | `cline-super-vl` (`cline/nemotron-3.5-super-vl`) | NVIDIA | **1M** (1.000.000) | **131K** (131.072) | `minimal … xhigh` | ✅ |
279
+
282
280
  #### OVHcloud AI Endpoints (8 models), OpenAI compatible
283
281
 
284
- 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.
282
+ Anonymous access — no account, no key, nothing to sign in to. When the relay pool is armed, OVH requests route through your relays to bypass the 2 req/min per-IP limit for burst requests and subagents; otherwise requests route direct.
285
283
 
286
284
  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`.
287
285
  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.
@@ -396,7 +394,7 @@ pnpm smoke # verifies extensions/index.ts loads without crashing
396
394
  #### Project structure
397
395
 
398
396
  ```
399
- ├── models.ts # 37-model catalog definitions
397
+ ├── models.ts # 34-model catalog definitions
400
398
  ├── index.ts # extension entry, lifecycle hooks
401
399
  ├── catalog.ts # model catalog cache (24h disk)
402
400
  ├── 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.41.0",
4
+ "version": "1.42.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,7 +101,8 @@ export const DEAD_MODEL_IDS = new Set<string>([
101
101
  "mimo-v2.5-free",
102
102
  "stealth/space-bunny-alpha",
103
103
  // space-bunny-free: deprecated on the Zen free tier (2026-10-20) — the
104
- // endpoint answers 410 ModelDeprecated and points at minimax-m3.1-flash-free.
104
+ // endpoint answers 410 ModelDeprecated and points at minimax-m3.1-flash-free,
105
+ // which was itself retired on 2026-10-23 (see below).
105
106
  "space-bunny-free",
106
107
  "qwen/qwen3.8-27b:free",
107
108
  "nvidia/nemotron-3.5-content-safety:free",
@@ -159,6 +160,28 @@ export const DEAD_MODEL_IDS = new Set<string>([
159
160
  // path and gone from the live Zen listing, four days after it was re-registered
160
161
  // from a 2026-10-16 revival. Permanently dead-listed.
161
162
  "exo-free",
163
+ // minimax-m3.1-flash-free: the Zen free endpoint of MiniMax M3.1 Flash was
164
+ // retired on 2026-10-23 — it answers HTTP 410 with a model-level deprecation
165
+ // and points at deepseek-v4.1-flash-free. The KiloCode free twin of the same
166
+ // weights died the same day (see below), so both free endpoints are gone.
167
+ "minimax-m3.1-flash-free",
168
+ // minimax/minimax-m3.1-flash:free: the KiloCode free endpoint was retired on
169
+ // 2026-10-23 too — a direct gateway request answers HTTP 404 "The requested
170
+ // model 'minimax/minimax-m3.1-flash:free' does not exist" (model_not_found)
171
+ // on every attempt, and the live /api/gateway/models listing carries only the
172
+ // PAID minimax/minimax-m3.1-flash. No short alias exists for it: a short name
173
+ // must never route to a paid model.
174
+ "minimax/minimax-m3.1-flash:free",
175
+ // cat-jam-free: retired upstream (2026-10-24) — every request path
176
+ // (chat non-stream, chat stream, and responses) through the relay pool answers
177
+ // persistent HTTP 404 "Upstream request failed: Endpoint is unavailable." across
178
+ // multiple independent runs, while sibling OpenCode models (deepseek, mimo,
179
+ // nemotron) answer 200 on the same request shapes.
180
+ "cat-jam-free",
181
+ // z-ai/glm-5.3-flash: retired from the Cline free catalog (2026-10-24) — paid
182
+ // model that drains account credits and answers HTTP 402 insufficient_credits.
183
+ // Replaced with Cline's verified working free tier model.
184
+ "z-ai/glm-5.3-flash",
162
185
  ]);
163
186
  /**
164
187
  * Free-tier allowlist for anything entering the picker via network or stale disk.
@@ -198,7 +221,7 @@ export function sanitizeCatalogModels(models: RegisteredModel[]): RegisteredMode
198
221
  }
199
222
  /**
200
223
  * In-memory cache of currently active/available free models.
201
- * Initialized with all 37 verified models for 0ms instant availability.
224
+ * Initialized with all 35 verified models for 0ms instant availability.
202
225
  */
203
226
  let aliveCatalog: RegisteredModel[] = ALL_MODELS.map((m) => ({
204
227
  ...m,
@@ -16,7 +16,8 @@ import { writeFileAtomic } from "./atomic-write.ts";
16
16
  import { RELAY_STATE_FILE } from "./config.ts";
17
17
  import { logWarn } from "./logger.ts";
18
18
  import type { FetchImpl } from "./cline-device-auth.ts";
19
- import { isWorkosJwt } from "./cline-device-auth.ts";
19
+ import { isWorkosJwt, refreshClineToken, toApiKey } from "./cline-device-auth.ts";
20
+ import { fetchWithSystemCA } from "./system-ca-fetch.ts";
20
21
 
21
22
  /** Env override for the pool file location (tests/CI sandbox). */
22
23
  const CLINE_POOL_FILE_ENV = "PI_FREEFLOW_CLINE_POOL_FILE";
@@ -115,38 +116,183 @@ export function _resetClinePoolCacheForTest(): void {
115
116
  cachedMtime = -1;
116
117
  }
117
118
 
118
- /** Version this proxy reports as its Cline client build. */
119
- const CLINE_CLIENT_VERSION = "3.5.54";
119
+ /** Bundled fallback version if dynamic fetch and disk cache are absent. */
120
+ export const BUNDLED_CLINE_VERSION = "4.1.25";
121
+ export const CLINE_CLIENT_VERSION = BUNDLED_CLINE_VERSION;
122
+
123
+ const CLINE_VERSION_TTL_MS = 24 * 60 * 60 * 1000;
124
+ let liveClineVersion: string | null = null;
125
+
126
+ function clineVersionCachePath(): string {
127
+ return path.join(path.dirname(CLINE_POOL_FILE), "pi-freeflow-cline-version.json");
128
+ }
129
+
130
+ export function _resetLiveClineVersionForTest(): void {
131
+ liveClineVersion = null;
132
+ }
120
133
 
121
134
  /**
122
- * Client headers the Cline API expects. Measured live (2026-09-21): the
123
- * `cline-free/*` models answer 403 "only available via Cline product surfaces"
124
- * with a bare bearer, and 200 with this set; every non-prefixed catalog model
125
- * (e.g. z-ai/glm-5.3-flash) answers 200 either way. Header names and shape
126
- * mirror reference/cline providers/request-headers.ts
127
- * (DEFAULT_CLINE_REQUEST_HEADERS) — no credential is derived from them.
128
- *
129
- * `X-CLIENT-TYPE` is the Cline desktop app's identity, and it gates the
130
- * `cline-free/*` id space (measured live 2026-09-22: that prefix answers 403
131
- * "only available via Cline product surfaces" with a bare bearer, and 200 with
132
- * this set; every non-prefixed id answers 200 either way). The header is kept
133
- * because `cline-free/*` ids can still appear in a stale host registration —
134
- * until 2026-10-13 that prefix covered four models, and
135
- * `cline-free/muse-spark-1.3-contributor` survived it until 2026-10-22. It also
136
- * decided the free-model feed at the time: five entries for
137
- * `cline-sdk` / `cline-cli` / the `VSCode Extension` label against six for
138
- * `cline-desktop`, the extra one being `cline-free/kimi-k3`. That sixth model
139
- * is retired upstream and no longer requested. Version and User-Agent stay
140
- * as-is: the feed served the desktop list with them unchanged.
135
+ * Resolve effective Cline client version. Precedence:
136
+ * env override (PI_FREEFLOW_CLINE_VERSION) > in-memory version > fresh disk cache > BUNDLED_CLINE_VERSION.
141
137
  */
142
- const CLINE_CLIENT_HEADERS: Record<string, string> = {
143
- "HTTP-Referer": "https://cline.bot",
144
- "X-Title": "Cline",
145
- "X-IS-MULTIROOT": "false",
146
- "X-CLIENT-TYPE": "cline-desktop",
147
- "X-CLIENT-VERSION": CLINE_CLIENT_VERSION,
148
- "User-Agent": `Cline/${CLINE_CLIENT_VERSION}`,
149
- };
138
+ export function getClineVersion(): string {
139
+ const override = process.env.PI_FREEFLOW_CLINE_VERSION?.trim();
140
+ if (override) return override;
141
+ if (liveClineVersion) return liveClineVersion;
142
+ try {
143
+ const cachePath = clineVersionCachePath();
144
+ if (fs.existsSync(cachePath)) {
145
+ const data = JSON.parse(fs.readFileSync(cachePath, "utf8")) as { version?: string; checkedAt?: number };
146
+ if (typeof data.version === "string" && data.version.trim() && typeof data.checkedAt === "number" && Date.now() - data.checkedAt < CLINE_VERSION_TTL_MS) {
147
+ liveClineVersion = data.version.trim();
148
+ return liveClineVersion;
149
+ }
150
+ }
151
+ } catch { }
152
+ return BUNDLED_CLINE_VERSION;
153
+ }
154
+
155
+ /**
156
+ * Client headers the Cline API expects, evaluated dynamically with the current version.
157
+ */
158
+ export function getClineClientHeaders(): Record<string, string> {
159
+ const ver = getClineVersion();
160
+ return {
161
+ "HTTP-Referer": "https://cline.bot",
162
+ "X-Title": "Cline",
163
+ "X-IS-MULTIROOT": "false",
164
+ "X-CLIENT-TYPE": "cline-desktop",
165
+ "X-CLIENT-VERSION": ver,
166
+ "User-Agent": `Cline/${ver}`,
167
+ };
168
+ }
169
+
170
+ /**
171
+ * Proxy object for CLINE_CLIENT_HEADERS providing full backward compatibility.
172
+ */
173
+ export const CLINE_CLIENT_HEADERS: Record<string, string> = new Proxy({} as Record<string, string>, {
174
+ get(_target, prop) {
175
+ return getClineClientHeaders()[prop as string];
176
+ },
177
+ has(_target, prop) {
178
+ return prop in getClineClientHeaders();
179
+ },
180
+ ownKeys(_target) {
181
+ return Object.keys(getClineClientHeaders());
182
+ },
183
+ getOwnPropertyDescriptor(_target, prop) {
184
+ const val = getClineClientHeaders()[prop as string];
185
+ return val !== undefined ? { value: val, writable: false, enumerable: true, configurable: true } : undefined;
186
+ },
187
+ });
188
+
189
+ /**
190
+ * Refresh the live Cline version from open-vsx or GitHub releases and persist to disk.
191
+ */
192
+ export async function refreshClineVersion(fetchImpl: FetchImpl = fetchWithSystemCA): Promise<string> {
193
+ try {
194
+ const res = await fetchImpl("https://open-vsx.org/api/saoudrizwan/claude-dev", {
195
+ headers: { "User-Agent": "pi-freeflow" },
196
+ signal: AbortSignal.timeout(5000),
197
+ });
198
+ if (res.ok) {
199
+ const data = (await res.json()) as { version?: string };
200
+ if (typeof data.version === "string" && /^\d+\.\d+\.\d+/.test(data.version)) {
201
+ liveClineVersion = data.version.trim();
202
+ try {
203
+ const cachePath = clineVersionCachePath();
204
+ writeFileAtomic(cachePath, JSON.stringify({ version: liveClineVersion, checkedAt: Date.now() }));
205
+ } catch { }
206
+ return liveClineVersion;
207
+ }
208
+ }
209
+ } catch { }
210
+ return getClineVersion();
211
+ }
212
+ async function defaultClineRefresher(refreshToken: string): Promise<ClineRefreshResult | null> {
213
+ try {
214
+ const creds = await refreshClineToken(undefined, refreshToken);
215
+ return { token: toApiKey(creds.access), refreshToken: creds.refresh, expiresAt: creds.expires };
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+ /**
221
+ * Background auto-probing & keep-alive for Cline account tokens.
222
+ * Proactively refreshes tokens nearing expiry (<30 min remaining or expired)
223
+ * so that saved accounts never lapse into unrecoverable dead grants.
224
+ * Also performs a lightweight ping to verify the account is active.
225
+ */
226
+ export async function keepClineTokensAlive(
227
+ opts: {
228
+ fetchImpl?: FetchImpl;
229
+ refreshImpl?: ClineRefresher;
230
+ } = {},
231
+ ): Promise<{ refreshed: number; probed: number }> {
232
+ const pool = loadPool();
233
+ if (pool.accounts.length === 0) return { refreshed: 0, probed: 0 };
234
+ const fetchImpl = opts.fetchImpl ?? fetchWithSystemCA;
235
+ const refreshImpl = opts.refreshImpl ?? defaultClineRefresher;
236
+ const now = Date.now();
237
+ const REFRESH_THRESHOLD_MS = 30 * 60 * 1000;
238
+ let refreshed = 0;
239
+ let probed = 0;
240
+
241
+ for (const account of pool.accounts) {
242
+ if (!account.refreshToken) continue;
243
+ const isExpiringSoon = typeof account.expiresAt === "number" && (account.expiresAt - now < REFRESH_THRESHOLD_MS);
244
+ const isExpired = typeof account.expiresAt === "number" && account.expiresAt <= now;
245
+ const needsRefresh = isExpiringSoon || isExpired || isClineTokenStale(account);
246
+
247
+ if (needsRefresh) {
248
+ try {
249
+ const outcome = await refreshAccountInPlace(account, refreshImpl);
250
+ if (outcome === true) {
251
+ refreshed += 1;
252
+ }
253
+ } catch {
254
+ continue;
255
+ }
256
+ }
257
+
258
+ // Light probe: ping models endpoint with light timeout to keep the session warm
259
+ try {
260
+ const token = account.token;
261
+ const probeRes = await fetchImpl("https://api.cline.bot/api/v1/models", {
262
+ method: "GET",
263
+ headers: {
264
+ ...getClineClientHeaders(),
265
+ authorization: `Bearer ${token}`,
266
+ },
267
+ signal: AbortSignal.timeout(6000),
268
+ });
269
+ if (probeRes.ok) {
270
+ probed += 1;
271
+ }
272
+ } catch { }
273
+ }
274
+
275
+ return { refreshed, probed };
276
+ }
277
+
278
+ export const CLINE_MODEL_PREFIX = "cline/";
279
+
280
+ /**
281
+ * Maps a catalog Cline model ID to the exact wire model ID expected by Cline API.
282
+ */
283
+ export function clineUpstreamModelId(id: string): string {
284
+ const trimmed = id.trim();
285
+ if (trimmed === "cline/nemotron-3.5-super-vl") {
286
+ return "nvidia/nemotron-3.5-super-vl:free";
287
+ }
288
+ if (trimmed.startsWith(CLINE_MODEL_PREFIX)) {
289
+ return trimmed.slice(CLINE_MODEL_PREFIX.length);
290
+ }
291
+ if (trimmed.startsWith("cline-free/")) {
292
+ return trimmed.slice("cline-free/".length);
293
+ }
294
+ return trimmed;
295
+ }
150
296
 
151
297
  /**
152
298
  * Free-limit report carried by a Cline 429 body, e.g.
@@ -166,7 +312,19 @@ interface ClineFreeLimit {
166
312
  resetAt?: number;
167
313
  }
168
314
 
169
- const CLINE_FREE_LIMIT_MARKER = /free limit reached on model/i;
315
+ const CLINE_FREE_LIMIT_MARKER = /(?:free limit reached on model|Daily limit reached for)/i;
316
+
317
+ /**
318
+ * Extract epoch reset from X-RateLimit-Reset if present in headers or metadata.
319
+ * Converts epoch seconds (<1e11) to ms.
320
+ */
321
+ function parseXRateLimitReset(text: string): number | null {
322
+ const m = text.match(/X-RateLimit-Reset[\\"\s:]+([0-9.]+)/i);
323
+ if (!m) return null;
324
+ const val = Number(m[1]);
325
+ if (!Number.isFinite(val) || val <= 0) return null;
326
+ return val < 1e11 ? Math.round(val * 1000) : Math.round(val);
327
+ }
170
328
 
171
329
  /**
172
330
  * Delay stated as `try again in <N>h <N>m`, `<N>m`, or a bare `<N>` (hours).
@@ -191,13 +349,15 @@ function parseResetDelayMs(text: string): number | null {
191
349
  */
192
350
  export function parseClineFreeLimit(raw: string, now = Date.now()): ClineFreeLimit | null {
193
351
  if (typeof raw !== "string" || !CLINE_FREE_LIMIT_MARKER.test(raw)) return null;
194
- const named = raw.match(/free limit reached on model\s+(\S+)/i);
352
+ const named = raw.match(/(?:free limit reached on model|Daily limit reached for)\s+([^\\"\s,;]+)/i);
195
353
  // The model name ends the sentence, so a trailing "." belongs to the prose.
196
354
  const modelId = named ? named[1].replace(/[.,;:]+$/, "") : "";
197
- const delay = parseResetDelayMs(raw);
355
+ const epochReset = parseXRateLimitReset(raw);
356
+ const delay = epochReset !== null ? null : parseResetDelayMs(raw);
357
+ const resetAt = epochReset !== null ? epochReset : delay !== null ? now + delay : undefined;
198
358
  return {
199
359
  ...(modelId ? { modelId } : {}),
200
- ...(delay !== null ? { resetAt: now + delay } : {}),
360
+ ...(resetAt !== undefined ? { resetAt } : {}),
201
361
  };
202
362
  }
203
363
 
@@ -894,6 +1054,82 @@ function exhaustedResponse(reason: string, status: number): Response {
894
1054
  );
895
1055
  }
896
1056
 
1057
+ /**
1058
+ * For OpenRouter-backed Cline models, Cline sometimes answers HTTP 200
1059
+ * `text/event-stream` where the first event is an in-stream 429 error
1060
+ * (e.g. `stream_initialization_failed` with status 429 / daily limit reached).
1061
+ * Peek the first chunk: if it is an in-stream 429 error, convert it to a 429
1062
+ * Response so rollChat rolls to the next slot; otherwise, reconstruct the
1063
+ * stream with the peeked chunk re-enqueued.
1064
+ */
1065
+ async function peekStreamForInStreamError(res: Response): Promise<{ res: Response; inStreamError: boolean; errorText: string }> {
1066
+ const contentType = res.headers.get("content-type") || "";
1067
+ if (!contentType.includes("text/event-stream") || !res.body) {
1068
+ return { res, inStreamError: false, errorText: "" };
1069
+ }
1070
+ try {
1071
+ const reader = res.body.getReader();
1072
+ const { done, value } = await reader.read();
1073
+ if (done || !value || value.byteLength === 0) {
1074
+ return {
1075
+ res: new Response(null, { status: res.status, statusText: res.statusText, headers: res.headers }),
1076
+ inStreamError: false,
1077
+ errorText: "",
1078
+ };
1079
+ }
1080
+ const text = new TextDecoder().decode(value);
1081
+ const isInStream429 = (
1082
+ text.includes('"stream_initialization_failed"') ||
1083
+ text.includes('"error":') ||
1084
+ text.includes('"Rate limit exceeded"') ||
1085
+ text.includes("Daily limit reached")
1086
+ ) && (
1087
+ text.includes("429") ||
1088
+ /status["\s:]+429/i.test(text) ||
1089
+ /Rate limit exceeded/i.test(text) ||
1090
+ /Daily limit reached/i.test(text)
1091
+ );
1092
+
1093
+ if (isInStream429) {
1094
+ try { await reader.cancel(); } catch { }
1095
+ const errRes = new Response(text, {
1096
+ status: 429,
1097
+ headers: { "content-type": "application/json" },
1098
+ });
1099
+ return { res: errRes, inStreamError: true, errorText: text };
1100
+ }
1101
+
1102
+ const reconstructed = new ReadableStream<Uint8Array>({
1103
+ start(controller) {
1104
+ controller.enqueue(value);
1105
+ },
1106
+ async pull(controller) {
1107
+ try {
1108
+ const { done: nextDone, value: nextValue } = await reader.read();
1109
+ if (nextDone) {
1110
+ controller.close();
1111
+ } else if (nextValue) {
1112
+ controller.enqueue(nextValue);
1113
+ }
1114
+ } catch (err) {
1115
+ controller.error(err);
1116
+ }
1117
+ },
1118
+ cancel(reason) {
1119
+ return reader.cancel(reason);
1120
+ },
1121
+ });
1122
+
1123
+ return {
1124
+ res: new Response(reconstructed, { status: res.status, statusText: res.statusText, headers: res.headers }),
1125
+ inStreamError: false,
1126
+ errorText: "",
1127
+ };
1128
+ } catch {
1129
+ return { res, inStreamError: false, errorText: "" };
1130
+ }
1131
+ }
1132
+
897
1133
  /**
898
1134
  * POST one chat body to the Cline endpoint, rolling across every saved slot.
899
1135
  * Each attempt sends its slot bearer explicitly; raw tokens never leave this
@@ -1028,6 +1264,14 @@ export async function rollChat(opts: ClineRollOpts): Promise<ClineRollResult> {
1028
1264
  };
1029
1265
  let res = await attempt(account.token);
1030
1266
  if (!res) continue;
1267
+ let inStreamLimitText: string | null = null;
1268
+ if (res.status === 200 && res.headers.get("content-type")?.includes("text/event-stream")) {
1269
+ const peek = await peekStreamForInStreamError(res);
1270
+ res = peek.res;
1271
+ if (peek.inStreamError) {
1272
+ inStreamLimitText = peek.errorText;
1273
+ }
1274
+ }
1031
1275
  let kind = mapClineError(res.status);
1032
1276
  if (kind === "ok" || kind === "client") return await succeed(res, kind);
1033
1277
  // Auth failure on a refreshable slot that has not refreshed yet: one
@@ -1057,7 +1301,7 @@ export async function rollChat(opts: ClineRollOpts): Promise<ClineRollResult> {
1057
1301
  // for the requested model until the stated reset: remember it, so the next
1058
1302
  // turn tries the other logins first.
1059
1303
  if (res.status === 429) {
1060
- const limit = await freeLimitFrom(res, now);
1304
+ const limit = inStreamLimitText ? parseClineFreeLimit(inStreamLimitText, now) : await freeLimitFrom(res, now);
1061
1305
  if (limit) {
1062
1306
  cappedSlots.add(account.slot);
1063
1307
  if (limit.modelId) limitModelId = limit.modelId;
package/src/config.ts CHANGED
@@ -75,7 +75,7 @@ export const PORT = resolvePort();
75
75
  // OPENCODE_VERSION_FLOOR pins the minimum; OPENCODE_VERSION_FALLBACK is the
76
76
  // last-known-good npm version used when the live lookup is stale/offline.
77
77
  export const OPENCODE_VERSION_FLOOR = "1.17.0";
78
- export const OPENCODE_VERSION_FALLBACK = "1.19.0";
78
+ export const OPENCODE_VERSION_FALLBACK = "1.19.4";
79
79
  /** Live npm lookup TTL: 6h — within one window the cached UA is reused. */
80
80
  export const OPENCODE_VERSION_TTL_MS = 6 * 60 * 60 * 1_000;
81
81
  /** Env override: exact `opencode/<version>` UA (or bare version) for tests/pins. */
@@ -239,6 +239,10 @@ export function createOpenCodeId(descending: boolean, timestamp = Date.now()): s
239
239
  return time + Array.from(bytes, (byte) => ID_CHARS[byte % 62]).join("");
240
240
  }
241
241
 
242
+ // Upstream v1.19.4 refactored `SessionID.create` to compose the prefix itself
243
+ // (`schema.make("ses_" + descending())` in packages/schema/src/session-id.ts).
244
+ // The identifier generator is unchanged, so this call is still byte-for-byte
245
+ // equivalent: `ses_` + 12 hex timestamp chars + 14 base62 random chars.
242
246
  export function createOpenCodeSessionId(): string {
243
247
  return `ses_${createOpenCodeId(true)}`;
244
248
  }
package/src/daemon.ts CHANGED
@@ -31,7 +31,7 @@ import {
31
31
  stopLeaseGC,
32
32
  touchActivity,
33
33
  } from "./lease.ts";
34
- import { getActiveRequests, startProxy } from "./proxy.ts";
34
+ import { getActiveRequests, startClineKeepAlive, startProxy } from "./proxy.ts";
35
35
  import { loadRelayState, setActiveRelayState } from "./relay-state.ts";
36
36
 
37
37
  /** True when this module was run as the entry script (not imported by tests). */
@@ -122,6 +122,7 @@ async function runDaemon(): Promise<void> {
122
122
  touchActivity();
123
123
  syncRelayStateFromDisk();
124
124
  void seedCatalog();
125
+ startClineKeepAlive();
125
126
 
126
127
  // Retire only when zero leases persist past the grace window with nothing
127
128
  // in flight — never for request-idleness.
package/src/deploy.ts CHANGED
@@ -23,8 +23,8 @@ import { randomBytes } from "node:crypto";
23
23
  * auth gate (legacy/manual deploys); built-in deploys always embed one.
24
24
  */
25
25
  function buildRelayWorkerCore(relayAuth: string): string {
26
- return String.raw`// Only the 2 upstreams pi-freeflow talks to. Anything else = open proxy abuse.
27
- const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai"];
26
+ return String.raw`// Upstreams pi-freeflow talks to. Anything else = open proxy abuse.
27
+ const ALLOWED_TARGETS = ["https://opencode.ai", "https://api.kilo.ai", "https://oai.endpoints.kepler.ai.cloud.ovh.net"];
28
28
  const RELAY_AUTH = ${JSON.stringify(relayAuth)};
29
29
  const resolveRelayTarget = function(target, relayPath) {
30
30
  let targetUrl;
package/src/index.ts CHANGED
@@ -145,22 +145,44 @@ export function buildProviderConfig(
145
145
  contextWindow: m.contextWindow,
146
146
  maxTokens: m.maxTokens,
147
147
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
148
- compat: m.thinkingFormat
148
+ compat: {
149
+ ...(m.thinkingFormat
149
150
  ? {
150
151
  supportsDeveloperRole: false,
151
152
  thinkingFormat: m.thinkingFormat,
152
153
  }
153
154
  : m.api === "openai-responses"
154
155
  ? { sessionAffinityFormat: "openai-nosession" }
155
- : m.source === "kilo" || m.source === "cline" || m.source === "ovh"
156
+ : m.source === "kilo" || m.source === "cline"
156
157
  ? {
157
158
  supportsDeveloperRole: false,
158
159
  supportsReasoningEffort: !!m.thinkingLevelMap,
159
160
  }
160
- : {
161
- supportsDeveloperRole: false,
162
- supportsReasoningEffort: true,
163
- },
161
+ // OVH is a plain OpenAI chat endpoint. Without an explicit format the
162
+ // host sees the loopback proxy URL, classifies the lane as a local
163
+ // backend and treats `Qwen3.x` ids as template-dialect Qwen, emitting
164
+ // `chat_template_kwargs` that OVH rejects with
165
+ // `400 feature 'extra arguments: ...' is not currently supported`.
166
+ // Pin the format so the host sends `reasoning_effort` instead.
167
+ : m.source === "ovh"
168
+ ? {
169
+ supportsDeveloperRole: false,
170
+ supportsReasoningEffort: !!m.thinkingLevelMap,
171
+ thinkingFormat: "openai",
172
+ // The host derives these two from the loopback base URL before the
173
+ // spec override lands: `thinkingFormat` is overridden above, but
174
+ // `qwenPreserveThinking` was already latched on and still emits
175
+ // `chat_template_kwargs.preserve_thinking`. Clear it explicitly.
176
+ qwenPreserveThinking: false,
177
+ }
178
+ : {
179
+ supportsDeveloperRole: false,
180
+ supportsReasoningEffort: true,
181
+ }),
182
+ ...(m.requiresReasoningContentOnAssistantMessages !== undefined
183
+ ? { requiresReasoningContentOnAssistantMessages: m.requiresReasoningContentOnAssistantMessages }
184
+ : {}),
185
+ },
164
186
  };
165
187
  }),
166
188
  };
package/src/models.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  /**
2
2
  * Static model definitions and upstream routing catalogs for pi-freeflow
3
3
  *
4
- * Defines the 37 verified free models (live-verified 2026-10-22):
5
- * - 12 OpenCode Zen models (all Chat Completions)
6
- * - 16 KiloCode Keyless Gateway models (OpenRouter format)
4
+ * Defines the 34 verified free models (live-verified 2026-10-24):
5
+ * - 10 OpenCode Zen models (all Chat Completions)
6
+ * - 15 KiloCode Keyless Gateway models (OpenRouter format)
7
7
  * - 1 Cline direct-only model (per-user pool; z-ai/glm-5.3-flash)
8
8
  * - 8 OVHcloud AI Endpoints models (keyless, direct-only)
9
9
  *
@@ -33,32 +33,6 @@ import type { ModelDef, ThinkingLevelMap, Upstream } from "./types.ts";
33
33
  * Endpoint: https://opencode.ai/inference/v1
34
34
  */
35
35
  export const OPENCODE_MODELS: ModelDef[] = [
36
- {
37
- id: "cat-jam-free",
38
- name: "Cat Jam [OpenCode]",
39
- reasoning: true,
40
- // Confirmed 2026-10-20: the endpoint refuses a 1.05M-token prompt with
41
- // "maximum context length is 1048576 tokens", and a 900,337-token needle
42
- // probe recalled correctly. The earlier ~250K miss was recall, not
43
- // truncation.
44
- contextWindow: 1_048_576,
45
- maxTokens: 131_072,
46
- input: ["text"],
47
- // Effort ladder live-probed 2026-10-16: the gateway enumerates
48
- // max/xhigh/high/medium/low as the supported set for this model — a probe
49
- // with `minimal`, `none`, or a bogus value answers 400 naming exactly
50
- // that list, and `max` answers 200 with a populated reasoning stream. So
51
- // `max` is a real level here, not a silent clamp.
52
- thinkingLevelMap: {
53
- off: null,
54
- minimal: null,
55
- low: "low",
56
- medium: "medium",
57
- high: "high",
58
- xhigh: "xhigh",
59
- max: "max",
60
- },
61
- },
62
36
  {
63
37
  id: "mimo-v2.6-flash-free",
64
38
  name: "MiMo V2.6 Flash [OpenCode]",
@@ -70,7 +44,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
70
44
  // a caller sees above the lane's cap.
71
45
  contextWindow: 1_048_576,
72
46
  maxTokens: 131_072,
73
- input: ["text", "image"],
47
+ input: ["text"],
74
48
  thinkingLevelMap: {
75
49
  off: null,
76
50
  minimal: "low",
@@ -270,6 +244,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
270
244
  id: "deepseek-v4.1-flash-free",
271
245
  name: "DeepSeek V4.1 Flash [OpenCode]",
272
246
  reasoning: true,
247
+ requiresReasoningContentOnAssistantMessages: true,
273
248
  // 1,048,576, matching both the published twin and the probe: a marker
274
249
  // planted mid-prompt is still recalled through prompt_tokens=1,030,800, and
275
250
  // the gateway turns an oversized request away with an empty-bodied 400
@@ -298,43 +273,6 @@ export const OPENCODE_MODELS: ModelDef[] = [
298
273
  max: "max",
299
274
  },
300
275
  },
301
- {
302
- // Added 2026-10-22: new free model on the OpenCode Zen lane, live-probed
303
- // against the gateway rather than copied from a published row.
304
- // Availability: 5/5 probes answered 200 through the fingerprint path
305
- // (chat + stream + tool call), so this is a stable listing.
306
- // Context: 1,000,000 measured — a needle planted at prompt_tokens 991,641
307
- // is recalled, and a request above the window is refused. That makes the
308
- // declared figure the lane's own 1M cap, not a published twin value.
309
- // maxTokens: the rejection boundary is 524,288 (600,000 is refused with
310
- // "does not support max tokens > 524288"), so the exact served ceiling is
311
- // the budget. models.dev publishes the rounded 512,000; acceptance is not a
312
- // cap, so the boundary wins.
313
- // Vision verified live. Effort ladder probed level-by-level: low, medium,
314
- // high, xhigh and max each answer 200 with reasoning deltas. `off` answers
315
- // 400 and `minimal` is accepted but absent from the published ladder, so
316
- // both stay null and the picker never offers them. Chat is the native
317
- // shape, so `api` stays unset.
318
- // Known caveat, measured on trivial prompts at high effort: the model
319
- // sometimes calls the injected fingerprint placeholder tool, which the
320
- // cloak strips — the host then sees a tool_calls finish with no visible
321
- // calls. The turn still completes; nothing is dropped from a real call.
322
- id: "minimax-m3.1-flash-free",
323
- name: "MiniMax M3.1 Flash [OpenCode]",
324
- reasoning: true,
325
- contextWindow: 1_000_000,
326
- maxTokens: 524_288,
327
- input: ["text", "image"],
328
- thinkingLevelMap: {
329
- off: null,
330
- minimal: null,
331
- low: "low",
332
- medium: "medium",
333
- high: "high",
334
- xhigh: "xhigh",
335
- max: "max",
336
- },
337
- },
338
276
  {
339
277
  // Added 2026-10-22. Availability on this lane is bursty: the same day it
340
278
  // answered 1 of 19 probes across ~25 minutes and later 8 of 8 over 43
@@ -527,35 +465,6 @@ export const KILO_MODELS: ModelDef[] = [
527
465
  thinkingFormat: "openrouter",
528
466
  thinkingLevelMap: KILO_REASONING_MAP,
529
467
  },
530
- {
531
- // Added 2026-10-22: exact gateway id, tool call + agent loop verified on
532
- // both the chat and /v1/responses paths. 1,000,000 / 524,288 is the
533
- // gateway `top_provider` pair, and the Zen twin (minimax-m3.1-flash-free)
534
- // measured the same 524,288 output boundary — two lanes, one figure, so the
535
- // twin reading is corroboration rather than a copy. Conservative twin note:
536
- // both entries declare the served boundary; the rounded 512,000 some
537
- // datasheets publish is not what either lane enforces.
538
- // Reasoning is mandatory upstream: `none` and `off` answer 400 "Reasoning
539
- // is mandatory", which is why both rungs stay null. When tools are present
540
- // no reasoning deltas are reported at all (silent-thinking tool turns), so
541
- // the map's level labels describe the effort sent, not visible thought.
542
- id: "minimax/minimax-m3.1-flash:free",
543
- name: "MiniMax M3.1 Flash [Kilo]",
544
- reasoning: true,
545
- contextWindow: 1_000_000,
546
- maxTokens: 524_288,
547
- input: ["text", "image"],
548
- thinkingFormat: "openrouter",
549
- thinkingLevelMap: {
550
- off: null,
551
- minimal: null,
552
- low: "low",
553
- medium: "medium",
554
- high: "high",
555
- xhigh: "xhigh",
556
- max: "max",
557
- },
558
- },
559
468
  {
560
469
  // Added 2026-10-22: exact gateway id, tool call + agent loop verified on
561
470
  // both the chat and /v1/responses paths.
@@ -654,22 +563,6 @@ export const KILO_MODELS: ModelDef[] = [
654
563
  },
655
564
  ];
656
565
 
657
- /**
658
- * Narrow effort map for the Cline model in the catalog (GLM 5.3 Flash) — the
659
- * reference reasoningOptions list effort values [low, high, max] only (no
660
- * toggle on GLM); minimal/medium/xhigh are not offered so the host never
661
- * sends an unsupported level (the proxy forwards effort verbatim).
662
- */
663
- const CLINE_FLASH_REASONING_MAP: ThinkingLevelMap = {
664
- off: null,
665
- minimal: null,
666
- low: "low",
667
- medium: null,
668
- high: "high",
669
- xhigh: null,
670
- max: "max",
671
- };
672
-
673
566
  /**
674
567
  * Cline free models (per-user local pool — https://api.cline.bot).
675
568
  * Direct-only: never served through the relay pool. IDs are Cline-native
@@ -684,13 +577,21 @@ const CLINE_FLASH_REASONING_MAP: ThinkingLevelMap = {
684
577
  */
685
578
  export const CLINE_MODELS: ModelDef[] = [
686
579
  {
687
- id: "z-ai/glm-5.3-flash",
688
- name: "GLM 5.3 Flash [Cline]",
580
+ id: "cline/nemotron-3.5-super-vl",
581
+ name: "Nemotron 3.5 Super VL [Cline]",
689
582
  reasoning: true,
690
- contextWindow: 1_048_576,
583
+ contextWindow: 1_000_000,
691
584
  maxTokens: 131_072,
692
- input: ["text", "image"],
693
- thinkingLevelMap: CLINE_FLASH_REASONING_MAP,
585
+ input: ["text", "image"],
586
+ thinkingLevelMap: {
587
+ off: null,
588
+ minimal: "minimal",
589
+ low: "low",
590
+ medium: "medium",
591
+ high: "high",
592
+ xhigh: "xhigh",
593
+ max: null,
594
+ },
694
595
  },
695
596
  ];
696
597
 
@@ -700,7 +601,7 @@ export const CLINE_MODELS: ModelDef[] = [
700
601
  * populated reasoning stream). minimal/medium/xhigh/max were not confirmed,
701
602
  * so they map to null and the picker never offers them. `minimal` must be an
702
603
  * explicit null, not an omitted key: omitting it leaves the level selectable
703
- * in the host. Same shape as CLINE_FLASH_REASONING_MAP.
604
+ * in the host.
704
605
  */
705
606
  const OVH_REASONING_MAP: ThinkingLevelMap = {
706
607
  off: null,
@@ -858,6 +759,9 @@ export function ovhUpstreamModelId(id: string): string {
858
759
  * - Muse Spark 1.2 = Meta Superintelligence Labs (not Anthropic Claude) → removed claude-sonnet aliases
859
760
  * - Laguna S 2.1 = Poolside (not MiniMax) → removed minimax-m2.1 alias
860
761
  * - Hy3 = Tencent Hunyuan (not Alibaba Qwen) → removed qwen3-coder alias
762
+ * - Both free MiniMax M3.1 Flash endpoints retired on 2026-10-23 (Zen 410,
763
+ * Kilo 404 model-not-found) → removed minimax-m3.1-flash alias; a short
764
+ * name must never route to the surviving paid id
861
765
  */
862
766
  export const MODEL_ALIASES: Record<string, string> = {
863
767
  "dots-3-note-preview": "dots-studio/dots-3-note-preview:free",
@@ -874,9 +778,6 @@ export const MODEL_ALIASES: Record<string, string> = {
874
778
  "longcat": "longcat-2.5-preview-free",
875
779
  "beam": "beam-free",
876
780
  "deepseek-v4.1-flash": "deepseek-v4.1-flash-free",
877
- // Added 2026-10-22. "flint" stays on the Zen lane; the Kilo twin gets its
878
- // own name so neither entry can shadow the other.
879
- "minimax-m3.1-flash": "minimax-m3.1-flash-free",
880
781
  "flint": "flint-40b-free",
881
782
  "flint-kilo": "recursive/flint-v1.0:free",
882
783
  "beam-kilo": "reflection/beam-free",
@@ -895,6 +796,7 @@ export const MODEL_ALIASES: Record<string, string> = {
895
796
  "qwen3.8-27b": "ovh-free/Qwen3.8-27B",
896
797
  "mistral-small-3.2": "ovh-free/Mistral-Small-3.2-24B-Instruct-2506",
897
798
  "llama-3.3-70b": "ovh-free/Meta-Llama-3_3-70B-Instruct",
799
+ "cline-super-vl": "cline/nemotron-3.5-super-vl",
898
800
  };
899
801
 
900
802
  /**
@@ -934,7 +836,7 @@ export const OVH_MODEL_IDS = new Set<string>([
934
836
  .map(([alias]) => alias),
935
837
  ]);
936
838
  /**
937
- * Combined list of all 37 static free models (canonical)
839
+ * Combined list of all 35 static free models (canonical)
938
840
  */
939
841
  export const ALL_MODELS: ModelDef[] = [...OPENCODE_MODELS, ...KILO_MODELS, ...CLINE_MODELS, ...OVH_MODELS];
940
842
 
package/src/proxy.ts CHANGED
@@ -49,7 +49,7 @@ import {
49
49
  import { isDebugEnabled, log } from "./logger.ts";
50
50
  import { getModelUpstream, isClineModel, isOvhModel, KILO_MODEL_IDS, MODEL_MAP, ovhUpstreamModelId, resolveCanonicalModelId } from "./models.ts";
51
51
  import { ClineAuthError, refreshClineToken, toApiKey } from "./cline-device-auth.ts";
52
- import { rollChat } from "./cline-accounts.ts";
52
+ import { rollChat, clineUpstreamModelId, keepClineTokensAlive, refreshClineVersion } from "./cline-accounts.ts";
53
53
  import { fetchWithSystemCA } from "./system-ca-fetch.ts";
54
54
  import {
55
55
  buildFindGlobRestore,
@@ -251,6 +251,27 @@ async function clineRefreshImpl(refreshToken: string): Promise<{ token: string;
251
251
  }
252
252
  }
253
253
 
254
+ let clineKeepAliveTimer: NodeJS.Timeout | null = null;
255
+ const CLINE_KEEP_ALIVE_INTERVAL_MS = 15 * 60 * 1000;
256
+
257
+ export function startClineKeepAlive(): void {
258
+ if (clineKeepAliveTimer !== null) return;
259
+ void keepClineTokensAlive().catch(() => {});
260
+ void refreshClineVersion().catch(() => {});
261
+ clineKeepAliveTimer = setInterval(() => {
262
+ void keepClineTokensAlive().catch(() => {});
263
+ void refreshClineVersion().catch(() => {});
264
+ }, CLINE_KEEP_ALIVE_INTERVAL_MS);
265
+ clineKeepAliveTimer.unref?.();
266
+ }
267
+
268
+ export function stopClineKeepAlive(): void {
269
+ if (clineKeepAliveTimer !== null) {
270
+ clearInterval(clineKeepAliveTimer);
271
+ clineKeepAliveTimer = null;
272
+ }
273
+ }
274
+
254
275
  /**
255
276
  * Bound on the caller tool names the audit summary carries (the recorder
256
277
  * applies the same caps itself; bounding here keeps a hostile body from
@@ -385,6 +406,7 @@ async function handleClineRequest(opts: {
385
406
  if (Array.isArray(chatBody.tools)) chatBody.tools = translateToolsForPath(chatBody.tools, "/v1/chat/completions");
386
407
  delete chatBody.prompt_cache_key;
387
408
  }
409
+ chatBody.model = clineUpstreamModelId(model);
388
410
  // Cline bypasses the fingerprint, but its arguments still malform the same
389
411
  // way: capture the caller's own schemas before translation for repair.
390
412
  const argSchemas = buildArgRepairSchemas(Array.isArray(parsedBody.tools) ? parsedBody.tools : []);
@@ -699,14 +721,25 @@ async function handleOvhRequest(opts: {
699
721
  // Lane quirks, keyed by canonical catalog id so an alias resolves to the same
700
722
  // model (the wire id above has already lost its `ovh-free/` prefix).
701
723
  const canonicalModelId = resolveCanonicalModelId(requestedModel);
702
- if (canonicalModelId === OVH_NO_EFFORT_MODEL) {
703
- // Measured 2026-10-22: with `reasoning_effort` present this model answers
704
- // HTTP 200 whose body is a bare `data: [DONE]` — no deltas, no error — at
705
- // max_tokens 4000 and 32000 alike; without the field it streams its own
706
- // reasoning plus a proper tool call. Every other OVH model tolerates the
707
- // field, so the strip is this model only. The responses-path translation
708
- // cannot reintroduce it: chatBodyFromResponsesBody copies a fixed scalar
709
- // list that carries no reasoning/effort key.
724
+ // OVH's OpenAI-compatible surface rejects Qwen chat-template controls
725
+ // (`400 feature 'extra arguments: {"chat_template_kwargs":…}' is not
726
+ // currently supported`). The host classifies this loopback proxy as a local
727
+ // OpenAI-compatible backend, treats `Qwen3.x` models as template-dialect
728
+ // Qwen and injects them; the lane has no such feature. Strip defensively so
729
+ // no host version can leak them upstream.
730
+ delete chatBody.chat_template_kwargs;
731
+ delete chatBody.enable_thinking;
732
+ delete chatBody.preserve_thinking;
733
+ // OVH's OpenAI-compatible surface rejects Responses-native caps (`400 feature
734
+ // 'extra arguments: {"max_output_tokens":…}' is not currently supported`)
735
+ // when a responses-path request was translated via chatBodyFromResponsesBody.
736
+ // Strip defensively so the cap is not passed as an unknown extra argument.
737
+ delete chatBody.max_output_tokens;
738
+ const modelDef = MODEL_MAP.get(canonicalModelId) ?? MODEL_MAP.get(requestedModel);
739
+ if (canonicalModelId === OVH_NO_EFFORT_MODEL || (modelDef && !modelDef.reasoning)) {
740
+ // Qwen3.8-27B on OVH starves when reasoning_effort is present.
741
+ // Non-reasoning OVH models (Llama 3.3, Mistral Small) return HTTP 400
742
+ // "feature 'reasoning_effort for this model' is not currently supported".
710
743
  delete chatBody.reasoning_effort;
711
744
  }
712
745
  if (canonicalModelId === OVH_STRICT_TOOL_CALL_ID_MODEL) {
@@ -736,16 +769,35 @@ async function handleOvhRequest(opts: {
736
769
  res.once("close", abortOnClientGone);
737
770
  req.once("error", abortOnClientGone);
738
771
  let upstreamRes: Response;
772
+ let servedRelay: string | null = null;
739
773
  try {
740
- // Keyless, direct (never the relay pool). OVH serves anonymous callers but
741
- // rate-limits them, so a 429 here is a real per-IP cap and must NOT carry
742
- // the relay-deploy hint — this lane can never ride a relay.
743
- upstreamRes = await fetch(OVH_CHAT_URL, {
744
- method: "POST",
745
- headers: { "Content-Type": "application/json" },
746
- body: JSON.stringify(chatBody),
747
- signal: controller.signal,
748
- });
774
+ const relayState = getActiveRelayState();
775
+ const relayPoolOn =
776
+ relayState.mode !== "off" &&
777
+ relayState.enabled !== false &&
778
+ Boolean(relayState.url || (relayState.relays && relayState.relays.length > 0));
779
+
780
+ if (relayPoolOn) {
781
+ upstreamRes = await relayFetch(
782
+ OVH_CHAT_URL,
783
+ {
784
+ method: "POST",
785
+ headers: { "Content-Type": "application/json" },
786
+ body: JSON.stringify(chatBody),
787
+ signal: controller.signal,
788
+ },
789
+ reqId,
790
+ { onServed: (url) => { servedRelay = url; } },
791
+ (failure) => audit?.attemptFailure({ attempt: failure.attempt, status: failure.status, reason: failure.reason }),
792
+ );
793
+ } else {
794
+ upstreamRes = await fetch(OVH_CHAT_URL, {
795
+ method: "POST",
796
+ headers: { "Content-Type": "application/json" },
797
+ body: JSON.stringify(chatBody),
798
+ signal: controller.signal,
799
+ });
800
+ }
749
801
  } catch (e) {
750
802
  log("error", "ovh upstream fetch failed", { error: String(e), model: requestedModel }, reqId);
751
803
  if (!res.headersSent) {
@@ -793,17 +845,20 @@ async function handleOvhRequest(opts: {
793
845
  connection: "keep-alive",
794
846
  "x-accel-buffering": "no",
795
847
  });
796
- // Fetched direct, never through the relay pool: attributing an OVH-side
797
- // stream failure to an unrelated relay would mark a healthy relay failed.
848
+ // Fetched via relay pool or direct fallback.
798
849
  pipeUpstreamStream(
799
850
  Readable.fromWeb(upstreamRes.body as unknown as WebReadableStream),
800
851
  res,
801
852
  req,
802
853
  reqId,
803
- "direct",
804
- findGlob.renamedFindToGlob || findGlob.renamed !== undefined || argSchemas.size > 0
805
- ? { callerHadTools: true, argSchemas, findGlob, pathname: "/v1/chat/completions" }
806
- : undefined,
854
+ servedRelay ?? "direct",
855
+ {
856
+ callerHadTools: Array.isArray(parsedBody.tools) && parsedBody.tools.length > 0,
857
+ argSchemas,
858
+ findGlob,
859
+ pathname: "/v1/chat/completions",
860
+ normalizeReasoning: true,
861
+ },
807
862
  { upstreamContentType: upstreamRes.headers.get("content-type"), audit: audit?.active ? audit : undefined },
808
863
  );
809
864
  return;
@@ -1018,8 +1073,8 @@ export function isRelayEligibleModel(model: unknown): boolean {
1018
1073
  if (typeof model !== "string") return false;
1019
1074
  if (model.trim() === "") return true;
1020
1075
  const canonical = resolveCanonicalModelId(model.trim());
1021
- if (isClineModel(canonical) || isOvhModel(canonical)) return false;
1022
- return MODEL_MAP.has(canonical) || KILO_MODEL_IDS.has(canonical) || isFreeCatalogId(canonical);
1076
+ if (isClineModel(canonical)) return false;
1077
+ return MODEL_MAP.has(canonical) || KILO_MODEL_IDS.has(canonical) || isFreeCatalogId(canonical) || isOvhModel(canonical);
1023
1078
  }
1024
1079
 
1025
1080
  /**
@@ -1433,7 +1488,10 @@ export function handleControlRequest(
1433
1488
  res.writeHead(200, { "content-type": "application/json" });
1434
1489
  res.end(JSON.stringify({ ok: true }));
1435
1490
  setTimeout(() => {
1436
- try { closeServer(); } catch { }
1491
+ try {
1492
+ stopClineKeepAlive();
1493
+ closeServer();
1494
+ } catch { }
1437
1495
  if (shutdownShouldExit) process.exit(0);
1438
1496
  }, 50);
1439
1497
  return true;
@@ -314,6 +314,8 @@ export interface StreamCloakOptions {
314
314
  pathname: string;
315
315
  /** Caller tool schemas for argument repair; undefined/empty means byte-identical. */
316
316
  argSchemas?: ReadonlyMap<string, Record<string, unknown>>;
317
+ /** Normalize delta.reasoning to delta.reasoning_content for OpenAI/OMP compatibility. */
318
+ normalizeReasoning?: boolean;
317
319
  }
318
320
 
319
321
  /** Per-stream drop tracking (delta events carry no name, only an index/id). */
@@ -498,6 +500,7 @@ export function shouldBypassStreamCloak(cloak?: StreamCloakOptions): boolean {
498
500
  if (!cloak.callerHadTools && cloak.injected !== undefined) return false;
499
501
  // A repairable schema means fragmented arguments must be buffered.
500
502
  if (cloak.argSchemas !== undefined && cloak.argSchemas.size > 0) return false;
503
+ if (cloak.normalizeReasoning === true) return false;
501
504
  return true;
502
505
  }
503
506
 
@@ -843,7 +846,12 @@ function rewriteChatBlock(
843
846
  if (c.finish_reason || c.text || c.logprobs) allDeltasEmpty = false;
844
847
  continue;
845
848
  }
846
- const d = delta as Record<string, unknown>;
849
+ const d = delta as Record<string, unknown>;
850
+ if (cloak.normalizeReasoning && typeof d.reasoning === "string" && typeof d.reasoning_content !== "string") {
851
+ d.reasoning_content = d.reasoning;
852
+ changed = true;
853
+ allDeltasEmpty = false;
854
+ }
847
855
  // Terminal chunk for this choice: re-emit every buffered tool call's whole
848
856
  // argument packet, repaired, at its host index. The fragments never went
849
857
  // out, so this is the first and only time the client sees them. Runs
package/src/types.ts CHANGED
@@ -28,6 +28,7 @@ export interface ModelDef {
28
28
  input?: ModelInputType[];
29
29
  thinkingFormat?: "openrouter";
30
30
  thinkingLevelMap?: ThinkingLevelMap;
31
+ requiresReasoningContentOnAssistantMessages?: boolean;
31
32
  }
32
33
 
33
34
  export type RegisteredModel = ModelDef & {
@@ -119,6 +120,7 @@ export interface ProviderModelCost {
119
120
  export interface ProviderModelCompat {
120
121
  supportsDeveloperRole?: boolean;
121
122
  supportsReasoningEffort?: boolean;
123
+ requiresReasoningContentOnAssistantMessages?: boolean;
122
124
  thinkingFormat?: "openrouter" | string;
123
125
  sessionAffinityFormat?: "openai-nosession" | string;
124
126
  [key: string]: unknown;