pi-freeflow 1.33.3 → 1.35.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/src/models.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * Static model definitions and upstream routing catalogs for pi-freeflow
3
3
  *
4
- * Defines the 30 verified free models (live-verified 2026-10-08):
4
+ * Defines the 36 verified free models (live-verified 2026-10-13):
5
5
  * - 10 OpenCode Zen models (2 Responses API + 8 Chat Completions)
6
6
  * - 15 KiloCode Keyless Gateway models (OpenRouter format)
7
- * - 5 Cline direct-only models (per-user pool)
7
+ * - 2 Cline direct-only models (per-user pool)
8
+ * - 9 OVHcloud AI Endpoints models (keyless, direct-only)
8
9
  */
9
10
 
10
11
  import type { ModelDef, ThinkingLevelMap, Upstream } from "./types.ts";
@@ -21,7 +22,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
21
22
  contextWindow: 1_048_576,
22
23
  maxTokens: 131_072,
23
24
  api: "openai-responses",
24
- input: ["text", "image"],
25
+ input: ["text", "image", "video"],
25
26
  thinkingLevelMap: {
26
27
  off: null,
27
28
  minimal: "minimal",
@@ -39,7 +40,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
39
40
  contextWindow: 1_048_576,
40
41
  maxTokens: 131_072,
41
42
  api: "openai-responses",
42
- input: ["text", "image"],
43
+ input: ["text", "image", "video"],
43
44
  thinkingLevelMap: {
44
45
  off: null,
45
46
  minimal: "minimal",
@@ -56,7 +57,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
56
57
  reasoning: true,
57
58
  contextWindow: 1_048_576,
58
59
  maxTokens: 131_072,
59
- input: ["text", "image"],
60
+ input: ["text", "image", "video"],
60
61
  thinkingLevelMap: {
61
62
  off: null,
62
63
  minimal: "low",
@@ -124,7 +125,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
124
125
  reasoning: true,
125
126
  contextWindow: 1_048_576,
126
127
  maxTokens: 524_288,
127
- input: ["text", "image"],
128
+ input: ["text", "image", "video"],
128
129
  thinkingLevelMap: {
129
130
  off: null,
130
131
  minimal: null,
@@ -153,21 +154,30 @@ export const OPENCODE_MODELS: ModelDef[] = [
153
154
  },
154
155
  },
155
156
  {
156
- id: "fledge-alpha-free",
157
- name: "Fledge Alpha Free [OpenCode]",
158
- reasoning: true,
159
- contextWindow: 1_048_576,
160
- maxTokens: 131_072,
161
- input: ["text", "image"],
162
- thinkingLevelMap: {
163
- off: null,
164
- minimal: null,
165
- low: "low",
166
- medium: null,
167
- high: "high",
168
- xhigh: null,
169
- max: "max",
170
- },
157
+ // Added 2026-10-09: replaces fledge-alpha-free (withdrawn upstream —
158
+ // absent from the live list, chat 401 "not supported"). Live on the Zen
159
+ // list; gated-lane 403 matches the working models from a refused egress,
160
+ // so it rides the same fingerprint path. Specs per models.dev
161
+ // (opencode/step-5-preview-free) + StepFun docs, NOT the enrichModelDef
162
+ // substring heuristic (which yields reasoning:false / 262K / text-only):
163
+ // 1M ctx, 64K max output, text+image+video, efforts low/medium/high only.
164
+ // minimal is explicitly null (omitting the key leaves it selectable in
165
+ // the host); xhigh/max are unsupported upstream.
166
+ id: "step-5-preview-free",
167
+ name: "Step 5 Preview [OpenCode]",
168
+ reasoning: true,
169
+ contextWindow: 1_000_000,
170
+ maxTokens: 65_536,
171
+ input: ["text", "image", "video"],
172
+ thinkingLevelMap: {
173
+ off: null,
174
+ minimal: null,
175
+ low: "low",
176
+ medium: "medium",
177
+ high: "high",
178
+ xhigh: null,
179
+ max: null,
180
+ },
171
181
  },
172
182
  {
173
183
  id: "ling-3.1-flash-free",
@@ -191,9 +201,10 @@ export const OPENCODE_MODELS: ModelDef[] = [
191
201
  /**
192
202
  * Shared effort map for Kilo reasoning models — verified live 2026-08-29:
193
203
  * gateway accepts flat reasoning_effort minimal..xhigh for every reasoning
194
- * model; stepfun/step-3.7-flash measured monotonic 77→313 thinking chars
195
- * across minimal→xhigh. Declaring the map locks the picker (instead of
196
- * host guessing) and matches the OpenCode-model pattern.
204
+ * model; a StepFun reasoning model (step-3.7-flash, since retired from the
205
+ * free list) measured monotonic 77→313 thinking chars across minimal→xhigh.
206
+ * Declaring the map locks the picker (instead of host guessing) and matches
207
+ * the OpenCode-model pattern.
197
208
  */
198
209
  const KILO_REASONING_MAP: ThinkingLevelMap = {
199
210
  off: null,
@@ -225,7 +236,7 @@ export const KILO_MODELS: ModelDef[] = [
225
236
  reasoning: true,
226
237
  contextWindow: 256_000,
227
238
  maxTokens: 131_072,
228
- input: ["text", "image"],
239
+ input: ["text", "image", "video"],
229
240
  thinkingFormat: "openrouter",
230
241
  thinkingLevelMap: KILO_REASONING_MAP,
231
242
  },
@@ -320,14 +331,15 @@ export const KILO_MODELS: ModelDef[] = [
320
331
  thinkingLevelMap: KILO_REASONING_MAP,
321
332
  },
322
333
  {
323
- // Resurrected 2026-09-28: back on the live Kilo free list
324
- // (isFree:true, 0/0 pricing, no expiry) + keyless chat 200.
325
- id: "stepfun/step-3.7-flash:free",
326
- name: "Step 3.7 Flash [Kilo]",
334
+ // Added 2026-10-09: replaces step-3.7-flash (demoted to paid upstream —
335
+ // :free ID 404s keyless). Live on Kilo free list (isFree:true, 0/0
336
+ // pricing, 1M ctx) + keyless chat served (429 provider concurrency).
337
+ id: "stepfun/step-5-preview-free",
338
+ name: "Step 5 Preview [Kilo]",
327
339
  reasoning: true,
328
- contextWindow: 262_144,
329
- maxTokens: 262_144,
330
- input: ["text", "image"],
340
+ contextWindow: 1_000_000,
341
+ maxTokens: 65_536,
342
+ input: ["text", "image", "video"],
331
343
  thinkingFormat: "openrouter",
332
344
  thinkingLevelMap: KILO_REASONING_MAP,
333
345
  },
@@ -342,11 +354,13 @@ export const KILO_MODELS: ModelDef[] = [
342
354
  thinkingLevelMap: KILO_REASONING_MAP,
343
355
  },
344
356
  {
345
- id: "inclusionai/ling-3.0-flash-sante:free",
346
- name: "Ling 3.0 Flash Sante [Kilo]",
357
+ // Added 2026-10-09: live on Kilo free list (isFree:true, 0/0 pricing,
358
+ // 256K ctx) + keyless chat 200.
359
+ id: "stealth/glyph-cluster",
360
+ name: "Glyph Cluster [Kilo]",
347
361
  reasoning: true,
348
- contextWindow: 262_144,
349
- maxTokens: 32_768,
362
+ contextWindow: 256_000,
363
+ maxTokens: 256_000,
350
364
  input: ["text"],
351
365
  thinkingFormat: "openrouter",
352
366
  thinkingLevelMap: KILO_REASONING_MAP,
@@ -365,24 +379,10 @@ export const KILO_MODELS: ModelDef[] = [
365
379
  ];
366
380
 
367
381
  /**
368
- * Full-range effort map (Solar Pro 4 — reference lists none..max; off stays
369
- * null per repo convention). The proxy passes effort through verbatim and
370
- * upstream serves the levels it supports.
371
- */
372
- const CLINE_REASONING_MAP: ThinkingLevelMap = {
373
- off: null,
374
- minimal: "minimal",
375
- low: "low",
376
- medium: "medium",
377
- high: "high",
378
- xhigh: "xhigh",
379
- max: "max",
380
- };
381
- /**
382
- * Narrow effort map for DeepSeek V4 Flash + GLM Flash + Kimi K3 — reference
383
- * reasoningOptions list effort values [low, high, max] only (no toggle
384
- * on GLM); minimal/medium/xhigh are not offered so the host never sends
385
- * an unsupported level (the proxy forwards effort verbatim).
382
+ * Narrow effort map for the Cline flash models still in the catalog (GLM 5.3
383
+ * Flash) — the reference reasoningOptions list effort values [low, high, max]
384
+ * only (no toggle on GLM); minimal/medium/xhigh are not offered so the host
385
+ * never sends an unsupported level (the proxy forwards effort verbatim).
386
386
  */
387
387
  const CLINE_FLASH_REASONING_MAP: ThinkingLevelMap = {
388
388
  off: null,
@@ -402,15 +402,6 @@ const CLINE_FLASH_REASONING_MAP: ThinkingLevelMap = {
402
402
  * Endpoint: https://api.cline.bot/api/v1/chat/completions
403
403
  */
404
404
  export const CLINE_MODELS: ModelDef[] = [
405
- {
406
- id: "cline-free/deepseek-v4.1-flash",
407
- name: "DeepSeek V4.1 Flash [Cline]",
408
- reasoning: true,
409
- contextWindow: 1_000_000,
410
- maxTokens: 384_000,
411
- input: ["text", "image"],
412
- thinkingLevelMap: CLINE_FLASH_REASONING_MAP,
413
- },
414
405
  {
415
406
  id: "cline-free/muse-spark-1.3-contributor",
416
407
  name: "Muse Spark 1.3 [Cline]",
@@ -418,7 +409,7 @@ export const CLINE_MODELS: ModelDef[] = [
418
409
  contextWindow: 1_048_576,
419
410
  maxTokens: 131_072,
420
411
  api: "openai-responses",
421
- input: ["text", "image"],
412
+ input: ["text", "image", "video"],
422
413
  thinkingLevelMap: {
423
414
  off: null,
424
415
  minimal: "minimal",
@@ -433,35 +424,163 @@ export const CLINE_MODELS: ModelDef[] = [
433
424
  id: "z-ai/glm-5.3-flash",
434
425
  name: "GLM 5.3 Flash [Cline]",
435
426
  reasoning: true,
436
- contextWindow: 1_000_000,
427
+ contextWindow: 1_048_576,
437
428
  maxTokens: 131_072,
438
- input: ["text", "image"],
429
+ input: ["text", "image", "video"],
439
430
  thinkingLevelMap: CLINE_FLASH_REASONING_MAP,
440
431
  },
432
+ ];
433
+
434
+ /**
435
+ * Narrow effort map for the OVH reasoning models — probed live 2026-10-13:
436
+ * `reasoning_effort: "low"` and `"high"` are accepted (HTTP 200 with a
437
+ * populated reasoning stream). minimal/medium/xhigh/max were not confirmed,
438
+ * so they map to null and the picker never offers them. `minimal` must be an
439
+ * explicit null, not an omitted key: omitting it leaves the level selectable
440
+ * in the host. Same shape as CLINE_FLASH_REASONING_MAP.
441
+ */
442
+ const OVH_REASONING_MAP: ThinkingLevelMap = {
443
+ off: null,
444
+ minimal: null,
445
+ low: "low",
446
+ medium: null,
447
+ high: "high",
448
+ xhigh: null,
449
+ max: null,
450
+ };
451
+
452
+ /**
453
+ * Catalog-id namespace for the OVH lane. OVH model ids are case-sensitive on
454
+ * the wire and several carry capitals and dots, so the prefix namespaces the
455
+ * lane (and keeps the id recognized by isFreeCatalogId) while the suffix is
456
+ * sent upstream verbatim.
457
+ */
458
+ export const OVH_MODEL_PREFIX = "ovh-free/";
459
+
460
+ /**
461
+ * OVHcloud AI Endpoints free models — keyless, direct-only.
462
+ * Endpoint: https://oai.endpoints.kepler.ai.cloud.ovh.net/v1
463
+ *
464
+ * Every id is OVH-native with an `ovh-free/` prefix; upstream only knows the
465
+ * suffix (a prefixed id answers 404 `model_not_found`, verified 2026-10-13),
466
+ * so the proxy strips the prefix on the wire. `api` stays unset, i.e.
467
+ * openai-completions: chat is the native shape and responses is forwarded
468
+ * natively too (with store:false injected). Anthropic messages is not served
469
+ * (`/v1/messages` answers 404 unknown_url upstream).
470
+ *
471
+ * Specs (measured live 2026-10-13 and cross-checked against every twin
472
+ * provider carrying a spec): contextWindow is OVH's advertised value;
473
+ * maxTokens is deliberately the conservative FLOOR across twin providers —
474
+ * per-provider output caps for the same model range from 4K to 256K, and a
475
+ * cap that is too low never breaks a request while one that is too high lets
476
+ * a host ask for an output the provider refuses. OVH itself enforces only
477
+ * `context - prompt` (verified by binary search), so these are safe request
478
+ * budgets rather than a refusal boundary.
479
+ *
480
+ * Vision was probed with a generated solid-colour PNG sent as a data URL,
481
+ * twice with two different colours to defeat a memorised answer: the four
482
+ * Qwens and Mistral-Small-3.2 named the colour; Mistral-7B and Llama-3.3-70B
483
+ * answer HTTP 400 to an image part; gpt-oss-120b returns 200 while its own
484
+ * reasoning says it cannot see the image, so it is text-only. Reasoning is
485
+ * the presence of a populated `reasoning`/`reasoning_content` field on a
486
+ * non-tool answer.
487
+ */
488
+ export const OVH_MODELS: ModelDef[] = [
441
489
  {
442
- // Cline advertises this sixth free model only to its desktop client
443
- // identity (see CLINE_CLIENT_HEADERS). Specs follow the vendored Cline
444
- // catalog; maxTokens keeps the tighter chat value so a request can never
445
- // overrun what the chat endpoint accepts.
446
- id: "cline-free/kimi-k3",
447
- name: "Kimi K3 [Cline]",
490
+ id: "ovh-free/gpt-oss-120b",
491
+ name: "GPT-OSS 120B [OVH]",
448
492
  reasoning: true,
449
- contextWindow: 1_048_576,
450
- maxTokens: 131_072,
493
+ contextWindow: 131_072,
494
+ maxTokens: 32_768,
495
+ input: ["text"],
496
+ thinkingLevelMap: OVH_REASONING_MAP,
497
+ },
498
+ {
499
+ id: "ovh-free/gpt-oss-20b",
500
+ name: "GPT-OSS 20B [OVH]",
501
+ reasoning: true,
502
+ contextWindow: 131_072,
503
+ maxTokens: 32_768,
504
+ input: ["text"],
505
+ thinkingLevelMap: OVH_REASONING_MAP,
506
+ },
507
+ {
508
+ id: "ovh-free/Qwen3.5-397B-A17B",
509
+ name: "Qwen3.5 397B A17B [OVH]",
510
+ reasoning: true,
511
+ contextWindow: 262_144,
512
+ maxTokens: 65_536,
451
513
  input: ["text", "image"],
452
- thinkingLevelMap: CLINE_FLASH_REASONING_MAP,
514
+ thinkingLevelMap: OVH_REASONING_MAP,
453
515
  },
454
516
  {
455
- id: "cline-free/solar-pro4",
456
- name: "Solar Pro4 [Cline]",
517
+ id: "ovh-free/Qwen3.5-9B",
518
+ name: "Qwen3.5 9B [OVH]",
457
519
  reasoning: true,
458
- contextWindow: 524_288,
459
- maxTokens: 131_072,
520
+ contextWindow: 262_144,
521
+ maxTokens: 65_536,
522
+ input: ["text", "image"],
523
+ thinkingLevelMap: OVH_REASONING_MAP,
524
+ },
525
+ {
526
+ id: "ovh-free/Qwen3.6-27B",
527
+ name: "Qwen3.6 27B [OVH]",
528
+ reasoning: true,
529
+ contextWindow: 262_144,
530
+ maxTokens: 65_536,
531
+ input: ["text", "image"],
532
+ thinkingLevelMap: OVH_REASONING_MAP,
533
+ },
534
+ {
535
+ id: "ovh-free/Qwen3.8-27B",
536
+ name: "Qwen3.8 27B [OVH]",
537
+ reasoning: true,
538
+ contextWindow: 262_144,
539
+ maxTokens: 32_768,
540
+ input: ["text", "image"],
541
+ thinkingLevelMap: OVH_REASONING_MAP,
542
+ },
543
+ {
544
+ id: "ovh-free/Mistral-Small-3.2-24B-Instruct-2506",
545
+ name: "Mistral Small 3.2 24B [OVH]",
546
+ reasoning: false,
547
+ contextWindow: 131_072,
548
+ maxTokens: 16_384,
549
+ input: ["text", "image"],
550
+ },
551
+ {
552
+ id: "ovh-free/Mistral-7B-Instruct-v0.3",
553
+ name: "Mistral 7B Instruct v0.3 [OVH]",
554
+ reasoning: false,
555
+ contextWindow: 65_536,
556
+ maxTokens: 65_536,
557
+ input: ["text"],
558
+ },
559
+ {
560
+ id: "ovh-free/Meta-Llama-3_3-70B-Instruct",
561
+ name: "Meta Llama 3.3 70B Instruct [OVH]",
562
+ reasoning: false,
563
+ contextWindow: 131_072,
564
+ maxTokens: 16_384,
460
565
  input: ["text"],
461
- thinkingLevelMap: CLINE_REASONING_MAP,
462
566
  },
463
567
  ];
464
568
 
569
+ /**
570
+ * Resolve any OVH id to the bare id OVH knows on the wire. Canonicalises
571
+ * first, then strips: a short alias (`llama-3.3-70b`) must resolve to
572
+ * `ovh-free/Meta-Llama-3_3-70B-Instruct` before the prefix comes off, or it
573
+ * reaches OVH unrecognised and answers 404 `model_not_found` (live
574
+ * 2026-10-13, both for a prefixed id and for an alias sent verbatim). The
575
+ * remainder is case-sensitive, so it is returned unchanged.
576
+ */
577
+ export function ovhUpstreamModelId(id: string): string {
578
+ const canonical = resolveCanonicalModelId(id);
579
+ return canonical.startsWith(OVH_MODEL_PREFIX)
580
+ ? canonical.slice(OVH_MODEL_PREFIX.length)
581
+ : canonical;
582
+ }
583
+
465
584
  /**
466
585
  * Model ID Aliases — maps user-friendly / slash-free CLI IDs to canonical upstream model IDs.
467
586
  * Single clean alias per model (no :free duplicates). Wrong cross-lab aliases removed:
@@ -477,14 +596,14 @@ export const MODEL_ALIASES: Record<string, string> = {
477
596
  "nemotron-3-super": "nvidia/nemotron-3-super-120b-a12b:free",
478
597
  "north-mini-code": "cohere/north-mini-code:free",
479
598
  "lfm-2.5": "liquid/lfm-2.5-2.6b:free",
480
- "ling-3.0-flash-sante": "inclusionai/ling-3.0-flash-sante:free",
481
- "step-3.7-flash": "stepfun/step-3.7-flash:free",
599
+ "step-5-preview:kilo": "stepfun/step-5-preview-free",
600
+ "glyph-cluster": "stealth/glyph-cluster",
482
601
  "inkling-small": "thinkingmachines/inkling-small:free",
483
602
  "mimo-v2.6-flash": "mimo-v2.6-flash-free",
484
603
  "space-bunny": "space-bunny-free",
485
604
  "longcat-2.5-preview": "longcat-2.5-preview-free",
486
605
  "longcat": "longcat-2.5-preview-free",
487
- "fledge-alpha": "fledge-alpha-free",
606
+ "step-5-preview": "step-5-preview-free",
488
607
  "ling-3.1-flash": "ling-3.1-flash-free",
489
608
  "ling-3.1-flash:kilo": "inclusionai/ling-3.1-flash",
490
609
  // provider-prefixed short aliases (slash-normalized)
@@ -492,6 +611,16 @@ export const MODEL_ALIASES: Record<string, string> = {
492
611
  "laguna-xs-2.1:free": "poolside/laguna-xs-2.1:free",
493
612
  "kilo-auto": "kilo-auto/free",
494
613
  "openrouter": "openrouter/free",
614
+ // OVHcloud AI Endpoints — slash-free spellings of the `ovh-free/` ids.
615
+ "gpt-oss-120b": "ovh-free/gpt-oss-120b",
616
+ "gpt-oss-20b": "ovh-free/gpt-oss-20b",
617
+ "qwen3.5-397b": "ovh-free/Qwen3.5-397B-A17B",
618
+ "qwen3.5-9b": "ovh-free/Qwen3.5-9B",
619
+ "qwen3.6-27b": "ovh-free/Qwen3.6-27B",
620
+ "qwen3.8-27b": "ovh-free/Qwen3.8-27B",
621
+ "mistral-small-3.2": "ovh-free/Mistral-Small-3.2-24B-Instruct-2506",
622
+ "mistral-7b-v0.3": "ovh-free/Mistral-7B-Instruct-v0.3",
623
+ "llama-3.3-70b": "ovh-free/Meta-Llama-3_3-70B-Instruct",
495
624
  };
496
625
 
497
626
  /**
@@ -520,10 +649,20 @@ export const CLINE_MODEL_IDS = new Set<string>([
520
649
  .filter(([_, target]) => CLINE_MODELS.some((cm) => cm.id === target))
521
650
  .map(([alias]) => alias),
522
651
  ]);
652
+
523
653
  /**
524
- * Combined list of all 30 static free models (canonical)
654
+ * Set of all OVH model IDs (including aliases) for fast lookup
525
655
  */
526
- export const ALL_MODELS: ModelDef[] = [...OPENCODE_MODELS, ...KILO_MODELS, ...CLINE_MODELS];
656
+ export const OVH_MODEL_IDS = new Set<string>([
657
+ ...OVH_MODELS.map((m) => m.id),
658
+ ...Object.entries(MODEL_ALIASES)
659
+ .filter(([_, target]) => OVH_MODELS.some((om) => om.id === target))
660
+ .map(([alias]) => alias),
661
+ ]);
662
+ /**
663
+ * Combined list of all 36 static free models (canonical)
664
+ */
665
+ export const ALL_MODELS: ModelDef[] = [...OPENCODE_MODELS, ...KILO_MODELS, ...CLINE_MODELS, ...OVH_MODELS];
527
666
 
528
667
  /**
529
668
  * Map of model ID -> ModelDef
@@ -563,10 +702,19 @@ export function isClineModel(id: string): boolean {
563
702
  return CLINE_MODEL_IDS.has(id) || CLINE_MODEL_IDS.has(canonical);
564
703
  }
565
704
 
705
+ /**
706
+ * Check if a model ID belongs to OVHcloud AI Endpoints
707
+ */
708
+ export function isOvhModel(id: string): boolean {
709
+ const canonical = resolveCanonicalModelId(id);
710
+ return OVH_MODEL_IDS.has(id) || OVH_MODEL_IDS.has(canonical);
711
+ }
712
+
566
713
  /**
567
714
  * Determine the upstream provider for a given model ID
568
715
  */
569
716
  export function getModelUpstream(id: string): Upstream {
570
717
  if (isClineModel(id)) return "cline";
718
+ if (isOvhModel(id)) return "ovh";
571
719
  return isKiloModel(id) ? "kilo" : "opencode";
572
720
  }
@@ -33,6 +33,7 @@ import {
33
33
  isOmpLikeCaller,
34
34
  restoreToolNameForCaller,
35
35
  retargetToolChoiceForUpstream,
36
+ sanitizeUpstreamToolName,
36
37
  translateToolsForPath,
37
38
  upstreamToolNameFor,
38
39
  type FindGlobRestore,
@@ -283,6 +284,133 @@ export function ensureMessagesFingerprintTools(body: Record<string, unknown>, om
283
284
  }
284
285
  }
285
286
 
287
+ /**
288
+ * Drop `phase: "commentary"` from assistant message items in the replayed
289
+ * history.
290
+ *
291
+ * Upstream tags the lead-in text of a tool-calling turn `phase: "commentary"`
292
+ * and the closing answer `phase: "final_answer"`. When the model narrates a
293
+ * tool call and emits none (the muse-spark ATEM leak, upstream
294
+ * anomalyco/opencode#44659), that commentary item ends its own turn — and
295
+ * upstream then rejects every replay of the history with `400
296
+ * invalid_request_error: The request contains invalid parameters`.
297
+ *
298
+ * Measured on `muse-spark-1.3-contributor-free`, one field at a time: the
299
+ * phase alone decides it. A terminal commentary item 400s; the same item
300
+ * followed by a function call, or by a `final_answer` item, returns 200; the
301
+ * id prefix and the text are irrelevant. Only the phase field is removed, and
302
+ * `phase` is output metadata the model never reads as instruction, so dropping
303
+ * it cannot change the answer.
304
+ *
305
+ * Every assistant message is swept, not just the turn's last item: upstream
306
+ * validates the whole replayed history, and a host may seat `system`,
307
+ * `developer` or additional-tool items between a commentary item and the next
308
+ * user message, so a tail-only sweep would miss one. One sweep also covers a
309
+ * commentary item left behind by an earlier turn once later turns append to
310
+ * the same history.
311
+ */
312
+ function stripCommentaryPhase(body: Record<string, unknown>): void {
313
+ const input = body.input;
314
+ if (!Array.isArray(input)) return;
315
+ for (const item of input) {
316
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
317
+ const record = item as Record<string, unknown>;
318
+ if (record.role === "assistant" && record.phase === "commentary") delete record.phase;
319
+ }
320
+ }
321
+
322
+ /**
323
+ * Reduce `tool_choice` to what Zen accepts: `"auto"`, or absent.
324
+ *
325
+ * Upstream rejects every other value with `400 only "auto" is supported for
326
+ * tool_choice` — `"none"`, `"required"`, and named function choices alike
327
+ * (measured live 2026-10-12 on `muse-spark-1.3-contributor-free`, responses and
328
+ * chat). Absent means the same thing as `"auto"`, so an unsupported choice is
329
+ * dropped rather than translated. A choice is never imposed on a host that
330
+ * sent none.
331
+ *
332
+ * Dropping a choice can leave a host's native reasoning switched off: OMP
333
+ * forces its synthetic `think` tool with `reasoning: { effort: "none" }`
334
+ * (captured request 2026-10-12), so removing the forced call without
335
+ * restoring native effort would run the model with no reasoning at all.
336
+ */
337
+ function enforceAutoToolChoice(body: Record<string, unknown>): void {
338
+ const choice = body.tool_choice;
339
+ if (choice === undefined) return;
340
+ if (typeof choice === "string" && choice.trim().toLowerCase() === "auto") return;
341
+ delete body.tool_choice;
342
+ restoreNativeEffortAfterChoiceDrop(body);
343
+ }
344
+
345
+ /**
346
+ * Put native reasoning back when the only thing enabling it was a dropped
347
+ * forced tool call.
348
+ *
349
+ * `"none"` is not a reasoning-off switch upstream, it is an unsupported value:
350
+ * `muse-spark-1.3-contributor-free` answers `400 reasoning_effort 'none' is
351
+ * not supported` (measured 2026-10-12). So the override is replaced with the
352
+ * model's own highest supported level rather than merely deleted, and a model
353
+ * whose declared map supports nothing but `off` gets the field dropped.
354
+ * Genuine reasoning-off (`"off"`) is the caller's real intent, never rewritten.
355
+ */
356
+ function restoreNativeEffortAfterChoiceDrop(body: Record<string, unknown>): void {
357
+ const model = body.model;
358
+ if (typeof model !== "string") return;
359
+ const replacement = highestSupportedEffort(model);
360
+ if (replacement === null) return;
361
+ const nested = body.reasoning;
362
+ if (nested !== null && typeof nested === "object" && !Array.isArray(nested)) {
363
+ const rec = nested as Record<string, unknown>;
364
+ if (typeof rec.effort === "string" && rec.effort.trim().toLowerCase() === "none") {
365
+ rec.effort = replacement;
366
+ return;
367
+ }
368
+ }
369
+ for (const key of ["reasoning_effort", "reasoningEffort"] as const) {
370
+ const flat = body[key];
371
+ if (typeof flat === "string" && flat.trim().toLowerCase() === "none") {
372
+ body[key] = replacement;
373
+ return;
374
+ }
375
+ }
376
+ }
377
+
378
+ /**
379
+ * Highest non-null level a model's declared map supports, or null when the
380
+ * model declares no map or only supports `off` (nothing to fall back to).
381
+ */
382
+ function highestSupportedEffort(model: string): string | null {
383
+ const map = getModelDef(model)?.thinkingLevelMap;
384
+ if (!map) return null;
385
+ for (const level of EFFORT_DESCENDING) {
386
+ if (map[level] !== null && map[level] !== undefined) return level;
387
+ }
388
+ return null;
389
+ }
390
+
391
+ /**
392
+ * Fold the name of a replayed tool-call item to a shape upstream accepts.
393
+ *
394
+ * A model can emit a malformed call and the host then replays it in `input[]`
395
+ * forever: upstream validates every replayed item, so one call named
396
+ * `id id=` (captured in a live session, emitted by `space-bunny-free`) 400s
397
+ * every subsequent turn of that conversation. Declarations were sanitized
398
+ * already; this covers the calls.
399
+ */
400
+ function sanitizeReplayItemNames(body: Record<string, unknown>): void {
401
+ const input = body.input;
402
+ if (!Array.isArray(input)) return;
403
+ for (const item of input) {
404
+ if (!item || typeof item !== "object" || Array.isArray(item)) continue;
405
+ const record = item as Record<string, unknown>;
406
+ if (record.type !== "function_call" && record.type !== "custom_tool_call") continue;
407
+ const name = record.name;
408
+ if (typeof name !== "string") continue;
409
+ const folded = sanitizeUpstreamToolName(name, new Set());
410
+ if (folded !== name) record.name = folded;
411
+ }
412
+ }
413
+
286
414
  /**
287
415
  * Normalize a Zen Responses body to what upstream accepts. OMP/Pi hosts
288
416
  * sometimes carry Chat Completions leftovers on the responses path and
@@ -294,9 +422,12 @@ export function ensureMessagesFingerprintTools(body: Record<string, unknown>, om
294
422
  * - `temperature` / `top_p` are dropped (Responses rejects them).
295
423
  * - `response_format` is dropped (Chat Completions field, Responses rejects it).
296
424
  * - `parallel_tool_calls: false` is dropped; absent-or-true rides verbatim.
297
- * - `tool_choice` is auto-or-absent: `"none"` (string or `{ type: "none" }`)
298
- * is dropped, everything else rides (named choices still retarget find->glob).
425
+ * - `tool_choice` is auto-or-absent: `"auto"` rides verbatim and everything
426
+ else is dropped, because Zen supports no other value (see
427
+ enforceAutoToolChoice).
299
428
  * - `store` defaults to false (never persisted server-side).
429
+ * - Assistant message items lose `phase: "commentary"`; upstream 400s the whole
430
+ request otherwise (see stripCommentaryPhase).
300
431
  * - Reasoning effort is clamped per model: when the model's thinkingLevelMap
301
432
  * declares the requested effort as null (unsupported), the effort is clamped
302
433
  * to the highest supported level. Covers all OpenCode models generically
@@ -310,6 +441,10 @@ export function normalizeResponsesBody(body: Record<string, unknown>): void {
310
441
  body.input = body.messages;
311
442
  }
312
443
  delete body.messages;
444
+ stripCommentaryPhase(body);
445
+ // After input[] exists: a replayed call name is only folded once the array is
446
+ // the one upstream will read.
447
+ sanitizeReplayItemNames(body);
313
448
  if (body.max_output_tokens === undefined) {
314
449
  if (typeof body.max_tokens === "number") body.max_output_tokens = body.max_tokens;
315
450
  else if (typeof body.max_completion_tokens === "number") body.max_output_tokens = body.max_completion_tokens;
@@ -320,11 +455,6 @@ export function normalizeResponsesBody(body: Record<string, unknown>): void {
320
455
  delete body.top_p;
321
456
  delete body.response_format;
322
457
  if (body.parallel_tool_calls === false) delete body.parallel_tool_calls;
323
- const choice = body.tool_choice;
324
- if (typeof choice === "string" ? choice.toLowerCase() === "none" : (
325
- choice !== null && typeof choice === "object" && !Array.isArray(choice)
326
- && (choice as Record<string, unknown>).type === "none"
327
- )) delete body.tool_choice;
328
458
  if (typeof body.model === "string") {
329
459
  const reasoning = body.reasoning;
330
460
  if (reasoning !== null && typeof reasoning === "object" && !Array.isArray(reasoning)) {
@@ -342,6 +472,9 @@ export function normalizeResponsesBody(body: Record<string, unknown>): void {
342
472
  }
343
473
  }
344
474
  }
475
+ // After the effort clamp: dropping a forced tool call is what triggers the
476
+ // native-effort restore, so it runs once clamping is settled.
477
+ enforceAutoToolChoice(body);
345
478
  if (body.store === undefined) body.store = false;
346
479
  }
347
480
 
@@ -380,19 +513,13 @@ export function enforceOpencodeFingerprint(
380
513
  } {
381
514
  const clientRequestedStream = body.stream === true;
382
515
  const callerHadTools = Array.isArray(body.tools) && body.tools.length > 0;
383
- // Zen responses shape conformance first (Chat leftovers, tool_choice none,
384
- // spark effort clamp, store default): Kilo/Cline bodies never reach here.
516
+ // Zen responses shape conformance first (Chat leftovers, tool_choice, spark
517
+ // effort clamp, store default): Kilo/Cline bodies never reach here.
385
518
  if (pathname.endsWith("/responses")) normalizeResponsesBody(body);
386
519
  // Chat path mirrors the responses drop: Zen free tier accepts only auto, so a
387
520
  // caller "none" (string or { type: "none" }) would 400 upstream. Messages
388
521
  // path keeps its own choice semantics.
389
- if (!pathname.endsWith("/responses") && !pathname.endsWith("/messages")) {
390
- const noneChoice = body.tool_choice;
391
- if (typeof noneChoice === "string" ? noneChoice.toLowerCase() === "none" : (
392
- noneChoice !== null && typeof noneChoice === "object" && !Array.isArray(noneChoice)
393
- && (noneChoice as Record<string, unknown>).type === "none"
394
- )) delete body.tool_choice;
395
- }
522
+ enforceAutoToolChoice(body);
396
523
  // Upstream Zen free tier mandates stream: true for all free requests
397
524
  body.stream = true;
398
525