Repository navigation
fix(llm): replay tool_call extra_content and surface provider error bodies - #1393
lizhengfeng101 merged 5 commits into
Conversation
|
🔍 OpenCodeReview found 1 issue(s) in this PR.
|
7981144 to
58c4a64
Compare
…odies Gemini 3 on OpenAI-compatible endpoints requires the thought signature in tool_calls[].extra_content to be echoed back unchanged, and array-wrapped error documents were dropped before reaching the user. Fixes alibaba#1357 Fixes alibaba#1042
58c4a64 to
0fca776
Compare
|
Hi @chaojixinren , @wu21-web , @austinuni60-netizen , @Qiyuanqiii , @basil-k-aji-dev , can you kindly review this PR. |
|
No big deal. I’m planning to go through all open PRs starting this afternoon |
|
Please do not tag individual users for reviews. Somebody will review it for you. I will review for you soon, but please use ocr first. |
|
@Moon-Wrecker Before reviewing, I suggest splitting changes up into different PRs that are small and focused. Big chunks make it hard to review. Be aware that this closes 3 issues on merge, I don't really recommend that .. |
Qiyuanqiii
left a comment
There was a problem hiding this comment.
One scope question: how do you see #1393 relative to #1233?
#1393 fixes the concrete extra_content / thought-signature failure, while #1233 generalizes the same area to arbitrary opaque tool-call fields. Do you intend #1393 to supersede #1233 for #947, or do you still see #1233 as a useful follow-up for the generic case?
Just trying to understand the intended ownership between the two approaches.
Qiyuanqiii
left a comment
There was a problem hiding this comment.
I think there are really two separate questions here.
#1233 and #1393 substantially overlap on the concrete tool-call metadata replay problem, but they are not equivalent implementations. #1233 takes the broader generic ExtraFields approach, while #1393 deliberately narrows replay to extra_content and additionally addresses provider-error diagnostics and session persistence.
Because that is a genuine architectural choice rather than simply one implementation being a strict superset of the other, I don't think we as reviewers should unilaterally decide which approach should replace the other. That seems better left to the maintainers to resolve.
Separately, whichever technical direction is chosen, I think the contribution lineage should remain clear given that #1233 predates this PR and has continued to receive active implementation and independent verification.
So from my side, I'd prefer to hear both how you see #1393 relative to #1233 and what direction the maintainers want for the underlying abstraction before treating either PR as superseding the other.
|
Thanks @Moon-Wrecker for acknowledging the prior work and design from #1371 in the credit section. I agree with @wu21-web and @Qiyuanqiii regarding scope, PR size, and contribution lineage. Bundling three distinct issues (#1357, #1042, #947) along with session persistence refactors into a 700+ line PR creates a large blast radius and complicates review. For the concrete bug in #1357 and #1042, PR #1371 is already open, tested, and strictly focused on solving the Landing the focused fix in #1371 resolves the immediate blocker for Gemini users cleanly, while the broader architectural discussions around #947 and #1233 can proceed on their own timeline. |
Qiyuanqiii
left a comment
There was a problem hiding this comment.
One principle I think is useful here is to prefer the smallest complete fix that can be explained, validated, and attributed independently, while leaving broader architectural changes their own discussion space.
With #1233, #1371, and #1393 now overlapping in different ways, I think it would be healthier to make that boundary explicit and let the maintainers decide how the larger architectural pieces should converge.
|
100% agree with this principle, @Qiyuanqiii. “Prefer the smallest complete fix that can be explained, validated, and attributed independently” makes a lot of sense here. Just to be completely transparent about the lineage here: Right after I opened #1371 to address #1357, @Moon-Wrecker asked on that issue whether my fix also covered #1042. Shortly after that, #1393 was opened, lifting the exact implementation details from #1371, including the negative delta index clamping, the provider neutral error body enrichment for array wrapped Gemini payloads, and the 64KB log bounding, and combining them with @basil-k-aji-dev’s work from #1233 into one mega PR attempting to close all 3 issues at once. Subsuming active PRs from other contributors into a 700+ line PR without collaboration isn’t really great open source etiquette, and as @wu21-web also pointed out, it makes the review process much harder. I think the cleanest way forward, while keeping attribution clear and the changes easy to review, would be:
Both PRs are already tested and independently scoped, and this avoids the unnecessary blast radius of #1393 while keeping each fix focused and properly attributable. |
|
@Moon-Wrecker As a maintainer, I still recommend keeping these changes within a single PR; since they significantly impact the overall review, consolidating them makes it easier for me to conduct an end-to-end evaluation. From this perspective, I wonder if anyone holds a different view. cc @wu21-web @Qiyuanqiii @AyushDubey23 |
|
Thanks for looping me in, @lizhengfeng101. I get the end to end evaluation point. #1357 and #1042 are closely connected when testing live Gemini/Vertex endpoints, since the enriched error body makes tool call failures much easier to debug. That’s why I paired both fixes in #1371. I think we can address both concerns pretty cleanly:
Happy to go with whichever direction the maintainers prefer. |
I strongly agree with recognizing the high-quality contributions of existing developers through Git attribution. |
Thanks, that clarifies the PR-organization question for me. I'm fine with treating #1393 as the consolidated integration PR if that makes the end-to-end review easier. |
This comment has been minimized.
This comment has been minimized.
|
@Qiyuanqiii — answering your question directly, since it's about #1233's abstraction. On scope and which PR lands, I have no stake. @lizhengfeng101 and @wu21-web should pick whatever makes the end-to-end review easiest. If that's #1393, I'll close #1233 — I'd rather the Gemini bug were fixed this week than that mine is the PR that fixes it. @Moon-Wrecker already owned the process point and the credit section is more generous than I'd have asked for, so no complaint from me there. On the abstraction, I do think the generic form is the right default, and there's evidence in this thread rather than just a preference.
The concrete case is already in @subaru-ye's negative-index payload above. It carries two opaque fields: {"index":-1,"id":"call_1","type":"function",
"function":{"name":"file_read","arguments":"{}"},
"extra_content":{"google":{"thought_signature":"sig"}},
"provider_flag":true}A named-key capture replays The safety property people usually worry about with generic capture is a provider rewriting a tool call's identity. That is handled independently of the key name: On persistence I think #1393 is right and #1233 is wrong, and I would drop my behaviour. I made it So the combination you floated is the one I would argue for: generic replay abstraction, persisted. The vendor's field name stays out of the type, and the session export stays reconstructable. Whatever is decided, I am happy to rebase #1233 onto it or to have the pieces lifted — whichever is less work for the reviewers. One practical note on the credit section, since a mistyped trailer credits nobody: mine is |
|
agree with @basil-k-aji-dev's points on generic replay and persistence. it feels like a solid and future proof approach. following basil's lead, my trailer is:
thanks everyone for collaborating on this! |
|
@Qiyuanqiii, answering your question from yesterday about which behaviour the project should standardise on. Having worked through it, I agree with @basil-k-aji-dev: capture every unmodelled tool-call field, and persist it. That changes my position on capture. What changed my mind was testing the risk I'd have raised against generic capture, on the provider this is actually about. I took one real tool call from Vertex (
Vertex returns only This also showed that this PR was overclaiming. #947 asks for all unknown fields to be preserved, and this PR keeps only Which PR carries it is @lizhengfeng101's call. I see two options:
I'd prefer the first. The approach is basil's, #1233 was written against #947, and Qiyuanqiii has already found it technically sound. But either works for me, and I won't touch the code until you've picked. @Qiyuanqiii, your token-accounting point applies to both designs, so I'll deal with it once the route is settled. On credit: I have addresses from @basil-k-aji-dev, @AyushDubey23 and @subaru-ye. @xiaodu55 and @fkdosilovic, would you like co-author credit as well? If so, please post the address you want used; a trailer with an address GitHub doesn't recognise doesn't credit anyone. CI on |
|
Co-author lines for the squash commit, collected in one place so they can be pasted in at merge time: These go at the very end of the squash message, after a blank line. Each is the address its owner posted in this discussion, and I've checked that each one links to the right GitHub account, so none of them will show up as plain text. lizhengfeng101's own line is added by GitHub automatically, since he authored |
|
@Moon-Wrecker thank you for testing it rather than taking my argument on faith — the five-way replay table is more than I had, and the result that Vertex accepts echoed unknown fields is the part I could not check without an endpoint. Two corrections to your option-1 list, both in your favour. Deep-copying is already in #1233, as of 058d0cb this morning. @Qiyuanqiii raised it there: Persistence I have not changed, and I would rather you carried it than that I bolted it on. Your reasoning in the PR body — #1070 landing Option 1 works for me, and so does option 2. I have no stake in which PR carries it: @lizhengfeng101 and @Qiyuanqiii should pick whichever is easier to review end to end, and if that is option 2 then bring the capture across and I will close #1233 without ceremony. On credit, your three trailers are right and the address for me is the one you have. Worth repeating your own point for @xiaodu55 and @fkdosilovic: the address has to be one GitHub already knows, or the line renders as plain text and credits nobody — I have five commits in another repository that prove it, carrying a trailer whose address resolves to no account at all. |
|
@basil-k-aji-dev thanks, and for both corrections. I'd missed that the deep-copy landed in #1233 this morning. Given what you've said, I'd like to switch my suggestion to option 2. You're fine with either and would close #1233 if the capture comes across, and @lizhengfeng101 asked earlier to keep this in one PR so it can be reviewed end to end. So the simplest path seems to be: I bring #1233's current commits into this PR by cherry-picking them, so your authorship and subaru-ye's trailer come across unchanged, including @lizhengfeng101, if that works for you I'll go ahead. I'm holding the branch at |
|
@NanaseInori review |
wu21-web
left a comment
There was a problem hiding this comment.
My co-author line is
Co-authored-by: Tao Xin <[email protected]>
if you need it.
|
6 |
A character split by the 64 KiB display cap became a replacement character, which the source also carried as a literal. Co-authored-by: Tao Xin <[email protected]>
wu21-web
left a comment
There was a problem hiding this comment.
After make test passes, I think we are ready to merge.
|
@NanaseInori review |
NanaseInori
left a comment
There was a problem hiding this comment.
Automated review summary for current HEAD 05a7a2d:
Correctness: No blocking issues found.
Tool-call replay: Non-streaming and streaming extra_content paths are covered, including later chunks, negative indices, and multiple tool calls.
Persistence: Replay metadata is preserved in both request and response session records, with deep-copy protection in the session path.
Provider errors: Array-wrapped error bodies are surfaced with bounded output, response-body restoration, idempotence, and control-character filtering.
UTF-8 handling: The latest change correctly drops invalid/incomplete UTF-8 at the diagnostic size boundary and includes regression coverage.
Compatibility: Metadata-free tool calls preserve the previous serialized representation.
CI: Required checks are passing.
Non-blocking observations:
Replayed tool-call metadata is still not included in local token estimation.
The current implementation handles the concrete extra_content case; the broader generic requirement remains tracked by #947, so Refs #947 is appropriate.
Review result: APPROVE.
No remaining implementation issue identified that should block merge.
|
Awaiting for CI results. |
|
Two housekeeping notes now that this is heading for merge, neither of which needs a reply. #1233 will close as soon as this lands. It still carries approvals from @Qiyuanqiii and @NanaseInori, so I would rather it not sit there looking like a competing option once this is in. I am not asking for it to be reconsidered — my position from earlier stands: whichever PR is easier to review end to end should land, and @lizhengfeng101 asked for this to be one PR. Say the word after merge and I will close it, or close it yourselves, whichever is less work. The co-author list needs pasting at squash time. @Moon-Wrecker collected it earlier in this thread and noted it has to go into the squash message by hand. Flagging it only because GitHub's squash box does not carry trailers over from branch commits, and it is an easy thing to lose at the last step. The branch currently has @wu21-web's line on a commit; the others are still only in that comment: @lizhengfeng101's own line is added automatically since he authored Worth it for @subaru-ye in particular — they ran the Windows race-detector verification and the SSE reproduction that found the original streaming gap, across several revisions, without having a PR of their own here. |
|
On CI: the runs on this commit are still sitting at "action_required". Workflow runs from a fork need a maintainer to approve them before they start, so there are no results to wait for yet. @lizhengfeng101 I am also withdrawing my earlier suggestion to pull #1233's generic capture into this PR. With the approvals on this scope, and @Qiyuanqiii confirming that |
|
All review conversations on this PR are resolved now, so nothing is open on my side. Where 05a7a2d stands: three approvals (wu21-web, Qiyuanqiii, NanaseInori), no unresolved threads, CLA signed, and |
|
Please be patient and wait a while,the maintainer is busy with other commitments today. |
|
Hey @Moon-Wrecker, thanks again for getting this merged and for acknowledging the contributions throughout the discussion. I noticed that the final merged commit doesn't appear to include the Co-authored-by: Ayush Dubey [email protected] trailer that we had agreed on earlier. Since my contributions to the streaming capture and error-body fix were included in the final implementation, could you please add the co-author attribution if it's still possible? Thanks again for making sure the contribution lineage stays properly attributed! |
Description
Two reported bugs, one provider behaviour.
Gemini 3 on an OpenAI-compatible endpoint attaches a thought signature to each tool call as
tool_calls[].extra_contentand rejects the next request unless it is echoed back unchanged.llm.ToolCallmodelled onlyid/type/function, somapOpenAIResponsedropped it andbuildOpenAIParamscould not replay it — every request after the first tool call failed with400 Bad Request(#1357).The same provider array-wraps its error documents as
[{"error": {...}}]. openai-go extracts the payload with the gjson path"error"(internal/requestconfig/requestconfig.go:656), which does not match at an array root, soRawJSON()came back empty and the user saw a bare400 Bad Requestwith nothing after it (#1042). That is why #1357 was so hard to diagnose: the provider did explain the failure, and we threw the explanation away.They are fixed together because the second bug is what hides the first.
Where the metadata is kept
ToolCallgainsExtraContent json.RawMessagetaggedjson:"-", andinternal/sessionpersists it deliberately in both projections.That combination is deliberate and follows the rule the codebase already states for
Message.Nativeatinternal/llm/client.go:105-110:A bare
json:"-"keeps the wire format honest but loses the value from the session record. A plain struct tag persists it, but by the incidental marshaling that comment rejects. Doing both is whatNativedoes, soextra_contentdoes the same.This also closes a gap in the response record.
internal/session/history_test.go:281(TestSetResponse_PersistsNativePayload) guards a "training-reflow gap … otherwise a session exported for training cannot reconstruct a request that replays byte for byte", but thellm_responsewriter emitted onlyid/name/arguments, so the metadata that produced the turn was dropped.toolCallsForJSONand theSetResponseprojection now carry it in bothllm_requestandllm_response. To be precise about scope: resume does not need this —ResumeStateis a file-fingerprint checkpoint index — it is session export and diagnostics that do, exactly as fornative_payload.What changed
mapOpenAIResponsereadsextra_contentfrom each tool call's raw JSON. The SDK's tool-call union exposes noExtraFieldsmap, so the raw payload is the only place it survives.choice.Delta.ToolCalls[i].JSON.ExtraFields, keyed by choice and clamped tool index. It has to be taken from the deltas becauseChatCompletionAccumulatorsynthesises its result field by field and never sets raw JSON. It also has to be captured before thereasoning_contentlookup's earlycontinue— Gemini sends tool calls withoutreasoning_content, so a capture placed after it would never run. There is a test that fails if that ordering is reversed.buildOpenAIParamsreplays it viaSetExtraFieldson the per-tool-call param, the mechanismreasoning_contentalready uses (Thinking state is parsed but dropped when assistant tool-call turns are replayed #805).withProviderErrorBodyappends the provider body when the SDK left the error without one. The trigger is "an*openai.ErrorwhoseRawJSON()is empty", not "is this Gemini", so no provider-specific transport behaviour is introduced. The response body is restored after reading so later consumers still see it whole, only the diagnostic text is bounded (64 KiB), and control bytes are stripped so a body cannot rewrite the terminal. Applied to the chat-completions, streaming and Responses API exits.copyMessagesdeep-copiesExtraContent. Its contract is a deep copy; everyToolCallfield used to be a string, so an element copy was enough. Adding a byte slice broke that quietly, and the new test catches it.Providers that never send
extra_contentproduce a byte-identical request to before.Lineage
This is the same defect #1070 fixed for reasoning and thinking state, showing up one level lower. That change gave assistant turns an opaque
Nativereplay payload afterreasoning_content(#805) and Anthropic thinking signatures (#811) were found to be dropped when a tool-call turn was replayed.extra_contentsits on the tool call itself rather than the message, so it falls outside what the SDK's own response-to-request converters carry, and survived that pass.It was first reported as #947 by @vincekent in August, with the root cause already correct and
SetExtraFieldsalready named as the mechanism.One deliberate divergence from #947, which asked that opaque fields stay out of session logs: this PR persists
extra_contentin both JSONL projections. #947 was filed on 2026-08-16 and #1070 merged on 2026-08-28, introducingnativeTurnForJSONandTestSetResponse_PersistsNativePayload— which require the analogous Anthropic thinking signature to be written to thellm_responserecord so a session exported for training can reconstruct a byte-identical request. The request was reasonable when written; the project's position on opaque replay state moved after it. The wire format is unchanged either way, andToolCall.ExtraContentstaysjson:"-"so nothing is written incidentally.A second divergence: #947 asks for every unknown tool-call field to be preserved, and this PR keeps only the named
extra_contentkey. So it references #947 rather than closing it, pending the capture discussion below.Credit
There is good prior work on these two issues from @basil-k-aji-dev, @xiaodu55, @AyushDubey23 and @fkdosilovic, and this PR builds on it rather than around it. The
json:"-"choice and the diagnosis that the streaming accumulator drops raw JSON, the namedextra_contentkey and the negative-index clamping, the recognition that #1357 and #1042 are one problem, and the provider-neutral shape of the error enrichment (including trimmingRawJSON()before deciding to enrich) all come from their PRs. @subaru-ye's streaming reproduction and @pdrivom's AI Studio verification were directly useful too. Thanks to all of them.How Has This Been Tested?
make test— Passedgo testpasses oninternal/llm,internal/session,internal/llmloop,internal/agent,internal/scan,internal/mcp,internal/viewerandcmd/opencodereview, andgo test -racepasses oninternal/llmandinternal/session. I have not run the full suite to completion on this machine;TestRangeDiffDetectsRenameininternal/difffails here for me both with and without this change, because of a stray directory in my working tree, so I have left it out rather than report a pass I did not see.Live, against Vertex AI (
aiplatform.googleapis.com/.../endpoints/openapi/chat/completions,google/gemini-3.8-flash), same repository and diff both times — a small Python file with a planted SQL injection and aZeroDivisionError:400 Bad Requestwith an empty bodyall 1 file review(s) failedthought_signaturefailuresVerification note: previous reports on this bug were against AI Studio (
generativelanguage.googleapis.com). This is the Vertex surface.The error change also proved itself unintentionally in that run. The endpoint rate-limited me partway through, and instead of a bare
429 Too Many Requeststhe reason came through:That is the array-wrapped shape from #1042, surfaced from a real response rather than a fixture.
New tests, all deterministic and offline: non-streaming replay; streaming with the metadata on the opening chunk and on a later chunk; a streamed tool call carrying no
reasoning_content(the ordering trap); negative delta index clamping; interleaved tool calls keeping their own metadata; a later chunk not erasing an earlier value; absent field leaving the wire format unchanged; persistence inllm_requestandllm_response; the deep-copy contract; array-wrapped error surfacing on both the non-streaming and streaming exits; no duplication when the SDK already extracted a payload; the 64 KiB bound; the response body still readable afterwards; and terminal control bytes stripped.Type of Change
Checklist
go fmt,go vet)AI/LLM disclosure: I used Claude (Opus 5) to help refine this fix, with OpenAI Codex for an independent review pass. I reviewed and understand every line, ran the live Vertex verification myself, and will answer review comments myself.
One thing I deliberately did not change: surfacing a provider error body puts that body into terminal output and the session JSONL. That is the point of #1042, and it is not new behaviour —
apierror.Error()already formats the raw payload into every error for providers that use the standard{"error": ...}shape. This only brings array-wrapping providers to the same footing. Worth knowing when sharing session files.Related Issues
Fixes #1357
Fixes #1042
Refs #947