Skip to content

Fix ChatReqLLM streaming tool-call merge for OpenAI-style chunks#551

Merged
brainlid merged 1 commit into
mainfrom
me-req-llm-openai-tool-call-fix
May 15, 2026
Merged

Fix ChatReqLLM streaming tool-call merge for OpenAI-style chunks#551
brainlid merged 1 commit into
mainfrom
me-req-llm-openai-tool-call-fix

Conversation

@brainlid
Copy link
Copy Markdown
Owner

Problem

Streaming tool calls through ChatReqLLM against OpenAI-compatible backends (direct OpenAI, LiteLLM proxy, and similar) fail at finalize with:

delta_conversion_failed: "Error applying delta message: \"tool_calls: call_id: can't be blank; name: can't be blank\""

The error surfaces only when the model invokes a tool while streaming. Non-streaming calls and Anthropic streaming were unaffected.

The root cause is an index mismatch during MessageDelta merging. ReqLLM's default OpenAI decoder emits two kinds of chunks for a single tool call:

  1. An initial %StreamChunk{type: :tool_call, name: ..., arguments: %{}, metadata: %{id: ..., index: ...}} chunk.
  2. A stream of %StreamChunk{type: :meta, metadata: %{tool_call_args: %{index: ..., fragment: "..."}}} argument-fragment chunks.

ChatReqLLM.translate_stream_chunk/1 was reading id from the initial chunk's metadata but dropping index, and was emitting the call as :complete with arguments: %{}. Fragment chunks then arrived with index: 0 and no call_id/name. Because MessageDelta.merge_tool_calls/2 keys lookups by index, the fragments did not merge into the initial tool call — they landed in a separate slot. At finalize, Message.new ran ToolCall.complete/1 on every entry, and the orphan fragment ToolCall failed validate_required([:call_id, :name]).

Solution

Added a new process_stream_chunk/2 clause that handles OpenAI-shaped initial :tool_call chunks (those whose metadata carries :index). The clause:

  • Carries :index through onto the ToolCall, so subsequent fragment chunks merge into the same slot.
  • When arguments is an empty map (placeholder; fragments will follow), emits the ToolCall as :incomplete with arguments: nil. ToolCall.append_arguments/2 then string-concatenates the incoming JSON fragments, and ToolCall.complete/1 JSON-decodes the accumulated string at finalize.
  • When arguments is already a non-empty map (single-shot delivery — no fragments will follow), emits the ToolCall as :complete with the args inline.

The existing Anthropic %{start: true} clause still matches first, so Anthropic streaming is unchanged. Chunks without :index in metadata still fall through to the original translate_stream_chunk/1, preserving the previous single-shot behaviour for providers that deliver the whole call in one chunk.

Changes

  • lib/chat_models/chat_req_llm.ex — Added OpenAI-style :tool_call chunk clause to process_stream_chunk/2; preserves :index from metadata and chooses :incomplete/:complete based on whether args fragments are expected to follow.
  • test/chat_models/chat_req_llm_test.exs — Two regression tests:
    • Fragmented case: initial chunk with empty args + index + id, followed by two tool_call_args fragments, then a terminal :meta. Asserts the assembled Message has a single ToolCall with the correct call_id, name, and JSON-decoded arguments.
    • Single-shot case: initial chunk already carrying non-empty args. Asserts the assembled ToolCall is :complete with the inline args.

Testing

  • mix test test/chat_models/chat_req_llm_test.exs — 85 tests, 0 failures (7 live-tagged tests excluded).
  • mix test — full suite passes: 31 doctests, 1767 tests, 0 failures.
  • No live API tests were added for this path because the failure mode requires an OpenAI-compatible streaming backend (and was originally observed through a LiteLLM proxy that the test harness is not wired up to talk to). The regression tests exercise the exact StreamChunk sequence ReqLLM emits, which is what ChatReqLLM actually consumes.

@brainlid brainlid merged commit 1a70ab9 into main May 15, 2026
2 checks passed
@brainlid brainlid deleted the me-req-llm-openai-tool-call-fix branch May 15, 2026 03:05
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