renderers 0.1.13.dev8__tar.gz → 0.1.13.dev14__tar.gz

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.
Files changed (99) hide show
  1. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/PKG-INFO +1 -1
  2. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/_version.py +2 -2
  3. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/client.py +287 -20
  4. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/muse_glimmer.py +44 -18
  5. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_client.py +270 -7
  6. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_muse_glimmer.py +30 -2
  7. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_tool_arg_type_preservation.py +14 -5
  8. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/.github/workflows/publish-dev.yml +0 -0
  9. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/.github/workflows/publish.yml +0 -0
  10. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/.github/workflows/style.yml +0 -0
  11. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/.github/workflows/test.yml +0 -0
  12. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/.gitignore +0 -0
  13. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/.pre-commit-config.yaml +0 -0
  14. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/LICENSE +0 -0
  15. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/README.md +0 -0
  16. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/docs/renderer-config.md +0 -0
  17. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/examples/README.md +0 -0
  18. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/examples/sglang/multiturn_generate_sglang.py +0 -0
  19. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/examples/sglang/online_multiturn_sglang.py +0 -0
  20. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/examples/tinker/multiturn_generate_tinker.py +0 -0
  21. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/examples/transformers/multiturn_generate_transformers.py +0 -0
  22. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/examples/vllm/multiturn_generate_vllm.py +0 -0
  23. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/pyproject.toml +0 -0
  24. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/__init__.py +0 -0
  25. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/base.py +0 -0
  26. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/configs.py +0 -0
  27. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/custom.py +0 -0
  28. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/deepseek_r1.py +0 -0
  29. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/deepseek_v3.py +0 -0
  30. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/deepseek_v4.py +0 -0
  31. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/default.py +0 -0
  32. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/gemma4.py +0 -0
  33. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/glm45.py +0 -0
  34. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/glm5.py +0 -0
  35. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/gpt_oss.py +0 -0
  36. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/hy3.py +0 -0
  37. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/inkling.py +0 -0
  38. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/kimi_k2.py +0 -0
  39. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/kimi_k25.py +0 -0
  40. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/laguna_s21.py +0 -0
  41. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/laguna_xs2.py +0 -0
  42. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/llama_3.py +0 -0
  43. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/minimax_m2.py +0 -0
  44. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/nemotron3.py +0 -0
  45. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/parsers.py +0 -0
  46. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/parsing.py +0 -0
  47. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/prime_qwen3.py +0 -0
  48. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/qwen3.py +0 -0
  49. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/qwen35.py +0 -0
  50. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/qwen36.py +0 -0
  51. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/qwen38.py +0 -0
  52. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/qwen3_vl.py +0 -0
  53. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/reasoning.py +0 -0
  54. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/renderers/tagged.py +0 -0
  55. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/audit_prefix_stability.py +0 -0
  56. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/conftest.py +0 -0
  57. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/fixtures/prefix_stability.json +0 -0
  58. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/parity.py +0 -0
  59. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/reference_continuations.py +0 -0
  60. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/reference_rendering.py +0 -0
  61. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/replay.py +0 -0
  62. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_bridge.py +0 -0
  63. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_build_helpers.py +0 -0
  64. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_custom.py +0 -0
  65. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_deepseek_r1.py +0 -0
  66. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_deepseek_v4.py +0 -0
  67. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_disabled_thinking_stability.py +0 -0
  68. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_gemma4.py +0 -0
  69. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_glm53_flash.py +0 -0
  70. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_glm_tool_name_validation.py +0 -0
  71. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_hy3.py +0 -0
  72. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_incremental.py +0 -0
  73. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_inkling.py +0 -0
  74. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_is_content.py +0 -0
  75. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_kimi_k25_tool_schema.py +0 -0
  76. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_laguna_m1.py +0 -0
  77. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_laguna_xs21.py +0 -0
  78. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_llama_3.py +0 -0
  79. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_load_tokenizer.py +0 -0
  80. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_message_indices.py +0 -0
  81. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_message_tool_names.py +0 -0
  82. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_multimodal.py +0 -0
  83. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_nemotron35_vlm.py +0 -0
  84. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_nemotron3_ultra.py +0 -0
  85. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_offsetless_tokenizers.py +0 -0
  86. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_parity.py +0 -0
  87. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_parse_response.py +0 -0
  88. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_parse_response_robustness.py +0 -0
  89. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_parsers.py +0 -0
  90. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_preserve_thinking.py +0 -0
  91. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_qwen35_size_coverage.py +0 -0
  92. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_qwen38.py +0 -0
  93. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_reasoning_boundaries.py +0 -0
  94. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_renderer_config.py +0 -0
  95. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_roundtrip.py +0 -0
  96. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_sampled_mask.py +0 -0
  97. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_tagged_rollout.py +0 -0
  98. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/tests/test_tokens_per_message.py +0 -0
  99. {renderers-0.1.13.dev8 → renderers-0.1.13.dev14}/uv.lock +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: renderers
