Skip to content

fix(openai): preserve tool calls across the messages bridge - #9

Merged
VictorMinemu merged 3 commits into
VictorMinemu:mainfrom
unohee:fix/openai-tool-call-bridge
Aug 28, 2026
Merged

VictorMinemu merged 3 commits into
VictorMinemu:mainfrom
unohee:fix/openai-tool-call-bridge

Conversation

@unohee

@unohee unohee commented Aug 20, 2026 •

Copy link
Copy Markdown
Contributor

What this PR does

Preserves function tool calls across the Claude Code Messages ↔ OpenAI Responses bridge. Calls and tool outputs are sent as top-level Responses items, assistant text uses output_text, and JSON or SSE function-call responses return as Anthropic tool_use blocks with the original call_id and block order.

Type of change

  • Bug fix
  • New feature
  • Refactor
  • Documentation

Testing

  • npm run build passes
  • npm test passes (30 files, 204 tests)
  • Tested manually on: macOS 26.5 with an OpenAI subscription route

Related issues

Closes #8

unohee added 2 commits August 20, 2026 22:27
Function calls and their outputs are top-level Responses input items, while assistant text uses output_text. The reverse bridge now emits Anthropic tool_use blocks for JSON and SSE responses and preserves call IDs across the tool-result turn.

@VictorMinemu VictorMinemu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thank you for the contribution, the clear reproduction, and the comprehensive happy-path tests. The overall direction is good: keeping function_call and function_call_output as top-level Responses items matches the API contract.

Before merging, please address these blocking safety and protocol issues:

  1. src/protocol/openai-response-to-anthropic.ts:32-45 treats every non-message output item as a function_call. Responses can also return reasoning and other item types. A valid reasoning item currently becomes an Anthropic tool_use with missing id and name, and changes stop_reason to tool_use.

  2. src/proxy/messages-cross-route.ts:106-178 can materialize a tool call after response.output_item.added even if the SSE fails, is truncated, or ends before output_item.done / response.completed. Combined with parseToolInput falling back to {}, a malformed or incomplete call can look executable and may invoke a tool with unsafe defaults. Please fail closed: require finalized items and a successful terminal response, and propagate failed/incomplete/premature EOF as an error.

  3. JSON refusal content is currently turned into a text block without text, while streaming refusal events are dropped. Please preserve or explicitly map refusals so the model safety signal is not lost.

Please also require non-empty call_id and name, do not substitute item.id for call_id, and validate that parsed arguments are an object rather than silently synthesizing {}.

Suggested regression coverage: reasoning before text/tool output; JSON and SSE refusals; missing IDs; malformed, scalar, or partial arguments; and failed/incomplete/prematurely closed SSE streams.

I verified the current head with npm test (30 files, 204 tests), npm run build, and npm run lint; all pass. Once the guards and negative-path tests are added, we can re-review promptly. Thanks again for improving CC-Router.

@VictorMinemu VictorMinemu left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks again for the original contribution. I added the fail-closed protocol guards and negative-path coverage discussed in the review, including reasoning items, refusals, invalid metadata and arguments, and incomplete SSE streams. The published head passes build, lint, and all 221 tests. Approved for merge.

@VictorMinemu
VictorMinemu merged commit 555980d into VictorMinemu:main Aug 28, 2026
@unohee
unohee deleted the fix/openai-tool-call-bridge branch August 29, 2026 05:18
unohee added a commit to unohee/CC-Router that referenced this pull request Oct 14, 2026
This takes upstream VictorMinemu/CC-Router d770488..555980d (v0.6.2 through
the tool-call bridge) as the point this fork diverges, WITHOUT taking its
tree. Nothing in the working tree changes here, so read this merge as an
acknowledgement, not an integration.

The reason it cannot be a content merge: upstream's 04e5dd1 and our 9194727
are the same work by the same author on the same day, diverged in form after
PR VictorMinemu#9 was reshaped in review. A three-way merge therefore conflicts across
ten files - 50 hunks - between two editions of one change, and resolving it
yields a tree that is neither side and that no reviewer can check.

Upstream's two genuine improvements on top of that shared base - 08c69cb
(atomic tool arguments) and the maintainer's 0c91943 (fail closed on invalid
tool streams) - are real and we want them. They are ported deliberately in
the commits that follow this one, with their tests, keeping original
authorship. The remaining upstream delta is the reshaping of our own commit
and is deliberately not taken.

From here upstream is read-only: a source to cherry-pick from, not a branch
to track.
unohee added a commit to unohee/CC-Router that referenced this pull request Oct 14, 2026
Ports the response-side half of upstream's 0c91943 (VictorMinemu) together
with its shared helper, and takes upstream's edition of the two files whose
only local commit was 9194727 — the same work as upstream's 04e5dd1, by the
same author, reshaped in review on PR VictorMinemu#9. Nothing of ours is lost there.

The behaviour this changes: unparseable tool-call arguments used to degrade
to `input: {}`. That is worse than failing. A response truncated mid-JSON by
max_output_tokens then reached the client as a well-formed tool call with
arguments the model never produced — a search with no query, a delete with no
filter. It now throws, and because our cross-route catch treats any throw
before the first byte as a failed attempt, the request degrades to Anthropic
and the caller still gets a real answer. Upstream answers 502 here; having a
second provider, we can do better.

A refusal is now carried as a refusal rather than reported as a completed
turn, and a function call naming no tool is refused outright.

One upstream simplification is deliberately not taken. Its
`messageContentToAnthropic` reads `block.text` off every content block, which
is sound for what we emit — function calls belong at the top level of `input`
— but this is an inbound endpoint and a client may still nest them. That read
yields undefined and fails the whole request with a TypeError, so the nested
form is decoded here instead, as it was before. The narrow emit-side type is
kept; the defence is at runtime, where the malformed input actually arrives.
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.

OpenAI bridge drops Claude Code tool calls

2 participants