renderers 0.1.13.dev8__tar.gz → 0.1.13.dev13__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.dev13}/PKG-INFO +1 -1
  2. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/_version.py +2 -2
  3. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/client.py +287 -20
  4. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_client.py +270 -7
  5. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/.github/workflows/publish-dev.yml +0 -0
  6. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/.github/workflows/publish.yml +0 -0
  7. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/.github/workflows/style.yml +0 -0
  8. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/.github/workflows/test.yml +0 -0
  9. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/.gitignore +0 -0
  10. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/.pre-commit-config.yaml +0 -0
  11. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/LICENSE +0 -0
  12. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/README.md +0 -0
  13. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/docs/renderer-config.md +0 -0
  14. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/examples/README.md +0 -0
  15. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/examples/sglang/multiturn_generate_sglang.py +0 -0
  16. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/examples/sglang/online_multiturn_sglang.py +0 -0
  17. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/examples/tinker/multiturn_generate_tinker.py +0 -0
  18. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/examples/transformers/multiturn_generate_transformers.py +0 -0
  19. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/examples/vllm/multiturn_generate_vllm.py +0 -0
  20. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/pyproject.toml +0 -0
  21. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/__init__.py +0 -0
  22. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/base.py +0 -0
  23. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/configs.py +0 -0
  24. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/custom.py +0 -0
  25. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/deepseek_r1.py +0 -0
  26. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/deepseek_v3.py +0 -0
  27. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/deepseek_v4.py +0 -0
  28. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/default.py +0 -0
  29. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/gemma4.py +0 -0
  30. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/glm45.py +0 -0
  31. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/glm5.py +0 -0
  32. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/gpt_oss.py +0 -0
  33. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/hy3.py +0 -0
  34. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/inkling.py +0 -0
  35. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/kimi_k2.py +0 -0
  36. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/kimi_k25.py +0 -0
  37. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/laguna_s21.py +0 -0
  38. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/laguna_xs2.py +0 -0
  39. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/llama_3.py +0 -0
  40. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/minimax_m2.py +0 -0
  41. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/muse_glimmer.py +0 -0
  42. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/nemotron3.py +0 -0
  43. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/parsers.py +0 -0
  44. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/parsing.py +0 -0
  45. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/prime_qwen3.py +0 -0
  46. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/qwen3.py +0 -0
  47. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/qwen35.py +0 -0
  48. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/qwen36.py +0 -0
  49. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/qwen38.py +0 -0
  50. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/qwen3_vl.py +0 -0
  51. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/reasoning.py +0 -0
  52. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/renderers/tagged.py +0 -0
  53. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/audit_prefix_stability.py +0 -0
  54. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/conftest.py +0 -0
  55. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/fixtures/prefix_stability.json +0 -0
  56. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/parity.py +0 -0
  57. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/reference_continuations.py +0 -0
  58. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/reference_rendering.py +0 -0
  59. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/replay.py +0 -0
  60. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_bridge.py +0 -0
  61. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_build_helpers.py +0 -0
  62. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_custom.py +0 -0
  63. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_deepseek_r1.py +0 -0
  64. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_deepseek_v4.py +0 -0
  65. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_disabled_thinking_stability.py +0 -0
  66. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_gemma4.py +0 -0
  67. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_glm53_flash.py +0 -0
  68. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_glm_tool_name_validation.py +0 -0
  69. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_hy3.py +0 -0
  70. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_incremental.py +0 -0
  71. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_inkling.py +0 -0
  72. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_is_content.py +0 -0
  73. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_kimi_k25_tool_schema.py +0 -0
  74. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_laguna_m1.py +0 -0
  75. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_laguna_xs21.py +0 -0
  76. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_llama_3.py +0 -0
  77. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_load_tokenizer.py +0 -0
  78. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_message_indices.py +0 -0
  79. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_message_tool_names.py +0 -0
  80. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_multimodal.py +0 -0
  81. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_muse_glimmer.py +0 -0
  82. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_nemotron35_vlm.py +0 -0
  83. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_nemotron3_ultra.py +0 -0
  84. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_offsetless_tokenizers.py +0 -0
  85. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_parity.py +0 -0
  86. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_parse_response.py +0 -0
  87. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_parse_response_robustness.py +0 -0
  88. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_parsers.py +0 -0
  89. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_preserve_thinking.py +0 -0
  90. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_qwen35_size_coverage.py +0 -0
  91. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_qwen38.py +0 -0
  92. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_reasoning_boundaries.py +0 -0
  93. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_renderer_config.py +0 -0
  94. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_roundtrip.py +0 -0
  95. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_sampled_mask.py +0 -0
  96. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_tagged_rollout.py +0 -0
  97. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_tokens_per_message.py +0 -0
  98. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/tests/test_tool_arg_type_preservation.py +0 -0
  99. {renderers-0.1.13.dev8 → renderers-0.1.13.dev13}/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.dev13
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.dev13'
22
+ __version_tuple__ = version_tuple = (0, 1, 13, 'dev13')
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,
@@ -5,6 +5,7 @@ import json
5
5
  import httpx
