use serde_json::{Map, Value, json};
use sim_codec::{DecodeBudget, DecodeLimits};
use sim_codec_json::{JsonProjectionMode, json_number_to_u64, project_json_to_expr_budgeted};
use sim_kernel::{CodecId, Error, Expr, Result, Symbol};
use sim_value::access;
use crate::{
is_model_request_expr, model_response_expr, text_part, usage_record, validate_chat_transcript,
};
const OLLAMA_CODEC_ID: CodecId = CodecId(0);
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OllamaRequestOptions {
pub model: String,
pub stream: bool,
pub tools: bool,
}
impl OllamaRequestOptions {
pub fn new(model: impl Into<String>, stream: bool, tools: bool) -> Self {
Self {
model: model.into(),
stream,
tools,
}
}
}
pub fn encode_ollama_request(expr: &Expr, options: &OllamaRequestOptions) -> Result<Vec<u8>> {
if !is_model_request_expr(expr) {
return Err(Error::Eval(
"ollama codec expects a model-request transcript".to_owned(),
));
}
validate_chat_transcript(expr)?;
let mut payload = Map::new();
payload.insert("model".to_owned(), Value::String(options.model.clone()));
payload.insert("stream".to_owned(), Value::Bool(options.stream));
payload.insert(
"messages".to_owned(),
Value::Array(transcript_messages(expr)?),
);
if options.tools {
payload.insert("tools".to_owned(), Value::Array(Vec::new()));
}
serde_json::to_vec(&Value::Object(payload))
.map_err(|err| Error::Eval(format!("ollama codec failed to encode request: {err}")))
}
pub fn decode_ollama_response(
runner: Symbol,
model: &str,
body: &[u8],
include_raw: bool,
) -> Result<Expr> {
let mut budget = DecodeBudget::new(DecodeLimits::default());
budget.check_input_bytes(OLLAMA_CODEC_ID, body.len())?;
let value: Value = serde_json::from_slice(body)
.map_err(|err| Error::Eval(format!("ollama codec returned invalid json: {err}")))?;
response_expr_from_json(runner, model, &value, include_raw, &mut budget)
}
pub fn decode_ollama_stream(
runner: Symbol,
model: &str,
body: &[u8],
include_raw: bool,
) -> Result<Expr> {
let mut budget = DecodeBudget::new(DecodeLimits::default());
budget.check_input_bytes(OLLAMA_CODEC_ID, body.len())?;
let text = std::str::from_utf8(body)
.map_err(|err| Error::Eval(format!("ollama stream is not valid utf-8: {err}")))?;
let mut chunks = Vec::new();
let mut combined = String::new();
let mut usage_source = None;
let mut stop_reason = Symbol::new("stop");
for line in text.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let value: Value = serde_json::from_str(trimmed)
.map_err(|err| Error::Eval(format!("ollama stream returned invalid json: {err}")))?;
combined.push_str(&response_chunk_text(&value)?);
if usage_expr_from_value(&value)?.is_some() {
usage_source = Some(value.clone());
}
if let Some(reason) = value.get("done_reason").and_then(Value::as_str) {
stop_reason = Symbol::new(reason);
} else if value.get("done").and_then(Value::as_bool).unwrap_or(false) {
stop_reason = Symbol::new("stop");
}
chunks.push(value);
}
if chunks.is_empty() {
return Err(Error::Eval(
"ollama stream did not contain any response chunks".to_owned(),
));
}
let mut entries =
match model_response_expr(runner, model, vec![text_part(&combined)], stop_reason) {
Expr::Map(entries) => entries,
_ => unreachable!("model_response_expr always returns a map"),
};
if let Some(source) = usage_source.as_ref()
&& let Some(usage) = usage_expr_from_value(source)?
{
entries.push((Expr::Symbol(Symbol::new("usage")), usage));
}
if include_raw {
let raw = chunks
.iter()
.map(|chunk| {
project_json_to_expr_budgeted(
chunk,
JsonProjectionMode::UntaggedInterop,
OLLAMA_CODEC_ID,
&mut budget,
0,
)
})
.collect::<Result<Vec<_>>>()?;
entries.push((
Expr::Symbol(Symbol::new("raw-provider-response")),
Expr::List(raw),
));
}
Ok(Expr::Map(entries))
}
fn response_expr_from_json(
runner: Symbol,
model: &str,
value: &Value,
include_raw: bool,
budget: &mut DecodeBudget,
) -> Result<Expr> {
let response = value
.as_object()
.ok_or_else(|| Error::Eval("ollama response must be a json object".to_owned()))?;
let content = response_content(response)?;
let stop_reason = response
.get("done_reason")
.and_then(Value::as_str)
.unwrap_or("stop");
let mut entries = match model_response_expr(
runner,
model,
vec![text_part(&content)],
Symbol::new(stop_reason),
) {
Expr::Map(entries) => entries,
_ => unreachable!("model_response_expr always returns a map"),
};
if let Some(usage) = usage_expr_from_value(value)? {
entries.push((Expr::Symbol(Symbol::new("usage")), usage));
}
if include_raw {
entries.push((
Expr::Symbol(Symbol::new("raw-provider-response")),
project_json_to_expr_budgeted(
value,
JsonProjectionMode::UntaggedInterop,
OLLAMA_CODEC_ID,
budget,
0,
)?,
));
}
Ok(Expr::Map(entries))
}
fn transcript_messages(expr: &Expr) -> Result<Vec<Value>> {
let Expr::Map(entries) = expr else {
return Err(Error::Eval(
"ollama codec expects request transcript as a map".to_owned(),
));
};
let mut messages = access::entry_required_list_any(entries, "messages", "ollama messages")?
.iter()
.map(message_to_json)
.collect::<Result<Vec<_>>>()?;
messages.push(json!({
"role": "user",
"content": flatten_expr(map_field(entries, "task")?),
}));
Ok(messages)
}
fn message_to_json(expr: &Expr) -> Result<Value> {
let Expr::Map(entries) = expr else {
return Err(Error::Eval("ollama codec message must be a map".to_owned()));
};
let role = access::entry_required_sym_any(entries, "role", "ollama message role")?;
let content = access::entry_required_list_any(entries, "content", "ollama message content")?
.iter()
.map(content_part_to_text)
.collect::<Result<Vec<_>>>()?
.join(" ");
Ok(json!({
"role": role.name.as_ref(),
"content": content,
}))
}
fn content_part_to_text(expr: &Expr) -> Result<String> {
let Expr::Map(entries) = expr else {
return Err(Error::Eval(
"ollama codec content part must be a map".to_owned(),
));
};
match access::entry_required_sym_any(entries, "type", "ollama content part type")?
.name
.as_ref()
{
"text" => Ok(
access::entry_required_str_any(entries, "text", "ollama content part text")?.to_owned(),
),
other => Err(Error::Eval(format!(
"ollama codec does not support content part type {other}"
))),
}
}
fn response_content(response: &Map<String, Value>) -> Result<String> {
if let Some(content) = response
.get("message")
.and_then(Value::as_object)
.and_then(|message| message.get("content"))
.and_then(Value::as_str)
{
return Ok(content.to_owned());
}
if let Some(content) = response.get("response").and_then(Value::as_str) {
return Ok(content.to_owned());
}
Err(Error::Eval(
"ollama response missing message.content or response".to_owned(),
))
}
fn response_chunk_text(value: &Value) -> Result<String> {
let object = value
.as_object()
.ok_or_else(|| Error::Eval("ollama stream chunk must be a json object".to_owned()))?;
if let Some(content) = object
.get("message")
.and_then(Value::as_object)
.and_then(|message| message.get("content"))
.and_then(Value::as_str)
{
return Ok(content.to_owned());
}
if let Some(content) = object.get("response").and_then(Value::as_str) {
return Ok(content.to_owned());
}
Ok(String::new())
}
fn usage_expr_from_value(value: &Value) -> Result<Option<Expr>> {
let response = value
.as_object()
.ok_or_else(|| Error::Eval("ollama usage source must be a json object".to_owned()))?;
let input = response
.get("prompt_eval_count")
.and_then(json_number_to_u64);
let output = response.get("eval_count").and_then(json_number_to_u64);
let total = input
.zip(output)
.map(|(input, output)| input.saturating_add(output));
let fields = usage_record(input, output, total);
Ok((!fields.is_empty()).then_some(Expr::Map(fields)))
}
fn map_field<'a>(entries: &'a [(Expr, Expr)], key: &str) -> Result<&'a Expr> {
entries
.iter()
.find_map(|(field, value)| match field {
Expr::Symbol(symbol) if symbol.name.as_ref() == key => Some(value),
_ => None,
})
.ok_or_else(|| Error::Eval(format!("ollama codec missing {key} field")))
}
fn flatten_expr(expr: &Expr) -> String {
match expr {
Expr::Nil => "nil".to_owned(),
Expr::Bool(flag) => flag.to_string(),
Expr::Number(number) => number.canonical.clone(),
Expr::Symbol(symbol) | Expr::Local(symbol) => symbol.to_string(),
Expr::String(text) => text.clone(),
Expr::Bytes(bytes) => format!("{bytes:?}"),
Expr::List(items) | Expr::Vector(items) | Expr::Set(items) | Expr::Block(items) => {
items.iter().map(flatten_expr).collect::<Vec<_>>().join(" ")
}
Expr::Map(entries) => entries
.iter()
.map(|(key, value)| format!("{} {}", flatten_expr(key), flatten_expr(value)))
.collect::<Vec<_>>()
.join(" "),
Expr::Call { operator, args } => std::iter::once(flatten_expr(operator))
.chain(args.iter().map(flatten_expr))
.collect::<Vec<_>>()
.join(" "),
Expr::Infix {
operator,
left,
right,
} => format!(
"{} {} {}",
flatten_expr(left),
operator,
flatten_expr(right)
),
Expr::Prefix { operator, arg } => format!("{operator} {}", flatten_expr(arg)),
Expr::Postfix { operator, arg } => format!("{} {operator}", flatten_expr(arg)),
Expr::Quote { expr, .. } | Expr::Annotated { expr, .. } => flatten_expr(expr),
Expr::Extension { tag, payload } => format!("{tag} {}", flatten_expr(payload)),
}
}