1use serde_json::{Map, Value, json};
6use sim_codec::{DecodeBudget, DecodeLimits};
7use sim_codec_json::{JsonProjectionMode, json_number_to_u64, project_json_to_expr_budgeted};
8use sim_kernel::{CodecId, Error, Expr, Result, Symbol};
9use sim_value::access;
10
11use crate::{
12 is_model_request_expr, model_response_expr, text_part, usage_record, validate_chat_transcript,
13};
14
15const OLLAMA_CODEC_ID: CodecId = CodecId(0);
20
21#[derive(Clone, Debug, PartialEq, Eq)]
24pub struct OllamaRequestOptions {
25 pub model: String,
27 pub stream: bool,
29 pub tools: bool,
31}
32
33impl OllamaRequestOptions {
34 pub fn new(model: impl Into<String>, stream: bool, tools: bool) -> Self {
37 Self {
38 model: model.into(),
39 stream,
40 tools,
41 }
42 }
43}
44
45pub fn encode_ollama_request(expr: &Expr, options: &OllamaRequestOptions) -> Result<Vec<u8>> {
68 if !is_model_request_expr(expr) {
69 return Err(Error::Eval(
70 "ollama codec expects a model-request transcript".to_owned(),
71 ));
72 }
73 validate_chat_transcript(expr)?;
74 let mut payload = Map::new();
75 payload.insert("model".to_owned(), Value::String(options.model.clone()));
76 payload.insert("stream".to_owned(), Value::Bool(options.stream));
77 payload.insert(
78 "messages".to_owned(),
79 Value::Array(transcript_messages(expr)?),
80 );
81 if options.tools {
82 payload.insert("tools".to_owned(), Value::Array(Vec::new()));
83 }
84 serde_json::to_vec(&Value::Object(payload))
85 .map_err(|err| Error::Eval(format!("ollama codec failed to encode request: {err}")))
86}
87
88pub fn decode_ollama_response(
95 runner: Symbol,
96 model: &str,
97 body: &[u8],
98 include_raw: bool,
99) -> Result<Expr> {
100 let mut budget = DecodeBudget::new(DecodeLimits::default());
101 budget.check_input_bytes(OLLAMA_CODEC_ID, body.len())?;
102 let value: Value = serde_json::from_slice(body)
103 .map_err(|err| Error::Eval(format!("ollama codec returned invalid json: {err}")))?;
104 response_expr_from_json(runner, model, &value, include_raw, &mut budget)
105}
106
107pub fn decode_ollama_stream(
115 runner: Symbol,
116 model: &str,
117 body: &[u8],
118 include_raw: bool,
119) -> Result<Expr> {
120 let mut budget = DecodeBudget::new(DecodeLimits::default());
121 budget.check_input_bytes(OLLAMA_CODEC_ID, body.len())?;
122 let text = std::str::from_utf8(body)
123 .map_err(|err| Error::Eval(format!("ollama stream is not valid utf-8: {err}")))?;
124 let mut chunks = Vec::new();
125 let mut combined = String::new();
126 let mut usage_source = None;
127 let mut stop_reason = Symbol::new("stop");
128 for line in text.lines() {
129 let trimmed = line.trim();
130 if trimmed.is_empty() {
131 continue;
132 }
133 let value: Value = serde_json::from_str(trimmed)
134 .map_err(|err| Error::Eval(format!("ollama stream returned invalid json: {err}")))?;
135 combined.push_str(&response_chunk_text(&value)?);
136 if usage_expr_from_value(&value)?.is_some() {
137 usage_source = Some(value.clone());
138 }
139 if let Some(reason) = value.get("done_reason").and_then(Value::as_str) {
140 stop_reason = Symbol::new(reason);
141 } else if value.get("done").and_then(Value::as_bool).unwrap_or(false) {
142 stop_reason = Symbol::new("stop");
143 }
144 chunks.push(value);
145 }
146 if chunks.is_empty() {
147 return Err(Error::Eval(
148 "ollama stream did not contain any response chunks".to_owned(),
149 ));
150 }
151 let mut entries =
152 match model_response_expr(runner, model, vec![text_part(&combined)], stop_reason) {
153 Expr::Map(entries) => entries,
154 _ => unreachable!("model_response_expr always returns a map"),
155 };
156 if let Some(source) = usage_source.as_ref()
157 && let Some(usage) = usage_expr_from_value(source)?
158 {
159 entries.push((Expr::Symbol(Symbol::new("usage")), usage));
160 }
161 if include_raw {
162 let raw = chunks
163 .iter()
164 .map(|chunk| {
165 project_json_to_expr_budgeted(
166 chunk,
167 JsonProjectionMode::UntaggedInterop,
168 OLLAMA_CODEC_ID,
169 &mut budget,
170 0,
171 )
172 })
173 .collect::<Result<Vec<_>>>()?;
174 entries.push((
175 Expr::Symbol(Symbol::new("raw-provider-response")),
176 Expr::List(raw),
177 ));
178 }
179 Ok(Expr::Map(entries))
180}
181
182fn response_expr_from_json(
183 runner: Symbol,
184 model: &str,
185 value: &Value,
186 include_raw: bool,
187 budget: &mut DecodeBudget,
188) -> Result<Expr> {
189 let response = value
190 .as_object()
191 .ok_or_else(|| Error::Eval("ollama response must be a json object".to_owned()))?;
192 let content = response_content(response)?;
193 let stop_reason = response
194 .get("done_reason")
195 .and_then(Value::as_str)
196 .unwrap_or("stop");
197 let mut entries = match model_response_expr(
198 runner,
199 model,
200 vec![text_part(&content)],
201 Symbol::new(stop_reason),
202 ) {
203 Expr::Map(entries) => entries,
204 _ => unreachable!("model_response_expr always returns a map"),
205 };
206 if let Some(usage) = usage_expr_from_value(value)? {
207 entries.push((Expr::Symbol(Symbol::new("usage")), usage));
208 }
209 if include_raw {
210 entries.push((
211 Expr::Symbol(Symbol::new("raw-provider-response")),
212 project_json_to_expr_budgeted(
213 value,
214 JsonProjectionMode::UntaggedInterop,
215 OLLAMA_CODEC_ID,
216 budget,
217 0,
218 )?,
219 ));
220 }
221 Ok(Expr::Map(entries))
222}
223
224fn transcript_messages(expr: &Expr) -> Result<Vec<Value>> {
225 let Expr::Map(entries) = expr else {
226 return Err(Error::Eval(
227 "ollama codec expects request transcript as a map".to_owned(),
228 ));
229 };
230 let mut messages = access::entry_required_list_any(entries, "messages", "ollama messages")?
231 .iter()
232 .map(message_to_json)
233 .collect::<Result<Vec<_>>>()?;
234 messages.push(json!({
235 "role": "user",
236 "content": flatten_expr(map_field(entries, "task")?),
237 }));
238 Ok(messages)
239}
240
241fn message_to_json(expr: &Expr) -> Result<Value> {
242 let Expr::Map(entries) = expr else {
243 return Err(Error::Eval("ollama codec message must be a map".to_owned()));
244 };
245 let role = access::entry_required_sym_any(entries, "role", "ollama message role")?;
246 let content = access::entry_required_list_any(entries, "content", "ollama message content")?
247 .iter()
248 .map(content_part_to_text)
249 .collect::<Result<Vec<_>>>()?
250 .join(" ");
251 Ok(json!({
252 "role": role.name.as_ref(),
253 "content": content,
254 }))
255}
256
257fn content_part_to_text(expr: &Expr) -> Result<String> {
258 let Expr::Map(entries) = expr else {
259 return Err(Error::Eval(
260 "ollama codec content part must be a map".to_owned(),
261 ));
262 };
263 match access::entry_required_sym_any(entries, "type", "ollama content part type")?
264 .name
265 .as_ref()
266 {
267 "text" => Ok(
268 access::entry_required_str_any(entries, "text", "ollama content part text")?.to_owned(),
269 ),
270 other => Err(Error::Eval(format!(
271 "ollama codec does not support content part type {other}"
272 ))),
273 }
274}
275
276fn response_content(response: &Map<String, Value>) -> Result<String> {
277 if let Some(content) = response
278 .get("message")
279 .and_then(Value::as_object)
280 .and_then(|message| message.get("content"))
281 .and_then(Value::as_str)
282 {
283 return Ok(content.to_owned());
284 }
285 if let Some(content) = response.get("response").and_then(Value::as_str) {
286 return Ok(content.to_owned());
287 }
288 Err(Error::Eval(
289 "ollama response missing message.content or response".to_owned(),
290 ))
291}
292
293fn response_chunk_text(value: &Value) -> Result<String> {
294 let object = value
295 .as_object()
296 .ok_or_else(|| Error::Eval("ollama stream chunk must be a json object".to_owned()))?;
297 if let Some(content) = object
298 .get("message")
299 .and_then(Value::as_object)
300 .and_then(|message| message.get("content"))
301 .and_then(Value::as_str)
302 {
303 return Ok(content.to_owned());
304 }
305 if let Some(content) = object.get("response").and_then(Value::as_str) {
306 return Ok(content.to_owned());
307 }
308 Ok(String::new())
309}
310
311fn usage_expr_from_value(value: &Value) -> Result<Option<Expr>> {
312 let response = value
313 .as_object()
314 .ok_or_else(|| Error::Eval("ollama usage source must be a json object".to_owned()))?;
315 let input = response
316 .get("prompt_eval_count")
317 .and_then(json_number_to_u64);
318 let output = response.get("eval_count").and_then(json_number_to_u64);
319 let total = input
322 .zip(output)
323 .map(|(input, output)| input.saturating_add(output));
324 let fields = usage_record(input, output, total);
325 Ok((!fields.is_empty()).then_some(Expr::Map(fields)))
326}
327
328fn map_field<'a>(entries: &'a [(Expr, Expr)], key: &str) -> Result<&'a Expr> {
329 entries
330 .iter()
331 .find_map(|(field, value)| match field {
332 Expr::Symbol(symbol) if symbol.name.as_ref() == key => Some(value),
333 _ => None,
334 })
335 .ok_or_else(|| Error::Eval(format!("ollama codec missing {key} field")))
336}
337
338fn flatten_expr(expr: &Expr) -> String {
339 match expr {
340 Expr::Nil => "nil".to_owned(),
341 Expr::Bool(flag) => flag.to_string(),
342 Expr::Number(number) => number.canonical.clone(),
343 Expr::Symbol(symbol) | Expr::Local(symbol) => symbol.to_string(),
344 Expr::String(text) => text.clone(),
345 Expr::Bytes(bytes) => format!("{bytes:?}"),
346 Expr::List(items) | Expr::Vector(items) | Expr::Set(items) | Expr::Block(items) => {
347 items.iter().map(flatten_expr).collect::<Vec<_>>().join(" ")
348 }
349 Expr::Map(entries) => entries
350 .iter()
351 .map(|(key, value)| format!("{} {}", flatten_expr(key), flatten_expr(value)))
352 .collect::<Vec<_>>()
353 .join(" "),
354 Expr::Call { operator, args } => std::iter::once(flatten_expr(operator))
355 .chain(args.iter().map(flatten_expr))
356 .collect::<Vec<_>>()
357 .join(" "),
358 Expr::Infix {
359 operator,
360 left,
361 right,
362 } => format!(
363 "{} {} {}",
364 flatten_expr(left),
365 operator,
366 flatten_expr(right)
367 ),
368 Expr::Prefix { operator, arg } => format!("{operator} {}", flatten_expr(arg)),
369 Expr::Postfix { operator, arg } => format!("{} {operator}", flatten_expr(arg)),
370 Expr::Quote { expr, .. } | Expr::Annotated { expr, .. } => flatten_expr(expr),
371 Expr::Extension { tag, payload } => format!("{tag} {}", flatten_expr(payload)),
372 }
373}