6
6
  import numpy as np
7
7
  import pytest
8
+
8
9
  from renderers import MalformedGenerateResponseError
9
10
  from renderers.base import (
10
11
  ParsedResponse,
@@ -12,7 +13,210 @@ from renderers.base import (
12
13
  RenderedTokens,
13
14
  ToolCallParseStatus,
14
15
  )
15
- from renderers.client import generate
16
+ from renderers.client import (
17
+ _parse_compact_logprobs,
18
+ _parse_completion_top_logprobs,
19
+ generate,
20
+ parse_generate_response,
21
+ )
22
+
23
+
24
+ def _compact_choice(choice):
25
+ entries = choice["logprobs"]["content"]
26
+ ids, values, offsets = [], [], [0]
27
+ for entry in entries:
28
+ # Flat vLLM storage retains the sampled token even when it repeats in top-k.
29
+ for candidate in [entry, *entry.get("top_logprobs", [])]:
30
+ ids.append(
31
+ candidate["token_id"]
32
+ if "token_id" in candidate
33
+ else int(candidate["token"].removeprefix("token_id:"))
34
+ )
35
+ values.append(candidate["logprob"])
36
+ offsets.append(len(ids))
37
+ raw = (
38
+ np.asarray(ids, dtype="<u4").tobytes()
39
+ + np.asarray(values, dtype="<f4").tobytes()
40
+ )
41
+ return {
42
+ **choice,
43
+ "logprobs": None,
44
+ "compact_logprobs": {
45
+ "data": base64.b64encode(raw).decode("ascii"),
46
+ "format": "u32-f32-le-v1",
47
+ "offsets": offsets,
48
+ "num_top_logprobs": max(
49
+ (len(e.get("top_logprobs", [])) for e in entries), default=0
50
+ ),
51
+ },
52
+ }
53
+
54
+
55
+ def _parse_test_head(choice, sampled, k, compact):
56
+ if compact:
57
+ _, ids, logprobs = _parse_compact_logprobs(_compact_choice(choice), sampled, k)
58
+ return ids, logprobs
59
+ return _parse_completion_top_logprobs(choice, sampled, k)
60
+
61
+
62
+ @pytest.mark.parametrize(
63
+ "update",
64
+ [
65
+ {"format": "unknown"},
66
+ {"offsets": None},
67
+ {"offsets": []},
68
+ {"offsets": [1, 2]},
69
+ {"offsets": [0, 0]},
70
+ {"offsets": [0, -1]},
71
+ {"offsets": [False, 1]},
72
+ {"offsets": [0, 1.5]},
73
+ {"offsets": [0, 100]},
74
+ {"num_top_logprobs": True},
75
+ {"num_top_logprobs": -2},
76
+ {"data": "?"},
77
+ {"data": None},
78
+ {"data": ""},
79
+ ],
80
+ )
81
+ def test_compact_logprobs_reject_malformed_payload(update):
82
+ choice = _compact_choice(
83
+ {"logprobs": {"content": [{"token": "token_id:7", "logprob": -0.1}]}}
84
+ )
85
+ choice["compact_logprobs"].update(update)
86
+ with pytest.raises(MalformedGenerateResponseError):
87
+ _parse_compact_logprobs(choice, [7], None)
88
+
89
+
90
+ @pytest.mark.parametrize(
91
+ "logprob", [float("nan"), float("inf"), float("-inf"), 0.1, -9999.0]
92
+ )
93
+ def test_compact_logprobs_require_valid_sampled_evidence(logprob):
94
+ choice = _compact_choice(
95
+ {"logprobs": {"content": [{"token": "token_id:7", "logprob": logprob}]}}
96
+ )
97
+ with pytest.raises(MalformedGenerateResponseError):
98
+ _parse_compact_logprobs(choice, [7], None)
99
+ choice = _compact_choice(
100
+ {"logprobs": {"content": [{"token": "token_id:7", "logprob": -0.1}]}}
101
+ )
102
+ with pytest.raises(MalformedGenerateResponseError, match="Missing compact sampled"):
103
+ _parse_compact_logprobs(choice, [8], None)
104
+
105
+
106
+ @pytest.mark.parametrize("separators", [(",", ":"), (", ", ": ")])
107
+ def test_parse_generate_response_preserves_multiple_compact_choices(separators):
108
+ choices = [
109
+ _compact_choice(
110
+ {
111
+ "logprobs": {"content": [{"token": f"token_id:{i}", "logprob": -0.5}]},
112
+ "routed_experts": {
113
+ "data": base64.b64encode(bytes([i])).decode("ascii"),
114
+ "shape": [1, 1, 1],
115
+ },
116
+ }
117
+ )
118
+ for i in (7, 8)
119
+ ]
120
+ choices.append(_compact_choice({"logprobs": {"content": []}}))
121
+ payload = parse_generate_response(
122
+ json.dumps({"choices": choices}, separators=separators).encode(),
123
+ )
124
+ for i, choice in zip((7, 8), payload["choices"]):
125
+ assert _parse_compact_logprobs(choice, [i], None) == ([-0.5], None, None)
126
+ assert base64.b64decode(choice["routed_experts"]["data"]) == bytes([i])
127
+ assert _parse_compact_logprobs(payload["choices"][2], [], 2) == ([], [], [])
128
+
129
+
130
+ @pytest.mark.parametrize("sampled", [1, 3, 5])
131
+ @pytest.mark.parametrize("compact", [False, True])
132
+ @pytest.mark.parametrize(
133
+ "probabilities, expected_ids",
134
+ [
135
+ ({1: 0.6, 2: 0.25, 3: 0.1, 4: 0.04, 5: 0.01}, [1, 2]),
136
+ ({1: 0.4, 2: 0.2, 3: 0.2, 4: 0.1, 5: 0.1}, [1]),
137
+ ],
138
+ )
139
+ def test_top_logprobs_head_is_independent_of_sampled_action(
140
+ sampled, probabilities, expected_ids, compact
141
+ ):
142
+ import math
143
+
144
+ order = [sampled, *[i for i in probabilities if i != sampled]][:4]
145
+ entry = {
146
+ "token": f"token_id:{sampled}",
147
+ "logprob": math.log(probabilities[sampled]),
148
+ "top_logprobs": [
149
+ {"token": f"token_id:{i}", "logprob": math.log(probabilities[i])}
150
+ for i in order
151
+ ],
152
+ }
153
+ ids, logprobs = _parse_test_head(
154
+ {"logprobs": {"content": [entry]}}, [sampled], 2, compact
155
+ )
156
+ assert ids == [expected_ids]
157
+ np.testing.assert_allclose(
158
+ np.exp(logprobs), [[probabilities[i] for i in expected_ids]]
159
+ )
160
+
161
+ entry["top_logprobs"] = entry["top_logprobs"][:2]
162
+ with pytest.raises(MalformedGenerateResponseError, match="fewer top logprobs"):
163
+ _parse_test_head({"logprobs": {"content": [entry]}}, [sampled], 2, compact)
164
+
165
+
166
+ @pytest.mark.parametrize("compact", [False, True])
167
+ @pytest.mark.parametrize("packed", [False, True])
168
+ @pytest.mark.parametrize("error", [None, "missing", "mass", "mask"])
169
+ def test_top_logprobs_preserve_complete_replay_support(compact, packed, error):
170
+ import math
171
+
172
+ probabilities = {1: 0.6, 2: 0.2, 3: 0.2}
173
+ if error == "mass":
174
+ probabilities[1] = 0.5
175
+ entry = {
176
+ "token": "token_id:3",
177
+ "logprob": math.log(probabilities[3]),
178
+ "top_logprobs": [
179
+ {"token": f"token_id:{i}", "logprob": math.log(p)}
180
+ for i, p in probabilities.items()
181
+ if error != "missing" or i != 2
182
+ ],
183
+ }
184
+ support = [1, 2] if error == "mask" else [3, 1, 2]
185
+ mask = [support]
186
+ if packed:
187
+ mask = {
188
+ name: {
189
+ "data": base64.b64encode(np.asarray(values, dtype=np.int32)).decode(),
190
+ "shape": [len(values)],
191
+ "dtype": "int32",
192
+ }
193
+ for name, values in (("ids", support), ("counts", [len(support)]))
194
+ }
195
+ choice = {"logprobs": {"content": [entry]}, "sampling_mask": mask}
196
+ if error is not None:
197
+ with pytest.raises(MalformedGenerateResponseError):
198
+ _parse_test_head(choice, [3], 2, compact)
199
+ else:
200
+ ids, logprobs = _parse_test_head(choice, [3], 2, compact)
201
+ assert ids == [[1, 2, 3]]
202
+ np.testing.assert_allclose(np.exp(logprobs), [[0.6, 0.2, 0.2]])
203
+
204
+
205
+ @pytest.mark.parametrize("compact", [False, True])
206
+ def test_top_logprobs_preserve_filtered_candidates(compact):
207
+ entry = {
208
+ "token": "token_id:1",
209
+ "logprob": -0.5,
210
+ "top_logprobs": [
211
+ {"token": f"token_id:{i}", "logprob": p}
212
+ for i, p in [(1, -0.5), (2, -2.0), (3, -9999.0)]
213
+ ],
214
+ }
215
+ choice = {"logprobs": {"content": [entry]}}
216
+ assert _parse_test_head(choice, [1], 1, compact) == ([[1]], [[-0.5]])
217
+ entry["top_logprobs"][0]["logprob"] = -0.6
218
+ with pytest.raises(MalformedGenerateResponseError, match="disagree"):
219
+ _parse_test_head(choice, [1], 1, compact)
16
220
 
17
221
 
18
222
  class _FakeRenderer:
@@ -130,10 +334,38 @@ def _run_generate(client, renderer=None):
130
334
  },
