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/CHANGELOG.md +30 -0
- package/README.md +65 -33
- 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 +181 -46
- package/src/opencode-fingerprint.ts +143 -16
- package/src/proxy.ts +188 -13
- 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
|
|
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
|
-
* -
|
|
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
|
-
*
|
|
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).
|
|
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
|
-
|
|
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]",
|
|
490
|
+
id: "ovh-free/gpt-oss-120b",
|
|
491
|
+
name: "GPT-OSS 120B [OVH]",
|
|
461
492
|
reasoning: true,
|
|
462
|
-
contextWindow:
|
|
463
|
-
maxTokens:
|
|
464
|
-
|
|
465
|
-
thinkingLevelMap:
|
|
493
|
+
contextWindow: 131_072,
|
|
494
|
+
maxTokens: 32_768,
|
|
495
|
+
input: ["text"],
|
|
496
|
+
thinkingLevelMap: OVH_REASONING_MAP,
|
|
466
497
|
},
|
|
467
498
|
{
|
|
468
|
-
id: "
|
|
469
|
-
name: "
|
|
499
|
+
id: "ovh-free/gpt-oss-20b",
|
|
500
|
+
name: "GPT-OSS 20B [OVH]",
|
|
470
501
|
reasoning: true,
|
|
471
|
-
contextWindow:
|
|
472
|
-
maxTokens:
|
|
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
|
|
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: `"
|
|
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
|
|