Skip to content

Commit 2c08671

Browse files
fix: recover per-token ids and logprobs from content[] to stop RL KLD corruption (#458)
* fix: request exact token ids and fail loud in FireworksV1CompletionsClient The /v1/completions sampling path corrupted RL training via retokenization drift. The request never set return_token_ids, so choices[].token_ids came back empty and the client silently re-encoded decoded text. Retokenization drops the trailing end-of-turn/EOS token, making completion_ids one shorter than token_logprobs and misaligning every per-token logprob (inference KLD spiked to ~60 vs ~0.028 with exact ids). - Default return_token_ids=True in the request payload (overridable via request_params). - Remove the tokenizer.encode() re-encode fallback; raise when exact ids are absent instead of silently returning corrupted data. - Assert len(completion_token_ids) == len(completion_logprobs) at the boundary to catch any residual drift. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor: recover per-token ids and logprobs from content[] logprobs shape This client always requests boolean logprobs=True, so every response uses the new content[] logprobs shape. Read the exact per-token ids and logprobs together from content[] (token_id + sampling_logprob) so they are aligned by construction. Bug 1 (alignment / KLD corruption) — the real fix: previously ids were read only from the top-level choices[].token_ids (populated by return_token_ids, which this client never requested) and, when absent, silently re-derived via tokenizer.encode(decode(text)). Retokenization drops the trailing end-of-turn/EOS token, making completion_ids one shorter than the logprobs and misaligning every per-token logprob (inference_kld ~60 vs ~0.028). Since content[] already carries token_id per entry, reading ids there removes the drift entirely — no return_token_ids, no re-encode fallback. Bug 2 (precision) — more of a feature: prefer content[].sampling_logprob (the exact value the sampler drew with) over content[].logprob (rounded). - Drop legacy token_logprobs / top-level token_ids / raw_output id sources. - Fail loud when content[] is absent or a content[] entry lacks token_id, instead of silently returning corrupted data. Co-authored-by: Cursor <cursoragent@cursor.com> * fix: require sampling_logprob per content[] entry, no fallback Always read the per-token logprob from content[].sampling_logprob (the exact value the sampler drew with). Fail loud if any content[] entry lacks sampling_logprob instead of substituting the rounded content[].logprob or 0.0, so silently degraded logprobs never reach RL training. Removes the now-unused _extract_entry_logprob helper. Co-authored-by: Cursor <cursoragent@cursor.com> * test: use pytest.approx for logprob list comparison Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 08e9b85 commit 2c08671

2 files changed

Lines changed: 200 additions & 22 deletions

File tree

eval_protocol/integrations/fireworks_v1_completions_client.py

Lines changed: 37 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -393,35 +393,50 @@ async def create_completion_from_prompt_ids(
393393
finish_reason = str(choice.get("finish_reason") or "unknown")
394394

395395
raw_output = choice.get("raw_output") if isinstance(choice.get("raw_output"), dict) else {}
396-
completion_token_ids = _normalize_token_id_sequence(
397-
choice.get("token_ids") or raw_output.get("completion_token_ids") or []
398-
)
399396
choice_prompt_token_ids = _normalize_token_id_sequence(
400397
choice.get("prompt_token_ids") or raw_output.get("prompt_token_ids") or normalized_prompt_token_ids
401398
)
402399

400+
# -- Extract per-token ids and logprobs together --------------------
401+
# Both come from the same ``content[]`` array entry-by-entry, so they are
402+
# inherently the same length and aligned. Reading ids from a different
403+
# source (top-level token_ids) and re-encoding decoded text when it is
404+
# absent drops the trailing end-of-turn token and misaligns per-token
405+
# logprobs, corrupting inference KLD — so we never do that.
406+
choice_logprobs = choice.get("logprobs")
407+
content_entries = choice_logprobs.get("content") if isinstance(choice_logprobs, dict) else None
408+
if not content_entries:
409+
raise RuntimeError(
410+
"Fireworks /v1/completions returned no content[] logprobs entries. "
411+
"This client requires the boolean logprobs=True (content) shape to "
412+
"recover exact per-token ids and sampling logprobs. Refusing to "
413+
"re-encode decoded text: retokenization drops the end-of-turn token "
414+
"and misaligns per-token logprobs, corrupting inference KLD. "
415+
f"choice keys={list(choice.keys())}"
416+
)
417+
418+
completion_token_ids: List[int] = []
419+
completion_logprobs: List[float] = []
420+
for index, entry in enumerate(content_entries):
421+
if not isinstance(entry, dict) or entry.get("token_id") is None:
422+
raise RuntimeError(
423+
"Fireworks /v1/completions content[] entry is missing token_id "
424+
f"at index {index}; cannot align per-token logprobs to token ids "
425+
"without re-encoding. Refusing to return corrupted data."
426+
)
427+
if entry.get("sampling_logprob") is None:
428+
raise RuntimeError(
429+
"Fireworks /v1/completions content[] entry is missing "
430+
f"sampling_logprob at index {index}. The sampling logprob is the "
431+
"exact value the sampler drew with and is required for correct "
432+
"inference KLD; refusing to substitute the rounded logprob or 0.0."
433+
)
434+
completion_token_ids.append(int(entry["token_id"]))
435+
completion_logprobs.append(float(entry["sampling_logprob"]))
436+
403437
completion_text = self.decode_token_ids(token_ids=completion_token_ids)
404438
if not completion_text:
405439
completion_text = str(choice.get("text") or "")
406-
if not completion_token_ids and completion_text:
407-
tokenizer = self._get_tokenizer()
408-
completion_token_ids = list(tokenizer.encode(completion_text, add_special_tokens=False))
409-
410-
# -- Extract logprobs -----------------------------------------------
411-
completion_logprobs: List[float] = []
412-
choice_logprobs = choice.get("logprobs")
413-
if isinstance(choice_logprobs, dict):
414-
token_logprobs = choice_logprobs.get("token_logprobs") or []
415-
if token_logprobs:
416-
completion_logprobs = [float(lp) if lp is not None else 0.0 for lp in token_logprobs]
417-
else:
418-
content_logprobs = choice_logprobs.get("content") or []
419-
completion_logprobs = [
420-
float(entry.get("logprob", 0.0)) if isinstance(entry, dict) else 0.0
421-
for entry in content_logprobs
422-
]
423-
elif isinstance(choice_logprobs, list):
424-
completion_logprobs = [float(lp) if lp is not None else 0.0 for lp in choice_logprobs]
425440

426441
# -- Build message via parser or raw --------------------------------
427442
if self.tool_call_parser is not None:

tests/test_fireworks_v1_completions_client.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,169 @@ def encode(self, text, add_special_tokens=False):
124124
asyncio.run(client.close())
125125

126126

127+
class _FakeResponse:
128+
def __init__(self, payload: Dict[str, Any]):
129+
self._payload = payload
130+
131+
def model_dump(self) -> Dict[str, Any]:
132+
return self._payload
133+
134+
135+
def _install_fake_completion(client, monkeypatch, payload):
136+
captured: Dict[str, Any] = {}
137+
138+
async def fake_create(**kwargs):
139+
captured.update(kwargs)
140+
return _FakeResponse(payload)
141+
142+
class _FakeCompletions:
143+
create = staticmethod(fake_create)
144+
145+
class _FakeClient:
146+
completions = _FakeCompletions()
147+
148+
async def close(self):
149+
return None
150+
151+
monkeypatch.setattr(client, "_client", _FakeClient())
152+
return captured
153+
154+
155+
def test_reads_ids_and_sampling_logprobs_from_content(monkeypatch):
156+
client = FireworksV1CompletionsClient(
157+
model_id="test-model",
158+
tokenizer_name_or_path="Qwen/Qwen3-0.6B",
159+
)
160+
monkeypatch.setattr(client, "decode_token_ids", lambda token_ids: "text")
161+
162+
def _fail_tokenizer():
163+
raise AssertionError("tokenizer must not be used to re-encode completion text")
164+
165+
monkeypatch.setattr(client, "_get_tokenizer", lambda: _fail_tokenizer())
166+
captured = _install_fake_completion(
167+
client,
168+
monkeypatch,
169+
{
170+
"choices": [
171+
{
172+
"finish_reason": "stop",
173+
"logprobs": {
174+
"content": [
175+
{"token_id": 271, "sampling_logprob": -0.05483185, "logprob": -0.0548313},
176+
{"token_id": 248068, "sampling_logprob": -0.0014, "logprob": -0.0014},
177+
{"token_id": 26108, "sampling_logprob": -1.0, "logprob": -0.99},
178+
]
179+
},
180+
}
181+
],
182+
},
183+
)
184+
result = asyncio.run(client.create_completion_from_prompt_ids(prompt_token_ids=[1, 2]))
185+
assert "return_token_ids" not in captured
186+
assert result["completion_ids"] == [271, 248068, 26108]
187+
assert result["completion_logprobs"] == pytest.approx([-0.05483185, -0.0014, -1.0])
188+
assert len(result["completion_ids"]) == len(result["completion_logprobs"])
189+
asyncio.run(client.close())
190+
191+
192+
def test_raises_when_content_entry_missing_sampling_logprob(monkeypatch):
193+
client = FireworksV1CompletionsClient(
194+
model_id="test-model",
195+
tokenizer_name_or_path="Qwen/Qwen3-0.6B",
196+
)
197+
monkeypatch.setattr(client, "decode_token_ids", lambda token_ids: "text")
198+
_install_fake_completion(
199+
client,
200+
monkeypatch,
201+
{
202+
"choices": [
203+
{
204+
"finish_reason": "stop",
205+
"logprobs": {
206+
"content": [
207+
{"token_id": 1, "sampling_logprob": -0.1},
208+
{"token_id": 2, "logprob": -0.2},
209+
]
210+
},
211+
}
212+
],
213+
},
214+
)
215+
with pytest.raises(RuntimeError, match="missing .*sampling_logprob"):
216+
asyncio.run(client.create_completion_from_prompt_ids(prompt_token_ids=[1]))
217+
asyncio.run(client.close())
218+
219+
220+
def test_raises_when_no_content_logprobs(monkeypatch):
221+
client = FireworksV1CompletionsClient(
222+
model_id="test-model",
223+
tokenizer_name_or_path="Qwen/Qwen3-0.6B",
224+
)
225+
_install_fake_completion(
226+
client,
227+
monkeypatch,
228+
{"choices": [{"text": "hello world", "finish_reason": "stop"}]},
229+
)
230+
with pytest.raises(RuntimeError, match="no content\\[\\] logprobs entries"):
231+
asyncio.run(client.create_completion_from_prompt_ids(prompt_token_ids=[1, 2]))
232+
asyncio.run(client.close())
233+
234+
235+
def test_ignores_legacy_token_logprobs_shape(monkeypatch):
236+
"""The legacy token_logprobs shape has no content[]; the client must not use it."""
237+
client = FireworksV1CompletionsClient(
238+
model_id="test-model",
239+
tokenizer_name_or_path="Qwen/Qwen3-0.6B",
240+
)
241+
_install_fake_completion(
242+
client,
243+
monkeypatch,
244+
{
245+
"choices": [
246+
{
247+
"finish_reason": "stop",
248+
"token_ids": [1, 2, 3],
249+
"logprobs": {
250+
"token_ids": [1, 2, 3],
251+
"token_logprobs": [-0.1, -0.2, -0.3],
252+
},
253+
}
254+
],
255+
},
256+
)
257+
with pytest.raises(RuntimeError, match="no content\\[\\] logprobs entries"):
258+
asyncio.run(client.create_completion_from_prompt_ids(prompt_token_ids=[1]))
259+
asyncio.run(client.close())
260+
261+
262+
def test_raises_when_content_entry_missing_token_id(monkeypatch):
263+
client = FireworksV1CompletionsClient(
264+
model_id="test-model",
265+
tokenizer_name_or_path="Qwen/Qwen3-0.6B",
266+
)
267+
monkeypatch.setattr(client, "decode_token_ids", lambda token_ids: "text")
268+
_install_fake_completion(
269+
client,
270+
monkeypatch,
271+
{
272+
"choices": [
273+
{
274+
"finish_reason": "stop",
275+
"logprobs": {
276+
"content": [
277+
{"token_id": 1, "sampling_logprob": -0.1},
278+
{"sampling_logprob": -0.2},
279+
]
280+
},
281+
}
282+
],
283+
},
284+
)
285+
with pytest.raises(RuntimeError, match="missing token_id"):
286+
asyncio.run(client.create_completion_from_prompt_ids(prompt_token_ids=[1]))
287+
asyncio.run(client.close())
288+
289+
127290
def test_thinking_kwargs_respects_enable_thinking():
128291
client_none = FireworksV1CompletionsClient(
129292
model_id="test", tokenizer_name_or_path="Qwen/Qwen3-0.6B",

0 commit comments

Comments
 (0)