131
335
  ],
132
336
  )
133
- def test_generate_builds_request_body_and_parses_response(usage):
337
+ @pytest.mark.parametrize("head_size", [None, 2])
338
+ @pytest.mark.parametrize("transport", ["native", "compact", "opt_out"])
339
+ @pytest.mark.parametrize("numeric_ids", [False, True])
340
+ def test_generate_builds_request_body_and_parses_response(
341
+ usage, head_size, transport, numeric_ids
342
+ ):
134
343
  client = _FakeClient()
135
344
  client.usage = usage
136
345
  renderer = _FakeRenderer()
346
+ sampling_params = {"temperature": 0.3, "max_tokens": 7, "min_tokens": 2}
347
+ if head_size is not None:
348
+ sampling_params["logprobs"] = head_size
349
+ for entry in client.choice["logprobs"]["content"]:
350
+ entry["top_logprobs"] = [
351
+ {"token": entry["token"], "logprob": entry["logprob"]},
352
+ {"token": "token_id:20", "logprob": -4.0},
353
+ {"token": "token_id:21", "logprob": -5.0},
354
+ {"token": "token_id:22", "logprob": -6.0},
355
+ ]
356
+ if numeric_ids:
357
+ for entry in client.choice["logprobs"]["content"]:
358
+ for candidate in [entry, *entry.get("top_logprobs", [])]:
359
+ candidate["token_id"] = int(candidate.pop("token")[9:])
360
+ extra_args = (
361
+ {"prl_compact_logprobs": False, "custom_arg": 7}
362
+ if transport == "opt_out"
363
+ else None
364
+ )
365
+ if extra_args is not None:
366
+ sampling_params["extra_args"] = extra_args
367
+ if head_size is not None and transport == "compact":
368
+ client.choice = _compact_choice(client.choice)
137
369
 
