fix: align custom tool SSE lifecycle with OpenAI API compatibility - #158
fix: align custom tool SSE lifecycle with OpenAI API compatibility#158maralbahari wants to merge 13 commits into
Conversation
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
franciscojavierarceo
left a comment
There was a problem hiding this comment.
left a few inline comments.
| fn uses_public_call_shape(registry: &ToolRegistry, name: &str) -> bool { | ||
| registry | ||
| .lookup(name) | ||
| .is_some_and(|entry| entry.tool_type == ToolType::Custom || entry.tool_type.is_gateway_owned()) |
There was a problem hiding this comment.
once a custom call appears, the rest of the upstream stream gets buffered until inference finishes. this turns the input delta into one full-input event, delays later message and reasoning events, and leaves us with an unbounded buffer. we should emit normalized custom deltas incrementally and bound any remaining deferred storage.
There was a problem hiding this comment.
@franciscojavierarceo Fixed by adding a function SSE translator that runs immediately after ResponseAccumulator updates the authoritative in-flight function call. Normalized custom-tool argument deltas are now converted and emitted incrementally as custom_tool_call_input.delta, without delaying later stream events. Only unnamed calls are temporarily buffered, with a 256 KiB limit. The re-recorded OpenAI and gateway cassettes both emit the same six-delta lifecycle, and the integration test now compares the exact delta sequence, lifecycle ordering, stable item ID, and completed output.
There was a problem hiding this comment.
@franciscojavierarceo
Function-call classification and wire conversion were moved out of executor/upstream.rs into a dedicated FunctionSseTranslator. After ResponseAccumulator updates the authoritative internal function call, the translator uses a request-scoped name → ToolType map to pass through normal functions, incrementally translate custom-tool events, and suppress gateway-owned MCP/web-search frames until execution emits their public lifecycle. This replaces the previous hide, buffer, flush, and function-call classification helpers scattered throughout executor/upstream.rs; only initially unnamed calls are buffered, with a 256 KiB limit.
these changes made the PR pushed out of the scope but it's cleaner and helps in up coming tool type support.
ashwing
left a comment
There was a problem hiding this comment.
Focused on the accumulator/normalizer seam since the client-contract and public-API surface is already well covered. Two things there — a coverage gap on the done-only path and the input-extraction heuristic.
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
Signed-off-by: maral <maralbahari.98@gmail.com>
|
@franciscojavierarceo @ashwing
|
| pending_bytes: usize, | ||
| } | ||
|
|
||
| impl FunctionSseTranslator { |
There was a problem hiding this comment.
Function-call SSE translation refactor
- Detecting normalized function calls that represent gateway-owned tools.
- Hiding their internal
function_callevents. - Buffering calls whose names were unavailable in
output_item.added. - Flushing ordinary function events once their ownership was known.
- Deferring events after a gateway-owned call to preserve output ordering.
- Synthesizing the public custom-tool lifecycle after inference completed.
This logic was spread across emit_upstream_stream_event, defer_after_gateway_call, record_first_hidden_gateway_output_index, defer_or_flush_function_event, should_hide_upstream_event, flush_pending_function_events, and drop_pending_function_events in executor/upstream.rs.
The refactor moves function-call classification and public wire translation into a dedicated FunctionSseTranslator. The translator receives only a request-scoped name → ToolType map, rather than the full ToolRegistry, and handles normalized function calls according to their declared public type:
- Ordinary functions and namespace members pass through unchanged.
- Custom tools are translated incrementally from
function_call_arguments.*intocustom_tool_call_input.*. - Gateway-owned MCP and web-search function frames are suppressed until their execution-specific public lifecycle is emitted.
- Calls without an initial name are buffered only until their type can be resolved, with a 256 KiB safety limit.
ResponseAccumulator still processes the original upstream frame first. This keeps the normalized FunctionToolCall as the authoritative internal representation used for dispatch and model continuation. The translator then reads that accumulated call state to produce client-facing frames, avoiding a second argument accumulator or changes to internal output items.
Summary
This PR fixes custom tool support across the gateway while preserving OpenAI’s public Responses API contract.
What was wrong
vLLM accepts a
type: "custom"declaration, but exposes the resulting call as a regularfunction_call. Its streaming response emits:response.function_call_arguments.deltaresponse.function_call_arguments.doneIt does not emit OpenAI-compatible custom-tool events:
response.custom_tool_call_input.deltaresponse.custom_tool_call_input.doneForwarding vLLM’s response directly therefore leaked the internal normalized function shape instead of returning a public
custom_tool_call.What changed
The gateway now:
inputparameter.custom_tool_call.response.output_item.addedresponse.custom_tool_call_input.deltaresponse.custom_tool_call_input.doneresponse.output_item.donectc_item ID throughout the lifecycle.Testing
Added OpenAI and gateway cassettes for streaming and non-streaming two-turn custom-tool flows. Tests verify:
function_callitems do not leak.custom_tool_call_outputworks correctly on the continuation turn.