use std::io::{BufRead, BufReader};
use std::sync::atomic::AtomicBool;
use serde_json::{json, Value};
use super::ollama;
use super::wire::{self, ChatMessage, Fragment, Usage};
use super::PromptCache;
pub(crate) struct ChatRequest<'a> {
pub base: &'a str,
pub model: &'a str,
pub messages: &'a [ChatMessage],
pub tools: &'a [Value],
pub api_key: Option<&'a str>,
pub extras: RequestExtras<'a>,
}
#[derive(Default)]
pub(crate) struct RequestExtras<'a> {
pub response_format: Option<&'a Value>,
pub image_data_uris: &'a [String],
pub reasoning_tag: Option<&'a str>,
pub cache: PromptCache,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum Dialect {
OpenAi,
OllamaNative { num_ctx: u64 },
}
impl Dialect {
fn path(self) -> &'static str {
match self {
Self::OpenAi => "/v1/chat/completions",
Self::OllamaNative { .. } => "/api/chat",
}
}
fn envelope(self, request: &ChatRequest<'_>) -> Value {
let ChatRequest { model, messages, extras, .. } = request;
match self {
Self::OpenAi => json!({
"model": model,
"messages": messages,
"stream": true,
"stream_options": { "include_usage": true },
}),
Self::OllamaNative { num_ctx } => json!({
"model": model,
"messages": ollama::to_native_messages(messages, extras.image_data_uris),
"stream": true,
"keep_alive": ollama::KEEP_ALIVE,
"options": { "num_ctx": num_ctx, "temperature": ollama::TEMPERATURE },
}),
}
}
fn apply_schema(self, body: &mut Value, response_format: &Value) {
match self {
Self::OpenAi => body["response_format"] = response_format.clone(),
Self::OllamaNative { .. } => {
if let Some(bare) = ollama::native_format(response_format) {
body["format"] = bare;
}
}
}
}
fn body(self, request: &ChatRequest<'_>) -> Value {
let extras = &request.extras;
let mut body = self.envelope(request);
if !request.tools.is_empty() {
body["tools"] = Value::Array(request.tools.to_vec());
}
if let Some(schema) = extras.response_format {
self.apply_schema(&mut body, schema);
}
if self == Self::OpenAi {
if !extras.image_data_uris.is_empty() {
wire::attach_images(&mut body, extras.image_data_uris);
}
if extras.cache == PromptCache::Ephemeral {
wire::mark_cache_breakpoints(&mut body);
}
}
body
}
fn drain(
self,
lines: impl Iterator<Item = String>,
reasoning_tag: Option<&str>,
cancel: &AtomicBool,
on_delta: impl FnMut(Fragment),
) -> Result<(ChatMessage, Option<Usage>), String> {
match self {
Self::OpenAi => Ok(wire::drain_stream(lines, reasoning_tag, cancel, on_delta)),
Self::OllamaNative { .. } => {
ollama::drain_native_stream(lines, reasoning_tag, cancel, on_delta)
}
}
}
}
pub(crate) fn post_chat_stream(
request: ChatRequest<'_>,
dialect: Dialect,
cancel: &AtomicBool,
on_delta: impl FnMut(Fragment),
) -> Result<(ChatMessage, Option<Usage>), String> {
let url = format!("{}{}", request.base, dialect.path());
let body = dialect.body(&request);
let api_key = request.api_key;
let response = wire::send_with_retry(&url, || {
let mut post = ureq::post(&url);
if let Some(key) = api_key {
post = post.set("Authorization", &format!("Bearer {key}"));
}
post.send_json(body.clone()).map_err(Box::new)
})?;
let lines = BufReader::new(response.into_reader()).lines().map_while(Result::ok);
dialect.drain(lines, request.extras.reasoning_tag, cancel, on_delta)
}
#[cfg(test)]
mod tests {
use super::*;
fn request<'a>(messages: &'a [ChatMessage], tools: &'a [Value], extras: RequestExtras<'a>) -> ChatRequest<'a> {
ChatRequest { base: "http://host", model: "m", messages, tools, api_key: None, extras }
}
#[test]
fn both_dialects_carry_the_same_conversation_and_tools() {
let messages = [ChatMessage::user("hello")];
let tools = [json!({ "type": "function", "function": { "name": "read" } })];
let native = Dialect::OllamaNative { num_ctx: 8_192 };
for dialect in [Dialect::OpenAi, native] {
let body = dialect.body(&request(&messages, &tools, RequestExtras::default()));
assert_eq!(body["model"], "m", "{dialect:?}");
assert_eq!(body["stream"], true, "{dialect:?}");
assert_eq!(body["messages"].as_array().map(Vec::len), Some(1), "{dialect:?}");
assert_eq!(body["tools"].as_array().map(Vec::len), Some(1), "{dialect:?}");
}
}
#[test]
fn an_empty_tool_list_is_omitted_rather_than_sent_empty() {
for dialect in [Dialect::OpenAi, Dialect::OllamaNative { num_ctx: 8_192 }] {
let body = dialect.body(&request(&[ChatMessage::user("hi")], &[], RequestExtras::default()));
assert!(body.get("tools").is_none(), "{dialect:?}");
}
}
#[test]
fn each_dialect_puts_the_schema_where_its_endpoint_wants_it() {
let schema = json!({
"type": "json_schema",
"json_schema": { "name": "answer", "schema": { "type": "object" } }
});
let messages = [ChatMessage::user("hi")];
let extras = RequestExtras { response_format: Some(&schema), ..Default::default() };
let open_ai = Dialect::OpenAi.body(&request(&messages, &[], extras));
assert_eq!(open_ai["response_format"], schema, "OpenAI takes it wrapped");
assert!(open_ai.get("format").is_none());
let extras = RequestExtras { response_format: Some(&schema), ..Default::default() };
let native = Dialect::OllamaNative { num_ctx: 8_192 }.body(&request(&messages, &[], extras));
assert_eq!(native["format"], json!({ "type": "object" }), "Ollama takes it bare");
assert!(native.get("response_format").is_none());
}
#[test]
fn the_native_dialect_asks_for_the_window_it_was_given() {
let body = Dialect::OllamaNative { num_ctx: 32_768 }
.body(&request(&[ChatMessage::user("hi")], &[], RequestExtras::default()));
assert_eq!(body["options"]["num_ctx"], 32_768);
assert_eq!(Dialect::OllamaNative { num_ctx: 1 }.path(), "/api/chat");
assert_eq!(Dialect::OpenAi.path(), "/v1/chat/completions");
}
#[test]
fn implicit_is_the_default_so_an_unmarked_request_stays_unmarked() {
assert_eq!(PromptCache::default(), PromptCache::Implicit);
let body = Dialect::OpenAi.body(&request(&[ChatMessage::user("hi")], &[], RequestExtras::default()));
assert!(body["messages"][0]["content"].is_string(), "left as it was written");
}
#[test]
fn cache_breakpoints_and_usage_are_asked_for_only_where_they_exist() {
let messages = [
ChatMessage::system("rules"),
ChatMessage::user("first"),
ChatMessage::tool_result("c1", "done"),
ChatMessage::user("second"),
];
let extras = RequestExtras { cache: PromptCache::Ephemeral, ..Default::default() };
let open_ai = Dialect::OpenAi.body(&request(&messages, &[], extras));
assert_eq!(open_ai["stream_options"]["include_usage"], true);
assert!(
open_ai["messages"][0]["content"][0]["cache_control"].is_object(),
"the system prefix is marked: {open_ai}"
);
let extras = RequestExtras { cache: PromptCache::Ephemeral, ..Default::default() };
let native = Dialect::OllamaNative { num_ctx: 8_192 }.body(&request(&messages, &[], extras));
assert!(native.get("stream_options").is_none());
assert!(!native.to_string().contains("cache_control"), "nothing to mark natively: {native}");
}
}