pi-freeflow 1.34.0 → 1.36.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 +59 -0
- package/README.md +67 -34
- package/package.json +1 -1
- package/src/catalog.ts +13 -1
- package/src/cline-accounts.ts +15 -13
- package/src/config.ts +11 -0
- package/src/index.ts +3 -3
- package/src/models.ts +203 -47
- package/src/opencode-fingerprint.ts +143 -16
- package/src/proxy.ts +188 -13
- package/src/tool-args.ts +63 -10
- package/src/tool-translation.ts +112 -33
- package/src/types.ts +162 -162
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
|
|
5
|
-
* -
|
|
4
|
+
* Defines the 37 verified free models (live-verified 2026-10-13):
|
|
5
|
+
* - 11 OpenCode Zen models (2 Responses API + 9 Chat Completions)
|
|
6
6
|
* - 15 KiloCode Keyless Gateway models (OpenRouter format)
|
|
7
|
-
* -
|
|
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";
|
|
@@ -14,6 +15,27 @@ import type { ModelDef, ThinkingLevelMap, Upstream } from "./types.ts";
|
|
|
14
15
|
* Endpoint: https://opencode.ai/zen/v1
|
|
15
16
|
*/
|
|
16
17
|
export const OPENCODE_MODELS: ModelDef[] = [
|
|
18
|
+
{
|
|
19
|
+
id: "cat-jam-free",
|
|
20
|
+
name: "Cat Jam [OpenCode]",
|
|
21
|
+
reasoning: true,
|
|
22
|
+
// Context is 262_144, NOT the 1M an acceptance probe suggests: the gateway
|
|
23
|
+
// answers 200 to oversized prompts and silently truncates. A needle planted
|
|
24
|
+
// at the end of a 248,232-token prompt is recalled; at 250,272 it is not.
|
|
25
|
+
// Cross-checked against the published spec, which agrees.
|
|
26
|
+
contextWindow: 262_144,
|
|
27
|
+
maxTokens: 131_072,
|
|
28
|
+
input: ["text"],
|
|
29
|
+
thinkingLevelMap: {
|
|
30
|
+
off: null,
|
|
31
|
+
minimal: null,
|
|
32
|
+
low: "low",
|
|
33
|
+
medium: "medium",
|
|
34
|
+
high: "high",
|
|
35
|
+
xhigh: "xhigh",
|
|
36
|
+
max: null,
|
|
37
|
+
},
|
|
38
|
+
},
|
|
17
39
|
{
|
|
18
40
|
id: "muse-spark-1.2-contributor-free",
|
|
19
41
|
name: "Muse Spark 1.2 [OpenCode]",
|
|
@@ -378,24 +400,10 @@ export const KILO_MODELS: ModelDef[] = [
|
|
|
378
400
|
];
|
|
379
401
|
|
|
380
402
|
/**
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
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).
|
|
403
|
+
* Narrow effort map for the Cline flash models still in the catalog (GLM 5.3
|
|
404
|
+
* Flash) — the reference reasoningOptions list effort values [low, high, max]
|
|
405
|
+
* only (no toggle on GLM); minimal/medium/xhigh are not offered so the host
|
|
406
|
+
* never sends an unsupported level (the proxy forwards effort verbatim).
|
|
399
407
|
*/
|
|
400
408
|
const CLINE_FLASH_REASONING_MAP: ThinkingLevelMap = {
|
|
401
409
|
off: null,
|
|
@@ -415,15 +423,6 @@ const CLINE_FLASH_REASONING_MAP: ThinkingLevelMap = {
|
|
|
415
423
|
* Endpoint: https://api.cline.bot/api/v1/chat/completions
|
|
416
424
|
*/
|
|
417
425
|
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
426
|
{
|
|
428
427
|
id: "cline-free/muse-spark-1.3-contributor",
|
|
429
428
|
name: "Muse Spark 1.3 [Cline]",
|
|
@@ -451,30 +450,158 @@ export const CLINE_MODELS: ModelDef[] = [
|
|
|
451
450
|
input: ["text", "image", "video"],
|
|
452
451
|
thinkingLevelMap: CLINE_FLASH_REASONING_MAP,
|
|
453
452
|
},
|
|
453
|
+
];
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Narrow effort map for the OVH reasoning models — probed live 2026-10-13:
|
|
457
|
+
* `reasoning_effort: "low"` and `"high"` are accepted (HTTP 200 with a
|
|
458
|
+
* populated reasoning stream). minimal/medium/xhigh/max were not confirmed,
|
|
459
|
+
* so they map to null and the picker never offers them. `minimal` must be an
|
|
460
|
+
* explicit null, not an omitted key: omitting it leaves the level selectable
|
|
461
|
+
* in the host. Same shape as CLINE_FLASH_REASONING_MAP.
|
|
462
|
+
*/
|
|
463
|
+
const OVH_REASONING_MAP: ThinkingLevelMap = {
|
|
464
|
+
off: null,
|
|
465
|
+
minimal: null,
|
|
466
|
+
low: "low",
|
|
467
|
+
medium: null,
|
|
468
|
+
high: "high",
|
|
469
|
+
xhigh: null,
|
|
470
|
+
max: null,
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Catalog-id namespace for the OVH lane. OVH model ids are case-sensitive on
|
|
475
|
+
* the wire and several carry capitals and dots, so the prefix namespaces the
|
|
476
|
+
* lane (and keeps the id recognized by isFreeCatalogId) while the suffix is
|
|
477
|
+
* sent upstream verbatim.
|
|
478
|
+
*/
|
|
479
|
+
export const OVH_MODEL_PREFIX = "ovh-free/";
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* OVHcloud AI Endpoints free models — keyless, direct-only.
|
|
483
|
+
* Endpoint: https://oai.endpoints.kepler.ai.cloud.ovh.net/v1
|
|
484
|
+
*
|
|
485
|
+
* Every id is OVH-native with an `ovh-free/` prefix; upstream only knows the
|
|
486
|
+
* suffix (a prefixed id answers 404 `model_not_found`, verified 2026-10-13),
|
|
487
|
+
* so the proxy strips the prefix on the wire. `api` stays unset, i.e.
|
|
488
|
+
* openai-completions: chat is the native shape and responses is forwarded
|
|
489
|
+
* natively too (with store:false injected). Anthropic messages is not served
|
|
490
|
+
* (`/v1/messages` answers 404 unknown_url upstream).
|
|
491
|
+
*
|
|
492
|
+
* Specs (measured live 2026-10-13 and cross-checked against every twin
|
|
493
|
+
* provider carrying a spec): contextWindow is OVH's advertised value;
|
|
494
|
+
* maxTokens is deliberately the conservative FLOOR across twin providers —
|
|
495
|
+
* per-provider output caps for the same model range from 4K to 256K, and a
|
|
496
|
+
* cap that is too low never breaks a request while one that is too high lets
|
|
497
|
+
* a host ask for an output the provider refuses. OVH itself enforces only
|
|
498
|
+
* `context - prompt` (verified by binary search), so these are safe request
|
|
499
|
+
* budgets rather than a refusal boundary.
|
|
500
|
+
*
|
|
501
|
+
* Vision was probed with a generated solid-colour PNG sent as a data URL,
|
|
502
|
+
* twice with two different colours to defeat a memorised answer: the four
|
|
503
|
+
* Qwens and Mistral-Small-3.2 named the colour; Mistral-7B and Llama-3.3-70B
|
|
504
|
+
* answer HTTP 400 to an image part; gpt-oss-120b returns 200 while its own
|
|
505
|
+
* reasoning says it cannot see the image, so it is text-only. Reasoning is
|
|
506
|
+
* the presence of a populated `reasoning`/`reasoning_content` field on a
|
|
507
|
+
* non-tool answer.
|
|
508
|
+
*/
|
|
509
|
+
export const OVH_MODELS: ModelDef[] = [
|
|
454
510
|
{
|
|
455
|
-
|
|
456
|
-
|
|
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]",
|
|
511
|
+
id: "ovh-free/gpt-oss-120b",
|
|
512
|
+
name: "GPT-OSS 120B [OVH]",
|
|
461
513
|
reasoning: true,
|
|
462
|
-
contextWindow:
|
|
463
|
-
maxTokens:
|
|
464
|
-
|
|
465
|
-
thinkingLevelMap:
|
|
514
|
+
contextWindow: 131_072,
|
|
515
|
+
maxTokens: 32_768,
|
|
516
|
+
input: ["text"],
|
|
517
|
+
thinkingLevelMap: OVH_REASONING_MAP,
|
|
466
518
|
},
|
|
467
519
|
{
|
|
468
|
-
id: "
|
|
469
|
-
name: "
|
|
520
|
+
id: "ovh-free/gpt-oss-20b",
|
|
521
|
+
name: "GPT-OSS 20B [OVH]",
|
|
470
522
|
reasoning: true,
|
|
471
|
-
contextWindow:
|
|
472
|
-
maxTokens:
|
|
523
|
+
contextWindow: 131_072,
|
|
524
|
+
maxTokens: 32_768,
|
|
525
|
+
input: ["text"],
|
|
526
|
+
thinkingLevelMap: OVH_REASONING_MAP,
|
|
527
|
+
},
|
|
528
|
+
{
|
|
529
|
+
id: "ovh-free/Qwen3.5-397B-A17B",
|
|
530
|
+
name: "Qwen3.5 397B A17B [OVH]",
|
|
531
|
+
reasoning: true,
|
|
532
|
+
contextWindow: 262_144,
|
|
533
|
+
maxTokens: 65_536,
|
|
534
|
+
input: ["text", "image"],
|
|
535
|
+
thinkingLevelMap: OVH_REASONING_MAP,
|
|
536
|
+
},
|
|
537
|
+
{
|
|
538
|
+
id: "ovh-free/Qwen3.5-9B",
|
|
539
|
+
name: "Qwen3.5 9B [OVH]",
|
|
540
|
+
reasoning: true,
|
|
541
|
+
contextWindow: 262_144,
|
|
542
|
+
maxTokens: 65_536,
|
|
543
|
+
input: ["text", "image"],
|
|
544
|
+
thinkingLevelMap: OVH_REASONING_MAP,
|
|
545
|
+
},
|
|
546
|
+
{
|
|
547
|
+
id: "ovh-free/Qwen3.6-27B",
|
|
548
|
+
name: "Qwen3.6 27B [OVH]",
|
|
549
|
+
reasoning: true,
|
|
550
|
+
contextWindow: 262_144,
|
|
551
|
+
maxTokens: 65_536,
|
|
552
|
+
input: ["text", "image"],
|
|
553
|
+
thinkingLevelMap: OVH_REASONING_MAP,
|
|
554
|
+
},
|
|
555
|
+
{
|
|
556
|
+
id: "ovh-free/Qwen3.8-27B",
|
|
557
|
+
name: "Qwen3.8 27B [OVH]",
|
|
558
|
+
reasoning: true,
|
|
559
|
+
contextWindow: 262_144,
|
|
560
|
+
maxTokens: 32_768,
|
|
561
|
+
input: ["text", "image"],
|
|
562
|
+
thinkingLevelMap: OVH_REASONING_MAP,
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
id: "ovh-free/Mistral-Small-3.2-24B-Instruct-2506",
|
|
566
|
+
name: "Mistral Small 3.2 24B [OVH]",
|
|
567
|
+
reasoning: false,
|
|
568
|
+
contextWindow: 131_072,
|
|
569
|
+
maxTokens: 16_384,
|
|
570
|
+
input: ["text", "image"],
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
id: "ovh-free/Mistral-7B-Instruct-v0.3",
|
|
574
|
+
name: "Mistral 7B Instruct v0.3 [OVH]",
|
|
575
|
+
reasoning: false,
|
|
576
|
+
contextWindow: 65_536,
|
|
577
|
+
maxTokens: 65_536,
|
|
578
|
+
input: ["text"],
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
id: "ovh-free/Meta-Llama-3_3-70B-Instruct",
|
|
582
|
+
name: "Meta Llama 3.3 70B Instruct [OVH]",
|
|
583
|
+
reasoning: false,
|
|
584
|
+
contextWindow: 131_072,
|
|
585
|
+
maxTokens: 16_384,
|
|
473
586
|
input: ["text"],
|
|
474
|
-
thinkingLevelMap: CLINE_REASONING_MAP,
|
|
475
587
|
},
|
|
476
588
|
];
|
|
477
589
|
|
|
590
|
+
/**
|
|
591
|
+
* Resolve any OVH id to the bare id OVH knows on the wire. Canonicalises
|
|
592
|
+
* first, then strips: a short alias (`llama-3.3-70b`) must resolve to
|
|
593
|
+
* `ovh-free/Meta-Llama-3_3-70B-Instruct` before the prefix comes off, or it
|
|
594
|
+
* reaches OVH unrecognised and answers 404 `model_not_found` (live
|
|
595
|
+
* 2026-10-13, both for a prefixed id and for an alias sent verbatim). The
|
|
596
|
+
* remainder is case-sensitive, so it is returned unchanged.
|
|
597
|
+
*/
|
|
598
|
+
export function ovhUpstreamModelId(id: string): string {
|
|
599
|
+
const canonical = resolveCanonicalModelId(id);
|
|
600
|
+
return canonical.startsWith(OVH_MODEL_PREFIX)
|
|
601
|
+
? canonical.slice(OVH_MODEL_PREFIX.length)
|
|
602
|
+
: canonical;
|
|
603
|
+
}
|
|
604
|
+
|
|
478
605
|
/**
|
|
479
606
|
* Model ID Aliases — maps user-friendly / slash-free CLI IDs to canonical upstream model IDs.
|
|
480
607
|
* Single clean alias per model (no :free duplicates). Wrong cross-lab aliases removed:
|
|
@@ -505,6 +632,16 @@ export const MODEL_ALIASES: Record<string, string> = {
|
|
|
505
632
|
"laguna-xs-2.1:free": "poolside/laguna-xs-2.1:free",
|
|
506
633
|
"kilo-auto": "kilo-auto/free",
|
|
507
634
|
"openrouter": "openrouter/free",
|
|
635
|
+
// OVHcloud AI Endpoints — slash-free spellings of the `ovh-free/` ids.
|
|
636
|
+
"gpt-oss-120b": "ovh-free/gpt-oss-120b",
|
|
637
|
+
"gpt-oss-20b": "ovh-free/gpt-oss-20b",
|
|
638
|
+
"qwen3.5-397b": "ovh-free/Qwen3.5-397B-A17B",
|
|
639
|
+
"qwen3.5-9b": "ovh-free/Qwen3.5-9B",
|
|
640
|
+
"qwen3.6-27b": "ovh-free/Qwen3.6-27B",
|
|
641
|
+
"qwen3.8-27b": "ovh-free/Qwen3.8-27B",
|
|
642
|
+
"mistral-small-3.2": "ovh-free/Mistral-Small-3.2-24B-Instruct-2506",
|
|
643
|
+
"mistral-7b-v0.3": "ovh-free/Mistral-7B-Instruct-v0.3",
|
|
644
|
+
"llama-3.3-70b": "ovh-free/Meta-Llama-3_3-70B-Instruct",
|
|
508
645
|
};
|
|
509
646
|
|
|
510
647
|
/**
|
|
@@ -533,10 +670,20 @@ export const CLINE_MODEL_IDS = new Set<string>([
|
|
|
533
670
|
.filter(([_, target]) => CLINE_MODELS.some((cm) => cm.id === target))
|
|
534
671
|
.map(([alias]) => alias),
|
|
535
672
|
]);
|
|
673
|
+
|
|
536
674
|
/**
|
|
537
|
-
*
|
|
675
|
+
* Set of all OVH model IDs (including aliases) for fast lookup
|
|
538
676
|
*/
|
|
539
|
-
export const
|
|
677
|
+
export const OVH_MODEL_IDS = new Set<string>([
|
|
678
|
+
...OVH_MODELS.map((m) => m.id),
|
|
679
|
+
...Object.entries(MODEL_ALIASES)
|
|
680
|
+
.filter(([_, target]) => OVH_MODELS.some((om) => om.id === target))
|
|
681
|
+
.map(([alias]) => alias),
|
|
682
|
+
]);
|
|
683
|
+
/**
|
|
684
|
+
* Combined list of all 36 static free models (canonical)
|
|
685
|
+
*/
|
|
686
|
+
export const ALL_MODELS: ModelDef[] = [...OPENCODE_MODELS, ...KILO_MODELS, ...CLINE_MODELS, ...OVH_MODELS];
|
|
540
687
|
|
|
541
688
|
/**
|
|
542
689
|
* Map of model ID -> ModelDef
|
|
@@ -576,10 +723,19 @@ export function isClineModel(id: string): boolean {
|
|
|
576
723
|
return CLINE_MODEL_IDS.has(id) || CLINE_MODEL_IDS.has(canonical);
|
|
577
724
|
}
|
|
578
725
|
|
|
726
|
+
/**
|
|
727
|
+
* Check if a model ID belongs to OVHcloud AI Endpoints
|
|
728
|
+
*/
|
|
729
|
+
export function isOvhModel(id: string): boolean {
|
|
730
|
+
const canonical = resolveCanonicalModelId(id);
|
|
731
|
+
return OVH_MODEL_IDS.has(id) || OVH_MODEL_IDS.has(canonical);
|
|
732
|
+
}
|
|
733
|
+
|
|
579
734
|
/**
|
|
580
735
|
* Determine the upstream provider for a given model ID
|
|
581
736
|
*/
|
|
582
737
|
export function getModelUpstream(id: string): Upstream {
|
|
583
738
|
if (isClineModel(id)) return "cline";
|
|
739
|
+
if (isOvhModel(id)) return "ovh";
|
|
584
740
|
return isKiloModel(id) ? "kilo" : "opencode";
|
|
585
741
|
}
|
|
@@ -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: `"
|
|
298
|
-
|
|
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
|
|
384
|
-
//
|
|
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
|
-
|
|
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
|
|