Skip to content

[issue-8134] [SDK] fix(g-eval): locate the score token by content instead of a fixed offset - #8152

Merged
petrotiurin merged 12 commits into
comet-ml:mainfrom
feiiiiii5:fix/geval-logprob-score-token-location
Sep 17, 2026
Merged

petrotiurin merged 12 commits into
comet-ml:mainfrom
feiiiiii5:fix/geval-logprob-score-token-location

Conversation

@feiiiiii5

@feiiiiii5 feiiiiii5 commented Sep 4, 2026 •

Copy link
Copy Markdown
Contributor

Details

  • The G-Eval logprob scorer located the score digit at a hardcoded token offset and assumed single-digit scores. A two-digit score ("score": 10) tokenizes across two tokens, so only the first digit was averaged: a confident 10 parsed as ~0.099. A tokenizer folding the whitespace after the colon into the score token (" 0") made every candidate fail the decimal check and raised MetricComputationError on a perfectly parseable response.
  • The score digits are now located by reconstructing the token stream and walking the first JSON object once, stepping over whole strings and reading the "score" key only at depth 1, stopping when that object closes. Last assignment wins, so duplicate top-level keys resolve the way json.loads does, and a nested "score" cannot capture the position because it sits deeper. Candidate tokens are stripped before the decimal check; a two-token span combines both positions' candidates ("1" + "0" -> 10).
  • When the located span carries no probability mass (digits span punctuation-carrying tokens), the parser degrades to the text path like the short-stream and no-logprob branches instead of raising.
  • Falls back to the legacy fixed offset when the first object holds no top-level key.

Change checklist

  • User facing
  • Documentation update

Issues

Related: #8134 (defect 3 of 4 — Resolves intentionally avoided so the four-defect issue stays open for defects 1 and 2)

AI-WATERMARK

AI-WATERMARK: yes

  • Tools: OpenCode (Sisyphus agent session)

  • Model(s): muse-spark

  • Scope: review-feedback commits only — 5486cc3 (typehints, _to_dict wrap, no-mass degrade, content-free log line, test dedup/parametrize, punctuation-split regression test), 3b8cfe9 (DEBUG fallback log + message assertion), 0e48cce (locator value-agreement fix, two regression tests, two docstring corrections), ae048eadf (locator brace-depth fix + one regression test), 670fdd61e (one test-only commit closing a coverage hole the depth rule opened), 3b056b5d9 (locator rewritten to a single-pass structural walk, design and reference implementation from petrotiurin on the thread, plus three regression tests)

  • Human verification: pytest tests/unit/evaluation/metrics/llm_judges/g_eval/ (18 passed at 3b056b5d9, 15 at 670fdd61e, 13 at 0e48cce), pytest tests/unit/evaluation/metrics/llm_judges/ 126 passed, new tests verified to fail on the pre-fix parser, [email protected] check + format clean, [email protected] clean; mutation table below re-run whole at each head, each mutation restoring the file byte-for-byte

Testing

At 3b056b5d9:

  • PYTHONPATH=src python -m pytest tests/unit/evaluation/metrics/llm_judges/g_eval/ -q → 18 passed
  • PYTHONPATH=src python -m pytest tests/unit/evaluation/metrics/llm_judges/ -q → 126 passed
  • The three tests added in 3b056b5d9 fail against 670fdd61e's src/ and only those three do; the fifteen existing tests pass unmodified on the new locator
  • uvx [email protected] check + format --check on both touched files → clean; uvx [email protected] --config-file pyproject.toml --disable-error-code misc --ignore-missing-imports on parser.py → Success: no issues found in 1 source file
  • PYTHONPATH=src python -m pytest tests/unit -q --tb=no -rf → 1 failed, 5161 passed, 3 skipped in 243.78s. The failure is tests/unit/evaluation/metrics/test_heuristics.py::test_evaluation__levenshtein_ratio, in a file this diff does not touch; importlib.util.find_spec("rapidfuzz") returns None here, so an optional dependency is missing rather than anything in this change being wrong
  • E2E not run (needs a live backend stack)

Historical, at earlier heads: test_punctuation_carrying_split_digits_degrade_to_text_path fails on the pre-PR parser with MetricComputationError and passes post-fix with 1.0. A broader tests/unit/evaluation/ sweep at that time showed the same failure count on the clean tree (91 failed / 927 passed) and on this branch (91 failed / 930 passed); those 91 are pre-existing environment failures unrelated to this diff.