3
- Version: 0.1.13.dev8
3
+ Version: 0.1.13.dev14
4
4
  Summary: Chat template renderers — deterministic message-to-token conversion for LLM training
5
5
  License-Expression: Apache-2.0
6
6
  License-File: LICENSE
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '0.1.13.dev8'
22
- __version_tuple__ = version_tuple = (0, 1, 13, 'dev8')
21
+ __version__ = version = '0.1.13.dev14'
22
+ __version_tuple__ = version_tuple = (0, 1, 13, 'dev14')
23
23
 
24
24
  __commit_id__ = commit_id = None
@@ -8,10 +8,12 @@ from __future__ import annotations
8
8
 
9
9
  import asyncio
10
10
  import base64
11
+ import binascii
11
12
  import json
12
13
  import logging
13
14
  import math
14
15
  from collections.abc import Mapping
16
+ from itertools import islice, pairwise
15
17
  from typing import Any, cast
16
18
 
17
19
  import httpx
@@ -93,7 +95,7 @@ async def _resolve_max_prompt_len(client: AsyncOpenAI, model: str) -> int | None
93
95
  return _max_prompt_len_cache[key]
94
96
  try:
95
97
  payload = await client.get("/models", cast_to=cast(Any, dict[str, Any]))
96
- except Exception as exc:
98
+ except Exception as exc: # noqa: BLE001
97
99
  _request_logger.debug("max_prompt_len lookup failed: %s", exc)
98
100
  _max_prompt_len_cache[key] = None
99
101
  return None
@@ -111,28 +113,45 @@ async def _resolve_max_prompt_len(client: AsyncOpenAI, model: str) -> int | None
111
113
  return value
112
114
 
113
115
 
114
- def _strip_base64_field(raw: bytes, prefix: bytes) -> tuple[bytes, memoryview | None]:
116
+ def _strip_base64_fields(raw: bytes, prefix: bytes) -> tuple[bytes, list[memoryview]]:
115
117
  """Splice a large base64 string field out of raw JSON bytes.
116
118
 
117
119
  Avoids json-decoding megabytes of base64; the returned memoryview
118
120
  references ``raw`` and is re-inserted into the parsed payload.
119
121
  """
120
- data_start = raw.find(prefix)
121
- if data_start < 0:
122
- return raw, None
123
-
124
- data_start += len(prefix)
125
- data_end = raw.index(b'"', data_start)
126
- data = memoryview(raw)[data_start:data_end]
127
- stripped = raw[:data_start] + raw[data_end:]
128
- return stripped, data
122
+ chunks = []
123
+ fields = []
124
+ cursor = 0
125
+ while (start := raw.find(prefix, cursor)) >= 0:
126
+ start += len(prefix)
127
+ end = raw.index(b'"', start)
128
+ chunks.append(raw[cursor:start])
129
+ fields.append(memoryview(raw)[start:end])
130
+ cursor = end
131
+ if not fields:
132
+ return raw, fields
133
+ chunks.append(raw[cursor:])
134
+ return b"".join(chunks), fields
129
135
 
130
136
 
131
137
  def parse_generate_response(raw: bytes) -> dict[str, Any]:
132
- stripped, routed_data = _strip_base64_field(raw, ROUTED_EXPERTS_DATA_PREFIX)
138
+ stripped, routed_data = _strip_base64_fields(raw, ROUTED_EXPERTS_DATA_PREFIX)
139
+ stripped, compact_data = _strip_base64_fields(
140
+ stripped, b'"compact_logprobs":{"data":"'
141
+ )
133
142
  payload: dict[str, Any] = json.loads(stripped)
