mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-11 19:59:50 +08:00
feat(openai): align GPT-5.6 and Codex request contracts
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -4,22 +4,22 @@ use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{
|
||||
formats::context::FormatContext,
|
||||
formats::openai::shared::{
|
||||
map_thinking_budget_to_openai_reasoning_effort, OpenAiResponsesReasoningEffort,
|
||||
},
|
||||
formats::openai::shared::map_thinking_budget_to_openai_reasoning_effort,
|
||||
protocol::canonical::{
|
||||
canonical_response_format_to_openai_responses, canonical_tool_is_openai_custom,
|
||||
canonical_tool_use_to_openai_responses_input_item, is_claude_messages_request,
|
||||
is_claude_system_instruction, is_claude_thinking_block, is_claude_tool_result,
|
||||
is_openai_responses_input_message, is_openai_thinking_block, media_data_or_url,
|
||||
namespace_extension_object, openai_content_text, openai_extensions,
|
||||
is_openai_responses_content_block, is_openai_responses_input_message,
|
||||
is_openai_responses_raw_block, is_openai_responses_raw_content_block,
|
||||
is_openai_thinking_block, media_data_or_url, namespace_extension_object,
|
||||
openai_content_text, openai_extensions, openai_prompt_cache_breakpoint_from_extensions,
|
||||
openai_response_format_to_canonical, openai_responses_extension,
|
||||
openai_responses_generation_config, openai_responses_input_to_canonical_messages,
|
||||
openai_responses_tool_choice_to_canonical, openai_responses_tools_to_canonical,
|
||||
openai_tool_choice_raw_to_responses, strip_claude_billing_header, CanonicalContentBlock,
|
||||
CanonicalInstruction, CanonicalRequest, CanonicalRole, CanonicalThinkingConfig,
|
||||
CanonicalToolChoice, CanonicalToolDefinition, OPENAI_RESPONSES_EXTENSION_NAMESPACE,
|
||||
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
openai_responses_item_extension_object, openai_responses_tool_choice_to_canonical,
|
||||
openai_responses_tools_to_canonical, openai_tool_choice_raw_to_responses,
|
||||
strip_claude_billing_header, CanonicalContentBlock, CanonicalInstruction, CanonicalRequest,
|
||||
CanonicalRole, CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition,
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -45,6 +45,77 @@ pub fn to_compact(request: &CanonicalRequest, ctx: &FormatContext) -> Option<Val
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct OpenAiResponsesRequestContractViolation {
|
||||
pub field: &'static str,
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
const COMPACT_OMITTED_REQUEST_FIELDS: &[&str] = &[
|
||||
"client_metadata",
|
||||
"include",
|
||||
"store",
|
||||
"stream",
|
||||
"stream_options",
|
||||
"tool_choice",
|
||||
];
|
||||
|
||||
/// Validates combinations that the Responses API rejects before transport.
|
||||
///
|
||||
/// This contract intentionally operates on the wire request so conversion
|
||||
/// boundaries can reject combinations the target API does not accept. The
|
||||
/// authoritative same-format transport remains a transparent raw pass-through.
|
||||
pub fn validate_openai_responses_request_contract(
|
||||
body: &Value,
|
||||
target_api_format: &str,
|
||||
) -> Result<(), OpenAiResponsesRequestContractViolation> {
|
||||
if !crate::is_openai_responses_family_format(target_api_format) {
|
||||
return Ok(());
|
||||
}
|
||||
let Some(object) = body.as_object() else {
|
||||
return Ok(());
|
||||
};
|
||||
let multi_agent_enabled = object
|
||||
.get("multi_agent")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|multi_agent| multi_agent.get("enabled"))
|
||||
.and_then(Value::as_bool)
|
||||
== Some(true);
|
||||
if !multi_agent_enabled {
|
||||
return Ok(());
|
||||
}
|
||||
if crate::is_openai_responses_compact_format(target_api_format) {
|
||||
return Err(OpenAiResponsesRequestContractViolation {
|
||||
field: "multi_agent",
|
||||
reason: "OpenAI multi-agent requests are incompatible with Responses Compact",
|
||||
});
|
||||
}
|
||||
if object
|
||||
.get("reasoning")
|
||||
.and_then(Value::as_object)
|
||||
.is_some_and(|reasoning| {
|
||||
reasoning
|
||||
.get("summary")
|
||||
.is_some_and(|value| !value.is_null())
|
||||
})
|
||||
{
|
||||
return Err(OpenAiResponsesRequestContractViolation {
|
||||
field: "reasoning.summary",
|
||||
reason: "OpenAI multi-agent requests do not support reasoning summaries",
|
||||
});
|
||||
}
|
||||
if object
|
||||
.get("max_tool_calls")
|
||||
.is_some_and(|value| !value.is_null())
|
||||
{
|
||||
return Err(OpenAiResponsesRequestContractViolation {
|
||||
field: "max_tool_calls",
|
||||
reason: "OpenAI multi-agent requests do not support max_tool_calls",
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
|
||||
let request = body_json.as_object()?;
|
||||
let mut canonical = CanonicalRequest {
|
||||
@@ -205,10 +276,26 @@ pub fn to_raw(
|
||||
));
|
||||
apply_claude_responses_request_defaults(canonical, mapped_model, &mut output);
|
||||
if compact {
|
||||
output.remove("stream");
|
||||
apply_compact_request_projection(&mut output);
|
||||
}
|
||||
output.remove("verbosity");
|
||||
Some(Value::Object(output))
|
||||
let output = Value::Object(output);
|
||||
validate_openai_responses_request_contract(
|
||||
&output,
|
||||
if compact {
|
||||
"openai:responses:compact"
|
||||
} else {
|
||||
"openai:responses"
|
||||
},
|
||||
)
|
||||
.ok()?;
|
||||
Some(output)
|
||||
}
|
||||
|
||||
pub(super) fn apply_compact_request_projection(output: &mut Map<String, Value>) {
|
||||
for field in COMPACT_OMITTED_REQUEST_FIELDS {
|
||||
output.remove(*field);
|
||||
}
|
||||
}
|
||||
|
||||
fn chat_openai_extension_object_to_responses(
|
||||
@@ -221,6 +308,9 @@ fn chat_openai_extension_object_to_responses(
|
||||
"service_tier",
|
||||
"safety_identifier",
|
||||
"prompt_cache_key",
|
||||
"prompt_cache_options",
|
||||
"prompt_cache_retention",
|
||||
"user",
|
||||
];
|
||||
extensions
|
||||
.get("openai")
|
||||
@@ -337,7 +427,7 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
|
||||
input: arguments,
|
||||
extensions,
|
||||
} => {
|
||||
flush_responses_message(&mut input, role, &mut content);
|
||||
flush_responses_message(&mut input, role, &mut content, &message.extensions);
|
||||
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);
|
||||
@@ -350,10 +440,11 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
|
||||
tool_use_id,
|
||||
output,
|
||||
content_text,
|
||||
is_error,
|
||||
extensions,
|
||||
..
|
||||
} => {
|
||||
flush_responses_message(&mut input, role, &mut content);
|
||||
flush_responses_message(&mut input, role, &mut content, &message.extensions);
|
||||
saw_tool_item = true;
|
||||
let (tool_output, extra_user_content) = responses_tool_result_payload(
|
||||
output.as_ref(),
|
||||
@@ -362,12 +453,24 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
|
||||
)?;
|
||||
let call_id =
|
||||
responses_tool_result_call_id(tool_use_id, &mut pending_tool_call_ids)?;
|
||||
input.push(json!({
|
||||
"type": responses_tool_result_item_type(extensions)
|
||||
.unwrap_or("function_call_output"),
|
||||
"call_id": call_id,
|
||||
"output": tool_output,
|
||||
}));
|
||||
let mut item = Map::new();
|
||||
item.insert(
|
||||
"type".to_string(),
|
||||
Value::String(
|
||||
responses_tool_result_item_type(extensions)
|
||||
.unwrap_or("function_call_output")
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
item.insert("call_id".to_string(), Value::String(call_id));
|
||||
item.insert("output".to_string(), tool_output);
|
||||
if *is_error {
|
||||
item.insert("is_error".to_string(), Value::Bool(true));
|
||||
}
|
||||
let extension_fields =
|
||||
openai_responses_item_extension_object(extensions, &item);
|
||||
item.extend(extension_fields);
|
||||
input.push(Value::Object(item));
|
||||
if !extra_user_content.is_empty() {
|
||||
input.push(json!({
|
||||
"type": "message",
|
||||
@@ -388,7 +491,12 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
|
||||
if role == "assistant"
|
||||
&& is_openai_responses_reasoning_history_block(extensions)
|
||||
{
|
||||
flush_responses_message(&mut input, role, &mut content);
|
||||
flush_responses_message(
|
||||
&mut input,
|
||||
role,
|
||||
&mut content,
|
||||
&message.extensions,
|
||||
);
|
||||
if let Some(reasoning_item) = canonical_thinking_to_responses_reasoning_item(
|
||||
text,
|
||||
encrypted_content.as_deref(),
|
||||
@@ -406,6 +514,15 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
|
||||
}));
|
||||
}
|
||||
}
|
||||
CanonicalContentBlock::Unknown {
|
||||
payload,
|
||||
extensions,
|
||||
..
|
||||
} if is_openai_responses_raw_block(extensions) => {
|
||||
flush_responses_message(&mut input, role, &mut content, &message.extensions);
|
||||
input.push(payload.clone());
|
||||
saw_tool_item = true;
|
||||
}
|
||||
other => {
|
||||
if let Some(part) = canonical_block_to_responses_input_part(
|
||||
other,
|
||||
@@ -418,25 +535,25 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
|
||||
}
|
||||
}
|
||||
if content.is_empty() && !saw_tool_item {
|
||||
if role == "assistant" {
|
||||
input.push(json!({
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": [{
|
||||
"type": "output_text",
|
||||
"text": "",
|
||||
}],
|
||||
}));
|
||||
let content = if role == "assistant" {
|
||||
json!([{
|
||||
"type": "output_text",
|
||||
"text": "",
|
||||
}])
|
||||
} else {
|
||||
input.push(json!({
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": "",
|
||||
}));
|
||||
}
|
||||
Value::String(String::new())
|
||||
};
|
||||
let mut item = Map::new();
|
||||
item.insert("type".to_string(), Value::String("message".to_string()));
|
||||
item.insert("role".to_string(), Value::String(role.to_string()));
|
||||
item.insert("content".to_string(), content);
|
||||
let extension_fields =
|
||||
openai_responses_item_extension_object(&message.extensions, &item);
|
||||
item.extend(extension_fields);
|
||||
input.push(Value::Object(item));
|
||||
continue;
|
||||
}
|
||||
flush_responses_message(&mut input, role, &mut content);
|
||||
flush_responses_message(&mut input, role, &mut content, &message.extensions);
|
||||
}
|
||||
Some(input)
|
||||
}
|
||||
@@ -556,15 +673,22 @@ fn value_contains_json_word(value: &Value) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_responses_message(input: &mut Vec<Value>, role: &str, content: &mut Vec<Value>) {
|
||||
fn flush_responses_message(
|
||||
input: &mut Vec<Value>,
|
||||
role: &str,
|
||||
content: &mut Vec<Value>,
|
||||
extensions: &BTreeMap<String, Value>,
|
||||
) {
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
input.push(json!({
|
||||
"type": "message",
|
||||
"role": role,
|
||||
"content": std::mem::take(content),
|
||||
}));
|
||||
let mut item = Map::new();
|
||||
item.insert("type".to_string(), Value::String("message".to_string()));
|
||||
item.insert("role".to_string(), Value::String(role.to_string()));
|
||||
item.insert("content".to_string(), Value::Array(std::mem::take(content)));
|
||||
let extension_fields = openai_responses_item_extension_object(extensions, &item);
|
||||
item.extend(extension_fields);
|
||||
input.push(Value::Object(item));
|
||||
}
|
||||
|
||||
fn canonical_thinking_to_responses_reasoning_item(
|
||||
@@ -610,26 +734,36 @@ fn canonical_block_to_responses_input_part(
|
||||
strip_claude_billing_header_from_text: bool,
|
||||
) -> Option<Value> {
|
||||
match block {
|
||||
CanonicalContentBlock::Text { text, .. } => {
|
||||
CanonicalContentBlock::Text { text, extensions } => {
|
||||
let text = if strip_claude_billing_header_from_text {
|
||||
strip_claude_billing_header(text)
|
||||
} else {
|
||||
text.clone()
|
||||
};
|
||||
if text.is_empty() {
|
||||
if text.is_empty() && !is_openai_responses_content_block(extensions) {
|
||||
return None;
|
||||
}
|
||||
Some(json!({
|
||||
"type": if role == "assistant" { "output_text" } else { "input_text" },
|
||||
"text": text,
|
||||
}))
|
||||
let mut part = Map::new();
|
||||
part.insert(
|
||||
"type".to_string(),
|
||||
Value::String(if role == "assistant" {
|
||||
"output_text".to_string()
|
||||
} else {
|
||||
"input_text".to_string()
|
||||
}),
|
||||
);
|
||||
part.insert("text".to_string(), Value::String(text));
|
||||
insert_prompt_cache_breakpoint(&mut part, extensions);
|
||||
let extension_fields = openai_responses_item_extension_object(extensions, &part);
|
||||
part.extend(extension_fields);
|
||||
Some(Value::Object(part))
|
||||
}
|
||||
CanonicalContentBlock::Image {
|
||||
data,
|
||||
url,
|
||||
media_type,
|
||||
detail,
|
||||
..
|
||||
extensions,
|
||||
} => {
|
||||
let mut item = Map::new();
|
||||
item.insert(
|
||||
@@ -647,6 +781,9 @@ fn canonical_block_to_responses_input_part(
|
||||
if let Some(detail) = detail {
|
||||
item.insert("detail".to_string(), Value::String(detail.clone()));
|
||||
}
|
||||
insert_prompt_cache_breakpoint(&mut item, extensions);
|
||||
let extension_fields = openai_responses_item_extension_object(extensions, &item);
|
||||
item.extend(extension_fields);
|
||||
Some(Value::Object(item))
|
||||
}
|
||||
CanonicalContentBlock::File {
|
||||
@@ -655,7 +792,7 @@ fn canonical_block_to_responses_input_part(
|
||||
file_url,
|
||||
media_type,
|
||||
filename,
|
||||
..
|
||||
extensions,
|
||||
} => {
|
||||
let mut item = Map::new();
|
||||
item.insert("type".to_string(), Value::String("input_file".to_string()));
|
||||
@@ -675,15 +812,35 @@ fn canonical_block_to_responses_input_part(
|
||||
if let Some(value) = filename {
|
||||
item.insert("filename".to_string(), Value::String(value.clone()));
|
||||
}
|
||||
insert_prompt_cache_breakpoint(&mut item, extensions);
|
||||
let extension_fields = openai_responses_item_extension_object(extensions, &item);
|
||||
item.extend(extension_fields);
|
||||
(item.len() > 1).then_some(Value::Object(item))
|
||||
}
|
||||
CanonicalContentBlock::Audio { data, format, .. } => Some(json!({
|
||||
"type": "input_audio",
|
||||
"input_audio": {
|
||||
"data": data.clone().unwrap_or_default(),
|
||||
"format": format.clone().unwrap_or_else(|| "mp3".to_string()),
|
||||
}
|
||||
})),
|
||||
CanonicalContentBlock::Audio {
|
||||
data,
|
||||
format,
|
||||
extensions,
|
||||
..
|
||||
} => {
|
||||
let mut item = Map::new();
|
||||
item.insert("type".to_string(), Value::String("input_audio".to_string()));
|
||||
item.insert(
|
||||
"input_audio".to_string(),
|
||||
json!({
|
||||
"data": data.clone().unwrap_or_default(),
|
||||
"format": format.clone().unwrap_or_else(|| "mp3".to_string()),
|
||||
}),
|
||||
);
|
||||
let extension_fields = openai_responses_item_extension_object(extensions, &item);
|
||||
item.extend(extension_fields);
|
||||
Some(Value::Object(item))
|
||||
}
|
||||
CanonicalContentBlock::Unknown {
|
||||
payload,
|
||||
extensions,
|
||||
..
|
||||
} if is_openai_responses_raw_content_block(extensions) => Some(payload.clone()),
|
||||
CanonicalContentBlock::Unknown {
|
||||
raw_type, payload, ..
|
||||
} if raw_type == "refusal" => payload
|
||||
@@ -698,6 +855,15 @@ fn canonical_block_to_responses_input_part(
|
||||
}
|
||||
}
|
||||
|
||||
fn insert_prompt_cache_breakpoint(
|
||||
part: &mut Map<String, Value>,
|
||||
extensions: &BTreeMap<String, Value>,
|
||||
) {
|
||||
if let Some(value) = openai_prompt_cache_breakpoint_from_extensions(extensions) {
|
||||
part.insert("prompt_cache_breakpoint".to_string(), value);
|
||||
}
|
||||
}
|
||||
|
||||
fn canonical_tools_to_responses(canonical: &CanonicalRequest) -> Vec<Value> {
|
||||
let mut tools = canonical
|
||||
.tools
|
||||
@@ -788,13 +954,8 @@ fn reasoning_config_to_responses(thinking: &CanonicalThinkingConfig) -> Option<V
|
||||
})
|
||||
}
|
||||
|
||||
fn openai_responses_reasoning_effort(effort: &str) -> Option<&'static str> {
|
||||
match effort.trim().to_ascii_lowercase().as_str() {
|
||||
"max" => Some("xhigh"),
|
||||
value => {
|
||||
OpenAiResponsesReasoningEffort::parse(value).map(OpenAiResponsesReasoningEffort::as_str)
|
||||
}
|
||||
}
|
||||
fn openai_responses_reasoning_effort(effort: &str) -> Option<&str> {
|
||||
(!effort.trim().is_empty()).then_some(effort)
|
||||
}
|
||||
|
||||
fn canonical_text_config_to_responses(canonical: &CanonicalRequest) -> Option<Value> {
|
||||
@@ -834,6 +995,11 @@ fn canonical_tool_to_responses(tool: &CanonicalToolDefinition) -> Value {
|
||||
tool.extensions
|
||||
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
|
||||
})
|
||||
.filter(|raw| {
|
||||
raw.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|tool_type| !tool_type.eq_ignore_ascii_case("function"))
|
||||
})
|
||||
{
|
||||
return raw.clone();
|
||||
}
|
||||
@@ -1304,7 +1470,7 @@ fn insert_number(output: &mut Map<String, Value>, key: &str, value: Option<f64>)
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::to_raw;
|
||||
use super::{from_raw, to_raw, COMPACT_OMITTED_REQUEST_FIELDS};
|
||||
use crate::protocol::canonical::{
|
||||
CanonicalContentBlock, CanonicalMessage, CanonicalRequest, CanonicalResponseFormat,
|
||||
CanonicalRole,
|
||||
@@ -1389,6 +1555,85 @@ mod tests {
|
||||
assert_eq!(body["input"][1]["content"][0]["text"], "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_request_uses_the_codex_request_projection() {
|
||||
let source = json!({
|
||||
"model": "gpt-5.6-sol",
|
||||
"input": [{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "hello"}]
|
||||
}],
|
||||
"client_metadata": {"origin": "codex"},
|
||||
"include": ["reasoning.encrypted_content"],
|
||||
"store": false,
|
||||
"stream": true,
|
||||
"stream_options": {"reasoning_summary_delivery": "sequential_cutoff"},
|
||||
"tool_choice": "auto",
|
||||
"parallel_tool_calls": true,
|
||||
"reasoning": {"effort": "max", "context": "all_turns"},
|
||||
"text": {"verbosity": "medium"},
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"name": "lookup",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}],
|
||||
"service_tier": "priority",
|
||||
"prompt_cache_key": "session:compact"
|
||||
});
|
||||
let request = from_raw(&source).expect("canonical Responses request");
|
||||
|
||||
let regular = to_raw(&request, "gpt-5.6-sol", true, false).expect("Responses request body");
|
||||
let compact = to_raw(&request, "gpt-5.6-sol", false, true).expect("Compact request body");
|
||||
|
||||
for field in COMPACT_OMITTED_REQUEST_FIELDS {
|
||||
assert!(
|
||||
regular.get(*field).is_some(),
|
||||
"regular request should contain {field}"
|
||||
);
|
||||
assert!(
|
||||
compact.get(*field).is_none(),
|
||||
"Compact request should omit {field}"
|
||||
);
|
||||
}
|
||||
for field in [
|
||||
"model",
|
||||
"parallel_tool_calls",
|
||||
"reasoning",
|
||||
"text",
|
||||
"tools",
|
||||
"service_tier",
|
||||
"prompt_cache_key",
|
||||
] {
|
||||
assert_eq!(
|
||||
compact[field], regular[field],
|
||||
"Compact should preserve {field}"
|
||||
);
|
||||
}
|
||||
assert_eq!(compact["input"], regular["input"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_preserves_compaction_trigger_input_item() {
|
||||
let source = json!({
|
||||
"model": "gpt-5.6-sol",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": [{"type": "input_text", "text": "compact"}]
|
||||
},
|
||||
{"type": "compaction_trigger"}
|
||||
],
|
||||
"stream": true
|
||||
});
|
||||
let request = from_raw(&source).expect("canonical Responses request");
|
||||
let body = to_raw(&request, "gpt-5.6-sol", true, false).expect("Responses request body");
|
||||
|
||||
assert_eq!(body["input"].as_array().map(Vec::len), Some(2));
|
||||
assert_eq!(body["input"][1], json!({"type": "compaction_trigger"}));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_request_uses_empty_marker_for_empty_tool_output() {
|
||||
let request = CanonicalRequest {
|
||||
|
||||
@@ -12,13 +12,15 @@ use crate::{
|
||||
canonical_tool_use_to_openai_responses_item, canonical_usage_to_openai_responses_usage,
|
||||
flush_openai_responses_message_item, is_openai_responses_raw_block,
|
||||
is_openai_thinking_block, namespace_extension_object, openai_responses_extensions,
|
||||
openai_responses_output_to_canonical_blocks, openai_usage_to_canonical,
|
||||
CanonicalContentBlock, CanonicalResponse, CanonicalResponseOutput, CanonicalRole,
|
||||
CanonicalStopReason, OPENAI_RESPONSES_EXTENSION_NAMESPACE,
|
||||
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
openai_responses_item_extension_object, openai_responses_output_to_canonical,
|
||||
openai_responses_usage_to_canonical, CanonicalContentBlock, CanonicalResponse,
|
||||
CanonicalResponseOutput, CanonicalRole, CanonicalStopReason,
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
},
|
||||
};
|
||||
|
||||
const AETHER_RESPONSES_RAW_OUTPUT_KEY: &str = "openai_responses_raw_output";
|
||||
|
||||
pub fn from(body: &Value, _ctx: &FormatContext) -> Option<CanonicalResponse> {
|
||||
from_raw(body)
|
||||
}
|
||||
@@ -38,7 +40,7 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let content = openai_responses_output_to_canonical_blocks(body.get("output"))?;
|
||||
let (content, output_extensions) = openai_responses_output_to_canonical(body.get("output"))?;
|
||||
let has_tool_use = content
|
||||
.iter()
|
||||
.any(|block| matches!(block, CanonicalContentBlock::ToolUse { .. }));
|
||||
@@ -53,12 +55,18 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
|
||||
};
|
||||
let mut extensions = openai_responses_extensions(
|
||||
body,
|
||||
&["id", "object", "model", "output", "usage", "status"],
|
||||
&[
|
||||
"id", "object", "model", "output", "usage", "status", "error",
|
||||
],
|
||||
);
|
||||
if let Some(raw_status) = body.get("status").cloned() {
|
||||
canonical_extension_object_mut(&mut extensions, OPENAI_RESPONSES_EXTENSION_NAMESPACE)
|
||||
.insert("raw_status".to_string(), raw_status);
|
||||
}
|
||||
if let Some(raw_output) = body.get("output").cloned() {
|
||||
canonical_extension_object_mut(&mut extensions, "aether")
|
||||
.insert(AETHER_RESPONSES_RAW_OUTPUT_KEY.to_string(), raw_output);
|
||||
}
|
||||
Some(CanonicalResponse {
|
||||
id: body
|
||||
.get("id")
|
||||
@@ -75,11 +83,11 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalResponse> {
|
||||
role: CanonicalRole::Assistant,
|
||||
content: content.clone(),
|
||||
stop_reason: stop_reason.clone(),
|
||||
extensions: BTreeMap::new(),
|
||||
extensions: output_extensions,
|
||||
}],
|
||||
content,
|
||||
stop_reason,
|
||||
usage: openai_usage_to_canonical(body.get("usage")),
|
||||
usage: openai_responses_usage_to_canonical(body.get("usage")),
|
||||
extensions,
|
||||
})
|
||||
}
|
||||
@@ -97,26 +105,35 @@ fn openai_responses_incomplete_stop_reason(body: &Map<String, Value>) -> Canonic
|
||||
}
|
||||
}
|
||||
|
||||
pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: bool) -> Value {
|
||||
pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, compact: bool) -> Value {
|
||||
let mut response = Map::new();
|
||||
let response_id = canonical.id.replace("chatcmpl", "resp");
|
||||
response.insert("id".to_string(), Value::String(response_id.clone()));
|
||||
response.insert("object".to_string(), Value::String("response".to_string()));
|
||||
response.insert("status".to_string(), Value::String("completed".to_string()));
|
||||
response.insert("model".to_string(), Value::String(canonical.model.clone()));
|
||||
if let Some(raw_status) = canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
|
||||
.or_else(|| {
|
||||
canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
|
||||
})
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|openai| openai.get("raw_status"))
|
||||
.cloned()
|
||||
{
|
||||
response.insert("status".to_string(), raw_status);
|
||||
response.insert(
|
||||
"object".to_string(),
|
||||
Value::String(if compact {
|
||||
"response.compaction".to_string()
|
||||
} else {
|
||||
"response".to_string()
|
||||
}),
|
||||
);
|
||||
if !compact {
|
||||
response.insert("status".to_string(), Value::String("completed".to_string()));
|
||||
response.insert("model".to_string(), Value::String(canonical.model.clone()));
|
||||
if let Some(raw_status) = canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
|
||||
.or_else(|| {
|
||||
canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
|
||||
})
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|openai| openai.get("raw_status"))
|
||||
.cloned()
|
||||
{
|
||||
response.insert("status".to_string(), raw_status);
|
||||
}
|
||||
}
|
||||
|
||||
let mut output = Vec::new();
|
||||
@@ -260,6 +277,8 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
|
||||
if *is_error {
|
||||
item.insert("is_error".to_string(), Value::Bool(true));
|
||||
}
|
||||
let extension_fields = openai_responses_item_extension_object(extensions, &item);
|
||||
item.extend(extension_fields);
|
||||
output.push(Value::Object(item));
|
||||
}
|
||||
CanonicalContentBlock::Unknown {
|
||||
@@ -296,6 +315,15 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
|
||||
&response_id,
|
||||
&mut message_index,
|
||||
);
|
||||
if let Some(raw_output) = canonical
|
||||
.extensions
|
||||
.get("aether")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|aether| aether.get(AETHER_RESPONSES_RAW_OUTPUT_KEY))
|
||||
.and_then(Value::as_array)
|
||||
{
|
||||
output.clone_from(raw_output);
|
||||
}
|
||||
response.insert("output".to_string(), Value::Array(output));
|
||||
if let Some(usage) = &canonical.usage {
|
||||
response.insert(
|
||||
@@ -303,6 +331,22 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
|
||||
canonical_usage_to_openai_responses_usage(usage),
|
||||
);
|
||||
}
|
||||
if compact {
|
||||
let created_at = canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
|
||||
.or_else(|| {
|
||||
canonical
|
||||
.extensions
|
||||
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
|
||||
})
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|openai| openai.get("created_at").or_else(|| openai.get("created")))
|
||||
.and_then(openai_responses_timestamp_value)
|
||||
.unwrap_or_else(openai_responses_current_timestamp);
|
||||
response.insert("created_at".to_string(), Value::from(created_at));
|
||||
return Value::Object(response);
|
||||
}
|
||||
if let Some(request_object) = report_context
|
||||
.get("original_request_body")
|
||||
.and_then(Value::as_object)
|
||||
@@ -574,6 +618,42 @@ mod tests {
|
||||
assert_eq!(body["conversation"]["id"], "conv_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compact_response_builder_emits_the_compaction_resource_shape() {
|
||||
let mut extensions = BTreeMap::new();
|
||||
extensions.insert(
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(),
|
||||
json!({"created_at": 123}),
|
||||
);
|
||||
let response = CanonicalResponse {
|
||||
id: "resp_compact".to_string(),
|
||||
model: "gpt-5.6-sol".to_string(),
|
||||
content: vec![CanonicalContentBlock::Text {
|
||||
text: "compacted context".to_string(),
|
||||
extensions: BTreeMap::new(),
|
||||
}],
|
||||
outputs: Vec::new(),
|
||||
stop_reason: Some(CanonicalStopReason::EndTurn),
|
||||
usage: None,
|
||||
extensions,
|
||||
};
|
||||
|
||||
let body = to_raw(&response, &json!({}), true);
|
||||
let keys = body
|
||||
.as_object()
|
||||
.expect("Compact response should be an object")
|
||||
.keys()
|
||||
.map(String::as_str)
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
|
||||
assert_eq!(body["object"], "response.compaction");
|
||||
assert_eq!(body["created_at"], 123);
|
||||
assert_eq!(
|
||||
keys,
|
||||
std::collections::BTreeSet::from(["created_at", "id", "object", "output"])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn responses_response_parser_preserves_encrypted_reasoning_without_summary() {
|
||||
let body = json!({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use crate::contracts::{
|
||||
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND, OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND,
|
||||
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND, OPENAI_RESPONSES_COMPACT_SYNC_SUCCESS_REPORT_KIND,
|
||||
OPENAI_RESPONSES_STREAM_PLAN_KIND, OPENAI_RESPONSES_STREAM_SUCCESS_REPORT_KIND,
|
||||
OPENAI_RESPONSES_SYNC_PLAN_KIND, OPENAI_RESPONSES_SYNC_SUCCESS_REPORT_KIND,
|
||||
@@ -43,13 +42,6 @@ pub fn resolve_stream_spec(plan_kind: &str) -> Option<LocalOpenAiResponsesSpec>
|
||||
compact: false,
|
||||
require_streaming: true,
|
||||
}),
|
||||
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND => Some(LocalOpenAiResponsesSpec {
|
||||
api_format: "openai:responses:compact",
|
||||
decision_kind: OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND,
|
||||
report_kind: OPENAI_RESPONSES_COMPACT_STREAM_SUCCESS_REPORT_KIND,
|
||||
compact: true,
|
||||
require_streaming: true,
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -68,11 +60,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_openai_responses_compact_stream_spec() {
|
||||
let spec = resolve_stream_spec("openai_responses_compact_stream").expect("spec");
|
||||
assert_eq!(spec.api_format, "openai:responses:compact");
|
||||
assert_eq!(spec.report_kind, "openai_responses_compact_stream_success");
|
||||
assert!(spec.compact);
|
||||
assert!(spec.require_streaming);
|
||||
fn does_not_resolve_openai_responses_compact_as_streaming() {
|
||||
assert!(resolve_stream_spec("openai_responses_compact_stream").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user