Documentation

N/A — no user-facing API or config change.


Follow-up round (3b8cfe9). On Baz's question, the zero-probability-mass fallback now logs at DEBUG with the metric name, matching the sibling locator-failure branch, and test_punctuation_carrying_split_digits_degrade_to_text_path asserts the message as well as the value. Measured against its immediate predecessor 5486cc3, which degrades silently, the assertion fails on an empty capture; against the pre-PR parser the test fails outright with MetricComputationError. Post-fix: 11 passed in tests/unit/evaluation/metrics/llm_judges/g_eval/, ruff check and ruff format --check clean, mypy clean on parser.py.


Follow-up round (0e48cce, addressing the nested-key concern). Reproduced first, then applied the suggested agreement check. Measured on {"score":7, "reason": "ok", "breakdown": {"score":3}}, with the top-level digit at entry 3 and the nested one last in text order:

tree located entry score
merge base d53961c4 fixed offset 3 0.6219349153822014
3b8cfe9 (before this round) 13, the nested 3 0.27397830512740046
0e48cce 3 0.6219349153822014
text path (no logprobs) — 0.7

Two tests added:

  • test_nested_score_key_does_not_capture_the_located_token — fails on 3b8cfe9 with AssertionError: scored from the nested key: 0.27397830512740046, passes here.
  • test_unparseable_reconstruction_keeps_the_positional_match — pins the fallback when the reconstructed stream is not valid JSON.

Mutations, each restoring parser.py byte-for-byte (sha256 checked) and re-running the suite:

mutation tests that die
drop the agreement check (positional last match, i.e. 3b8cfe9) nested-key test, 1
positional first match (matches[0]) duplicate-key test, 1 — retracted in the next round: re-run at 0e48cce it kills nothing
agreement check without the except truncated-stream test + 5 existing tests, 6

The third row is why the fallback stays: removing it fails 6 of the 13 tests, not just the new one, because the reconstructed text is not valid JSON in several existing fixtures (their token streams stop before the document closes). In those cases the positional choice is kept, which is today's behaviour.

Two notes on the suggestion. I did not hoist the json.loads into the one further down: that one parses choice_dict's message content, this one parses the reconstructed token text, and those can differ (the truncated-stream case), so the parses are not interchangeable. And the two docstring claims that said last-match-wins mirrors json.loads were true for top-level duplicates only, so both now state the value-agreement rule.

Verification: 13 passed in tests/unit/evaluation/metrics/llm_judges/g_eval/; uvx [email protected] check and format --check clean on both files; uvx [email protected] --config-file sdks/python/pyproject.toml --disable-error-code misc clean on parser.py (run with --ignore-missing-imports locally, since this environment has no litellm stubs).


Follow-up round (ae048eadf + 670fdd61e, on the three remaining locator claims).
Each claim was measured as one response per claim across three trees — origin/main, this
PR at 0e48cce, and after the change at 670fdd61e — instead of being reasoned about:

response json.loads main 0e48cce 670fdd61e text path
{"score":7, "reason": "ok", "breakdown": {"score":7}} 7 0.6219349153822014 0.7809998433984686 0.6219349153822014 0.7
{"\u0073core": 7, "reason": "ok"} 7 MetricComputationError 0.7 0.7 0.7
{"score": 1e1, "reason": "ok"} 10.0 0.20408677949039836 0.20408677949039836 0.20408677949039836 1.0

Row 1 is real and is fixed here. Value agreement does not identify a position: when a
breakdown restates the top-level score, both digits agree, so the reversed scan kept
returning the nested one and the weighted average came off the breakdown token's
distribution. main scores 0.6219349153822014 on that response, so this was a regression
the previous round introduced — and a worse shape than the differing-value case, because it
yields a plausible number rather than an obviously wrong one. The rule is now: take the
agreeing matches, restrict them to the shallowest brace depth, then take the last of those,
which keeps duplicate top-level keys resolving to the final one.

test_nested_score_key_with_the_same_value_does_not_win_the_locator fails pre-fix with
assert [13] == [3] and passes post-fix. Selection differential over 32 reconstructed texts
(top level only; breakdown with the same and with a different value; two criteria; criteria
inside an array; braces inside a string; duplicate top-level keys; doubly nested) changes
the chosen span in 5 of them, and all 5 are the equal-valued-nested family — the other 27
select the same span as before.

