Skip to content

fix(protocol): derive the completion signal once for both the streamed and the whole-body translators - #205

Merged
argszero merged 1 commit into
mainfrom
fix/completion-signal-parity
Sep 13, 2026
Merged

argszero merged 1 commit into
mainfrom
fix/completion-signal-parity

Conversation

@argszero

Copy link
Copy Markdown
Owner

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 (in src/protocol.rs) and calls it from both paths, then deletes the duplicated inline matches so the two sides cannot drift apart again.

# pair upstream signal streamed (before) whole-body (before) both (after)
① anthropic → openai_chat stop_reason outside end_turn/max_tokens/tool_use (refusal, pause_turn, …) stop the raw anthropic token, passed through verbatim — a value outside OpenAI's closed finish_reason set stop
② responses → openai_chat response with a tool call stop, after the tool-call deltas were already streamed tool_calls tool_calls
③ openai_chat → anthropic tool_calls with a null/absent finish_reason stop_reason: null — or no message_delta at all when the upstream ended with [DONE] without ever sending a finish_reason, so the client never learned a stop reason tool_use tool_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:

// src/protocol.rs — the only place these signals are derived
pub fn anthropic_to_openai_chat_finish_reason(stop_reason: Option<&str>) -> &'static str
pub fn openai_chat_to_anthropic_stop_reason(finish_reason: Option<&str>, has_tool_calls: bool) -> Option<String>
pub fn openai_responses_to_openai_chat_finish_reason(has_tool_calls: bool) -> &'static str

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), its has_tool_use fallback, anthropic_to_openai_chat_resp's Some(other) => other passthrough arm (deleted), and openai_responses_to_openai_chat_resp.
  • src/sse.rs — the local map_stop_reason is deleted and its two callers use the shared helper; the three literal "finish_reason": "stop" terminals (anthropic_sse_to_openai's message_stop/fallback, responses_sse_to_openai_chat's completed/fallback) now report the derived signal; and in openai_sse_to_anthropic the [DONE] handler emits the terminal message_delta whenever message_start went out, deriving the stop reason when the upstream never supplied a finish_reason (previously that case emitted message_stop alone, leaving the client with no stop reason).
  • 涉及配置/数据结构的改动已同步示例文件 — N/A: no config, schema or data-structure change.

Behaviour changes, exhaustively: ① unknown anthropic stop_reason now converges to stop instead of leaking an out-of-enum value; ② a responses stream that emitted tool calls now ends with tool_calls; ③ an openai_chat upstream that carried tool calls with no finish_reason now yields tool_use, and one that ends with [DONE] without any finish_reason now gets a terminal message_delta instead of none. Everything else is byte-identical: the end_turn/stop_sequence/None → stop and length/max_tokens, tool_calls/function_call/tool_use mappings keep their previous values on both paths.

Tests

  • cargo test 全部通过 — 192 passed / 0 failed (was 190 on main; 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, and None), and pins the precedence rule that an explicit finish_reason is 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 (① every finish_reason × with/without a tool call, ② every anthropic stop_reason, ③ responses with/without a function call). It also requires that a [DONE]-terminated openai_chat stream actually emits a message_delta.
    • A/B (red-before, green-after): restoring any one arm makes exactly that face red while the rest stay green — (m1) the Some(other) => other passthrough → ①; (m2a/m2b) the literal "stop" in responses_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-parity
  • Commit message 使用 Conventional Commits 格式 — fix(protocol): derive the completion signal once for both the streamed and the whole-body translators
  • 单一职责,改动最小化 — one defect class (a completion signal derived twice), 2 files; the duplicated matches are deleted rather than aligned, and no unrelated refactor rides along.

…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.
@argszero
argszero merged commit 45a26bd into main Sep 13, 2026
1 check passed
@argszero
argszero deleted the fix/completion-signal-parity branch September 13, 2026 11:11
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant