pi-freeflow 1.33.2 → 1.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.34.0
4
+
5
+ ### Minor Changes
6
+
7
+ - aa6f259: Three new free models, picked the same way as the rest (`/model` → `freeflow` → pick):
8
+
9
+ - **Step 5 Preview** on OpenCode Zen (`step-5-preview-free`, short name `step-5-preview`) — 1M context, answers with vision.
10
+ - **Step 5 Preview** on KiloCode (`stepfun/step-5-preview-free`, short name `step-5-preview:kilo`) — 1M context, answers with vision.
11
+ - **Glyph Cluster** on KiloCode (`stealth/glyph-cluster`, short name `glyph-cluster`) — 256K context, text answers.
12
+
13
+ ### Patch Changes
14
+
15
+ - 50981fb: Align nine model entries with the specs their upstream catalogs publish. Models now advertise video input where the vendor lists it, two entries pick up a larger context window, and Step 5 Preview on KiloCode reports the full 64K output allowance.
16
+ - f637c58: Recover tool calls that arrive with the tail of the previous call stuck to the
17
+ front. Some models open a new call by echoing the end of the one they have just
18
+ seen, so a single call is delivered as `<tail of previous>{"…this call…"}` and
19
+ the host refuses it as invalid JSON. When such a payload does not parse, the
20
+ proxy now locates the complete call behind the stray text and hands that back.
21
+
22
+ Only payloads that already fail to parse are rescanned, and only a position that
23
+ yields a complete object is accepted — a well-formed call is never rewritten,
24
+ and a truncated one is still refused rather than completed.
25
+
26
+ Also adds a whole-inventory fidelity test covering every tool either host can
27
+ declare, across all three API shapes.
28
+
29
+ - aa6f259: Remove three free models that stopped serving upstream, so they no longer appear in the model list:
30
+
31
+ - **Fledge Alpha** on OpenCode (`fledge-alpha-free`) — withdrawn upstream and no longer listed.
32
+ - **Ling 3.0 Flash Sante** on KiloCode (`inclusionai/ling-3.0-flash-sante:free`) — the free variant is gone; only the paid one remains.
33
+ - **Step 3.7 Flash** on KiloCode (`stepfun/step-3.7-flash:free`) — the free variant is gone; only the paid one remains.
34
+
35
+ All three are excluded permanently, so a stale on-disk catalog or a later catalog refresh cannot bring them back.
36
+
37
+ - 50981fb: Correct the OpenCode Zen Step 5 Preview entry: it is a reasoning model with 1M context, 64K max output, text/image/video input, and only `low`/`medium`/`high` thinking levels (`minimal` is explicitly disabled). Previously the entry mirrored the Kilo sibling and the fallback heuristic, so the picker showed no thinking support.
38
+
39
+ ## 1.33.3
40
+
41
+ ### Patch Changes
42
+
43
+ - Fix tool calls being rejected with an invalid-arguments error when a model makes several calls in one turn. Some models label every parallel call `0`, so the pieces of different calls were joined together and arrived as one unreadable blob — a planning tool could then reject every update, even a minimal one, and leave its task list stuck mid-turn. Calls are now kept apart by their own identifier, and a call whose text arrives in several pieces still reassembles whole.
44
+
3
45
  ## 1.33.2
4
46
 
5
47
  ### Patch Changes
package/README.md CHANGED
@@ -205,7 +205,7 @@ Good defaults for long coding sessions and agentic work.
205
205
  | `big-pickle` | Big Pickle | **200K** (200.000) | **32K** (32.000) | `high / max` | ❌ |
206
206
  | `space-bunny-free` | Stealth preview (lab undisclosed) | **1M** (1.048.576) | **512K** (524.288) | `low … max` | ✅ |
207
207
  | `longcat-2.5-preview-free` | Meituan LongCat | **1M** (1.000.000) | **131K** (131.072) | `minimal … max` | ✅ |