Rows 2 and 3 are not changed, and the numbers say why. The escaped key returns None
from the locator, so the caller falls back to the fixed offset and lands on 0.7, the same
value json.loads gives, and better than main, which raises MetricComputationError on
that response. This PR therefore does not worsen the escaped-key case; it leaves it on a
path that already resolves it. The exponent row is identical on all three trees, so it is
inherited rather than introduced; consuming the full number grammar would also extend the
digit span onto e1, which _weighted_score_sums cannot weight because it filters
candidates on isdecimal(). Fixing that means changing the weighting code, and it is only
reachable if the integer contract in _COT_SYSTEM_PROMPT ("SCORE VALUE MUST BE AN INTEGER")
is relaxed. Left out rather than widening this diff.

Corrections to my own previous round, found while measuring this one.

  • The matches[0] row in the table above is retracted. Re-run at 0e48cce it kills nothing
    (13 passed): the value-agreement step overrides the positional default whenever any match
    agrees, so first-vs-last only survives on an unparseable reconstruction, and the test for
    that path used a single score key. The row was true of an earlier head and I carried it
    forward without re-measuring it.
  • 670fdd61e closes the coverage hole that explanation describes: two score keys in a
    truncated stream, where last-wins is the only rule left standing. It passes on 0e48cce
    too, so it demonstrates missing coverage rather than a fixed defect.

Mutation table at 670fdd61e, 15 tests as the baseline. Each mutation was applied to
parser.py, the suite run, then the file restored byte-for-byte (sha256 e81072a313fb,
checked before and after). This table is superseded by the one at 3b056b5d9 below: the
code it mutates no longer exists.

mutation tests that die
drop the depth rule (match = agreed[-1][0]) equal-valued nested test, 1
drop value agreement and depth (bare matches[-1], i.e. 3b8cfe9) both nested tests, 2
positional first match (matches[0]) unparseable duplicate-key test, 1
remove the unparseable fallback (raise instead of keeping positional) the 3 locator-fallback tests + 4 provider-path e2e cases, 7

The last row is why the fallback stays load-bearing: it takes the punctuation-split
degradation test and both unparseable-reconstruction tests, plus all four provider-path e2e
cases (sync and async, two parameterizations each).

Verification at 670fdd61e: PYTHONPATH=src pytest tests/unit/evaluation/metrics/llm_judges/g_eval/ → 15 passed; uvx [email protected] check and
format --check clean on both files; uvx [email protected] --config-file pyproject.toml --ignore-missing-imports clean on parser.py (the --ignore-missing-imports is local
only, this machine has no litellm stubs; CI runs without it, and tests/ is excluded from
mypy by .pre-commit-config.yaml). Source diff is 23 lines added / 5 removed in
parser.py; the rest is tests.


Follow-up round (3b056b5d9, the depth rule replaced by a structural walk).

petrotiurin reproduced two defects in the rule the previous round added, and supplied the
design and a reference implementation. Both reproduce here, measured before changing
anything.

The depth rule counted { and } characters in the text preceding each match, so braces
inside a JSON string value read as structure. With }}} in a reason the top-level key came
out at depth 1 and the nested one at depth 0, so "shallowest agreeing match" selected the
nested key. The same rule copied a growing prefix and ran two full .count() scans per
agreeing match, which is quadratic in the response length.

response main 670fdd61e 3b056b5d9
{"score":7, "reason": "returns }}} here", "breakdown": {"score": 7}} 0.6219349153822014 entry 13, 0.7809998433984686 entry 3, 0.6219349153822014
{"reason": "ok", "breakdown": {"score": 7}}, the only score nested MetricComputationError entry 9, 0.6219349153822014 None, MetricComputationError

Timing on a rubric-shaped response that doubles each step (top-level score plus N criteria,
each {"name": ..., "score": 7}, four-character tokens):

bytes criteria 670fdd61e 3b056b5d9
4119 143 0.443 ms 0.307 ms
8208 284 1.253 ms 0.659 ms
16386 566 4.229 ms 1.297 ms
32783 1127 15.244 ms 2.721 ms
65543 2219 58.116 ms 5.461 ms

