Skip to content

Commit 594f226

Browse files
authored
fix: align custom tool SSE lifecycle with OpenAI API compatibility (vllm-project#158)
## 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 regular `function_call`. Its streaming response emits: - `response.function_call_arguments.delta` - `response.function_call_arguments.done` It does not emit OpenAI-compatible custom-tool events: - `response.custom_tool_call_input.delta` - `response.custom_tool_call_input.done` Forwarding vLLM’s response directly therefore leaked the internal normalized function shape instead of returning a public `custom_tool_call`. ## What changed The gateway now: - Normalizes custom tools into model-facing function tools with a required string `input` parameter. - Keeps custom tools client-owned; the gateway does not execute them. - Hides vLLM’s internal normalized function-call events. - Restores blocking output as `custom_tool_call`. - Emits the OpenAI-compatible streaming lifecycle: - `response.output_item.added` - `response.custom_tool_call_input.delta` - `response.custom_tool_call_input.done` - `response.output_item.done` - Uses one stable `ctc_` item ID throughout the lifecycle. - Converts custom call/output history into the normalized function representation only when sending continuation context upstream. ## Testing Added OpenAI and gateway cassettes for streaming and non-streaming two-turn custom-tool flows. Tests verify: - Gateway output matches OpenAI’s public custom-tool contract. - Raw custom input is preserved. - Internal `function_call` items do not leak. - Streaming lifecycle IDs and sequence numbers remain consistent. - `custom_tool_call_output` works correctly on the continuation turn. --------- Signed-off-by: maral <maralbahari.98@gmail.com>
1 parent 58908ce commit 594f226

32 files changed

Lines changed: 4359 additions & 505 deletions

‎crates/agentic-server-core/src/executor/accumulator.rs‎

Lines changed: 222 additions & 72 deletions
Large diffs are not rendered by default.

‎crates/agentic-server-core/src/executor/compaction.rs‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -86,9 +86,9 @@ fn item_has_meaningful_context(item: &InputItem) -> bool {
8686
}),
8787
},
8888
InputItem::FunctionCall(call) => !call.name.trim().is_empty() || !call.arguments.trim().is_empty(),
89-
InputItem::FunctionCallOutput(output) => !output.output.trim().is_empty(),
89+
InputItem::FunctionCallOutput(output) => output.output.has_content(),
9090
InputItem::CustomToolCall(call) => !call.name.trim().is_empty() || !call.input.trim().is_empty(),
91-
InputItem::CustomToolCallOutput(output) => value_has_content(&output.output),
91+
InputItem::CustomToolCallOutput(output) => output.output.has_content(),
9292
InputItem::Reasoning(reasoning) => {
9393
reasoning.content.iter().any(|content| !content.text.trim().is_empty())
9494
|| reasoning.summary.iter().any(value_has_content)
@@ -391,7 +391,7 @@ mod tests {
391391
user_message("first"),
392392
InputItem::FunctionCallOutput(FunctionToolResultMessage {
393393
call_id: "call_1".to_owned(),
394-
output: "tool output".to_owned(),
394+
output: "tool output".into(),
395395
}),
396396
user_message("second"),
397397
];
@@ -428,7 +428,7 @@ mod tests {
428428
user_message("hello context"),
429429
InputItem::FunctionCallOutput(FunctionToolResultMessage {
430430
call_id: "call_1".to_owned(),
431-
output: "substantial tool output".to_owned(),
431+
output: "substantial tool output".into(),
432432
}),
433433
]);
434434

‎crates/agentic-server-core/src/executor/engine.rs‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ use super::gateway::{
1717
GatewayCallResult, LoopDecision, append_gateway_calls_to_new_input, append_output_items_to_input,
1818
append_tool_outputs, classify_round, emit_gateway_completed_events, emit_gateway_start_events,
1919
execute_and_emit_output_calls, execute_output_calls, gateway_event_plans, has_client_owned_calls,
20-
is_gateway_owned_call, public_output_items,
20+
is_client_custom_call, is_gateway_owned_call, public_output_items,
2121
};
2222
use super::gateway_accumulator::{GatewayStreamAccumulator, StreamEvent, error_sse_chunk};
2323
use crate::events::EventFrame;
@@ -262,13 +262,21 @@ async fn execute_and_emit_ordered_output_calls(
262262
let first_gateway_index = output_items
263263
.iter()
264264
.position(|item| matches!(item, OutputItem::FunctionCall(call) if is_gateway_owned_call(call, registry)));
265-
let first_gateway_run_end = first_gateway_index.map_or(0, |start| {
266-
output_items[start..]
267-
.iter()
268-
.take_while(|item| matches!(item, OutputItem::FunctionCall(call) if is_gateway_owned_call(call, registry)))
269-
.count()
270-
.saturating_add(start)
271-
});
265+
let first_gateway_run_end = first_gateway_index
266+
.filter(|start| {
267+
!output_items[..*start]
268+
.iter()
269+
.any(|item| matches!(item, OutputItem::FunctionCall(call) if is_client_custom_call(call, registry)))
270+
})
271+
.map_or(0, |start| {
272+
output_items[start..]
273+
.iter()
274+
.take_while(
275+
|item| matches!(item, OutputItem::FunctionCall(call) if is_gateway_owned_call(call, registry)),
276+
)
277+
.count()
278+
.saturating_add(start)
279+
});
272280
let first_gateway_run_len = first_gateway_run_end.saturating_sub(first_gateway_index.unwrap_or(0));
273281
emit_gateway_start_events(&event_plans[..first_gateway_run_len], stream_accumulator, stream_sender)?;
274282

0 commit comments

Comments
 (0)