mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-10 03:09:50 +08:00
feat(openai): align GPT-5.6 and Codex request contracts
This commit is contained in:
@@ -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"));
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user