The walk is one pass: _STRUCTURE_RE matches either a whole JSON string, escapes included
and an unterminated tail tolerated, or a single brace. Strings are consumed whole, so
neither a brace nor a "score" echo inside a reason can move the structure. The key is read
only at depth 1 and the walk stops when that object closes. Last assignment wins, so
duplicate top-level keys still resolve as json.loads does. Nothing is validated, so a
truncated reconstruction behaves exactly as before, which lets the json.loads agreement
check and its bare except go away. json stays imported: parser.py:111 still uses it.

Three tests added. The fifteen existing ones pass unmodified, and against 670fdd61e's
src/ the file gives three failures which are exactly the three new tests.

  • test_braces_inside_a_string_do_not_shift_the_score_depth — fails pre-fix with
    assert [13] == [3].
  • test_score_only_nested_falls_back_instead_of_scoring_from_it — pins the decision on the
    consequence below; fails pre-fix, which scored 0.6219349153822014 from the nested key.
  • test_walk_stops_at_the_first_object_close — {"score": 7} and {"score": 3} located
    entry 9 pre-fix, entry 4 now.

The consequence flagged on the thread is taken, not absorbed. A response whose only
"score" is nested now returns None, falls through to the legacy offset and raises,
matching main and the text path. Otherwise the same response succeeds or fails purely on
whether the provider returned logprobs.

One shape where the walk is stricter than the rule it replaces. Prose carrying a complete
brace pair before the JSON, Use {this} format: {"score": 7}: the walk closes on the prose
brace and returns None, where the old rule reached the real key. On that input all three
trees still end in MetricComputationError, because parser.py:111 parses the message
content with a strict json.loads and rejects anything prose-wrapped before the located
score matters. So the narrowing changes no outcome today, and where it eventually shows up it
degrades to the legacy offset, which is main's behaviour. Raised on the thread rather than
silently widened; continuing past a close while span is still None is a one-line change
and is deliberately not made here.

A distinction Baz's report and the thread did not separate. Baz's literal example,
{"reason":"{", "score":7}, does not reproduce: it has one match, so the shallowest-of-
agreeing step has nothing to choose between and it locates correctly. main is what raises
there, because the fixed offset lands on the { token and the zero-mass degrade did not
exist before this PR. Only the }}} variant breaks the depth rule, since the comparison
needs a second match to pick wrongly.

Mutation table at 3b056b5d9, 18 tests as the baseline, each mutation restored
byte-for-byte with sha256 765c826e checked before and after:

mutation tests that die
read the key at depth >= 1 instead of == 1 4: braces-in-string, both nested-key tests, nested-only
do not stop when the first object closes 1: test_walk_stops_at_the_first_object_close
keep the first span instead of the last 2: duplicate-key, unparseable duplicate-key
match braces only, do not consume whole strings 9
raise instead of returning None when there is no top-level key 1: nested-only

No test pins the linear scan. A timing bound loose enough to survive a loaded runner would
not kill the quadratic version either, since 58 ms at 64KB passes anything sane, so that
measurement lives in this description rather than in the suite.

Follow-up, not touched here. parser.py:111 reads the reason with a strict json.loads
on the raw message content, while every other read goes through
parsing_helpers.extract_json_content_or_raise (:21). That is why a prose-wrapped or
markdown-fenced response fails on the logprob path and succeeds without logprobs. Verified,
and out of scope for this diff.

Verification at 3b056b5d9: 18 passed in
tests/unit/evaluation/metrics/llm_judges/g_eval/, 126 passed in
tests/unit/evaluation/metrics/llm_judges/; uvx [email protected] check and format --check
clean on both touched files; uvx [email protected] --config-file pyproject.toml --disable-error-code misc --ignore-missing-imports on parser.py → Success: no issues found in 1 source file. The commit is 36 lines added / 36 removed in parser.py plus 108
lines of tests. Full tests/unit at this head: 1 failed, 5161 passed, 3 skipped in 243.78s,
the failure being test_evaluation__levenshtein_ratio on a missing optional rapidfuzz in a
file outside the diff.

The logprob scorer assumed the score digit is always the fourth token
and that scores are single digits. Both assumptions break in the wild:
a two-digit score ("score": 10) tokenizes across two tokens, so only
the first digit is averaged and a confident 10 scores ~0.1; and
tokenizer variants that fold the whitespace after the colon into the
score token (" 0") make every candidate non-decimal, so a perfectly
parseable response raises MetricComputationError instead.