138
370
  result = asyncio.run(
139
371
  generate(
@@ -142,7 +374,7 @@ def test_generate_builds_request_body_and_parses_response(usage):
142
374
  messages=[{"role": "user", "content": "hi"}],
143
375
  model="test-model",
144
376
  tools=[{"type": "function", "function": {"name": "echo"}}],
145
- sampling_params={"temperature": 0.3, "max_tokens": 7, "min_tokens": 2},
377
+ sampling_params=sampling_params,
146
378
  cache_salt="ckpt-42",
147
379
  )
148
380
  )
@@ -167,7 +399,15 @@ def test_generate_builds_request_body_and_parses_response(usage):
167
399
  "max_tokens": 7,
168
400
  "min_tokens": 2,
169
401
  "stop_token_ids": [99],
170
- "logprobs": 1,
402
+ "logprobs": head_size + 2 if head_size is not None else 1,
403
+ **(
404
+ {
405
+ "extra_args": {"prl_compact_logprobs": True},
406
+ }
407
+ if head_size
408
+ else {}
409
+ ),
410
+ **({"extra_args": extra_args} if extra_args is not None else {}),
171
411
  "skip_special_tokens": False,
172
412
  },
173
413
  }
@@ -178,7 +418,17 @@ def test_generate_builds_request_body_and_parses_response(usage):
178
418
  assert result["reasoning_content"] == "think"
