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