feat: add workspace and claude messages support

This commit is contained in:
boos
2026-05-22 05:57:37 +08:00
parent c1074ddf71
commit 7cdb02ef1d
24 changed files with 3282 additions and 479 deletions
+191 -1
View File
@@ -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::*;
+1 -1
View File
@@ -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;