Locate the score digits by reconstructing the token stream and
matching the "score" key, strip candidate tokens before the decimal
check, and for a two-token span combine the two positions' candidates
("1" + "0" -> 10) while counting a first-position candidate only
when it covers the whole span. Falls back to the legacy fixed offset
when the key cannot be found in the reconstructed text.

Related: comet-ml#8134 (defect 3 of 4)
@feiiiiii5
feiiiiii5 requested a review from a team as a code owner September 4, 2026 10:17
@github-actions github-actions Bot added python Pull requests that update Python code tests Including test files, or tests related like configuration. Python SDK 🟡 size/M labels Sep 4, 2026
@andrescrz

Copy link
Copy Markdown
Member

Hi @feiiiiii5

Thanks for the contribution!

When you have a chance, could you take a look at the automated review comments from Baz? For each comment, please either address it with a code change or reply with a brief explanation if you believe the current implementation is the right approach.

Thanks!

…loads

The token-stream locator matched the first "score": <digits> in the
reconstructed text, but json.loads (and therefore the no-logprob text
path) resolves duplicate keys to the LAST one. A model that repeats the
score key would be scored from the stale first key in the logprob path
while the text path reports the final value. Take the last match so
both paths agree.
When the score key cannot be located, the legacy fixed offset is used
with no diagnostics; a malformed response that lands on an unrelated
token is then hard to diagnose. Log the reconstructed text at debug
level before falling back.
@feiiiiii5

Copy link
Copy Markdown
Contributor Author

Thanks @andrescrz — all Baz comments on this PR are now either addressed or answered inline: locator-failure diagnostics in ecb2385, duplicate-"score"-key resolution (last-wins, mirroring json.loads and the text path) in 137faf9 with regression tests, and the two-token probability note answered inline as a documented approximation of the existing weighted-average design.

@andrescrz

Copy link
Copy Markdown
Member

Hi @feiiiiii5

Thanks for the contribution!

There's another round of Baz comments. When you have a chance, could you take a look again? For each comment, please either address it with a code change or reply with a brief explanation if you believe the current implementation is the right approach.

Thanks!

@feiiiiii5

Copy link
Copy Markdown
Contributor Author

Thanks @andrescrz — addressing the second round of Baz comments:

  • Stale score-token docstring: fixed in ab62edc (docstring now describes content-based lookup with legacy-offset fallback).
  • Duplicate-key assertion range (0.55-0.68): agreed it is too wide to pin the distribution. I will tighten it to the exact weighted value for the second key with a small tolerance once I verify the computed value locally (no code pushed for this yet to avoid guessing the constant).
  • Provider-shaped GEval integration test: agreed it would cover the public path better than hand-built dicts. I will add one exercising the direct parser branch (split-score + folded whitespace) unless you prefer it as a follow-up.

Let me know if you would like the remaining two as code in this PR or as a follow-up.

Tighten the duplicate-score-key regression to the exact weighted value
of the second key's candidates (pytest.approx, abs=1e-9) instead of a
direction-only range, and add two provider-shaped GEval.score tests
through the real LiteLLM branch (split-score 10, folded whitespace),
asserting the exact weighted values plus reason and provider kwargs.
@feiiiiii5

Copy link
Copy Markdown
Contributor Author

Hi @andrescrz — the remaining two second-round items are addressed in 4f14e81 (both landed in this PR, per my 09-09 note; no follow-up preference was indicated):

  • Duplicate-key assertion: tightened from the 0.55–0.68 range to the exact weighted value of the second key's candidates, pytest.approx(0.6219349153822014, abs=1e-9) — computed locally through the parser, not guessed. Verified it fails on the pre-fix code and passes post-fix. The escaped-echo control pins the same distribution (same candidates), so I tightened it identically.
  • Provider-shaped GEval test: added two tests through the public GEval.score LiteLLM branch with a stubbed provider response (dict-style choice + logprobs token stream), covering split-score 10 ("1"+"0" → approx(0.9007723389857002)) and folded whitespace (" 0" → approx(0.07984052568223204)), each also asserting the reason, logprobs=True, and top_logprobs=20 so the real branch is pinned. Same pre/post verification: both fail on the base parser (0.09916… / MetricComputationError), all 8 pass with the fix.

[email protected] check passes; the format --check hunks on this file are pre-existing lines from 137faf9, untouched to keep the diff minimal.

