diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f7f410..6b03b2a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -3,6 +3,33 @@
All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/).
+## [0.2.48] - 2026-08-21
+
+Verified end to end: Open WebUI 0.11.0 → gateway → managed CMF/Qwen, native
+function calling, tool executed and its result fed back into the reply.
+
+### Fixed
+- **A tool call is never delivered twice.** Qwen-family templates write the call into the
+ generated text as `{…}`; some runtimes parse it into structured
+ `tool_calls` *and* still stream the raw markup as `content`, so the client sees the call
+ once as prose it must not show and once as the real thing. The gateway now strips the
+ markup from `content` — including when the marker is split across SSE chunks — while
+ passing the structured call through untouched. Ordinary text containing `<` is not
+ affected, and an unterminated block is handed back as text rather than swallowed.
+- **`finish_reason` agrees with the reply.** A response carrying tool calls now always ends
+ with `finish_reason: "tool_calls"`, whatever the upstream called it — clients branch on
+ that field to decide whether to run the tool.
+- **Streamed `tool_calls` are brought to the OpenAI shape**: `index` on every entry, `id`
+ and `type` exactly once per call (continuation deltas that carry only an argument
+ fragment are left alone, as the spec requires), and `function.arguments` always a string
+ even when the runtime sends an object.
+
+### Added
+- **Unparsed `` markup is promoted into a real call.** When the upstream never
+ parsed it, the gateway turns it into a proper `tool_calls` entry with a generated
+ `call_…` id, so the call is executable instead of decorative. Markup that is not a JSON
+ object with a `name` is ignored — a stray marker must not become a bogus call.
+
## [0.2.47] - 2026-08-21
### Changed
diff --git a/Cargo.lock b/Cargo.lock
index 0916880..d1ff057 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -285,7 +285,7 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "cortiq-gateway"
-version = "0.2.47"
+version = "0.2.48"
dependencies = [
"anyhow",
"arc-swap",
diff --git a/Cargo.toml b/Cargo.toml
index 3d5d9a2..3f9ec28 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
[package]
name = "cortiq-gateway"
-version = "0.2.47"
+version = "0.2.48"
edition = "2021"
rust-version = "1.75"
license = "Apache-2.0"
diff --git a/docs/PROTOCOLS.md b/docs/PROTOCOLS.md
index 51c1312..76925aa 100644
--- a/docs/PROTOCOLS.md
+++ b/docs/PROTOCOLS.md
@@ -121,6 +121,20 @@ opens with a `role` delta, carries exactly one terminal `finish_reason`, and end
failure arrives as an SSE `error` event. A client can therefore never be left waiting on a
half-formed stream.
+### Tool calls
+
+`tools` and `tool_calls` are proxied through, with the wire kept clean:
+
+* raw `…` markup (how Qwen-family templates express a call in the
+ generated text) is stripped from `content`, so a client never receives the same call
+ twice — once as prose and once structured. Markers split across SSE chunks are handled;
+ ordinary text containing `<` is not touched;
+* when the upstream never parsed that markup, it is promoted into a real `tool_calls`
+ entry with a generated `call_…` id, so the call is executable rather than decorative;
+* streamed entries carry `index` always, `id`/`type` once per call, and
+ `function.arguments` as a string even if the runtime sent an object;
+* a reply carrying tool calls always ends with `finish_reason: "tool_calls"`.
+
### Metadata in response headers (always present)
```
diff --git a/docs/PROTOCOLS.ru.md b/docs/PROTOCOLS.ru.md
index 45b688e..6569512 100644
--- a/docs/PROTOCOLS.ru.md
+++ b/docs/PROTOCOLS.ru.md
@@ -119,6 +119,21 @@ HTTP 400 — но не «голый» 422, который OpenAI-клиенты
`data: [DONE]` — даже если upstream оборвался или упал на середине (тогда ошибка приходит
SSE-событием `error`). Клиент никогда не остаётся ждать недосказанный поток.
+### Tool calls
+
+`tools` и `tool_calls` проксируются, но провод держится чистым:
+
+* сырая разметка `…` (так Qwen-подобные шаблоны выражают вызов
+ прямо в тексте) вырезается из `content` — клиент никогда не получает один и тот же
+ вызов дважды, прозой и структурой. Маркер, разорванный между SSE-чанками, обрабатывается
+ корректно; обычный текст с символом `<` не трогается;
+* если апстрим эту разметку не распарсил, она повышается до настоящего элемента
+ `tool_calls` со сгенерированным `call_…` id — вызов становится исполнимым, а не
+ декоративным;
+* в стриме у каждого элемента всегда есть `index`, `id`/`type` — ровно один раз на вызов,
+ а `function.arguments` — всегда строка, даже если рантайм прислал объект;
+* ответ с tool-вызовами всегда завершается `finish_reason: "tool_calls"`.
+
### Метаданные в заголовках (всегда)
```
diff --git a/src/main.rs b/src/main.rs
index 142b4aa..3201f02 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -29,6 +29,7 @@ mod secrets;
mod state;
mod stats;
mod sysmon;
+mod toolcalls;
use axum::{routing::get, Router};
use clap::Parser;
diff --git a/src/protocols/openai_chat.rs b/src/protocols/openai_chat.rs
index 5eb4cdd..784a6be 100644
--- a/src/protocols/openai_chat.rs
+++ b/src/protocols/openai_chat.rs
@@ -341,6 +341,12 @@ async fn handler(
/// to end with `data: [DONE]` — even when the upstream dies mid-flight, in
/// which case the failure is delivered as an SSE `error` event so the client
/// shows a message instead of waiting forever.
+///
+/// Tool calls are settled here too (see [`crate::toolcalls`]): raw
+/// `` markup never reaches `content`, structured deltas are brought
+/// to the OpenAI shape, markup is promoted to a real call when the upstream
+/// produced none, and a stream that emitted tool calls always finishes with
+/// `finish_reason: "tool_calls"`.
fn normalize_sse(
stream: crate::providers::ChatStream,
model: String,
@@ -355,6 +361,13 @@ fn normalize_sse(
let mut roled: std::collections::HashSet = std::collections::HashSet::new();
let mut sent_finish = false;
let mut failure: Option = None;
+ // Tool-call hygiene: one markup filter per choice index, the set of
+ // tool-call indices already opened, and whether anything structured
+ // was emitted at all.
+ let mut markup: std::collections::HashMap =
+ std::collections::HashMap::new();
+ let mut tool_indices: std::collections::HashSet = std::collections::HashSet::new();
+ let mut emitted_tool_calls = false;
// Emit one normalised event; `None` means "nothing worth sending".
macro_rules! chunk {
@@ -417,8 +430,15 @@ fn normalize_sse(
delta.insert("role".into(), serde_json::json!("assistant"));
}
if let Some(text) = src["content"].as_str() {
- if !text.is_empty() {
- delta.insert("content".into(), serde_json::json!(text));
+ // Strip any `` markup before it can
+ // reach the client as prose — the structured
+ // call is the only form a tool loop may see.
+ let visible = markup
+ .entry(index)
+ .or_default()
+ .push(text);
+ if !visible.is_empty() {
+ delta.insert("content".into(), serde_json::json!(visible));
}
}
// Reasoning traces and citations are not in the base
@@ -430,10 +450,45 @@ fn normalize_sse(
}
}
if let Some(tc) = src["tool_calls"].as_array().filter(|a| !a.is_empty()) {
- delta.insert("tool_calls".into(), serde_json::Value::Array(tc.clone()));
+ let normalized =
+ crate::toolcalls::normalize_delta_tool_calls(tc, &mut tool_indices);
+ if !normalized.is_empty() {
+ emitted_tool_calls = true;
+ delta.insert(
+ "tool_calls".into(),
+ serde_json::Value::Array(normalized),
+ );
+ }
}
let finish = c["finish_reason"].as_str().map(normalize_finish_reason);
+
+ // Last chance to promote `` markup into a
+ // real call: an upstream that never parsed it would
+ // otherwise hand the client prose no tool loop runs.
+ if finish.is_some() && !emitted_tool_calls {
+ if let Some(filter) = markup.get_mut(&index) {
+ let leftover = filter.finish();
+ if !leftover.is_empty() {
+ let merged = match delta.get("content").and_then(|v| v.as_str()) {
+ Some(prev) => format!("{prev}{leftover}"),
+ None => leftover,
+ };
+ delta.insert("content".into(), serde_json::json!(merged));
+ }
+ let calls = crate::toolcalls::markup_to_tool_calls(filter.captured(), 0);
+ if !calls.is_empty() {
+ emitted_tool_calls = true;
+ let tc = serde_json::json!({
+ "index": index,
+ "delta": { "tool_calls": calls },
+ });
+ let extra: Vec<(&str, serde_json::Value)> = Vec::new();
+ yield Ok(chunk!(vec![tc], extra));
+ }
+ }
+ }
+
if finish.is_none() && delta.is_empty() {
continue;
}
@@ -442,6 +497,10 @@ fn normalize_sse(
"delta": serde_json::Value::Object(delta),
});
if let Some(f) = finish {
+ // A reply that carries tool calls stops for that
+ // reason, whatever the upstream called it —
+ // clients branch on this field to run the tool.
+ let f = if emitted_tool_calls { "tool_calls" } else { f };
choice["finish_reason"] = serde_json::json!(f);
sent_finish = true;
}
@@ -482,10 +541,24 @@ fn normalize_sse(
if roled.insert(0) {
delta.insert("role".into(), serde_json::json!("assistant"));
}
+ // Held-back text and unparsed markup must not be lost with the stream.
+ for (index, filter) in markup.iter_mut() {
+ let leftover = filter.finish();
+ if !leftover.is_empty() && *index == 0 {
+ delta.insert("content".into(), serde_json::json!(leftover));
+ }
+ if !emitted_tool_calls {
+ let calls = crate::toolcalls::markup_to_tool_calls(filter.captured(), 0);
+ if !calls.is_empty() {
+ emitted_tool_calls = true;
+ delta.insert("tool_calls".into(), serde_json::Value::Array(calls));
+ }
+ }
+ }
let choices = vec![serde_json::json!({
"index": 0,
"delta": serde_json::Value::Object(delta),
- "finish_reason": "stop",
+ "finish_reason": if emitted_tool_calls { "tool_calls" } else { "stop" },
})];
let extra: Vec<(&str, serde_json::Value)> = Vec::new();
yield Ok(chunk!(choices, extra));
@@ -530,6 +603,82 @@ mod tests {
assert_eq!(normalize_finish_reason("tool_use"), "tool_calls");
}
+ /// Collect a normalised stream into one string for assertions.
+ async fn drain(chunks: Vec<&'static str>) -> String {
+ use futures::StreamExt;
+ let upstream = futures::stream::iter(
+ chunks
+ .into_iter()
+ .map(|c| Ok(bytes::Bytes::from(c)))
+ .collect::>(),
+ );
+ normalize_sse(Box::pin(upstream), "cortiq-auto".into())
+ .map(|b| String::from_utf8_lossy(&b.unwrap()).to_string())
+ .collect::>()
+ .await
+ .join("")
+ }
+
+ #[tokio::test]
+ async fn tool_markup_is_not_duplicated_into_content() {
+ // The reported shape: the upstream streams the call as prose AND then
+ // sends it properly structured.
+ let out = drain(vec![
+ "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Sure. \"}}]}\n\n",
+ "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"{\\\"name\\\":\\\"get_weather\\\",\\\"arguments\\\":{\\\"city\\\":\\\"Paris\\\"}}\"}}]}\n\n",
+ "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"}}]}\n\n",
+ "data: {\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"id\":\"call_1\",\"type\":\"function\",\"function\":{\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}]}}]}\n\n",
+ "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
+ "data: [DONE]\n\n",
+ ])
+ .await;
+ assert!(
+ !out.contains("tool_call>"),
+ "markup leaked into content: {out}"
+ );
+ assert!(
+ out.contains("\"id\":\"call_1\""),
+ "structured call missing: {out}"
+ );
+ // exactly one structured call, and the stop reason agrees with it
+ assert_eq!(out.matches("\"tool_calls\":[").count(), 1);
+ assert!(out.contains("\"finish_reason\":\"tool_calls\""));
+ assert!(out.trim_end().ends_with("data: [DONE]"));
+ }
+
+ #[tokio::test]
+ async fn unparsed_markup_is_promoted_to_a_real_tool_call() {
+ // An upstream that never parses the markup: the call must still be
+ // executable by the client rather than arriving as prose.
+ let out = drain(vec![
+ "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"{\\\"name\\\":\\\"get_weather\\\",\\\"arguments\\\":{\\\"city\\\":\\\"Paris\\\"}}\"}}]}\n\n",
+ "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
+ "data: [DONE]\n\n",
+ ])
+ .await;
+ assert!(!out.contains("tool_call>"), "markup leaked: {out}");
+ assert!(
+ out.contains("\"name\":\"get_weather\""),
+ "call not promoted: {out}"
+ );
+ assert!(
+ out.contains("\"finish_reason\":\"tool_calls\""),
+ "wrong stop reason: {out}"
+ );
+ }
+
+ #[tokio::test]
+ async fn ordinary_text_is_untouched_by_the_tool_filter() {
+ let out = drain(vec![
+ "data: {\"choices\":[{\"index\":0,\"delta\":{\"content\":\"a < b and 2<3\"}}]}\n\n",
+ "data: {\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}]}\n\n",
+ "data: [DONE]\n\n",
+ ])
+ .await;
+ assert!(out.contains("a < b and 2<3"), "text mangled: {out}");
+ assert!(out.contains("\"finish_reason\":\"stop\""));
+ }
+
#[tokio::test]
async fn stream_is_closed_even_when_the_upstream_stops_early() {
use futures::StreamExt;
diff --git a/src/providers/openai.rs b/src/providers/openai.rs
index edbd2be..172bfef 100644
--- a/src/providers/openai.rs
+++ b/src/providers/openai.rs
@@ -122,13 +122,23 @@ impl Provider for OpenAiProvider {
for (i, c) in choices_arr.iter().enumerate() {
let msg_val = &c["message"];
let role = msg_val["role"].as_str().unwrap_or("assistant").to_string();
- let content = msg_val["content"].as_str().unwrap_or("").to_string();
+ let raw_content = msg_val["content"].as_str().unwrap_or("");
+ // Qwen-style templates put the call in the text as `…`.
+ // Strip it: the structured array is the only form a client may act
+ // on, and prose that merely looks like a call is worse than none.
+ let (content, markup) = crate::toolcalls::split_tool_markup(raw_content);
- let tool_calls = msg_val["tool_calls"]
+ let mut tool_calls = msg_val["tool_calls"]
.as_array()
.cloned()
.unwrap_or_default();
+ if tool_calls.is_empty() {
+ // Upstream never parsed the markup — promote it, so the call is
+ // executable instead of decorative.
+ tool_calls = crate::toolcalls::markup_to_tool_calls(&markup, 0);
+ }
+ let tool_calls_present = !tool_calls.is_empty();
choices.push(crate::model::Choice {
index: c["index"].as_u64().unwrap_or(i as u64) as u32,
message: crate::model::Message {
@@ -137,7 +147,11 @@ impl Provider for OpenAiProvider {
tool_calls,
..Default::default()
},
- finish_reason: c["finish_reason"].as_str().unwrap_or("stop").to_string(),
+ finish_reason: if tool_calls_present {
+ "tool_calls".to_string()
+ } else {
+ c["finish_reason"].as_str().unwrap_or("stop").to_string()
+ },
});
}
diff --git a/src/toolcalls.rs b/src/toolcalls.rs
new file mode 100644
index 0000000..ef55d1d
--- /dev/null
+++ b/src/toolcalls.rs
@@ -0,0 +1,300 @@
+//! Tool-call hygiene for OpenAI-compatible responses.
+//!
+//! Qwen-family chat templates express a function call as literal markup in the
+//! generated text:
+//!
+//! ```text
+//!
+//! {"name": "get_weather", "arguments": {"city": "Paris"}}
+//!
+//! ```
+//!
+//! Runtimes handle that inconsistently. Some parse it into structured
+//! `tool_calls` **and** still stream the raw markup as `content` — the client
+//! then sees the call twice, once as prose it must not show and once as the real
+//! thing, and tool-loop implementations (Open WebUI's among them) get confused.
+//! Others never parse it at all, so a perfectly good call reaches the client as
+//! text no tool loop will ever execute.
+//!
+//! This module makes the gateway the place where that is settled: markup is
+//! stripped from the text, and when nothing structured came with it the markup
+//! is promoted into a real `tool_calls` array.
+
+const OPEN: &str = "";
+const CLOSE: &str = "";
+
+/// Longest suffix of `s` that is a proper prefix of `pat`. Used to hold back the
+/// tail of a streamed chunk that might be the beginning of a marker split across
+/// chunk boundaries (`""`).
+fn partial_marker_len(s: &str, pat: &str) -> usize {
+ let max = pat.len().saturating_sub(1).min(s.len());
+ (1..=max)
+ .rev()
+ .find(|&n| {
+ s.is_char_boundary(s.len() - n)
+ && pat.as_bytes().starts_with(&s.as_bytes()[s.len() - n..])
+ })
+ .unwrap_or(0)
+}
+
+/// Incremental `` filter for SSE deltas.
+///
+/// Feed it content fragments in order; it returns the text that is safe to
+/// forward and keeps the markup payloads for [`Self::captured`].
+#[derive(Default)]
+pub struct ToolMarkupFilter {
+ /// Text held back: either an unterminated marker prefix or the inside of an
+ /// open `` block.
+ pending: String,
+ inside: bool,
+ captured: Vec,
+}
+
+impl ToolMarkupFilter {
+ pub fn new() -> Self {
+ Self::default()
+ }
+
+ /// Consume one content fragment; returns the part that may be forwarded now.
+ pub fn push(&mut self, chunk: &str) -> String {
+ self.pending.push_str(chunk);
+ let mut out = String::new();
+ loop {
+ if self.inside {
+ let Some(end) = self.pending.find(CLOSE) else {
+ // still inside the block — nothing to forward
+ return out;
+ };
+ let payload: String = self.pending[..end].to_string();
+ self.captured.push(payload);
+ self.pending = self.pending[end + CLOSE.len()..].to_string();
+ self.inside = false;
+ continue;
+ }
+ match self.pending.find(OPEN) {
+ Some(start) => {
+ out.push_str(&self.pending[..start]);
+ self.pending = self.pending[start + OPEN.len()..].to_string();
+ self.inside = true;
+ }
+ None => {
+ // Hold back a trailing partial marker so a split like
+ // "……" is not forwarded as visible text.
+ let hold = partial_marker_len(&self.pending, OPEN);
+ let cut = self.pending.len() - hold;
+ out.push_str(&self.pending[..cut]);
+ self.pending = self.pending[cut..].to_string();
+ return out;
+ }
+ }
+ }
+ }
+
+ /// End of stream: an unterminated block was never a tool call after all, so
+ /// give the text back rather than swallowing output.
+ pub fn finish(&mut self) -> String {
+ let mut out = String::new();
+ if self.inside {
+ out.push_str(OPEN);
+ self.inside = false;
+ }
+ out.push_str(&std::mem::take(&mut self.pending));
+ out
+ }
+
+ /// Payloads found between `` and ``.
+ pub fn captured(&self) -> &[String] {
+ &self.captured
+ }
+}
+
+/// Strip every complete `` block from a finished string.
+/// Returns the cleaned text and the payloads that were removed.
+pub fn split_tool_markup(text: &str) -> (String, Vec) {
+ let mut f = ToolMarkupFilter::new();
+ let mut out = f.push(text);
+ out.push_str(&f.finish());
+ (out, f.captured)
+}
+
+/// Turn `` payloads into OpenAI `tool_calls` entries, numbered from
+/// `start_index`. Payloads that are not a JSON object with a `name` are skipped —
+/// a stray marker must not become a bogus call.
+pub fn markup_to_tool_calls(payloads: &[String], start_index: usize) -> Vec {
+ let mut out = Vec::new();
+ for payload in payloads {
+ let Ok(v) = serde_json::from_str::(payload.trim()) else {
+ continue;
+ };
+ let Some(name) = v.get("name").and_then(|n| n.as_str()) else {
+ continue;
+ };
+ // `arguments` is a JSON string on the wire; templates emit an object.
+ let arguments = match v.get("arguments").or_else(|| v.get("parameters")) {
+ Some(serde_json::Value::String(s)) => s.clone(),
+ Some(other) => other.to_string(),
+ None => "{}".to_string(),
+ };
+ out.push(serde_json::json!({
+ "index": start_index + out.len(),
+ "id": new_call_id(),
+ "type": "function",
+ "function": { "name": name, "arguments": arguments },
+ }));
+ }
+ out
+}
+
+pub fn new_call_id() -> String {
+ format!("call_{}", crate::admin::random_token(12))
+}
+
+/// Bring one streamed `tool_calls` array up to the OpenAI shape.
+///
+/// `seen` tracks which choice-local indices already had their opening delta, so
+/// the identity fields (`id`, `type`) are added once and continuation deltas —
+/// which legitimately carry nothing but an argument fragment — are left alone.
+pub fn normalize_delta_tool_calls(
+ raw: &[serde_json::Value],
+ seen: &mut std::collections::HashSet,
+) -> Vec {
+ let mut out = Vec::new();
+ for (i, tc) in raw.iter().enumerate() {
+ let index = tc.get("index").and_then(|v| v.as_u64()).unwrap_or(i as u64);
+ let mut entry = serde_json::Map::new();
+ entry.insert("index".into(), serde_json::json!(index));
+
+ let first = seen.insert(index);
+ if let Some(id) = tc
+ .get("id")
+ .and_then(|v| v.as_str())
+ .filter(|s| !s.is_empty())
+ {
+ entry.insert("id".into(), serde_json::json!(id));
+ } else if first {
+ entry.insert("id".into(), serde_json::json!(new_call_id()));
+ }
+ if first {
+ let kind = tc
+ .get("type")
+ .and_then(|v| v.as_str())
+ .unwrap_or("function");
+ entry.insert("type".into(), serde_json::json!(kind));
+ }
+
+ if let Some(f) = tc.get("function") {
+ let mut func = serde_json::Map::new();
+ if let Some(name) = f.get("name").and_then(|v| v.as_str()) {
+ func.insert("name".into(), serde_json::json!(name));
+ } else if first {
+ func.insert("name".into(), serde_json::json!(""));
+ }
+ match f.get("arguments") {
+ // Arguments are a string on the wire; some runtimes send the object.
+ Some(serde_json::Value::String(s)) => {
+ func.insert("arguments".into(), serde_json::json!(s));
+ }
+ Some(serde_json::Value::Null) | None => {
+ if first {
+ func.insert("arguments".into(), serde_json::json!(""));
+ }
+ }
+ Some(other) => {
+ func.insert("arguments".into(), serde_json::json!(other.to_string()));
+ }
+ }
+ entry.insert("function".into(), serde_json::Value::Object(func));
+ }
+ out.push(serde_json::Value::Object(entry));
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const CALL: &str = r#"
+{"name": "get_weather", "arguments": {"city": "Paris"}}
+"#;
+
+ #[test]
+ fn markup_is_stripped_from_finished_text() {
+ let (clean, payloads) = split_tool_markup(&format!("Sure!{CALL}"));
+ assert_eq!(clean, "Sure!");
+ assert_eq!(payloads.len(), 1);
+ }
+
+ #[test]
+ fn markup_split_across_stream_chunks_never_leaks() {
+ let mut f = ToolMarkupFilter::new();
+ let mut seen = String::new();
+ // the marker itself is broken across three fragments
+ for chunk in [
+ "Let me check. ",
+ "{\"name\":\"get_weather\",",
+ "\"arguments\":{\"city\":\"Paris\"}}",
+ "",
+ " done",
+ ] {
+ seen.push_str(&f.push(chunk));
+ }
+ seen.push_str(&f.finish());
+ assert_eq!(seen, "Let me check. done");
+ assert!(!seen.contains("tool_call"));
+ assert_eq!(f.captured().len(), 1);
+ }
+
+ #[test]
+ fn captured_markup_becomes_a_real_tool_call() {
+ let (_, payloads) = split_tool_markup(CALL);
+ let calls = markup_to_tool_calls(&payloads, 0);
+ assert_eq!(calls.len(), 1);
+ assert_eq!(calls[0]["type"], "function");
+ assert_eq!(calls[0]["index"], 0);
+ assert_eq!(calls[0]["function"]["name"], "get_weather");
+ // arguments must be a STRING, not an object
+ assert_eq!(
+ calls[0]["function"]["arguments"].as_str().unwrap(),
+ r#"{"city":"Paris"}"#
+ );
+ assert!(calls[0]["id"].as_str().unwrap().starts_with("call_"));
+ }
+
+ #[test]
+ fn a_stray_marker_does_not_become_a_call() {
+ let calls = markup_to_tool_calls(&["not json at all".into(), "{\"no_name\":1}".into()], 0);
+ assert!(calls.is_empty());
+ }
+
+ #[test]
+ fn an_unterminated_block_is_given_back_as_text() {
+ let mut f = ToolMarkupFilter::new();
+ let mut out = f.push("thinking {\"name\":\"x\"");
+ out.push_str(&f.finish());
+ assert_eq!(out, "thinking {\"name\":\"x\"");
+ assert!(f.captured().is_empty());
+ }
+
+ #[test]
+ fn delta_tool_calls_get_identity_once_and_object_arguments_become_strings() {
+ let mut seen = std::collections::HashSet::new();
+ let opening = normalize_delta_tool_calls(
+ &[serde_json::json!({"index": 0, "function": {"name": "f", "arguments": {"a": 1}}})],
+ &mut seen,
+ );
+ assert_eq!(opening[0]["type"], "function");
+ assert!(opening[0]["id"].as_str().unwrap().starts_with("call_"));
+ assert_eq!(opening[0]["function"]["arguments"], r#"{"a":1}"#);
+
+ // a continuation delta must not be handed a second id
+ let cont = normalize_delta_tool_calls(
+ &[serde_json::json!({"index": 0, "function": {"arguments": "\"}"}})],
+ &mut seen,
+ );
+ assert!(cont[0].get("id").is_none());
+ assert!(cont[0].get("type").is_none());
+ }
+}