commit 4a8a5e56a9baa6631fdb1cc3d3d6afc73d8f1596 from: mtmn date: Sun Jul 26 20:06:44 2026 UTC replace openai api with ollama cloud 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 +) 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 + 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 "Sets model. See https://platform.openai.com/docs/models for model identifiers.").default_value("gpt-5.5")) + .arg(arg!(model: -m --model "Sets model. See https://ollama.com/library for model identifiers.").default_value("gpt-oss:120b")) .arg( arg!(temperature: -t --temperature "Sets temperature") .value_parser(value_parser!(f32)), @@ -59,7 +59,7 @@ fn cli() -> Command { .arg(arg!(user_messages: -u --user ... "Appends user message").required(false)) .arg( arg!(api_key: --apikey "Sets API Key to use") - .env("OPENAI_API_KEY") + .env("OLLAMA_API_KEY") .hide_env_values(true), ) .arg( @@ -90,7 +90,7 @@ impl From for Invocation { let model = matches .get_one::("model") .expect("Models is required") - .to_string(); + .clone(); let temperature = matches.get_one::("temperature").copied(); @@ -106,7 +106,7 @@ impl From for Invocation { let file = matches .get_one::("file") .expect("File is required") - .to_string(); + .clone(); let reasoning_effort = matches .get_one::("reasoning_effort") @@ -117,9 +117,9 @@ impl From for Invocation { messages, model, temperature, - timeout, output_format, file, + timeout, reasoning_effort, } } @@ -127,6 +127,7 @@ impl From 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::>(); // 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 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, - /// Base URL for API Endpoint - base_url: String, -} - -/// Requests for the Responses API -/// Reference: -#[derive(Builder, Default)] -pub struct ResponseRequest { - model: String, - messages: Vec, - #[builder(default)] - temperature: Option, - timeout: Duration, - #[builder(default)] - reasoning: Option, -} - -/// Responses from the Responses API -/// Reference: -#[derive(Builder, Default, Debug, Serialize, Deserialize)] -pub struct Response { - #[serde(with = "ts_seconds")] - pub created: DateTime, - pub choices: Vec, - 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, - pub code: Option, -} - -/// 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, base_url: String) -> Result { - 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 { - 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::() - .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::>(); - - 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, - model: String, - #[serde(default)] - output: Vec, - 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, - #[serde(default)] - content: Vec, -} - -#[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 { - 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 for Response { - type Error = String; - - fn try_from(value: ResponsesAPIResponse) -> Result { - 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::(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::(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::(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::(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: + +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, + /// Base URL for API Endpoint + base_url: String, +} + +/// Requests for the Chat API +/// Reference: +#[derive(Builder, Default)] +pub struct ChatRequest { + model: String, + messages: Vec, + #[builder(default)] + temperature: Option, + timeout: Duration, + #[builder(default)] + think: Option, +} + +/// 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, + pub choices: Vec, + 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, base_url: String) -> Result { + 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 { + 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::() + .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::>(); + + 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, + model: String, + message: Option, + #[serde(default)] + done_reason: Option, + #[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 for Response { + type Error = String; + + fn try_from(value: ChatAPIResponse) -> Result { + 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::(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::(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::(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, 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();