#![allow(unreachable_pub)]
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
Json,
};
use serde::{Deserialize, Serialize};
use super::{
openai_chat_completions_handler, AppState, ChatCompletionRequest, ChatCompletionResponse,
ChatMessage,
};
#[derive(Debug, Clone, Deserialize)]
pub struct OllamaChatRequest {
#[serde(default)]
pub model: Option<String>,
pub messages: Vec<OllamaMessage>,
#[serde(default)]
pub stream: bool,
#[serde(default)]
pub options: Option<OllamaOptions>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct OllamaOptions {
#[serde(default)]
pub temperature: Option<f32>,
#[serde(default)]
pub top_p: Option<f32>,
#[serde(default)]
pub top_k: Option<usize>,
#[serde(default)]
pub seed: Option<u64>,
#[serde(default)]
pub num_predict: Option<usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OllamaChatResponse {
pub model: String,
pub created_at: String,
pub message: OllamaMessage,
pub done: bool,
pub prompt_eval_count: usize,
pub eval_count: usize,
}
#[derive(Debug, Clone, Deserialize)]
pub struct OllamaGenerateRequest {
#[serde(default)]
pub model: Option<String>,
pub prompt: String,
#[serde(default)]
pub system: Option<String>,
#[serde(default)]
pub stream: bool,
#[serde(default)]
pub options: Option<OllamaOptions>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OllamaGenerateResponse {
pub model: String,
pub created_at: String,
pub response: String,
pub done: bool,
pub prompt_eval_count: usize,
pub eval_count: usize,
}
fn created_at_now() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("{secs}.000000000Z")
}
fn model_label(model: &Option<String>) -> String {
model
.clone()
.filter(|m| !m.is_empty())
.unwrap_or_else(|| "apr".to_string())
}
fn to_chat_request(
model: &str,
messages: Vec<OllamaMessage>,
options: &Option<OllamaOptions>,
) -> ChatCompletionRequest {
let opts = options.clone().unwrap_or_default();
ChatCompletionRequest {
model: model.to_string(),
messages: messages
.into_iter()
.map(|m| ChatMessage {
role: m.role,
content: m.content,
..Default::default()
})
.collect(),
max_tokens: opts.num_predict,
temperature: opts.temperature,
top_p: opts.top_p,
top_k: opts.top_k,
seed: opts.seed,
n: 1,
stream: false,
..Default::default()
}
}
fn chat_response_to_parts(status: StatusCode, body: &[u8]) -> (String, usize, usize) {
if status.is_success() {
if let Ok(resp) = serde_json::from_slice::<ChatCompletionResponse>(body) {
let content = resp
.choices
.first()
.map(|c| c.message.content.clone())
.unwrap_or_default();
return (
content,
resp.usage.prompt_tokens,
resp.usage.completion_tokens,
);
}
}
let msg = serde_json::from_slice::<serde_json::Value>(body)
.ok()
.and_then(|v| v.get("error").and_then(|e| e.as_str().map(str::to_string)))
.unwrap_or_else(|| "generation unavailable".to_string());
(msg, 0, 0)
}
async fn split_response(resp: Response) -> (StatusCode, axum::body::Bytes) {
let status = resp.status();
let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
.await
.unwrap_or_default();
(status, bytes)
}
pub async fn ollama_chat_handler(
State(state): State<AppState>,
headers: HeaderMap,
Json(request): Json<OllamaChatRequest>,
) -> Response {
let model = model_label(&request.model);
let chat_req = to_chat_request(&model, request.messages, &request.options);
let inner = openai_chat_completions_handler(State(state), headers, Json(chat_req)).await;
let (status, body) = split_response(inner).await;
let (content, prompt_tokens, eval_count) = chat_response_to_parts(status, &body);
Json(OllamaChatResponse {
model,
created_at: created_at_now(),
message: OllamaMessage {
role: "assistant".to_string(),
content,
},
done: true,
prompt_eval_count: prompt_tokens,
eval_count,
})
.into_response()
}
pub async fn ollama_generate_handler(
State(state): State<AppState>,
headers: HeaderMap,
Json(request): Json<OllamaGenerateRequest>,
) -> Response {
let model = model_label(&request.model);
let mut messages = Vec::new();
if let Some(system) = request.system.filter(|s| !s.is_empty()) {
messages.push(OllamaMessage {
role: "system".to_string(),
content: system,
});
}
messages.push(OllamaMessage {
role: "user".to_string(),
content: request.prompt,
});
let chat_req = to_chat_request(&model, messages, &request.options);
let inner = openai_chat_completions_handler(State(state), headers, Json(chat_req)).await;
let (status, body) = split_response(inner).await;
let (content, prompt_tokens, eval_count) = chat_response_to_parts(status, &body);
Json(OllamaGenerateResponse {
model,
created_at: created_at_now(),
response: content,
done: true,
prompt_eval_count: prompt_tokens,
eval_count,
})
.into_response()
}
#[cfg(test)]
mod tests {
use super::*;
use axum::http::StatusCode;
#[test]
fn model_label_defaults_to_apr_when_absent() {
assert_eq!(model_label(&None), "apr");
assert_eq!(model_label(&Some(String::new())), "apr");
assert_eq!(model_label(&Some("qwen".to_string())), "qwen");
}
#[test]
fn to_chat_request_maps_messages_and_options() {
let msgs = vec![
OllamaMessage {
role: "system".to_string(),
content: "be brief".to_string(),
},
OllamaMessage {
role: "user".to_string(),
content: "hi".to_string(),
},
];
let opts = Some(OllamaOptions {
temperature: Some(0.5),
top_k: Some(10),
num_predict: Some(32),
..Default::default()
});
let req = to_chat_request("m", msgs, &opts);
assert_eq!(req.model, "m");
assert_eq!(req.messages.len(), 2);
assert_eq!(req.messages[0].role, "system");
assert_eq!(req.messages[1].content, "hi");
assert_eq!(req.max_tokens, Some(32));
assert_eq!(req.top_k, Some(10));
assert!(!req.stream, "Ollama path always drives chat non-streaming");
}
#[test]
fn chat_response_to_parts_extracts_content_on_success() {
let body = br#"{
"id":"x","object":"chat.completion","created":0,"model":"m",
"choices":[{"index":0,"message":{"role":"assistant","content":"4"},"finish_reason":"stop"}],
"usage":{"prompt_tokens":3,"completion_tokens":1,"total_tokens":4}
}"#;
let (content, p, c) = chat_response_to_parts(StatusCode::OK, body);
assert_eq!(content, "4");
assert_eq!(p, 3);
assert_eq!(c, 1);
}
#[test]
fn chat_response_to_parts_surfaces_error_as_content() {
let body = br#"{"error":"model not found"}"#;
let (content, p, c) = chat_response_to_parts(StatusCode::NOT_FOUND, body);
assert_eq!(content, "model not found");
assert_eq!(p, 0);
assert_eq!(c, 0);
}
#[test]
fn ollama_chat_response_serializes_with_ollama_fields() {
let resp = OllamaChatResponse {
model: "apr".to_string(),
created_at: created_at_now(),
message: OllamaMessage {
role: "assistant".to_string(),
content: "hello".to_string(),
},
done: true,
prompt_eval_count: 1,
eval_count: 2,
};
let json = serde_json::to_value(&resp).expect("serialize");
assert_eq!(json["message"]["role"], "assistant");
assert_eq!(json["message"]["content"], "hello");
assert_eq!(json["done"], true);
}
#[test]
fn ollama_generate_response_serializes_flat_response_field() {
let resp = OllamaGenerateResponse {
model: "apr".to_string(),
created_at: created_at_now(),
response: "hi".to_string(),
done: true,
prompt_eval_count: 0,
eval_count: 1,
};
let json = serde_json::to_value(&resp).expect("serialize");
assert_eq!(json["response"], "hi");
assert_eq!(json["done"], true);
assert!(json.get("message").is_none(), "generate uses flat response");
}
}