fix(ai-formats): support cyber policy failover and custom tool/audio passthrough

This commit is contained in:
zhefox
2026-06-17 02:33:14 +08:00
parent f9d97ececb
commit 628a3a0d8d
17 changed files with 2255 additions and 159 deletions
@@ -937,6 +937,7 @@ mod tests {
continue_status_codes: [409, 429].into_iter().collect(),
success_failover_patterns: Vec::new(),
error_stop_patterns: Vec::new(),
stop_cyber_policy_errors: false,
}
);
}
@@ -1,5 +1,6 @@
use crate::handlers::admin::shared::unix_secs_to_rfc3339;
use crate::handlers::public::{request_candidate_event_unix_ms, request_candidate_status_label};
use crate::orchestration::codex_cyber_flag_passthrough_enabled;
use crate::provider_key_auth::provider_key_effective_api_formats;
use aether_data_contracts::repository::candidates::{
RequestCandidateStatus, StoredRequestCandidate,
@@ -204,6 +205,7 @@ pub(crate) fn build_admin_provider_summary_value(
"ops_configured": ops_configured,
"ops_architecture_id": ops_architecture_id,
"kiro_simulated_cache_enabled": kiro_simulated_cache_enabled,
"codex_cyber_flag_passthrough_enabled": codex_cyber_flag_passthrough_enabled(&provider.provider_type, provider.config.as_ref()),
"ops_quota_alert_enabled": ops_quota_alert_enabled,
"created_at": endpoint_timestamp_or_now(provider.created_at_unix_ms, now_unix_secs),
"updated_at": endpoint_timestamp_or_now(provider.updated_at_unix_secs, now_unix_secs),
@@ -33,6 +33,7 @@ pub(crate) enum LocalFailoverClassification {
StopStatusCode,
StopErrorPattern,
StopExecutionError,
StopCyberPolicy,
RetrySuccessPattern,
RetryStatusCode,
RetryUpstreamFailure,
@@ -45,6 +46,7 @@ impl LocalFailoverClassification {
Self::StopStatusCode => "stop_status_code",
Self::StopErrorPattern => "stop_error_pattern",
Self::StopExecutionError => "stop_execution_error",
Self::StopCyberPolicy => "stop_cyber_policy",
Self::RetrySuccessPattern => "retry_success_pattern",
Self::RetryStatusCode => "retry_status_code",
Self::RetryUpstreamFailure => "retry_upstream_failure",
@@ -60,6 +62,13 @@ pub(crate) fn classify_local_failover(
return LocalFailoverClassification::StopStatusCode;
}
if policy.stop_cyber_policy_errors
&& input.status_code >= 400
&& local_error_response_has_cyber_policy_code(input.response_text)
{
return LocalFailoverClassification::StopCyberPolicy;
}
if input.status_code >= 400
&& policy.error_stop_patterns.iter().any(|rule| {
local_failover_regex_rule_matches(rule, input.response_text, input.status_code)
@@ -104,6 +113,44 @@ fn should_failover_local_upstream_status(status_code: u16) -> bool {
status_code >= 400
}
fn local_error_response_has_cyber_policy_code(response_text: Option<&str>) -> bool {
let Some(response_text) = response_text else {
return false;
};
let Ok(value) = serde_json::from_str::<Value>(response_text) else {
return false;
};
json_value_has_cyber_policy_code(&value, 0)
}
fn json_value_has_cyber_policy_code(value: &Value, depth: usize) -> bool {
if depth > 16 {
return false;
}
match value {
Value::Object(object) => object.iter().any(|(key, value)| {
(key == "code"
&& value
.as_str()
.is_some_and(|code| code.eq_ignore_ascii_case("cyber_policy")))
|| json_value_has_cyber_policy_code(value, depth + 1)
}),
Value::Array(values) => values
.iter()
.any(|value| json_value_has_cyber_policy_code(value, depth + 1)),
Value::String(text) => {
let text = text.trim_start();
if !text.starts_with('{') && !text.starts_with('[') {
return false;
}
serde_json::from_str::<Value>(text)
.ok()
.is_some_and(|value| json_value_has_cyber_policy_code(&value, depth + 1))
}
_ => false,
}
}
fn parse_local_error_response(response_text: Option<&str>) -> ParsedLocalErrorResponse {
let raw = response_text
.map(str::trim)
@@ -310,6 +357,58 @@ mod tests {
);
}
#[test]
fn classifier_stops_cyber_policy_when_policy_enabled() {
let policy = LocalFailoverPolicy {
stop_cyber_policy_errors: true,
..LocalFailoverPolicy::default()
};
assert_eq!(
classify_local_failover(
&policy,
LocalFailoverInput::new(
400,
Some(
r#"{"type":"error","error":{"type":"invalid_request","code":"cyber_policy","message":"flagged"}}"#,
)
)
),
LocalFailoverClassification::StopCyberPolicy
);
assert_eq!(
classify_local_failover(
&policy,
LocalFailoverInput::new(
400,
Some(r#"{"outer":{"error":{"code":"cyber_policy"}}}"#)
)
),
LocalFailoverClassification::StopCyberPolicy
);
assert_eq!(
classify_local_failover(
&policy,
LocalFailoverInput::new(400, Some(r#"{"error":{"code":"other"}}"#))
),
LocalFailoverClassification::RetryUpstreamFailure
);
}
#[test]
fn classifier_retries_cyber_policy_when_policy_disabled() {
assert_eq!(
classify_local_failover(
&LocalFailoverPolicy::default(),
LocalFailoverInput::new(
400,
Some(r#"{"error":{"code":"cyber_policy","message":"flagged"}}"#)
)
),
LocalFailoverClassification::RetryUpstreamFailure
);
}
#[test]
fn classifier_detects_success_continue_status_code() {
let policy = LocalFailoverPolicy {
@@ -903,7 +903,8 @@ fn local_candidate_failure_should_invalidate_affinity(
status_code >= 500
}
LocalFailoverClassification::StopErrorPattern
| LocalFailoverClassification::StopExecutionError => false,
| LocalFailoverClassification::StopExecutionError
| LocalFailoverClassification::StopCyberPolicy => false,
}
}
@@ -332,7 +332,8 @@ fn local_candidate_failure_should_project_health(
status_code >= 500
}
LocalFailoverClassification::StopErrorPattern
| LocalFailoverClassification::StopExecutionError => false,
| LocalFailoverClassification::StopExecutionError
| LocalFailoverClassification::StopCyberPolicy => false,
}
}
+4 -3
View File
@@ -38,9 +38,9 @@ pub(crate) use self::health::{
project_local_key_circuit_failure, project_local_success_health,
};
pub(crate) use self::policy::{
append_local_failover_policy_to_value, local_failover_policy_from_report_context,
local_failover_policy_from_transport, resolve_local_failover_policy, LocalFailoverPolicy,
LocalFailoverRegexRule,
append_local_failover_policy_to_value, codex_cyber_flag_passthrough_enabled,
local_failover_policy_from_report_context, local_failover_policy_from_transport,
resolve_local_failover_policy, LocalFailoverPolicy, LocalFailoverRegexRule,
};
pub(crate) use self::recovery::{
analyze_local_failover, recover_local_failover_decision, LocalFailoverAnalysis,
@@ -95,6 +95,7 @@ pub(crate) fn build_local_error_flow_metadata(
LocalFailoverClassification::StopStatusCode
| LocalFailoverClassification::StopErrorPattern
| LocalFailoverClassification::StopExecutionError
| LocalFailoverClassification::StopCyberPolicy
);
let propagation = match analysis.decision {
LocalFailoverDecision::RetryNextCandidate => "suppressed",
@@ -14,6 +14,7 @@ pub(crate) struct LocalFailoverPolicy {
pub(crate) continue_status_codes: BTreeSet<u16>,
pub(crate) success_failover_patterns: Vec<LocalFailoverRegexRule>,
pub(crate) error_stop_patterns: Vec<LocalFailoverRegexRule>,
pub(crate) stop_cyber_policy_errors: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -80,6 +81,10 @@ pub(crate) fn local_failover_policy_from_transport(
LocalFailoverPolicy {
max_retries,
stop_cyber_policy_errors: codex_cyber_flag_passthrough_enabled(
&transport.provider.provider_type,
transport.provider.config.as_ref(),
),
stop_status_codes: rules
.map(|value| {
parse_status_code_set(
@@ -135,6 +140,10 @@ pub(crate) fn local_failover_policy_from_report_context(
.unwrap_or_default(),
success_failover_patterns: parse_regex_rules(object, "success_failover_patterns"),
error_stop_patterns: parse_regex_rules(object, "error_stop_patterns"),
stop_cyber_policy_errors: object
.get("stop_cyber_policy_errors")
.and_then(Value::as_bool)
.unwrap_or(false),
})
}
@@ -168,9 +177,29 @@ fn local_failover_policy_to_value(policy: &LocalFailoverPolicy) -> Value {
"continue_status_codes": policy.continue_status_codes.iter().copied().collect::<Vec<_>>(),
"success_failover_patterns": policy.success_failover_patterns.iter().map(local_failover_regex_rule_to_value).collect::<Vec<_>>(),
"error_stop_patterns": policy.error_stop_patterns.iter().map(local_failover_regex_rule_to_value).collect::<Vec<_>>(),
"stop_cyber_policy_errors": policy.stop_cyber_policy_errors,
})
}
pub(crate) fn codex_cyber_flag_passthrough_enabled(
provider_type: &str,
provider_config: Option<&Value>,
) -> bool {
if !provider_type.trim().eq_ignore_ascii_case("codex") {
return false;
}
provider_config
.and_then(|config| config.get("codex"))
.and_then(Value::as_object)
.and_then(|codex| {
codex
.get("pass_through_cyber_flag_interrupt")
.or_else(|| codex.get("passthrough_cyber_flag_interrupt"))
.and_then(Value::as_bool)
})
.unwrap_or(true)
}
fn local_failover_regex_rule_to_value(rule: &LocalFailoverRegexRule) -> Value {
json!({
"pattern": rule.pattern,
@@ -345,7 +374,28 @@ mod tests {
pattern: "validation".to_string(),
status_codes: [422].into_iter().collect(),
}],
stop_cyber_policy_errors: false,
})
);
}
#[test]
fn codex_cyber_policy_passthrough_defaults_on_and_can_be_disabled() {
let mut transport = sample_transport(None, None, None);
transport.provider.provider_type = "codex".to_string();
assert!(local_failover_policy_from_transport(&transport).stop_cyber_policy_errors);
transport.provider.config = Some(json!({
"codex": {"pass_through_cyber_flag_interrupt": false}
}));
assert!(!local_failover_policy_from_transport(&transport).stop_cyber_policy_errors);
transport.provider.config = Some(json!({
"codex": {"passthrough_cyber_flag_interrupt": true}
}));
assert!(local_failover_policy_from_transport(&transport).stop_cyber_policy_errors);
transport.provider.provider_type = "llm".to_string();
assert!(!local_failover_policy_from_transport(&transport).stop_cyber_policy_errors);
}
}
@@ -58,9 +58,8 @@ const fn decision_from_classification(
LocalFailoverClassification::UseDefault => LocalFailoverDecision::UseDefault,
LocalFailoverClassification::StopStatusCode
| LocalFailoverClassification::StopErrorPattern
| LocalFailoverClassification::StopExecutionError => {
LocalFailoverDecision::StopLocalFailover
}
| LocalFailoverClassification::StopExecutionError
| LocalFailoverClassification::StopCyberPolicy => LocalFailoverDecision::StopLocalFailover,
LocalFailoverClassification::RetrySuccessPattern
| LocalFailoverClassification::RetryStatusCode
| LocalFailoverClassification::RetryUpstreamFailure => {
@@ -144,4 +143,22 @@ mod tests {
LocalFailoverClassification::RetryUpstreamFailure
);
}
#[test]
fn recovery_stops_cyber_policy_failover() {
let policy = LocalFailoverPolicy {
stop_cyber_policy_errors: true,
..LocalFailoverPolicy::default()
};
let analysis = analyze_local_failover(
&policy,
LocalFailoverInput::new(400, Some(r#"{"error":{"code":"cyber_policy"}}"#)),
);
assert_eq!(analysis.decision, LocalFailoverDecision::StopLocalFailover);
assert_eq!(
analysis.classification,
LocalFailoverClassification::StopCyberPolicy
);
}
}
@@ -221,7 +221,7 @@ mod tests {
}
#[test]
fn claude_request_to_chat_clamps_max_reasoning_effort_to_high() {
fn claude_request_to_chat_maps_max_reasoning_effort_to_xhigh() {
let body = json!({
"model": "claude-sonnet",
"messages": [{"role": "user", "content": "hello"}],
@@ -233,11 +233,11 @@ mod tests {
let converted =
normalize_claude_request_to_openai_chat_request(&body).expect("openai chat request");
assert_eq!(converted["reasoning_effort"], "high");
assert_eq!(converted["reasoning_effort"], "xhigh");
}
#[test]
fn gemini_request_to_chat_clamps_xhigh_reasoning_effort_to_high() {
fn gemini_request_to_chat_preserves_xhigh_reasoning_effort() {
let body = json!({
"contents": [{
"role": "user",
@@ -254,7 +254,7 @@ mod tests {
)
.expect("openai chat request");
assert_eq!(converted["reasoning_effort"], "high");
assert_eq!(converted["reasoning_effort"], "xhigh");
}
#[test]
@@ -372,7 +372,8 @@ mod tests {
}
#[test]
fn responses_request_normalizer_clamps_chat_reasoning_effort_and_filters_extensions() {
fn responses_request_normalizer_preserves_official_chat_reasoning_effort_and_filters_extensions(
) {
let body = json!({
"model": "gpt-5.1",
"input": "hello",
@@ -388,7 +389,7 @@ mod tests {
let converted = normalize_openai_responses_request_to_openai_chat_request(&body)
.expect("openai chat request");
assert_eq!(converted["reasoning_effort"], "high");
assert_eq!(converted["reasoning_effort"], "xhigh");
assert_eq!(converted["verbosity"], "high");
assert_eq!(converted["service_tier"], "priority");
assert_eq!(converted["prompt_cache_key"], "cache_123");
@@ -399,6 +400,22 @@ mod tests {
assert!(converted.get("reasoning").is_none());
}
#[test]
fn responses_request_normalizer_preserves_none_and_minimal_chat_reasoning_effort() {
for effort in ["none", "minimal"] {
let body = json!({
"model": "gpt-5.1",
"input": "hello",
"reasoning": {"effort": effort},
});
let converted = normalize_openai_responses_request_to_openai_chat_request(&body)
.expect("openai chat request");
assert_eq!(converted["reasoning_effort"], effort);
}
}
#[test]
fn request_normalizer_preserves_multiple_claude_tool_results() {
let body = json!({
@@ -2,16 +2,19 @@ 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,
canonical_tool_to_openai, is_claude_tool_result, namespace_extension_object,
openai_content_text, openai_extensions, openai_generation_config,
openai_message_content_blocks, openai_response_format_to_canonical,
openai_responses_extension, openai_role_to_canonical, openai_tool_choice_to_canonical,
openai_tools_to_canonical, write_openai_generation_config, CanonicalContentBlock,
CanonicalInstruction, CanonicalRequest, CanonicalRole, CanonicalThinkingConfig,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
canonical_tool_is_openai_custom, canonical_tool_to_openai, is_claude_tool_result,
namespace_extension_object, openai_content_text, openai_extensions,
openai_generation_config, openai_message_content_blocks,
openai_response_format_to_canonical, openai_responses_extension, openai_role_to_canonical,
openai_tool_choice_raw_to_chat, openai_tool_choice_to_canonical, openai_tools_to_canonical,
write_openai_generation_config, CanonicalContentBlock, CanonicalInstruction,
CanonicalRequest, CanonicalRole, CanonicalThinkingConfig, CanonicalToolChoice,
CanonicalToolDefinition, OPENAI_RESPONSES_EXTENSION_NAMESPACE,
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
},
};
@@ -108,7 +111,6 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
"top_k",
"stop",
"tools",
"tool_choice",
"parallel_tool_calls",
"metadata",
"response_format",
@@ -121,6 +123,9 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
"top_logprobs",
],
);
if canonical.tool_choice.is_some() {
remove_tool_choice_extension(&mut canonical.extensions, "openai");
}
if let Some(verbosity) = request.get("verbosity").cloned() {
canonical_extension_object_mut(
&mut canonical.extensions,
@@ -168,11 +173,8 @@ pub fn to_raw(canonical: &CanonicalRequest) -> Value {
),
);
}
if let Some(tool_choice) = &canonical.tool_choice {
output.insert(
"tool_choice".to_string(),
canonical_tool_choice_to_openai(tool_choice),
);
if let Some(tool_choice) = canonical_tool_choice_to_openai_for_request(canonical) {
output.insert("tool_choice".to_string(), tool_choice);
}
if let Some(value) = canonical.parallel_tool_calls {
output.insert("parallel_tool_calls".to_string(), Value::Bool(value));
@@ -223,6 +225,60 @@ pub fn to_raw(canonical: &CanonicalRequest) -> Value {
Value::Object(output)
}
fn canonical_tool_choice_to_openai_for_request(canonical: &CanonicalRequest) -> Option<Value> {
canonical
.tool_choice
.as_ref()
.map(|tool_choice| canonical_tool_choice_to_openai_for_tools(tool_choice, &canonical.tools))
.or_else(|| raw_tool_choice_extension(canonical).map(openai_tool_choice_raw_to_chat))
}
fn canonical_tool_choice_to_openai_for_tools(
choice: &CanonicalToolChoice,
tools: &[CanonicalToolDefinition],
) -> Value {
match choice {
CanonicalToolChoice::Tool { name }
if tools
.iter()
.any(|tool| tool.name == *name && canonical_tool_is_openai_custom(tool)) =>
{
json!({
"type": "custom",
"custom": { "name": name },
})
}
_ => canonical_tool_choice_to_openai(choice),
}
}
fn raw_tool_choice_extension(canonical: &CanonicalRequest) -> Option<&Value> {
canonical
.extensions
.get("openai")
.and_then(|value| value.get("tool_choice"))
.or_else(|| {
openai_responses_extension(&canonical.extensions)
.and_then(|value| value.get("tool_choice"))
})
}
fn remove_tool_choice_extension(
extensions: &mut std::collections::BTreeMap<String, Value>,
namespace: &str,
) {
let should_remove_namespace = extensions
.get_mut(namespace)
.and_then(Value::as_object_mut)
.is_some_and(|object| {
object.remove("tool_choice");
object.is_empty()
});
if should_remove_namespace {
extensions.remove(namespace);
}
}
fn canonical_request_has_unrepresentable_claude_tool_result_for_openai_chat(
request: &CanonicalRequest,
) -> bool {
@@ -312,12 +368,10 @@ fn non_empty_source_str<'a>(source: &'a Map<String, Value>, key: &str) -> Option
}
fn openai_chat_reasoning_effort(value: &str) -> Option<&'static str> {
match value.trim().to_ascii_lowercase().as_str() {
"low" => Some("low"),
"medium" => Some("medium"),
"high" | "xhigh" | "max" => Some("high"),
_ => None,
if value.trim().eq_ignore_ascii_case("max") {
return Some("xhigh");
}
OpenAiChatReasoningEffort::parse(value).map(OpenAiChatReasoningEffort::as_str)
}
fn chat_compatible_openai_responses_extension_object(
@@ -1528,6 +1528,48 @@ impl OpenAIResponsesProviderState {
&content,
);
}
event_type if openai_responses_hosted_tool_output_item_type(event_type).is_some() => {
let item_type = openai_responses_hosted_tool_output_item_type(event_type)
.expect("guarded by is_some");
let tool_use_id = value
.get("call_id")
.or_else(|| value.get("tool_call_id"))
.or_else(|| value.get("item_id"))
.or_else(|| value.get("id"))
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.unwrap_or("call_auto_0")
.to_string();
let output_index = value
.get("output_index")
.and_then(Value::as_u64)
.map(|value| value as usize);
let index = self
.tool_index_for_key(Some(format!("{item_type}:{tool_use_id}")), output_index);
let content = openai_tool_result_content_from_value(
value
.get("delta")
.or_else(|| value.get("output"))
.or_else(|| value.get("content")),
);
let name = openai_responses_hosted_tool_output_name(item_type)
.map(ToOwned::to_owned)
.or_else(|| {
value
.get("name")
.and_then(Value::as_str)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
});
self.emit_missing_tool_result(
report_context,
&mut out,
index,
tool_use_id,
name,
&content,
);
}
"response.output_item.done" => {
let Some(item) = value.get("item").and_then(Value::as_object) else {
return Ok(out);
@@ -3114,9 +3156,56 @@ fn openai_responses_stream_event_is_known_noop(event_type: &str) -> bool {
| "response.web_search_call.in_progress"
| "response.web_search_call.searching"
| "response.web_search_call.completed"
| "response.local_shell_call.in_progress"
| "response.local_shell_call.running"
| "response.local_shell_call.completed"
| "response.local_shell_call.failed"
| "response.shell_call.in_progress"
| "response.shell_call.running"
| "response.shell_call.completed"
| "response.shell_call.failed"
| "response.apply_patch_call.in_progress"
| "response.apply_patch_call.running"
| "response.apply_patch_call.completed"
| "response.apply_patch_call.failed"
| "response.computer_call.in_progress"
| "response.computer_call.running"
| "response.computer_call.completed"
| "response.computer_call.failed"
)
}
fn openai_responses_hosted_tool_output_item_type(event_type: &str) -> Option<&'static str> {
match event_type {
"response.custom_tool_call_output.delta" | "response.custom_tool_call_output.done" => {
Some("custom_tool_call_output")
}
"response.local_shell_call_output.delta" | "response.local_shell_call_output.done" => {
Some("local_shell_call_output")
}
"response.shell_call_output.delta" | "response.shell_call_output.done" => {
Some("shell_call_output")
}
"response.apply_patch_call_output.delta" | "response.apply_patch_call_output.done" => {
Some("apply_patch_call_output")
}
"response.computer_call_output.delta" | "response.computer_call_output.done" => {
Some("computer_call_output")
}
_ => None,
}
}
fn openai_responses_hosted_tool_output_name(item_type: &str) -> Option<&'static str> {
match item_type {
"local_shell_call_output" => Some("local_shell"),
"shell_call_output" => Some("shell"),
"apply_patch_call_output" => Some("apply_patch"),
"computer_call_output" => Some("computer"),
_ => None,
}
}
fn openai_responses_incomplete_finish_reason(payload: &Value) -> String {
let reason = payload
.get("response")
@@ -4011,6 +4100,78 @@ mod tests {
)));
}
#[test]
fn openai_responses_provider_state_ignores_hosted_tool_progress_events() {
let mut state = OpenAIResponsesProviderState::default();
let report_context = json!({});
let mut frames = Vec::new();
for event_type in [
"response.local_shell_call.in_progress",
"response.local_shell_call.running",
"response.local_shell_call.completed",
"response.apply_patch_call.in_progress",
"response.apply_patch_call.completed",
"response.computer_call.in_progress",
"response.computer_call.completed",
] {
frames.extend(
state
.push_line(
&report_context,
data_line(json!({
"type": event_type,
"response_id": "resp_123",
"output_index": 0,
"item_id": "call_123",
})),
)
.expect("hosted tool progress event should parse"),
);
}
assert!(frames
.iter()
.any(|frame| matches!(frame.event, CanonicalStreamEvent::Start)));
assert!(!frames
.iter()
.any(|frame| matches!(frame.event, CanonicalStreamEvent::UnknownEvent { .. })));
}
#[test]
fn openai_responses_provider_state_parses_hosted_tool_output_as_tool_result() {
let mut state = OpenAIResponsesProviderState::default();
let report_context = json!({});
let frames = state
.push_line(
&report_context,
data_line(json!({
"type": "response.local_shell_call_output.done",
"response_id": "resp_123",
"output_index": 3,
"call_id": "call_shell_123",
"output": {
"stdout": "ok\n",
"stderr": "",
"outcome": "success"
},
})),
)
.expect("hosted tool result should parse");
assert!(frames.iter().any(|frame| matches!(
frame.event,
CanonicalStreamEvent::ToolResultDelta {
index: 3,
ref tool_use_id,
name: Some(ref name),
ref content,
} if tool_use_id == "call_shell_123"
&& name == "local_shell"
&& content.contains("\"stdout\":\"ok\\n\"")
)));
}
#[test]
fn openai_responses_provider_state_preserves_image_generation_calls() {
let mut state = OpenAIResponsesProviderState::default();
@@ -8,12 +8,14 @@ use crate::{
map_thinking_budget_to_openai_reasoning_effort, OpenAiResponsesReasoningEffort,
},
protocol::canonical::{
canonical_response_format_to_openai, canonicalize_tool_arguments,
is_claude_messages_request, is_claude_system_instruction, is_claude_thinking_block,
is_claude_tool_result, media_data_or_url, namespace_extension_object, openai_content_text,
openai_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,
canonical_response_format_to_openai_responses, canonical_tool_is_openai_custom,
canonical_tool_use_to_openai_responses_item, is_claude_messages_request,
is_claude_system_instruction, is_claude_thinking_block, is_claude_tool_result,
is_openai_thinking_block, media_data_or_url, namespace_extension_object,
openai_content_text, openai_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,
CanonicalContentBlock, CanonicalInstruction, CanonicalRequest, CanonicalRole,
CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition,
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
@@ -98,7 +100,6 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
"top_p",
"metadata",
"tools",
"tool_choice",
"parallel_tool_calls",
"text",
"reasoning",
@@ -109,6 +110,12 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
.extensions
.insert(OPENAI_RESPONSES_EXTENSION_NAMESPACE.to_string(), raw);
}
if canonical.tool_choice.is_some() {
remove_tool_choice_extension(
&mut canonical.extensions,
OPENAI_RESPONSES_EXTENSION_NAMESPACE,
);
}
if let Some(verbosity) = request
.get("text")
.and_then(Value::as_object)
@@ -174,11 +181,8 @@ pub fn to_raw(
Value::Array(canonical_tools_to_responses(canonical)),
);
}
if let Some(tool_choice) = canonical.tool_choice.as_ref() {
output.insert(
"tool_choice".to_string(),
canonical_tool_choice_to_responses(tool_choice),
);
if let Some(tool_choice) = canonical_tool_choice_to_responses_for_request(canonical) {
output.insert("tool_choice".to_string(), tool_choice);
}
if let Some(reasoning) = canonical_reasoning_config_to_responses(canonical) {
output.insert("reasoning".to_string(), reasoning);
@@ -313,19 +317,16 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
id,
name,
input: arguments,
..
extensions,
} => {
flush_responses_message(&mut input, role, &mut content);
saw_tool_item = true;
let call_id = responses_tool_call_id(id, &mut next_generated_tool_call_index);
let tool_name = responses_tool_name(name);
pending_tool_call_ids.push_back(call_id.clone());
input.push(json!({
"type": "function_call",
"call_id": call_id,
"name": tool_name,
"arguments": canonicalize_tool_arguments(arguments),
}));
input.push(canonical_tool_use_to_openai_responses_item(
&call_id, &tool_name, arguments, extensions,
));
}
CanonicalContentBlock::ToolResult {
tool_use_id,
@@ -344,7 +345,8 @@ 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": "function_call_output",
"type": responses_tool_result_item_type(extensions)
.unwrap_or("function_call_output"),
"call_id": call_id,
"output": tool_output,
}));
@@ -357,11 +359,28 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
}
}
CanonicalContentBlock::Thinking {
text, extensions, ..
text,
encrypted_content,
extensions,
..
} => {
if is_claude_thinking_block(extensions) {
continue;
}
if role == "assistant"
&& is_openai_responses_reasoning_history_block(extensions)
{
flush_responses_message(&mut input, role, &mut content);
if let Some(reasoning_item) = canonical_thinking_to_responses_reasoning_item(
text,
encrypted_content.as_deref(),
extensions,
) {
input.push(reasoning_item);
saw_tool_item = true;
}
continue;
}
if role == "assistant" && !text.trim().is_empty() {
content.push(json!({
"type": "output_text",
@@ -526,6 +545,43 @@ fn flush_responses_message(input: &mut Vec<Value>, role: &str, content: &mut Vec
}));
}
fn canonical_thinking_to_responses_reasoning_item(
text: &str,
encrypted_content: Option<&str>,
extensions: &BTreeMap<String, Value>,
) -> Option<Value> {
let mut item = openai_responses_extension(extensions)
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
item.remove("item_type");
item.insert("type".to_string(), Value::String("reasoning".to_string()));
if !text.trim().is_empty() {
item.entry("summary".to_string()).or_insert_with(|| {
json!([{
"type": "summary_text",
"text": text,
}])
});
}
if let Some(value) = encrypted_content.filter(|value| !value.is_empty()) {
item.insert(
"encrypted_content".to_string(),
Value::String(value.to_string()),
);
}
(item.len() > 1).then_some(Value::Object(item))
}
fn is_openai_responses_reasoning_history_block(extensions: &BTreeMap<String, Value>) -> bool {
is_openai_thinking_block(extensions)
&& openai_responses_extension(extensions)
.and_then(Value::as_object)
.and_then(|object| object.get("item_type"))
.and_then(Value::as_str)
== Some("reasoning")
}
fn canonical_block_to_responses_input_part(
block: &CanonicalContentBlock,
role: &str,
@@ -579,10 +635,14 @@ fn canonical_block_to_responses_input_part(
item.insert("file_id".to_string(), Value::String(value.clone()));
}
if data.is_some() || file_url.is_some() {
item.insert(
"file_data".to_string(),
Value::String(media_data_or_url(media_type, data, file_url)),
);
if data.is_some() {
item.insert(
"file_data".to_string(),
Value::String(media_data_or_url(media_type, data, file_url)),
);
} else if let Some(value) = file_url {
item.insert("file_url".to_string(), Value::String(value.clone()));
}
}
if let Some(value) = filename {
item.insert("filename".to_string(), Value::String(value.clone()));
@@ -714,7 +774,7 @@ fn canonical_text_config_to_responses(canonical: &CanonicalRequest) -> Option<Va
if let Some(response_format) = &canonical.response_format {
text.insert(
"format".to_string(),
canonical_response_format_to_openai(response_format),
canonical_response_format_to_openai_responses(response_format),
);
}
if let Some(verbosity) = canonical
@@ -757,6 +817,14 @@ fn canonical_tool_to_responses(tool: &CanonicalToolDefinition) -> Value {
{
return raw.clone();
}
if let Some(raw) = tool.extensions.get("openai").filter(|value| {
value
.get("type")
.and_then(Value::as_str)
.is_some_and(|tool_type| tool_type.eq_ignore_ascii_case("custom"))
}) {
return openai_chat_custom_tool_to_responses_tool(tool, raw);
}
let mut out = Map::new();
out.insert("type".to_string(), Value::String("function".to_string()));
out.insert("name".to_string(), Value::String(tool.name.clone()));
@@ -781,6 +849,22 @@ fn canonical_tool_to_responses(tool: &CanonicalToolDefinition) -> Value {
Value::Object(out)
}
fn openai_chat_custom_tool_to_responses_tool(tool: &CanonicalToolDefinition, raw: &Value) -> Value {
let mut out = raw
.get("custom")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default();
out.insert("type".to_string(), Value::String("custom".to_string()));
out.entry("name".to_string())
.or_insert_with(|| Value::String(tool.name.clone()));
if let Some(description) = &tool.description {
out.entry("description".to_string())
.or_insert_with(|| Value::String(description.clone()));
}
Value::Object(out)
}
fn responses_tool_parameters_schema(parameters: Option<&Value>) -> Value {
match parameters {
Some(Value::Object(schema)) => {
@@ -800,11 +884,59 @@ fn responses_tool_parameters_schema(parameters: Option<&Value>) -> Value {
}
}
fn canonical_tool_choice_to_responses(choice: &CanonicalToolChoice) -> Value {
fn canonical_tool_choice_to_responses_for_request(canonical: &CanonicalRequest) -> Option<Value> {
canonical
.tool_choice
.as_ref()
.map(|tool_choice| canonical_tool_choice_to_responses(tool_choice, &canonical.tools))
.or_else(|| raw_tool_choice_extension(canonical).map(openai_tool_choice_raw_to_responses))
}
fn raw_tool_choice_extension(canonical: &CanonicalRequest) -> Option<&Value> {
canonical
.extensions
.get("openai")
.and_then(|value| value.get("tool_choice"))
.or_else(|| {
openai_responses_extension(&canonical.extensions)
.and_then(|value| value.get("tool_choice"))
})
}
fn remove_tool_choice_extension(
extensions: &mut std::collections::BTreeMap<String, Value>,
namespace: &str,
) {
let should_remove_namespace = extensions
.get_mut(namespace)
.and_then(Value::as_object_mut)
.is_some_and(|object| {
object.remove("tool_choice");
object.is_empty()
});
if should_remove_namespace {
extensions.remove(namespace);
}
}
fn canonical_tool_choice_to_responses(
choice: &CanonicalToolChoice,
tools: &[CanonicalToolDefinition],
) -> Value {
match choice {
CanonicalToolChoice::Auto => Value::String("auto".to_string()),
CanonicalToolChoice::None => Value::String("none".to_string()),
CanonicalToolChoice::Required => Value::String("required".to_string()),
CanonicalToolChoice::Tool { name }
if tools
.iter()
.any(|tool| tool.name == *name && canonical_tool_is_openai_custom(tool)) =>
{
json!({
"type": "custom",
"name": name,
})
}
CanonicalToolChoice::Tool { name } => json!({
"type": "function",
"name": name,
@@ -817,10 +949,13 @@ fn responses_tool_result_payload(
content_text: Option<&str>,
extensions: &BTreeMap<String, Value>,
) -> Option<(Value, Vec<Value>)> {
if is_claude_tool_result(extensions) {
if let Some(Value::Array(parts)) = output {
if let Some(Value::Array(parts)) = output {
if is_claude_tool_result(extensions) {
return claude_tool_result_parts_to_responses_payload(parts);
}
if let Some(output) = openai_chat_tool_result_parts_to_responses_output(parts) {
return Some((output, Vec::new()));
}
}
Some((
responses_tool_result_output(output, content_text),
@@ -828,6 +963,117 @@ fn responses_tool_result_payload(
))
}
fn responses_tool_result_item_type(extensions: &BTreeMap<String, Value>) -> Option<&str> {
let item_type = extensions
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.or_else(|| extensions.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE))
.and_then(|value| value.get("item_type"))
.and_then(Value::as_str)?;
matches!(
item_type,
"custom_tool_call_output"
| "local_shell_call_output"
| "shell_call_output"
| "apply_patch_call_output"
| "computer_call_output"
)
.then_some(item_type)
}
fn openai_chat_tool_result_parts_to_responses_output(parts: &[Value]) -> Option<Value> {
if parts.is_empty()
|| !parts.iter().all(|part| {
part.as_object()
.and_then(|object| object.get("type"))
.and_then(Value::as_str)
.is_some()
})
{
return None;
}
parts
.iter()
.map(openai_chat_tool_result_part_to_responses_output_part)
.collect::<Option<Vec<_>>>()
.map(Value::Array)
}
fn openai_chat_tool_result_part_to_responses_output_part(part: &Value) -> Option<Value> {
let part_object = part.as_object()?;
match part_object
.get("type")
.and_then(Value::as_str)
.unwrap_or_default()
{
"input_text" | "input_image" | "input_file" => Some(part.clone()),
"text" => part_object
.get("text")
.and_then(Value::as_str)
.map(|text| json!({ "type": "input_text", "text": text }))
.or_else(|| Some(openai_chat_tool_result_fallback_part(part))),
"image_url" => openai_chat_tool_result_image_part(part_object)
.or_else(|| Some(openai_chat_tool_result_fallback_part(part))),
"file" => openai_chat_tool_result_file_part(part_object)
.or_else(|| Some(openai_chat_tool_result_fallback_part(part))),
_ => Some(openai_chat_tool_result_fallback_part(part)),
}
}
fn openai_chat_tool_result_image_part(part_object: &Map<String, Value>) -> Option<Value> {
let image_value = part_object.get("image_url")?;
let image_object = image_value.as_object();
let image_url = image_value.as_str().or_else(|| {
image_object
.and_then(|image| image.get("url"))
.and_then(Value::as_str)
});
let file_id = image_object
.and_then(|image| image.get("file_id"))
.and_then(Value::as_str)
.or_else(|| part_object.get("file_id").and_then(Value::as_str));
if image_url.is_none() && file_id.is_none() {
return None;
}
let mut part = Map::new();
part.insert("type".to_string(), Value::String("input_image".to_string()));
if let Some(value) = image_url {
part.insert("image_url".to_string(), Value::String(value.to_string()));
}
if let Some(value) = file_id {
part.insert("file_id".to_string(), Value::String(value.to_string()));
}
if let Some(detail) = image_object
.and_then(|image| image.get("detail"))
.and_then(Value::as_str)
.or_else(|| part_object.get("detail").and_then(Value::as_str))
{
part.insert("detail".to_string(), Value::String(detail.to_string()));
}
Some(Value::Object(part))
}
fn openai_chat_tool_result_file_part(part_object: &Map<String, Value>) -> Option<Value> {
let file_object = part_object
.get("file")
.and_then(Value::as_object)
.unwrap_or(part_object);
let mut part = Map::new();
part.insert("type".to_string(), Value::String("input_file".to_string()));
for field in ["file_id", "file_data", "file_url", "filename"] {
if let Some(value) = file_object.get(field).and_then(Value::as_str) {
part.insert(field.to_string(), Value::String(value.to_string()));
}
}
(part.len() > 1).then_some(Value::Object(part))
}
fn openai_chat_tool_result_fallback_part(part: &Value) -> Value {
json!({
"type": "input_text",
"text": serde_json::to_string(part).unwrap_or_else(|_| part.to_string()),
})
}
fn responses_tool_result_output(output: Option<&Value>, content_text: Option<&str>) -> Value {
let text = match output {
Some(Value::String(text)) => text.clone(),
@@ -9,7 +9,7 @@ use crate::{
formats::context::FormatContext,
protocol::canonical::{
canonical_content_block_to_openai_responses_part, canonical_extension_object_mut,
canonical_usage_to_openai_responses_usage, canonicalize_tool_arguments,
canonical_tool_use_to_openai_responses_item, canonical_usage_to_openai_responses_usage,
flush_openai_responses_message_item, is_openai_thinking_block, namespace_extension_object,
openai_responses_extensions, openai_responses_output_to_canonical_blocks,
openai_usage_to_canonical, CanonicalContentBlock, CanonicalResponse,
@@ -199,7 +199,10 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
output.push(Value::Object(item));
}
CanonicalContentBlock::ToolUse {
id, name, input, ..
id,
name,
input,
extensions,
} => {
flush_openai_responses_message_item(
&mut output,
@@ -218,13 +221,9 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
},
}));
} else {
output.push(json!({
"type": "function_call",
"id": id,
"call_id": id,
"name": name,
"arguments": canonicalize_tool_arguments(input),
}));
output.push(canonical_tool_use_to_openai_responses_item(
id, name, input, extensions,
));
}
}
CanonicalContentBlock::ToolResult {
@@ -232,6 +231,7 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
output: result_output,
content_text,
is_error,
extensions,
..
} => {
flush_openai_responses_message_item(
@@ -243,7 +243,11 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
let mut item = Map::new();
item.insert(
"type".to_string(),
Value::String("function_call_output".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(tool_use_id.clone()));
item.insert(
@@ -331,6 +335,23 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
Value::Object(response)
}
fn responses_tool_result_item_type(extensions: &BTreeMap<String, Value>) -> Option<&str> {
let item_type = extensions
.get(OPENAI_RESPONSES_EXTENSION_NAMESPACE)
.or_else(|| extensions.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE))
.and_then(|value| value.get("item_type"))
.and_then(Value::as_str)?;
matches!(
item_type,
"custom_tool_call_output"
| "local_shell_call_output"
| "shell_call_output"
| "apply_patch_call_output"
| "computer_call_output"
)
.then_some(item_type)
}
pub(crate) fn ensure_modern_openai_responses_response_fields(
response: &mut Map<String, Value>,
) -> bool {
@@ -2681,7 +2681,7 @@ mod tests {
.expect("pure conversion should succeed")
.value;
assert_eq!(converted["reasoning_effort"], "high");
assert_eq!(converted["reasoning_effort"], "xhigh");
}
#[test]
@@ -44,7 +44,7 @@ impl ReasoningEffort {
Self::Low => "low",
Self::Medium => "medium",
Self::High => "high",
Self::XHigh | Self::Max => "high",
Self::XHigh | Self::Max => "xhigh",
}
}
@@ -534,7 +534,7 @@ mod tests {
"gpt-5.4-xhigh",
)
.expect("directive should apply");
assert_eq!(openai_chat["reasoning_effort"], "high");
assert_eq!(openai_chat["reasoning_effort"], "xhigh");
let mut responses = json!({
"model": "gpt-5-upstream",
@@ -607,7 +607,7 @@ mod tests {
"gpt-5.4-fast-xhigh",
)
.expect("directive should apply");
assert_eq!(openai_chat["reasoning_effort"], "high");
assert_eq!(openai_chat["reasoning_effort"], "xhigh");
assert_eq!(openai_chat["service_tier"], "priority");
let mut reversed = json!({"model": "gpt-5-upstream", "reasoning_effort": "low"});
@@ -738,7 +738,7 @@ mod tests {
.expect("openai chat body should build");
assert_eq!(provider_request_body["model"], "gpt-5-upstream");
assert_eq!(provider_request_body["reasoning_effort"], "high");
assert_eq!(provider_request_body["reasoning_effort"], "xhigh");
}
#[test]
File diff suppressed because it is too large Load Diff