Extend the provider-shaped coverage to GEval.ascore, which calls
aget_provider_response independently of the sync path: two async twins
(split-score 10, folded whitespace) with identical fixtures and the same
exact expectations as their sync counterparts (same sync parser, so
identical input must yield identical values). Verified both fail on the
pre-fix parser (0.09916... / MetricComputationError) and pass with it.
@feiiiiii5

Copy link
Copy Markdown
Contributor Author

Hi @andrescrz — the async-coverage Baz comment is addressed in 88a2b7c: two async twins of the provider-shaped tests through the public GEval.ascore LiteLLM branch (split-score 10 and folded whitespace, same exact expectations as the sync versions since the parser is shared), plus an async provider stub for aget_provider_response. Both fail on the pre-fix parser (0.09916… / MetricComputationError) and pass with the fix; [email protected] check clean.

@petrotiurin petrotiurin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for opening this PR @feiiiiii5 ! Your approach looks good, but I have some concerns (outlined below), please take a look!

- _decimal_candidates: narrow typehints (entry: Any -> list[tuple[str, float)]), wrap entry in _to_dict
- degrade to the text path when the located span carries no probability mass (punctuation-carrying tokens) instead of raising
- locator fallback log: reconstructed length only, no response text
- tests: drop redundant SimpleNamespace import; lift provider fixtures to module constants + parametrize sync/async; pin punctuation-split degrade
@feiiiiii5 feiiiiii5 changed the title fix(g-eval): locate the score token by content instead of a fixed offset [issue-8134] [SDK] fix(g-eval): locate the score token by content instead of a fixed offset Sep 14, 2026
The branch that sums to zero probability mass on the located digits was the
only degradation path that had already found the score key, so a low-fidelity
text score was indistinguishable in the logs from a provider that never
returns logprobs. Log it at DEBUG like the sibling locator-failure branch and
pin the message in the regression test.
@feiiiiii5

Copy link
Copy Markdown
Contributor Author

@petrotiurin all six of your points are on the branch, plus one from Baz's pass on top. Mapping each to its commit so the re-review is a single pass:

# Your point Commit What changed
1 no-untyped-def mypy failure 5486cc3 def _decimal_candidates(entry: Any) -> list[tuple[str, float]] (parser.py:156). Any is the honest narrow bound because an entry may be a dict or an attribute-style object; the return type is exact. mypy clean.
2 entry.get crashes on a non-dict entry 5486cc3 entries are hoisted through _to_dict before .get("top_logprobs") (parser.py:157), so a non-dict degrades to "no candidates" instead of an AttributeError.
3 Digits split across punctuation-carrying tokens raise on a perfectly parseable response 5486cc3 linear_probs_sum == 0.0 now returns _extract_score_from_text_content(choice_dict, name=name), the same degradation the short-stream and no-logprob branches use.
4 Redundant local SimpleNamespace re-import 5486cc3 dropped; the module-level import covers both builders.
5 entries fixtures repeated four times 5486cc3 lifted to _SPLIT_SCORE_ENTRIES / _FOLDED_WHITESPACE_ENTRIES, with one _PROVIDER_CASES table driving a parametrized sync test and its async twin -- same four cases and exact expectations, roughly half the lines.
6 nit: the DEBUG line put the judge's full response text into a log 5486cc3 logs only the reconstructed length (parser.py:73-80), matching the content-free convention the other judge parsers keep.
7 (Baz) log the reason for the zero-probability-mass fallback 3b8cfe9 DEBUG with the metric name, same level as the sibling locator-failure log; the regression test asserts the message as well as the value.

Verified locally at 3b8cfe9: pytest tests/unit/evaluation/metrics/llm_judges/g_eval -q -> 11 passed; ruff check and ruff format --check clean; mypy clean on parser.py. Only the labeler and Baz checks run on push for this repo, so if the SDK suite needs a maintainer-triggered label, tell me which one and I will note it here.

Nothing is open on my side. The six inline replies carry the before/after detail if any entry above reads differently than you intended.

@petrotiurin petrotiurin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for your hard work on this and for addressing the comments @feiiiiii5 ! One final concern that needs to be addressed and I think we can go ahead and merge this PR.

`matches[-1]` mirrors json.loads for a duplicate key at the top level, but not
for a nested one. On `{"score":7, "reason": "ok", "breakdown": {"score":3}}` the
locator landed on the nested 3 and weighted the average from its logprobs, while
json.loads and the text path both read the top-level 7. Measured on that
response: main 0.6219, this branch before the change 0.2740, text path 0.7.

