Update encoding_k3.py

#173
Files changed (1) hide show
  1. encoding_k3.py +125 -22
encoding_k3.py CHANGED
@@ -187,21 +187,117 @@ def deep_sort_dict(obj: Any) -> Any:
187
  return obj
188
 
189
 
190
- def normalize_tool_arguments(arguments: Any) -> tuple[dict[str, Any], Optional[str]]:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  if arguments is None:
192
- return {}, None
193
  if isinstance(arguments, dict):
194
- return arguments, None
 
 
 
195
  if isinstance(arguments, str):
196
- if not arguments.strip():
197
- return {}, None
198
  try:
199
- parsed = json.loads(arguments)
200
- except json.JSONDecodeError:
201
- return {}, arguments
202
- if not isinstance(parsed, dict):
203
- raise ValueError("Kimi K3 tool call arguments must be a JSON object.")
204
- return parsed, None
205
  raise TypeError(
206
  "Kimi K3 tool call arguments must be a dict or a JSON object string."
207
  )
@@ -421,7 +517,9 @@ def _render_assistant_segments(
421
  "reasoning"
422
  )
423
  segments.extend(_open_tag("think"))
424
- if reasoning_content is not None and str(reasoning_content).strip():
 
 
425
  _append_text(segments, reasoning_content, image_state)
426
  segments.extend(_close_tag("think"))
427
 
