use super::ndjson::consume_ollama_ndjson_lines;
use super::sse::reqwest_send_error;
use super::{
append_ollama_tool_calls, non_stream_send_error, parse_ollama_tool_arguments,
should_request_stream_usage, telemetry_source,
};
use crate::value::{error_to_category, ErrorCategory, VmError};
use std::time::Duration;
async fn real_send_error(timeout: Duration) -> reqwest::Error {
let client = reqwest::Client::builder()
.timeout(timeout)
.connect_timeout(timeout)
.build()
.expect("client builds");
client
.get("http://192.0.2.1:9/never")
.send()
.await
.expect_err("send to unroutable TEST-NET-1 address must fail")
}
#[tokio::test]
async fn non_stream_send_error_is_typed_network_not_substring() {
let raw = real_send_error(Duration::from_millis(150)).await;
let typed = non_stream_send_error("openai", raw);
match &typed {
VmError::CategorizedError { category, .. } => {
assert!(
matches!(
category,
ErrorCategory::Timeout | ErrorCategory::TransientNetwork
),
"expected Timeout/TransientNetwork, got {category:?}"
);
assert!(
category.is_transient(),
"network send error must be transient"
);
}
other => panic!("expected typed CategorizedError, got {other:?}"),
}
assert!(error_to_category(&typed).is_transient());
}
#[tokio::test]
async fn stream_and_non_stream_send_paths_share_one_classifier() {
let stream_err = reqwest_send_error(
"anthropic",
"stream",
real_send_error(Duration::from_millis(150)).await,
);
let request_err = reqwest_send_error(
"anthropic",
"request",
real_send_error(Duration::from_millis(150)).await,
);
assert_eq!(
error_to_category(&stream_err).is_transient(),
error_to_category(&request_err).is_transient(),
"stream and non-stream send errors must classify identically"
);
}
#[test]
fn stream_usage_requested_for_openai_compatible_endpoints() {
assert!(should_request_stream_usage(
false,
false,
"/chat/completions"
));
assert!(should_request_stream_usage(
false,
true,
"/v1/chat/completions"
));
assert!(!should_request_stream_usage(false, true, "/api/chat"));
assert!(!should_request_stream_usage(true, false, "/messages"));
}
#[test]
fn ollama_tool_arguments_accept_object_shape() {
let arguments = serde_json::json!({"path": "README.md"});
assert_eq!(parse_ollama_tool_arguments(&arguments), arguments);
}
#[test]
fn ollama_tool_arguments_parse_json_strings() {
let parsed = parse_ollama_tool_arguments(&serde_json::json!("{\"path\":\"README.md\"}"));
assert_eq!(parsed["path"], "README.md");
}
#[test]
fn ollama_stream_chunks_surface_tool_calls() {
let message = serde_json::json!({
"role": "assistant",
"content": "",
"tool_calls": [{
"function": {
"name": "read_file",
"arguments": {
"path": "README.md"
}
}
}]
});
let mut tool_calls = Vec::new();
let mut blocks = Vec::new();
append_ollama_tool_calls(&message, &mut tool_calls, &mut blocks);
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0]["name"], "read_file");
assert_eq!(tool_calls[0]["arguments"]["path"], "README.md");
assert_eq!(blocks.len(), 1);
assert_eq!(blocks[0]["type"], "tool_call");
}
#[tokio::test]
async fn ollama_ndjson_empty_content_eval_count_is_marked_for_retry() {
let body = b"{\"message\":{\"role\":\"assistant\",\"content\":\"\"},\"done\":true,\"prompt_eval_count\":10,\"eval_count\":4}\n";
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mut warmup_gate = false;
let err = consume_ollama_ndjson_lines(
&body[..],
"ollama",
"stub-model",
tx,
Duration::ZERO,
&mut warmup_gate,
None,
)
.await
.expect_err("empty Ollama parser-bug response should fail");
assert!(
err.to_string()
.contains("[ollama_empty_content_parser_bug]"),
"err was: {err}"
);
}
#[tokio::test]
async fn ollama_ndjson_requires_done_frame() {
let body = b"{\"message\":{\"role\":\"assistant\",\"content\":\"partial\"},\"done\":false}\n";
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mut warmup_gate = false;
let err = consume_ollama_ndjson_lines(
&body[..],
"ollama",
"stub-model",
tx,
Duration::ZERO,
&mut warmup_gate,
None,
)
.await
.expect_err("truncated Ollama stream should fail");
assert!(err.to_string().contains("unexpected EOF before done=true"));
}
#[tokio::test]
async fn ollama_ndjson_captures_server_telemetry_from_done_frame() {
let body = b"{\"message\":{\"role\":\"assistant\",\"content\":\"ok\"},\"done\":false}\n\
{\"message\":{\"role\":\"assistant\",\"content\":\"\"},\"done\":true,\
\"model\":\"devstral-small-2:24b\",\
\"total_duration\":4500000000,\"load_duration\":300000000,\
\"prompt_eval_count\":42,\"prompt_eval_duration\":1100000000,\
\"eval_count\":7,\"eval_duration\":3000000000}\n";
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mut warmup_gate = false;
let result = consume_ollama_ndjson_lines(
&body[..],
"ollama",
"devstral-small-2:24b",
tx,
Duration::ZERO,
&mut warmup_gate,
None,
)
.await
.expect("ollama stream parses");
assert_eq!(result.telemetry.source, telemetry_source::OLLAMA_CHAT);
assert_eq!(result.telemetry.server_total_ms, Some(4500));
assert_eq!(result.telemetry.server_load_ms, Some(300));
assert_eq!(result.telemetry.server_prompt_eval_ms, Some(1100));
assert_eq!(result.telemetry.server_generation_ms, Some(3000));
assert_eq!(result.telemetry.server_prompt_tokens, Some(42));
assert_eq!(result.telemetry.server_output_tokens, Some(7));
assert_eq!(
result.telemetry.runtime_loaded_model.as_deref(),
Some("devstral-small-2:24b")
);
}
#[tokio::test]
async fn ollama_ndjson_done_reason_stop_is_captured_as_stop_reason() {
let body = b"{\"message\":{\"role\":\"assistant\",\"content\":\"hi\"},\"done\":true,\
\"done_reason\":\"stop\",\"prompt_eval_count\":5,\"eval_count\":2}\n";
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mut warmup_gate = false;
let result = consume_ollama_ndjson_lines(
&body[..],
"ollama",
"stub-model",
tx,
Duration::ZERO,
&mut warmup_gate,
None,
)
.await
.expect("ollama stream parses");
assert_eq!(result.stop_reason.as_deref(), Some("stop"));
assert!(!result.cache_supported);
}
#[tokio::test]
async fn ollama_ndjson_thinking_is_private_not_visible_delta() {
let body = b"{\"message\":{\"role\":\"assistant\",\"content\":\"\",\"thinking\":\"private plan\"},\"done\":false}\n\
{\"message\":{\"role\":\"assistant\",\"content\":\"visible answer\"},\"done\":false}\n\
{\"message\":{\"role\":\"assistant\",\"content\":\"\"},\"done\":true,\
\"done_reason\":\"stop\",\"prompt_eval_count\":5,\"eval_count\":4}\n";
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let mut warmup_gate = false;
let result = consume_ollama_ndjson_lines(
&body[..],
"ollama",
"stub-model",
tx,
Duration::ZERO,
&mut warmup_gate,
None,
)
.await
.expect("ollama stream parses");
let mut deltas = Vec::new();
while let Ok(delta) = rx.try_recv() {
deltas.push(delta);
}
assert_eq!(deltas, vec!["visible answer".to_string()]);
assert_eq!(result.text, "visible answer");
assert_eq!(result.thinking.as_deref(), Some("private plan"));
assert!(
!result.text.contains("private plan"),
"private thinking leaked into visible text"
);
assert!(result.blocks.iter().any(|block| {
block["type"] == "reasoning"
&& block["visibility"] == "private"
&& block["text"] == "private plan"
}));
assert!(result.blocks.iter().any(|block| {
block["type"] == "output_text"
&& block["visibility"] == "public"
&& block["text"] == "visible answer"
}));
}
#[tokio::test]
async fn ollama_ndjson_done_reason_length_surfaces_truncation_not_parser_bug() {
let body = b"{\"message\":{\"role\":\"assistant\",\"content\":\"\"},\"done\":true,\
\"done_reason\":\"length\",\"prompt_eval_count\":10,\"eval_count\":8}\n";
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mut warmup_gate = false;
let result = consume_ollama_ndjson_lines(
&body[..],
"ollama",
"stub-model",
tx,
Duration::ZERO,
&mut warmup_gate,
None,
)
.await
.expect("length truncation should return Ok, not the parser-bug error");
assert_eq!(result.stop_reason.as_deref(), Some("length"));
assert!(result.text.is_empty());
assert!(result.tool_calls.is_empty());
}
#[tokio::test]
async fn ollama_ndjson_thinking_only_length_stays_private() {
let body = b"{\"message\":{\"role\":\"assistant\",\"content\":\"\",\"thinking\":\"partial private trace\"},\"done\":true,\
\"done_reason\":\"length\",\"prompt_eval_count\":5,\"eval_count\":4}\n";
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let mut warmup_gate = false;
let result = consume_ollama_ndjson_lines(
&body[..],
"ollama",
"stub-model",
tx,
Duration::ZERO,
&mut warmup_gate,
None,
)
.await
.expect("length truncation should return Ok, not promote thinking");
assert!(rx.try_recv().is_err());
assert_eq!(result.stop_reason.as_deref(), Some("length"));
assert_eq!(result.text, "");
assert_eq!(result.thinking.as_deref(), Some("partial private trace"));
}
#[tokio::test]
async fn ollama_ndjson_empty_content_without_done_reason_is_still_parser_bug() {
let body = b"{\"message\":{\"role\":\"assistant\",\"content\":\"\"},\"done\":true,\
\"done_reason\":\"stop\",\"prompt_eval_count\":10,\"eval_count\":4}\n";
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
let mut warmup_gate = false;
let err = consume_ollama_ndjson_lines(
&body[..],
"ollama",
"stub-model",
tx,
Duration::ZERO,
&mut warmup_gate,
None,
)
.await
.expect_err("empty content with done_reason=stop is still a parser bug");
assert!(
err.to_string()
.contains("[ollama_empty_content_parser_bug]"),
"err was: {err}"
);
}