-
-
Notifications
You must be signed in to change notification settings - Fork 407
Server-side tool-call emulation for models without native tool support #946
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
8143151
f5a3e63
c4a98ca
20b6365
a728a6c
662fb43
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,13 @@ | ||
| use super::*; | ||
|
|
||
| /// Output of a single chat-template application, before it is folded into a | ||
| /// [`PreparedGenerationPrompt`]. | ||
| struct RenderedChatPrompt { | ||
| prompt: String, | ||
| media: Vec<MediaInput>, | ||
| metadata_json: String, | ||
| } | ||
|
|
||
| impl StageOpenAiBackend { | ||
| pub(super) fn prepare_chat_prompt( | ||
| &self, | ||
|
|
@@ -13,29 +21,82 @@ impl StageOpenAiBackend { | |
| .map_err(|_| OpenAiError::backend("runtime lock poisoned"))?; | ||
| runtime.media_marker() | ||
| }; | ||
|
|
||
| // Native path: render with tools and use the template's own metadata. | ||
| let native = self.render_chat_prompt(request, &options, &marker, None, true)?; | ||
|
|
||
| // If the request carries tools but the template does not support native | ||
| // tool calling, re-render with server-side tool-call emulation: strip | ||
| // tools, inject a text-convention instruction, and rewrite history so | ||
| // the template never sees tool roles. Tool-capable templates keep the | ||
| // native prompt unchanged. | ||
| if tool_calls_requested(request) | ||
| && tool_emulation::should_emulate_tool_calls(&native.metadata_json) | ||
| && let Some(tools) = request.tools.as_ref() | ||
| && let Some(instruction) = tool_emulation::build_emulation_instruction(tools) | ||
| { | ||
| let rewritten = | ||
| tool_emulation::rewrite_history_for_emulation(&request.messages, &instruction); | ||
| let emulated = | ||
| self.render_chat_prompt(request, &options, &marker, Some(&rewritten), false)?; | ||
|
Comment on lines
+26
to
+41
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Avoid probing unsupported templates with the original tool-role history. Line 26 renders the native prompt before emulation is selected, so non-tool-capable templates can still see 🤖 Prompt for AI Agents
Comment on lines
+35
to
+41
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Preserve The emulation path strips 🤖 Prompt for AI Agents |
||
| return Ok(PreparedGenerationPrompt { | ||
| text: emulated.prompt, | ||
| media: emulated.media, | ||
| chat_parse_metadata: Some(emulated.metadata_json), | ||
| }); | ||
| } | ||
|
|
||
| Ok(PreparedGenerationPrompt { | ||
| text: native.prompt, | ||
| media: native.media, | ||
| chat_parse_metadata: Some(native.metadata_json), | ||
| }) | ||
| } | ||
|
|
||
| /// Applies the chat template for the given messages. When `messages` is | ||
| /// `None`, the request's own messages are used. When `include_tools` is | ||
| /// false, `tools`/`tool_choice` are omitted (used by the emulation path). | ||
| fn render_chat_prompt( | ||
| &self, | ||
| request: &ChatCompletionRequest, | ||
| options: &ChatTemplateOptions, | ||
| marker: &str, | ||
| messages: Option<&[openai_frontend::ChatMessage]>, | ||
| include_tools: bool, | ||
| ) -> OpenAiResult<RenderedChatPrompt> { | ||
| let source_messages = messages.unwrap_or(&request.messages); | ||
| let mut media = Vec::new(); | ||
| let template_messages = request | ||
| .messages | ||
| let template_messages = source_messages | ||
| .iter() | ||
| .map(|message| chat_message_generation_value(message, &marker, &mut media)) | ||
| .map(|message| chat_message_generation_value(message, marker, &mut media)) | ||
| .collect::<OpenAiResult<Vec<_>>>()?; | ||
| let messages_json = serde_json::to_string(&template_messages).map_err(|error| { | ||
| OpenAiError::invalid_request(format!("serialize messages: {error}")) | ||
| })?; | ||
| let tools_json = request | ||
| .tools | ||
| .as_ref() | ||
| .map(serde_json::to_string) | ||
| .transpose() | ||
| .map_err(|error| OpenAiError::invalid_request(format!("serialize tools: {error}")))?; | ||
| let tool_choice_json = request | ||
| .tool_choice | ||
| .as_ref() | ||
| .map(serde_json::to_string) | ||
| .transpose() | ||
| .map_err(|error| { | ||
| OpenAiError::invalid_request(format!("serialize tool_choice: {error}")) | ||
| })?; | ||
| let tools_json = if include_tools { | ||
| request | ||
| .tools | ||
| .as_ref() | ||
| .map(serde_json::to_string) | ||
| .transpose() | ||
| .map_err(|error| { | ||
| OpenAiError::invalid_request(format!("serialize tools: {error}")) | ||
| })? | ||
| } else { | ||
| None | ||
| }; | ||
| let tool_choice_json = if include_tools { | ||
| request | ||
| .tool_choice | ||
| .as_ref() | ||
| .map(serde_json::to_string) | ||
| .transpose() | ||
| .map_err(|error| { | ||
| OpenAiError::invalid_request(format!("serialize tool_choice: {error}")) | ||
| })? | ||
| } else { | ||
| None | ||
| }; | ||
| let runtime = self | ||
| .runtime | ||
| .lock() | ||
|
|
@@ -53,10 +114,10 @@ impl StageOpenAiBackend { | |
| }, | ||
| ) | ||
| .map_err(openai_backend_error)?; | ||
| Ok(PreparedGenerationPrompt { | ||
| text: result.prompt, | ||
| Ok(RenderedChatPrompt { | ||
| prompt: result.prompt, | ||
| media, | ||
| chat_parse_metadata: Some(result.metadata_json), | ||
| metadata_json: result.metadata_json, | ||
| }) | ||
| } | ||
|
|
||
|
|
@@ -70,6 +131,19 @@ impl StageOpenAiBackend { | |
| let Some(metadata) = metadata else { | ||
| return Ok(None); | ||
| }; | ||
|
|
||
| // Emulation path: when tools were requested but the prompt was rendered | ||
| // without native tool support, the model emitted TOOL_CALL text lines. | ||
| // Parse those into OpenAI tool_calls instead of using the native parser. | ||
| if tool_calls_requested(request) | ||
| && !tool_emulation::template_supports_native_tool_calls(metadata) | ||
| { | ||
| if !is_partial { | ||
| eprintln!("EMU_RAW_TEXT<<<{}>>>", text); | ||
| } | ||
| return Ok(parse_emulated_chat_output(text, request, is_partial)); | ||
| } | ||
|
|
||
| let parsed_json = { | ||
| let runtime = self | ||
| .runtime | ||
|
|
@@ -204,3 +278,38 @@ impl StageOpenAiBackend { | |
| && hook_request.as_ref().is_some_and(chat_mesh_hooks_enabled) | ||
| } | ||
| } | ||
|
|
||
| /// Parses generated text produced under tool-call emulation into a | ||
| /// [`ParsedChatMessage`]. During streaming (`is_partial`) the trailing | ||
| /// incomplete line is held back and tool calls are withheld, so a half-formed | ||
| /// `TOOL_CALL` marker is never streamed as content and calls are emitted once, | ||
| /// on finalization — matching native tool-call streaming semantics. | ||
| pub(super) fn parse_emulated_chat_output( | ||
| text: &str, | ||
| request: &ChatCompletionRequest, | ||
| is_partial: bool, | ||
| ) -> Option<ParsedChatMessage> { | ||
| let allowed = request_allowed_tool_names(request); | ||
| let scan_text = if is_partial { | ||
| text.rsplit_once('\n') | ||
| .map(|(head, _)| head) | ||
| .unwrap_or_default() | ||
|
Comment on lines
+293
to
+296
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win Do not withhold all partial prose while streaming. This drops every trailing line during partial parsing, so a normal one-line answer streams no content until finalization. Only hold the trailing segment when it could be a partial Suggested shape- let scan_text = if is_partial {
- text.rsplit_once('\n')
- .map(|(head, _)| head)
- .unwrap_or_default()
+ let scan_text = if is_partial && trailing_segment_may_be_tool_call(text) {
+ text.rsplit_once('\n').map(|(head, _)| head).unwrap_or_default()
} else {
text
};🤖 Prompt for AI Agents |
||
| } else { | ||
| text | ||
| }; | ||
| let parse = tool_emulation::parse_emulated_tool_calls(scan_text, &allowed); | ||
| let tool_calls = if is_partial || parse.tool_calls.is_empty() { | ||
| None | ||
| } else { | ||
| let mut calls = parse.tool_calls; | ||
| if request.parallel_tool_calls == Some(false) { | ||
| calls.truncate(1); | ||
| } | ||
| Some(Value::Array(calls)) | ||
| }; | ||
| Some(ParsedChatMessage { | ||
| content: parse.content, | ||
| reasoning_content: None, | ||
| tool_calls, | ||
| }) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.