Prefer the last match that agrees with the parsed value, and keep the positional
choice when the reconstruction is not parseable (several token streams in the
suite end mid-object, so that branch is the common path there).
Comparing against the parsed value identifies the value, not the position.
On {"score":7, "reason": "ok", "breakdown": {"score":7}} both digits agree,
so the reversed scan still returned the nested one and the weighted average
was built from the breakdown token's logprobs. Measured on that response:
main 0.6219349153822014, 0e48cce 0.7809998433984686, text path 0.7.

Restrict agreeing matches to the shallowest brace depth first, then take the
last of those, which keeps duplicate top-level keys resolving to the final
one. Differentials over 32 reconstructed texts show selection changes only
where a nested score restates the top-level value; every other shape picks
the same span as before.
The value-agreement path replaces the positional default whenever it finds
agreeing matches, so a matches[0] locator no longer changes any outcome that
has a parsed value to agree with. Last-wins is then only reachable through an
unparseable reconstruction, and the existing test for that path has a single
score key, so nothing pinned it. Two keys in a truncated stream do.

This one passes on the previous head too: it closes a hole in coverage rather
than demonstrating a fix.
@feiiiiii5

Copy link
Copy Markdown
Contributor Author

@petrotiurin your concern is closed on 0e48cce, and a follow-up pass over the same locator found the residue of it, now fixed in ae048eadf.

The check you suggested compares the located digits against the parsed value, which rejects a nested key whose value differs. It cannot reject one whose value is the same: {"score":7, "reason": "ok", "breakdown": {"score":7}} has two agreeing matches and the reversed scan still lands on the nested one, so the average was weighted from the breakdown token's logprobs. Measured on that response: main 0.6219349153822014, 0e48cce 0.7809998433984686, after the change 0.6219349153822014, text path 0.7.

The rule now restricts agreeing matches to the shallowest brace depth before taking the last of them, so duplicate top-level keys still resolve to the final one. Over 32 reconstructed texts the selected span changes only where a nested score restates the top-level value; every other shape selects the same span as before. Suite is 15 passed, [email protected] and [email protected] clean, mutation table in the body.

Two things that came out of measuring it, both in the body: the matches[0] mutation row from my previous round was stale (re-run at 0e48cce it kills nothing), and the escaped-key and exponent cases Baz raised are unchanged by this PR, with main numbers alongside.

The depth rule counted brace characters in the text preceding each match, so braces inside a string value read as structure. A reason carrying three closing braces left the nested key shallower than the top-level one and the weighted score was built from the wrong token: 0.7809998433984686 where main and the text path both give 0.6219349153822014. The same rule copied a growing prefix per match, which is quadratic in the response length: 58.1 ms at 64KB against 5.5 ms for the walk.

Walk the first object once with a pattern that matches a whole JSON string or a single brace, reading the key only at depth 1 and stopping when that object closes. Strings are consumed whole, so neither a brace nor a score echo inside a reason can move the structure, and last assignment wins so duplicate top-level keys still resolve as json.loads does. Nothing is validated, so a truncated reconstruction behaves as before, which lets the json.loads agreement check and its bare except go away.

Design and reference implementation from petrotiurin on the PR thread.

@petrotiurin petrotiurin left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thank you for fixing this and for addressing all the comments! Really appreciate the responsiveness here!

