Skip to content

Commit 6efb82b

Browse files
author
root
committed
Fix moderation text extraction after format transforms
1 parent 0caeb19 commit 6efb82b

4 files changed

Lines changed: 126 additions & 2 deletions

File tree

src/format.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ impl RequestFormat {
3434
pub struct RequestPlan {
3535
pub source_format: Option<RequestFormat>,
3636
pub target_format: Option<RequestFormat>,
37+
pub moderation_text: Option<String>,
3738
pub body: Value,
3839
pub path: String,
3940
pub stream: bool,
@@ -96,6 +97,7 @@ pub fn process_request(
9697
let mut plan = RequestPlan {
9798
source_format: None,
9899
target_format: None,
100+
moderation_text: None,
99101
stream: body.get("stream").and_then(Value::as_bool).unwrap_or(false),
100102
body,
101103
path: path.to_string(),
@@ -187,6 +189,7 @@ pub fn process_request(
187189
plan.stream = internal.stream;
188190
plan.source_format = Some(source);
189191
plan.target_format = Some(target);
192+
plan.moderation_text = Some(moderation_text_from_internal_request(&internal));
190193

191194
if target != source || disable_tools {
192195
plan.body = emit_request(target, &internal)
@@ -199,6 +202,40 @@ pub fn process_request(
199202
Ok(plan)
200203
}
201204

205+
fn moderation_text_from_internal_request(req: &InternalRequest) -> String {
206+
let mut texts = Vec::new();
207+
for message in &req.messages {
208+
for block in &message.content {
209+
match block {
210+
InternalContentBlock::Text(text) => push_non_empty_text(text, &mut texts),
211+
InternalContentBlock::ToolResult { output, .. } => {
212+
collect_moderation_value_text(output, &mut texts);
213+
}
214+
InternalContentBlock::ToolCall { .. } | InternalContentBlock::ImageUrl { .. } => {}
215+
}
216+
}
217+
}
218+
texts.join("\n")
219+
}
220+
221+
fn collect_moderation_value_text(value: &Value, texts: &mut Vec<String>) {
222+
match value {
223+
Value::String(text) => push_non_empty_text(text, texts),
224+
Value::Array(items) => {
225+
for item in items {
226+
collect_moderation_value_text(item, texts);
227+
}
228+
}
229+
_ => {}
230+
}
231+
}
232+
233+
fn push_non_empty_text(text: &str, texts: &mut Vec<String>) {
234+
if !text.is_empty() {
235+
texts.push(text.to_string());
236+
}
237+
}
238+
202239
#[cfg_attr(not(test), allow(dead_code))]
203240
fn detect_format(from_cfg: Option<&Value>, path: &str, headers: &[(String, String)], body: &Value) -> Option<RequestFormat> {
204241
detect_formats_from_candidates(&configured_candidates(from_cfg), path, headers, body).into_iter().next()

src/proxy.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,8 +134,9 @@ async fn proxy_entry_with_cfg(
134134
.or_else(|| detect_source_format(&path, request_json.as_ref()))
135135
.unwrap_or("openai_chat")
136136
.to_string();
137-
let moderation_text =
138-
extract::extract_text_for_moderation(&request_plan.body, moderation_format.as_str());
137+
let moderation_text = request_plan.moderation_text.clone().unwrap_or_else(|| {
138+
extract::extract_text_for_moderation(&request_plan.body, moderation_format.as_str())
139+
});
139140
Some((moderation_format, moderation_text))
140141
} else {
141142
None

tests/format_process_tests.rs

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,11 +34,60 @@ fn detects_openai_chat_and_rewrites_path_for_responses_target() {
3434
assert_eq!(plan.target_format, Some(RequestFormat::OpenAiResponses));
3535
assert!(plan.stream);
3636
assert_eq!(plan.path, "/proxy/openai/v1/responses");
37+
assert_eq!(plan.moderation_text.as_deref(), Some("Be terse.\nPing"));
3738
assert_eq!(plan.body["instructions"], "Be terse.");
3839
assert_eq!(plan.body["input"][0]["role"], "user");
3940
assert_eq!(plan.body["input"][0]["content"][0]["text"], "Ping");
4041
}
4142

43+
#[test]
44+
fn preserves_moderation_text_when_chat_transforms_into_responses_instructions() {
45+
let plan = process_request(
46+
&transform_config(true, "openai_responses"),
47+
"/v1/chat/completions",
48+
&[],
49+
json!({
50+
"model": "gpt-4.1-mini",
51+
"stream": false,
52+
"messages": [
53+
{"role": "system", "content": "forbidden system text"},
54+
{"role": "user", "content": "safe user text"}
55+
]
56+
}),
57+
)
58+
.expect("openai chat request should transform");
59+
60+
assert_eq!(plan.source_format, Some(RequestFormat::OpenAiChat));
61+
assert_eq!(plan.target_format, Some(RequestFormat::OpenAiResponses));
62+
assert_eq!(
63+
plan.moderation_text.as_deref(),
64+
Some("forbidden system text\nsafe user text")
65+
);
66+
assert_eq!(plan.body["instructions"], "forbidden system text");
67+
}
68+
69+
#[test]
70+
fn preserves_moderation_text_for_native_openai_responses_requests() {
71+
let plan = process_request(
72+
&transform_config(true, "claude_chat"),
73+
"/v1/responses",
74+
&[],
75+
json!({
76+
"model": "gpt-4.1-mini",
77+
"instructions": "forbidden instruction text",
78+
"input": "safe user text"
79+
}),
80+
)
81+
.expect("responses request should transform");
82+
83+
assert_eq!(plan.source_format, Some(RequestFormat::OpenAiResponses));
84+
assert_eq!(plan.target_format, Some(RequestFormat::ClaudeChat));
85+
assert_eq!(
86+
plan.moderation_text.as_deref(),
87+
Some("forbidden instruction text\nsafe user text")
88+
);
89+
}
90+
4291
#[test]
4392
fn detects_claude_chat_from_headers_and_rewrites_path_for_openai_chat_target() {
4493
let plan = process_request(

tests/format_runtime.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ impl RequestFormat {
3636
pub struct RequestPlan {
3737
pub source_format: Option<RequestFormat>,
3838
pub target_format: Option<RequestFormat>,
39+
pub moderation_text: Option<String>,
3940
pub body: Value,
4041
pub path: String,
4142
pub stream: bool,
@@ -98,6 +99,7 @@ pub fn process_request(
9899
let mut plan = RequestPlan {
99100
source_format: None,
100101
target_format: None,
102+
moderation_text: None,
101103
stream: body.get("stream").and_then(Value::as_bool).unwrap_or(false),
102104
body,
103105
path: path.to_string(),
@@ -190,6 +192,7 @@ pub fn process_request(
190192
plan.stream = internal.stream;
191193
plan.source_format = Some(source);
192194
plan.target_format = Some(target);
195+
plan.moderation_text = Some(moderation_text_from_internal_request(&internal));
193196

194197
if target != source || disable_tools {
195198
plan.body = emit_request(target, &internal)
@@ -202,6 +205,40 @@ pub fn process_request(
202205
Ok(plan)
203206
}
204207

208+
fn moderation_text_from_internal_request(req: &InternalRequest) -> String {
209+
let mut texts = Vec::new();
210+
for message in &req.messages {
211+
for block in &message.content {
212+
match block {
213+
InternalContentBlock::Text(text) => push_non_empty_text(text, &mut texts),
214+
InternalContentBlock::ToolResult { output, .. } => {
215+
collect_moderation_value_text(output, &mut texts);
216+
}
217+
InternalContentBlock::ToolCall { .. } | InternalContentBlock::ImageUrl { .. } => {}
218+
}
219+
}
220+
}
221+
texts.join("\n")
222+
}
223+
224+
fn collect_moderation_value_text(value: &Value, texts: &mut Vec<String>) {
225+
match value {
226+
Value::String(text) => push_non_empty_text(text, texts),
227+
Value::Array(items) => {
228+
for item in items {
229+
collect_moderation_value_text(item, texts);
230+
}
231+
}
232+
_ => {}
233+
}
234+
}
235+
236+
fn push_non_empty_text(text: &str, texts: &mut Vec<String>) {
237+
if !text.is_empty() {
238+
texts.push(text.to_string());
239+
}
240+
}
241+
205242
fn detect_format(from_cfg: Option<&Value>, path: &str, headers: &[(String, String)], body: &Value) -> Option<RequestFormat> {
206243
detect_formats_from_candidates(&configured_candidates(from_cfg), path, headers, body)
207244
.into_iter()

0 commit comments

Comments
 (0)