mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 04:30:20 +08:00
Harden PII redaction format conversion
This commit is contained in:
@@ -29,7 +29,6 @@ impl<'a> ProviderRequestRedaction<'a> {
|
||||
#[derive(Clone, Copy, Debug, Default)]
|
||||
struct ChatPiiRedactionFeatureSettings {
|
||||
enabled: Option<bool>,
|
||||
inject_model_instruction: Option<bool>,
|
||||
}
|
||||
|
||||
impl ChatPiiRedactionFeatureSettings {
|
||||
@@ -44,21 +43,11 @@ impl ChatPiiRedactionFeatureSettings {
|
||||
if let Some(enabled) = settings.get("enabled").and_then(Value::as_bool) {
|
||||
self.enabled = Some(enabled);
|
||||
}
|
||||
if let Some(inject_model_instruction) = settings
|
||||
.get("inject_model_instruction")
|
||||
.and_then(Value::as_bool)
|
||||
{
|
||||
self.inject_model_instruction = Some(inject_model_instruction);
|
||||
}
|
||||
}
|
||||
|
||||
fn effective_enabled(self) -> bool {
|
||||
self.enabled.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn effective_inject_model_instruction(self) -> bool {
|
||||
self.inject_model_instruction.unwrap_or(true)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn request_identity_response_encoding_when_redacted(
|
||||
@@ -122,7 +111,7 @@ pub(crate) async fn resolve_provider_chat_pii_redaction<'a>(
|
||||
&body_bytes,
|
||||
format,
|
||||
build_redaction_session_config(hmac_key, &runtime_config, now_unix_secs),
|
||||
MaskChatRequestOptions::runtime(feature_settings.effective_inject_model_instruction()),
|
||||
MaskChatRequestOptions::runtime(),
|
||||
Some(&cache),
|
||||
)
|
||||
.await
|
||||
@@ -190,3 +179,22 @@ fn redaction_mask_error_to_gateway_error(error: RedactionMaskError) -> GatewayEr
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::ChatPiiRedactionFeatureSettings;
|
||||
|
||||
#[test]
|
||||
fn chat_pii_redaction_feature_settings_only_control_enablement() {
|
||||
let mut settings = ChatPiiRedactionFeatureSettings::default();
|
||||
settings.merge_from_value(Some(&json!({
|
||||
"chat_pii_redaction": {
|
||||
"enabled": true
|
||||
}
|
||||
})));
|
||||
|
||||
assert!(settings.effective_enabled());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::collections::BTreeMap;
|
||||
use super::{
|
||||
apply_codex_openai_responses_special_body_edits, apply_codex_openai_responses_special_headers,
|
||||
};
|
||||
use crate::ai_serving::planner::standard::build_local_openai_responses_request_body;
|
||||
use http::{HeaderMap, HeaderValue};
|
||||
use serde_json::json;
|
||||
|
||||
@@ -36,6 +37,40 @@ fn applies_codex_defaults_when_body_rules_do_not_handle_fields() {
|
||||
assert!(body.get("reasoning").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_openai_responses_codex_body_wraps_string_input_for_backend() {
|
||||
let body = json!({
|
||||
"model": "gpt-5",
|
||||
"input": "hello"
|
||||
});
|
||||
|
||||
let provider_request_body = build_local_openai_responses_request_body(
|
||||
&body,
|
||||
"gpt-5-upstream",
|
||||
false,
|
||||
false,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
None,
|
||||
Some("key-123"),
|
||||
&HeaderMap::new(),
|
||||
false,
|
||||
)
|
||||
.expect("codex local openai responses body should build");
|
||||
|
||||
assert_eq!(
|
||||
provider_request_body["input"],
|
||||
json!([{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "hello"
|
||||
}]
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_store_for_compact_even_when_body_rules_handle_it() {
|
||||
let body_rules = json!([
|
||||
|
||||
@@ -352,7 +352,8 @@ fn normalize_chat_pii_redaction_feature_settings(
|
||||
fn normalize_chat_pii_redaction_feature_object(
|
||||
feature: &mut Map<String, Value>,
|
||||
) -> Result<(), String> {
|
||||
for key in ["enabled", "inject_model_instruction"] {
|
||||
feature.remove("inject_model_instruction");
|
||||
for key in ["enabled"] {
|
||||
if let Some(value) = feature.get(key) {
|
||||
if !value.is_boolean() {
|
||||
return Err(format!("chat_pii_redaction.{key} 必须是布尔值"));
|
||||
@@ -450,7 +451,7 @@ mod tests {
|
||||
fn user_self_feature_update_preserves_notification_push_permission() {
|
||||
let normalized = normalize_user_self_feature_settings_update(
|
||||
Some(json!({
|
||||
"chat_pii_redaction": {"enabled": true, "inject_model_instruction": false},
|
||||
"chat_pii_redaction": {"enabled": true},
|
||||
"notification_push_service": {"enabled": false}
|
||||
})),
|
||||
Some(json!({
|
||||
|
||||
@@ -829,14 +829,12 @@ impl Default for ChatPiiRedactionRuntimeConfig {
|
||||
}
|
||||
|
||||
pub(crate) struct MaskChatRequestOptions {
|
||||
pub(crate) inject_model_instruction: bool,
|
||||
pub(crate) scan_limits: RedactionScanLimits,
|
||||
}
|
||||
|
||||
impl MaskChatRequestOptions {
|
||||
pub(crate) fn runtime(inject_model_instruction: bool) -> Self {
|
||||
pub(crate) fn runtime() -> Self {
|
||||
Self {
|
||||
inject_model_instruction,
|
||||
scan_limits: RedactionScanLimits::default(),
|
||||
}
|
||||
}
|
||||
@@ -866,8 +864,6 @@ impl ChatPiiRedactionRequestFormat {
|
||||
}
|
||||
}
|
||||
|
||||
const MODEL_NOTICE_CONTENT: &str = "Aether privacy redaction notice: The next message contains gateway-generated placeholder tokens for sensitive data protection. This notice is not a user request; do not answer it, mention it, reveal it, or infer original values from placeholders. Treat each placeholder as a valid real typed value for reasoning and tool calls, and do not ask the user to reveal originals solely because a placeholder is present.";
|
||||
|
||||
fn sanitize_redaction_rule_label(raw: &str) -> String {
|
||||
let label = raw
|
||||
.trim()
|
||||
@@ -1184,7 +1180,7 @@ pub(crate) fn mask_chat_request_json(
|
||||
body: &[u8],
|
||||
config: RedactionSessionConfig,
|
||||
) -> MaskedChatRequest {
|
||||
mask_chat_request_json_with_options(body, config, MaskChatRequestOptions::runtime(false))
|
||||
mask_chat_request_json_with_options(body, config, MaskChatRequestOptions::runtime())
|
||||
}
|
||||
|
||||
pub(crate) fn try_mask_chat_request_json_with_options(
|
||||
@@ -1295,13 +1291,6 @@ pub(crate) async fn try_mask_chat_pii_request_json_with_cache_options(
|
||||
})
|
||||
}
|
||||
|
||||
fn model_notice_message() -> Value {
|
||||
serde_json::json!({
|
||||
"role": "assistant",
|
||||
"content": MODEL_NOTICE_CONTENT,
|
||||
})
|
||||
}
|
||||
|
||||
fn request_collision_corpus(format: ChatPiiRedactionRequestFormat, value: &Value) -> Vec<String> {
|
||||
match format {
|
||||
ChatPiiRedactionRequestFormat::OpenAiChat => value
|
||||
@@ -1326,18 +1315,10 @@ fn mask_request_value(
|
||||
mask_openai_chat_request_value(value, session, scan_state, options)
|
||||
}
|
||||
ChatPiiRedactionRequestFormat::OpenAiResponses => {
|
||||
let redacted = mask_openai_responses_request_value(value, session, scan_state)?;
|
||||
if redacted && options.inject_model_instruction {
|
||||
inject_openai_responses_model_notice(value);
|
||||
}
|
||||
Ok(redacted)
|
||||
mask_openai_responses_request_value(value, session, scan_state)
|
||||
}
|
||||
ChatPiiRedactionRequestFormat::ClaudeMessages => {
|
||||
let redacted = mask_claude_messages_request_value(value, session, scan_state)?;
|
||||
if redacted && options.inject_model_instruction {
|
||||
inject_claude_model_notice(value);
|
||||
}
|
||||
Ok(redacted)
|
||||
mask_claude_messages_request_value(value, session, scan_state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1355,21 +1336,10 @@ async fn mask_request_value_async(
|
||||
mask_openai_chat_request_value_async(value, session, scan_state, options, cache).await
|
||||
}
|
||||
ChatPiiRedactionRequestFormat::OpenAiResponses => {
|
||||
let redacted =
|
||||
mask_openai_responses_request_value_async(value, session, scan_state, cache)
|
||||
.await?;
|
||||
if redacted && options.inject_model_instruction {
|
||||
inject_openai_responses_model_notice(value);
|
||||
}
|
||||
Ok(redacted)
|
||||
mask_openai_responses_request_value_async(value, session, scan_state, cache).await
|
||||
}
|
||||
ChatPiiRedactionRequestFormat::ClaudeMessages => {
|
||||
let redacted =
|
||||
mask_claude_messages_request_value_async(value, session, scan_state, cache).await?;
|
||||
if redacted && options.inject_model_instruction {
|
||||
inject_claude_model_notice(value);
|
||||
}
|
||||
Ok(redacted)
|
||||
mask_claude_messages_request_value_async(value, session, scan_state, cache).await
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1385,16 +1355,10 @@ fn mask_openai_chat_request_value(
|
||||
};
|
||||
|
||||
let mut redacted = false;
|
||||
let mut notice_inserted = false;
|
||||
let mut index = 0;
|
||||
while index < messages.len() {
|
||||
let message_redacted = mask_chat_message_value(&mut messages[index], session, scan_state)?;
|
||||
redacted |= message_redacted;
|
||||
if options.inject_model_instruction && message_redacted && !notice_inserted {
|
||||
messages.insert(index, model_notice_message());
|
||||
notice_inserted = true;
|
||||
index += 1;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
Ok(redacted)
|
||||
@@ -1412,17 +1376,11 @@ async fn mask_openai_chat_request_value_async(
|
||||
};
|
||||
|
||||
let mut redacted = false;
|
||||
let mut notice_inserted = false;
|
||||
let mut index = 0;
|
||||
while index < messages.len() {
|
||||
let message_redacted =
|
||||
mask_chat_message_value_async(&mut messages[index], session, scan_state, cache).await?;
|
||||
redacted |= message_redacted;
|
||||
if options.inject_model_instruction && message_redacted && !notice_inserted {
|
||||
messages.insert(index, model_notice_message());
|
||||
notice_inserted = true;
|
||||
index += 1;
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
Ok(redacted)
|
||||
@@ -2150,56 +2108,6 @@ async fn mask_json_string_async(
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
fn inject_openai_responses_model_notice(value: &mut Value) {
|
||||
let Some(request) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
match request.get_mut("instructions") {
|
||||
Some(Value::String(instructions)) => prepend_model_notice(instructions),
|
||||
Some(_) => {}
|
||||
None => {
|
||||
request.insert(
|
||||
"instructions".to_string(),
|
||||
Value::String(MODEL_NOTICE_CONTENT.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn inject_claude_model_notice(value: &mut Value) {
|
||||
let Some(request) = value.as_object_mut() else {
|
||||
return;
|
||||
};
|
||||
match request.get_mut("system") {
|
||||
Some(Value::String(system)) => prepend_model_notice(system),
|
||||
Some(Value::Array(parts)) => parts.insert(
|
||||
0,
|
||||
serde_json::json!({
|
||||
"type": "text",
|
||||
"text": MODEL_NOTICE_CONTENT,
|
||||
}),
|
||||
),
|
||||
Some(_) => {}
|
||||
None => {
|
||||
request.insert(
|
||||
"system".to_string(),
|
||||
Value::String(MODEL_NOTICE_CONTENT.to_string()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn prepend_model_notice(text: &mut String) {
|
||||
if text.contains(MODEL_NOTICE_CONTENT) {
|
||||
return;
|
||||
}
|
||||
if text.trim().is_empty() {
|
||||
*text = MODEL_NOTICE_CONTENT.to_string();
|
||||
} else {
|
||||
*text = format!("{MODEL_NOTICE_CONTENT}\n\n{text}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct RestoredSyncResponseBody {
|
||||
pub(crate) body: Vec<u8>,
|
||||
pub(crate) restored: bool,
|
||||
@@ -4408,7 +4316,7 @@ mod tests {
|
||||
&raw,
|
||||
ChatPiiRedactionRequestFormat::ClaudeMessages,
|
||||
test_config(),
|
||||
MaskChatRequestOptions::runtime(true),
|
||||
MaskChatRequestOptions::runtime(),
|
||||
)
|
||||
.expect("claude messages request should mask");
|
||||
|
||||
@@ -4417,11 +4325,7 @@ mod tests {
|
||||
let masked_json: serde_json::Value =
|
||||
serde_json::from_slice(&masked.body).expect("masked request should stay valid JSON");
|
||||
assert_eq!(masked_json["metadata"]["owner"], "metadata@example.com");
|
||||
assert!(masked_json["system"][0]["text"]
|
||||
.as_str()
|
||||
.expect("notice should remain a string")
|
||||
.contains("Aether privacy redaction notice"));
|
||||
assert!(!masked_json["system"][1]["text"]
|
||||
assert!(!masked_json["system"][0]["text"]
|
||||
.as_str()
|
||||
.expect("system text should remain a string")
|
||||
.contains("alice@example.com"));
|
||||
@@ -4454,7 +4358,7 @@ mod tests {
|
||||
&raw,
|
||||
ChatPiiRedactionRequestFormat::OpenAiChat,
|
||||
test_config(),
|
||||
MaskChatRequestOptions::runtime(false),
|
||||
MaskChatRequestOptions::runtime(),
|
||||
)
|
||||
.expect("chat request should mask");
|
||||
|
||||
@@ -4497,7 +4401,7 @@ mod tests {
|
||||
&raw,
|
||||
ChatPiiRedactionRequestFormat::OpenAiResponses,
|
||||
test_config(),
|
||||
MaskChatRequestOptions::runtime(true),
|
||||
MaskChatRequestOptions::runtime(),
|
||||
)
|
||||
.expect("responses request should mask");
|
||||
|
||||
@@ -4509,7 +4413,6 @@ mod tests {
|
||||
let instructions = masked_json["instructions"]
|
||||
.as_str()
|
||||
.expect("instructions should remain a string");
|
||||
assert!(instructions.contains("Aether privacy redaction notice"));
|
||||
assert!(!instructions.contains("alice@example.com"));
|
||||
assert!(!masked_json["input"][0]["content"][0]["text"]
|
||||
.as_str()
|
||||
@@ -4995,7 +4898,7 @@ mod tests {
|
||||
let masked = mask_chat_request_json_with_options(
|
||||
&serde_json::to_vec(&request).expect("request should serialize"),
|
||||
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 600),
|
||||
MaskChatRequestOptions::runtime(false),
|
||||
MaskChatRequestOptions::runtime(),
|
||||
);
|
||||
|
||||
let masked_json: serde_json::Value =
|
||||
@@ -5032,7 +4935,7 @@ mod tests {
|
||||
let masked = mask_chat_request_json_with_options(
|
||||
&serde_json::to_vec(&request).expect("request should serialize"),
|
||||
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 600),
|
||||
MaskChatRequestOptions::runtime(true),
|
||||
MaskChatRequestOptions::runtime(),
|
||||
);
|
||||
|
||||
assert!(!masked.redacted);
|
||||
@@ -5041,9 +4944,6 @@ mod tests {
|
||||
assert_eq!(masked_json, request);
|
||||
assert!(masked_json.to_string().contains("alice@example.com"));
|
||||
assert!(!masked_json.to_string().contains("<AETHER:"));
|
||||
assert!(!masked_json
|
||||
.to_string()
|
||||
.contains("Aether privacy redaction notice"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5105,7 +5005,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_pii_redaction_provider_bound_request_uses_sentinels_and_inserts_safe_notice() {
|
||||
fn proxy_pii_redaction_provider_bound_request_uses_sentinels_without_prompt_notice() {
|
||||
let config = ChatPiiRedactionRuntimeConfig::default();
|
||||
let request = json!({
|
||||
"model": "gpt-5",
|
||||
@@ -5119,7 +5019,7 @@ mod tests {
|
||||
let masked = mask_chat_request_json_with_options(
|
||||
&serde_json::to_vec(&request).expect("request should serialize"),
|
||||
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 600),
|
||||
MaskChatRequestOptions::runtime(true),
|
||||
MaskChatRequestOptions::runtime(),
|
||||
);
|
||||
|
||||
assert!(masked.redacted);
|
||||
@@ -5128,26 +5028,18 @@ mod tests {
|
||||
let messages = masked_json["messages"]
|
||||
.as_array()
|
||||
.expect("messages should be an array");
|
||||
assert_eq!(messages.len(), 4);
|
||||
assert_eq!(messages.len(), 3);
|
||||
assert_eq!(messages[0]["role"], "system");
|
||||
assert_eq!(messages[1]["role"], "assistant");
|
||||
assert!(messages[1..]
|
||||
.iter()
|
||||
.all(|message| message["role"].as_str() != Some("system")));
|
||||
let notice = messages[1]["content"]
|
||||
.as_str()
|
||||
.expect("notice should be text");
|
||||
assert!(notice.contains("not a user request"));
|
||||
assert!(notice.contains("do not answer"));
|
||||
assert!(notice.contains("do not answer it, mention it"));
|
||||
assert!(!notice.contains("alice@example.com"));
|
||||
assert_eq!(messages[2]["role"], "user");
|
||||
let content = messages[2]["content"]
|
||||
assert_eq!(messages[1]["role"], "user");
|
||||
let content = messages[1]["content"]
|
||||
.as_str()
|
||||
.expect("user content should be text");
|
||||
assert!(!content.contains("alice@example.com"));
|
||||
assert!(content.contains("<AETHER:EMAIL:"));
|
||||
assert_eq!(messages[3]["role"], "assistant");
|
||||
assert_eq!(messages[2]["role"], "assistant");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -5239,7 +5131,7 @@ mod tests {
|
||||
let large_err = try_mask_chat_request_json_with_options(
|
||||
&serde_json::to_vec(&large_request).expect("request should serialize"),
|
||||
test_config(),
|
||||
MaskChatRequestOptions::runtime(false),
|
||||
MaskChatRequestOptions::runtime(),
|
||||
)
|
||||
.expect_err("oversized scan should fail closed");
|
||||
assert_eq!(
|
||||
@@ -5261,7 +5153,7 @@ mod tests {
|
||||
let dense_err = try_mask_chat_request_json_with_options(
|
||||
&serde_json::to_vec(&dense_request).expect("request should serialize"),
|
||||
test_config(),
|
||||
MaskChatRequestOptions::runtime(false).with_scan_limits(RedactionScanLimits {
|
||||
MaskChatRequestOptions::runtime().with_scan_limits(RedactionScanLimits {
|
||||
max_scanned_text_bytes: 1024,
|
||||
max_detections: 1,
|
||||
}),
|
||||
@@ -5296,7 +5188,7 @@ mod tests {
|
||||
let first_masked = try_mask_chat_request_json_with_cache_options(
|
||||
&serde_json::to_vec(&first_request).expect("request should serialize"),
|
||||
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 600),
|
||||
MaskChatRequestOptions::runtime(false),
|
||||
MaskChatRequestOptions::runtime(),
|
||||
Some(&cache),
|
||||
)
|
||||
.await
|
||||
@@ -5315,7 +5207,7 @@ mod tests {
|
||||
let second_masked = try_mask_chat_request_json_with_cache_options(
|
||||
&serde_json::to_vec(&second_request).expect("request should serialize"),
|
||||
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 899),
|
||||
MaskChatRequestOptions::runtime(false),
|
||||
MaskChatRequestOptions::runtime(),
|
||||
Some(&cache),
|
||||
)
|
||||
.await
|
||||
@@ -5356,7 +5248,7 @@ mod tests {
|
||||
let rolled_masked = try_mask_chat_request_json_with_cache_options(
|
||||
&serde_json::to_vec(&second_request).expect("request should serialize"),
|
||||
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 900),
|
||||
MaskChatRequestOptions::runtime(false),
|
||||
MaskChatRequestOptions::runtime(),
|
||||
Some(&cache),
|
||||
)
|
||||
.await
|
||||
@@ -5420,7 +5312,7 @@ mod tests {
|
||||
let first_masked = try_mask_chat_request_json_with_cache_options(
|
||||
&serde_json::to_vec(&first_request).expect("request should serialize"),
|
||||
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 600),
|
||||
MaskChatRequestOptions::runtime(false),
|
||||
MaskChatRequestOptions::runtime(),
|
||||
Some(&cache),
|
||||
)
|
||||
.await
|
||||
@@ -5454,7 +5346,7 @@ mod tests {
|
||||
let second_masked = try_mask_chat_request_json_with_cache_options(
|
||||
&serde_json::to_vec(&colliding_request).expect("request should serialize"),
|
||||
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 899),
|
||||
MaskChatRequestOptions::runtime(false),
|
||||
MaskChatRequestOptions::runtime(),
|
||||
Some(&cache),
|
||||
)
|
||||
.await
|
||||
@@ -5525,7 +5417,7 @@ mod tests {
|
||||
let masked = try_mask_chat_request_json_with_cache_options(
|
||||
&serde_json::to_vec(&request).expect("request should serialize"),
|
||||
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 600),
|
||||
MaskChatRequestOptions::runtime(false),
|
||||
MaskChatRequestOptions::runtime(),
|
||||
Some(&cache),
|
||||
)
|
||||
.await
|
||||
@@ -5570,7 +5462,7 @@ mod tests {
|
||||
let masked = try_mask_chat_request_json_with_cache_options(
|
||||
&serde_json::to_vec(&request).expect("request should serialize"),
|
||||
build_redaction_session_config(b"redaction-test-key".to_vec(), &config, 600),
|
||||
MaskChatRequestOptions::runtime(false),
|
||||
MaskChatRequestOptions::runtime(),
|
||||
Some(&cache),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -112,7 +112,6 @@ fn auth_repository_with_redaction_feature_settings() -> Arc<InMemoryAuthApiKeySn
|
||||
Some(json!({
|
||||
"chat_pii_redaction": {
|
||||
"enabled": true,
|
||||
"inject_model_instruction": true,
|
||||
}
|
||||
})),
|
||||
)]),
|
||||
|
||||
@@ -112,7 +112,6 @@ async fn proxy_pii_redaction_local_openai_chat_runtime_masks_headers_and_restore
|
||||
Some(json!({
|
||||
"chat_pii_redaction": {
|
||||
"enabled": true,
|
||||
"inject_model_instruction": true,
|
||||
}
|
||||
})),
|
||||
)]),
|
||||
@@ -363,13 +362,7 @@ async fn proxy_pii_redaction_local_openai_chat_runtime_masks_headers_and_restore
|
||||
let provider_body_text = serde_json::to_string(&seen.body).expect("body should serialize");
|
||||
assert!(!provider_body_text.contains("alice@example.com"));
|
||||
assert!(provider_body_text.contains("<AETHER:EMAIL:"));
|
||||
assert_eq!(seen.body["messages"][0]["role"], "assistant");
|
||||
let notice = seen.body["messages"][0]["content"]
|
||||
.as_str()
|
||||
.expect("notice should be text");
|
||||
assert!(notice.contains("not a user request"));
|
||||
assert!(notice.contains("do not answer"));
|
||||
assert_eq!(seen.body["messages"][1]["role"], "user");
|
||||
assert_eq!(seen.body["messages"][0]["role"], "user");
|
||||
|
||||
let stored_candidates = request_candidate_repository
|
||||
.list_by_request_id("trace-proxy-pii-redaction-sync")
|
||||
|
||||
@@ -230,14 +230,10 @@ fn redaction_test_rules() -> serde_json::Value {
|
||||
])
|
||||
}
|
||||
|
||||
fn chat_pii_redaction_feature_settings(
|
||||
enabled: bool,
|
||||
inject_model_instruction: bool,
|
||||
) -> serde_json::Value {
|
||||
fn chat_pii_redaction_feature_settings(enabled: bool) -> serde_json::Value {
|
||||
json!({
|
||||
"chat_pii_redaction": {
|
||||
"enabled": enabled,
|
||||
"inject_model_instruction": inject_model_instruction,
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -245,7 +241,6 @@ fn chat_pii_redaction_feature_settings(
|
||||
fn auth_repository_with_redaction_feature_settings(
|
||||
test_id: &str,
|
||||
feature_enabled: bool,
|
||||
inject_model_instruction: bool,
|
||||
) -> Arc<InMemoryAuthApiKeySnapshotRepository> {
|
||||
let snapshot = auth_snapshot(&format!("api-key-{test_id}"), &format!("user-{test_id}"));
|
||||
let key_hash = hash_api_key(&format!("sk-client-{test_id}"));
|
||||
@@ -257,10 +252,7 @@ fn auth_repository_with_redaction_feature_settings(
|
||||
.with_export_records(vec![auth_export_record(
|
||||
&snapshot,
|
||||
key_hash,
|
||||
Some(chat_pii_redaction_feature_settings(
|
||||
feature_enabled,
|
||||
inject_model_instruction,
|
||||
)),
|
||||
Some(chat_pii_redaction_feature_settings(feature_enabled)),
|
||||
)]),
|
||||
)
|
||||
}
|
||||
@@ -372,8 +364,7 @@ async fn run_sync_redaction_case_with_system_config(
|
||||
}),
|
||||
);
|
||||
let (provider_url, provider_handle) = start_server(provider_app).await;
|
||||
let auth_repository =
|
||||
auth_repository_with_redaction_feature_settings(test_id, feature_enabled, true);
|
||||
let auth_repository = auth_repository_with_redaction_feature_settings(test_id, feature_enabled);
|
||||
let candidate_selection_repository =
|
||||
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||
candidate_row(test_id),
|
||||
@@ -522,14 +513,9 @@ async fn ai_execute_sync_pii_redaction_round_trip_impl() {
|
||||
assert!(provider_body_text.contains("<AETHER:ACCESS_TOKEN:"));
|
||||
assert!(provider_body_text.contains("<AETHER:SECRET_KEY:"));
|
||||
assert_eq!(seen.body["messages"][0]["role"], "system");
|
||||
assert_eq!(seen.body["messages"][1]["role"], "assistant");
|
||||
let notice = seen.body["messages"][1]["content"]
|
||||
.as_str()
|
||||
.expect("notice should be text");
|
||||
assert!(notice.contains("not a user request"));
|
||||
assert_eq!(seen.body["messages"][2]["role"], "user");
|
||||
assert_eq!(seen.body["messages"][3]["role"], "assistant");
|
||||
assert_eq!(seen.body["messages"][4]["role"], "tool");
|
||||
assert_eq!(seen.body["messages"][1]["role"], "user");
|
||||
assert_eq!(seen.body["messages"][2]["role"], "assistant");
|
||||
assert_eq!(seen.body["messages"][3]["role"], "tool");
|
||||
|
||||
let response_content = response_json["choices"][0]["message"]["content"]
|
||||
.as_str()
|
||||
@@ -702,7 +688,7 @@ async fn ai_execute_pii_redaction_restores_executed_candidate_session_after_late
|
||||
);
|
||||
let (provider_url, provider_handle) = start_server(provider_app).await;
|
||||
let auth_repository =
|
||||
auth_repository_with_redaction_feature_settings("redaction-candidate-session", true, true);
|
||||
auth_repository_with_redaction_feature_settings("redaction-candidate-session", true);
|
||||
let mut later_candidate = candidate_row("redaction-candidate-session");
|
||||
later_candidate.provider_id = "provider-redaction-candidate-session-later".to_string();
|
||||
later_candidate.endpoint_id = "endpoint-redaction-candidate-session-later".to_string();
|
||||
@@ -817,7 +803,7 @@ async fn pii_redaction_performance_limits_do_not_forward_unredacted_body_upstrea
|
||||
);
|
||||
let (provider_url, provider_handle) = start_server(provider_app).await;
|
||||
let auth_repository =
|
||||
auth_repository_with_redaction_feature_settings("pii-redaction-limit", true, true);
|
||||
auth_repository_with_redaction_feature_settings("pii-redaction-limit", true);
|
||||
let candidate_selection_repository =
|
||||
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||
candidate_row("pii-redaction-limit"),
|
||||
@@ -893,7 +879,7 @@ async fn ai_execute_pii_redaction_missing_encryption_key_fails_closed_before_pro
|
||||
);
|
||||
let (execution_runtime_url, execution_runtime_handle) = start_server(execution_runtime).await;
|
||||
let test_id = "ai-execute-pii-redaction-missing-encryption-key";
|
||||
let auth_repository = auth_repository_with_redaction_feature_settings(test_id, true, true);
|
||||
let auth_repository = auth_repository_with_redaction_feature_settings(test_id, true);
|
||||
let candidate_selection_repository =
|
||||
Arc::new(InMemoryMinimalCandidateSelectionReadRepository::seed(vec![
|
||||
candidate_row(test_id),
|
||||
|
||||
@@ -498,8 +498,7 @@ fn auth_repository(case: &RedactionFormatCase) -> Arc<InMemoryAuthApiKeySnapshot
|
||||
key_hash,
|
||||
Some(json!({
|
||||
"chat_pii_redaction": {
|
||||
"enabled": true,
|
||||
"inject_model_instruction": true
|
||||
"enabled": true
|
||||
}
|
||||
})),
|
||||
)]),
|
||||
|
||||
@@ -4999,8 +4999,7 @@ async fn gateway_updates_users_me_detail_locally_without_proxying_upstream() {
|
||||
"username": "alice-updated",
|
||||
"feature_settings": {
|
||||
"chat_pii_redaction": {
|
||||
"enabled": true,
|
||||
"inject_model_instruction": false
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -5034,10 +5033,6 @@ async fn gateway_updates_users_me_detail_locally_without_proxying_upstream() {
|
||||
get_payload["feature_settings"]["chat_pii_redaction"]["enabled"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
get_payload["feature_settings"]["chat_pii_redaction"]["inject_model_instruction"],
|
||||
false
|
||||
);
|
||||
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
|
||||
|
||||
gateway_handle.abort();
|
||||
@@ -7167,8 +7162,7 @@ async fn gateway_handles_users_me_api_key_writes_locally_without_proxying_upstre
|
||||
"concurrent_limit": 4,
|
||||
"feature_settings": {
|
||||
"chat_pii_redaction": {
|
||||
"enabled": true,
|
||||
"inject_model_instruction": false
|
||||
"enabled": true
|
||||
}
|
||||
}
|
||||
}))
|
||||
@@ -7187,10 +7181,6 @@ async fn gateway_handles_users_me_api_key_writes_locally_without_proxying_upstre
|
||||
update_payload["feature_settings"]["chat_pii_redaction"]["enabled"],
|
||||
true
|
||||
);
|
||||
assert_eq!(
|
||||
update_payload["feature_settings"]["chat_pii_redaction"]["inject_model_instruction"],
|
||||
false
|
||||
);
|
||||
assert_eq!(update_payload["message"], "API密钥已更新");
|
||||
|
||||
let toggle_response = client
|
||||
|
||||
@@ -723,15 +723,13 @@ mod tests {
|
||||
"file": {"file_data": "data:application/pdf;base64,JVBERi0x"}
|
||||
}),
|
||||
json!({"type": "text", "text": "[File: https://example.com/report.pdf]"}),
|
||||
json!({
|
||||
"type": "text",
|
||||
"text": "[Claude tool_result document content omitted: text/plain]"
|
||||
}),
|
||||
json!({"type": "text", "text": "document body"}),
|
||||
]
|
||||
);
|
||||
let block_content_json = Value::Array(block_content.clone()).to_string();
|
||||
assert!(!block_content_json.contains("\"source\""));
|
||||
assert!(!block_content_json.contains("document body"));
|
||||
assert!(block_content_json.contains("document body"));
|
||||
assert!(!block_content_json.contains("content omitted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -960,4 +958,90 @@ mod tests {
|
||||
"data:image/png;base64,AAAA"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_request_to_responses_rejects_unrepresentable_tool_result_blocks() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_read",
|
||||
"content": [{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "unsupported",
|
||||
"media_type": "image/png",
|
||||
"data": "AAAA"
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}],
|
||||
"max_tokens": 128,
|
||||
});
|
||||
|
||||
let error = registry::convert_request(
|
||||
"claude:messages",
|
||||
"openai:responses",
|
||||
&body,
|
||||
&FormatContext::default(),
|
||||
)
|
||||
.expect_err("unrepresentable Claude tool_result block should fail closed");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
registry::FormatError::LossyConversionBlocked {
|
||||
ref source_format,
|
||||
ref target_format,
|
||||
ref field,
|
||||
..
|
||||
} if source_format == "claude:messages"
|
||||
&& target_format == "openai:responses"
|
||||
&& field == "messages[].content[].tool_result.content"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_request_to_openai_chat_rejects_unrepresentable_tool_result_blocks() {
|
||||
let body = json!({
|
||||
"model": "claude-sonnet",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_read",
|
||||
"content": [{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "unsupported",
|
||||
"media_type": "image/png",
|
||||
"data": "AAAA"
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}],
|
||||
"max_tokens": 128,
|
||||
});
|
||||
|
||||
let error = registry::convert_request(
|
||||
"claude:messages",
|
||||
"openai:chat",
|
||||
&body,
|
||||
&FormatContext::default(),
|
||||
)
|
||||
.expect_err("unrepresentable Claude tool_result block should fail closed for Chat");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
registry::FormatError::LossyConversionBlocked {
|
||||
ref source_format,
|
||||
ref target_format,
|
||||
ref field,
|
||||
..
|
||||
} if source_format == "claude:messages"
|
||||
&& target_format == "openai:chat"
|
||||
&& field == "messages[].content[].tool_result.content"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,10 +5,11 @@ use crate::{
|
||||
protocol::canonical::{
|
||||
canonical_extension_object_mut, canonical_message_to_openai_chat_messages,
|
||||
canonical_response_format_to_openai, canonical_tool_choice_to_openai,
|
||||
canonical_tool_to_openai, namespace_extension_object, openai_content_text,
|
||||
openai_extensions, openai_generation_config, openai_message_content_blocks,
|
||||
openai_response_format_to_canonical, openai_responses_extension, openai_role_to_canonical,
|
||||
openai_tool_choice_to_canonical, openai_tools_to_canonical, write_openai_generation_config,
|
||||
canonical_tool_to_openai, is_claude_tool_result, namespace_extension_object,
|
||||
openai_content_text, openai_extensions, openai_generation_config,
|
||||
openai_message_content_blocks, openai_response_format_to_canonical,
|
||||
openai_responses_extension, openai_role_to_canonical, openai_tool_choice_to_canonical,
|
||||
openai_tools_to_canonical, write_openai_generation_config, CanonicalContentBlock,
|
||||
CanonicalInstruction, CanonicalRequest, CanonicalRole, CanonicalThinkingConfig,
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
},
|
||||
@@ -19,6 +20,9 @@ pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalRequest> {
|
||||
}
|
||||
|
||||
pub fn to(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Value> {
|
||||
if canonical_request_has_unrepresentable_claude_tool_result_for_openai_chat(request) {
|
||||
return None;
|
||||
}
|
||||
let mut body = to_raw(request);
|
||||
force_stream_options(&mut body, ctx.upstream_is_stream);
|
||||
Some(body)
|
||||
@@ -219,6 +223,94 @@ pub fn to_raw(canonical: &CanonicalRequest) -> Value {
|
||||
Value::Object(output)
|
||||
}
|
||||
|
||||
fn canonical_request_has_unrepresentable_claude_tool_result_for_openai_chat(
|
||||
request: &CanonicalRequest,
|
||||
) -> bool {
|
||||
request.messages.iter().any(|message| {
|
||||
message.content.iter().any(|block| {
|
||||
let CanonicalContentBlock::ToolResult {
|
||||
output, extensions, ..
|
||||
} = block
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
is_claude_tool_result(extensions)
|
||||
&& output
|
||||
.as_ref()
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|parts| {
|
||||
!claude_tool_result_parts_are_openai_chat_representable(parts)
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn claude_tool_result_parts_are_openai_chat_representable(parts: &[Value]) -> bool {
|
||||
parts
|
||||
.iter()
|
||||
.all(claude_tool_result_part_is_openai_chat_representable)
|
||||
}
|
||||
|
||||
fn claude_tool_result_part_is_openai_chat_representable(part: &Value) -> bool {
|
||||
let Some(part_object) = part.as_object() else {
|
||||
return false;
|
||||
};
|
||||
match part_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"text" => true,
|
||||
"image" => claude_image_block_is_openai_chat_representable(part_object),
|
||||
"document" | "file" => claude_document_block_is_openai_chat_representable(part_object),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_image_block_is_openai_chat_representable(block: &Map<String, Value>) -> bool {
|
||||
let Some(source) = block.get("source").and_then(Value::as_object) else {
|
||||
return false;
|
||||
};
|
||||
match source
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"base64" => {
|
||||
non_empty_source_str(source, "media_type").is_some()
|
||||
&& non_empty_source_str(source, "data").is_some()
|
||||
}
|
||||
"url" => non_empty_source_str(source, "url").is_some(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_document_block_is_openai_chat_representable(block: &Map<String, Value>) -> bool {
|
||||
let Some(source) = block.get("source").and_then(Value::as_object) else {
|
||||
return false;
|
||||
};
|
||||
match source
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"base64" => {
|
||||
non_empty_source_str(source, "media_type").is_some()
|
||||
&& non_empty_source_str(source, "data").is_some()
|
||||
}
|
||||
"url" => non_empty_source_str(source, "url").is_some(),
|
||||
"text" => non_empty_source_str(source, "data").is_some(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn non_empty_source_str<'a>(source: &'a Map<String, Value>, key: &str) -> Option<&'a str> {
|
||||
source
|
||||
.get(key)
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.trim().is_empty())
|
||||
}
|
||||
|
||||
fn openai_chat_reasoning_effort(value: &str) -> Option<&'static str> {
|
||||
match value.trim().to_ascii_lowercase().as_str() {
|
||||
"low" => Some("low"),
|
||||
|
||||
@@ -738,6 +738,34 @@ fn strip_codex_hosted_tool_choice_name_for_backend(
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_codex_responses_string_input_for_backend(
|
||||
body_object: &mut serde_json::Map<String, Value>,
|
||||
provider_api_format: &str,
|
||||
) {
|
||||
if !aether_ai_formats::is_openai_responses_family_format(provider_api_format) {
|
||||
return;
|
||||
}
|
||||
let Some(text) = body_object
|
||||
.get("input")
|
||||
.and_then(Value::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
body_object.insert(
|
||||
"input".to_string(),
|
||||
json!([{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": text,
|
||||
}],
|
||||
}]),
|
||||
);
|
||||
}
|
||||
|
||||
pub fn apply_codex_openai_responses_special_body_edits(
|
||||
provider_request_body: &mut Value,
|
||||
provider_type: &str,
|
||||
@@ -760,6 +788,7 @@ pub fn apply_codex_openai_responses_special_body_edits(
|
||||
return;
|
||||
};
|
||||
|
||||
wrap_codex_responses_string_input_for_backend(body_object, provider_api_format);
|
||||
for field in CODEX_OPENAI_RESPONSES_UNSUPPORTED_BODY_FIELDS {
|
||||
if !body_rules_handle_path(body_rules, field) {
|
||||
body_object.remove(*field);
|
||||
@@ -985,6 +1014,34 @@ mod tests {
|
||||
assert_eq!(provider_request_body["parallel_tool_calls"], json!(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_responses_body_edits_wrap_string_input_for_backend() {
|
||||
let mut provider_request_body = json!({
|
||||
"input": "hello",
|
||||
"model": "gpt-5.4"
|
||||
});
|
||||
|
||||
apply_codex_openai_responses_special_body_edits(
|
||||
&mut provider_request_body,
|
||||
"codex",
|
||||
"openai:responses",
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
provider_request_body["input"],
|
||||
json!([{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "input_text",
|
||||
"text": "hello"
|
||||
}]
|
||||
}])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn codex_responses_body_edits_preserve_function_tools_for_codex_backend() {
|
||||
let mut provider_request_body = json!({
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::{BTreeMap, VecDeque};
|
||||
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
@@ -297,6 +297,8 @@ fn claude_system_instruction_to_responses_part(
|
||||
|
||||
fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option<Vec<Value>> {
|
||||
let mut input = Vec::new();
|
||||
let mut next_generated_tool_call_index = 0usize;
|
||||
let mut pending_tool_call_ids = VecDeque::new();
|
||||
for message in &canonical.messages {
|
||||
let role = match message.role {
|
||||
CanonicalRole::Assistant => "assistant",
|
||||
@@ -315,10 +317,13 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
|
||||
} => {
|
||||
flush_responses_message(&mut input, role, &mut content);
|
||||
saw_tool_item = true;
|
||||
let call_id = responses_tool_call_id(id, &mut next_generated_tool_call_index);
|
||||
let tool_name = responses_tool_name(name);
|
||||
pending_tool_call_ids.push_back(call_id.clone());
|
||||
input.push(json!({
|
||||
"type": "function_call",
|
||||
"call_id": id,
|
||||
"name": name,
|
||||
"call_id": call_id,
|
||||
"name": tool_name,
|
||||
"arguments": canonicalize_tool_arguments(arguments),
|
||||
}));
|
||||
}
|
||||
@@ -335,10 +340,12 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
|
||||
output.as_ref(),
|
||||
content_text.as_deref(),
|
||||
extensions,
|
||||
);
|
||||
)?;
|
||||
let call_id =
|
||||
responses_tool_result_call_id(tool_use_id, &mut pending_tool_call_ids)?;
|
||||
input.push(json!({
|
||||
"type": "function_call_output",
|
||||
"call_id": tool_use_id,
|
||||
"call_id": call_id,
|
||||
"output": tool_output,
|
||||
}));
|
||||
if !extra_user_content.is_empty() {
|
||||
@@ -393,6 +400,42 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
|
||||
Some(input)
|
||||
}
|
||||
|
||||
fn responses_tool_call_id(id: &str, next_generated_tool_call_index: &mut usize) -> String {
|
||||
let trimmed = id.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return trimmed.to_string();
|
||||
}
|
||||
let generated = format!("call_auto_{next_generated_tool_call_index}");
|
||||
*next_generated_tool_call_index += 1;
|
||||
generated
|
||||
}
|
||||
|
||||
fn responses_tool_result_call_id(
|
||||
id: &str,
|
||||
pending_tool_call_ids: &mut VecDeque<String>,
|
||||
) -> Option<String> {
|
||||
let trimmed = id.trim();
|
||||
if !trimmed.is_empty() {
|
||||
if let Some(position) = pending_tool_call_ids
|
||||
.iter()
|
||||
.position(|pending_id| pending_id == trimmed)
|
||||
{
|
||||
pending_tool_call_ids.remove(position);
|
||||
}
|
||||
return Some(trimmed.to_string());
|
||||
}
|
||||
pending_tool_call_ids.pop_front()
|
||||
}
|
||||
|
||||
fn responses_tool_name(name: &str) -> String {
|
||||
let trimmed = name.trim();
|
||||
if trimmed.is_empty() {
|
||||
"unknown".to_string()
|
||||
} else {
|
||||
trimmed.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn responses_max_output_tokens(canonical: &CanonicalRequest) -> Option<u64> {
|
||||
canonical.generation.max_tokens.map(|max_tokens| {
|
||||
if is_claude_messages_request(&canonical.extensions) && max_tokens < 128 {
|
||||
@@ -773,16 +816,16 @@ fn responses_tool_result_payload(
|
||||
output: Option<&Value>,
|
||||
content_text: Option<&str>,
|
||||
extensions: &BTreeMap<String, Value>,
|
||||
) -> (Value, Vec<Value>) {
|
||||
) -> Option<(Value, Vec<Value>)> {
|
||||
if is_claude_tool_result(extensions) {
|
||||
if let Some(Value::Array(parts)) = output {
|
||||
return claude_tool_result_parts_to_responses_payload(parts);
|
||||
}
|
||||
}
|
||||
(
|
||||
Some((
|
||||
responses_tool_result_output(output, content_text),
|
||||
Vec::new(),
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
fn responses_tool_result_output(output: Option<&Value>, content_text: Option<&str>) -> Value {
|
||||
@@ -795,15 +838,64 @@ fn responses_tool_result_output(output: Option<&Value>, content_text: Option<&st
|
||||
Value::String(non_empty_responses_tool_output(&text))
|
||||
}
|
||||
|
||||
fn claude_tool_result_parts_to_responses_payload(parts: &[Value]) -> (Value, Vec<Value>) {
|
||||
pub(crate) fn claude_tool_result_parts_are_openai_responses_representable(parts: &[Value]) -> bool {
|
||||
parts
|
||||
.iter()
|
||||
.all(claude_tool_result_part_is_openai_responses_representable)
|
||||
}
|
||||
|
||||
fn claude_tool_result_part_is_openai_responses_representable(part: &Value) -> bool {
|
||||
let Some(part_object) = part.as_object() else {
|
||||
return false;
|
||||
};
|
||||
match part_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"text" => true,
|
||||
"image" => claude_image_block_is_openai_responses_representable(part_object),
|
||||
"document" | "file" => claude_document_block_is_openai_responses_representable(part_object),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_image_block_is_openai_responses_representable(block: &Map<String, Value>) -> bool {
|
||||
let Some(source) = block.get("source").and_then(Value::as_object) else {
|
||||
return false;
|
||||
};
|
||||
match source
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"base64" => claude_source_str(source, "data").is_some(),
|
||||
"url" => claude_source_str(source, "url").is_some(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_document_block_is_openai_responses_representable(block: &Map<String, Value>) -> bool {
|
||||
let Some(source) = block.get("source").and_then(Value::as_object) else {
|
||||
return false;
|
||||
};
|
||||
match source
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"base64" | "text" => claude_source_str(source, "data").is_some(),
|
||||
"url" => claude_source_str(source, "url").is_some(),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn claude_tool_result_parts_to_responses_payload(parts: &[Value]) -> Option<(Value, Vec<Value>)> {
|
||||
let mut output_texts = Vec::new();
|
||||
let mut extra_user_content = Vec::new();
|
||||
|
||||
for part in parts {
|
||||
let Some(part_object) = part.as_object() else {
|
||||
output_texts.push("[Claude tool_result non-text content omitted]".to_string());
|
||||
continue;
|
||||
};
|
||||
let part_object = part.as_object()?;
|
||||
match part_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
@@ -820,27 +912,31 @@ fn claude_tool_result_parts_to_responses_payload(parts: &[Value]) -> (Value, Vec
|
||||
if let Some(part) = claude_image_block_to_responses_input_part(part_object) {
|
||||
extra_user_content.push(part);
|
||||
} else {
|
||||
output_texts.push(claude_tool_result_media_summary("image", part_object));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
"document" | "file" => {
|
||||
if let Some(part) = claude_document_block_to_responses_input_part(part_object) {
|
||||
if let Some(text) = claude_text_document_block_to_responses_output_text(part_object)
|
||||
{
|
||||
if !text.is_empty() {
|
||||
output_texts.push(text.to_string());
|
||||
}
|
||||
} else if let Some(part) =
|
||||
claude_document_block_to_responses_input_part(part_object)
|
||||
{
|
||||
extra_user_content.push(part);
|
||||
} else {
|
||||
output_texts.push(claude_tool_result_media_summary("document", part_object));
|
||||
return None;
|
||||
}
|
||||
}
|
||||
"" => output_texts.push("[Claude tool_result object content omitted]".to_string()),
|
||||
raw_type => {
|
||||
output_texts.push(format!("[Claude tool_result {raw_type} content omitted]"))
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
Some((
|
||||
Value::String(non_empty_responses_tool_output(&output_texts.join("\n\n"))),
|
||||
extra_user_content,
|
||||
)
|
||||
))
|
||||
}
|
||||
|
||||
fn claude_image_block_to_responses_input_part(block: &Map<String, Value>) -> Option<Value> {
|
||||
@@ -899,16 +995,15 @@ fn claude_document_block_to_responses_input_part(block: &Map<String, Value>) ->
|
||||
Some(Value::Object(part))
|
||||
}
|
||||
|
||||
fn claude_tool_result_media_summary(kind: &str, block: &Map<String, Value>) -> String {
|
||||
let media_type = block
|
||||
.get("source")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(claude_source_media_type);
|
||||
match media_type {
|
||||
Some(media_type) if !media_type.trim().is_empty() => {
|
||||
format!("[Claude tool_result {kind} content omitted: {media_type}]")
|
||||
}
|
||||
_ => format!("[Claude tool_result {kind} content omitted]"),
|
||||
fn claude_text_document_block_to_responses_output_text(block: &Map<String, Value>) -> Option<&str> {
|
||||
let source = block.get("source")?.as_object()?;
|
||||
match source
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"text" => claude_source_str(source, "data"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -949,6 +1044,16 @@ mod tests {
|
||||
CanonicalRole,
|
||||
};
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn claude_tool_result_extensions() -> BTreeMap<String, serde_json::Value> {
|
||||
let mut extensions = BTreeMap::new();
|
||||
extensions.insert(
|
||||
"aether".to_string(),
|
||||
json!({ "source": "claude_tool_result" }),
|
||||
);
|
||||
extensions
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn json_object_response_injects_json_hint_into_input_when_only_instructions_have_it() {
|
||||
@@ -974,12 +1079,17 @@ mod tests {
|
||||
let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body");
|
||||
|
||||
assert_eq!(body["text"]["format"]["type"], json!("json_object"));
|
||||
assert_eq!(body["input"][0]["role"], json!("system"));
|
||||
assert!(body["input"][0]["content"][0]["text"]
|
||||
assert_eq!(body["instructions"], json!("Please answer in JSON."));
|
||||
let input = body["input"].as_array().expect("input");
|
||||
assert_eq!(input.len(), 2);
|
||||
assert_eq!(input[0]["role"], json!("system"));
|
||||
assert!(input[0]["content"][0]["text"]
|
||||
.as_str()
|
||||
.expect("hint text")
|
||||
.to_ascii_lowercase()
|
||||
.contains("json"));
|
||||
assert_eq!(input[1]["role"], json!("user"));
|
||||
assert_eq!(input[1]["content"][0]["text"], json!("hello"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1039,4 +1149,192 @@ mod tests {
|
||||
assert_eq!(body["input"][0]["call_id"], "call_empty");
|
||||
assert_eq!(body["input"][0]["output"], "(empty)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_replaces_empty_tool_call_identifiers() {
|
||||
let request = CanonicalRequest {
|
||||
model: "gpt-5.5".to_string(),
|
||||
messages: vec![
|
||||
CanonicalMessage {
|
||||
role: CanonicalRole::Assistant,
|
||||
content: vec![CanonicalContentBlock::ToolUse {
|
||||
id: " ".to_string(),
|
||||
name: "".to_string(),
|
||||
input: json!({"q": "rust"}),
|
||||
extensions: Default::default(),
|
||||
}],
|
||||
extensions: Default::default(),
|
||||
},
|
||||
CanonicalMessage {
|
||||
role: CanonicalRole::Tool,
|
||||
content: vec![CanonicalContentBlock::ToolResult {
|
||||
tool_use_id: "".to_string(),
|
||||
name: None,
|
||||
output: Some(json!({"ok": true})),
|
||||
content_text: None,
|
||||
is_error: false,
|
||||
extensions: Default::default(),
|
||||
}],
|
||||
extensions: Default::default(),
|
||||
},
|
||||
],
|
||||
..CanonicalRequest::default()
|
||||
};
|
||||
|
||||
let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body");
|
||||
|
||||
assert_eq!(body["input"].as_array().expect("input").len(), 2);
|
||||
assert_eq!(body["input"][0]["type"], "function_call");
|
||||
assert_eq!(body["input"][0]["call_id"], "call_auto_0");
|
||||
assert_eq!(body["input"][0]["name"], "unknown");
|
||||
assert_eq!(body["input"][0]["arguments"], "{\"q\":\"rust\"}");
|
||||
assert_eq!(body["input"][1]["type"], "function_call_output");
|
||||
assert_eq!(body["input"][1]["call_id"], "call_auto_0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_assigns_empty_tool_result_identifiers_from_pending_tool_calls_in_order() {
|
||||
let request = CanonicalRequest {
|
||||
model: "gpt-5.5".to_string(),
|
||||
messages: vec![
|
||||
CanonicalMessage {
|
||||
role: CanonicalRole::Assistant,
|
||||
content: vec![
|
||||
CanonicalContentBlock::ToolUse {
|
||||
id: "call_a".to_string(),
|
||||
name: "lookup_a".to_string(),
|
||||
input: json!({"q": "a"}),
|
||||
extensions: Default::default(),
|
||||
},
|
||||
CanonicalContentBlock::ToolUse {
|
||||
id: "call_b".to_string(),
|
||||
name: "lookup_b".to_string(),
|
||||
input: json!({"q": "b"}),
|
||||
extensions: Default::default(),
|
||||
},
|
||||
],
|
||||
extensions: Default::default(),
|
||||
},
|
||||
CanonicalMessage {
|
||||
role: CanonicalRole::Tool,
|
||||
content: vec![
|
||||
CanonicalContentBlock::ToolResult {
|
||||
tool_use_id: " ".to_string(),
|
||||
name: None,
|
||||
output: Some(json!("result a")),
|
||||
content_text: None,
|
||||
is_error: false,
|
||||
extensions: Default::default(),
|
||||
},
|
||||
CanonicalContentBlock::ToolResult {
|
||||
tool_use_id: "".to_string(),
|
||||
name: None,
|
||||
output: Some(json!("result b")),
|
||||
content_text: None,
|
||||
is_error: false,
|
||||
extensions: Default::default(),
|
||||
},
|
||||
],
|
||||
extensions: Default::default(),
|
||||
},
|
||||
],
|
||||
..CanonicalRequest::default()
|
||||
};
|
||||
|
||||
let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body");
|
||||
|
||||
assert_eq!(body["input"][0]["call_id"], "call_a");
|
||||
assert_eq!(body["input"][1]["call_id"], "call_b");
|
||||
assert_eq!(body["input"][2]["call_id"], "call_a");
|
||||
assert_eq!(body["input"][2]["output"], "result a");
|
||||
assert_eq!(body["input"][3]["call_id"], "call_b");
|
||||
assert_eq!(body["input"][3]["output"], "result b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_rejects_orphan_empty_tool_result_identifier() {
|
||||
let request = CanonicalRequest {
|
||||
model: "gpt-5.5".to_string(),
|
||||
messages: vec![CanonicalMessage {
|
||||
role: CanonicalRole::Tool,
|
||||
content: vec![CanonicalContentBlock::ToolResult {
|
||||
tool_use_id: " ".to_string(),
|
||||
name: None,
|
||||
output: Some(json!({"ok": true})),
|
||||
content_text: None,
|
||||
is_error: false,
|
||||
extensions: Default::default(),
|
||||
}],
|
||||
extensions: Default::default(),
|
||||
}],
|
||||
..CanonicalRequest::default()
|
||||
};
|
||||
|
||||
assert!(to_raw(&request, "gpt-5.5", false, false).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_preserves_claude_text_document_tool_result_content() {
|
||||
let request = CanonicalRequest {
|
||||
model: "gpt-5.5".to_string(),
|
||||
messages: vec![CanonicalMessage {
|
||||
role: CanonicalRole::Tool,
|
||||
content: vec![CanonicalContentBlock::ToolResult {
|
||||
tool_use_id: "call_doc".to_string(),
|
||||
name: None,
|
||||
output: Some(json!([
|
||||
{"type": "text", "text": "preview"},
|
||||
{
|
||||
"type": "document",
|
||||
"source": {
|
||||
"type": "text",
|
||||
"media_type": "text/plain",
|
||||
"data": "document body"
|
||||
}
|
||||
}
|
||||
])),
|
||||
content_text: None,
|
||||
is_error: false,
|
||||
extensions: claude_tool_result_extensions(),
|
||||
}],
|
||||
extensions: Default::default(),
|
||||
}],
|
||||
..CanonicalRequest::default()
|
||||
};
|
||||
|
||||
let body = to_raw(&request, "gpt-5.5", false, false).expect("responses body");
|
||||
|
||||
assert_eq!(body["input"][0]["type"], "function_call_output");
|
||||
assert_eq!(body["input"][0]["output"], "preview\n\ndocument body");
|
||||
assert!(!body.to_string().contains("content omitted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_rejects_unrepresentable_claude_tool_result_blocks() {
|
||||
let request = CanonicalRequest {
|
||||
model: "gpt-5.5".to_string(),
|
||||
messages: vec![CanonicalMessage {
|
||||
role: CanonicalRole::Tool,
|
||||
content: vec![CanonicalContentBlock::ToolResult {
|
||||
tool_use_id: "call_img".to_string(),
|
||||
name: None,
|
||||
output: Some(json!([{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "unsupported",
|
||||
"media_type": "image/png",
|
||||
"data": "AAAA"
|
||||
}
|
||||
}])),
|
||||
content_text: None,
|
||||
is_error: false,
|
||||
extensions: claude_tool_result_extensions(),
|
||||
}],
|
||||
extensions: Default::default(),
|
||||
}],
|
||||
..CanonicalRequest::default()
|
||||
};
|
||||
|
||||
assert!(to_raw(&request, "gpt-5.5", false, false).is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,9 @@ pub fn convert_request(
|
||||
body: &Value,
|
||||
ctx: &FormatContext,
|
||||
) -> Result<Value, FormatError> {
|
||||
let source = parse_format(source_format)?;
|
||||
let target = parse_format(target_format)?;
|
||||
validate_runtime_request_conversion(source, target, body)?;
|
||||
let mut request = parse_request(source_format, body, ctx)?;
|
||||
if let Some(mapped_model) = ctx
|
||||
.mapped_model
|
||||
@@ -135,6 +138,43 @@ pub fn convert_request(
|
||||
emit_request_inner(target_format, &request, ctx)
|
||||
}
|
||||
|
||||
fn validate_runtime_request_conversion(
|
||||
source: FormatId,
|
||||
target: FormatId,
|
||||
body: &Value,
|
||||
) -> Result<(), FormatError> {
|
||||
if source == FormatId::ClaudeMessages {
|
||||
match target {
|
||||
FormatId::OpenAiChat
|
||||
if claude_request_contains_unrepresentable_tool_result_content_for_openai_chat(
|
||||
body,
|
||||
) =>
|
||||
{
|
||||
return Err(FormatError::LossyConversionBlocked {
|
||||
source_format: source.as_str().to_string(),
|
||||
target_format: target.as_str().to_string(),
|
||||
field: "messages[].content[].tool_result.content".to_string(),
|
||||
reason: "OpenAI Chat tool messages cannot represent one or more Claude tool_result content blocks".to_string(),
|
||||
});
|
||||
}
|
||||
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact
|
||||
if claude_request_contains_unrepresentable_tool_result_content_for_openai_responses(
|
||||
body,
|
||||
) =>
|
||||
{
|
||||
return Err(FormatError::LossyConversionBlocked {
|
||||
source_format: source.as_str().to_string(),
|
||||
target_format: target.as_str().to_string(),
|
||||
field: "messages[].content[].tool_result.content".to_string(),
|
||||
reason: "OpenAI Responses function_call_output cannot represent one or more Claude tool_result content blocks".to_string(),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn parse_response(
|
||||
source_format: &str,
|
||||
body: &Value,
|
||||
@@ -2022,6 +2062,54 @@ fn claude_request_contains_tool_result_content_array(body: &Value) -> bool {
|
||||
})
|
||||
}
|
||||
|
||||
fn claude_request_contains_unrepresentable_tool_result_content_for_openai_chat(
|
||||
body: &Value,
|
||||
) -> bool {
|
||||
claude_request_contains_unrepresentable_tool_result_content(body, |parts| {
|
||||
!openai_chat::request::claude_tool_result_parts_are_openai_chat_representable(parts)
|
||||
})
|
||||
}
|
||||
|
||||
fn claude_request_contains_unrepresentable_tool_result_content_for_openai_responses(
|
||||
body: &Value,
|
||||
) -> bool {
|
||||
claude_request_contains_unrepresentable_tool_result_content(body, |parts| {
|
||||
!openai_responses::request::claude_tool_result_parts_are_openai_responses_representable(
|
||||
parts,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn claude_request_contains_unrepresentable_tool_result_content(
|
||||
body: &Value,
|
||||
is_unrepresentable: impl Fn(&[Value]) -> bool,
|
||||
) -> bool {
|
||||
let Some(messages) = body
|
||||
.as_object()
|
||||
.and_then(|object| object.get("messages"))
|
||||
.and_then(Value::as_array)
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
messages.iter().any(|message| {
|
||||
message
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|blocks| {
|
||||
blocks.iter().any(|block| {
|
||||
block
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|block_type| block_type.eq_ignore_ascii_case("tool_result"))
|
||||
&& block
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.is_some_and(|parts| is_unrepresentable(parts.as_slice()))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn gemini_request_contains_builtin_tool(body: &Value, camel: &str, snake: &str) -> bool {
|
||||
let Some(tools) = body
|
||||
.as_object()
|
||||
|
||||
@@ -463,8 +463,11 @@ pub fn from_openai_chat_to_canonical_request(body_json: &Value) -> Option<Canoni
|
||||
crate::formats::openai::chat::request::from_raw(body_json)
|
||||
}
|
||||
|
||||
pub fn canonical_to_openai_chat_request(canonical: &CanonicalRequest) -> Value {
|
||||
crate::formats::openai::chat::request::to_raw(canonical)
|
||||
pub fn canonical_to_openai_chat_request(canonical: &CanonicalRequest) -> Option<Value> {
|
||||
crate::formats::openai::chat::request::to(
|
||||
canonical,
|
||||
&crate::formats::context::FormatContext::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn from_openai_responses_to_canonical_request(body_json: &Value) -> Option<CanonicalRequest> {
|
||||
@@ -2771,16 +2774,16 @@ fn anthropic_tool_result_blocks_to_openai_chat_content(parts: &[Value]) -> Value
|
||||
}
|
||||
|
||||
let mut has_media_part = false;
|
||||
let converted_parts = parts
|
||||
.iter()
|
||||
.map(|part| {
|
||||
let openai_part = anthropic_tool_result_block_to_openai_chat_part(part);
|
||||
if !openai_chat_part_is_text(&openai_part) {
|
||||
has_media_part = true;
|
||||
}
|
||||
openai_part
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut converted_parts = Vec::with_capacity(parts.len());
|
||||
for part in parts {
|
||||
let Some(openai_part) = anthropic_tool_result_block_to_openai_chat_part(part) else {
|
||||
return Value::String(Value::Array(parts.to_vec()).to_string());
|
||||
};
|
||||
if !openai_chat_part_is_text(&openai_part) {
|
||||
has_media_part = true;
|
||||
}
|
||||
converted_parts.push(openai_part);
|
||||
}
|
||||
|
||||
if has_media_part {
|
||||
Value::Array(converted_parts)
|
||||
@@ -2801,34 +2804,22 @@ fn anthropic_text_blocks_to_string(parts: &[Value]) -> Option<String> {
|
||||
Some(texts.join("\n\n"))
|
||||
}
|
||||
|
||||
fn anthropic_tool_result_block_to_openai_chat_part(part: &Value) -> Value {
|
||||
let Some(part_object) = part.as_object() else {
|
||||
return openai_text_part("[Claude tool_result non-text content omitted]");
|
||||
};
|
||||
fn anthropic_tool_result_block_to_openai_chat_part(part: &Value) -> Option<Value> {
|
||||
let part_object = part.as_object()?;
|
||||
match part_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
{
|
||||
"text" => openai_text_part(
|
||||
"text" => Some(openai_text_part(
|
||||
part_object
|
||||
.get("text")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
"image" => anthropic_image_block_to_openai_chat_part(part_object).unwrap_or_else(|| {
|
||||
openai_text_part(anthropic_media_block_summary("image", part_object))
|
||||
}),
|
||||
"document" => {
|
||||
anthropic_document_block_to_openai_chat_part(part_object).unwrap_or_else(|| {
|
||||
openai_text_part(anthropic_media_block_summary("document", part_object))
|
||||
})
|
||||
}
|
||||
"file" => anthropic_document_block_to_openai_chat_part(part_object).unwrap_or_else(|| {
|
||||
openai_text_part(anthropic_media_block_summary("file", part_object))
|
||||
}),
|
||||
"" => openai_text_part("[Claude tool_result object content omitted]"),
|
||||
raw_type => openai_text_part(format!("[Claude tool_result {raw_type} content omitted]")),
|
||||
)),
|
||||
"image" => anthropic_image_block_to_openai_chat_part(part_object),
|
||||
"document" | "file" => anthropic_document_block_to_openai_chat_part(part_object),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2883,23 +2874,11 @@ fn anthropic_document_block_to_openai_chat_part(block: &Map<String, Value>) -> O
|
||||
let url = anthropic_source_str(source, "url")?;
|
||||
Some(openai_text_part(format!("[File: {url}]")))
|
||||
}
|
||||
"text" => anthropic_source_str(source, "data").map(openai_text_part),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn anthropic_media_block_summary(kind: &str, block: &Map<String, Value>) -> String {
|
||||
let media_type = block
|
||||
.get("source")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(anthropic_source_media_type);
|
||||
match media_type {
|
||||
Some(media_type) if !media_type.trim().is_empty() => {
|
||||
format!("[Claude tool_result {kind} content omitted: {media_type}]")
|
||||
}
|
||||
_ => format!("[Claude tool_result {kind} content omitted]"),
|
||||
}
|
||||
}
|
||||
|
||||
fn openai_text_part(text: impl Into<String>) -> Value {
|
||||
json!({
|
||||
"type": "text",
|
||||
@@ -5785,7 +5764,7 @@ mod tests {
|
||||
assert_eq!(canonical_unknown_block_count(user_blocks), 1);
|
||||
assert_eq!(canonical_request_unknown_block_count(&canonical), 1);
|
||||
|
||||
let rebuilt = canonical_to_openai_chat_request(&canonical);
|
||||
let rebuilt = canonical_to_openai_chat_request(&canonical).expect("openai chat request");
|
||||
assert_eq!(rebuilt["model"], "gpt-5");
|
||||
assert_eq!(rebuilt["messages"][0]["role"], "system");
|
||||
assert_eq!(rebuilt["messages"][1]["role"], "developer");
|
||||
@@ -5873,7 +5852,7 @@ mod tests {
|
||||
"n": 2
|
||||
});
|
||||
let canonical = from_openai_chat_to_canonical_request(&request).expect("canonical request");
|
||||
let rebuilt = canonical_to_openai_chat_request(&canonical);
|
||||
let rebuilt = canonical_to_openai_chat_request(&canonical).expect("openai chat request");
|
||||
assert_eq!(rebuilt["model"], request["model"]);
|
||||
assert_eq!(rebuilt["messages"], request["messages"]);
|
||||
assert_eq!(rebuilt["stop"], Value::Array(vec![json!("x"), json!("y")]));
|
||||
@@ -6368,7 +6347,8 @@ mod tests {
|
||||
CanonicalContentBlock::ToolUse { ref id, .. } if id == "toolu_auto_0"
|
||||
));
|
||||
|
||||
let openai_chat = canonical_to_openai_chat_request(&canonical);
|
||||
let openai_chat =
|
||||
canonical_to_openai_chat_request(&canonical).expect("openai chat request");
|
||||
assert_eq!(
|
||||
openai_chat["messages"][2]["reasoning_parts"][0]["signature"],
|
||||
"sig_123"
|
||||
@@ -6388,6 +6368,32 @@ mod tests {
|
||||
assert_eq!(rebuilt["output_config"]["effort"], "medium");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn canonical_to_openai_chat_request_rejects_unrepresentable_claude_tool_result() {
|
||||
let request = json!({
|
||||
"model": "claude-sonnet",
|
||||
"messages": [{
|
||||
"role": "user",
|
||||
"content": [{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "toolu_read",
|
||||
"content": [{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "unsupported",
|
||||
"media_type": "image/png",
|
||||
"data": "AAAA"
|
||||
}
|
||||
}]
|
||||
}]
|
||||
}]
|
||||
});
|
||||
|
||||
let canonical = from_claude_to_canonical_request(&request).expect("canonical request");
|
||||
|
||||
assert!(canonical_to_openai_chat_request(&canonical).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn claude_response_adapter_preserves_thinking_signature_tool_and_cache_usage() {
|
||||
let response = json!({
|
||||
|
||||
Reference in New Issue
Block a user