208
- | `fledge-alpha-free` | Stealth preview (lab undisclosed) | **1M** (1.048.576) | **131K** (131.072) | `low / high / max` | ✅ |
208
+ | `step-5-preview-free` | StepFun | **1M** (1.000.000) | **64K** (65.536) | `low … high` | ✅ |
209
209
  | `ling-3.1-flash-free` | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal … max` | ❌ |
210
210
 
211
211
  #### KiloCode Gateway (16 models), OpenRouter compatible
@@ -225,8 +225,8 @@ Keyless access. Short aliases work for every row (the full ID is in parentheses)
225
225
  | `lfm-2.5` (`liquid/lfm-2.5-2.6b:free`) | Liquid AI | **65K** (65.536) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
226
226
  | `kilo-auto` (`kilo-auto/free`) | Kilo Gateway Auto | **256K** (256.000) | **10K** (10.000) | `minimal…xhigh`\* | ❌ |
227
227
  | `openrouter` (`openrouter/free`) | OpenRouter Free | **200K** (200.000) | **65K** (65.536) | `minimal…xhigh`\* | ✅ |
228
- | `ling-3.0-flash-sante` (`inclusionai/ling-3.0-flash-sante:free`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
229
- | `step-3.7-flash` (`stepfun/...:free`) | StepFun | **262K** (262.144) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
228
+ | `glyph-cluster` (`stealth/glyph-cluster`) | Stealth preview (lab undisclosed) | **256K** (256.000) | **256K** (256.000) | `minimal…xhigh`\* | ❌ |
229
+ | `step-5-preview:kilo` (`stepfun/...:free`) | StepFun | **1M** (1.000.000) | **64K** (64.000) | `minimal…xhigh`\* | ✅ |
230
230
  | `inkling-small` (`thinkingmachines/inkling-small:free`) | Thinking Machines | **1M** (1.048.576) | **262K** (262.144) | `minimal…xhigh`\* | ✅ |
231
231
  | `ling-3.1-flash-kilo` (`inclusionai/ling-3.1-flash`) | Inclusion AI | **262K** (262.144) | **32K** (32.768) | `minimal…xhigh`\* | ❌ |
232
232
 
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-freeflow",
3
3
  "type": "module",
4
- "version": "1.33.2",
4
+ "version": "1.34.0",
5
5
  "description": "Thin provider for OMP/Pi — model list + dumb relay proxy + log; host pi-ai owns thinking/normalization",
6
6
  "main": "extensions/index.ts",
7
7
  "types": "src/index.ts",
package/src/catalog.ts CHANGED
@@ -30,7 +30,8 @@ import type {
30
30
 
31
31
  /**
32
32
  * Pruned model IDs that must never re-enter the catalog via disk cache or upstream merge.
33
- * - jev-1.13-free: non-chat decision model, chat-incompatible (live chat 500) — never registered.
33
+ * - jev-1.13-free: non-chat decision model, chat-incompatible — never registered.
34
+ * Re-confirmed 2026-10-09: 400 through the plugin while every catalog model 200s.
34
35
  * - deepseek-v4-flash-free: listed but unserved upstream (live chat 400 "Model is unavailable").
35
36
  * - nex-agi/nex-n2.5-pro:free + nex-agi/nex-n2.5-mini:free: gone 2026-09-28 — zero nex-agi
36
37
  * IDs on the live Kilo list, keyless chat 404 "does not exist".
@@ -43,7 +44,18 @@ import type {
43
44
  * - inclusionai/ling-3.0-flash-fin:free: gone from Kilo (2026-10-01) — keyless chat 404
44
45
  * "The requested model ... does not exist".
45
46
  * - mimo-v2.5-free: decommissioned upstream (2026-10-07) — HTTP 401 "Model mimo-v2.5-free is not supported".
46
- * - exo-free: unserved upstream (2026-10-07) — HTTP 503 "Error from provider (Console): Upstream request failed: Endpoint is unavailable.".
47
+ * - exo-free: deprecated upstream (2026-10-09) — HTTP 410 "Model exo-free has
48
+ * been deprecated." (was 503 "Endpoint is unavailable" on 2026-10-07).
49
+ * - fledge-alpha-free: withdrawn upstream (2026-10-09) — absent from the live
50
+ * Zen list, chat 401 "Model fledge-alpha-free is not supported".
51
+ * - inclusionai/ling-3.0-flash-sante:free: demoted to paid on Kilo (2026-10-09) —
52
+ * :free ID gone from the live list, keyless chat 404 "does not exist";
53
+ * only the paid counterpart remains.
54
+ * - stepfun/step-3.7-flash:free: demoted to paid on Kilo (2026-10-09) — same
55
+ * shape: :free ID gone, keyless 404; paid ID remains.
56
+ * - meituan/longcat-2.0-free: demoted to paid on Kilo (2026-10-09) —
57
+ * `meituan/longcat-2.0` remains live with isFree:false; keyless chat
58
+ * answers 401 PAID_MODEL_AUTH_REQUIRED.
47
59
  * - stealth/space-bunny-alpha: gone from Kilo (2026-10-07) — keyless chat 404 "The requested model ... does not exist".
48
60
  * - qwen/qwen3.8-27b:free: gone from Kilo (2026-10-07) — keyless chat 404 "The requested model is currently unavailable.".
49
61
  * - nvidia/nemotron-3.5-content-safety:free: tool-less guardrail model (2026-10-08),
@@ -78,6 +90,9 @@ export const DEAD_MODEL_IDS = new Set<string>([
78
90
  "stealth/space-bunny-alpha",
79
91
  "qwen/qwen3.8-27b:free",
80
92
  "nvidia/nemotron-3.5-content-safety:free",
93
+ "fledge-alpha-free",
94
+ "inclusionai/ling-3.0-flash-sante:free",
95
+ "stepfun/step-3.7-flash:free",
81
96
  ]);
82
97
  /**
83
98
  * Free-tier allowlist for anything entering the picker via network or stale disk.
package/src/models.ts CHANGED
@@ -1,7 +1,7 @@
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 30 verified free models (live-verified 2026-10-09):
5
5
  * - 10 OpenCode Zen models (2 Responses API + 8 Chat Completions)
6
6
  * - 15 KiloCode Keyless Gateway models (OpenRouter format)
7
7
  * - 5 Cline direct-only models (per-user pool)
@@ -21,7 +21,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
21
21
  contextWindow: 1_048_576,
22
22
  maxTokens: 131_072,
23
23
  api: "openai-responses",
24
- input: ["text", "image"],
24
+ input: ["text", "image", "video"],
25
25
  thinkingLevelMap: {
26
26
  off: null,
27
27
  minimal: "minimal",
@@ -39,7 +39,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
39
39
  contextWindow: 1_048_576,
40
40
  maxTokens: 131_072,
41
41
  api: "openai-responses",
42
- input: ["text", "image"],
42
+ input: ["text", "image", "video"],
43
43
  thinkingLevelMap: {
44
44
  off: null,
45
45
  minimal: "minimal",
@@ -56,7 +56,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
56
56
  reasoning: true,
57
57
  contextWindow: 1_048_576,
58
58
  maxTokens: 131_072,
59
- input: ["text", "image"],
59
+ input: ["text", "image", "video"],
60
60
  thinkingLevelMap: {
61
61
  off: null,
62
62
  minimal: "low",
@@ -124,7 +124,7 @@ export const OPENCODE_MODELS: ModelDef[] = [
124
124
  reasoning: true,
125
125
  contextWindow: 1_048_576,
126
126
  maxTokens: 524_288,
127
- input: ["text", "image"],
127
+ input: ["text", "image", "video"],
128
128
  thinkingLevelMap: {
129
129
  off: null,
130
130
  minimal: null,
@@ -153,21 +153,30 @@ export const OPENCODE_MODELS: ModelDef[] = [
153
153
  },
154
154
  },
155
155
  {
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
- },
156
+ // Added 2026-10-09: replaces fledge-alpha-free (withdrawn upstream —
157
+ // absent from the live list, chat 401 "not supported"). Live on the Zen
158
+ // list; gated-lane 403 matches the working models from a refused egress,
159
+ // so it rides the same fingerprint path. Specs per models.dev
160
+ // (opencode/step-5-preview-free) + StepFun docs, NOT the enrichModelDef
161
+ // substring heuristic (which yields reasoning:false / 262K / text-only):
162
+ // 1M ctx, 64K max output, text+image+video, efforts low/medium/high only.
163
+ // minimal is explicitly null (omitting the key leaves it selectable in
164
+ // the host); xhigh/max are unsupported upstream.
165
+ id: "step-5-preview-free",
166
+ name: "Step 5 Preview [OpenCode]",
167
+ reasoning: true,
168
+ contextWindow: 1_000_000,
169
+ maxTokens: 65_536,
170
+ input: ["text", "image", "video"],
171
+ thinkingLevelMap: {
172
+ off: null,
173
+ minimal: null,
174
+ low: "low",
175
+ medium: "medium",
176
+ high: "high",
177
+ xhigh: null,
178
+ max: null,
179
+ },
171
180
  },
172
181
  {
173
182
  id: "ling-3.1-flash-free",
@@ -191,9 +200,10 @@ export const OPENCODE_MODELS: ModelDef[] = [
191
200
  /**
192
201
  * Shared effort map for Kilo reasoning models — verified live 2026-08-29:
193
202
  * 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.
203
+ * model; a StepFun reasoning model (step-3.7-flash, since retired from the
204
+ * free list) measured monotonic 77→313 thinking chars across minimal→xhigh.
205
+ * Declaring the map locks the picker (instead of host guessing) and matches
206
+ * the OpenCode-model pattern.
197
207
  */
198
208
  const KILO_REASONING_MAP: ThinkingLevelMap = {
199
209
  off: null,
@@ -225,7 +235,7 @@ export const KILO_MODELS: ModelDef[] = [
225
235
  reasoning: true,
226
236
  contextWindow: 256_000,
227
237
  maxTokens: 131_072,
228
- input: ["text", "image"],
238
+ input: ["text", "image", "video"],
229
239
  thinkingFormat: "openrouter",
230
240
  thinkingLevelMap: KILO_REASONING_MAP,
231
241
  },
@@ -320,14 +330,15 @@ export const KILO_MODELS: ModelDef[] = [
320
330
  thinkingLevelMap: KILO_REASONING_MAP,
321
331
  },
322
332
  {
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]",
333
+ // Added 2026-10-09: replaces step-3.7-flash (demoted to paid upstream —
334
+ // :free ID 404s keyless). Live on Kilo free list (isFree:true, 0/0
335
+ // pricing, 1M ctx) + keyless chat served (429 provider concurrency).
336
+ id: "stepfun/step-5-preview-free",
337
+ name: "Step 5 Preview [Kilo]",
327
338
  reasoning: true,
328
- contextWindow: 262_144,
329
- maxTokens: 262_144,
330
- input: ["text", "image"],
339
+ contextWindow: 1_000_000,
340
+ maxTokens: 65_536,
341
+ input: ["text", "image", "video"],
331
342
  thinkingFormat: "openrouter",
332
343
  thinkingLevelMap: KILO_REASONING_MAP,
333
344
  },
@@ -342,11 +353,13 @@ export const KILO_MODELS: ModelDef[] = [
342
353
  thinkingLevelMap: KILO_REASONING_MAP,
343
354
  },
344
355
  {
345
- id: "inclusionai/ling-3.0-flash-sante:free",
346
- name: "Ling 3.0 Flash Sante [Kilo]",
356
+ // Added 2026-10-09: live on Kilo free list (isFree:true, 0/0 pricing,
357
+ // 256K ctx) + keyless chat 200.
358
+ id: "stealth/glyph-cluster",
359
+ name: "Glyph Cluster [Kilo]",
347
360
  reasoning: true,
348
- contextWindow: 262_144,
349
- maxTokens: 32_768,
361
+ contextWindow: 256_000,
362
+ maxTokens: 256_000,
350
363
  input: ["text"],
351
364
  thinkingFormat: "openrouter",
352
365
  thinkingLevelMap: KILO_REASONING_MAP,
@@ -418,7 +431,7 @@ export const CLINE_MODELS: ModelDef[] = [
418
431
  contextWindow: 1_048_576,
419
432
  maxTokens: 131_072,
420
433
  api: "openai-responses",
421
- input: ["text", "image"],
434
+ input: ["text", "image", "video"],
422
435
  thinkingLevelMap: {
423
436
  off: null,
424
437
  minimal: "minimal",
@@ -433,9 +446,9 @@ export const CLINE_MODELS: ModelDef[] = [
433
446
  id: "z-ai/glm-5.3-flash",
434
447
  name: "GLM 5.3 Flash [Cline]",
435
448
  reasoning: true,
436
- contextWindow: 1_000_000,
449
+ contextWindow: 1_048_576,
437
450
  maxTokens: 131_072,
438
- input: ["text", "image"],
451
+ input: ["text", "image", "video"],
439
452
  thinkingLevelMap: CLINE_FLASH_REASONING_MAP,
440
453
  },
441
454
  {
@@ -448,7 +461,7 @@ export const CLINE_MODELS: ModelDef[] = [
448
461
  reasoning: true,
449
462
  contextWindow: 1_048_576,
450
463
  maxTokens: 131_072,
451
- input: ["text", "image"],
464
+ input: ["text", "image", "video"],
452
465
  thinkingLevelMap: CLINE_FLASH_REASONING_MAP,
453
466
  },
454
467
  {
@@ -477,14 +490,14 @@ export const MODEL_ALIASES: Record<string, string> = {
477
490
  "nemotron-3-super": "nvidia/nemotron-3-super-120b-a12b:free",
478
491
  "north-mini-code": "cohere/north-mini-code:free",
479
492
  "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",
493
+ "step-5-preview:kilo": "stepfun/step-5-preview-free",
494
+ "glyph-cluster": "stealth/glyph-cluster",
482
495
  "inkling-small": "thinkingmachines/inkling-small:free",
483
496
  "mimo-v2.6-flash": "mimo-v2.6-flash-free",
484
497
  "space-bunny": "space-bunny-free",
485
498
  "longcat-2.5-preview": "longcat-2.5-preview-free",
486
499
  "longcat": "longcat-2.5-preview-free",
487
- "fledge-alpha": "fledge-alpha-free",
500
+ "step-5-preview": "step-5-preview-free",
488
501
  "ling-3.1-flash": "ling-3.1-flash-free",
489
502
  "ling-3.1-flash:kilo": "inclusionai/ling-3.1-flash",
490
503
  // provider-prefixed short aliases (slash-normalized)
@@ -564,10 +564,16 @@ export function sseToChatCompletionJson(
564
564
  let reasoningContent = "";
565
565
  let role = "assistant";
566
566
  let usage: unknown = undefined;
567
+ // Keyed by tool-call id whenever the provider sends one. Index is NOT
568
+ // trustworthy: free models routinely label every parallel call `index: 0`,
569
+ // and merging their fragments produces one unparseable argument blob
570
+ // (observed live: `{"i":"a","op":"done"}{"i":"b","op":"done"}`). Index is
571
+ // only the fallback for providers that omit an id entirely.
567
572
  const toolCallsMap = new Map<
568
- number,
573
+ string,
569
574
  { id: string; type: string; function: { name: string; arguments: string } }
570
575
  >();
576
+ const keyByIndex = new Map<number, string>();
571
577
 
572
578
  for (const ev of events) {
573
579
  if (ev.data === "[DONE]") continue;
@@ -591,8 +597,15 @@ export function sseToChatCompletionJson(
591
597
  }
592
598
  if (Array.isArray(delta.tool_calls)) {
593
599
  for (const tc of delta.tool_calls) {
594
- const idx = tc.index ?? 0;
595
- const existing = toolCallsMap.get(idx) ?? {
600
+ // The first delta of a call carries its id; later deltas carry only
601
+ // `arguments`. A later id-less delta therefore belongs to whatever
602
+ // call this index already opened.
603
+ const idx = typeof tc.index === "number" ? tc.index : 0;
604
+ const hasId = typeof tc.id === "string" && tc.id !== "";
605
+ let key = hasId ? `id:${tc.id}` : keyByIndex.get(idx);
606
+ if (key === undefined) key = `idx:${idx}`;
607
+ if (hasId) keyByIndex.set(idx, key);
608
+ const existing = toolCallsMap.get(key) ?? {
596
609
  id: tc.id || "",
597
610
  type: tc.type || "function",
598
611
  function: { name: "", arguments: "" },
@@ -601,7 +614,7 @@ export function sseToChatCompletionJson(
601
614
  if (tc.type) existing.type = tc.type;
602
615
  if (tc.function?.name) existing.function.name += tc.function.name;
603
616
  if (tc.function?.arguments) existing.function.arguments += tc.function.arguments;
604
- toolCallsMap.set(idx, existing);
617
+ toolCallsMap.set(key, existing);
605
618
  }
606
619
  }
607
620
  }
@@ -617,9 +630,9 @@ export function sseToChatCompletionJson(
617
630
  }
618
631
  }
619
632
 
620
- const toolCalls = Array.from(toolCallsMap.entries())
621
- .sort(([a], [b]) => a - b)
622
- .map(([, tc]) => tc);
633
+ // Map insertion order is emission order, which is what the host expects. The
634
+ // previous numeric sort assumed the keys were indices; they are now ids.
635
+ const toolCalls = Array.from(toolCallsMap.values());
623
636
 
624
637
  const message: Record<string, unknown> = {
625
638
  role,
package/src/tool-args.ts CHANGED
@@ -342,6 +342,46 @@ export function buildArgRepairSchemas(tools: unknown[]): Map<string, Record<stri
342
342
  return schemas;
343
343
  }
344
344
 
345
+
346
+ /**
347
+ * Parse a tool-call argument string, salvaging one that arrives with a leading
348
+ * fragment of the *previous* call spliced onto its front.
349
+ *
350
+ * Observed live on space-bunny-free: a model that emitted a call, saw the
351
+ * result, then opened its next call by echoing the tail of the old one. The
352
+ * stream carried a single tool call, so keying fragments by call id cannot
353
+ * separate them, and the host rejected the payload:
354
+ *
355
+ * `,"timeout":400}{"i":"…","command":"…"}`
356
+ * `]}]}{"i":"Planning research phases","op":"init",…}`
357
+ *
358
+ * Both are one complete JSON object with junk in front of it. Only strings that
359
+ * do not parse as-is are considered, and only a position that yields a complete
360
+ * object is accepted, so a well-formed call never reaches this path and a
361
+ * truncated one is still refused rather than completed.
362
+ *
363
+ * Returns undefined when nothing salvageable is present.
364
+ */
365
+ function parseArguments(argsJson: string): { value: Record<string, unknown>; salvaged: boolean } | undefined {
366
+ try {
367
+ const value = JSON.parse(argsJson);
368
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined;
369
+ return { value: value as Record<string, unknown>, salvaged: false };
370
+ } catch {
371
+ // fall through to the salvage scan
372
+ }
373
+ for (let i = argsJson.indexOf("{"); i !== -1; i = argsJson.indexOf("{", i + 1)) {
374
+ try {
375
+ const value = JSON.parse(argsJson.slice(i));
376
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
377
+ return { value: value as Record<string, unknown>, salvaged: true };
378
+ }
379
+ } catch {
380
+ // not a start position; try the next brace
381
+ }
382
+ }
383
+ return undefined;
384
+ }
345
385
  /**
346
386
  * Repair one tool call's arguments against its declared schema. `name` is the
347
387
  * downstream (restored) caller tool name. Returns the input string untouched
@@ -356,21 +396,20 @@ export function repairToolArguments(
356
396
  if (schemas === undefined || schemas.size === 0) return argsJson;
357
397
  const schema = schemas.get(name.trim().toLowerCase());
358
398
  if (schema === undefined) return argsJson;
359
- let parsed: unknown;
360
- try {
361
- parsed = JSON.parse(argsJson);
362
- } catch {
363
- // Truncated or non-JSON arguments: paper over nothing.
364
- return argsJson;
365
- }
366
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) return argsJson;
367
- const rec = parsed as Record<string, unknown>;
399
+ const parsed = parseArguments(argsJson);
400
+ // Nothing parses: truncated or non-JSON arguments. Paper over nothing.
401
+ if (parsed === undefined) return argsJson;
402
+ const rec = parsed.value;
368
403
  const repaired = repairValue(rec, schema, 0);
369
404
  const value = repaired.value === null || typeof repaired.value !== "object" || Array.isArray(repaired.value)
370
405
  ? rec
371
406
  : (repaired.value as Record<string, unknown>);
372
407
  const promoted = promoteStringArray(value, schema, 0);
373
408
  const result = promoted === value ? value : promoted;
409
+ // A salvaged payload needs no further reshaping: what follows the junk is
410
+ // already the caller's own object, and re-serializing it would be a change
411
+ // the repair was never asked to make.
412
+ if (parsed.salvaged) return matchesSchema(rec, schema, 0) ? JSON.stringify(rec) : argsJson;
374
413
  if (!repaired.changed && promoted === value) return argsJson;
375
414
  if (!matchesSchema(result, schema, 0)) return argsJson;
376
415
  return JSON.stringify(result);
package/src/types.ts CHANGED
@@ -16,7 +16,7 @@ export type ThinkingLevel =
16
16
 
17
17
  export type ThinkingLevelMap = Partial<Record<ThinkingLevel, string | null>>;
18
18
 
19
- export type ModelInputType = "text" | "image";
19
+ export type ModelInputType = "text" | "image" | "video";
20
20
 
21
21
  export interface ModelDef {
22
22
  id: string;