commit - c5b95489c75049dee80844b53e706be704929dfb
commit + 4a8a5e56a9baa6631fdb1cc3d3d6afc73d8f1596
blob - a7a29a2410c7c3e9c83c8f0a2f3ae13af35f650a
blob + 44bad23e5815399eef88ca35390aadfa6c6d51a7
--- README.md
+++ README.md
## 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
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
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,
};
/// 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)),
.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(
let model = matches
.get_one::<String>("model")
.expect("Models is required")
- .to_string();
+ .clone();
let temperature = matches.get_one::<f32>("temperature").copied();
let file = matches
.get_one::<String>("file")
.expect("File is required")
- .to_string();
+ .clone();
let reasoning_effort = matches
.get_one::<ReasoningEffort>("reasoning_effort")
messages,
model,
temperature,
- timeout,
output_format,
file,
+ timeout,
reasoning_effort,
}
}
impl Invocation {
/// Builder
+ #[must_use]
pub fn builder() -> InvocationBuilder {
InvocationBuilder::default()
}
.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
#[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
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};
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();
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())
.timeout(args.timeout);
if let Some(effort) = args.reasoning_effort {
- builder.reasoning(Some(Reasoning::from_effort(effort)));
+ builder.think(Some(effort));
}
let request = builder
}
}
-/// 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:?}"
)));
}
};
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:?}"
)));
}
}
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
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
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
-//! 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
+//! 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
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
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")
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();
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();
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();
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")
"-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();
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",
));
}
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();