refactor: purge Node.js, Rust is the one true backend

- Moved boosapi-rs/* to repo root
- Deleted all Node.js source (src/server/, src/web/, scripts/)
- Deleted node_modules, package.json, tsconfig, vite.config
- Updated .gitignore and AGENTS.md for Rust-only stack
- Fixed static file path for frontend dist/web/
- Dockerfile stays the same (already Rust-only)
- Kept dist/web/ for Rust static serving
This commit is contained in:
boos
2026-05-18 09:11:29 +08:00
parent 725f44699a
commit 5d29f03d50
1165 changed files with 271 additions and 323571 deletions
@@ -0,0 +1,43 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Anthropic Messages API compatibility switches.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AnthropicMessagesCompatibility {
/// Value suitable for the `anthropic-version` header when callers need it.
pub anthropic_version: Option<String>,
/// Messages API requires `max_tokens`; use this when canonical input omits it.
pub default_max_tokens: Option<i32>,
/// Emit system content as an Anthropic content-block array instead of a string.
pub system_as_array: bool,
/// Copy canonical metadata to the Anthropic request `metadata` field.
pub preserve_metadata: bool,
/// Extra provider-specific request fields to merge into the formatted payload.
pub extra_request_fields: Option<Value>,
}
impl Default for AnthropicMessagesCompatibility {
fn default() -> Self {
Self {
anthropic_version: Some("2023-06-01".to_string()),
default_max_tokens: Some(1024),
system_as_array: false,
preserve_metadata: true,
extra_request_fields: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_compatibility() {
let compat = AnthropicMessagesCompatibility::default();
assert_eq!(compat.anthropic_version.as_deref(), Some("2023-06-01"));
assert_eq!(compat.default_max_tokens, Some(1024));
assert!(!compat.system_as_array);
assert!(compat.preserve_metadata);
}
}
@@ -0,0 +1,272 @@
// CanonicalRequest -> Anthropic /v1/messages JSON formatter
use crate::transformers::anthropic::messages::AnthropicMessagesCompatibility;
use crate::transformers::canonical::*;
use serde_json::{json, Value};
/// Format a canonical request into Anthropic Messages API JSON.
pub fn format_messages_request(request: &CanonicalRequest) -> Value {
format_messages_request_with_compat(request, &AnthropicMessagesCompatibility::default())
}
/// Format a canonical request into Anthropic Messages API JSON with compatibility switches.
pub fn format_messages_request_with_compat(
request: &CanonicalRequest,
compatibility: &AnthropicMessagesCompatibility,
) -> Value {
let mut result = json!({
"model": request.model,
"messages": format_messages(&request.messages),
"stream": request.stream,
});
if let Some(max_tokens) = request.max_tokens.or(compatibility.default_max_tokens) {
result["max_tokens"] = json!(max_tokens);
}
if let Some(temperature) = request.temperature {
result["temperature"] = json!(temperature);
}
if let Some(top_p) = request.top_p {
result["top_p"] = json!(top_p);
}
if let Some(stop) = &request.stop {
result["stop_sequences"] = json!(stop);
}
if let Some(tools) = &request.tools {
result["tools"] = json!(format_tools(tools));
}
if let Some(tool_choice) = &request.tool_choice {
if let Some(value) = format_tool_choice(tool_choice) {
result["tool_choice"] = value;
}
}
if let Some(reasoning) = &request.reasoning {
if let Some(effort) = &reasoning.effort {
result["thinking"] = json!({
"type": "enabled",
"budget_tokens": reasoning_budget_tokens(effort),
});
}
}
if compatibility.preserve_metadata {
if let Some(metadata) = &request.metadata {
result["metadata"] = metadata.clone();
}
}
let system_parts = collect_system_parts(&request.messages);
if !system_parts.is_empty() {
result["system"] = if compatibility.system_as_array {
Value::Array(system_parts)
} else {
Value::String(system_parts_to_text(&system_parts))
};
}
if let Some(extra) = &compatibility.extra_request_fields {
merge_extra_fields(&mut result, extra);
}
result
}
fn format_messages(messages: &[CanonicalMessage]) -> Vec<Value> {
messages
.iter()
.filter(|message| message.role != Role::System)
.map(format_message)
.collect()
}
fn format_message(message: &CanonicalMessage) -> Value {
let role = match message.role {
Role::Assistant => "assistant",
Role::User | Role::Tool | Role::System => "user",
};
let mut content = match message.role {
Role::Tool => format_tool_result_content(message),
_ => format_content_parts(&message.content),
};
if let Some(tool_calls) = &message.tool_calls {
content.extend(tool_calls.iter().map(format_tool_use));
}
json!({
"role": role,
"content": content,
})
}
fn collect_system_parts(messages: &[CanonicalMessage]) -> Vec<Value> {
messages
.iter()
.filter(|message| message.role == Role::System)
.flat_map(|message| format_content_parts(&message.content))
.collect()
}
fn system_parts_to_text(parts: &[Value]) -> String {
parts
.iter()
.filter_map(|part| part["text"].as_str())
.collect::<Vec<_>>()
.join("\n")
}
fn format_content_parts(parts: &[ContentPart]) -> Vec<Value> {
parts
.iter()
.map(|part| match part {
ContentPart::Text { text } => json!({
"type": "text",
"text": text,
}),
ContentPart::Image { url, .. } => json!({
"type": "image",
"source": {
"type": "url",
"url": url,
},
}),
})
.collect()
}
fn format_tool_result_content(message: &CanonicalMessage) -> Vec<Value> {
let content = if message.content.len() == 1 {
match &message.content[0] {
ContentPart::Text { text } => Value::String(text.clone()),
_ => Value::Array(format_content_parts(&message.content)),
}
} else {
Value::Array(format_content_parts(&message.content))
};
vec![json!({
"type": "tool_result",
"tool_use_id": message.tool_call_id.clone().unwrap_or_default(),
"content": content,
})]
}
fn format_tool_use(tool_call: &ToolCall) -> Value {
json!({
"type": "tool_use",
"id": tool_call.id,
"name": tool_call.name,
"input": tool_call.arguments,
})
}
fn format_tools(tools: &[ToolDefinition]) -> Vec<Value> {
tools
.iter()
.map(|tool| {
let mut value = json!({
"name": tool.name,
"input_schema": tool.input_schema,
});
if let Some(description) = &tool.description {
value["description"] = json!(description);
}
value
})
.collect()
}
fn format_tool_choice(tool_choice: &ToolChoice) -> Option<Value> {
match tool_choice {
ToolChoice::None => None,
ToolChoice::Auto => Some(json!({ "type": "auto" })),
ToolChoice::Required => Some(json!({ "type": "any" })),
ToolChoice::Specific(name) => Some(json!({ "type": "tool", "name": name })),
}
}
fn reasoning_budget_tokens(effort: &str) -> i32 {
match effort {
"low" => 1024,
"medium" => 4096,
"high" => 8192,
_ => 2048,
}
}
fn merge_extra_fields(target: &mut Value, extra: &Value) {
let (Some(target), Some(extra)) = (target.as_object_mut(), extra.as_object()) else {
return;
};
for (key, value) in extra {
target.insert(key.clone(), value.clone());
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_format_simple_request() {
let req = CanonicalRequest::chat("claude-3-5-sonnet-20241022", "Hello");
let json = format_messages_request(&req);
assert_eq!(json["model"], "claude-3-5-sonnet-20241022");
assert_eq!(json["max_tokens"], 1024);
assert_eq!(json["messages"][0]["role"], "user");
assert_eq!(json["messages"][0]["content"][0]["text"], "Hello");
}
#[test]
fn test_format_system_and_tools() {
let mut req = CanonicalRequest::chat("claude", "Use a tool");
req.messages.insert(0, CanonicalMessage::system("Be brief"));
req.tools = Some(vec![ToolDefinition {
name: "search".to_string(),
description: Some("Search docs".to_string()),
input_schema: json!({"type": "object"}),
}]);
req.tool_choice = Some(ToolChoice::Specific("search".to_string()));
let json = format_messages_request(&req);
assert_eq!(json["system"], "Be brief");
assert_eq!(json["tools"][0]["name"], "search");
assert_eq!(json["tool_choice"]["type"], "tool");
assert_eq!(json["tool_choice"]["name"], "search");
}
#[test]
fn test_format_tool_call_and_result() {
let assistant = CanonicalMessage {
role: Role::Assistant,
content: vec![],
name: None,
tool_calls: Some(vec![ToolCall {
id: "toolu_1".to_string(),
name: "calc".to_string(),
arguments: json!({"x": 1}),
}]),
tool_call_id: None,
};
let tool = CanonicalMessage::tool("2", "toolu_1");
let req = CanonicalRequest {
model: "claude".to_string(),
messages: vec![assistant, tool],
stream: false,
max_tokens: Some(10),
temperature: None,
top_p: None,
stop: None,
tools: None,
tool_choice: None,
reasoning: None,
metadata: None,
};
let json = format_messages_request(&req);
assert_eq!(json["messages"][0]["content"][0]["type"], "tool_use");
assert_eq!(json["messages"][1]["content"][0]["type"], "tool_result");
assert_eq!(json["messages"][1]["content"][0]["tool_use_id"], "toolu_1");
}
}
@@ -0,0 +1,10 @@
// Transformers - Anthropic Messages
pub mod compatibility;
pub mod inbound;
pub mod outbound;
pub mod stream;
pub use compatibility::AnthropicMessagesCompatibility;
pub use inbound::{format_messages_request, format_messages_request_with_compat};
pub use outbound::parse_messages_response;
pub use stream::parse_stream_event;
@@ -0,0 +1,143 @@
// Anthropic /v1/messages response -> CanonicalResponse parser
use crate::transformers::canonical::*;
use serde_json::Value;
/// Parse an Anthropic Messages API response body into canonical format.
pub fn parse_messages_response(body: &Value) -> Result<CanonicalResponse, String> {
let id = body["id"].as_str().unwrap_or_default().to_string();
let model = body["model"]
.as_str()
.ok_or_else(|| "model is required".to_string())?
.to_string();
let content = body["content"]
.as_array()
.ok_or_else(|| "content is required".to_string())?;
let mut text_parts = Vec::new();
let mut tool_calls = Vec::new();
for part in content {
match part["type"].as_str() {
Some("text") => {
if let Some(text) = part["text"].as_str() {
text_parts.push(ContentPart::Text {
text: text.to_string(),
});
}
}
Some("tool_use") => {
let Some(id) = part["id"].as_str() else {
continue;
};
let Some(name) = part["name"].as_str() else {
continue;
};
tool_calls.push(ToolCall {
id: id.to_string(),
name: name.to_string(),
arguments: part["input"].clone(),
});
}
_ => {}
}
}
let message = CanonicalMessage {
role: Role::Assistant,
content: text_parts,
name: None,
tool_calls: if tool_calls.is_empty() {
None
} else {
Some(tool_calls)
},
tool_call_id: None,
};
Ok(CanonicalResponse {
id,
model,
choices: vec![CanonicalChoice {
index: 0,
message,
finish_reason: body["stop_reason"].as_str().map(normalize_stop_reason),
}],
usage: parse_usage(body.get("usage")),
metadata: Some(body.clone()),
})
}
fn parse_usage(value: Option<&Value>) -> Option<Usage> {
let usage = value?.as_object()?;
let prompt_tokens = usage.get("input_tokens").and_then(as_i32);
let completion_tokens = usage.get("output_tokens").and_then(as_i32);
Some(Usage {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens.zip(completion_tokens).map(|(a, b)| a + b),
})
}
fn as_i32(value: &Value) -> Option<i32> {
value.as_i64().map(|n| n as i32)
}
fn normalize_stop_reason(reason: &str) -> String {
match reason {
"end_turn" | "stop_sequence" => "stop".to_string(),
"max_tokens" => "length".to_string(),
"tool_use" => "tool_calls".to_string(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_parse_simple_response() {
let body = json!({
"id": "msg_123",
"type": "message",
"role": "assistant",
"model": "claude",
"content": [{"type": "text", "text": "Hello"}],
"stop_reason": "end_turn",
"usage": {"input_tokens": 10, "output_tokens": 3}
});
let response = parse_messages_response(&body).unwrap();
assert_eq!(response.id, "msg_123");
assert_eq!(response.model, "claude");
assert_eq!(response.choices[0].finish_reason.as_deref(), Some("stop"));
assert_eq!(response.usage.unwrap().total_tokens, Some(13));
}
#[test]
fn test_parse_tool_use_response() {
let body = json!({
"id": "msg_123",
"model": "claude",
"content": [{
"type": "tool_use",
"id": "toolu_1",
"name": "search",
"input": {"q": "rust"}
}],
"stop_reason": "tool_use"
});
let response = parse_messages_response(&body).unwrap();
let tool_calls = response.choices[0].message.tool_calls.as_ref().unwrap();
assert_eq!(tool_calls[0].id, "toolu_1");
assert_eq!(tool_calls[0].name, "search");
assert_eq!(tool_calls[0].arguments["q"], "rust");
assert_eq!(
response.choices[0].finish_reason.as_deref(),
Some("tool_calls")
);
}
}
@@ -0,0 +1,204 @@
// Anthropic Messages SSE stream parser
use crate::transformers::canonical::*;
use serde_json::Value;
/// Parse a single Anthropic Messages SSE event into a canonical stream event.
///
/// Accepts either a full SSE frame (`event: ...\ndata: ...`) or a raw JSON data payload.
pub fn parse_stream_event(frame: &str) -> Option<CanonicalStreamEvent> {
let (event_name, data) = parse_sse_frame(frame)?;
let parsed: Value = serde_json::from_str(&data).ok()?;
let event_type = event_name
.as_deref()
.or_else(|| parsed["type"].as_str())
.unwrap_or_default();
match event_type {
"message_start" => parse_message_start(&parsed),
"content_block_delta" => parse_content_block_delta(&parsed),
"content_block_stop" => Some(empty_event()),
"message_delta" => parse_message_delta(&parsed),
"message_stop" => Some(empty_event()),
_ => None,
}
}
fn parse_sse_frame(frame: &str) -> Option<(Option<String>, String)> {
let trimmed = frame.trim();
if trimmed.is_empty() || trimmed == "data: [DONE]" || trimmed == "[DONE]" {
return None;
}
if trimmed.starts_with('{') {
return Some((None, trimmed.to_string()));
}
let mut event_name = None;
let mut data_lines = Vec::new();
for line in trimmed.lines() {
if let Some(value) = line.strip_prefix("event:") {
event_name = Some(value.trim().to_string());
} else if let Some(value) = line.strip_prefix("data:") {
data_lines.push(value.trim());
}
}
if data_lines.is_empty() {
None
} else {
Some((event_name, data_lines.join("\n")))
}
}
fn parse_message_start(parsed: &Value) -> Option<CanonicalStreamEvent> {
let message = parsed.get("message")?;
Some(CanonicalStreamEvent {
id: message["id"].as_str().map(|s| s.to_string()),
choices: vec![CanonicalStreamChoice {
index: 0,
delta: StreamDelta {
role: Some(Role::Assistant),
content: None,
tool_calls: None,
},
finish_reason: None,
}],
usage: parse_usage(message.get("usage")),
})
}
fn parse_content_block_delta(parsed: &Value) -> Option<CanonicalStreamEvent> {
let index = parsed["index"].as_i64().unwrap_or(0) as i32;
let delta = parsed.get("delta")?;
let stream_delta = match delta["type"].as_str()? {
"text_delta" => StreamDelta {
role: None,
content: delta["text"].as_str().map(|s| s.to_string()),
tool_calls: None,
},
"input_json_delta" => StreamDelta {
role: None,
content: None,
tool_calls: Some(vec![StreamToolCall {
index,
id: None,
name: None,
arguments: delta["partial_json"].as_str().map(|s| s.to_string()),
}]),
},
_ => return None,
};
Some(CanonicalStreamEvent {
id: None,
choices: vec![CanonicalStreamChoice {
index,
delta: stream_delta,
finish_reason: None,
}],
usage: None,
})
}
fn parse_message_delta(parsed: &Value) -> Option<CanonicalStreamEvent> {
let delta = parsed.get("delta")?;
let finish_reason = delta["stop_reason"].as_str().map(normalize_stop_reason);
Some(CanonicalStreamEvent {
id: None,
choices: vec![CanonicalStreamChoice {
index: 0,
delta: StreamDelta {
role: None,
content: None,
tool_calls: None,
},
finish_reason,
}],
usage: parse_usage(parsed.get("usage")),
})
}
fn parse_usage(value: Option<&Value>) -> Option<Usage> {
let usage = value?.as_object()?;
let prompt_tokens = usage.get("input_tokens").and_then(as_i32);
let completion_tokens = usage.get("output_tokens").and_then(as_i32);
Some(Usage {
prompt_tokens,
completion_tokens,
total_tokens: prompt_tokens.zip(completion_tokens).map(|(a, b)| a + b),
})
}
fn as_i32(value: &Value) -> Option<i32> {
value.as_i64().map(|n| n as i32)
}
fn normalize_stop_reason(reason: &str) -> String {
match reason {
"end_turn" | "stop_sequence" => "stop".to_string(),
"max_tokens" => "length".to_string(),
"tool_use" => "tool_calls".to_string(),
other => other.to_string(),
}
}
fn empty_event() -> CanonicalStreamEvent {
CanonicalStreamEvent {
id: None,
choices: vec![],
usage: None,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_message_start() {
let frame = r#"event: message_start
data: {"type":"message_start","message":{"id":"msg_1","model":"claude","role":"assistant","content":[],"usage":{"input_tokens":4,"output_tokens":1}}}"#;
let event = parse_stream_event(frame).unwrap();
assert_eq!(event.id.as_deref(), Some("msg_1"));
assert_eq!(event.choices[0].delta.role, Some(Role::Assistant));
assert_eq!(event.usage.unwrap().total_tokens, Some(5));
}
#[test]
fn test_parse_text_delta() {
let frame = r#"event: content_block_delta
data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"Hello"}}"#;
let event = parse_stream_event(frame).unwrap();
assert_eq!(event.choices[0].index, 0);
assert_eq!(event.choices[0].delta.content.as_deref(), Some("Hello"));
}
#[test]
fn test_parse_tool_input_delta() {
let frame = r#"data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"q\""}}"#;
let event = parse_stream_event(frame).unwrap();
let tool_calls = event.choices[0].delta.tool_calls.as_ref().unwrap();
assert_eq!(tool_calls[0].index, 1);
assert_eq!(tool_calls[0].arguments.as_deref(), Some("{\"q\""));
}
#[test]
fn test_parse_message_delta() {
let frame = r#"event: message_delta
data: {"type":"message_delta","delta":{"stop_reason":"max_tokens"},"usage":{"output_tokens":9}}"#;
let event = parse_stream_event(frame).unwrap();
assert_eq!(event.choices[0].finish_reason.as_deref(), Some("length"));
assert_eq!(event.usage.unwrap().completion_tokens, Some(9));
}
#[test]
fn test_parse_message_stop() {
let frame = r#"event: message_stop
data: {"type":"message_stop"}"#;
let event = parse_stream_event(frame).unwrap();
assert!(event.choices.is_empty());
}
}
+7
View File
@@ -0,0 +1,7 @@
// Transformers - Anthropic
pub mod messages;
pub use messages::{
format_messages_request, format_messages_request_with_compat, parse_messages_response,
parse_stream_event, AnthropicMessagesCompatibility,
};