feat(openai): align GPT-5.6 and Codex request contracts

This commit is contained in:
MMEXA
2026-07-11 07:40:12 +08:00
parent bc1da3bf3f
commit dfa121dd5b
178 changed files with 17947 additions and 3541 deletions
+38 -9
View File
@@ -61,6 +61,18 @@ pub use crate::formats::openai::shared::{
map_openai_reasoning_effort_to_claude_output, map_openai_reasoning_effort_to_gemini_budget,
parse_openai_stop_sequences, resolve_openai_chat_max_tokens, value_as_u64,
};
pub use crate::formats::openai::{
reasoning::{
validate_openai_reasoning_request, OpenAiReasoningContractViolation,
OpenAiReasoningViolationKind,
},
request_contract::{
finalize_openai_provider_request,
finalize_openai_provider_request_with_codex_model_capabilities,
validate_openai_provider_request_contract, OpenAiProviderRequestContractViolation,
OpenAiProviderRequestFinalization,
},
};
pub use crate::formats::shared::error_body::{
build_core_error_body_for_client_format, is_core_error_finalize_kind, LocalCoreSyncErrorKind,
};
@@ -78,9 +90,16 @@ pub use crate::formats::shared::image_bridge::{
pub use crate::formats::shared::model_directives::{
apply_model_directive_mapping_patch, apply_model_directive_overrides_from_model,
apply_model_directive_overrides_from_request, claude_model_uses_adaptive_effort,
extract_gemini_model_from_path, gemini_model_uses_thinking_level, model_directive_base_model,
normalize_model_directive_model, parse_model_directive, ModelDirective, ModelOverride,
ReasoningEffort, ServiceTier,
default_model_directive_mapping_patch, default_model_directive_suffixes,
default_model_directives_config, extract_gemini_model_from_path,
gemini_model_uses_thinking_level, model_directive_base_model,
model_directive_builtin_suffix_supported_for_source_model,
model_directive_suffix_has_builtin_mapping, normalize_model_directive_model,
openai_model_supports_prompt_cache_options, parse_model_directive,
parse_model_directive_with_suffixes, reasoning_effort_supported_for_model, ModelDirective,
ModelDirectiveSuffixResolution, ModelOverride, ReasoningEffort, ServiceTier,
CROSS_PROVIDER_MODEL_DIRECTIVE_SUFFIXES, MODEL_DIRECTIVE_API_FORMATS,
OPENAI_MODEL_DIRECTIVE_SUFFIXES,
};
pub use crate::formats::shared::passthrough::{
resolve_stream_spec as resolve_local_same_format_stream_spec,
@@ -89,7 +108,8 @@ pub use crate::formats::shared::passthrough::{
};
pub use crate::formats::shared::request::{
endpoint_config_forces_upstream_stream_policy, enforce_request_body_stream_field,
force_upstream_streaming_for_provider, parse_direct_request_body,
forbid_upstream_streaming_for_provider, force_upstream_streaming_for_provider,
parse_direct_request_body, resolve_upstream_is_stream_for_provider,
resolve_upstream_is_stream_from_endpoint_config,
};
pub use crate::formats::shared::request_matrix::{
@@ -149,11 +169,17 @@ pub use crate::formats::{
embedding::spec::resolve_sync_spec as resolve_openai_embedding_sync_spec,
responses::{
codex::{
apply_codex_openai_compact_terminal_headers,
apply_codex_openai_responses_chat_body_edits,
apply_codex_openai_responses_lite_header_with_capabilities,
apply_codex_openai_responses_special_body_edits,
apply_codex_openai_responses_special_headers,
apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities,
apply_codex_openai_special_headers,
apply_openai_responses_compact_special_body_edits,
CODEX_OPENAI_IMAGE_DEFAULT_MODEL, CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT,
build_codex_model_catalog_metadata, parse_codex_auth_identity,
resolve_codex_responses_model_capabilities, CodexAuthIdentity,
CodexResponsesModelCapabilities, CODEX_OPENAI_IMAGE_DEFAULT_MODEL,
CODEX_OPENAI_IMAGE_DEFAULT_OUTPUT_FORMAT,
CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_MODEL,
CODEX_OPENAI_IMAGE_DEFAULT_VARIATION_PROMPT, CODEX_OPENAI_IMAGE_INTERNAL_MODEL,
},
@@ -189,10 +215,13 @@ pub use crate::formats::{
},
openai::image::{
request::{
build_chatgpt_web_image_request_body, build_openai_image_api_provider_request_body,
build_openai_image_provider_request_body, default_model_for_openai_image_operation,
is_openai_image_stream_request, normalize_openai_image_request,
build_chatgpt_web_image_request_body,
build_codex_openai_image_api_provider_request_body,
build_openai_image_api_provider_request_body, build_openai_image_provider_request_body,
default_model_for_openai_image_operation, is_openai_image_stream_request,
normalize_openai_image_quality, normalize_openai_image_request,
normalize_openai_image_request_with_options, openai_image_operation_from_path,
project_codex_openai_image_api_request_body, project_openai_image_api_request_body,
resolve_requested_openai_image_model_for_request, ChatGptWebImageRequestError,
NormalizedOpenAiImageRequest, OpenAiImageNormalizeOptions, OpenAiImageOperation,
OpenAiImageResponseFormat,
@@ -704,6 +704,11 @@ impl ClaudeClientEmitter {
};
self.emit_content_part(part)
}
CanonicalStreamEvent::OpenAiResponsesOutputItem { .. } => Err(
AiSurfaceFinalizeError::new(
"OpenAI Responses output items cannot be converted losslessly to Claude Messages",
),
),
CanonicalStreamEvent::ToolCallStart {
index,
call_id,
@@ -221,7 +221,7 @@ mod tests {
}
#[test]
fn claude_request_to_chat_maps_max_reasoning_effort_to_xhigh() {
fn claude_request_to_chat_preserves_max_reasoning_effort() {
let body = json!({
"model": "claude-sonnet",
"messages": [{"role": "user", "content": "hello"}],
@@ -233,7 +233,7 @@ mod tests {
let converted =
normalize_claude_request_to_openai_chat_request(&body).expect("openai chat request");
assert_eq!(converted["reasoning_effort"], "xhigh");
assert_eq!(converted["reasoning_effort"], "max");
}
#[test]
@@ -289,11 +289,6 @@ mod tests {
"name": "mcp__mapsWeather",
"arguments": "{\"city\":\"Hangzhou\"}"
},
{
"type": "web_search_call",
"id": "ignored_web_search",
"action": {"query": "should be skipped"}
},
{
"type": "function_call",
"call_id": call_id_two,
@@ -395,7 +390,7 @@ mod tests {
assert_eq!(converted["prompt_cache_key"], "cache_123");
assert_eq!(converted["safety_identifier"], "user_123");
assert!(converted.get("include").is_none());
assert!(converted.get("store").is_none());
assert_eq!(converted["store"], false);
assert!(converted.get("text").is_none());
assert!(converted.get("reasoning").is_none());
}
@@ -880,7 +875,7 @@ mod tests {
}
#[test]
fn openai_responses_request_normalizer_strips_content_cache_control() {
fn openai_responses_same_format_preserves_content_extensions() {
let body = json!({
"model": "gpt-5.1",
"input": [{
@@ -904,7 +899,10 @@ mod tests {
.expect("responses request");
assert_eq!(converted["prompt_cache_key"], "cache_123");
assert!(!converted["input"].to_string().contains("cache_control"));
assert_eq!(
converted["input"][0]["content"][0]["cache_control"],
json!({"type": "ephemeral"})
);
}
#[test]
@@ -925,7 +923,7 @@ mod tests {
)
.expect("responses request");
assert_eq!(converted["reasoning"]["effort"], "xhigh");
assert_eq!(converted["reasoning"]["effort"], "max");
assert_eq!(converted["reasoning"]["summary"], "auto");
}
@@ -490,6 +490,11 @@ impl GeminiClientEmitter {
None,
)
}
CanonicalStreamEvent::OpenAiResponsesOutputItem { .. } => Err(
AiSurfaceFinalizeError::new(
"OpenAI Responses output items cannot be converted losslessly to Gemini GenerateContent",
),
),
CanonicalStreamEvent::ToolCallStart {
index,
call_id,
@@ -2,7 +2,6 @@ use serde_json::{json, Map, Value};
use crate::{
formats::context::FormatContext,
formats::openai::shared::OpenAiChatReasoningEffort,
protocol::canonical::{
canonical_extension_object_mut, canonical_message_to_openai_chat_messages,
canonical_response_format_to_openai, canonical_tool_choice_to_openai,
@@ -367,11 +366,8 @@ fn non_empty_source_str<'a>(source: &'a Map<String, Value>, key: &str) -> Option
.filter(|value| !value.trim().is_empty())
}
fn openai_chat_reasoning_effort(value: &str) -> Option<&'static str> {
if value.trim().eq_ignore_ascii_case("max") {
return Some("xhigh");
}
OpenAiChatReasoningEffort::parse(value).map(OpenAiChatReasoningEffort::as_str)
fn openai_chat_reasoning_effort(value: &str) -> Option<&str> {
(!value.trim().is_empty()).then_some(value)
}
fn chat_compatible_openai_responses_extension_object(
@@ -384,7 +380,14 @@ fn chat_compatible_openai_responses_extension_object(
.filter(|(key, _)| {
matches!(
key.as_str(),
"verbosity" | "service_tier" | "prompt_cache_key" | "safety_identifier" | "user"
"verbosity"
| "store"
| "service_tier"
| "prompt_cache_key"
| "prompt_cache_options"
| "prompt_cache_retention"
| "safety_identifier"
| "user"
)
})
.collect()
@@ -55,6 +55,7 @@ pub struct OpenAIResponsesProviderState {
tool_results: BTreeMap<usize, OpenAIResponsesProviderToolResultState>,
tool_index_by_key: BTreeMap<String, usize>,
image_item_keys: BTreeSet<String>,
opaque_completed_item_keys: BTreeSet<String>,
last_tool_index: Option<usize>,
}
@@ -655,6 +656,14 @@ impl OpenAIResponsesProviderState {
if item.get("type").and_then(Value::as_str) != Some("function_call") {
return;
}
const MAPPED_FIELDS: &[&str] = &["type", "id", "call_id", "status", "name", "arguments"];
if item
.keys()
.any(|field| !MAPPED_FIELDS.contains(&field.as_str()))
{
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
return;
}
self.ensure_started(report_context, out);
let key = item
.get("call_id")
@@ -1048,6 +1057,20 @@ impl OpenAIResponsesProviderState {
});
}
fn output_item_key(item: &Map<String, Value>) -> String {
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
return format!("{item_type}:id:{item_id}");
}
if let Some(encrypted_content) = item.get("encrypted_content").and_then(Value::as_str) {
return format!("{item_type}:encrypted_content:{encrypted_content}");
}
format!(
"{item_type}:{}",
serde_json::to_string(item).unwrap_or_default()
)
}
fn emit_output_item(
&mut self,
report_context: &Value,
@@ -1055,23 +1078,31 @@ impl OpenAIResponsesProviderState {
item: &Map<String, Value>,
output_index: Option<usize>,
final_item: bool,
) {
) -> bool {
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
"function_call" => self.emit_tool_call_item(report_context, out, item, output_index),
"function_call" => {
self.emit_tool_call_item(report_context, out, item, output_index);
true
}
"function_call_output" => {
self.emit_tool_result_item(report_context, out, item, output_index);
true
}
"custom_tool_call" => {
self.emit_custom_tool_call_item(report_context, out, item, output_index);
true
}
"local_shell_call" | "shell_call" => {
self.emit_shell_tool_call_item(report_context, out, item, output_index);
true
}
"apply_patch_call" => {
self.emit_apply_patch_tool_call_item(report_context, out, item, output_index);
true
}
"computer_call" => {
self.emit_computer_tool_call_item(report_context, out, item, output_index);
true
}
"custom_tool_call_output"
| "local_shell_call_output"
@@ -1079,10 +1110,20 @@ impl OpenAIResponsesProviderState {
| "apply_patch_call_output"
| "computer_call_output" => {
self.emit_generic_tool_result_item(report_context, out, item, output_index);
true
}
"message" => {
self.emit_message_item(report_context, out, item, output_index);
true
}
"reasoning" if final_item => {
self.emit_reasoning_item(report_context, out, item);
true
}
"reasoning" => {
self.ensure_started(report_context, out);
true
}
"message" => self.emit_message_item(report_context, out, item, output_index),
"reasoning" if final_item => self.emit_reasoning_item(report_context, out, item),
"reasoning" => self.ensure_started(report_context, out),
"image_generation_call" => {
self.emit_image_generation_item(
report_context,
@@ -1091,16 +1132,57 @@ impl OpenAIResponsesProviderState {
output_index,
final_item,
);
true
}
"web_search_call" | "file_search_call" | "code_interpreter_call" | "mcp_call" => {
if !final_item {
self.ensure_started(report_context, out);
}
true
}
_ => out.push(self.unknown_frame(report_context, Value::Object(item.clone()))),
_ => false,
}
}
fn emit_output_item_event(
&mut self,
report_context: &Value,
out: &mut Vec<CanonicalStreamFrame>,
raw_event: &Value,
item: &Map<String, Value>,
output_index: Option<usize>,
final_item: bool,
) {
if self.emit_output_item(report_context, out, item, output_index, final_item) {
return;
}
if item
.get("type")
.and_then(Value::as_str)
.is_none_or(str::is_empty)
{
out.push(self.unknown_frame(report_context, raw_event.clone()));
return;
}
if final_item {
self.opaque_completed_item_keys
.insert(Self::output_item_key(item));
}
self.ensure_started(report_context, out);
let (id, model) = self.identity(report_context);
out.push(CanonicalStreamFrame {
id,
model,
event: CanonicalStreamEvent::OpenAiResponsesOutputItem {
output_index,
item: Value::Object(item.clone()),
raw_event: raw_event.clone(),
},
});
}
fn emit_response_output_items(
&mut self,
report_context: &Value,
@@ -1117,7 +1199,13 @@ impl OpenAIResponsesProviderState {
let Some(item) = raw_item.as_object() else {
continue;
};
self.emit_output_item(report_context, out, item, Some(output_index), true);
if !self.emit_output_item(report_context, out, item, Some(output_index), true)
&& !self
.opaque_completed_item_keys
.contains(&Self::output_item_key(item))
{
out.push(self.unknown_frame(report_context, Value::Object(item.clone())));
}
}
}
@@ -1326,7 +1414,14 @@ impl OpenAIResponsesProviderState {
.get("output_index")
.and_then(Value::as_u64)
.map(|value| value as usize);
self.emit_output_item(report_context, &mut out, item, output_index, false);
self.emit_output_item_event(
report_context,
&mut out,
&value,
item,
output_index,
false,
);
}
"response.custom_tool_call_input.delta" => {
let delta = value
@@ -1578,7 +1673,14 @@ impl OpenAIResponsesProviderState {
.get("output_index")
.and_then(Value::as_u64)
.map(|value| value as usize);
self.emit_output_item(report_context, &mut out, item, output_index, true);
self.emit_output_item_event(
report_context,
&mut out,
&value,
item,
output_index,
true,
);
}
"response.incomplete" => {
let Some(response) = value.get("response").and_then(Value::as_object) else {
@@ -1744,6 +1846,8 @@ pub struct OpenAIResponsesClientEmitter {
tool_calls: BTreeMap<usize, OpenAIResponsesClientToolState>,
tool_results: BTreeMap<usize, OpenAIResponsesClientToolResultState>,
image_generation_items: BTreeMap<usize, Value>,
opaque_output_items: BTreeMap<usize, Value>,
opaque_output_indexes: BTreeMap<String, usize>,
}
impl OpenAIChatClientEmitter {
@@ -1881,6 +1985,11 @@ impl OpenAIChatClientEmitter {
)?);
Ok(out)
}
CanonicalStreamEvent::OpenAiResponsesOutputItem { .. } => {
Err(AiSurfaceFinalizeError::new(
"OpenAI Responses output items cannot be converted losslessly to OpenAI Chat",
))
}
CanonicalStreamEvent::ToolCallStart {
index,
call_id,
@@ -2682,6 +2791,9 @@ impl OpenAIResponsesClientEmitter {
for (output_index, item) in &self.image_generation_items {
ordered_output.push((*output_index, item.clone()));
}
for (output_index, item) in &self.opaque_output_items {
ordered_output.push((*output_index, item.clone()));
}
ordered_output.sort_by_key(|(output_index, _)| *output_index);
let mut response = json!({
@@ -2715,6 +2827,61 @@ impl OpenAIResponsesClientEmitter {
self.terminal_response(usage, "incomplete", Some(reason))
}
pub(crate) fn finish_with_authoritative_response_event(
&mut self,
response: Value,
event_type: &'static str,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
if self.finished {
return Ok(Vec::new());
}
if !matches!(
event_type,
"response.completed" | "response.incomplete" | "response.failed"
) {
return Err(AiSurfaceFinalizeError::new(format!(
"unsupported authoritative OpenAI Responses terminal event: {event_type}"
)));
}
let response_object = response.as_object().ok_or_else(|| {
AiSurfaceFinalizeError::new(
"authoritative OpenAI Responses terminal payload must be an object",
)
})?;
if let Some(response_id) = response_object
.get("id")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
{
self.response_id = Some(response_id.to_string());
}
if let Some(model) = response_object
.get("model")
.and_then(Value::as_str)
.filter(|value| !value.is_empty())
{
self.model = Some(model.to_string());
}
if let Some(created_at) = response_object.get("created_at").and_then(Value::as_i64) {
self.created_at = Some(created_at);
}
let mut out = self.ensure_started()?;
out.extend(self.finish_reasoning_item()?);
out.extend(self.finish_text_item()?);
out.extend(self.finish_tool_items()?);
out.extend(self.finish_tool_result_items()?);
out.extend(self.encode_response_event(
event_type,
json!({
"type": event_type,
"response": response,
}),
)?);
self.finished = true;
Ok(out)
}
pub fn emit(&mut self, frame: CanonicalStreamFrame) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
self.update_identity(&frame);
match frame.event {
@@ -2814,6 +2981,50 @@ impl OpenAIResponsesClientEmitter {
CanonicalStreamEvent::ImageGenerationCall { index, item } => {
self.emit_image_generation_call_item(index, item)
}
CanonicalStreamEvent::OpenAiResponsesOutputItem {
output_index,
item,
raw_event,
} => {
let event_type = raw_event
.get("type")
.and_then(Value::as_str)
.filter(|value| {
matches!(
*value,
"response.output_item.added" | "response.output_item.done"
)
})
.ok_or_else(|| {
AiSurfaceFinalizeError::new(
"OpenAI Responses output item event must be added or done",
)
})?
.to_string();
let item_key = item
.as_object()
.map(OpenAIResponsesProviderState::output_item_key)
.unwrap_or_else(|| item.to_string());
let materialized_output_index = if let Some(output_index) = output_index {
self.next_output_index =
self.next_output_index.max(output_index.saturating_add(1));
self.opaque_output_indexes.insert(item_key, output_index);
output_index
} else if let Some(output_index) = self.opaque_output_indexes.get(&item_key) {
*output_index
} else {
let output_index = self.allocate_output_index();
self.opaque_output_indexes.insert(item_key, output_index);
output_index
};
let mut out = self.ensure_started()?;
if event_type == "response.output_item.done" {
self.opaque_output_items
.insert(materialized_output_index, item);
}
out.extend(encode_json_sse(Some(event_type.as_str()), &raw_event)?);
Ok(out)
}
CanonicalStreamEvent::ToolCallStart {
index,
call_id,
@@ -3362,6 +3573,93 @@ mod tests {
)));
}
#[test]
fn openai_responses_provider_state_recognizes_compaction_output_without_index() {
let mut state = OpenAIResponsesProviderState::default();
let report_context = json!({});
let raw_event = json!({
"type": "response.output_item.done",
"item": {
"type": "compaction",
"encrypted_content": "ENCRYPTED_CONTEXT_COMPACTION_SUMMARY"
}
});
let frames = state
.push_line(&report_context, data_line(raw_event.clone()))
.expect("compaction output item should parse");
assert!(frames.iter().any(|frame| matches!(
&frame.event,
CanonicalStreamEvent::OpenAiResponsesOutputItem {
output_index: None,
item,
raw_event: emitted_event,
} if item["type"] == "compaction" && emitted_event == &raw_event
)));
assert!(!frames
.iter()
.any(|frame| matches!(frame.event, CanonicalStreamEvent::UnknownEvent(_))));
let terminal = state
.push_line(
&report_context,
data_line(json!({
"type": "response.completed",
"response": {
"id": "resp-compact",
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0
}
}
})),
)
.expect("completed response should parse");
assert!(terminal
.iter()
.any(|frame| matches!(frame.event, CanonicalStreamEvent::Finish { .. })));
assert!(!terminal
.iter()
.any(|frame| matches!(frame.event, CanonicalStreamEvent::UnknownEvent(_))));
}
#[test]
fn openai_responses_provider_state_rejects_function_call_provenance() {
let mut state = OpenAIResponsesProviderState::default();
let report_context = json!({});
let frames = state
.push_line(
&report_context,
data_line(json!({
"type": "response.output_item.added",
"response_id": "resp_ptc_123",
"output_index": 0,
"item": {
"type": "function_call",
"id": "fc_123",
"call_id": "call_123",
"status": "completed",
"name": "lookup",
"arguments": "{}",
"caller": {"type": "program", "id": "program_123"}
}
})),
)
.expect("function call event should parse");
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::UnknownEvent(ref payload)
if payload.get("caller").is_some()
)));
assert!(!frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolCallStart { .. }
| CanonicalStreamEvent::ToolCallArgumentsDelta { .. }
)));
}
#[test]
fn openai_responses_provider_state_treats_failed_event_as_terminal() {
let mut state = OpenAIResponsesProviderState::default();
@@ -3489,6 +3787,7 @@ mod tests {
"input_tokens": 26,
"input_tokens_details": {
"cached_tokens": 0,
"cache_write_tokens": 6,
},
"output_tokens": 137,
"output_tokens_details": {
@@ -3508,6 +3807,7 @@ mod tests {
usage: Some(CanonicalUsage {
input_tokens: 26,
output_tokens: 137,
cache_creation_tokens: 6,
cache_read_tokens: 0,
..
}),
@@ -4560,6 +4860,7 @@ mod tests {
assert!(sse.contains("\"completion_tokens\":2"));
assert!(sse.contains("\"completion_tokens_details\":{\"reasoning_tokens\":1}"));
assert!(sse.contains("\"cache_write_tokens\":5"));
assert!(!sse.contains("\"cached_creation_tokens\""));
assert!(sse.contains("\"cached_tokens\":4"));
assert!(sse.contains("\"total_tokens\":3"));
assert!(sse.contains("data: [DONE]\n\n"));
@@ -4684,6 +4985,7 @@ mod tests {
assert!(sse.contains("\"output_tokens_details\":{\"reasoning_tokens\":1}"));
assert!(sse.contains("\"input_tokens_details\""));
assert!(sse.contains("\"cache_write_tokens\":5"));
assert!(!sse.contains("\"cached_creation_tokens\""));
assert!(sse.contains("\"cached_tokens\":4"));
}
File diff suppressed because it is too large Load Diff
@@ -742,6 +742,7 @@ impl OpenAiImageStreamTerminalState {
.model
.clone()
.or_else(|| image_bridge_model(Some(report_context))),
provider_actual_service_tier: None,
observed_finish: self.observed_finish,
unknown_event_count: 0,
parser_error: self.parser_error.clone(),
@@ -856,6 +857,7 @@ fn openai_image_usage_to_standardized_usage(value: &Value) -> Option<Standardize
details
.get("cache_write_tokens")
.or_else(|| details.get("cached_creation_tokens"))
.or_else(|| details.get("cache_creation_tokens"))
})
.and_then(Value::as_i64)
})
@@ -937,6 +939,7 @@ fn openai_image_chat_usage_counts(usage: Option<&Value>) -> Option<(u64, u64, u6
details
.get("cache_write_tokens")
.or_else(|| details.get("cached_creation_tokens"))
.or_else(|| details.get("cache_creation_tokens"))
})
.and_then(Value::as_u64)
})
@@ -1,6 +1,9 @@
pub mod chat;
pub mod embedding;
pub mod image;
pub mod prompt_cache;
pub mod reasoning;
pub mod request_contract;
pub mod rerank;
pub mod responses;
pub mod shared;
@@ -0,0 +1,582 @@
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenAiPromptCacheViolationKind {
InvalidType,
InvalidEnum,
UnsupportedForModel,
UnsupportedContentBlock,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenAiPromptCacheContractViolation {
pub kind: OpenAiPromptCacheViolationKind,
pub field: String,
pub value: Option<String>,
pub reason: String,
}
pub fn validate_openai_prompt_cache_request(
source_api_format: &str,
provider_model: &str,
body: &Value,
) -> Result<(), OpenAiPromptCacheContractViolation> {
let source_model = body
.get("model")
.and_then(Value::as_str)
.unwrap_or_default();
validate_openai_prompt_cache_request_with_source_model(
source_api_format,
provider_model,
source_model,
body,
)
}
pub(crate) fn validate_openai_prompt_cache_request_with_source_model(
source_api_format: &str,
provider_model: &str,
source_model: &str,
body: &Value,
) -> Result<(), OpenAiPromptCacheContractViolation> {
let Some(api) = OpenAiPromptCacheApi::parse(source_api_format) else {
return Ok(());
};
let Some(request) = body.as_object() else {
return Ok(());
};
let capability_model =
crate::formats::shared::model_directives::openai_model_capability_identity(
provider_model,
source_model,
);
let supports_prompt_cache_options =
crate::formats::shared::model_directives::openai_model_capability_is_opaque(
provider_model,
source_model,
) || crate::openai_model_supports_prompt_cache_options(&capability_model);
if let Some(options) = request
.get("prompt_cache_options")
.filter(|value| !value.is_null())
{
validate_prompt_cache_options(options, supports_prompt_cache_options)?;
}
if let Some(retention) = request
.get("prompt_cache_retention")
.filter(|value| !value.is_null())
{
validate_prompt_cache_retention(retention, &capability_model)?;
}
match api {
OpenAiPromptCacheApi::Chat => {
validate_chat_prompt_cache_breakpoints(request, supports_prompt_cache_options)
}
OpenAiPromptCacheApi::Responses => {
validate_responses_prompt_cache_breakpoints(request, supports_prompt_cache_options)
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum OpenAiPromptCacheApi {
Chat,
Responses,
}
impl OpenAiPromptCacheApi {
fn parse(api_format: &str) -> Option<Self> {
match crate::normalize_api_format_alias(api_format).as_str() {
"openai:chat" => Some(Self::Chat),
"openai:responses" | "openai:responses:compact" => Some(Self::Responses),
_ => None,
}
}
}
fn normalize_provider_model(model: &str) -> &str {
model.trim().rsplit('/').next().unwrap_or_default()
}
fn validate_prompt_cache_options(
value: &Value,
supported_for_model: bool,
) -> Result<(), OpenAiPromptCacheContractViolation> {
if !supported_for_model {
return Err(unsupported_for_model(
"prompt_cache_options",
"provider model does not support prompt_cache_options",
));
}
let Some(options) = value.as_object() else {
return Err(invalid_type(
"prompt_cache_options",
value,
"prompt_cache_options must be an object",
));
};
if let Some(mode) = options.get("mode") {
let Some(raw) = mode.as_str() else {
return Err(invalid_type(
"prompt_cache_options.mode",
mode,
"prompt_cache_options.mode must be a string",
));
};
if !matches!(raw, "implicit" | "explicit") {
return Err(invalid_enum(
"prompt_cache_options.mode",
raw,
"prompt_cache_options.mode supports implicit or explicit",
));
}
}
if let Some(ttl) = options.get("ttl") {
let Some(raw) = ttl.as_str() else {
return Err(invalid_type(
"prompt_cache_options.ttl",
ttl,
"prompt_cache_options.ttl must be a string",
));
};
if raw != "30m" {
return Err(invalid_enum(
"prompt_cache_options.ttl",
raw,
"prompt_cache_options.ttl supports 30m",
));
}
}
Ok(())
}
fn validate_prompt_cache_retention(
value: &Value,
provider_model: &str,
) -> Result<(), OpenAiPromptCacheContractViolation> {
if crate::openai_model_supports_prompt_cache_options(provider_model) {
return Err(unsupported_for_model(
"prompt_cache_retention",
"provider model uses prompt_cache_options.ttl",
));
}
let Some(raw) = value.as_str() else {
return Err(invalid_type(
"prompt_cache_retention",
value,
"prompt_cache_retention must be a string",
));
};
if super::shared::OpenAiPromptCacheRetention::parse(raw).is_none() {
return Err(invalid_enum(
"prompt_cache_retention",
raw,
"prompt_cache_retention supports in_memory or 24h",
));
}
if gpt_5_5_retention_is_24h_only(provider_model) && raw != "24h" {
return Err(invalid_enum(
"prompt_cache_retention",
raw,
"GPT-5.5 family models support 24h retention",
));
}
Ok(())
}
fn gpt_5_5_retention_is_24h_only(model: &str) -> bool {
let normalized = normalize_provider_model(model)
.to_ascii_lowercase()
.replace('_', "-");
matches!(normalized.as_str(), "gpt-5.5" | "gpt-5.5-pro")
}
fn validate_chat_prompt_cache_breakpoints(
request: &serde_json::Map<String, Value>,
supported_for_model: bool,
) -> Result<(), OpenAiPromptCacheContractViolation> {
let Some(messages) = request.get("messages").and_then(Value::as_array) else {
return Ok(());
};
for (message_index, message) in messages.iter().enumerate() {
let Some(content) = message
.as_object()
.and_then(|message| message.get("content"))
.and_then(Value::as_array)
else {
continue;
};
for (content_index, part) in content.iter().enumerate() {
let Some(part) = part.as_object() else {
continue;
};
let Some(breakpoint) = part.get("prompt_cache_breakpoint") else {
continue;
};
let block_type = part.get("type").and_then(Value::as_str);
let supported = matches!(
block_type,
Some("text" | "image_url" | "input_audio" | "file" | "refusal")
);
validate_prompt_cache_breakpoint(
breakpoint,
&format!(
"messages[{message_index}].content[{content_index}].prompt_cache_breakpoint"
),
supported,
supported_for_model,
)?;
}
}
Ok(())
}
fn validate_responses_prompt_cache_breakpoints(
request: &serde_json::Map<String, Value>,
supported_for_model: bool,
) -> Result<(), OpenAiPromptCacheContractViolation> {
let Some(input) = request.get("input").and_then(Value::as_array) else {
return Ok(());
};
for (item_index, item) in input.iter().enumerate() {
let Some(item) = item.as_object() else {
continue;
};
if let Some(breakpoint) = item.get("prompt_cache_breakpoint") {
validate_prompt_cache_breakpoint(
breakpoint,
&format!("input[{item_index}].prompt_cache_breakpoint"),
responses_cache_breakpoint_block_is_supported(item),
supported_for_model,
)?;
}
let Some(content) = item.get("content").and_then(Value::as_array) else {
continue;
};
for (content_index, part) in content.iter().enumerate() {
let Some(part) = part.as_object() else {
continue;
};
let Some(breakpoint) = part.get("prompt_cache_breakpoint") else {
continue;
};
validate_prompt_cache_breakpoint(
breakpoint,
&format!("input[{item_index}].content[{content_index}].prompt_cache_breakpoint"),
responses_cache_breakpoint_block_is_supported(part),
supported_for_model,
)?;
}
}
Ok(())
}
fn responses_cache_breakpoint_block_is_supported(block: &serde_json::Map<String, Value>) -> bool {
matches!(
block.get("type").and_then(Value::as_str),
Some("input_text" | "input_image" | "input_file")
)
}
fn validate_prompt_cache_breakpoint(
value: &Value,
field: &str,
supported_content_block: bool,
supported_for_model: bool,
) -> Result<(), OpenAiPromptCacheContractViolation> {
if !supported_for_model {
return Err(unsupported_for_model(
field,
"provider model does not support prompt_cache_breakpoint",
));
}
if !supported_content_block {
return Err(OpenAiPromptCacheContractViolation {
kind: OpenAiPromptCacheViolationKind::UnsupportedContentBlock,
field: field.to_string(),
value: None,
reason: "content block does not support prompt_cache_breakpoint".to_string(),
});
}
let Some(breakpoint) = value.as_object() else {
return Err(invalid_type(
field,
value,
"prompt_cache_breakpoint must be an object",
));
};
let mode_field = format!("{field}.mode");
let Some(mode) = breakpoint.get("mode") else {
return Err(OpenAiPromptCacheContractViolation {
kind: OpenAiPromptCacheViolationKind::InvalidType,
field: mode_field,
value: None,
reason: "prompt_cache_breakpoint.mode is required".to_string(),
});
};
let Some(raw) = mode.as_str() else {
return Err(invalid_type(
&mode_field,
mode,
"prompt_cache_breakpoint.mode must be a string",
));
};
if raw != "explicit" {
return Err(invalid_enum(
&mode_field,
raw,
"prompt_cache_breakpoint.mode supports explicit",
));
}
Ok(())
}
fn invalid_type(field: &str, value: &Value, reason: &str) -> OpenAiPromptCacheContractViolation {
OpenAiPromptCacheContractViolation {
kind: OpenAiPromptCacheViolationKind::InvalidType,
field: field.to_string(),
value: Some(value.to_string()),
reason: reason.to_string(),
}
}
fn invalid_enum(field: &str, value: &str, reason: &str) -> OpenAiPromptCacheContractViolation {
OpenAiPromptCacheContractViolation {
kind: OpenAiPromptCacheViolationKind::InvalidEnum,
field: field.to_string(),
value: Some(value.to_string()),
reason: reason.to_string(),
}
}
fn unsupported_for_model(field: &str, reason: &str) -> OpenAiPromptCacheContractViolation {
OpenAiPromptCacheContractViolation {
kind: OpenAiPromptCacheViolationKind::UnsupportedForModel,
field: field.to_string(),
value: None,
reason: reason.to_string(),
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{validate_openai_prompt_cache_request, OpenAiPromptCacheViolationKind};
#[test]
fn gpt_5_6_accepts_current_prompt_cache_options_and_breakpoints() {
for (format, body) in [
(
"openai:chat",
json!({
"model": "client-alias",
"prompt_cache_options": {"mode": "implicit", "ttl": "30m"},
"messages": [{
"role": "user",
"content": [{
"type": "input_audio",
"input_audio": {"data": "ZmFrZQ==", "format": "mp3"},
"prompt_cache_breakpoint": {"mode": "explicit"}
}]
}]
}),
),
(
"openai:responses",
json!({
"model": "client-alias",
"prompt_cache_options": {"mode": "explicit", "ttl": "30m"},
"input": [{
"type": "message",
"role": "user",
"content": [{
"type": "input_file",
"file_id": "file_123",
"prompt_cache_breakpoint": {"mode": "explicit"}
}]
}]
}),
),
] {
validate_openai_prompt_cache_request(format, "gpt-5.6-sol", &body)
.expect("GPT-5.6 prompt cache contract should be accepted");
}
}
#[test]
fn gpt_5_6_uses_prompt_cache_options_and_rejects_invalid_enums() {
let retention_error = validate_openai_prompt_cache_request(
"openai:chat",
"gpt-5.6-sol",
&json!({"prompt_cache_retention": "24h"}),
)
.expect_err("GPT-5.6 uses prompt_cache_options.ttl");
assert_eq!(
retention_error.kind,
OpenAiPromptCacheViolationKind::UnsupportedForModel
);
let cases = [
(
json!({"prompt_cache_options": {"mode": "automatic"}}),
"prompt_cache_options.mode",
OpenAiPromptCacheViolationKind::InvalidEnum,
),
(
json!({"prompt_cache_options": {"ttl": "1h"}}),
"prompt_cache_options.ttl",
OpenAiPromptCacheViolationKind::InvalidEnum,
),
(
json!({
"messages": [{
"role": "user",
"content": [{
"type": "text",
"text": "stable",
"prompt_cache_breakpoint": {"mode": "implicit"}
}]
}]
}),
"messages[0].content[0].prompt_cache_breakpoint.mode",
OpenAiPromptCacheViolationKind::InvalidEnum,
),
];
for (body, field, kind) in cases {
let error = validate_openai_prompt_cache_request("openai:chat", "gpt-5.6-sol", &body)
.expect_err("invalid GPT-5.6 cache contract should fail");
assert_eq!(error.field, field);
assert_eq!(error.kind, kind);
}
}
#[test]
fn prompt_cache_capability_uses_the_provider_model() {
let options = json!({
"model": "gpt-5.5",
"prompt_cache_options": {"mode": "explicit", "ttl": "30m"},
"messages": [{"role": "user", "content": "hello"}]
});
validate_openai_prompt_cache_request("openai:chat", "gpt-5.6-terra", &options)
.expect("mapped GPT-5.6 provider model should enable prompt_cache_options");
let error = validate_openai_prompt_cache_request("openai:chat", "gpt-5.5", &options)
.expect_err("mapped earlier provider model should reject prompt_cache_options");
assert_eq!(
error.kind,
OpenAiPromptCacheViolationKind::UnsupportedForModel
);
let error =
validate_openai_prompt_cache_request("openai:chat", "deployment-alias", &options)
.expect_err("opaque provider model must not inherit source model capability");
assert_eq!(
error.kind,
OpenAiPromptCacheViolationKind::UnsupportedForModel
);
let retention = json!({
"model": "gpt-5.6-sol",
"prompt_cache_retention": "24h",
"messages": [{"role": "user", "content": "hello"}]
});
validate_openai_prompt_cache_request("openai:chat", "gpt-5.5", &retention)
.expect("mapped earlier provider model should retain its retention contract");
let error = validate_openai_prompt_cache_request("openai:chat", "gpt-5.6-luna", &retention)
.expect_err("GPT-5.6 uses prompt_cache_options.ttl");
assert_eq!(
error.kind,
OpenAiPromptCacheViolationKind::UnsupportedForModel
);
}
#[test]
fn prompt_cache_breakpoints_validate_supported_blocks_per_api() {
let cases = [
(
"openai:chat",
json!({
"messages": [{
"role": "user",
"content": [{
"type": "video_url",
"video_url": {"url": "https://example.com/video.mp4"},
"prompt_cache_breakpoint": {"mode": "explicit"}
}]
}]
}),
"messages[0].content[0].prompt_cache_breakpoint",
),
(
"openai:responses",
json!({
"input": [{
"type": "message",
"role": "user",
"content": [{
"type": "input_audio",
"input_audio": {"data": "ZmFrZQ==", "format": "mp3"},
"prompt_cache_breakpoint": {"mode": "explicit"}
}]
}]
}),
"input[0].content[0].prompt_cache_breakpoint",
),
];
for (format, body, field) in cases {
let error = validate_openai_prompt_cache_request(format, "gpt-5.6-sol", &body)
.expect_err("unsupported cache breakpoint block should fail");
assert_eq!(error.field, field);
assert_eq!(
error.kind,
OpenAiPromptCacheViolationKind::UnsupportedContentBlock
);
}
}
#[test]
fn earlier_models_reject_breakpoint_options_and_validate_retention() {
let options = json!({"prompt_cache_options": {"mode": "explicit"}});
let error = validate_openai_prompt_cache_request("openai:responses", "gpt-5.5", &options)
.expect_err("earlier model should reject prompt_cache_options");
assert_eq!(
error.kind,
OpenAiPromptCacheViolationKind::UnsupportedForModel
);
validate_openai_prompt_cache_request(
"openai:responses",
"gpt-5.5-pro",
&json!({"prompt_cache_retention": "24h"}),
)
.expect("GPT-5.5 supports 24h retention");
let error = validate_openai_prompt_cache_request(
"openai:responses",
"gpt-5.5",
&json!({"prompt_cache_retention": "in_memory"}),
)
.expect_err("GPT-5.5 only supports 24h retention");
assert_eq!(error.kind, OpenAiPromptCacheViolationKind::InvalidEnum);
}
#[test]
fn nullable_prompt_cache_fields_are_treated_as_unconfigured() {
for format in [
"openai:chat",
"openai:responses",
"openai:responses:compact",
] {
validate_openai_prompt_cache_request(
format,
"gpt-5.6-sol",
&json!({
"model": "gpt-5.6-sol",
"prompt_cache_options": null,
"prompt_cache_retention": null
}),
)
.expect("nullable prompt cache fields should be omitted semantically");
}
}
}
@@ -0,0 +1,434 @@
use serde_json::Value;
use crate::formats::shared::model_directives::ReasoningEffort;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenAiReasoningViolationKind {
InvalidType,
InvalidEnum,
UnsupportedForModel,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OpenAiReasoningContractViolation {
pub kind: OpenAiReasoningViolationKind,
pub field: String,
pub value: Option<String>,
pub reason: String,
}
pub fn validate_openai_reasoning_request(
source_api_format: &str,
provider_api_format: &str,
provider_model: &str,
body: &Value,
) -> Result<(), OpenAiReasoningContractViolation> {
let source_model = body
.get("model")
.and_then(Value::as_str)
.unwrap_or_default();
validate_openai_reasoning_request_with_source_model(
source_api_format,
provider_api_format,
provider_model,
source_model,
body,
)
}
pub(crate) fn validate_openai_reasoning_request_with_source_model(
source_api_format: &str,
provider_api_format: &str,
provider_model: &str,
source_model: &str,
body: &Value,
) -> Result<(), OpenAiReasoningContractViolation> {
validate_openai_reasoning_request_with_model_profile(
source_api_format,
provider_api_format,
provider_model,
source_model,
body,
false,
None,
)
}
pub(crate) fn validate_openai_reasoning_request_with_model_profile(
source_api_format: &str,
provider_api_format: &str,
provider_model: &str,
source_model: &str,
body: &Value,
use_model_card_reasoning_contract: bool,
supports_reasoning_mode: Option<bool>,
) -> Result<(), OpenAiReasoningContractViolation> {
let Some(object) = body.as_object() else {
return Ok(());
};
let source_api_format = crate::normalize_api_format_alias(source_api_format);
let reasoning = match source_api_format.as_str() {
"openai:responses" | "openai:responses:compact" => match object.get("reasoning") {
Some(Value::Object(reasoning)) => Some(reasoning),
Some(Value::Null) => None,
Some(value) => {
return Err(OpenAiReasoningContractViolation {
kind: OpenAiReasoningViolationKind::InvalidType,
field: "reasoning".to_string(),
value: Some(value.to_string()),
reason: "reasoning must be an object".to_string(),
});
}
None => None,
},
"openai:chat" => None,
_ => return Ok(()),
};
let provider_model = provider_model.trim();
let provider_model = if provider_model.is_empty() {
source_model
} else {
provider_model
};
let effort = match source_api_format.as_str() {
"openai:chat" => object.get("reasoning_effort"),
"openai:responses" | "openai:responses:compact" => {
reasoning.and_then(|reasoning| reasoning.get("effort"))
}
_ => None,
};
if let Some(value) = effort.filter(|value| !value.is_null()) {
validate_reasoning_effort(
value,
source_api_format.as_str(),
provider_api_format,
provider_model,
source_model,
use_model_card_reasoning_contract,
)?;
}
if let Some(mode) = reasoning
.and_then(|reasoning| reasoning.get("mode"))
.filter(|value| !value.is_null())
{
validate_reasoning_mode(mode, provider_model, source_model, supports_reasoning_mode)?;
}
if let Some(context) = reasoning
.and_then(|reasoning| reasoning.get("context"))
.filter(|value| !value.is_null())
{
validate_reasoning_context(context)?;
}
Ok(())
}
fn validate_reasoning_effort(
value: &Value,
source_api_format: &str,
provider_api_format: &str,
provider_model: &str,
source_model: &str,
use_model_card_reasoning_contract: bool,
) -> Result<(), OpenAiReasoningContractViolation> {
let field = if source_api_format == "openai:chat" {
"reasoning_effort"
} else {
"reasoning.effort"
};
let Some(raw) = value.as_str() else {
return Err(OpenAiReasoningContractViolation {
kind: OpenAiReasoningViolationKind::InvalidType,
field: field.to_string(),
value: Some(value.to_string()),
reason: "reasoning effort must be a string".to_string(),
});
};
if raw.trim().is_empty() {
return Err(OpenAiReasoningContractViolation {
kind: OpenAiReasoningViolationKind::InvalidEnum,
field: field.to_string(),
value: Some(raw.to_string()),
reason: "reasoning effort must not be empty".to_string(),
});
}
if raw.trim() == "ultra" {
return Err(OpenAiReasoningContractViolation {
kind: OpenAiReasoningViolationKind::InvalidEnum,
field: field.to_string(),
value: Some(raw.to_string()),
reason: "ultra is a Codex client preset, not an OpenAI wire effort".to_string(),
});
}
if use_model_card_reasoning_contract {
return Ok(());
}
let Some(effort) = ReasoningEffort::parse(raw) else {
return Ok(());
};
if crate::reasoning_effort_supported_for_model(
provider_api_format,
provider_model,
source_model,
effort,
) {
return Ok(());
}
Err(OpenAiReasoningContractViolation {
kind: OpenAiReasoningViolationKind::UnsupportedForModel,
field: field.to_string(),
value: Some(raw.to_string()),
reason: "provider model does not support the requested reasoning effort".to_string(),
})
}
fn validate_reasoning_mode(
value: &Value,
provider_model: &str,
source_model: &str,
supports_reasoning_mode: Option<bool>,
) -> Result<(), OpenAiReasoningContractViolation> {
let Some(mode) = value.as_str() else {
return Err(OpenAiReasoningContractViolation {
kind: OpenAiReasoningViolationKind::InvalidType,
field: "reasoning.mode".to_string(),
value: Some(value.to_string()),
reason: "reasoning mode must be a string".to_string(),
});
};
if mode.trim().is_empty() {
return Err(OpenAiReasoningContractViolation {
kind: OpenAiReasoningViolationKind::InvalidEnum,
field: "reasoning.mode".to_string(),
value: Some(mode.to_string()),
reason: "reasoning mode must not be empty".to_string(),
});
}
if !matches!(mode, "standard" | "pro") {
return Err(OpenAiReasoningContractViolation {
kind: OpenAiReasoningViolationKind::InvalidEnum,
field: "reasoning.mode".to_string(),
value: Some(mode.to_string()),
reason: "reasoning mode supports standard or pro".to_string(),
});
}
let supported = supports_reasoning_mode.unwrap_or_else(|| {
crate::formats::shared::model_directives::openai_model_resolves_to_gpt_5_6(
provider_model,
source_model,
)
});
if supported {
return Ok(());
}
Err(OpenAiReasoningContractViolation {
kind: OpenAiReasoningViolationKind::UnsupportedForModel,
field: "reasoning.mode".to_string(),
value: Some(mode.to_string()),
reason: "provider model does not support reasoning mode".to_string(),
})
}
fn validate_reasoning_context(value: &Value) -> Result<(), OpenAiReasoningContractViolation> {
let Some(context) = value.as_str() else {
return Err(OpenAiReasoningContractViolation {
kind: OpenAiReasoningViolationKind::InvalidType,
field: "reasoning.context".to_string(),
value: Some(value.to_string()),
reason: "reasoning context must be a string".to_string(),
});
};
if matches!(context, "auto" | "current_turn" | "all_turns") {
return Ok(());
}
Err(OpenAiReasoningContractViolation {
kind: OpenAiReasoningViolationKind::InvalidEnum,
field: "reasoning.context".to_string(),
value: Some(context.to_string()),
reason: "reasoning context is not a supported wire value".to_string(),
})
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{validate_openai_reasoning_request, OpenAiReasoningViolationKind};
#[test]
fn mapped_model_is_authoritative_for_openai_reasoning_effort() {
let alias = json!({
"model": "deployment-alias",
"reasoning": {"effort": "max"}
});
validate_openai_reasoning_request(
"openai:responses",
"openai:responses",
"gpt-5.6-sol",
&alias,
)
.expect("GPT-5.6 should accept max");
let error = validate_openai_reasoning_request(
"openai:responses",
"openai:responses",
"gpt-5.4",
&alias,
)
.expect_err("GPT-5.4 should reject max");
assert_eq!(
error.kind,
OpenAiReasoningViolationKind::UnsupportedForModel
);
}
#[test]
fn gpt_5_6_rejects_known_unsupported_effort_and_preserves_custom_effort() {
let unsupported = json!({
"model": "gpt-5.6-terra",
"reasoning_effort": "minimal"
});
let error = validate_openai_reasoning_request(
"openai:chat",
"openai:chat",
"gpt-5.6-terra",
&unsupported,
)
.expect_err("known unsupported effort should be rejected");
assert_eq!(
error.kind,
OpenAiReasoningViolationKind::UnsupportedForModel
);
validate_openai_reasoning_request(
"openai:responses",
"openai:responses",
"gpt-5.6-terra",
&json!({
"model": "gpt-5.6-terra",
"reasoning": {"effort": "future"}
}),
)
.expect("model-advertised custom effort should pass through");
let ultra = validate_openai_reasoning_request(
"openai:responses",
"openai:responses",
"gpt-5.6-terra",
&json!({"reasoning": {"effort": "ultra"}}),
)
.expect_err("Codex local ultra preset should not enter the OpenAI wire contract");
assert_eq!(ultra.kind, OpenAiReasoningViolationKind::InvalidEnum);
let empty = validate_openai_reasoning_request(
"openai:responses",
"openai:responses",
"gpt-5.6-terra",
&json!({"reasoning": {"effort": " "}}),
)
.expect_err("empty reasoning effort should be rejected");
assert_eq!(empty.kind, OpenAiReasoningViolationKind::InvalidEnum);
}
#[test]
fn reasoning_mode_is_responses_only_and_requires_gpt_5_6() {
for mode in ["standard", "pro"] {
validate_openai_reasoning_request(
"openai:responses",
"openai:responses",
"gpt-5.6-sol",
&json!({"model": "deployment-alias", "reasoning": {"mode": mode}}),
)
.expect("GPT-5.6 should accept reasoning mode");
}
let unsupported = validate_openai_reasoning_request(
"openai:responses",
"openai:responses",
"gpt-5.4",
&json!({"reasoning": {"mode": "pro"}}),
)
.expect_err("earlier GPT models should reject reasoning mode");
assert_eq!(
unsupported.kind,
OpenAiReasoningViolationKind::UnsupportedForModel
);
let invalid = validate_openai_reasoning_request(
"openai:responses",
"openai:responses",
"gpt-5.6-sol",
&json!({"reasoning": {"mode": "fast"}}),
)
.expect_err("unknown reasoning mode should be rejected");
assert_eq!(invalid.kind, OpenAiReasoningViolationKind::InvalidEnum);
let empty = validate_openai_reasoning_request(
"openai:responses",
"openai:responses",
"gpt-5.6-sol",
&json!({"reasoning": {"mode": ""}}),
)
.expect_err("empty reasoning mode should be rejected");
assert_eq!(empty.kind, OpenAiReasoningViolationKind::InvalidEnum);
validate_openai_reasoning_request(
"openai:chat",
"openai:chat",
"gpt-5.4",
&json!({"reasoning": {"mode": "pro"}}),
)
.expect("Chat Completions does not define reasoning.mode");
}
#[test]
fn reasoning_context_validates_wire_values_without_model_gating() {
for context in ["auto", "current_turn", "all_turns"] {
validate_openai_reasoning_request(
"openai:responses",
"openai:responses",
"gpt-5.4",
&json!({"reasoning": {"context": context}}),
)
.expect("reasoning context should remain available to Codex Responses models");
}
let invalid = validate_openai_reasoning_request(
"openai:responses",
"openai:responses",
"gpt-5.6-sol",
&json!({"reasoning": {"context": "session"}}),
)
.expect_err("unknown reasoning context should be rejected");
assert_eq!(invalid.kind, OpenAiReasoningViolationKind::InvalidEnum);
}
#[test]
fn nullable_reasoning_fields_are_treated_as_unconfigured() {
for body in [
json!({"model": "gpt-5.6-sol", "reasoning": null}),
json!({
"model": "gpt-5.6-sol",
"reasoning": {"effort": null, "mode": null, "context": null}
}),
] {
validate_openai_reasoning_request(
"openai:responses",
"openai:responses",
"gpt-5.6-sol",
&body,
)
.expect("nullable Responses reasoning fields should be omitted semantically");
}
validate_openai_reasoning_request(
"openai:chat",
"openai:chat",
"gpt-5.6-sol",
&json!({"model": "gpt-5.6-sol", "reasoning_effort": null}),
)
.expect("nullable Chat reasoning effort should be omitted semantically");
}
}
@@ -0,0 +1,662 @@
use serde_json::Value;
use super::prompt_cache::OpenAiPromptCacheContractViolation;
use super::reasoning::OpenAiReasoningContractViolation;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OpenAiProviderRequestContractViolation {
CodexCompact(super::responses::codex::CodexOpenAiCompactRequestContractViolation),
Responses(super::responses::request::OpenAiResponsesRequestContractViolation),
PromptCache(OpenAiPromptCacheContractViolation),
Reasoning(OpenAiReasoningContractViolation),
}
#[derive(Clone, Copy, Debug)]
pub struct OpenAiProviderRequestFinalization<'a> {
pub source_api_format: &'a str,
pub provider_api_format: &'a str,
pub provider_type: &'a str,
pub provider_model: &'a str,
pub source_model: &'a str,
pub body_rules: Option<&'a Value>,
pub upstream_is_stream: bool,
pub require_body_stream_field: bool,
}
pub fn finalize_openai_provider_request(
body: &mut Value,
finalization: OpenAiProviderRequestFinalization<'_>,
) -> Result<(), OpenAiProviderRequestContractViolation> {
finalize_openai_provider_request_with_codex_model_capabilities(body, finalization, None)
}
pub fn finalize_openai_provider_request_with_codex_model_capabilities(
body: &mut Value,
finalization: OpenAiProviderRequestFinalization<'_>,
model_capabilities: Option<&super::responses::codex::CodexResponsesModelCapabilities>,
) -> Result<(), OpenAiProviderRequestContractViolation> {
let is_codex_responses = finalization
.provider_type
.trim()
.eq_ignore_ascii_case("codex")
&& crate::is_openai_responses_family_format(finalization.provider_api_format);
let resolved_model_capabilities =
(is_codex_responses && model_capabilities.is_none()).then(|| {
super::responses::codex::resolve_codex_responses_model_capabilities(
finalization.provider_model,
finalization.source_model,
None,
)
});
let model_capabilities = is_codex_responses
.then(|| model_capabilities.or(resolved_model_capabilities.as_ref()))
.flatten();
match crate::normalize_api_format_alias(finalization.source_api_format).as_str() {
"openai:responses" | "openai:responses:compact" => {
super::responses::codex::apply_codex_openai_responses_special_body_edits_with_source_model_and_capabilities(
body,
finalization.provider_type,
finalization.provider_api_format,
finalization.provider_model,
finalization.source_model,
model_capabilities,
finalization.body_rules,
);
}
_ => {
super::responses::codex::apply_codex_openai_responses_chat_body_edits_with_source_model_and_capabilities(
body,
finalization.provider_type,
finalization.provider_api_format,
finalization.provider_model,
finalization.source_model,
model_capabilities,
finalization.body_rules,
)
}
}
super::responses::codex::apply_openai_responses_compact_special_body_edits(
body,
finalization.provider_api_format,
);
crate::enforce_request_body_stream_field(
body,
finalization.provider_api_format,
finalization.upstream_is_stream,
finalization.require_body_stream_field,
);
let provider_model = body
.get("model")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.unwrap_or(finalization.provider_model);
super::responses::codex::validate_codex_openai_responses_compact_request_contract(
body,
finalization.provider_type,
finalization.provider_api_format,
)
.map_err(OpenAiProviderRequestContractViolation::CodexCompact)?;
validate_openai_provider_request_contract_with_codex_model_capabilities(
finalization.provider_api_format,
provider_model,
finalization.source_model,
body,
model_capabilities,
)
}
pub fn validate_openai_provider_request_contract(
provider_api_format: &str,
provider_model: &str,
source_model: &str,
body: &Value,
) -> Result<(), OpenAiProviderRequestContractViolation> {
validate_openai_provider_request_contract_with_codex_model_capabilities(
provider_api_format,
provider_model,
source_model,
body,
None,
)
}
fn validate_openai_provider_request_contract_with_codex_model_capabilities(
provider_api_format: &str,
provider_model: &str,
source_model: &str,
body: &Value,
model_capabilities: Option<&super::responses::codex::CodexResponsesModelCapabilities>,
) -> Result<(), OpenAiProviderRequestContractViolation> {
super::responses::request::validate_openai_responses_request_contract(
body,
provider_api_format,
)
.map_err(OpenAiProviderRequestContractViolation::Responses)?;
super::prompt_cache::validate_openai_prompt_cache_request_with_source_model(
provider_api_format,
provider_model,
source_model,
body,
)
.map_err(OpenAiProviderRequestContractViolation::PromptCache)?;
super::reasoning::validate_openai_reasoning_request_with_model_profile(
provider_api_format,
provider_api_format,
provider_model,
source_model,
body,
model_capabilities.is_some(),
model_capabilities.map(|capabilities| capabilities.use_responses_lite),
)
.map_err(OpenAiProviderRequestContractViolation::Reasoning)
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
finalize_openai_provider_request,
finalize_openai_provider_request_with_codex_model_capabilities,
validate_openai_provider_request_contract, OpenAiProviderRequestFinalization,
};
use crate::CodexResponsesModelCapabilities;
#[test]
fn validates_reasoning_and_prompt_cache_against_the_final_provider_model() {
let body = json!({
"model": "gpt-5.6-sol",
"input": [],
"reasoning": {"effort": "max"},
"prompt_cache_options": {"mode": "explicit", "ttl": "30m"}
});
validate_openai_provider_request_contract(
"openai:responses",
"gpt-5.6-sol",
"gpt-5.6-sol",
&body,
)
.expect("GPT-5.6 request should satisfy the final provider contract");
assert!(validate_openai_provider_request_contract(
"openai:responses",
"gpt-5.4",
"gpt-5.6-sol",
&body,
)
.is_err());
}
#[test]
fn opaque_provider_models_inherit_source_capabilities_but_concrete_models_do_not() {
let body = json!({
"model": "azure-production",
"input": [],
"reasoning": {"effort": "max", "mode": "pro"},
"prompt_cache_options": {"mode": "explicit", "ttl": "30m"}
});
validate_openai_provider_request_contract(
"openai:responses",
"azure-production",
"gpt-5.6-sol-max",
&body,
)
.expect("opaque deployments should inherit the concrete source model capability");
assert!(validate_openai_provider_request_contract(
"openai:responses",
"gpt-5.4",
"gpt-5.6-sol",
&body,
)
.is_err());
}
#[test]
fn finalization_reapplies_codex_and_compact_projection_after_mutations() {
let mut body = json!({
"model": "gpt-5.6-sol",
"input": [],
"store": true,
"include": ["reasoning.encrypted_content"],
"client_metadata": {"source": "mapping"},
"stream": true,
"stream_options": {"include_usage": true},
"tool_choice": "auto",
"temperature": 0.5,
"previous_response_id": "resp_123"
});
finalize_openai_provider_request(
&mut body,
OpenAiProviderRequestFinalization {
source_api_format: "openai:responses",
provider_api_format: "openai:responses:compact",
provider_type: "codex",
provider_model: "gpt-5.6-sol",
source_model: "gpt-5.6-sol",
body_rules: None,
upstream_is_stream: false,
require_body_stream_field: true,
},
)
.expect("final Compact request should satisfy its provider contract");
for field in [
"store",
"include",
"client_metadata",
"stream",
"stream_options",
"tool_choice",
"temperature",
"previous_response_id",
] {
assert!(body.get(field).is_none(), "{field} must not reach Compact");
}
}
#[test]
fn non_responses_sources_receive_codex_responses_reasoning_defaults() {
for source_api_format in ["openai:chat", "claude:messages", "gemini:generate_content"] {
let mut body = json!({
"model": "gpt-5.6-sol",
"input": []
});
finalize_openai_provider_request(
&mut body,
OpenAiProviderRequestFinalization {
source_api_format,
provider_api_format: "openai:responses",
provider_type: "codex",
provider_model: "gpt-5.6-sol",
source_model: "gpt-5.6-sol",
body_rules: None,
upstream_is_stream: true,
require_body_stream_field: true,
},
)
.expect("Codex Responses request should satisfy the final provider contract");
assert_eq!(body["reasoning"]["effort"], "low");
assert!(body["reasoning"].get("summary").is_none());
}
}
#[test]
fn codex_ultra_preset_uses_max_for_every_codex_model_on_the_wire() {
let mut sol = json!({
"model": "gpt-5.6-sol",
"input": [],
"reasoning": {"effort": "ultra"}
});
let mut luna = json!({
"model": "gpt-5.6-luna",
"input": [],
"reasoning": {"effort": "ultra"}
});
let finalization_for = |model| OpenAiProviderRequestFinalization {
source_api_format: "openai:responses",
provider_api_format: "openai:responses",
provider_type: "codex",
provider_model: model,
source_model: model,
body_rules: None,
upstream_is_stream: true,
require_body_stream_field: true,
};
finalize_openai_provider_request(&mut sol, finalization_for("gpt-5.6-sol"))
.expect("Sol ultra preset should map to the OpenAI wire contract");
assert_eq!(sol["reasoning"]["effort"], "max");
finalize_openai_provider_request(&mut luna, finalization_for("gpt-5.6-luna"))
.expect("Luna ultra preset should map to the OpenAI wire contract");
assert_eq!(luna["reasoning"]["effort"], "max");
}
#[test]
fn dynamic_codex_card_controls_default_effort_mode_and_final_validation() {
let finalization = OpenAiProviderRequestFinalization {
source_api_format: "openai:responses",
provider_api_format: "openai:responses",
provider_type: "codex",
provider_model: "gpt-5.7-sol",
source_model: "gpt-5.7-sol",
body_rules: None,
upstream_is_stream: true,
require_body_stream_field: true,
};
let capabilities = CodexResponsesModelCapabilities {
use_responses_lite: true,
supports_reasoning_summaries: true,
default_reasoning_effort: Some("ultra".to_string()),
default_reasoning_summary: None,
supported_reasoning_efforts: vec!["max".to_string(), "ultra".to_string()],
supports_parallel_tool_calls: true,
support_verbosity: true,
default_verbosity: Some("low".to_string()),
supported_service_tiers: vec!["priority".to_string()],
};
let mut default_body = json!({"model": "gpt-5.7-sol", "input": []});
finalize_openai_provider_request_with_codex_model_capabilities(
&mut default_body,
finalization,
Some(&capabilities),
)
.expect("card default ultra should use the max wire effort");
assert_eq!(default_body["reasoning"]["effort"], "max");
let mut mode_body = json!({
"model": "gpt-5.7-sol",
"input": [],
"reasoning": {"effort": "max", "mode": "pro"}
});
finalize_openai_provider_request_with_codex_model_capabilities(
&mut mode_body,
finalization,
Some(&capabilities),
)
.expect("Responses Lite card should allow the current reasoning mode contract");
let ultra_only = CodexResponsesModelCapabilities {
supported_reasoning_efforts: vec!["ultra".to_string()],
..capabilities.clone()
};
let mut ultra_only_body = json!({
"model": "gpt-5.7-sol",
"input": [],
"reasoning": {"effort": "ultra"}
});
finalize_openai_provider_request_with_codex_model_capabilities(
&mut ultra_only_body,
finalization,
Some(&ultra_only),
)
.expect("Codex maps the Ultra preset to max without card-list wire validation");
assert_eq!(ultra_only_body["reasoning"]["effort"], "max");
}
#[test]
fn dynamic_codex_card_preserves_custom_reasoning_effort_case() {
let finalization = OpenAiProviderRequestFinalization {
source_api_format: "openai:responses",
provider_api_format: "openai:responses",
provider_type: "codex",
provider_model: "codex-custom",
source_model: "codex-custom",
body_rules: None,
upstream_is_stream: true,
require_body_stream_field: true,
};
let capabilities = CodexResponsesModelCapabilities {
use_responses_lite: false,
supports_reasoning_summaries: true,
default_reasoning_effort: Some("VendorEffortX".to_string()),
default_reasoning_summary: None,
supported_reasoning_efforts: vec!["VendorEffortX".to_string()],
supports_parallel_tool_calls: true,
support_verbosity: true,
default_verbosity: Some("low".to_string()),
supported_service_tiers: vec![],
};
let mut body = json!({"model": "codex-custom", "input": []});
finalize_openai_provider_request_with_codex_model_capabilities(
&mut body,
finalization,
Some(&capabilities),
)
.expect("custom card effort should remain exact");
assert_eq!(body["reasoning"]["effort"], "VendorEffortX");
let mut custom = json!({
"model": "codex-custom",
"input": [],
"reasoning": {"effort": "vendoreffortx"}
});
finalize_openai_provider_request_with_codex_model_capabilities(
&mut custom,
finalization,
Some(&capabilities),
)
.expect("Codex custom reasoning efforts should remain exact");
assert_eq!(custom["reasoning"]["effort"], "vendoreffortx");
let mut ultra = json!({
"model": "codex-custom",
"input": [],
"reasoning": {"effort": "ultra"}
});
finalize_openai_provider_request_with_codex_model_capabilities(
&mut ultra,
finalization,
Some(&capabilities),
)
.expect("ultra should use max on the OpenAI wire contract");
assert_eq!(ultra["reasoning"]["effort"], "max");
}
#[test]
fn gpt_5_6_sol_uses_the_responses_lite_request_contract() {
let mut body = json!({
"model": "gpt-5.6-sol",
"instructions": "Follow the project instructions.",
"input": [
{
"type": "message",
"role": "user",
"content": [{
"type": "input_image",
"image_url": "data:image/png;base64,aGVsbG8=",
"detail": "original"
}]
},
{
"type": "function_call_output",
"call_id": "call-1",
"output": [{
"type": "input_image",
"image_url": "data:image/png;base64,ZnVuY3Rpb24=",
"detail": "high"
}]
},
{
"type": "custom_tool_call_output",
"call_id": "call-2",
"output": [{
"type": "input_image",
"image_url": "data:image/png;base64,Y3VzdG9t",
"detail": "auto"
}]
}
],
"tools": [{
"type": "function",
"name": "lookup",
"parameters": {
"type": "object",
"properties": {
"detail": {"type": "string"}
}
}
}],
"parallel_tool_calls": true
});
let finalization = OpenAiProviderRequestFinalization {
source_api_format: "openai:responses",
provider_api_format: "openai:responses",
provider_type: "codex",
provider_model: "gpt-5.6-sol",
source_model: "gpt-5.6-sol",
body_rules: None,
upstream_is_stream: true,
require_body_stream_field: true,
};
finalize_openai_provider_request(&mut body, finalization)
.expect("GPT-5.6 Sol should satisfy the Responses Lite contract");
let first = body.clone();
finalize_openai_provider_request(&mut body, finalization)
.expect("Responses Lite finalization should be idempotent");
assert_eq!(body, first);
assert!(body.get("instructions").is_none());
assert!(body.get("tools").is_none());
assert_eq!(body["input"][0]["type"], "additional_tools");
assert_eq!(body["input"][0]["role"], "developer");
assert_eq!(body["input"][0]["tools"][0]["name"], "lookup");
assert_eq!(body["input"][1]["type"], "message");
assert_eq!(body["input"][1]["role"], "developer");
assert_eq!(
body["input"][1]["content"][0]["text"],
"Follow the project instructions."
);
assert!(body["input"][2]["content"][0].get("detail").is_none());
assert!(body["input"][3]["output"][0].get("detail").is_none());
assert!(body["input"][4]["output"][0].get("detail").is_none());
assert_eq!(
body["input"][0]["tools"][0]["parameters"]["properties"]["detail"]["type"],
"string"
);
assert_eq!(body["parallel_tool_calls"], false);
assert_eq!(body["reasoning"]["effort"], "low");
assert_eq!(body["reasoning"]["context"], "all_turns");
assert!(body["reasoning"].get("summary").is_none());
}
#[test]
fn opaque_codex_deployments_use_the_exact_source_model_card() {
let mut body = json!({
"model": "azure-production",
"instructions": "Use the configured tools.",
"input": [],
"tools": [],
"parallel_tool_calls": true
});
finalize_openai_provider_request(
&mut body,
OpenAiProviderRequestFinalization {
source_api_format: "openai:responses",
provider_api_format: "openai:responses:compact",
provider_type: "codex",
provider_model: "azure-production",
source_model: "gpt-5.6-terra",
body_rules: None,
upstream_is_stream: false,
require_body_stream_field: true,
},
)
.expect("opaque Codex deployment should use the exact source model card");
assert!(body.get("instructions").is_none());
assert!(body.get("tools").is_none());
assert_eq!(body["input"][0]["type"], "additional_tools");
assert_eq!(body["input"][1]["role"], "developer");
assert_eq!(body["parallel_tool_calls"], false);
assert_eq!(body["reasoning"]["effort"], "medium");
assert_eq!(body["reasoning"]["context"], "all_turns");
for field in body.as_object().expect("object").keys() {
assert!(
[
"model",
"input",
"instructions",
"tools",
"parallel_tool_calls",
"reasoning",
"service_tier",
"prompt_cache_key",
"text",
]
.contains(&field.as_str()),
"unexpected Compact field: {field}"
);
}
}
#[test]
fn gpt_5_4_keeps_the_standard_codex_responses_shape() {
let mut body = json!({
"model": "gpt-5.4",
"instructions": "Keep this top-level instruction.",
"input": [],
"tools": [{"type": "function", "name": "lookup", "parameters": {}}],
"parallel_tool_calls": true
});
finalize_openai_provider_request(
&mut body,
OpenAiProviderRequestFinalization {
source_api_format: "openai:responses",
provider_api_format: "openai:responses",
provider_type: "codex",
provider_model: "gpt-5.4",
source_model: "gpt-5.4",
body_rules: None,
upstream_is_stream: true,
require_body_stream_field: true,
},
)
.expect("GPT-5.4 should satisfy the standard Codex Responses contract");
assert_eq!(body["instructions"], "Keep this top-level instruction.");
assert_eq!(body["tools"][0]["name"], "lookup");
assert_eq!(body["parallel_tool_calls"], true);
assert_eq!(body["reasoning"]["effort"], "medium");
assert!(body["reasoning"].get("context").is_none());
assert!(body["reasoning"].get("summary").is_none());
}
#[test]
fn cross_format_compact_finalization_removes_post_conversion_fields() {
for source_api_format in ["claude:messages", "gemini:generate_content"] {
let mut body = json!({
"model": "gpt-5.6-sol",
"input": [],
"client_metadata": {"source": "mapping"},
"include": ["reasoning.encrypted_content"],
"store": true,
"stream": true,
"stream_options": {"include_usage": true},
"tool_choice": "auto",
"parallel_tool_calls": true,
"reasoning": {"effort": "future"},
"text": {"verbosity": "medium"},
"tools": [{"type": "function", "name": "lookup", "parameters": {}}]
});
finalize_openai_provider_request(
&mut body,
OpenAiProviderRequestFinalization {
source_api_format,
provider_api_format: "openai:responses:compact",
provider_type: "codex",
provider_model: "gpt-5.6-sol",
source_model: "gpt-5.6-sol",
body_rules: None,
upstream_is_stream: false,
require_body_stream_field: true,
},
)
.expect("cross-format Compact request should satisfy its final contract");
for field in [
"client_metadata",
"include",
"store",
"stream",
"stream_options",
"tool_choice",
] {
assert!(body.get(field).is_none(), "{field} must not reach Compact");
}
assert_eq!(body["parallel_tool_calls"], false);
assert_eq!(body["reasoning"]["effort"], "future");
assert_eq!(body["reasoning"]["context"], "all_turns");
assert_eq!(body["text"]["verbosity"], "medium");
assert!(body.get("tools").is_none());
assert_eq!(body["input"][0]["tools"][0]["name"], "lookup");
}
}
}
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());
}
}
@@ -12,6 +12,7 @@ macro_rules! define_openai_reasoning_effort {
Medium,
High,
XHigh,
Max,
}
impl $name {
@@ -23,6 +24,7 @@ macro_rules! define_openai_reasoning_effort {
"medium" => Some(Self::Medium),
"high" => Some(Self::High),
"xhigh" => Some(Self::XHigh),
"max" => Some(Self::Max),
_ => None,
}
}
@@ -35,6 +37,7 @@ macro_rules! define_openai_reasoning_effort {
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Max => "max",
}
}
}
@@ -44,6 +47,29 @@ macro_rules! define_openai_reasoning_effort {
define_openai_reasoning_effort!(OpenAiChatReasoningEffort);
define_openai_reasoning_effort!(OpenAiResponsesReasoningEffort);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OpenAiPromptCacheRetention {
InMemory,
TwentyFourHours,
}
impl OpenAiPromptCacheRetention {
pub fn parse(value: &str) -> Option<Self> {
match value.trim() {
"in_memory" => Some(Self::InMemory),
"24h" => Some(Self::TwentyFourHours),
_ => None,
}
}
pub fn as_str(self) -> &'static str {
match self {
Self::InMemory => "in_memory",
Self::TwentyFourHours => "24h",
}
}
}
#[deprecated(note = "use OpenAiChatReasoningEffort or OpenAiResponsesReasoningEffort")]
pub type OpenAiReasoningEffort = OpenAiChatReasoningEffort;
@@ -137,3 +163,36 @@ pub fn extract_openai_reasoning_effort(request: &Map<String, Value>) -> Option<S
.filter(|value| !value.is_empty())
.map(|value| value.to_ascii_lowercase())
}
#[cfg(test)]
mod tests {
use super::{
OpenAiChatReasoningEffort, OpenAiPromptCacheRetention, OpenAiResponsesReasoningEffort,
};
#[test]
fn openai_reasoning_effort_contract_includes_gpt_5_6_max() {
assert_eq!(
OpenAiChatReasoningEffort::parse("max").map(OpenAiChatReasoningEffort::as_str),
Some("max")
);
assert_eq!(
OpenAiResponsesReasoningEffort::parse("max")
.map(OpenAiResponsesReasoningEffort::as_str),
Some("max")
);
}
#[test]
fn prompt_cache_retention_uses_the_openai_wire_enum() {
assert_eq!(
OpenAiPromptCacheRetention::parse("in_memory").map(OpenAiPromptCacheRetention::as_str),
Some("in_memory")
);
assert_eq!(
OpenAiPromptCacheRetention::parse("24h").map(OpenAiPromptCacheRetention::as_str),
Some("24h")
);
assert_eq!(OpenAiPromptCacheRetention::parse("in-memory"), None);
}
}
File diff suppressed because it is too large Load Diff
@@ -14,6 +14,7 @@ pub struct OpenAiImageRequestForGemini {
pub struct GeminiImageRequestForOpenAi {
pub requested_model: String,
pub mapped_model: String,
pub operation: crate::formats::openai::image::request::OpenAiImageOperation,
pub body_json: Value,
pub summary_json: Value,
}
@@ -136,52 +137,53 @@ pub fn build_openai_image_request_body_from_gemini_image_request(
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("\n\n");
if !prompt.is_empty() {
content.insert(
0,
json!({
"type": "input_text",
"text": prompt,
}),
);
}
if content.is_empty() {
if prompt.is_empty() {
return None;
}
let action = if content.iter().any(|value| {
let operation = if content.iter().any(|value| {
value
.get("type")
.and_then(Value::as_str)
.is_some_and(|kind| kind == "input_image")
}) {
"edit"
crate::formats::openai::image::request::OpenAiImageOperation::Edit
} else {
"generate"
crate::formats::openai::image::request::OpenAiImageOperation::Generate
};
let body_json = json!({
"model": mapped_model,
"input": [{
"role": "user",
"content": content,
}],
"tools": [{
"type": "image_generation",
"action": action,
}],
"tool_choice": {
"type": "image_generation"
},
"stream": false,
});
let mut body = Map::new();
body.insert("model".to_string(), Value::String(mapped_model.to_string()));
body.insert("prompt".to_string(), Value::String(prompt));
if operation == crate::formats::openai::image::request::OpenAiImageOperation::Edit {
let images = content
.iter()
.filter(|value| value.get("type").and_then(Value::as_str) == Some("input_image"))
.map(|value| {
let image_url = value
.get("image_url")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())?;
Some(json!({ "image_url": image_url }))
})
.collect::<Option<Vec<_>>>()?;
if images.is_empty() {
return None;
}
crate::formats::openai::image::request::insert_standard_openai_image_inputs(
&mut body, images,
);
}
let body_json = Value::Object(body);
let summary_json = json!({
"operation": action,
"operation": operation.as_str(),
"response_format": "b64_json",
});
Some(GeminiImageRequestForOpenAi {
requested_model,
mapped_model: mapped_model.to_string(),
operation,
body_json,
summary_json,
})
@@ -1003,11 +1005,50 @@ mod tests {
assert_eq!(converted.requested_model, "gemini-image");
assert_eq!(converted.body_json["model"], "gpt-image-2");
assert_eq!(converted.body_json["tools"][0]["action"], "edit");
assert_eq!(converted.operation.as_str(), "edit");
assert_eq!(
converted.body_json["input"][0]["content"][1]["image_url"],
converted.body_json["image"]["image_url"],
"data:image/png;base64,aGVsbG8="
);
assert!(converted.body_json.get("input").is_none());
assert!(converted.body_json.get("tools").is_none());
assert!(converted.body_json.get("stream").is_none());
}
#[test]
fn converts_multiple_gemini_image_inputs_to_standard_openai_image_array() {
let body = json!({
"generationConfig": {"responseModalities": ["TEXT", "IMAGE"]},
"contents": [{
"role": "user",
"parts": [
{"text": "Combine these references"},
{"inlineData": {"mimeType": "image/png", "data": "aGVsbG8="}},
{"fileData": {"mimeType": "image/jpeg", "fileUri": "https://example.test/reference.jpg"}}
]
}]
});
let converted = build_openai_image_request_body_from_gemini_image_request(
&body,
"/v1beta/models/gemini-image:generateContent",
"gpt-image-2",
)
.expect("conversion should succeed");
assert_eq!(
converted.body_json["image"].as_array().map(Vec::len),
Some(2)
);
assert_eq!(
converted.body_json["image"][0]["image_url"],
"data:image/png;base64,aGVsbG8="
);
assert_eq!(
converted.body_json["image"][1]["image_url"],
"https://example.test/reference.jpg"
);
assert!(converted.body_json.get("images").is_none());
}
#[test]
@@ -1,17 +1,47 @@
use serde_json::{json, Value};
pub const MODEL_DIRECTIVE_API_FORMATS: [&str; 5] = [
"openai:chat",
"openai:responses",
"openai:responses:compact",
"claude:messages",
"gemini:generate_content",
];
pub const OPENAI_MODEL_DIRECTIVE_SUFFIXES: [&str; 9] = [
"none", "minimal", "low", "medium", "high", "xhigh", "max", "ultra", "fast",
];
pub const CROSS_PROVIDER_MODEL_DIRECTIVE_SUFFIXES: [&str; 5] =
["low", "medium", "high", "xhigh", "max"];
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelDirective {
pub base_model: String,
pub overrides: Vec<ModelOverride>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ModelDirectiveSuffixResolution {
pub base_model: String,
pub suffixes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ModelOverride {
ReasoningEffort(ReasoningEffort),
CodexReasoningPreset(CodexReasoningPreset),
ServiceTier(ServiceTier),
}
impl ModelOverride {
pub fn suffix(&self) -> &'static str {
match self {
Self::ReasoningEffort(effort) => effort.as_str(),
Self::CodexReasoningPreset(preset) => preset.as_str(),
Self::ServiceTier(tier) => tier.as_directive_suffix(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReasoningEffort {
None,
@@ -24,6 +54,16 @@ pub enum ReasoningEffort {
}
impl ReasoningEffort {
pub const ALL: [Self; 7] = [
Self::None,
Self::Minimal,
Self::Low,
Self::Medium,
Self::High,
Self::XHigh,
Self::Max,
];
pub fn parse(value: &str) -> Option<Self> {
match value.trim().to_ascii_lowercase().as_str() {
"none" => Some(Self::None),
@@ -37,7 +77,7 @@ impl ReasoningEffort {
}
}
pub fn as_openai_chat_value(self) -> &'static str {
pub fn as_str(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
@@ -49,16 +89,16 @@ impl ReasoningEffort {
}
}
pub fn as_openai_chat_value(self) -> &'static str {
self.as_str()
}
pub fn as_openai_responses_value(self) -> &'static str {
match self {
Self::None => "none",
Self::Minimal => "minimal",
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh => "xhigh",
Self::Max => "max",
}
self.as_str()
}
pub fn as_openai_model_directive_value(self) -> &'static str {
self.as_str()
}
pub fn as_claude_output_value(self) -> &'static str {
@@ -92,6 +132,19 @@ impl ReasoningEffort {
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CodexReasoningPreset {
Ultra,
}
impl CodexReasoningPreset {
pub fn as_str(self) -> &'static str {
match self {
Self::Ultra => "ultra",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ServiceTier {
Priority,
@@ -105,6 +158,12 @@ impl ServiceTier {
}
}
pub fn as_directive_suffix(self) -> &'static str {
match self {
Self::Priority => "fast",
}
}
pub fn as_openai_value(self) -> &'static str {
match self {
Self::Priority => "priority",
@@ -113,28 +172,109 @@ impl ServiceTier {
}
pub fn parse_model_directive(model: &str) -> Option<ModelDirective> {
let (base_model, overrides) = parse_model_directive_parts(model)?;
let resolution = parse_model_directive_with_suffixes(
model,
OPENAI_MODEL_DIRECTIVE_SUFFIXES.iter().copied(),
)?;
let overrides = resolution
.suffixes
.iter()
.map(|suffix| parse_model_override_for_model(suffix, &resolution.base_model))
.collect::<Option<Vec<_>>>()?;
Some(ModelDirective {
base_model,
base_model: resolution.base_model,
overrides,
})
}
fn parse_model_directive_parts(model: &str) -> Option<(String, Vec<ModelOverride>)> {
pub fn parse_model_directive_with_suffixes<'a>(
model: &str,
suffixes: impl IntoIterator<Item = &'a str>,
) -> Option<ModelDirectiveSuffixResolution> {
let mut configured_suffixes = Vec::<String>::new();
for suffix in suffixes {
let suffix = suffix.trim();
if suffix.is_empty() || suffix.starts_with('-') || suffix.ends_with('-') {
continue;
}
if let Some(existing) = configured_suffixes
.iter()
.find(|existing| existing.eq_ignore_ascii_case(suffix))
{
if existing != suffix {
return None;
}
continue;
}
configured_suffixes.push(suffix.to_string());
}
configured_suffixes
.sort_by(|left, right| right.len().cmp(&left.len()).then_with(|| left.cmp(right)));
let mut base_model = model.trim();
let mut overrides = ModelOverrideAccumulator::default();
while let Some((candidate_base, suffix)) = base_model.rsplit_once('-') {
let Some(override_item) = parse_model_override(suffix) else {
break;
};
overrides.insert(override_item)?;
let mut matched_suffixes = Vec::<String>::new();
let mut matched_reasoning_effort = false;
let mut matched_service_tier = false;
while let Some((candidate_base, suffix)) = configured_suffixes.iter().find_map(|suffix| {
strip_model_directive_suffix(base_model, suffix)
.map(|candidate_base| (candidate_base, suffix))
}) {
if matched_suffixes
.iter()
.any(|matched| matched.eq_ignore_ascii_case(suffix))
{
return None;
}
if model_directive_suffix_is_reasoning(suffix) {
if matched_reasoning_effort {
return None;
}
matched_reasoning_effort = true;
} else if ServiceTier::parse(suffix).is_some() {
if matched_service_tier {
return None;
}
matched_service_tier = true;
}
matched_suffixes.push(suffix.clone());
base_model = candidate_base.trim();
}
if base_model.is_empty() {
if base_model.is_empty() || matched_suffixes.is_empty() {
return None;
}
let overrides = overrides.into_overrides()?;
Some((base_model.to_string(), overrides))
matched_suffixes.sort_by(|left, right| {
model_directive_suffix_rank(left)
.cmp(&model_directive_suffix_rank(right))
.then_with(|| left.cmp(right))
});
Some(ModelDirectiveSuffixResolution {
base_model: base_model.to_string(),
suffixes: matched_suffixes,
})
}
fn strip_model_directive_suffix<'a>(model: &'a str, suffix: &str) -> Option<&'a str> {
let suffix_start = model.len().checked_sub(suffix.len())?;
let separator = suffix_start.checked_sub(1)?;
if !model.is_char_boundary(suffix_start)
|| !model.is_char_boundary(separator)
|| model.as_bytes().get(separator) != Some(&b'-')
|| !model[suffix_start..].eq_ignore_ascii_case(suffix)
{
return None;
}
Some(&model[..separator])
}
fn model_directive_suffix_rank(suffix: &str) -> u8 {
if model_directive_suffix_is_reasoning(suffix) {
0
} else if ServiceTier::parse(suffix).is_some() {
1
} else {
2
}
}
fn parse_model_override(suffix: &str) -> Option<ModelOverride> {
@@ -143,39 +283,35 @@ fn parse_model_override(suffix: &str) -> Option<ModelOverride> {
.or_else(|| ServiceTier::parse(suffix).map(ModelOverride::ServiceTier))
}
#[derive(Default)]
struct ModelOverrideAccumulator {
reasoning_effort: Option<ReasoningEffort>,
service_tier: Option<ServiceTier>,
fn parse_model_override_for_model(suffix: &str, model: &str) -> Option<ModelOverride> {
if suffix.eq_ignore_ascii_case("ultra") && codex_ultra_preset_supported_for_model(model) {
return Some(ModelOverride::CodexReasoningPreset(
CodexReasoningPreset::Ultra,
));
}
parse_model_override(suffix)
}
impl ModelOverrideAccumulator {
fn insert(&mut self, override_item: ModelOverride) -> Option<()> {
match override_item {
ModelOverride::ReasoningEffort(value) => {
if self.reasoning_effort.replace(value).is_some() {
return None;
}
}
ModelOverride::ServiceTier(value) => {
if self.service_tier.replace(value).is_some() {
return None;
}
}
}
Some(())
}
pub fn model_directive_suffix_has_builtin_mapping(suffix: &str) -> bool {
parse_model_override(suffix).is_some() || suffix.eq_ignore_ascii_case("ultra")
}
fn into_overrides(self) -> Option<Vec<ModelOverride>> {
let mut overrides = Vec::new();
if let Some(reasoning_effort) = self.reasoning_effort {
overrides.push(ModelOverride::ReasoningEffort(reasoning_effort));
}
if let Some(service_tier) = self.service_tier {
overrides.push(ModelOverride::ServiceTier(service_tier));
}
(!overrides.is_empty()).then_some(overrides)
}
pub fn model_directive_builtin_suffix_supported_for_source_model(
suffix: &str,
source_model: &str,
) -> bool {
parse_model_override_for_model(suffix, source_model).is_some()
}
fn model_directive_suffix_is_reasoning(suffix: &str) -> bool {
ReasoningEffort::parse(suffix).is_some() || suffix.eq_ignore_ascii_case("ultra")
}
fn codex_ultra_preset_supported_for_model(model: &str) -> bool {
crate::formats::openai::responses::codex::resolve_codex_responses_model_capabilities(
model, model, None,
)
.supports_reasoning_effort("ultra")
}
pub fn model_directive_base_model(model: &str) -> Option<String> {
@@ -241,9 +377,17 @@ pub fn apply_model_directive_overrides_from_model(
&mut patched_body,
provider_api_format,
provider_model,
&directive.base_model,
*effort,
)?;
}
ModelOverride::CodexReasoningPreset(preset) => {
apply_codex_reasoning_preset_override(
&mut patched_body,
provider_api_format,
*preset,
)?;
}
ModelOverride::ServiceTier(tier) => {
apply_service_tier_override(&mut patched_body, provider_api_format, *tier)?;
}
@@ -261,6 +405,65 @@ pub fn apply_model_directive_mapping_patch(
Some(())
}
pub fn default_model_directive_mapping_patch(
provider_api_format: &str,
provider_model: &str,
source_model: &str,
suffix: &str,
) -> Option<Value> {
let override_item = parse_model_override_for_model(suffix, source_model)?;
let mut patch = json!({});
match override_item {
ModelOverride::ReasoningEffort(effort) => apply_reasoning_effort_override(
&mut patch,
provider_api_format,
provider_model,
source_model,
effort,
)?,
ModelOverride::CodexReasoningPreset(preset) => {
apply_codex_reasoning_preset_override(&mut patch, provider_api_format, preset)?
}
ModelOverride::ServiceTier(tier) => {
apply_service_tier_override(&mut patch, provider_api_format, tier)?
}
}
Some(patch)
}
pub fn default_model_directive_suffixes(provider_api_format: &str) -> &'static [&'static str] {
match crate::normalize_api_format_alias(provider_api_format).as_str() {
"openai:chat" | "openai:responses" | "openai:responses:compact" => {
&OPENAI_MODEL_DIRECTIVE_SUFFIXES
}
"claude:messages" | "gemini:generate_content" => &CROSS_PROVIDER_MODEL_DIRECTIVE_SUFFIXES,
_ => &[],
}
}
pub fn default_model_directives_config() -> Value {
let api_formats = MODEL_DIRECTIVE_API_FORMATS
.into_iter()
.map(|api_format| {
(
api_format.to_string(),
json!({
"enabled": true,
"suffixes": default_model_directive_suffixes(api_format),
"mappings": {},
}),
)
})
.collect::<serde_json::Map<_, _>>();
json!({
"reasoning_effort": {
"enabled": true,
"api_formats": api_formats,
},
})
}
fn deep_merge_json(target: &mut Value, patch: &Value) {
match (target, patch) {
(Value::Object(target_object), Value::Object(patch_object)) => {
@@ -283,13 +486,22 @@ fn apply_reasoning_effort_override(
provider_request_body: &mut Value,
provider_api_format: &str,
provider_model: &str,
source_model: &str,
effort: ReasoningEffort,
) -> Option<()> {
if !reasoning_effort_supported_for_model(
provider_api_format,
provider_model,
source_model,
effort,
) {
return None;
}
match crate::normalize_api_format_alias(provider_api_format).as_str() {
"openai:chat" => set_object_string(
provider_request_body,
"reasoning_effort",
effort.as_openai_chat_value(),
effort.as_openai_model_directive_value(),
),
"openai:responses" | "openai:responses:compact" => {
set_openai_responses_reasoning_effort(provider_request_body, effort)
@@ -304,6 +516,29 @@ fn apply_reasoning_effort_override(
}
}
fn apply_codex_reasoning_preset_override(
provider_request_body: &mut Value,
provider_api_format: &str,
preset: CodexReasoningPreset,
) -> Option<()> {
match crate::normalize_api_format_alias(provider_api_format).as_str() {
"openai:chat" => {
set_object_string(provider_request_body, "reasoning_effort", preset.as_str())
}
"openai:responses" | "openai:responses:compact" => {
let object = provider_request_body.as_object_mut()?;
let reasoning = object
.entry("reasoning".to_string())
.or_insert_with(|| json!({}));
reasoning
.as_object_mut()?
.insert("effort".to_string(), json!(preset.as_str()));
Some(())
}
_ => None,
}
}
fn apply_service_tier_override(
provider_request_body: &mut Value,
provider_api_format: &str,
@@ -335,7 +570,7 @@ fn set_openai_responses_reasoning_effort(body: &mut Value, effort: ReasoningEffo
}
reasoning.as_object_mut()?.insert(
"effort".to_string(),
Value::String(effort.as_openai_responses_value().to_string()),
Value::String(effort.as_openai_model_directive_value().to_string()),
);
Some(())
}
@@ -457,6 +692,158 @@ pub fn gemini_model_uses_thinking_level(model: &str) -> bool {
.any(|part| part.starts_with("gemini-3"))
}
pub fn openai_model_supports_max_reasoning_effort(model: &str) -> bool {
is_openai_gpt_5_6_family(model)
}
pub fn openai_model_supports_prompt_cache_options(model: &str) -> bool {
let normalized = model.trim().to_ascii_lowercase().replace('_', "-");
let model = normalized.rsplit('/').next().unwrap_or_default();
openai_gpt_model_version(model)
.is_some_and(|(major, minor)| major > 5 || (major == 5 && minor >= 6))
}
pub fn reasoning_effort_supported_for_model(
provider_api_format: &str,
provider_model: &str,
source_model: &str,
effort: ReasoningEffort,
) -> bool {
match crate::normalize_api_format_alias(provider_api_format).as_str() {
"openai:chat" | "openai:responses" | "openai:responses:compact" => {
match resolved_openai_model_identity(provider_model, source_model).0 {
OpenAiModelIdentity::Gpt56 => effort != ReasoningEffort::Minimal,
OpenAiModelIdentity::ConcreteOther => effort != ReasoningEffort::Max,
OpenAiModelIdentity::Opaque => true,
}
}
"claude:messages" | "gemini:generate_content" => matches!(
effort,
ReasoningEffort::Low
| ReasoningEffort::Medium
| ReasoningEffort::High
| ReasoningEffort::XHigh
| ReasoningEffort::Max
),
_ => false,
}
}
pub(crate) fn openai_model_resolves_to_gpt_5_6(provider_model: &str, source_model: &str) -> bool {
matches!(
resolved_openai_model_identity(provider_model, source_model).0,
OpenAiModelIdentity::Gpt56 | OpenAiModelIdentity::Opaque
)
}
pub(crate) fn openai_model_capability_identity(provider_model: &str, source_model: &str) -> String {
resolved_openai_model_identity(provider_model, source_model).1
}
pub(crate) fn openai_model_capability_is_opaque(provider_model: &str, source_model: &str) -> bool {
resolved_openai_model_identity(provider_model, source_model).0 == OpenAiModelIdentity::Opaque
}
fn resolved_openai_model_identity(
provider_model: &str,
source_model: &str,
) -> (OpenAiModelIdentity, String) {
let provider_model = normalize_model_directive_model(provider_model);
let provider_identity = classify_openai_model_identity(&provider_model);
if provider_identity != OpenAiModelIdentity::Opaque {
return (provider_identity, provider_model);
}
let source_model = normalize_model_directive_model(source_model);
(classify_openai_model_identity(&source_model), source_model)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum OpenAiModelIdentity {
Gpt56,
ConcreteOther,
Opaque,
}
fn classify_openai_model_identity(model: &str) -> OpenAiModelIdentity {
if is_openai_gpt_5_6_family(model) {
return OpenAiModelIdentity::Gpt56;
}
let normalized = model.trim().to_ascii_lowercase().replace('_', "-");
let model = normalized.rsplit('/').next().unwrap_or_default();
if openai_gpt_model_identity_is_concrete(model)
|| openai_o_series_model_identity_is_concrete(model)
|| model.starts_with("chatgpt-")
|| model.starts_with("codex-")
{
OpenAiModelIdentity::ConcreteOther
} else {
OpenAiModelIdentity::Opaque
}
}
fn openai_gpt_model_identity_is_concrete(model: &str) -> bool {
let Some(rest) = model.strip_prefix("gpt-") else {
return false;
};
let mut segments = rest.split('-');
let Some(version) = segments.next() else {
return false;
};
if openai_gpt_model_version(model).is_none() {
return false;
}
let version_is_omni = version.ends_with('o');
if version.contains('.') || version_is_omni {
return true;
}
match segments.next() {
None => true,
Some(variant) => {
variant.chars().all(|character| character.is_ascii_digit())
|| matches!(
variant,
"audio"
| "chat"
| "codex"
| "mini"
| "nano"
| "pro"
| "realtime"
| "search"
| "turbo"
| "vision"
)
}
}
}
fn openai_gpt_model_version(model: &str) -> Option<(u64, u64)> {
let version = model.strip_prefix("gpt-")?.split('-').next()?;
let version = version.strip_suffix('o').unwrap_or(version);
let mut parts = version.split('.');
let major = parts.next()?.parse().ok()?;
let minor = parts.next().map(str::parse).transpose().ok()?.unwrap_or(0);
if parts.next().is_some() {
return None;
}
Some((major, minor))
}
fn openai_o_series_model_identity_is_concrete(model: &str) -> bool {
let Some(rest) = model.strip_prefix('o') else {
return false;
};
rest.split('-').next().is_some_and(|version| {
!version.is_empty() && version.chars().all(|character| character.is_ascii_digit())
})
}
fn is_openai_gpt_5_6_family(model: &str) -> bool {
let normalized = model.trim().to_ascii_lowercase().replace('_', "-");
let model = normalized.rsplit('/').next().unwrap_or_default();
openai_gpt_model_version(model) == Some((5, 6))
}
pub fn extract_gemini_model_from_path(path: &str) -> Option<String> {
let marker = "/models/";
let start = path.find(marker)? + marker.len();
@@ -471,12 +858,87 @@ mod tests {
use serde_json::json;
use super::{
apply_model_directive_overrides_from_model, parse_model_directive, ModelDirective,
ModelOverride, ReasoningEffort, ServiceTier,
apply_model_directive_overrides_from_model, default_model_directive_suffixes,
default_model_directives_config, parse_model_directive,
parse_model_directive_with_suffixes, CodexReasoningPreset, ModelDirective,
ModelDirectiveSuffixResolution, ModelOverride, ReasoningEffort, ServiceTier,
MODEL_DIRECTIVE_API_FORMATS,
};
#[test]
fn policy_suffix_parser_prefers_the_longest_configured_suffix() {
assert_eq!(
parse_model_directive_with_suffixes(
"deployment-alias-VendorFuture",
["Future", "VendorFuture"],
),
Some(ModelDirectiveSuffixResolution {
base_model: "deployment-alias".to_string(),
suffixes: vec!["VendorFuture".to_string()],
})
);
assert_eq!(
parse_model_directive_with_suffixes(
"deployment-alias-high-VendorFuture",
["high", "VendorFuture"],
),
Some(ModelDirectiveSuffixResolution {
base_model: "deployment-alias".to_string(),
suffixes: vec!["high".to_string(), "VendorFuture".to_string()],
})
);
}
#[test]
fn policy_suffix_parser_rejects_duplicate_or_ambiguous_suffixes() {
assert_eq!(
parse_model_directive_with_suffixes("deployment-low-high", ["low", "high"]),
None
);
assert_eq!(
parse_model_directive_with_suffixes(
"deployment-VendorFuture",
["VendorFuture", "vendorfuture"],
),
None
);
assert_eq!(
parse_model_directive_with_suffixes(
"deployment-VendorFuture-VendorFuture",
["VendorFuture"],
),
None
);
}
#[test]
fn policy_suffix_parser_does_not_strip_unconfigured_suffixes() {
assert_eq!(
parse_model_directive_with_suffixes("deployment-alias-VendorFuture", ["high"]),
None
);
}
#[test]
fn parses_supported_reasoning_effort_suffixes() {
let expected = [
("none", ReasoningEffort::None),
("minimal", ReasoningEffort::Minimal),
("low", ReasoningEffort::Low),
("medium", ReasoningEffort::Medium),
("high", ReasoningEffort::High),
("xhigh", ReasoningEffort::XHigh),
("max", ReasoningEffort::Max),
];
for (suffix, effort) in expected {
assert_eq!(
parse_model_directive(&format!("gpt-5.6-sol-{suffix}")),
Some(ModelDirective {
base_model: "gpt-5.6-sol".to_string(),
overrides: vec![ModelOverride::ReasoningEffort(effort)],
})
);
}
assert_eq!(
parse_model_directive("gpt-5.4-xhigh"),
Some(ModelDirective {
@@ -493,6 +955,21 @@ mod tests {
);
}
#[test]
fn default_config_is_generated_from_the_shared_directive_contract() {
let config = default_model_directives_config();
for api_format in MODEL_DIRECTIVE_API_FORMATS {
assert_eq!(
config["reasoning_effort"]["api_formats"][api_format]["mappings"],
json!({})
);
assert_eq!(
config["reasoning_effort"]["api_formats"][api_format]["suffixes"],
json!(default_model_directive_suffixes(api_format))
);
}
}
#[test]
fn parses_supported_service_tier_suffixes() {
assert_eq!(
@@ -519,13 +996,44 @@ mod tests {
#[test]
fn ignores_unknown_or_incomplete_suffixes() {
assert_eq!(parse_model_directive("gpt-5.4-ultra"), None);
assert_eq!(parse_model_directive("gpt-5.4-turbo"), None);
assert_eq!(parse_model_directive("gpt-5.4"), None);
assert_eq!(parse_model_directive("-high"), None);
assert_eq!(parse_model_directive("gpt-5.4-high-json"), None);
assert_eq!(parse_model_directive("gpt-5.4-low-high"), None);
}
#[test]
fn parses_gpt_5_6_ultra_as_an_internal_reasoning_preset() {
assert_eq!(
parse_model_directive("gpt-5.6-sol-ultra"),
Some(ModelDirective {
base_model: "gpt-5.6-sol".to_string(),
overrides: vec![ModelOverride::CodexReasoningPreset(
CodexReasoningPreset::Ultra,
)],
})
);
for model in [
"gpt-5.6-ultra",
"gpt-5.6-luna-ultra",
"gpt-5.4-ultra",
"gemini-ultra",
] {
assert_eq!(parse_model_directive(model), None);
}
let mut unsupported = json!({"model": "gpt-5.4"});
assert!(apply_model_directive_overrides_from_model(
&mut unsupported,
"openai:responses",
"gpt-5.4",
"gpt-5.4-ultra",
)
.is_none());
}
#[test]
fn applies_reasoning_effort_to_provider_body_shapes() {
let mut openai_chat = json!({"model": "gpt-5-upstream", "reasoning_effort": "low"});
@@ -546,7 +1054,7 @@ mod tests {
&mut responses,
"openai:responses",
"gpt-5-upstream",
"gpt-5.4-max",
"gpt-5.6-max",
)
.expect("directive should apply");
assert_eq!(responses["reasoning"]["effort"], "max");
@@ -560,7 +1068,7 @@ mod tests {
&mut compact,
"openai:responses:compact",
"gpt-5-upstream",
"gpt-5.4-max",
"gpt-5.6-max",
)
.expect("directive should apply");
assert_eq!(compact["reasoning"]["effort"], "max");
@@ -570,7 +1078,7 @@ mod tests {
&mut openai_chat_max,
"openai:chat",
"gpt-5-upstream",
"gpt-5.4-max",
"gpt-5.6-max",
)
.expect("directive should apply");
assert_eq!(openai_chat_max["reasoning_effort"], "max");
@@ -599,6 +1107,142 @@ mod tests {
);
}
#[test]
fn max_suffix_is_capability_aware_for_openai_models() {
for model in ["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"] {
let mut body = json!({"model": model});
apply_model_directive_overrides_from_model(
&mut body,
"openai:responses",
model,
&format!("{model}-max"),
)
.expect("max directive should apply");
assert_eq!(body["reasoning"]["effort"], "max", "model: {model}");
}
let mut unsupported_model = json!({"model": "gpt-5.4"});
let original = unsupported_model.clone();
assert!(apply_model_directive_overrides_from_model(
&mut unsupported_model,
"openai:responses",
"gpt-5.4",
"gpt-5.4-max",
)
.is_none());
assert_eq!(unsupported_model, original);
let mut mapped_deployment = json!({"model": "azure-production"});
apply_model_directive_overrides_from_model(
&mut mapped_deployment,
"openai:responses",
"azure-production",
"gpt-5.6-sol-max",
)
.expect("source model capability should survive provider model mapping");
assert_eq!(mapped_deployment["reasoning"]["effort"], "max");
let mut explicit_unsupported_target = json!({"model": "gpt-5.4"});
let original = explicit_unsupported_target.clone();
assert!(apply_model_directive_overrides_from_model(
&mut explicit_unsupported_target,
"openai:responses",
"gpt-5.4",
"gpt-5.6-sol-max",
)
.is_none());
assert_eq!(explicit_unsupported_target, original);
let mut unknown_future = json!({"model": "gpt-6.0"});
let original = unknown_future.clone();
assert!(apply_model_directive_overrides_from_model(
&mut unknown_future,
"openai:responses",
"gpt-6.0",
"gpt-6.0-max",
)
.is_none());
assert_eq!(unknown_future, original);
}
#[test]
fn openai_only_efforts_do_not_leak_into_cross_provider_mappings() {
for effort in ["none", "minimal"] {
for (api_format, provider_model) in [
("claude:messages", "claude-sonnet-4-6"),
("gemini:generate_content", "gemini-3-pro"),
] {
let mut body = json!({"model": provider_model});
let original = body.clone();
assert!(apply_model_directive_overrides_from_model(
&mut body,
api_format,
provider_model,
&format!("gpt-5.6-sol-{effort}"),
)
.is_none());
assert_eq!(body, original);
}
}
}
#[test]
fn gpt_5_6_rejects_minimal_but_accepts_published_efforts() {
for effort in ["none", "low", "medium", "high", "xhigh", "max"] {
let mut body = json!({"model": "azure-production"});
apply_model_directive_overrides_from_model(
&mut body,
"openai:responses",
"azure-production",
&format!("gpt-5.6-sol-{effort}"),
)
.expect("published GPT-5.6 effort should apply");
assert_eq!(body["reasoning"]["effort"], effort);
}
let mut body = json!({"model": "azure-production"});
let original = body.clone();
assert!(apply_model_directive_overrides_from_model(
&mut body,
"openai:responses",
"azure-production",
"gpt-5.6-sol-minimal",
)
.is_none());
assert_eq!(body, original);
for family_variant in ["gpt-5.6-preview", "gpt-5.6-sol-2026-07-01"] {
assert!(super::openai_model_supports_max_reasoning_effort(
family_variant
));
let mut body = json!({"model": family_variant});
apply_model_directive_overrides_from_model(
&mut body,
"openai:responses",
family_variant,
&format!("{family_variant}-max"),
)
.expect("GPT-5.6 family variants should accept max");
assert_eq!(body["reasoning"]["effort"], "max");
}
}
#[test]
fn prompt_cache_options_capability_requires_gpt_5_6_or_later() {
for model in [
"gpt-5.6",
"gpt-5.6-sol",
"gpt-5.7",
"gpt-6",
"openai/gpt-6.1-pro",
] {
assert!(super::openai_model_supports_prompt_cache_options(model));
}
for model in ["gpt-5.5", "gpt-4o", "o3", "azure-production", "gpt-5.6.1"] {
assert!(!super::openai_model_supports_prompt_cache_options(model));
}
}
#[test]
fn applies_fast_suffix_to_openai_service_tier() {
let mut openai_chat = json!({"model": "gpt-5-upstream"});
@@ -46,6 +46,17 @@ pub fn force_upstream_streaming_for_provider(
&& aether_ai_formats::is_openai_responses_format(provider_api_format)
}
pub fn forbid_upstream_streaming_for_provider(
provider_type: &str,
provider_api_format: &str,
) -> bool {
aether_ai_formats::is_openai_responses_compact_format(provider_api_format)
|| (provider_type.trim().eq_ignore_ascii_case("codex")
&& provider_api_format
.trim()
.eq_ignore_ascii_case("openai:image"))
}
pub(crate) fn parse_upstream_stream_policy(
value: Option<&serde_json::Value>,
) -> UpstreamStreamPolicy {
@@ -160,13 +171,31 @@ pub fn resolve_upstream_is_stream_from_endpoint_config(
)
}
pub fn resolve_upstream_is_stream_for_provider(
endpoint_config: Option<&serde_json::Value>,
provider_type: &str,
provider_api_format: &str,
client_is_stream: bool,
hard_requires_streaming: bool,
) -> bool {
if forbid_upstream_streaming_for_provider(provider_type, provider_api_format) {
return false;
}
resolve_upstream_is_stream_from_endpoint_config(
endpoint_config,
client_is_stream,
hard_requires_streaming
|| force_upstream_streaming_for_provider(provider_type, provider_api_format),
)
}
#[cfg(test)]
mod tests {
use super::{
endpoint_config_forces_upstream_stream_policy, enforce_request_body_stream_field,
force_upstream_streaming_for_provider, parse_direct_request_body,
parse_upstream_stream_policy, resolve_upstream_is_stream,
resolve_upstream_is_stream_from_endpoint_config,
forbid_upstream_streaming_for_provider, force_upstream_streaming_for_provider,
parse_direct_request_body, parse_upstream_stream_policy, resolve_upstream_is_stream,
resolve_upstream_is_stream_for_provider, resolve_upstream_is_stream_from_endpoint_config,
upstream_stream_policy_from_endpoint_config, UpstreamStreamPolicy,
};
use serde_json::json;
@@ -228,6 +257,34 @@ mod tests {
));
}
#[test]
fn forbids_streaming_for_compact_and_codex_images() {
assert!(forbid_upstream_streaming_for_provider(
"codex",
"openai:responses:compact"
));
assert!(forbid_upstream_streaming_for_provider(
"openai",
"openai:responses:compact"
));
assert!(forbid_upstream_streaming_for_provider(
"custom",
"openai:responses:compact"
));
assert!(forbid_upstream_streaming_for_provider(
"codex",
"openai:image"
));
assert!(!forbid_upstream_streaming_for_provider(
"codex",
"openai:responses"
));
assert!(!forbid_upstream_streaming_for_provider(
"openai",
"openai:image"
));
}
#[test]
fn parses_python_compatible_upstream_stream_policy_values() {
assert_eq!(
@@ -400,4 +457,30 @@ mod tests {
None, false, false,
));
}
#[test]
fn provider_policy_gives_non_stream_contracts_precedence() {
let force_stream = json!({"upstream_stream_policy": "force_stream"});
assert!(!resolve_upstream_is_stream_for_provider(
Some(&force_stream),
"codex",
"openai:responses:compact",
true,
true,
));
assert!(!resolve_upstream_is_stream_for_provider(
Some(&force_stream),
"codex",
"openai:image",
true,
true,
));
assert!(resolve_upstream_is_stream_for_provider(
Some(&json!({"upstream_stream_policy": "force_non_stream"})),
"codex",
"openai:responses",
false,
false,
));
}
}
@@ -95,14 +95,6 @@ pub fn resolve_execution_runtime_stream_plan_kind(
return Some(OPENAI_RESPONSES_STREAM_PLAN_KIND);
}
if route_family == Some("openai")
&& is_openai_responses_compact_route_kind(route_kind)
&& *method == Method::POST
&& path == "/v1/responses/compact"
{
return Some(OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND);
}
if route_family == Some("openai")
&& route_kind == Some("image")
&& *method == Method::POST
@@ -402,11 +394,13 @@ pub fn is_matching_stream_request(
path: &str,
body_json: &serde_json::Value,
) -> bool {
if plan_kind == OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND {
return false;
}
match plan_kind {
OPENAI_CHAT_STREAM_PLAN_KIND
| CLAUDE_CHAT_STREAM_PLAN_KIND
| OPENAI_RESPONSES_STREAM_PLAN_KIND
| OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
| CLAUDE_CLI_STREAM_PLAN_KIND
| OPENAI_IMAGE_STREAM_PLAN_KIND
| GEMINI_INTERACTIONS_STREAM_PLAN_KIND => body_json
@@ -468,7 +462,6 @@ pub fn supports_stream_execution_decision_kind(plan_kind: &str) -> bool {
| CLAUDE_CHAT_STREAM_PLAN_KIND
| GEMINI_CHAT_STREAM_PLAN_KIND
| OPENAI_RESPONSES_STREAM_PLAN_KIND
| OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
| OPENAI_IMAGE_STREAM_PLAN_KIND
| CLAUDE_CLI_STREAM_PLAN_KIND
| GEMINI_CLI_STREAM_PLAN_KIND
@@ -592,7 +585,7 @@ mod tests {
&Method::POST,
"/v1/responses/compact",
),
Some(OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND)
None
);
assert_eq!(
resolve_execution_runtime_sync_plan_kind(
@@ -608,7 +601,7 @@ mod tests {
assert!(supports_sync_execution_decision_kind(
OPENAI_RESPONSES_COMPACT_SYNC_PLAN_KIND
));
assert!(supports_stream_execution_decision_kind(
assert!(!supports_stream_execution_decision_kind(
OPENAI_RESPONSES_COMPACT_STREAM_PLAN_KIND
));
}
@@ -1292,6 +1292,36 @@ mod tests {
}
}
#[test]
fn codex_store_body_rule_preserves_response_input_item_ids() {
let request = json!({
"model": "gpt-5.4",
"input": [{
"id": "msg-1",
"type": "message",
"role": "user",
"content": [{"type": "input_text", "text": "hello"}]
}]
});
let body_rules = json!([{"action":"set","path":"store","value":true}]);
let converted = build_standard_request_body(
&request,
"openai:responses",
"gpt-5.4",
"codex",
"openai:responses",
"/v1/responses",
true,
Some(&body_rules),
Some("key-1"),
)
.expect("Codex Responses request should apply the store body rule");
assert_eq!(converted["store"], true);
assert_eq!(converted["input"][0]["id"], "msg-1");
}
#[test]
fn standard_openai_responses_strips_content_cache_control_after_body_rules() {
let request = json!({
@@ -1329,7 +1359,7 @@ mod tests {
}
#[test]
fn standard_codex_responses_derives_prompt_cache_key_before_stripping_cache_control() {
fn standard_codex_responses_strip_cache_control_without_synthesizing_a_cache_key() {
fn claude_request(user_text: &str) -> Value {
json!({
"model": "claude-sonnet",
@@ -1373,13 +1403,8 @@ mod tests {
)
.expect("claude to codex responses request should build");
assert!(converted_a["prompt_cache_key"]
.as_str()
.is_some_and(|value| !value.trim().is_empty()));
assert_eq!(
converted_a["prompt_cache_key"],
converted_b["prompt_cache_key"]
);
assert!(converted_a.get("prompt_cache_key").is_none());
assert!(converted_b.get("prompt_cache_key").is_none());
assert!(!converted_a.to_string().contains("cache_control"));
assert!(!converted_b.to_string().contains("cache_control"));
}
@@ -760,7 +760,7 @@ mod tests {
#[test]
fn local_openai_responses_request_body_applies_reasoning_effort_suffix() {
let body_json = json!({
"model": "gpt-5.4-max",
"model": "gpt-5.6-sol-max",
"input": "hello",
"reasoning": {"effort": "low", "summary": "auto"}
});
@@ -152,6 +152,7 @@ pub fn canonical_usage_from_openai_usage(value: Option<&Value>) -> Option<Canoni
details
.get("cache_write_tokens")
.or_else(|| details.get("cached_creation_tokens"))
.or_else(|| details.get("cache_creation_tokens"))
})
.and_then(Value::as_u64)
})
@@ -100,6 +100,14 @@ impl StreamingStandardFormatMatrix {
out.extend(client.emit_unknown_event(payload)?);
break;
}
if let CanonicalStreamEvent::OpenAiResponsesOutputItem { raw_event, .. } = &frame.event
{
if !matches!(client, ClientStreamEmitter::OpenAIResponses(_)) {
self.terminated = true;
out.extend(client.emit_unknown_event(raw_event)?);
break;
}
}
out.extend(client.emit(frame)?);
}
Ok(out)
@@ -125,6 +133,14 @@ impl StreamingStandardTerminalObserver {
report_context: &Value,
line: Vec<u8>,
) -> Result<(), AiSurfaceFinalizeError> {
if let Some(service_tier) = decode_json_data_line(&line)
.as_ref()
.and_then(provider_actual_service_tier_from_stream_event)
{
self.latest_summary
.get_or_insert_with(ExecutionStreamTerminalSummary::default)
.provider_actual_service_tier = Some(service_tier);
}
self.ensure_initialized(report_context);
let Some(provider) = self.provider.as_mut() else {
return Ok(());
@@ -238,6 +254,17 @@ impl StreamingStandardTerminalObserver {
}
}
fn provider_actual_service_tier_from_stream_event(event: &Value) -> Option<String> {
event
.get("response")
.and_then(|response| response.get("service_tier"))
.or_else(|| event.get("service_tier"))
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty() && value.len() <= 64)
.map(str::to_ascii_lowercase)
}
enum TerminalStreamParser {
Standard(ProviderStreamParser),
OpenAIImage(OpenAiImageStreamTerminalState),
@@ -310,7 +337,7 @@ impl ProviderStreamParser {
enum ClientStreamEmitter {
OpenAIChat(OpenAIChatClientEmitter),
OpenAIResponses(OpenAIResponsesClientEmitter),
OpenAIResponses(Box<OpenAIResponsesClientEmitter>),
Claude(ClaudeClientEmitter),
Gemini(GeminiClientEmitter),
}
@@ -359,7 +386,7 @@ impl ClientStreamEmitter {
Some(match FormatId::parse(client_api_format)? {
FormatId::OpenAiChat => Self::OpenAIChat(OpenAIChatClientEmitter::default()),
FormatId::OpenAiResponses | FormatId::OpenAiResponsesCompact => {
Self::OpenAIResponses(OpenAIResponsesClientEmitter::default())
Self::OpenAIResponses(Box::default())
}
FormatId::ClaudeMessages => Self::Claude(ClaudeClientEmitter::default()),
FormatId::GeminiGenerateContent => Self::Gemini(GeminiClientEmitter::default()),
@@ -1093,6 +1120,66 @@ mod tests {
}
}
#[test]
fn responses_compaction_output_is_lossless_within_family_and_rejected_cross_format() {
let compaction_event = json!({
"type": "response.output_item.done",
"item": {
"type": "compaction",
"encrypted_content": "ENCRYPTED_CONTEXT_COMPACTION_SUMMARY"
}
});
let mut responses_matrix = StreamingStandardFormatMatrix::default();
let responses_context = report_context("openai:responses", "openai:responses");
let mut responses_output = responses_matrix
.transform_line(&responses_context, data_line(compaction_event.clone()))
.expect("same-family compaction output should convert");
responses_output.extend(
responses_matrix
.transform_line(
&responses_context,
data_line(json!({
"type": "response.completed",
"response": {
"id": "resp-compact",
"model": "gpt-5.6-sol",
"usage": {
"input_tokens": 0,
"output_tokens": 0,
"total_tokens": 0
}
}
})),
)
.expect("terminal response should convert"),
);
let responses_sse = String::from_utf8(responses_output).expect("valid Responses SSE");
assert!(responses_sse.contains("event: response.output_item.done\n"));
assert!(responses_sse.contains("\"type\":\"compaction\""));
assert!(!responses_sse.contains("\"output_index\""));
assert!(responses_sse.contains("event: response.completed\n"));
for client_api_format in ["openai:chat", "claude:messages", "gemini:generate_content"] {
let mut matrix = StreamingStandardFormatMatrix::default();
let context = report_context("openai:responses", client_api_format);
let output = matrix
.transform_line(&context, data_line(compaction_event.clone()))
.expect("cross-format rejection should be encoded for the client");
let sse = String::from_utf8(output).expect("valid error SSE");
assert!(
sse.contains("Unsupported provider stream event cannot be converted losslessly")
&& sse.contains("compaction"),
"{client_api_format}: {sse}"
);
if client_api_format == "gemini:generate_content" {
assert!(sse.contains("\"status\":\"INTERNAL\""), "{sse}");
} else {
assert!(sse.contains("unsupported_stream_event"), "{sse}");
}
}
}
#[test]
fn transforms_openai_responses_known_sidecar_events_without_unsupported_errors() {
let report_context = report_context("openai:responses", "claude:messages");
@@ -1795,6 +1882,55 @@ mod tests {
assert_eq!(summary.unknown_event_count, 0);
}
#[test]
fn terminal_observer_preserves_actual_service_tier_without_response_capture() {
let chat_context = report_context("openai:chat", "openai:chat");
let mut chat_observer = StreamingStandardTerminalObserver::default();
chat_observer
.push_line(
&chat_context,
data_line(json!({
"id": "chatcmpl_tier_1",
"object": "chat.completion.chunk",
"model": "gpt-5.6",
"service_tier": "Default",
"choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}],
})),
)
.expect("Chat terminal tier should be observed");
assert_eq!(
chat_observer
.latest_summary()
.and_then(|summary| summary.provider_actual_service_tier.as_deref()),
Some("default")
);
let responses_context = report_context("openai:responses", "openai:responses");
let mut responses_observer = StreamingStandardTerminalObserver::default();
responses_observer
.push_line(
&responses_context,
data_line(json!({
"type": "response.completed",
"response": {
"id": "resp_tier_1",
"model": "gpt-5.6",
"status": "completed",
"service_tier": "Flex",
"output": [],
},
"sequence_number": 1,
})),
)
.expect("Responses terminal tier should be observed");
assert_eq!(
responses_observer
.latest_summary()
.and_then(|summary| summary.provider_actual_service_tier.as_deref()),
Some("flex")
);
}
#[test]
fn terminal_observer_tracks_openai_image_stream_usage() {
let mut report_context = report_context("openai:image", "openai:chat");
@@ -699,6 +699,13 @@ fn maybe_build_openai_responses_same_family_stream_sync_body(
return Ok(None);
};
let body_bytes = base64::engine::general_purpose::STANDARD.decode(body_base64)?;
if let Some(terminal_body) = terminal_openai_responses_stream_response(&body_bytes) {
return Ok(Some(client_body_with_report_context_model(
terminal_body,
report_context,
&client_api_format,
)));
}
Ok(
try_aggregate_openai_responses_stream_sync_response(&body_bytes)?.map(|body| {
client_body_with_report_context_model(body, report_context, &client_api_format)
@@ -706,6 +713,32 @@ fn maybe_build_openai_responses_same_family_stream_sync_body(
)
}
fn terminal_openai_responses_stream_response(body: &[u8]) -> Option<Value> {
parse_stream_json_events(body)?
.into_iter()
.rev()
.find_map(|event| {
let event = event.as_object()?;
let event_type = event.get("type").and_then(Value::as_str)?;
if !matches!(
event_type,
"response.completed" | "response.done" | "response.incomplete" | "response.failed"
) {
return None;
}
let response = event.get("response").and_then(Value::as_object).cloned()?;
if matches!(event_type, "response.completed" | "response.done")
&& response
.get("output")
.and_then(Value::as_array)
.is_none_or(|output| output.is_empty())
{
return None;
}
Some(Value::Object(response))
})
}
fn maybe_build_openai_cross_format_provider_body_from_normalized_payload(
body_json: Option<&Value>,
body_base64: Option<&str>,
@@ -781,10 +814,20 @@ pub fn maybe_build_standard_cross_format_sync_product(
let client_api_format = client_api_format.trim().to_ascii_lowercase();
if provider_api_format == "openai:image" && client_api_format == "gemini:generate_content" {
let client_body_json = crate::formats::shared::image_bridge::build_gemini_image_response_from_openai_responses_image_response(
&provider_body_json,
Some(report_context),
)?;
let client_body_json = match (
provider_body_json.get("data").and_then(Value::as_array),
provider_body_json.get("output").and_then(Value::as_array),
) {
(Some(_), None) => crate::formats::shared::image_bridge::build_gemini_image_response_from_openai_image_response(
&provider_body_json,
Some(report_context),
)?,
(None, Some(_)) => crate::formats::shared::image_bridge::build_gemini_image_response_from_openai_responses_image_response(
&provider_body_json,
Some(report_context),
)?,
_ => return None,
};
return Some(StandardCrossFormatSyncProduct {
client_body_json,
provider_body_json,
@@ -1875,7 +1918,10 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
let mut reasoning_states: BTreeMap<usize, OpenAIResponsesSyncReasoningState> = BTreeMap::new();
let mut tool_states: BTreeMap<usize, OpenAIResponsesSyncToolState> = BTreeMap::new();
let mut image_items: BTreeMap<usize, Value> = BTreeMap::new();
let mut opaque_items: BTreeMap<usize, Value> = BTreeMap::new();
let mut item_output_indexes = BTreeMap::<String, usize>::new();
let mut generic_output_indexes = BTreeMap::<String, usize>::new();
let mut next_output_index = 0_usize;
for event in events {
let event_object = event.as_object()?;
@@ -2057,8 +2103,20 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
let Some(item) = event_object.get("item").and_then(Value::as_object) else {
continue;
};
let output_index = openai_responses_event_output_index(event_object)
.unwrap_or(item_output_indexes.len());
let item_key = openai_responses_generic_output_item_key(item);
let output_index =
if let Some(output_index) = openai_responses_event_output_index(event_object) {
next_output_index = next_output_index.max(output_index.saturating_add(1));
generic_output_indexes.insert(item_key, output_index);
output_index
} else if let Some(output_index) = generic_output_indexes.get(&item_key) {
*output_index
} else {
let output_index = next_output_index;
next_output_index = next_output_index.saturating_add(1);
generic_output_indexes.insert(item_key, output_index);
output_index
};
match item.get("type").and_then(Value::as_str).unwrap_or_default() {
"message" => merge_openai_responses_message_item(
message_states.entry(output_index).or_default(),
@@ -2082,6 +2140,11 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
"image_generation_call" => {
image_items.insert(output_index, Value::Object(item.clone()));
}
_ if event_object.get("type").and_then(Value::as_str)
== Some("response.output_item.done") =>
{
opaque_items.insert(output_index, Value::Object(item.clone()));
}
_ => {}
}
}
@@ -2239,7 +2302,9 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
"image_generation_call" => {
image_items.insert(output_index, Value::Object(item.clone()));
}
_ => {}
_ => {
opaque_items.insert(output_index, Value::Object(item.clone()));
}
}
}
}
@@ -2282,6 +2347,7 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
.chain(reasoning_states.keys())
.chain(tool_states.keys())
.chain(image_items.keys())
.chain(opaque_items.keys())
.copied()
.collect::<Vec<_>>();
output_indexes.sort_unstable();
@@ -2308,6 +2374,9 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
if let Some(item) = image_items.remove(&output_index) {
output.push(item);
}
if let Some(item) = opaque_items.remove(&output_index) {
output.push(item);
}
}
response.insert("output".to_string(), Value::Array(output));
}
@@ -2335,6 +2404,20 @@ struct OpenAIResponsesSyncToolState {
arguments: String,
}
fn openai_responses_generic_output_item_key(item: &Map<String, Value>) -> String {
let item_type = item.get("type").and_then(Value::as_str).unwrap_or_default();
if let Some(item_id) = item.get("id").and_then(Value::as_str) {
return format!("{item_type}:id:{item_id}");
}
if let Some(encrypted_content) = item.get("encrypted_content").and_then(Value::as_str) {
return format!("{item_type}:encrypted_content:{encrypted_content}");
}
format!(
"{item_type}:{}",
serde_json::to_string(item).unwrap_or_default()
)
}
fn openai_responses_event_output_index(event: &Map<String, Value>) -> Option<usize> {
event
.get("output_index")
@@ -3158,6 +3241,9 @@ fn try_aggregate_gemini_stream_sync_response(
parts.push(gemini_sync_part_from_canonical_content_part(part));
}
}
CanonicalStreamEvent::OpenAiResponsesOutputItem { raw_event, .. } => {
return Err(unsupported_stream_event_finalize_error(&raw_event))
}
CanonicalStreamEvent::ToolCallStart {
index,
call_id,
@@ -3554,6 +3640,87 @@ mod tests {
use base64::Engine as _;
use serde_json::json;
#[test]
fn converts_openai_images_sync_body_to_gemini_image_body() {
let provider_body_json = json!({
"created": 1776839946,
"model": "gpt-image-2",
"data": [{
"revised_prompt": "revised prompt",
"b64_json": "aGVsbG8="
}],
"usage": {
"input_tokens": 3,
"output_tokens": 4,
"total_tokens": 7
}
});
let report_context = json!({
"provider_api_format": "openai:image",
"client_api_format": "gemini:generate_content",
});
let product = maybe_build_standard_cross_format_sync_product(
"gemini_chat_sync_finalize",
"openai:image",
"gemini:generate_content",
&report_context,
provider_body_json,
)
.expect("OpenAI Images sync body should convert");
assert_eq!(product.client_body_json["modelVersion"], "gpt-image-2");
assert_eq!(
product.client_body_json["candidates"][0]["content"]["parts"][0]["text"],
"revised prompt"
);
assert_eq!(
product.client_body_json["candidates"][0]["content"]["parts"][1]["inlineData"]["data"],
"aGVsbG8="
);
assert_eq!(
product.client_body_json["usageMetadata"]["totalTokenCount"],
7
);
}
#[test]
fn converts_openai_responses_image_body_to_gemini_image_body() {
let provider_body_json = json!({
"model": "gpt-image-2",
"output": [{
"type": "image_generation_call",
"status": "completed",
"result": "aGVsbG8=",
"output_format": "png"
}]
});
let report_context = json!({
"provider_api_format": "openai:image",
"client_api_format": "gemini:generate_content",
});
let product = maybe_build_standard_cross_format_sync_product(
"gemini_chat_sync_finalize",
"openai:image",
"gemini:generate_content",
&report_context,
provider_body_json,
)
.expect("OpenAI Responses image body should convert");
assert_eq!(product.client_body_json["modelVersion"], "gpt-image-2");
assert_eq!(
product.client_body_json["candidates"][0]["content"]["parts"][0]["inlineData"]
["mimeType"],
"image/png"
);
assert_eq!(
product.client_body_json["candidates"][0]["content"]["parts"][0]["inlineData"]["data"],
"aGVsbG8="
);
}
#[test]
fn aggregates_openai_chat_stream_tool_usage_and_finish_into_sync_body() {
let body = concat!(
@@ -4203,7 +4370,7 @@ mod tests {
"event: response.output_text.delta\n",
"data: {\"type\":\"response.output_text.delta\",\"output_index\":0,\"content_index\":0,\"delta\":\"Hello\"}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_123\",\"object\":\"response\",\"model\":\"gpt-5\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_123\",\"object\":\"response\",\"model\":\"gpt-5\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello\"}]}],\"usage\":{\"input_tokens\":1,\"output_tokens\":2,\"total_tokens\":3}}}\n\n",
);
let report_context = json!({
"provider_api_format": "openai:responses:compact",
@@ -4224,9 +4391,241 @@ mod tests {
assert_eq!(body_json.get("id"), Some(&json!("resp_123")));
assert_eq!(body_json.get("status"), Some(&json!("completed")));
assert_eq!(body_json["output"][0]["content"][0]["text"], json!("Hello"));
assert_eq!(body_json["output_text"], "Hello");
assert!(body_json["created_at"].as_i64().is_some());
assert!(body_json["completed_at"].as_i64().is_some());
}
#[test]
fn same_family_completed_metadata_uses_materialized_stream_output() {
for terminal_response in [
json!({
"id": "resp_codex_terminal_metadata",
"object": "response",
"model": "gpt-5.6-sol",
"status": "completed",
"output": [],
"usage": {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}
}),
json!({
"id": "resp_codex_terminal_metadata",
"object": "response",
"model": "gpt-5.6-sol",
"status": "completed",
"usage": {"input_tokens": 1, "output_tokens": 2, "total_tokens": 3}
}),
] {
let body = format!(
"event: response.created\ndata: {}\n\n\
event: response.output_text.delta\ndata: {}\n\n\
event: response.completed\ndata: {}\n\n",
json!({
"type": "response.created",
"response": {
"id": "resp_codex_terminal_metadata",
"object": "response",
"model": "gpt-5.6-sol",
"status": "in_progress",
"output": []
}
}),
json!({
"type": "response.output_text.delta",
"output_index": 0,
"content_index": 0,
"delta": "Hello from Codex"
}),
json!({"type": "response.completed", "response": terminal_response})
);
let report_context = json!({
"provider_api_format": "openai:responses",
"client_api_format": "openai:responses",
"needs_conversion": false,
});
let body_json =
maybe_build_openai_responses_same_family_sync_body_from_normalized_payload(
"openai_responses_sync_finalize",
200,
Some(&report_context),
None,
Some(&base64::engine::general_purpose::STANDARD.encode(body)),
)
.expect("Responses stream should aggregate")
.expect("sync response should exist");
assert_eq!(
body_json["output"][0]["content"][0]["text"],
"Hello from Codex"
);
}
}
#[test]
fn same_family_responses_stream_uses_terminal_response_as_authoritative_snapshot() {
let terminal_response = json!({
"id": "resp_gpt56_terminal",
"object": "response",
"model": "gpt-5.6-sol",
"status": "completed",
"output": [
{
"type": "program",
"call_id": "program-call-1",
"fingerprint": "fp-program-1"
},
{
"type": "program_output",
"call_id": "program-call-1",
"result": "hello",
"status": "completed"
},
{
"type": "multi_agent_call",
"call_id": "agent-call-1",
"action": "delegate",
"arguments": {"query": "release notes"},
"agent": "researcher"
},
{
"type": "agent_message",
"author": "researcher",
"recipient": "assistant",
"encrypted_content": "encrypted-agent-message"
}
],
"usage": {
"input_tokens": 12,
"output_tokens": 4,
"total_tokens": 16,
"input_tokens_details": {"cache_write_tokens": 3}
}
});
let body = format!(
"event: response.program.delta\ndata: {{\"type\":\"response.program.delta\",\"delta\":\"print\"}}\n\n\
event: response.multi_agent_call.in_progress\ndata: {{\"type\":\"response.multi_agent_call.in_progress\",\"item_id\":\"agent-call-1\"}}\n\n\
event: response.completed\ndata: {}\n\n",
json!({"type": "response.completed", "response": terminal_response})
);
let report_context = json!({
"provider_api_format": "openai:responses",
"client_api_format": "openai:responses",
"needs_conversion": false,
});
let body_json = maybe_build_openai_responses_same_family_sync_body_from_normalized_payload(
"openai_responses_sync_finalize",
200,
Some(&report_context),
None,
Some(&base64::engine::general_purpose::STANDARD.encode(body)),
)
.expect("same-family terminal snapshot should bypass unknown-event aggregation")
.expect("terminal response should become the sync response");
assert_eq!(body_json, terminal_response);
}
#[test]
fn cross_format_responses_stream_still_rejects_unknown_gpt_5_6_events() {
let body = concat!(
"event: response.program.delta\n",
"data: {\"type\":\"response.program.delta\",\"delta\":\"print\"}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_cross_unknown\",\"object\":\"response\",\"model\":\"gpt-5.6-sol\",\"status\":\"completed\",\"output\":[{\"type\":\"program\",\"id\":\"program-1\"}]}}\n\n",
);
let report_context = json!({
"provider_api_format": "openai:responses",
"client_api_format": "openai:chat",
"needs_conversion": true,
});
let result = maybe_build_openai_chat_cross_format_sync_product_from_normalized_payload(
"openai_chat_sync_finalize",
200,
Some(&report_context),
None,
Some(&base64::engine::general_purpose::STANDARD.encode(body)),
);
assert!(result.is_err());
}
#[test]
fn same_family_responses_stream_accepts_authoritative_incomplete_snapshot() {
let terminal_response = json!({
"id": "resp_gpt56_incomplete",
"object": "response",
"model": "gpt-5.6-sol",
"status": "incomplete",
"output": [{
"type": "agent_message",
"author": "researcher",
"recipient": "assistant",
"encrypted_content": "encrypted-partial-message"
}],
"incomplete_details": {"reason": "max_output_tokens"}
});
let body = format!(
"event: response.agent_message.delta\ndata: {{\"type\":\"response.agent_message.delta\",\"delta\":\"partial\"}}\n\n\
event: response.incomplete\ndata: {}\n\n",
json!({"type": "response.incomplete", "response": terminal_response})
);
let report_context = json!({
"provider_api_format": "openai:responses",
"client_api_format": "openai:responses",
"needs_conversion": false,
});
let body_json = maybe_build_openai_responses_same_family_sync_body_from_normalized_payload(
"openai_responses_sync_finalize",
200,
Some(&report_context),
None,
Some(&base64::engine::general_purpose::STANDARD.encode(body)),
)
.expect("incomplete terminal snapshot should remain a valid Responses result")
.expect("terminal response should become the sync response");
assert_eq!(body_json, terminal_response);
}
#[test]
fn same_family_responses_stream_accepts_authoritative_failed_snapshot() {
let terminal_response = json!({
"id": "resp_gpt56_failed",
"object": "response",
"model": "gpt-5.6-sol",
"status": "failed",
"output": [{
"type": "program",
"call_id": "program-call-1",
"fingerprint": "fp-program-1"
}],
"error": {"code": "server_error", "message": "upstream failed"}
});
let body = format!(
"event: response.failed\ndata: {}\n\n",
json!({
"type": "response.failed",
"sequence_number": 3,
"response": terminal_response
})
);
let report_context = json!({
"provider_api_format": "openai:responses",
"client_api_format": "openai:responses",
"needs_conversion": false,
});
let body_json = maybe_build_openai_responses_same_family_sync_body_from_normalized_payload(
"openai_responses_sync_finalize",
200,
Some(&report_context),
None,
Some(&base64::engine::general_purpose::STANDARD.encode(body)),
)
.expect("failed terminal snapshot should remain an authoritative Responses result")
.expect("failed terminal response should become the sync response");
assert_eq!(body_json, terminal_response);
}
#[test]
@@ -4237,7 +4636,7 @@ mod tests {
"event: response.outtext.delta\n",
"data: {\"type\":\"response.outtext.delta\",\"output_index\":0,\"content_index\":0,\"delta\":\"Hello from legacy alias\"}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_legacy_123\",\"object\":\"response\",\"model\":\"gpt-5\",\"status\":\"completed\",\"output\":[],\"usage\":{\"input_tokens\":1,\"output_tokens\":4,\"total_tokens\":5}}}\n\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_legacy_123\",\"object\":\"response\",\"model\":\"gpt-5\",\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"role\":\"assistant\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello from legacy alias\"}]}],\"usage\":{\"input_tokens\":1,\"output_tokens\":4,\"total_tokens\":5}}}\n\n",
);
let report_context = json!({
"provider_api_format": "openai:responses",
@@ -4296,6 +4695,26 @@ mod tests {
assert_eq!(result["output"][0]["output_format"], "png");
}
#[test]
fn reconstructs_openai_responses_compaction_without_output_index_or_terminal_output() {
let body = concat!(
"event: response.output_item.done\n",
"data: {\"type\":\"response.output_item.done\",\"item\":{\"type\":\"compaction\",\"encrypted_content\":\"ENCRYPTED_CONTEXT_COMPACTION_SUMMARY\"}}\n\n",
"event: response.completed\n",
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp-compact\",\"usage\":{\"input_tokens\":0,\"output_tokens\":0,\"total_tokens\":0}}}\n\n",
);
let result = aggregate_openai_responses_stream_sync_response(body.as_bytes())
.expect("compaction stream should aggregate into a sync response");
assert_eq!(result["output"].as_array().map(Vec::len), Some(1));
assert_eq!(result["output"][0]["type"], "compaction");
assert_eq!(
result["output"][0]["encrypted_content"],
"ENCRYPTED_CONTEXT_COMPACTION_SUMMARY"
);
}
#[test]
fn reconstructs_openai_responses_multi_part_message_content_order() {
let body = concat!(
@@ -4947,8 +5366,19 @@ mod tests {
.expect("canonical openai responses -> openai chat");
assert_eq!(converted_openai_chat, legacy_openai_chat);
let mut representable_body_json = provider_body_json.clone();
representable_body_json
.as_object_mut()
.expect("response object")
.remove("service_tier");
representable_body_json["output"][1]["content"] = json!([{
"type": "output_text",
"text": "Hello",
"annotations": []
}]);
let converted_claude = convert_standard_chat_response(
&provider_body_json,
&representable_body_json,
"openai:responses",
"claude:messages",
&report_context,
@@ -4965,7 +5395,7 @@ mod tests {
assert_eq!(converted_claude["usage"]["output_tokens"], 5);
let converted_gemini = convert_standard_chat_response(
&provider_body_json,
&representable_body_json,
"openai:responses",
"gemini:generate_content",
&report_context,
@@ -18,7 +18,8 @@ use crate::formats::shared::stream_core::common::{
build_openai_chat_usage_chunk_with_cache,
};
use crate::formats::shared::stream_core::{
CanonicalStreamFrame, StreamingStandardFormatMatrix, StreamingStandardTerminalObserver,
CanonicalStreamEvent, CanonicalStreamFrame, StreamingStandardFormatMatrix,
StreamingStandardTerminalObserver,
};
use crate::formats::shared::stream_rewrite::maybe_build_ai_surface_stream_rewriter;
use crate::formats::shared::AiSurfaceFinalizeError;
@@ -70,12 +71,21 @@ pub fn maybe_bridge_standard_sync_json_to_stream(
{
return Ok(None);
}
let bridge_context = build_bridge_report_context(
report_context,
provider_api_format.as_str(),
client_api_format.as_str(),
);
if is_openai_responses_family_api_format(provider_api_format.as_str())
&& is_openai_responses_family_api_format(client_api_format.as_str())
{
return bridge_openai_responses_same_family_sync_json_to_stream(
provider_body_json,
provider_api_format.as_str(),
&bridge_context,
);
}
let Some(openai_responses_response) = convert_provider_sync_response_to_openai_responses(
provider_body_json,
provider_api_format.as_str(),
@@ -98,6 +108,46 @@ pub fn maybe_bridge_standard_sync_json_to_stream(
}))
}
fn bridge_openai_responses_same_family_sync_json_to_stream(
response: &Value,
provider_api_format: &str,
report_context: &Value,
) -> Result<Option<SyncToStreamBridgeOutcome>, AiSurfaceFinalizeError> {
let bridge_response = if openai_responses_terminal_event_type(response).is_some() {
Cow::Borrowed(response)
} else if provider_api_format == "openai:responses:compact"
&& response.get("output").is_some_and(Value::is_array)
{
let mut response = response.clone();
let object = response
.as_object_mut()
.expect("Compact response with output should be an object");
object.insert("status".to_string(), Value::String("completed".to_string()));
object
.entry("object".to_string())
.or_insert_with(|| Value::String("response.compaction".to_string()));
Cow::Owned(response)
} else {
return Ok(None);
};
let canonical_frames = build_canonical_frames_from_openai_responses_response(
bridge_response.as_ref(),
report_context,
)?;
let terminal_event_type = openai_responses_terminal_event_type(bridge_response.as_ref())
.expect("bridge response should have a terminal status");
let sse_body = emit_openai_responses_stream_with_authoritative_terminal(
canonical_frames,
response,
terminal_event_type,
)?;
Ok(Some(SyncToStreamBridgeOutcome {
sse_body,
terminal_summary: build_terminal_summary_from_openai_responses_response(response),
}))
}
fn maybe_bridge_openai_image_sync_json_to_stream(
provider_body_json: &Value,
report_context: Option<&Value>,
@@ -462,6 +512,7 @@ fn openai_image_terminal_summary(
.and_then(Value::as_str)
.map(ToOwned::to_owned)
.or_else(|| image_bridge_model(report_context)),
provider_actual_service_tier: None,
observed_finish: true,
unknown_event_count: 0,
parser_error: None,
@@ -616,6 +667,10 @@ fn is_standard_api_format(value: &str) -> bool {
)
}
fn is_openai_responses_family_api_format(value: &str) -> bool {
matches!(value, "openai:responses" | "openai:responses:compact")
}
fn maybe_bridge_aether_sse_response_capture_to_stream(
provider_body_json: &Value,
provider_api_format: &str,
@@ -922,10 +977,12 @@ fn build_canonical_frames_from_openai_responses_response(
report_context: &Value,
) -> Result<Vec<CanonicalStreamFrame>, AiSurfaceFinalizeError> {
let mut state = OpenAIResponsesProviderState::default();
let event_type = openai_responses_terminal_event_type(openai_responses_response)
.unwrap_or("response.completed");
let line = format!(
"data: {}\n",
serde_json::to_string(&json!({
"type": "response.completed",
"type": event_type,
"response": openai_responses_response,
}))
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?
@@ -941,6 +998,15 @@ fn build_canonical_frames_from_openai_responses_response(
Ok(frames)
}
fn openai_responses_terminal_event_type(response: &Value) -> Option<&'static str> {
match response.get("status").and_then(Value::as_str) {
Some("completed") => Some("response.completed"),
Some("incomplete") => Some("response.incomplete"),
Some("failed") => Some("response.failed"),
_ => None,
}
}
fn emit_client_stream_from_canonical_frames(
canonical_frames: Vec<CanonicalStreamFrame>,
client_api_format: &str,
@@ -1006,6 +1072,37 @@ fn emit_with_openai_responses_emitter(
Ok(output)
}
fn emit_openai_responses_stream_with_authoritative_terminal(
canonical_frames: Vec<CanonicalStreamFrame>,
authoritative_response: &Value,
terminal_event_type: &'static str,
) -> Result<Vec<u8>, AiSurfaceFinalizeError> {
let mut emitter = OpenAIResponsesClientEmitter::default();
let mut output = Vec::new();
for frame in canonical_frames {
if matches!(
&frame.event,
CanonicalStreamEvent::Finish { .. } | CanonicalStreamEvent::UnknownEvent(_)
) {
continue;
}
output.extend(
emitter
.emit(frame)
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
);
}
output.extend(
emitter
.finish_with_authoritative_response_event(
authoritative_response.clone(),
terminal_event_type,
)
.map_err(|err| AiSurfaceFinalizeError::new(err.to_string()))?,
);
Ok(output)
}
fn emit_with_claude_emitter(
emitter: &mut ClaudeClientEmitter,
canonical_frames: Vec<CanonicalStreamFrame>,
@@ -1071,6 +1168,12 @@ fn build_terminal_summary_from_openai_responses_response(
finish_reason,
response_id,
model,
provider_actual_service_tier: response
.get("service_tier")
.and_then(Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty() && value.len() <= 64)
.map(str::to_ascii_lowercase),
observed_finish: true,
unknown_event_count: 0,
parser_error: None,
@@ -1114,6 +1217,7 @@ fn standardized_usage_from_openai_usage(value: &Value) -> Option<StandardizedUsa
details
.get("cache_write_tokens")
.or_else(|| details.get("cached_creation_tokens"))
.or_else(|| details.get("cache_creation_tokens"))
})
.and_then(Value::as_i64)
})
@@ -1152,7 +1256,7 @@ fn standardized_usage_from_openai_usage(value: &Value) -> Option<StandardizedUsa
#[cfg(test)]
mod tests {
use serde_json::json;
use serde_json::{json, Value};
use super::{maybe_bridge_standard_sync_json_to_stream, standardized_usage_from_openai_usage};
@@ -1160,6 +1264,26 @@ mod tests {
String::from_utf8(bytes).expect("utf8 should decode")
}
fn json_sse_events(body: &str) -> Vec<Value> {
body.lines()
.filter_map(|line| line.strip_prefix("data: "))
.filter(|line| *line != "[DONE]")
.map(|line| serde_json::from_str(line).expect("valid SSE JSON"))
.collect()
}
fn assert_strictly_increasing_sequence_numbers(events: &[Value]) {
let sequence_numbers = events
.iter()
.filter_map(|event| event.get("sequence_number").and_then(Value::as_u64))
.collect::<Vec<_>>();
assert_eq!(sequence_numbers.len(), events.len());
assert!(
sequence_numbers.windows(2).all(|pair| pair[0] < pair[1]),
"sequence numbers must be strictly increasing: {sequence_numbers:?}"
);
}
#[test]
fn openai_sync_usage_derives_missing_input_tokens_from_total() {
let usage = standardized_usage_from_openai_usage(&json!({
@@ -1176,6 +1300,140 @@ mod tests {
assert_eq!(usage.cache_read_tokens, 19_840);
}
#[test]
fn bridges_same_family_responses_sync_with_standard_lifecycle_and_authoritative_terminal() {
let response = json!({
"id": "resp_gpt56_raw_bridge",
"object": "response",
"model": "gpt-5.6-sol",
"status": "completed",
"output": [
{
"type": "program",
"call_id": "program-call-1",
"fingerprint": "fp-program-1"
},
{
"type": "program_output",
"call_id": "program-call-1",
"result": "hello",
"status": "completed"
},
{
"type": "multi_agent_call",
"call_id": "agent-call-1",
"action": "delegate",
"arguments": {"query": "release notes"},
"agent": "researcher"
},
{
"type": "agent_message",
"author": "researcher",
"recipient": "assistant",
"encrypted_content": "encrypted-agent-message"
}
],
"usage": {
"input_tokens": 20,
"input_tokens_details": {
"cached_tokens": 4,
"cache_write_tokens": 6
},
"output_tokens": 5,
"total_tokens": 25
}
});
let outcome = maybe_bridge_standard_sync_json_to_stream(
&response,
"openai:responses",
"openai:responses:compact",
None,
)
.expect("same-family raw bridge should succeed")
.expect("same-family raw bridge should emit terminal SSE");
let output = utf8(outcome.sse_body);
assert!(output.contains("event: response.created"));
assert!(output.contains("event: response.in_progress"));
let events = json_sse_events(&output);
assert_strictly_increasing_sequence_numbers(&events);
let event = events
.iter()
.find(|event| event["type"] == "response.completed")
.expect("terminal event data should exist");
assert_eq!(events.last(), Some(event));
assert_eq!(event["type"], "response.completed");
assert_eq!(event["response"], response);
}
#[test]
fn bridges_output_only_compact_json_without_reshaping_the_terminal_response() {
let response = json!({
"output": [{
"type": "compaction",
"id": "cmp_123",
"encrypted_content": "encrypted-compact-history"
}]
});
let outcome = maybe_bridge_standard_sync_json_to_stream(
&response,
"openai:responses:compact",
"openai:responses:compact",
None,
)
.expect("Compact bridge should succeed")
.expect("Compact output should emit terminal SSE");
let output = utf8(outcome.sse_body);
assert!(output.contains("event: response.created"));
assert!(output.contains("event: response.in_progress"));
let events = json_sse_events(&output);
let terminal = events
.iter()
.find(|event| event["type"] == "response.completed")
.expect("Compact terminal event should exist");
assert_eq!(terminal["response"], response);
assert_eq!(events.last(), Some(terminal));
}
#[test]
fn bridges_failed_same_family_responses_sync_with_authoritative_terminal() {
let response = json!({
"id": "resp_gpt56_failed",
"object": "response",
"model": "gpt-5.6-sol",
"status": "failed",
"error": {"code": "server_error", "message": "upstream failed"},
"output": [{
"type": "program",
"call_id": "program-call-1",
"fingerprint": "fp-program-1"
}]
});
let outcome = maybe_bridge_standard_sync_json_to_stream(
&response,
"openai:responses",
"openai:responses",
None,
)
.expect("failed same-family raw bridge should succeed")
.expect("failed response should emit terminal SSE");
let output = utf8(outcome.sse_body);
let events = json_sse_events(&output);
assert_strictly_increasing_sequence_numbers(&events);
let event = events
.iter()
.find(|event| event["type"] == "response.failed")
.expect("terminal event data should exist");
assert_eq!(events.last(), Some(event));
assert_eq!(event["type"], "response.failed");
assert_eq!(event["response"], response);
}
#[test]
fn bridges_openai_image_sync_json_to_generation_completed_sse() {
let report_context = json!({
@@ -1284,6 +1542,7 @@ mod tests {
assert!(output.contains("\"finish_reason\":\"stop\""));
assert!(output.contains("\"cached_tokens\":20"));
assert!(output.contains("\"cache_write_tokens\":10"));
assert!(!output.contains("\"cached_creation_tokens\""));
assert!(output.contains("data: [DONE]"));
assert!(!output.contains("image_generation.completed"));
+35 -3
View File
@@ -22,6 +22,29 @@ pub use formats::matrix::{
sync_chat_response_conversion_kind, sync_cli_response_conversion_kind, RequestConversionKind,
SyncChatResponseConversionKind, SyncCliResponseConversionKind,
};
pub use formats::openai::prompt_cache::{
validate_openai_prompt_cache_request, OpenAiPromptCacheContractViolation,
OpenAiPromptCacheViolationKind,
};
pub use formats::openai::reasoning::{
validate_openai_reasoning_request, OpenAiReasoningContractViolation,
OpenAiReasoningViolationKind,
};
pub use formats::openai::request_contract::{
finalize_openai_provider_request,
finalize_openai_provider_request_with_codex_model_capabilities,
validate_openai_provider_request_contract, OpenAiProviderRequestContractViolation,
OpenAiProviderRequestFinalization,
};
pub use formats::openai::responses::codex::{
build_codex_model_catalog_metadata, bundled_codex_model_cards, effective_codex_model_cards,
parse_codex_auth_identity, resolve_codex_responses_model_capabilities, CodexAuthIdentity,
CodexResponsesModelCapabilities, CODEX_CLIENT_ORIGINATOR, CODEX_CLIENT_USER_AGENT,
CODEX_CLIENT_VERSION, CODEX_MODEL_CATALOG_METADATA_FIELD,
};
pub use formats::openai::responses::request::{
validate_openai_responses_request_contract, OpenAiResponsesRequestContractViolation,
};
pub use formats::registry::{
build_stream_transcoder, convert_request, convert_request_pure,
convert_request_pure_with_context, convert_response, convert_response_pure, emit_request_pure,
@@ -30,12 +53,21 @@ pub use formats::registry::{
pub use formats::shared::model_directives::{
apply_model_directive_mapping_patch, apply_model_directive_overrides_from_model,
apply_model_directive_overrides_from_request, claude_model_uses_adaptive_effort,
extract_gemini_model_from_path, gemini_model_uses_thinking_level, model_directive_base_model,
normalize_model_directive_model, parse_model_directive, ModelDirective, ModelOverride,
ReasoningEffort, ServiceTier,
default_model_directive_mapping_patch, default_model_directive_suffixes,
default_model_directives_config, extract_gemini_model_from_path,
gemini_model_uses_thinking_level, model_directive_base_model,
model_directive_builtin_suffix_supported_for_source_model,
model_directive_suffix_has_builtin_mapping, normalize_model_directive_model,
openai_model_supports_prompt_cache_options, parse_model_directive,
parse_model_directive_with_suffixes, reasoning_effort_supported_for_model, ModelDirective,
ModelDirectiveSuffixResolution, ModelOverride, ReasoningEffort, ServiceTier,
CROSS_PROVIDER_MODEL_DIRECTIVE_SUFFIXES, MODEL_DIRECTIVE_API_FORMATS,
OPENAI_MODEL_DIRECTIVE_SUFFIXES,
};
pub use formats::shared::request::{
endpoint_config_forces_upstream_stream_policy, enforce_request_body_stream_field,
forbid_upstream_streaming_for_provider, force_upstream_streaming_for_provider,
parse_direct_request_body, resolve_upstream_is_stream_for_provider,
resolve_upstream_is_stream_from_endpoint_config, UPSTREAM_IS_STREAM_KEY,
};
pub use protocol::canonical::{
@@ -24,6 +24,8 @@ const OPENAI_CHAT_TOOL_RESULT_SOURCE_MARKER: &str = "openai_chat_tool_result";
const OPENAI_RESPONSES_TOOL_RESULT_SOURCE_MARKER: &str = "openai_responses_tool_result";
const OPENAI_RESPONSES_INPUT_MESSAGE_SOURCE_MARKER: &str = "openai_responses_input_message";
const OPENAI_RESPONSES_RAW_SOURCE_MARKER: &str = "openai_responses_raw";
const OPENAI_RESPONSES_RAW_CONTENT_SOURCE_MARKER: &str = "openai_responses_raw_content";
const OPENAI_RESPONSES_CONTENT_MARKER: &str = "openai_responses_content";
const OPENAI_CHAT_TOOL_ERROR_PREFIX: &str = "[tool error]";
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
@@ -785,27 +787,45 @@ pub(crate) fn canonical_tool_use_to_openai_responses_item(
} else if !name.trim().is_empty() {
item.insert("name".to_string(), Value::String(name.to_string()));
}
let extension_fields = openai_responses_item_extension_object(extensions, &item);
item.extend(extension_fields);
return Value::Object(item);
}
if is_openai_custom_tool_call(extensions) {
let item_id = openai_responses_tool_call_item_id(id, extensions);
return json!({
"type": "custom_tool_call",
"id": item_id,
"call_id": id,
"status": "completed",
"name": name,
"input": openai_custom_tool_input_text(input),
});
let mut item = Map::new();
item.insert(
"type".to_string(),
Value::String("custom_tool_call".to_string()),
);
item.insert("id".to_string(), Value::String(item_id));
item.insert("call_id".to_string(), Value::String(id.to_string()));
item.insert("status".to_string(), Value::String("completed".to_string()));
item.insert("name".to_string(), Value::String(name.to_string()));
item.insert(
"input".to_string(),
Value::String(openai_custom_tool_input_text(input)),
);
let extension_fields = openai_responses_item_extension_object(extensions, &item);
item.extend(extension_fields);
return Value::Object(item);
}
let item_id = openai_responses_tool_call_item_id(id, extensions);
json!({
"type": "function_call",
"id": item_id,
"call_id": id,
"name": name,
"arguments": canonicalize_tool_arguments(input),
})
let mut item = Map::new();
item.insert(
"type".to_string(),
Value::String("function_call".to_string()),
);
item.insert("id".to_string(), Value::String(item_id));
item.insert("call_id".to_string(), Value::String(id.to_string()));
item.insert("name".to_string(), Value::String(name.to_string()));
item.insert(
"arguments".to_string(),
Value::String(canonicalize_tool_arguments(input)),
);
let extension_fields = openai_responses_item_extension_object(extensions, &item);
item.extend(extension_fields);
Value::Object(item)
}
pub(crate) fn canonical_tool_use_to_openai_responses_input_item(
@@ -831,6 +851,8 @@ pub(crate) fn canonical_tool_use_to_openai_responses_input_item(
} else if !name.trim().is_empty() {
item.insert("name".to_string(), Value::String(name.to_string()));
}
let extension_fields = openai_responses_item_extension_object(extensions, &item);
item.extend(extension_fields);
return Value::Object(item);
}
if is_openai_custom_tool_call(extensions) {
@@ -849,6 +871,8 @@ pub(crate) fn canonical_tool_use_to_openai_responses_input_item(
"input".to_string(),
Value::String(openai_custom_tool_input_text(input)),
);
let extension_fields = openai_responses_item_extension_object(extensions, &item);
item.extend(extension_fields);
return Value::Object(item);
}
let mut item = Map::new();
@@ -865,6 +889,8 @@ pub(crate) fn canonical_tool_use_to_openai_responses_input_item(
"arguments".to_string(),
Value::String(canonicalize_tool_arguments(input)),
);
let extension_fields = openai_responses_item_extension_object(extensions, &item);
item.extend(extension_fields);
Value::Object(item)
}
@@ -1940,6 +1966,10 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
continue;
}
let Some(item_object) = item.as_object() else {
messages.push(openai_responses_opaque_input_item_message(
item,
String::new(),
));
pending_reasoning = None;
continue;
};
@@ -1960,36 +1990,16 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
.and_then(Value::as_str)
.unwrap_or("user"),
);
if matches!(role, CanonicalRole::System | CanonicalRole::Developer) {
let text = openai_content_text(item_object.get("content"));
if !text.trim().is_empty() {
let mut extensions = openai_responses_extensions(
item_object,
&["type", "role", "content"],
);
mark_openai_responses_input_message(&mut extensions);
messages.push(CanonicalMessage {
role,
content: vec![CanonicalContentBlock::Text {
text,
extensions: BTreeMap::new(),
}],
extensions,
});
}
pending_reasoning = None;
continue;
}
let is_assistant = role == CanonicalRole::Assistant;
let mut extensions =
openai_responses_extensions(item_object, &["type", "role", "content"]);
mark_openai_responses_input_message(&mut extensions);
let mut message = CanonicalMessage {
role,
content: openai_responses_chat_safe_content_to_blocks(
content: openai_responses_content_to_blocks(
item_object.get("content"),
)?,
extensions: openai_responses_extensions(
item_object,
&["type", "role", "content"],
),
extensions,
};
if is_assistant {
if let Some(reasoning) = pending_reasoning.take() {
@@ -2154,6 +2164,7 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
pending_reasoning = None;
}
_ => {
messages.push(openai_responses_opaque_input_item_message(item, item_type));
pending_reasoning = None;
}
}
@@ -2167,6 +2178,18 @@ pub(crate) fn openai_responses_input_to_canonical_messages(
}
}
fn openai_responses_opaque_input_item_message(item: &Value, raw_type: String) -> CanonicalMessage {
CanonicalMessage {
role: CanonicalRole::Unknown,
content: vec![CanonicalContentBlock::Unknown {
raw_type,
payload: item.clone(),
extensions: openai_responses_raw_extensions(BTreeMap::new()),
}],
extensions: BTreeMap::new(),
}
}
fn append_openai_responses_tool_use(
messages: &mut Vec<CanonicalMessage>,
tool_use: CanonicalContentBlock,
@@ -2174,7 +2197,10 @@ fn append_openai_responses_tool_use(
) {
let reasoning = pending_reasoning.take();
if let Some(last_message) = messages.last_mut() {
if last_message.role == CanonicalRole::Assistant {
if last_message.role == CanonicalRole::Assistant
&& (!is_openai_responses_input_message(&last_message.extensions)
|| canonical_assistant_message_has_visible_content(last_message))
{
if let Some(reasoning) = reasoning {
prepend_openai_responses_reasoning_block(last_message, reasoning);
}
@@ -2195,6 +2221,20 @@ fn append_openai_responses_tool_use(
});
}
fn canonical_assistant_message_has_visible_content(message: &CanonicalMessage) -> bool {
message.content.iter().any(|block| match block {
CanonicalContentBlock::Text { text, .. } | CanonicalContentBlock::Thinking { text, .. } => {
!text.trim().is_empty()
}
CanonicalContentBlock::Unknown { payload, .. } => !payload.is_null(),
CanonicalContentBlock::Image { .. }
| CanonicalContentBlock::File { .. }
| CanonicalContentBlock::Audio { .. }
| CanonicalContentBlock::ToolUse { .. }
| CanonicalContentBlock::ToolResult { .. } => true,
})
}
fn prepend_openai_responses_reasoning_block(
message: &mut CanonicalMessage,
reasoning: CanonicalContentBlock,
@@ -2280,17 +2320,6 @@ fn openai_responses_reasoning_text_part(raw: &Value) -> Option<String> {
(!text.is_empty()).then(|| text.to_string())
}
fn openai_responses_chat_safe_content_to_blocks(
content: Option<&Value>,
) -> Option<Vec<CanonicalContentBlock>> {
Some(
openai_responses_content_to_blocks(content)?
.into_iter()
.filter(|block| !matches!(block, CanonicalContentBlock::Unknown { .. }))
.collect(),
)
}
pub(crate) fn openai_responses_content_to_blocks(
content: Option<&Value>,
) -> Option<Vec<CanonicalContentBlock>> {
@@ -2311,14 +2340,15 @@ pub(crate) fn openai_responses_content_to_blocks(
}
}
pub(crate) fn openai_responses_output_to_canonical_blocks(
pub(crate) fn openai_responses_output_to_canonical(
output: Option<&Value>,
) -> Option<Vec<CanonicalContentBlock>> {
) -> Option<(Vec<CanonicalContentBlock>, BTreeMap<String, Value>)> {
let Some(output) = output else {
return Some(Vec::new());
return Some((Vec::new(), BTreeMap::new()));
};
let output_items = output.as_array()?;
let mut blocks = Vec::new();
let mut message_item_provenance = Vec::new();
for (index, item) in output_items.iter().enumerate() {
let Some(item_object) = item.as_object() else {
blocks.push(CanonicalContentBlock::Unknown {
@@ -2336,6 +2366,19 @@ pub(crate) fn openai_responses_output_to_canonical_blocks(
.to_ascii_lowercase();
match item_type.as_str() {
"message" => {
let message_extensions = openai_responses_extensions(
item_object,
&["type", "id", "status", "role", "content"],
)
.remove(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.and_then(|value| value.as_object().cloned())
.unwrap_or_default();
if !message_extensions.is_empty() {
message_item_provenance.push(json!({
"output_index": index,
"fields": message_extensions,
}));
}
blocks.extend(openai_responses_content_to_blocks(
item_object.get("content"),
)?);
@@ -2415,7 +2458,7 @@ pub(crate) fn openai_responses_output_to_canonical_blocks(
.unwrap_or_else(|| format!("call_auto_{index}"));
let mut extensions = openai_responses_extensions(
item_object,
&["type", "id", "call_id", "name", "arguments", "status"],
&["type", "id", "call_id", "name", "arguments"],
);
remember_openai_responses_tool_call_item_id(&mut extensions, item_object);
blocks.push(CanonicalContentBlock::ToolUse {
@@ -2562,7 +2605,14 @@ pub(crate) fn openai_responses_output_to_canonical_blocks(
}),
}
}
Some(blocks)
let mut extensions = BTreeMap::new();
if !message_item_provenance.is_empty() {
extensions.insert(
OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(),
json!({ "message_items": message_item_provenance }),
);
}
Some((blocks, extensions))
}
fn openai_responses_hosted_tool_call_to_block(
@@ -2790,7 +2840,11 @@ pub(crate) fn openai_responses_part_to_canonical_block(
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
extensions: openai_responses_extensions(part_object, &["type", "text"]),
extensions: {
let mut extensions = openai_responses_extensions(part_object, &["type", "text"]);
mark_openai_responses_content_block(&mut extensions);
extensions
},
}),
"reasoning" | "thinking" => Some(CanonicalContentBlock::Thinking {
text: part_object
@@ -2940,12 +2994,12 @@ pub(crate) fn openai_responses_part_to_canonical_block(
"refusal" => Some(CanonicalContentBlock::Unknown {
raw_type,
payload: part.clone(),
extensions: BTreeMap::new(),
extensions: openai_responses_raw_content_extensions(BTreeMap::new()),
}),
_ => Some(CanonicalContentBlock::Unknown {
raw_type,
payload: part.clone(),
extensions: BTreeMap::new(),
extensions: openai_responses_raw_content_extensions(BTreeMap::new()),
}),
}
}
@@ -3309,6 +3363,23 @@ fn openai_responses_raw_extensions(
extensions
}
fn openai_responses_raw_content_extensions(
mut extensions: BTreeMap<String, Value>,
) -> BTreeMap<String, Value> {
canonical_extension_object_mut(&mut extensions, AETHER_EXTENSION_NAMESPACE).insert(
"source".to_string(),
Value::String(OPENAI_RESPONSES_RAW_CONTENT_SOURCE_MARKER.to_string()),
);
extensions
}
fn mark_openai_responses_content_block(extensions: &mut BTreeMap<String, Value>) {
canonical_extension_object_mut(extensions, AETHER_EXTENSION_NAMESPACE).insert(
OPENAI_RESPONSES_CONTENT_MARKER.to_string(),
Value::Bool(true),
);
}
fn openai_thinking_extensions(mut extensions: BTreeMap<String, Value>) -> BTreeMap<String, Value> {
canonical_extension_object_mut(&mut extensions, AETHER_EXTENSION_NAMESPACE).insert(
"source".to_string(),
@@ -3377,6 +3448,22 @@ pub(crate) fn is_openai_responses_raw_block(extensions: &BTreeMap<String, Value>
== Some(OPENAI_RESPONSES_RAW_SOURCE_MARKER)
}
pub(crate) fn is_openai_responses_raw_content_block(extensions: &BTreeMap<String, Value>) -> bool {
extensions
.get(AETHER_EXTENSION_NAMESPACE)
.and_then(|value| value.get("source"))
.and_then(Value::as_str)
== Some(OPENAI_RESPONSES_RAW_CONTENT_SOURCE_MARKER)
}
pub(crate) fn is_openai_responses_content_block(extensions: &BTreeMap<String, Value>) -> bool {
extensions
.get(AETHER_EXTENSION_NAMESPACE)
.and_then(|value| value.get(OPENAI_RESPONSES_CONTENT_MARKER))
.and_then(Value::as_bool)
== Some(true)
}
pub(crate) fn is_openai_responses_input_message(extensions: &BTreeMap<String, Value>) -> bool {
extensions
.get(AETHER_EXTENSION_NAMESPACE)
@@ -3620,16 +3707,19 @@ pub(crate) fn canonical_content_block_to_openai_part(
block: &CanonicalContentBlock,
) -> Option<Value> {
match block {
CanonicalContentBlock::Text { text, .. } => Some(json!({
"type": "text",
"text": text,
})),
CanonicalContentBlock::Text { text, extensions } => {
let mut part = Map::new();
part.insert("type".to_string(), Value::String("text".to_string()));
part.insert("text".to_string(), Value::String(text.clone()));
insert_openai_prompt_cache_breakpoint(&mut part, extensions);
Some(Value::Object(part))
}
CanonicalContentBlock::Image {
data,
url,
media_type,
detail,
..
extensions,
} => {
let mut image = Map::new();
image.insert(
@@ -3639,10 +3729,11 @@ pub(crate) fn canonical_content_block_to_openai_part(
if let Some(detail) = detail {
image.insert("detail".to_string(), Value::String(detail.clone()));
}
Some(json!({
"type": "image_url",
"image_url": Value::Object(image),
}))
let mut part = Map::new();
part.insert("type".to_string(), Value::String("image_url".to_string()));
part.insert("image_url".to_string(), Value::Object(image));
insert_openai_prompt_cache_breakpoint(&mut part, extensions);
Some(Value::Object(part))
}
CanonicalContentBlock::File {
data,
@@ -3650,7 +3741,7 @@ pub(crate) fn canonical_content_block_to_openai_part(
file_url,
media_type,
filename,
..
extensions,
} => {
let mut file = Map::new();
if let Some(value) = file_id {
@@ -3669,18 +3760,30 @@ pub(crate) fn canonical_content_block_to_openai_part(
if let Some(value) = filename {
file.insert("filename".to_string(), Value::String(value.clone()));
}
Some(json!({
"type": "file",
"file": Value::Object(file),
}))
let mut part = Map::new();
part.insert("type".to_string(), Value::String("file".to_string()));
part.insert("file".to_string(), Value::Object(file));
insert_openai_prompt_cache_breakpoint(&mut part, extensions);
Some(Value::Object(part))
}
CanonicalContentBlock::Audio {
data,
format,
extensions,
..
} => {
let mut part = Map::new();
part.insert("type".to_string(), Value::String("input_audio".to_string()));
part.insert(
"input_audio".to_string(),
json!({
"data": data.clone().unwrap_or_default(),
"format": format.clone().unwrap_or_else(|| "mp3".to_string()),
}),
);
insert_openai_prompt_cache_breakpoint(&mut part, extensions);
Some(Value::Object(part))
}
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::Thinking { text, .. } => Some(json!({
"type": "text",
"text": text,
@@ -3691,6 +3794,32 @@ pub(crate) fn canonical_content_block_to_openai_part(
}
}
pub(crate) fn openai_prompt_cache_breakpoint_from_extensions(
extensions: &BTreeMap<String, Value>,
) -> Option<Value> {
[
"openai",
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
]
.into_iter()
.find_map(|namespace| {
extensions
.get(namespace)
.and_then(|value| value.get("prompt_cache_breakpoint"))
.cloned()
})
}
fn insert_openai_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);
}
}
pub(crate) fn canonical_content_block_to_openai_responses_part(
block: &CanonicalContentBlock,
) -> Option<Value> {
@@ -5819,6 +5948,40 @@ pub(crate) fn openai_usage_to_canonical(value: Option<&Value>) -> Option<Canonic
})
}
pub(crate) fn openai_responses_usage_to_canonical(value: Option<&Value>) -> Option<CanonicalUsage> {
let usage = value?.as_object()?;
let mut canonical = openai_usage_to_canonical(value)?;
let provider_fields = usage
.iter()
.filter(|(key, _)| {
!matches!(
key.as_str(),
"input_tokens" | "output_tokens" | "total_tokens"
)
})
.map(|(key, value)| {
let value = if key == "input_tokens_details" {
let mut details = value.as_object().cloned().unwrap_or_default();
details.remove("cached_creation_tokens");
details.remove("cache_creation_tokens");
Value::Object(details)
} else {
value.clone()
};
(key.clone(), value)
})
.collect::<Map<String, Value>>();
canonical.extensions = if provider_fields.is_empty() {
BTreeMap::new()
} else {
BTreeMap::from([(
OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(),
Value::Object(provider_fields),
)])
};
Some(canonical)
}
pub(crate) fn claude_usage_to_canonical(value: Option<&Value>) -> Option<CanonicalUsage> {
let usage = value?.as_object()?;
let input_tokens = usage
@@ -5989,9 +6152,29 @@ pub(crate) fn canonical_usage_to_openai_responses_usage(value: &CanonicalUsage)
output["input_tokens_details"]["cache_write_tokens"] =
Value::from(value.cache_write_tokens);
}
if let (Some(output), Some(provider_fields)) = (
output.as_object_mut(),
openai_responses_extension(&value.extensions).and_then(Value::as_object),
) {
merge_json_object_missing(output, provider_fields);
}
output
}
fn merge_json_object_missing(target: &mut Map<String, Value>, source: &Map<String, Value>) {
for (key, source_value) in source {
match (target.get_mut(key), source_value) {
(Some(Value::Object(target_object)), Value::Object(source_object)) => {
merge_json_object_missing(target_object, source_object);
}
(Some(_), _) => {}
(None, _) => {
target.insert(key.clone(), source_value.clone());
}
}
}
}
pub(crate) fn canonical_usage_to_claude(value: &CanonicalUsage) -> Value {
let mut output = json!({
"input_tokens": canonical_usage_uncached_input_tokens(value),
@@ -6394,6 +6577,24 @@ pub(crate) fn openai_responses_extension(extensions: &BTreeMap<String, Value>) -
.or_else(|| extensions.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE))
}
pub(crate) fn openai_responses_item_extension_object(
extensions: &BTreeMap<String, Value>,
existing: &Map<String, Value>,
) -> Map<String, Value> {
openai_responses_extension(extensions)
.and_then(Value::as_object)
.map(|object| {
object
.iter()
.filter(|(key, _)| {
!existing.contains_key(*key) && !matches!(key.as_str(), "item_id" | "item_type")
})
.map(|(key, value)| (key.clone(), value.clone()))
.collect()
})
.unwrap_or_default()
}
pub(crate) fn strip_claude_billing_header(text: &str) -> String {
let trimmed = text.trim();
let prefix = "x-anthropic-billing-header:";
@@ -8179,6 +8380,67 @@ mod tests {
assert_eq!(rebuilt_gemini["usageMetadata"]["totalTokenCount"], 23);
}
#[test]
fn openai_usage_prefers_cache_write_tokens_and_emits_the_official_field() {
let response = json!({
"id": "resp_cache_write",
"model": "gpt-5.6-sol",
"status": "completed",
"output": [],
"usage": {
"input_tokens": 20,
"output_tokens": 4,
"total_tokens": 24,
"input_tokens_details": {
"cached_tokens": 3,
"cache_write_tokens": 7,
"cached_creation_tokens": 99
}
}
});
let canonical = from_openai_responses_to_canonical_response(&response)
.expect("Responses usage should parse");
assert_eq!(canonical.usage.as_ref().unwrap().cache_write_tokens, 7);
let rebuilt = canonical_to_openai_responses_response(&canonical, &json!({}));
assert_eq!(
rebuilt["usage"]["input_tokens_details"]["cache_write_tokens"],
7
);
assert!(rebuilt["usage"]["input_tokens_details"]
.get("cached_creation_tokens")
.is_none());
let rebuilt_chat = canonical_to_openai_chat_response(&canonical);
assert_eq!(
rebuilt_chat["usage"]["prompt_tokens_details"]["cache_write_tokens"],
7
);
assert!(rebuilt_chat["usage"]["prompt_tokens_details"]
.get("cached_creation_tokens")
.is_none());
}
#[test]
fn openai_usage_accepts_cached_creation_tokens_as_legacy_input_alias() {
let response = json!({
"id": "resp_cache_write_legacy",
"model": "gpt-5.6-sol",
"status": "completed",
"output": [],
"usage": {
"input_tokens": 10,
"output_tokens": 2,
"input_tokens_details": {"cached_creation_tokens": 5}
}
});
let canonical = from_openai_responses_to_canonical_response(&response)
.expect("legacy usage alias should parse");
assert_eq!(canonical.usage.as_ref().unwrap().cache_write_tokens, 5);
}
#[test]
fn gemini_request_adapter_preserves_thinking_tools_media_and_extensions() {
let request = json!({
@@ -50,6 +50,11 @@ pub enum CanonicalStreamEvent {
index: usize,
item: Value,
},
OpenAiResponsesOutputItem {
output_index: Option<usize>,
item: Value,
raw_event: Value,
},
ToolCallStart {
index: usize,
call_id: String,