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:
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user