@petrotiurin
petrotiurin merged commit 2c04af3 into comet-ml:main Sep 17, 2026
49 of 52 checks passed
kimnamu added a commit to kimnamu/opik that referenced this pull request Sep 24, 2026
Addresses Baz's comment on 39270cd: a tool input that failed json.loads
fell back to the streamed string without a log. It is now logged at
DEBUG with the content block index and the exception text, as the
Claude, Nova, Llama and Mistral aggregators and invoke_model
stream_wrappers do, and without the input itself: tool input can carry
user data, and users turn DEBUG on (petrotiurin on comet-ml#8152).

A new unit test checks that the log names the block and leaves the
input out; it fails on 39270cd. Live, claude-sonnet-4-6 with maxTokens
40 stops at max_tokens after streaming '{"city": "Paris"'; the span
keeps that string and the log reads "Could not parse tool input of
content block 0: Expecting ',' delimiter: line 1 column 17 (char 16)".

Co-Authored-By: Claude <[email protected]>
aadereiko pushed a commit that referenced this pull request Sep 25, 2026
…ock converse_stream traces (#8527)

* [issue-8526] [SDK] fix: keep each streamed tool call complete in Bedrock converse_stream traces

converse_stream sends a tool call's input as JSON string fragments, one
per contentBlockDelta. The aggregator merged each delta with
dict.update(), so every fragment replaced the previous one and the span
kept only the last piece (for example '"}'). toolUseId and name, which
arrive in contentBlockStart, were never read, and parallel tool calls
all landed in the same entry.

Rebuild each tool call from its contentBlockStart and deltas, keyed by
contentBlockIndex, the way the OpenAI and Mistral aggregators key
tool-call fragments by index. The first tool call stays in content[0];
each further one gets its own content entry. Text streaming is
unchanged.

Co-Authored-By: Claude <[email protected]>

* fix(bedrock): give converse_stream spans the converse content layout

Addresses the three points in aadereiko's review:

- toolUse.input was the joined JSON string in converse_stream spans and a
  parsed document in converse spans. The joined input now goes through
  json.loads. An empty input (Claude streams a no-argument call as "")
  becomes {}, as in converse, and input that does not parse (e.g. cut off
  by maxTokens) is kept as the streamed string.
- Content entries followed arrival order. Blocks are now collected by
  contentBlockIndex and written out sorted, one entry per block, so the
  first tool call no longer shares content[0] with the empty text
  placeholder. This is the layout Baz suggested in the first review.
- A missing contentBlockIndex defaulted to 0, which merged two calls.
  botocore marks the index as required on start, delta and stop events;
  without it, a start now opens the next block and a delta goes to the
  latest one.

The block ordering runs after the per-event try/except, and the stream
wrapper calls the aggregator in `finally`, so the index goes through int()
while each event is handled: a malformed index drops that event instead
of raising from the user's stream loop.

Tests: the unit tests are one parametrized test with 8 cases; 7 fail on
the previous head and on main, and the text-only case is unchanged.
Live in us-east-1, the converse_stream span has the same layout as the
converse span for the same request (one call, two calls, a no-argument
call) on GPT-6 Sol, Luna and Astra, claude-sonnet-4-6 and Nova Pro, and
tests/library_integration/bedrock/test_converse.py passes (13).

Co-Authored-By: Claude <[email protected]>

* fix(bedrock): log converse_stream tool input that does not parse

Addresses Baz's comment on 39270cd: a tool input that failed json.loads
fell back to the streamed string without a log. It is now logged at
DEBUG with the content block index and the exception text, as the
Claude, Nova, Llama and Mistral aggregators and invoke_model
stream_wrappers do, and without the input itself: tool input can carry
user data, and users turn DEBUG on (petrotiurin on #8152).

A new unit test checks that the log names the block and leaves the
input out; it fails on 39270cd. Live, claude-sonnet-4-6 with maxTokens
40 stops at max_tokens after streaming '{"city": "Paris"'; the span
keeps that string and the log reads "Could not parse tool input of
content block 0: Expecting ',' delimiter: line 1 column 17 (char 16)".

Co-Authored-By: Claude <[email protected]>

* test(bedrock): check converse_stream tool calls in the logged trace and span

Addresses Baz's two comments on f3e4de0:

- Nothing checked what track_bedrock logs for a streamed tool call: the
  unit tests call aggregate_converse_stream_chunks directly, and the live
  tool-use test in test_converse.py asserts output=ANY_DICT because live
  ids and inputs vary. A new unit test streams two tool calls (block 1
  first) from a stand-in client through track_bedrock and asserts the
  exact output of the trace and of the span. It needs no credentials, so
  the unit test workflow runs it on pull requests. It fails on main's
  aggregator and on four changes the aggregator tests still pass:
  converse_decorator logging no response key as output, wrap_stream not
  collecting events, converse_stream wired to the invoke_agent
  aggregator, and the trace not ended with the stream output.
- The cut-off input log test now also asserts that the streamed string is
  kept. The cut-off-input case of the parametrized test already checked
  that value; now the log test checks it too.

Co-Authored-By: Claude <[email protected]>

---------

Co-authored-by: Claude <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Python SDK python Pull requests that update Python code 🔴 size/XL tests Including test files, or tests related like configuration.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants