use serde_json::Value;
pub const OLLAMA_CHAT_REQUIRED_KEYS: &[&str] = &[
"model",
"created_at",
"message",
"done",
"total_duration",
"load_duration",
"prompt_eval_count",
"prompt_eval_duration",
"eval_count",
"eval_duration",
];
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OllamaSchemaOutcome {
Ok,
NotAnObject,
MissingRequiredKey { key: &'static str },
MessageNotAnObject,
WrongMessageRole { got: Option<String> },
NotDone { got: Value },
}
pub fn classify_ollama_chat_schema(response: &Value) -> OllamaSchemaOutcome {
let obj = match response.as_object() {
Some(o) => o,
None => return OllamaSchemaOutcome::NotAnObject,
};
for &key in OLLAMA_CHAT_REQUIRED_KEYS {
if !obj.contains_key(key) {
return OllamaSchemaOutcome::MissingRequiredKey { key };
}
}
let message = obj.get("message").expect("presence checked above");
let message_obj = match message.as_object() {
Some(o) => o,
None => return OllamaSchemaOutcome::MessageNotAnObject,
};
match message_obj.get("role").and_then(Value::as_str) {
Some("assistant") => {}
Some(other) => {
return OllamaSchemaOutcome::WrongMessageRole {
got: Some(other.to_string()),
};
}
None => return OllamaSchemaOutcome::WrongMessageRole { got: None },
}
let done = obj.get("done").expect("presence checked above");
if done.as_bool() != Some(true) {
return OllamaSchemaOutcome::NotDone { got: done.clone() };
}
OllamaSchemaOutcome::Ok
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum EvalMetricsOutcome {
Ok,
EvalCountZeroWithContent,
EvalDurationZeroWithContent,
}
pub fn classify_eval_metrics(
eval_count: u64,
eval_duration: u64,
message_content_nonempty: bool,
) -> EvalMetricsOutcome {
if !message_content_nonempty {
return EvalMetricsOutcome::Ok;
}
if eval_count == 0 {
return EvalMetricsOutcome::EvalCountZeroWithContent;
}
if eval_duration == 0 {
return EvalMetricsOutcome::EvalDurationZeroWithContent;
}
EvalMetricsOutcome::Ok
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NdjsonStreamOutcome {
Ok,
NoFrames,
FrameNotAnObject { frame_index: usize },
MissingDoneField { frame_index: usize },
DoneNotBool { frame_index: usize },
TerminalFrameNotDone { last_frame_index: usize },
EarlyDoneFrame { frame_index: usize },
NoTerminalFrame,
}
pub fn classify_ndjson_stream(frames: &[Value]) -> NdjsonStreamOutcome {
if frames.is_empty() {
return NdjsonStreamOutcome::NoFrames;
}
let last_idx = frames.len() - 1;
let mut terminal_seen = false;
for (i, frame) in frames.iter().enumerate() {
let obj = match frame.as_object() {
Some(o) => o,
None => return NdjsonStreamOutcome::FrameNotAnObject { frame_index: i },
};
let done_val = match obj.get("done") {
Some(v) => v,
None => return NdjsonStreamOutcome::MissingDoneField { frame_index: i },
};
let done = match done_val.as_bool() {
Some(b) => b,
None => return NdjsonStreamOutcome::DoneNotBool { frame_index: i },
};
if done {
if i != last_idx {
return NdjsonStreamOutcome::EarlyDoneFrame { frame_index: i };
}
terminal_seen = true;
}
}
if !terminal_seen {
return NdjsonStreamOutcome::TerminalFrameNotDone {
last_frame_index: last_idx,
};
}
NdjsonStreamOutcome::Ok
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn well_formed_response() -> Value {
json!({
"model": "tiny",
"created_at": "2026-04-21T00:00:00Z",
"message": {"role": "assistant", "content": "ok"},
"done": true,
"total_duration": 1000_u64,
"load_duration": 100_u64,
"prompt_eval_count": 2_u64,
"prompt_eval_duration": 200_u64,
"eval_count": 1_u64,
"eval_duration": 500_u64,
})
}
#[test]
fn schema_ok_on_well_formed_response() {
assert_eq!(
classify_ollama_chat_schema(&well_formed_response()),
OllamaSchemaOutcome::Ok
);
}
#[test]
fn schema_rejects_non_object() {
assert_eq!(
classify_ollama_chat_schema(&json!([1, 2, 3])),
OllamaSchemaOutcome::NotAnObject
);
}
#[test]
fn schema_rejects_missing_each_required_key() {
for &key in OLLAMA_CHAT_REQUIRED_KEYS {
let mut resp = well_formed_response();
resp.as_object_mut().unwrap().remove(key);
let outcome = classify_ollama_chat_schema(&resp);
assert_eq!(
outcome,
OllamaSchemaOutcome::MissingRequiredKey { key },
"expected MissingRequiredKey({key}), got {outcome:?}"
);
}
}
#[test]
fn schema_rejects_non_object_message() {
let mut resp = well_formed_response();
resp["message"] = json!("just a string");
assert_eq!(
classify_ollama_chat_schema(&resp),
OllamaSchemaOutcome::MessageNotAnObject
);
}
#[test]
fn schema_rejects_wrong_role() {
let mut resp = well_formed_response();
resp["message"] = json!({"role": "user", "content": "x"});
assert_eq!(
classify_ollama_chat_schema(&resp),
OllamaSchemaOutcome::WrongMessageRole {
got: Some("user".to_string())
}
);
}
#[test]
fn schema_rejects_missing_role() {
let mut resp = well_formed_response();
resp["message"] = json!({"content": "x"});
assert_eq!(
classify_ollama_chat_schema(&resp),
OllamaSchemaOutcome::WrongMessageRole { got: None }
);
}
#[test]
fn schema_rejects_done_false() {
let mut resp = well_formed_response();
resp["done"] = json!(false);
assert_eq!(
classify_ollama_chat_schema(&resp),
OllamaSchemaOutcome::NotDone { got: json!(false) }
);
}
#[test]
fn schema_rejects_done_non_bool() {
let mut resp = well_formed_response();
resp["done"] = json!("true");
assert_eq!(
classify_ollama_chat_schema(&resp),
OllamaSchemaOutcome::NotDone { got: json!("true") }
);
}
#[test]
fn schema_classifier_is_deterministic() {
let r = well_formed_response();
let a = classify_ollama_chat_schema(&r);
let b = classify_ollama_chat_schema(&r);
assert_eq!(a, b);
}
#[test]
fn eval_metrics_ok_on_nonempty_reply() {
assert_eq!(classify_eval_metrics(1, 500, true), EvalMetricsOutcome::Ok);
}
#[test]
fn eval_metrics_ok_on_empty_reply_even_with_zero_metrics() {
assert_eq!(classify_eval_metrics(0, 0, false), EvalMetricsOutcome::Ok);
}
#[test]
fn eval_metrics_rejects_eval_count_zero_with_content() {
assert_eq!(
classify_eval_metrics(0, 500, true),
EvalMetricsOutcome::EvalCountZeroWithContent
);
}
#[test]
fn eval_metrics_rejects_eval_duration_zero_with_content() {
assert_eq!(
classify_eval_metrics(1, 0, true),
EvalMetricsOutcome::EvalDurationZeroWithContent
);
}
#[test]
fn eval_metrics_is_deterministic() {
let a = classify_eval_metrics(3, 42, true);
let b = classify_eval_metrics(3, 42, true);
assert_eq!(a, b);
}
fn delta_frame(content: &str) -> Value {
json!({"message": {"role": "assistant", "content": content}, "done": false})
}
fn terminal_frame() -> Value {
json!({"done": true, "eval_count": 3, "eval_duration": 500})
}
#[test]
fn ndjson_ok_on_well_formed_stream() {
let frames = vec![delta_frame("he"), delta_frame("llo"), terminal_frame()];
assert_eq!(classify_ndjson_stream(&frames), NdjsonStreamOutcome::Ok);
}
#[test]
fn ndjson_ok_on_single_terminal_frame() {
let frames = vec![terminal_frame()];
assert_eq!(classify_ndjson_stream(&frames), NdjsonStreamOutcome::Ok);
}
#[test]
fn ndjson_rejects_empty_stream() {
let frames: Vec<Value> = vec![];
assert_eq!(
classify_ndjson_stream(&frames),
NdjsonStreamOutcome::NoFrames
);
}
#[test]
fn ndjson_rejects_non_object_frame() {
let frames = vec![json!([1, 2]), terminal_frame()];
assert_eq!(
classify_ndjson_stream(&frames),
NdjsonStreamOutcome::FrameNotAnObject { frame_index: 0 }
);
}
#[test]
fn ndjson_rejects_missing_done_field() {
let frames = vec![
json!({"message": {"role":"assistant","content":"x"}}),
terminal_frame(),
];
assert_eq!(
classify_ndjson_stream(&frames),
NdjsonStreamOutcome::MissingDoneField { frame_index: 0 }
);
}
#[test]
fn ndjson_rejects_non_bool_done() {
let frames = vec![json!({"done": "false"}), terminal_frame()];
assert_eq!(
classify_ndjson_stream(&frames),
NdjsonStreamOutcome::DoneNotBool { frame_index: 0 }
);
}
#[test]
fn ndjson_rejects_early_done_true() {
let frames = vec![
delta_frame("hi"),
json!({"done": true}),
delta_frame("!"),
terminal_frame(),
];
assert_eq!(
classify_ndjson_stream(&frames),
NdjsonStreamOutcome::EarlyDoneFrame { frame_index: 1 }
);
}
#[test]
fn ndjson_rejects_no_terminal_frame() {
let frames = vec![delta_frame("a"), delta_frame("b")];
assert_eq!(
classify_ndjson_stream(&frames),
NdjsonStreamOutcome::TerminalFrameNotDone {
last_frame_index: 1
}
);
}
#[test]
fn ndjson_classifier_is_deterministic() {
let frames = vec![delta_frame("a"), terminal_frame()];
let a = classify_ndjson_stream(&frames);
let b = classify_ndjson_stream(&frames);
assert_eq!(a, b);
}
#[test]
fn ndjson_first_frame_done_true_alone_is_ok() {
let frames = vec![terminal_frame()];
assert_eq!(classify_ndjson_stream(&frames), NdjsonStreamOutcome::Ok);
}
#[test]
fn required_keys_has_expected_count_and_order() {
assert_eq!(OLLAMA_CHAT_REQUIRED_KEYS.len(), 10);
assert_eq!(OLLAMA_CHAT_REQUIRED_KEYS[0], "model");
assert_eq!(OLLAMA_CHAT_REQUIRED_KEYS[9], "eval_duration");
}
}