#![allow(unreachable_pub)]
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response},
Extension, Json,
};
use serde::{Deserialize, Serialize};
use super::{
openai_chat_completions_handler, AppState, ChatCompletionRequest, ChatCompletionResponse,
ChatMessage, ChoiceCount, ModelSourceInfo,
};
#[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,
deserialize_with = "crate::api::types::deserialize_temperature_f32"
)]
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,
}
#[derive(Debug, Clone, Serialize)]
pub struct OllamaChatChunk {
pub model: String,
pub created_at: String,
pub message: OllamaMessage,
pub done: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub done_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_eval_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub eval_count: Option<usize>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OllamaGenerateChunk {
pub model: String,
pub created_at: String,
pub response: String,
pub done: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub done_reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub prompt_eval_count: Option<usize>,
#[serde(skip_serializing_if = "Option::is_none")]
pub eval_count: Option<usize>,
}
#[derive(Debug, Clone, Default, Serialize)]
pub struct OllamaModelDetails {
#[serde(skip_serializing_if = "Option::is_none")]
pub format: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub family: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub families: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub parameter_size: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quantization_level: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OllamaTag {
pub name: String,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub modified_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
pub details: OllamaModelDetails,
}
#[derive(Debug, Clone, Serialize)]
pub struct OllamaTagsResponse {
pub models: Vec<OllamaTag>,
}
#[derive(Debug, Clone, Default, Deserialize)]
pub struct OllamaShowRequest {
#[serde(default)]
pub model: Option<String>,
#[serde(default)]
pub name: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OllamaShowResponse {
pub details: OllamaModelDetails,
pub model_info: serde_json::Map<String, serde_json::Value>,
pub capabilities: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct OllamaVersionResponse {
pub version: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct OllamaEmbeddingsRequest {
#[serde(default)]
pub model: Option<String>,
pub prompt: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct OllamaEmbeddingsResponse {
pub embedding: Vec<f32>,
}
fn created_at_now() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Nanos, true)
}
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: ChoiceCount::ONE,
stream: false,
..Default::default()
}
}
pub(crate) fn content_fragments(content: &str) -> Vec<String> {
if content.is_empty() {
return Vec::new();
}
content
.split_inclusive(char::is_whitespace)
.map(str::to_string)
.collect()
}
fn ndjson_response<T: Serialize>(objects: &[T]) -> Response {
let mut lines: Vec<axum::body::Bytes> = Vec::with_capacity(objects.len());
for obj in objects {
match serde_json::to_string(obj) {
Ok(mut s) => {
s.push('\n');
lines.push(axum::body::Bytes::from(s));
},
Err(e) => {
return (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
.into_response()
},
}
}
let stream = tokio_stream::iter(
lines
.into_iter()
.map(Ok::<axum::body::Bytes, std::convert::Infallible>),
);
match Response::builder()
.status(StatusCode::OK)
.header(
axum::http::header::CONTENT_TYPE,
"application/x-ndjson; charset=utf-8",
)
.body(axum::body::Body::from_stream(stream))
{
Ok(resp) => resp,
Err(e) => (
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": e.to_string() })),
)
.into_response(),
}
}
fn chat_stream_objects(
model: &str,
content: &str,
prompt_eval_count: usize,
eval_count: usize,
) -> Vec<OllamaChatChunk> {
let created_at = created_at_now();
let mut out: Vec<OllamaChatChunk> = content_fragments(content)
.into_iter()
.map(|fragment| OllamaChatChunk {
model: model.to_string(),
created_at: created_at.clone(),
message: OllamaMessage {
role: "assistant".to_string(),
content: fragment,
},
done: false,
done_reason: None,
prompt_eval_count: None,
eval_count: None,
})
.collect();
out.push(OllamaChatChunk {
model: model.to_string(),
created_at,
message: OllamaMessage {
role: "assistant".to_string(),
content: String::new(),
},
done: true,
done_reason: Some("stop".to_string()),
prompt_eval_count: Some(prompt_eval_count),
eval_count: Some(eval_count),
});
out
}
fn generate_stream_objects(
model: &str,
content: &str,
prompt_eval_count: usize,
eval_count: usize,
) -> Vec<OllamaGenerateChunk> {
let created_at = created_at_now();
let mut out: Vec<OllamaGenerateChunk> = content_fragments(content)
.into_iter()
.map(|fragment| OllamaGenerateChunk {
model: model.to_string(),
created_at: created_at.clone(),
response: fragment,
done: false,
done_reason: None,
prompt_eval_count: None,
eval_count: None,
})
.collect();
out.push(OllamaGenerateChunk {
model: model.to_string(),
created_at,
response: String::new(),
done: true,
done_reason: Some("stop".to_string()),
prompt_eval_count: Some(prompt_eval_count),
eval_count: Some(eval_count),
});
out
}
fn parameter_size_label(count: u64) -> String {
if count >= 1_000_000_000 {
format!("{:.1}B", count as f64 / 1e9)
} else if count >= 1_000_000 {
format!("{:.0}M", count as f64 / 1e6)
} else {
format!("{count}")
}
}
fn tag_name(source: Option<&ModelSourceInfo>) -> String {
let stem = source
.and_then(ModelSourceInfo::path)
.and_then(|p| {
std::path::Path::new(p)
.file_stem()
.map(|s| s.to_string_lossy().into_owned())
})
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "apr".to_string());
format!("{stem}:latest")
}
fn details_from_source(source: Option<&ModelSourceInfo>) -> OllamaModelDetails {
let Some(src) = source else {
return OllamaModelDetails::default();
};
OllamaModelDetails {
format: src.format().map(str::to_string),
family: src.architecture().map(str::to_string),
families: src.architecture().map(|a| vec![a.to_string()]),
parameter_size: src.parameter_count().map(parameter_size_label),
quantization_level: src.quantization().map(str::to_string),
}
}
fn modified_at(source: Option<&ModelSourceInfo>) -> Option<String> {
let path = source.and_then(ModelSourceInfo::path)?;
let modified = std::fs::metadata(path).ok()?.modified().ok()?;
Some(
chrono::DateTime::<chrono::Utc>::from(modified)
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true),
)
}
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)
}
fn with_upstream_status(status: StatusCode, mut resp: Response) -> Response {
if !status.is_success() {
*resp.status_mut() = status;
}
resp
}
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,
Extension(cancel): Extension<crate::generate::CancelToken>,
Json(request): Json<OllamaChatRequest>,
) -> Response {
let model = model_label(&request.model);
let stream = request.stream;
let chat_req = to_chat_request(&model, request.messages, &request.options);
let inner =
openai_chat_completions_handler(State(state), headers, Extension(cancel), Json(chat_req))
.await;
let (status, body) = split_response(inner).await;
let (content, prompt_tokens, eval_count) = chat_response_to_parts(status, &body);
if stream {
return with_upstream_status(
status,
ndjson_response(&chat_stream_objects(
&model,
&content,
prompt_tokens,
eval_count,
)),
);
}
with_upstream_status(
status,
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,
Extension(cancel): Extension<crate::generate::CancelToken>,
Json(request): Json<OllamaGenerateRequest>,
) -> Response {
let model = model_label(&request.model);
let stream = request.stream;
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, Extension(cancel), Json(chat_req))
.await;
let (status, body) = split_response(inner).await;
let (content, prompt_tokens, eval_count) = chat_response_to_parts(status, &body);
if stream {
return with_upstream_status(
status,
ndjson_response(&generate_stream_objects(
&model,
&content,
prompt_tokens,
eval_count,
)),
);
}
with_upstream_status(
status,
Json(OllamaGenerateResponse {
model,
created_at: created_at_now(),
response: content,
done: true,
prompt_eval_count: prompt_tokens,
eval_count,
})
.into_response(),
)
}
pub async fn ollama_tags_handler(State(state): State<AppState>) -> Json<OllamaTagsResponse> {
let source = state.model_source();
let name = tag_name(source);
Json(OllamaTagsResponse {
models: vec![OllamaTag {
model: name.clone(),
name,
modified_at: modified_at(source),
size: source.and_then(ModelSourceInfo::size_bytes),
digest: None,
details: details_from_source(source),
}],
})
}
pub async fn ollama_show_handler(
State(state): State<AppState>,
Json(_request): Json<OllamaShowRequest>,
) -> Json<OllamaShowResponse> {
let source = state.model_source();
let mut model_info = serde_json::Map::new();
if let Some(src) = source {
if let Some(arch) = src.architecture() {
model_info.insert(
"general.architecture".to_string(),
serde_json::Value::String(arch.to_string()),
);
}
if let Some(q) = src.quantization() {
model_info.insert(
"general.quantization".to_string(),
serde_json::Value::String(q.to_string()),
);
}
if let Some(size) = src.size_bytes() {
model_info.insert("general.size_bytes".to_string(), serde_json::json!(size));
}
if let Some(ctx) = src.context_length() {
model_info.insert(
"apr.configured_context_length".to_string(),
serde_json::json!(ctx),
);
}
if let Some(ctx) = src.model_max_context_length() {
model_info.insert("general.context_length".to_string(), serde_json::json!(ctx));
}
}
Json(OllamaShowResponse {
details: details_from_source(source),
model_info,
capabilities: vec!["completion".to_string()],
})
}
pub async fn ollama_version_handler() -> Json<OllamaVersionResponse> {
Json(OllamaVersionResponse {
version: env!("CARGO_PKG_VERSION").to_string(),
})
}
pub async fn ollama_embeddings_handler(
State(state): State<AppState>,
Json(request): Json<OllamaEmbeddingsRequest>,
) -> Result<Json<OllamaEmbeddingsResponse>, (StatusCode, Json<super::ErrorResponse>)> {
let input = super::EmbeddingInput::Single(request.prompt);
let (embeddings, _prompt_tokens) = super::realize_handlers::embed_inputs(
&state,
request.model.as_deref(),
&input,
"/api/embeddings",
)?;
Ok(Json(OllamaEmbeddingsResponse {
embedding: embeddings.into_iter().next().unwrap_or_default(),
}))
}
#[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, "internal chat path is driven 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");
}
#[test]
fn rfc3339_oracle_rejects_the_bare_epoch_shape() {
chrono::DateTime::parse_from_rfc3339("1786293998.000000000Z")
.expect_err("a bare epoch string is not RFC 3339 — oracle is not discriminating");
}
#[test]
fn created_at_now_is_rfc3339_utc_at_the_current_instant() {
let s = created_at_now();
let parsed = chrono::DateTime::parse_from_rfc3339(&s)
.unwrap_or_else(|e| panic!("created_at {s:?} must parse as RFC 3339: {e}"));
let now = chrono::Utc::now().timestamp();
let skew = (parsed.timestamp() - now).abs();
assert!(skew <= 60, "created_at {s:?} is {skew}s away from now");
assert_eq!(parsed.offset().local_minus_utc(), 0, "must be UTC: {s:?}");
}
#[test]
fn chat_response_created_at_is_client_decodable() {
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");
let created_at = json["created_at"].as_str().expect("created_at is a string");
chrono::DateTime::parse_from_rfc3339(created_at).unwrap_or_else(|e| {
panic!("/api/chat created_at {created_at:?} must parse as RFC 3339: {e}")
});
}
#[test]
fn generate_response_created_at_is_client_decodable() {
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");
let created_at = json["created_at"].as_str().expect("created_at is a string");
chrono::DateTime::parse_from_rfc3339(created_at).unwrap_or_else(|e| {
panic!("/api/generate created_at {created_at:?} must parse as RFC 3339: {e}")
});
}
}
#[cfg(test)]
mod stream_and_discovery_tests {
use super::*;
#[test]
fn fragments_reassemble_to_the_original_text() {
for text in [
"The capital of France is Paris.",
"one",
" leading and trailing ",
"multi\nline\ttext with double spaces",
"unicode: héllo wörld 日本語 🎉",
] {
let joined: String = content_fragments(text).concat();
assert_eq!(joined, text, "fragments must reassemble {text:?} exactly");
}
}
#[test]
fn empty_content_yields_no_fragments() {
assert!(content_fragments("").is_empty());
}
#[test]
fn chat_stream_is_a_sequence_terminated_by_done_true() {
let objs = chat_stream_objects("apr", "The capital of France is Paris.", 21, 7);
assert!(
objs.len() > 2,
"a 6-word answer must arrive as several chunks, got {}",
objs.len()
);
let (last, rest) = objs.split_last().expect("non-empty");
assert!(last.done, "final object must be done:true");
assert_eq!(last.done_reason.as_deref(), Some("stop"));
assert_eq!(last.prompt_eval_count, Some(21));
assert_eq!(last.eval_count, Some(7));
assert!(
last.message.content.is_empty(),
"ollama's terminal chat object carries no content"
);
for chunk in rest {
assert!(!chunk.done, "only the last object may be done:true");
assert!(chunk.done_reason.is_none());
assert!(chunk.prompt_eval_count.is_none());
assert!(chunk.eval_count.is_none());
assert_eq!(chunk.message.role, "assistant");
}
let assembled: String = rest.iter().map(|c| c.message.content.as_str()).collect();
assert_eq!(assembled, "The capital of France is Paris.");
}
#[test]
fn generate_stream_is_a_sequence_terminated_by_done_true() {
let objs = generate_stream_objects("apr", "1 2 3 4 5 6 7 8", 16, 16);
let (last, rest) = objs.split_last().expect("non-empty");
assert!(last.done);
assert_eq!(last.done_reason.as_deref(), Some("stop"));
assert_eq!(last.eval_count, Some(16));
assert!(last.response.is_empty());
assert!(!rest.is_empty(), "must emit incremental chunks");
assert!(rest.iter().all(|c| !c.done));
let assembled: String = rest.iter().map(|c| c.response.as_str()).collect();
assert_eq!(assembled, "1 2 3 4 5 6 7 8");
}
#[test]
fn empty_generation_still_terminates_the_stream() {
let objs = chat_stream_objects("apr", "", 3, 0);
assert_eq!(objs.len(), 1);
assert!(objs[0].done);
}
#[test]
fn each_object_serializes_to_exactly_one_json_line() {
for chunk in chat_stream_objects("apr", "a b\nc", 1, 3) {
let line = serde_json::to_string(&chunk).expect("serialize");
assert!(
!line.contains('\n'),
"raw newline would break NDJSON framing: {line}"
);
serde_json::from_str::<serde_json::Value>(&line).expect("each line is valid JSON");
}
}
#[test]
fn non_terminal_chunks_omit_counts_and_done_reason() {
let objs = chat_stream_objects("apr", "two words", 5, 2);
let first = serde_json::to_value(&objs[0]).expect("serialize");
assert!(first.get("eval_count").is_none());
assert!(first.get("prompt_eval_count").is_none());
assert!(first.get("done_reason").is_none());
assert_eq!(first["done"], false);
}
#[test]
fn stream_chunk_created_at_is_rfc3339() {
for chunk in chat_stream_objects("apr", "hi there", 1, 2) {
let json = serde_json::to_value(&chunk).expect("serialize");
let created_at = json["created_at"].as_str().expect("string");
chrono::DateTime::parse_from_rfc3339(created_at)
.unwrap_or_else(|e| panic!("chunk created_at {created_at:?} not RFC 3339: {e}"));
}
}
#[test]
fn details_without_a_source_claim_nothing() {
let details = details_from_source(None);
let json = serde_json::to_value(details).expect("serialize");
assert_eq!(
json.as_object().map(serde_json::Map::len),
Some(0),
"unmeasured details must be absent, got {json}"
);
}
#[test]
fn details_report_measured_values_only() {
let src = ModelSourceInfo::default()
.with_quantization("Q4_K")
.with_architecture("qwen2");
let json = serde_json::to_value(details_from_source(Some(&src))).expect("serialize");
assert_eq!(json["family"], "qwen2");
assert_eq!(json["families"][0], "qwen2");
assert_eq!(json["quantization_level"], "Q4_K");
assert!(json.get("parameter_size").is_none());
assert!(json.get("format").is_none());
}
#[test]
fn tag_name_comes_from_the_served_file() {
let src = ModelSourceInfo::default();
assert_eq!(tag_name(Some(&src)), "apr:latest");
assert_eq!(tag_name(None), "apr:latest");
let dir = std::env::temp_dir().join(format!("apr-tag-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("mkdir");
let path = dir.join("qwen2.5-coder-1.5b.gguf");
std::fs::write(&path, b"GGUF\0\0\0\0").expect("write");
let src = ModelSourceInfo::from_path(&path);
assert_eq!(tag_name(Some(&src)), "qwen2.5-coder-1.5b:latest");
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn parameter_size_labels_match_ollama_style() {
assert_eq!(parameter_size_label(1_500_000_000), "1.5B");
assert_eq!(parameter_size_label(370_000_000), "370M");
assert_eq!(parameter_size_label(512), "512");
}
}