134
- if routed_data is not None:
135
- payload["choices"][0]["routed_experts"]["data"] = routed_data
143
+ for field, buffers in (
144
+ ("routed_experts", routed_data),
145
+ ("compact_logprobs", compact_data),
146
+ ):
147
+ if buffers:
148
+ pending = iter(buffers)
149
+ for choice in payload["choices"]:
150
+ if (
151
+ isinstance(choice.get(field), dict)
152
+ and choice[field].get("data") == ""
153
+ ):
154
+ choice[field]["data"] = next(pending)
136
155
  return payload
137
156
 
138
157
 
@@ -150,6 +169,80 @@ def _parse_packed_completion_logprobs(
150
169
  return values.astype(np.float64).tolist()
151
170
 
152
171
 
172
+ def _parse_compact_logprobs(
173
+ choice: Mapping[str, Any], completion_ids: list[int], k: int | None
174
+ ) -> tuple[list[float], list[list[int]] | None, list[list[float]] | None]:
175
+ packed = choice.get("compact_logprobs")
176
+ if not isinstance(packed, Mapping) or packed.get("format") != "u32-f32-le-v1":
177
+ raise MalformedGenerateResponseError("Unsupported compact logprob format")
178
+ offsets = packed.get("offsets")
179
+ if (
180
+ not isinstance(offsets, list)
181
+ or len(offsets) != len(completion_ids) + 1
182
+ or any(type(x) is not int or x < 0 for x in offsets)
183
+ or offsets[0] != 0
184
+ or any(end <= start for start, end in pairwise(offsets))
185
+ ):
186
+ raise MalformedGenerateResponseError("Invalid compact logprob offsets")
187
+ num_top = packed.get("num_top_logprobs")
188
+ if type(num_top) is not int or num_top < -1:
189
+ raise MalformedGenerateResponseError("Invalid compact logprob candidate count")
190
+ try:
191
+ raw = base64.b64decode(packed["data"], validate=True)
192
+ except (KeyError, TypeError, ValueError, binascii.Error) as exc:
193
+ raise MalformedGenerateResponseError("Invalid compact logprob base64") from exc
194
+ count = offsets[-1]
195
+ if len(raw) != count * 8:
196
+ raise MalformedGenerateResponseError("Invalid compact logprob buffer length")
197
+ ids = np.frombuffer(raw, dtype="<u4", count=count)
198
+ values = np.frombuffer(raw, dtype="<f4", count=count, offset=count * 4)
199
+ if not np.isfinite(values).all() or (values > 1e-6).any():
200
+ raise MalformedGenerateResponseError("Invalid compact logprob values")
201
+ sampled = []
202
+ top_ids: list[list[int]] = []
203
+ top_logprobs: list[list[float]] = []
204
+ supports = _parse_sampling_mask(choice, completion_ids) if k is not None else None
205
+ for index, (token_id, start, end) in enumerate(
206
+ zip(completion_ids, offsets, offsets[1:])
207
+ ):
208
+ # FlatLogprobs can repeat the sampled ID. Match vLLM's dict order/value semantics.
209
+ items = list(zip(ids[start:end].tolist(), values[start:end].tolist()))
210
+ row = dict(items)
211
+ if any(row[i] != p for i, p in items):
212
+ raise MalformedGenerateResponseError("Repeated compact logprobs disagree")
213
+ if token_id not in row or row[token_id] == VLLM_LOGPROB_SENTINEL:
214
+ raise MalformedGenerateResponseError(
215
+ "Missing compact sampled-token evidence"
216
+ )
217
+ sampled.append(row[token_id])
218
+ if k is not None:
219
+ head = dict(islice(row.items(), max(num_top, 1)))
220
+ selected_ids, selected_logprobs = _select_sampler_head(
221
+ head,
222
+ token_id,
223
+ row[token_id],
224
+ k,
225
+ supports[index] if supports is not None else None,
226
+ )
227
+ top_ids.append(selected_ids)
228
+ top_logprobs.append(selected_logprobs)
229
+ return (
230
+ sampled,
231
+ top_ids if k is not None else None,
232
+ top_logprobs if k is not None else None,
233
+ )
234
+
235
+
236
+ def _logprob_token_id(entry: Mapping[str, Any]) -> int | None:
237
+ if "token_id" in entry:
238
+ token_id = entry["token_id"]
239
+ return token_id if type(token_id) is int and token_id >= 0 else None
240
+ token = entry.get("token")
241
+ if isinstance(token, str) and token.startswith("token_id:") and token[9:].isdigit():
242
+ return int(token[9:])
243
+ return None
244
+
245
+
153
246
  def _parse_completion_logprobs(
154
247
  choice: Mapping[str, Any], completion_ids: list[int]
155
248
  ) -> list[float]:
@@ -180,7 +273,7 @@ def _parse_completion_logprobs(
180
273
  f"Engine response choice.logprobs.content[{index}] must be an object."
181
274
  )
182
275
  expected_token = f"token_id:{completion_ids[index]}"
183
- if entry.get("token") != expected_token:
276
+ if _logprob_token_id(entry) != completion_ids[index]:
184
277
  raise MalformedGenerateResponseError(
185
278
  f"Engine response choice.logprobs.content[{index}].token must be {expected_token!r}."
186
279
  )
@@ -207,6 +300,145 @@ def _parse_completion_logprobs(
207
300
  return completion_logprobs
208
301
 
209
302
 
303
+ def _parse_sampling_mask(
304
+ choice: Mapping[str, Any], completion_ids: list[int]
305
+ ) -> list[list[int]] | None:
306
+ mask = choice.get("sampling_mask")
307
+ if mask is None:
308
+ return None
309
+ if isinstance(mask, Mapping):
310
+ arrays = {}
311
+ for name in ("ids", "counts"):
312
+ packed = mask.get(name)
313
+ if not isinstance(packed, Mapping) or packed.get("dtype") != "int32":
314
+ raise MalformedGenerateResponseError("Invalid packed sampling mask")
315
+ try:
316
+ values = np.frombuffer(
317
+ base64.b64decode(packed["data"], validate=True), dtype=np.int32
318
+ )
319
+ except (KeyError, TypeError, ValueError, binascii.Error) as exc:
320
+ raise MalformedGenerateResponseError(
321
+ "Invalid packed sampling mask"
322
+ ) from exc
323
+ if packed.get("shape") != [len(values)] or (values < 0).any():
324
+ raise MalformedGenerateResponseError("Invalid packed sampling mask")
325
+ arrays[name] = values
326
+ counts, ids = arrays["counts"], arrays["ids"]
327
+ if len(counts) != len(completion_ids) or int(counts.sum()) != len(ids):
328
+ raise MalformedGenerateResponseError("Sampling mask token count mismatch")
329
+ mask = [row.tolist() for row in np.split(ids, np.cumsum(counts)[:-1])]
330
+ if not len(counts):
331
+ mask = []
332
+ if not isinstance(mask, list) or len(mask) != len(completion_ids):
333
+ raise MalformedGenerateResponseError("Sampling mask token count mismatch")
334
+ for sampled, support in zip(completion_ids, mask, strict=True):
335
+ if (
336
+ not isinstance(support, list)
337
+ or not support
338
+ or any(type(i) is not int or i < 0 for i in support)
339
+ or len(set(support)) != len(support)
340
+ or sampled not in support
341
+ ):
342
+ raise MalformedGenerateResponseError("Invalid recorded sampling support")
343
+ return mask
344
+
345
+
346
+ def _select_sampler_head(
347
+ head: dict[int, float],
348
+ sampled_id: int,
349
+ sampled_logprob: float,
350
+ k: int,
351
+ support: list[int] | None = None,
352
+ ) -> tuple[list[int], list[float]]:
353
+ """Keep an action-independent head, or the complete recorded replay support."""
354
+ if sampled_id in head and not math.isclose(
355
+ head[sampled_id], sampled_logprob, rel_tol=1e-6, abs_tol=1e-5
356
+ ):
357
+ raise MalformedGenerateResponseError(
358
+ "Sampler head and sampled-token logprob disagree"
359
+ )
360
+ # vLLM serializes filtered-out (-inf) candidates as -9999.
361
+ ranked = sorted(
362
+ ((i, p) for i, p in head.items() if p != VLLM_LOGPROB_SENTINEL),
363
+ key=lambda x: (-x[1], x[0]),
364
+ )
365
+ if support is not None:
366
+ support_set = set(support)
367
+ if not support_set.issubset(i for i, _ in ranked):
368
+ raise MalformedGenerateResponseError(
369
+ "Missing sampling-support probabilities"
370
+ )
371
+ ranked = [(i, p) for i, p in ranked if i in support_set]
372
+ if not math.isclose(
373
+ math.fsum(math.exp(p) for _, p in ranked), 1.0, rel_tol=0, abs_tol=1e-4
374
+ ):
375
+ raise MalformedGenerateResponseError(
376
+ "Sampler probabilities must normalize over the recorded support"
377
+ )
378
+ elif len(ranked) > k and ranked[k - 1][1] == ranked[k][1]:
379
+ boundary = ranked[k][1]
380
+ ranked = [(i, p) for i, p in ranked if p > boundary]
381
+ else:
382
+ ranked = ranked[:k]
383
+ if not ranked or math.fsum(math.exp(p) for _, p in ranked) > 1.0001:
384
+ raise MalformedGenerateResponseError("Invalid sampler head probability mass")
385
+ if support is None and len(head) < k + 2:
386
+ raise MalformedGenerateResponseError(
387
+ "Engine returned fewer top logprobs than requested"
388
+ )
389
+ return [i for i, _ in ranked], [p for _, p in ranked]
390
+
391
+
392
+ def _parse_completion_top_logprobs(
393
+ choice: Mapping[str, Any], completion_ids: list[int], k: int
394
+ ) -> tuple[list[list[int]], list[list[float]]]:
395
+ """Select an action-independent head from k+2 candidates."""
396
+ logprobs = choice.get("logprobs")
397
+ content = logprobs.get("content") if isinstance(logprobs, Mapping) else None
398
+ if not isinstance(content, list) or len(content) != len(completion_ids):
399
+ raise MalformedGenerateResponseError("Missing requested top logprobs")
400
+ top_ids: list[list[int]] = []
401
+ top_logprobs: list[list[float]] = []
402
+ supports = _parse_sampling_mask(choice, completion_ids)
403
+ for index, (sampled_id, entry) in enumerate(
404
+ zip(completion_ids, content, strict=True)
405
+ ):
406
+ candidates = entry.get("top_logprobs")
407
+ if not isinstance(candidates, list) or not candidates:
408
+ raise MalformedGenerateResponseError("Missing requested top logprobs")
409
+ head: dict[int, float] = {}
410
+ for candidate in candidates:
411
+ if not isinstance(candidate, Mapping):
412
+ raise MalformedGenerateResponseError("Top logprob must be an object")
413
+ token_id = _logprob_token_id(candidate)
414
+ if token_id is None:
415
+ raise MalformedGenerateResponseError(
416
+ "Top logprob must identify a nonnegative token id"
417
+ )
418
+ logprob = candidate.get("logprob")
419
+ if (
420
+ isinstance(logprob, bool)
421
+ or not isinstance(logprob, (int, float))
422
+ or not math.isfinite(logprob)
423
+ ):
424
+ raise MalformedGenerateResponseError("Top logprob must be finite")
425
+ if logprob > 1e-6:
426
+ raise MalformedGenerateResponseError("Top logprob cannot be positive")
427
+ if token_id in head:
428
+ raise MalformedGenerateResponseError("Duplicate top logprob token id")
429
+ head[token_id] = float(logprob)
430
+ selected_ids, selected_logprobs = _select_sampler_head(
431
+ head,
432
+ sampled_id,
433
+ entry["logprob"],
434
+ k,
435
+ supports[index] if supports is not None else None,
436
+ )
437
+ top_ids.append(selected_ids)
438
+ top_logprobs.append(selected_logprobs)
439
+ return top_ids, top_logprobs
440
+
441
+
210
442
  async def generate(
211
443
  *,
212
444
  client: AsyncOpenAI,
@@ -226,9 +458,16 @@ async def generate(
226
458
  ) -> dict[str, Any]:
227
459
  """Tokenize messages, call vLLM /inference/v1/generate, parse the response.
228
460
 
229
- ``sampling_params`` is forwarded to vLLM verbatim. Two fields are always
230
- set by us and override caller values: ``stop_token_ids`` (from the
231
- renderer) and ``logprobs=1`` (we always emit completion_logprobs). Pass
461
+ ``sampling_params`` is forwarded to vLLM, with ``stop_token_ids`` taken
462
+ from the renderer and ``logprobs`` set to at least one for sampled-token
463
+ evidence. A positive integer requests a top-k head; we ask the engine
464
+ for two extra candidates to select that head independently of the sampled
465
+ action, excluding ties at its boundary.
466
+ Head requests opt into compact numeric HTTP logprobs; set
467
+ ``extra_args.prl_compact_logprobs=False`` to use native JSON. Servers without
468
+ the extension can still return native JSON. Sampled-only requests keep the
469
+ native path by default.
470
+ Pass
232
471
  ``prompt_ids`` to skip rendering and use a prebuilt token sequence —
233
472
  pair it with ``multi_modal_data`` when the prebuilt prompt has image /
234
473
  video placeholders that need engine-side mm payload, and with
@@ -267,6 +506,10 @@ async def generate(
267
506
  is vLLM's (>= 0.32) list of ``{version, start, end}`` spans over
268
507
  completion-token indexes, or ``None`` when the engine labels none.
269
508
 
509
+ When ``sampling_params["logprobs"]`` is a positive int, also
510
+ ``completion_top_ids`` / ``completion_top_logprobs``
511
+ (original sampler probabilities, retaining full-vocabulary normalization).
512
+
270
513
  ``prompt_attribution`` is the renderer's :class:`RenderedTokens` for
271
514
  the prompt — either the one this call computed via
272
515
  ``renderer.render(...)`` or the one the caller threaded in alongside
@@ -330,7 +573,18 @@ async def generate(
330
573
 
331
574
  sp: dict[str, Any] = dict(sampling_params or {})
332
575
  sp["stop_token_ids"] = stop_token_ids
333
- sp["logprobs"] = 1
576
+ requested_logprobs = sp.get("logprobs")
577
+ head_size = (
578
+ requested_logprobs
579
+ if type(requested_logprobs) is int and requested_logprobs > 0
580
+ else None
581
+ )
582
+ sp["logprobs"] = head_size + 2 if head_size is not None else 1
583
+ if head_size is not None:
584
+ sp["extra_args"] = {
585
+ "prl_compact_logprobs": True,
586
+ **(sp.get("extra_args") or {}),
587
+ }
334
588
  sp.setdefault("skip_special_tokens", False)
335
589
 
336
590
  body: dict[str, Any] = {
@@ -389,7 +643,18 @@ async def generate(
389
643
  "Engine response must include mm_placeholders when process_multimodal=False."
390
644
  )
391
645
 
392
- completion_logprobs = _parse_completion_logprobs(choice, completion_ids)
646
+ completion_top_ids = None
647
+ completion_top_logprobs = None
648
+ if choice.get("compact_logprobs") is not None:
649
+ completion_logprobs, completion_top_ids, completion_top_logprobs = (
650
+ _parse_compact_logprobs(choice, completion_ids, head_size)
651
+ )
652
+ else:
653
+ completion_logprobs = _parse_completion_logprobs(choice, completion_ids)
654
+ if head_size is not None:
655
+ completion_top_ids, completion_top_logprobs = (
656
+ _parse_completion_top_logprobs(choice, completion_ids, head_size)
657
+ )
393
658
 
394
659
  parsed = renderer.parse_response(
395
660
  completion_ids,
@@ -426,6 +691,8 @@ async def generate(
426
691
  "mm_placeholders": mm_placeholders,
427
692
  "completion_ids": list(completion_ids),
428
693
  "completion_logprobs": completion_logprobs,
694
+ "completion_top_ids": completion_top_ids,
695
+ "completion_top_logprobs": completion_top_logprobs,
429
696
  "content": parsed.content,
430
697
  "reasoning_content": parsed.reasoning_content,
431
698
  "tool_calls": parsed.tool_calls,
@@ -51,7 +51,6 @@ from renderers.base import (
51
51
  should_rerender_for_thinking_retention,
52
52
  )
53
53
  from renderers.configs import MuseGlimmerRendererConfig
54
- from renderers.parsing import _build_param_type_index, _coerce_arg_value
55
54
 
56
55
  _IMAGE_TYPES = ("image", "image_url")
57
56
  _UNSUPPORTED_MEDIA_TYPES = ("video", "video_url", "audio", "audio_url", "input_audio")
@@ -104,6 +103,15 @@ _PARAM_RE = re.compile(
104
103
  r'<atem:parameter\b[^>]*?\bname="(?P<key>[^"]+)"[^>]*?>(?P<value>.*?)</atem:parameter>',
105
104
  re.DOTALL,
106
105
  )
106
+ _STRIP_REASONING_RE = re.compile(
107
+ r"(?:<\|start\|>assistant\s*)?to=self<\|message\|>.*?<\|eom\|>", re.DOTALL
108
+ )
109
+ _STRIP_OPEN_REASONING_RE = re.compile(
110
+ r"(?:<\|start\|>assistant\s*)?to=self<\|message\|>"
111
+ r"(?:(?!<\|eom\|>)(?!to=[^\s<]+<\|message\|>).)*"
112
+ r"(?=to=[^\s<]+<\|message\|>|$)",
113
+ re.DOTALL,
114
+ )
107
115
 
108
116
 
109
117
  def _json(value: Any) -> str:
@@ -723,23 +731,42 @@ class MuseGlimmerRenderer:
723
731
  reasoning = (
724
732
  "\n".join(p for p in reasoning_parts if p) if reasoning_parts else None
725
733
  )
726
- param_types = _build_param_type_index(tools)
734
+ registered = {_function(tool)["name"] for tool in tools or []}
727
735
  calls = [
728
736
  c
729
737
  for recipient, body, closed in tool_bodies
730
- for c in self._parse_calls(recipient, body, param_types)
738
+ for c in self._parse_calls(body, registered)
731
739
  ]
740
+ content = "".join(content_parts)
741
+ if (
742
+ messages
743
+ and messages[-1][0] == "user"
744
+ and not messages[-1][2]
745
+ and not any(call.name for call in calls)
746
+ and content.endswith("\n")
747
+ and "<atem:invoke" not in content
748
+ and "<atem:function_calls>" not in content
749
+ ):
750
+ # vLLM's non-streaming content regex ends at `$`, before one final LF.
751
+ content = content[:-1]
752
+ if tool_bodies and not any(call.name for call in calls):
753
+ # Stock vLLM preserves malformed tool markup as content when no
754
+ # complete invocation can be dispatched; it does not invent a call.
755
+ remainder = _STRIP_REASONING_RE.sub("", text)
756
+ remainder = _STRIP_OPEN_REASONING_RE.sub("", remainder)
757
+ if "<atem:invoke" in remainder or "<atem:function_calls>" in remainder:
758
+ content = remainder
759
+ elif not content:
760
+ content = "" if "to=self<|message|>" in text else text
732
761
  return ParsedResponse(
733
- content="".join(content_parts),
762
+ content=content,
734
763
  reasoning_content=reasoning,
735
764
  tool_calls=calls,
736
765
  reasoning_complete=reasoning_complete,
737
766
  )
738
767
 
739
768
  @staticmethod
740
- def _parse_calls(
741
- recipient: str, body: str, param_types: dict[str, dict[str, dict[str, Any]]]
742
- ) -> list[ParsedToolCall]:
769
+ def _parse_calls(body: str, registered: set[str]) -> list[ParsedToolCall]:
743
770
  """Every ``<atem:invoke>`` in one tool-channel message."""
744
771
  calls: list[ParsedToolCall] = []
745
772
  last_end = 0
@@ -754,23 +781,24 @@ class MuseGlimmerRenderer:
754
781
  )
755
782
  )
756
783
  continue
757
- schema = param_types.get(name, {})
784
+ if name not in registered:
785
+ head, sep, tail = name.partition(".")
786
+ if sep and head == tail and head in registered:
787
+ name = head
758
788
  arguments: dict[str, Any] = {}
759
- fallback = False
760
789
  for p in _PARAM_RE.finditer(m.group("body")):
761
- value, used = _coerce_arg_value(
762
- p.group("value"), schema.get(p.group("key"))
763
- )
790
+ value = p.group("value")
791
+ try:
792
+ value = json.loads(value)
793
+ except (json.JSONDecodeError, ValueError):
794
+ pass
764
795
  arguments[p.group("key")] = value
765
- fallback = fallback or used
766
796
  calls.append(
767
797
  ParsedToolCall(
768
798
  raw=m.group(0),
769
799
  name=name,
770
800
  arguments=arguments,
771
- status=ToolCallParseStatus.INVALID_JSON
772
- if fallback
773
- else ToolCallParseStatus.OK,
801
+ status=ToolCallParseStatus.OK,
774
802
  )
775
803
  )
776
804
  rest = body[last_end:]
@@ -779,7 +807,6 @@ class MuseGlimmerRenderer:
779
807
  calls.append(
780
808
  ParsedToolCall(
781
809
  raw=rest.strip(),
782
- name=recipient,
783
810
  status=ToolCallParseStatus.UNCLOSED_BLOCK,
784
811
  )
785
812
  )
@@ -787,7 +814,6 @@ class MuseGlimmerRenderer:
787
814
  calls.append(
788
815
  ParsedToolCall(
789
816
  raw=body,
790
- name=recipient,
791
817
  status=ToolCallParseStatus.MALFORMED_STRUCTURE,
792
818
  )
793
819
  )