pi-freeflow 1.34.0 → 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-09):
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";
@@ -378,24 +379,10 @@ export const KILO_MODELS: ModelDef[] = [
378
379
  ];
379
380
 
380
381
  /**
381
- * Full-range effort map (Solar Pro 4 — reference lists none..max; off stays
382
- * null per repo convention). The proxy passes effort through verbatim and
383
- * upstream serves the levels it supports.
384
- */
385
- const CLINE_REASONING_MAP: ThinkingLevelMap = {
386
- off: null,
387
- minimal: "minimal",
388
- low: "low",
389
- medium: "medium",
390
- high: "high",
391
- xhigh: "xhigh",
392
- max: "max",
393
- };
394
- /**
395
- * Narrow effort map for DeepSeek V4 Flash + GLM Flash + Kimi K3 — reference
396
- * reasoningOptions list effort values [low, high, max] only (no toggle
397
- * on GLM); minimal/medium/xhigh are not offered so the host never sends
398
- * 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).
399
386
  */
400
387
  const CLINE_FLASH_REASONING_MAP: ThinkingLevelMap = {
401
388
  off: null,
@@ -415,15 +402,6 @@ const CLINE_FLASH_REASONING_MAP: ThinkingLevelMap = {
415
402
  * Endpoint: https://api.cline.bot/api/v1/chat/completions
416
403
  */
417
404
  export const CLINE_MODELS: ModelDef[] = [
418
- {
419
- id: "cline-free/deepseek-v4.1-flash",
420
- name: "DeepSeek V4.1 Flash [Cline]",
421
- reasoning: true,
422
- contextWindow: 1_000_000,
423
- maxTokens: 384_000,
424
- input: ["text", "image"],
425
- thinkingLevelMap: CLINE_FLASH_REASONING_MAP,
426
- },
427
405
  {
428
406
  id: "cline-free/muse-spark-1.3-contributor",
429
407
  name: "Muse Spark 1.3 [Cline]",
@@ -451,30 +429,158 @@ export const CLINE_MODELS: ModelDef[] = [
451
429
  input: ["text", "image", "video"],
452
430
  thinkingLevelMap: CLINE_FLASH_REASONING_MAP,
453
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[] = [
454
489
  {
455
- // Cline advertises this sixth free model only to its desktop client
456
- // identity (see CLINE_CLIENT_HEADERS). Specs follow the vendored Cline
457
- // catalog; maxTokens keeps the tighter chat value so a request can never
458
- // overrun what the chat endpoint accepts.
459
- id: "cline-free/kimi-k3",
460
- name: "Kimi K3 [Cline]",
490
+ id: "ovh-free/gpt-oss-120b",
491
+ name: "GPT-OSS 120B [OVH]",
461
492
  reasoning: true,
462
- contextWindow: 1_048_576,
463
- maxTokens: 131_072,
464
- input: ["text", "image", "video"],
465
- thinkingLevelMap: CLINE_FLASH_REASONING_MAP,
493
+ contextWindow: 131_072,
494
+ maxTokens: 32_768,
495
+ input: ["text"],
496
+ thinkingLevelMap: OVH_REASONING_MAP,
466
497
  },
467
498
  {
468
- id: "cline-free/solar-pro4",
469
- name: "Solar Pro4 [Cline]",
499
+ id: "ovh-free/gpt-oss-20b",
500
+ name: "GPT-OSS 20B [OVH]",
470
501
  reasoning: true,
471
- contextWindow: 524_288,
472
- maxTokens: 131_072,
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,
513
+ input: ["text", "image"],
514
+ thinkingLevelMap: OVH_REASONING_MAP,
515
+ },
516
+ {
517
+ id: "ovh-free/Qwen3.5-9B",
518
+ name: "Qwen3.5 9B [OVH]",
519
+ reasoning: true,
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,
473
565
  input: ["text"],
474
- thinkingLevelMap: CLINE_REASONING_MAP,
475
566
  },
476
567
  ];
477
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
+
478
584
  /**
479
585
  * Model ID Aliases — maps user-friendly / slash-free CLI IDs to canonical upstream model IDs.
480
586
  * Single clean alias per model (no :free duplicates). Wrong cross-lab aliases removed:
@@ -505,6 +611,16 @@ export const MODEL_ALIASES: Record<string, string> = {
505
611
  "laguna-xs-2.1:free": "poolside/laguna-xs-2.1:free",
506
612
  "kilo-auto": "kilo-auto/free",
507
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",
508
624
  };
509
625
 
510
626
  /**
@@ -533,10 +649,20 @@ export const CLINE_MODEL_IDS = new Set<string>([
533
649
  .filter(([_, target]) => CLINE_MODELS.some((cm) => cm.id === target))
534
650
  .map(([alias]) => alias),
535
651
  ]);
652
+
653
+ /**
654
+ * Set of all OVH model IDs (including aliases) for fast lookup
655
+ */
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
+ ]);
536
662
  /**
537
- * Combined list of all 30 static free models (canonical)
663
+ * Combined list of all 36 static free models (canonical)
538
664
  */
539
- export const ALL_MODELS: ModelDef[] = [...OPENCODE_MODELS, ...KILO_MODELS, ...CLINE_MODELS];
665
+ export const ALL_MODELS: ModelDef[] = [...OPENCODE_MODELS, ...KILO_MODELS, ...CLINE_MODELS, ...OVH_MODELS];
540
666
 
541
667
  /**
542
668
  * Map of model ID -> ModelDef
@@ -576,10 +702,19 @@ export function isClineModel(id: string): boolean {
576
702
  return CLINE_MODEL_IDS.has(id) || CLINE_MODEL_IDS.has(canonical);
577
703
  }
578
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
+
579
713
  /**
580
714
  * Determine the upstream provider for a given model ID
581
715
  */
582
716
  export function getModelUpstream(id: string): Upstream {
583
717
  if (isClineModel(id)) return "cline";
718
+ if (isOvhModel(id)) return "ovh";
584
719
  return isKiloModel(id) ? "kilo" : "opencode";
585
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