Commit Diff


commit - c5b95489c75049dee80844b53e706be704929dfb
commit + 4a8a5e56a9baa6631fdb1cc3d3d6afc73d8f1596
blob - a7a29a2410c7c3e9c83c8f0a2f3ae13af35f650a
blob + 44bad23e5815399eef88ca35390aadfa6c6d51a7
--- README.md
+++ README.md
@@ -41,14 +41,26 @@ $ cargo install --path .
 
 ## Setup
 
-`cogni` expects an OpenAI API Key to be supplied via `--apikey` option or more
-conveniently `OPENAI_API_KEY` environment variable:
+`cogni` talks to the [Ollama Cloud API](https://docs.ollama.com/cloud). It
+expects an Ollama API Key (create one at
+<https://ollama.com/settings/keys>) supplied via the `--apikey` option or more
+conveniently the `OLLAMA_API_KEY` environment variable:
 
 ```sh
 # in shell configuration
-export OPENAI_API_KEY=sk-DEADBEEF
+export OLLAMA_API_KEY=your-api-key
 ```
 
+By default requests are sent to `https://ollama.com`. To target a different
+host — for example a local Ollama server — set `OLLAMA_API_ENDPOINT`:
+
+```sh
+export OLLAMA_API_ENDPOINT=http://localhost:11434
+```
+
+Pick a model with `-m/--model` (default `gpt-oss:120b`). See
+<https://ollama.com/library> for available model identifiers.
+
 ---
 
 ## Basic Usage
@@ -96,7 +108,7 @@ $ echo "50 + 50" | cogni --system "Solve the following
 
 An gallery of examples to get the inspiration flowing
 
-> :warning: `cogni` uses the [OpenAI API](https://openai.com/blog/openai-api), thus *any data fed into program will be sent to OpenAI*.
+> :warning: `cogni` uses the [Ollama Cloud API](https://docs.ollama.com/cloud), thus *any data fed into program will be sent to Ollama* (unless you point `OLLAMA_API_ENDPOINT` at a local server).
 
 ### In the Shell
 
blob - e7752d5851ce6dd49241bb19fd098610f22b89ae
blob + d7c93c9a6478d7169d61b65240b746386c0b72f7
--- src/cli.rs
+++ src/cli.rs
@@ -2,7 +2,7 @@
 
 use std::time::Duration;
 
-use crate::openai::{Message, ReasoningEffort};
+use crate::ollama::{Message, ReasoningEffort};
 use clap::{
     ArgGroup, ArgMatches, Command, ValueEnum, arg, builder::PossibleValue, command, value_parser,
 };
@@ -41,7 +41,7 @@ pub fn parse() -> Invocation {
 /// Top-level command
 fn cli() -> Command {
     command!()
-        .arg(arg!(model: -m --model <MODEL> "Sets model. See https://platform.openai.com/docs/models for model identifiers.").default_value("gpt-5.5"))
+        .arg(arg!(model: -m --model <MODEL> "Sets model. See https://ollama.com/library for model identifiers.").default_value("gpt-oss:120b"))
         .arg(
             arg!(temperature: -t --temperature <TEMP> "Sets temperature")
                 .value_parser(value_parser!(f32)),
@@ -59,7 +59,7 @@ fn cli() -> Command {
         .arg(arg!(user_messages: -u --user <MSG> ... "Appends user message").required(false))
         .arg(
             arg!(api_key: --apikey <API_KEY> "Sets API Key to use")
-                .env("OPENAI_API_KEY")
+                .env("OLLAMA_API_KEY")
                 .hide_env_values(true),
         )
         .arg(
@@ -90,7 +90,7 @@ impl From<ArgMatches> for Invocation {
         let model = matches
             .get_one::<String>("model")
             .expect("Models is required")
-            .to_string();
+            .clone();
 
         let temperature = matches.get_one::<f32>("temperature").copied();
 
@@ -106,7 +106,7 @@ impl From<ArgMatches> for Invocation {
         let file = matches
             .get_one::<String>("file")
             .expect("File is required")
-            .to_string();
+            .clone();
 
         let reasoning_effort = matches
             .get_one::<ReasoningEffort>("reasoning_effort")
@@ -117,9 +117,9 @@ impl From<ArgMatches> for Invocation {
             messages,
             model,
             temperature,
-            timeout,
             output_format,
             file,
+            timeout,
             reasoning_effort,
         }
     }
@@ -127,6 +127,7 @@ impl From<ArgMatches> for Invocation {
 
 impl Invocation {
     /// Builder
+    #[must_use]
     pub fn builder() -> InvocationBuilder {
         InvocationBuilder::default()
     }
@@ -149,7 +150,7 @@ impl Invocation {
                     .zip(matches.indices_of("assistant_messages").unwrap()),
             );
         }
-        messages.sort_by(|(_a, a_idx), (_b, b_idx)| a_idx.cmp(b_idx));
+        messages.sort_by_key(|(_a, a_idx)| *a_idx);
         let mut messages = messages.into_iter().map(|(a, _)| a).collect::<Vec<_>>();
 
         // System message is always first
blob - ce54ce0c588d937b02f3b2c05e0178e0d51da24e
blob + fe04925caff5a9b4c8a99ebb9fd3565fab66dde4
--- src/error.rs
+++ src/error.rs
@@ -20,6 +20,6 @@ pub enum Error {
     #[error("json serialization error - {0}")]
     JSON(#[from] serde_json::Error),
 
-    #[error("openai api returned error - {}", .error.message)]
-    OpenAIError { error: crate::openai::APIError },
+    #[error("ollama api returned error - {}", .error.message)]
+    OllamaError { error: crate::ollama::APIError },
 }
blob - 076c7e1b35e95e97574dc4be3b457573adc05731
blob + 59bdca51a92c1fa19274bc1751f17f89bd226900
--- src/exec/chat.rs
+++ src/exec/chat.rs
@@ -2,7 +2,7 @@
 
 use crate::Error;
 use crate::cli::{Invocation, OutputFormat};
-use crate::openai::{self, FinishReason, Message, Reasoning, Response};
+use crate::ollama::{self, FinishReason, Message, Response};
 use crate::parse;
 
 use anyhow::{Context, Result};
@@ -10,15 +10,19 @@ use std::fs::File;
 use std::io::{self, BufWriter, IsTerminal, Read, Write};
 
 /// Executes `Invocation` via given args
+///
+/// # Errors
+///
+/// Returns an error if no messages are provided, the request fails, or the
+/// response cannot be shown.
 pub async fn exec(args: Invocation) -> Result<()> {
-    let base_url =
-        std::env::var("OPENAI_API_ENDPOINT").unwrap_or("https://api.openai.com".to_string());
+    let base_url = std::env::var("OLLAMA_API_ENDPOINT").unwrap_or("https://ollama.com".to_string());
 
-    let client = openai::Client::new(args.api_key.clone(), base_url)
+    let client = ollama::Client::new(args.api_key.clone(), base_url)
         .with_context(|| "failed to create http client")?;
 
     let file_msgs = read_messages_from_file(&args.file)
-        .with_context(|| format!("failed to open {}", &args.file))?;
+        .with_context(|| format!("failed to open {}", args.file))?;
 
     let msgs = [args.messages.clone(), file_msgs].concat();
 
@@ -26,8 +30,8 @@ pub async fn exec(args: Invocation) -> Result<()> {
         return Err(Error::NoMessagesProvided.into());
     }
 
-    // TODO: Lifetimes for `ResponseRequest` fields
-    let mut builder = openai::ResponseRequest::builder();
+    // TODO: Lifetimes for `ChatRequest` fields
+    let mut builder = ollama::ChatRequest::builder();
 
     builder
         .model(args.model.clone())
@@ -36,7 +40,7 @@ pub async fn exec(args: Invocation) -> Result<()> {
         .timeout(args.timeout);
 
     if let Some(effort) = args.reasoning_effort {
-        builder.reasoning(Some(Reasoning::from_effort(effort)));
+        builder.think(Some(effort));
     }
 
     let request = builder
@@ -72,15 +76,14 @@ fn read_messages_from_file(file: &str) -> Result<Vec<M
     }
 }
 
-/// Show formatted output for a Responses API result
+/// Show formatted output for a Chat API result
 fn show_response(dest: impl Write, args: &Invocation, resp: &Response) -> Result<(), Error> {
     let mut writer = BufWriter::new(dest);
     let choice = match resp.choices.len() {
         1 => &resp.choices[0],
         _ => {
             return Err(Error::UnexpectedResponse(format!(
-                "Unexpected number of choices in response: {:?}",
-                resp
+                "Unexpected number of choices in response: {resp:?}"
             )));
         }
     };
@@ -88,18 +91,17 @@ fn show_response(dest: impl Write, args: &Invocation, 
     match choice.finish_reason {
         FinishReason::Stop => {
             let output = match args.output_format {
-                OutputFormat::Plaintext => choice.message.content.to_string(),
+                OutputFormat::Plaintext => choice.message.content.clone(),
                 OutputFormat::JSON => serde_json::to_string(resp).map_err(Error::JSON)?,
                 OutputFormat::JSONPretty => {
                     serde_json::to_string_pretty(resp).map_err(Error::JSON)?
                 }
             };
-            writeln!(writer, "{}", output).map_err(Error::IO)?
+            writeln!(writer, "{output}").map_err(Error::IO)?;
         }
-        _ => {
+        FinishReason::Length => {
             return Err(Error::UnexpectedResponse(format!(
-                "Received unrecognized stop reason for choice: {:?}",
-                choice
+                "Received unrecognized stop reason for choice: {choice:?}"
             )));
         }
     }
@@ -117,7 +119,7 @@ mod test {
 
     use crate::{
         cli::{Invocation, InvocationBuilder, OutputFormat},
-        openai::{Choice, FinishReason, Message, Response, ResponseBuilder, Usage},
+        ollama::{Choice, FinishReason, Message, Response, ResponseBuilder, Usage},
     };
 
     use super::*;
blob - af78f4c6570652223be4f83b293ce8f61f0e6405
blob + 63e9b602a43e20ee1171a85bbf9c662276f68f37
--- src/exec/mod.rs
+++ src/exec/mod.rs
@@ -5,6 +5,10 @@ use crate::cli::Invocation;
 use anyhow::Result;
 
 /// Execute the invocation
+///
+/// # Errors
+///
+/// Returns an error if the chat request fails or its response cannot be shown.
 pub async fn exec(inv: Invocation) -> Result<()> {
     chat::exec(inv).await
 }
blob - 8ebc4db083b8382d8471843a01358505f53a4a1c
blob + 15cf9e339df89215549608b3604bb8874ccd57e8
--- src/lib.rs
+++ src/lib.rs
@@ -1,7 +1,7 @@
 pub mod cli;
 pub mod error;
 pub mod exec;
-pub mod openai;
+pub mod ollama;
 pub mod parse;
 
 pub use error::Error;
blob - e0e9bedd15e252c826cee3ae5507c2a1bba259a3 (mode 644)
blob + /dev/null
--- src/openai.rs
+++ /dev/null
@@ -1,547 +0,0 @@
-//! Interactions with OpenAI APIs
-
-use std::convert::TryFrom;
-use std::time::Duration;
-
-use crate::Error;
-use chrono::serde::ts_seconds;
-use chrono::{DateTime, Utc};
-use derive_builder::Builder;
-use reqwest::StatusCode;
-use serde::{Deserialize, Serialize};
-use serde_json::{Value, json};
-
-/// Convienience Client for OpenAI Responses API
-pub struct Client {
-    /// Inner client
-    client: reqwest::Client,
-    /// Default API Key
-    api_key: Option<String>,
-    /// Base URL for API Endpoint
-    base_url: String,
-}
-
-/// Requests for the Responses API
-/// Reference: <https://platform.openai.com/docs/api-reference/responses>
-#[derive(Builder, Default)]
-pub struct ResponseRequest {
-    model: String,
-    messages: Vec<Message>,
-    #[builder(default)]
-    temperature: Option<f32>,
-    timeout: Duration,
-    #[builder(default)]
-    reasoning: Option<Reasoning>,
-}
-
-/// Responses from the Responses API
-/// Reference: <https://platform.openai.com/docs/api-reference/responses>
-#[derive(Builder, Default, Debug, Serialize, Deserialize)]
-pub struct Response {
-    #[serde(with = "ts_seconds")]
-    pub created: DateTime<Utc>,
-    pub choices: Vec<Choice>,
-    pub model: String,
-    pub usage: Usage,
-}
-
-/// API Errors from OpenAI
-#[derive(Debug, Deserialize)]
-pub struct APIError {
-    pub message: String,
-    #[serde(rename = "type")]
-    pub error_type: String,
-    pub param: Option<String>,
-    pub code: Option<String>,
-}
-
-/// Wraps `APIError` for deserializing OpenAI Response
-#[derive(Debug, Deserialize)]
-struct APIErrorContainer {
-    error: APIError,
-}
-
-/// Messages in Responses API request and response
-#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
-pub struct Message {
-    pub role: Role,
-    pub content: String,
-}
-
-#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
-#[serde(rename_all = "lowercase")]
-pub enum Role {
-    System,
-    Assistant,
-    User,
-}
-
-#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
-pub struct Usage {
-    pub input_tokens: u32,
-    pub output_tokens: u32,
-    pub total_tokens: u32,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
-pub struct Reasoning {
-    pub effort: ReasoningEffort,
-}
-
-impl Reasoning {
-    pub fn from_effort(effort: ReasoningEffort) -> Self {
-        Self { effort }
-    }
-}
-
-#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
-#[serde(rename_all = "lowercase")]
-pub enum ReasoningEffort {
-    Low,
-    Medium,
-    High,
-}
-
-#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
-#[serde(rename_all = "snake_case")]
-pub enum FinishReason {
-    Stop,
-    Length,
-    FunctionCall,
-    ContentFilter,
-}
-
-#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
-pub struct Choice {
-    pub message: Message,
-    pub finish_reason: FinishReason,
-}
-
-impl Client {
-    pub fn new(api_key: Option<String>, base_url: String) -> Result<Self, Error> {
-        let client = reqwest::Client::builder()
-            .build()
-            .map_err(Error::FailedToFetch)?;
-        Ok(Self {
-            client,
-            api_key,
-            base_url,
-        })
-    }
-
-    pub async fn create_response(&self, request: &ResponseRequest) -> Result<Response, Error> {
-        let api_key = &self.api_key.as_ref().ok_or(Error::NoAPIKey)?;
-
-        let resp = self
-            .client
-            .post(self.responses_endpoint())
-            .bearer_auth(api_key)
-            .timeout(request.timeout)
-            .header("Content-Type", "application/json")
-            .json(&request.to_payload())
-            .send()
-            .await
-            .map_err(Error::FailedToFetch)?;
-
-        match resp.status() {
-            StatusCode::OK => {
-                let responses: ResponsesAPIResponse =
-                    resp.json().await.map_err(Error::FailedToFetch)?;
-                let response = Response::try_from(responses).map_err(Error::UnexpectedResponse)?;
-                Ok(response)
-            }
-            _ => {
-                let error = resp
-                    .json::<APIErrorContainer>()
-                    .await
-                    .map_err(Error::FailedToFetch)?
-                    .error;
-                Err(Error::OpenAIError { error })
-            }
-        }
-    }
-
-    fn responses_endpoint(&self) -> String {
-        format!("{}{}", self.base_url, "/v1/responses")
-    }
-}
-
-impl Message {
-    pub fn system(content: &str) -> Message {
-        Message {
-            role: Role::System,
-            content: content.to_string(),
-        }
-    }
-    pub fn user(content: &str) -> Message {
-        Message {
-            role: Role::User,
-            content: content.to_string(),
-        }
-    }
-    pub fn assistant(content: &str) -> Message {
-        Message {
-            role: Role::Assistant,
-            content: content.to_string(),
-        }
-    }
-}
-
-impl Message {
-    fn to_responses_input(&self) -> serde_json::Value {
-        json!({
-            "role": self.role.as_str(),
-            "content": [{
-                "type": "input_text",
-                "text": self.content.clone(),
-            }]
-        })
-    }
-}
-
-impl Role {
-    fn as_str(&self) -> &'static str {
-        match self {
-            Role::System => "system",
-            Role::Assistant => "assistant",
-            Role::User => "user",
-        }
-    }
-}
-
-impl ResponseRequest {
-    pub fn builder() -> ResponseRequestBuilder {
-        ResponseRequestBuilder::default()
-    }
-
-    fn to_payload(&self) -> Value {
-        let input = self
-            .messages
-            .iter()
-            .map(Message::to_responses_input)
-            .collect::<Vec<_>>();
-
-        let mut payload = json!({
-            "model": self.model,
-            "input": input,
-        });
-
-        if let Some(temperature) = self.temperature
-            && let Some(obj) = payload.as_object_mut()
-        {
-            obj.insert("temperature".to_string(), json!(temperature));
-        }
-
-        if let Some(reasoning) = &self.reasoning
-            && let Some(obj) = payload.as_object_mut()
-        {
-            obj.insert(
-                "reasoning".to_string(),
-                serde_json::to_value(reasoning).expect("failed to serialize reasoning"),
-            );
-        }
-
-        payload
-    }
-}
-
-impl Response {
-    pub fn builder() -> ResponseBuilder {
-        ResponseBuilder::default()
-    }
-}
-
-#[derive(Debug, Deserialize)]
-struct ResponsesAPIResponse {
-    #[serde(rename = "created_at", alias = "created")]
-    #[serde(with = "ts_seconds")]
-    created: DateTime<Utc>,
-    model: String,
-    #[serde(default)]
-    output: Vec<ResponseOutput>,
-    usage: ResponsesUsage,
-}
-
-#[derive(Debug, Deserialize)]
-struct ResponsesUsage {
-    #[serde(default)]
-    input_tokens: u32,
-    #[serde(default)]
-    output_tokens: u32,
-    #[serde(default)]
-    total_tokens: u32,
-}
-
-#[derive(Debug, Deserialize)]
-struct ResponseOutput {
-    #[serde(rename = "type")]
-    item_type: String,
-    role: Option<Role>,
-    #[serde(default)]
-    content: Vec<ResponseContent>,
-}
-
-#[derive(Debug, Deserialize)]
-#[serde(tag = "type", rename_all = "snake_case")]
-enum ResponseContent {
-    OutputText {
-        text: String,
-    },
-    Text {
-        text: String,
-    },
-    #[serde(other)]
-    Other,
-}
-
-impl ResponseContent {
-    fn as_text(&self) -> Option<&str> {
-        match self {
-            ResponseContent::OutputText { text } => Some(text),
-            ResponseContent::Text { text } => Some(text),
-            ResponseContent::Other => None,
-        }
-    }
-}
-
-impl ResponseOutput {
-    fn aggregated_text(&self) -> Option<String> {
-        let mut aggregated = String::new();
-
-        for part in &self.content {
-            if let Some(text) = part.as_text() {
-                aggregated.push_str(text);
-            }
-        }
-
-        if aggregated.is_empty() {
-            None
-        } else {
-            Some(aggregated)
-        }
-    }
-}
-
-impl TryFrom<ResponsesAPIResponse> for Response {
-    type Error = String;
-
-    fn try_from(value: ResponsesAPIResponse) -> Result<Self, Self::Error> {
-        let mut choices = Vec::new();
-
-        for output in value.output.into_iter() {
-            if output.item_type != "message" {
-                continue;
-            }
-
-            let text = output
-                .aggregated_text()
-                .ok_or_else(|| "response message missing text content".to_string())?;
-
-            let message = Message {
-                role: output.role.unwrap_or(Role::Assistant),
-                content: text,
-            };
-
-            choices.push(Choice {
-                message,
-                finish_reason: FinishReason::Stop,
-            });
-        }
-
-        if choices.is_empty() {
-            return Err("response did not contain any assistant messages".to_string());
-        }
-
-        Ok(Response {
-            created: value.created,
-            choices,
-            model: value.model,
-            usage: Usage {
-                input_tokens: value.usage.input_tokens,
-                output_tokens: value.usage.output_tokens,
-                total_tokens: value.usage.total_tokens,
-            },
-        })
-    }
-}
-
-#[cfg(test)]
-mod test {
-
-    use super::*;
-    use anyhow::Result;
-    use chrono::TimeZone;
-    use std::time::Duration;
-
-    #[test]
-    fn parse_response_payload() -> Result<()> {
-        let data = r#"{
-             "created": 1688413145,
-             "model": "gpt-5",
-             "output": [{
-                 "id": "msg_123",
-                 "type": "message",
-                 "role": "assistant",
-                 "content": [{
-                     "type": "output_text",
-                     "text": "Hello! How can I assist you today?"
-                 }]
-             }],
-             "usage": {
-                 "input_tokens": 8,
-                 "output_tokens": 9,
-                 "total_tokens": 17
-             }
-        }
-        "#;
-
-        let resp = serde_json::from_str::<ResponsesAPIResponse>(data)?;
-        let resp = Response::try_from(resp).map_err(|e| anyhow::anyhow!(e))?;
-
-        assert_eq!(resp.created, Utc.timestamp_opt(1688413145, 0).unwrap());
-        assert_eq!(
-            resp.choices,
-            vec![Choice {
-                message: Message {
-                    role: Role::Assistant,
-                    content: "Hello! How can I assist you today?".to_string()
-                },
-                finish_reason: FinishReason::Stop
-            }]
-        );
-        assert_eq!(resp.model, "gpt-5");
-        assert_eq!(
-            resp.usage,
-            Usage {
-                input_tokens: 8,
-                output_tokens: 9,
-                total_tokens: 17,
-            }
-        );
-
-        Ok(())
-    }
-
-    #[test]
-    fn response_payload_includes_reasoning() -> Result<()> {
-        let request = ResponseRequest::builder()
-            .model("gpt-5".to_string())
-            .messages(vec![Message::user("Hello")])
-            .temperature(Some(0.0))
-            .timeout(Duration::from_secs(30))
-            .reasoning(Some(Reasoning::from_effort(ReasoningEffort::High)))
-            .build()
-            .expect("request builds");
-
-        let payload = request.to_payload();
-
-        assert_eq!(payload["reasoning"]["effort"], "high");
-        assert_eq!(payload["model"], "gpt-5");
-        assert_eq!(payload["temperature"], 0.0);
-
-        Ok(())
-    }
-
-    #[test]
-    fn response_payload_omits_reasoning_when_not_set() -> Result<()> {
-        let request = ResponseRequest::builder()
-            .model("gpt-5".to_string())
-            .messages(vec![Message::user("Hello")])
-            .timeout(Duration::from_secs(30))
-            .build()
-            .expect("request builds");
-
-        let payload = request.to_payload();
-
-        assert!(payload.get("reasoning").is_none());
-        assert!(payload.get("temperature").is_none());
-        Ok(())
-    }
-
-    #[test]
-    fn response_try_from_concatenates_segments() -> Result<()> {
-        let data = r#"{
-             "created": 1688413145,
-             "model": "gpt-5",
-             "output": [{
-                 "id": "msg_123",
-                 "type": "message",
-                 "role": "assistant",
-                 "content": [{
-                     "type": "text",
-                     "text": "Hello"
-                 }, {
-                     "type": "output_text",
-                     "text": " world"
-                 }]
-             }],
-             "usage": {
-                 "input_tokens": 3,
-                 "output_tokens": 4,
-                 "total_tokens": 7
-             }
-        }
-        "#;
-
-        let resp = serde_json::from_str::<ResponsesAPIResponse>(data)?;
-        let resp = Response::try_from(resp).map_err(|e| anyhow::anyhow!(e))?;
-
-        assert_eq!(resp.choices.len(), 1);
-        assert_eq!(resp.choices[0].message.content, "Hello world");
-        Ok(())
-    }
-
-    #[test]
-    fn response_try_from_errors_without_assistant_message() -> Result<()> {
-        let data = r#"{
-             "created": 1688413145,
-             "model": "gpt-5",
-             "output": [{
-                 "id": "reasoning_1",
-                 "type": "reasoning",
-                 "content": [{
-                     "type": "text",
-                     "text": "Thinking..."
-                 }]
-             }],
-             "usage": {
-                 "input_tokens": 1,
-                 "output_tokens": 1,
-                 "total_tokens": 2
-             }
-        }
-        "#;
-
-        let resp = serde_json::from_str::<ResponsesAPIResponse>(data)?;
-        let err = Response::try_from(resp).expect_err("should error");
-
-        assert!(
-            err.contains("did not contain any assistant messages"),
-            "unexpected error text: {err}"
-        );
-
-        Ok(())
-    }
-
-    #[test]
-    fn parse_response_error() -> Result<()> {
-        let data = r#"{
-            "error": {
-                "message": "An error message",
-                "type": "invalid_request_error",
-                "param": null,
-                "code": null
-            }
-        }
-        "#;
-
-        let resp = serde_json::from_str::<APIErrorContainer>(data)?.error;
-
-        assert_eq!(resp.message, "An error message");
-        assert_eq!(resp.error_type, "invalid_request_error");
-        assert_eq!(resp.param, None);
-        assert_eq!(resp.code, None);
-
-        Ok(())
-    }
-}
blob - /dev/null
blob + 1222e8f98e75c65db69f6da9f295530d10cf9ea3 (mode 644)
--- /dev/null
+++ src/ollama.rs
@@ -0,0 +1,442 @@
+//! Interactions with the Ollama Cloud API
+//!
+//! Reference: <https://docs.ollama.com/api/chat>
+
+use std::convert::TryFrom;
+use std::time::Duration;
+
+use crate::Error;
+use chrono::serde::ts_seconds;
+use chrono::{DateTime, Utc};
+use derive_builder::Builder;
+use reqwest::StatusCode;
+use serde::{Deserialize, Serialize};
+use serde_json::{Value, json};
+
+/// Convenience Client for the Ollama Chat API
+pub struct Client {
+    /// Inner HTTP client
+    http: reqwest::Client,
+    /// Default API Key
+    api_key: Option<String>,
+    /// Base URL for API Endpoint
+    base_url: String,
+}
+
+/// Requests for the Chat API
+/// Reference: <https://docs.ollama.com/api/chat>
+#[derive(Builder, Default)]
+pub struct ChatRequest {
+    model: String,
+    messages: Vec<Message>,
+    #[builder(default)]
+    temperature: Option<f32>,
+    timeout: Duration,
+    #[builder(default)]
+    think: Option<ReasoningEffort>,
+}
+
+/// Normalized response surfaced to the rest of the crate
+#[derive(Builder, Default, Debug, Serialize, Deserialize)]
+pub struct Response {
+    #[serde(with = "ts_seconds")]
+    pub created: DateTime<Utc>,
+    pub choices: Vec<Choice>,
+    pub model: String,
+    pub usage: Usage,
+}
+
+/// API Errors from Ollama
+#[derive(Debug, Deserialize)]
+pub struct APIError {
+    pub message: String,
+}
+
+/// Wraps the raw error field for deserializing an Ollama error response
+///
+/// Ollama returns errors as `{"error": "message"}`.
+#[derive(Debug, Deserialize)]
+struct APIErrorContainer {
+    error: String,
+}
+
+/// Messages in Chat API request and response
+#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
+pub struct Message {
+    pub role: Role,
+    pub content: String,
+}
+
+#[derive(PartialEq, Eq, Debug, Serialize, Deserialize, Clone)]
+#[serde(rename_all = "lowercase")]
+pub enum Role {
+    System,
+    Assistant,
+    User,
+    Tool,
+}
+
+#[derive(Debug, Serialize, Deserialize, Clone, Default, PartialEq, Eq)]
+pub struct Usage {
+    pub input_tokens: u32,
+    pub output_tokens: u32,
+    pub total_tokens: u32,
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "lowercase")]
+pub enum ReasoningEffort {
+    Low,
+    Medium,
+    High,
+}
+
+impl ReasoningEffort {
+    fn as_str(self) -> &'static str {
+        match self {
+            ReasoningEffort::Low => "low",
+            ReasoningEffort::Medium => "medium",
+            ReasoningEffort::High => "high",
+        }
+    }
+}
+
+/// Reason generation stopped, derived from Ollama's `done_reason`
+#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Eq)]
+#[serde(rename_all = "snake_case")]
+pub enum FinishReason {
+    Stop,
+    Length,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct Choice {
+    pub message: Message,
+    pub finish_reason: FinishReason,
+}
+
+impl Client {
+    /// # Errors
+    ///
+    /// Returns an error if the underlying HTTP client cannot be built.
+    pub fn new(api_key: Option<String>, base_url: String) -> Result<Self, Error> {
+        let http = reqwest::Client::builder()
+            .build()
+            .map_err(Error::FailedToFetch)?;
+        Ok(Self {
+            http,
+            api_key,
+            base_url,
+        })
+    }
+
+    /// # Errors
+    ///
+    /// Returns an error if no API key is set, the request fails, or the
+    /// response cannot be parsed into a [`Response`].
+    pub async fn create_response(&self, request: &ChatRequest) -> Result<Response, Error> {
+        let api_key = &self.api_key.as_ref().ok_or(Error::NoAPIKey)?;
+
+        let resp = self
+            .http
+            .post(self.chat_endpoint())
+            .bearer_auth(api_key)
+            .timeout(request.timeout)
+            .header("Content-Type", "application/json")
+            .json(&request.to_payload())
+            .send()
+            .await
+            .map_err(Error::FailedToFetch)?;
+
+        if resp.status() == StatusCode::OK {
+            let chat: ChatAPIResponse = resp.json().await.map_err(Error::FailedToFetch)?;
+            Response::try_from(chat).map_err(Error::UnexpectedResponse)
+        } else {
+            let message = resp
+                .json::<APIErrorContainer>()
+                .await
+                .map_err(Error::FailedToFetch)?
+                .error;
+            Err(Error::OllamaError {
+                error: APIError { message },
+            })
+        }
+    }
+
+    fn chat_endpoint(&self) -> String {
+        format!("{}{}", self.base_url, "/api/chat")
+    }
+}
+
+impl Message {
+    #[must_use]
+    pub fn system(content: &str) -> Message {
+        Message {
+            role: Role::System,
+            content: content.to_string(),
+        }
+    }
+    #[must_use]
+    pub fn user(content: &str) -> Message {
+        Message {
+            role: Role::User,
+            content: content.to_string(),
+        }
+    }
+    #[must_use]
+    pub fn assistant(content: &str) -> Message {
+        Message {
+            role: Role::Assistant,
+            content: content.to_string(),
+        }
+    }
+}
+
+impl Message {
+    fn to_chat_message(&self) -> serde_json::Value {
+        json!({
+            "role": self.role.as_str(),
+            "content": self.content.clone(),
+        })
+    }
+}
+
+impl Role {
+    fn as_str(&self) -> &'static str {
+        match self {
+            Role::System => "system",
+            Role::Assistant => "assistant",
+            Role::User => "user",
+            Role::Tool => "tool",
+        }
+    }
+}
+
+impl ChatRequest {
+    #[must_use]
+    pub fn builder() -> ChatRequestBuilder {
+        ChatRequestBuilder::default()
+    }
+
+    fn to_payload(&self) -> Value {
+        let messages = self
+            .messages
+            .iter()
+            .map(Message::to_chat_message)
+            .collect::<Vec<_>>();
+
+        let mut payload = json!({
+            "model": self.model,
+            "messages": messages,
+            "stream": false,
+        });
+
+        if let Some(temperature) = self.temperature
+            && let Some(obj) = payload.as_object_mut()
+        {
+            obj.insert("options".to_string(), json!({ "temperature": temperature }));
+        }
+
+        if let Some(think) = &self.think
+            && let Some(obj) = payload.as_object_mut()
+        {
+            obj.insert("think".to_string(), json!(think.as_str()));
+        }
+
+        payload
+    }
+}
+
+impl Response {
+    #[must_use]
+    pub fn builder() -> ResponseBuilder {
+        ResponseBuilder::default()
+    }
+}
+
+#[derive(Debug, Deserialize)]
+struct ChatAPIResponse {
+    created_at: DateTime<Utc>,
+    model: String,
+    message: Option<ChatMessage>,
+    #[serde(default)]
+    done_reason: Option<String>,
+    #[serde(default)]
+    prompt_eval_count: u32,
+    #[serde(default)]
+    eval_count: u32,
+}
+
+#[derive(Debug, Deserialize)]
+struct ChatMessage {
+    #[serde(default = "default_role")]
+    role: Role,
+    #[serde(default)]
+    content: String,
+}
+
+fn default_role() -> Role {
+    Role::Assistant
+}
+
+impl TryFrom<ChatAPIResponse> for Response {
+    type Error = String;
+
+    fn try_from(value: ChatAPIResponse) -> Result<Self, Self::Error> {
+        let message = value
+            .message
+            .ok_or_else(|| "response did not contain a message".to_string())?;
+
+        if message.content.is_empty() {
+            return Err("response message missing text content".to_string());
+        }
+
+        let finish_reason = match value.done_reason.as_deref() {
+            Some("length") => FinishReason::Length,
+            _ => FinishReason::Stop,
+        };
+
+        let choice = Choice {
+            message: Message {
+                role: message.role,
+                content: message.content,
+            },
+            finish_reason,
+        };
+
+        Ok(Response {
+            created: value.created_at,
+            choices: vec![choice],
+            model: value.model,
+            usage: Usage {
+                input_tokens: value.prompt_eval_count,
+                output_tokens: value.eval_count,
+                total_tokens: value.prompt_eval_count + value.eval_count,
+            },
+        })
+    }
+}
+
+#[cfg(test)]
+mod test {
+
+    use super::*;
+    use anyhow::Result;
+    use std::time::Duration;
+
+    #[test]
+    fn parse_response_payload() -> Result<()> {
+        let data = r#"{
+             "model": "gpt-oss:120b",
+             "created_at": "2025-10-17T23:14:07.414671Z",
+             "message": {
+                 "role": "assistant",
+                 "content": "Hello! How can I assist you today?"
+             },
+             "done": true,
+             "done_reason": "stop",
+             "prompt_eval_count": 8,
+             "eval_count": 9
+        }
+        "#;
+
+        let resp = serde_json::from_str::<ChatAPIResponse>(data)?;
+        let resp = Response::try_from(resp).map_err(|e| anyhow::anyhow!(e))?;
+
+        assert_eq!(
+            resp.choices,
+            vec![Choice {
+                message: Message {
+                    role: Role::Assistant,
+                    content: "Hello! How can I assist you today?".to_string()
+                },
+                finish_reason: FinishReason::Stop
+            }]
+        );
+        assert_eq!(resp.model, "gpt-oss:120b");
+        assert_eq!(
+            resp.usage,
+            Usage {
+                input_tokens: 8,
+                output_tokens: 9,
+                total_tokens: 17,
+            }
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn response_payload_includes_think() -> Result<()> {
+        let request = ChatRequest::builder()
+            .model("gpt-oss:120b".to_string())
+            .messages(vec![Message::user("Hello")])
+            .temperature(Some(0.0))
+            .timeout(Duration::from_secs(30))
+            .think(Some(ReasoningEffort::High))
+            .build()
+            .expect("request builds");
+
+        let payload = request.to_payload();
+
+        assert_eq!(payload["think"], "high");
+        assert_eq!(payload["model"], "gpt-oss:120b");
+        assert_eq!(payload["options"]["temperature"], 0.0);
+        assert_eq!(payload["stream"], false);
+
+        Ok(())
+    }
+
+    #[test]
+    fn response_payload_omits_think_when_not_set() -> Result<()> {
+        let request = ChatRequest::builder()
+            .model("gpt-oss:120b".to_string())
+            .messages(vec![Message::user("Hello")])
+            .timeout(Duration::from_secs(30))
+            .build()
+            .expect("request builds");
+
+        let payload = request.to_payload();
+
+        assert!(payload.get("think").is_none());
+        assert!(payload.get("options").is_none());
+        Ok(())
+    }
+
+    #[test]
+    fn response_try_from_errors_without_message() -> Result<()> {
+        let data = r#"{
+             "model": "gpt-oss:120b",
+             "created_at": "2025-10-17T23:14:07.414671Z",
+             "done": true,
+             "done_reason": "stop",
+             "prompt_eval_count": 1,
+             "eval_count": 1
+        }
+        "#;
+
+        let resp = serde_json::from_str::<ChatAPIResponse>(data)?;
+        let err = Response::try_from(resp).expect_err("should error");
+
+        assert!(
+            err.contains("did not contain a message"),
+            "unexpected error text: {err}"
+        );
+
+        Ok(())
+    }
+
+    #[test]
+    fn parse_response_error() -> Result<()> {
+        let data = r#"{
+            "error": "model 'nope' not found"
+        }
+        "#;
+
+        let resp = serde_json::from_str::<APIErrorContainer>(data)?;
+
+        assert_eq!(resp.error, "model 'nope' not found");
+
+        Ok(())
+    }
+}
blob - f460ab826c292d162c6bb358cc58afa74e3e560c
blob + 378e86478140600edf16cd0af18cc2c48cb55185
--- src/parse.rs
+++ src/parse.rs
@@ -2,10 +2,14 @@
 
 use std::io::Read;
 
-use crate::openai::Message;
 use crate::Error;
+use crate::ollama::Message;
 
 /// Read from `std::io::Read` into a vector of messages
+///
+/// # Errors
+///
+/// Returns an error if reading from `input` fails.
 pub fn parse_messages(input: &mut impl Read) -> Result<Vec<Message>, Error> {
     let mut content = String::new();
     input.read_to_string(&mut content).map_err(Error::IO)?;
blob - b5bfc88ba906ba1e4d7e8addfd1917c584906417
blob + 9b6cb148b3c092cbec1b6e0fa7e9c3e20c0fed29
--- tests/chat.rs
+++ tests/chat.rs
@@ -5,6 +5,22 @@ use assert_fs::prelude::*;
 use predicates::prelude::*;
 use serde_json::json;
 
+/// A canned successful Ollama `/api/chat` response body
+fn ok_body() -> &'static str {
+    r#"{
+         "model": "gpt-oss:120b",
+         "created_at": "2025-10-17T23:14:07.414671Z",
+         "message": {
+             "role": "assistant",
+             "content": "ASSISTANT REPLY"
+         },
+         "done": true,
+         "done_reason": "stop",
+         "prompt_eval_count": 8,
+         "eval_count": 9
+    }"#
+}
+
 #[test]
 fn chat_no_message() {
     Command::cargo_bin("cogni")
@@ -31,47 +47,25 @@ fn chat_user_message_from_flag() {
     let mut server = mockito::Server::new();
 
     let mock = server
-        .mock("POST", "/v1/responses")
+        .mock("POST", "/api/chat")
         .with_header("content-type", "application/json")
         .with_header("authorization", "Bearer ABCDE")
         .match_body(mockito::Matcher::PartialJson(json!({
-            "model": "gpt-5.5",
-            "input": [{
+            "model": "gpt-oss:120b",
+            "stream": false,
+            "messages": [{
                 "role": "user",
-                "content": [{
-                    "type": "input_text",
-                    "text": "Hello"
-                }]
+                "content": "Hello"
             }]
         })))
-        .with_body(
-            r#"{
-             "id": "resp_XXXXX",
-             "created": 1688413145,
-             "model": "gpt-5.5",
-             "output": [{
-                 "id": "msg_XXXXX",
-                 "type": "message",
-                 "role": "assistant",
-                 "content": [{
-                     "type": "output_text",
-                     "text": "ASSISTANT REPLY"
-                 }]
-             }],
-             "usage": {
-                 "input_tokens": 8,
-                 "output_tokens": 9,
-                 "total_tokens": 17
-             }
-        }"#,
-        )
+        .with_body(ok_body())
         .create();
 
     let cmd = Command::cargo_bin("cogni")
         .unwrap()
         .args(["-u", "Hello"])
-        .env("OPENAI_API_ENDPOINT", server.url())
-        .env("OPENAI_API_KEY", "ABCDE")
+        .env("OLLAMA_API_ENDPOINT", server.url())
+        .env("OLLAMA_API_KEY", "ABCDE")
         .assert();
 
     mock.assert();
@@ -85,47 +79,25 @@ fn chat_user_message_from_stdin() {
     let mut server = mockito::Server::new();
 
     let mock = server
-        .mock("POST", "/v1/responses")
+        .mock("POST", "/api/chat")
         .with_header("content-type", "application/json")
         .with_header("authorization", "Bearer ABCDE")
         .match_body(mockito::Matcher::PartialJson(json!({
-            "model": "gpt-5.5",
-            "input": [{
+            "model": "gpt-oss:120b",
+            "stream": false,
+            "messages": [{
                 "role": "user",
-                "content": [{
-                    "type": "input_text",
-                    "text": "Hello"
-                }]
+                "content": "Hello"
             }]
         })))
-        .with_body(
-            r#"{
-             "id": "resp_XXXXX",
-             "created": 1688413145,
-             "model": "gpt-5.5",
-             "output": [{
-                 "id": "msg_XXXXX",
-                 "type": "message",
-                 "role": "assistant",
-                 "content": [{
-                     "type": "output_text",
-                     "text": "ASSISTANT REPLY"
-                 }]
-             }],
-            "usage": {
-                "input_tokens": 8,
-                "output_tokens": 9,
-                "total_tokens": 17
-            }
-       }"#,
-        )
+        .with_body(ok_body())
         .create();
 
     let cmd = Command::cargo_bin("cogni")
         .unwrap()
         .write_stdin("Hello")
-        .env("OPENAI_API_ENDPOINT", server.url())
-        .env("OPENAI_API_KEY", "ABCDE")
+        .env("OLLAMA_API_ENDPOINT", server.url())
+        .env("OLLAMA_API_KEY", "ABCDE")
         .assert();
 
     mock.assert();
@@ -139,50 +111,25 @@ fn chat_with_reasoning_effort() {
     let mut server = mockito::Server::new();
 
     let mock = server
-        .mock("POST", "/v1/responses")
+        .mock("POST", "/api/chat")
         .with_header("content-type", "application/json")
         .with_header("authorization", "Bearer ABCDE")
         .match_body(mockito::Matcher::PartialJson(json!({
-            "model": "gpt-5.5",
-            "reasoning": {
-                "effort": "medium"
-            },
-            "input": [{
+            "model": "gpt-oss:120b",
+            "think": "medium",
+            "messages": [{
                 "role": "user",
-                "content": [{
-                    "type": "input_text",
-                    "text": "Hello"
-                }]
+                "content": "Hello"
             }]
         })))
-        .with_body(
-            r#"{
-             "id": "resp_XXXXX",
-             "created": 1688413145,
-             "model": "gpt-5.5",
-             "output": [{
-                 "id": "msg_XXXXX",
-                 "type": "message",
-                 "role": "assistant",
-                 "content": [{
-                     "type": "output_text",
-                     "text": "ASSISTANT REPLY"
-                 }]
-             }],
-             "usage": {
-                 "input_tokens": 8,
-                 "output_tokens": 9,
-                 "total_tokens": 17
-             }
-        }"#,
-        )
+        .with_body(ok_body())
         .create();
 
     let cmd = Command::cargo_bin("cogni")
         .unwrap()
         .args(["-u", "Hello", "--reasoning-effort", "medium"])
-        .env("OPENAI_API_ENDPOINT", server.url())
-        .env("OPENAI_API_KEY", "ABCDE")
+        .env("OLLAMA_API_ENDPOINT", server.url())
+        .env("OLLAMA_API_KEY", "ABCDE")
         .assert();
 
     mock.assert();
@@ -201,70 +148,32 @@ fn chat_multiple_messages() {
     let mut server = mockito::Server::new();
 
     let mock = server
-        .mock("POST", "/v1/responses")
+        .mock("POST", "/api/chat")
         .with_header("content-type", "application/json")
         .with_header("authorization", "Bearer ABCDE")
         .match_body(mockito::Matcher::PartialJson(json!({
-            "model": "gpt-5.5",
-            "input": [{
+            "model": "gpt-oss:120b",
+            "messages": [{
                 "role": "system",
-                "content": [{
-                    "type": "input_text",
-                    "text": "SYSTEM"
-                }],
+                "content": "SYSTEM"
             }, {
                 "role": "user",
-                "content": [{
-                    "type": "input_text",
-                    "text": "USER_1"
-                }],
+                "content": "USER_1"
             }, {
                 "role": "assistant",
-                "content": [{
-                    "type": "input_text",
-                    "text": "ASSI_1"
-                }],
+                "content": "ASSI_1"
             }, {
                 "role": "user",
-                "content": [{
-                    "type": "input_text",
-                    "text": "USER_2"
-                }],
+                "content": "USER_2"
             }, {
                 "role": "assistant",
-                "content": [{
-                    "type": "input_text",
-                    "text": "ASSI_2"
-                }],
+                "content": "ASSI_2"
             }, {
                 "role": "user",
-                "content": [{
-                    "type": "input_text",
-                    "text": "USER_STDIN"
-                }],
+                "content": "USER_STDIN"
             }]
         })))
-        .with_body(
-            r#"{
-             "id": "resp_XXXXX",
-             "created": 1688413145,
-             "model": "gpt-5.5",
-             "output": [{
-                 "id": "msg_XXXXX",
-                 "type": "message",
-                 "role": "assistant",
-                 "content": [{
-                     "type": "output_text",
-                     "text": "ASSISTANT REPLY"
-                 }]
-             }],
-             "usage": {
-                 "input_tokens": 8,
-                 "output_tokens": 9,
-                 "total_tokens": 17
-             }
-        }"#,
-        )
+        .with_body(ok_body())
         .create();
 
     let cmd = Command::cargo_bin("cogni")
@@ -273,8 +182,8 @@ fn chat_multiple_messages() {
             "-s", "SYSTEM", "-u", "USER_1", "-a", "ASSI_1", "-u", "USER_2", "-a", "ASSI_2",
         ])
         .write_stdin("USER_STDIN")
-        .env("OPENAI_API_ENDPOINT", server.url())
-        .env("OPENAI_API_KEY", "ABCDE")
+        .env("OLLAMA_API_ENDPOINT", server.url())
+        .env("OLLAMA_API_KEY", "ABCDE")
         .assert();
 
     mock.assert();
@@ -289,45 +198,35 @@ fn chat_api_error() {
     let mut server = mockito::Server::new();
 
     let mock = server
-        .mock("POST", "/v1/responses")
+        .mock("POST", "/api/chat")
         .with_status(400)
         .with_header("content-type", "application/json")
         .with_header("authorization", "Bearer ABCDE")
         .match_body(mockito::Matcher::PartialJson(json!({
-            "model": "gpt-5.5",
-            "input": [{
+            "model": "gpt-oss:120b",
+            "messages": [{
                 "role": "user",
-                "content": [{
-                    "type": "input_text",
-                    "text": "USER"
-                }],
+                "content": "USER"
             }],
-            "temperature": 1000.0 // invalid param
+            "options": {
+                "temperature": 1000.0 // invalid param
+            }
         })))
-        .with_body(
-            r#"{
-               "error": {
-                 "message": "1000 is greater than the maximum of 2 - 'temperature'",
-                 "type": "invalid_request_error",
-                 "param": null,
-                 "code": null
-               }
-             }"#,
-        )
+        .with_body(r#"{ "error": "invalid options: temperature out of range" }"#)
         .create();
 
     let cmd = Command::cargo_bin("cogni")
         .unwrap()
         .args(["-u", "USER", "-t", "1000"])
         .write_stdin("USER_STDIN")
-        .env("OPENAI_API_ENDPOINT", server.url())
-        .env("OPENAI_API_KEY", "ABCDE")
+        .env("OLLAMA_API_ENDPOINT", server.url())
+        .env("OLLAMA_API_KEY", "ABCDE")
         .assert();
 
     mock.assert();
 
     cmd.failure().stderr(predicate::str::contains(
-        "1000 is greater than the maximum of 2",
+        "invalid options: temperature out of range",
     ));
 }
 
@@ -340,47 +239,24 @@ fn chat_user_message_from_file() {
     infile.write_str("Hello from file").unwrap();
 
     let mock = server
-        .mock("POST", "/v1/responses")
+        .mock("POST", "/api/chat")
         .with_header("content-type", "application/json")
         .with_header("authorization", "Bearer ABCDE")
         .match_body(mockito::Matcher::PartialJson(json!({
-            "model": "gpt-5.5",
-            "input": [{
+            "model": "gpt-oss:120b",
+            "messages": [{
                 "role": "user",
-                "content": [{
-                    "type": "input_text",
-                    "text": "Hello from file"
-                }],
-            }],
+                "content": "Hello from file"
+            }]
         })))
-        .with_body(
-            r#"{
-             "id": "resp_XXXXX",
-             "created": 1688413145,
-             "model": "gpt-5.5",
-             "output": [{
-                 "id": "msg_XXXXX",
-                 "type": "message",
-                 "role": "assistant",
-                 "content": [{
-                     "type": "output_text",
-                     "text": "ASSISTANT REPLY"
-                 }]
-             }],
-             "usage": {
-                 "input_tokens": 8,
-                 "output_tokens": 9,
-                 "total_tokens": 17
-             }
-        }"#,
-        )
+        .with_body(ok_body())
         .create();
 
     let cmd = Command::cargo_bin("cogni")
         .unwrap()
         .args([infile.path().to_str().unwrap()])
-        .env("OPENAI_API_ENDPOINT", server.url())
-        .env("OPENAI_API_KEY", "ABCDE")
+        .env("OLLAMA_API_ENDPOINT", server.url())
+        .env("OLLAMA_API_KEY", "ABCDE")
         .assert();
 
     mock.assert();