mirror of
https://github.com/fawney19/Aether.git
synced 2026-09-09 04:30:20 +08:00
Merge pull request #645 from zhefox/main
修复 OpenAI Chat/Responses/Messages 转换兼容性并透传 Codex cyber_policy 错误
This commit is contained in:
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -242,7 +271,7 @@ mod tests {
|
||||
|
||||
use super::{
|
||||
append_local_failover_policy_to_value, local_failover_policy_from_report_context,
|
||||
LocalFailoverPolicy, LocalFailoverRegexRule,
|
||||
local_failover_policy_from_transport, LocalFailoverPolicy, LocalFailoverRegexRule,
|
||||
};
|
||||
use crate::provider_transport::snapshot::{
|
||||
GatewayProviderTransportEndpoint, GatewayProviderTransportKey,
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,27 @@ fn hash_api_key(value: &str) -> String {
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
fn run_async_test_on_large_stack<F>(name: &'static str, future: F)
|
||||
where
|
||||
F: std::future::Future<Output = ()> + Send + 'static,
|
||||
{
|
||||
let handle = std::thread::Builder::new()
|
||||
.name(name.to_string())
|
||||
.stack_size(16 * 1024 * 1024)
|
||||
.spawn(move || {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.expect("tokio runtime should build")
|
||||
.block_on(future);
|
||||
})
|
||||
.expect("large-stack audit test thread should spawn");
|
||||
|
||||
if let Err(payload) = handle.join() {
|
||||
std::panic::resume_unwind(payload);
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_local_openai_auth_snapshot(api_key_id: &str, user_id: &str) -> StoredAuthApiKeySnapshot {
|
||||
StoredAuthApiKeySnapshot::new(
|
||||
user_id.to_string(),
|
||||
@@ -158,8 +179,15 @@ fn sample_local_openai_key() -> StoredProviderCatalogKey {
|
||||
.expect("key transport should build")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_exposes_request_id_header_for_local_execution_response() {
|
||||
#[test]
|
||||
fn gateway_exposes_request_id_header_for_local_execution_response() {
|
||||
run_async_test_on_large_stack(
|
||||
"gateway_exposes_request_id_header_for_local_execution_response",
|
||||
gateway_exposes_request_id_header_for_local_execution_response_impl(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn gateway_exposes_request_id_header_for_local_execution_response_impl() {
|
||||
let auth_repository = Arc::new(InMemoryAuthApiKeySnapshotRepository::seed(vec![(
|
||||
Some(hash_api_key("sk-client-openai-audit-bundle")),
|
||||
sample_local_openai_auth_snapshot("api-key-1", "user-1"),
|
||||
|
||||
@@ -40,8 +40,15 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_records_usage_for_execution_runtime_sync_when_runtime_enabled() {
|
||||
#[test]
|
||||
fn gateway_records_usage_for_execution_runtime_sync_when_runtime_enabled() {
|
||||
run_async_test_on_large_stack(
|
||||
"gateway_records_usage_for_execution_runtime_sync_when_runtime_enabled",
|
||||
gateway_records_usage_for_execution_runtime_sync_when_runtime_enabled_impl(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn gateway_records_usage_for_execution_runtime_sync_when_runtime_enabled_impl() {
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
|
||||
@@ -312,8 +319,15 @@ async fn gateway_records_pending_usage_before_execution_runtime_sync_result_arri
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_keeps_pending_sync_usage_lightweight_for_large_request_body() {
|
||||
#[test]
|
||||
fn gateway_keeps_pending_sync_usage_lightweight_for_large_request_body() {
|
||||
run_async_test_on_large_stack(
|
||||
"gateway_keeps_pending_sync_usage_lightweight_for_large_request_body",
|
||||
gateway_keeps_pending_sync_usage_lightweight_for_large_request_body_impl(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn gateway_keeps_pending_sync_usage_lightweight_for_large_request_body_impl() {
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let execution_request_started = Arc::new(tokio::sync::Notify::new());
|
||||
@@ -576,8 +590,15 @@ async fn gateway_records_usage_for_execution_runtime_stream_when_runtime_enabled
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_records_pending_usage_before_execution_runtime_stream_headers_arrive() {
|
||||
#[test]
|
||||
fn gateway_records_pending_usage_before_execution_runtime_stream_headers_arrive() {
|
||||
run_async_test_on_large_stack(
|
||||
"gateway_records_pending_usage_before_execution_runtime_stream_headers_arrive",
|
||||
gateway_records_pending_usage_before_execution_runtime_stream_headers_arrive_impl(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn gateway_records_pending_usage_before_execution_runtime_stream_headers_arrive_impl() {
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let execution_request_started = Arc::new(tokio::sync::Notify::new());
|
||||
@@ -725,8 +746,15 @@ async fn gateway_records_pending_usage_before_execution_runtime_stream_headers_a
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_keeps_pending_stream_usage_lightweight_for_large_request_body() {
|
||||
#[test]
|
||||
fn gateway_keeps_pending_stream_usage_lightweight_for_large_request_body() {
|
||||
run_async_test_on_large_stack(
|
||||
"gateway_keeps_pending_stream_usage_lightweight_for_large_request_body",
|
||||
gateway_keeps_pending_stream_usage_lightweight_for_large_request_body_impl(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn gateway_keeps_pending_stream_usage_lightweight_for_large_request_body_impl() {
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
let execution_request_started = Arc::new(tokio::sync::Notify::new());
|
||||
|
||||
@@ -31,7 +31,7 @@ where
|
||||
{
|
||||
let handle = std::thread::Builder::new()
|
||||
.name(name.to_string())
|
||||
.stack_size(8 * 1024 * 1024)
|
||||
.stack_size(16 * 1024 * 1024)
|
||||
.spawn(move || {
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
@@ -421,8 +421,15 @@ async fn gateway_truncates_deep_request_echo_for_local_openai_chat_sync_usage_im
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_applies_system_max_request_body_size_to_local_openai_chat_sync_usage() {
|
||||
#[test]
|
||||
fn gateway_applies_system_max_request_body_size_to_local_openai_chat_sync_usage() {
|
||||
run_async_test_on_large_stack(
|
||||
"gateway_applies_system_max_request_body_size_to_local_openai_chat_sync_usage",
|
||||
gateway_applies_system_max_request_body_size_to_local_openai_chat_sync_usage_impl(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn gateway_applies_system_max_request_body_size_to_local_openai_chat_sync_usage_impl() {
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
|
||||
@@ -711,8 +718,16 @@ async fn gateway_strips_request_and_response_bodies_when_request_record_level_is
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_records_failed_usage_when_all_local_openai_chat_candidates_exhaust_after_retryable_sync_failure(
|
||||
#[test]
|
||||
fn gateway_records_failed_usage_when_all_local_openai_chat_candidates_exhaust_after_retryable_sync_failure(
|
||||
) {
|
||||
run_async_test_on_large_stack(
|
||||
"gateway_records_failed_usage_when_all_local_openai_chat_candidates_exhaust_after_retryable_sync_failure",
|
||||
gateway_records_failed_usage_when_all_local_openai_chat_candidates_exhaust_after_retryable_sync_failure_impl(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn gateway_records_failed_usage_when_all_local_openai_chat_candidates_exhaust_after_retryable_sync_failure_impl(
|
||||
) {
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
@@ -854,8 +869,15 @@ async fn gateway_records_failed_usage_when_all_local_openai_chat_candidates_exha
|
||||
assert_eq!(stored_candidates[0].status_code, Some(503));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_records_failed_usage_when_sync_runtime_transport_is_unavailable_without_plan_fallback(
|
||||
#[test]
|
||||
fn gateway_records_failed_usage_when_sync_runtime_transport_is_unavailable_without_plan_fallback() {
|
||||
run_async_test_on_large_stack(
|
||||
"gateway_records_failed_usage_when_sync_runtime_transport_is_unavailable_without_plan_fallback",
|
||||
gateway_records_failed_usage_when_sync_runtime_transport_is_unavailable_without_plan_fallback_impl(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn gateway_records_failed_usage_when_sync_runtime_transport_is_unavailable_without_plan_fallback_impl(
|
||||
) {
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
@@ -1166,8 +1188,16 @@ async fn gateway_records_failed_usage_for_claude_runtime_miss_without_execution_
|
||||
execution_runtime_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_handles_local_openai_chat_stream_report_with_local_reporting_when_usage_runtime_enabled(
|
||||
#[test]
|
||||
fn gateway_handles_local_openai_chat_stream_report_with_local_reporting_when_usage_runtime_enabled()
|
||||
{
|
||||
run_async_test_on_large_stack(
|
||||
"gateway_handles_local_openai_chat_stream_report_with_local_reporting_when_usage_runtime_enabled",
|
||||
gateway_handles_local_openai_chat_stream_report_with_local_reporting_when_usage_runtime_enabled_impl(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn gateway_handles_local_openai_chat_stream_report_with_local_reporting_when_usage_runtime_enabled_impl(
|
||||
) {
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
@@ -1333,8 +1363,15 @@ async fn gateway_handles_local_openai_chat_stream_report_with_local_reporting_wh
|
||||
upstream_handle.abort();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gateway_preserves_stream_usage_when_max_response_body_size_truncates_capture() {
|
||||
#[test]
|
||||
fn gateway_preserves_stream_usage_when_max_response_body_size_truncates_capture() {
|
||||
run_async_test_on_large_stack(
|
||||
"gateway_preserves_stream_usage_when_max_response_body_size_truncates_capture",
|
||||
gateway_preserves_stream_usage_when_max_response_body_size_truncates_capture_impl(),
|
||||
);
|
||||
}
|
||||
|
||||
async fn gateway_preserves_stream_usage_when_max_response_body_size_truncates_capture_impl() {
|
||||
let usage_repository = Arc::new(InMemoryUsageReadRepository::default());
|
||||
let request_candidate_repository = Arc::new(InMemoryRequestCandidateRepository::default());
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -3087,6 +3129,7 @@ fn openai_responses_stream_event_is_known_noop(event_type: &str) -> bool {
|
||||
matches!(
|
||||
event_type,
|
||||
"response.queued"
|
||||
| "response.metadata"
|
||||
| "response.output_text.annotation.added"
|
||||
| "response.audio.delta"
|
||||
| "response.audio.done"
|
||||
@@ -3113,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")
|
||||
@@ -4010,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,15 +8,18 @@ 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,
|
||||
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,
|
||||
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,
|
||||
CanonicalContentBlock, CanonicalInstruction, CanonicalRequest, CanonicalRole,
|
||||
CanonicalThinkingConfig, CanonicalToolChoice, CanonicalToolDefinition,
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
openai_tool_choice_raw_to_responses, CanonicalContentBlock, CanonicalInstruction,
|
||||
CanonicalRequest, CanonicalRole, CanonicalThinkingConfig, CanonicalToolChoice,
|
||||
CanonicalToolDefinition, OPENAI_RESPONSES_EXTENSION_NAMESPACE,
|
||||
OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -98,7 +101,6 @@ pub fn from_raw(body_json: &Value) -> Option<CanonicalRequest> {
|
||||
"top_p",
|
||||
"metadata",
|
||||
"tools",
|
||||
"tool_choice",
|
||||
"parallel_tool_calls",
|
||||
"text",
|
||||
"reasoning",
|
||||
@@ -109,6 +111,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 +182,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);
|
||||
@@ -303,6 +308,12 @@ fn canonical_messages_to_responses_input(canonical: &CanonicalRequest) -> Option
|
||||
let role = match message.role {
|
||||
CanonicalRole::Assistant => "assistant",
|
||||
CanonicalRole::Tool | CanonicalRole::User | CanonicalRole::Unknown => "user",
|
||||
CanonicalRole::System if is_openai_responses_input_message(&message.extensions) => {
|
||||
"system"
|
||||
}
|
||||
CanonicalRole::Developer if is_openai_responses_input_message(&message.extensions) => {
|
||||
"developer"
|
||||
}
|
||||
CanonicalRole::System | CanonicalRole::Developer => continue,
|
||||
};
|
||||
let mut content = Vec::new();
|
||||
@@ -313,19 +324,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_input_item(
|
||||
&call_id, &tool_name, arguments, extensions,
|
||||
));
|
||||
}
|
||||
CanonicalContentBlock::ToolResult {
|
||||
tool_use_id,
|
||||
@@ -344,7 +352,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 +366,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 +552,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 +642,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 +781,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
|
||||
@@ -746,17 +813,17 @@ fn canonical_tool_to_responses(tool: &CanonicalToolDefinition) -> Value {
|
||||
tool.extensions
|
||||
.get(OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE)
|
||||
})
|
||||
.filter(|value| {
|
||||
value
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|tool_type| {
|
||||
tool_type == "custom" || tool_type.starts_with("web_search")
|
||||
})
|
||||
})
|
||||
{
|
||||
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 +848,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 +883,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 +948,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 +962,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(),
|
||||
@@ -1185,6 +1430,7 @@ mod tests {
|
||||
|
||||
assert_eq!(body["input"].as_array().expect("input").len(), 2);
|
||||
assert_eq!(body["input"][0]["type"], "function_call");
|
||||
assert!(body["input"][0].get("id").is_none());
|
||||
assert_eq!(body["input"][0]["call_id"], "call_auto_0");
|
||||
assert_eq!(body["input"][0]["name"], "unknown");
|
||||
assert_eq!(body["input"][0]["arguments"], "{\"q\":\"rust\"}");
|
||||
|
||||
@@ -9,12 +9,13 @@ 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,
|
||||
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,
|
||||
CanonicalResponseOutput, CanonicalRole, CanonicalStopReason,
|
||||
OPENAI_RESPONSES_EXTENSION_NAMESPACE, OPENAI_RESPONSES_LEGACY_EXTENSION_NAMESPACE,
|
||||
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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -199,7 +200,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 +222,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 +232,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 +244,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(
|
||||
@@ -269,6 +274,19 @@ pub fn to_raw(canonical: &CanonicalResponse, report_context: &Value, _compact: b
|
||||
}
|
||||
}
|
||||
}
|
||||
CanonicalContentBlock::Unknown {
|
||||
payload,
|
||||
extensions,
|
||||
..
|
||||
} if is_openai_responses_raw_block(extensions) => {
|
||||
flush_openai_responses_message_item(
|
||||
&mut output,
|
||||
&mut message_content,
|
||||
&response_id,
|
||||
&mut message_index,
|
||||
);
|
||||
output.push(payload.clone());
|
||||
}
|
||||
CanonicalContentBlock::Unknown { .. } => {}
|
||||
}
|
||||
}
|
||||
@@ -331,6 +349,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 {
|
||||
|
||||
@@ -356,9 +356,38 @@ fn validate_response_conversion(
|
||||
}
|
||||
|
||||
validate_source_response_stop_enums(source, target, body)?;
|
||||
validate_response_content_has_no_unknown_blocks(source, target, response)?;
|
||||
validate_canonical_response_stop_reasons(source, target, response)
|
||||
}
|
||||
|
||||
fn validate_response_content_has_no_unknown_blocks(
|
||||
source: FormatId,
|
||||
target: FormatId,
|
||||
response: &CanonicalResponse,
|
||||
) -> Result<(), FormatError> {
|
||||
for block in response.content.iter().chain(
|
||||
response
|
||||
.outputs
|
||||
.iter()
|
||||
.flat_map(|output| output.content.iter()),
|
||||
) {
|
||||
if let CanonicalContentBlock::Unknown { raw_type, .. } = block {
|
||||
if raw_type == "refusal" {
|
||||
continue;
|
||||
}
|
||||
return Err(FormatError::LossyConversionBlocked {
|
||||
source_format: source.as_str().to_string(),
|
||||
target_format: target.as_str().to_string(),
|
||||
field: "output[].type".to_string(),
|
||||
reason: format!(
|
||||
"target format has no lossless mapping for unknown source output item type {raw_type:?}"
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_known_standard_request_root_fields(
|
||||
source: FormatId,
|
||||
target: FormatId,
|
||||
@@ -2580,6 +2609,7 @@ mod tests {
|
||||
.value;
|
||||
|
||||
assert_eq!(converted["input"][0]["type"], "function_call");
|
||||
assert!(converted["input"][0].get("id").is_none());
|
||||
assert_eq!(converted["input"][0]["call_id"], "call_lookup_1");
|
||||
assert_eq!(converted["input"][1]["type"], "function_call_output");
|
||||
assert_eq!(converted["input"][1]["call_id"], "call_lookup_1");
|
||||
@@ -2681,7 +2711,7 @@ mod tests {
|
||||
.expect("pure conversion should succeed")
|
||||
.value;
|
||||
|
||||
assert_eq!(converted["reasoning_effort"], "high");
|
||||
assert_eq!(converted["reasoning_effort"], "xhigh");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -3129,6 +3159,44 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_openai_responses_request_same_format_preserves_raw_tools_and_roles() {
|
||||
let body = json!({
|
||||
"model": "gpt-source",
|
||||
"input": [
|
||||
{
|
||||
"type": "message",
|
||||
"role": "developer",
|
||||
"content": [{"type": "input_text", "text": "Use policy"}]
|
||||
},
|
||||
{"role": "user", "content": "hello"}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"type": "file_search",
|
||||
"vector_store_ids": ["vs_123"],
|
||||
"max_num_results": 3
|
||||
},
|
||||
{
|
||||
"type": "mcp",
|
||||
"server_label": "docs",
|
||||
"server_url": "https://example.com/mcp"
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let converted = convert_request_pure("openai:responses", "openai:responses", &body)
|
||||
.expect("same-format Responses request should preserve official raw fields")
|
||||
.value;
|
||||
|
||||
assert_eq!(converted["input"][0]["role"], "developer");
|
||||
assert_eq!(converted["input"][0]["content"][0]["text"], "Use policy");
|
||||
assert_eq!(converted["tools"][0]["type"], "file_search");
|
||||
assert_eq!(converted["tools"][0]["vector_store_ids"][0], "vs_123");
|
||||
assert_eq!(converted["tools"][1]["type"], "mcp");
|
||||
assert_eq!(converted["tools"][1]["server_label"], "docs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_openai_chat_to_claude_blocks_target_unsupported_generation_field() {
|
||||
let body = json!({
|
||||
@@ -3452,6 +3520,72 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_openai_responses_response_same_format_preserves_raw_output_items() {
|
||||
let body = json!({
|
||||
"id": "resp_raw_items",
|
||||
"object": "response",
|
||||
"model": "gpt-source",
|
||||
"status": "completed",
|
||||
"output": [
|
||||
{
|
||||
"type": "file_search_call",
|
||||
"id": "fs_123",
|
||||
"status": "completed",
|
||||
"queries": ["rust"],
|
||||
"results": [{"file_id": "file_123", "text": "Rust"}]
|
||||
},
|
||||
{
|
||||
"type": "code_interpreter_call",
|
||||
"id": "ci_123",
|
||||
"status": "completed",
|
||||
"code": "print('hi')",
|
||||
"outputs": []
|
||||
},
|
||||
{
|
||||
"type": "message",
|
||||
"id": "msg_123",
|
||||
"role": "assistant",
|
||||
"content": [{"type": "output_text", "text": "done"}]
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
let converted = convert_response_pure("openai:responses", "openai:responses", &body)
|
||||
.expect("same-format Responses response should preserve raw output items")
|
||||
.value;
|
||||
|
||||
assert_eq!(converted["output"][0]["type"], "file_search_call");
|
||||
assert_eq!(converted["output"][0]["results"][0]["file_id"], "file_123");
|
||||
assert_eq!(converted["output"][1]["type"], "code_interpreter_call");
|
||||
assert_eq!(converted["output"][2]["content"][0]["text"], "done");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_openai_responses_response_cross_format_blocks_raw_output_items() {
|
||||
let body = json!({
|
||||
"id": "resp_raw_items",
|
||||
"object": "response",
|
||||
"model": "gpt-source",
|
||||
"status": "completed",
|
||||
"output": [{
|
||||
"type": "mcp_call",
|
||||
"id": "mcp_123",
|
||||
"status": "completed",
|
||||
"name": "lookup"
|
||||
}]
|
||||
});
|
||||
|
||||
let error = convert_response_pure("openai:responses", "openai:chat", &body)
|
||||
.expect_err("cross-format raw Responses output items should fail closed");
|
||||
|
||||
assert!(matches!(
|
||||
error,
|
||||
super::FormatError::LossyConversionBlocked { ref field, .. }
|
||||
if field == "output[].type"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pure_claude_response_same_format_preserves_unknown_stop_reason() {
|
||||
let body = json!({
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -331,7 +331,14 @@ pub fn canonical_usage_from_claude_usage(value: Option<&Value>) -> Option<Canoni
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
let reasoning_tokens = usage
|
||||
.get("reasoning_tokens")
|
||||
.get("output_tokens_details")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|details| {
|
||||
details
|
||||
.get("thinking_tokens")
|
||||
.or_else(|| details.get("reasoning_tokens"))
|
||||
})
|
||||
.or_else(|| usage.get("reasoning_tokens"))
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or(0);
|
||||
Some(CanonicalUsage {
|
||||
|
||||
@@ -1121,6 +1121,14 @@ mod tests {
|
||||
"item_id": "ws_123",
|
||||
"output_index": 0,
|
||||
})),
|
||||
data_line(json!({
|
||||
"type": "response.metadata",
|
||||
"response_id": "resp_sidecar_123",
|
||||
"sequence_number": 4,
|
||||
"metadata": {
|
||||
"candidate_id": "provider-a",
|
||||
},
|
||||
})),
|
||||
data_line(json!({
|
||||
"type": "response.output_text.annotation.added",
|
||||
"response_id": "resp_sidecar_123",
|
||||
|
||||
@@ -2050,7 +2050,7 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
|
||||
reasoning_states.entry(output_index).or_default(),
|
||||
item,
|
||||
),
|
||||
"function_call" => {
|
||||
"function_call" | "custom_tool_call" => {
|
||||
merge_openai_responses_tool_item(
|
||||
tool_states.entry(output_index).or_default(),
|
||||
item,
|
||||
@@ -2067,7 +2067,7 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
"response.function_call_arguments.delta" => {
|
||||
"response.function_call_arguments.delta" | "response.custom_tool_call_input.delta" => {
|
||||
let Some(output_index) =
|
||||
resolve_openai_responses_tool_output_index(event_object, &item_output_indexes)
|
||||
else {
|
||||
@@ -2080,18 +2080,43 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
|
||||
if delta.is_empty() {
|
||||
continue;
|
||||
}
|
||||
tool_states
|
||||
.entry(output_index)
|
||||
.or_default()
|
||||
.arguments
|
||||
.push_str(delta);
|
||||
let state = tool_states.entry(output_index).or_default();
|
||||
if event_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.starts_with("response.custom_tool_call_input."))
|
||||
{
|
||||
state
|
||||
.item
|
||||
.entry("type".to_string())
|
||||
.or_insert_with(|| Value::String("custom_tool_call".to_string()));
|
||||
if let Some(name) = event_object.get("name").and_then(Value::as_str) {
|
||||
state
|
||||
.item
|
||||
.entry("name".to_string())
|
||||
.or_insert_with(|| Value::String(name.to_string()));
|
||||
}
|
||||
if let Some(item_id) = event_object.get("item_id").and_then(Value::as_str) {
|
||||
state
|
||||
.item
|
||||
.entry("id".to_string())
|
||||
.or_insert_with(|| Value::String(item_id.to_string()));
|
||||
}
|
||||
if let Some(call_id) = event_object.get("call_id").and_then(Value::as_str) {
|
||||
state
|
||||
.item
|
||||
.entry("call_id".to_string())
|
||||
.or_insert_with(|| Value::String(call_id.to_string()));
|
||||
}
|
||||
}
|
||||
state.arguments.push_str(delta);
|
||||
register_openai_responses_tool_event_aliases(
|
||||
&mut item_output_indexes,
|
||||
event_object,
|
||||
output_index,
|
||||
);
|
||||
}
|
||||
"response.function_call_arguments.done" => {
|
||||
"response.function_call_arguments.done" | "response.custom_tool_call_input.done" => {
|
||||
let Some(output_index) =
|
||||
resolve_openai_responses_tool_output_index(event_object, &item_output_indexes)
|
||||
else {
|
||||
@@ -2108,14 +2133,44 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
|
||||
item,
|
||||
);
|
||||
}
|
||||
if event_object
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.is_some_and(|value| value.starts_with("response.custom_tool_call_input."))
|
||||
{
|
||||
let state = tool_states.entry(output_index).or_default();
|
||||
state
|
||||
.item
|
||||
.entry("type".to_string())
|
||||
.or_insert_with(|| Value::String("custom_tool_call".to_string()));
|
||||
if let Some(name) = event_object.get("name").and_then(Value::as_str) {
|
||||
state
|
||||
.item
|
||||
.entry("name".to_string())
|
||||
.or_insert_with(|| Value::String(name.to_string()));
|
||||
}
|
||||
if let Some(item_id) = event_object.get("item_id").and_then(Value::as_str) {
|
||||
state
|
||||
.item
|
||||
.entry("id".to_string())
|
||||
.or_insert_with(|| Value::String(item_id.to_string()));
|
||||
}
|
||||
if let Some(call_id) = event_object.get("call_id").and_then(Value::as_str) {
|
||||
state
|
||||
.item
|
||||
.entry("call_id".to_string())
|
||||
.or_insert_with(|| Value::String(call_id.to_string()));
|
||||
}
|
||||
}
|
||||
let arguments = event_object
|
||||
.get("arguments")
|
||||
.or_else(|| event_object.get("input"))
|
||||
.and_then(Value::as_str)
|
||||
.or_else(|| {
|
||||
event_object
|
||||
.get("item")
|
||||
.and_then(Value::as_object)
|
||||
.and_then(|item| item.get("arguments"))
|
||||
.and_then(|item| item.get("arguments").or_else(|| item.get("input")))
|
||||
.and_then(Value::as_str)
|
||||
})
|
||||
.unwrap_or_default();
|
||||
@@ -2152,7 +2207,7 @@ pub fn aggregate_openai_responses_stream_sync_response(body: &[u8]) -> Option<Va
|
||||
reasoning_states.entry(output_index).or_default(),
|
||||
item,
|
||||
),
|
||||
"function_call" => {
|
||||
"function_call" | "custom_tool_call" => {
|
||||
merge_openai_responses_tool_item(
|
||||
tool_states.entry(output_index).or_default(),
|
||||
item,
|
||||
@@ -2720,6 +2775,12 @@ fn materialize_openai_responses_tool_item(
|
||||
state: OpenAIResponsesSyncToolState,
|
||||
) -> Value {
|
||||
let mut item = state.item;
|
||||
let item_type = item
|
||||
.get("type")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| *value == "custom_tool_call")
|
||||
.unwrap_or("function_call")
|
||||
.to_string();
|
||||
let generated_id = format!("call_auto_{output_index}");
|
||||
let call_id = item
|
||||
.get("call_id")
|
||||
@@ -2736,10 +2797,7 @@ fn materialize_openai_responses_tool_item(
|
||||
})
|
||||
.unwrap_or(generated_id.clone());
|
||||
|
||||
item.insert(
|
||||
"type".to_string(),
|
||||
Value::String("function_call".to_string()),
|
||||
);
|
||||
item.insert("type".to_string(), Value::String(item_type.clone()));
|
||||
item.entry("id".to_string())
|
||||
.or_insert_with(|| Value::String(call_id.clone()));
|
||||
item.insert("call_id".to_string(), Value::String(call_id));
|
||||
@@ -2748,9 +2806,19 @@ fn materialize_openai_responses_tool_item(
|
||||
item.entry("status".to_string())
|
||||
.or_insert_with(|| Value::String("completed".to_string()));
|
||||
if !state.arguments.is_empty() {
|
||||
item.insert("arguments".to_string(), Value::String(state.arguments));
|
||||
let argument_key = if item_type == "custom_tool_call" {
|
||||
"input"
|
||||
} else {
|
||||
"arguments"
|
||||
};
|
||||
item.insert(argument_key.to_string(), Value::String(state.arguments));
|
||||
} else {
|
||||
item.entry("arguments".to_string())
|
||||
let argument_key = if item_type == "custom_tool_call" {
|
||||
"input"
|
||||
} else {
|
||||
"arguments"
|
||||
};
|
||||
item.entry(argument_key.to_string())
|
||||
.or_insert_with(|| Value::String(String::new()));
|
||||
}
|
||||
Value::Object(item)
|
||||
@@ -4365,6 +4433,27 @@ mod tests {
|
||||
assert_eq!(result["output"][0]["arguments"], r#"{"location": "Tokyo"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_tool_call_input_events_materialize_custom_tool_call() {
|
||||
let body = concat!(
|
||||
"event: response.custom_tool_call_input.delta\n",
|
||||
"data: {\"type\":\"response.custom_tool_call_input.delta\",\"output_index\":0,\"item_id\":\"ctc_123\",\"name\":\"code_exec\",\"delta\":\"print\"}\n\n",
|
||||
"event: response.custom_tool_call_input.done\n",
|
||||
"data: {\"type\":\"response.custom_tool_call_input.done\",\"output_index\":0,\"item_id\":\"ctc_123\",\"call_id\":\"call_custom_123\",\"name\":\"code_exec\",\"input\":\"print('hi')\"}\n\n",
|
||||
"event: response.completed\n",
|
||||
"data: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_custom_123\",\"object\":\"response\",\"model\":\"gpt-5\",\"status\":\"completed\",\"output\":[]}}\n\n",
|
||||
);
|
||||
|
||||
let result = aggregate_openai_responses_stream_sync_response(body.as_bytes())
|
||||
.expect("custom tool stream should aggregate into a sync body");
|
||||
|
||||
assert_eq!(result["output"][0]["type"], "custom_tool_call");
|
||||
assert_eq!(result["output"][0]["call_id"], "call_custom_123");
|
||||
assert_eq!(result["output"][0]["name"], "code_exec");
|
||||
assert_eq!(result["output"][0]["input"], "print('hi')");
|
||||
assert!(result["output"][0].get("arguments").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aggregates_modern_reasoning_text_and_response_done_alias() {
|
||||
let body = concat!(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user