Skip to main content

mecha_core/provider/
openai.rs

1//! OpenAI-compatible `/v1/chat/completions`.
2//!
3//! One implementation covers OpenAI itself, llama.cpp's `llama-server`, vLLM,
4//! Ollama's compat endpoint, and anything else speaking the same dialect.
5//! Point `base_url` at whichever you're running.
6//!
7//! The shape is lossier than Anthropic's: no cache breakpoints, no effort.
8//! Those fields are accepted and ignored.
9//!
10//! Reasoning is the exception. Servers that split it out (llama.cpp's
11//! `--reasoning-format auto`, vLLM, DeepSeek) return it as
12//! `reasoning_content`, which decodes into a `Block::Thinking` so it can be
13//! shown and recorded. It is never *output* — see `produced_output`.
14//!
15//! It is currently one-way, and that is a **known gap, not a decision**.
16//! `reasoning_content` is a request field too: measured against llama-server
17//! on 2026-08-10 via `/apply-template`, an assistant message carrying it
18//! renders back into the prompt as a `<think>` block, and without it the same
19//! turn renders as a bare `<tool_call>` with no thinking at all. So every
20//! prior assistant turn in a mecha conversation shows this model calling
21//! tools without reasoning — which is both a lost prior and the suspected
22//! cause of the empty-turn bug, since the malformation reproduced 7/7 was a
23//! bare tool call emitted with no think block.
24//!
25//! `anthropic.rs` already replays thinking (signature-gated, see
26//! `encode_block`); this backend has simply never had the code. Fixing it
27//! needs care about context cost — reasoning runs to thousands of tokens a
28//! turn — and about servers in this dialect that reject the field.
29
30use crate::config::ProviderConfig;
31use crate::message::*;
32use crate::provider::{Provider, StreamEvent, StreamSink};
33use anyhow::{Context, Result};
34use async_trait::async_trait;
35use futures::StreamExt;
36use serde_json::{json, Value};
37use std::collections::BTreeMap;
38
39pub struct OpenAiCompatible {
40    http: reqwest::Client,
41    api_key: Option<String>,
42    base_url: String,
43    default_model: String,
44    temperature: Option<f64>,
45    seed: Option<u64>,
46    id: String,
47    retry: crate::provider::retry::RetryPolicy,
48}
49
50impl OpenAiCompatible {
51    pub fn from_config(cfg: &ProviderConfig) -> Result<Self> {
52        Ok(Self {
53            http: reqwest::Client::builder()
54                .timeout(std::time::Duration::from_secs(900))
55                .build()?,
56            // Local servers usually don't check it.
57            api_key: cfg.resolve_api_key(),
58            base_url: cfg
59                .base_url
60                .clone()
61                .unwrap_or_else(|| "https://api.openai.com".to_string()),
62            default_model: cfg
63                .model
64                .clone()
65                .unwrap_or_else(|| "gpt-4o-mini".to_string()),
66            temperature: cfg.temperature,
67            seed: cfg.seed,
68            id: cfg.kind.clone(),
69            retry: crate::provider::retry::RetryPolicy::from_config(cfg),
70        })
71    }
72
73    fn body(&self, req: &CompletionRequest, stream: bool) -> Value {
74        let mut messages = Vec::new();
75        if let Some(system) = &req.system {
76            messages.push(json!({"role": "system", "content": system}));
77        }
78        for m in &req.messages {
79            encode_message(m, &mut messages);
80        }
81
82        let mut body = json!({
83            "model": req.model,
84            "max_tokens": req.max_tokens,
85            "messages": messages,
86        });
87        let obj = body.as_object_mut().unwrap();
88        if let Some(t) = self.temperature {
89            obj.insert("temperature".into(), json!(t));
90        }
91        if let Some(s) = self.seed {
92            obj.insert("seed".into(), json!(s));
93        }
94        if stream {
95            obj.insert("stream".into(), json!(true));
96            obj.insert("stream_options".into(), json!({"include_usage": true}));
97        }
98        if !req.tools.is_empty() {
99            let tools: Vec<Value> = req
100                .tools
101                .iter()
102                .map(|t| {
103                    json!({"type": "function", "function": {
104                        "name": t.name,
105                        "description": t.description,
106                        "parameters": t.input_schema,
107                    }})
108                })
109                .collect();
110            obj.insert("tools".into(), json!(tools));
111        }
112        body
113    }
114
115    fn request(&self, body: &Value) -> reqwest::RequestBuilder {
116        let mut rb = self
117            .http
118            .post(format!(
119                "{}/v1/chat/completions",
120                self.base_url.trim_end_matches('/')
121            ))
122            .header("content-type", "application/json");
123        if let Some(key) = &self.api_key {
124            rb = rb.bearer_auth(key);
125        }
126        rb.json(body)
127    }
128}
129
130#[async_trait]
131impl Provider for OpenAiCompatible {
132    fn id(&self) -> &str {
133        &self.id
134    }
135
136    fn default_model(&self) -> &str {
137        &self.default_model
138    }
139
140    async fn complete(
141        &self,
142        req: &CompletionRequest,
143        sink: Option<&StreamSink>,
144    ) -> Result<CompletionResponse> {
145        let body = self.body(req, sink.is_some());
146        // Retries cover the send and the status line — nothing has streamed
147        // yet. Mid-stream failures below propagate without a `ProviderError`,
148        // which is what keeps them out of the retry and failover paths:
149        // deltas may already be on the user's screen.
150        let resp = crate::provider::retry::send_with_retry(|| self.request(&body), &self.retry)
151            .await
152            .map_err(|f| {
153                let message = match f.status {
154                    Some(status) => format!(
155                        "{} {status}: {}",
156                        self.id,
157                        f.detail.chars().take(500).collect::<String>()
158                    ),
159                    None => format!("{}: {}", self.id, f.detail),
160                };
161                anyhow::Error::new(f.class).context(message)
162            })?;
163
164        let Some(sink) = sink else {
165            let v: Value = resp.json().await.context("malformed response body")?;
166            return decode_response(&v);
167        };
168
169        let mut acc = Accumulator::default();
170        let mut buf = crate::provider::sse::SseBuffer::default();
171        let mut stream = resp.bytes_stream();
172        while let Some(chunk) = stream.next().await {
173            buf.push(&chunk?);
174            // Lines are split on bytes, not decoded text: a network chunk can
175            // end mid-character, and only a complete line is guaranteed to be
176            // complete UTF-8.
177            while let Some(line) = buf.next_segment(b"\n") {
178                let Some(data) = line.trim().strip_prefix("data:") else {
179                    continue;
180                };
181                let data = data.trim();
182                if data.is_empty() || data == "[DONE]" {
183                    continue;
184                }
185                let v: Value = serde_json::from_str(data).context("malformed SSE data frame")?;
186                acc.push(&v, sink);
187            }
188        }
189        Ok(acc.finish())
190    }
191}
192
193/// Expand our block list into however many OpenAI messages it takes: an
194/// assistant turn carries its tool calls inline, but every tool result is its
195/// own `role: "tool"` message.
196fn encode_message(m: &Message, out: &mut Vec<Value>) {
197    match m.role {
198        Role::Assistant => {
199            let mut text = String::new();
200            let mut reasoning = String::new();
201            let mut tool_calls = Vec::new();
202            for b in &m.content {
203                match b {
204                    Block::Text { text: t } => text.push_str(t),
205                    // Sent back, because the model's own history is what
206                    // teaches it the shape of a turn. Measured 2026-08-10
207                    // against llama-server on the prefix that had gone quiet
208                    // seven times out of seven: with reasoning stripped from
209                    // the history — which is what this backend did — the model
210                    // produced a bare `<tool_call>` with no think block, 6 of
211                    // 6, and the server misfiled the whole thing as reasoning
212                    // so the turn arrived empty. With reasoning restored to
213                    // the history and nothing else changed, 0 of 6. The
214                    // parser bug upstream is real, but this is the half that
215                    // was ours: we showed the model turn after turn of itself
216                    // calling tools without thinking, and it obliged.
217                    //
218                    // Self-gating, which is what keeps it honest: on this path
219                    // a Thinking block exists only because a server sent
220                    // `reasoning_content` in the first place. So this rides
221                    // back only to servers that speak the field — llama.cpp,
222                    // vLLM, DeepSeek — and never to an endpoint that would
223                    // reject an unknown one. No provider sniffing, no flag.
224                    //
225                    // `signature` is dropped: it is Anthropic's echo-back
226                    // token and has no counterpart here. That asymmetry is
227                    // why `anthropic.rs` refuses to replay an unsigned block
228                    // while this backend replays every one it has.
229                    Block::Thinking { text: t, .. } => reasoning.push_str(t),
230                    Block::ToolUse { id, name, input } => tool_calls.push(json!({
231                        "id": id,
232                        "type": "function",
233                        "function": {"name": name, "arguments": input.to_string()},
234                    })),
235                    // A result is its own message, never part of the turn.
236                    Block::ToolResult { .. } => {}
237                }
238            }
239            let mut msg = json!({"role": "assistant"});
240            let obj = msg.as_object_mut().unwrap();
241            obj.insert(
242                "content".into(),
243                if text.is_empty() {
244                    Value::Null
245                } else {
246                    json!(text)
247                },
248            );
249            if !tool_calls.is_empty() {
250                obj.insert("tool_calls".into(), json!(tool_calls));
251            }
252            // Absent rather than empty when there was no thinking: a server
253            // that renders the field conditionally must see it missing, not
254            // see an empty think block.
255            if !reasoning.is_empty() {
256                obj.insert("reasoning_content".into(), json!(reasoning));
257            }
258            out.push(msg);
259        }
260        Role::User => {
261            let mut text = String::new();
262            for b in &m.content {
263                match b {
264                    Block::Text { text: t } => text.push_str(t),
265                    Block::ToolResult {
266                        tool_use_id,
267                        content,
268                        ..
269                    } => out.push(json!({
270                        "role": "tool",
271                        "tool_call_id": tool_use_id,
272                        "content": content,
273                    })),
274                    _ => {}
275                }
276            }
277            if !text.is_empty() {
278                out.push(json!({"role": "user", "content": text}));
279            }
280        }
281    }
282}
283
284fn decode_finish(s: Option<&str>) -> StopReason {
285    match s {
286        Some("stop") => StopReason::EndTurn,
287        Some("tool_calls") | Some("function_call") => StopReason::ToolUse,
288        Some("length") => StopReason::MaxTokens,
289        Some("content_filter") => StopReason::Refusal,
290        _ => StopReason::Other,
291    }
292}
293
294fn decode_usage(v: Option<&Value>) -> Usage {
295    let Some(v) = v else { return Usage::default() };
296    let g = |k: &str| v.get(k).and_then(Value::as_u64).unwrap_or(0);
297    let prompt = g("prompt_tokens");
298
299    // The two dialects disagree about what the prompt count contains, and
300    // `Usage::total_input` sums all three fields. Anthropic reports
301    // `input_tokens` *beside* the cache tiers; OpenAI reports `prompt_tokens`
302    // with `cached_tokens` already *inside* it. Carrying the cached half over
303    // without subtracting it would report every prompt at nearly twice its
304    // size — and the compaction threshold reads exactly that number, so a long
305    // run would start summarising itself at half the window it actually had.
306    // Saturated because a provider is not to be trusted to keep the subset
307    // relation it documents.
308    let cached = v
309        .pointer("/prompt_tokens_details/cached_tokens")
310        .and_then(Value::as_u64)
311        .unwrap_or(0)
312        .min(prompt);
313
314    // No write tier in this dialect: a local server's prefix cache is filled
315    // as a side effect of serving, never billed or reported separately.
316    Usage {
317        input_tokens: prompt - cached,
318        output_tokens: g("completion_tokens"),
319        cache_read_input_tokens: cached,
320        ..Usage::default()
321    }
322}
323
324/// Whether a turn produced anything the loop counts as output.
325///
326/// Deliberately the same definition `agent.rs` decides `produced_nothing` on —
327/// `Message::text()` collects only `Block::Text`, and `tool_uses()` only
328/// `Block::ToolUse`. Thinking is not output: a model that reasons and says
329/// nothing has still said nothing, and the day this disagrees with the loop is
330/// the day a reasoning-only turn ends a run with an empty answer instead of
331/// being nudged.
332fn produced_output(blocks: &[Block]) -> bool {
333    blocks.iter().any(|b| match b {
334        Block::Text { text } => !text.trim().is_empty(),
335        Block::ToolUse { .. } => true,
336        Block::Thinking { .. } | Block::ToolResult { .. } => false,
337    })
338}
339
340/// The bytes a turn arrived with, when it produced no output at all.
341///
342/// `llama-server --reasoning-format` defaults to `auto`, which for a thinking
343/// model routes the whole `<think>` block into `message.reasoning_content` and
344/// leaves `message.content` null. That channel is now decoded into a
345/// `Block::Thinking` — visible, recorded, and dropped again by
346/// `encode_message` so it is never sent back — but it is still not *output*,
347/// so two very different turns arrive at the loop identically, with no text
348/// and no calls and `finish_reason: "stop"`:
349///
350/// - the model reasoned and then genuinely said nothing, and
351/// - the model said something, or called a tool, inside the reasoning channel.
352///
353/// The agent loop answers both with a nudge and a retry. That is correct for
354/// the first and pure waste for the second — a re-prefill of the whole
355/// transcript to ask for output that was already produced. Measured on the
356/// 2026-08-10 Terminal-Bench run: every one of `break-filter-js-from-html`'s
357/// ten nudges was followed immediately by a well-formed `shell` call, twice a
358/// near-duplicate of one issued a few messages earlier, which is what a lost
359/// tool call looks like — and also what a compliant answer to the nudge looks
360/// like. The two are not separable without these bytes.
361///
362/// So this reports them at the point they are dropped, and deliberately does
363/// **not** change the decode. Recovering a tool call out of a think block is a
364/// behaviour change that wants evidence first, and this is the instrument that
365/// produces it.
366/// Syntaxes that mean "a tool call was written here", across the model
367/// families this backend actually meets.
368///
369/// A table rather than one string, on purpose. The *phenomenon* is general —
370/// a reasoning model writes its action before closing the think block, the
371/// server's parser never sees it, and the turn arrives empty — and only the
372/// syntax is per-family. Measured on Qwen3.6 (2026-08-10): the reasoning of a
373/// reproduced empty turn held a complete `<tool_call><function=shell>…` that
374/// was never parsed. Gemma, Llama and DeepSeek would each write that same
375/// intent differently, and a matcher that knew only Qwen would report their
376/// identical failure as an unexplained silence.
377///
378/// This is a **hint on a warning, never a decision**: nothing branches on it,
379/// so a family missing from this list costs a vaguer log line and nothing
380/// more. Extend it when a new one turns up rather than reaching for a regex —
381/// the failure mode of a clever matcher is a false positive on a model merely
382/// *discussing* tool calls in prose, and this must not become the thing that
383/// decides whether a call gets executed.
384const TOOL_CALL_MARKERS: &[&str] = &[
385    "<tool_call>",           // Qwen, Hermes
386    "<function=",            // Qwen's inner form, also emitted bare
387    "<|python_tag|>",        // Llama 3.x
388    "<|tool▁call▁begin|>", // DeepSeek, fullwidth delimiters
389    "```tool_code",          // Gemma
390    "<function_call>",       // assorted OpenAI-compatible shims
391];
392
393#[derive(Debug, PartialEq)]
394struct DroppedReasoning<'a> {
395    chars: usize,
396    /// A model that started calling before closing its think block. When this
397    /// is true the empty turn was a lost call, not a silent model.
398    looks_like_tool_call: bool,
399    /// The end of the reasoning, which is where a call or a concluded answer
400    /// would sit. The head is throat-clearing.
401    tail: &'a str,
402}
403
404/// `None` when there is nothing to explain: the turn produced output, or the
405/// reasoning channel was empty too.
406fn dropped_reasoning(produced_output: bool, reasoning: &str) -> Option<DroppedReasoning<'_>> {
407    if produced_output || reasoning.trim().is_empty() {
408        return None;
409    }
410    // Counted in chars and sliced on a char boundary: reasoning is model prose
411    // and slicing it by byte would panic on the first multibyte character.
412    let tail = match reasoning.char_indices().rev().nth(400) {
413        Some((i, _)) => &reasoning[i..],
414        None => reasoning,
415    };
416    Some(DroppedReasoning {
417        chars: reasoning.chars().count(),
418        looks_like_tool_call: TOOL_CALL_MARKERS.iter().any(|m| reasoning.contains(m)),
419        tail,
420    })
421}
422
423fn log_dropped_reasoning(produced_output: bool, reasoning: &str, finish: Option<&str>) {
424    if let Some(d) = dropped_reasoning(produced_output, reasoning) {
425        tracing::warn!(
426            reasoning_chars = d.chars,
427            looks_like_tool_call = d.looks_like_tool_call,
428            finish_reason = finish.unwrap_or("<absent>"),
429            tail = d.tail,
430            "turn produced no output but the response carried reasoning_content"
431        );
432        // The whole trace, at debug, because the tail is enough to classify a
433        // silence and never enough to explain it. An empty turn is not in the
434        // transcript at all — the loop nudges and continues before pushing the
435        // message, and the loop holds no session to record it into — so this
436        // log is the only durable record that the turn happened or what was in
437        // it. `MECHA_LOG=debug` is what the bench adapter already runs with,
438        // and it downloads the stderr beside the transcript.
439        tracing::debug!(reasoning = reasoning, "the dropped reasoning, in full");
440    }
441}
442
443fn parse_arguments(name: &str, raw: &str) -> Result<Value> {
444    if raw.trim().is_empty() {
445        return Ok(json!({}));
446    }
447    serde_json::from_str(raw)
448        .with_context(|| format!("tool {name} returned unparseable arguments: {raw}"))
449}
450
451fn decode_response(v: &Value) -> Result<CompletionResponse> {
452    let mut malformed = 0u32;
453    let choice = v.pointer("/choices/0").context("response has no choices")?;
454    let msg = choice.get("message").context("choice has no message")?;
455
456    let mut content = Vec::new();
457    // Thinking first, as Anthropic orders it, so a transcript reads in the
458    // order the model produced it. `signature: None` — that field is
459    // Anthropic's opaque echo-back token and this dialect has no equivalent;
460    // `encode_message` drops the whole block for this API regardless, so
461    // nothing is ever sent back and no context is spent on it.
462    let reasoning = msg
463        .get("reasoning_content")
464        .and_then(Value::as_str)
465        .unwrap_or("");
466    if !reasoning.is_empty() {
467        content.push(Block::Thinking {
468            text: reasoning.to_string(),
469            signature: None,
470        });
471    }
472    if let Some(text) = msg.get("content").and_then(Value::as_str) {
473        if !text.is_empty() {
474            content.push(Block::text(text));
475        }
476    }
477    for call in msg
478        .get("tool_calls")
479        .and_then(Value::as_array)
480        .unwrap_or(&vec![])
481    {
482        let name = call
483            .pointer("/function/name")
484            .and_then(Value::as_str)
485            .unwrap_or_default()
486            .to_string();
487        let raw = call
488            .pointer("/function/arguments")
489            .and_then(Value::as_str)
490            .unwrap_or("");
491        let input = match parse_arguments(&name, raw) {
492            Ok(v) => v,
493            Err(_) => {
494                malformed += 1;
495                json!({"__malformed_arguments": raw})
496            }
497        };
498        content.push(Block::ToolUse {
499            id: call
500                .get("id")
501                .and_then(Value::as_str)
502                .unwrap_or_default()
503                .to_string(),
504            input,
505            name,
506        });
507    }
508
509    let finish = choice.get("finish_reason").and_then(Value::as_str);
510    log_dropped_reasoning(produced_output(&content), reasoning, finish);
511
512    Ok(CompletionResponse {
513        message: Message::assistant(content),
514        stop_reason: decode_finish(finish),
515        usage: decode_usage(v.get("usage")),
516        refusal: None,
517        model: v
518            .get("model")
519            .and_then(Value::as_str)
520            .unwrap_or_default()
521            .to_string(),
522        malformed_tool_args: malformed,
523    })
524}
525
526#[derive(Default)]
527struct Accumulator {
528    text: String,
529    /// Keyed by the `index` field, which is how deltas identify their call.
530    calls: BTreeMap<u64, (String, String, String)>,
531    finish: Option<StopReason>,
532    usage: Usage,
533    model: String,
534    /// Accumulated for the diagnostic only — never turned into a block. See
535    /// `DroppedReasoning`. The streamed dialect carries it as
536    /// `delta.reasoning_content`, alongside the `delta.content` this decodes.
537    reasoning: String,
538}
539
540impl Accumulator {
541    fn push(&mut self, v: &Value, sink: &StreamSink) {
542        if let Some(m) = v.get("model").and_then(Value::as_str) {
543            self.model = m.to_string();
544        }
545        if let Some(u) = v.get("usage") {
546            if !u.is_null() {
547                self.usage = decode_usage(Some(u));
548                // Reported as it arrives, so cancelling mid-stream does not
549                // throw away the count with the dropped future. This dialect
550                // usually sends it only in the final chunk (`include_usage`),
551                // so there is often nothing to salvage — but when there is, it
552                // beats reporting zero.
553                let _ = sink.send(StreamEvent::Usage(self.usage.clone()));
554            }
555        }
556        let Some(choice) = v.pointer("/choices/0") else {
557            return;
558        };
559        if let Some(f) = choice.get("finish_reason").and_then(Value::as_str) {
560            self.finish = Some(decode_finish(Some(f)));
561        }
562        let Some(delta) = choice.get("delta") else {
563            return;
564        };
565
566        if let Some(t) = delta.get("content").and_then(Value::as_str) {
567            self.text.push_str(t);
568            let _ = sink.send(StreamEvent::TextDelta(t.to_string()));
569        }
570        // `ThinkingDelta`, not `TextDelta`: front-ends render the two
571        // differently and an answer is not made of reasoning. This is the
572        // event `anthropic.rs` has always emitted and this dialect never did,
573        // which is why the reasoning toggle did nothing against a local model.
574        if let Some(r) = delta.get("reasoning_content").and_then(Value::as_str) {
575            self.reasoning.push_str(r);
576            let _ = sink.send(StreamEvent::ThinkingDelta(r.to_string()));
577        }
578        for call in delta
579            .get("tool_calls")
580            .and_then(Value::as_array)
581            .unwrap_or(&vec![])
582        {
583            let idx = call.get("index").and_then(Value::as_u64).unwrap_or(0);
584            let entry = self.calls.entry(idx).or_default();
585            if let Some(id) = call.get("id").and_then(Value::as_str) {
586                entry.0 = id.to_string();
587            }
588            if let Some(name) = call.pointer("/function/name").and_then(Value::as_str) {
589                if entry.1.is_empty() && !name.is_empty() {
590                    let _ = sink.send(StreamEvent::ToolUseStart {
591                        name: name.to_string(),
592                    });
593                }
594                entry.1.push_str(name);
595            }
596            if let Some(args) = call.pointer("/function/arguments").and_then(Value::as_str) {
597                entry.2.push_str(args);
598            }
599        }
600    }
601
602    fn finish(self) -> CompletionResponse {
603        let mut content = Vec::new();
604        let mut malformed = 0u32;
605        if !self.reasoning.is_empty() {
606            content.push(Block::Thinking {
607                text: self.reasoning.clone(),
608                signature: None,
609            });
610        }
611        if !self.text.is_empty() {
612            content.push(Block::text(self.text));
613        }
614        for (_, (id, name, args)) in self.calls {
615            // A model that streams malformed arguments gets told so via an
616            // error tool result rather than killing the whole turn.
617            let input = match parse_arguments(&name, &args) {
618                Ok(v) => v,
619                Err(_) => {
620                    malformed += 1;
621                    json!({"__malformed_arguments": args})
622                }
623            };
624            content.push(Block::ToolUse { id, name, input });
625        }
626        log_dropped_reasoning(produced_output(&content), &self.reasoning, None);
627        CompletionResponse {
628            message: Message::assistant(content),
629            stop_reason: self.finish.unwrap_or(StopReason::Other),
630            usage: self.usage,
631            refusal: None,
632            model: self.model,
633            malformed_tool_args: malformed,
634        }
635    }
636}
637
638#[cfg(test)]
639mod tests {
640    use super::*;
641
642    fn provider(temperature: Option<f64>, seed: Option<u64>) -> OpenAiCompatible {
643        OpenAiCompatible::from_config(&ProviderConfig {
644            kind: "local".into(),
645            temperature,
646            seed,
647            ..Default::default()
648        })
649        .unwrap()
650    }
651
652    fn plain_req() -> CompletionRequest {
653        CompletionRequest {
654            model: "m".into(),
655            system: None,
656            messages: vec![Message::user("hi")],
657            tools: Vec::new(),
658            max_tokens: 64,
659            effort: None,
660            thinking: false,
661            cache_prompt: false,
662        }
663    }
664
665    #[test]
666    fn a_pinned_sampler_is_sent_and_an_unpinned_one_is_absent() {
667        let body = provider(Some(0.8), Some(42)).body(&plain_req(), false);
668        assert_eq!(body["temperature"], json!(0.8));
669        assert_eq!(body["seed"], json!(42));
670
671        // Absent, not defaulted: unset means "the server's choice", and
672        // inventing a value here would misrecord what was measured.
673        let body = provider(None, None).body(&plain_req(), false);
674        assert!(body.get("temperature").is_none());
675        assert!(body.get("seed").is_none());
676    }
677
678    fn sink() -> (
679        StreamSink,
680        tokio::sync::mpsc::UnboundedReceiver<StreamEvent>,
681    ) {
682        tokio::sync::mpsc::unbounded_channel()
683    }
684
685    /// One SSE chunk carrying a delta for choice 0.
686    fn chunk(delta: Value) -> Value {
687        json!({"choices": [{"index": 0, "delta": delta}]})
688    }
689
690    #[test]
691    fn a_turn_that_produced_output_reports_no_dropped_reasoning() {
692        // The diagnostic is about turns that arrive empty. A turn with output
693        // is ordinary, however much it reasoned on the way, and firing here
694        // would bury the real signal under every thinking turn in the run.
695        assert_eq!(dropped_reasoning(true, "a long think"), None);
696    }
697
698    #[test]
699    fn thinking_is_not_output_so_a_reasoning_only_turn_still_reports() {
700        // The load-bearing one. `decode_response` now turns reasoning into a
701        // Block::Thinking, so a reasoning-only turn is no longer block-empty —
702        // but it is still output-empty, and must still be both nudged by the
703        // loop and reported here. Counting blocks instead of output would
704        // silence the instrument at the exact moment it matters.
705        let blocks = vec![Block::Thinking {
706            text: "thinking".into(),
707            signature: None,
708        }];
709        assert!(!produced_output(&blocks));
710        assert!(dropped_reasoning(produced_output(&blocks), "thinking").is_some());
711    }
712
713    #[test]
714    fn whitespace_only_text_is_not_output_either() {
715        // `agent.rs` decides on `text.trim().is_empty()`; disagreeing here
716        // would report a turn the loop nudges, or stay silent on one it does.
717        assert!(!produced_output(&[Block::text("  \n ")]));
718        assert!(produced_output(&[Block::text("an answer")]));
719        assert!(produced_output(&[Block::ToolUse {
720            id: "t1".into(),
721            name: "shell".into(),
722            input: json!({}),
723        }]));
724    }
725
726    #[test]
727    fn an_empty_turn_with_an_empty_reasoning_channel_reports_nothing() {
728        // Nothing arrived anywhere: the model really did say nothing, and
729        // there are no dropped bytes to show. Whitespace counts as empty, or
730        // a lone newline would be reported as if it were evidence.
731        assert_eq!(dropped_reasoning(false, ""), None);
732        assert_eq!(dropped_reasoning(false, "  \n "), None);
733    }
734
735    #[test]
736    fn an_empty_turn_carrying_a_tool_call_in_its_reasoning_is_named_as_one() {
737        // The whole question the instrument exists to answer: was the empty
738        // turn a silent model, or a call we failed to see?
739        let d = dropped_reasoning(
740            false,
741            "let me check the file\n<tool_call>\n{\"name\": \"shell\"}",
742        )
743        .expect("an empty turn with reasoning is reportable");
744        assert!(
745            d.looks_like_tool_call,
746            "a <tool_call> in the think block is the lost-call signature"
747        );
748    }
749
750    #[test]
751    fn reasoning_survives_the_round_trip_and_rides_back_with_the_turn() {
752        // The whole fix, end to end: what the server sends as
753        // `reasoning_content` must come back as `reasoning_content`, beside
754        // the call it belongs to. Stripping it is what produced a history of
755        // bare tool calls with no thinking, and with it the model reproduced
756        // that shape 6 times in 6 — see `encode_message`.
757        let v = json!({
758            "choices": [{
759                "finish_reason": "tool_calls",
760                "message": {
761                    "role": "assistant",
762                    "content": null,
763                    "reasoning_content": "I should list the directory first.",
764                    "tool_calls": [{
765                        "id": "call_1",
766                        "type": "function",
767                        "function": {"name": "shell", "arguments": "{\"command\": \"ls\"}"},
768                    }],
769                },
770            }],
771            "model": "qwen3.6-35b-a3b",
772        });
773        let decoded = decode_response(&v).unwrap();
774
775        let mut out = Vec::new();
776        encode_message(&decoded.message, &mut out);
777        assert_eq!(out.len(), 1);
778        assert_eq!(
779            out[0]["reasoning_content"], "I should list the directory first.",
780            "the reasoning was dropped on the way back out"
781        );
782        assert_eq!(out[0]["tool_calls"][0]["function"]["name"], "shell");
783        assert_eq!(
784            out[0]["content"],
785            Value::Null,
786            "reasoning must not leak into content"
787        );
788    }
789
790    #[test]
791    fn cached_prompt_tokens_are_split_out_rather_than_counted_twice() {
792        // The load-bearing one. `total_input` sums the three fields and the
793        // compaction threshold reads it, so carrying `cached_tokens` over
794        // without removing it from `prompt_tokens` would report a 1,000-token
795        // prompt as 1,800 and compact at little over half the real window.
796        let u = decode_usage(Some(&json!({
797            "prompt_tokens": 1000,
798            "completion_tokens": 42,
799            "prompt_tokens_details": {"cached_tokens": 800},
800        })));
801        assert_eq!(u.input_tokens, 200, "the uncached remainder");
802        assert_eq!(u.cache_read_input_tokens, 800);
803        assert_eq!(u.output_tokens, 42);
804        assert_eq!(
805            u.total_input(),
806            1000,
807            "the reported prompt size must survive the split unchanged"
808        );
809    }
810
811    #[test]
812    fn a_server_that_reports_no_cache_detail_is_unchanged() {
813        // Most of this dialect's servers say nothing about caching. They must
814        // keep reporting exactly what they did before this field was read.
815        let u = decode_usage(Some(&json!({"prompt_tokens": 500, "completion_tokens": 7})));
816        assert_eq!(u.input_tokens, 500);
817        assert_eq!(u.cache_read_input_tokens, 0);
818        assert_eq!(u.total_input(), 500);
819    }
820
821    #[test]
822    fn a_cached_count_larger_than_the_prompt_cannot_underflow() {
823        // Subset by documentation, not by guarantee — and `input_tokens` is
824        // unsigned, so believing a bad provider would panic in release-mode
825        // wrapping or produce an astronomical prompt size.
826        let u = decode_usage(Some(&json!({
827            "prompt_tokens": 100,
828            "completion_tokens": 1,
829            "prompt_tokens_details": {"cached_tokens": 9999},
830        })));
831        assert_eq!(u.input_tokens, 0);
832        assert_eq!(u.cache_read_input_tokens, 100);
833        assert_eq!(u.total_input(), 100);
834    }
835
836    #[test]
837    fn a_turn_with_no_thinking_sends_no_reasoning_field() {
838        // Absent, not empty. A server rendering the field conditionally must
839        // see it missing rather than see an empty think block — and this is
840        // also what keeps the round trip self-gating: an endpoint that never
841        // sends `reasoning_content` never receives one.
842        let mut out = Vec::new();
843        encode_message(&Message::assistant(vec![Block::text("done")]), &mut out);
844        assert!(
845            out[0].get("reasoning_content").is_none(),
846            "an unrelated endpoint must not be sent a field it never spoke"
847        );
848    }
849
850    #[test]
851    fn the_lost_call_signature_is_not_only_qwens() {
852        // The failure is a property of reasoning models, not of one vendor,
853        // and this backend serves every OpenAI-compatible server there is.
854        // Recognising only the family that happened to be on the bench would
855        // report an identical Gemma or Llama failure as an unexplained
856        // silence — which is how a diagnostic quietly stops working when the
857        // model changes.
858        for (family, reasoning) in [
859            (
860                "gemma",
861                "let me check\n```tool_code\nprint(shell(...))\n```",
862            ),
863            (
864                "llama",
865                "first I will look\n<|python_tag|>{\"name\": \"shell\"}",
866            ),
867            ("deepseek", "checking\n<|tool▁call▁begin|>shell"),
868            ("hermes", "<function_call>{\"name\": \"shell\"}"),
869        ] {
870            let d = dropped_reasoning(false, reasoning)
871                .unwrap_or_else(|| panic!("{family}: reportable"));
872            assert!(d.looks_like_tool_call, "{family} went unrecognised");
873        }
874    }
875
876    #[test]
877    fn reasoning_without_a_call_is_reported_but_not_labelled_a_call() {
878        let d = dropped_reasoning(false, "I think the answer is 42, so I am done.")
879            .expect("an empty turn with reasoning is reportable");
880        assert!(!d.looks_like_tool_call);
881        assert_eq!(d.chars, 39);
882    }
883
884    #[test]
885    fn the_tail_is_kept_and_multibyte_reasoning_does_not_panic() {
886        // The end is where a call or a concluded answer sits, so the tail is
887        // the part worth keeping. Slicing model prose by byte offset would
888        // panic on the first multibyte character — and reasoning is exactly
889        // where an em dash or a CJK identifier turns up.
890        let long = format!("{}—the answer is 42", "x".repeat(5_000));
891        let d = dropped_reasoning(false, &long).expect("reportable");
892        assert_eq!(d.chars, 5_017);
893        assert!(d.tail.ends_with("—the answer is 42"));
894        assert!(
895            d.tail.chars().count() <= 401,
896            "the tail is bounded, not the whole think block"
897        );
898
899        // Short reasoning is kept whole rather than sliced to nothing.
900        let d = dropped_reasoning(false, "早い").expect("reportable");
901        assert_eq!(d.tail, "早い");
902    }
903
904    #[test]
905    fn llama_servers_empty_turn_shape_decodes_to_no_blocks_and_is_reported() {
906        // The exact wire shape behind the 2026-08-07 and 2026-08-10 empty
907        // turns: `--reasoning-format` defaults to `auto`, which puts the think
908        // block in `reasoning_content` and leaves `content` null, with
909        // `finish_reason: "stop"` — NOT "length", so this is not truncation
910        // and no budget raise addresses it.
911        let v = json!({
912            "choices": [{
913                "finish_reason": "stop",
914                "message": {
915                    "role": "assistant",
916                    "content": null,
917                    "reasoning_content": "I should read the file first.\n<tool_call>",
918                },
919            }],
920            "model": "qwen3.6-35b-a3b",
921        });
922        let resp = decode_response(&v).unwrap();
923
924        assert_eq!(resp.stop_reason, StopReason::EndTurn);
925
926        // The reasoning is now kept, as a Thinking block — visible in the TUI
927        // and recorded in the transcript, where before it was discarded on the
928        // floor.
929        assert_eq!(
930            resp.message.content.len(),
931            1,
932            "reasoning_content should survive decoding as a Thinking block"
933        );
934        assert!(matches!(resp.message.content[0], Block::Thinking { .. }));
935
936        // But it is NOT an answer. `text()` is what the loop reads, and it must
937        // stay empty or a reasoning-only turn ends the run with nothing said.
938        assert_eq!(
939            resp.message.text(),
940            "",
941            "reasoning_content must never silently become the answer"
942        );
943        assert!(resp.message.tool_uses().is_empty());
944        assert!(!produced_output(&resp.message.content));
945
946        // Still the shape the diagnostic exists for, and still reported.
947        let d = dropped_reasoning(
948            produced_output(&resp.message.content),
949            "I should read the file first.\n<tool_call>",
950        )
951        .expect("this is the shape the diagnostic exists for");
952        assert!(d.looks_like_tool_call);
953    }
954
955    #[test]
956    fn streamed_reasoning_arrives_as_thinking_and_never_as_answer_text() {
957        // Same rule on the streaming path, which had the same blind spot. The
958        // reasoning reaches the front-end as ThinkingDelta — the event
959        // anthropic.rs has always sent and this dialect never did — and still
960        // never counts as the answer.
961        let (tx, mut rx) = sink();
962        let mut acc = Accumulator::default();
963        acc.push(&chunk(json!({"reasoning_content": "thinking "})), &tx);
964        acc.push(&chunk(json!({"reasoning_content": "hard"})), &tx);
965        acc.push(
966            &json!({"choices": [{"index": 0, "finish_reason": "stop", "delta": {}}]}),
967            &tx,
968        );
969
970        // Asserted before `finish` consumes it, and asserted at all because
971        // the rest of this test is about absence: without this, deleting the
972        // accumulation entirely would leave every assertion below still true.
973        assert_eq!(
974            acc.reasoning, "thinking hard",
975            "deltas must accumulate, or the diagnostic has nothing to report"
976        );
977
978        let resp = acc.finish();
979        assert_eq!(resp.stop_reason, StopReason::EndTurn);
980        assert_eq!(
981            resp.message.text(),
982            "",
983            "a reasoning-only stream produced no answer"
984        );
985        assert!(
986            !produced_output(&resp.message.content),
987            "and must still be nudged rather than ending the run"
988        );
989
990        // The deltas went out as thinking, never as text.
991        rx.close();
992        let mut events = Vec::new();
993        while let Ok(e) = rx.try_recv() {
994            events.push(e);
995        }
996        let thinking: Vec<_> = events
997            .iter()
998            .filter_map(|e| match e {
999                StreamEvent::ThinkingDelta(t) => Some(t.as_str()),
1000                _ => None,
1001            })
1002            .collect();
1003        assert_eq!(thinking, vec!["thinking ", "hard"]);
1004        assert!(
1005            !events
1006                .iter()
1007                .any(|e| matches!(e, StreamEvent::TextDelta(_))),
1008            "reasoning must not be emitted as a TextDelta"
1009        );
1010    }
1011
1012    fn call_delta(index: u64, id: Option<&str>, name: Option<&str>, args: &str) -> Value {
1013        let mut function = serde_json::Map::new();
1014        if let Some(name) = name {
1015            function.insert("name".into(), json!(name));
1016        }
1017        function.insert("arguments".into(), json!(args));
1018
1019        let mut call = serde_json::Map::new();
1020        call.insert("index".into(), json!(index));
1021        if let Some(id) = id {
1022            call.insert("id".into(), json!(id));
1023        }
1024        call.insert("type".into(), json!("function"));
1025        call.insert("function".into(), Value::Object(function));
1026
1027        chunk(json!({"tool_calls": [Value::Object(call)]}))
1028    }
1029
1030    #[test]
1031    fn tool_call_arguments_split_across_chunks_reassemble_into_one_object() {
1032        // Arguments arrive as partial JSON that only parses once complete, and
1033        // the name can be split too. Every fragment has to land in the same
1034        // slot or the call is silently corrupted rather than loudly broken.
1035        let (tx, _rx) = sink();
1036        let mut acc = Accumulator::default();
1037
1038        acc.push(&call_delta(0, Some("call_1"), Some("fs_"), ""), &tx);
1039        acc.push(&call_delta(0, None, Some("read"), "{\"pa"), &tx);
1040        acc.push(&call_delta(0, None, None, "th\": \"notes/"), &tx);
1041        acc.push(&call_delta(0, None, None, "a.md\"}"), &tx);
1042
1043        let resp = acc.finish();
1044        let calls = resp.message.tool_uses();
1045
1046        assert_eq!(calls.len(), 1);
1047        assert_eq!(calls[0].0, "call_1");
1048        assert_eq!(calls[0].1, "fs_read");
1049        assert_eq!(calls[0].2, &json!({"path": "notes/a.md"}));
1050        assert_eq!(resp.malformed_tool_args, 0);
1051    }
1052
1053    #[test]
1054    fn parallel_tool_calls_are_kept_apart_by_their_index() {
1055        // Interleaved on purpose: the index is the only thing tying a fragment
1056        // to its call, and merging two calls produces arguments that parse.
1057        let (tx, _rx) = sink();
1058        let mut acc = Accumulator::default();
1059
1060        acc.push(
1061            &call_delta(0, Some("call_a"), Some("fs_read"), "{\"path\":"),
1062            &tx,
1063        );
1064        acc.push(
1065            &call_delta(1, Some("call_b"), Some("shell"), "{\"cmd\":"),
1066            &tx,
1067        );
1068        acc.push(&call_delta(0, None, None, " \"a.md\"}"), &tx);
1069        acc.push(&call_delta(1, None, None, " \"ls\"}"), &tx);
1070
1071        let resp = acc.finish();
1072        let calls = resp.message.tool_uses();
1073
1074        assert_eq!(calls.len(), 2);
1075        assert_eq!(calls[0].1, "fs_read");
1076        assert_eq!(calls[0].2, &json!({"path": "a.md"}));
1077        assert_eq!(calls[1].1, "shell");
1078        assert_eq!(calls[1].2, &json!({"cmd": "ls"}));
1079    }
1080
1081    #[test]
1082    fn a_call_with_no_arguments_becomes_an_empty_object_not_a_parse_failure() {
1083        let (tx, _rx) = sink();
1084        let mut acc = Accumulator::default();
1085        acc.push(&call_delta(0, Some("call_1"), Some("todo_read"), ""), &tx);
1086
1087        let resp = acc.finish();
1088        assert_eq!(resp.message.tool_uses()[0].2, &json!({}));
1089        assert_eq!(resp.malformed_tool_args, 0);
1090    }
1091
1092    #[test]
1093    fn malformed_arguments_are_counted_and_handed_back_rather_than_killing_the_turn() {
1094        let (tx, _rx) = sink();
1095        let mut acc = Accumulator::default();
1096        acc.push(
1097            &call_delta(0, Some("call_1"), Some("fs_read"), "{\"path\": "),
1098            &tx,
1099        );
1100
1101        let resp = acc.finish();
1102
1103        // The model gets an error result it can retry from, and the count is
1104        // the reliability signal worth comparing models on.
1105        assert_eq!(resp.malformed_tool_args, 1);
1106        assert!(resp.message.tool_uses()[0]
1107            .2
1108            .get("__malformed_arguments")
1109            .is_some());
1110    }
1111
1112    #[test]
1113    fn tool_calls_are_still_decoded_when_the_server_says_the_turn_merely_stopped() {
1114        // llama-server reports `finish_reason: "stop"` alongside tool_calls.
1115        // The loop re-classifies any turn containing tool_use blocks, but that
1116        // only works if the blocks survive decoding in the first place.
1117        let (tx, _rx) = sink();
1118        let mut acc = Accumulator::default();
1119        acc.push(
1120            &call_delta(0, Some("call_1"), Some("fs_read"), "{\"path\": \"a.md\"}"),
1121            &tx,
1122        );
1123        acc.push(
1124            &json!({"choices": [{"index": 0, "finish_reason": "stop", "delta": {}}]}),
1125            &tx,
1126        );
1127
1128        let resp = acc.finish();
1129        assert_eq!(resp.stop_reason, StopReason::EndTurn);
1130        assert_eq!(
1131            resp.message.tool_uses().len(),
1132            1,
1133            "the calls were dropped with the label"
1134        );
1135
1136        // And the same on the non-streaming path.
1137        let v = json!({
1138            "choices": [{
1139                "finish_reason": "stop",
1140                "message": {
1141                    "content": null,
1142                    "tool_calls": [{
1143                        "id": "call_1",
1144                        "type": "function",
1145                        "function": {"name": "fs_read", "arguments": "{\"path\": \"a.md\"}"},
1146                    }],
1147                },
1148            }],
1149            "model": "local",
1150        });
1151        let resp = decode_response(&v).unwrap();
1152        assert_eq!(resp.stop_reason, StopReason::EndTurn);
1153        assert_eq!(resp.message.tool_uses().len(), 1);
1154    }
1155
1156    #[test]
1157    fn text_and_tool_calls_in_one_turn_both_survive() {
1158        let (tx, _rx) = sink();
1159        let mut acc = Accumulator::default();
1160        acc.push(&chunk(json!({"content": "let me look. "})), &tx);
1161        acc.push(&call_delta(0, Some("call_1"), Some("fs_read"), "{}"), &tx);
1162        acc.push(&chunk(json!({"content": "one moment."})), &tx);
1163
1164        let resp = acc.finish();
1165        assert_eq!(resp.message.text(), "let me look. one moment.");
1166        assert_eq!(resp.message.tool_uses().len(), 1);
1167    }
1168
1169    #[test]
1170    fn tool_results_become_their_own_messages_and_a_steer_follows_them() {
1171        // The OpenAI half of the steering placement: results are `role: "tool"`
1172        // messages, and the queued text trails them as a `role: "user"` — never
1173        // ahead, which would put a user message between the assistant's call
1174        // and its result.
1175        let mut out = Vec::new();
1176        encode_message(
1177            &Message::tool_results(vec![
1178                Block::ToolResult {
1179                    tool_use_id: "t1".into(),
1180                    content: "42".into(),
1181                    is_error: false,
1182                },
1183                Block::ToolResult {
1184                    tool_use_id: "t2".into(),
1185                    content: "7".into(),
1186                    is_error: false,
1187                },
1188                Block::text("actually, focus on X"),
1189            ]),
1190            &mut out,
1191        );
1192
1193        assert_eq!(out.len(), 3);
1194        assert_eq!(out[0]["role"], "tool");
1195        assert_eq!(out[0]["tool_call_id"], "t1");
1196        assert_eq!(out[1]["role"], "tool");
1197        assert_eq!(out[1]["tool_call_id"], "t2");
1198        assert_eq!(out[2]["role"], "user");
1199        assert_eq!(out[2]["content"], "actually, focus on X");
1200    }
1201
1202    #[test]
1203    fn an_assistant_turn_carries_its_tool_calls_inline_with_arguments_as_a_string() {
1204        let mut out = Vec::new();
1205        encode_message(
1206            &Message::assistant(vec![Block::ToolUse {
1207                id: "call_1".into(),
1208                name: "fs_read".into(),
1209                input: json!({"path": "a.md"}),
1210            }]),
1211            &mut out,
1212        );
1213
1214        assert_eq!(out.len(), 1);
1215        assert_eq!(out[0]["role"], "assistant");
1216        assert_eq!(out[0]["content"], Value::Null);
1217        // A JSON *string*, not an object — sending the object is a 400 here.
1218        let args = out[0]["tool_calls"][0]["function"]["arguments"]
1219            .as_str()
1220            .unwrap();
1221        assert_eq!(
1222            serde_json::from_str::<Value>(args).unwrap(),
1223            json!({"path": "a.md"})
1224        );
1225    }
1226}