mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 03:09:50 +08:00
fix(format): avoid false cache-control conversion blocks
This commit is contained in:
@@ -220,7 +220,7 @@ pub fn convert_response_pure(
|
|||||||
let response = parse_response_pure(source_format, body)?;
|
let response = parse_response_pure(source_format, body)?;
|
||||||
validate_response_conversion(source_format, target_format, body, &response)?;
|
validate_response_conversion(source_format, target_format, body, &response)?;
|
||||||
let value = emit_response_pure(target_format, &response)?;
|
let value = emit_response_pure(target_format, &response)?;
|
||||||
let report = build_request_conversion_report(source_format, target_format, body, &value);
|
let report = build_response_conversion_report(source_format, target_format, body, &value);
|
||||||
Ok(Converted { value, report })
|
Ok(Converted { value, report })
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1655,7 +1655,7 @@ fn validate_openai_responses_to_chat(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn validate_claude_cross_format_request(body: &Value, target: FormatId) -> Result<(), FormatError> {
|
fn validate_claude_cross_format_request(body: &Value, target: FormatId) -> Result<(), FormatError> {
|
||||||
if value_contains_object_key(body, "cache_control") {
|
if claude_request_contains_provider_cache_control(body) {
|
||||||
return Err(FormatError::LossyConversionBlocked {
|
return Err(FormatError::LossyConversionBlocked {
|
||||||
source_format: FormatId::ClaudeMessages.as_str().to_string(),
|
source_format: FormatId::ClaudeMessages.as_str().to_string(),
|
||||||
target_format: target.as_str().to_string(),
|
target_format: target.as_str().to_string(),
|
||||||
@@ -2052,21 +2052,73 @@ fn gemini_request_contains_thought_parts(body: &Value) -> bool {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn value_contains_object_key(value: &Value, key: &str) -> bool {
|
fn claude_request_contains_provider_cache_control(body: &Value) -> bool {
|
||||||
match value {
|
let Some(object) = body.as_object() else {
|
||||||
Value::Object(object) => {
|
return false;
|
||||||
object.contains_key(key)
|
};
|
||||||
|| object
|
object.contains_key("cache_control")
|
||||||
.values()
|
|| claude_system_contains_cache_control(object.get("system"))
|
||||||
.any(|value| value_contains_object_key(value, key))
|
|| claude_messages_contain_cache_control(object.get("messages"))
|
||||||
}
|
|| claude_tools_contain_cache_control(object.get("tools"))
|
||||||
Value::Array(values) => values
|
}
|
||||||
.iter()
|
|
||||||
.any(|value| value_contains_object_key(value, key)),
|
fn claude_system_contains_cache_control(system: Option<&Value>) -> bool {
|
||||||
|
match system {
|
||||||
|
Some(Value::Array(blocks)) => blocks.iter().any(|block| {
|
||||||
|
block
|
||||||
|
.as_object()
|
||||||
|
.is_some_and(|object| object.contains_key("cache_control"))
|
||||||
|
}),
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn claude_messages_contain_cache_control(messages: Option<&Value>) -> bool {
|
||||||
|
let Some(messages) = messages.and_then(Value::as_array) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
messages.iter().any(|message| {
|
||||||
|
message
|
||||||
|
.get("content")
|
||||||
|
.is_some_and(claude_content_value_contains_cache_control)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claude_content_value_contains_cache_control(content: &Value) -> bool {
|
||||||
|
match content {
|
||||||
|
Value::Array(blocks) => blocks
|
||||||
|
.iter()
|
||||||
|
.any(claude_content_block_contains_cache_control),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claude_content_block_contains_cache_control(block: &Value) -> bool {
|
||||||
|
let Some(object) = block.as_object() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
if object.contains_key("cache_control") {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
object
|
||||||
|
.get("type")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.is_some_and(|block_type| block_type.eq_ignore_ascii_case("tool_result"))
|
||||||
|
&& object
|
||||||
|
.get("content")
|
||||||
|
.is_some_and(claude_content_value_contains_cache_control)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn claude_tools_contain_cache_control(tools: Option<&Value>) -> bool {
|
||||||
|
let Some(tools) = tools.and_then(Value::as_array) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
tools.iter().any(|tool| {
|
||||||
|
tool.as_object()
|
||||||
|
.is_some_and(|object| object.contains_key("cache_control"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn build_request_conversion_report(
|
fn build_request_conversion_report(
|
||||||
source_format: &str,
|
source_format: &str,
|
||||||
target_format: &str,
|
target_format: &str,
|
||||||
@@ -2105,6 +2157,44 @@ fn build_request_conversion_report(
|
|||||||
report
|
report
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn build_response_conversion_report(
|
||||||
|
source_format: &str,
|
||||||
|
target_format: &str,
|
||||||
|
body: &Value,
|
||||||
|
output: &Value,
|
||||||
|
) -> ConversionReport {
|
||||||
|
let mut report = ConversionReport::new(source_format, target_format);
|
||||||
|
let source = body.as_object();
|
||||||
|
let target = output.as_object();
|
||||||
|
if let Some(source) = source {
|
||||||
|
for key in source.keys() {
|
||||||
|
let status = if target.is_some_and(|target| target.contains_key(key)) {
|
||||||
|
ConversionFieldStatus::Native
|
||||||
|
} else if response_field_has_known_mapping(source_format, target_format, key.as_str()) {
|
||||||
|
ConversionFieldStatus::Mapped
|
||||||
|
} else {
|
||||||
|
ConversionFieldStatus::ExtensionPreserved
|
||||||
|
};
|
||||||
|
report.record(key.clone(), status, None);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if let Some(target) = target {
|
||||||
|
for key in target.keys() {
|
||||||
|
if source.is_some_and(|source| source.contains_key(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if response_field_is_target_native(source_format, target_format, key.as_str()) {
|
||||||
|
report.record(
|
||||||
|
key.clone(),
|
||||||
|
ConversionFieldStatus::Mapped,
|
||||||
|
Some("emitted from canonical response field".to_string()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
report
|
||||||
|
}
|
||||||
|
|
||||||
fn request_field_has_known_mapping(source_format: &str, target_format: &str, field: &str) -> bool {
|
fn request_field_has_known_mapping(source_format: &str, target_format: &str, field: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
(
|
(
|
||||||
@@ -2199,6 +2289,73 @@ fn request_field_is_target_native(source_format: &str, target_format: &str, fiel
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn response_field_has_known_mapping(source_format: &str, target_format: &str, field: &str) -> bool {
|
||||||
|
match (
|
||||||
|
normalize_known_format(source_format),
|
||||||
|
normalize_known_format(target_format),
|
||||||
|
) {
|
||||||
|
(Some(FormatId::OpenAiChat), Some(FormatId::OpenAiResponses)) => {
|
||||||
|
matches!(field, "choices")
|
||||||
|
}
|
||||||
|
(Some(FormatId::OpenAiResponses), Some(FormatId::OpenAiChat)) => {
|
||||||
|
matches!(
|
||||||
|
field,
|
||||||
|
"output" | "status" | "incomplete_details" | "output_text"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(
|
||||||
|
Some(FormatId::ClaudeMessages),
|
||||||
|
Some(FormatId::OpenAiChat | FormatId::OpenAiResponses),
|
||||||
|
) => {
|
||||||
|
matches!(field, "content" | "stop_reason" | "stop_sequence")
|
||||||
|
}
|
||||||
|
(
|
||||||
|
Some(FormatId::GeminiGenerateContent),
|
||||||
|
Some(FormatId::OpenAiChat | FormatId::OpenAiResponses),
|
||||||
|
) => {
|
||||||
|
matches!(field, "candidates" | "usageMetadata" | "usage_metadata")
|
||||||
|
}
|
||||||
|
(
|
||||||
|
Some(FormatId::OpenAiChat | FormatId::OpenAiResponses),
|
||||||
|
Some(FormatId::ClaudeMessages),
|
||||||
|
) => {
|
||||||
|
matches!(
|
||||||
|
field,
|
||||||
|
"choices" | "output" | "status" | "incomplete_details"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
(
|
||||||
|
Some(FormatId::OpenAiChat | FormatId::OpenAiResponses),
|
||||||
|
Some(FormatId::GeminiGenerateContent),
|
||||||
|
) => {
|
||||||
|
matches!(
|
||||||
|
field,
|
||||||
|
"choices" | "output" | "status" | "incomplete_details"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn response_field_is_target_native(source_format: &str, target_format: &str, field: &str) -> bool {
|
||||||
|
match (
|
||||||
|
normalize_known_format(source_format),
|
||||||
|
normalize_known_format(target_format),
|
||||||
|
) {
|
||||||
|
(Some(FormatId::OpenAiChat), Some(FormatId::OpenAiResponses)) => {
|
||||||
|
matches!(field, "output" | "status" | "output_text")
|
||||||
|
}
|
||||||
|
(Some(FormatId::OpenAiResponses), Some(FormatId::OpenAiChat)) => matches!(field, "choices"),
|
||||||
|
(_, Some(FormatId::OpenAiChat)) => matches!(field, "choices"),
|
||||||
|
(_, Some(FormatId::OpenAiResponses)) => {
|
||||||
|
matches!(field, "output" | "status" | "output_text")
|
||||||
|
}
|
||||||
|
(_, Some(FormatId::ClaudeMessages)) => matches!(field, "content" | "stop_reason"),
|
||||||
|
(_, Some(FormatId::GeminiGenerateContent)) => matches!(field, "candidates"),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn normalize_known_format(format: &str) -> Option<FormatId> {
|
fn normalize_known_format(format: &str) -> Option<FormatId> {
|
||||||
match FormatId::parse(format)? {
|
match FormatId::parse(format)? {
|
||||||
FormatId::OpenAiResponsesCompact => Some(FormatId::OpenAiResponses),
|
FormatId::OpenAiResponsesCompact => Some(FormatId::OpenAiResponses),
|
||||||
@@ -2736,6 +2893,37 @@ mod tests {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pure_claude_to_openai_chat_allows_tool_schema_property_named_cache_control() {
|
||||||
|
let body = json!({
|
||||||
|
"model": "claude-sonnet",
|
||||||
|
"messages": [{"role": "user", "content": "hello"}],
|
||||||
|
"max_tokens": 64,
|
||||||
|
"tools": [{
|
||||||
|
"name": "configure_cache",
|
||||||
|
"description": "Configure application cache behavior",
|
||||||
|
"input_schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"cache_control": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Application-level cache policy"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted = convert_request_pure("claude:messages", "openai:chat", &body)
|
||||||
|
.expect("tool schema property names should not be treated as Claude cache_control")
|
||||||
|
.value;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
converted["tools"][0]["function"]["parameters"]["properties"]["cache_control"]["type"],
|
||||||
|
"string"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pure_openai_chat_to_responses_blocks_lossy_chat_only_fields() {
|
fn pure_openai_chat_to_responses_blocks_lossy_chat_only_fields() {
|
||||||
let body = json!({
|
let body = json!({
|
||||||
@@ -3059,6 +3247,35 @@ mod tests {
|
|||||||
assert_eq!(converted["choices"][0]["finish_reason"], "content_filter");
|
assert_eq!(converted["choices"][0]["finish_reason"], "content_filter");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pure_response_conversion_reports_response_field_mappings() {
|
||||||
|
let body = json!({
|
||||||
|
"id": "resp_report",
|
||||||
|
"object": "response",
|
||||||
|
"model": "gpt-source",
|
||||||
|
"status": "completed",
|
||||||
|
"output": [{
|
||||||
|
"type": "message",
|
||||||
|
"id": "msg_report",
|
||||||
|
"role": "assistant",
|
||||||
|
"content": [{
|
||||||
|
"type": "output_text",
|
||||||
|
"text": "hello"
|
||||||
|
}]
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
|
||||||
|
let converted = convert_response_pure("openai:responses", "openai:chat", &body)
|
||||||
|
.expect("response conversion should succeed");
|
||||||
|
|
||||||
|
assert!(converted.report.fields.iter().any(|field| {
|
||||||
|
field.field == "output" && field.status == super::ConversionFieldStatus::Mapped
|
||||||
|
}));
|
||||||
|
assert!(converted.report.fields.iter().any(|field| {
|
||||||
|
field.field == "choices" && field.status == super::ConversionFieldStatus::Mapped
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pure_response_conversion_blocks_non_terminal_openai_responses_status() {
|
fn pure_response_conversion_blocks_non_terminal_openai_responses_status() {
|
||||||
let body = json!({
|
let body = json!({
|
||||||
|
|||||||
Reference in New Issue
Block a user