mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 11:19:50 +08:00
refactor(ai-formats): group formats by provider
Move protocol/request/response format modules under provider-oriented formats modules and update registry, transport, and architecture paths.
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
pub mod request;
|
||||
pub mod response;
|
||||
@@ -0,0 +1,218 @@
|
||||
//! Pairwise request conversion helpers.
|
||||
//!
|
||||
//! These helpers keep the call sites readable while delegating wire-format
|
||||
//! parsing and emitting to `formats::<format>::request` through the registry's
|
||||
//! canonical IR path.
|
||||
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::formats::{context::FormatContext, registry};
|
||||
|
||||
pub fn convert_openai_chat_request_to_claude_request(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
registry::convert_request(
|
||||
"openai:chat",
|
||||
"claude:messages",
|
||||
body_json,
|
||||
&request_context(mapped_model, upstream_is_stream),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_openai_chat_request_to_gemini_request(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
) -> Option<Value> {
|
||||
registry::convert_request(
|
||||
"openai:chat",
|
||||
"gemini:generate_content",
|
||||
body_json,
|
||||
&request_context(mapped_model, upstream_is_stream),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_openai_chat_request_to_openai_responses_request(
|
||||
body_json: &Value,
|
||||
mapped_model: &str,
|
||||
upstream_is_stream: bool,
|
||||
compact: bool,
|
||||
) -> Option<Value> {
|
||||
let target_format = if compact {
|
||||
"openai:responses:compact"
|
||||
} else {
|
||||
"openai:responses"
|
||||
};
|
||||
registry::convert_request(
|
||||
"openai:chat",
|
||||
target_format,
|
||||
body_json,
|
||||
&request_context(mapped_model, upstream_is_stream),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn normalize_openai_responses_request_to_openai_chat_request(
|
||||
body_json: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_request(
|
||||
"openai:responses",
|
||||
"openai:chat",
|
||||
body_json,
|
||||
&FormatContext::default(),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn normalize_claude_request_to_openai_chat_request(body_json: &Value) -> Option<Value> {
|
||||
registry::convert_request(
|
||||
"claude:messages",
|
||||
"openai:chat",
|
||||
body_json,
|
||||
&FormatContext::default(),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn normalize_gemini_request_to_openai_chat_request(
|
||||
body_json: &Value,
|
||||
request_path: &str,
|
||||
) -> Option<Value> {
|
||||
registry::convert_request(
|
||||
"gemini:generate_content",
|
||||
"openai:chat",
|
||||
body_json,
|
||||
&FormatContext::default().with_request_path(request_path),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn extract_openai_text_content(content: Option<&Value>) -> Option<String> {
|
||||
match content {
|
||||
None | Some(Value::Null) => Some(String::new()),
|
||||
Some(Value::String(text)) => Some(text.clone()),
|
||||
Some(Value::Array(parts)) => {
|
||||
let mut collected = Vec::new();
|
||||
for part in parts {
|
||||
let part_object = part.as_object()?;
|
||||
let part_type = part_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default();
|
||||
if matches!(part_type, "text" | "input_text") {
|
||||
if let Some(text) = part_object.get("text").and_then(Value::as_str) {
|
||||
if !text.trim().is_empty() {
|
||||
collected.push(text.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(collected.join("\n"))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_openai_tool_result_content(content: Option<&Value>) -> Value {
|
||||
match content {
|
||||
Some(Value::String(raw)) => {
|
||||
let trimmed = raw.trim();
|
||||
if trimmed.is_empty() {
|
||||
Value::String(String::new())
|
||||
} else {
|
||||
serde_json::from_str::<Value>(trimmed)
|
||||
.unwrap_or_else(|_| Value::String(raw.clone()))
|
||||
}
|
||||
}
|
||||
Some(Value::Array(parts)) => {
|
||||
let texts = parts
|
||||
.iter()
|
||||
.filter_map(|part| {
|
||||
part.as_object()
|
||||
.and_then(|object| object.get("text"))
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if texts.is_empty() {
|
||||
Value::Array(parts.clone())
|
||||
} else {
|
||||
Value::String(texts.join("\n"))
|
||||
}
|
||||
}
|
||||
Some(value) => value.clone(),
|
||||
None => Value::String(String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn request_context(mapped_model: &str, upstream_is_stream: bool) -> FormatContext {
|
||||
FormatContext::default()
|
||||
.with_mapped_model(mapped_model)
|
||||
.with_upstream_stream(upstream_is_stream)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
convert_openai_chat_request_to_claude_request,
|
||||
convert_openai_chat_request_to_openai_responses_request,
|
||||
normalize_claude_request_to_openai_chat_request,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn pairwise_request_helper_routes_through_registry() {
|
||||
let body = json!({
|
||||
"model": "gpt-source",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
});
|
||||
|
||||
let converted = convert_openai_chat_request_to_openai_responses_request(
|
||||
&body,
|
||||
"gpt-target",
|
||||
true,
|
||||
false,
|
||||
)
|
||||
.expect("responses request");
|
||||
|
||||
assert_eq!(converted["model"], "gpt-target");
|
||||
assert_eq!(converted["stream"], true);
|
||||
assert_eq!(converted["input"][0]["type"], "message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairwise_request_helper_keeps_claude_shape() {
|
||||
let body = json!({
|
||||
"model": "gpt-source",
|
||||
"messages": [{"role": "user", "content": "hello"}],
|
||||
});
|
||||
|
||||
let converted =
|
||||
convert_openai_chat_request_to_claude_request(&body, "claude-target", false)
|
||||
.expect("claude request");
|
||||
|
||||
assert_eq!(converted["model"], "claude-target");
|
||||
assert_eq!(converted["messages"][0]["role"], "user");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn request_normalizer_uses_format_adapter() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet",
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": "hello"}]}],
|
||||
"max_tokens": 128,
|
||||
});
|
||||
|
||||
let converted =
|
||||
normalize_claude_request_to_openai_chat_request(&body).expect("openai chat request");
|
||||
|
||||
assert_eq!(converted["model"], "claude-sonnet");
|
||||
assert_eq!(converted["messages"][0]["role"], "user");
|
||||
assert_eq!(converted["messages"][0]["content"], "hello");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
//! Pairwise response conversion helpers.
|
||||
//!
|
||||
//! These helpers keep the call sites readable while delegating wire-format
|
||||
//! parsing and emitting to `formats::<format>::response` through the registry's
|
||||
//! canonical IR path.
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::formats::{context::FormatContext, registry};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OpenAiResponsesResponseUsage {
|
||||
pub prompt_tokens: u64,
|
||||
pub output_tokens: u64,
|
||||
pub total_tokens: u64,
|
||||
}
|
||||
|
||||
pub fn convert_claude_chat_response_to_openai_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"claude:messages",
|
||||
"openai:chat",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_gemini_chat_response_to_openai_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"gemini:generate_content",
|
||||
"openai:chat",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_openai_chat_response_to_claude_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"openai:chat",
|
||||
"claude:messages",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_openai_chat_response_to_gemini_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"openai:chat",
|
||||
"gemini:generate_content",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_openai_responses_response_to_openai_chat(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"openai:responses",
|
||||
"openai:chat",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_openai_chat_response_to_openai_responses(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
compact: bool,
|
||||
) -> Option<Value> {
|
||||
let target_format = if compact {
|
||||
"openai:responses:compact"
|
||||
} else {
|
||||
"openai:responses"
|
||||
};
|
||||
registry::convert_response(
|
||||
"openai:chat",
|
||||
target_format,
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_claude_response_to_openai_responses(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"claude:messages",
|
||||
"openai:responses",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn convert_gemini_response_to_openai_responses(
|
||||
body_json: &Value,
|
||||
report_context: &Value,
|
||||
) -> Option<Value> {
|
||||
registry::convert_response(
|
||||
"gemini:generate_content",
|
||||
"openai:responses",
|
||||
body_json,
|
||||
&response_context(report_context),
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
|
||||
pub fn build_openai_responses_response(
|
||||
response_id: &str,
|
||||
model: &str,
|
||||
text: &str,
|
||||
function_calls: Vec<Value>,
|
||||
prompt_tokens: u64,
|
||||
output_tokens: u64,
|
||||
total_tokens: u64,
|
||||
) -> Value {
|
||||
let content = if text.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![json!({
|
||||
"type": "output_text",
|
||||
"text": text,
|
||||
"annotations": []
|
||||
})]
|
||||
};
|
||||
build_openai_responses_response_with_content(
|
||||
response_id,
|
||||
model,
|
||||
content,
|
||||
Vec::new(),
|
||||
function_calls,
|
||||
OpenAiResponsesResponseUsage {
|
||||
prompt_tokens,
|
||||
output_tokens,
|
||||
total_tokens,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_openai_responses_response_with_reasoning(
|
||||
response_id: &str,
|
||||
model: &str,
|
||||
text: &str,
|
||||
reasoning_summaries: Vec<String>,
|
||||
function_calls: Vec<Value>,
|
||||
usage: OpenAiResponsesResponseUsage,
|
||||
) -> Value {
|
||||
let content = if text.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
vec![json!({
|
||||
"type": "output_text",
|
||||
"text": text,
|
||||
"annotations": []
|
||||
})]
|
||||
};
|
||||
build_openai_responses_response_with_content(
|
||||
response_id,
|
||||
model,
|
||||
content,
|
||||
reasoning_summaries,
|
||||
function_calls,
|
||||
usage,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn build_openai_responses_response_with_content(
|
||||
response_id: &str,
|
||||
model: &str,
|
||||
content: Vec<Value>,
|
||||
reasoning_summaries: Vec<String>,
|
||||
function_calls: Vec<Value>,
|
||||
usage: OpenAiResponsesResponseUsage,
|
||||
) -> Value {
|
||||
let mut output = Vec::new();
|
||||
for (index, summary) in reasoning_summaries.into_iter().enumerate() {
|
||||
let trimmed = summary.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
output.push(json!({
|
||||
"type": "reasoning",
|
||||
"id": format!("{response_id}_rs_{index}"),
|
||||
"status": "completed",
|
||||
"summary": [{
|
||||
"type": "summary_text",
|
||||
"text": trimmed,
|
||||
}]
|
||||
}));
|
||||
}
|
||||
if !content.is_empty() {
|
||||
output.push(json!({
|
||||
"type": "message",
|
||||
"id": format!("{response_id}_msg"),
|
||||
"role": "assistant",
|
||||
"status": "completed",
|
||||
"content": content
|
||||
}));
|
||||
}
|
||||
output.extend(function_calls);
|
||||
json!({
|
||||
"id": response_id,
|
||||
"object": "response",
|
||||
"status": "completed",
|
||||
"model": model,
|
||||
"output": output,
|
||||
"usage": {
|
||||
"input_tokens": usage.prompt_tokens,
|
||||
"output_tokens": usage.output_tokens,
|
||||
"total_tokens": usage.total_tokens,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn response_context(report_context: &Value) -> FormatContext {
|
||||
let mut context = FormatContext::default().with_report_context(report_context.clone());
|
||||
if let Some(model) = report_context
|
||||
.get("mapped_model")
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| report_context.get("model").and_then(Value::as_str))
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
{
|
||||
context = context.with_mapped_model(model);
|
||||
}
|
||||
context
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::{
|
||||
convert_claude_chat_response_to_openai_chat,
|
||||
convert_openai_chat_response_to_openai_responses,
|
||||
};
|
||||
|
||||
#[test]
|
||||
fn pairwise_response_helper_routes_through_registry() {
|
||||
let body = json!({
|
||||
"id": "chatcmpl-test",
|
||||
"object": "chat.completion",
|
||||
"model": "gpt-source",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {"role": "assistant", "content": "hello"},
|
||||
"finish_reason": "stop"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 1, "completion_tokens": 2, "total_tokens": 3}
|
||||
});
|
||||
|
||||
let converted = convert_openai_chat_response_to_openai_responses(&body, &json!({}), false)
|
||||
.expect("responses response");
|
||||
|
||||
assert_eq!(converted["object"], "response");
|
||||
assert_eq!(converted["output"][0]["type"], "message");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pairwise_response_helper_uses_report_context_model_fallback() {
|
||||
let body = json!({
|
||||
"id": "msg-test",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": "hello"}],
|
||||
"stop_reason": "end_turn",
|
||||
"usage": {"input_tokens": 1, "output_tokens": 2}
|
||||
});
|
||||
|
||||
let converted = convert_claude_chat_response_to_openai_chat(
|
||||
&body,
|
||||
&json!({"mapped_model": "gpt-target"}),
|
||||
)
|
||||
.expect("openai chat response");
|
||||
|
||||
assert_eq!(converted["model"], "gpt-target");
|
||||
assert_eq!(converted["choices"][0]["message"]["content"], "hello");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user