179
419
  assert result["prompt_ids"] == [1, 2, 3]
180
420
  assert result["completion_ids"] == [7, 8]
181
- assert result["completion_logprobs"] == [-0.1, -0.2]
421
+ np.testing.assert_allclose(result["completion_logprobs"], [-0.1, -0.2])
422
+ if head_size is not None:
423
+ assert result["completion_top_ids"] == [[7, 20], [8, 20]]
424
+ np.testing.assert_allclose(
425
+ result["completion_top_logprobs"], [[-0.1, -4.0], [-0.2, -4.0]]
426
+ )
427
+ else:
428
+ assert result["completion_top_ids"] is None
429
+ assert result["completion_top_logprobs"] is None
430
+ if extra_args is not None:
431
+ assert extra_args == {"prl_compact_logprobs": False, "custom_arg": 7}
182
432
  assert result["routed_experts"]["shape"] == [2, 1, 1]
183
433
  assert isinstance(result["routed_experts"]["data"], memoryview)
184
434
  assert result["routed_experts"]["data"].tobytes() == base64.b64encode(b"\x01\x02")
@@ -316,9 +566,20 @@ def test_generate_rejects_vllm_missing_logprob_sentinel():
316
566
  _run_generate(client)
317
567
 
318
568
 
319
- def test_generate_rejects_logprob_token_id_mismatch():
569
+ @pytest.mark.parametrize(
570
+ "identity",
571
+ [
572
+ {"token": "token_id:8"},
573
+ {"token_id": 8},
574
+ {"token_id": True},
575
+ {"token_id": -1},
576
+ {"token_id": "7"},
577
+ {"token_id": None},
578
+ ],
579
+ )
580
+ def test_generate_rejects_logprob_token_id_mismatch(identity):
320
581
  client = _FakeClient()
321
- client.choice["logprobs"]["content"][0]["token"] = "token_id:8"
582
+ client.choice["logprobs"]["content"][0].update(identity)
322
583
 
323
584
  with pytest.raises(
324
585
  MalformedGenerateResponseError,
@@ -494,6 +755,7 @@ def test_generate_serializes_multimodal_features_for_qwen_vl_family(
494
755
  pytest.importorskip("vllm", reason="vllm needed for features serialization")
495
756
 
496
757
  import torch as _torch
758
+
497
759
  from renderers.base import (
498
760
  MultiModalData,
499
761
  PlaceholderRange,
@@ -570,6 +832,7 @@ def test_generate_serializes_multimodal_features_for_gemma4():
570
832
  pytest.importorskip("vllm", reason="vllm needed for features serialization")
571
833
 
572
834
  import torch as _torch
835
+
573
836
  from renderers.base import MultiModalData, PlaceholderRange, load_tokenizer
574
837
  from renderers.gemma4 import Gemma4Renderer
575
838