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,
};
+13
View File
@@ -0,0 +1,13 @@
// Canonical format — unified internal representation
pub mod request;
pub mod response;
pub mod types;
pub use request::{CanonicalMessage, CanonicalRequest};
pub use response::{
CanonicalChoice, CanonicalResponse, CanonicalStreamChoice, CanonicalStreamEvent,
};
pub use types::{
ContentPart, ReasoningConfig, Role, StreamDelta, StreamToolCall, ToolCall, ToolChoice,
ToolDefinition, Usage,
};
+107
View File
@@ -0,0 +1,107 @@
// Canonical request — unified input format for all providers
use crate::transformers::canonical::types::{
ContentPart, ReasoningConfig, Role, ToolCall, ToolChoice, ToolDefinition,
};
use serde::{Deserialize, Serialize};
/// Unified internal request format
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanonicalRequest {
pub model: String,
pub messages: Vec<CanonicalMessage>,
pub stream: bool,
pub max_tokens: Option<i32>,
pub temperature: Option<f64>,
pub top_p: Option<f64>,
pub stop: Option<Vec<String>>,
pub tools: Option<Vec<ToolDefinition>>,
pub tool_choice: Option<ToolChoice>,
pub reasoning: Option<ReasoningConfig>,
pub metadata: Option<serde_json::Value>,
}
impl CanonicalRequest {
/// Create a simple chat request
pub fn chat(model: &str, content: &str) -> Self {
Self {
model: model.to_string(),
messages: vec![CanonicalMessage::user(content)],
stream: false,
max_tokens: None,
temperature: None,
top_p: None,
stop: None,
tools: None,
tool_choice: None,
reasoning: None,
metadata: None,
}
}
/// Create a streaming chat request
pub fn chat_stream(model: &str, content: &str) -> Self {
let mut req = Self::chat(model, content);
req.stream = true;
req
}
}
/// A single message in canonical format
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanonicalMessage {
pub role: Role,
pub content: Vec<ContentPart>,
pub name: Option<String>,
pub tool_calls: Option<Vec<ToolCall>>,
pub tool_call_id: Option<String>,
}
impl CanonicalMessage {
pub fn system(content: &str) -> Self {
Self {
role: Role::System,
content: vec![ContentPart::Text {
text: content.to_string(),
}],
name: None,
tool_calls: None,
tool_call_id: None,
}
}
pub fn user(content: &str) -> Self {
Self {
role: Role::User,
content: vec![ContentPart::Text {
text: content.to_string(),
}],
name: None,
tool_calls: None,
tool_call_id: None,
}
}
pub fn assistant(content: &str) -> Self {
Self {
role: Role::Assistant,
content: vec![ContentPart::Text {
text: content.to_string(),
}],
name: None,
tool_calls: None,
tool_call_id: None,
}
}
pub fn tool(content: &str, tool_call_id: &str) -> Self {
Self {
role: Role::Tool,
content: vec![ContentPart::Text {
text: content.to_string(),
}],
name: None,
tool_calls: None,
tool_call_id: Some(tool_call_id.to_string()),
}
}
}
+54
View File
@@ -0,0 +1,54 @@
// Canonical response — unified output format for all providers
use crate::transformers::canonical::request::CanonicalMessage;
use crate::transformers::canonical::types::{StreamDelta, ToolCall, Usage};
use serde::{Deserialize, Serialize};
/// Unified internal response format
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanonicalResponse {
pub id: String,
pub model: String,
pub choices: Vec<CanonicalChoice>,
pub usage: Option<Usage>,
pub metadata: Option<serde_json::Value>,
}
/// A single choice in the response
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanonicalChoice {
pub index: i32,
pub message: CanonicalMessage,
pub finish_reason: Option<String>,
}
impl CanonicalResponse {
pub fn simple(model: &str, content: &str) -> Self {
Self {
id: format!("canonical-{}", uuid::Uuid::new_v4()),
model: model.to_string(),
choices: vec![CanonicalChoice {
index: 0,
message: CanonicalMessage::assistant(content),
finish_reason: Some("stop".to_string()),
}],
usage: None,
metadata: None,
}
}
}
// ── SSE stream event ───────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanonicalStreamEvent {
pub id: Option<String>,
pub choices: Vec<CanonicalStreamChoice>,
pub usage: Option<Usage>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CanonicalStreamChoice {
pub index: i32,
pub delta: StreamDelta,
pub finish_reason: Option<String>,
}
+88
View File
@@ -0,0 +1,88 @@
// Canonical format types — unified internal representation for protocol transformers
use serde::{Deserialize, Serialize};
// ── Message Role ───────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum Role {
System,
User,
Assistant,
Tool,
}
impl std::fmt::Display for Role {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::System => write!(f, "system"),
Self::User => write!(f, "user"),
Self::Assistant => write!(f, "assistant"),
Self::Tool => write!(f, "tool"),
}
}
}
// ── Content ────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ContentPart {
Text { text: String },
Image { url: String, detail: Option<String> },
}
// ── Tools ───────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolDefinition {
pub name: String,
pub description: Option<String>,
pub input_schema: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCall {
pub id: String,
pub name: String,
pub arguments: serde_json::Value,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ToolChoice {
None,
Auto,
Required,
Specific(String),
}
// ── Reasoning ───────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ReasoningConfig {
pub effort: Option<String>,
}
// ── Usage ───────────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Usage {
pub prompt_tokens: Option<i32>,
pub completion_tokens: Option<i32>,
pub total_tokens: Option<i32>,
}
// ── Stream Delta ────────────────────────────────────────────────────────
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamDelta {
pub role: Option<Role>,
pub content: Option<String>,
pub tool_calls: Option<Vec<StreamToolCall>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StreamToolCall {
pub index: i32,
pub id: Option<String>,
pub name: Option<String>,
pub arguments: Option<String>,
}
@@ -0,0 +1,37 @@
// Gemini CLI integration stub
use crate::transformers::canonical::CanonicalResponse;
use anyhow::{bail, Result};
use serde_json::Value;
#[derive(Debug, Clone, Default)]
pub struct GeminiCliBridge;
impl GeminiCliBridge {
pub fn new() -> Self {
Self
}
pub fn execute(&self, _request: &Value) -> Result<CanonicalResponse> {
bail!("Gemini CLI bridge is not implemented (501)")
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_new_bridge() {
let bridge = GeminiCliBridge::new();
let error = bridge.execute(&json!({})).unwrap_err();
assert!(error.to_string().contains("501"));
}
#[test]
fn test_default_bridge_returns_not_implemented() {
let bridge = GeminiCliBridge;
let error = bridge.execute(&json!({"contents": []})).unwrap_err();
assert!(error.to_string().contains("not implemented"));
}
}
@@ -0,0 +1,125 @@
// CanonicalRequest -> Gemini GenerateContent JSON formatter
use crate::transformers::canonical::*;
use serde_json::{json, Value};
/// Format a canonical request into Gemini's GenerateContent request body.
pub fn format_generate_content_request(request: &CanonicalRequest) -> Value {
let mut result = json!({
"contents": format_contents(&request.messages),
});
let generation_config = format_generation_config(request);
if !generation_config
.as_object()
.is_none_or(|object| object.is_empty())
{
result["generationConfig"] = generation_config;
}
let system_parts = collect_system_parts(&request.messages);
if !system_parts.is_empty() {
result["systemInstruction"] = json!({
"parts": system_parts,
});
}
result
}
fn format_contents(messages: &[CanonicalMessage]) -> Vec<Value> {
messages
.iter()
.filter(|message| message.role != Role::System)
.map(format_content)
.collect()
}
fn format_content(message: &CanonicalMessage) -> Value {
let role = match message.role {
Role::Assistant => "model",
Role::User | Role::Tool | Role::System => "user",
};
json!({
"role": role,
"parts": format_parts(&message.content),
})
}
fn collect_system_parts(messages: &[CanonicalMessage]) -> Vec<Value> {
messages
.iter()
.filter(|message| message.role == Role::System)
.flat_map(|message| format_parts(&message.content))
.collect()
}
fn format_parts(parts: &[ContentPart]) -> Vec<Value> {
parts
.iter()
.map(|part| match part {
ContentPart::Text { text } => json!({ "text": text }),
ContentPart::Image { url, .. } => json!({
"fileData": {
"fileUri": url,
},
}),
})
.collect()
}
fn format_generation_config(request: &CanonicalRequest) -> Value {
let mut config = json!({});
if let Some(max_tokens) = request.max_tokens {
config["maxOutputTokens"] = json!(max_tokens);
}
if let Some(temperature) = request.temperature {
config["temperature"] = json!(temperature);
}
if let Some(top_p) = request.top_p {
config["topP"] = json!(top_p);
}
if let Some(stop) = &request.stop {
config["stopSequences"] = json!(stop);
}
if request.stream {
config["streamResponse"] = json!(true);
}
config
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_simple_request() {
let req = CanonicalRequest::chat("gemini-2.0-flash", "Hello");
let json = format_generate_content_request(&req);
assert_eq!(json["contents"][0]["role"], "user");
assert_eq!(json["contents"][0]["parts"][0]["text"], "Hello");
assert!(json.get("systemInstruction").is_none());
}
#[test]
fn test_format_system_assistant_and_stream_config() {
let mut req = CanonicalRequest::chat_stream("gemini-2.0-flash", "Continue");
req.messages.insert(0, CanonicalMessage::system("Be brief"));
req.messages.insert(1, CanonicalMessage::assistant("Once"));
req.max_tokens = Some(128);
req.temperature = Some(0.4);
req.top_p = Some(0.9);
req.stop = Some(vec!["END".to_string()]);
let json = format_generate_content_request(&req);
assert_eq!(json["systemInstruction"]["parts"][0]["text"], "Be brief");
assert_eq!(json["contents"][0]["role"], "model");
assert_eq!(json["generationConfig"]["maxOutputTokens"], 128);
assert_eq!(json["generationConfig"]["streamResponse"], true);
assert_eq!(json["generationConfig"]["stopSequences"][0], "END");
}
}
@@ -0,0 +1,15 @@
// Transformers - Gemini Generate Content
pub mod cli_bridge;
pub mod inbound;
pub mod outbound;
pub mod stream;
pub mod url_resolver;
pub use cli_bridge::GeminiCliBridge;
pub use inbound::format_generate_content_request;
pub use outbound::{normalize_finish_reason, parse_generate_content_response};
pub use stream::parse_stream_event;
pub use url_resolver::{
resolve_generate_content_url, resolve_generate_content_url_with_base,
resolve_generate_content_url_with_key,
};
@@ -0,0 +1,132 @@
// Gemini GenerateContent response -> CanonicalResponse parser
use crate::transformers::canonical::*;
use serde_json::Value;
/// Parse a Gemini GenerateContent response body into canonical format.
pub fn parse_generate_content_response(body: &Value) -> Result<CanonicalResponse, String> {
let candidates = body["candidates"]
.as_array()
.ok_or_else(|| "candidates is required".to_string())?;
let choices = candidates
.iter()
.enumerate()
.map(parse_candidate)
.collect::<Vec<_>>();
Ok(CanonicalResponse {
id: body["responseId"]
.as_str()
.map(ToString::to_string)
.unwrap_or_else(|| format!("gemini-{}", uuid::Uuid::new_v4())),
model: body["model"]
.as_str()
.or_else(|| body["modelVersion"].as_str())
.unwrap_or_default()
.to_string(),
choices,
usage: parse_usage(body.get("usageMetadata")),
metadata: Some(body.clone()),
})
}
fn parse_candidate((index, candidate): (usize, &Value)) -> CanonicalChoice {
CanonicalChoice {
index: candidate["index"].as_i64().unwrap_or(index as i64) as i32,
message: CanonicalMessage {
role: Role::Assistant,
content: parse_parts(candidate["content"]["parts"].as_array()),
name: None,
tool_calls: None,
tool_call_id: None,
},
finish_reason: candidate["finishReason"]
.as_str()
.and_then(normalize_finish_reason),
}
}
fn parse_parts(parts: Option<&Vec<Value>>) -> Vec<ContentPart> {
parts
.into_iter()
.flatten()
.filter_map(|part| {
part["text"].as_str().map(|text| ContentPart::Text {
text: text.to_string(),
})
})
.collect()
}
fn parse_usage(value: Option<&Value>) -> Option<Usage> {
let usage = value?.as_object()?;
Some(Usage {
prompt_tokens: usage.get("promptTokenCount").and_then(as_i32),
completion_tokens: usage.get("candidatesTokenCount").and_then(as_i32),
total_tokens: usage.get("totalTokenCount").and_then(as_i32),
})
}
fn as_i32(value: &Value) -> Option<i32> {
value.as_i64().map(|n| n as i32)
}
pub fn normalize_finish_reason(reason: &str) -> Option<String> {
match reason {
"FINISH_REASON_UNSPECIFIED" => None,
"STOP" => Some("stop".to_string()),
"MAX_TOKENS" => Some("length".to_string()),
"SAFETY" | "RECITATION" | "BLOCKLIST" | "PROHIBITED_CONTENT" | "SPII" => {
Some("content_filter".to_string())
}
"MALFORMED_FUNCTION_CALL" => Some("tool_calls".to_string()),
other => Some(other.to_ascii_lowercase()),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_parse_simple_response() {
let body = json!({
"responseId": "resp_1",
"modelVersion": "gemini-2.0-flash",
"candidates": [{
"content": {"parts": [{"text": "Hello"}], "role": "model"},
"finishReason": "STOP"
}],
"usageMetadata": {
"promptTokenCount": 10,
"candidatesTokenCount": 5,
"totalTokenCount": 15
}
});
let response = parse_generate_content_response(&body).unwrap();
assert_eq!(response.id, "resp_1");
assert_eq!(response.model, "gemini-2.0-flash");
assert_eq!(response.choices[0].finish_reason.as_deref(), Some("stop"));
assert_eq!(response.usage.unwrap().total_tokens, Some(15));
}
#[test]
fn test_parse_safety_finish_reason() {
let body = json!({
"candidates": [{
"index": 2,
"content": {"parts": [{"text": "Blocked"}]},
"finishReason": "SAFETY"
}]
});
let response = parse_generate_content_response(&body).unwrap();
assert_eq!(response.choices[0].index, 2);
assert_eq!(
response.choices[0].finish_reason.as_deref(),
Some("content_filter")
);
}
}
@@ -0,0 +1,131 @@
// Gemini GenerateContent SSE stream parser
use crate::transformers::canonical::*;
use crate::transformers::gemini::generate_content::outbound::normalize_finish_reason;
use serde_json::Value;
/// Parse a single Gemini SSE frame or `data: ...` line into a canonical stream event.
pub fn parse_stream_event(frame: &str) -> Option<CanonicalStreamEvent> {
let data = parse_sse_data(frame)?;
let parsed: Value = serde_json::from_str(&data).ok()?;
let choices = parsed["candidates"]
.as_array()
.map(|candidates| {
candidates
.iter()
.enumerate()
.map(parse_candidate_delta)
.collect::<Vec<_>>()
})
.unwrap_or_default();
let usage = parse_usage(parsed.get("usageMetadata"));
Some(CanonicalStreamEvent {
id: parsed["responseId"].as_str().map(|s| s.to_string()),
choices,
usage,
})
}
fn parse_sse_data(frame: &str) -> Option<String> {
let trimmed = frame.trim();
if trimmed.is_empty() || trimmed == "[DONE]" || trimmed == "data: [DONE]" {
return None;
}
if trimmed.starts_with('{') {
return Some(trimmed.to_string());
}
let data_lines: Vec<&str> = trimmed
.lines()
.filter_map(|line| line.strip_prefix("data:").map(str::trim))
.collect();
if data_lines.is_empty() {
None
} else {
Some(data_lines.join("\n"))
}
}
fn parse_candidate_delta((index, candidate): (usize, &Value)) -> CanonicalStreamChoice {
CanonicalStreamChoice {
index: candidate["index"].as_i64().unwrap_or(index as i64) as i32,
delta: StreamDelta {
role: parse_role(candidate["content"]["role"].as_str()),
content: collect_text(candidate["content"]["parts"].as_array()),
tool_calls: None,
},
finish_reason: candidate["finishReason"]
.as_str()
.and_then(normalize_finish_reason),
}
}
fn parse_role(role: Option<&str>) -> Option<Role> {
match role {
Some("model") => Some(Role::Assistant),
Some("user") => Some(Role::User),
_ => None,
}
}
fn collect_text(parts: Option<&Vec<Value>>) -> Option<String> {
let text = parts
.into_iter()
.flatten()
.filter_map(|part| part["text"].as_str())
.collect::<Vec<_>>()
.join("");
if text.is_empty() {
None
} else {
Some(text)
}
}
fn parse_usage(value: Option<&Value>) -> Option<Usage> {
let usage = value?.as_object()?;
Some(Usage {
prompt_tokens: usage.get("promptTokenCount").and_then(as_i32),
completion_tokens: usage.get("candidatesTokenCount").and_then(as_i32),
total_tokens: usage.get("totalTokenCount").and_then(as_i32),
})
}
fn as_i32(value: &Value) -> Option<i32> {
value.as_i64().map(|n| n as i32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_text_chunk() {
let frame = r#"data: {"responseId":"r1","candidates":[{"content":{"parts":[{"text":"Hel"},{"text":"lo"}],"role":"model"},"finishReason":null}]}"#;
let event = parse_stream_event(frame).unwrap();
assert_eq!(event.id.as_deref(), Some("r1"));
assert_eq!(event.choices[0].delta.role, Some(Role::Assistant));
assert_eq!(event.choices[0].delta.content.as_deref(), Some("Hello"));
assert!(event.choices[0].finish_reason.is_none());
}
#[test]
fn test_parse_final_chunk_with_usage() {
let frame = r#"data: {"candidates":[{"content":{"parts":[]},"finishReason":"MAX_TOKENS"}],"usageMetadata":{"promptTokenCount":4,"candidatesTokenCount":6,"totalTokenCount":10}}"#;
let event = parse_stream_event(frame).unwrap();
assert_eq!(event.choices[0].finish_reason.as_deref(), Some("length"));
assert_eq!(event.usage.unwrap().total_tokens, Some(10));
}
#[test]
fn test_parse_done() {
assert!(parse_stream_event("data: [DONE]").is_none());
}
}
@@ -0,0 +1,73 @@
// Gemini GenerateContent URL resolver
const DEFAULT_GEMINI_BASE_URL: &str = "https://generativelanguage.googleapis.com/v1beta";
/// Resolve a Gemini model name into a GenerateContent endpoint URL.
pub fn resolve_generate_content_url(model: &str) -> String {
resolve_generate_content_url_with_base(model, DEFAULT_GEMINI_BASE_URL)
}
/// Resolve a Gemini model name into a GenerateContent endpoint URL with an API key query.
pub fn resolve_generate_content_url_with_key(model: &str, api_key: &str) -> String {
let url = resolve_generate_content_url(model);
if api_key.is_empty() {
url
} else {
format!("{}?key={}", url, api_key)
}
}
/// Resolve with a custom base URL for tests or alternate Gemini-compatible hosts.
pub fn resolve_generate_content_url_with_base(model: &str, base_url: &str) -> String {
let base = base_url.trim_end_matches('/');
let model = normalize_model_name(model);
format!("{}/models/{}:generateContent", base, model)
}
fn normalize_model_name(model: &str) -> String {
let trimmed = model.trim().trim_start_matches('/');
if let Some(rest) = trimmed.strip_prefix("models/") {
return rest.to_string();
}
if let Some((_, model_id)) = trimmed.rsplit_once("/models/") {
return model_id.to_string();
}
trimmed.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_resolve_plain_model() {
let url = resolve_generate_content_url("gemini-2.0-flash");
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"
);
}
#[test]
fn test_resolve_prefixed_model_with_key() {
let url = resolve_generate_content_url_with_key("models/gemini-1.5-pro", "secret");
assert_eq!(
url,
"https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-pro:generateContent?key=secret"
);
}
#[test]
fn test_resolve_publisher_model() {
let url = resolve_generate_content_url_with_base(
"publishers/google/models/gemini-2.0-flash",
"https://example.test/v1beta/",
);
assert_eq!(
url,
"https://example.test/v1beta/models/gemini-2.0-flash:generateContent"
);
}
}
+8
View File
@@ -0,0 +1,8 @@
// Transformers - Gemini
pub mod generate_content;
pub use generate_content::{
format_generate_content_request, parse_generate_content_response, parse_stream_event,
resolve_generate_content_url, resolve_generate_content_url_with_base,
resolve_generate_content_url_with_key, GeminiCliBridge,
};
+6
View File
@@ -0,0 +1,6 @@
// Transformers module — protocol conversion between canonical and provider formats
pub mod anthropic;
pub mod canonical;
pub mod gemini;
pub mod openai;
pub mod shared;
+205
View File
@@ -0,0 +1,205 @@
// OpenAI chat/completions request → CanonicalRequest parser
use crate::transformers::canonical::{
CanonicalMessage, CanonicalRequest, ContentPart, ReasoningConfig, Role, ToolCall, ToolChoice,
ToolDefinition,
};
use serde_json::Value;
/// Parse an OpenAI /v1/chat/completions request body into canonical format
pub fn parse_chat_completions_request(body: &Value) -> Result<CanonicalRequest, String> {
let model = body["model"]
.as_str()
.ok_or_else(|| "model is required".to_string())?
.to_string();
let stream = body["stream"].as_bool().unwrap_or(false);
let messages = body["messages"]
.as_array()
.ok_or_else(|| "messages is required".to_string())?;
let canonical_messages: Vec<CanonicalMessage> = messages
.iter()
.map(parse_message)
.collect::<Result<Vec<_>, _>>()?;
let max_tokens = body["max_tokens"].as_i64().map(|n| n as i32);
let max_completion_tokens = body["max_completion_tokens"].as_i64().map(|n| n as i32);
let temperature = body["temperature"].as_f64();
let top_p = body["top_p"].as_f64();
let stop = body["stop"].as_array().map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect()
});
let tools = body["tools"]
.as_array()
.map(|a| a.iter().filter_map(parse_tool).collect());
let tool_choice = body["tool_choice"].as_str().and_then(|s| match s {
"none" => Some(ToolChoice::None),
"auto" => Some(ToolChoice::Auto),
"required" => Some(ToolChoice::Required),
name => Some(ToolChoice::Specific(name.to_string())),
});
let reasoning = body["reasoning_config"]
.as_object()
.map(|_| ReasoningConfig {
effort: body["reasoning_config"]["effort"]
.as_str()
.map(|s| s.to_string()),
});
Ok(CanonicalRequest {
model,
messages: canonical_messages,
stream,
max_tokens: max_tokens.or(max_completion_tokens),
temperature,
top_p,
stop,
tools,
tool_choice,
reasoning,
metadata: None,
})
}
fn parse_message(msg: &Value) -> Result<CanonicalMessage, String> {
let role = match msg["role"].as_str() {
Some("system") => Role::System,
Some("user") => Role::User,
Some("assistant") => Role::Assistant,
Some("tool") => Role::Tool,
Some(other) => return Err(format!("Unknown role: {}", other)),
None => return Err("Missing role in message".to_string()),
};
let content = parse_content(msg);
let tool_calls = parse_tool_calls(msg);
let tool_call_id = msg["tool_call_id"].as_str().map(|s| s.to_string());
let name = msg["name"].as_str().map(|s| s.to_string());
Ok(CanonicalMessage {
role,
content,
name,
tool_calls,
tool_call_id,
})
}
fn parse_content(msg: &Value) -> Vec<ContentPart> {
match &msg["content"] {
Value::String(s) => {
vec![ContentPart::Text { text: s.clone() }]
}
Value::Array(arr) => arr
.iter()
.filter_map(|part| {
if let Some(text) = part["text"].as_str() {
Some(ContentPart::Text {
text: text.to_string(),
})
} else if let Some(image_url) = part["image_url"]["url"].as_str() {
Some(ContentPart::Image {
url: image_url.to_string(),
detail: part["image_url"]["detail"].as_str().map(|s| s.to_string()),
})
} else {
None
}
})
.collect(),
Value::Null => vec![],
_ => vec![],
}
}
fn parse_tool_calls(msg: &Value) -> Option<Vec<ToolCall>> {
msg["tool_calls"].as_array().map(|calls| {
calls
.iter()
.filter_map(|tc| {
let id = tc["id"].as_str()?.to_string();
let name = tc["function"]["name"].as_str()?.to_string();
let arguments = tc["function"]["arguments"].as_str()?;
Some(ToolCall {
id,
name,
arguments: serde_json::from_str(arguments).unwrap_or(Value::Null),
})
})
.collect()
})
}
fn parse_tool(v: &Value) -> Option<ToolDefinition> {
let name = v["function"]["name"].as_str()?;
Some(ToolDefinition {
name: name.to_string(),
description: v["function"]["description"].as_str().map(|s| s.to_string()),
input_schema: v["function"]["parameters"].clone(),
})
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_parse_simple_request() {
let body = json!({
"model": "gpt-4o",
"messages": [
{"role": "system", "content": "You are helpful"},
{"role": "user", "content": "Hello!"}
],
"stream": false
});
let req = parse_chat_completions_request(&body).unwrap();
assert_eq!(req.model, "gpt-4o");
assert_eq!(req.messages.len(), 2);
assert!(!req.stream);
}
#[test]
fn test_parse_streaming_request() {
let body = json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "Hi"}],
"stream": true,
"max_tokens": 100,
"temperature": 0.7,
});
let req = parse_chat_completions_request(&body).unwrap();
assert!(req.stream);
assert_eq!(req.max_tokens, Some(100));
assert!((req.temperature.unwrap() - 0.7).abs() < 0.001);
}
#[test]
fn test_parse_with_tools() {
let body = json!({
"model": "gpt-4o",
"messages": [{"role": "user", "content": "What's the weather?"}],
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "Get weather",
"parameters": {"type": "object", "properties": {}}
}
}],
"tool_choice": "auto"
});
let req = parse_chat_completions_request(&body).unwrap();
assert!(req.tools.is_some());
assert_eq!(req.tools.as_ref().unwrap().len(), 1);
assert_eq!(req.tools.as_ref().unwrap()[0].name, "get_weather");
}
}
+8
View File
@@ -0,0 +1,8 @@
// OpenAI /v1/chat/completions transformers
pub mod inbound;
pub mod outbound;
pub mod stream;
pub use inbound::parse_chat_completions_request;
pub use outbound::format_chat_completions_response;
pub use stream::{format_done_event, format_stream_event, parse_stream_chunk};
+141
View File
@@ -0,0 +1,141 @@
// CanonicalResponse → OpenAI /v1/chat/completions JSON formatter
use crate::transformers::canonical::{
CanonicalChoice, CanonicalMessage, CanonicalResponse, ContentPart, Role, Usage,
};
use serde_json::{json, Value};
/// Format a canonical response into OpenAI /v1/chat/completions JSON
pub fn format_chat_completions_response(response: &CanonicalResponse) -> Value {
let choices: Vec<Value> = response.choices.iter().map(format_choice).collect();
let mut result = json!({
"id": response.id,
"object": "chat.completion",
"created": chrono::Utc::now().timestamp(),
"model": response.model,
"choices": choices,
});
if let Some(usage) = &response.usage {
result["usage"] = json!({
"prompt_tokens": usage.prompt_tokens,
"completion_tokens": usage.completion_tokens,
"total_tokens": usage.total_tokens,
});
}
result
}
fn format_choice(choice: &CanonicalChoice) -> Value {
json!({
"index": choice.index,
"message": format_message(&choice.message),
"finish_reason": choice.finish_reason,
})
}
fn format_message(msg: &CanonicalMessage) -> Value {
let role_str = match msg.role {
Role::System => "system",
Role::User => "user",
Role::Assistant => "assistant",
Role::Tool => "tool",
};
let content: Value = if msg.content.is_empty() {
Value::Null
} else if msg.content.len() == 1 {
match &msg.content[0] {
ContentPart::Text { text } => Value::String(text.clone()),
_ => format_content_parts(&msg.content),
}
} else {
format_content_parts(&msg.content)
};
let mut result = json!({
"role": role_str,
"content": content,
});
if let Some(name) = &msg.name {
result["name"] = json!(name);
}
if let Some(tool_calls) = &msg.tool_calls {
let formatted: Vec<Value> = tool_calls
.iter()
.map(|tc| {
json!({
"id": tc.id,
"type": "function",
"function": {
"name": tc.name,
"arguments": tc.arguments.to_string(),
}
})
})
.collect();
result["tool_calls"] = json!(formatted);
}
if let Some(tool_call_id) = &msg.tool_call_id {
result["tool_call_id"] = json!(tool_call_id);
}
result
}
fn format_content_parts(parts: &[ContentPart]) -> Value {
let arr: Vec<Value> = parts
.iter()
.map(|part| match part {
ContentPart::Text { text } => json!({
"type": "text",
"text": text,
}),
ContentPart::Image { url, detail } => {
let mut img = json!({
"type": "image_url",
"image_url": {
"url": url,
}
});
if let Some(d) = detail {
img["image_url"]["detail"] = json!(d);
}
img
}
})
.collect();
Value::Array(arr)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::transformers::canonical::{CanonicalMessage, CanonicalResponse};
#[test]
fn test_format_simple_response() {
let resp = CanonicalResponse::simple("gpt-4o", "Hello!");
let json = format_chat_completions_response(&resp);
assert_eq!(json["model"], "gpt-4o");
assert_eq!(json["object"], "chat.completion");
assert_eq!(json["choices"][0]["message"]["content"], "Hello!");
assert_eq!(json["choices"][0]["finish_reason"], "stop");
}
#[test]
fn test_format_with_usage() {
let mut resp = CanonicalResponse::simple("gpt-4o", "Hi");
resp.usage = Some(crate::transformers::canonical::Usage {
prompt_tokens: Some(10),
completion_tokens: Some(5),
total_tokens: Some(15),
});
let json = format_chat_completions_response(&resp);
assert_eq!(json["usage"]["total_tokens"], 15);
}
}
+190
View File
@@ -0,0 +1,190 @@
// OpenAI SSE stream parser and formatter
use crate::transformers::canonical::{
CanonicalStreamChoice, CanonicalStreamEvent, Role, StreamDelta, Usage,
};
use serde_json::{json, Value};
/// Parse a single SSE data line from OpenAI stream into a CanonicalStreamEvent
pub fn parse_stream_chunk(line: &str) -> Option<CanonicalStreamEvent> {
let data = line.strip_prefix("data: ")?.trim();
if data == "[DONE]" {
return None;
}
let parsed: Value = serde_json::from_str(data).ok()?;
let id = parsed["id"].as_str().map(|s| s.to_string());
let choices: Vec<CanonicalStreamChoice> = parsed["choices"]
.as_array()
.map(|arr| {
arr.iter()
.filter_map(|c| {
let index = c["index"].as_i64().unwrap_or(0) as i32;
let finish_reason = c["finish_reason"].as_str().map(|s| s.to_string());
let delta = c.get("delta")?;
let role = delta["role"].as_str().and_then(|s| match s {
"system" => Some(Role::System),
"user" => Some(Role::User),
"assistant" => Some(Role::Assistant),
"tool" => Some(Role::Tool),
_ => None,
});
let content = delta["content"].as_str().map(|s| s.to_string());
let tool_calls = delta["tool_calls"].as_array().map(|tc_arr| {
tc_arr
.iter()
.filter_map(|tc| {
let index = tc["index"].as_i64().unwrap_or(0) as i32;
Some(crate::transformers::canonical::StreamToolCall {
index,
id: tc["id"].as_str().map(|s| s.to_string()),
name: tc["function"]["name"].as_str().map(|s| s.to_string()),
arguments: tc["function"]["arguments"]
.as_str()
.map(|s| s.to_string()),
})
})
.collect()
});
Some(CanonicalStreamChoice {
index,
delta: StreamDelta {
role,
content,
tool_calls,
},
finish_reason,
})
})
.collect()
})
.unwrap_or_default();
let usage = parsed["usage"].as_object().map(|_| Usage {
prompt_tokens: parsed["usage"]["prompt_tokens"].as_i64().map(|n| n as i32),
completion_tokens: parsed["usage"]["completion_tokens"]
.as_i64()
.map(|n| n as i32),
total_tokens: parsed["usage"]["total_tokens"].as_i64().map(|n| n as i32),
});
Some(CanonicalStreamEvent { id, choices, usage })
}
/// Format a CanonicalStreamEvent back into an OpenAI SSE data line
pub fn format_stream_event(event: &CanonicalStreamEvent) -> String {
let choices: Vec<Value> = event
.choices
.iter()
.map(|c| {
let mut delta = json!({});
if let Some(ref role) = c.delta.role {
delta["role"] = json!(role.to_string());
}
if let Some(ref content) = c.delta.content {
delta["content"] = json!(content);
}
if let Some(ref tool_calls) = c.delta.tool_calls {
let tc_arr: Vec<Value> = tool_calls
.iter()
.map(|tc| {
let mut func = json!({});
if let Some(ref name) = tc.name {
func["name"] = json!(name);
}
if let Some(ref args) = tc.arguments {
func["arguments"] = json!(args);
}
json!({
"index": tc.index,
"id": tc.id,
"type": "function",
"function": func,
})
})
.collect();
delta["tool_calls"] = json!(tc_arr);
}
let mut choice = json!({
"index": c.index,
"delta": delta,
});
if let Some(ref fr) = c.finish_reason {
choice["finish_reason"] = json!(fr);
}
choice
})
.collect();
let mut result = json!({
"choices": choices,
});
if let Some(ref id) = event.id {
result["id"] = json!(id);
result["object"] = json!("chat.completion.chunk");
}
if let Some(ref usage) = event.usage {
result["usage"] = json!({
"prompt_tokens": usage.prompt_tokens,
"completion_tokens": usage.completion_tokens,
"total_tokens": usage.total_tokens,
});
}
format!(
"data: {}\n\n",
serde_json::to_string(&result).unwrap_or_default()
)
}
/// Format the [DONE] event
pub fn format_done_event() -> &'static str {
"data: [DONE]\n\n"
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_content_chunk() {
let line = r#"data: {"id":"123","choices":[{"index":0,"delta":{"content":"Hello"},"finish_reason":null}]}"#;
let event = parse_stream_chunk(line).unwrap();
assert_eq!(event.choices.len(), 1);
assert_eq!(event.choices[0].delta.content.as_deref(), Some("Hello"));
}
#[test]
fn test_parse_done() {
let result = parse_stream_chunk("data: [DONE]");
assert!(result.is_none());
}
#[test]
fn test_format_roundtrip() {
let event = CanonicalStreamEvent {
id: Some("test-id".into()),
choices: vec![CanonicalStreamChoice {
index: 0,
delta: StreamDelta {
role: None,
content: Some("Hi".into()),
tool_calls: None,
},
finish_reason: None,
}],
usage: None,
};
let formatted = format_stream_event(&event);
assert!(formatted.starts_with("data: "));
assert!(formatted.contains("Hi"));
}
}
+8
View File
@@ -0,0 +1,8 @@
// Transformers — OpenAI protocol
pub mod chat;
pub mod responses;
pub use chat::{
format_chat_completions_response, format_done_event, format_stream_event,
parse_chat_completions_request, parse_stream_chunk,
};
+22
View File
@@ -0,0 +1,22 @@
use crate::transformers::canonical::{CanonicalRequest, CanonicalResponse};
use serde_json::{json, Value};
pub fn parse_responses_request(body: &Value) -> Result<CanonicalRequest, String> {
super::chat::parse_chat_completions_request(body)
}
pub fn format_responses_response(response: &CanonicalResponse) -> Value {
let message = response.choices.first().map(|choice| &choice.message);
json!({
"id": response.id,
"object": "response",
"model": response.model,
"created_at": chrono::Utc::now().timestamp(),
"output": message.map(|message| json!([{
"type": "message",
"role": message.role.to_string(),
"content": message.content,
}])).unwrap_or_else(|| json!([])),
"usage": response.usage,
})
}
+22
View File
@@ -0,0 +1,22 @@
use serde_json::Value;
pub fn string_field<'a>(body: &'a Value, key: &str) -> Option<&'a str> {
body.get(key).and_then(Value::as_str)
}
pub fn bool_field(body: &Value, key: &str) -> Option<bool> {
body.get(key).and_then(Value::as_bool)
}
pub fn array_field<'a>(body: &'a Value, key: &str) -> Option<&'a Vec<Value>> {
body.get(key).and_then(Value::as_array)
}
pub fn merge_json_object(base: &mut Value, extra: Value) {
let (Some(base), Some(extra)) = (base.as_object_mut(), extra.as_object()) else {
return;
};
for (key, value) in extra {
base.insert(key.clone(), value.clone());
}
}