feat: add workspace and claude messages support
This commit is contained in:
@@ -1,8 +1,75 @@
|
||||
// CanonicalRequest -> Anthropic /v1/messages JSON formatter
|
||||
// Anthropic /v1/messages request parser + CanonicalRequest -> Anthropic formatter
|
||||
use crate::transformers::anthropic::messages::AnthropicMessagesCompatibility;
|
||||
use crate::transformers::canonical::*;
|
||||
use serde_json::{json, Value};
|
||||
|
||||
/// Parse an Anthropic Messages API request body into canonical format.
|
||||
pub fn parse_messages_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 mut messages = Vec::new();
|
||||
|
||||
if let Some(system) = body.get("system") {
|
||||
let system_parts = parse_anthropic_content(system)?;
|
||||
if !system_parts.is_empty() {
|
||||
messages.push(CanonicalMessage {
|
||||
role: Role::System,
|
||||
content: system_parts,
|
||||
name: None,
|
||||
tool_calls: None,
|
||||
tool_call_id: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let raw_messages = body["messages"]
|
||||
.as_array()
|
||||
.ok_or_else(|| "messages is required".to_string())?;
|
||||
for message in raw_messages {
|
||||
messages.push(parse_anthropic_message(message)?);
|
||||
}
|
||||
|
||||
let stop = body["stop_sequences"].as_array().map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(|item| item.as_str().map(str::to_string))
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let tools = body["tools"].as_array().map(|items| {
|
||||
items
|
||||
.iter()
|
||||
.filter_map(parse_anthropic_tool)
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
let tool_choice = parse_anthropic_tool_choice(body.get("tool_choice"));
|
||||
let reasoning = body.get("thinking").and_then(|thinking| {
|
||||
if thinking["type"].as_str() == Some("enabled") {
|
||||
Some(ReasoningConfig {
|
||||
effort: Some("medium".to_string()),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
|
||||
Ok(CanonicalRequest {
|
||||
model,
|
||||
messages,
|
||||
stream,
|
||||
max_tokens: body["max_tokens"].as_i64().map(|value| value as i32),
|
||||
temperature: body["temperature"].as_f64(),
|
||||
top_p: body["top_p"].as_f64(),
|
||||
stop,
|
||||
tools,
|
||||
tool_choice,
|
||||
reasoning,
|
||||
metadata: body.get("metadata").cloned(),
|
||||
})
|
||||
}
|
||||
|
||||
/// 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())
|
||||
@@ -203,6 +270,129 @@ fn merge_extra_fields(target: &mut Value, extra: &Value) {
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_anthropic_message(message: &Value) -> Result<CanonicalMessage, String> {
|
||||
let role = match message["role"].as_str() {
|
||||
Some("user") => Role::User,
|
||||
Some("assistant") => Role::Assistant,
|
||||
Some(other) => return Err(format!("Unknown role: {other}")),
|
||||
None => return Err("Missing role in message".to_string()),
|
||||
};
|
||||
let (content, tool_calls, tool_call_id) = parse_message_content(message.get("content"))?;
|
||||
Ok(CanonicalMessage {
|
||||
role,
|
||||
content,
|
||||
name: None,
|
||||
tool_calls,
|
||||
tool_call_id,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_message_content(
|
||||
content: Option<&Value>,
|
||||
) -> Result<(Vec<ContentPart>, Option<Vec<ToolCall>>, Option<String>), String> {
|
||||
let Some(content) = content else {
|
||||
return Ok((Vec::new(), None, None));
|
||||
};
|
||||
if let Some(text) = content.as_str() {
|
||||
return Ok((vec![ContentPart::Text { text: text.to_string() }], None, None));
|
||||
}
|
||||
|
||||
let mut parts = Vec::new();
|
||||
let mut tool_calls = Vec::new();
|
||||
let mut tool_call_id = None;
|
||||
let Some(items) = content.as_array() else {
|
||||
return Ok((parts, None, None));
|
||||
};
|
||||
for item in items {
|
||||
match item["type"].as_str() {
|
||||
Some("text") => {
|
||||
if let Some(text) = item["text"].as_str() {
|
||||
parts.push(ContentPart::Text { text: text.to_string() });
|
||||
}
|
||||
}
|
||||
Some("image") => {
|
||||
if let Some(url) = item["source"]["url"].as_str() {
|
||||
parts.push(ContentPart::Image {
|
||||
url: url.to_string(),
|
||||
detail: None,
|
||||
});
|
||||
} else if item["source"]["type"].as_str() == Some("base64") {
|
||||
let media_type = item["source"]["media_type"]
|
||||
.as_str()
|
||||
.unwrap_or("image/png");
|
||||
if let Some(data) = item["source"]["data"].as_str() {
|
||||
parts.push(ContentPart::Image {
|
||||
url: format!("data:{media_type};base64,{data}"),
|
||||
detail: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("tool_use") => {
|
||||
if let (Some(id), Some(name)) = (item["id"].as_str(), item["name"].as_str()) {
|
||||
tool_calls.push(ToolCall {
|
||||
id: id.to_string(),
|
||||
name: name.to_string(),
|
||||
arguments: item["input"].clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
Some("tool_result") => {
|
||||
tool_call_id = item["tool_use_id"].as_str().map(str::to_string);
|
||||
match &item["content"] {
|
||||
Value::String(text) => parts.push(ContentPart::Text { text: text.clone() }),
|
||||
Value::Array(_) => parts.extend(parse_anthropic_content(&item["content"])?),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok((
|
||||
parts,
|
||||
if tool_calls.is_empty() { None } else { Some(tool_calls) },
|
||||
tool_call_id,
|
||||
))
|
||||
}
|
||||
|
||||
fn parse_anthropic_content(value: &Value) -> Result<Vec<ContentPart>, String> {
|
||||
if let Some(text) = value.as_str() {
|
||||
return Ok(vec![ContentPart::Text { text: text.to_string() }]);
|
||||
}
|
||||
let Some(items) = value.as_array() else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut parts = Vec::new();
|
||||
for item in items {
|
||||
if item["type"].as_str() == Some("text") {
|
||||
if let Some(text) = item["text"].as_str() {
|
||||
parts.push(ContentPart::Text { text: text.to_string() });
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
fn parse_anthropic_tool(value: &Value) -> Option<ToolDefinition> {
|
||||
let name = value["name"].as_str()?.to_string();
|
||||
Some(ToolDefinition {
|
||||
name,
|
||||
description: value["description"].as_str().map(str::to_string),
|
||||
input_schema: value["input_schema"].clone(),
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_anthropic_tool_choice(value: Option<&Value>) -> Option<ToolChoice> {
|
||||
let value = value?;
|
||||
match value["type"].as_str()? {
|
||||
"auto" => Some(ToolChoice::Auto),
|
||||
"any" => Some(ToolChoice::Required),
|
||||
"tool" => value["name"].as_str().map(|name| ToolChoice::Specific(name.to_string())),
|
||||
"none" => Some(ToolChoice::None),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -5,6 +5,6 @@ pub mod outbound;
|
||||
pub mod stream;
|
||||
|
||||
pub use compatibility::AnthropicMessagesCompatibility;
|
||||
pub use inbound::{format_messages_request, format_messages_request_with_compat};
|
||||
pub use inbound::{format_messages_request, format_messages_request_with_compat, parse_messages_request};
|
||||
pub use outbound::parse_messages_response;
|
||||
pub use stream::parse_stream_event;
|
||||
|
||||
Reference in New Issue
Block a user