Skip to main content

sim_codec_chat/
ollama.rs

1//! Ollama provider bridge: encode a chat model-request transcript into an
2//! Ollama JSON request, and decode Ollama JSON responses and streamed chunks
3//! back into canonical chat transcript `Expr` values.
4
5use 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
14/// Codec id used to tag decode-budget errors raised while projecting an Ollama
15/// provider response. The Ollama bridge is a set of free functions with no
16/// registered codec id of its own; the value only appears in budget-exceeded
17/// error messages on hostile input.
18const OLLAMA_CODEC_ID: CodecId = CodecId(0);
19
20/// Options controlling how a chat model-request transcript is projected into an
21/// Ollama JSON request body.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub struct OllamaRequestOptions {
24    /// The Ollama model name to send in the request.
25    pub model: String,
26    /// Whether to request a streamed response.
27    pub stream: bool,
28    /// Whether to include a (currently empty) `tools` array in the request.
29    pub tools: bool,
30}
31
32impl OllamaRequestOptions {
33    /// Creates request options from a `model` name and the `stream`/`tools`
34    /// flags.
35    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
44/// Encodes a chat model-request transcript into an Ollama JSON request body.
45///
46/// Fails closed unless `expr` is a valid `model-request` transcript: prior
47/// messages plus the request `task` are flattened into Ollama `messages`, and
48/// the `model`/`stream`/`tools` options from `options` are applied.
49///
50/// # Examples
51///
52/// ```
53/// use sim_codec_chat::{encode_ollama_request, model_card_expr, OllamaRequestOptions};
54/// use sim_kernel::Symbol;
55///
56/// // A model-card is not a model-request, so the codec fails closed.
57/// let card = model_card_expr(
58///     Symbol::new("local-reasoner"),
59///     "qwen2.5-coder:14b",
60///     Symbol::new("ollama"),
61///     Symbol::new("local"),
62/// );
63/// let options = OllamaRequestOptions::new("qwen2.5-coder:14b", false, false);
64/// assert!(encode_ollama_request(&card, &options).is_err());
65/// ```
66pub 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
87/// Decodes a non-streamed Ollama JSON response `body` into a canonical
88/// model-response transcript attributed to `runner` and `model`.
89///
90/// Extracts the response text and any token-usage counts; when `include_raw`
91/// is set, the original JSON is also attached as a `raw-provider-response`
92/// field.
93pub 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
106/// Decodes a newline-delimited Ollama streaming response `body` into a single
107/// canonical model-response transcript attributed to `runner` and `model`.
108///
109/// Concatenates the text of every chunk, derives the stop reason from the final
110/// chunk, and folds in token-usage counts; when `include_raw` is set, the raw
111/// chunks are attached as a `raw-provider-response` list. Errors if no chunks
112/// are present.
113pub 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    // Ollama reports no total; it is derived only when both counts are present.
313    // Saturate rather than wrap so a hostile body cannot overflow the u64 add.
314    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}