@@ -437,21 +535,18 @@ def _render_assistant_segments(
437
  segments.extend(
438
  _open_tag("call", [("tool", fn["name"]), ("index", index)])
439
  )
440
- args = fn.get("arguments", {})
441
  json_block = fn.get("_xtml_json_block")
442
  if json_block is not None:
443
  segments.extend(_open_tag("json", [("type", "object")]))
444
  _append_text(segments, json_block, image_state)
445
  segments.extend(_close_tag("json"))
446
- elif _is_mapping(args):
447
- for key, value in args.items():
448
  segments.extend(
449
- _open_tag(
450
- "argument",
451
- [("key", key), ("type", _xtml_type(value))],
452
- )
453
  )
454
- _append_text(segments, _xtml_value(value), image_state)
455
  segments.extend(_close_tag("argument"))
456
  segments.extend(_close_tag("call"))
457
  segments.extend(_close_tag("tools"))
@@ -538,10 +633,14 @@ def build_chat_segments(
538
  )
539
 
540
  for message_index, message in enumerate(messages):
 
541
  if not isinstance(message, dict):
542
- continue
 
 
 
543
 
544
- role = message["role"]
545
  if role == "user":
546
  attrs = [("role", "user")]
547
  if message.get("name"):
@@ -596,6 +695,10 @@ def build_chat_segments(
596
  segments.extend(_render_assistant_segments(message, image_state, thinking))
597
  segments.extend(_close_tag("message"))
598
  segments.extend(_end_of_msg())
 
 
 
 
599
 
600
  tool_choice = kwargs.get("tool_choice")
601
  if tool_choice == "required":
 
187
  return obj
188
 
189
 
190
+ # One normalized tool-call argument: (key, XTML type, rendered text). The
191
+ # text keeps the original JSON literal for non-string values and is the
192
+ # decoded string for string values.
193
+ XtmlArgument = tuple[str, str, str]
194
+
195
+
196
+ def _parse_arguments_object(s: str) -> list[XtmlArgument]:
197
+ """Parse a JSON object string one level deep.
198
+
199
+ Each top-level key-value pair yields one ``(key, type, text)`` triple. The
200
+ text keeps the original JSON literal for non-string values (``1e2`` stays
201
+ ``1e2``, ``[1,2]`` keeps its exact bytes) and is the decoded (unescaped)
202
+ string for string values. Nested values are never re-serialized. Anything
203
+ after the closing ``}`` is ignored. Raises ``ValueError`` on malformed
204
+ input (including a valid non-object JSON document).
205
+ """
206
+ idx = 0
207
+
208
+ def _skip_whitespaces() -> None:
209
+ nonlocal idx
210
+ while idx < len(s) and s[idx] in (" ", "\t", "\n", "\r"):
211
+ idx += 1
212
+
213
+ def _next_char() -> Optional[str]:
214
+ nonlocal idx
215
+ if idx < len(s):
216
+ c = s[idx]
217
+ idx += 1
218
+ return c
219
+ return None
220
+
221
+ _skip_whitespaces()
222
+ if _next_char() != "{":
223
+ raise ValueError("JSON arguments must be an object")
224
+ _skip_whitespaces()
225
+
226
+ parsed: list[XtmlArgument] = []
227
+
228
+ if idx >= len(s):
229
+ raise ValueError("Unexpected end of JSON object")
230
+ if s[idx] == "}":
231
+ return parsed
232
+
233
+ json_decoder = json.JSONDecoder(strict=False)
234
+
235
+ def _raw_decode(start_idx: int) -> tuple[Any, int]:
236
+ try:
237
+ return json_decoder.raw_decode(s, idx=start_idx)
238
+ except json.JSONDecodeError as e:
239
+ raise ValueError(str(e)) from e
240
+
241
+ while True:
242
+ decoded_key, idx = _raw_decode(idx)
243
+ if not isinstance(decoded_key, str):
244
+ raise ValueError(f"JSON object key must be a string, got {decoded_key!r}")
245
+ _skip_whitespaces()
246
+ if _next_char() != ":":
247
+ raise ValueError(f"Expects ':' after {decoded_key}")
248
+ _skip_whitespaces()
249
+
250
+ value_start_idx = idx
251
+ decoded_value, idx = _raw_decode(idx)
252
+ value_end_idx = idx
253
+
254
+ text = (
255
+ decoded_value
256
+ if isinstance(decoded_value, str)
257
+ else s[value_start_idx:value_end_idx]
258
+ )
259
+ parsed.append((decoded_key, _xtml_type(decoded_value), text))
260
+
261
+ _skip_whitespaces()
262
+ c = _next_char()
263
+ _skip_whitespaces()
264
+
265
+ if c == "}":
266
+ break
267
+ elif c == ",":
268
+ continue
269
+ else:
270
+ raise ValueError(f"Expect '}}' or ',', got {c!r}")
271
+
272
+ return parsed
273
+
274
+
275
+ def normalize_tool_arguments(
276
+ arguments: Any,
277
+ ) -> tuple[list[XtmlArgument], Optional[str]]:
278
+ """Normalize tool call arguments for XTML rendering.
279
+
280
+ Returns ``(argument_triples, raw_json_block)``. String arguments are parsed
281
+ one level deep so non-string values keep their original JSON literal text,
282
+ and anything after the closing ``}`` is discarded; dict arguments (already
283
+ Python objects) are serialized on the spot. Any string that is not a
284
+ well-formed JSON object -- unparseable, whitespace only, or valid
285
+ non-object JSON -- falls back to the raw ``<json>`` block.
286
+ """
287
  if arguments is None:
288
+ return [], None
289
  if isinstance(arguments, dict):
290
+ return [
291
+ (str(key), _xtml_type(value), _xtml_value(value))
292
+ for key, value in arguments.items()
293
+ ], None
294
  if isinstance(arguments, str):
295
+ if arguments == "":
296
+ return [], None
297
  try:
298
+ return _parse_arguments_object(arguments), None
299
+ except ValueError:
300
+ return [], arguments
 
 
 
301
  raise TypeError(
302
  "Kimi K3 tool call arguments must be a dict or a JSON object string."
303
  )
 
517
  "reasoning"
518
  )
519
  segments.extend(_open_tag("think"))
520
+ # Only an empty string counts as no reasoning, so whitespace-only
521
+ # reasoning is still rendered into the think channel.
522
+ if reasoning_content is not None and str(reasoning_content) != "":
523
  _append_text(segments, reasoning_content, image_state)
524
  segments.extend(_close_tag("think"))
525
 
 
535
  segments.extend(
536
  _open_tag("call", [("tool", fn["name"]), ("index", index)])
537
  )
538
+ args = fn.get("arguments", [])
539
  json_block = fn.get("_xtml_json_block")
540
  if json_block is not None:
541
  segments.extend(_open_tag("json", [("type", "object")]))
542
  _append_text(segments, json_block, image_state)
543
  segments.extend(_close_tag("json"))
544
+ else:
545
+ for key, arg_type, arg_text in args:
546
  segments.extend(
547
+ _open_tag("argument", [("key", key), ("type", arg_type)])
 
 
 
548
  )
549
+ _append_text(segments, arg_text, image_state)
550
  segments.extend(_close_tag("argument"))
551
  segments.extend(_close_tag("call"))
552
  segments.extend(_close_tag("tools"))
 
633
  )
634
 
635
  for message_index, message in enumerate(messages):
636
+ # Malformed messages are rejected instead of being silently skipped.
637
  if not isinstance(message, dict):
638
+ raise ValueError(
639
+ f"Kimi K3 messages must be dicts, got {type(message).__name__} "
640
+ f"at index {message_index}."
641
+ )
642
 
643
+ role = message.get("role")
644
  if role == "user":
645
  attrs = [("role", "user")]
646
  if message.get("name"):
 
695
  segments.extend(_render_assistant_segments(message, image_state, thinking))
696
  segments.extend(_close_tag("message"))
697
  segments.extend(_end_of_msg())
698
+ else:
699
+ raise ValueError(
700
+ f"Unknown message role {role!r} at index {message_index}."
701
+ )
702
 
703
  tool_choice = kwargs.get("tool_choice")
704
  if tool_choice == "required":