use crate::config::ProviderConfig;
use crate::message::*;
use crate::provider::{Provider, StreamEvent, StreamSink};
use anyhow::{Context, Result};
use async_trait::async_trait;
use futures::StreamExt;
use serde_json::{json, Value};
use std::collections::BTreeMap;
pub struct OpenAiCompatible {
http: reqwest::Client,
api_key: Option<String>,
base_url: String,
default_model: String,
temperature: Option<f64>,
seed: Option<u64>,
id: String,
vision: bool,
retry: crate::provider::retry::RetryPolicy,
}
impl OpenAiCompatible {
pub fn from_config(cfg: &ProviderConfig) -> Result<Self> {
Ok(Self {
http: reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(900))
.build()?,
api_key: cfg.resolve_api_key(),
base_url: cfg
.base_url
.clone()
.unwrap_or_else(|| "https://api.openai.com".to_string()),
default_model: cfg
.model
.clone()
.unwrap_or_else(|| "gpt-4o-mini".to_string()),
temperature: cfg.temperature,
seed: cfg.seed,
id: cfg.kind.clone(),
vision: cfg.vision_enabled(),
retry: crate::provider::retry::RetryPolicy::from_config(cfg),
})
}
fn body(&self, req: &CompletionRequest, stream: bool) -> Value {
let mut messages = Vec::new();
if let Some(system) = &req.system {
messages.push(json!({"role": "system", "content": system}));
}
for m in &req.messages {
encode_message(m, &mut messages, self.vision);
}
let mut body = json!({
"model": req.model,
"max_tokens": req.max_tokens,
"messages": messages,
});
let obj = body.as_object_mut().unwrap();
if let Some(t) = self.temperature {
obj.insert("temperature".into(), json!(t));
}
if let Some(s) = self.seed {
obj.insert("seed".into(), json!(s));
}
if stream {
obj.insert("stream".into(), json!(true));
obj.insert("stream_options".into(), json!({"include_usage": true}));
}
if !req.tools.is_empty() {
let tools: Vec<Value> = req
.tools
.iter()
.map(|t| {
json!({"type": "function", "function": {
"name": t.name,
"description": t.description,
"parameters": t.input_schema,
}})
})
.collect();
obj.insert("tools".into(), json!(tools));
}
body
}
fn request(&self, body: &Value) -> reqwest::RequestBuilder {
let mut rb = self
.http
.post(format!(
"{}/v1/chat/completions",
self.base_url.trim_end_matches('/')
))
.header("content-type", "application/json");
if let Some(key) = &self.api_key {
rb = rb.bearer_auth(key);
}
rb.json(body)
}
}
#[async_trait]
impl Provider for OpenAiCompatible {
fn id(&self) -> &str {
&self.id
}
fn default_model(&self) -> &str {
&self.default_model
}
fn vision(&self) -> bool {
self.vision
}
async fn complete(
&self,
req: &CompletionRequest,
sink: Option<&StreamSink>,
) -> Result<CompletionResponse> {
let body = self.body(req, sink.is_some());
let resp = crate::provider::retry::send_with_retry(|| self.request(&body), &self.retry)
.await
.map_err(|f| {
let message = match f.status {
Some(status) => format!(
"{} {status}: {}",
self.id,
f.detail.chars().take(500).collect::<String>()
),
None => format!("{}: {}", self.id, f.detail),
};
anyhow::Error::new(f.class).context(message)
})?;
let Some(sink) = sink else {
let v: Value = resp.json().await.context("malformed response body")?;
return decode_response(&v);
};
let mut acc = Accumulator::default();
let mut buf = crate::provider::sse::SseBuffer::default();
let mut stream = resp.bytes_stream();
while let Some(chunk) = stream.next().await {
buf.push(&chunk?);
while let Some(line) = buf.next_segment(b"\n") {
let Some(data) = line.trim().strip_prefix("data:") else {
continue;
};
let data = data.trim();
if data.is_empty() || data == "[DONE]" {
continue;
}
let v: Value = serde_json::from_str(data).context("malformed SSE data frame")?;
acc.push(&v, sink);
}
}
Ok(acc.finish())
}
}
#[cfg(test)]
pub(crate) fn encode_message_for_test(m: &Message, out: &mut Vec<Value>, vision: bool) {
encode_message(m, out, vision)
}
fn encode_message(m: &Message, out: &mut Vec<Value>, vision: bool) {
match m.role {
Role::Assistant => {
let mut text = String::new();
let mut reasoning = String::new();
let mut tool_calls = Vec::new();
for b in &m.content {
match b {
Block::Text { text: t } => text.push_str(t),
Block::Thinking { text: t, .. } => reasoning.push_str(t),
Block::ToolUse { id, name, input } => tool_calls.push(json!({
"id": id,
"type": "function",
"function": {"name": name, "arguments": input.to_string()},
})),
Block::ToolResult { .. } => {}
Block::Image { .. } => {}
}
}
let mut msg = json!({"role": "assistant"});
let obj = msg.as_object_mut().unwrap();
obj.insert(
"content".into(),
if text.is_empty() {
Value::Null
} else {
json!(text)
},
);
if !tool_calls.is_empty() {
obj.insert("tool_calls".into(), json!(tool_calls));
}
if !reasoning.is_empty() {
obj.insert("reasoning_content".into(), json!(reasoning));
}
out.push(msg);
}
Role::User => {
let mut text = String::new();
let mut images = Vec::new();
for b in &m.content {
match b {
Block::Text { text: t } => text.push_str(t),
Block::ToolResult {
tool_use_id,
content,
..
} => out.push(json!({
"role": "tool",
"tool_call_id": tool_use_id,
"content": content,
})),
Block::Image {
media_type, data, ..
} if vision => images.push(json!({
"type": "image_url",
"image_url": {"url": format!("data:{media_type};base64,{data}")},
})),
Block::Image {
media_type, source, ..
} => {
if !text.is_empty() && !text.ends_with('\n') {
text.push('\n');
}
text.push_str(&Block::image_placeholder(media_type, source.as_deref()));
}
_ => {}
}
}
if images.is_empty() {
if !text.is_empty() {
out.push(json!({"role": "user", "content": text}));
}
} else {
let mut parts = Vec::with_capacity(images.len() + 1);
if !text.is_empty() {
parts.push(json!({"type": "text", "text": text}));
}
parts.append(&mut images);
out.push(json!({"role": "user", "content": parts}));
}
}
}
}
fn decode_finish(s: Option<&str>) -> StopReason {
match s {
Some("stop") => StopReason::EndTurn,
Some("tool_calls") | Some("function_call") => StopReason::ToolUse,
Some("length") => StopReason::MaxTokens,
Some("content_filter") => StopReason::Refusal,
_ => StopReason::Other,
}
}
fn decode_usage(v: Option<&Value>) -> Usage {
let Some(v) = v else { return Usage::default() };
let g = |k: &str| v.get(k).and_then(Value::as_u64).unwrap_or(0);
let prompt = g("prompt_tokens");
let cached = v
.pointer("/prompt_tokens_details/cached_tokens")
.and_then(Value::as_u64)
.unwrap_or(0)
.min(prompt);
Usage {
input_tokens: prompt - cached,
output_tokens: g("completion_tokens"),
cache_read_input_tokens: cached,
..Usage::default()
}
}
fn produced_output(blocks: &[Block]) -> bool {
blocks.iter().any(|b| match b {
Block::Text { text } => !text.trim().is_empty(),
Block::ToolUse { .. } => true,
Block::Thinking { .. } | Block::ToolResult { .. } | Block::Image { .. } => false,
})
}
const TOOL_CALL_MARKERS: &[&str] = &[
"<tool_call>", "<function=", "<|python_tag|>", "<|tool▁call▁begin|>", "```tool_code", "<function_call>", ];
#[derive(Debug, PartialEq)]
struct DroppedReasoning<'a> {
chars: usize,
looks_like_tool_call: bool,
tail: &'a str,
}
fn dropped_reasoning(produced_output: bool, reasoning: &str) -> Option<DroppedReasoning<'_>> {
if produced_output || reasoning.trim().is_empty() {
return None;
}
let tail = match reasoning.char_indices().rev().nth(400) {
Some((i, _)) => &reasoning[i..],
None => reasoning,
};
Some(DroppedReasoning {
chars: reasoning.chars().count(),
looks_like_tool_call: TOOL_CALL_MARKERS.iter().any(|m| reasoning.contains(m)),
tail,
})
}
fn log_dropped_reasoning(produced_output: bool, reasoning: &str, finish: Option<&str>) {
if let Some(d) = dropped_reasoning(produced_output, reasoning) {
tracing::warn!(
reasoning_chars = d.chars,
looks_like_tool_call = d.looks_like_tool_call,
finish_reason = finish.unwrap_or("<absent>"),
tail = d.tail,
"turn produced no output but the response carried reasoning_content"
);
tracing::debug!(reasoning = reasoning, "the dropped reasoning, in full");
}
}
fn parse_arguments(name: &str, raw: &str) -> Result<Value> {
if raw.trim().is_empty() {
return Ok(json!({}));
}
serde_json::from_str(raw)
.with_context(|| format!("tool {name} returned unparseable arguments: {raw}"))
}
fn decode_response(v: &Value) -> Result<CompletionResponse> {
let mut malformed = 0u32;
let choice = v.pointer("/choices/0").context("response has no choices")?;
let msg = choice.get("message").context("choice has no message")?;
let mut content = Vec::new();
let reasoning = msg
.get("reasoning_content")
.and_then(Value::as_str)
.unwrap_or("");
if !reasoning.is_empty() {
content.push(Block::Thinking {
text: reasoning.to_string(),
signature: None,
});
}
if let Some(text) = msg.get("content").and_then(Value::as_str) {
if !text.is_empty() {
content.push(Block::text(text));
}
}
for call in msg
.get("tool_calls")
.and_then(Value::as_array)
.unwrap_or(&vec![])
{
let name = call
.pointer("/function/name")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let raw = call
.pointer("/function/arguments")
.and_then(Value::as_str)
.unwrap_or("");
let input = match parse_arguments(&name, raw) {
Ok(v) => v,
Err(_) => {
malformed += 1;
json!({"__malformed_arguments": raw})
}
};
content.push(Block::ToolUse {
id: call
.get("id")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
input,
name,
});
}
let finish = choice.get("finish_reason").and_then(Value::as_str);
log_dropped_reasoning(produced_output(&content), reasoning, finish);
Ok(CompletionResponse {
message: Message::assistant(content),
stop_reason: decode_finish(finish),
usage: decode_usage(v.get("usage")),
refusal: None,
model: v
.get("model")
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
malformed_tool_args: malformed,
})
}
#[derive(Default)]
struct Accumulator {
text: String,
calls: BTreeMap<u64, (String, String, String)>,
finish: Option<StopReason>,
usage: Usage,
model: String,
reasoning: String,
}
impl Accumulator {
fn push(&mut self, v: &Value, sink: &StreamSink) {
if let Some(m) = v.get("model").and_then(Value::as_str) {
self.model = m.to_string();
}
if let Some(u) = v.get("usage") {
if !u.is_null() {
self.usage = decode_usage(Some(u));
let _ = sink.send(StreamEvent::Usage(self.usage.clone()));
}
}
let Some(choice) = v.pointer("/choices/0") else {
return;
};
if let Some(f) = choice.get("finish_reason").and_then(Value::as_str) {
self.finish = Some(decode_finish(Some(f)));
}
let Some(delta) = choice.get("delta") else {
return;
};
if let Some(t) = delta.get("content").and_then(Value::as_str) {
self.text.push_str(t);
let _ = sink.send(StreamEvent::TextDelta(t.to_string()));
}
if let Some(r) = delta.get("reasoning_content").and_then(Value::as_str) {
self.reasoning.push_str(r);
let _ = sink.send(StreamEvent::ThinkingDelta(r.to_string()));
}
for call in delta
.get("tool_calls")
.and_then(Value::as_array)
.unwrap_or(&vec![])
{
let idx = call.get("index").and_then(Value::as_u64).unwrap_or(0);
let entry = self.calls.entry(idx).or_default();
if let Some(id) = call.get("id").and_then(Value::as_str) {
entry.0 = id.to_string();
}
if let Some(name) = call.pointer("/function/name").and_then(Value::as_str) {
if entry.1.is_empty() && !name.is_empty() {
let _ = sink.send(StreamEvent::ToolUseStart {
name: name.to_string(),
});
}
entry.1.push_str(name);
}
if let Some(args) = call.pointer("/function/arguments").and_then(Value::as_str) {
entry.2.push_str(args);
}
}
}
fn finish(self) -> CompletionResponse {
let mut content = Vec::new();
let mut malformed = 0u32;
if !self.reasoning.is_empty() {
content.push(Block::Thinking {
text: self.reasoning.clone(),
signature: None,
});
}
if !self.text.is_empty() {
content.push(Block::text(self.text));
}
for (_, (id, name, args)) in self.calls {
let input = match parse_arguments(&name, &args) {
Ok(v) => v,
Err(_) => {
malformed += 1;
json!({"__malformed_arguments": args})
}
};
content.push(Block::ToolUse { id, name, input });
}
log_dropped_reasoning(produced_output(&content), &self.reasoning, None);
CompletionResponse {
message: Message::assistant(content),
stop_reason: self.finish.unwrap_or(StopReason::Other),
usage: self.usage,
refusal: None,
model: self.model,
malformed_tool_args: malformed,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn provider(temperature: Option<f64>, seed: Option<u64>) -> OpenAiCompatible {
OpenAiCompatible::from_config(&ProviderConfig {
kind: "local".into(),
temperature,
seed,
..Default::default()
})
.unwrap()
}
fn plain_req() -> CompletionRequest {
CompletionRequest {
model: "m".into(),
system: None,
messages: vec![Message::user("hi")],
tools: Vec::new(),
max_tokens: 64,
effort: None,
thinking: false,
cache_prompt: false,
}
}
#[test]
fn a_pinned_sampler_is_sent_and_an_unpinned_one_is_absent() {
let body = provider(Some(0.8), Some(42)).body(&plain_req(), false);
assert_eq!(body["temperature"], json!(0.8));
assert_eq!(body["seed"], json!(42));
let body = provider(None, None).body(&plain_req(), false);
assert!(body.get("temperature").is_none());
assert!(body.get("seed").is_none());
}
fn sink() -> (
StreamSink,
tokio::sync::mpsc::UnboundedReceiver<StreamEvent>,
) {
tokio::sync::mpsc::unbounded_channel()
}
fn chunk(delta: Value) -> Value {
json!({"choices": [{"index": 0, "delta": delta}]})
}
#[test]
fn a_turn_that_produced_output_reports_no_dropped_reasoning() {
assert_eq!(dropped_reasoning(true, "a long think"), None);
}
#[test]
fn thinking_is_not_output_so_a_reasoning_only_turn_still_reports() {
let blocks = vec![Block::Thinking {
text: "thinking".into(),
signature: None,
}];
assert!(!produced_output(&blocks));
assert!(dropped_reasoning(produced_output(&blocks), "thinking").is_some());
}
#[test]
fn whitespace_only_text_is_not_output_either() {
assert!(!produced_output(&[Block::text(" \n ")]));
assert!(produced_output(&[Block::text("an answer")]));
assert!(produced_output(&[Block::ToolUse {
id: "t1".into(),
name: "shell".into(),
input: json!({}),
}]));
}
#[test]
fn an_empty_turn_with_an_empty_reasoning_channel_reports_nothing() {
assert_eq!(dropped_reasoning(false, ""), None);
assert_eq!(dropped_reasoning(false, " \n "), None);
}
#[test]
fn an_empty_turn_carrying_a_tool_call_in_its_reasoning_is_named_as_one() {
let d = dropped_reasoning(
false,
"let me check the file\n<tool_call>\n{\"name\": \"shell\"}",
)
.expect("an empty turn with reasoning is reportable");
assert!(
d.looks_like_tool_call,
"a <tool_call> in the think block is the lost-call signature"
);
}
#[test]
fn reasoning_survives_the_round_trip_and_rides_back_with_the_turn() {
let v = json!({
"choices": [{
"finish_reason": "tool_calls",
"message": {
"role": "assistant",
"content": null,
"reasoning_content": "I should list the directory first.",
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "shell", "arguments": "{\"command\": \"ls\"}"},
}],
},
}],
"model": "qwen3.6-35b-a3b",
});
let decoded = decode_response(&v).unwrap();
let mut out = Vec::new();
encode_message(&decoded.message, &mut out, false);
assert_eq!(out.len(), 1);
assert_eq!(
out[0]["reasoning_content"], "I should list the directory first.",
"the reasoning was dropped on the way back out"
);
assert_eq!(out[0]["tool_calls"][0]["function"]["name"], "shell");
assert_eq!(
out[0]["content"],
Value::Null,
"reasoning must not leak into content"
);
}
#[test]
fn cached_prompt_tokens_are_split_out_rather_than_counted_twice() {
let u = decode_usage(Some(&json!({
"prompt_tokens": 1000,
"completion_tokens": 42,
"prompt_tokens_details": {"cached_tokens": 800},
})));
assert_eq!(u.input_tokens, 200, "the uncached remainder");
assert_eq!(u.cache_read_input_tokens, 800);
assert_eq!(u.output_tokens, 42);
assert_eq!(
u.total_input(),
1000,
"the reported prompt size must survive the split unchanged"
);
}
#[test]
fn a_server_that_reports_no_cache_detail_is_unchanged() {
let u = decode_usage(Some(&json!({"prompt_tokens": 500, "completion_tokens": 7})));
assert_eq!(u.input_tokens, 500);
assert_eq!(u.cache_read_input_tokens, 0);
assert_eq!(u.total_input(), 500);
}
#[test]
fn a_cached_count_larger_than_the_prompt_cannot_underflow() {
let u = decode_usage(Some(&json!({
"prompt_tokens": 100,
"completion_tokens": 1,
"prompt_tokens_details": {"cached_tokens": 9999},
})));
assert_eq!(u.input_tokens, 0);
assert_eq!(u.cache_read_input_tokens, 100);
assert_eq!(u.total_input(), 100);
}
#[test]
fn a_turn_with_no_thinking_sends_no_reasoning_field() {
let mut out = Vec::new();
encode_message(
&Message::assistant(vec![Block::text("done")]),
&mut out,
false,
);
assert!(
out[0].get("reasoning_content").is_none(),
"an unrelated endpoint must not be sent a field it never spoke"
);
}
#[test]
fn the_lost_call_signature_is_not_only_qwens() {
for (family, reasoning) in [
(
"gemma",
"let me check\n```tool_code\nprint(shell(...))\n```",
),
(
"llama",
"first I will look\n<|python_tag|>{\"name\": \"shell\"}",
),
("deepseek", "checking\n<|tool▁call▁begin|>shell"),
("hermes", "<function_call>{\"name\": \"shell\"}"),
] {
let d = dropped_reasoning(false, reasoning)
.unwrap_or_else(|| panic!("{family}: reportable"));
assert!(d.looks_like_tool_call, "{family} went unrecognised");
}
}
#[test]
fn reasoning_without_a_call_is_reported_but_not_labelled_a_call() {
let d = dropped_reasoning(false, "I think the answer is 42, so I am done.")
.expect("an empty turn with reasoning is reportable");
assert!(!d.looks_like_tool_call);
assert_eq!(d.chars, 39);
}
#[test]
fn the_tail_is_kept_and_multibyte_reasoning_does_not_panic() {
let long = format!("{}—the answer is 42", "x".repeat(5_000));
let d = dropped_reasoning(false, &long).expect("reportable");
assert_eq!(d.chars, 5_017);
assert!(d.tail.ends_with("—the answer is 42"));
assert!(
d.tail.chars().count() <= 401,
"the tail is bounded, not the whole think block"
);
let d = dropped_reasoning(false, "早い").expect("reportable");
assert_eq!(d.tail, "早い");
}
#[test]
fn llama_servers_empty_turn_shape_decodes_to_no_blocks_and_is_reported() {
let v = json!({
"choices": [{
"finish_reason": "stop",
"message": {
"role": "assistant",
"content": null,
"reasoning_content": "I should read the file first.\n<tool_call>",
},
}],
"model": "qwen3.6-35b-a3b",
});
let resp = decode_response(&v).unwrap();
assert_eq!(resp.stop_reason, StopReason::EndTurn);
assert_eq!(
resp.message.content.len(),
1,
"reasoning_content should survive decoding as a Thinking block"
);
assert!(matches!(resp.message.content[0], Block::Thinking { .. }));
assert_eq!(
resp.message.text(),
"",
"reasoning_content must never silently become the answer"
);
assert!(resp.message.tool_uses().is_empty());
assert!(!produced_output(&resp.message.content));
let d = dropped_reasoning(
produced_output(&resp.message.content),
"I should read the file first.\n<tool_call>",
)
.expect("this is the shape the diagnostic exists for");
assert!(d.looks_like_tool_call);
}
#[test]
fn streamed_reasoning_arrives_as_thinking_and_never_as_answer_text() {
let (tx, mut rx) = sink();
let mut acc = Accumulator::default();
acc.push(&chunk(json!({"reasoning_content": "thinking "})), &tx);
acc.push(&chunk(json!({"reasoning_content": "hard"})), &tx);
acc.push(
&json!({"choices": [{"index": 0, "finish_reason": "stop", "delta": {}}]}),
&tx,
);
assert_eq!(
acc.reasoning, "thinking hard",
"deltas must accumulate, or the diagnostic has nothing to report"
);
let resp = acc.finish();
assert_eq!(resp.stop_reason, StopReason::EndTurn);
assert_eq!(
resp.message.text(),
"",
"a reasoning-only stream produced no answer"
);
assert!(
!produced_output(&resp.message.content),
"and must still be nudged rather than ending the run"
);
rx.close();
let mut events = Vec::new();
while let Ok(e) = rx.try_recv() {
events.push(e);
}
let thinking: Vec<_> = events
.iter()
.filter_map(|e| match e {
StreamEvent::ThinkingDelta(t) => Some(t.as_str()),
_ => None,
})
.collect();
assert_eq!(thinking, vec!["thinking ", "hard"]);
assert!(
!events
.iter()
.any(|e| matches!(e, StreamEvent::TextDelta(_))),
"reasoning must not be emitted as a TextDelta"
);
}
fn call_delta(index: u64, id: Option<&str>, name: Option<&str>, args: &str) -> Value {
let mut function = serde_json::Map::new();
if let Some(name) = name {
function.insert("name".into(), json!(name));
}
function.insert("arguments".into(), json!(args));
let mut call = serde_json::Map::new();
call.insert("index".into(), json!(index));
if let Some(id) = id {
call.insert("id".into(), json!(id));
}
call.insert("type".into(), json!("function"));
call.insert("function".into(), Value::Object(function));
chunk(json!({"tool_calls": [Value::Object(call)]}))
}
#[test]
fn tool_call_arguments_split_across_chunks_reassemble_into_one_object() {
let (tx, _rx) = sink();
let mut acc = Accumulator::default();
acc.push(&call_delta(0, Some("call_1"), Some("fs_"), ""), &tx);
acc.push(&call_delta(0, None, Some("read"), "{\"pa"), &tx);
acc.push(&call_delta(0, None, None, "th\": \"notes/"), &tx);
acc.push(&call_delta(0, None, None, "a.md\"}"), &tx);
let resp = acc.finish();
let calls = resp.message.tool_uses();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "call_1");
assert_eq!(calls[0].1, "fs_read");
assert_eq!(calls[0].2, &json!({"path": "notes/a.md"}));
assert_eq!(resp.malformed_tool_args, 0);
}
#[test]
fn parallel_tool_calls_are_kept_apart_by_their_index() {
let (tx, _rx) = sink();
let mut acc = Accumulator::default();
acc.push(
&call_delta(0, Some("call_a"), Some("fs_read"), "{\"path\":"),
&tx,
);
acc.push(
&call_delta(1, Some("call_b"), Some("shell"), "{\"cmd\":"),
&tx,
);
acc.push(&call_delta(0, None, None, " \"a.md\"}"), &tx);
acc.push(&call_delta(1, None, None, " \"ls\"}"), &tx);
let resp = acc.finish();
let calls = resp.message.tool_uses();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].1, "fs_read");
assert_eq!(calls[0].2, &json!({"path": "a.md"}));
assert_eq!(calls[1].1, "shell");
assert_eq!(calls[1].2, &json!({"cmd": "ls"}));
}
#[test]
fn a_call_with_no_arguments_becomes_an_empty_object_not_a_parse_failure() {
let (tx, _rx) = sink();
let mut acc = Accumulator::default();
acc.push(&call_delta(0, Some("call_1"), Some("todo_read"), ""), &tx);
let resp = acc.finish();
assert_eq!(resp.message.tool_uses()[0].2, &json!({}));
assert_eq!(resp.malformed_tool_args, 0);
}
#[test]
fn malformed_arguments_are_counted_and_handed_back_rather_than_killing_the_turn() {
let (tx, _rx) = sink();
let mut acc = Accumulator::default();
acc.push(
&call_delta(0, Some("call_1"), Some("fs_read"), "{\"path\": "),
&tx,
);
let resp = acc.finish();
assert_eq!(resp.malformed_tool_args, 1);
assert!(resp.message.tool_uses()[0]
.2
.get("__malformed_arguments")
.is_some());
}
#[test]
fn tool_calls_are_still_decoded_when_the_server_says_the_turn_merely_stopped() {
let (tx, _rx) = sink();
let mut acc = Accumulator::default();
acc.push(
&call_delta(0, Some("call_1"), Some("fs_read"), "{\"path\": \"a.md\"}"),
&tx,
);
acc.push(
&json!({"choices": [{"index": 0, "finish_reason": "stop", "delta": {}}]}),
&tx,
);
let resp = acc.finish();
assert_eq!(resp.stop_reason, StopReason::EndTurn);
assert_eq!(
resp.message.tool_uses().len(),
1,
"the calls were dropped with the label"
);
let v = json!({
"choices": [{
"finish_reason": "stop",
"message": {
"content": null,
"tool_calls": [{
"id": "call_1",
"type": "function",
"function": {"name": "fs_read", "arguments": "{\"path\": \"a.md\"}"},
}],
},
}],
"model": "local",
});
let resp = decode_response(&v).unwrap();
assert_eq!(resp.stop_reason, StopReason::EndTurn);
assert_eq!(resp.message.tool_uses().len(), 1);
}
#[test]
fn text_and_tool_calls_in_one_turn_both_survive() {
let (tx, _rx) = sink();
let mut acc = Accumulator::default();
acc.push(&chunk(json!({"content": "let me look. "})), &tx);
acc.push(&call_delta(0, Some("call_1"), Some("fs_read"), "{}"), &tx);
acc.push(&chunk(json!({"content": "one moment."})), &tx);
let resp = acc.finish();
assert_eq!(resp.message.text(), "let me look. one moment.");
assert_eq!(resp.message.tool_uses().len(), 1);
}
#[test]
fn tool_results_become_their_own_messages_and_a_steer_follows_them() {
let mut out = Vec::new();
encode_message(
&Message::tool_results(vec![
Block::ToolResult {
tool_use_id: "t1".into(),
content: "42".into(),
is_error: false,
},
Block::ToolResult {
tool_use_id: "t2".into(),
content: "7".into(),
is_error: false,
},
Block::text("actually, focus on X"),
]),
&mut out,
false,
);
assert_eq!(out.len(), 3);
assert_eq!(out[0]["role"], "tool");
assert_eq!(out[0]["tool_call_id"], "t1");
assert_eq!(out[1]["role"], "tool");
assert_eq!(out[1]["tool_call_id"], "t2");
assert_eq!(out[2]["role"], "user");
assert_eq!(out[2]["content"], "actually, focus on X");
}
#[test]
fn an_image_rides_as_a_parts_array_only_when_the_model_can_see() {
let msg = Message {
role: Role::User,
content: vec![
Block::text("what is this?"),
Block::image("image/png", b"\x89PNG-ish", Some("shot.png".into())),
],
};
let mut seeing = Vec::new();
encode_message(&msg, &mut seeing, true);
assert_eq!(seeing.len(), 1);
let parts = seeing[0]["content"].as_array().unwrap();
assert_eq!(parts.len(), 2);
assert_eq!(parts[0]["type"], "text");
assert_eq!(parts[0]["text"], "what is this?");
assert_eq!(parts[1]["type"], "image_url");
let url = parts[1]["image_url"]["url"].as_str().unwrap();
assert!(
url.starts_with("data:image/png;base64,"),
"the `data:` prefix is this dialect's, added at the wire: {url}"
);
let mut blind = Vec::new();
encode_message(&msg, &mut blind, false);
assert_eq!(blind.len(), 1);
let content = blind[0]["content"].as_str().expect("a plain string");
assert!(content.contains("what is this?"));
assert!(
content.contains("shot.png"),
"a model that cannot see is still told what it was handed: {content}"
);
assert!(
!content.contains("base64") && !content.contains("PNG-ish"),
"and never the payload: {content}"
);
}
#[test]
fn a_message_with_no_image_is_encoded_exactly_as_it_always_was() {
let msg = Message::user("ordinary text");
for vision in [true, false] {
let mut out = Vec::new();
encode_message(&msg, &mut out, vision);
assert_eq!(out.len(), 1);
assert_eq!(
out[0]["content"],
json!("ordinary text"),
"a bare string, never a one-element parts array (vision={vision})"
);
}
}
#[test]
fn an_assistant_turn_carries_its_tool_calls_inline_with_arguments_as_a_string() {
let mut out = Vec::new();
encode_message(
&Message::assistant(vec![Block::ToolUse {
id: "call_1".into(),
name: "fs_read".into(),
input: json!({"path": "a.md"}),
}]),
&mut out,
false,
);
assert_eq!(out.len(), 1);
assert_eq!(out[0]["role"], "assistant");
assert_eq!(out[0]["content"], Value::Null);
let args = out[0]["tool_calls"][0]["function"]["arguments"]
.as_str()
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(args).unwrap(),
json!({"path": "a.md"})
);
}
}