Repository navigation
fix(protocol): derive the completion signal once for both the streamed and the whole-body translators - #205
Merged
Merged
Conversation
…d and the whole-body translators The streamed (sse.rs) and the whole-body (protocol.rs) translator of the same protocol pair each derived the completion signal on their own, and the two give different answers about how the upstream finished: 1. anthropic -> openai_chat: the whole-body translator passed an unknown stop_reason through verbatim (refusal / pause_turn are outside OpenAI's finish_reason set), while the streamed one collapsed it to "stop". 2. responses -> openai_chat: after streaming tool_calls deltas the streamed path still reported finish_reason="stop", the whole-body one "tool_calls" - an OpenAI client that dispatches tools on finish_reason never dispatches. 3. openai_chat -> anthropic: when the upstream returned tool_calls with a null finish_reason the streamed path emitted no message_delta at all (the client never learns a stop_reason) while the whole-body one produced "tool_use". Rather than aligning each copy, collapse each direction into a single function (anthropic_to_openai_chat_finish_reason / openai_chat_to_anthropic_stop_reason / openai_responses_to_openai_chat_finish_reason) and call it from both paths, then delete the three duplicated inline matches plus sse.rs's local map_stop_reason so the two sides cannot drift apart again. Tests: completion_signal_helpers_cover_every_documented_value pins each function over every documented value of its protocol plus unknown-value controls, and completion_signal_agrees_between_stream_and_whole_body drives the real stream translators and the real whole-body translators with the same upstream signal and requires the same terminal value. Reverting any one arm turns exactly that face red.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The streamed translator (
src/sse.rs) and the whole-body translator (src/protocol.rs) of the same protocol pair each derived the completion signal on their own, and the two give different answers about how the upstream finished — so "did it end normally / should I dispatch a tool / was it truncated" depends on whether the client streamed. This PR derives each completion signal once (insrc/protocol.rs) and calls it from both paths, then deletes the duplicated inline matches so the two sides cannot drift apart again.anthropic → openai_chatstop_reasonoutsideend_turn/max_tokens/tool_use(refusal,pause_turn, …)stopfinish_reasonsetstopresponses → openai_chatstop, after the tool-call deltas were already streamedtool_callstool_callsopenai_chat → anthropictool_callswith anull/absentfinish_reasonstop_reason: null— or nomessage_deltaat all when the upstream ended with[DONE]without ever sending afinish_reason, so the client never learned a stop reasontool_usetool_use① is dormant-but-reachable (it needs a single-protocol plan; neither the example config nor this deployment has one) — it is fixed and pinned anyway, because the passthrough arm's only possible inputs are anthropic values with no OpenAI equivalent. ② and ③ are live today: ② on the four
openai_chat-only plans, ③ for an anthropic-protocol client converted from an OpenAI-compatible endpoint.Both directions are single-source now, so this is not "one copy aligned with the other" — the second copy is gone:
Related Issue
None — the repository carries no open issues. This closes a self-driven work order: the divergence was found by pushing the same upstream completion signal through both translators and printing both columns, then implemented per the plan recorded in the previous cycle.
Changes
src/protocol.rs— the three helpers above are added next to the translators, and every whole-body call site goes through them:openai_chat_to_anthropic_resp(including its empty-response early return), itshas_tool_usefallback,anthropic_to_openai_chat_resp'sSome(other) => otherpassthrough arm (deleted), andopenai_responses_to_openai_chat_resp.src/sse.rs— the localmap_stop_reasonis deleted and its two callers use the shared helper; the three literal"finish_reason": "stop"terminals (anthropic_sse_to_openai'smessage_stop/fallback,responses_sse_to_openai_chat's completed/fallback) now report the derived signal; and inopenai_sse_to_anthropicthe[DONE]handler emits the terminalmessage_deltawhenevermessage_startwent out, deriving the stop reason when the upstream never supplied afinish_reason(previously that case emittedmessage_stopalone, leaving the client with no stop reason).Behaviour changes, exhaustively: ① unknown anthropic
stop_reasonnow converges tostopinstead of leaking an out-of-enum value; ② aresponsesstream that emitted tool calls now ends withtool_calls; ③ anopenai_chatupstream that carried tool calls with nofinish_reasonnow yieldstool_use, and one that ends with[DONE]without anyfinish_reasonnow gets a terminalmessage_deltainstead of none. Everything else is byte-identical: theend_turn/stop_sequence/None → stopandlength/max_tokens,tool_calls/function_call/tool_usemappings keep their previous values on both paths.Tests
cargo test全部通过 — 192 passed / 0 failed (was 190 onmain; the two new tests below).cargo fmt --check通过cargo clippy --all-targets -- -D warnings通过protocol::tests::completion_signal_helpers_cover_every_documented_value— pins each helper over every documented value of its protocol plus unknown-value controls (content_filter,not_a_reason, andNone), and pins the precedence rule that an explicitfinish_reasonis not overridden by tool-call content.sse::tests::completion_signal_agrees_between_stream_and_whole_body— the axis assertion: for each upstream signal, the real stream translator and the real whole-body translator are driven with the same input and must produce the same terminal value (① everyfinish_reason× with/without a tool call, ② every anthropicstop_reason, ③ responses with/without a function call). It also requires that a[DONE]-terminatedopenai_chatstream actually emits amessage_delta.Some(other) => otherpassthrough → ①; (m2a/m2b) the literal"stop"inresponses_sse_to_openai_chat's completed and fallback terminals → ②; (m3) the[DONE]path's original "only if a pending delta exists" condition → ③.Checklist
fix/completion-signal-parityfix(protocol): derive the completion signal once for both the streamed and the whole-body translators