use std::sync::Arc;
use axum::extract::State;
use axum::http::StatusCode;
use axum::Json;
use ferrox_models::sampling::SamplingParams;
use serde::Deserialize;
use crate::generate::{FinishReason, GenerationParams};
use crate::{
decode_error_response, join_error_response, prompt_from_messages, run_generation,
unsupported_feature, ApiError, AppState, ChatMessage,
};
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum ContentIn {
Text(String),
Blocks(Vec<ContentBlockIn>),
}
#[derive(Debug, Deserialize)]
struct ContentBlockIn {
#[serde(rename = "type")]
kind: String,
#[serde(default)]
text: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AnthropicMessage {
role: String,
content: ContentIn,
}
#[derive(Debug, Deserialize)]
#[serde(untagged)]
enum SystemIn {
Text(String),
Blocks(Vec<ContentBlockIn>),
}
#[derive(Debug, Deserialize)]
pub(crate) struct MessagesRequest {
model: String,
messages: Vec<AnthropicMessage>,
max_tokens: usize,
#[serde(default)]
system: Option<SystemIn>,
#[serde(default)]
temperature: Option<f32>,
#[serde(default)]
top_p: Option<f32>,
#[serde(default)]
top_k: Option<usize>,
#[serde(default)]
stop_sequences: Option<Vec<String>>,
#[serde(default)]
stream: Option<bool>,
#[serde(default)]
metadata: Option<serde_json::Value>,
}
fn blocks_to_text(blocks: &[ContentBlockIn]) -> Result<String, ApiError> {
let mut out = String::new();
for b in blocks {
if b.kind != "text" {
return Err(unsupported_feature(
"Anthropic Messages: only text content blocks are supported",
));
}
if let Some(t) = &b.text {
out.push_str(t);
}
}
Ok(out)
}
fn content_to_text(content: &ContentIn) -> Result<String, ApiError> {
match content {
ContentIn::Text(s) => Ok(s.clone()),
ContentIn::Blocks(blocks) => blocks_to_text(blocks),
}
}
fn system_to_text(system: &SystemIn) -> Result<String, ApiError> {
match system {
SystemIn::Text(s) => Ok(s.clone()),
SystemIn::Blocks(blocks) => blocks_to_text(blocks),
}
}
fn to_chat_messages(req: &MessagesRequest) -> Result<Vec<ChatMessage>, ApiError> {
let mut out = Vec::new();
if let Some(system) = &req.system {
out.push(ChatMessage {
role: "system".to_string(),
content: Some(crate::MessageContent::Text(system_to_text(system)?)),
tool_calls: None,
tool_call_id: None,
});
}
for m in &req.messages {
let role = m.role.as_str();
if role != "user" && role != "assistant" {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"type": "error",
"error": {
"type": "invalid_request_error",
"message": format!("unsupported message role '{role}' (user/assistant only)")
}
})),
));
}
out.push(ChatMessage {
role: m.role.clone(),
content: Some(crate::MessageContent::Text(content_to_text(&m.content)?)),
tool_calls: None,
tool_call_id: None,
});
}
Ok(out)
}
fn stop_reason(finish: FinishReason) -> &'static str {
match finish {
FinishReason::Stop => "end_turn",
FinishReason::Length => "max_tokens",
FinishReason::Cancelled => "stop_sequence",
}
}
pub async fn messages(
State(state): State<Arc<AppState>>,
headers: axum::http::HeaderMap,
Json(req): Json<MessagesRequest>,
) -> Result<Json<serde_json::Value>, ApiError> {
let attribution = crate::attribution::Attribution::from_headers(&headers);
let _ = req.metadata;
let request_id = ferrox_api::next_request_id();
if req.stream == Some(true) {
return Err(unsupported_feature(
"Anthropic Messages streaming is not implemented yet; omit stream or set stream=false",
));
}
if req.max_tokens == 0 {
return Err((
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"type": "error",
"error": {
"type": "invalid_request_error",
"message": "max_tokens must be > 0"
}
})),
));
}
let started = std::time::Instant::now();
let active = state.require_active()?;
let history = to_chat_messages(&req)?;
let template = active.model.chat_template();
let prompt = prompt_from_messages(&history, template, &[]);
let mut stop = req.stop_sequences.clone().unwrap_or_default();
if matches!(
template,
crate::chat_template::ChatTemplate::Gemma | crate::chat_template::ChatTemplate::Gemma4
) {
let marker = match template {
crate::chat_template::ChatTemplate::Gemma => "<end_of_turn>",
crate::chat_template::ChatTemplate::Gemma4 => "<turn|>",
_ => unreachable!(),
};
if !stop.iter().any(|s| s == marker) {
stop.push(marker.to_string());
}
}
let params = GenerationParams {
max_tokens: req.max_tokens,
sampling: SamplingParams {
temperature: req.temperature.unwrap_or(0.0),
top_p: req.top_p.unwrap_or(1.0),
top_k: req.top_k.unwrap_or(0),
repetition_penalty: 1.0,
presence_penalty: 0.0,
frequency_penalty: 0.0,
},
seed: 0,
stop,
json_object: false,
stop_token_ids: Vec::new(),
cancel: None,
};
let model = Arc::clone(&active.model);
let kv_pool = state.kv_pool.clone();
let prefix_cache = state.prefix_cache.clone();
let batcher = active.batcher.clone();
let ceiling = active.ceiling.clone();
let (chunks, finish, usage) = tokio::task::spawn_blocking(move || {
run_generation(
&model,
&prompt,
¶ms,
kv_pool.as_ref(),
prefix_cache.as_deref(),
batcher.as_ref(),
ceiling.as_deref(),
)
})
.await
.map_err(join_error_response)?
.map_err(decode_error_response)?;
let text = chunks.concat();
state.record_request(crate::stats::Record {
request_id: &request_id,
route: ferrox_api::routes::V1_MESSAGES,
model: Some(active.model.name().to_string()),
status: 200,
stream: false,
duration_ms: started.elapsed().as_millis() as u64,
usage: Some(&usage),
attribution: &attribution,
});
Ok(Json(serde_json::json!({
"id": "msg_ferrox_0",
"type": "message",
"role": "assistant",
"model": req.model,
"content": [{ "type": "text", "text": text }],
"stop_reason": stop_reason(finish),
"stop_sequence": serde_json::Value::Null,
"usage": {
"input_tokens": usage.prompt_tokens,
"output_tokens": usage.completion_tokens,
}
})))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn content_string_and_blocks() {
let t = content_to_text(&ContentIn::Text("hi".into())).unwrap();
assert_eq!(t, "hi");
let blocks = content_to_text(&ContentIn::Blocks(vec![ContentBlockIn {
kind: "text".into(),
text: Some("a".into()),
}]))
.unwrap();
assert_eq!(blocks, "a");
}
}