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